Skip to main content

sidereon_core/fusion/
tight.rs

1//! Tightly coupled raw GNSS updates for the INS error-state filter.
2//!
3//! The update consumes one epoch of satellite pseudorange and optional
4//! range-rate observations. It keeps the INS layout unchanged and carries the
5//! receiver clock bias and drift in a private two-state augmentation.
6
7use std::collections::BTreeSet;
8
9use crate::astro::math::mat3::{inline_rxr, mul_vec3};
10use crate::astro::math::vec3::{add3, cross3};
11use crate::constants::C_M_S;
12use crate::inertial::state::skew;
13use crate::inertial::{validate_finite, validate_vec3};
14use crate::observables::{
15    transmit_time_satellite_state, ObservableEphemerisSource, ObservablesError, TransmitTimeOptions,
16};
17use crate::precise_positioning::{
18    predict_range_rate_m_s, ReceiverVelocityState, VelocityObservation,
19};
20
21use super::ekf::{
22    apply_closed_loop_navigation_error, apply_closed_loop_scale_error, innovation_covariance,
23    joseph_covariance_update, normalized_innovation_squared, screen_correction, EkfCorrection,
24    EkfCorrectionReport, EkfUpdateOptions,
25};
26use super::loose::{FusionUpdate, InertialFilter};
27use super::state::{
28    identity, invalid_input, matmul, matrix_add, reproject_covariance_psd, symmetrize_in_place,
29    validate_covariance_matrix, validate_finite_slice, validate_nonnegative, validate_positive,
30    validate_square_matrix, FusionError, InsFilterState, ERROR_ATTITUDE_INDEX,
31    ERROR_GYRO_BIAS_INDEX, ERROR_POSITION_INDEX, ERROR_VELOCITY_INDEX,
32};
33
34/// Receiver-clock bias index in the tight augmented covariance.
35pub const TIGHT_CLOCK_BIAS_OFFSET: usize = 0;
36/// Receiver-clock drift index in the tight augmented covariance.
37pub const TIGHT_CLOCK_DRIFT_OFFSET: usize = 1;
38/// Number of receiver-clock states appended to the INS error state.
39pub const TIGHT_CLOCK_STATE_COUNT: usize = 2;
40
41/// Doppler-derived range-rate measurement for one satellite.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct TightRangeRateObservation {
44    /// Measured pseudorange rate in meters per second.
45    pub measured_range_rate_m_s: f64,
46    /// One-sigma range-rate uncertainty in meters per second.
47    pub sigma_m_s: f64,
48    /// Satellite clock drift as an equivalent range-rate bias in meters per second.
49    pub satellite_clock_drift_m_s: f64,
50}
51
52impl TightRangeRateObservation {
53    /// Validate finite range-rate fields and positive sigma.
54    pub fn validate(&self) -> Result<(), FusionError> {
55        validate_finite(self.measured_range_rate_m_s, "measured_range_rate_m_s")
56            .map_err(FusionError::from)?;
57        validate_positive(self.sigma_m_s, "range_rate_sigma_m_s")?;
58        validate_finite(self.satellite_clock_drift_m_s, "satellite_clock_drift_m_s")
59            .map_err(FusionError::from)
60    }
61}
62
63/// Carrier-phase range row with a caller-supplied float ambiguity.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct TightCarrierPhaseObservation {
66    /// Carrier phase converted to range units in meters.
67    pub phase_range_m: f64,
68    /// One-sigma carrier-phase range uncertainty in meters.
69    pub sigma_m: f64,
70    /// Current float ambiguity estimate for this continuous arc, in meters.
71    pub float_ambiguity_m: f64,
72}
73
74impl TightCarrierPhaseObservation {
75    /// Validate finite carrier fields and positive sigma.
76    pub fn validate(&self) -> Result<(), FusionError> {
77        validate_finite(self.phase_range_m, "phase_range_m").map_err(FusionError::from)?;
78        validate_positive(self.sigma_m, "carrier_sigma_m")?;
79        validate_finite(self.float_ambiguity_m, "float_ambiguity_m").map_err(FusionError::from)
80    }
81}
82
83/// Raw GNSS observation for one satellite in a tight update.
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct TightGnssObservation {
86    /// Satellite identifier.
87    pub satellite_id: crate::GnssSatelliteId,
88    /// Measured code pseudorange in meters.
89    pub pseudorange_m: f64,
90    /// One-sigma pseudorange uncertainty in meters.
91    pub pseudorange_sigma_m: f64,
92    /// Optional Doppler-derived range-rate row.
93    pub range_rate: Option<TightRangeRateObservation>,
94    /// Optional carrier-phase row using a supplied float ambiguity.
95    pub carrier_phase: Option<TightCarrierPhaseObservation>,
96    /// Ionospheric group delay correction for code, in meters.
97    pub ionosphere_delay_m: f64,
98    /// Tropospheric delay correction, in meters.
99    pub troposphere_delay_m: f64,
100}
101
102impl TightGnssObservation {
103    /// Build a pseudorange-only observation.
104    pub fn pseudorange(
105        satellite_id: crate::GnssSatelliteId,
106        pseudorange_m: f64,
107        pseudorange_sigma_m: f64,
108    ) -> Result<Self, FusionError> {
109        let observation = Self {
110            satellite_id,
111            pseudorange_m,
112            pseudorange_sigma_m,
113            range_rate: None,
114            carrier_phase: None,
115            ionosphere_delay_m: 0.0,
116            troposphere_delay_m: 0.0,
117        };
118        observation.validate()?;
119        Ok(observation)
120    }
121
122    /// Validate finite measurement values, positive sigmas, and optional rows.
123    pub fn validate(&self) -> Result<(), FusionError> {
124        validate_finite(self.pseudorange_m, "pseudorange_m").map_err(FusionError::from)?;
125        validate_positive(self.pseudorange_sigma_m, "pseudorange_sigma_m")?;
126        validate_finite(self.ionosphere_delay_m, "ionosphere_delay_m")
127            .map_err(FusionError::from)?;
128        validate_finite(self.troposphere_delay_m, "troposphere_delay_m")
129            .map_err(FusionError::from)?;
130        if let Some(range_rate) = self.range_rate {
131            range_rate.validate()?;
132        }
133        if let Some(carrier_phase) = self.carrier_phase {
134            carrier_phase.validate()?;
135        }
136        Ok(())
137    }
138}
139
140/// One receiver epoch of raw GNSS observations for a tight update.
141#[derive(Debug, Clone, PartialEq)]
142pub struct TightGnssEpoch {
143    /// Measurement epoch in seconds since J2000 on the caller's GNSS time scale.
144    pub t_j2000_s: f64,
145    /// One or more satellite observations.
146    pub observations: Vec<TightGnssObservation>,
147}
148
149impl TightGnssEpoch {
150    /// Build and validate an epoch from raw observations.
151    pub fn new(
152        t_j2000_s: f64,
153        observations: Vec<TightGnssObservation>,
154    ) -> Result<Self, FusionError> {
155        let epoch = Self {
156            t_j2000_s,
157            observations,
158        };
159        epoch.validate()?;
160        Ok(epoch)
161    }
162
163    /// Validate epoch time, row count, duplicate satellites, and observations.
164    pub fn validate(&self) -> Result<(), FusionError> {
165        validate_finite(self.t_j2000_s, "t_j2000_s").map_err(FusionError::from)?;
166        if self.observations.is_empty() {
167            return Err(invalid_input("tight_observations", "must not be empty"));
168        }
169        let mut seen = BTreeSet::new();
170        for observation in &self.observations {
171            observation.validate()?;
172            if !seen.insert(observation.satellite_id) {
173                return Err(invalid_input(
174                    "tight_observations",
175                    "satellites must be unique",
176                ));
177            }
178        }
179        Ok(())
180    }
181}
182
183/// Configuration for tightly coupled raw GNSS updates.
184#[derive(Debug, Clone, Copy, PartialEq)]
185pub struct TightCouplingConfig {
186    /// Body-frame vector from IMU origin to GNSS antenna phase center, in meters.
187    pub lever_arm_body_m: [f64; 3],
188    /// Apply fixed-count transmit-time light-time correction.
189    pub light_time: bool,
190    /// Apply Earth-rotation Sagnac correction.
191    pub sagnac: bool,
192    /// Initial receiver-clock bias variance in square meters.
193    pub initial_clock_bias_variance_m2: f64,
194    /// Initial receiver-clock drift variance in `(m/s)^2`.
195    pub initial_clock_drift_variance_m2_s2: f64,
196    /// Receiver-clock bias random-walk spectral density in `m^2/s`.
197    pub clock_bias_random_walk_m2_s: f64,
198    /// Receiver-clock drift random-walk spectral density in `m^2/s^3`.
199    pub clock_drift_random_walk_m2_s3: f64,
200    /// Generic EKF correction options applied to each tight update.
201    pub update_options: EkfUpdateOptions,
202}
203
204impl Default for TightCouplingConfig {
205    fn default() -> Self {
206        Self {
207            lever_arm_body_m: [0.0; 3],
208            light_time: true,
209            sagnac: true,
210            initial_clock_bias_variance_m2: 1.0e12,
211            initial_clock_drift_variance_m2_s2: 1.0e6,
212            clock_bias_random_walk_m2_s: 1.0,
213            clock_drift_random_walk_m2_s3: 1.0e-2,
214            update_options: EkfUpdateOptions::default(),
215        }
216    }
217}
218
219impl TightCouplingConfig {
220    /// Validate lever arm, clock covariance, clock process noise, and update options.
221    pub fn validate(&self) -> Result<(), FusionError> {
222        validate_vec3(self.lever_arm_body_m, "tight_lever_arm_body_m")
223            .map_err(FusionError::from)?;
224        validate_nonnegative(
225            self.initial_clock_bias_variance_m2,
226            "initial_clock_bias_variance_m2",
227        )?;
228        validate_nonnegative(
229            self.initial_clock_drift_variance_m2_s2,
230            "initial_clock_drift_variance_m2_s2",
231        )?;
232        validate_nonnegative(
233            self.clock_bias_random_walk_m2_s,
234            "clock_bias_random_walk_m2_s",
235        )?;
236        validate_nonnegative(
237            self.clock_drift_random_walk_m2_s3,
238            "clock_drift_random_walk_m2_s3",
239        )?;
240        if let Some(gate) = self.update_options.innovation_gate {
241            gate.validate()?;
242        }
243        Ok(())
244    }
245}
246
247/// Receiver-clock state reported by the tight filter.
248#[derive(Debug, Clone, Copy, PartialEq)]
249pub struct TightClockState {
250    /// Receiver-clock range bias in meters.
251    pub bias_m: f64,
252    /// Receiver-clock drift in meters per second.
253    pub drift_m_s: f64,
254    /// Two-by-two clock covariance ordered as `[bias_m, drift_m_s]`.
255    pub covariance: [[f64; TIGHT_CLOCK_STATE_COUNT]; TIGHT_CLOCK_STATE_COUNT],
256}
257
258/// Snapshot of the tight clock augmentation for replay and restore.
259#[derive(Debug, Clone, PartialEq)]
260pub struct TightFilterSnapshot {
261    /// Receiver-clock range bias in meters.
262    pub clock_bias_m: f64,
263    /// Receiver-clock drift in meters per second.
264    pub clock_drift_m_s: f64,
265    /// Full augmented covariance ordered as `[INS error state, clock bias, clock drift]`.
266    pub augmented_covariance: Vec<Vec<f64>>,
267}
268
269#[derive(Debug, Clone, PartialEq)]
270pub(super) struct TightFusionState {
271    clock_bias_m: f64,
272    clock_drift_m_s: f64,
273    augmented_covariance: Vec<Vec<f64>>,
274}
275
276impl TightFusionState {
277    pub(super) fn from_filter_state(
278        state: &InsFilterState,
279        config: TightCouplingConfig,
280    ) -> Result<Self, FusionError> {
281        config.validate()?;
282        let base_dim = state.dimension();
283        let aug_dim = augmented_dimension(base_dim);
284        let mut augmented_covariance = vec![vec![0.0; aug_dim]; aug_dim];
285        for (row, base_row) in state.covariance.iter().enumerate().take(base_dim) {
286            augmented_covariance[row][..base_dim].copy_from_slice(&base_row[..base_dim]);
287        }
288        let clock_bias_index = clock_bias_index(base_dim);
289        let clock_drift_index = clock_drift_index(base_dim);
290        augmented_covariance[clock_bias_index][clock_bias_index] =
291            config.initial_clock_bias_variance_m2;
292        augmented_covariance[clock_drift_index][clock_drift_index] =
293            config.initial_clock_drift_variance_m2_s2;
294        let tight = Self {
295            clock_bias_m: 0.0,
296            clock_drift_m_s: 0.0,
297            augmented_covariance,
298        };
299        tight.validate(base_dim)?;
300        Ok(tight)
301    }
302
303    pub(super) fn snapshot(&self) -> TightFilterSnapshot {
304        TightFilterSnapshot {
305            clock_bias_m: self.clock_bias_m,
306            clock_drift_m_s: self.clock_drift_m_s,
307            augmented_covariance: self.augmented_covariance.clone(),
308        }
309    }
310
311    pub(super) fn restore(
312        &mut self,
313        snapshot: &TightFilterSnapshot,
314        base_dim: usize,
315    ) -> Result<(), FusionError> {
316        validate_finite(snapshot.clock_bias_m, "clock_bias_m").map_err(FusionError::from)?;
317        validate_finite(snapshot.clock_drift_m_s, "clock_drift_m_s").map_err(FusionError::from)?;
318        validate_covariance_matrix(
319            &snapshot.augmented_covariance,
320            augmented_dimension(base_dim),
321            "tight_augmented_covariance",
322        )?;
323        self.clock_bias_m = snapshot.clock_bias_m;
324        self.clock_drift_m_s = snapshot.clock_drift_m_s;
325        self.augmented_covariance = snapshot.augmented_covariance.clone();
326        self.validate(base_dim)
327    }
328
329    pub(super) fn clock_state(&self, base_dim: usize) -> Result<TightClockState, FusionError> {
330        self.validate(base_dim)?;
331        let bias = clock_bias_index(base_dim);
332        let drift = clock_drift_index(base_dim);
333        Ok(TightClockState {
334            bias_m: self.clock_bias_m,
335            drift_m_s: self.clock_drift_m_s,
336            covariance: [
337                [
338                    self.augmented_covariance[bias][bias],
339                    self.augmented_covariance[bias][drift],
340                ],
341                [
342                    self.augmented_covariance[drift][bias],
343                    self.augmented_covariance[drift][drift],
344                ],
345            ],
346        })
347    }
348
349    pub(super) fn validate(&self, base_dim: usize) -> Result<(), FusionError> {
350        validate_finite(self.clock_bias_m, "clock_bias_m").map_err(FusionError::from)?;
351        validate_finite(self.clock_drift_m_s, "clock_drift_m_s").map_err(FusionError::from)?;
352        validate_covariance_matrix(
353            &self.augmented_covariance,
354            augmented_dimension(base_dim),
355            "tight_augmented_covariance",
356        )
357    }
358
359    pub(super) fn align_with_filter_state(
360        &mut self,
361        state: &InsFilterState,
362    ) -> Result<(), FusionError> {
363        state.validate()?;
364        let base_dim = state.dimension();
365        self.validate(base_dim)?;
366        let mut differs = false;
367        'outer: for row in 0..base_dim {
368            for col in 0..base_dim {
369                if self.augmented_covariance[row][col].to_bits()
370                    != state.covariance[row][col].to_bits()
371                {
372                    differs = true;
373                    break 'outer;
374                }
375            }
376        }
377        if differs {
378            self.replace_base_covariance_and_clear_cross(&state.covariance)?;
379        }
380        Ok(())
381    }
382
383    pub(super) fn replace_base_covariance_and_clear_cross(
384        &mut self,
385        base_covariance: &[Vec<f64>],
386    ) -> Result<(), FusionError> {
387        let base_dim = base_covariance.len();
388        validate_covariance_matrix(base_covariance, base_dim, "covariance")?;
389        self.validate(base_dim)?;
390        let aug_dim = augmented_dimension(base_dim);
391        for (row, base_row) in base_covariance.iter().enumerate().take(base_dim) {
392            self.augmented_covariance[row][..base_dim].copy_from_slice(&base_row[..base_dim]);
393        }
394        for idx in 0..base_dim {
395            for clock in base_dim..aug_dim {
396                self.augmented_covariance[idx][clock] = 0.0;
397                self.augmented_covariance[clock][idx] = 0.0;
398            }
399        }
400        self.validate(base_dim)
401    }
402
403    pub(super) fn predict_covariance(
404        &mut self,
405        phi_base: &[Vec<f64>],
406        q_base: &[Vec<f64>],
407        dt_s: f64,
408        config: TightCouplingConfig,
409    ) -> Result<(), FusionError> {
410        config.validate()?;
411        validate_nonnegative(dt_s, "dt_s")?;
412        let base_dim = phi_base.len();
413        validate_square_matrix(phi_base, base_dim, "phi")?;
414        validate_covariance_matrix(q_base, base_dim, "q_d")?;
415        self.validate(base_dim)?;
416
417        let aug_dim = augmented_dimension(base_dim);
418        let mut phi = identity(aug_dim);
419        for row in 0..base_dim {
420            for col in 0..base_dim {
421                phi[row][col] = phi_base[row][col];
422            }
423        }
424        let bias = clock_bias_index(base_dim);
425        let drift = clock_drift_index(base_dim);
426        phi[bias][drift] = dt_s;
427
428        let mut q = vec![vec![0.0; aug_dim]; aug_dim];
429        for row in 0..base_dim {
430            for col in 0..base_dim {
431                q[row][col] = q_base[row][col];
432            }
433        }
434        let dt2 = dt_s * dt_s;
435        let dt3 = dt2 * dt_s;
436        q[bias][bias] += config.clock_bias_random_walk_m2_s * dt_s
437            + config.clock_drift_random_walk_m2_s3 * dt3 / 3.0;
438        q[bias][drift] += config.clock_drift_random_walk_m2_s3 * dt2 / 2.0;
439        q[drift][bias] = q[bias][drift];
440        q[drift][drift] += config.clock_drift_random_walk_m2_s3 * dt_s;
441        reproject_covariance_psd(&mut q, "tight_process_noise")?;
442
443        let left = matmul(&phi, &self.augmented_covariance)?;
444        let phi_t = super::state::transpose(&phi)?;
445        let propagated = matmul(&left, &phi_t)?;
446        let mut next = matrix_add(&propagated, &q)?;
447        symmetrize_in_place(&mut next);
448        reproject_covariance_psd(&mut next, "tight_augmented_covariance")?;
449        self.augmented_covariance = next;
450        self.validate(base_dim)
451    }
452
453    pub(super) fn copy_base_covariance_to_state(
454        &self,
455        state: &mut InsFilterState,
456    ) -> Result<(), FusionError> {
457        let base_dim = state.dimension();
458        self.validate(base_dim)?;
459        for row in 0..base_dim {
460            for col in 0..base_dim {
461                state.covariance[row][col] = self.augmented_covariance[row][col];
462            }
463        }
464        state.validate()
465    }
466}
467
468impl InertialFilter {
469    /// Borrow the current receiver-clock state carried by tight coupling.
470    pub fn tight_clock_state(&self) -> Result<TightClockState, FusionError> {
471        self.tight.clock_state(self.state.dimension())
472    }
473
474    /// Apply a tight raw GNSS update at the current propagated epoch.
475    ///
476    /// GNSS epochs must be strictly increasing across the filter's stateful
477    /// update surface. One satellite is a valid update.
478    pub fn update_tight(
479        &mut self,
480        source: &dyn ObservableEphemerisSource,
481        epoch: &TightGnssEpoch,
482    ) -> Result<FusionUpdate, FusionError> {
483        if let Some(last) = self.time_sync.last_measurement_t_j2000_s() {
484            if epoch.t_j2000_s <= last {
485                return Err(invalid_input(
486                    "t_j2000_s",
487                    "GNSS measurement epochs must be strictly increasing",
488                ));
489            }
490        }
491        let update = self.update_tight_core(source, epoch)?;
492        let snapshot = self.snapshot();
493        self.time_sync
494            .push_tight_measurement_and_checkpoint(epoch.clone(), snapshot);
495        Ok(update)
496    }
497
498    pub(super) fn update_tight_core(
499        &mut self,
500        source: &dyn ObservableEphemerisSource,
501        epoch: &TightGnssEpoch,
502    ) -> Result<FusionUpdate, FusionError> {
503        self.tight.align_with_filter_state(&self.state)?;
504        let correction = tight_coupling_correction(
505            source,
506            &self.state,
507            &self.tight,
508            epoch,
509            self.config.tight,
510            self.last_body_rate_wrt_ecef_rps,
511        )?;
512        let rows = correction.row_count();
513        let report = apply_tight_correction(self, &correction, self.config.tight.update_options)?;
514        Ok(FusionUpdate {
515            applied: report.applied,
516            nis: report.normalized_innovation_squared,
517            rows,
518            accepted_rows: report.accepted_rows,
519            rejected_rows: report.rejected_rows,
520            ekf: report,
521        })
522    }
523}
524
525pub(super) fn tight_coupling_correction(
526    source: &dyn ObservableEphemerisSource,
527    state: &InsFilterState,
528    tight_state: &TightFusionState,
529    epoch: &TightGnssEpoch,
530    config: TightCouplingConfig,
531    body_rate_wrt_ecef_rps: [f64; 3],
532) -> Result<EkfCorrection, FusionError> {
533    state.validate()?;
534    tight_state.validate(state.dimension())?;
535    epoch.validate()?;
536    config.validate()?;
537    validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
538    if epoch.t_j2000_s != state.nominal.t_j2000_s {
539        return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
540    }
541
542    let base_dim = state.dimension();
543    let aug_dim = augmented_dimension(base_dim);
544    let clock_bias = clock_bias_index(base_dim);
545    let clock_drift = clock_drift_index(base_dim);
546    let kinematics = antenna_kinematics(state, config.lever_arm_body_m, body_rate_wrt_ecef_rps);
547    let options = TransmitTimeOptions {
548        light_time: config.light_time,
549        sagnac: config.sagnac,
550    };
551
552    let mut innovation = Vec::new();
553    let mut design = Vec::new();
554    let mut variances = Vec::new();
555
556    for observation in &epoch.observations {
557        let satellite = transmit_time_satellite_state(
558            source,
559            observation.satellite_id,
560            kinematics.antenna_position_ecef_m,
561            epoch.t_j2000_s,
562            options,
563        )
564        .map_err(map_observables_error)?;
565        let sat_clock_s = satellite
566            .clock_s
567            .ok_or_else(|| invalid_input("satellite_clock_s", "must be present"))?;
568
569        let code_prediction_m = satellite.geometric_range_m + tight_state.clock_bias_m
570            - C_M_S * sat_clock_s
571            + observation.ionosphere_delay_m
572            + observation.troposphere_delay_m;
573        let mut row = pseudorange_design_row(
574            aug_dim,
575            clock_bias,
576            satellite.los_unit,
577            kinematics.lever_arm_ecef_m,
578        );
579        innovation.push(observation.pseudorange_m - code_prediction_m);
580        design.push(row);
581        variances.push(observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
582
583        if let Some(carrier_phase) = observation.carrier_phase {
584            let phase_prediction_m = satellite.geometric_range_m + tight_state.clock_bias_m
585                - C_M_S * sat_clock_s
586                - observation.ionosphere_delay_m
587                + observation.troposphere_delay_m
588                + carrier_phase.float_ambiguity_m;
589            row = pseudorange_design_row(
590                aug_dim,
591                clock_bias,
592                satellite.los_unit,
593                kinematics.lever_arm_ecef_m,
594            );
595            innovation.push(carrier_phase.phase_range_m - phase_prediction_m);
596            design.push(row);
597            variances.push(carrier_phase.sigma_m * carrier_phase.sigma_m);
598        }
599
600        if let Some(range_rate) = observation.range_rate {
601            let velocity_observation = VelocityObservation {
602                sat: observation.satellite_id,
603                satellite_position_m: satellite.position_ecef_m,
604                satellite_velocity_m_s: satellite.velocity_m_s,
605                measured_range_rate_m_s: range_rate.measured_range_rate_m_s,
606                sigma_m_s: range_rate.sigma_m_s,
607                satellite_clock_drift_m_s: range_rate.satellite_clock_drift_m_s,
608            };
609            let receiver = ReceiverVelocityState {
610                position_m: kinematics.antenna_position_ecef_m,
611                velocity_m_s: kinematics.antenna_velocity_ecef_mps,
612                clock_drift_m_s: tight_state.clock_drift_m_s,
613            };
614            let prediction = predict_range_rate_m_s(&velocity_observation, receiver)
615                .ok_or_else(|| invalid_input("range_rate", "line of sight must be nonzero"))?;
616            let row = range_rate_design_row(
617                aug_dim,
618                clock_drift,
619                prediction.los_unit,
620                kinematics.lever_velocity_ecef_mps,
621                kinematics.gyro_bias_velocity_block,
622            );
623            innovation.push(range_rate.measured_range_rate_m_s - prediction.range_rate_m_s);
624            design.push(row);
625            variances.push(range_rate.sigma_m_s * range_rate.sigma_m_s);
626        }
627    }
628
629    validate_finite_slice(&innovation, "tight_innovation")?;
630    let measurement_covariance = diagonal_covariance(&variances)?;
631    EkfCorrection::new(innovation, design, measurement_covariance)
632}
633
634fn apply_tight_correction(
635    filter: &mut InertialFilter,
636    correction: &EkfCorrection,
637    options: EkfUpdateOptions,
638) -> Result<EkfCorrectionReport, FusionError> {
639    filter.state.validate()?;
640    let base_dim = filter.state.dimension();
641    filter.tight.validate(base_dim)?;
642    correction.validate_for_dimension(augmented_dimension(base_dim))?;
643
644    if let Some(gate) = options.innovation_gate {
645        gate.validate()?;
646        let full_s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
647        let (screened, gate_report) = screen_correction(correction, &full_s, gate)?;
648        let full_nis = normalized_innovation_squared(&full_s, &correction.innovation)?;
649        if gate_report.coasted {
650            return Ok(EkfCorrectionReport {
651                applied: false,
652                normalized_innovation_squared: full_nis,
653                accepted_rows: gate_report.accepted_rows,
654                rejected_rows: gate_report.rejected_rows,
655                innovation_gate: Some(gate_report),
656                innovation_covariance: full_s,
657                kalman_gain: vec![vec![0.0; correction.row_count()]; augmented_dimension(base_dim)],
658                dx: vec![0.0; augmented_dimension(base_dim)],
659            });
660        }
661        let accepted_rows = gate_report.accepted_rows;
662        let rejected_rows = gate_report.rejected_rows;
663        let mut report = apply_tight_correction_inner(filter, &screened)?;
664        report.accepted_rows = accepted_rows;
665        report.rejected_rows = rejected_rows;
666        report.innovation_gate = Some(gate_report);
667        return Ok(report);
668    }
669
670    apply_tight_correction_inner(filter, correction)
671}
672
673fn apply_tight_correction_inner(
674    filter: &mut InertialFilter,
675    correction: &EkfCorrection,
676) -> Result<EkfCorrectionReport, FusionError> {
677    let base_dim = filter.state.dimension();
678    let aug_dim = augmented_dimension(base_dim);
679    let s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
680    let h_t = super::state::transpose(&correction.design)?;
681    let p_h_t = matmul(&filter.tight.augmented_covariance, &h_t)?;
682    let mut kalman_gain = vec![vec![0.0; correction.row_count()]; aug_dim];
683    let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
684    for row in 0..aug_dim {
685        kalman_gain[row] = super::state::solve_spd(&s, &p_h_t[row], &mut scratch)?;
686    }
687    let dx = super::state::matvec(&kalman_gain, &correction.innovation)?;
688    let nis = normalized_innovation_squared(&s, &correction.innovation)?;
689    let covariance = joseph_covariance_update(
690        &filter.tight.augmented_covariance,
691        &correction.design,
692        &kalman_gain,
693        &correction.measurement_covariance,
694    )?;
695
696    apply_closed_loop_navigation_error(&mut filter.state.nominal, &dx[..base_dim])?;
697    apply_closed_loop_scale_error(&mut filter.state, &dx[..base_dim]);
698    filter.tight.clock_bias_m += dx[clock_bias_index(base_dim)];
699    filter.tight.clock_drift_m_s += dx[clock_drift_index(base_dim)];
700    filter.tight.augmented_covariance = covariance;
701    filter
702        .tight
703        .copy_base_covariance_to_state(&mut filter.state)?;
704    filter.state.reset_error_state();
705    filter.state.validate()?;
706    filter.tight.validate(base_dim)?;
707
708    Ok(EkfCorrectionReport {
709        applied: true,
710        normalized_innovation_squared: nis,
711        accepted_rows: correction.row_count(),
712        rejected_rows: 0,
713        innovation_gate: None,
714        innovation_covariance: s,
715        kalman_gain,
716        dx,
717    })
718}
719
720#[derive(Debug, Clone, Copy)]
721struct AntennaKinematics {
722    antenna_position_ecef_m: [f64; 3],
723    antenna_velocity_ecef_mps: [f64; 3],
724    lever_arm_ecef_m: [f64; 3],
725    lever_velocity_ecef_mps: [f64; 3],
726    gyro_bias_velocity_block: [[f64; 3]; 3],
727}
728
729fn antenna_kinematics(
730    state: &InsFilterState,
731    lever_arm_body_m: [f64; 3],
732    body_rate_wrt_ecef_rps: [f64; 3],
733) -> AntennaKinematics {
734    let c_b_e = state.nominal.attitude_body_to_ecef;
735    let lever_arm_ecef_m = mul_vec3(&c_b_e, lever_arm_body_m);
736    let antenna_position_ecef_m = add3(state.nominal.position_ecef_m, lever_arm_ecef_m);
737    let lever_velocity_body_mps = cross3(body_rate_wrt_ecef_rps, lever_arm_body_m);
738    let lever_velocity_ecef_mps = mul_vec3(&c_b_e, lever_velocity_body_mps);
739    let antenna_velocity_ecef_mps = add3(state.nominal.velocity_ecef_mps, lever_velocity_ecef_mps);
740    let gyro_bias_velocity_block = inline_rxr(&c_b_e, &skew(lever_arm_body_m));
741    AntennaKinematics {
742        antenna_position_ecef_m,
743        antenna_velocity_ecef_mps,
744        lever_arm_ecef_m,
745        lever_velocity_ecef_mps,
746        gyro_bias_velocity_block,
747    }
748}
749
750fn pseudorange_design_row(
751    aug_dim: usize,
752    clock_bias: usize,
753    los_unit: [f64; 3],
754    lever_arm_ecef_m: [f64; 3],
755) -> Vec<f64> {
756    let mut row = vec![0.0; aug_dim];
757    for axis in 0..3 {
758        row[ERROR_POSITION_INDEX + axis] = -los_unit[axis];
759    }
760    let lever_skew = skew(lever_arm_ecef_m);
761    for col in 0..3 {
762        row[ERROR_ATTITUDE_INDEX + col] = los_unit[0] * lever_skew[0][col]
763            + los_unit[1] * lever_skew[1][col]
764            + los_unit[2] * lever_skew[2][col];
765    }
766    row[clock_bias] = 1.0;
767    row
768}
769
770fn range_rate_design_row(
771    aug_dim: usize,
772    clock_drift: usize,
773    los_unit: [f64; 3],
774    lever_velocity_ecef_mps: [f64; 3],
775    gyro_bias_velocity_block: [[f64; 3]; 3],
776) -> Vec<f64> {
777    let mut row = vec![0.0; aug_dim];
778    for axis in 0..3 {
779        row[ERROR_VELOCITY_INDEX + axis] = -los_unit[axis];
780    }
781    let lever_velocity_skew = skew(lever_velocity_ecef_mps);
782    for col in 0..3 {
783        row[ERROR_ATTITUDE_INDEX + col] = los_unit[0] * lever_velocity_skew[0][col]
784            + los_unit[1] * lever_velocity_skew[1][col]
785            + los_unit[2] * lever_velocity_skew[2][col];
786        row[ERROR_GYRO_BIAS_INDEX + col] = los_unit[0] * gyro_bias_velocity_block[0][col]
787            + los_unit[1] * gyro_bias_velocity_block[1][col]
788            + los_unit[2] * gyro_bias_velocity_block[2][col];
789    }
790    row[clock_drift] = 1.0;
791    row
792}
793
794fn diagonal_covariance(variances: &[f64]) -> Result<Vec<Vec<f64>>, FusionError> {
795    if variances.is_empty() {
796        return Err(invalid_input("measurement_covariance", "must not be empty"));
797    }
798    let mut covariance = vec![vec![0.0; variances.len()]; variances.len()];
799    for (idx, variance) in variances.iter().enumerate() {
800        validate_positive(*variance, "measurement_variance")?;
801        covariance[idx][idx] = *variance;
802    }
803    Ok(covariance)
804}
805
806fn map_observables_error(error: ObservablesError) -> FusionError {
807    match error {
808        ObservablesError::NoEphemeris => invalid_input("ephemeris", "no usable satellite state"),
809        ObservablesError::InvalidInput { .. } => {
810            invalid_input("observable_state", "must be finite and in range")
811        }
812        ObservablesError::Ephemeris(_) => invalid_input("ephemeris", "satellite state failed"),
813    }
814}
815
816pub(super) const fn augmented_dimension(base_dim: usize) -> usize {
817    base_dim + TIGHT_CLOCK_STATE_COUNT
818}
819
820pub(super) const fn clock_bias_index(base_dim: usize) -> usize {
821    base_dim + TIGHT_CLOCK_BIAS_OFFSET
822}
823
824pub(super) const fn clock_drift_index(base_dim: usize) -> usize {
825    base_dim + TIGHT_CLOCK_DRIFT_OFFSET
826}
827
828#[cfg(test)]
829mod tests {
830    //! Provenance: tight-coupled GNSS/INS rows follow Groves, Principles of
831    //! GNSS, Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.,
832    //! Chapter 14.2. Pseudorange-only convergence is checked against the
833    //! in-crate SPP solver as an independent snapshot oracle. Doppler rows are
834    //! checked against the existing `predict_range_rate_m_s` primitive. The
835    //! weak-geometry properties check the information-form identity
836    //! `P_plus^-1 = P_minus^-1 + H' R^-1 H`.
837
838    use super::*;
839    use crate::astro::constants::earth::WGS84_A_M;
840    use crate::fusion::state::{
841        covariance_is_positive_semidefinite, ErrorStateLayout, ERROR_STATE_DIMENSION_15,
842    };
843    use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
844    use crate::inertial::state::mat3_identity;
845    use crate::inertial::{ImuSample, ImuSpec, NavState};
846    use crate::observables::{ObservableState, ObservablesError};
847    use crate::spp::{
848        Corrections, KlobucharCoeffs, Observation, SolveInputs, SppError, SurfaceMet,
849    };
850    use crate::{GnssSatelliteId, GnssSystem};
851    use nalgebra::DMatrix;
852
853    const T0: f64 = 646_229_000.0;
854    const SOD: f64 = 200.0;
855    const DOY: f64 = 176.0;
856
857    #[derive(Debug, Clone)]
858    struct LinearSource {
859        t0_j2000_s: f64,
860        states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>,
861    }
862
863    impl LinearSource {
864        fn new(t0_j2000_s: f64, states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>) -> Self {
865            Self { t0_j2000_s, states }
866        }
867    }
868
869    impl ObservableEphemerisSource for LinearSource {
870        fn observable_state_at_j2000_s(
871            &self,
872            sat: GnssSatelliteId,
873            t_j2000_s: f64,
874        ) -> Result<ObservableState, ObservablesError> {
875            let (_, position, velocity, clock_s) = self
876                .states
877                .iter()
878                .find(|(id, _, _, _)| *id == sat)
879                .ok_or(ObservablesError::NoEphemeris)?;
880            let dt_s = t_j2000_s - self.t0_j2000_s;
881            Ok(ObservableState {
882                position_ecef_m: [
883                    position[0] + velocity[0] * dt_s,
884                    position[1] + velocity[1] * dt_s,
885                    position[2] + velocity[2] * dt_s,
886                ],
887                clock_s: Some(*clock_s),
888            })
889        }
890    }
891
892    impl crate::spp::EphemerisSource for LinearSource {
893        fn position_clock_at_j2000_s(
894            &self,
895            sat: GnssSatelliteId,
896            t_j2000_s: f64,
897        ) -> Option<([f64; 3], f64)> {
898            let (_, position, velocity, clock_s) =
899                self.states.iter().find(|(id, _, _, _)| *id == sat)?;
900            let dt_s = t_j2000_s - self.t0_j2000_s;
901            Some((
902                [
903                    position[0] + velocity[0] * dt_s,
904                    position[1] + velocity[1] * dt_s,
905                    position[2] + velocity[2] * dt_s,
906                ],
907                *clock_s,
908            ))
909        }
910    }
911
912    fn sat(prn: u8) -> GnssSatelliteId {
913        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
914    }
915
916    fn normalized(v: [f64; 3]) -> [f64; 3] {
917        let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
918        [v[0] / n, v[1] / n, v[2] / n]
919    }
920
921    fn source_from_directions(receiver: [f64; 3], directions: &[[f64; 3]]) -> LinearSource {
922        let range_m = 22_000_000.0;
923        let states = directions
924            .iter()
925            .enumerate()
926            .map(|(idx, direction)| {
927                let unit = normalized(*direction);
928                (
929                    sat((idx + 1) as u8),
930                    [
931                        receiver[0] + range_m * unit[0],
932                        receiver[1] + range_m * unit[1],
933                        receiver[2] + range_m * unit[2],
934                    ],
935                    [0.0; 3],
936                    0.0,
937                )
938            })
939            .collect();
940        LinearSource::new(T0, states)
941    }
942
943    fn tight_epoch_from_source(
944        source: &LinearSource,
945        receiver: [f64; 3],
946        clock_m: f64,
947        sigma_m: f64,
948    ) -> TightGnssEpoch {
949        let observations = source
950            .states
951            .iter()
952            .map(|(satellite_id, _, _, _)| {
953                let prediction = transmit_time_satellite_state(
954                    source,
955                    *satellite_id,
956                    receiver,
957                    T0,
958                    TransmitTimeOptions::default(),
959                )
960                .expect("satellite state");
961                TightGnssObservation::pseudorange(
962                    *satellite_id,
963                    prediction.geometric_range_m + clock_m,
964                    sigma_m,
965                )
966                .expect("observation")
967            })
968            .collect();
969        TightGnssEpoch::new(T0, observations).expect("tight epoch")
970    }
971
972    fn solve_inputs_from_epoch(epoch: &TightGnssEpoch, initial_guess: [f64; 4]) -> SolveInputs {
973        SolveInputs {
974            observations: epoch
975                .observations
976                .iter()
977                .map(|observation| Observation {
978                    satellite_id: observation.satellite_id,
979                    pseudorange_m: observation.pseudorange_m,
980                })
981                .collect(),
982            t_rx_j2000_s: epoch.t_j2000_s,
983            t_rx_second_of_day_s: SOD,
984            day_of_year: DOY,
985            initial_guess,
986            corrections: Corrections::NONE,
987            klobuchar: KlobucharCoeffs {
988                alpha: [0.0; 4],
989                beta: [0.0; 4],
990            },
991            beidou_klobuchar: None,
992            galileo_nequick: None,
993            sbas_iono: None,
994            glonass_channels: std::collections::BTreeMap::new(),
995            met: SurfaceMet::default(),
996            robust: None,
997        }
998    }
999
1000    fn zero_noise_spec() -> ImuSpec {
1001        ImuSpec::datasheet(
1002            0.0,
1003            0.0,
1004            0.0,
1005            0.0,
1006            RANDOM_WALK_BIAS_TAU_S,
1007            RANDOM_WALK_BIAS_TAU_S,
1008            None,
1009            None,
1010        )
1011    }
1012
1013    fn filter_with_config(
1014        nominal: NavState,
1015        diagonal: &[f64],
1016        tight: TightCouplingConfig,
1017    ) -> InertialFilter {
1018        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, diagonal)
1019            .expect("state");
1020        let mut config =
1021            super::super::loose::InertialFilterConfig::new(zero_noise_spec()).expect("config");
1022        config.tight = tight;
1023        InertialFilter::with_config(state, config).expect("filter")
1024    }
1025
1026    fn tight_config_for_test() -> TightCouplingConfig {
1027        TightCouplingConfig {
1028            initial_clock_bias_variance_m2: 1.0e12,
1029            initial_clock_drift_variance_m2_s2: 1.0e6,
1030            clock_bias_random_walk_m2_s: 0.0,
1031            clock_drift_random_walk_m2_s3: 0.0,
1032            ..TightCouplingConfig::default()
1033        }
1034    }
1035
1036    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1037        assert!(
1038            (actual - expected).abs() <= tolerance,
1039            "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
1040        );
1041    }
1042
1043    fn logdet_spd(matrix: &[Vec<f64>]) -> f64 {
1044        let n = matrix.len();
1045        let flat = matrix.iter().flatten().copied().collect::<Vec<_>>();
1046        let dmatrix = DMatrix::from_row_slice(n, n, &flat);
1047        let cholesky = dmatrix.cholesky().expect("SPD matrix");
1048        2.0 * cholesky
1049            .l()
1050            .diagonal()
1051            .iter()
1052            .map(|value| value.ln())
1053            .sum::<f64>()
1054    }
1055
1056    fn position_clock_block(filter: &InertialFilter) -> Vec<Vec<f64>> {
1057        let base_dim = filter.state.dimension();
1058        let clock = clock_bias_index(base_dim);
1059        let indices = [0usize, 1, 2, clock];
1060        indices
1061            .iter()
1062            .map(|row| {
1063                indices
1064                    .iter()
1065                    .map(|col| filter.tight.augmented_covariance[*row][*col])
1066                    .collect::<Vec<_>>()
1067            })
1068            .collect()
1069    }
1070
1071    fn snapshot_position_clock_covariance(
1072        source: &LinearSource,
1073        receiver: [f64; 3],
1074        epoch: &TightGnssEpoch,
1075    ) -> Vec<Vec<f64>> {
1076        let mut normal = DMatrix::<f64>::zeros(4, 4);
1077        for observation in &epoch.observations {
1078            let prediction = transmit_time_satellite_state(
1079                source,
1080                observation.satellite_id,
1081                receiver,
1082                epoch.t_j2000_s,
1083                TransmitTimeOptions::default(),
1084            )
1085            .expect("satellite state");
1086            let h = [
1087                -prediction.los_unit[0],
1088                -prediction.los_unit[1],
1089                -prediction.los_unit[2],
1090                1.0,
1091            ];
1092            let inv_var = 1.0 / (observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
1093            for row in 0..4 {
1094                for col in 0..4 {
1095                    normal[(row, col)] += h[row] * h[col] * inv_var;
1096                }
1097            }
1098        }
1099        let covariance = normal.try_inverse().expect("full-rank snapshot");
1100        (0..4)
1101            .map(|row| (0..4).map(|col| covariance[(row, col)]).collect())
1102            .collect()
1103    }
1104
1105    #[test]
1106    fn pseudorange_only_update_matches_spp_clock_oracle_with_frozen_ins_prior() {
1107        let receiver = [WGS84_A_M, 0.0, 0.0];
1108        let directions = [
1109            [1.0, 0.0, 0.0],
1110            [0.82, 0.42, 0.39],
1111            [0.83, -0.46, 0.31],
1112            [0.90, 0.18, -0.40],
1113            [0.78, -0.25, -0.58],
1114        ];
1115        let clock_m = 12.5;
1116        let source = source_from_directions(receiver, &directions);
1117        let epoch = tight_epoch_from_source(&source, receiver, clock_m, 1.0);
1118        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1119        let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1120
1121        let spp_position = spp.position.as_array();
1122        let nominal = NavState::new(T0, spp_position, [0.0; 3], mat3_identity()).expect("nominal");
1123        let diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1124        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1125
1126        let update = filter.update_tight(&source, &epoch).expect("tight update");
1127
1128        assert!(update.applied);
1129        for (got, expected) in filter
1130            .state()
1131            .nominal
1132            .position_ecef_m
1133            .iter()
1134            .zip(spp_position)
1135        {
1136            assert_close(*got, expected, 1.0e-6);
1137        }
1138        let clock = filter.tight_clock_state().expect("clock");
1139        assert_close(clock.bias_m, spp.rx_clock_s * C_M_S, 1.0e-5);
1140    }
1141
1142    #[test]
1143    fn doppler_row_uses_range_rate_predictor_geometry_bits() {
1144        let receiver = [WGS84_A_M, 0.0, 0.0];
1145        let satellite_id = sat(1);
1146        let source = LinearSource::new(
1147            T0,
1148            vec![(
1149                satellite_id,
1150                [WGS84_A_M + 22_000_000.0, 1_000_000.0, 2_000_000.0],
1151                [120.0, -40.0, 30.0],
1152                0.0,
1153            )],
1154        );
1155        let sat_state = transmit_time_satellite_state(
1156            &source,
1157            satellite_id,
1158            receiver,
1159            T0,
1160            TransmitTimeOptions::default(),
1161        )
1162        .expect("satellite state");
1163        let measured_receiver = ReceiverVelocityState {
1164            position_m: receiver,
1165            velocity_m_s: [5.0, -2.0, 1.0],
1166            clock_drift_m_s: 0.25,
1167        };
1168        let velocity_observation = VelocityObservation {
1169            sat: satellite_id,
1170            satellite_position_m: sat_state.position_ecef_m,
1171            satellite_velocity_m_s: sat_state.velocity_m_s,
1172            measured_range_rate_m_s: 0.0,
1173            sigma_m_s: 0.05,
1174            satellite_clock_drift_m_s: 0.01,
1175        };
1176        let measured = predict_range_rate_m_s(&velocity_observation, measured_receiver)
1177            .expect("measured range rate")
1178            .range_rate_m_s;
1179        let observation = TightGnssObservation {
1180            satellite_id,
1181            pseudorange_m: sat_state.geometric_range_m,
1182            pseudorange_sigma_m: 2.0,
1183            range_rate: Some(TightRangeRateObservation {
1184                measured_range_rate_m_s: measured,
1185                sigma_m_s: 0.05,
1186                satellite_clock_drift_m_s: 0.01,
1187            }),
1188            carrier_phase: None,
1189            ionosphere_delay_m: 0.0,
1190            troposphere_delay_m: 0.0,
1191        };
1192        let epoch = TightGnssEpoch::new(T0, vec![observation]).expect("epoch");
1193        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1194        let filter = filter_with_config(
1195            nominal,
1196            &[1.0; ERROR_STATE_DIMENSION_15],
1197            tight_config_for_test(),
1198        );
1199        let correction = tight_coupling_correction(
1200            &source,
1201            filter.state(),
1202            &filter.tight,
1203            &epoch,
1204            filter.config.tight,
1205            [0.0; 3],
1206        )
1207        .expect("correction");
1208        let predicted_at_nominal = predict_range_rate_m_s(
1209            &VelocityObservation {
1210                measured_range_rate_m_s: measured,
1211                ..velocity_observation
1212            },
1213            ReceiverVelocityState {
1214                position_m: receiver,
1215                velocity_m_s: [0.0; 3],
1216                clock_drift_m_s: 0.0,
1217            },
1218        )
1219        .expect("nominal range rate");
1220
1221        let doppler_row = &correction.design[1];
1222        for axis in 0..3 {
1223            assert_eq!(
1224                doppler_row[ERROR_VELOCITY_INDEX + axis].to_bits(),
1225                (-predicted_at_nominal.los_unit[axis]).to_bits()
1226            );
1227        }
1228        assert_eq!(
1229            doppler_row[clock_drift_index(filter.state.dimension())].to_bits(),
1230            1.0_f64.to_bits()
1231        );
1232        assert_eq!(
1233            correction.innovation[1].to_bits(),
1234            (measured - predicted_at_nominal.range_rate_m_s).to_bits()
1235        );
1236    }
1237
1238    #[test]
1239    fn singular_snapshot_geometry_keeps_unobserved_prior_covariance() {
1240        let receiver = [WGS84_A_M, 0.0, 0.0];
1241        let directions = [[1.0, 0.0, 0.0]; 5];
1242        let source = source_from_directions(receiver, &directions);
1243        let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
1244        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1245        assert!(matches!(
1246            crate::spp::solve(&source, &inputs, false),
1247            Err(SppError::Singular(_))
1248        ));
1249
1250        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1251        let mut diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
1252        diagonal[ERROR_POSITION_INDEX] = 100.0;
1253        diagonal[ERROR_POSITION_INDEX + 1] = 225.0;
1254        diagonal[ERROR_POSITION_INDEX + 2] = 400.0;
1255        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1256        let prior_y = filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1];
1257        let prior_z = filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2];
1258
1259        let update = filter.update_tight(&source, &epoch).expect("tight update");
1260
1261        assert!(update.applied);
1262        assert!(covariance_is_positive_semidefinite(&filter.state.covariance).expect("PSD"));
1263        assert_eq!(
1264            filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1].to_bits(),
1265            prior_y.to_bits()
1266        );
1267        assert_eq!(
1268            filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2].to_bits(),
1269            prior_z.to_bits()
1270        );
1271        assert!(filter
1272            .state
1273            .nominal
1274            .position_ecef_m
1275            .iter()
1276            .all(|value| value.is_finite() && value.abs() < 1.0e8));
1277    }
1278
1279    #[test]
1280    fn high_dop_fused_covariance_has_lower_logdet_than_snapshot() {
1281        let receiver = [WGS84_A_M, 0.0, 0.0];
1282        let directions = [
1283            [0.44974122498328417, -0.8581153514788689, 0.2477314556265159],
1284            [0.20081904418348107, 0.5332143328087052, 0.8217993591994339],
1285            [0.43760604888398824, -0.4903647504582244, 0.7536865114145189],
1286            [
1287                0.2148508784686108,
1288                -0.9558725523345635,
1289                -0.20036657334663732,
1290            ],
1291            [0.30949187488876595, 0.3289789392404428, 0.8921813923827763],
1292        ];
1293        let source = source_from_directions(receiver, &directions);
1294        let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
1295        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1296        let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1297        assert_eq!(
1298            spp.geometry_quality.tier,
1299            crate::geometry_quality::ObservabilityTier::Weak
1300        );
1301        let snapshot_covariance = snapshot_position_clock_covariance(&source, receiver, &epoch);
1302        let snapshot_logdet = logdet_spd(&snapshot_covariance);
1303
1304        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1305        let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1306        for axis in 0..3 {
1307            diagonal[ERROR_POSITION_INDEX + axis] = 1.0e8;
1308        }
1309        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1310
1311        filter.update_tight(&source, &epoch).expect("tight update");
1312
1313        let fused_logdet = logdet_spd(&position_clock_block(&filter));
1314        assert!(
1315            fused_logdet < snapshot_logdet,
1316            "fused {fused_logdet:.17e}, snapshot {snapshot_logdet:.17e}"
1317        );
1318    }
1319
1320    #[test]
1321    fn outage_growth_and_single_satellite_observed_direction_update() {
1322        let receiver = [WGS84_A_M, 0.0, 0.0];
1323        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1324        let diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1325        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
1326            .expect("state");
1327        let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
1328        let mut config = super::super::loose::InertialFilterConfig::new(spec).expect("config");
1329        config.tight = TightCouplingConfig {
1330            light_time: false,
1331            sagnac: false,
1332            initial_clock_bias_variance_m2: 100.0,
1333            initial_clock_drift_variance_m2_s2: 1.0,
1334            clock_bias_random_walk_m2_s: 4.0,
1335            clock_drift_random_walk_m2_s3: 0.25,
1336            ..TightCouplingConfig::default()
1337        };
1338        let mut filter = InertialFilter::with_config(state, config).expect("filter");
1339        let mut previous_logdet = logdet_spd(&filter.tight.augmented_covariance);
1340
1341        for step in 1..=3 {
1342            filter
1343                .propagate(ImuSample::increment(
1344                    T0 + step as f64,
1345                    [0.0; 3],
1346                    [0.0; 3],
1347                    1.0,
1348                ))
1349                .expect("propagate");
1350            let next_logdet = logdet_spd(&filter.tight.augmented_covariance);
1351            assert!(
1352                next_logdet > previous_logdet,
1353                "step {step} logdet {next_logdet:.17e} <= {previous_logdet:.17e}"
1354            );
1355            previous_logdet = next_logdet;
1356        }
1357
1358        let current_position = filter.state.nominal.position_ecef_m;
1359        let satellite_id = sat(1);
1360        let source = LinearSource::new(
1361            filter.state.nominal.t_j2000_s,
1362            vec![(
1363                satellite_id,
1364                [
1365                    current_position[0] + 22_000_000.0,
1366                    current_position[1],
1367                    current_position[2],
1368                ],
1369                [0.0; 3],
1370                0.0,
1371            )],
1372        );
1373        let prediction = transmit_time_satellite_state(
1374            &source,
1375            satellite_id,
1376            current_position,
1377            filter.state.nominal.t_j2000_s,
1378            TransmitTimeOptions {
1379                light_time: false,
1380                sagnac: false,
1381            },
1382        )
1383        .expect("satellite state");
1384        let clock = filter.tight_clock_state().expect("clock");
1385        let epoch = TightGnssEpoch::new(
1386            filter.state.nominal.t_j2000_s,
1387            vec![TightGnssObservation::pseudorange(
1388                satellite_id,
1389                prediction.geometric_range_m + clock.bias_m,
1390                0.5,
1391            )
1392            .expect("observation")],
1393        )
1394        .expect("epoch");
1395        let pre = filter.state.covariance.clone();
1396
1397        filter
1398            .update_tight(&source, &epoch)
1399            .expect("single-sat update");
1400
1401        assert!(
1402            filter.state.covariance[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
1403                < pre[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
1404        );
1405        for axis in [1usize, 2] {
1406            assert_eq!(
1407                filter.state.covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis]
1408                    .to_bits(),
1409                pre[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis].to_bits()
1410            );
1411        }
1412    }
1413}