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