1use 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
41pub const TIGHT_CLOCK_BIAS_OFFSET: usize = 0;
43pub const TIGHT_CLOCK_DRIFT_OFFSET: usize = 1;
45pub const TIGHT_CLOCK_STATE_COUNT: usize = 2;
47
48#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct TightRangeRateObservation {
51 pub measured_range_rate_m_s: f64,
53 pub sigma_m_s: f64,
55 pub satellite_clock_drift_m_s: f64,
57}
58
59impl TightRangeRateObservation {
60 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#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct TightCarrierPhaseObservation {
73 pub phase_range_m: f64,
75 pub sigma_m: f64,
77 pub float_ambiguity_m: f64,
79}
80
81impl TightCarrierPhaseObservation {
82 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#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct TightGnssObservation {
93 pub satellite_id: crate::GnssSatelliteId,
95 pub pseudorange_m: f64,
97 pub pseudorange_sigma_m: f64,
99 pub range_rate: Option<TightRangeRateObservation>,
101 pub carrier_phase: Option<TightCarrierPhaseObservation>,
103 pub ionosphere_delay_m: f64,
105 pub troposphere_delay_m: f64,
107}
108
109impl TightGnssObservation {
110 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 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#[derive(Debug, Clone, PartialEq)]
149pub struct TightGnssEpoch {
150 pub t_j2000_s: f64,
152 pub observations: Vec<TightGnssObservation>,
154}
155
156impl TightGnssEpoch {
157 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 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#[derive(Debug, Clone, Copy, PartialEq)]
192pub struct TightCouplingConfig {
193 pub lever_arm_body_m: [f64; 3],
195 pub light_time: bool,
198 pub sagnac: bool,
200 pub initial_clock_bias_variance_m2: f64,
202 pub initial_clock_drift_variance_m2_s2: f64,
204 pub clock_bias_random_walk_m2_s: f64,
206 pub clock_drift_random_walk_m2_s3: f64,
208 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 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#[derive(Debug, Clone, Copy, PartialEq)]
257pub struct TightClockState {
258 pub bias_m: f64,
260 pub drift_m_s: f64,
262 pub covariance: [[f64; TIGHT_CLOCK_STATE_COUNT]; TIGHT_CLOCK_STATE_COUNT],
264}
265
266#[derive(Debug, Clone, PartialEq)]
268pub struct TightFilterSnapshot {
269 pub clock_bias_m: f64,
271 pub clock_drift_m_s: f64,
273 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 pub fn tight_clock_state(&self) -> Result<TightClockState, FusionError> {
480 self.tight.clock_state(self.state.dimension())
481 }
482
483 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 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}