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.clock_bias_m += self.clock_drift_m_s * dt_s;
450        self.augmented_covariance = next;
451        self.validate(base_dim)
452    }
453
454    pub(super) fn copy_base_covariance_to_state(
455        &self,
456        state: &mut InsFilterState,
457    ) -> Result<(), FusionError> {
458        let base_dim = state.dimension();
459        self.validate(base_dim)?;
460        for row in 0..base_dim {
461            for col in 0..base_dim {
462                state.covariance[row][col] = self.augmented_covariance[row][col];
463            }
464        }
465        state.validate()
466    }
467}
468
469impl InertialFilter {
470    /// Borrow the current receiver-clock state carried by tight coupling.
471    pub fn tight_clock_state(&self) -> Result<TightClockState, FusionError> {
472        self.tight.clock_state(self.state.dimension())
473    }
474
475    /// Apply a tight raw GNSS update at the current propagated epoch.
476    ///
477    /// GNSS epochs must be strictly increasing across the filter's stateful
478    /// update surface. One satellite is a valid update.
479    pub fn update_tight(
480        &mut self,
481        source: &dyn ObservableEphemerisSource,
482        epoch: &TightGnssEpoch,
483    ) -> Result<FusionUpdate, FusionError> {
484        if let Some(last) = self.time_sync.last_measurement_t_j2000_s() {
485            if epoch.t_j2000_s <= last {
486                return Err(invalid_input(
487                    "t_j2000_s",
488                    "GNSS measurement epochs must be strictly increasing",
489                ));
490            }
491        }
492        let update = self.update_tight_core(source, epoch)?;
493        let snapshot = self.snapshot();
494        self.time_sync
495            .push_tight_measurement_and_checkpoint(epoch.clone(), snapshot);
496        Ok(update)
497    }
498
499    pub(super) fn update_tight_core(
500        &mut self,
501        source: &dyn ObservableEphemerisSource,
502        epoch: &TightGnssEpoch,
503    ) -> Result<FusionUpdate, FusionError> {
504        self.tight.align_with_filter_state(&self.state)?;
505        let correction = tight_coupling_correction(
506            source,
507            &self.state,
508            &self.tight,
509            epoch,
510            self.config.tight,
511            self.last_body_rate_wrt_ecef_rps,
512        )?;
513        let rows = correction.row_count();
514        let report = apply_tight_correction(self, &correction, self.config.tight.update_options)?;
515        Ok(FusionUpdate {
516            applied: report.applied,
517            nis: report.normalized_innovation_squared,
518            rows,
519            accepted_rows: report.accepted_rows,
520            rejected_rows: report.rejected_rows,
521            ekf: report,
522        })
523    }
524}
525
526pub(super) fn tight_coupling_correction(
527    source: &dyn ObservableEphemerisSource,
528    state: &InsFilterState,
529    tight_state: &TightFusionState,
530    epoch: &TightGnssEpoch,
531    config: TightCouplingConfig,
532    body_rate_wrt_ecef_rps: [f64; 3],
533) -> Result<EkfCorrection, FusionError> {
534    state.validate()?;
535    tight_state.validate(state.dimension())?;
536    epoch.validate()?;
537    config.validate()?;
538    validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
539    if epoch.t_j2000_s != state.nominal.t_j2000_s {
540        return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
541    }
542
543    let base_dim = state.dimension();
544    let aug_dim = augmented_dimension(base_dim);
545    let clock_bias = clock_bias_index(base_dim);
546    let clock_drift = clock_drift_index(base_dim);
547    let kinematics = antenna_kinematics(state, config.lever_arm_body_m, body_rate_wrt_ecef_rps);
548    let options = TransmitTimeOptions {
549        light_time: config.light_time,
550        sagnac: config.sagnac,
551    };
552
553    let mut innovation = Vec::new();
554    let mut design = Vec::new();
555    let mut variances = Vec::new();
556
557    for observation in &epoch.observations {
558        let satellite = transmit_time_satellite_state(
559            source,
560            observation.satellite_id,
561            kinematics.antenna_position_ecef_m,
562            epoch.t_j2000_s,
563            options,
564        )
565        .map_err(map_observables_error)?;
566        let sat_clock_s = satellite
567            .clock_s
568            .ok_or_else(|| invalid_input("satellite_clock_s", "must be present"))?;
569
570        let code_prediction_m = satellite.geometric_range_m + tight_state.clock_bias_m
571            - C_M_S * sat_clock_s
572            + observation.ionosphere_delay_m
573            + observation.troposphere_delay_m;
574        let mut row = pseudorange_design_row(
575            aug_dim,
576            clock_bias,
577            satellite.los_unit,
578            kinematics.lever_arm_ecef_m,
579        );
580        innovation.push(observation.pseudorange_m - code_prediction_m);
581        design.push(row);
582        variances.push(observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
583
584        if let Some(carrier_phase) = observation.carrier_phase {
585            let phase_prediction_m = satellite.geometric_range_m + tight_state.clock_bias_m
586                - C_M_S * sat_clock_s
587                - observation.ionosphere_delay_m
588                + observation.troposphere_delay_m
589                + carrier_phase.float_ambiguity_m;
590            row = pseudorange_design_row(
591                aug_dim,
592                clock_bias,
593                satellite.los_unit,
594                kinematics.lever_arm_ecef_m,
595            );
596            innovation.push(carrier_phase.phase_range_m - phase_prediction_m);
597            design.push(row);
598            variances.push(carrier_phase.sigma_m * carrier_phase.sigma_m);
599        }
600
601        if let Some(range_rate) = observation.range_rate {
602            let velocity_observation = VelocityObservation {
603                sat: observation.satellite_id,
604                satellite_position_m: satellite.position_ecef_m,
605                satellite_velocity_m_s: satellite.velocity_m_s,
606                measured_range_rate_m_s: range_rate.measured_range_rate_m_s,
607                sigma_m_s: range_rate.sigma_m_s,
608                satellite_clock_drift_m_s: range_rate.satellite_clock_drift_m_s,
609            };
610            let receiver = ReceiverVelocityState {
611                position_m: kinematics.antenna_position_ecef_m,
612                velocity_m_s: kinematics.antenna_velocity_ecef_mps,
613                clock_drift_m_s: tight_state.clock_drift_m_s,
614            };
615            let prediction = predict_range_rate_m_s(&velocity_observation, receiver)
616                .ok_or_else(|| invalid_input("range_rate", "line of sight must be nonzero"))?;
617            let row = range_rate_design_row(
618                aug_dim,
619                clock_drift,
620                prediction.los_unit,
621                kinematics.lever_velocity_ecef_mps,
622                kinematics.gyro_bias_velocity_block,
623            );
624            innovation.push(range_rate.measured_range_rate_m_s - prediction.range_rate_m_s);
625            design.push(row);
626            variances.push(range_rate.sigma_m_s * range_rate.sigma_m_s);
627        }
628    }
629
630    validate_finite_slice(&innovation, "tight_innovation")?;
631    let measurement_covariance = diagonal_covariance(&variances)?;
632    EkfCorrection::new(innovation, design, measurement_covariance)
633}
634
635fn apply_tight_correction(
636    filter: &mut InertialFilter,
637    correction: &EkfCorrection,
638    options: EkfUpdateOptions,
639) -> Result<EkfCorrectionReport, FusionError> {
640    filter.state.validate()?;
641    let base_dim = filter.state.dimension();
642    filter.tight.validate(base_dim)?;
643    correction.validate_for_dimension(augmented_dimension(base_dim))?;
644
645    if let Some(gate) = options.innovation_gate {
646        gate.validate()?;
647        let full_s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
648        let (screened, gate_report) = screen_correction(correction, &full_s, gate)?;
649        let full_nis = normalized_innovation_squared(&full_s, &correction.innovation)?;
650        if gate_report.coasted {
651            return Ok(EkfCorrectionReport {
652                applied: false,
653                normalized_innovation_squared: full_nis,
654                accepted_rows: gate_report.accepted_rows,
655                rejected_rows: gate_report.rejected_rows,
656                innovation_gate: Some(gate_report),
657                innovation_covariance: full_s,
658                kalman_gain: vec![vec![0.0; correction.row_count()]; augmented_dimension(base_dim)],
659                dx: vec![0.0; augmented_dimension(base_dim)],
660            });
661        }
662        let accepted_rows = gate_report.accepted_rows;
663        let rejected_rows = gate_report.rejected_rows;
664        let mut report = apply_tight_correction_inner(filter, &screened)?;
665        report.accepted_rows = accepted_rows;
666        report.rejected_rows = rejected_rows;
667        report.innovation_gate = Some(gate_report);
668        return Ok(report);
669    }
670
671    apply_tight_correction_inner(filter, correction)
672}
673
674fn apply_tight_correction_inner(
675    filter: &mut InertialFilter,
676    correction: &EkfCorrection,
677) -> Result<EkfCorrectionReport, FusionError> {
678    let base_dim = filter.state.dimension();
679    let aug_dim = augmented_dimension(base_dim);
680    let s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
681    let h_t = super::state::transpose(&correction.design)?;
682    let p_h_t = matmul(&filter.tight.augmented_covariance, &h_t)?;
683    let mut kalman_gain = vec![vec![0.0; correction.row_count()]; aug_dim];
684    let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
685    for row in 0..aug_dim {
686        kalman_gain[row] = super::state::solve_spd(&s, &p_h_t[row], &mut scratch)?;
687    }
688    let dx = super::state::matvec(&kalman_gain, &correction.innovation)?;
689    let nis = normalized_innovation_squared(&s, &correction.innovation)?;
690    let covariance = joseph_covariance_update(
691        &filter.tight.augmented_covariance,
692        &correction.design,
693        &kalman_gain,
694        &correction.measurement_covariance,
695    )?;
696
697    apply_closed_loop_navigation_error(&mut filter.state.nominal, &dx[..base_dim])?;
698    apply_closed_loop_scale_error(&mut filter.state, &dx[..base_dim]);
699    filter.tight.clock_bias_m += dx[clock_bias_index(base_dim)];
700    filter.tight.clock_drift_m_s += dx[clock_drift_index(base_dim)];
701    filter.tight.augmented_covariance = covariance;
702    filter
703        .tight
704        .copy_base_covariance_to_state(&mut filter.state)?;
705    filter.state.reset_error_state();
706    filter.state.validate()?;
707    filter.tight.validate(base_dim)?;
708
709    Ok(EkfCorrectionReport {
710        applied: true,
711        normalized_innovation_squared: nis,
712        accepted_rows: correction.row_count(),
713        rejected_rows: 0,
714        innovation_gate: None,
715        innovation_covariance: s,
716        kalman_gain,
717        dx,
718    })
719}
720
721#[derive(Debug, Clone, Copy)]
722struct AntennaKinematics {
723    antenna_position_ecef_m: [f64; 3],
724    antenna_velocity_ecef_mps: [f64; 3],
725    lever_arm_ecef_m: [f64; 3],
726    lever_velocity_ecef_mps: [f64; 3],
727    gyro_bias_velocity_block: [[f64; 3]; 3],
728}
729
730fn antenna_kinematics(
731    state: &InsFilterState,
732    lever_arm_body_m: [f64; 3],
733    body_rate_wrt_ecef_rps: [f64; 3],
734) -> AntennaKinematics {
735    let c_b_e = state.nominal.attitude_body_to_ecef;
736    let lever_arm_ecef_m = mul_vec3(&c_b_e, lever_arm_body_m);
737    let antenna_position_ecef_m = add3(state.nominal.position_ecef_m, lever_arm_ecef_m);
738    let lever_velocity_body_mps = cross3(body_rate_wrt_ecef_rps, lever_arm_body_m);
739    let lever_velocity_ecef_mps = mul_vec3(&c_b_e, lever_velocity_body_mps);
740    let antenna_velocity_ecef_mps = add3(state.nominal.velocity_ecef_mps, lever_velocity_ecef_mps);
741    let gyro_bias_velocity_block = inline_rxr(&c_b_e, &skew(lever_arm_body_m));
742    AntennaKinematics {
743        antenna_position_ecef_m,
744        antenna_velocity_ecef_mps,
745        lever_arm_ecef_m,
746        lever_velocity_ecef_mps,
747        gyro_bias_velocity_block,
748    }
749}
750
751fn pseudorange_design_row(
752    aug_dim: usize,
753    clock_bias: usize,
754    los_unit: [f64; 3],
755    lever_arm_ecef_m: [f64; 3],
756) -> Vec<f64> {
757    let mut row = vec![0.0; aug_dim];
758    for axis in 0..3 {
759        row[ERROR_POSITION_INDEX + axis] = -los_unit[axis];
760    }
761    let lever_skew = skew(lever_arm_ecef_m);
762    for col in 0..3 {
763        row[ERROR_ATTITUDE_INDEX + col] = los_unit[0] * lever_skew[0][col]
764            + los_unit[1] * lever_skew[1][col]
765            + los_unit[2] * lever_skew[2][col];
766    }
767    row[clock_bias] = 1.0;
768    row
769}
770
771fn range_rate_design_row(
772    aug_dim: usize,
773    clock_drift: usize,
774    los_unit: [f64; 3],
775    lever_velocity_ecef_mps: [f64; 3],
776    gyro_bias_velocity_block: [[f64; 3]; 3],
777) -> Vec<f64> {
778    let mut row = vec![0.0; aug_dim];
779    for axis in 0..3 {
780        row[ERROR_VELOCITY_INDEX + axis] = -los_unit[axis];
781    }
782    let lever_velocity_skew = skew(lever_velocity_ecef_mps);
783    for col in 0..3 {
784        row[ERROR_ATTITUDE_INDEX + col] = los_unit[0] * lever_velocity_skew[0][col]
785            + los_unit[1] * lever_velocity_skew[1][col]
786            + los_unit[2] * lever_velocity_skew[2][col];
787        row[ERROR_GYRO_BIAS_INDEX + col] = los_unit[0] * gyro_bias_velocity_block[0][col]
788            + los_unit[1] * gyro_bias_velocity_block[1][col]
789            + los_unit[2] * gyro_bias_velocity_block[2][col];
790    }
791    row[clock_drift] = 1.0;
792    row
793}
794
795fn diagonal_covariance(variances: &[f64]) -> Result<Vec<Vec<f64>>, FusionError> {
796    if variances.is_empty() {
797        return Err(invalid_input("measurement_covariance", "must not be empty"));
798    }
799    let mut covariance = vec![vec![0.0; variances.len()]; variances.len()];
800    for (idx, variance) in variances.iter().enumerate() {
801        validate_positive(*variance, "measurement_variance")?;
802        covariance[idx][idx] = *variance;
803    }
804    Ok(covariance)
805}
806
807fn map_observables_error(error: ObservablesError) -> FusionError {
808    match error {
809        ObservablesError::NoEphemeris => invalid_input("ephemeris", "no usable satellite state"),
810        ObservablesError::InvalidInput { .. } => {
811            invalid_input("observable_state", "must be finite and in range")
812        }
813        ObservablesError::Ephemeris(_) => invalid_input("ephemeris", "satellite state failed"),
814    }
815}
816
817pub(super) const fn augmented_dimension(base_dim: usize) -> usize {
818    base_dim + TIGHT_CLOCK_STATE_COUNT
819}
820
821pub(super) const fn clock_bias_index(base_dim: usize) -> usize {
822    base_dim + TIGHT_CLOCK_BIAS_OFFSET
823}
824
825pub(super) const fn clock_drift_index(base_dim: usize) -> usize {
826    base_dim + TIGHT_CLOCK_DRIFT_OFFSET
827}
828
829#[cfg(test)]
830mod tests {
831    //! Provenance: tight-coupled GNSS/INS rows follow Groves, Principles of
832    //! GNSS, Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.,
833    //! Chapter 14.2. Pseudorange-only convergence is checked against the
834    //! in-crate SPP solver as an independent snapshot oracle. Doppler rows are
835    //! checked against the existing `predict_range_rate_m_s` primitive. The
836    //! weak-geometry properties check the information-form identity
837    //! `P_plus^-1 = P_minus^-1 + H' R^-1 H`.
838
839    use super::*;
840    use crate::astro::constants::earth::WGS84_A_M;
841    use crate::fusion::state::{
842        covariance_is_positive_semidefinite, ErrorStateLayout, ERROR_STATE_DIMENSION_15,
843    };
844    use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
845    use crate::inertial::state::mat3_identity;
846    use crate::inertial::{ImuSample, ImuSpec, NavState};
847    use crate::observables::{ObservableState, ObservablesError};
848    use crate::spp::{
849        Corrections, KlobucharCoeffs, Observation, SolveInputs, SppError, SurfaceMet,
850    };
851    use crate::{GnssSatelliteId, GnssSystem};
852    use nalgebra::DMatrix;
853
854    const T0: f64 = 646_229_000.0;
855    const SOD: f64 = 200.0;
856    const DOY: f64 = 176.0;
857
858    #[derive(Debug, Clone)]
859    struct LinearSource {
860        t0_j2000_s: f64,
861        states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>,
862    }
863
864    impl LinearSource {
865        fn new(t0_j2000_s: f64, states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>) -> Self {
866            Self { t0_j2000_s, states }
867        }
868    }
869
870    impl ObservableEphemerisSource for LinearSource {
871        fn observable_state_at_j2000_s(
872            &self,
873            sat: GnssSatelliteId,
874            t_j2000_s: f64,
875        ) -> Result<ObservableState, ObservablesError> {
876            let (_, position, velocity, clock_s) = self
877                .states
878                .iter()
879                .find(|(id, _, _, _)| *id == sat)
880                .ok_or(ObservablesError::NoEphemeris)?;
881            let dt_s = t_j2000_s - self.t0_j2000_s;
882            Ok(ObservableState {
883                position_ecef_m: [
884                    position[0] + velocity[0] * dt_s,
885                    position[1] + velocity[1] * dt_s,
886                    position[2] + velocity[2] * dt_s,
887                ],
888                clock_s: Some(*clock_s),
889            })
890        }
891    }
892
893    impl crate::spp::EphemerisSource for LinearSource {
894        fn position_clock_at_j2000_s(
895            &self,
896            sat: GnssSatelliteId,
897            t_j2000_s: f64,
898        ) -> Option<([f64; 3], f64)> {
899            let (_, position, velocity, clock_s) =
900                self.states.iter().find(|(id, _, _, _)| *id == sat)?;
901            let dt_s = t_j2000_s - self.t0_j2000_s;
902            Some((
903                [
904                    position[0] + velocity[0] * dt_s,
905                    position[1] + velocity[1] * dt_s,
906                    position[2] + velocity[2] * dt_s,
907                ],
908                *clock_s,
909            ))
910        }
911    }
912
913    fn sat(prn: u8) -> GnssSatelliteId {
914        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
915    }
916
917    fn normalized(v: [f64; 3]) -> [f64; 3] {
918        let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
919        [v[0] / n, v[1] / n, v[2] / n]
920    }
921
922    fn source_from_directions(receiver: [f64; 3], directions: &[[f64; 3]]) -> LinearSource {
923        let range_m = 22_000_000.0;
924        let states = directions
925            .iter()
926            .enumerate()
927            .map(|(idx, direction)| {
928                let unit = normalized(*direction);
929                (
930                    sat((idx + 1) as u8),
931                    [
932                        receiver[0] + range_m * unit[0],
933                        receiver[1] + range_m * unit[1],
934                        receiver[2] + range_m * unit[2],
935                    ],
936                    [0.0; 3],
937                    0.0,
938                )
939            })
940            .collect();
941        LinearSource::new(T0, states)
942    }
943
944    fn tight_epoch_from_source(
945        source: &LinearSource,
946        receiver: [f64; 3],
947        clock_m: f64,
948        sigma_m: f64,
949    ) -> TightGnssEpoch {
950        let observations = source
951            .states
952            .iter()
953            .map(|(satellite_id, _, _, _)| {
954                let prediction = transmit_time_satellite_state(
955                    source,
956                    *satellite_id,
957                    receiver,
958                    T0,
959                    TransmitTimeOptions::default(),
960                )
961                .expect("satellite state");
962                TightGnssObservation::pseudorange(
963                    *satellite_id,
964                    prediction.geometric_range_m + clock_m,
965                    sigma_m,
966                )
967                .expect("observation")
968            })
969            .collect();
970        TightGnssEpoch::new(T0, observations).expect("tight epoch")
971    }
972
973    fn solve_inputs_from_epoch(epoch: &TightGnssEpoch, initial_guess: [f64; 4]) -> SolveInputs {
974        SolveInputs {
975            observations: epoch
976                .observations
977                .iter()
978                .map(|observation| Observation {
979                    satellite_id: observation.satellite_id,
980                    pseudorange_m: observation.pseudorange_m,
981                })
982                .collect(),
983            t_rx_j2000_s: epoch.t_j2000_s,
984            t_rx_second_of_day_s: SOD,
985            day_of_year: DOY,
986            initial_guess,
987            corrections: Corrections::NONE,
988            klobuchar: KlobucharCoeffs {
989                alpha: [0.0; 4],
990                beta: [0.0; 4],
991            },
992            beidou_klobuchar: None,
993            galileo_nequick: None,
994            sbas_iono: None,
995            glonass_channels: std::collections::BTreeMap::new(),
996            met: SurfaceMet::default(),
997            robust: None,
998        }
999    }
1000
1001    fn zero_noise_spec() -> ImuSpec {
1002        ImuSpec::datasheet(
1003            0.0,
1004            0.0,
1005            0.0,
1006            0.0,
1007            RANDOM_WALK_BIAS_TAU_S,
1008            RANDOM_WALK_BIAS_TAU_S,
1009            None,
1010            None,
1011        )
1012    }
1013
1014    fn filter_with_config(
1015        nominal: NavState,
1016        diagonal: &[f64],
1017        tight: TightCouplingConfig,
1018    ) -> InertialFilter {
1019        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, diagonal)
1020            .expect("state");
1021        let mut config =
1022            super::super::loose::InertialFilterConfig::new(zero_noise_spec()).expect("config");
1023        config.tight = tight;
1024        InertialFilter::with_config(state, config).expect("filter")
1025    }
1026
1027    fn tight_config_for_test() -> TightCouplingConfig {
1028        TightCouplingConfig {
1029            initial_clock_bias_variance_m2: 1.0e12,
1030            initial_clock_drift_variance_m2_s2: 1.0e6,
1031            clock_bias_random_walk_m2_s: 0.0,
1032            clock_drift_random_walk_m2_s3: 0.0,
1033            ..TightCouplingConfig::default()
1034        }
1035    }
1036
1037    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1038        assert!(
1039            (actual - expected).abs() <= tolerance,
1040            "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
1041        );
1042    }
1043
1044    fn logdet_spd(matrix: &[Vec<f64>]) -> f64 {
1045        let n = matrix.len();
1046        let flat = matrix.iter().flatten().copied().collect::<Vec<_>>();
1047        let dmatrix = DMatrix::from_row_slice(n, n, &flat);
1048        let cholesky = dmatrix.cholesky().expect("SPD matrix");
1049        2.0 * cholesky
1050            .l()
1051            .diagonal()
1052            .iter()
1053            .map(|value| value.ln())
1054            .sum::<f64>()
1055    }
1056
1057    fn position_clock_block(filter: &InertialFilter) -> Vec<Vec<f64>> {
1058        let base_dim = filter.state.dimension();
1059        let clock = clock_bias_index(base_dim);
1060        let indices = [0usize, 1, 2, clock];
1061        indices
1062            .iter()
1063            .map(|row| {
1064                indices
1065                    .iter()
1066                    .map(|col| filter.tight.augmented_covariance[*row][*col])
1067                    .collect::<Vec<_>>()
1068            })
1069            .collect()
1070    }
1071
1072    fn snapshot_position_clock_covariance(
1073        source: &LinearSource,
1074        receiver: [f64; 3],
1075        epoch: &TightGnssEpoch,
1076    ) -> Vec<Vec<f64>> {
1077        let mut normal = DMatrix::<f64>::zeros(4, 4);
1078        for observation in &epoch.observations {
1079            let prediction = transmit_time_satellite_state(
1080                source,
1081                observation.satellite_id,
1082                receiver,
1083                epoch.t_j2000_s,
1084                TransmitTimeOptions::default(),
1085            )
1086            .expect("satellite state");
1087            let h = [
1088                -prediction.los_unit[0],
1089                -prediction.los_unit[1],
1090                -prediction.los_unit[2],
1091                1.0,
1092            ];
1093            let inv_var = 1.0 / (observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
1094            for row in 0..4 {
1095                for col in 0..4 {
1096                    normal[(row, col)] += h[row] * h[col] * inv_var;
1097                }
1098            }
1099        }
1100        let covariance = normal.try_inverse().expect("full-rank snapshot");
1101        (0..4)
1102            .map(|row| (0..4).map(|col| covariance[(row, col)]).collect())
1103            .collect()
1104    }
1105
1106    #[test]
1107    fn pseudorange_only_update_matches_spp_clock_oracle_with_frozen_ins_prior() {
1108        let receiver = [WGS84_A_M, 0.0, 0.0];
1109        let directions = [
1110            [1.0, 0.0, 0.0],
1111            [0.82, 0.42, 0.39],
1112            [0.83, -0.46, 0.31],
1113            [0.90, 0.18, -0.40],
1114            [0.78, -0.25, -0.58],
1115        ];
1116        let clock_m = 12.5;
1117        let source = source_from_directions(receiver, &directions);
1118        let epoch = tight_epoch_from_source(&source, receiver, clock_m, 1.0);
1119        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1120        let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1121
1122        let spp_position = spp.position.as_array();
1123        let nominal = NavState::new(T0, spp_position, [0.0; 3], mat3_identity()).expect("nominal");
1124        let diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1125        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1126
1127        let update = filter.update_tight(&source, &epoch).expect("tight update");
1128
1129        assert!(update.applied);
1130        for (got, expected) in filter
1131            .state()
1132            .nominal
1133            .position_ecef_m
1134            .iter()
1135            .zip(spp_position)
1136        {
1137            assert_close(*got, expected, 1.0e-6);
1138        }
1139        let clock = filter.tight_clock_state().expect("clock");
1140        assert_close(clock.bias_m, spp.rx_clock_s * C_M_S, 1.0e-5);
1141    }
1142
1143    #[test]
1144    fn doppler_row_uses_range_rate_predictor_geometry_bits() {
1145        let receiver = [WGS84_A_M, 0.0, 0.0];
1146        let satellite_id = sat(1);
1147        let source = LinearSource::new(
1148            T0,
1149            vec![(
1150                satellite_id,
1151                [WGS84_A_M + 22_000_000.0, 1_000_000.0, 2_000_000.0],
1152                [120.0, -40.0, 30.0],
1153                0.0,
1154            )],
1155        );
1156        let sat_state = transmit_time_satellite_state(
1157            &source,
1158            satellite_id,
1159            receiver,
1160            T0,
1161            TransmitTimeOptions::default(),
1162        )
1163        .expect("satellite state");
1164        let measured_receiver = ReceiverVelocityState {
1165            position_m: receiver,
1166            velocity_m_s: [5.0, -2.0, 1.0],
1167            clock_drift_m_s: 0.25,
1168        };
1169        let velocity_observation = VelocityObservation {
1170            sat: satellite_id,
1171            satellite_position_m: sat_state.position_ecef_m,
1172            satellite_velocity_m_s: sat_state.velocity_m_s,
1173            measured_range_rate_m_s: 0.0,
1174            sigma_m_s: 0.05,
1175            satellite_clock_drift_m_s: 0.01,
1176        };
1177        let measured = predict_range_rate_m_s(&velocity_observation, measured_receiver)
1178            .expect("measured range rate")
1179            .range_rate_m_s;
1180        let observation = TightGnssObservation {
1181            satellite_id,
1182            pseudorange_m: sat_state.geometric_range_m,
1183            pseudorange_sigma_m: 2.0,
1184            range_rate: Some(TightRangeRateObservation {
1185                measured_range_rate_m_s: measured,
1186                sigma_m_s: 0.05,
1187                satellite_clock_drift_m_s: 0.01,
1188            }),
1189            carrier_phase: None,
1190            ionosphere_delay_m: 0.0,
1191            troposphere_delay_m: 0.0,
1192        };
1193        let epoch = TightGnssEpoch::new(T0, vec![observation]).expect("epoch");
1194        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1195        let filter = filter_with_config(
1196            nominal,
1197            &[1.0; ERROR_STATE_DIMENSION_15],
1198            tight_config_for_test(),
1199        );
1200        let correction = tight_coupling_correction(
1201            &source,
1202            filter.state(),
1203            &filter.tight,
1204            &epoch,
1205            filter.config.tight,
1206            [0.0; 3],
1207        )
1208        .expect("correction");
1209        let predicted_at_nominal = predict_range_rate_m_s(
1210            &VelocityObservation {
1211                measured_range_rate_m_s: measured,
1212                ..velocity_observation
1213            },
1214            ReceiverVelocityState {
1215                position_m: receiver,
1216                velocity_m_s: [0.0; 3],
1217                clock_drift_m_s: 0.0,
1218            },
1219        )
1220        .expect("nominal range rate");
1221
1222        let doppler_row = &correction.design[1];
1223        for axis in 0..3 {
1224            assert_eq!(
1225                doppler_row[ERROR_VELOCITY_INDEX + axis].to_bits(),
1226                (-predicted_at_nominal.los_unit[axis]).to_bits()
1227            );
1228        }
1229        assert_eq!(
1230            doppler_row[clock_drift_index(filter.state.dimension())].to_bits(),
1231            1.0_f64.to_bits()
1232        );
1233        assert_eq!(
1234            correction.innovation[1].to_bits(),
1235            (measured - predicted_at_nominal.range_rate_m_s).to_bits()
1236        );
1237    }
1238
1239    #[test]
1240    fn singular_snapshot_geometry_keeps_unobserved_prior_covariance() {
1241        let receiver = [WGS84_A_M, 0.0, 0.0];
1242        let directions = [[1.0, 0.0, 0.0]; 5];
1243        let source = source_from_directions(receiver, &directions);
1244        let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
1245        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1246        assert!(matches!(
1247            crate::spp::solve(&source, &inputs, false),
1248            Err(SppError::Singular(_))
1249        ));
1250
1251        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1252        let mut diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
1253        diagonal[ERROR_POSITION_INDEX] = 100.0;
1254        diagonal[ERROR_POSITION_INDEX + 1] = 225.0;
1255        diagonal[ERROR_POSITION_INDEX + 2] = 400.0;
1256        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1257        let prior_y = filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1];
1258        let prior_z = filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2];
1259
1260        let update = filter.update_tight(&source, &epoch).expect("tight update");
1261
1262        assert!(update.applied);
1263        assert!(covariance_is_positive_semidefinite(&filter.state.covariance).expect("PSD"));
1264        assert_eq!(
1265            filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1].to_bits(),
1266            prior_y.to_bits()
1267        );
1268        assert_eq!(
1269            filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2].to_bits(),
1270            prior_z.to_bits()
1271        );
1272        assert!(filter
1273            .state
1274            .nominal
1275            .position_ecef_m
1276            .iter()
1277            .all(|value| value.is_finite() && value.abs() < 1.0e8));
1278    }
1279
1280    #[test]
1281    fn high_dop_fused_covariance_has_lower_logdet_than_snapshot() {
1282        let receiver = [WGS84_A_M, 0.0, 0.0];
1283        let directions = [
1284            [0.44974122498328417, -0.8581153514788689, 0.2477314556265159],
1285            [0.20081904418348107, 0.5332143328087052, 0.8217993591994339],
1286            [0.43760604888398824, -0.4903647504582244, 0.7536865114145189],
1287            [
1288                0.2148508784686108,
1289                -0.9558725523345635,
1290                -0.20036657334663732,
1291            ],
1292            [0.30949187488876595, 0.3289789392404428, 0.8921813923827763],
1293        ];
1294        let source = source_from_directions(receiver, &directions);
1295        let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
1296        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1297        let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1298        assert_eq!(
1299            spp.geometry_quality.tier,
1300            crate::geometry_quality::ObservabilityTier::Weak
1301        );
1302        let snapshot_covariance = snapshot_position_clock_covariance(&source, receiver, &epoch);
1303        let snapshot_logdet = logdet_spd(&snapshot_covariance);
1304
1305        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1306        let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1307        for axis in 0..3 {
1308            diagonal[ERROR_POSITION_INDEX + axis] = 1.0e8;
1309        }
1310        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1311
1312        filter.update_tight(&source, &epoch).expect("tight update");
1313
1314        let fused_logdet = logdet_spd(&position_clock_block(&filter));
1315        assert!(
1316            fused_logdet < snapshot_logdet,
1317            "fused {fused_logdet:.17e}, snapshot {snapshot_logdet:.17e}"
1318        );
1319    }
1320
1321    #[test]
1322    fn outage_growth_and_single_satellite_observed_direction_update() {
1323        let receiver = [WGS84_A_M, 0.0, 0.0];
1324        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1325        let diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1326        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
1327            .expect("state");
1328        let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
1329        let mut config = super::super::loose::InertialFilterConfig::new(spec).expect("config");
1330        config.tight = TightCouplingConfig {
1331            light_time: false,
1332            sagnac: false,
1333            initial_clock_bias_variance_m2: 100.0,
1334            initial_clock_drift_variance_m2_s2: 1.0,
1335            clock_bias_random_walk_m2_s: 4.0,
1336            clock_drift_random_walk_m2_s3: 0.25,
1337            ..TightCouplingConfig::default()
1338        };
1339        let mut filter = InertialFilter::with_config(state, config).expect("filter");
1340        let mut previous_logdet = logdet_spd(&filter.tight.augmented_covariance);
1341
1342        for step in 1..=3 {
1343            filter
1344                .propagate(ImuSample::increment(
1345                    T0 + step as f64,
1346                    [0.0; 3],
1347                    [0.0; 3],
1348                    1.0,
1349                ))
1350                .expect("propagate");
1351            let next_logdet = logdet_spd(&filter.tight.augmented_covariance);
1352            assert!(
1353                next_logdet > previous_logdet,
1354                "step {step} logdet {next_logdet:.17e} <= {previous_logdet:.17e}"
1355            );
1356            previous_logdet = next_logdet;
1357        }
1358
1359        let current_position = filter.state.nominal.position_ecef_m;
1360        let satellite_id = sat(1);
1361        let source = LinearSource::new(
1362            filter.state.nominal.t_j2000_s,
1363            vec![(
1364                satellite_id,
1365                [
1366                    current_position[0] + 22_000_000.0,
1367                    current_position[1],
1368                    current_position[2],
1369                ],
1370                [0.0; 3],
1371                0.0,
1372            )],
1373        );
1374        let prediction = transmit_time_satellite_state(
1375            &source,
1376            satellite_id,
1377            current_position,
1378            filter.state.nominal.t_j2000_s,
1379            TransmitTimeOptions {
1380                light_time: false,
1381                sagnac: false,
1382            },
1383        )
1384        .expect("satellite state");
1385        let clock = filter.tight_clock_state().expect("clock");
1386        let epoch = TightGnssEpoch::new(
1387            filter.state.nominal.t_j2000_s,
1388            vec![TightGnssObservation::pseudorange(
1389                satellite_id,
1390                prediction.geometric_range_m + clock.bias_m,
1391                0.5,
1392            )
1393            .expect("observation")],
1394        )
1395        .expect("epoch");
1396        let pre = filter.state.covariance.clone();
1397
1398        filter
1399            .update_tight(&source, &epoch)
1400            .expect("single-sat update");
1401
1402        assert!(
1403            filter.state.covariance[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
1404                < pre[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
1405        );
1406        for axis in [1usize, 2] {
1407            assert_eq!(
1408                filter.state.covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis]
1409                    .to_bits(),
1410                pre[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis].to_bits()
1411            );
1412        }
1413    }
1414}