1use crate::astro::constants::earth::OMEGA_E_DOT_RAD_S;
8use crate::astro::math::mat3::{inline_rxr, inline_tr, mul_vec3, Mat3};
9use crate::astro::math::vec3::{add3, cross3, norm3, scale3, sub3};
10use crate::inertial::frames::gravity_ecef_mps2;
11use crate::inertial::state::{mat3_identity, skew, validate_dcm_orthonormal};
12use crate::inertial::{
13 mechanize_ecef, validate_finite, validate_vec3, ImuCalibration, ImuErrorModel, ImuSample,
14 ImuSpec, MechanizationConfig,
15};
16use std::collections::VecDeque;
17
18use super::ekf::{
19 ekf_correct_closed_loop, ekf_correct_closed_loop_with_predicted_covariance_scale,
20 innovation_covariance, normalized_innovation_squared, EkfCorrection, EkfCorrectionReport,
21 EkfUpdateOptions,
22};
23use super::error_state::{
24 linearize_error_state_ecef_with_imu_to_body, predict_error_state_covariance,
25 ErrorStateImuKinematics,
26};
27use super::smoother::FusionPredictionStep;
28use super::state::FusionFilterKind;
29use super::state::{
30 invalid_input, validate_covariance_matrix, validate_nonnegative, validate_positive,
31 FusionError, InsFilterState, ERROR_ATTITUDE_INDEX, ERROR_GYRO_BIAS_INDEX, ERROR_POSITION_INDEX,
32 ERROR_STATE_DIMENSION_15, ERROR_STATE_DIMENSION_21, ERROR_VELOCITY_INDEX,
33};
34use super::tight::{TightCouplingConfig, TightFusionState};
35use super::timesync::{StationarityDetectorSnapshotSample, TimeSyncHistory};
36use super::ukf::{ukf_correct_closed_loop, UkfUpdateOptions};
37
38const LOOSE_MIN_SATELLITES: usize = 4;
39const POSITION_ROWS: usize = 3;
40const POSITION_VELOCITY_ROWS: usize = 6;
41const ZUPT_ZARU_ROWS: usize = 6;
42const NHC_ROWS: usize = 2;
43const IGG_III_REJECTION_VARIANCE_SCALE: f64 = 1.0e4;
44const DEFAULT_PREDICTION_ADAPTATION_GATE_PROBABILITY: f64 = 0.99;
45
46#[derive(Debug, Clone, PartialEq)]
52pub struct GnssFixMeasurement {
53 pub t_j2000_s: f64,
55 pub position_ecef_m: [f64; 3],
57 pub velocity_ecef_mps: Option<[f64; 3]>,
59 pub covariance: Vec<Vec<f64>>,
61 pub satellites_used: usize,
63 pub solution_valid: bool,
65 pub fix_status: GnssFixStatus,
67}
68
69impl GnssFixMeasurement {
70 pub fn position(
72 t_j2000_s: f64,
73 position_ecef_m: [f64; 3],
74 position_covariance_m2: [[f64; 3]; 3],
75 satellites_used: usize,
76 ) -> Result<Self, FusionError> {
77 let measurement = Self {
78 t_j2000_s,
79 position_ecef_m,
80 velocity_ecef_mps: None,
81 covariance: mat3_to_rows(position_covariance_m2),
82 satellites_used,
83 solution_valid: true,
84 fix_status: GnssFixStatus::Single,
85 };
86 measurement.validate()?;
87 Ok(measurement)
88 }
89
90 pub fn position_velocity(
92 t_j2000_s: f64,
93 position_ecef_m: [f64; 3],
94 velocity_ecef_mps: [f64; 3],
95 covariance: Vec<Vec<f64>>,
96 satellites_used: usize,
97 ) -> Result<Self, FusionError> {
98 let measurement = Self {
99 t_j2000_s,
100 position_ecef_m,
101 velocity_ecef_mps: Some(velocity_ecef_mps),
102 covariance,
103 satellites_used,
104 solution_valid: true,
105 fix_status: GnssFixStatus::Single,
106 };
107 measurement.validate()?;
108 Ok(measurement)
109 }
110
111 pub fn with_fix_status(mut self, fix_status: GnssFixStatus) -> Self {
113 self.fix_status = fix_status;
114 self
115 }
116
117 pub fn validate(&self) -> Result<(), FusionError> {
119 validate_finite(self.t_j2000_s, "t_j2000_s").map_err(FusionError::from)?;
120 validate_vec3(self.position_ecef_m, "position_ecef_m").map_err(FusionError::from)?;
121 if let Some(velocity) = self.velocity_ecef_mps {
122 validate_vec3(velocity, "velocity_ecef_mps").map_err(FusionError::from)?;
123 }
124 if !self.solution_valid {
125 return Err(invalid_input(
126 "solution_valid",
127 "GNSS fix must be successful",
128 ));
129 }
130 if self.satellites_used < LOOSE_MIN_SATELLITES {
131 return Err(invalid_input(
132 "satellites_used",
133 "at least 4 satellites required",
134 ));
135 }
136 validate_covariance_matrix(&self.covariance, self.row_count(), "gnss_covariance")
137 }
138
139 pub fn row_count(&self) -> usize {
141 if self.velocity_ecef_mps.is_some() {
142 POSITION_VELOCITY_ROWS
143 } else {
144 POSITION_ROWS
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
151pub enum GnssFixStatus {
152 Single,
154 Float,
156 Fixed,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq)]
162pub struct LooseCouplingConfig {
163 pub lever_arm_body_m: [f64; 3],
165 pub update_options: EkfUpdateOptions,
167 pub fix_status_weighting: GnssFixStatusWeighting,
169 pub measurement_reweighting: Option<IggIiiMeasurementReweighting>,
171 pub prediction_adaptation: Option<YangPredictionAdaptiveFactor>,
173 pub stationary_updates: Option<StationaryUpdateConfig>,
175 pub non_holonomic: Option<NonHolonomicConstraintConfig>,
177}
178
179impl Default for LooseCouplingConfig {
180 fn default() -> Self {
181 Self {
182 lever_arm_body_m: [0.0; 3],
183 update_options: EkfUpdateOptions::default(),
184 fix_status_weighting: GnssFixStatusWeighting::default(),
185 measurement_reweighting: None,
186 prediction_adaptation: None,
187 stationary_updates: None,
188 non_holonomic: None,
189 }
190 }
191}
192
193impl LooseCouplingConfig {
194 pub fn validate(&self) -> Result<(), FusionError> {
196 validate_vec3(self.lever_arm_body_m, "lever_arm_body_m").map_err(FusionError::from)?;
197 if let Some(gate) = self.update_options.innovation_gate {
198 gate.validate()?;
199 }
200 self.fix_status_weighting.validate()?;
201 if let Some(reweighting) = self.measurement_reweighting {
202 reweighting.validate()?;
203 }
204 if let Some(adaptation) = self.prediction_adaptation {
205 adaptation.validate()?;
206 }
207 if let Some(stationary) = self.stationary_updates {
208 stationary.validate()?;
209 }
210 if let Some(non_holonomic) = self.non_holonomic {
211 non_holonomic.validate()?;
212 }
213 Ok(())
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct GnssFixStatusWeighting {
220 pub single_sigma_multiplier: f64,
222 pub float_sigma_multiplier: f64,
224 pub fixed_sigma_multiplier: f64,
226}
227
228impl Default for GnssFixStatusWeighting {
229 fn default() -> Self {
230 Self {
231 single_sigma_multiplier: 1.0,
232 float_sigma_multiplier: 1.0,
233 fixed_sigma_multiplier: 1.0,
234 }
235 }
236}
237
238impl GnssFixStatusWeighting {
239 pub fn multiplier(self, status: GnssFixStatus) -> f64 {
241 match status {
242 GnssFixStatus::Single => self.single_sigma_multiplier,
243 GnssFixStatus::Float => self.float_sigma_multiplier,
244 GnssFixStatus::Fixed => self.fixed_sigma_multiplier,
245 }
246 }
247
248 pub fn validate(&self) -> Result<(), FusionError> {
250 validate_positive(self.single_sigma_multiplier, "single_sigma_multiplier")?;
251 validate_positive(self.float_sigma_multiplier, "float_sigma_multiplier")?;
252 validate_positive(self.fixed_sigma_multiplier, "fixed_sigma_multiplier")
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq)]
258pub struct StationaryUpdateConfig {
259 pub detector: StationaryDetectorConfig,
261 pub zero_velocity_sigma_mps: f64,
263 pub zero_angular_rate_sigma_rps: f64,
265}
266
267impl StationaryUpdateConfig {
268 pub fn validate(&self) -> Result<(), FusionError> {
270 self.detector.validate()?;
271 validate_positive(self.zero_velocity_sigma_mps, "zero_velocity_sigma_mps")?;
272 validate_positive(
273 self.zero_angular_rate_sigma_rps,
274 "zero_angular_rate_sigma_rps",
275 )
276 }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq)]
281pub struct StationaryDetectorConfig {
282 pub window_len: usize,
284 pub max_specific_force_norm_error_mps2: f64,
286 pub max_body_rate_wrt_ecef_norm_rps: f64,
288}
289
290impl StationaryDetectorConfig {
291 pub fn validate(&self) -> Result<(), FusionError> {
293 if self.window_len == 0 {
294 return Err(invalid_input("stationary_window_len", "must be nonzero"));
295 }
296 validate_nonnegative(
297 self.max_specific_force_norm_error_mps2,
298 "max_specific_force_norm_error_mps2",
299 )?;
300 validate_nonnegative(
301 self.max_body_rate_wrt_ecef_norm_rps,
302 "max_body_rate_wrt_ecef_norm_rps",
303 )
304 }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq)]
309pub struct NonHolonomicConstraintConfig {
310 pub lateral_velocity_sigma_mps: f64,
312 pub vertical_velocity_sigma_mps: f64,
314 pub min_speed_mps: f64,
316 pub max_body_rate_wrt_ecef_norm_rps: f64,
318}
319
320impl NonHolonomicConstraintConfig {
321 pub fn validate(&self) -> Result<(), FusionError> {
323 validate_positive(
324 self.lateral_velocity_sigma_mps,
325 "lateral_velocity_sigma_mps",
326 )?;
327 validate_positive(
328 self.vertical_velocity_sigma_mps,
329 "vertical_velocity_sigma_mps",
330 )?;
331 validate_nonnegative(self.min_speed_mps, "nhc_min_speed_mps")?;
332 validate_nonnegative(
333 self.max_body_rate_wrt_ecef_norm_rps,
334 "nhc_max_body_rate_wrt_ecef_norm_rps",
335 )
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq)]
346pub struct VelocityMatchingConfig {
347 pub max_outage_duration_s: f64,
349}
350
351impl VelocityMatchingConfig {
352 pub fn validate(&self) -> Result<(), FusionError> {
354 validate_positive(self.max_outage_duration_s, "max_outage_duration_s")
355 }
356}
357
358#[derive(Debug, Clone, Copy, PartialEq)]
365pub struct IggIiiMeasurementReweighting {
366 pub k0_sigma: f64,
368 pub k1_sigma: f64,
370}
371
372impl IggIiiMeasurementReweighting {
373 pub const fn standard() -> Self {
375 Self {
376 k0_sigma: 2.0,
377 k1_sigma: 5.0,
378 }
379 }
380
381 pub fn validate(&self) -> Result<(), FusionError> {
383 validate_finite(self.k0_sigma, "igg_iii_k0_sigma").map_err(FusionError::from)?;
384 validate_finite(self.k1_sigma, "igg_iii_k1_sigma").map_err(FusionError::from)?;
385 if !(1.5..=3.0).contains(&self.k0_sigma) {
386 return Err(invalid_input(
387 "igg_iii_k0_sigma",
388 "must be in the literature range [1.5, 3.0]",
389 ));
390 }
391 if !(3.0..=8.0).contains(&self.k1_sigma) {
392 return Err(invalid_input(
393 "igg_iii_k1_sigma",
394 "must be in the literature range [3.0, 8.0]",
395 ));
396 }
397 if self.k0_sigma < self.k1_sigma {
398 Ok(())
399 } else {
400 Err(invalid_input(
401 "igg_iii_thresholds",
402 "k0_sigma must be smaller than k1_sigma",
403 ))
404 }
405 }
406}
407
408#[derive(Debug, Clone, Copy, PartialEq)]
416pub struct YangPredictionAdaptiveFactor {
417 pub threshold: f64,
419 pub outlier_gate_probability: f64,
421}
422
423impl YangPredictionAdaptiveFactor {
424 pub const fn standard() -> Self {
426 Self {
427 threshold: 1.0,
428 outlier_gate_probability: DEFAULT_PREDICTION_ADAPTATION_GATE_PROBABILITY,
429 }
430 }
431
432 pub fn validate(&self) -> Result<(), FusionError> {
434 validate_finite(self.threshold, "yang_prediction_threshold").map_err(FusionError::from)?;
435 validate_finite(
436 self.outlier_gate_probability,
437 "yang_outlier_gate_probability",
438 )
439 .map_err(FusionError::from)?;
440 if self.threshold <= 0.0 {
441 return Err(invalid_input(
442 "yang_prediction_threshold",
443 "must be positive",
444 ));
445 }
446 if self.outlier_gate_probability > 0.0 && self.outlier_gate_probability < 1.0 {
447 Ok(())
448 } else {
449 Err(invalid_input(
450 "yang_outlier_gate_probability",
451 "must be in (0, 1)",
452 ))
453 }
454 }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq)]
459pub struct InertialFilterConfig {
460 pub imu_spec: ImuSpec,
462 pub filter_kind: FusionFilterKind,
464 pub imu_model: ImuErrorModel,
466 pub imu_to_body_dcm: Mat3,
472 pub mechanization: MechanizationConfig,
474 pub loose: LooseCouplingConfig,
476 pub tight: TightCouplingConfig,
478 pub ukf_update_options: UkfUpdateOptions,
480}
481
482impl InertialFilterConfig {
483 pub fn new(imu_spec: ImuSpec) -> Result<Self, FusionError> {
485 let config = Self {
486 imu_spec,
487 filter_kind: FusionFilterKind::Ekf,
488 imu_model: ImuErrorModel::default(),
489 imu_to_body_dcm: mat3_identity(),
490 mechanization: MechanizationConfig::default(),
491 loose: LooseCouplingConfig::default(),
492 tight: TightCouplingConfig::default(),
493 ukf_update_options: UkfUpdateOptions::default(),
494 };
495 config.validate()?;
496 Ok(config)
497 }
498
499 pub fn validate(&self) -> Result<(), FusionError> {
501 self.imu_spec.validate().map_err(FusionError::from)?;
502 self.imu_model.bias.validate().map_err(FusionError::from)?;
503 self.imu_model
504 .calibration
505 .validate()
506 .map_err(FusionError::from)?;
507 validate_dcm_orthonormal(&self.imu_to_body_dcm, "imu_to_body_dcm")
508 .map_err(FusionError::from)?;
509 self.loose.validate()?;
510 if self.configures_ukf_prediction_adaptation() {
511 return Err(invalid_input(
512 "loose_prediction_adaptation",
513 "prediction adaptation is defined for EKF loose updates",
514 ));
515 }
516 self.tight.validate()?;
517 self.ukf_update_options
518 .validate_for_dimension(ERROR_STATE_DIMENSION_15)?;
519 self.ukf_update_options
520 .validate_for_dimension(ERROR_STATE_DIMENSION_21)
521 }
522
523 fn configures_ukf_prediction_adaptation(&self) -> bool {
524 self.filter_kind == FusionFilterKind::Ukf && self.loose.prediction_adaptation.is_some()
525 }
526}
527
528#[derive(Debug, Clone, PartialEq)]
530pub struct FusionUpdate {
531 pub applied: bool,
533 pub nis: f64,
535 pub rows: usize,
537 pub accepted_rows: usize,
539 pub rejected_rows: usize,
541 pub ekf: EkfCorrectionReport,
543}
544
545impl FusionUpdate {
546 fn from_report(rows: usize, report: EkfCorrectionReport) -> Self {
547 Self {
548 applied: report.applied,
549 nis: report.normalized_innovation_squared,
550 rows,
551 accepted_rows: report.accepted_rows,
552 rejected_rows: report.rejected_rows,
553 ekf: report,
554 }
555 }
556}
557
558#[derive(Debug, Clone, PartialEq)]
560pub struct InertialFilter {
561 pub(super) state: InsFilterState,
562 pub(super) config: InertialFilterConfig,
563 pub(super) last_body_rate_wrt_ecef_rps: [f64; 3],
564 pub(super) stationarity_window: VecDeque<StationarityDetectorSnapshotSample>,
565 pub(super) last_stationary_update_t_j2000_s: Option<f64>,
566 pub(super) last_non_holonomic_update_t_j2000_s: Option<f64>,
567 pub(super) time_sync: TimeSyncHistory,
568 pub(super) tight: TightFusionState,
569}
570
571impl InertialFilter {
572 pub fn new(state: InsFilterState, imu_spec: ImuSpec) -> Result<Self, FusionError> {
574 let config = InertialFilterConfig::new(imu_spec)?;
575 Self::with_config(state, config)
576 }
577
578 pub fn with_config(
580 state: InsFilterState,
581 config: InertialFilterConfig,
582 ) -> Result<Self, FusionError> {
583 state.validate()?;
584 config.validate()?;
585 let tight = TightFusionState::from_filter_state(&state, config.tight)?;
586 let time_sync = TimeSyncHistory::from_initial(&state, &tight);
587 Ok(Self {
588 state,
589 config,
590 last_body_rate_wrt_ecef_rps: [0.0; 3],
591 stationarity_window: VecDeque::new(),
592 last_stationary_update_t_j2000_s: None,
593 last_non_holonomic_update_t_j2000_s: None,
594 time_sync,
595 tight,
596 })
597 }
598
599 pub const fn state(&self) -> &InsFilterState {
601 &self.state
602 }
603
604 pub fn state_mut(&mut self) -> &mut InsFilterState {
606 &mut self.state
607 }
608
609 pub const fn config(&self) -> &InertialFilterConfig {
611 &self.config
612 }
613
614 pub const fn last_body_rate_wrt_ecef_rps(&self) -> [f64; 3] {
616 self.last_body_rate_wrt_ecef_rps
617 }
618
619 pub fn propagate(&mut self, sample: ImuSample) -> Result<&InsFilterState, FusionError> {
621 let previous_t_j2000_s = self.state.nominal.t_j2000_s;
622 self.time_sync
623 .validate_next_imu(previous_t_j2000_s, sample)?;
624 self.propagate_core(sample)?;
625 self.time_sync.push_imu(previous_t_j2000_s, sample);
626 Ok(&self.state)
627 }
628
629 pub(super) fn propagate_core(
630 &mut self,
631 sample: ImuSample,
632 ) -> Result<FusionPredictionStep, FusionError> {
633 self.state.validate()?;
634 self.config.validate()?;
635 self.tight.align_with_filter_state(&self.state)?;
636
637 let previous = self.state.nominal;
638 let imu_model = self.effective_imu_model()?;
639 let increment = rotate_increment_imu_to_body(
640 imu_model
641 .correct_sample(&sample, previous.t_j2000_s)
642 .map_err(FusionError::from)?,
643 self.config.imu_to_body_dcm,
644 );
645 let kinematics = ErrorStateImuKinematics::new(
646 scale3(increment.delta_velocity_mps, 1.0 / increment.dt_s),
647 scale3(increment.delta_theta_rad, 1.0 / increment.dt_s),
648 )?;
649 let linearization = linearize_error_state_ecef_with_imu_to_body(
650 &previous,
651 kinematics,
652 &self.config.imu_spec,
653 increment.dt_s,
654 self.state.layout(),
655 self.config.imu_to_body_dcm,
656 )?;
657 let next_nominal = mechanize_ecef(&previous, &increment, self.config.mechanization)
658 .map_err(FusionError::from)?;
659 let body_rate_wrt_ecef_rps = body_rate_relative_to_ecef(
660 &next_nominal.attitude_body_to_ecef,
661 kinematics.angular_rate_body_rps,
662 );
663
664 predict_error_state_covariance(
665 &mut self.state.covariance,
666 &linearization.phi,
667 &linearization.q_d,
668 )?;
669 self.tight.predict_covariance(
670 &linearization.phi,
671 &linearization.q_d,
672 increment.dt_s,
673 self.config.tight,
674 )?;
675 self.tight.copy_base_covariance_to_state(&mut self.state)?;
676 self.state.nominal = next_nominal;
677 self.state.reset_error_state();
678 self.last_body_rate_wrt_ecef_rps = body_rate_wrt_ecef_rps;
679 self.record_stationarity_sample(
680 kinematics.specific_force_body_mps2,
681 body_rate_wrt_ecef_rps,
682 )?;
683 self.state.validate()?;
684 Ok(FusionPredictionStep {
685 transition: linearization.phi,
686 })
687 }
688
689 pub fn update_loose(
695 &mut self,
696 measurement: &GnssFixMeasurement,
697 ) -> Result<FusionUpdate, FusionError> {
698 if let Some(last) = self.time_sync.last_measurement_t_j2000_s() {
699 if measurement.t_j2000_s <= last {
700 return Err(invalid_input(
701 "t_j2000_s",
702 "GNSS measurement epochs must be strictly increasing",
703 ));
704 }
705 }
706 let update = self.update_loose_core(measurement)?;
707 let snapshot = self.snapshot();
708 self.time_sync
709 .push_loose_measurement_and_checkpoint(measurement.clone(), snapshot);
710 Ok(update)
711 }
712
713 pub(super) fn update_loose_core(
714 &mut self,
715 measurement: &GnssFixMeasurement,
716 ) -> Result<FusionUpdate, FusionError> {
717 let correction = loose_coupling_correction_with_imu_to_body(
718 &self.state,
719 measurement,
720 self.config.loose.lever_arm_body_m,
721 self.last_body_rate_wrt_ecef_rps,
722 self.config.imu_to_body_dcm,
723 )?;
724 let correction = apply_fix_status_weighting(
725 correction,
726 measurement.fix_status,
727 self.config.loose.fix_status_weighting,
728 )?;
729 self.apply_loose_style_correction(correction, self.config.loose)
730 }
731
732 pub fn update_stationary(&mut self) -> Result<Option<FusionUpdate>, FusionError> {
734 let Some(config) = self.config.loose.stationary_updates else {
735 return Ok(None);
736 };
737 if !self.is_stationary(config.detector)? {
738 return Ok(None);
739 }
740 let update_t_j2000_s = self.state.nominal.t_j2000_s;
741 if self.last_stationary_update_t_j2000_s == Some(update_t_j2000_s) {
742 return Err(invalid_input(
743 "t_j2000_s",
744 "stationary update already applied at this epoch",
745 ));
746 }
747 let correction = stationary_correction(
748 &self.state,
749 self.last_body_rate_wrt_ecef_rps,
750 config,
751 self.config.imu_to_body_dcm,
752 )?;
753 let update =
754 self.apply_loose_style_correction(correction, self.pseudo_measurement_config())?;
755 self.last_stationary_update_t_j2000_s = Some(update_t_j2000_s);
756 Ok(Some(update))
757 }
758
759 pub fn update_non_holonomic(&mut self) -> Result<Option<FusionUpdate>, FusionError> {
761 let Some(config) = self.config.loose.non_holonomic else {
762 return Ok(None);
763 };
764 if !self.nhc_motion_gate(config)? {
765 return Ok(None);
766 }
767 let update_t_j2000_s = self.state.nominal.t_j2000_s;
768 if self.last_non_holonomic_update_t_j2000_s == Some(update_t_j2000_s) {
769 return Err(invalid_input(
770 "t_j2000_s",
771 "non-holonomic update already applied at this epoch",
772 ));
773 }
774 let correction = non_holonomic_correction(&self.state, config)?;
775 let update =
776 self.apply_loose_style_correction(correction, self.pseudo_measurement_config())?;
777 self.last_non_holonomic_update_t_j2000_s = Some(update_t_j2000_s);
778 Ok(Some(update))
779 }
780
781 fn apply_loose_style_correction(
782 &mut self,
783 correction: EkfCorrection,
784 loose_config: LooseCouplingConfig,
785 ) -> Result<FusionUpdate, FusionError> {
786 let prepared = prepare_loose_correction(&self.state, correction, loose_config)?;
787 let rows = prepared.correction.row_count();
788 let filter_kind = self.config.filter_kind;
789 let ekf_options = self.config.loose.update_options;
790 let ukf_options = self.config.ukf_update_options;
791 let report = match filter_kind {
792 FusionFilterKind::Ekf => {
793 if prepared.predicted_covariance_scale == 1.0 {
794 ekf_correct_closed_loop(&mut self.state, &prepared.correction, ekf_options)?
795 } else {
796 ekf_correct_closed_loop_with_predicted_covariance_scale(
797 &mut self.state,
798 &prepared.correction,
799 ekf_options,
800 prepared.predicted_covariance_scale,
801 )?
802 }
803 }
804 FusionFilterKind::Ukf => {
805 ukf_correct_closed_loop(&mut self.state, &prepared.correction, ukf_options)?
806 }
807 };
808 self.tight
809 .replace_base_covariance_and_clear_cross(&self.state.covariance)?;
810 Ok(FusionUpdate::from_report(rows, report))
811 }
812
813 fn pseudo_measurement_config(&self) -> LooseCouplingConfig {
814 LooseCouplingConfig {
815 measurement_reweighting: None,
816 prediction_adaptation: None,
817 ..self.config.loose
818 }
819 }
820
821 fn record_stationarity_sample(
822 &mut self,
823 specific_force_body_mps2: [f64; 3],
824 body_rate_wrt_ecef_rps: [f64; 3],
825 ) -> Result<(), FusionError> {
826 let gravity_norm_mps2 = norm3(gravity_ecef_mps2(self.state.nominal.position_ecef_m)?);
827 let sample = StationarityDetectorSnapshotSample {
828 specific_force_norm_error_mps2: (norm3(specific_force_body_mps2) - gravity_norm_mps2)
829 .abs(),
830 body_rate_wrt_ecef_norm_rps: norm3(body_rate_wrt_ecef_rps),
831 };
832 validate_finite(
833 sample.specific_force_norm_error_mps2,
834 "specific_force_norm_error_mps2",
835 )
836 .map_err(FusionError::from)?;
837 validate_finite(
838 sample.body_rate_wrt_ecef_norm_rps,
839 "body_rate_wrt_ecef_norm_rps",
840 )
841 .map_err(FusionError::from)?;
842 self.stationarity_window.push_back(sample);
843 let max_len = self
844 .config
845 .loose
846 .stationary_updates
847 .map_or(1, |config| config.detector.window_len);
848 while self.stationarity_window.len() > max_len {
849 self.stationarity_window.pop_front();
850 }
851 Ok(())
852 }
853
854 fn is_stationary(&self, detector: StationaryDetectorConfig) -> Result<bool, FusionError> {
855 detector.validate()?;
856 if self.stationarity_window.len() < detector.window_len {
857 return Ok(false);
858 }
859 Ok(self
860 .stationarity_window
861 .iter()
862 .rev()
863 .take(detector.window_len)
864 .all(|sample| {
865 sample.specific_force_norm_error_mps2 <= detector.max_specific_force_norm_error_mps2
866 && sample.body_rate_wrt_ecef_norm_rps
867 <= detector.max_body_rate_wrt_ecef_norm_rps
868 }))
869 }
870
871 fn nhc_motion_gate(&self, config: NonHolonomicConstraintConfig) -> Result<bool, FusionError> {
872 config.validate()?;
873 let speed_mps = norm3(self.state.nominal.velocity_ecef_mps);
874 validate_finite(speed_mps, "nhc_speed_mps").map_err(FusionError::from)?;
875 Ok(speed_mps >= config.min_speed_mps
876 && norm3(self.last_body_rate_wrt_ecef_rps) <= config.max_body_rate_wrt_ecef_norm_rps)
877 }
878
879 fn effective_imu_model(&self) -> Result<ImuErrorModel, FusionError> {
880 let mut bias = self.config.imu_model.bias;
881 for axis in 0..3 {
882 bias.accel_mps2[axis] += self.state.nominal.accel_bias_mps2[axis];
883 bias.gyro_rps[axis] += self.state.nominal.gyro_bias_rps[axis];
884 }
885 let calibration = effective_calibration(
886 self.config.imu_model.calibration,
887 self.state.accel_scale_factor,
888 self.state.gyro_scale_factor,
889 )?;
890 let model = ImuErrorModel { bias, calibration };
891 model.bias.validate().map_err(FusionError::from)?;
892 model.calibration.validate().map_err(FusionError::from)?;
893 Ok(model)
894 }
895}
896
897pub fn loose_coupling_correction(
907 state: &InsFilterState,
908 measurement: &GnssFixMeasurement,
909 lever_arm_body_m: [f64; 3],
910 body_rate_wrt_ecef_rps: [f64; 3],
911) -> Result<EkfCorrection, FusionError> {
912 loose_coupling_correction_with_imu_to_body(
913 state,
914 measurement,
915 lever_arm_body_m,
916 body_rate_wrt_ecef_rps,
917 mat3_identity(),
918 )
919}
920
921fn loose_coupling_correction_with_imu_to_body(
922 state: &InsFilterState,
923 measurement: &GnssFixMeasurement,
924 lever_arm_body_m: [f64; 3],
925 body_rate_wrt_ecef_rps: [f64; 3],
926 imu_to_body_dcm: Mat3,
927) -> Result<EkfCorrection, FusionError> {
928 state.validate()?;
929 measurement.validate()?;
930 validate_vec3(lever_arm_body_m, "lever_arm_body_m").map_err(FusionError::from)?;
931 validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
932 validate_dcm_orthonormal(&imu_to_body_dcm, "imu_to_body_dcm").map_err(FusionError::from)?;
933 if measurement.t_j2000_s != state.nominal.t_j2000_s {
934 return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
935 }
936
937 let dimension = state.dimension();
938 let c_b_e = state.nominal.attitude_body_to_ecef;
939 let lever_ecef_m = mul_vec3(&c_b_e, lever_arm_body_m);
940 let antenna_position_ecef_m = add3(state.nominal.position_ecef_m, lever_ecef_m);
941 let lever_velocity_body_mps = cross3(body_rate_wrt_ecef_rps, lever_arm_body_m);
942 let lever_velocity_ecef_mps = mul_vec3(&c_b_e, lever_velocity_body_mps);
943 let antenna_velocity_ecef_mps = add3(state.nominal.velocity_ecef_mps, lever_velocity_ecef_mps);
944
945 let mut innovation = Vec::with_capacity(measurement.row_count());
946 let mut design = Vec::with_capacity(measurement.row_count());
947 let position_residual = sub3(measurement.position_ecef_m, antenna_position_ecef_m);
948 let lever_position_skew = skew(lever_ecef_m);
949 for axis in 0..3 {
950 let mut row = vec![0.0; dimension];
951 row[ERROR_POSITION_INDEX + axis] = -1.0;
952 row[ERROR_ATTITUDE_INDEX..ERROR_ATTITUDE_INDEX + 3]
953 .copy_from_slice(&lever_position_skew[axis]);
954 innovation.push(position_residual[axis]);
955 design.push(row);
956 }
957
958 if let Some(velocity_ecef_mps) = measurement.velocity_ecef_mps {
959 let velocity_residual = sub3(velocity_ecef_mps, antenna_velocity_ecef_mps);
960 let lever_velocity_skew = skew(lever_velocity_ecef_mps);
961 let gyro_bias_block = inline_rxr(
962 &inline_rxr(&c_b_e, &skew(lever_arm_body_m)),
963 &imu_to_body_dcm,
964 );
965 for axis in 0..3 {
966 let mut row = vec![0.0; dimension];
967 row[ERROR_VELOCITY_INDEX + axis] = -1.0;
968 row[ERROR_ATTITUDE_INDEX..ERROR_ATTITUDE_INDEX + 3]
969 .copy_from_slice(&lever_velocity_skew[axis]);
970 row[ERROR_GYRO_BIAS_INDEX..ERROR_GYRO_BIAS_INDEX + 3]
971 .copy_from_slice(&gyro_bias_block[axis]);
972 innovation.push(velocity_residual[axis]);
973 design.push(row);
974 }
975 }
976
977 EkfCorrection::new(innovation, design, measurement.covariance.clone())
978}
979
980fn stationary_correction(
981 state: &InsFilterState,
982 body_rate_wrt_ecef_rps: [f64; 3],
983 config: StationaryUpdateConfig,
984 imu_to_body_dcm: Mat3,
985) -> Result<EkfCorrection, FusionError> {
986 state.validate()?;
987 config.validate()?;
988 validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
989 validate_dcm_orthonormal(&imu_to_body_dcm, "imu_to_body_dcm").map_err(FusionError::from)?;
990 let dimension = state.dimension();
991 let mut innovation = Vec::with_capacity(ZUPT_ZARU_ROWS);
992 let mut design = Vec::with_capacity(ZUPT_ZARU_ROWS);
993 let mut covariance = vec![vec![0.0; ZUPT_ZARU_ROWS]; ZUPT_ZARU_ROWS];
994 let velocity_variance = config.zero_velocity_sigma_mps * config.zero_velocity_sigma_mps;
995 let rate_variance = config.zero_angular_rate_sigma_rps * config.zero_angular_rate_sigma_rps;
996
997 for axis in 0..3 {
998 let mut row = vec![0.0; dimension];
999 row[ERROR_VELOCITY_INDEX + axis] = -1.0;
1000 innovation.push(-state.nominal.velocity_ecef_mps[axis]);
1001 covariance[axis][axis] = velocity_variance;
1002 design.push(row);
1003 }
1004 for axis in 0..3 {
1005 let mut row = vec![0.0; dimension];
1006 for col in 0..3 {
1007 row[ERROR_GYRO_BIAS_INDEX + col] = -imu_to_body_dcm[axis][col];
1008 }
1009 innovation.push(-body_rate_wrt_ecef_rps[axis]);
1010 covariance[3 + axis][3 + axis] = rate_variance;
1011 design.push(row);
1012 }
1013
1014 EkfCorrection::new(innovation, design, covariance)
1015}
1016
1017fn non_holonomic_correction(
1018 state: &InsFilterState,
1019 config: NonHolonomicConstraintConfig,
1020) -> Result<EkfCorrection, FusionError> {
1021 state.validate()?;
1022 config.validate()?;
1023 let dimension = state.dimension();
1024 let c_e_b = inline_tr(&state.nominal.attitude_body_to_ecef);
1025 let velocity_body_mps = mul_vec3(&c_e_b, state.nominal.velocity_ecef_mps);
1026 let attitude_block = inline_rxr(&c_e_b, &skew(state.nominal.velocity_ecef_mps));
1027 let mut innovation = Vec::with_capacity(NHC_ROWS);
1028 let mut design = Vec::with_capacity(NHC_ROWS);
1029 let mut covariance = vec![vec![0.0; NHC_ROWS]; NHC_ROWS];
1030 let constrained_axes = [1usize, 2usize];
1031
1032 for (row_idx, body_axis) in constrained_axes.into_iter().enumerate() {
1033 let mut row = vec![0.0; dimension];
1034 for axis in 0..3 {
1035 row[ERROR_VELOCITY_INDEX + axis] = -c_e_b[body_axis][axis];
1036 row[ERROR_ATTITUDE_INDEX + axis] = -attitude_block[body_axis][axis];
1037 }
1038 innovation.push(-velocity_body_mps[body_axis]);
1039 design.push(row);
1040 let sigma = if body_axis == 1 {
1041 config.lateral_velocity_sigma_mps
1042 } else {
1043 config.vertical_velocity_sigma_mps
1044 };
1045 covariance[row_idx][row_idx] = sigma * sigma;
1046 }
1047
1048 EkfCorrection::new(innovation, design, covariance)
1049}
1050
1051fn apply_fix_status_weighting(
1052 correction: EkfCorrection,
1053 status: GnssFixStatus,
1054 weighting: GnssFixStatusWeighting,
1055) -> Result<EkfCorrection, FusionError> {
1056 weighting.validate()?;
1057 let multiplier = weighting.multiplier(status);
1058 if multiplier.to_bits() == 1.0_f64.to_bits() {
1059 return Ok(correction);
1060 }
1061 let variance_scale = multiplier * multiplier;
1062 let covariance = correction
1063 .measurement_covariance
1064 .iter()
1065 .map(|row| row.iter().map(|value| value * variance_scale).collect())
1066 .collect();
1067 EkfCorrection::new(correction.innovation, correction.design, covariance)
1068}
1069
1070fn rotate_increment_imu_to_body(
1071 increment: crate::inertial::CorrectedImuIncrement,
1072 imu_to_body_dcm: Mat3,
1073) -> crate::inertial::CorrectedImuIncrement {
1074 if imu_to_body_dcm == mat3_identity() {
1075 return increment;
1076 }
1077 crate::inertial::CorrectedImuIncrement {
1078 delta_velocity_mps: mul_vec3(&imu_to_body_dcm, increment.delta_velocity_mps),
1079 delta_theta_rad: mul_vec3(&imu_to_body_dcm, increment.delta_theta_rad),
1080 ..increment
1081 }
1082}
1083
1084#[derive(Debug, Clone, Copy, PartialEq)]
1086pub struct VelocityMatchState {
1087 pub t_j2000_s: f64,
1089 pub position_ecef_m: [f64; 3],
1091 pub velocity_ecef_mps: [f64; 3],
1093}
1094
1095impl VelocityMatchState {
1096 pub fn new(
1098 t_j2000_s: f64,
1099 position_ecef_m: [f64; 3],
1100 velocity_ecef_mps: [f64; 3],
1101 ) -> Result<Self, FusionError> {
1102 validate_finite(t_j2000_s, "t_j2000_s").map_err(FusionError::from)?;
1103 validate_vec3(position_ecef_m, "position_ecef_m").map_err(FusionError::from)?;
1104 validate_vec3(velocity_ecef_mps, "velocity_ecef_mps").map_err(FusionError::from)?;
1105 Ok(Self {
1106 t_j2000_s,
1107 position_ecef_m,
1108 velocity_ecef_mps,
1109 })
1110 }
1111}
1112
1113#[derive(Debug, Clone, PartialEq)]
1115pub struct VelocityMatchedTrajectory {
1116 pub states: Vec<VelocityMatchState>,
1118 pub endpoint_position_correction_ecef_m: [f64; 3],
1120 pub endpoint_velocity_correction_ecef_mps: [f64; 3],
1122}
1123
1124pub fn velocity_match_outage(
1133 states: &[VelocityMatchState],
1134 first_good_fix: &GnssFixMeasurement,
1135 config: VelocityMatchingConfig,
1136) -> Result<VelocityMatchedTrajectory, FusionError> {
1137 first_good_fix.validate()?;
1138 let Some(fix_velocity) = first_good_fix.velocity_ecef_mps else {
1139 return Err(invalid_input(
1140 "velocity_ecef_mps",
1141 "return fix must include velocity",
1142 ));
1143 };
1144 let endpoint = VelocityMatchState::new(
1145 first_good_fix.t_j2000_s,
1146 first_good_fix.position_ecef_m,
1147 fix_velocity,
1148 )?;
1149 velocity_match_outage_to_state(states, endpoint, config)
1150}
1151
1152pub fn velocity_match_outage_to_state(
1158 states: &[VelocityMatchState],
1159 endpoint: VelocityMatchState,
1160 config: VelocityMatchingConfig,
1161) -> Result<VelocityMatchedTrajectory, FusionError> {
1162 config.validate()?;
1163 if states.len() < 2 {
1164 return Err(invalid_input(
1165 "velocity_match_states",
1166 "must contain at least two states",
1167 ));
1168 }
1169 for state in states {
1170 VelocityMatchState::new(
1171 state.t_j2000_s,
1172 state.position_ecef_m,
1173 state.velocity_ecef_mps,
1174 )?;
1175 }
1176 for pair in states.windows(2) {
1177 if pair[1].t_j2000_s <= pair[0].t_j2000_s {
1178 return Err(invalid_input(
1179 "velocity_match_states",
1180 "epochs must be strictly increasing",
1181 ));
1182 }
1183 }
1184 let first = states[0];
1185 let last = states[states.len() - 1];
1186 let endpoint = VelocityMatchState::new(
1187 endpoint.t_j2000_s,
1188 endpoint.position_ecef_m,
1189 endpoint.velocity_ecef_mps,
1190 )?;
1191 if endpoint.t_j2000_s != last.t_j2000_s {
1192 return Err(invalid_input(
1193 "t_j2000_s",
1194 "endpoint state must match the last state epoch",
1195 ));
1196 }
1197 let duration_s = last.t_j2000_s - first.t_j2000_s;
1198 validate_positive(duration_s, "velocity_match_duration_s")?;
1199 if duration_s > config.max_outage_duration_s {
1200 return Err(invalid_input(
1201 "velocity_match_duration_s",
1202 "exceeds configured maximum",
1203 ));
1204 }
1205
1206 let endpoint_position_correction_ecef_m = sub3(endpoint.position_ecef_m, last.position_ecef_m);
1207 let endpoint_velocity_correction_ecef_mps =
1208 sub3(endpoint.velocity_ecef_mps, last.velocity_ecef_mps);
1209 let mut matched = Vec::with_capacity(states.len());
1210 for state in states {
1211 let tau = (state.t_j2000_s - first.t_j2000_s) / duration_s;
1212 let tau2 = tau * tau;
1213 let tau3 = tau2 * tau;
1214 let h01 = -2.0 * tau3 + 3.0 * tau2;
1215 let h11 = tau3 - tau2;
1216 let dh01 = (-6.0 * tau2 + 6.0 * tau) / duration_s;
1217 let dh11 = 3.0 * tau2 - 2.0 * tau;
1218 let mut position = state.position_ecef_m;
1219 let mut velocity = state.velocity_ecef_mps;
1220 for axis in 0..3 {
1221 position[axis] += h01 * endpoint_position_correction_ecef_m[axis]
1222 + duration_s * h11 * endpoint_velocity_correction_ecef_mps[axis];
1223 velocity[axis] += dh01 * endpoint_position_correction_ecef_m[axis]
1224 + dh11 * endpoint_velocity_correction_ecef_mps[axis];
1225 }
1226 matched.push(VelocityMatchState::new(
1227 state.t_j2000_s,
1228 position,
1229 velocity,
1230 )?);
1231 }
1232
1233 Ok(VelocityMatchedTrajectory {
1234 states: matched,
1235 endpoint_position_correction_ecef_m,
1236 endpoint_velocity_correction_ecef_mps,
1237 })
1238}
1239
1240#[derive(Debug, Clone, PartialEq)]
1241struct PreparedLooseCorrection {
1242 correction: EkfCorrection,
1243 predicted_covariance_scale: f64,
1244}
1245
1246fn prepare_loose_correction(
1247 state: &InsFilterState,
1248 correction: EkfCorrection,
1249 config: LooseCouplingConfig,
1250) -> Result<PreparedLooseCorrection, FusionError> {
1251 if config.measurement_reweighting.is_none() && config.prediction_adaptation.is_none() {
1252 return Ok(PreparedLooseCorrection {
1253 correction,
1254 predicted_covariance_scale: 1.0,
1255 });
1256 }
1257
1258 let raw_innovation_covariance = innovation_covariance(&state.covariance, &correction)?;
1259 let correction = if let Some(reweighting) = config.measurement_reweighting {
1260 apply_igg_iii_reweighting(&correction, &raw_innovation_covariance, reweighting)?
1261 } else {
1262 correction
1263 };
1264 let predicted_covariance_scale = if let Some(adaptation) = config.prediction_adaptation {
1265 yang_predicted_covariance_scale(state, &correction, &raw_innovation_covariance, adaptation)?
1266 } else {
1267 1.0
1268 };
1269
1270 Ok(PreparedLooseCorrection {
1271 correction,
1272 predicted_covariance_scale,
1273 })
1274}
1275
1276fn apply_igg_iii_reweighting(
1277 correction: &EkfCorrection,
1278 innovation_covariance: &[Vec<f64>],
1279 reweighting: IggIiiMeasurementReweighting,
1280) -> Result<EkfCorrection, FusionError> {
1281 reweighting.validate()?;
1282 let mut gammas = Vec::with_capacity(correction.row_count());
1283 let mut all_one = true;
1284 for (row, s_row) in innovation_covariance
1285 .iter()
1286 .enumerate()
1287 .take(correction.row_count())
1288 {
1289 let variance = s_row[row];
1290 validate_positive(variance, "innovation_covariance_diagonal")?;
1291 let standardized = (correction.innovation[row] / variance.sqrt()).abs();
1292 let gamma =
1293 igg_iii_variance_scale(standardized, reweighting.k0_sigma, reweighting.k1_sigma);
1294 all_one &= gamma.to_bits() == 1.0_f64.to_bits();
1295 gammas.push(gamma);
1296 }
1297
1298 if all_one {
1299 return Ok(correction.clone());
1300 }
1301
1302 let covariance = inflate_measurement_covariance(&correction.measurement_covariance, &gammas);
1303 EkfCorrection::new(
1304 correction.innovation.clone(),
1305 correction.design.clone(),
1306 covariance,
1307 )
1308}
1309
1310fn igg_iii_variance_scale(abs_standardized: f64, k0_sigma: f64, k1_sigma: f64) -> f64 {
1311 if abs_standardized <= k0_sigma {
1312 1.0
1313 } else if abs_standardized < k1_sigma {
1314 let ratio = abs_standardized / k0_sigma;
1315 let transition = (k1_sigma - k0_sigma) / (k1_sigma - abs_standardized);
1316 ratio * transition * transition
1317 } else {
1318 IGG_III_REJECTION_VARIANCE_SCALE
1319 }
1320}
1321
1322fn inflate_measurement_covariance(covariance: &[Vec<f64>], gammas: &[f64]) -> Vec<Vec<f64>> {
1323 let sqrt_gammas = gammas.iter().map(|gamma| gamma.sqrt()).collect::<Vec<_>>();
1324 let mut inflated = covariance.to_vec();
1325 for row in 0..inflated.len() {
1326 for col in 0..inflated[row].len() {
1327 inflated[row][col] *= sqrt_gammas[row] * sqrt_gammas[col];
1328 }
1329 }
1330 inflated
1331}
1332
1333fn yang_predicted_covariance_scale(
1334 state: &InsFilterState,
1335 correction: &EkfCorrection,
1336 raw_innovation_covariance: &[Vec<f64>],
1337 adaptation: YangPredictionAdaptiveFactor,
1338) -> Result<f64, FusionError> {
1339 adaptation.validate()?;
1340 let raw_mahalanobis =
1341 normalized_innovation_squared(raw_innovation_covariance, &correction.innovation)?;
1342 let outlier_threshold =
1343 crate::quality::chi2_inv(adaptation.outlier_gate_probability, correction.row_count())
1344 .map_err(|_| {
1345 invalid_input(
1346 "yang_outlier_gate_probability",
1347 "must produce a chi-square threshold",
1348 )
1349 })?;
1350 if raw_mahalanobis > outlier_threshold {
1353 return Ok(1.0);
1354 }
1355
1356 let innovation_covariance = innovation_covariance(&state.covariance, correction)?;
1357 let trace = innovation_covariance
1358 .iter()
1359 .enumerate()
1360 .map(|(idx, row)| row[idx])
1361 .sum::<f64>();
1362 validate_positive(trace, "innovation_covariance_trace")?;
1363 let squared_norm = correction
1364 .innovation
1365 .iter()
1366 .map(|value| value * value)
1367 .sum::<f64>();
1368 let statistic = squared_norm / trace;
1369 if statistic <= adaptation.threshold {
1370 Ok(1.0)
1371 } else {
1372 Ok(statistic / adaptation.threshold)
1373 }
1374}
1375
1376fn body_rate_relative_to_ecef(
1377 attitude_body_to_ecef: &Mat3,
1378 inertial_body_rate_rps: [f64; 3],
1379) -> [f64; 3] {
1380 let attitude_ecef_to_body = inline_tr(attitude_body_to_ecef);
1381 let earth_rate_body_rps = mul_vec3(&attitude_ecef_to_body, [0.0, 0.0, OMEGA_E_DOT_RAD_S]);
1382 sub3(inertial_body_rate_rps, earth_rate_body_rps)
1383}
1384
1385fn effective_calibration(
1386 base: ImuCalibration,
1387 accel_scale_factor: [f64; 3],
1388 gyro_scale_factor: [f64; 3],
1389) -> Result<ImuCalibration, FusionError> {
1390 let mut calibration = base;
1391 for axis in 0..3 {
1392 calibration.accel_scale_misalignment[axis][axis] += accel_scale_factor[axis];
1393 calibration.gyro_scale_misalignment[axis][axis] += gyro_scale_factor[axis];
1394 }
1395 calibration.validate().map_err(FusionError::from)?;
1396 Ok(calibration)
1397}
1398
1399fn mat3_to_rows(matrix: [[f64; 3]; 3]) -> Vec<Vec<f64>> {
1400 matrix.into_iter().map(Vec::from).collect()
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405 use super::*;
1413 use crate::astro::constants::earth::{OMEGA_E_DOT_RAD_S, WGS84_A_M};
1414 use crate::astro::math::mat3::{inline_tr, Mat3};
1415 use crate::astro::math::vec3::{dot3, norm3};
1416 use crate::fusion::state::{
1417 ERROR_ACCEL_BIAS_INDEX, ERROR_GYRO_BIAS_INDEX, ERROR_STATE_DIMENSION_15,
1418 };
1419 use crate::inertial::frames::gravity_ecef_mps2;
1420 use crate::inertial::state::{mat3_identity, mat3_mul, mat3_mul_vec, reorthonormalize_dcm};
1421 use crate::inertial::{CorrectedImuIncrement, NavState};
1422 use nalgebra::{DMatrix, DVector};
1423
1424 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1425 assert!(
1426 (actual - expected).abs() <= tolerance,
1427 "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
1428 );
1429 }
1430
1431 fn covariance_from_diag(diagonal: &[f64]) -> Vec<Vec<f64>> {
1432 let mut covariance = vec![vec![0.0; diagonal.len()]; diagonal.len()];
1433 for (idx, value) in diagonal.iter().enumerate() {
1434 covariance[idx][idx] = *value;
1435 }
1436 covariance
1437 }
1438
1439 fn reference_filter_state(
1440 nominal: NavState,
1441 diagonal: &[f64],
1442 ) -> Result<InsFilterState, FusionError> {
1443 InsFilterState::from_diagonal(
1444 nominal,
1445 super::super::state::ErrorStateLayout::Fifteen,
1446 diagonal,
1447 )
1448 }
1449
1450 #[test]
1451 fn loose_correction_builds_lever_arm_rows_and_keeps_input_covariance() {
1452 let state = reference_filter_state(
1453 NavState::new(10.0, [10.0, 20.0, 30.0], [1.0, 2.0, 3.0], mat3_identity())
1454 .expect("state"),
1455 &[1.0; ERROR_STATE_DIMENSION_15],
1456 )
1457 .expect("filter state");
1458 let lever = [0.5, -1.0, 2.0];
1459 let omega = [0.1, 0.2, -0.3];
1460 let lever_position = lever;
1461 let lever_velocity = cross3(omega, lever);
1462 let position_residual = [1.0, -2.0, 3.0];
1463 let velocity_residual = [0.4, -0.5, 0.6];
1464 let covariance = covariance_from_diag(&[4.0, 5.0, 6.0, 0.7, 0.8, 0.9]);
1465 let measurement = GnssFixMeasurement::position_velocity(
1466 10.0,
1467 add3(
1468 add3(state.nominal.position_ecef_m, lever_position),
1469 position_residual,
1470 ),
1471 add3(
1472 add3(state.nominal.velocity_ecef_mps, lever_velocity),
1473 velocity_residual,
1474 ),
1475 covariance.clone(),
1476 6,
1477 )
1478 .expect("measurement");
1479
1480 let correction =
1481 loose_coupling_correction(&state, &measurement, lever, omega).expect("correction");
1482
1483 for axis in 0..3 {
1484 assert_close(
1485 correction.innovation[axis],
1486 position_residual[axis],
1487 2.0e-16,
1488 );
1489 assert_close(
1490 correction.innovation[3 + axis],
1491 velocity_residual[axis],
1492 2.0e-16,
1493 );
1494 }
1495 assert_eq!(correction.measurement_covariance, covariance);
1496 assert_eq!(
1497 correction.design[0][ERROR_POSITION_INDEX].to_bits(),
1498 (-1.0_f64).to_bits()
1499 );
1500 assert_eq!(
1501 correction.design[1][ERROR_POSITION_INDEX + 1].to_bits(),
1502 (-1.0_f64).to_bits()
1503 );
1504 let lever_skew = skew(lever);
1505 for (row, expected_row) in lever_skew.iter().enumerate() {
1506 for (col, expected) in expected_row.iter().enumerate() {
1507 assert_eq!(
1508 correction.design[row][ERROR_ATTITUDE_INDEX + col].to_bits(),
1509 expected.to_bits()
1510 );
1511 }
1512 }
1513 let gyro_block = skew(lever);
1514 for (row, expected_row) in gyro_block.iter().enumerate() {
1515 for (col, expected) in expected_row.iter().enumerate() {
1516 assert_eq!(
1517 correction.design[3 + row][ERROR_GYRO_BIAS_INDEX + col].to_bits(),
1518 expected.to_bits()
1519 );
1520 }
1521 }
1522 }
1523
1524 #[test]
1525 fn loose_and_zaru_jacobians_rotate_imu_bias_axes() {
1526 let state = reference_filter_state(
1527 NavState::new(10.0, [10.0, 20.0, 30.0], [1.0, 2.0, 3.0], mat3_identity())
1528 .expect("state"),
1529 &[1.0; ERROR_STATE_DIMENSION_15],
1530 )
1531 .expect("filter state");
1532 let lever = [0.5, -1.0, 2.0];
1533 let imu_to_body = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
1534 let measurement = GnssFixMeasurement::position_velocity(
1535 10.0,
1536 state.nominal.position_ecef_m,
1537 state.nominal.velocity_ecef_mps,
1538 covariance_from_diag(&[1.0; 6]),
1539 6,
1540 )
1541 .expect("measurement");
1542 let correction = loose_coupling_correction_with_imu_to_body(
1543 &state,
1544 &measurement,
1545 lever,
1546 [0.0; 3],
1547 imu_to_body,
1548 )
1549 .expect("correction");
1550 let expected_gyro_block = inline_rxr(&skew(lever), &imu_to_body);
1551 for (row, expected_row) in expected_gyro_block.iter().enumerate() {
1552 for (col, expected) in expected_row.iter().enumerate() {
1553 assert_eq!(
1554 correction.design[3 + row][ERROR_GYRO_BIAS_INDEX + col].to_bits(),
1555 expected.to_bits()
1556 );
1557 }
1558 }
1559
1560 let stationary = stationary_correction(
1561 &state,
1562 [0.01, -0.02, 0.03],
1563 StationaryUpdateConfig {
1564 detector: StationaryDetectorConfig {
1565 window_len: 1,
1566 max_specific_force_norm_error_mps2: 1.0,
1567 max_body_rate_wrt_ecef_norm_rps: 1.0,
1568 },
1569 zero_velocity_sigma_mps: 0.1,
1570 zero_angular_rate_sigma_rps: 0.01,
1571 },
1572 imu_to_body,
1573 )
1574 .expect("stationary correction");
1575 for (row, expected_row) in imu_to_body.iter().enumerate() {
1576 for (col, expected) in expected_row.iter().enumerate() {
1577 assert_eq!(
1578 stationary.design[3 + row][ERROR_GYRO_BIAS_INDEX + col].to_bits(),
1579 (-*expected).to_bits()
1580 );
1581 }
1582 }
1583 }
1584
1585 #[test]
1586 fn propagated_static_ecef_body_reports_zero_lever_velocity() {
1587 let lever = [1.0, 0.5, -0.25];
1588 let truth =
1589 NavState::new(0.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("truth");
1590 let state =
1591 reference_filter_state(truth, &[1.0; ERROR_STATE_DIMENSION_15]).expect("filter state");
1592 let spec = ImuSpec::datasheet(
1593 0.0,
1594 0.0,
1595 0.0,
1596 0.0,
1597 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1598 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1599 None,
1600 None,
1601 );
1602 let mut config = InertialFilterConfig::new(spec).expect("config");
1603 config.loose.lever_arm_body_m = lever;
1604 let mut filter = InertialFilter::with_config(state, config).expect("filter");
1605 let (truth_next, sample, truth_body_rate_wrt_ecef) =
1606 inverted_static_sample(&truth, 1.0, 1.0, [0.0; 3], [0.0; 3]);
1607
1608 for value in truth_body_rate_wrt_ecef {
1609 assert_close(value, 0.0, 0.0);
1610 }
1611 filter.propagate(sample).expect("propagate");
1612 for value in filter.last_body_rate_wrt_ecef_rps() {
1613 assert_close(value, 0.0, 0.0);
1614 }
1615
1616 let antenna_position = add3(
1617 truth_next.position_ecef_m,
1618 mul_vec3(&truth_next.attitude_body_to_ecef, lever),
1619 );
1620 let measurement = GnssFixMeasurement::position_velocity(
1621 truth_next.t_j2000_s,
1622 antenna_position,
1623 truth_next.velocity_ecef_mps,
1624 covariance_from_diag(&[1.0, 1.0, 1.0, 1.0e-6, 1.0e-6, 1.0e-6]),
1625 8,
1626 )
1627 .expect("measurement");
1628 let correction = loose_coupling_correction(
1629 filter.state(),
1630 &measurement,
1631 lever,
1632 filter.last_body_rate_wrt_ecef_rps(),
1633 )
1634 .expect("correction");
1635 for axis in 0..3 {
1636 assert_close(correction.innovation[3 + axis], 0.0, 0.0);
1637 }
1638 }
1639
1640 #[test]
1641 fn loose_update_rejects_failed_or_short_gnss_fix() {
1642 let measurement = GnssFixMeasurement {
1643 t_j2000_s: 0.0,
1644 position_ecef_m: [WGS84_A_M, 0.0, 0.0],
1645 velocity_ecef_mps: None,
1646 covariance: covariance_from_diag(&[1.0, 1.0, 1.0]),
1647 satellites_used: 3,
1648 solution_valid: true,
1649 fix_status: GnssFixStatus::Single,
1650 };
1651 assert!(matches!(
1652 measurement.validate(),
1653 Err(FusionError::InvalidInput {
1654 field: "satellites_used",
1655 reason: "at least 4 satellites required"
1656 })
1657 ));
1658
1659 let failed = GnssFixMeasurement {
1660 satellites_used: 6,
1661 solution_valid: false,
1662 ..measurement
1663 };
1664 assert!(matches!(
1665 failed.validate(),
1666 Err(FusionError::InvalidInput {
1667 field: "solution_valid",
1668 reason: "GNSS fix must be successful"
1669 })
1670 ));
1671 }
1672
1673 #[test]
1674 fn synthetic_static_truth_recovers_within_three_sigma_and_biases_converge() {
1675 let dt_s = 1.0;
1676 let steps = 20usize;
1677 let lever = [1.0, 0.5, -0.25];
1678 let accel_bias = [0.0015, -0.0010, 0.0020];
1679 let gyro_bias = [0.000009765625, -0.000009765625, 0.00001953125];
1680 let mut truth =
1681 NavState::new(0.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("truth");
1682 let nominal = NavState::new(
1683 0.0,
1684 [WGS84_A_M + 2.0, -1.0, 0.5],
1685 [0.3, -0.2, 0.1],
1686 mat3_identity(),
1687 )
1688 .expect("nominal");
1689 let mut diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1690 for axis in 0..3 {
1691 diagonal[ERROR_POSITION_INDEX + axis] = 25.0;
1692 diagonal[ERROR_VELOCITY_INDEX + axis] = 1.0;
1693 diagonal[ERROR_ATTITUDE_INDEX + axis] = 0.05 * 0.05;
1694 diagonal[ERROR_ACCEL_BIAS_INDEX + axis] = 0.05 * 0.05;
1695 diagonal[ERROR_GYRO_BIAS_INDEX + axis] = 0.003 * 0.003;
1696 }
1697 let state = reference_filter_state(nominal, &diagonal).expect("filter state");
1698 let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
1699 let mut config = InertialFilterConfig::new(spec).expect("config");
1700 config.loose.lever_arm_body_m = lever;
1701 let mut filter = InertialFilter::with_config(state, config).expect("filter");
1702 let mut rng = SplitMix64::new(0x4c4f_4f53_455f_0001);
1703 let position_sigma_m = 0.20;
1704 let velocity_sigma_mps = 0.030;
1705 let covariance = covariance_from_diag(&[
1706 position_sigma_m * position_sigma_m,
1707 position_sigma_m * position_sigma_m,
1708 position_sigma_m * position_sigma_m,
1709 velocity_sigma_mps * velocity_sigma_mps,
1710 velocity_sigma_mps * velocity_sigma_mps,
1711 velocity_sigma_mps * velocity_sigma_mps,
1712 ]);
1713
1714 for step in 1..=steps {
1715 let (truth_next, sample, true_body_rate_wrt_ecef) =
1716 inverted_static_sample(&truth, step as f64 * dt_s, dt_s, accel_bias, gyro_bias);
1717 truth = truth_next;
1718 filter.propagate(sample).expect("propagate");
1719 let antenna_position = add3(
1720 truth.position_ecef_m,
1721 mul_vec3(&truth.attitude_body_to_ecef, lever),
1722 );
1723 let antenna_velocity = add3(
1724 truth.velocity_ecef_mps,
1725 mul_vec3(
1726 &truth.attitude_body_to_ecef,
1727 cross3(true_body_rate_wrt_ecef, lever),
1728 ),
1729 );
1730 let measurement = GnssFixMeasurement::position_velocity(
1731 truth.t_j2000_s,
1732 add_noise3(antenna_position, position_sigma_m, &mut rng),
1733 add_noise3(antenna_velocity, velocity_sigma_mps, &mut rng),
1734 covariance.clone(),
1735 8,
1736 )
1737 .expect("measurement");
1738 let update = filter.update_loose(&measurement).expect("loose update");
1739 assert!(update.applied);
1740 assert_eq!(
1741 update.nis.to_bits(),
1742 update.ekf.normalized_innovation_squared.to_bits()
1743 );
1744 }
1745
1746 let state = filter.state();
1747 for (axis, expected_accel_bias) in accel_bias.iter().enumerate() {
1748 let position_error = state.nominal.position_ecef_m[axis] - truth.position_ecef_m[axis];
1749 let velocity_error =
1750 state.nominal.velocity_ecef_mps[axis] - truth.velocity_ecef_mps[axis];
1751 let position_bound = 3.0
1752 * state.covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis].sqrt();
1753 assert!(
1754 position_error.abs() <= position_bound,
1755 "position axis {axis} error {position_error:.17e}, bound {position_bound:.17e}"
1756 );
1757 assert!(
1758 velocity_error.abs()
1759 <= 3.0
1760 * state.covariance[ERROR_VELOCITY_INDEX + axis]
1761 [ERROR_VELOCITY_INDEX + axis]
1762 .sqrt(),
1763 "velocity axis {axis} error {velocity_error:.17e}"
1764 );
1765 let accel_bias_error = state.nominal.accel_bias_mps2[axis] - *expected_accel_bias;
1766 let accel_bias_bound = 3.0
1767 * state.covariance[ERROR_ACCEL_BIAS_INDEX + axis][ERROR_ACCEL_BIAS_INDEX + axis]
1768 .sqrt();
1769 assert!(
1770 accel_bias_error.abs() <= accel_bias_bound,
1771 "accelerometer bias axis {axis} error {accel_bias_error:.17e}, bound {accel_bias_bound:.17e}"
1772 );
1773 }
1774 }
1775
1776 #[test]
1777 fn lever_velocity_update_converges_observable_gyro_bias_components() {
1778 let dt_s = 0.1;
1779 let lever = [1.0, 0.5, -0.25];
1780 let gyro_bias = [0.0009765625, -0.0009765625, 0.001953125];
1781 let truth =
1782 NavState::new(0.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("truth");
1783 let mut diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1784 for axis in 0..3 {
1785 diagonal[ERROR_POSITION_INDEX + axis] = 1.0;
1786 diagonal[ERROR_VELOCITY_INDEX + axis] = 1.0e-10;
1787 diagonal[ERROR_ATTITUDE_INDEX + axis] = 1.0e-10;
1788 diagonal[ERROR_ACCEL_BIAS_INDEX + axis] = 1.0e-10;
1789 diagonal[ERROR_GYRO_BIAS_INDEX + axis] = 1.0e-4;
1790 }
1791 let state = reference_filter_state(truth, &diagonal).expect("filter state");
1792 let spec = ImuSpec::datasheet(
1793 0.0,
1794 0.0,
1795 0.0,
1796 0.0,
1797 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1798 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1799 None,
1800 None,
1801 );
1802 let mut config = InertialFilterConfig::new(spec).expect("config");
1803 config.loose.lever_arm_body_m = lever;
1804 let mut filter = InertialFilter::with_config(state, config).expect("filter");
1805 let (truth_next, sample, true_body_rate_wrt_ecef) =
1806 inverted_static_sample(&truth, dt_s, dt_s, [0.0; 3], gyro_bias);
1807 filter.propagate(sample).expect("propagate");
1808
1809 let antenna_position = add3(
1810 truth_next.position_ecef_m,
1811 mul_vec3(&truth_next.attitude_body_to_ecef, lever),
1812 );
1813 let antenna_velocity = add3(
1814 truth_next.velocity_ecef_mps,
1815 mul_vec3(
1816 &truth_next.attitude_body_to_ecef,
1817 cross3(true_body_rate_wrt_ecef, lever),
1818 ),
1819 );
1820 let measurement = GnssFixMeasurement::position_velocity(
1821 truth_next.t_j2000_s,
1822 antenna_position,
1823 antenna_velocity,
1824 covariance_from_diag(&[1.0e6, 1.0e6, 1.0e6, 1.0e-8, 1.0e-8, 1.0e-8]),
1825 8,
1826 )
1827 .expect("measurement");
1828 let update = filter.update_loose(&measurement).expect("loose update");
1829 assert!(update.applied);
1830
1831 let state = filter.state();
1832 for (axis, expected_gyro_bias) in gyro_bias.iter().enumerate() {
1833 let error = state.nominal.gyro_bias_rps[axis] - *expected_gyro_bias;
1834 let bound = 3.0
1835 * state.covariance[ERROR_GYRO_BIAS_INDEX + axis][ERROR_GYRO_BIAS_INDEX + axis]
1836 .sqrt();
1837 assert!(
1838 error.abs() <= bound,
1839 "gyroscope bias axis {axis} error {error:.17e}, bound {bound:.17e}"
1840 );
1841 }
1842 }
1843
1844 #[test]
1845 fn loose_nees_and_nis_land_inside_bar_shalom_chi_square_bands() {
1846 let trials = 40usize;
1847 let alpha = 0.05;
1848 let p_diag: [f64; 6] = [9.0, 4.0, 16.0, 0.25, 0.36, 0.49];
1849 let r_diag: [f64; 6] = [1.0, 1.44, 0.64, 0.04, 0.09, 0.16];
1850 let truth =
1851 NavState::new(20.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("truth");
1852 let mut rng = SplitMix64::new(0x4241_5253_4841_4c4f);
1853 let mut nees_sum = 0.0;
1854 let mut nis_sum = 0.0;
1855
1856 for _ in 0..trials {
1857 let mut initial_error = [0.0; 6];
1858 let mut measurement_noise = [0.0; 6];
1859 for idx in 0..6 {
1860 initial_error[idx] = p_diag[idx].sqrt() * rng.standard_normal();
1861 measurement_noise[idx] = r_diag[idx].sqrt() * rng.standard_normal();
1862 }
1863 let nominal = NavState::new(
1864 20.0,
1865 [
1866 truth.position_ecef_m[0] + initial_error[0],
1867 truth.position_ecef_m[1] + initial_error[1],
1868 truth.position_ecef_m[2] + initial_error[2],
1869 ],
1870 [
1871 truth.velocity_ecef_mps[0] + initial_error[3],
1872 truth.velocity_ecef_mps[1] + initial_error[4],
1873 truth.velocity_ecef_mps[2] + initial_error[5],
1874 ],
1875 mat3_identity(),
1876 )
1877 .expect("nominal");
1878 let mut diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1879 diagonal[..6].copy_from_slice(&p_diag);
1880 for value in diagonal.iter_mut().take(ERROR_STATE_DIMENSION_15).skip(6) {
1881 *value = 1.0;
1882 }
1883 let state = reference_filter_state(nominal, &diagonal).expect("filter state");
1884 let spec = ImuSpec::datasheet(
1885 0.0,
1886 0.0,
1887 0.0,
1888 0.0,
1889 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1890 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1891 None,
1892 None,
1893 );
1894 let mut filter = InertialFilter::new(state, spec).expect("filter");
1895 let measurement = GnssFixMeasurement::position_velocity(
1896 20.0,
1897 [
1898 truth.position_ecef_m[0] + measurement_noise[0],
1899 truth.position_ecef_m[1] + measurement_noise[1],
1900 truth.position_ecef_m[2] + measurement_noise[2],
1901 ],
1902 [
1903 truth.velocity_ecef_mps[0] + measurement_noise[3],
1904 truth.velocity_ecef_mps[1] + measurement_noise[4],
1905 truth.velocity_ecef_mps[2] + measurement_noise[5],
1906 ],
1907 covariance_from_diag(&r_diag),
1908 8,
1909 )
1910 .expect("measurement");
1911 let expected_nis = (0..6)
1912 .map(|idx| {
1913 let innovation = measurement_noise[idx] - initial_error[idx];
1914 innovation * innovation / (p_diag[idx] + r_diag[idx])
1915 })
1916 .sum::<f64>();
1917 let update = filter.update_loose(&measurement).expect("loose update");
1918 assert_close(update.nis, expected_nis, 1.0e-9);
1919 nis_sum += update.nis;
1920
1921 let updated = filter.state();
1922 for idx in 0..6 {
1923 let expected_variance = p_diag[idx] * r_diag[idx] / (p_diag[idx] + r_diag[idx]);
1924 assert_close(updated.covariance[idx][idx], expected_variance, 5.0e-15);
1925 }
1926 let dx = [
1927 updated.nominal.position_ecef_m[0] - truth.position_ecef_m[0],
1928 updated.nominal.position_ecef_m[1] - truth.position_ecef_m[1],
1929 updated.nominal.position_ecef_m[2] - truth.position_ecef_m[2],
1930 updated.nominal.velocity_ecef_mps[0] - truth.velocity_ecef_mps[0],
1931 updated.nominal.velocity_ecef_mps[1] - truth.velocity_ecef_mps[1],
1932 updated.nominal.velocity_ecef_mps[2] - truth.velocity_ecef_mps[2],
1933 ];
1934 nees_sum += quadratic_form(&updated.covariance, &dx, 6);
1935 }
1936
1937 let nis_average = nis_sum / trials as f64;
1938 let nees_average = nees_sum / trials as f64;
1939 let dof = trials * 6;
1940 let lower = crate::quality::chi2_inv(alpha * 0.5, dof).expect("lower") / trials as f64;
1941 let upper =
1942 crate::quality::chi2_inv(1.0 - alpha * 0.5, dof).expect("upper") / trials as f64;
1943 assert!(
1944 (lower..=upper).contains(&nis_average),
1945 "NIS average {nis_average:.17e}, band [{lower:.17e}, {upper:.17e}]"
1946 );
1947 assert!(
1948 (lower..=upper).contains(&nees_average),
1949 "NEES average {nees_average:.17e}, band [{lower:.17e}, {upper:.17e}]"
1950 );
1951 }
1952
1953 #[test]
1954 fn igg_iii_noop_region_matches_plain_l2_to_bits() {
1955 let measurement = direct_position_velocity_measurement(
1956 30.0,
1957 [WGS84_A_M + 0.25, -0.125, 0.0625],
1958 [0.03125, -0.015625, 0.0078125],
1959 1.0,
1960 );
1961 let mut plain = direct_update_filter(30.0, LooseCouplingConfig::default());
1962 let mut robust = direct_update_filter(
1963 30.0,
1964 LooseCouplingConfig {
1965 measurement_reweighting: Some(IggIiiMeasurementReweighting::standard()),
1966 ..LooseCouplingConfig::default()
1967 },
1968 );
1969
1970 let plain_update = plain.update_loose(&measurement).expect("plain update");
1971 let robust_update = robust.update_loose(&measurement).expect("robust update");
1972
1973 assert_eq!(plain_update, robust_update);
1974 assert_eq!(plain.state(), robust.state());
1975 }
1976
1977 #[test]
1978 fn igg_iii_single_outlier_stays_within_tenth_sigma_of_clean_run() {
1979 const X_SIGMA: f64 = 0.1;
1983 let clean_measurement =
1984 direct_position_velocity_measurement(40.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], 1.0);
1985 let outlier_measurement =
1986 direct_position_velocity_measurement(40.0, [WGS84_A_M + 50.0, 0.0, 0.0], [0.0; 3], 1.0);
1987 let robust_config = LooseCouplingConfig {
1988 measurement_reweighting: Some(IggIiiMeasurementReweighting::standard()),
1989 ..LooseCouplingConfig::default()
1990 };
1991 let mut clean = direct_update_filter(40.0, LooseCouplingConfig::default());
1992 let mut plain = direct_update_filter(40.0, LooseCouplingConfig::default());
1993 let mut robust = direct_update_filter(40.0, robust_config);
1994
1995 clean
1996 .update_loose(&clean_measurement)
1997 .expect("clean update");
1998 plain
1999 .update_loose(&outlier_measurement)
2000 .expect("plain update");
2001 robust
2002 .update_loose(&outlier_measurement)
2003 .expect("robust update");
2004
2005 let clean_x = clean.state().nominal.position_ecef_m[0];
2006 let clean_sigma =
2007 clean.state().covariance[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX].sqrt();
2008 let robust_error = (robust.state().nominal.position_ecef_m[0] - clean_x).abs();
2009 let plain_error = (plain.state().nominal.position_ecef_m[0] - clean_x).abs();
2010 assert!(
2011 robust_error <= X_SIGMA * clean_sigma,
2012 "robust error {robust_error:.17e}, bound {:.17e}",
2013 X_SIGMA * clean_sigma
2014 );
2015 assert!(
2016 plain_error > X_SIGMA * clean_sigma,
2017 "plain error {plain_error:.17e}, bound {:.17e}",
2018 X_SIGMA * clean_sigma
2019 );
2020 }
2021
2022 #[test]
2023 fn yang_prediction_adaptation_inflates_covariance_when_gate_passes() {
2024 let measurement =
2025 direct_position_velocity_measurement(50.0, [WGS84_A_M + 5.0, 0.0, 0.0], [0.0; 3], 1.0);
2026 let mut plain = direct_update_filter(50.0, LooseCouplingConfig::default());
2027 let mut adaptive = direct_update_filter(
2028 50.0,
2029 LooseCouplingConfig {
2030 prediction_adaptation: Some(YangPredictionAdaptiveFactor {
2031 threshold: 0.1,
2032 outlier_gate_probability: 0.99,
2033 }),
2034 ..LooseCouplingConfig::default()
2035 },
2036 );
2037
2038 plain.update_loose(&measurement).expect("plain update");
2039 adaptive
2040 .update_loose(&measurement)
2041 .expect("adaptive update");
2042
2043 let plain_error = (plain.state().nominal.position_ecef_m[0] - (WGS84_A_M + 5.0)).abs();
2044 let adaptive_error =
2045 (adaptive.state().nominal.position_ecef_m[0] - (WGS84_A_M + 5.0)).abs();
2046 assert!(
2047 adaptive_error < plain_error,
2048 "adaptive error {adaptive_error:.17e}, plain error {plain_error:.17e}"
2049 );
2050 }
2051
2052 #[test]
2053 fn yang_prediction_adaptation_is_disabled_by_mahalanobis_outlier_gate() {
2054 let measurement =
2055 direct_position_velocity_measurement(60.0, [WGS84_A_M + 50.0, 0.0, 0.0], [0.0; 3], 1.0);
2056 let robust_only = LooseCouplingConfig {
2057 measurement_reweighting: Some(IggIiiMeasurementReweighting::standard()),
2058 ..LooseCouplingConfig::default()
2059 };
2060 let robust_and_adaptive = LooseCouplingConfig {
2061 prediction_adaptation: Some(YangPredictionAdaptiveFactor {
2062 threshold: 0.1,
2063 outlier_gate_probability: 0.99,
2064 }),
2065 ..robust_only
2066 };
2067 let mut robust = direct_update_filter(60.0, robust_only);
2068 let mut guarded = direct_update_filter(60.0, robust_and_adaptive);
2069
2070 let robust_update = robust.update_loose(&measurement).expect("robust update");
2071 let guarded_update = guarded.update_loose(&measurement).expect("guarded update");
2072
2073 assert_eq!(robust_update, guarded_update);
2074 assert_eq!(robust.state(), guarded.state());
2075 }
2076
2077 #[test]
2078 fn stationary_pseudo_update_ignores_gnss_robust_options() {
2079 let stationary_updates = StationaryUpdateConfig {
2080 detector: StationaryDetectorConfig {
2081 window_len: 1,
2082 max_specific_force_norm_error_mps2: 1.0,
2083 max_body_rate_wrt_ecef_norm_rps: 1.0,
2084 },
2085 zero_velocity_sigma_mps: 1.0,
2086 zero_angular_rate_sigma_rps: 1.0,
2087 };
2088 let plain_config = LooseCouplingConfig {
2089 stationary_updates: Some(stationary_updates),
2090 ..LooseCouplingConfig::default()
2091 };
2092 let robust_config = LooseCouplingConfig {
2093 measurement_reweighting: Some(IggIiiMeasurementReweighting::standard()),
2094 prediction_adaptation: Some(YangPredictionAdaptiveFactor {
2095 threshold: 0.1,
2096 outlier_gate_probability: 0.99,
2097 }),
2098 stationary_updates: Some(stationary_updates),
2099 ..LooseCouplingConfig::default()
2100 };
2101 let mut plain = direct_update_filter(70.0, plain_config);
2102 let mut robust = direct_update_filter(70.0, robust_config);
2103 for filter in [&mut plain, &mut robust] {
2104 filter.state.nominal.velocity_ecef_mps = [50.0, 0.0, 0.0];
2105 filter
2106 .stationarity_window
2107 .push_back(StationarityDetectorSnapshotSample {
2108 specific_force_norm_error_mps2: 0.0,
2109 body_rate_wrt_ecef_norm_rps: 0.0,
2110 });
2111 }
2112
2113 let plain_update = plain
2114 .update_stationary()
2115 .expect("plain stationary update")
2116 .expect("plain stationary update applied");
2117 let robust_update = robust
2118 .update_stationary()
2119 .expect("robust stationary update")
2120 .expect("robust stationary update applied");
2121
2122 assert_eq!(plain_update, robust_update);
2123 assert_eq!(plain.state(), robust.state());
2124 }
2125
2126 fn inverted_static_sample(
2127 state: &NavState,
2128 t_j2000_s: f64,
2129 dt_s: f64,
2130 accel_bias_mps2: [f64; 3],
2131 gyro_bias_rps: [f64; 3],
2132 ) -> (NavState, ImuSample, [f64; 3]) {
2133 let true_delta_theta_rad = [0.0, 0.0, OMEGA_E_DOT_RAD_S * dt_s];
2134 let true_delta_velocity_mps =
2135 inverse_delta_velocity(state, [0.0; 3], true_delta_theta_rad, dt_s);
2136 let increment = CorrectedImuIncrement {
2137 t_j2000_s,
2138 delta_velocity_mps: true_delta_velocity_mps,
2139 delta_theta_rad: true_delta_theta_rad,
2140 dt_s,
2141 };
2142 let truth_next =
2143 mechanize_ecef(state, &increment, MechanizationConfig::default()).expect("truth step");
2144 let sample = ImuSample::increment(
2145 t_j2000_s,
2146 add3(true_delta_velocity_mps, scale3(accel_bias_mps2, dt_s)),
2147 add3(true_delta_theta_rad, scale3(gyro_bias_rps, dt_s)),
2148 dt_s,
2149 );
2150 let true_body_rate_wrt_ecef = body_rate_relative_to_ecef(
2151 &truth_next.attitude_body_to_ecef,
2152 scale3(true_delta_theta_rad, 1.0 / dt_s),
2153 );
2154 (truth_next, sample, true_body_rate_wrt_ecef)
2155 }
2156
2157 fn inverse_delta_velocity(
2158 state: &NavState,
2159 target_velocity_ecef_mps: [f64; 3],
2160 delta_theta_rad: [f64; 3],
2161 dt_s: f64,
2162 ) -> [f64; 3] {
2163 let c_avg = mid_interval_dcm(&state.attitude_body_to_ecef, delta_theta_rad, dt_s);
2164 let c_avg_t = inline_tr(&c_avg);
2165 let gravity = gravity_ecef_mps2(state.position_ecef_m).expect("gravity");
2166 let required_ecef = sub3(
2167 sub3(target_velocity_ecef_mps, state.velocity_ecef_mps),
2168 scale3(gravity, dt_s),
2169 );
2170 mat3_mul_vec(&c_avg_t, required_ecef)
2171 }
2172
2173 fn mid_interval_dcm(
2174 attitude_body_to_ecef: &Mat3,
2175 delta_theta_rad: [f64; 3],
2176 dt_s: f64,
2177 ) -> Mat3 {
2178 let earth_half = earth_rotation_first_order(0.5 * dt_s);
2179 let body_half =
2180 crate::inertial::rodrigues_delta_dcm(scale3(delta_theta_rad, 0.5)).expect("body half");
2181 reorthonormalize_dcm(&mat3_mul(
2182 &mat3_mul(&earth_half, attitude_body_to_ecef),
2183 &body_half,
2184 ))
2185 .expect("mid dcm")
2186 }
2187
2188 fn earth_rotation_first_order(dt_s: f64) -> Mat3 {
2189 [
2190 [1.0, OMEGA_E_DOT_RAD_S * dt_s, 0.0],
2191 [-OMEGA_E_DOT_RAD_S * dt_s, 1.0, 0.0],
2192 [0.0, 0.0, 1.0],
2193 ]
2194 }
2195
2196 fn add_noise3(value: [f64; 3], sigma: f64, rng: &mut SplitMix64) -> [f64; 3] {
2197 [
2198 value[0] + sigma * rng.symmetric_unit(),
2199 value[1] + sigma * rng.symmetric_unit(),
2200 value[2] + sigma * rng.symmetric_unit(),
2201 ]
2202 }
2203
2204 fn quadratic_form(covariance: &[Vec<f64>], dx: &[f64], dimension: usize) -> f64 {
2205 let mut data = Vec::with_capacity(dimension * dimension);
2206 for row in covariance.iter().take(dimension) {
2207 data.extend(row.iter().take(dimension));
2208 }
2209 let matrix = DMatrix::from_row_slice(dimension, dimension, &data);
2210 let vector = DVector::from_column_slice(dx);
2211 let solved = matrix.cholesky().expect("covariance SPD").solve(&vector);
2212 dot_slice(dx, solved.as_slice())
2213 }
2214
2215 fn dot_slice(a: &[f64], b: &[f64]) -> f64 {
2216 a.iter().zip(b).map(|(x, y)| x * y).sum()
2217 }
2218
2219 fn direct_update_filter(t_j2000_s: f64, loose: LooseCouplingConfig) -> InertialFilter {
2220 let nominal = NavState::new(t_j2000_s, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity())
2221 .expect("nominal");
2222 let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
2223 for value in diagonal.iter_mut().take(6) {
2224 *value = 1.0;
2225 }
2226 let state = reference_filter_state(nominal, &diagonal).expect("filter state");
2227 let spec = ImuSpec::datasheet(
2228 0.0,
2229 0.0,
2230 0.0,
2231 0.0,
2232 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
2233 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
2234 None,
2235 None,
2236 );
2237 let mut config = InertialFilterConfig::new(spec).expect("config");
2238 config.loose = loose;
2239 InertialFilter::with_config(state, config).expect("filter")
2240 }
2241
2242 fn direct_position_velocity_measurement(
2243 t_j2000_s: f64,
2244 position_ecef_m: [f64; 3],
2245 velocity_ecef_mps: [f64; 3],
2246 sigma: f64,
2247 ) -> GnssFixMeasurement {
2248 GnssFixMeasurement::position_velocity(
2249 t_j2000_s,
2250 position_ecef_m,
2251 velocity_ecef_mps,
2252 covariance_from_diag(&[sigma * sigma; 6]),
2253 8,
2254 )
2255 .expect("measurement")
2256 }
2257
2258 struct SplitMix64 {
2259 state: u64,
2260 }
2261
2262 impl SplitMix64 {
2263 fn new(seed: u64) -> Self {
2264 Self { state: seed }
2265 }
2266
2267 fn next_u64(&mut self) -> u64 {
2268 self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
2269 let mut z = self.state;
2270 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
2271 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
2272 z ^ (z >> 31)
2273 }
2274
2275 fn unit_f64(&mut self) -> f64 {
2276 let bits = 0x3ff0_0000_0000_0000 | (self.next_u64() >> 12);
2277 f64::from_bits(bits) - 1.0
2278 }
2279
2280 fn symmetric_unit(&mut self) -> f64 {
2281 2.0 * self.unit_f64() - 1.0
2282 }
2283
2284 fn standard_normal(&mut self) -> f64 {
2285 let u1 = self.unit_f64().max(f64::MIN_POSITIVE);
2286 let u2 = self.unit_f64();
2287 (-2.0 * u1.ln()).sqrt() * (2.0 * core::f64::consts::PI * u2).cos()
2288 }
2289 }
2290
2291 #[test]
2292 fn splitmix_sequence_matches_covariance_fixture_pattern_bits() {
2293 let mut rng = SplitMix64::new(0x9876_5432_10fe_dcba);
2294 assert_eq!(rng.next_u64(), 0xaf45_24ce_f491_bb91);
2295 assert_eq!(rng.next_u64(), 0x25fc_5376_94a6_001c);
2296 let mut rng = SplitMix64::new(0x9876_5432_10fe_dcba);
2297 assert_eq!(rng.unit_f64().to_bits(), 0x3fe5_e8a4_99de_9236);
2298 }
2299
2300 #[test]
2301 fn gyro_bias_test_vector_is_observable_for_non_axis_lever() {
2302 let lever = [1.0, 0.5, -0.25];
2303 let gyro_bias = [0.000009765625, -0.000009765625, 0.00001953125];
2304 assert_eq!(dot3(lever, gyro_bias).to_bits(), 0.0_f64.to_bits());
2305 assert!(norm3(cross3(gyro_bias, lever)) > 0.0);
2306 }
2307}