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, Mat3};
10use crate::astro::math::vec3::{add3, cross3, norm3, sub3};
11use crate::constants::C_M_S;
12use crate::estimation::recipe::{FrameRecipe, RangeRecipe, SagnacRecipe};
13use crate::inertial::state::{skew, validate_dcm_orthonormal};
14use crate::inertial::{validate_finite, validate_vec3};
15use crate::observables::{
16    transmit_time_satellite_state, ObservableEphemerisSource, ObservablesError, TransmitTimeOptions,
17};
18use crate::precise_positioning::{
19    predict_range_rate_m_s, ReceiverVelocityState, VelocityObservation,
20};
21use crate::spp::{
22    sat_model, Corrections, EphemerisSource, KlobucharCoeffs, SatModelEnv, SppIonosphere,
23    SppModelRecipe, SurfaceMet,
24};
25
26use super::ekf::{
27    apply_closed_loop_navigation_error, apply_closed_loop_scale_error, innovation_covariance,
28    joseph_covariance_update, normalized_innovation_squared, screen_correction, EkfCorrection,
29    EkfCorrectionReport, EkfUpdateOptions,
30};
31use super::loose::{FusionUpdate, InertialFilter};
32use super::state::FusionFilterKind;
33use super::state::{
34    identity, invalid_input, matmul, matrix_add, reproject_covariance_psd, symmetrize_in_place,
35    validate_covariance_matrix, validate_finite_slice, validate_nonnegative, validate_positive,
36    validate_square_matrix, FusionError, InsFilterState, ERROR_ATTITUDE_INDEX,
37    ERROR_GYRO_BIAS_INDEX, ERROR_POSITION_INDEX, ERROR_VELOCITY_INDEX,
38};
39use super::ukf::{ukf_measurement_update, UkfUpdateOptions};
40
41/// Receiver-clock bias index in the tight augmented covariance.
42pub const TIGHT_CLOCK_BIAS_OFFSET: usize = 0;
43/// Receiver-clock drift index in the tight augmented covariance.
44pub const TIGHT_CLOCK_DRIFT_OFFSET: usize = 1;
45/// Number of receiver-clock states appended to the INS error state.
46pub const TIGHT_CLOCK_STATE_COUNT: usize = 2;
47
48/// Doppler-derived range-rate measurement for one satellite.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct TightRangeRateObservation {
51    /// Measured pseudorange rate in meters per second.
52    pub measured_range_rate_m_s: f64,
53    /// One-sigma range-rate uncertainty in meters per second.
54    pub sigma_m_s: f64,
55    /// Satellite clock drift as an equivalent range-rate bias in meters per second.
56    pub satellite_clock_drift_m_s: f64,
57}
58
59impl TightRangeRateObservation {
60    /// Validate finite range-rate fields and positive sigma.
61    pub fn validate(&self) -> Result<(), FusionError> {
62        validate_finite(self.measured_range_rate_m_s, "measured_range_rate_m_s")
63            .map_err(FusionError::from)?;
64        validate_positive(self.sigma_m_s, "range_rate_sigma_m_s")?;
65        validate_finite(self.satellite_clock_drift_m_s, "satellite_clock_drift_m_s")
66            .map_err(FusionError::from)
67    }
68}
69
70/// Carrier-phase range row with a caller-supplied float ambiguity.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct TightCarrierPhaseObservation {
73    /// Carrier phase converted to range units in meters.
74    pub phase_range_m: f64,
75    /// One-sigma carrier-phase range uncertainty in meters.
76    pub sigma_m: f64,
77    /// Current float ambiguity estimate for this continuous arc, in meters.
78    pub float_ambiguity_m: f64,
79}
80
81impl TightCarrierPhaseObservation {
82    /// Validate finite carrier fields and positive sigma.
83    pub fn validate(&self) -> Result<(), FusionError> {
84        validate_finite(self.phase_range_m, "phase_range_m").map_err(FusionError::from)?;
85        validate_positive(self.sigma_m, "carrier_sigma_m")?;
86        validate_finite(self.float_ambiguity_m, "float_ambiguity_m").map_err(FusionError::from)
87    }
88}
89
90/// Raw GNSS observation for one satellite in a tight update.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct TightGnssObservation {
93    /// Satellite identifier.
94    pub satellite_id: crate::GnssSatelliteId,
95    /// Measured code pseudorange in meters.
96    pub pseudorange_m: f64,
97    /// One-sigma pseudorange uncertainty in meters.
98    pub pseudorange_sigma_m: f64,
99    /// Optional Doppler-derived range-rate row.
100    pub range_rate: Option<TightRangeRateObservation>,
101    /// Optional carrier-phase row using a supplied float ambiguity.
102    pub carrier_phase: Option<TightCarrierPhaseObservation>,
103    /// Ionospheric group delay correction for code, in meters.
104    pub ionosphere_delay_m: f64,
105    /// Tropospheric delay correction, in meters.
106    pub troposphere_delay_m: f64,
107}
108
109impl TightGnssObservation {
110    /// Build a pseudorange-only observation.
111    pub fn pseudorange(
112        satellite_id: crate::GnssSatelliteId,
113        pseudorange_m: f64,
114        pseudorange_sigma_m: f64,
115    ) -> Result<Self, FusionError> {
116        let observation = Self {
117            satellite_id,
118            pseudorange_m,
119            pseudorange_sigma_m,
120            range_rate: None,
121            carrier_phase: None,
122            ionosphere_delay_m: 0.0,
123            troposphere_delay_m: 0.0,
124        };
125        observation.validate()?;
126        Ok(observation)
127    }
128
129    /// Validate finite measurement values, positive sigmas, and optional rows.
130    pub fn validate(&self) -> Result<(), FusionError> {
131        validate_finite(self.pseudorange_m, "pseudorange_m").map_err(FusionError::from)?;
132        validate_positive(self.pseudorange_sigma_m, "pseudorange_sigma_m")?;
133        validate_finite(self.ionosphere_delay_m, "ionosphere_delay_m")
134            .map_err(FusionError::from)?;
135        validate_finite(self.troposphere_delay_m, "troposphere_delay_m")
136            .map_err(FusionError::from)?;
137        if let Some(range_rate) = self.range_rate {
138            range_rate.validate()?;
139        }
140        if let Some(carrier_phase) = self.carrier_phase {
141            carrier_phase.validate()?;
142        }
143        Ok(())
144    }
145}
146
147/// One receiver epoch of raw GNSS observations for a tight update.
148#[derive(Debug, Clone, PartialEq)]
149pub struct TightGnssEpoch {
150    /// Measurement epoch in seconds since J2000 on the caller's GNSS time scale.
151    pub t_j2000_s: f64,
152    /// One or more satellite observations.
153    pub observations: Vec<TightGnssObservation>,
154}
155
156impl TightGnssEpoch {
157    /// Build and validate an epoch from raw observations.
158    pub fn new(
159        t_j2000_s: f64,
160        observations: Vec<TightGnssObservation>,
161    ) -> Result<Self, FusionError> {
162        let epoch = Self {
163            t_j2000_s,
164            observations,
165        };
166        epoch.validate()?;
167        Ok(epoch)
168    }
169
170    /// Validate epoch time, row count, duplicate satellites, and observations.
171    pub fn validate(&self) -> Result<(), FusionError> {
172        validate_finite(self.t_j2000_s, "t_j2000_s").map_err(FusionError::from)?;
173        if self.observations.is_empty() {
174            return Err(invalid_input("tight_observations", "must not be empty"));
175        }
176        let mut seen = BTreeSet::new();
177        for observation in &self.observations {
178            observation.validate()?;
179            if !seen.insert(observation.satellite_id) {
180                return Err(invalid_input(
181                    "tight_observations",
182                    "satellites must be unique",
183                ));
184            }
185        }
186        Ok(())
187    }
188}
189
190/// Configuration for tightly coupled raw GNSS updates.
191#[derive(Debug, Clone, Copy, PartialEq)]
192pub struct TightCouplingConfig {
193    /// Body-frame vector from IMU origin to GNSS antenna phase center, in meters.
194    pub lever_arm_body_m: [f64; 3],
195    /// Apply the SPP measured-pseudorange transmit-time light-time correction to
196    /// code and carrier-phase rows.
197    pub light_time: bool,
198    /// Apply Earth-rotation Sagnac correction.
199    pub sagnac: bool,
200    /// Initial receiver-clock bias variance in square meters.
201    pub initial_clock_bias_variance_m2: f64,
202    /// Initial receiver-clock drift variance in `(m/s)^2`.
203    pub initial_clock_drift_variance_m2_s2: f64,
204    /// Receiver-clock bias random-walk spectral density in `m^2/s`.
205    pub clock_bias_random_walk_m2_s: f64,
206    /// Receiver-clock drift random-walk spectral density in `m^2/s^3`.
207    pub clock_drift_random_walk_m2_s3: f64,
208    /// Generic EKF correction options applied to each tight update.
209    pub update_options: EkfUpdateOptions,
210}
211
212impl Default for TightCouplingConfig {
213    fn default() -> Self {
214        Self {
215            lever_arm_body_m: [0.0; 3],
216            light_time: true,
217            sagnac: true,
218            initial_clock_bias_variance_m2: 1.0e12,
219            initial_clock_drift_variance_m2_s2: 1.0e6,
220            clock_bias_random_walk_m2_s: 1.0,
221            clock_drift_random_walk_m2_s3: 1.0e-2,
222            update_options: EkfUpdateOptions::default(),
223        }
224    }
225}
226
227impl TightCouplingConfig {
228    /// Validate lever arm, clock covariance, clock process noise, and update options.
229    pub fn validate(&self) -> Result<(), FusionError> {
230        validate_vec3(self.lever_arm_body_m, "tight_lever_arm_body_m")
231            .map_err(FusionError::from)?;
232        validate_nonnegative(
233            self.initial_clock_bias_variance_m2,
234            "initial_clock_bias_variance_m2",
235        )?;
236        validate_nonnegative(
237            self.initial_clock_drift_variance_m2_s2,
238            "initial_clock_drift_variance_m2_s2",
239        )?;
240        validate_nonnegative(
241            self.clock_bias_random_walk_m2_s,
242            "clock_bias_random_walk_m2_s",
243        )?;
244        validate_nonnegative(
245            self.clock_drift_random_walk_m2_s3,
246            "clock_drift_random_walk_m2_s3",
247        )?;
248        if let Some(gate) = self.update_options.innovation_gate {
249            gate.validate()?;
250        }
251        Ok(())
252    }
253}
254
255/// Receiver-clock state reported by the tight filter.
256#[derive(Debug, Clone, Copy, PartialEq)]
257pub struct TightClockState {
258    /// Receiver-clock range bias in meters.
259    pub bias_m: f64,
260    /// Receiver-clock drift in meters per second.
261    pub drift_m_s: f64,
262    /// Two-by-two clock covariance ordered as `[bias_m, drift_m_s]`.
263    pub covariance: [[f64; TIGHT_CLOCK_STATE_COUNT]; TIGHT_CLOCK_STATE_COUNT],
264}
265
266/// Snapshot of the tight clock augmentation for replay and restore.
267#[derive(Debug, Clone, PartialEq)]
268pub struct TightFilterSnapshot {
269    /// Receiver-clock range bias in meters.
270    pub clock_bias_m: f64,
271    /// Receiver-clock drift in meters per second.
272    pub clock_drift_m_s: f64,
273    /// Full augmented covariance ordered as `[INS error state, clock bias, clock drift]`.
274    pub augmented_covariance: Vec<Vec<f64>>,
275}
276
277#[derive(Debug, Clone, PartialEq)]
278pub(super) struct TightFusionState {
279    clock_bias_m: f64,
280    clock_drift_m_s: f64,
281    augmented_covariance: Vec<Vec<f64>>,
282}
283
284impl TightFusionState {
285    pub(super) fn from_filter_state(
286        state: &InsFilterState,
287        config: TightCouplingConfig,
288    ) -> Result<Self, FusionError> {
289        config.validate()?;
290        let base_dim = state.dimension();
291        let aug_dim = augmented_dimension(base_dim);
292        let mut augmented_covariance = vec![vec![0.0; aug_dim]; aug_dim];
293        for (row, base_row) in state.covariance.iter().enumerate().take(base_dim) {
294            augmented_covariance[row][..base_dim].copy_from_slice(&base_row[..base_dim]);
295        }
296        let clock_bias_index = clock_bias_index(base_dim);
297        let clock_drift_index = clock_drift_index(base_dim);
298        augmented_covariance[clock_bias_index][clock_bias_index] =
299            config.initial_clock_bias_variance_m2;
300        augmented_covariance[clock_drift_index][clock_drift_index] =
301            config.initial_clock_drift_variance_m2_s2;
302        let tight = Self {
303            clock_bias_m: 0.0,
304            clock_drift_m_s: 0.0,
305            augmented_covariance,
306        };
307        tight.validate(base_dim)?;
308        Ok(tight)
309    }
310
311    pub(super) fn snapshot(&self) -> TightFilterSnapshot {
312        TightFilterSnapshot {
313            clock_bias_m: self.clock_bias_m,
314            clock_drift_m_s: self.clock_drift_m_s,
315            augmented_covariance: self.augmented_covariance.clone(),
316        }
317    }
318
319    pub(super) fn restore(
320        &mut self,
321        snapshot: &TightFilterSnapshot,
322        base_dim: usize,
323    ) -> Result<(), FusionError> {
324        validate_finite(snapshot.clock_bias_m, "clock_bias_m").map_err(FusionError::from)?;
325        validate_finite(snapshot.clock_drift_m_s, "clock_drift_m_s").map_err(FusionError::from)?;
326        validate_covariance_matrix(
327            &snapshot.augmented_covariance,
328            augmented_dimension(base_dim),
329            "tight_augmented_covariance",
330        )?;
331        self.clock_bias_m = snapshot.clock_bias_m;
332        self.clock_drift_m_s = snapshot.clock_drift_m_s;
333        self.augmented_covariance = snapshot.augmented_covariance.clone();
334        self.validate(base_dim)
335    }
336
337    pub(super) fn clock_state(&self, base_dim: usize) -> Result<TightClockState, FusionError> {
338        self.validate(base_dim)?;
339        let bias = clock_bias_index(base_dim);
340        let drift = clock_drift_index(base_dim);
341        Ok(TightClockState {
342            bias_m: self.clock_bias_m,
343            drift_m_s: self.clock_drift_m_s,
344            covariance: [
345                [
346                    self.augmented_covariance[bias][bias],
347                    self.augmented_covariance[bias][drift],
348                ],
349                [
350                    self.augmented_covariance[drift][bias],
351                    self.augmented_covariance[drift][drift],
352                ],
353            ],
354        })
355    }
356
357    pub(super) fn validate(&self, base_dim: usize) -> Result<(), FusionError> {
358        validate_finite(self.clock_bias_m, "clock_bias_m").map_err(FusionError::from)?;
359        validate_finite(self.clock_drift_m_s, "clock_drift_m_s").map_err(FusionError::from)?;
360        validate_covariance_matrix(
361            &self.augmented_covariance,
362            augmented_dimension(base_dim),
363            "tight_augmented_covariance",
364        )
365    }
366
367    pub(super) fn align_with_filter_state(
368        &mut self,
369        state: &InsFilterState,
370    ) -> Result<(), FusionError> {
371        state.validate()?;
372        let base_dim = state.dimension();
373        self.validate(base_dim)?;
374        let mut differs = false;
375        'outer: for row in 0..base_dim {
376            for col in 0..base_dim {
377                if self.augmented_covariance[row][col].to_bits()
378                    != state.covariance[row][col].to_bits()
379                {
380                    differs = true;
381                    break 'outer;
382                }
383            }
384        }
385        if differs {
386            self.replace_base_covariance_and_clear_cross(&state.covariance)?;
387        }
388        Ok(())
389    }
390
391    pub(super) fn replace_base_covariance_and_clear_cross(
392        &mut self,
393        base_covariance: &[Vec<f64>],
394    ) -> Result<(), FusionError> {
395        let base_dim = base_covariance.len();
396        validate_covariance_matrix(base_covariance, base_dim, "covariance")?;
397        self.validate(base_dim)?;
398        let aug_dim = augmented_dimension(base_dim);
399        for (row, base_row) in base_covariance.iter().enumerate().take(base_dim) {
400            self.augmented_covariance[row][..base_dim].copy_from_slice(&base_row[..base_dim]);
401        }
402        for idx in 0..base_dim {
403            for clock in base_dim..aug_dim {
404                self.augmented_covariance[idx][clock] = 0.0;
405                self.augmented_covariance[clock][idx] = 0.0;
406            }
407        }
408        self.validate(base_dim)
409    }
410
411    pub(super) fn predict_covariance(
412        &mut self,
413        phi_base: &[Vec<f64>],
414        q_base: &[Vec<f64>],
415        dt_s: f64,
416        config: TightCouplingConfig,
417    ) -> Result<(), FusionError> {
418        config.validate()?;
419        validate_nonnegative(dt_s, "dt_s")?;
420        let base_dim = phi_base.len();
421        validate_square_matrix(phi_base, base_dim, "phi")?;
422        validate_covariance_matrix(q_base, base_dim, "q_d")?;
423        self.validate(base_dim)?;
424
425        let aug_dim = augmented_dimension(base_dim);
426        let mut phi = identity(aug_dim);
427        for row in 0..base_dim {
428            for col in 0..base_dim {
429                phi[row][col] = phi_base[row][col];
430            }
431        }
432        let bias = clock_bias_index(base_dim);
433        let drift = clock_drift_index(base_dim);
434        phi[bias][drift] = dt_s;
435
436        let mut q = vec![vec![0.0; aug_dim]; aug_dim];
437        for row in 0..base_dim {
438            for col in 0..base_dim {
439                q[row][col] = q_base[row][col];
440            }
441        }
442        let dt2 = dt_s * dt_s;
443        let dt3 = dt2 * dt_s;
444        q[bias][bias] += config.clock_bias_random_walk_m2_s * dt_s
445            + config.clock_drift_random_walk_m2_s3 * dt3 / 3.0;
446        q[bias][drift] += config.clock_drift_random_walk_m2_s3 * dt2 / 2.0;
447        q[drift][bias] = q[bias][drift];
448        q[drift][drift] += config.clock_drift_random_walk_m2_s3 * dt_s;
449        reproject_covariance_psd(&mut q, "tight_process_noise")?;
450
451        let left = matmul(&phi, &self.augmented_covariance)?;
452        let phi_t = super::state::transpose(&phi)?;
453        let propagated = matmul(&left, &phi_t)?;
454        let mut next = matrix_add(&propagated, &q)?;
455        symmetrize_in_place(&mut next);
456        reproject_covariance_psd(&mut next, "tight_augmented_covariance")?;
457        self.clock_bias_m += self.clock_drift_m_s * dt_s;
458        self.augmented_covariance = next;
459        self.validate(base_dim)
460    }
461
462    pub(super) fn copy_base_covariance_to_state(
463        &self,
464        state: &mut InsFilterState,
465    ) -> Result<(), FusionError> {
466        let base_dim = state.dimension();
467        self.validate(base_dim)?;
468        for row in 0..base_dim {
469            for col in 0..base_dim {
470                state.covariance[row][col] = self.augmented_covariance[row][col];
471            }
472        }
473        state.validate()
474    }
475}
476
477impl InertialFilter {
478    /// Borrow the current receiver-clock state carried by tight coupling.
479    pub fn tight_clock_state(&self) -> Result<TightClockState, FusionError> {
480        self.tight.clock_state(self.state.dimension())
481    }
482
483    /// Apply a tight raw GNSS update at the current propagated epoch.
484    ///
485    /// GNSS epochs must be strictly increasing across the filter's stateful
486    /// update surface. One satellite is a valid update.
487    pub fn update_tight(
488        &mut self,
489        source: &dyn ObservableEphemerisSource,
490        epoch: &TightGnssEpoch,
491    ) -> Result<FusionUpdate, FusionError> {
492        if let Some(last) = self.time_sync.last_measurement_t_j2000_s() {
493            if epoch.t_j2000_s <= last {
494                return Err(invalid_input(
495                    "t_j2000_s",
496                    "GNSS measurement epochs must be strictly increasing",
497                ));
498            }
499        }
500        let update = self.update_tight_core(source, epoch)?;
501        let snapshot = self.snapshot();
502        self.time_sync
503            .push_tight_measurement_and_checkpoint(epoch.clone(), snapshot);
504        Ok(update)
505    }
506
507    pub(super) fn update_tight_core(
508        &mut self,
509        source: &dyn ObservableEphemerisSource,
510        epoch: &TightGnssEpoch,
511    ) -> Result<FusionUpdate, FusionError> {
512        self.tight.align_with_filter_state(&self.state)?;
513        let correction = tight_coupling_correction(
514            source,
515            &self.state,
516            &self.tight,
517            epoch,
518            self.config.tight,
519            self.config.imu_to_body_dcm,
520            self.last_body_rate_wrt_ecef_rps,
521        )?;
522        let rows = correction.row_count();
523        let filter_kind = self.config.filter_kind;
524        let ekf_options = self.config.tight.update_options;
525        let ukf_options = self.config.ukf_update_options;
526        let report = match filter_kind {
527            FusionFilterKind::Ekf => apply_tight_correction(self, &correction, ekf_options)?,
528            FusionFilterKind::Ukf => {
529                apply_tight_ukf_correction(self, source, epoch, &correction, ukf_options)?
530            }
531        };
532        Ok(FusionUpdate {
533            applied: report.applied,
534            nis: report.normalized_innovation_squared,
535            rows,
536            accepted_rows: report.accepted_rows,
537            rejected_rows: report.rejected_rows,
538            ekf: report,
539        })
540    }
541}
542
543pub(super) fn tight_coupling_correction(
544    source: &dyn ObservableEphemerisSource,
545    state: &InsFilterState,
546    tight_state: &TightFusionState,
547    epoch: &TightGnssEpoch,
548    config: TightCouplingConfig,
549    imu_to_body_dcm: Mat3,
550    body_rate_wrt_ecef_rps: [f64; 3],
551) -> Result<EkfCorrection, FusionError> {
552    state.validate()?;
553    tight_state.validate(state.dimension())?;
554    epoch.validate()?;
555    config.validate()?;
556    validate_dcm_orthonormal(&imu_to_body_dcm, "imu_to_body_dcm").map_err(FusionError::from)?;
557    validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
558    if epoch.t_j2000_s != state.nominal.t_j2000_s {
559        return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
560    }
561
562    let base_dim = state.dimension();
563    let aug_dim = augmented_dimension(base_dim);
564    let clock_bias = clock_bias_index(base_dim);
565    let clock_drift = clock_drift_index(base_dim);
566    let kinematics = antenna_kinematics(
567        state,
568        config.lever_arm_body_m,
569        body_rate_wrt_ecef_rps,
570        imu_to_body_dcm,
571    );
572    let options = TransmitTimeOptions {
573        light_time: config.light_time,
574        sagnac: config.sagnac,
575    };
576
577    let mut innovation = Vec::new();
578    let mut design = Vec::new();
579    let mut variances = Vec::new();
580
581    for observation in &epoch.observations {
582        let code_satellite = tight_code_satellite_prediction(
583            source,
584            observation.satellite_id,
585            kinematics.antenna_position_ecef_m,
586            epoch.t_j2000_s,
587            observation.pseudorange_m,
588            options,
589        )
590        .map_err(map_observables_error)?;
591
592        let code_prediction_m = code_satellite.clock_corrected_range_m
593            + tight_state.clock_bias_m
594            + observation.ionosphere_delay_m
595            + observation.troposphere_delay_m;
596        let mut row = pseudorange_design_row(
597            aug_dim,
598            clock_bias,
599            code_satellite.los_unit,
600            kinematics.lever_arm_ecef_m,
601        );
602        innovation.push(observation.pseudorange_m - code_prediction_m);
603        design.push(row);
604        variances.push(observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
605
606        if let Some(carrier_phase) = observation.carrier_phase {
607            let phase_prediction_m = code_satellite.clock_corrected_range_m
608                + tight_state.clock_bias_m
609                - observation.ionosphere_delay_m
610                + observation.troposphere_delay_m
611                + carrier_phase.float_ambiguity_m;
612            row = pseudorange_design_row(
613                aug_dim,
614                clock_bias,
615                code_satellite.los_unit,
616                kinematics.lever_arm_ecef_m,
617            );
618            innovation.push(carrier_phase.phase_range_m - phase_prediction_m);
619            design.push(row);
620            variances.push(carrier_phase.sigma_m * carrier_phase.sigma_m);
621        }
622
623        if let Some(range_rate) = observation.range_rate {
624            let satellite = transmit_time_satellite_state(
625                source,
626                observation.satellite_id,
627                kinematics.antenna_position_ecef_m,
628                epoch.t_j2000_s,
629                options,
630            )
631            .map_err(map_observables_error)?;
632            let velocity_observation = VelocityObservation {
633                sat: observation.satellite_id,
634                satellite_position_m: satellite.position_ecef_m,
635                satellite_velocity_m_s: satellite.velocity_m_s,
636                measured_range_rate_m_s: range_rate.measured_range_rate_m_s,
637                sigma_m_s: range_rate.sigma_m_s,
638                satellite_clock_drift_m_s: range_rate.satellite_clock_drift_m_s,
639            };
640            let receiver = ReceiverVelocityState {
641                position_m: kinematics.antenna_position_ecef_m,
642                velocity_m_s: kinematics.antenna_velocity_ecef_mps,
643                clock_drift_m_s: tight_state.clock_drift_m_s,
644            };
645            let prediction = predict_range_rate_m_s(&velocity_observation, receiver)
646                .ok_or_else(|| invalid_input("range_rate", "line of sight must be nonzero"))?;
647            let row = range_rate_design_row(
648                aug_dim,
649                clock_drift,
650                prediction.los_unit,
651                kinematics.lever_velocity_ecef_mps,
652                kinematics.gyro_bias_velocity_block,
653            );
654            innovation.push(range_rate.measured_range_rate_m_s - prediction.range_rate_m_s);
655            design.push(row);
656            variances.push(range_rate.sigma_m_s * range_rate.sigma_m_s);
657        }
658    }
659
660    validate_finite_slice(&innovation, "tight_innovation")?;
661    let measurement_covariance = diagonal_covariance(&variances)?;
662    EkfCorrection::new(innovation, design, measurement_covariance)
663}
664
665fn apply_tight_correction(
666    filter: &mut InertialFilter,
667    correction: &EkfCorrection,
668    options: EkfUpdateOptions,
669) -> Result<EkfCorrectionReport, FusionError> {
670    filter.state.validate()?;
671    let base_dim = filter.state.dimension();
672    filter.tight.validate(base_dim)?;
673    correction.validate_for_dimension(augmented_dimension(base_dim))?;
674
675    if let Some(gate) = options.innovation_gate {
676        gate.validate()?;
677        let full_s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
678        let (screened, gate_report) = screen_correction(correction, &full_s, gate)?;
679        let full_nis = normalized_innovation_squared(&full_s, &correction.innovation)?;
680        if gate_report.coasted {
681            return Ok(EkfCorrectionReport {
682                applied: false,
683                normalized_innovation_squared: full_nis,
684                accepted_rows: gate_report.accepted_rows,
685                rejected_rows: gate_report.rejected_rows,
686                innovation_gate: Some(gate_report),
687                innovation_covariance: full_s,
688                kalman_gain: vec![vec![0.0; correction.row_count()]; augmented_dimension(base_dim)],
689                dx: vec![0.0; augmented_dimension(base_dim)],
690            });
691        }
692        let accepted_rows = gate_report.accepted_rows;
693        let rejected_rows = gate_report.rejected_rows;
694        let mut report = apply_tight_correction_inner(filter, &screened)?;
695        report.accepted_rows = accepted_rows;
696        report.rejected_rows = rejected_rows;
697        report.innovation_gate = Some(gate_report);
698        return Ok(report);
699    }
700
701    apply_tight_correction_inner(filter, correction)
702}
703
704fn apply_tight_correction_inner(
705    filter: &mut InertialFilter,
706    correction: &EkfCorrection,
707) -> Result<EkfCorrectionReport, FusionError> {
708    let base_dim = filter.state.dimension();
709    let aug_dim = augmented_dimension(base_dim);
710    let s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
711    let h_t = super::state::transpose(&correction.design)?;
712    let p_h_t = matmul(&filter.tight.augmented_covariance, &h_t)?;
713    let mut kalman_gain = vec![vec![0.0; correction.row_count()]; aug_dim];
714    let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
715    for row in 0..aug_dim {
716        kalman_gain[row] = super::state::solve_spd(&s, &p_h_t[row], &mut scratch)?;
717    }
718    let dx = super::state::matvec(&kalman_gain, &correction.innovation)?;
719    let nis = normalized_innovation_squared(&s, &correction.innovation)?;
720    let covariance = joseph_covariance_update(
721        &filter.tight.augmented_covariance,
722        &correction.design,
723        &kalman_gain,
724        &correction.measurement_covariance,
725    )?;
726
727    apply_closed_loop_navigation_error(&mut filter.state.nominal, &dx[..base_dim])?;
728    apply_closed_loop_scale_error(&mut filter.state, &dx[..base_dim]);
729    filter.tight.clock_bias_m += dx[clock_bias_index(base_dim)];
730    filter.tight.clock_drift_m_s += dx[clock_drift_index(base_dim)];
731    filter.tight.augmented_covariance = covariance;
732    filter
733        .tight
734        .copy_base_covariance_to_state(&mut filter.state)?;
735    filter.state.reset_error_state();
736    filter.state.validate()?;
737    filter.tight.validate(base_dim)?;
738
739    Ok(EkfCorrectionReport {
740        applied: true,
741        normalized_innovation_squared: nis,
742        accepted_rows: correction.row_count(),
743        rejected_rows: 0,
744        innovation_gate: None,
745        innovation_covariance: s,
746        kalman_gain,
747        dx,
748    })
749}
750
751fn apply_tight_ukf_correction(
752    filter: &mut InertialFilter,
753    source: &dyn ObservableEphemerisSource,
754    epoch: &TightGnssEpoch,
755    correction: &EkfCorrection,
756    options: UkfUpdateOptions,
757) -> Result<EkfCorrectionReport, FusionError> {
758    filter.state.validate()?;
759    let base_dim = filter.state.dimension();
760    filter.tight.validate(base_dim)?;
761    correction.validate_for_dimension(augmented_dimension(base_dim))?;
762    options.validate_for_dimension(augmented_dimension(base_dim))?;
763
764    let reference_state = filter.state.clone();
765    let reference_tight = filter.tight.clone();
766    let config = filter.config.tight;
767    let body_rate_wrt_ecef_rps = filter.last_body_rate_wrt_ecef_rps;
768    let reference_prediction = tight_measurement_predictions(
769        source,
770        &reference_state,
771        reference_tight.clock_bias_m,
772        reference_tight.clock_drift_m_s,
773        epoch,
774        config,
775        body_rate_wrt_ecef_rps,
776    )?;
777
778    let report = ukf_measurement_update(
779        &filter.tight.augmented_covariance,
780        &correction.innovation,
781        &correction.measurement_covariance,
782        options,
783        |dx| {
784            tight_sigma_measurement_residual(
785                source,
786                &reference_state,
787                &reference_tight,
788                epoch,
789                config,
790                body_rate_wrt_ecef_rps,
791                &reference_prediction,
792                dx,
793            )
794        },
795    )?;
796    if !report.applied {
797        return Ok(report.into_public_report());
798    }
799
800    let dx = report.dx.clone();
801    let posterior_covariance = report.posterior_covariance.clone();
802    apply_closed_loop_navigation_error(&mut filter.state.nominal, &dx[..base_dim])?;
803    apply_closed_loop_scale_error(&mut filter.state, &dx[..base_dim]);
804    filter.tight.clock_bias_m += dx[clock_bias_index(base_dim)];
805    filter.tight.clock_drift_m_s += dx[clock_drift_index(base_dim)];
806    filter.tight.augmented_covariance = posterior_covariance;
807    filter
808        .tight
809        .copy_base_covariance_to_state(&mut filter.state)?;
810    filter.state.reset_error_state();
811    filter.state.validate()?;
812    filter.tight.validate(base_dim)?;
813    Ok(report.into_public_report())
814}
815
816#[allow(clippy::too_many_arguments)]
817fn tight_sigma_measurement_residual(
818    source: &dyn ObservableEphemerisSource,
819    reference_state: &InsFilterState,
820    reference_tight: &TightFusionState,
821    epoch: &TightGnssEpoch,
822    config: TightCouplingConfig,
823    body_rate_wrt_ecef_rps: [f64; 3],
824    reference_prediction: &[f64],
825    dx: &[f64],
826) -> Result<Vec<f64>, FusionError> {
827    let base_dim = reference_state.dimension();
828    if dx.len() != augmented_dimension(base_dim) {
829        return Err(FusionError::DimensionMismatch {
830            field: "ukf_sigma_point",
831            expected: augmented_dimension(base_dim),
832            actual: dx.len(),
833        });
834    }
835
836    let mut candidate_state = reference_state.clone();
837    apply_closed_loop_navigation_error(&mut candidate_state.nominal, &dx[..base_dim])?;
838    apply_closed_loop_scale_error(&mut candidate_state, &dx[..base_dim]);
839    candidate_state.validate()?;
840    let mut candidate_body_rate_wrt_ecef_rps = body_rate_wrt_ecef_rps;
841    for axis in 0..3 {
842        candidate_body_rate_wrt_ecef_rps[axis] -= dx[ERROR_GYRO_BIAS_INDEX + axis];
843    }
844    let clock_bias_m = reference_tight.clock_bias_m + dx[clock_bias_index(base_dim)];
845    let clock_drift_m_s = reference_tight.clock_drift_m_s + dx[clock_drift_index(base_dim)];
846    let candidate_prediction = tight_measurement_predictions(
847        source,
848        &candidate_state,
849        clock_bias_m,
850        clock_drift_m_s,
851        epoch,
852        config,
853        candidate_body_rate_wrt_ecef_rps,
854    )?;
855    if candidate_prediction.len() != reference_prediction.len() {
856        return Err(FusionError::DimensionMismatch {
857            field: "tight_prediction",
858            expected: reference_prediction.len(),
859            actual: candidate_prediction.len(),
860        });
861    }
862    Ok(candidate_prediction
863        .iter()
864        .zip(reference_prediction.iter())
865        .map(|(candidate, reference)| candidate - reference)
866        .collect())
867}
868
869fn tight_measurement_predictions(
870    source: &dyn ObservableEphemerisSource,
871    state: &InsFilterState,
872    clock_bias_m: f64,
873    clock_drift_m_s: f64,
874    epoch: &TightGnssEpoch,
875    config: TightCouplingConfig,
876    body_rate_wrt_ecef_rps: [f64; 3],
877) -> Result<Vec<f64>, FusionError> {
878    state.validate()?;
879    epoch.validate()?;
880    config.validate()?;
881    validate_finite_slice(&[clock_bias_m, clock_drift_m_s], "tight_clock")?;
882    validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
883    if epoch.t_j2000_s != state.nominal.t_j2000_s {
884        return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
885    }
886
887    let kinematics = antenna_kinematics(
888        state,
889        config.lever_arm_body_m,
890        body_rate_wrt_ecef_rps,
891        crate::inertial::state::mat3_identity(),
892    );
893    let options = TransmitTimeOptions {
894        light_time: config.light_time,
895        sagnac: config.sagnac,
896    };
897    let mut predictions = Vec::new();
898    for observation in &epoch.observations {
899        let code_satellite = tight_code_satellite_prediction(
900            source,
901            observation.satellite_id,
902            kinematics.antenna_position_ecef_m,
903            epoch.t_j2000_s,
904            observation.pseudorange_m,
905            options,
906        )
907        .map_err(map_observables_error)?;
908        predictions.push(
909            code_satellite.clock_corrected_range_m
910                + clock_bias_m
911                + observation.ionosphere_delay_m
912                + observation.troposphere_delay_m,
913        );
914
915        if let Some(carrier_phase) = observation.carrier_phase {
916            predictions.push(
917                code_satellite.clock_corrected_range_m + clock_bias_m
918                    - observation.ionosphere_delay_m
919                    + observation.troposphere_delay_m
920                    + carrier_phase.float_ambiguity_m,
921            );
922        }
923
924        if let Some(range_rate) = observation.range_rate {
925            let satellite = transmit_time_satellite_state(
926                source,
927                observation.satellite_id,
928                kinematics.antenna_position_ecef_m,
929                epoch.t_j2000_s,
930                options,
931            )
932            .map_err(map_observables_error)?;
933            let velocity_observation = VelocityObservation {
934                sat: observation.satellite_id,
935                satellite_position_m: satellite.position_ecef_m,
936                satellite_velocity_m_s: satellite.velocity_m_s,
937                measured_range_rate_m_s: range_rate.measured_range_rate_m_s,
938                sigma_m_s: range_rate.sigma_m_s,
939                satellite_clock_drift_m_s: range_rate.satellite_clock_drift_m_s,
940            };
941            let receiver = ReceiverVelocityState {
942                position_m: kinematics.antenna_position_ecef_m,
943                velocity_m_s: kinematics.antenna_velocity_ecef_mps,
944                clock_drift_m_s,
945            };
946            let prediction = predict_range_rate_m_s(&velocity_observation, receiver)
947                .ok_or_else(|| invalid_input("range_rate", "line of sight must be nonzero"))?;
948            predictions.push(prediction.range_rate_m_s);
949        }
950    }
951    validate_finite_slice(&predictions, "tight_prediction")?;
952    Ok(predictions)
953}
954
955#[derive(Debug, Clone, Copy)]
956struct CodeSatellitePrediction {
957    clock_corrected_range_m: f64,
958    los_unit: [f64; 3],
959}
960
961fn tight_code_satellite_prediction(
962    source: &dyn ObservableEphemerisSource,
963    sat: crate::GnssSatelliteId,
964    receiver_ecef_m: [f64; 3],
965    t_rx_j2000_s: f64,
966    pseudorange_m: f64,
967    options: TransmitTimeOptions,
968) -> Result<CodeSatellitePrediction, ObservablesError> {
969    if options.light_time {
970        return spp_code_satellite_prediction(
971            source,
972            sat,
973            receiver_ecef_m,
974            t_rx_j2000_s,
975            pseudorange_m,
976            options.sagnac,
977        );
978    }
979
980    let satellite =
981        transmit_time_satellite_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
982    let sat_clock_s = satellite.clock_s.ok_or(ObservablesError::NoEphemeris)?;
983    Ok(CodeSatellitePrediction {
984        clock_corrected_range_m: satellite.geometric_range_m - C_M_S * sat_clock_s,
985        los_unit: satellite.los_unit,
986    })
987}
988
989fn spp_code_satellite_prediction(
990    source: &dyn ObservableEphemerisSource,
991    sat: crate::GnssSatelliteId,
992    receiver_ecef_m: [f64; 3],
993    t_rx_j2000_s: f64,
994    pseudorange_m: f64,
995    sagnac: bool,
996) -> Result<CodeSatellitePrediction, ObservablesError> {
997    let source = ObservableClockSource { source };
998    let glonass_channels = std::collections::BTreeMap::new();
999    let met = SurfaceMet::default();
1000    let env = SatModelEnv {
1001        eph: &source,
1002        t_rx_j2000_s,
1003        t_rx_second_of_day_s: 0.0,
1004        day_of_year: 1.0,
1005        corrections: Corrections::NONE,
1006        met: &met,
1007        glonass_channels: &glonass_channels,
1008        model: SppModelRecipe {
1009            range: RangeRecipe::SppMeasuredPseudorangeFixedIter,
1010            sagnac: if sagnac {
1011                SagnacRecipe::ClosedFormZRotation
1012            } else {
1013                SagnacRecipe::Off
1014            },
1015            frame: FrameRecipe::SppSkyfieldAuThreeIter,
1016        },
1017    };
1018    let model = sat_model(
1019        &env,
1020        sat,
1021        receiver_ecef_m,
1022        0.0,
1023        pseudorange_m,
1024        SppIonosphere::Klobuchar(KlobucharCoeffs {
1025            alpha: [0.0; 4],
1026            beta: [0.0; 4],
1027        }),
1028    )
1029    .ok_or(ObservablesError::NoEphemeris)?;
1030    let line_of_sight = sub3(model.sat_rot_ecef_m, receiver_ecef_m);
1031    let range = norm3(line_of_sight);
1032    if !range.is_finite() || range <= 0.0 {
1033        return Err(ObservablesError::InvalidInput {
1034            field: "receiver_ecef_m",
1035            kind: crate::observables::ObservablesInputErrorKind::OutOfRange,
1036        });
1037    }
1038    let los_unit = [
1039        line_of_sight[0] / range,
1040        line_of_sight[1] / range,
1041        line_of_sight[2] / range,
1042    ];
1043    crate::validate::finite_vec3(los_unit, "los_unit").map_err(|_| {
1044        ObservablesError::InvalidInput {
1045            field: "receiver_ecef_m",
1046            kind: crate::observables::ObservablesInputErrorKind::OutOfRange,
1047        }
1048    })?;
1049    Ok(CodeSatellitePrediction {
1050        clock_corrected_range_m: model.p_hat_m,
1051        los_unit,
1052    })
1053}
1054
1055struct ObservableClockSource<'a> {
1056    source: &'a dyn ObservableEphemerisSource,
1057}
1058
1059impl EphemerisSource for ObservableClockSource<'_> {
1060    fn position_clock_at_j2000_s(
1061        &self,
1062        sat: crate::GnssSatelliteId,
1063        t_j2000_s: f64,
1064    ) -> Option<([f64; 3], f64)> {
1065        let state = self
1066            .source
1067            .observable_state_at_j2000_s(sat, t_j2000_s)
1068            .ok()?;
1069        Some((state.position_ecef_m, state.clock_s?))
1070    }
1071}
1072
1073#[derive(Debug, Clone, Copy)]
1074struct AntennaKinematics {
1075    antenna_position_ecef_m: [f64; 3],
1076    antenna_velocity_ecef_mps: [f64; 3],
1077    lever_arm_ecef_m: [f64; 3],
1078    lever_velocity_ecef_mps: [f64; 3],
1079    gyro_bias_velocity_block: [[f64; 3]; 3],
1080}
1081
1082fn antenna_kinematics(
1083    state: &InsFilterState,
1084    lever_arm_body_m: [f64; 3],
1085    body_rate_wrt_ecef_rps: [f64; 3],
1086    imu_to_body_dcm: Mat3,
1087) -> AntennaKinematics {
1088    let c_b_e = state.nominal.attitude_body_to_ecef;
1089    let lever_arm_ecef_m = mul_vec3(&c_b_e, lever_arm_body_m);
1090    let antenna_position_ecef_m = add3(state.nominal.position_ecef_m, lever_arm_ecef_m);
1091    let lever_velocity_body_mps = cross3(body_rate_wrt_ecef_rps, lever_arm_body_m);
1092    let lever_velocity_ecef_mps = mul_vec3(&c_b_e, lever_velocity_body_mps);
1093    let antenna_velocity_ecef_mps = add3(state.nominal.velocity_ecef_mps, lever_velocity_ecef_mps);
1094    let gyro_bias_velocity_block = inline_rxr(
1095        &inline_rxr(&c_b_e, &skew(lever_arm_body_m)),
1096        &imu_to_body_dcm,
1097    );
1098    AntennaKinematics {
1099        antenna_position_ecef_m,
1100        antenna_velocity_ecef_mps,
1101        lever_arm_ecef_m,
1102        lever_velocity_ecef_mps,
1103        gyro_bias_velocity_block,
1104    }
1105}
1106
1107fn pseudorange_design_row(
1108    aug_dim: usize,
1109    clock_bias: usize,
1110    los_unit: [f64; 3],
1111    lever_arm_ecef_m: [f64; 3],
1112) -> Vec<f64> {
1113    let mut row = vec![0.0; aug_dim];
1114    row[ERROR_POSITION_INDEX..ERROR_POSITION_INDEX + 3].copy_from_slice(&los_unit);
1115    let lever_skew = skew(lever_arm_ecef_m);
1116    for col in 0..3 {
1117        row[ERROR_ATTITUDE_INDEX + col] = -(los_unit[0] * lever_skew[0][col]
1118            + los_unit[1] * lever_skew[1][col]
1119            + los_unit[2] * lever_skew[2][col]);
1120    }
1121    row[clock_bias] = 1.0;
1122    row
1123}
1124
1125fn range_rate_design_row(
1126    aug_dim: usize,
1127    clock_drift: usize,
1128    los_unit: [f64; 3],
1129    lever_velocity_ecef_mps: [f64; 3],
1130    gyro_bias_velocity_block: [[f64; 3]; 3],
1131) -> Vec<f64> {
1132    let mut row = vec![0.0; aug_dim];
1133    row[ERROR_VELOCITY_INDEX..ERROR_VELOCITY_INDEX + 3].copy_from_slice(&los_unit);
1134    let lever_velocity_skew = skew(lever_velocity_ecef_mps);
1135    for col in 0..3 {
1136        row[ERROR_ATTITUDE_INDEX + col] = -(los_unit[0] * lever_velocity_skew[0][col]
1137            + los_unit[1] * lever_velocity_skew[1][col]
1138            + los_unit[2] * lever_velocity_skew[2][col]);
1139        row[ERROR_GYRO_BIAS_INDEX + col] = -(los_unit[0] * gyro_bias_velocity_block[0][col]
1140            + los_unit[1] * gyro_bias_velocity_block[1][col]
1141            + los_unit[2] * gyro_bias_velocity_block[2][col]);
1142    }
1143    row[clock_drift] = 1.0;
1144    row
1145}
1146
1147fn diagonal_covariance(variances: &[f64]) -> Result<Vec<Vec<f64>>, FusionError> {
1148    if variances.is_empty() {
1149        return Err(invalid_input("measurement_covariance", "must not be empty"));
1150    }
1151    let mut covariance = vec![vec![0.0; variances.len()]; variances.len()];
1152    for (idx, variance) in variances.iter().enumerate() {
1153        validate_positive(*variance, "measurement_variance")?;
1154        covariance[idx][idx] = *variance;
1155    }
1156    Ok(covariance)
1157}
1158
1159fn map_observables_error(error: ObservablesError) -> FusionError {
1160    match error {
1161        ObservablesError::NoEphemeris => invalid_input("ephemeris", "no usable satellite state"),
1162        ObservablesError::InvalidInput { .. } => {
1163            invalid_input("observable_state", "must be finite and in range")
1164        }
1165        ObservablesError::Ephemeris(_) => invalid_input("ephemeris", "satellite state failed"),
1166        ObservablesError::Media(_) => invalid_input("media", "correction failed"),
1167    }
1168}
1169
1170pub(super) const fn augmented_dimension(base_dim: usize) -> usize {
1171    base_dim + TIGHT_CLOCK_STATE_COUNT
1172}
1173
1174pub(super) const fn clock_bias_index(base_dim: usize) -> usize {
1175    base_dim + TIGHT_CLOCK_BIAS_OFFSET
1176}
1177
1178pub(super) const fn clock_drift_index(base_dim: usize) -> usize {
1179    base_dim + TIGHT_CLOCK_DRIFT_OFFSET
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184    //! Provenance: tight-coupled GNSS/INS rows follow Groves, Principles of
1185    //! GNSS, Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.,
1186    //! Chapter 14.2. Pseudorange-only convergence is checked against the
1187    //! in-crate SPP solver as an independent snapshot oracle. Doppler rows are
1188    //! checked against the existing `predict_range_rate_m_s` primitive. The
1189    //! weak-geometry properties check the information-form identity
1190    //! `P_plus^-1 = P_minus^-1 + H' R^-1 H`.
1191
1192    use super::*;
1193    use crate::astro::constants::earth::WGS84_A_M;
1194    use crate::fusion::state::{
1195        covariance_is_positive_semidefinite, ErrorStateLayout, ERROR_STATE_DIMENSION_15,
1196    };
1197    use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
1198    use crate::inertial::state::mat3_identity;
1199    use crate::inertial::{ImuSample, ImuSpec, NavState};
1200    use crate::observables::{ObservableState, ObservablesError};
1201    use crate::spp::{
1202        Corrections, KlobucharCoeffs, Observation, SolveInputs, SppError, SurfaceMet,
1203    };
1204    use crate::{GnssSatelliteId, GnssSystem};
1205    use nalgebra::{DMatrix, DVector};
1206
1207    const T0: f64 = 646_229_000.0;
1208    const SOD: f64 = 200.0;
1209    const DOY: f64 = 176.0;
1210
1211    #[derive(Debug, Clone)]
1212    struct LinearSource {
1213        t0_j2000_s: f64,
1214        states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>,
1215    }
1216
1217    impl LinearSource {
1218        fn new(t0_j2000_s: f64, states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>) -> Self {
1219            Self { t0_j2000_s, states }
1220        }
1221    }
1222
1223    impl ObservableEphemerisSource for LinearSource {
1224        fn observable_state_at_j2000_s(
1225            &self,
1226            sat: GnssSatelliteId,
1227            t_j2000_s: f64,
1228        ) -> Result<ObservableState, ObservablesError> {
1229            let (_, position, velocity, clock_s) = self
1230                .states
1231                .iter()
1232                .find(|(id, _, _, _)| *id == sat)
1233                .ok_or(ObservablesError::NoEphemeris)?;
1234            let dt_s = t_j2000_s - self.t0_j2000_s;
1235            Ok(ObservableState {
1236                position_ecef_m: [
1237                    position[0] + velocity[0] * dt_s,
1238                    position[1] + velocity[1] * dt_s,
1239                    position[2] + velocity[2] * dt_s,
1240                ],
1241                clock_s: Some(*clock_s),
1242            })
1243        }
1244    }
1245
1246    impl crate::spp::EphemerisSource for LinearSource {
1247        fn position_clock_at_j2000_s(
1248            &self,
1249            sat: GnssSatelliteId,
1250            t_j2000_s: f64,
1251        ) -> Option<([f64; 3], f64)> {
1252            let (_, position, velocity, clock_s) =
1253                self.states.iter().find(|(id, _, _, _)| *id == sat)?;
1254            let dt_s = t_j2000_s - self.t0_j2000_s;
1255            Some((
1256                [
1257                    position[0] + velocity[0] * dt_s,
1258                    position[1] + velocity[1] * dt_s,
1259                    position[2] + velocity[2] * dt_s,
1260                ],
1261                *clock_s,
1262            ))
1263        }
1264    }
1265
1266    fn sat(prn: u8) -> GnssSatelliteId {
1267        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1268    }
1269
1270    fn normalized(v: [f64; 3]) -> [f64; 3] {
1271        let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1272        [v[0] / n, v[1] / n, v[2] / n]
1273    }
1274
1275    fn source_from_directions(receiver: [f64; 3], directions: &[[f64; 3]]) -> LinearSource {
1276        source_from_directions_at_range(receiver, directions, 22_000_000.0)
1277    }
1278
1279    fn source_from_directions_at_range(
1280        receiver: [f64; 3],
1281        directions: &[[f64; 3]],
1282        range_m: f64,
1283    ) -> LinearSource {
1284        let states = directions
1285            .iter()
1286            .enumerate()
1287            .map(|(idx, direction)| {
1288                let unit = normalized(*direction);
1289                (
1290                    sat((idx + 1) as u8),
1291                    [
1292                        receiver[0] + range_m * unit[0],
1293                        receiver[1] + range_m * unit[1],
1294                        receiver[2] + range_m * unit[2],
1295                    ],
1296                    [0.0; 3],
1297                    0.0,
1298                )
1299            })
1300            .collect();
1301        LinearSource::new(T0, states)
1302    }
1303
1304    fn tight_epoch_from_source(
1305        source: &LinearSource,
1306        receiver: [f64; 3],
1307        clock_m: f64,
1308        sigma_m: f64,
1309    ) -> TightGnssEpoch {
1310        let observations = source
1311            .states
1312            .iter()
1313            .map(|(satellite_id, _, _, _)| {
1314                let prediction = transmit_time_satellite_state(
1315                    source,
1316                    *satellite_id,
1317                    receiver,
1318                    T0,
1319                    TransmitTimeOptions::default(),
1320                )
1321                .expect("satellite state");
1322                TightGnssObservation::pseudorange(
1323                    *satellite_id,
1324                    prediction.geometric_range_m + clock_m,
1325                    sigma_m,
1326                )
1327                .expect("observation")
1328            })
1329            .collect();
1330        TightGnssEpoch::new(T0, observations).expect("tight epoch")
1331    }
1332
1333    fn solve_inputs_from_epoch(epoch: &TightGnssEpoch, initial_guess: [f64; 4]) -> SolveInputs {
1334        SolveInputs {
1335            observations: epoch
1336                .observations
1337                .iter()
1338                .map(|observation| Observation {
1339                    satellite_id: observation.satellite_id,
1340                    pseudorange_m: observation.pseudorange_m,
1341                })
1342                .collect(),
1343            t_rx_j2000_s: epoch.t_j2000_s,
1344            t_rx_second_of_day_s: SOD,
1345            day_of_year: DOY,
1346            initial_guess,
1347            corrections: Corrections::NONE,
1348            klobuchar: KlobucharCoeffs {
1349                alpha: [0.0; 4],
1350                beta: [0.0; 4],
1351            },
1352            beidou_klobuchar: None,
1353            galileo_nequick: None,
1354            sbas_iono: None,
1355            glonass_channels: std::collections::BTreeMap::new(),
1356            met: SurfaceMet::default(),
1357            robust: None,
1358        }
1359    }
1360
1361    fn zero_noise_spec() -> ImuSpec {
1362        ImuSpec::datasheet(
1363            0.0,
1364            0.0,
1365            0.0,
1366            0.0,
1367            RANDOM_WALK_BIAS_TAU_S,
1368            RANDOM_WALK_BIAS_TAU_S,
1369            None,
1370            None,
1371        )
1372    }
1373
1374    fn filter_with_config(
1375        nominal: NavState,
1376        diagonal: &[f64],
1377        tight: TightCouplingConfig,
1378    ) -> InertialFilter {
1379        filter_with_kind(nominal, diagonal, tight, FusionFilterKind::Ekf)
1380    }
1381
1382    fn filter_with_kind(
1383        nominal: NavState,
1384        diagonal: &[f64],
1385        tight: TightCouplingConfig,
1386        filter_kind: FusionFilterKind,
1387    ) -> InertialFilter {
1388        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, diagonal)
1389            .expect("state");
1390        let mut config =
1391            super::super::loose::InertialFilterConfig::new(zero_noise_spec()).expect("config");
1392        config.tight = tight;
1393        config.filter_kind = filter_kind;
1394        InertialFilter::with_config(state, config).expect("filter")
1395    }
1396
1397    fn tight_config_for_test() -> TightCouplingConfig {
1398        TightCouplingConfig {
1399            initial_clock_bias_variance_m2: 1.0e12,
1400            initial_clock_drift_variance_m2_s2: 1.0e6,
1401            clock_bias_random_walk_m2_s: 0.0,
1402            clock_drift_random_walk_m2_s3: 0.0,
1403            ..TightCouplingConfig::default()
1404        }
1405    }
1406
1407    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1408        assert!(
1409            (actual - expected).abs() <= tolerance,
1410            "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
1411        );
1412    }
1413
1414    #[test]
1415    fn range_rate_gyro_bias_row_rotates_imu_to_body_dcm() {
1416        let nominal = NavState::new(
1417            T0,
1418            [WGS84_A_M + 10.0, 20.0, -30.0],
1419            [0.0; 3],
1420            mat3_identity(),
1421        )
1422        .expect("nominal");
1423        let state = InsFilterState::from_diagonal(
1424            nominal,
1425            ErrorStateLayout::Fifteen,
1426            &[1.0; ERROR_STATE_DIMENSION_15],
1427        )
1428        .expect("state");
1429        let lever_arm_body_m = [2.0, -3.0, 5.0];
1430        let imu_to_body = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
1431        let kinematics =
1432            antenna_kinematics(&state, lever_arm_body_m, [0.01, -0.02, 0.03], imu_to_body);
1433        let los_unit = normalized([0.25, -0.5, 0.75]);
1434        let row = range_rate_design_row(
1435            augmented_dimension(ERROR_STATE_DIMENSION_15),
1436            clock_drift_index(ERROR_STATE_DIMENSION_15),
1437            los_unit,
1438            kinematics.lever_velocity_ecef_mps,
1439            kinematics.gyro_bias_velocity_block,
1440        );
1441        let expected_block = inline_rxr(&skew(lever_arm_body_m), &imu_to_body);
1442        let unrotated_block = skew(lever_arm_body_m);
1443
1444        for col in 0..3 {
1445            let expected = -(los_unit[0] * expected_block[0][col]
1446                + los_unit[1] * expected_block[1][col]
1447                + los_unit[2] * expected_block[2][col]);
1448            assert_eq!(
1449                row[ERROR_GYRO_BIAS_INDEX + col].to_bits(),
1450                expected.to_bits()
1451            );
1452        }
1453        let unrotated_col0 = -(los_unit[0] * unrotated_block[0][0]
1454            + los_unit[1] * unrotated_block[1][0]
1455            + los_unit[2] * unrotated_block[2][0]);
1456        assert_ne!(
1457            row[ERROR_GYRO_BIAS_INDEX].to_bits(),
1458            unrotated_col0.to_bits()
1459        );
1460    }
1461
1462    fn logdet_spd(matrix: &[Vec<f64>]) -> f64 {
1463        let n = matrix.len();
1464        let flat = matrix.iter().flatten().copied().collect::<Vec<_>>();
1465        let dmatrix = DMatrix::from_row_slice(n, n, &flat);
1466        let cholesky = dmatrix.cholesky().expect("SPD matrix");
1467        2.0 * cholesky
1468            .l()
1469            .diagonal()
1470            .iter()
1471            .map(|value| value.ln())
1472            .sum::<f64>()
1473    }
1474
1475    fn position_clock_block(filter: &InertialFilter) -> Vec<Vec<f64>> {
1476        let base_dim = filter.state.dimension();
1477        let clock = clock_bias_index(base_dim);
1478        let indices = [0usize, 1, 2, clock];
1479        indices
1480            .iter()
1481            .map(|row| {
1482                indices
1483                    .iter()
1484                    .map(|col| filter.tight.augmented_covariance[*row][*col])
1485                    .collect::<Vec<_>>()
1486            })
1487            .collect()
1488    }
1489
1490    fn position_clock_nees(
1491        filter: &InertialFilter,
1492        truth_position_m: [f64; 3],
1493        truth_clock_m: f64,
1494    ) -> f64 {
1495        let block = position_clock_block(filter);
1496        let flat = block.iter().flatten().copied().collect::<Vec<_>>();
1497        let covariance = DMatrix::from_row_slice(4, 4, &flat);
1498        let clock = filter.tight_clock_state().expect("clock");
1499        let error = DVector::from_vec(vec![
1500            filter.state().nominal.position_ecef_m[0] - truth_position_m[0],
1501            filter.state().nominal.position_ecef_m[1] - truth_position_m[1],
1502            filter.state().nominal.position_ecef_m[2] - truth_position_m[2],
1503            clock.bias_m - truth_clock_m,
1504        ]);
1505        let solved = covariance
1506            .cholesky()
1507            .expect("posterior covariance SPD")
1508            .solve(&error);
1509        error.dot(&solved)
1510    }
1511
1512    fn snapshot_position_clock_covariance(
1513        source: &LinearSource,
1514        receiver: [f64; 3],
1515        epoch: &TightGnssEpoch,
1516    ) -> Vec<Vec<f64>> {
1517        let mut normal = DMatrix::<f64>::zeros(4, 4);
1518        for observation in &epoch.observations {
1519            let prediction = transmit_time_satellite_state(
1520                source,
1521                observation.satellite_id,
1522                receiver,
1523                epoch.t_j2000_s,
1524                TransmitTimeOptions::default(),
1525            )
1526            .expect("satellite state");
1527            let h = [
1528                prediction.los_unit[0],
1529                prediction.los_unit[1],
1530                prediction.los_unit[2],
1531                1.0,
1532            ];
1533            let inv_var = 1.0 / (observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
1534            for row in 0..4 {
1535                for col in 0..4 {
1536                    normal[(row, col)] += h[row] * h[col] * inv_var;
1537                }
1538            }
1539        }
1540        let covariance = normal.try_inverse().expect("full-rank snapshot");
1541        (0..4)
1542            .map(|row| (0..4).map(|col| covariance[(row, col)]).collect())
1543            .collect()
1544    }
1545
1546    #[test]
1547    fn pseudorange_only_update_matches_spp_clock_oracle_with_frozen_ins_prior() {
1548        let receiver = [WGS84_A_M, 0.0, 0.0];
1549        let directions = [
1550            [1.0, 0.0, 0.0],
1551            [0.82, 0.42, 0.39],
1552            [0.83, -0.46, 0.31],
1553            [0.90, 0.18, -0.40],
1554            [0.78, -0.25, -0.58],
1555        ];
1556        let clock_m = 12.5;
1557        let source = source_from_directions(receiver, &directions);
1558        let epoch = tight_epoch_from_source(&source, receiver, clock_m, 1.0);
1559        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1560        let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1561
1562        let spp_position = spp.position.as_array();
1563        let nominal = NavState::new(T0, spp_position, [0.0; 3], mat3_identity()).expect("nominal");
1564        let diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1565        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1566
1567        let update = filter.update_tight(&source, &epoch).expect("tight update");
1568
1569        assert!(update.applied);
1570        for (got, expected) in filter
1571            .state()
1572            .nominal
1573            .position_ecef_m
1574            .iter()
1575            .zip(spp_position)
1576        {
1577            assert_close(*got, expected, 1.0e-6);
1578        }
1579        let clock = filter.tight_clock_state().expect("clock");
1580        assert_close(clock.bias_m, spp.rx_clock_s * C_M_S, 1.0e-5);
1581    }
1582
1583    #[test]
1584    fn doppler_row_uses_range_rate_predictor_geometry_bits() {
1585        let receiver = [WGS84_A_M, 0.0, 0.0];
1586        let satellite_id = sat(1);
1587        let source = LinearSource::new(
1588            T0,
1589            vec![(
1590                satellite_id,
1591                [WGS84_A_M + 22_000_000.0, 1_000_000.0, 2_000_000.0],
1592                [120.0, -40.0, 30.0],
1593                0.0,
1594            )],
1595        );
1596        let sat_state = transmit_time_satellite_state(
1597            &source,
1598            satellite_id,
1599            receiver,
1600            T0,
1601            TransmitTimeOptions::default(),
1602        )
1603        .expect("satellite state");
1604        let measured_receiver = ReceiverVelocityState {
1605            position_m: receiver,
1606            velocity_m_s: [5.0, -2.0, 1.0],
1607            clock_drift_m_s: 0.25,
1608        };
1609        let velocity_observation = VelocityObservation {
1610            sat: satellite_id,
1611            satellite_position_m: sat_state.position_ecef_m,
1612            satellite_velocity_m_s: sat_state.velocity_m_s,
1613            measured_range_rate_m_s: 0.0,
1614            sigma_m_s: 0.05,
1615            satellite_clock_drift_m_s: 0.01,
1616        };
1617        let measured = predict_range_rate_m_s(&velocity_observation, measured_receiver)
1618            .expect("measured range rate")
1619            .range_rate_m_s;
1620        let observation = TightGnssObservation {
1621            satellite_id,
1622            pseudorange_m: sat_state.geometric_range_m,
1623            pseudorange_sigma_m: 2.0,
1624            range_rate: Some(TightRangeRateObservation {
1625                measured_range_rate_m_s: measured,
1626                sigma_m_s: 0.05,
1627                satellite_clock_drift_m_s: 0.01,
1628            }),
1629            carrier_phase: None,
1630            ionosphere_delay_m: 0.0,
1631            troposphere_delay_m: 0.0,
1632        };
1633        let epoch = TightGnssEpoch::new(T0, vec![observation]).expect("epoch");
1634        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1635        let filter = filter_with_config(
1636            nominal,
1637            &[1.0; ERROR_STATE_DIMENSION_15],
1638            tight_config_for_test(),
1639        );
1640        let correction = tight_coupling_correction(
1641            &source,
1642            filter.state(),
1643            &filter.tight,
1644            &epoch,
1645            filter.config.tight,
1646            filter.config.imu_to_body_dcm,
1647            [0.0; 3],
1648        )
1649        .expect("correction");
1650        let predicted_at_nominal = predict_range_rate_m_s(
1651            &VelocityObservation {
1652                measured_range_rate_m_s: measured,
1653                ..velocity_observation
1654            },
1655            ReceiverVelocityState {
1656                position_m: receiver,
1657                velocity_m_s: [0.0; 3],
1658                clock_drift_m_s: 0.0,
1659            },
1660        )
1661        .expect("nominal range rate");
1662
1663        let doppler_row = &correction.design[1];
1664        for axis in 0..3 {
1665            assert_eq!(
1666                doppler_row[ERROR_VELOCITY_INDEX + axis].to_bits(),
1667                predicted_at_nominal.los_unit[axis].to_bits()
1668            );
1669        }
1670        assert_eq!(
1671            doppler_row[clock_drift_index(filter.state.dimension())].to_bits(),
1672            1.0_f64.to_bits()
1673        );
1674        assert_eq!(
1675            correction.innovation[1].to_bits(),
1676            (measured - predicted_at_nominal.range_rate_m_s).to_bits()
1677        );
1678    }
1679
1680    #[derive(Debug, Clone)]
1681    struct MovingClockSource {
1682        t0_j2000_s: f64,
1683        states: Vec<MovingClockState>,
1684    }
1685
1686    #[derive(Debug, Clone, Copy)]
1687    struct MovingClockState {
1688        satellite_id: GnssSatelliteId,
1689        position_ecef_m: [f64; 3],
1690        velocity_ecef_m_s: [f64; 3],
1691        clock_s: f64,
1692        clock_drift_s_s: f64,
1693    }
1694
1695    impl ObservableEphemerisSource for MovingClockSource {
1696        fn observable_state_at_j2000_s(
1697            &self,
1698            sat: GnssSatelliteId,
1699            t_j2000_s: f64,
1700        ) -> Result<ObservableState, ObservablesError> {
1701            let state = self
1702                .states
1703                .iter()
1704                .find(|state| state.satellite_id == sat)
1705                .ok_or(ObservablesError::NoEphemeris)?;
1706            let dt_s = t_j2000_s - self.t0_j2000_s;
1707            Ok(ObservableState {
1708                position_ecef_m: [
1709                    state.position_ecef_m[0] + state.velocity_ecef_m_s[0] * dt_s,
1710                    state.position_ecef_m[1] + state.velocity_ecef_m_s[1] * dt_s,
1711                    state.position_ecef_m[2] + state.velocity_ecef_m_s[2] * dt_s,
1712                ],
1713                clock_s: Some(state.clock_s + state.clock_drift_s_s * dt_s),
1714            })
1715        }
1716    }
1717
1718    impl crate::spp::EphemerisSource for MovingClockSource {
1719        fn position_clock_at_j2000_s(
1720            &self,
1721            sat: GnssSatelliteId,
1722            t_j2000_s: f64,
1723        ) -> Option<([f64; 3], f64)> {
1724            let state = self.states.iter().find(|state| state.satellite_id == sat)?;
1725            let dt_s = t_j2000_s - self.t0_j2000_s;
1726            Some((
1727                [
1728                    state.position_ecef_m[0] + state.velocity_ecef_m_s[0] * dt_s,
1729                    state.position_ecef_m[1] + state.velocity_ecef_m_s[1] * dt_s,
1730                    state.position_ecef_m[2] + state.velocity_ecef_m_s[2] * dt_s,
1731                ],
1732                state.clock_s + state.clock_drift_s_s * dt_s,
1733            ))
1734        }
1735    }
1736
1737    #[derive(Debug, Clone, Copy)]
1738    struct CodeOracleTerms {
1739        geometric_m: f64,
1740        satellite_clock_m: f64,
1741        ionosphere_m: f64,
1742        troposphere_m: f64,
1743        total_m: f64,
1744    }
1745
1746    impl CodeOracleTerms {
1747        fn from_spp_model(
1748            source: &MovingClockSource,
1749            sat: GnssSatelliteId,
1750            receiver: [f64; 3],
1751            pseudorange_m: f64,
1752            ionosphere_m: f64,
1753            troposphere_m: f64,
1754            receiver_clock_m: f64,
1755        ) -> Self {
1756            let glonass_channels = std::collections::BTreeMap::new();
1757            let met = SurfaceMet::default();
1758            let env = SatModelEnv {
1759                eph: source,
1760                t_rx_j2000_s: T0,
1761                t_rx_second_of_day_s: SOD,
1762                day_of_year: DOY,
1763                corrections: Corrections::NONE,
1764                met: &met,
1765                glonass_channels: &glonass_channels,
1766                model: SppModelRecipe::reference(),
1767            };
1768            let model = sat_model(
1769                &env,
1770                sat,
1771                receiver,
1772                0.0,
1773                pseudorange_m,
1774                SppIonosphere::Klobuchar(KlobucharCoeffs {
1775                    alpha: [0.0; 4],
1776                    beta: [0.0; 4],
1777                }),
1778            )
1779            .expect("SPP model");
1780            let geometric_m = norm3(sub3(model.sat_rot_ecef_m, receiver));
1781            let satellite_clock_m = model.p_hat_m - geometric_m;
1782            let total_m = model.p_hat_m + receiver_clock_m + ionosphere_m + troposphere_m;
1783            Self {
1784                geometric_m,
1785                satellite_clock_m,
1786                ionosphere_m,
1787                troposphere_m,
1788                total_m,
1789            }
1790        }
1791
1792        fn from_observable_model(
1793            source: &MovingClockSource,
1794            sat: GnssSatelliteId,
1795            receiver: [f64; 3],
1796            ionosphere_m: f64,
1797            troposphere_m: f64,
1798            receiver_clock_m: f64,
1799        ) -> Self {
1800            let prediction = transmit_time_satellite_state(
1801                source,
1802                sat,
1803                receiver,
1804                T0,
1805                TransmitTimeOptions::default(),
1806            )
1807            .expect("observable model");
1808            let satellite_clock_m = -C_M_S * prediction.clock_s.expect("satellite clock");
1809            let total_m = prediction.geometric_range_m
1810                + satellite_clock_m
1811                + receiver_clock_m
1812                + ionosphere_m
1813                + troposphere_m;
1814            Self {
1815                geometric_m: prediction.geometric_range_m,
1816                satellite_clock_m,
1817                ionosphere_m,
1818                troposphere_m,
1819                total_m,
1820            }
1821        }
1822
1823        fn tight_total_m(
1824            source: &MovingClockSource,
1825            sat: GnssSatelliteId,
1826            receiver: [f64; 3],
1827            pseudorange_m: f64,
1828            ionosphere_m: f64,
1829            troposphere_m: f64,
1830            receiver_clock_m: f64,
1831        ) -> f64 {
1832            let prediction = tight_code_satellite_prediction(
1833                source,
1834                sat,
1835                receiver,
1836                T0,
1837                pseudorange_m,
1838                TransmitTimeOptions::default(),
1839            )
1840            .expect("tight code model");
1841            prediction.clock_corrected_range_m + receiver_clock_m + ionosphere_m + troposphere_m
1842        }
1843    }
1844
1845    #[test]
1846    fn synthetic_code_oracle_pins_tight_to_spp_residual_surface() {
1847        let receiver = [WGS84_A_M, 0.0, 0.0];
1848        let rows = [
1849            (
1850                "high-elevation",
1851                sat(1),
1852                20_800_000.0,
1853                normalized([0.96, 0.17, 0.23]),
1854                [220.0, -680.0, 120.0],
1855                2.0e-5,
1856                2.0e-10,
1857                1.25,
1858                2.40,
1859            ),
1860            (
1861                "low-elevation",
1862                sat(2),
1863                24_200_000.0,
1864                normalized([0.09, 0.98, 0.18]),
1865                [-180.0, 1120.0, -460.0],
1866                -1.0e-5,
1867                -4.0e-10,
1868                5.75,
1869                8.80,
1870            ),
1871            (
1872                "fast-moving",
1873                sat(3),
1874                25_400_000.0,
1875                normalized([0.34, -0.73, 0.59]),
1876                [28_400.0, -31_200.0, 16_400.0],
1877                1.5e-5,
1878                1.2e-8,
1879                3.40,
1880                4.65,
1881            ),
1882        ];
1883        let source = MovingClockSource {
1884            t0_j2000_s: T0,
1885            states: rows
1886                .iter()
1887                .map(
1888                    |(
1889                        _label,
1890                        satellite_id,
1891                        range_m,
1892                        direction,
1893                        velocity_m_s,
1894                        clock_s,
1895                        clock_drift_s_s,
1896                        _iono_m,
1897                        _tropo_m,
1898                    )| {
1899                        MovingClockState {
1900                            satellite_id: *satellite_id,
1901                            position_ecef_m: [
1902                                receiver[0] + range_m * direction[0],
1903                                receiver[1] + range_m * direction[1],
1904                                receiver[2] + range_m * direction[2],
1905                            ],
1906                            velocity_ecef_m_s: *velocity_m_s,
1907                            clock_s: *clock_s,
1908                            clock_drift_s_s: *clock_drift_s_s,
1909                        }
1910                    },
1911                )
1912                .collect(),
1913        };
1914        let receiver_clock_m = 43.25;
1915        let mut max_observable_minus_spp_m = 0.0_f64;
1916
1917        for (
1918            label,
1919            satellite_id,
1920            range_m,
1921            _direction,
1922            _velocity_m_s,
1923            _clock_s,
1924            _clock_drift_s_s,
1925            ionosphere_m,
1926            troposphere_m,
1927        ) in rows
1928        {
1929            let pseudorange_m = range_m + receiver_clock_m + ionosphere_m + troposphere_m + 11.0;
1930            let spp = CodeOracleTerms::from_spp_model(
1931                &source,
1932                satellite_id,
1933                receiver,
1934                pseudorange_m,
1935                ionosphere_m,
1936                troposphere_m,
1937                receiver_clock_m,
1938            );
1939            let observable = CodeOracleTerms::from_observable_model(
1940                &source,
1941                satellite_id,
1942                receiver,
1943                ionosphere_m,
1944                troposphere_m,
1945                receiver_clock_m,
1946            );
1947            let tight_total_m = CodeOracleTerms::tight_total_m(
1948                &source,
1949                satellite_id,
1950                receiver,
1951                pseudorange_m,
1952                ionosphere_m,
1953                troposphere_m,
1954                receiver_clock_m,
1955            );
1956            let geom_delta_m = observable.geometric_m - spp.geometric_m;
1957            let sat_clock_delta_m = observable.satellite_clock_m - spp.satellite_clock_m;
1958            let media_delta_m = (observable.ionosphere_m + observable.troposphere_m)
1959                - (spp.ionosphere_m + spp.troposphere_m);
1960            let total_delta_m = observable.total_m - spp.total_m;
1961            eprintln!(
1962                "tight C1C oracle {label}: geom_delta_m={geom_delta_m:.9e} \
1963                 sat_clock_delta_m={sat_clock_delta_m:.9e} media_delta_m={media_delta_m:.9e} \
1964                 total_delta_m={total_delta_m:.9e}"
1965            );
1966            max_observable_minus_spp_m = max_observable_minus_spp_m.max(total_delta_m.abs());
1967            assert_eq!(tight_total_m.to_bits(), spp.total_m.to_bits(), "{label}");
1968        }
1969
1970        assert!(
1971            max_observable_minus_spp_m > 1.0e-3,
1972            "synthetic oracle should expose the pre-unification discrepancy"
1973        );
1974    }
1975
1976    #[test]
1977    fn tight_rows_match_closed_loop_finite_difference_signs() {
1978        let receiver = [WGS84_A_M + 8.0, -3.0, 2.0];
1979        let satellite_id = sat(1);
1980        let source = LinearSource::new(
1981            T0,
1982            vec![(
1983                satellite_id,
1984                [WGS84_A_M + 8_000.0, 900.0, -1_200.0],
1985                [12.0, -7.0, 3.0],
1986                0.0,
1987            )],
1988        );
1989        let observation = TightGnssObservation {
1990            satellite_id,
1991            pseudorange_m: 8_125.25,
1992            pseudorange_sigma_m: 0.5,
1993            range_rate: Some(TightRangeRateObservation {
1994                measured_range_rate_m_s: -4.25,
1995                sigma_m_s: 0.125,
1996                satellite_clock_drift_m_s: 0.03125,
1997            }),
1998            carrier_phase: None,
1999            ionosphere_delay_m: 0.125,
2000            troposphere_delay_m: -0.0625,
2001        };
2002        let epoch = TightGnssEpoch::new(T0, vec![observation]).expect("epoch");
2003        let nominal =
2004            NavState::new(T0, receiver, [1.5, -0.75, 0.375], mat3_identity()).expect("nominal");
2005        let config = TightCouplingConfig {
2006            lever_arm_body_m: [1.25, -0.5, 0.75],
2007            light_time: false,
2008            sagnac: false,
2009            ..tight_config_for_test()
2010        };
2011        let filter = filter_with_config(nominal, &[1.0; ERROR_STATE_DIMENSION_15], config);
2012        let body_rate_wrt_ecef_rps = [0.01, -0.02, 0.03];
2013        let correction = tight_coupling_correction(
2014            &source,
2015            filter.state(),
2016            &filter.tight,
2017            &epoch,
2018            config,
2019            filter.config.imu_to_body_dcm,
2020            body_rate_wrt_ecef_rps,
2021        )
2022        .expect("correction");
2023        let reference_prediction = tight_measurement_predictions(
2024            &source,
2025            filter.state(),
2026            filter.tight.clock_bias_m,
2027            filter.tight.clock_drift_m_s,
2028            &epoch,
2029            config,
2030            body_rate_wrt_ecef_rps,
2031        )
2032        .expect("prediction");
2033        let base_dim = filter.state.dimension();
2034        let checks = [
2035            (0usize, ERROR_POSITION_INDEX, 1.0e-3),
2036            (0, ERROR_ATTITUDE_INDEX + 2, 1.0e-3),
2037            (0, clock_bias_index(base_dim), 1.0e-3),
2038            (1, ERROR_VELOCITY_INDEX + 1, 1.0e-3),
2039            (1, ERROR_GYRO_BIAS_INDEX + 2, 1.0e-3),
2040            (1, clock_drift_index(base_dim), 1.0e-3),
2041        ];
2042
2043        for (row, column, step) in checks {
2044            let mut plus_dx = vec![0.0; augmented_dimension(base_dim)];
2045            plus_dx[column] = step;
2046            let plus = tight_sigma_measurement_residual(
2047                &source,
2048                filter.state(),
2049                &filter.tight,
2050                &epoch,
2051                config,
2052                body_rate_wrt_ecef_rps,
2053                &reference_prediction,
2054                &plus_dx,
2055            )
2056            .expect("plus residual");
2057            let mut minus_dx = vec![0.0; augmented_dimension(base_dim)];
2058            minus_dx[column] = -step;
2059            let minus = tight_sigma_measurement_residual(
2060                &source,
2061                filter.state(),
2062                &filter.tight,
2063                &epoch,
2064                config,
2065                body_rate_wrt_ecef_rps,
2066                &reference_prediction,
2067                &minus_dx,
2068            )
2069            .expect("minus residual");
2070            let derivative = (plus[row] - minus[row]) / (2.0 * step);
2071            let expected = correction.design[row][column];
2072            assert!(
2073                (derivative - expected).abs() <= 5.0e-7,
2074                "row {row}, column {column}, derivative {derivative:.17e}, expected {expected:.17e}"
2075            );
2076        }
2077    }
2078
2079    #[test]
2080    fn singular_snapshot_geometry_keeps_unobserved_prior_covariance() {
2081        let receiver = [WGS84_A_M, 0.0, 0.0];
2082        let directions = [[1.0, 0.0, 0.0]; 5];
2083        let source = source_from_directions(receiver, &directions);
2084        let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
2085        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
2086        assert!(matches!(
2087            crate::spp::solve(&source, &inputs, false),
2088            Err(SppError::Singular(_))
2089        ));
2090
2091        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
2092        let mut diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
2093        diagonal[ERROR_POSITION_INDEX] = 100.0;
2094        diagonal[ERROR_POSITION_INDEX + 1] = 225.0;
2095        diagonal[ERROR_POSITION_INDEX + 2] = 400.0;
2096        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
2097        let prior_y = filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1];
2098        let prior_z = filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2];
2099
2100        let update = filter.update_tight(&source, &epoch).expect("tight update");
2101
2102        assert!(update.applied);
2103        assert!(covariance_is_positive_semidefinite(&filter.state.covariance).expect("PSD"));
2104        assert_eq!(
2105            filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1].to_bits(),
2106            prior_y.to_bits()
2107        );
2108        assert_eq!(
2109            filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2].to_bits(),
2110            prior_z.to_bits()
2111        );
2112        assert!(filter
2113            .state
2114            .nominal
2115            .position_ecef_m
2116            .iter()
2117            .all(|value| value.is_finite() && value.abs() < 1.0e8));
2118    }
2119
2120    #[test]
2121    fn high_dop_fused_covariance_has_lower_logdet_than_snapshot() {
2122        let receiver = [WGS84_A_M, 0.0, 0.0];
2123        let directions = [
2124            [0.44974122498328417, -0.8581153514788689, 0.2477314556265159],
2125            [0.20081904418348107, 0.5332143328087052, 0.8217993591994339],
2126            [0.43760604888398824, -0.4903647504582244, 0.7536865114145189],
2127            [
2128                0.2148508784686108,
2129                -0.9558725523345635,
2130                -0.20036657334663732,
2131            ],
2132            [0.30949187488876595, 0.3289789392404428, 0.8921813923827763],
2133        ];
2134        let source = source_from_directions(receiver, &directions);
2135        let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
2136        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
2137        let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
2138        assert_eq!(
2139            spp.geometry_quality.tier,
2140            crate::geometry_quality::ObservabilityTier::Weak
2141        );
2142        let snapshot_covariance = snapshot_position_clock_covariance(&source, receiver, &epoch);
2143        let snapshot_logdet = logdet_spd(&snapshot_covariance);
2144
2145        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
2146        let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
2147        for axis in 0..3 {
2148            diagonal[ERROR_POSITION_INDEX + axis] = 1.0e8;
2149        }
2150        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
2151
2152        filter.update_tight(&source, &epoch).expect("tight update");
2153
2154        let fused_logdet = logdet_spd(&position_clock_block(&filter));
2155        assert!(
2156            fused_logdet < snapshot_logdet,
2157            "fused {fused_logdet:.17e}, snapshot {snapshot_logdet:.17e}"
2158        );
2159    }
2160
2161    #[test]
2162    fn close_range_tight_ukf_nees_is_no_worse_than_ekf() {
2163        let truth_position = [WGS84_A_M + 10.0, 20.0, -15.0];
2164        let nominal_position = [
2165            truth_position[0] + 8.0,
2166            truth_position[1] - 6.0,
2167            truth_position[2] + 5.0,
2168        ];
2169        let directions = [
2170            [1.0, 0.0, 0.0],
2171            [-0.8, 0.5, 0.2],
2172            [0.2, 1.0, -0.1],
2173            [-0.2, -0.9, 0.4],
2174            [0.1, 0.2, 1.0],
2175            [-0.3, 0.1, -1.0],
2176        ];
2177        let source = source_from_directions_at_range(truth_position, &directions, 80.0);
2178        let truth_clock_m = 3.0;
2179        let observations = source
2180            .states
2181            .iter()
2182            .map(|(satellite_id, _, _, _)| {
2183                let prediction = transmit_time_satellite_state(
2184                    &source,
2185                    *satellite_id,
2186                    truth_position,
2187                    T0,
2188                    TransmitTimeOptions {
2189                        light_time: false,
2190                        sagnac: false,
2191                    },
2192                )
2193                .expect("truth prediction");
2194                TightGnssObservation::pseudorange(
2195                    *satellite_id,
2196                    prediction.geometric_range_m + truth_clock_m,
2197                    0.25,
2198                )
2199                .expect("observation")
2200            })
2201            .collect::<Vec<_>>();
2202        let epoch = TightGnssEpoch::new(T0, observations).expect("epoch");
2203        let nominal =
2204            NavState::new(T0, nominal_position, [0.0; 3], mat3_identity()).expect("nominal");
2205        let mut diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
2206        for axis in 0..3 {
2207            diagonal[ERROR_POSITION_INDEX + axis] = 100.0;
2208        }
2209        let tight = TightCouplingConfig {
2210            light_time: false,
2211            sagnac: false,
2212            initial_clock_bias_variance_m2: 100.0,
2213            initial_clock_drift_variance_m2_s2: 1.0e-6,
2214            clock_bias_random_walk_m2_s: 0.0,
2215            clock_drift_random_walk_m2_s3: 0.0,
2216            ..TightCouplingConfig::default()
2217        };
2218        let mut ekf = filter_with_kind(nominal, &diagonal, tight, FusionFilterKind::Ekf);
2219        let mut ukf = filter_with_kind(nominal, &diagonal, tight, FusionFilterKind::Ukf);
2220
2221        ekf.update_tight(&source, &epoch).expect("ekf update");
2222        ukf.update_tight(&source, &epoch).expect("ukf update");
2223
2224        let ekf_nees = position_clock_nees(&ekf, truth_position, truth_clock_m);
2225        let ukf_nees = position_clock_nees(&ukf, truth_position, truth_clock_m);
2226        assert!(
2227            ukf_nees <= ekf_nees,
2228            "UKF NEES {ukf_nees:.17e}, EKF NEES {ekf_nees:.17e}"
2229        );
2230    }
2231
2232    #[test]
2233    fn outage_growth_and_single_satellite_observed_direction_update() {
2234        let receiver = [WGS84_A_M, 0.0, 0.0];
2235        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
2236        let diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
2237        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
2238            .expect("state");
2239        let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
2240        let mut config = super::super::loose::InertialFilterConfig::new(spec).expect("config");
2241        config.tight = TightCouplingConfig {
2242            light_time: false,
2243            sagnac: false,
2244            initial_clock_bias_variance_m2: 100.0,
2245            initial_clock_drift_variance_m2_s2: 1.0,
2246            clock_bias_random_walk_m2_s: 4.0,
2247            clock_drift_random_walk_m2_s3: 0.25,
2248            ..TightCouplingConfig::default()
2249        };
2250        let mut filter = InertialFilter::with_config(state, config).expect("filter");
2251        let mut previous_logdet = logdet_spd(&filter.tight.augmented_covariance);
2252
2253        for step in 1..=3 {
2254            filter
2255                .propagate(ImuSample::increment(
2256                    T0 + step as f64,
2257                    [0.0; 3],
2258                    [0.0; 3],
2259                    1.0,
2260                ))
2261                .expect("propagate");
2262            let next_logdet = logdet_spd(&filter.tight.augmented_covariance);
2263            assert!(
2264                next_logdet > previous_logdet,
2265                "step {step} logdet {next_logdet:.17e} <= {previous_logdet:.17e}"
2266            );
2267            previous_logdet = next_logdet;
2268        }
2269
2270        let current_position = filter.state.nominal.position_ecef_m;
2271        let satellite_id = sat(1);
2272        let source = LinearSource::new(
2273            filter.state.nominal.t_j2000_s,
2274            vec![(
2275                satellite_id,
2276                [
2277                    current_position[0] + 22_000_000.0,
2278                    current_position[1],
2279                    current_position[2],
2280                ],
2281                [0.0; 3],
2282                0.0,
2283            )],
2284        );
2285        let prediction = transmit_time_satellite_state(
2286            &source,
2287            satellite_id,
2288            current_position,
2289            filter.state.nominal.t_j2000_s,
2290            TransmitTimeOptions {
2291                light_time: false,
2292                sagnac: false,
2293            },
2294        )
2295        .expect("satellite state");
2296        let clock = filter.tight_clock_state().expect("clock");
2297        let epoch = TightGnssEpoch::new(
2298            filter.state.nominal.t_j2000_s,
2299            vec![TightGnssObservation::pseudorange(
2300                satellite_id,
2301                prediction.geometric_range_m + clock.bias_m,
2302                0.5,
2303            )
2304            .expect("observation")],
2305        )
2306        .expect("epoch");
2307        let pre = filter.state.covariance.clone();
2308
2309        filter
2310            .update_tight(&source, &epoch)
2311            .expect("single-sat update");
2312
2313        assert!(
2314            filter.state.covariance[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
2315                < pre[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
2316        );
2317        for axis in [1usize, 2] {
2318            assert_eq!(
2319                filter.state.covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis]
2320                    .to_bits(),
2321                pre[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis].to_bits()
2322            );
2323        }
2324    }
2325}