Skip to main content

sidereon_core/fusion/
tight.rs

1//! Tightly coupled raw GNSS updates for the INS error-state filter.
2//!
3//! The update consumes one epoch of satellite pseudorange and optional
4//! range-rate observations. It keeps the INS layout unchanged and carries the
5//! receiver clock bias and drift in a private two-state augmentation.
6
7use std::collections::BTreeSet;
8
9use crate::astro::math::mat3::{inline_rxr, mul_vec3};
10use crate::astro::math::vec3::{add3, cross3, norm3, sub3};
11use crate::constants::C_M_S;
12use crate::estimation::recipe::{FrameRecipe, RangeRecipe, SagnacRecipe};
13use crate::inertial::state::skew;
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.last_body_rate_wrt_ecef_rps,
520        )?;
521        let rows = correction.row_count();
522        let filter_kind = self.config.filter_kind;
523        let ekf_options = self.config.tight.update_options;
524        let ukf_options = self.config.ukf_update_options;
525        let report = match filter_kind {
526            FusionFilterKind::Ekf => apply_tight_correction(self, &correction, ekf_options)?,
527            FusionFilterKind::Ukf => {
528                apply_tight_ukf_correction(self, source, epoch, &correction, ukf_options)?
529            }
530        };
531        Ok(FusionUpdate {
532            applied: report.applied,
533            nis: report.normalized_innovation_squared,
534            rows,
535            accepted_rows: report.accepted_rows,
536            rejected_rows: report.rejected_rows,
537            ekf: report,
538        })
539    }
540}
541
542pub(super) fn tight_coupling_correction(
543    source: &dyn ObservableEphemerisSource,
544    state: &InsFilterState,
545    tight_state: &TightFusionState,
546    epoch: &TightGnssEpoch,
547    config: TightCouplingConfig,
548    body_rate_wrt_ecef_rps: [f64; 3],
549) -> Result<EkfCorrection, FusionError> {
550    state.validate()?;
551    tight_state.validate(state.dimension())?;
552    epoch.validate()?;
553    config.validate()?;
554    validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
555    if epoch.t_j2000_s != state.nominal.t_j2000_s {
556        return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
557    }
558
559    let base_dim = state.dimension();
560    let aug_dim = augmented_dimension(base_dim);
561    let clock_bias = clock_bias_index(base_dim);
562    let clock_drift = clock_drift_index(base_dim);
563    let kinematics = antenna_kinematics(state, config.lever_arm_body_m, body_rate_wrt_ecef_rps);
564    let options = TransmitTimeOptions {
565        light_time: config.light_time,
566        sagnac: config.sagnac,
567    };
568
569    let mut innovation = Vec::new();
570    let mut design = Vec::new();
571    let mut variances = Vec::new();
572
573    for observation in &epoch.observations {
574        let code_satellite = tight_code_satellite_prediction(
575            source,
576            observation.satellite_id,
577            kinematics.antenna_position_ecef_m,
578            epoch.t_j2000_s,
579            observation.pseudorange_m,
580            options,
581        )
582        .map_err(map_observables_error)?;
583
584        let code_prediction_m = code_satellite.clock_corrected_range_m
585            + tight_state.clock_bias_m
586            + observation.ionosphere_delay_m
587            + observation.troposphere_delay_m;
588        let mut row = pseudorange_design_row(
589            aug_dim,
590            clock_bias,
591            code_satellite.los_unit,
592            kinematics.lever_arm_ecef_m,
593        );
594        innovation.push(observation.pseudorange_m - code_prediction_m);
595        design.push(row);
596        variances.push(observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
597
598        if let Some(carrier_phase) = observation.carrier_phase {
599            let phase_prediction_m = code_satellite.clock_corrected_range_m
600                + tight_state.clock_bias_m
601                - observation.ionosphere_delay_m
602                + observation.troposphere_delay_m
603                + carrier_phase.float_ambiguity_m;
604            row = pseudorange_design_row(
605                aug_dim,
606                clock_bias,
607                code_satellite.los_unit,
608                kinematics.lever_arm_ecef_m,
609            );
610            innovation.push(carrier_phase.phase_range_m - phase_prediction_m);
611            design.push(row);
612            variances.push(carrier_phase.sigma_m * carrier_phase.sigma_m);
613        }
614
615        if let Some(range_rate) = observation.range_rate {
616            let satellite = transmit_time_satellite_state(
617                source,
618                observation.satellite_id,
619                kinematics.antenna_position_ecef_m,
620                epoch.t_j2000_s,
621                options,
622            )
623            .map_err(map_observables_error)?;
624            let velocity_observation = VelocityObservation {
625                sat: observation.satellite_id,
626                satellite_position_m: satellite.position_ecef_m,
627                satellite_velocity_m_s: satellite.velocity_m_s,
628                measured_range_rate_m_s: range_rate.measured_range_rate_m_s,
629                sigma_m_s: range_rate.sigma_m_s,
630                satellite_clock_drift_m_s: range_rate.satellite_clock_drift_m_s,
631            };
632            let receiver = ReceiverVelocityState {
633                position_m: kinematics.antenna_position_ecef_m,
634                velocity_m_s: kinematics.antenna_velocity_ecef_mps,
635                clock_drift_m_s: tight_state.clock_drift_m_s,
636            };
637            let prediction = predict_range_rate_m_s(&velocity_observation, receiver)
638                .ok_or_else(|| invalid_input("range_rate", "line of sight must be nonzero"))?;
639            let row = range_rate_design_row(
640                aug_dim,
641                clock_drift,
642                prediction.los_unit,
643                kinematics.lever_velocity_ecef_mps,
644                kinematics.gyro_bias_velocity_block,
645            );
646            innovation.push(range_rate.measured_range_rate_m_s - prediction.range_rate_m_s);
647            design.push(row);
648            variances.push(range_rate.sigma_m_s * range_rate.sigma_m_s);
649        }
650    }
651
652    validate_finite_slice(&innovation, "tight_innovation")?;
653    let measurement_covariance = diagonal_covariance(&variances)?;
654    EkfCorrection::new(innovation, design, measurement_covariance)
655}
656
657fn apply_tight_correction(
658    filter: &mut InertialFilter,
659    correction: &EkfCorrection,
660    options: EkfUpdateOptions,
661) -> Result<EkfCorrectionReport, FusionError> {
662    filter.state.validate()?;
663    let base_dim = filter.state.dimension();
664    filter.tight.validate(base_dim)?;
665    correction.validate_for_dimension(augmented_dimension(base_dim))?;
666
667    if let Some(gate) = options.innovation_gate {
668        gate.validate()?;
669        let full_s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
670        let (screened, gate_report) = screen_correction(correction, &full_s, gate)?;
671        let full_nis = normalized_innovation_squared(&full_s, &correction.innovation)?;
672        if gate_report.coasted {
673            return Ok(EkfCorrectionReport {
674                applied: false,
675                normalized_innovation_squared: full_nis,
676                accepted_rows: gate_report.accepted_rows,
677                rejected_rows: gate_report.rejected_rows,
678                innovation_gate: Some(gate_report),
679                innovation_covariance: full_s,
680                kalman_gain: vec![vec![0.0; correction.row_count()]; augmented_dimension(base_dim)],
681                dx: vec![0.0; augmented_dimension(base_dim)],
682            });
683        }
684        let accepted_rows = gate_report.accepted_rows;
685        let rejected_rows = gate_report.rejected_rows;
686        let mut report = apply_tight_correction_inner(filter, &screened)?;
687        report.accepted_rows = accepted_rows;
688        report.rejected_rows = rejected_rows;
689        report.innovation_gate = Some(gate_report);
690        return Ok(report);
691    }
692
693    apply_tight_correction_inner(filter, correction)
694}
695
696fn apply_tight_correction_inner(
697    filter: &mut InertialFilter,
698    correction: &EkfCorrection,
699) -> Result<EkfCorrectionReport, FusionError> {
700    let base_dim = filter.state.dimension();
701    let aug_dim = augmented_dimension(base_dim);
702    let s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
703    let h_t = super::state::transpose(&correction.design)?;
704    let p_h_t = matmul(&filter.tight.augmented_covariance, &h_t)?;
705    let mut kalman_gain = vec![vec![0.0; correction.row_count()]; aug_dim];
706    let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
707    for row in 0..aug_dim {
708        kalman_gain[row] = super::state::solve_spd(&s, &p_h_t[row], &mut scratch)?;
709    }
710    let dx = super::state::matvec(&kalman_gain, &correction.innovation)?;
711    let nis = normalized_innovation_squared(&s, &correction.innovation)?;
712    let covariance = joseph_covariance_update(
713        &filter.tight.augmented_covariance,
714        &correction.design,
715        &kalman_gain,
716        &correction.measurement_covariance,
717    )?;
718
719    apply_closed_loop_navigation_error(&mut filter.state.nominal, &dx[..base_dim])?;
720    apply_closed_loop_scale_error(&mut filter.state, &dx[..base_dim]);
721    filter.tight.clock_bias_m += dx[clock_bias_index(base_dim)];
722    filter.tight.clock_drift_m_s += dx[clock_drift_index(base_dim)];
723    filter.tight.augmented_covariance = covariance;
724    filter
725        .tight
726        .copy_base_covariance_to_state(&mut filter.state)?;
727    filter.state.reset_error_state();
728    filter.state.validate()?;
729    filter.tight.validate(base_dim)?;
730
731    Ok(EkfCorrectionReport {
732        applied: true,
733        normalized_innovation_squared: nis,
734        accepted_rows: correction.row_count(),
735        rejected_rows: 0,
736        innovation_gate: None,
737        innovation_covariance: s,
738        kalman_gain,
739        dx,
740    })
741}
742
743fn apply_tight_ukf_correction(
744    filter: &mut InertialFilter,
745    source: &dyn ObservableEphemerisSource,
746    epoch: &TightGnssEpoch,
747    correction: &EkfCorrection,
748    options: UkfUpdateOptions,
749) -> Result<EkfCorrectionReport, FusionError> {
750    filter.state.validate()?;
751    let base_dim = filter.state.dimension();
752    filter.tight.validate(base_dim)?;
753    correction.validate_for_dimension(augmented_dimension(base_dim))?;
754    options.validate_for_dimension(augmented_dimension(base_dim))?;
755
756    let reference_state = filter.state.clone();
757    let reference_tight = filter.tight.clone();
758    let config = filter.config.tight;
759    let body_rate_wrt_ecef_rps = filter.last_body_rate_wrt_ecef_rps;
760    let reference_prediction = tight_measurement_predictions(
761        source,
762        &reference_state,
763        reference_tight.clock_bias_m,
764        reference_tight.clock_drift_m_s,
765        epoch,
766        config,
767        body_rate_wrt_ecef_rps,
768    )?;
769
770    let report = ukf_measurement_update(
771        &filter.tight.augmented_covariance,
772        &correction.innovation,
773        &correction.measurement_covariance,
774        options,
775        |dx| {
776            tight_sigma_measurement_residual(
777                source,
778                &reference_state,
779                &reference_tight,
780                epoch,
781                config,
782                body_rate_wrt_ecef_rps,
783                &reference_prediction,
784                dx,
785            )
786        },
787    )?;
788    if !report.applied {
789        return Ok(report.into_public_report());
790    }
791
792    let dx = report.dx.clone();
793    let posterior_covariance = report.posterior_covariance.clone();
794    apply_closed_loop_navigation_error(&mut filter.state.nominal, &dx[..base_dim])?;
795    apply_closed_loop_scale_error(&mut filter.state, &dx[..base_dim]);
796    filter.tight.clock_bias_m += dx[clock_bias_index(base_dim)];
797    filter.tight.clock_drift_m_s += dx[clock_drift_index(base_dim)];
798    filter.tight.augmented_covariance = posterior_covariance;
799    filter
800        .tight
801        .copy_base_covariance_to_state(&mut filter.state)?;
802    filter.state.reset_error_state();
803    filter.state.validate()?;
804    filter.tight.validate(base_dim)?;
805    Ok(report.into_public_report())
806}
807
808#[allow(clippy::too_many_arguments)]
809fn tight_sigma_measurement_residual(
810    source: &dyn ObservableEphemerisSource,
811    reference_state: &InsFilterState,
812    reference_tight: &TightFusionState,
813    epoch: &TightGnssEpoch,
814    config: TightCouplingConfig,
815    body_rate_wrt_ecef_rps: [f64; 3],
816    reference_prediction: &[f64],
817    dx: &[f64],
818) -> Result<Vec<f64>, FusionError> {
819    let base_dim = reference_state.dimension();
820    if dx.len() != augmented_dimension(base_dim) {
821        return Err(FusionError::DimensionMismatch {
822            field: "ukf_sigma_point",
823            expected: augmented_dimension(base_dim),
824            actual: dx.len(),
825        });
826    }
827
828    let mut candidate_state = reference_state.clone();
829    apply_closed_loop_navigation_error(&mut candidate_state.nominal, &dx[..base_dim])?;
830    apply_closed_loop_scale_error(&mut candidate_state, &dx[..base_dim]);
831    candidate_state.validate()?;
832    let mut candidate_body_rate_wrt_ecef_rps = body_rate_wrt_ecef_rps;
833    for axis in 0..3 {
834        candidate_body_rate_wrt_ecef_rps[axis] -= dx[ERROR_GYRO_BIAS_INDEX + axis];
835    }
836    let clock_bias_m = reference_tight.clock_bias_m + dx[clock_bias_index(base_dim)];
837    let clock_drift_m_s = reference_tight.clock_drift_m_s + dx[clock_drift_index(base_dim)];
838    let candidate_prediction = tight_measurement_predictions(
839        source,
840        &candidate_state,
841        clock_bias_m,
842        clock_drift_m_s,
843        epoch,
844        config,
845        candidate_body_rate_wrt_ecef_rps,
846    )?;
847    if candidate_prediction.len() != reference_prediction.len() {
848        return Err(FusionError::DimensionMismatch {
849            field: "tight_prediction",
850            expected: reference_prediction.len(),
851            actual: candidate_prediction.len(),
852        });
853    }
854    Ok(candidate_prediction
855        .iter()
856        .zip(reference_prediction.iter())
857        .map(|(candidate, reference)| candidate - reference)
858        .collect())
859}
860
861fn tight_measurement_predictions(
862    source: &dyn ObservableEphemerisSource,
863    state: &InsFilterState,
864    clock_bias_m: f64,
865    clock_drift_m_s: f64,
866    epoch: &TightGnssEpoch,
867    config: TightCouplingConfig,
868    body_rate_wrt_ecef_rps: [f64; 3],
869) -> Result<Vec<f64>, FusionError> {
870    state.validate()?;
871    epoch.validate()?;
872    config.validate()?;
873    validate_finite_slice(&[clock_bias_m, clock_drift_m_s], "tight_clock")?;
874    validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
875    if epoch.t_j2000_s != state.nominal.t_j2000_s {
876        return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
877    }
878
879    let kinematics = antenna_kinematics(state, config.lever_arm_body_m, body_rate_wrt_ecef_rps);
880    let options = TransmitTimeOptions {
881        light_time: config.light_time,
882        sagnac: config.sagnac,
883    };
884    let mut predictions = Vec::new();
885    for observation in &epoch.observations {
886        let code_satellite = tight_code_satellite_prediction(
887            source,
888            observation.satellite_id,
889            kinematics.antenna_position_ecef_m,
890            epoch.t_j2000_s,
891            observation.pseudorange_m,
892            options,
893        )
894        .map_err(map_observables_error)?;
895        predictions.push(
896            code_satellite.clock_corrected_range_m
897                + clock_bias_m
898                + observation.ionosphere_delay_m
899                + observation.troposphere_delay_m,
900        );
901
902        if let Some(carrier_phase) = observation.carrier_phase {
903            predictions.push(
904                code_satellite.clock_corrected_range_m + clock_bias_m
905                    - observation.ionosphere_delay_m
906                    + observation.troposphere_delay_m
907                    + carrier_phase.float_ambiguity_m,
908            );
909        }
910
911        if let Some(range_rate) = observation.range_rate {
912            let satellite = transmit_time_satellite_state(
913                source,
914                observation.satellite_id,
915                kinematics.antenna_position_ecef_m,
916                epoch.t_j2000_s,
917                options,
918            )
919            .map_err(map_observables_error)?;
920            let velocity_observation = VelocityObservation {
921                sat: observation.satellite_id,
922                satellite_position_m: satellite.position_ecef_m,
923                satellite_velocity_m_s: satellite.velocity_m_s,
924                measured_range_rate_m_s: range_rate.measured_range_rate_m_s,
925                sigma_m_s: range_rate.sigma_m_s,
926                satellite_clock_drift_m_s: range_rate.satellite_clock_drift_m_s,
927            };
928            let receiver = ReceiverVelocityState {
929                position_m: kinematics.antenna_position_ecef_m,
930                velocity_m_s: kinematics.antenna_velocity_ecef_mps,
931                clock_drift_m_s,
932            };
933            let prediction = predict_range_rate_m_s(&velocity_observation, receiver)
934                .ok_or_else(|| invalid_input("range_rate", "line of sight must be nonzero"))?;
935            predictions.push(prediction.range_rate_m_s);
936        }
937    }
938    validate_finite_slice(&predictions, "tight_prediction")?;
939    Ok(predictions)
940}
941
942#[derive(Debug, Clone, Copy)]
943struct CodeSatellitePrediction {
944    clock_corrected_range_m: f64,
945    los_unit: [f64; 3],
946}
947
948fn tight_code_satellite_prediction(
949    source: &dyn ObservableEphemerisSource,
950    sat: crate::GnssSatelliteId,
951    receiver_ecef_m: [f64; 3],
952    t_rx_j2000_s: f64,
953    pseudorange_m: f64,
954    options: TransmitTimeOptions,
955) -> Result<CodeSatellitePrediction, ObservablesError> {
956    if options.light_time {
957        return spp_code_satellite_prediction(
958            source,
959            sat,
960            receiver_ecef_m,
961            t_rx_j2000_s,
962            pseudorange_m,
963            options.sagnac,
964        );
965    }
966
967    let satellite =
968        transmit_time_satellite_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
969    let sat_clock_s = satellite.clock_s.ok_or(ObservablesError::NoEphemeris)?;
970    Ok(CodeSatellitePrediction {
971        clock_corrected_range_m: satellite.geometric_range_m - C_M_S * sat_clock_s,
972        los_unit: satellite.los_unit,
973    })
974}
975
976fn spp_code_satellite_prediction(
977    source: &dyn ObservableEphemerisSource,
978    sat: crate::GnssSatelliteId,
979    receiver_ecef_m: [f64; 3],
980    t_rx_j2000_s: f64,
981    pseudorange_m: f64,
982    sagnac: bool,
983) -> Result<CodeSatellitePrediction, ObservablesError> {
984    let source = ObservableClockSource { source };
985    let glonass_channels = std::collections::BTreeMap::new();
986    let met = SurfaceMet::default();
987    let env = SatModelEnv {
988        eph: &source,
989        t_rx_j2000_s,
990        t_rx_second_of_day_s: 0.0,
991        day_of_year: 1.0,
992        corrections: Corrections::NONE,
993        met: &met,
994        glonass_channels: &glonass_channels,
995        model: SppModelRecipe {
996            range: RangeRecipe::SppMeasuredPseudorangeFixedIter,
997            sagnac: if sagnac {
998                SagnacRecipe::ClosedFormZRotation
999            } else {
1000                SagnacRecipe::Off
1001            },
1002            frame: FrameRecipe::SppSkyfieldAuThreeIter,
1003        },
1004    };
1005    let model = sat_model(
1006        &env,
1007        sat,
1008        receiver_ecef_m,
1009        0.0,
1010        pseudorange_m,
1011        SppIonosphere::Klobuchar(KlobucharCoeffs {
1012            alpha: [0.0; 4],
1013            beta: [0.0; 4],
1014        }),
1015    )
1016    .ok_or(ObservablesError::NoEphemeris)?;
1017    let line_of_sight = sub3(model.sat_rot_ecef_m, receiver_ecef_m);
1018    let range = norm3(line_of_sight);
1019    if !range.is_finite() || range <= 0.0 {
1020        return Err(ObservablesError::InvalidInput {
1021            field: "receiver_ecef_m",
1022            kind: crate::observables::ObservablesInputErrorKind::OutOfRange,
1023        });
1024    }
1025    let los_unit = [
1026        line_of_sight[0] / range,
1027        line_of_sight[1] / range,
1028        line_of_sight[2] / range,
1029    ];
1030    crate::validate::finite_vec3(los_unit, "los_unit").map_err(|_| {
1031        ObservablesError::InvalidInput {
1032            field: "receiver_ecef_m",
1033            kind: crate::observables::ObservablesInputErrorKind::OutOfRange,
1034        }
1035    })?;
1036    Ok(CodeSatellitePrediction {
1037        clock_corrected_range_m: model.p_hat_m,
1038        los_unit,
1039    })
1040}
1041
1042struct ObservableClockSource<'a> {
1043    source: &'a dyn ObservableEphemerisSource,
1044}
1045
1046impl EphemerisSource for ObservableClockSource<'_> {
1047    fn position_clock_at_j2000_s(
1048        &self,
1049        sat: crate::GnssSatelliteId,
1050        t_j2000_s: f64,
1051    ) -> Option<([f64; 3], f64)> {
1052        let state = self
1053            .source
1054            .observable_state_at_j2000_s(sat, t_j2000_s)
1055            .ok()?;
1056        Some((state.position_ecef_m, state.clock_s?))
1057    }
1058}
1059
1060#[derive(Debug, Clone, Copy)]
1061struct AntennaKinematics {
1062    antenna_position_ecef_m: [f64; 3],
1063    antenna_velocity_ecef_mps: [f64; 3],
1064    lever_arm_ecef_m: [f64; 3],
1065    lever_velocity_ecef_mps: [f64; 3],
1066    gyro_bias_velocity_block: [[f64; 3]; 3],
1067}
1068
1069fn antenna_kinematics(
1070    state: &InsFilterState,
1071    lever_arm_body_m: [f64; 3],
1072    body_rate_wrt_ecef_rps: [f64; 3],
1073) -> AntennaKinematics {
1074    let c_b_e = state.nominal.attitude_body_to_ecef;
1075    let lever_arm_ecef_m = mul_vec3(&c_b_e, lever_arm_body_m);
1076    let antenna_position_ecef_m = add3(state.nominal.position_ecef_m, lever_arm_ecef_m);
1077    let lever_velocity_body_mps = cross3(body_rate_wrt_ecef_rps, lever_arm_body_m);
1078    let lever_velocity_ecef_mps = mul_vec3(&c_b_e, lever_velocity_body_mps);
1079    let antenna_velocity_ecef_mps = add3(state.nominal.velocity_ecef_mps, lever_velocity_ecef_mps);
1080    let gyro_bias_velocity_block = inline_rxr(&c_b_e, &skew(lever_arm_body_m));
1081    AntennaKinematics {
1082        antenna_position_ecef_m,
1083        antenna_velocity_ecef_mps,
1084        lever_arm_ecef_m,
1085        lever_velocity_ecef_mps,
1086        gyro_bias_velocity_block,
1087    }
1088}
1089
1090fn pseudorange_design_row(
1091    aug_dim: usize,
1092    clock_bias: usize,
1093    los_unit: [f64; 3],
1094    lever_arm_ecef_m: [f64; 3],
1095) -> Vec<f64> {
1096    let mut row = vec![0.0; aug_dim];
1097    row[ERROR_POSITION_INDEX..ERROR_POSITION_INDEX + 3].copy_from_slice(&los_unit);
1098    let lever_skew = skew(lever_arm_ecef_m);
1099    for col in 0..3 {
1100        row[ERROR_ATTITUDE_INDEX + col] = -(los_unit[0] * lever_skew[0][col]
1101            + los_unit[1] * lever_skew[1][col]
1102            + los_unit[2] * lever_skew[2][col]);
1103    }
1104    row[clock_bias] = 1.0;
1105    row
1106}
1107
1108fn range_rate_design_row(
1109    aug_dim: usize,
1110    clock_drift: usize,
1111    los_unit: [f64; 3],
1112    lever_velocity_ecef_mps: [f64; 3],
1113    gyro_bias_velocity_block: [[f64; 3]; 3],
1114) -> Vec<f64> {
1115    let mut row = vec![0.0; aug_dim];
1116    row[ERROR_VELOCITY_INDEX..ERROR_VELOCITY_INDEX + 3].copy_from_slice(&los_unit);
1117    let lever_velocity_skew = skew(lever_velocity_ecef_mps);
1118    for col in 0..3 {
1119        row[ERROR_ATTITUDE_INDEX + col] = -(los_unit[0] * lever_velocity_skew[0][col]
1120            + los_unit[1] * lever_velocity_skew[1][col]
1121            + los_unit[2] * lever_velocity_skew[2][col]);
1122        row[ERROR_GYRO_BIAS_INDEX + col] = -(los_unit[0] * gyro_bias_velocity_block[0][col]
1123            + los_unit[1] * gyro_bias_velocity_block[1][col]
1124            + los_unit[2] * gyro_bias_velocity_block[2][col]);
1125    }
1126    row[clock_drift] = 1.0;
1127    row
1128}
1129
1130fn diagonal_covariance(variances: &[f64]) -> Result<Vec<Vec<f64>>, FusionError> {
1131    if variances.is_empty() {
1132        return Err(invalid_input("measurement_covariance", "must not be empty"));
1133    }
1134    let mut covariance = vec![vec![0.0; variances.len()]; variances.len()];
1135    for (idx, variance) in variances.iter().enumerate() {
1136        validate_positive(*variance, "measurement_variance")?;
1137        covariance[idx][idx] = *variance;
1138    }
1139    Ok(covariance)
1140}
1141
1142fn map_observables_error(error: ObservablesError) -> FusionError {
1143    match error {
1144        ObservablesError::NoEphemeris => invalid_input("ephemeris", "no usable satellite state"),
1145        ObservablesError::InvalidInput { .. } => {
1146            invalid_input("observable_state", "must be finite and in range")
1147        }
1148        ObservablesError::Ephemeris(_) => invalid_input("ephemeris", "satellite state failed"),
1149        ObservablesError::Media(_) => invalid_input("media", "correction failed"),
1150    }
1151}
1152
1153pub(super) const fn augmented_dimension(base_dim: usize) -> usize {
1154    base_dim + TIGHT_CLOCK_STATE_COUNT
1155}
1156
1157pub(super) const fn clock_bias_index(base_dim: usize) -> usize {
1158    base_dim + TIGHT_CLOCK_BIAS_OFFSET
1159}
1160
1161pub(super) const fn clock_drift_index(base_dim: usize) -> usize {
1162    base_dim + TIGHT_CLOCK_DRIFT_OFFSET
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    //! Provenance: tight-coupled GNSS/INS rows follow Groves, Principles of
1168    //! GNSS, Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.,
1169    //! Chapter 14.2. Pseudorange-only convergence is checked against the
1170    //! in-crate SPP solver as an independent snapshot oracle. Doppler rows are
1171    //! checked against the existing `predict_range_rate_m_s` primitive. The
1172    //! weak-geometry properties check the information-form identity
1173    //! `P_plus^-1 = P_minus^-1 + H' R^-1 H`.
1174
1175    use super::*;
1176    use crate::astro::constants::earth::WGS84_A_M;
1177    use crate::fusion::state::{
1178        covariance_is_positive_semidefinite, ErrorStateLayout, ERROR_STATE_DIMENSION_15,
1179    };
1180    use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
1181    use crate::inertial::state::mat3_identity;
1182    use crate::inertial::{ImuSample, ImuSpec, NavState};
1183    use crate::observables::{ObservableState, ObservablesError};
1184    use crate::spp::{
1185        Corrections, KlobucharCoeffs, Observation, SolveInputs, SppError, SurfaceMet,
1186    };
1187    use crate::{GnssSatelliteId, GnssSystem};
1188    use nalgebra::{DMatrix, DVector};
1189
1190    const T0: f64 = 646_229_000.0;
1191    const SOD: f64 = 200.0;
1192    const DOY: f64 = 176.0;
1193
1194    #[derive(Debug, Clone)]
1195    struct LinearSource {
1196        t0_j2000_s: f64,
1197        states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>,
1198    }
1199
1200    impl LinearSource {
1201        fn new(t0_j2000_s: f64, states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>) -> Self {
1202            Self { t0_j2000_s, states }
1203        }
1204    }
1205
1206    impl ObservableEphemerisSource for LinearSource {
1207        fn observable_state_at_j2000_s(
1208            &self,
1209            sat: GnssSatelliteId,
1210            t_j2000_s: f64,
1211        ) -> Result<ObservableState, ObservablesError> {
1212            let (_, position, velocity, clock_s) = self
1213                .states
1214                .iter()
1215                .find(|(id, _, _, _)| *id == sat)
1216                .ok_or(ObservablesError::NoEphemeris)?;
1217            let dt_s = t_j2000_s - self.t0_j2000_s;
1218            Ok(ObservableState {
1219                position_ecef_m: [
1220                    position[0] + velocity[0] * dt_s,
1221                    position[1] + velocity[1] * dt_s,
1222                    position[2] + velocity[2] * dt_s,
1223                ],
1224                clock_s: Some(*clock_s),
1225            })
1226        }
1227    }
1228
1229    impl crate::spp::EphemerisSource for LinearSource {
1230        fn position_clock_at_j2000_s(
1231            &self,
1232            sat: GnssSatelliteId,
1233            t_j2000_s: f64,
1234        ) -> Option<([f64; 3], f64)> {
1235            let (_, position, velocity, clock_s) =
1236                self.states.iter().find(|(id, _, _, _)| *id == sat)?;
1237            let dt_s = t_j2000_s - self.t0_j2000_s;
1238            Some((
1239                [
1240                    position[0] + velocity[0] * dt_s,
1241                    position[1] + velocity[1] * dt_s,
1242                    position[2] + velocity[2] * dt_s,
1243                ],
1244                *clock_s,
1245            ))
1246        }
1247    }
1248
1249    fn sat(prn: u8) -> GnssSatelliteId {
1250        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1251    }
1252
1253    fn normalized(v: [f64; 3]) -> [f64; 3] {
1254        let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1255        [v[0] / n, v[1] / n, v[2] / n]
1256    }
1257
1258    fn source_from_directions(receiver: [f64; 3], directions: &[[f64; 3]]) -> LinearSource {
1259        source_from_directions_at_range(receiver, directions, 22_000_000.0)
1260    }
1261
1262    fn source_from_directions_at_range(
1263        receiver: [f64; 3],
1264        directions: &[[f64; 3]],
1265        range_m: f64,
1266    ) -> LinearSource {
1267        let states = directions
1268            .iter()
1269            .enumerate()
1270            .map(|(idx, direction)| {
1271                let unit = normalized(*direction);
1272                (
1273                    sat((idx + 1) as u8),
1274                    [
1275                        receiver[0] + range_m * unit[0],
1276                        receiver[1] + range_m * unit[1],
1277                        receiver[2] + range_m * unit[2],
1278                    ],
1279                    [0.0; 3],
1280                    0.0,
1281                )
1282            })
1283            .collect();
1284        LinearSource::new(T0, states)
1285    }
1286
1287    fn tight_epoch_from_source(
1288        source: &LinearSource,
1289        receiver: [f64; 3],
1290        clock_m: f64,
1291        sigma_m: f64,
1292    ) -> TightGnssEpoch {
1293        let observations = source
1294            .states
1295            .iter()
1296            .map(|(satellite_id, _, _, _)| {
1297                let prediction = transmit_time_satellite_state(
1298                    source,
1299                    *satellite_id,
1300                    receiver,
1301                    T0,
1302                    TransmitTimeOptions::default(),
1303                )
1304                .expect("satellite state");
1305                TightGnssObservation::pseudorange(
1306                    *satellite_id,
1307                    prediction.geometric_range_m + clock_m,
1308                    sigma_m,
1309                )
1310                .expect("observation")
1311            })
1312            .collect();
1313        TightGnssEpoch::new(T0, observations).expect("tight epoch")
1314    }
1315
1316    fn solve_inputs_from_epoch(epoch: &TightGnssEpoch, initial_guess: [f64; 4]) -> SolveInputs {
1317        SolveInputs {
1318            observations: epoch
1319                .observations
1320                .iter()
1321                .map(|observation| Observation {
1322                    satellite_id: observation.satellite_id,
1323                    pseudorange_m: observation.pseudorange_m,
1324                })
1325                .collect(),
1326            t_rx_j2000_s: epoch.t_j2000_s,
1327            t_rx_second_of_day_s: SOD,
1328            day_of_year: DOY,
1329            initial_guess,
1330            corrections: Corrections::NONE,
1331            klobuchar: KlobucharCoeffs {
1332                alpha: [0.0; 4],
1333                beta: [0.0; 4],
1334            },
1335            beidou_klobuchar: None,
1336            galileo_nequick: None,
1337            sbas_iono: None,
1338            glonass_channels: std::collections::BTreeMap::new(),
1339            met: SurfaceMet::default(),
1340            robust: None,
1341        }
1342    }
1343
1344    fn zero_noise_spec() -> ImuSpec {
1345        ImuSpec::datasheet(
1346            0.0,
1347            0.0,
1348            0.0,
1349            0.0,
1350            RANDOM_WALK_BIAS_TAU_S,
1351            RANDOM_WALK_BIAS_TAU_S,
1352            None,
1353            None,
1354        )
1355    }
1356
1357    fn filter_with_config(
1358        nominal: NavState,
1359        diagonal: &[f64],
1360        tight: TightCouplingConfig,
1361    ) -> InertialFilter {
1362        filter_with_kind(nominal, diagonal, tight, FusionFilterKind::Ekf)
1363    }
1364
1365    fn filter_with_kind(
1366        nominal: NavState,
1367        diagonal: &[f64],
1368        tight: TightCouplingConfig,
1369        filter_kind: FusionFilterKind,
1370    ) -> InertialFilter {
1371        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, diagonal)
1372            .expect("state");
1373        let mut config =
1374            super::super::loose::InertialFilterConfig::new(zero_noise_spec()).expect("config");
1375        config.tight = tight;
1376        config.filter_kind = filter_kind;
1377        InertialFilter::with_config(state, config).expect("filter")
1378    }
1379
1380    fn tight_config_for_test() -> TightCouplingConfig {
1381        TightCouplingConfig {
1382            initial_clock_bias_variance_m2: 1.0e12,
1383            initial_clock_drift_variance_m2_s2: 1.0e6,
1384            clock_bias_random_walk_m2_s: 0.0,
1385            clock_drift_random_walk_m2_s3: 0.0,
1386            ..TightCouplingConfig::default()
1387        }
1388    }
1389
1390    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1391        assert!(
1392            (actual - expected).abs() <= tolerance,
1393            "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
1394        );
1395    }
1396
1397    fn logdet_spd(matrix: &[Vec<f64>]) -> f64 {
1398        let n = matrix.len();
1399        let flat = matrix.iter().flatten().copied().collect::<Vec<_>>();
1400        let dmatrix = DMatrix::from_row_slice(n, n, &flat);
1401        let cholesky = dmatrix.cholesky().expect("SPD matrix");
1402        2.0 * cholesky
1403            .l()
1404            .diagonal()
1405            .iter()
1406            .map(|value| value.ln())
1407            .sum::<f64>()
1408    }
1409
1410    fn position_clock_block(filter: &InertialFilter) -> Vec<Vec<f64>> {
1411        let base_dim = filter.state.dimension();
1412        let clock = clock_bias_index(base_dim);
1413        let indices = [0usize, 1, 2, clock];
1414        indices
1415            .iter()
1416            .map(|row| {
1417                indices
1418                    .iter()
1419                    .map(|col| filter.tight.augmented_covariance[*row][*col])
1420                    .collect::<Vec<_>>()
1421            })
1422            .collect()
1423    }
1424
1425    fn position_clock_nees(
1426        filter: &InertialFilter,
1427        truth_position_m: [f64; 3],
1428        truth_clock_m: f64,
1429    ) -> f64 {
1430        let block = position_clock_block(filter);
1431        let flat = block.iter().flatten().copied().collect::<Vec<_>>();
1432        let covariance = DMatrix::from_row_slice(4, 4, &flat);
1433        let clock = filter.tight_clock_state().expect("clock");
1434        let error = DVector::from_vec(vec![
1435            filter.state().nominal.position_ecef_m[0] - truth_position_m[0],
1436            filter.state().nominal.position_ecef_m[1] - truth_position_m[1],
1437            filter.state().nominal.position_ecef_m[2] - truth_position_m[2],
1438            clock.bias_m - truth_clock_m,
1439        ]);
1440        let solved = covariance
1441            .cholesky()
1442            .expect("posterior covariance SPD")
1443            .solve(&error);
1444        error.dot(&solved)
1445    }
1446
1447    fn snapshot_position_clock_covariance(
1448        source: &LinearSource,
1449        receiver: [f64; 3],
1450        epoch: &TightGnssEpoch,
1451    ) -> Vec<Vec<f64>> {
1452        let mut normal = DMatrix::<f64>::zeros(4, 4);
1453        for observation in &epoch.observations {
1454            let prediction = transmit_time_satellite_state(
1455                source,
1456                observation.satellite_id,
1457                receiver,
1458                epoch.t_j2000_s,
1459                TransmitTimeOptions::default(),
1460            )
1461            .expect("satellite state");
1462            let h = [
1463                prediction.los_unit[0],
1464                prediction.los_unit[1],
1465                prediction.los_unit[2],
1466                1.0,
1467            ];
1468            let inv_var = 1.0 / (observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
1469            for row in 0..4 {
1470                for col in 0..4 {
1471                    normal[(row, col)] += h[row] * h[col] * inv_var;
1472                }
1473            }
1474        }
1475        let covariance = normal.try_inverse().expect("full-rank snapshot");
1476        (0..4)
1477            .map(|row| (0..4).map(|col| covariance[(row, col)]).collect())
1478            .collect()
1479    }
1480
1481    #[test]
1482    fn pseudorange_only_update_matches_spp_clock_oracle_with_frozen_ins_prior() {
1483        let receiver = [WGS84_A_M, 0.0, 0.0];
1484        let directions = [
1485            [1.0, 0.0, 0.0],
1486            [0.82, 0.42, 0.39],
1487            [0.83, -0.46, 0.31],
1488            [0.90, 0.18, -0.40],
1489            [0.78, -0.25, -0.58],
1490        ];
1491        let clock_m = 12.5;
1492        let source = source_from_directions(receiver, &directions);
1493        let epoch = tight_epoch_from_source(&source, receiver, clock_m, 1.0);
1494        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1495        let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1496
1497        let spp_position = spp.position.as_array();
1498        let nominal = NavState::new(T0, spp_position, [0.0; 3], mat3_identity()).expect("nominal");
1499        let diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1500        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1501
1502        let update = filter.update_tight(&source, &epoch).expect("tight update");
1503
1504        assert!(update.applied);
1505        for (got, expected) in filter
1506            .state()
1507            .nominal
1508            .position_ecef_m
1509            .iter()
1510            .zip(spp_position)
1511        {
1512            assert_close(*got, expected, 1.0e-6);
1513        }
1514        let clock = filter.tight_clock_state().expect("clock");
1515        assert_close(clock.bias_m, spp.rx_clock_s * C_M_S, 1.0e-5);
1516    }
1517
1518    #[test]
1519    fn doppler_row_uses_range_rate_predictor_geometry_bits() {
1520        let receiver = [WGS84_A_M, 0.0, 0.0];
1521        let satellite_id = sat(1);
1522        let source = LinearSource::new(
1523            T0,
1524            vec![(
1525                satellite_id,
1526                [WGS84_A_M + 22_000_000.0, 1_000_000.0, 2_000_000.0],
1527                [120.0, -40.0, 30.0],
1528                0.0,
1529            )],
1530        );
1531        let sat_state = transmit_time_satellite_state(
1532            &source,
1533            satellite_id,
1534            receiver,
1535            T0,
1536            TransmitTimeOptions::default(),
1537        )
1538        .expect("satellite state");
1539        let measured_receiver = ReceiverVelocityState {
1540            position_m: receiver,
1541            velocity_m_s: [5.0, -2.0, 1.0],
1542            clock_drift_m_s: 0.25,
1543        };
1544        let velocity_observation = VelocityObservation {
1545            sat: satellite_id,
1546            satellite_position_m: sat_state.position_ecef_m,
1547            satellite_velocity_m_s: sat_state.velocity_m_s,
1548            measured_range_rate_m_s: 0.0,
1549            sigma_m_s: 0.05,
1550            satellite_clock_drift_m_s: 0.01,
1551        };
1552        let measured = predict_range_rate_m_s(&velocity_observation, measured_receiver)
1553            .expect("measured range rate")
1554            .range_rate_m_s;
1555        let observation = TightGnssObservation {
1556            satellite_id,
1557            pseudorange_m: sat_state.geometric_range_m,
1558            pseudorange_sigma_m: 2.0,
1559            range_rate: Some(TightRangeRateObservation {
1560                measured_range_rate_m_s: measured,
1561                sigma_m_s: 0.05,
1562                satellite_clock_drift_m_s: 0.01,
1563            }),
1564            carrier_phase: None,
1565            ionosphere_delay_m: 0.0,
1566            troposphere_delay_m: 0.0,
1567        };
1568        let epoch = TightGnssEpoch::new(T0, vec![observation]).expect("epoch");
1569        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1570        let filter = filter_with_config(
1571            nominal,
1572            &[1.0; ERROR_STATE_DIMENSION_15],
1573            tight_config_for_test(),
1574        );
1575        let correction = tight_coupling_correction(
1576            &source,
1577            filter.state(),
1578            &filter.tight,
1579            &epoch,
1580            filter.config.tight,
1581            [0.0; 3],
1582        )
1583        .expect("correction");
1584        let predicted_at_nominal = predict_range_rate_m_s(
1585            &VelocityObservation {
1586                measured_range_rate_m_s: measured,
1587                ..velocity_observation
1588            },
1589            ReceiverVelocityState {
1590                position_m: receiver,
1591                velocity_m_s: [0.0; 3],
1592                clock_drift_m_s: 0.0,
1593            },
1594        )
1595        .expect("nominal range rate");
1596
1597        let doppler_row = &correction.design[1];
1598        for axis in 0..3 {
1599            assert_eq!(
1600                doppler_row[ERROR_VELOCITY_INDEX + axis].to_bits(),
1601                predicted_at_nominal.los_unit[axis].to_bits()
1602            );
1603        }
1604        assert_eq!(
1605            doppler_row[clock_drift_index(filter.state.dimension())].to_bits(),
1606            1.0_f64.to_bits()
1607        );
1608        assert_eq!(
1609            correction.innovation[1].to_bits(),
1610            (measured - predicted_at_nominal.range_rate_m_s).to_bits()
1611        );
1612    }
1613
1614    #[derive(Debug, Clone)]
1615    struct MovingClockSource {
1616        t0_j2000_s: f64,
1617        states: Vec<MovingClockState>,
1618    }
1619
1620    #[derive(Debug, Clone, Copy)]
1621    struct MovingClockState {
1622        satellite_id: GnssSatelliteId,
1623        position_ecef_m: [f64; 3],
1624        velocity_ecef_m_s: [f64; 3],
1625        clock_s: f64,
1626        clock_drift_s_s: f64,
1627    }
1628
1629    impl ObservableEphemerisSource for MovingClockSource {
1630        fn observable_state_at_j2000_s(
1631            &self,
1632            sat: GnssSatelliteId,
1633            t_j2000_s: f64,
1634        ) -> Result<ObservableState, ObservablesError> {
1635            let state = self
1636                .states
1637                .iter()
1638                .find(|state| state.satellite_id == sat)
1639                .ok_or(ObservablesError::NoEphemeris)?;
1640            let dt_s = t_j2000_s - self.t0_j2000_s;
1641            Ok(ObservableState {
1642                position_ecef_m: [
1643                    state.position_ecef_m[0] + state.velocity_ecef_m_s[0] * dt_s,
1644                    state.position_ecef_m[1] + state.velocity_ecef_m_s[1] * dt_s,
1645                    state.position_ecef_m[2] + state.velocity_ecef_m_s[2] * dt_s,
1646                ],
1647                clock_s: Some(state.clock_s + state.clock_drift_s_s * dt_s),
1648            })
1649        }
1650    }
1651
1652    impl crate::spp::EphemerisSource for MovingClockSource {
1653        fn position_clock_at_j2000_s(
1654            &self,
1655            sat: GnssSatelliteId,
1656            t_j2000_s: f64,
1657        ) -> Option<([f64; 3], f64)> {
1658            let state = self.states.iter().find(|state| state.satellite_id == sat)?;
1659            let dt_s = t_j2000_s - self.t0_j2000_s;
1660            Some((
1661                [
1662                    state.position_ecef_m[0] + state.velocity_ecef_m_s[0] * dt_s,
1663                    state.position_ecef_m[1] + state.velocity_ecef_m_s[1] * dt_s,
1664                    state.position_ecef_m[2] + state.velocity_ecef_m_s[2] * dt_s,
1665                ],
1666                state.clock_s + state.clock_drift_s_s * dt_s,
1667            ))
1668        }
1669    }
1670
1671    #[derive(Debug, Clone, Copy)]
1672    struct CodeOracleTerms {
1673        geometric_m: f64,
1674        satellite_clock_m: f64,
1675        ionosphere_m: f64,
1676        troposphere_m: f64,
1677        total_m: f64,
1678    }
1679
1680    impl CodeOracleTerms {
1681        fn from_spp_model(
1682            source: &MovingClockSource,
1683            sat: GnssSatelliteId,
1684            receiver: [f64; 3],
1685            pseudorange_m: f64,
1686            ionosphere_m: f64,
1687            troposphere_m: f64,
1688            receiver_clock_m: f64,
1689        ) -> Self {
1690            let glonass_channels = std::collections::BTreeMap::new();
1691            let met = SurfaceMet::default();
1692            let env = SatModelEnv {
1693                eph: source,
1694                t_rx_j2000_s: T0,
1695                t_rx_second_of_day_s: SOD,
1696                day_of_year: DOY,
1697                corrections: Corrections::NONE,
1698                met: &met,
1699                glonass_channels: &glonass_channels,
1700                model: SppModelRecipe::reference(),
1701            };
1702            let model = sat_model(
1703                &env,
1704                sat,
1705                receiver,
1706                0.0,
1707                pseudorange_m,
1708                SppIonosphere::Klobuchar(KlobucharCoeffs {
1709                    alpha: [0.0; 4],
1710                    beta: [0.0; 4],
1711                }),
1712            )
1713            .expect("SPP model");
1714            let geometric_m = norm3(sub3(model.sat_rot_ecef_m, receiver));
1715            let satellite_clock_m = model.p_hat_m - geometric_m;
1716            let total_m = model.p_hat_m + receiver_clock_m + ionosphere_m + troposphere_m;
1717            Self {
1718                geometric_m,
1719                satellite_clock_m,
1720                ionosphere_m,
1721                troposphere_m,
1722                total_m,
1723            }
1724        }
1725
1726        fn from_observable_model(
1727            source: &MovingClockSource,
1728            sat: GnssSatelliteId,
1729            receiver: [f64; 3],
1730            ionosphere_m: f64,
1731            troposphere_m: f64,
1732            receiver_clock_m: f64,
1733        ) -> Self {
1734            let prediction = transmit_time_satellite_state(
1735                source,
1736                sat,
1737                receiver,
1738                T0,
1739                TransmitTimeOptions::default(),
1740            )
1741            .expect("observable model");
1742            let satellite_clock_m = -C_M_S * prediction.clock_s.expect("satellite clock");
1743            let total_m = prediction.geometric_range_m
1744                + satellite_clock_m
1745                + receiver_clock_m
1746                + ionosphere_m
1747                + troposphere_m;
1748            Self {
1749                geometric_m: prediction.geometric_range_m,
1750                satellite_clock_m,
1751                ionosphere_m,
1752                troposphere_m,
1753                total_m,
1754            }
1755        }
1756
1757        fn tight_total_m(
1758            source: &MovingClockSource,
1759            sat: GnssSatelliteId,
1760            receiver: [f64; 3],
1761            pseudorange_m: f64,
1762            ionosphere_m: f64,
1763            troposphere_m: f64,
1764            receiver_clock_m: f64,
1765        ) -> f64 {
1766            let prediction = tight_code_satellite_prediction(
1767                source,
1768                sat,
1769                receiver,
1770                T0,
1771                pseudorange_m,
1772                TransmitTimeOptions::default(),
1773            )
1774            .expect("tight code model");
1775            prediction.clock_corrected_range_m + receiver_clock_m + ionosphere_m + troposphere_m
1776        }
1777    }
1778
1779    #[test]
1780    fn synthetic_code_oracle_pins_tight_to_spp_residual_surface() {
1781        let receiver = [WGS84_A_M, 0.0, 0.0];
1782        let rows = [
1783            (
1784                "high-elevation",
1785                sat(1),
1786                20_800_000.0,
1787                normalized([0.96, 0.17, 0.23]),
1788                [220.0, -680.0, 120.0],
1789                2.0e-5,
1790                2.0e-10,
1791                1.25,
1792                2.40,
1793            ),
1794            (
1795                "low-elevation",
1796                sat(2),
1797                24_200_000.0,
1798                normalized([0.09, 0.98, 0.18]),
1799                [-180.0, 1120.0, -460.0],
1800                -1.0e-5,
1801                -4.0e-10,
1802                5.75,
1803                8.80,
1804            ),
1805            (
1806                "fast-moving",
1807                sat(3),
1808                25_400_000.0,
1809                normalized([0.34, -0.73, 0.59]),
1810                [28_400.0, -31_200.0, 16_400.0],
1811                1.5e-5,
1812                1.2e-8,
1813                3.40,
1814                4.65,
1815            ),
1816        ];
1817        let source = MovingClockSource {
1818            t0_j2000_s: T0,
1819            states: rows
1820                .iter()
1821                .map(
1822                    |(
1823                        _label,
1824                        satellite_id,
1825                        range_m,
1826                        direction,
1827                        velocity_m_s,
1828                        clock_s,
1829                        clock_drift_s_s,
1830                        _iono_m,
1831                        _tropo_m,
1832                    )| {
1833                        MovingClockState {
1834                            satellite_id: *satellite_id,
1835                            position_ecef_m: [
1836                                receiver[0] + range_m * direction[0],
1837                                receiver[1] + range_m * direction[1],
1838                                receiver[2] + range_m * direction[2],
1839                            ],
1840                            velocity_ecef_m_s: *velocity_m_s,
1841                            clock_s: *clock_s,
1842                            clock_drift_s_s: *clock_drift_s_s,
1843                        }
1844                    },
1845                )
1846                .collect(),
1847        };
1848        let receiver_clock_m = 43.25;
1849        let mut max_observable_minus_spp_m = 0.0_f64;
1850
1851        for (
1852            label,
1853            satellite_id,
1854            range_m,
1855            _direction,
1856            _velocity_m_s,
1857            _clock_s,
1858            _clock_drift_s_s,
1859            ionosphere_m,
1860            troposphere_m,
1861        ) in rows
1862        {
1863            let pseudorange_m = range_m + receiver_clock_m + ionosphere_m + troposphere_m + 11.0;
1864            let spp = CodeOracleTerms::from_spp_model(
1865                &source,
1866                satellite_id,
1867                receiver,
1868                pseudorange_m,
1869                ionosphere_m,
1870                troposphere_m,
1871                receiver_clock_m,
1872            );
1873            let observable = CodeOracleTerms::from_observable_model(
1874                &source,
1875                satellite_id,
1876                receiver,
1877                ionosphere_m,
1878                troposphere_m,
1879                receiver_clock_m,
1880            );
1881            let tight_total_m = CodeOracleTerms::tight_total_m(
1882                &source,
1883                satellite_id,
1884                receiver,
1885                pseudorange_m,
1886                ionosphere_m,
1887                troposphere_m,
1888                receiver_clock_m,
1889            );
1890            let geom_delta_m = observable.geometric_m - spp.geometric_m;
1891            let sat_clock_delta_m = observable.satellite_clock_m - spp.satellite_clock_m;
1892            let media_delta_m = (observable.ionosphere_m + observable.troposphere_m)
1893                - (spp.ionosphere_m + spp.troposphere_m);
1894            let total_delta_m = observable.total_m - spp.total_m;
1895            eprintln!(
1896                "tight C1C oracle {label}: geom_delta_m={geom_delta_m:.9e} \
1897                 sat_clock_delta_m={sat_clock_delta_m:.9e} media_delta_m={media_delta_m:.9e} \
1898                 total_delta_m={total_delta_m:.9e}"
1899            );
1900            max_observable_minus_spp_m = max_observable_minus_spp_m.max(total_delta_m.abs());
1901            assert_eq!(tight_total_m.to_bits(), spp.total_m.to_bits(), "{label}");
1902        }
1903
1904        assert!(
1905            max_observable_minus_spp_m > 1.0e-3,
1906            "synthetic oracle should expose the pre-unification discrepancy"
1907        );
1908    }
1909
1910    #[test]
1911    fn tight_rows_match_closed_loop_finite_difference_signs() {
1912        let receiver = [WGS84_A_M + 8.0, -3.0, 2.0];
1913        let satellite_id = sat(1);
1914        let source = LinearSource::new(
1915            T0,
1916            vec![(
1917                satellite_id,
1918                [WGS84_A_M + 8_000.0, 900.0, -1_200.0],
1919                [12.0, -7.0, 3.0],
1920                0.0,
1921            )],
1922        );
1923        let observation = TightGnssObservation {
1924            satellite_id,
1925            pseudorange_m: 8_125.25,
1926            pseudorange_sigma_m: 0.5,
1927            range_rate: Some(TightRangeRateObservation {
1928                measured_range_rate_m_s: -4.25,
1929                sigma_m_s: 0.125,
1930                satellite_clock_drift_m_s: 0.03125,
1931            }),
1932            carrier_phase: None,
1933            ionosphere_delay_m: 0.125,
1934            troposphere_delay_m: -0.0625,
1935        };
1936        let epoch = TightGnssEpoch::new(T0, vec![observation]).expect("epoch");
1937        let nominal =
1938            NavState::new(T0, receiver, [1.5, -0.75, 0.375], mat3_identity()).expect("nominal");
1939        let config = TightCouplingConfig {
1940            lever_arm_body_m: [1.25, -0.5, 0.75],
1941            light_time: false,
1942            sagnac: false,
1943            ..tight_config_for_test()
1944        };
1945        let filter = filter_with_config(nominal, &[1.0; ERROR_STATE_DIMENSION_15], config);
1946        let body_rate_wrt_ecef_rps = [0.01, -0.02, 0.03];
1947        let correction = tight_coupling_correction(
1948            &source,
1949            filter.state(),
1950            &filter.tight,
1951            &epoch,
1952            config,
1953            body_rate_wrt_ecef_rps,
1954        )
1955        .expect("correction");
1956        let reference_prediction = tight_measurement_predictions(
1957            &source,
1958            filter.state(),
1959            filter.tight.clock_bias_m,
1960            filter.tight.clock_drift_m_s,
1961            &epoch,
1962            config,
1963            body_rate_wrt_ecef_rps,
1964        )
1965        .expect("prediction");
1966        let base_dim = filter.state.dimension();
1967        let checks = [
1968            (0usize, ERROR_POSITION_INDEX, 1.0e-3),
1969            (0, ERROR_ATTITUDE_INDEX + 2, 1.0e-3),
1970            (0, clock_bias_index(base_dim), 1.0e-3),
1971            (1, ERROR_VELOCITY_INDEX + 1, 1.0e-3),
1972            (1, ERROR_GYRO_BIAS_INDEX + 2, 1.0e-3),
1973            (1, clock_drift_index(base_dim), 1.0e-3),
1974        ];
1975
1976        for (row, column, step) in checks {
1977            let mut plus_dx = vec![0.0; augmented_dimension(base_dim)];
1978            plus_dx[column] = step;
1979            let plus = tight_sigma_measurement_residual(
1980                &source,
1981                filter.state(),
1982                &filter.tight,
1983                &epoch,
1984                config,
1985                body_rate_wrt_ecef_rps,
1986                &reference_prediction,
1987                &plus_dx,
1988            )
1989            .expect("plus residual");
1990            let mut minus_dx = vec![0.0; augmented_dimension(base_dim)];
1991            minus_dx[column] = -step;
1992            let minus = tight_sigma_measurement_residual(
1993                &source,
1994                filter.state(),
1995                &filter.tight,
1996                &epoch,
1997                config,
1998                body_rate_wrt_ecef_rps,
1999                &reference_prediction,
2000                &minus_dx,
2001            )
2002            .expect("minus residual");
2003            let derivative = (plus[row] - minus[row]) / (2.0 * step);
2004            let expected = correction.design[row][column];
2005            assert!(
2006                (derivative - expected).abs() <= 5.0e-7,
2007                "row {row}, column {column}, derivative {derivative:.17e}, expected {expected:.17e}"
2008            );
2009        }
2010    }
2011
2012    #[test]
2013    fn singular_snapshot_geometry_keeps_unobserved_prior_covariance() {
2014        let receiver = [WGS84_A_M, 0.0, 0.0];
2015        let directions = [[1.0, 0.0, 0.0]; 5];
2016        let source = source_from_directions(receiver, &directions);
2017        let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
2018        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
2019        assert!(matches!(
2020            crate::spp::solve(&source, &inputs, false),
2021            Err(SppError::Singular(_))
2022        ));
2023
2024        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
2025        let mut diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
2026        diagonal[ERROR_POSITION_INDEX] = 100.0;
2027        diagonal[ERROR_POSITION_INDEX + 1] = 225.0;
2028        diagonal[ERROR_POSITION_INDEX + 2] = 400.0;
2029        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
2030        let prior_y = filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1];
2031        let prior_z = filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2];
2032
2033        let update = filter.update_tight(&source, &epoch).expect("tight update");
2034
2035        assert!(update.applied);
2036        assert!(covariance_is_positive_semidefinite(&filter.state.covariance).expect("PSD"));
2037        assert_eq!(
2038            filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1].to_bits(),
2039            prior_y.to_bits()
2040        );
2041        assert_eq!(
2042            filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2].to_bits(),
2043            prior_z.to_bits()
2044        );
2045        assert!(filter
2046            .state
2047            .nominal
2048            .position_ecef_m
2049            .iter()
2050            .all(|value| value.is_finite() && value.abs() < 1.0e8));
2051    }
2052
2053    #[test]
2054    fn high_dop_fused_covariance_has_lower_logdet_than_snapshot() {
2055        let receiver = [WGS84_A_M, 0.0, 0.0];
2056        let directions = [
2057            [0.44974122498328417, -0.8581153514788689, 0.2477314556265159],
2058            [0.20081904418348107, 0.5332143328087052, 0.8217993591994339],
2059            [0.43760604888398824, -0.4903647504582244, 0.7536865114145189],
2060            [
2061                0.2148508784686108,
2062                -0.9558725523345635,
2063                -0.20036657334663732,
2064            ],
2065            [0.30949187488876595, 0.3289789392404428, 0.8921813923827763],
2066        ];
2067        let source = source_from_directions(receiver, &directions);
2068        let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
2069        let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
2070        let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
2071        assert_eq!(
2072            spp.geometry_quality.tier,
2073            crate::geometry_quality::ObservabilityTier::Weak
2074        );
2075        let snapshot_covariance = snapshot_position_clock_covariance(&source, receiver, &epoch);
2076        let snapshot_logdet = logdet_spd(&snapshot_covariance);
2077
2078        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
2079        let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
2080        for axis in 0..3 {
2081            diagonal[ERROR_POSITION_INDEX + axis] = 1.0e8;
2082        }
2083        let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
2084
2085        filter.update_tight(&source, &epoch).expect("tight update");
2086
2087        let fused_logdet = logdet_spd(&position_clock_block(&filter));
2088        assert!(
2089            fused_logdet < snapshot_logdet,
2090            "fused {fused_logdet:.17e}, snapshot {snapshot_logdet:.17e}"
2091        );
2092    }
2093
2094    #[test]
2095    fn close_range_tight_ukf_nees_is_no_worse_than_ekf() {
2096        let truth_position = [WGS84_A_M + 10.0, 20.0, -15.0];
2097        let nominal_position = [
2098            truth_position[0] + 8.0,
2099            truth_position[1] - 6.0,
2100            truth_position[2] + 5.0,
2101        ];
2102        let directions = [
2103            [1.0, 0.0, 0.0],
2104            [-0.8, 0.5, 0.2],
2105            [0.2, 1.0, -0.1],
2106            [-0.2, -0.9, 0.4],
2107            [0.1, 0.2, 1.0],
2108            [-0.3, 0.1, -1.0],
2109        ];
2110        let source = source_from_directions_at_range(truth_position, &directions, 80.0);
2111        let truth_clock_m = 3.0;
2112        let observations = source
2113            .states
2114            .iter()
2115            .map(|(satellite_id, _, _, _)| {
2116                let prediction = transmit_time_satellite_state(
2117                    &source,
2118                    *satellite_id,
2119                    truth_position,
2120                    T0,
2121                    TransmitTimeOptions {
2122                        light_time: false,
2123                        sagnac: false,
2124                    },
2125                )
2126                .expect("truth prediction");
2127                TightGnssObservation::pseudorange(
2128                    *satellite_id,
2129                    prediction.geometric_range_m + truth_clock_m,
2130                    0.25,
2131                )
2132                .expect("observation")
2133            })
2134            .collect::<Vec<_>>();
2135        let epoch = TightGnssEpoch::new(T0, observations).expect("epoch");
2136        let nominal =
2137            NavState::new(T0, nominal_position, [0.0; 3], mat3_identity()).expect("nominal");
2138        let mut diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
2139        for axis in 0..3 {
2140            diagonal[ERROR_POSITION_INDEX + axis] = 100.0;
2141        }
2142        let tight = TightCouplingConfig {
2143            light_time: false,
2144            sagnac: false,
2145            initial_clock_bias_variance_m2: 100.0,
2146            initial_clock_drift_variance_m2_s2: 1.0e-6,
2147            clock_bias_random_walk_m2_s: 0.0,
2148            clock_drift_random_walk_m2_s3: 0.0,
2149            ..TightCouplingConfig::default()
2150        };
2151        let mut ekf = filter_with_kind(nominal, &diagonal, tight, FusionFilterKind::Ekf);
2152        let mut ukf = filter_with_kind(nominal, &diagonal, tight, FusionFilterKind::Ukf);
2153
2154        ekf.update_tight(&source, &epoch).expect("ekf update");
2155        ukf.update_tight(&source, &epoch).expect("ukf update");
2156
2157        let ekf_nees = position_clock_nees(&ekf, truth_position, truth_clock_m);
2158        let ukf_nees = position_clock_nees(&ukf, truth_position, truth_clock_m);
2159        assert!(
2160            ukf_nees <= ekf_nees,
2161            "UKF NEES {ukf_nees:.17e}, EKF NEES {ekf_nees:.17e}"
2162        );
2163    }
2164
2165    #[test]
2166    fn outage_growth_and_single_satellite_observed_direction_update() {
2167        let receiver = [WGS84_A_M, 0.0, 0.0];
2168        let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
2169        let diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
2170        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
2171            .expect("state");
2172        let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
2173        let mut config = super::super::loose::InertialFilterConfig::new(spec).expect("config");
2174        config.tight = TightCouplingConfig {
2175            light_time: false,
2176            sagnac: false,
2177            initial_clock_bias_variance_m2: 100.0,
2178            initial_clock_drift_variance_m2_s2: 1.0,
2179            clock_bias_random_walk_m2_s: 4.0,
2180            clock_drift_random_walk_m2_s3: 0.25,
2181            ..TightCouplingConfig::default()
2182        };
2183        let mut filter = InertialFilter::with_config(state, config).expect("filter");
2184        let mut previous_logdet = logdet_spd(&filter.tight.augmented_covariance);
2185
2186        for step in 1..=3 {
2187            filter
2188                .propagate(ImuSample::increment(
2189                    T0 + step as f64,
2190                    [0.0; 3],
2191                    [0.0; 3],
2192                    1.0,
2193                ))
2194                .expect("propagate");
2195            let next_logdet = logdet_spd(&filter.tight.augmented_covariance);
2196            assert!(
2197                next_logdet > previous_logdet,
2198                "step {step} logdet {next_logdet:.17e} <= {previous_logdet:.17e}"
2199            );
2200            previous_logdet = next_logdet;
2201        }
2202
2203        let current_position = filter.state.nominal.position_ecef_m;
2204        let satellite_id = sat(1);
2205        let source = LinearSource::new(
2206            filter.state.nominal.t_j2000_s,
2207            vec![(
2208                satellite_id,
2209                [
2210                    current_position[0] + 22_000_000.0,
2211                    current_position[1],
2212                    current_position[2],
2213                ],
2214                [0.0; 3],
2215                0.0,
2216            )],
2217        );
2218        let prediction = transmit_time_satellite_state(
2219            &source,
2220            satellite_id,
2221            current_position,
2222            filter.state.nominal.t_j2000_s,
2223            TransmitTimeOptions {
2224                light_time: false,
2225                sagnac: false,
2226            },
2227        )
2228        .expect("satellite state");
2229        let clock = filter.tight_clock_state().expect("clock");
2230        let epoch = TightGnssEpoch::new(
2231            filter.state.nominal.t_j2000_s,
2232            vec![TightGnssObservation::pseudorange(
2233                satellite_id,
2234                prediction.geometric_range_m + clock.bias_m,
2235                0.5,
2236            )
2237            .expect("observation")],
2238        )
2239        .expect("epoch");
2240        let pre = filter.state.covariance.clone();
2241
2242        filter
2243            .update_tight(&source, &epoch)
2244            .expect("single-sat update");
2245
2246        assert!(
2247            filter.state.covariance[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
2248                < pre[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
2249        );
2250        for axis in [1usize, 2] {
2251            assert_eq!(
2252                filter.state.covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis]
2253                    .to_bits(),
2254                pre[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis].to_bits()
2255            );
2256        }
2257    }
2258}