Skip to main content

sidereon_core/fusion/
loose.rs

1//! Loosely coupled GNSS PVT updates for the INS error-state filter.
2//!
3//! Measurement epochs are required to match the propagated INS epoch exactly.
4//! A later time-synchronization layer can lift that requirement without
5//! changing the measurement model here.
6
7use 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/// GNSS PVT measurement used by the loose-coupled INS update.
42///
43/// The covariance matrix is ordered as `[position_x, position_y, position_z]`
44/// for a position-only fix and as `[position_x, position_y, position_z,
45/// velocity_x, velocity_y, velocity_z]` when velocity is present.
46#[derive(Debug, Clone, PartialEq)]
47pub struct GnssFixMeasurement {
48    /// Measurement epoch in seconds since J2000 on the caller's GNSS time scale.
49    pub t_j2000_s: f64,
50    /// GNSS antenna position in ECEF meters.
51    pub position_ecef_m: [f64; 3],
52    /// Optional GNSS antenna velocity in ECEF meters per second.
53    pub velocity_ecef_mps: Option<[f64; 3]>,
54    /// Measurement covariance in the order documented on this type.
55    pub covariance: Vec<Vec<f64>>,
56    /// Number of satellites used by the upstream GNSS fix.
57    pub satellites_used: usize,
58    /// Whether the upstream GNSS solver reported a successful fix.
59    pub solution_valid: bool,
60}
61
62impl GnssFixMeasurement {
63    /// Build a position-only GNSS fix measurement.
64    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    /// Build a position and velocity GNSS fix measurement.
83    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    /// Validate finite values, solver status, satellite count, and covariance.
103    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    /// Return the number of measurement rows implied by this fix.
125    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/// Configuration for loose-coupled GNSS updates.
135#[derive(Debug, Clone, Copy, PartialEq)]
136pub struct LooseCouplingConfig {
137    /// Body-frame vector from IMU origin to GNSS antenna phase center, in meters.
138    pub lever_arm_body_m: [f64; 3],
139    /// Generic EKF correction options applied to each loose update.
140    pub update_options: EkfUpdateOptions,
141    /// Optional IGG-III variance inflation on standardized innovation rows.
142    pub measurement_reweighting: Option<IggIiiMeasurementReweighting>,
143    /// Optional Yang two-segment predicted-covariance inflation.
144    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    /// Validate finite lever-arm entries and nested update options.
160    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/// IGG-III measurement variance inflation for loose GNSS updates.
176///
177/// The standardized innovation `v_i / sqrt(S_ii)` selects one of three
178/// variance-domain segments from Remote Sensing 13(10):1943, Eq. 32: unchanged
179/// below `k0`, IGG-III middle-segment inflation up to `k1`, and a fixed
180/// `1e4` variance scale in the rejection segment.
181#[derive(Debug, Clone, Copy, PartialEq)]
182pub struct IggIiiMeasurementReweighting {
183    /// Lower standardized-innovation break point. Literature range: `[1.5, 3.0]`.
184    pub k0_sigma: f64,
185    /// Upper standardized-innovation break point. Literature range: `[3.0, 8.0]`.
186    pub k1_sigma: f64,
187}
188
189impl IggIiiMeasurementReweighting {
190    /// Common loose-GNSS setting inside the cited ranges.
191    pub const fn standard() -> Self {
192        Self {
193            k0_sigma: 2.0,
194            k1_sigma: 5.0,
195        }
196    }
197
198    /// Validate IGG-III break points against the cited ranges.
199    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/// Yang two-segment predicted-residual adaptive factor for loose GNSS updates.
226///
227/// `threshold` is the `c` value used with the un-square-rooted statistic
228/// `innovation^T innovation / tr(S)`. The Jiang-Zhang Sensors 2018 guard is
229/// part of this option: if the raw innovation Mahalanobis distance exceeds
230/// `chi2_inv(outlier_gate_probability, rows)`, prediction adaptation is
231/// disabled for that epoch and measurement-side reweighting handles the fault.
232#[derive(Debug, Clone, Copy, PartialEq)]
233pub struct YangPredictionAdaptiveFactor {
234    /// Two-segment threshold `c` for the predicted-residual statistic.
235    pub threshold: f64,
236    /// Probability used for the chi-square Mahalanobis measurement-outlier gate.
237    pub outlier_gate_probability: f64,
238}
239
240impl YangPredictionAdaptiveFactor {
241    /// Conservative default for the un-square-rooted statistic and 99% gate.
242    pub const fn standard() -> Self {
243        Self {
244            threshold: 1.0,
245            outlier_gate_probability: DEFAULT_PREDICTION_ADAPTATION_GATE_PROBABILITY,
246        }
247    }
248
249    /// Validate threshold and chi-square probability.
250    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/// Configuration for an [`InertialFilter`].
275#[derive(Debug, Clone, Copy, PartialEq)]
276pub struct InertialFilterConfig {
277    /// IMU stochastic model used for covariance prediction.
278    pub imu_spec: ImuSpec,
279    /// Measurement-update algorithm used by loose and tight GNSS updates.
280    pub filter_kind: FusionFilterKind,
281    /// Deterministic IMU calibration applied before mechanization.
282    pub imu_model: ImuErrorModel,
283    /// Strapdown mechanization options.
284    pub mechanization: MechanizationConfig,
285    /// Loose GNSS update options.
286    pub loose: LooseCouplingConfig,
287    /// Tight raw GNSS update options.
288    pub tight: TightCouplingConfig,
289    /// UKF correction options used when [`Self::filter_kind`] is [`FusionFilterKind::Ukf`].
290    pub ukf_update_options: UkfUpdateOptions,
291}
292
293impl InertialFilterConfig {
294    /// Build a filter configuration with default calibration and loose settings.
295    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    /// Validate IMU, mechanization, and loose-coupling settings.
310    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/// Result of one fusion update.
337#[derive(Debug, Clone, PartialEq)]
338pub struct FusionUpdate {
339    /// Whether the update modified the nominal state and covariance.
340    pub applied: bool,
341    /// Normalized innovation squared for the update rows.
342    pub nis: f64,
343    /// Number of measurement rows entering the update.
344    pub rows: usize,
345    /// Number of rows accepted by any configured innovation screen.
346    pub accepted_rows: usize,
347    /// Number of rows rejected by any configured innovation screen.
348    pub rejected_rows: usize,
349    /// Full correction report from the selected update primitive.
350    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/// Closed-loop INS filter with loose GNSS PVT updates.
367#[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    /// Build a filter with default calibration and loose-coupling settings.
378    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    /// Build a filter with explicit configuration.
384    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    /// Borrow the current INS filter state.
402    pub const fn state(&self) -> &InsFilterState {
403        &self.state
404    }
405
406    /// Mutably borrow the current INS filter state.
407    pub fn state_mut(&mut self) -> &mut InsFilterState {
408        &mut self.state
409    }
410
411    /// Borrow the immutable filter configuration.
412    pub const fn config(&self) -> &InertialFilterConfig {
413        &self.config
414    }
415
416    /// Return the most recent body angular rate relative to ECEF, resolved in body axes.
417    pub const fn last_body_rate_wrt_ecef_rps(&self) -> [f64; 3] {
418        self.last_body_rate_wrt_ecef_rps
419    }
420
421    /// Propagate the nominal INS state and error covariance with one IMU sample.
422    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    /// Apply a loose GNSS PVT update at the current propagated epoch.
484    ///
485    /// GNSS epochs must be strictly increasing across the filter's stateful
486    /// update surface, matching the standalone time-sync order validator;
487    /// duplicate or regressed epochs are rejected rather than fused twice.
488    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
562/// Build the loose-coupled GNSS EKF correction for an INS state.
563///
564/// The returned design matrix follows the nominal-error convention used by
565/// [`InsFilterState`]: navigation errors are subtracted during closed-loop reset,
566/// while bias errors are added to the closed-loop bias estimates.
567///
568/// `body_rate_wrt_ecef_rps` is the body angular rate relative to ECEF, resolved
569/// in body axes. A body fixed in ECEF supplies zero for this rate even though
570/// its gyroscopes measure Earth rate.
571pub 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    // Jiang and Zhang, Sensors 2018: innovation-driven adaptation is disabled
736    // when the Mahalanobis gate flags a measurement outlier.
737    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    //! Provenance: loose-coupled GNSS/INS equations follow Groves, Principles
791    //! of GNSS, Inertial, and Multisensor Integrated Navigation Systems, 2nd
792    //! ed., Chapter 14, with the lever-arm position and velocity model stated
793    //! in the build spec. Synthetic noise uses the SplitMix64 sequence pattern
794    //! from `astro/propagator/covariance.rs`. NEES/NIS consistency bands use
795    //! the Bar-Shalom two-sided chi-square test.
796
797    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        // With P = R = 1 and gamma = 1e4, a 50 m rejection-row outlier moves
1303        // the robust scalar posterior by 50 / 10001 = 0.0071 clean posterior
1304        // sigma. The assertion uses 0.1 sigma to leave numerical margin.
1305        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}