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, scale3, sub3};
10use crate::inertial::state::skew;
11use crate::inertial::{
12 mechanize_ecef, validate_finite, validate_vec3, ImuCalibration, ImuErrorModel, ImuSample,
13 ImuSpec, MechanizationConfig,
14};
15
16use super::ekf::{
17 ekf_correct_closed_loop, ekf_correct_closed_loop_with_predicted_covariance_scale,
18 innovation_covariance, normalized_innovation_squared, EkfCorrection, EkfCorrectionReport,
19 EkfUpdateOptions,
20};
21use super::error_state::{
22 linearize_error_state_ecef, predict_error_state_covariance, ErrorStateImuKinematics,
23};
24use super::smoother::FusionPredictionStep;
25use super::state::FusionFilterKind;
26use super::state::{
27 invalid_input, validate_covariance_matrix, validate_positive, FusionError, InsFilterState,
28 ERROR_ATTITUDE_INDEX, ERROR_GYRO_BIAS_INDEX, ERROR_POSITION_INDEX, ERROR_STATE_DIMENSION_15,
29 ERROR_STATE_DIMENSION_21, ERROR_VELOCITY_INDEX,
30};
31use super::tight::{TightCouplingConfig, TightFusionState};
32use super::timesync::TimeSyncHistory;
33use super::ukf::{ukf_correct_closed_loop, UkfUpdateOptions};
34
35const LOOSE_MIN_SATELLITES: usize = 4;
36const POSITION_ROWS: usize = 3;
37const POSITION_VELOCITY_ROWS: usize = 6;
38const IGG_III_REJECTION_VARIANCE_SCALE: f64 = 1.0e4;
39const DEFAULT_PREDICTION_ADAPTATION_GATE_PROBABILITY: f64 = 0.99;
40
41#[derive(Debug, Clone, PartialEq)]
47pub struct GnssFixMeasurement {
48 pub t_j2000_s: f64,
50 pub position_ecef_m: [f64; 3],
52 pub velocity_ecef_mps: Option<[f64; 3]>,
54 pub covariance: Vec<Vec<f64>>,
56 pub satellites_used: usize,
58 pub solution_valid: bool,
60}
61
62impl GnssFixMeasurement {
63 pub fn position(
65 t_j2000_s: f64,
66 position_ecef_m: [f64; 3],
67 position_covariance_m2: [[f64; 3]; 3],
68 satellites_used: usize,
69 ) -> Result<Self, FusionError> {
70 let measurement = Self {
71 t_j2000_s,
72 position_ecef_m,
73 velocity_ecef_mps: None,
74 covariance: mat3_to_rows(position_covariance_m2),
75 satellites_used,
76 solution_valid: true,
77 };
78 measurement.validate()?;
79 Ok(measurement)
80 }
81
82 pub fn position_velocity(
84 t_j2000_s: f64,
85 position_ecef_m: [f64; 3],
86 velocity_ecef_mps: [f64; 3],
87 covariance: Vec<Vec<f64>>,
88 satellites_used: usize,
89 ) -> Result<Self, FusionError> {
90 let measurement = Self {
91 t_j2000_s,
92 position_ecef_m,
93 velocity_ecef_mps: Some(velocity_ecef_mps),
94 covariance,
95 satellites_used,
96 solution_valid: true,
97 };
98 measurement.validate()?;
99 Ok(measurement)
100 }
101
102 pub fn validate(&self) -> Result<(), FusionError> {
104 validate_finite(self.t_j2000_s, "t_j2000_s").map_err(FusionError::from)?;
105 validate_vec3(self.position_ecef_m, "position_ecef_m").map_err(FusionError::from)?;
106 if let Some(velocity) = self.velocity_ecef_mps {
107 validate_vec3(velocity, "velocity_ecef_mps").map_err(FusionError::from)?;
108 }
109 if !self.solution_valid {
110 return Err(invalid_input(
111 "solution_valid",
112 "GNSS fix must be successful",
113 ));
114 }
115 if self.satellites_used < LOOSE_MIN_SATELLITES {
116 return Err(invalid_input(
117 "satellites_used",
118 "at least 4 satellites required",
119 ));
120 }
121 validate_covariance_matrix(&self.covariance, self.row_count(), "gnss_covariance")
122 }
123
124 pub fn row_count(&self) -> usize {
126 if self.velocity_ecef_mps.is_some() {
127 POSITION_VELOCITY_ROWS
128 } else {
129 POSITION_ROWS
130 }
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq)]
136pub struct LooseCouplingConfig {
137 pub lever_arm_body_m: [f64; 3],
139 pub update_options: EkfUpdateOptions,
141 pub measurement_reweighting: Option<IggIiiMeasurementReweighting>,
143 pub prediction_adaptation: Option<YangPredictionAdaptiveFactor>,
145}
146
147impl Default for LooseCouplingConfig {
148 fn default() -> Self {
149 Self {
150 lever_arm_body_m: [0.0; 3],
151 update_options: EkfUpdateOptions::default(),
152 measurement_reweighting: None,
153 prediction_adaptation: None,
154 }
155 }
156}
157
158impl LooseCouplingConfig {
159 pub fn validate(&self) -> Result<(), FusionError> {
161 validate_vec3(self.lever_arm_body_m, "lever_arm_body_m").map_err(FusionError::from)?;
162 if let Some(gate) = self.update_options.innovation_gate {
163 gate.validate()?;
164 }
165 if let Some(reweighting) = self.measurement_reweighting {
166 reweighting.validate()?;
167 }
168 if let Some(adaptation) = self.prediction_adaptation {
169 adaptation.validate()?;
170 }
171 Ok(())
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq)]
182pub struct IggIiiMeasurementReweighting {
183 pub k0_sigma: f64,
185 pub k1_sigma: f64,
187}
188
189impl IggIiiMeasurementReweighting {
190 pub const fn standard() -> Self {
192 Self {
193 k0_sigma: 2.0,
194 k1_sigma: 5.0,
195 }
196 }
197
198 pub fn validate(&self) -> Result<(), FusionError> {
200 validate_finite(self.k0_sigma, "igg_iii_k0_sigma").map_err(FusionError::from)?;
201 validate_finite(self.k1_sigma, "igg_iii_k1_sigma").map_err(FusionError::from)?;
202 if !(1.5..=3.0).contains(&self.k0_sigma) {
203 return Err(invalid_input(
204 "igg_iii_k0_sigma",
205 "must be in the literature range [1.5, 3.0]",
206 ));
207 }
208 if !(3.0..=8.0).contains(&self.k1_sigma) {
209 return Err(invalid_input(
210 "igg_iii_k1_sigma",
211 "must be in the literature range [3.0, 8.0]",
212 ));
213 }
214 if self.k0_sigma < self.k1_sigma {
215 Ok(())
216 } else {
217 Err(invalid_input(
218 "igg_iii_thresholds",
219 "k0_sigma must be smaller than k1_sigma",
220 ))
221 }
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq)]
233pub struct YangPredictionAdaptiveFactor {
234 pub threshold: f64,
236 pub outlier_gate_probability: f64,
238}
239
240impl YangPredictionAdaptiveFactor {
241 pub const fn standard() -> Self {
243 Self {
244 threshold: 1.0,
245 outlier_gate_probability: DEFAULT_PREDICTION_ADAPTATION_GATE_PROBABILITY,
246 }
247 }
248
249 pub fn validate(&self) -> Result<(), FusionError> {
251 validate_finite(self.threshold, "yang_prediction_threshold").map_err(FusionError::from)?;
252 validate_finite(
253 self.outlier_gate_probability,
254 "yang_outlier_gate_probability",
255 )
256 .map_err(FusionError::from)?;
257 if self.threshold <= 0.0 {
258 return Err(invalid_input(
259 "yang_prediction_threshold",
260 "must be positive",
261 ));
262 }
263 if self.outlier_gate_probability > 0.0 && self.outlier_gate_probability < 1.0 {
264 Ok(())
265 } else {
266 Err(invalid_input(
267 "yang_outlier_gate_probability",
268 "must be in (0, 1)",
269 ))
270 }
271 }
272}
273
274#[derive(Debug, Clone, Copy, PartialEq)]
276pub struct InertialFilterConfig {
277 pub imu_spec: ImuSpec,
279 pub filter_kind: FusionFilterKind,
281 pub imu_model: ImuErrorModel,
283 pub mechanization: MechanizationConfig,
285 pub loose: LooseCouplingConfig,
287 pub tight: TightCouplingConfig,
289 pub ukf_update_options: UkfUpdateOptions,
291}
292
293impl InertialFilterConfig {
294 pub fn new(imu_spec: ImuSpec) -> Result<Self, FusionError> {
296 let config = Self {
297 imu_spec,
298 filter_kind: FusionFilterKind::Ekf,
299 imu_model: ImuErrorModel::default(),
300 mechanization: MechanizationConfig::default(),
301 loose: LooseCouplingConfig::default(),
302 tight: TightCouplingConfig::default(),
303 ukf_update_options: UkfUpdateOptions::default(),
304 };
305 config.validate()?;
306 Ok(config)
307 }
308
309 pub fn validate(&self) -> Result<(), FusionError> {
311 self.imu_spec.validate().map_err(FusionError::from)?;
312 self.imu_model.bias.validate().map_err(FusionError::from)?;
313 self.imu_model
314 .calibration
315 .validate()
316 .map_err(FusionError::from)?;
317 self.loose.validate()?;
318 if self.configures_ukf_prediction_adaptation() {
319 return Err(invalid_input(
320 "loose_prediction_adaptation",
321 "prediction adaptation is defined for EKF loose updates",
322 ));
323 }
324 self.tight.validate()?;
325 self.ukf_update_options
326 .validate_for_dimension(ERROR_STATE_DIMENSION_15)?;
327 self.ukf_update_options
328 .validate_for_dimension(ERROR_STATE_DIMENSION_21)
329 }
330
331 fn configures_ukf_prediction_adaptation(&self) -> bool {
332 self.filter_kind == FusionFilterKind::Ukf && self.loose.prediction_adaptation.is_some()
333 }
334}
335
336#[derive(Debug, Clone, PartialEq)]
338pub struct FusionUpdate {
339 pub applied: bool,
341 pub nis: f64,
343 pub rows: usize,
345 pub accepted_rows: usize,
347 pub rejected_rows: usize,
349 pub ekf: EkfCorrectionReport,
351}
352
353impl FusionUpdate {
354 fn from_report(rows: usize, report: EkfCorrectionReport) -> Self {
355 Self {
356 applied: report.applied,
357 nis: report.normalized_innovation_squared,
358 rows,
359 accepted_rows: report.accepted_rows,
360 rejected_rows: report.rejected_rows,
361 ekf: report,
362 }
363 }
364}
365
366#[derive(Debug, Clone, PartialEq)]
368pub struct InertialFilter {
369 pub(super) state: InsFilterState,
370 pub(super) config: InertialFilterConfig,
371 pub(super) last_body_rate_wrt_ecef_rps: [f64; 3],
372 pub(super) time_sync: TimeSyncHistory,
373 pub(super) tight: TightFusionState,
374}
375
376impl InertialFilter {
377 pub fn new(state: InsFilterState, imu_spec: ImuSpec) -> Result<Self, FusionError> {
379 let config = InertialFilterConfig::new(imu_spec)?;
380 Self::with_config(state, config)
381 }
382
383 pub fn with_config(
385 state: InsFilterState,
386 config: InertialFilterConfig,
387 ) -> Result<Self, FusionError> {
388 state.validate()?;
389 config.validate()?;
390 let tight = TightFusionState::from_filter_state(&state, config.tight)?;
391 let time_sync = TimeSyncHistory::from_initial(&state, &tight);
392 Ok(Self {
393 state,
394 config,
395 last_body_rate_wrt_ecef_rps: [0.0; 3],
396 time_sync,
397 tight,
398 })
399 }
400
401 pub const fn state(&self) -> &InsFilterState {
403 &self.state
404 }
405
406 pub fn state_mut(&mut self) -> &mut InsFilterState {
408 &mut self.state
409 }
410
411 pub const fn config(&self) -> &InertialFilterConfig {
413 &self.config
414 }
415
416 pub const fn last_body_rate_wrt_ecef_rps(&self) -> [f64; 3] {
418 self.last_body_rate_wrt_ecef_rps
419 }
420
421 pub fn propagate(&mut self, sample: ImuSample) -> Result<&InsFilterState, FusionError> {
423 let previous_t_j2000_s = self.state.nominal.t_j2000_s;
424 self.time_sync
425 .validate_next_imu(previous_t_j2000_s, sample)?;
426 self.propagate_core(sample)?;
427 self.time_sync.push_imu(previous_t_j2000_s, sample);
428 Ok(&self.state)
429 }
430
431 pub(super) fn propagate_core(
432 &mut self,
433 sample: ImuSample,
434 ) -> Result<FusionPredictionStep, FusionError> {
435 self.state.validate()?;
436 self.config.validate()?;
437 self.tight.align_with_filter_state(&self.state)?;
438
439 let previous = self.state.nominal;
440 let imu_model = self.effective_imu_model()?;
441 let increment = imu_model
442 .correct_sample(&sample, previous.t_j2000_s)
443 .map_err(FusionError::from)?;
444 let kinematics = ErrorStateImuKinematics::new(
445 scale3(increment.delta_velocity_mps, 1.0 / increment.dt_s),
446 scale3(increment.delta_theta_rad, 1.0 / increment.dt_s),
447 )?;
448 let linearization = linearize_error_state_ecef(
449 &previous,
450 kinematics,
451 &self.config.imu_spec,
452 increment.dt_s,
453 self.state.layout(),
454 )?;
455 let next_nominal = mechanize_ecef(&previous, &increment, self.config.mechanization)
456 .map_err(FusionError::from)?;
457 let body_rate_wrt_ecef_rps = body_rate_relative_to_ecef(
458 &next_nominal.attitude_body_to_ecef,
459 kinematics.angular_rate_body_rps,
460 );
461
462 predict_error_state_covariance(
463 &mut self.state.covariance,
464 &linearization.phi,
465 &linearization.q_d,
466 )?;
467 self.tight.predict_covariance(
468 &linearization.phi,
469 &linearization.q_d,
470 increment.dt_s,
471 self.config.tight,
472 )?;
473 self.tight.copy_base_covariance_to_state(&mut self.state)?;
474 self.state.nominal = next_nominal;
475 self.state.reset_error_state();
476 self.last_body_rate_wrt_ecef_rps = body_rate_wrt_ecef_rps;
477 self.state.validate()?;
478 Ok(FusionPredictionStep {
479 transition: linearization.phi,
480 })
481 }
482
483 pub fn update_loose(
489 &mut self,
490 measurement: &GnssFixMeasurement,
491 ) -> Result<FusionUpdate, FusionError> {
492 if let Some(last) = self.time_sync.last_measurement_t_j2000_s() {
493 if measurement.t_j2000_s <= last {
494 return Err(invalid_input(
495 "t_j2000_s",
496 "GNSS measurement epochs must be strictly increasing",
497 ));
498 }
499 }
500 let update = self.update_loose_core(measurement)?;
501 let snapshot = self.snapshot();
502 self.time_sync
503 .push_loose_measurement_and_checkpoint(measurement.clone(), snapshot);
504 Ok(update)
505 }
506
507 pub(super) fn update_loose_core(
508 &mut self,
509 measurement: &GnssFixMeasurement,
510 ) -> Result<FusionUpdate, FusionError> {
511 let correction = loose_coupling_correction(
512 &self.state,
513 measurement,
514 self.config.loose.lever_arm_body_m,
515 self.last_body_rate_wrt_ecef_rps,
516 )?;
517 let prepared = prepare_loose_correction(&self.state, correction, self.config.loose)?;
518 let rows = prepared.correction.row_count();
519 let filter_kind = self.config.filter_kind;
520 let ekf_options = self.config.loose.update_options;
521 let ukf_options = self.config.ukf_update_options;
522 let report = match filter_kind {
523 FusionFilterKind::Ekf => {
524 if prepared.predicted_covariance_scale == 1.0 {
525 ekf_correct_closed_loop(&mut self.state, &prepared.correction, ekf_options)?
526 } else {
527 ekf_correct_closed_loop_with_predicted_covariance_scale(
528 &mut self.state,
529 &prepared.correction,
530 ekf_options,
531 prepared.predicted_covariance_scale,
532 )?
533 }
534 }
535 FusionFilterKind::Ukf => {
536 ukf_correct_closed_loop(&mut self.state, &prepared.correction, ukf_options)?
537 }
538 };
539 self.tight
540 .replace_base_covariance_and_clear_cross(&self.state.covariance)?;
541 Ok(FusionUpdate::from_report(rows, report))
542 }
543
544 fn effective_imu_model(&self) -> Result<ImuErrorModel, FusionError> {
545 let mut bias = self.config.imu_model.bias;
546 for axis in 0..3 {
547 bias.accel_mps2[axis] += self.state.nominal.accel_bias_mps2[axis];
548 bias.gyro_rps[axis] += self.state.nominal.gyro_bias_rps[axis];
549 }
550 let calibration = effective_calibration(
551 self.config.imu_model.calibration,
552 self.state.accel_scale_factor,
553 self.state.gyro_scale_factor,
554 )?;
555 let model = ImuErrorModel { bias, calibration };
556 model.bias.validate().map_err(FusionError::from)?;
557 model.calibration.validate().map_err(FusionError::from)?;
558 Ok(model)
559 }
560}
561
562pub fn loose_coupling_correction(
572 state: &InsFilterState,
573 measurement: &GnssFixMeasurement,
574 lever_arm_body_m: [f64; 3],
575 body_rate_wrt_ecef_rps: [f64; 3],
576) -> Result<EkfCorrection, FusionError> {
577 state.validate()?;
578 measurement.validate()?;
579 validate_vec3(lever_arm_body_m, "lever_arm_body_m").map_err(FusionError::from)?;
580 validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
581 if measurement.t_j2000_s != state.nominal.t_j2000_s {
582 return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
583 }
584
585 let dimension = state.dimension();
586 let c_b_e = state.nominal.attitude_body_to_ecef;
587 let lever_ecef_m = mul_vec3(&c_b_e, lever_arm_body_m);
588 let antenna_position_ecef_m = add3(state.nominal.position_ecef_m, lever_ecef_m);
589 let lever_velocity_body_mps = cross3(body_rate_wrt_ecef_rps, lever_arm_body_m);
590 let lever_velocity_ecef_mps = mul_vec3(&c_b_e, lever_velocity_body_mps);
591 let antenna_velocity_ecef_mps = add3(state.nominal.velocity_ecef_mps, lever_velocity_ecef_mps);
592
593 let mut innovation = Vec::with_capacity(measurement.row_count());
594 let mut design = Vec::with_capacity(measurement.row_count());
595 let position_residual = sub3(measurement.position_ecef_m, antenna_position_ecef_m);
596 let lever_position_skew = skew(lever_ecef_m);
597 for axis in 0..3 {
598 let mut row = vec![0.0; dimension];
599 row[ERROR_POSITION_INDEX + axis] = -1.0;
600 row[ERROR_ATTITUDE_INDEX..ERROR_ATTITUDE_INDEX + 3]
601 .copy_from_slice(&lever_position_skew[axis]);
602 innovation.push(position_residual[axis]);
603 design.push(row);
604 }
605
606 if let Some(velocity_ecef_mps) = measurement.velocity_ecef_mps {
607 let velocity_residual = sub3(velocity_ecef_mps, antenna_velocity_ecef_mps);
608 let lever_velocity_skew = skew(lever_velocity_ecef_mps);
609 let gyro_bias_block = inline_rxr(&c_b_e, &skew(lever_arm_body_m));
610 for axis in 0..3 {
611 let mut row = vec![0.0; dimension];
612 row[ERROR_VELOCITY_INDEX + axis] = -1.0;
613 row[ERROR_ATTITUDE_INDEX..ERROR_ATTITUDE_INDEX + 3]
614 .copy_from_slice(&lever_velocity_skew[axis]);
615 row[ERROR_GYRO_BIAS_INDEX..ERROR_GYRO_BIAS_INDEX + 3]
616 .copy_from_slice(&gyro_bias_block[axis]);
617 innovation.push(velocity_residual[axis]);
618 design.push(row);
619 }
620 }
621
622 EkfCorrection::new(innovation, design, measurement.covariance.clone())
623}
624
625#[derive(Debug, Clone, PartialEq)]
626struct PreparedLooseCorrection {
627 correction: EkfCorrection,
628 predicted_covariance_scale: f64,
629}
630
631fn prepare_loose_correction(
632 state: &InsFilterState,
633 correction: EkfCorrection,
634 config: LooseCouplingConfig,
635) -> Result<PreparedLooseCorrection, FusionError> {
636 if config.measurement_reweighting.is_none() && config.prediction_adaptation.is_none() {
637 return Ok(PreparedLooseCorrection {
638 correction,
639 predicted_covariance_scale: 1.0,
640 });
641 }
642
643 let raw_innovation_covariance = innovation_covariance(&state.covariance, &correction)?;
644 let correction = if let Some(reweighting) = config.measurement_reweighting {
645 apply_igg_iii_reweighting(&correction, &raw_innovation_covariance, reweighting)?
646 } else {
647 correction
648 };
649 let predicted_covariance_scale = if let Some(adaptation) = config.prediction_adaptation {
650 yang_predicted_covariance_scale(state, &correction, &raw_innovation_covariance, adaptation)?
651 } else {
652 1.0
653 };
654
655 Ok(PreparedLooseCorrection {
656 correction,
657 predicted_covariance_scale,
658 })
659}
660
661fn apply_igg_iii_reweighting(
662 correction: &EkfCorrection,
663 innovation_covariance: &[Vec<f64>],
664 reweighting: IggIiiMeasurementReweighting,
665) -> Result<EkfCorrection, FusionError> {
666 reweighting.validate()?;
667 let mut gammas = Vec::with_capacity(correction.row_count());
668 let mut all_one = true;
669 for (row, s_row) in innovation_covariance
670 .iter()
671 .enumerate()
672 .take(correction.row_count())
673 {
674 let variance = s_row[row];
675 validate_positive(variance, "innovation_covariance_diagonal")?;
676 let standardized = (correction.innovation[row] / variance.sqrt()).abs();
677 let gamma =
678 igg_iii_variance_scale(standardized, reweighting.k0_sigma, reweighting.k1_sigma);
679 all_one &= gamma.to_bits() == 1.0_f64.to_bits();
680 gammas.push(gamma);
681 }
682
683 if all_one {
684 return Ok(correction.clone());
685 }
686
687 let covariance = inflate_measurement_covariance(&correction.measurement_covariance, &gammas);
688 EkfCorrection::new(
689 correction.innovation.clone(),
690 correction.design.clone(),
691 covariance,
692 )
693}
694
695fn igg_iii_variance_scale(abs_standardized: f64, k0_sigma: f64, k1_sigma: f64) -> f64 {
696 if abs_standardized <= k0_sigma {
697 1.0
698 } else if abs_standardized < k1_sigma {
699 let ratio = abs_standardized / k0_sigma;
700 let transition = (k1_sigma - k0_sigma) / (k1_sigma - abs_standardized);
701 ratio * transition * transition
702 } else {
703 IGG_III_REJECTION_VARIANCE_SCALE
704 }
705}
706
707fn inflate_measurement_covariance(covariance: &[Vec<f64>], gammas: &[f64]) -> Vec<Vec<f64>> {
708 let sqrt_gammas = gammas.iter().map(|gamma| gamma.sqrt()).collect::<Vec<_>>();
709 let mut inflated = covariance.to_vec();
710 for row in 0..inflated.len() {
711 for col in 0..inflated[row].len() {
712 inflated[row][col] *= sqrt_gammas[row] * sqrt_gammas[col];
713 }
714 }
715 inflated
716}
717
718fn yang_predicted_covariance_scale(
719 state: &InsFilterState,
720 correction: &EkfCorrection,
721 raw_innovation_covariance: &[Vec<f64>],
722 adaptation: YangPredictionAdaptiveFactor,
723) -> Result<f64, FusionError> {
724 adaptation.validate()?;
725 let raw_mahalanobis =
726 normalized_innovation_squared(raw_innovation_covariance, &correction.innovation)?;
727 let outlier_threshold =
728 crate::quality::chi2_inv(adaptation.outlier_gate_probability, correction.row_count())
729 .map_err(|_| {
730 invalid_input(
731 "yang_outlier_gate_probability",
732 "must produce a chi-square threshold",
733 )
734 })?;
735 if raw_mahalanobis > outlier_threshold {
738 return Ok(1.0);
739 }
740
741 let innovation_covariance = innovation_covariance(&state.covariance, correction)?;
742 let trace = innovation_covariance
743 .iter()
744 .enumerate()
745 .map(|(idx, row)| row[idx])
746 .sum::<f64>();
747 validate_positive(trace, "innovation_covariance_trace")?;
748 let squared_norm = correction
749 .innovation
750 .iter()
751 .map(|value| value * value)
752 .sum::<f64>();
753 let statistic = squared_norm / trace;
754 if statistic <= adaptation.threshold {
755 Ok(1.0)
756 } else {
757 Ok(statistic / adaptation.threshold)
758 }
759}
760
761fn body_rate_relative_to_ecef(
762 attitude_body_to_ecef: &Mat3,
763 inertial_body_rate_rps: [f64; 3],
764) -> [f64; 3] {
765 let attitude_ecef_to_body = inline_tr(attitude_body_to_ecef);
766 let earth_rate_body_rps = mul_vec3(&attitude_ecef_to_body, [0.0, 0.0, OMEGA_E_DOT_RAD_S]);
767 sub3(inertial_body_rate_rps, earth_rate_body_rps)
768}
769
770fn effective_calibration(
771 base: ImuCalibration,
772 accel_scale_factor: [f64; 3],
773 gyro_scale_factor: [f64; 3],
774) -> Result<ImuCalibration, FusionError> {
775 let mut calibration = base;
776 for axis in 0..3 {
777 calibration.accel_scale_misalignment[axis][axis] += accel_scale_factor[axis];
778 calibration.gyro_scale_misalignment[axis][axis] += gyro_scale_factor[axis];
779 }
780 calibration.validate().map_err(FusionError::from)?;
781 Ok(calibration)
782}
783
784fn mat3_to_rows(matrix: [[f64; 3]; 3]) -> Vec<Vec<f64>> {
785 matrix.into_iter().map(Vec::from).collect()
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
798 use crate::astro::constants::earth::{OMEGA_E_DOT_RAD_S, WGS84_A_M};
799 use crate::astro::math::mat3::{inline_tr, Mat3};
800 use crate::astro::math::vec3::{dot3, norm3};
801 use crate::fusion::state::{
802 ERROR_ACCEL_BIAS_INDEX, ERROR_GYRO_BIAS_INDEX, ERROR_STATE_DIMENSION_15,
803 };
804 use crate::inertial::frames::gravity_ecef_mps2;
805 use crate::inertial::state::{mat3_identity, mat3_mul, mat3_mul_vec, reorthonormalize_dcm};
806 use crate::inertial::{CorrectedImuIncrement, NavState};
807 use nalgebra::{DMatrix, DVector};
808
809 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
810 assert!(
811 (actual - expected).abs() <= tolerance,
812 "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
813 );
814 }
815
816 fn covariance_from_diag(diagonal: &[f64]) -> Vec<Vec<f64>> {
817 let mut covariance = vec![vec![0.0; diagonal.len()]; diagonal.len()];
818 for (idx, value) in diagonal.iter().enumerate() {
819 covariance[idx][idx] = *value;
820 }
821 covariance
822 }
823
824 fn reference_filter_state(
825 nominal: NavState,
826 diagonal: &[f64],
827 ) -> Result<InsFilterState, FusionError> {
828 InsFilterState::from_diagonal(
829 nominal,
830 super::super::state::ErrorStateLayout::Fifteen,
831 diagonal,
832 )
833 }
834
835 #[test]
836 fn loose_correction_builds_lever_arm_rows_and_keeps_input_covariance() {
837 let state = reference_filter_state(
838 NavState::new(10.0, [10.0, 20.0, 30.0], [1.0, 2.0, 3.0], mat3_identity())
839 .expect("state"),
840 &[1.0; ERROR_STATE_DIMENSION_15],
841 )
842 .expect("filter state");
843 let lever = [0.5, -1.0, 2.0];
844 let omega = [0.1, 0.2, -0.3];
845 let lever_position = lever;
846 let lever_velocity = cross3(omega, lever);
847 let position_residual = [1.0, -2.0, 3.0];
848 let velocity_residual = [0.4, -0.5, 0.6];
849 let covariance = covariance_from_diag(&[4.0, 5.0, 6.0, 0.7, 0.8, 0.9]);
850 let measurement = GnssFixMeasurement::position_velocity(
851 10.0,
852 add3(
853 add3(state.nominal.position_ecef_m, lever_position),
854 position_residual,
855 ),
856 add3(
857 add3(state.nominal.velocity_ecef_mps, lever_velocity),
858 velocity_residual,
859 ),
860 covariance.clone(),
861 6,
862 )
863 .expect("measurement");
864
865 let correction =
866 loose_coupling_correction(&state, &measurement, lever, omega).expect("correction");
867
868 for axis in 0..3 {
869 assert_close(
870 correction.innovation[axis],
871 position_residual[axis],
872 2.0e-16,
873 );
874 assert_close(
875 correction.innovation[3 + axis],
876 velocity_residual[axis],
877 2.0e-16,
878 );
879 }
880 assert_eq!(correction.measurement_covariance, covariance);
881 assert_eq!(
882 correction.design[0][ERROR_POSITION_INDEX].to_bits(),
883 (-1.0_f64).to_bits()
884 );
885 assert_eq!(
886 correction.design[1][ERROR_POSITION_INDEX + 1].to_bits(),
887 (-1.0_f64).to_bits()
888 );
889 let lever_skew = skew(lever);
890 for (row, expected_row) in lever_skew.iter().enumerate() {
891 for (col, expected) in expected_row.iter().enumerate() {
892 assert_eq!(
893 correction.design[row][ERROR_ATTITUDE_INDEX + col].to_bits(),
894 expected.to_bits()
895 );
896 }
897 }
898 let gyro_block = skew(lever);
899 for (row, expected_row) in gyro_block.iter().enumerate() {
900 for (col, expected) in expected_row.iter().enumerate() {
901 assert_eq!(
902 correction.design[3 + row][ERROR_GYRO_BIAS_INDEX + col].to_bits(),
903 expected.to_bits()
904 );
905 }
906 }
907 }
908
909 #[test]
910 fn propagated_static_ecef_body_reports_zero_lever_velocity() {
911 let lever = [1.0, 0.5, -0.25];
912 let truth =
913 NavState::new(0.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("truth");
914 let state =
915 reference_filter_state(truth, &[1.0; ERROR_STATE_DIMENSION_15]).expect("filter state");
916 let spec = ImuSpec::datasheet(
917 0.0,
918 0.0,
919 0.0,
920 0.0,
921 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
922 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
923 None,
924 None,
925 );
926 let mut config = InertialFilterConfig::new(spec).expect("config");
927 config.loose.lever_arm_body_m = lever;
928 let mut filter = InertialFilter::with_config(state, config).expect("filter");
929 let (truth_next, sample, truth_body_rate_wrt_ecef) =
930 inverted_static_sample(&truth, 1.0, 1.0, [0.0; 3], [0.0; 3]);
931
932 for value in truth_body_rate_wrt_ecef {
933 assert_close(value, 0.0, 0.0);
934 }
935 filter.propagate(sample).expect("propagate");
936 for value in filter.last_body_rate_wrt_ecef_rps() {
937 assert_close(value, 0.0, 0.0);
938 }
939
940 let antenna_position = add3(
941 truth_next.position_ecef_m,
942 mul_vec3(&truth_next.attitude_body_to_ecef, lever),
943 );
944 let measurement = GnssFixMeasurement::position_velocity(
945 truth_next.t_j2000_s,
946 antenna_position,
947 truth_next.velocity_ecef_mps,
948 covariance_from_diag(&[1.0, 1.0, 1.0, 1.0e-6, 1.0e-6, 1.0e-6]),
949 8,
950 )
951 .expect("measurement");
952 let correction = loose_coupling_correction(
953 filter.state(),
954 &measurement,
955 lever,
956 filter.last_body_rate_wrt_ecef_rps(),
957 )
958 .expect("correction");
959 for axis in 0..3 {
960 assert_close(correction.innovation[3 + axis], 0.0, 0.0);
961 }
962 }
963
964 #[test]
965 fn loose_update_rejects_failed_or_short_gnss_fix() {
966 let measurement = GnssFixMeasurement {
967 t_j2000_s: 0.0,
968 position_ecef_m: [WGS84_A_M, 0.0, 0.0],
969 velocity_ecef_mps: None,
970 covariance: covariance_from_diag(&[1.0, 1.0, 1.0]),
971 satellites_used: 3,
972 solution_valid: true,
973 };
974 assert!(matches!(
975 measurement.validate(),
976 Err(FusionError::InvalidInput {
977 field: "satellites_used",
978 reason: "at least 4 satellites required"
979 })
980 ));
981
982 let failed = GnssFixMeasurement {
983 satellites_used: 6,
984 solution_valid: false,
985 ..measurement
986 };
987 assert!(matches!(
988 failed.validate(),
989 Err(FusionError::InvalidInput {
990 field: "solution_valid",
991 reason: "GNSS fix must be successful"
992 })
993 ));
994 }
995
996 #[test]
997 fn synthetic_static_truth_recovers_within_three_sigma_and_biases_converge() {
998 let dt_s = 1.0;
999 let steps = 20usize;
1000 let lever = [1.0, 0.5, -0.25];
1001 let accel_bias = [0.0015, -0.0010, 0.0020];
1002 let gyro_bias = [0.000009765625, -0.000009765625, 0.00001953125];
1003 let mut truth =
1004 NavState::new(0.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("truth");
1005 let nominal = NavState::new(
1006 0.0,
1007 [WGS84_A_M + 2.0, -1.0, 0.5],
1008 [0.3, -0.2, 0.1],
1009 mat3_identity(),
1010 )
1011 .expect("nominal");
1012 let mut diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1013 for axis in 0..3 {
1014 diagonal[ERROR_POSITION_INDEX + axis] = 25.0;
1015 diagonal[ERROR_VELOCITY_INDEX + axis] = 1.0;
1016 diagonal[ERROR_ATTITUDE_INDEX + axis] = 0.05 * 0.05;
1017 diagonal[ERROR_ACCEL_BIAS_INDEX + axis] = 0.05 * 0.05;
1018 diagonal[ERROR_GYRO_BIAS_INDEX + axis] = 0.003 * 0.003;
1019 }
1020 let state = reference_filter_state(nominal, &diagonal).expect("filter state");
1021 let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
1022 let mut config = InertialFilterConfig::new(spec).expect("config");
1023 config.loose.lever_arm_body_m = lever;
1024 let mut filter = InertialFilter::with_config(state, config).expect("filter");
1025 let mut rng = SplitMix64::new(0x4c4f_4f53_455f_0001);
1026 let position_sigma_m = 0.20;
1027 let velocity_sigma_mps = 0.030;
1028 let covariance = covariance_from_diag(&[
1029 position_sigma_m * position_sigma_m,
1030 position_sigma_m * position_sigma_m,
1031 position_sigma_m * position_sigma_m,
1032 velocity_sigma_mps * velocity_sigma_mps,
1033 velocity_sigma_mps * velocity_sigma_mps,
1034 velocity_sigma_mps * velocity_sigma_mps,
1035 ]);
1036
1037 for step in 1..=steps {
1038 let (truth_next, sample, true_body_rate_wrt_ecef) =
1039 inverted_static_sample(&truth, step as f64 * dt_s, dt_s, accel_bias, gyro_bias);
1040 truth = truth_next;
1041 filter.propagate(sample).expect("propagate");
1042 let antenna_position = add3(
1043 truth.position_ecef_m,
1044 mul_vec3(&truth.attitude_body_to_ecef, lever),
1045 );
1046 let antenna_velocity = add3(
1047 truth.velocity_ecef_mps,
1048 mul_vec3(
1049 &truth.attitude_body_to_ecef,
1050 cross3(true_body_rate_wrt_ecef, lever),
1051 ),
1052 );
1053 let measurement = GnssFixMeasurement::position_velocity(
1054 truth.t_j2000_s,
1055 add_noise3(antenna_position, position_sigma_m, &mut rng),
1056 add_noise3(antenna_velocity, velocity_sigma_mps, &mut rng),
1057 covariance.clone(),
1058 8,
1059 )
1060 .expect("measurement");
1061 let update = filter.update_loose(&measurement).expect("loose update");
1062 assert!(update.applied);
1063 assert_eq!(
1064 update.nis.to_bits(),
1065 update.ekf.normalized_innovation_squared.to_bits()
1066 );
1067 }
1068
1069 let state = filter.state();
1070 for (axis, expected_accel_bias) in accel_bias.iter().enumerate() {
1071 let position_error = state.nominal.position_ecef_m[axis] - truth.position_ecef_m[axis];
1072 let velocity_error =
1073 state.nominal.velocity_ecef_mps[axis] - truth.velocity_ecef_mps[axis];
1074 let position_bound = 3.0
1075 * state.covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis].sqrt();
1076 assert!(
1077 position_error.abs() <= position_bound,
1078 "position axis {axis} error {position_error:.17e}, bound {position_bound:.17e}"
1079 );
1080 assert!(
1081 velocity_error.abs()
1082 <= 3.0
1083 * state.covariance[ERROR_VELOCITY_INDEX + axis]
1084 [ERROR_VELOCITY_INDEX + axis]
1085 .sqrt(),
1086 "velocity axis {axis} error {velocity_error:.17e}"
1087 );
1088 let accel_bias_error = state.nominal.accel_bias_mps2[axis] - *expected_accel_bias;
1089 let accel_bias_bound = 3.0
1090 * state.covariance[ERROR_ACCEL_BIAS_INDEX + axis][ERROR_ACCEL_BIAS_INDEX + axis]
1091 .sqrt();
1092 assert!(
1093 accel_bias_error.abs() <= accel_bias_bound,
1094 "accelerometer bias axis {axis} error {accel_bias_error:.17e}, bound {accel_bias_bound:.17e}"
1095 );
1096 }
1097 }
1098
1099 #[test]
1100 fn lever_velocity_update_converges_observable_gyro_bias_components() {
1101 let dt_s = 0.1;
1102 let lever = [1.0, 0.5, -0.25];
1103 let gyro_bias = [0.0009765625, -0.0009765625, 0.001953125];
1104 let truth =
1105 NavState::new(0.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("truth");
1106 let mut diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1107 for axis in 0..3 {
1108 diagonal[ERROR_POSITION_INDEX + axis] = 1.0;
1109 diagonal[ERROR_VELOCITY_INDEX + axis] = 1.0e-10;
1110 diagonal[ERROR_ATTITUDE_INDEX + axis] = 1.0e-10;
1111 diagonal[ERROR_ACCEL_BIAS_INDEX + axis] = 1.0e-10;
1112 diagonal[ERROR_GYRO_BIAS_INDEX + axis] = 1.0e-4;
1113 }
1114 let state = reference_filter_state(truth, &diagonal).expect("filter state");
1115 let spec = ImuSpec::datasheet(
1116 0.0,
1117 0.0,
1118 0.0,
1119 0.0,
1120 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1121 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1122 None,
1123 None,
1124 );
1125 let mut config = InertialFilterConfig::new(spec).expect("config");
1126 config.loose.lever_arm_body_m = lever;
1127 let mut filter = InertialFilter::with_config(state, config).expect("filter");
1128 let (truth_next, sample, true_body_rate_wrt_ecef) =
1129 inverted_static_sample(&truth, dt_s, dt_s, [0.0; 3], gyro_bias);
1130 filter.propagate(sample).expect("propagate");
1131
1132 let antenna_position = add3(
1133 truth_next.position_ecef_m,
1134 mul_vec3(&truth_next.attitude_body_to_ecef, lever),
1135 );
1136 let antenna_velocity = add3(
1137 truth_next.velocity_ecef_mps,
1138 mul_vec3(
1139 &truth_next.attitude_body_to_ecef,
1140 cross3(true_body_rate_wrt_ecef, lever),
1141 ),
1142 );
1143 let measurement = GnssFixMeasurement::position_velocity(
1144 truth_next.t_j2000_s,
1145 antenna_position,
1146 antenna_velocity,
1147 covariance_from_diag(&[1.0e6, 1.0e6, 1.0e6, 1.0e-8, 1.0e-8, 1.0e-8]),
1148 8,
1149 )
1150 .expect("measurement");
1151 let update = filter.update_loose(&measurement).expect("loose update");
1152 assert!(update.applied);
1153
1154 let state = filter.state();
1155 for (axis, expected_gyro_bias) in gyro_bias.iter().enumerate() {
1156 let error = state.nominal.gyro_bias_rps[axis] - *expected_gyro_bias;
1157 let bound = 3.0
1158 * state.covariance[ERROR_GYRO_BIAS_INDEX + axis][ERROR_GYRO_BIAS_INDEX + axis]
1159 .sqrt();
1160 assert!(
1161 error.abs() <= bound,
1162 "gyroscope bias axis {axis} error {error:.17e}, bound {bound:.17e}"
1163 );
1164 }
1165 }
1166
1167 #[test]
1168 fn loose_nees_and_nis_land_inside_bar_shalom_chi_square_bands() {
1169 let trials = 40usize;
1170 let alpha = 0.05;
1171 let p_diag: [f64; 6] = [9.0, 4.0, 16.0, 0.25, 0.36, 0.49];
1172 let r_diag: [f64; 6] = [1.0, 1.44, 0.64, 0.04, 0.09, 0.16];
1173 let truth =
1174 NavState::new(20.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity()).expect("truth");
1175 let mut rng = SplitMix64::new(0x4241_5253_4841_4c4f);
1176 let mut nees_sum = 0.0;
1177 let mut nis_sum = 0.0;
1178
1179 for _ in 0..trials {
1180 let mut initial_error = [0.0; 6];
1181 let mut measurement_noise = [0.0; 6];
1182 for idx in 0..6 {
1183 initial_error[idx] = p_diag[idx].sqrt() * rng.standard_normal();
1184 measurement_noise[idx] = r_diag[idx].sqrt() * rng.standard_normal();
1185 }
1186 let nominal = NavState::new(
1187 20.0,
1188 [
1189 truth.position_ecef_m[0] + initial_error[0],
1190 truth.position_ecef_m[1] + initial_error[1],
1191 truth.position_ecef_m[2] + initial_error[2],
1192 ],
1193 [
1194 truth.velocity_ecef_mps[0] + initial_error[3],
1195 truth.velocity_ecef_mps[1] + initial_error[4],
1196 truth.velocity_ecef_mps[2] + initial_error[5],
1197 ],
1198 mat3_identity(),
1199 )
1200 .expect("nominal");
1201 let mut diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1202 diagonal[..6].copy_from_slice(&p_diag);
1203 for value in diagonal.iter_mut().take(ERROR_STATE_DIMENSION_15).skip(6) {
1204 *value = 1.0;
1205 }
1206 let state = reference_filter_state(nominal, &diagonal).expect("filter state");
1207 let spec = ImuSpec::datasheet(
1208 0.0,
1209 0.0,
1210 0.0,
1211 0.0,
1212 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1213 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1214 None,
1215 None,
1216 );
1217 let mut filter = InertialFilter::new(state, spec).expect("filter");
1218 let measurement = GnssFixMeasurement::position_velocity(
1219 20.0,
1220 [
1221 truth.position_ecef_m[0] + measurement_noise[0],
1222 truth.position_ecef_m[1] + measurement_noise[1],
1223 truth.position_ecef_m[2] + measurement_noise[2],
1224 ],
1225 [
1226 truth.velocity_ecef_mps[0] + measurement_noise[3],
1227 truth.velocity_ecef_mps[1] + measurement_noise[4],
1228 truth.velocity_ecef_mps[2] + measurement_noise[5],
1229 ],
1230 covariance_from_diag(&r_diag),
1231 8,
1232 )
1233 .expect("measurement");
1234 let expected_nis = (0..6)
1235 .map(|idx| {
1236 let innovation = measurement_noise[idx] - initial_error[idx];
1237 innovation * innovation / (p_diag[idx] + r_diag[idx])
1238 })
1239 .sum::<f64>();
1240 let update = filter.update_loose(&measurement).expect("loose update");
1241 assert_close(update.nis, expected_nis, 1.0e-9);
1242 nis_sum += update.nis;
1243
1244 let updated = filter.state();
1245 for idx in 0..6 {
1246 let expected_variance = p_diag[idx] * r_diag[idx] / (p_diag[idx] + r_diag[idx]);
1247 assert_close(updated.covariance[idx][idx], expected_variance, 5.0e-15);
1248 }
1249 let dx = [
1250 updated.nominal.position_ecef_m[0] - truth.position_ecef_m[0],
1251 updated.nominal.position_ecef_m[1] - truth.position_ecef_m[1],
1252 updated.nominal.position_ecef_m[2] - truth.position_ecef_m[2],
1253 updated.nominal.velocity_ecef_mps[0] - truth.velocity_ecef_mps[0],
1254 updated.nominal.velocity_ecef_mps[1] - truth.velocity_ecef_mps[1],
1255 updated.nominal.velocity_ecef_mps[2] - truth.velocity_ecef_mps[2],
1256 ];
1257 nees_sum += quadratic_form(&updated.covariance, &dx, 6);
1258 }
1259
1260 let nis_average = nis_sum / trials as f64;
1261 let nees_average = nees_sum / trials as f64;
1262 let dof = trials * 6;
1263 let lower = crate::quality::chi2_inv(alpha * 0.5, dof).expect("lower") / trials as f64;
1264 let upper =
1265 crate::quality::chi2_inv(1.0 - alpha * 0.5, dof).expect("upper") / trials as f64;
1266 assert!(
1267 (lower..=upper).contains(&nis_average),
1268 "NIS average {nis_average:.17e}, band [{lower:.17e}, {upper:.17e}]"
1269 );
1270 assert!(
1271 (lower..=upper).contains(&nees_average),
1272 "NEES average {nees_average:.17e}, band [{lower:.17e}, {upper:.17e}]"
1273 );
1274 }
1275
1276 #[test]
1277 fn igg_iii_noop_region_matches_plain_l2_to_bits() {
1278 let measurement = direct_position_velocity_measurement(
1279 30.0,
1280 [WGS84_A_M + 0.25, -0.125, 0.0625],
1281 [0.03125, -0.015625, 0.0078125],
1282 1.0,
1283 );
1284 let mut plain = direct_update_filter(30.0, LooseCouplingConfig::default());
1285 let mut robust = direct_update_filter(
1286 30.0,
1287 LooseCouplingConfig {
1288 measurement_reweighting: Some(IggIiiMeasurementReweighting::standard()),
1289 ..LooseCouplingConfig::default()
1290 },
1291 );
1292
1293 let plain_update = plain.update_loose(&measurement).expect("plain update");
1294 let robust_update = robust.update_loose(&measurement).expect("robust update");
1295
1296 assert_eq!(plain_update, robust_update);
1297 assert_eq!(plain.state(), robust.state());
1298 }
1299
1300 #[test]
1301 fn igg_iii_single_outlier_stays_within_tenth_sigma_of_clean_run() {
1302 const X_SIGMA: f64 = 0.1;
1306 let clean_measurement =
1307 direct_position_velocity_measurement(40.0, [WGS84_A_M, 0.0, 0.0], [0.0; 3], 1.0);
1308 let outlier_measurement =
1309 direct_position_velocity_measurement(40.0, [WGS84_A_M + 50.0, 0.0, 0.0], [0.0; 3], 1.0);
1310 let robust_config = LooseCouplingConfig {
1311 measurement_reweighting: Some(IggIiiMeasurementReweighting::standard()),
1312 ..LooseCouplingConfig::default()
1313 };
1314 let mut clean = direct_update_filter(40.0, LooseCouplingConfig::default());
1315 let mut plain = direct_update_filter(40.0, LooseCouplingConfig::default());
1316 let mut robust = direct_update_filter(40.0, robust_config);
1317
1318 clean
1319 .update_loose(&clean_measurement)
1320 .expect("clean update");
1321 plain
1322 .update_loose(&outlier_measurement)
1323 .expect("plain update");
1324 robust
1325 .update_loose(&outlier_measurement)
1326 .expect("robust update");
1327
1328 let clean_x = clean.state().nominal.position_ecef_m[0];
1329 let clean_sigma =
1330 clean.state().covariance[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX].sqrt();
1331 let robust_error = (robust.state().nominal.position_ecef_m[0] - clean_x).abs();
1332 let plain_error = (plain.state().nominal.position_ecef_m[0] - clean_x).abs();
1333 assert!(
1334 robust_error <= X_SIGMA * clean_sigma,
1335 "robust error {robust_error:.17e}, bound {:.17e}",
1336 X_SIGMA * clean_sigma
1337 );
1338 assert!(
1339 plain_error > X_SIGMA * clean_sigma,
1340 "plain error {plain_error:.17e}, bound {:.17e}",
1341 X_SIGMA * clean_sigma
1342 );
1343 }
1344
1345 #[test]
1346 fn yang_prediction_adaptation_inflates_covariance_when_gate_passes() {
1347 let measurement =
1348 direct_position_velocity_measurement(50.0, [WGS84_A_M + 5.0, 0.0, 0.0], [0.0; 3], 1.0);
1349 let mut plain = direct_update_filter(50.0, LooseCouplingConfig::default());
1350 let mut adaptive = direct_update_filter(
1351 50.0,
1352 LooseCouplingConfig {
1353 prediction_adaptation: Some(YangPredictionAdaptiveFactor {
1354 threshold: 0.1,
1355 outlier_gate_probability: 0.99,
1356 }),
1357 ..LooseCouplingConfig::default()
1358 },
1359 );
1360
1361 plain.update_loose(&measurement).expect("plain update");
1362 adaptive
1363 .update_loose(&measurement)
1364 .expect("adaptive update");
1365
1366 let plain_error = (plain.state().nominal.position_ecef_m[0] - (WGS84_A_M + 5.0)).abs();
1367 let adaptive_error =
1368 (adaptive.state().nominal.position_ecef_m[0] - (WGS84_A_M + 5.0)).abs();
1369 assert!(
1370 adaptive_error < plain_error,
1371 "adaptive error {adaptive_error:.17e}, plain error {plain_error:.17e}"
1372 );
1373 }
1374
1375 #[test]
1376 fn yang_prediction_adaptation_is_disabled_by_mahalanobis_outlier_gate() {
1377 let measurement =
1378 direct_position_velocity_measurement(60.0, [WGS84_A_M + 50.0, 0.0, 0.0], [0.0; 3], 1.0);
1379 let robust_only = LooseCouplingConfig {
1380 measurement_reweighting: Some(IggIiiMeasurementReweighting::standard()),
1381 ..LooseCouplingConfig::default()
1382 };
1383 let robust_and_adaptive = LooseCouplingConfig {
1384 prediction_adaptation: Some(YangPredictionAdaptiveFactor {
1385 threshold: 0.1,
1386 outlier_gate_probability: 0.99,
1387 }),
1388 ..robust_only
1389 };
1390 let mut robust = direct_update_filter(60.0, robust_only);
1391 let mut guarded = direct_update_filter(60.0, robust_and_adaptive);
1392
1393 let robust_update = robust.update_loose(&measurement).expect("robust update");
1394 let guarded_update = guarded.update_loose(&measurement).expect("guarded update");
1395
1396 assert_eq!(robust_update, guarded_update);
1397 assert_eq!(robust.state(), guarded.state());
1398 }
1399
1400 fn inverted_static_sample(
1401 state: &NavState,
1402 t_j2000_s: f64,
1403 dt_s: f64,
1404 accel_bias_mps2: [f64; 3],
1405 gyro_bias_rps: [f64; 3],
1406 ) -> (NavState, ImuSample, [f64; 3]) {
1407 let true_delta_theta_rad = [0.0, 0.0, OMEGA_E_DOT_RAD_S * dt_s];
1408 let true_delta_velocity_mps =
1409 inverse_delta_velocity(state, [0.0; 3], true_delta_theta_rad, dt_s);
1410 let increment = CorrectedImuIncrement {
1411 t_j2000_s,
1412 delta_velocity_mps: true_delta_velocity_mps,
1413 delta_theta_rad: true_delta_theta_rad,
1414 dt_s,
1415 };
1416 let truth_next =
1417 mechanize_ecef(state, &increment, MechanizationConfig::default()).expect("truth step");
1418 let sample = ImuSample::increment(
1419 t_j2000_s,
1420 add3(true_delta_velocity_mps, scale3(accel_bias_mps2, dt_s)),
1421 add3(true_delta_theta_rad, scale3(gyro_bias_rps, dt_s)),
1422 dt_s,
1423 );
1424 let true_body_rate_wrt_ecef = body_rate_relative_to_ecef(
1425 &truth_next.attitude_body_to_ecef,
1426 scale3(true_delta_theta_rad, 1.0 / dt_s),
1427 );
1428 (truth_next, sample, true_body_rate_wrt_ecef)
1429 }
1430
1431 fn inverse_delta_velocity(
1432 state: &NavState,
1433 target_velocity_ecef_mps: [f64; 3],
1434 delta_theta_rad: [f64; 3],
1435 dt_s: f64,
1436 ) -> [f64; 3] {
1437 let c_avg = mid_interval_dcm(&state.attitude_body_to_ecef, delta_theta_rad, dt_s);
1438 let c_avg_t = inline_tr(&c_avg);
1439 let gravity = gravity_ecef_mps2(state.position_ecef_m).expect("gravity");
1440 let required_ecef = sub3(
1441 sub3(target_velocity_ecef_mps, state.velocity_ecef_mps),
1442 scale3(gravity, dt_s),
1443 );
1444 mat3_mul_vec(&c_avg_t, required_ecef)
1445 }
1446
1447 fn mid_interval_dcm(
1448 attitude_body_to_ecef: &Mat3,
1449 delta_theta_rad: [f64; 3],
1450 dt_s: f64,
1451 ) -> Mat3 {
1452 let earth_half = earth_rotation_first_order(0.5 * dt_s);
1453 let body_half =
1454 crate::inertial::rodrigues_delta_dcm(scale3(delta_theta_rad, 0.5)).expect("body half");
1455 reorthonormalize_dcm(&mat3_mul(
1456 &mat3_mul(&earth_half, attitude_body_to_ecef),
1457 &body_half,
1458 ))
1459 .expect("mid dcm")
1460 }
1461
1462 fn earth_rotation_first_order(dt_s: f64) -> Mat3 {
1463 [
1464 [1.0, OMEGA_E_DOT_RAD_S * dt_s, 0.0],
1465 [-OMEGA_E_DOT_RAD_S * dt_s, 1.0, 0.0],
1466 [0.0, 0.0, 1.0],
1467 ]
1468 }
1469
1470 fn add_noise3(value: [f64; 3], sigma: f64, rng: &mut SplitMix64) -> [f64; 3] {
1471 [
1472 value[0] + sigma * rng.symmetric_unit(),
1473 value[1] + sigma * rng.symmetric_unit(),
1474 value[2] + sigma * rng.symmetric_unit(),
1475 ]
1476 }
1477
1478 fn quadratic_form(covariance: &[Vec<f64>], dx: &[f64], dimension: usize) -> f64 {
1479 let mut data = Vec::with_capacity(dimension * dimension);
1480 for row in covariance.iter().take(dimension) {
1481 data.extend(row.iter().take(dimension));
1482 }
1483 let matrix = DMatrix::from_row_slice(dimension, dimension, &data);
1484 let vector = DVector::from_column_slice(dx);
1485 let solved = matrix.cholesky().expect("covariance SPD").solve(&vector);
1486 dot_slice(dx, solved.as_slice())
1487 }
1488
1489 fn dot_slice(a: &[f64], b: &[f64]) -> f64 {
1490 a.iter().zip(b).map(|(x, y)| x * y).sum()
1491 }
1492
1493 fn direct_update_filter(t_j2000_s: f64, loose: LooseCouplingConfig) -> InertialFilter {
1494 let nominal = NavState::new(t_j2000_s, [WGS84_A_M, 0.0, 0.0], [0.0; 3], mat3_identity())
1495 .expect("nominal");
1496 let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1497 for value in diagonal.iter_mut().take(6) {
1498 *value = 1.0;
1499 }
1500 let state = reference_filter_state(nominal, &diagonal).expect("filter state");
1501 let spec = ImuSpec::datasheet(
1502 0.0,
1503 0.0,
1504 0.0,
1505 0.0,
1506 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1507 crate::inertial::config::RANDOM_WALK_BIAS_TAU_S,
1508 None,
1509 None,
1510 );
1511 let mut config = InertialFilterConfig::new(spec).expect("config");
1512 config.loose = loose;
1513 InertialFilter::with_config(state, config).expect("filter")
1514 }
1515
1516 fn direct_position_velocity_measurement(
1517 t_j2000_s: f64,
1518 position_ecef_m: [f64; 3],
1519 velocity_ecef_mps: [f64; 3],
1520 sigma: f64,
1521 ) -> GnssFixMeasurement {
1522 GnssFixMeasurement::position_velocity(
1523 t_j2000_s,
1524 position_ecef_m,
1525 velocity_ecef_mps,
1526 covariance_from_diag(&[sigma * sigma; 6]),
1527 8,
1528 )
1529 .expect("measurement")
1530 }
1531
1532 struct SplitMix64 {
1533 state: u64,
1534 }
1535
1536 impl SplitMix64 {
1537 fn new(seed: u64) -> Self {
1538 Self { state: seed }
1539 }
1540
1541 fn next_u64(&mut self) -> u64 {
1542 self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
1543 let mut z = self.state;
1544 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1545 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1546 z ^ (z >> 31)
1547 }
1548
1549 fn unit_f64(&mut self) -> f64 {
1550 let bits = 0x3ff0_0000_0000_0000 | (self.next_u64() >> 12);
1551 f64::from_bits(bits) - 1.0
1552 }
1553
1554 fn symmetric_unit(&mut self) -> f64 {
1555 2.0 * self.unit_f64() - 1.0
1556 }
1557
1558 fn standard_normal(&mut self) -> f64 {
1559 let u1 = self.unit_f64().max(f64::MIN_POSITIVE);
1560 let u2 = self.unit_f64();
1561 (-2.0 * u1.ln()).sqrt() * (2.0 * core::f64::consts::PI * u2).cos()
1562 }
1563 }
1564
1565 #[test]
1566 fn splitmix_sequence_matches_covariance_fixture_pattern_bits() {
1567 let mut rng = SplitMix64::new(0x9876_5432_10fe_dcba);
1568 assert_eq!(rng.next_u64(), 0xaf45_24ce_f491_bb91);
1569 assert_eq!(rng.next_u64(), 0x25fc_5376_94a6_001c);
1570 let mut rng = SplitMix64::new(0x9876_5432_10fe_dcba);
1571 assert_eq!(rng.unit_f64().to_bits(), 0x3fe5_e8a4_99de_9236);
1572 }
1573
1574 #[test]
1575 fn gyro_bias_test_vector_is_observable_for_non_axis_lever() {
1576 let lever = [1.0, 0.5, -0.25];
1577 let gyro_bias = [0.000009765625, -0.000009765625, 0.00001953125];
1578 assert_eq!(dot3(lever, gyro_bias).to_bits(), 0.0_f64.to_bits());
1579 assert!(norm3(cross3(gyro_bias, lever)) > 0.0);
1580 }
1581}