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