1use std::collections::BTreeSet;
8
9use crate::astro::math::mat3::{inline_rxr, mul_vec3};
10use crate::astro::math::vec3::{add3, cross3};
11use crate::constants::C_M_S;
12use crate::inertial::state::skew;
13use crate::inertial::{validate_finite, validate_vec3};
14use crate::observables::{
15 transmit_time_satellite_state, ObservableEphemerisSource, ObservablesError, TransmitTimeOptions,
16};
17use crate::precise_positioning::{
18 predict_range_rate_m_s, ReceiverVelocityState, VelocityObservation,
19};
20
21use super::ekf::{
22 apply_closed_loop_navigation_error, apply_closed_loop_scale_error, innovation_covariance,
23 joseph_covariance_update, normalized_innovation_squared, screen_correction, EkfCorrection,
24 EkfCorrectionReport, EkfUpdateOptions,
25};
26use super::loose::{FusionUpdate, InertialFilter};
27use super::state::{
28 identity, invalid_input, matmul, matrix_add, reproject_covariance_psd, symmetrize_in_place,
29 validate_covariance_matrix, validate_finite_slice, validate_nonnegative, validate_positive,
30 validate_square_matrix, FusionError, InsFilterState, ERROR_ATTITUDE_INDEX,
31 ERROR_GYRO_BIAS_INDEX, ERROR_POSITION_INDEX, ERROR_VELOCITY_INDEX,
32};
33
34pub const TIGHT_CLOCK_BIAS_OFFSET: usize = 0;
36pub const TIGHT_CLOCK_DRIFT_OFFSET: usize = 1;
38pub const TIGHT_CLOCK_STATE_COUNT: usize = 2;
40
41#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct TightRangeRateObservation {
44 pub measured_range_rate_m_s: f64,
46 pub sigma_m_s: f64,
48 pub satellite_clock_drift_m_s: f64,
50}
51
52impl TightRangeRateObservation {
53 pub fn validate(&self) -> Result<(), FusionError> {
55 validate_finite(self.measured_range_rate_m_s, "measured_range_rate_m_s")
56 .map_err(FusionError::from)?;
57 validate_positive(self.sigma_m_s, "range_rate_sigma_m_s")?;
58 validate_finite(self.satellite_clock_drift_m_s, "satellite_clock_drift_m_s")
59 .map_err(FusionError::from)
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct TightCarrierPhaseObservation {
66 pub phase_range_m: f64,
68 pub sigma_m: f64,
70 pub float_ambiguity_m: f64,
72}
73
74impl TightCarrierPhaseObservation {
75 pub fn validate(&self) -> Result<(), FusionError> {
77 validate_finite(self.phase_range_m, "phase_range_m").map_err(FusionError::from)?;
78 validate_positive(self.sigma_m, "carrier_sigma_m")?;
79 validate_finite(self.float_ambiguity_m, "float_ambiguity_m").map_err(FusionError::from)
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct TightGnssObservation {
86 pub satellite_id: crate::GnssSatelliteId,
88 pub pseudorange_m: f64,
90 pub pseudorange_sigma_m: f64,
92 pub range_rate: Option<TightRangeRateObservation>,
94 pub carrier_phase: Option<TightCarrierPhaseObservation>,
96 pub ionosphere_delay_m: f64,
98 pub troposphere_delay_m: f64,
100}
101
102impl TightGnssObservation {
103 pub fn pseudorange(
105 satellite_id: crate::GnssSatelliteId,
106 pseudorange_m: f64,
107 pseudorange_sigma_m: f64,
108 ) -> Result<Self, FusionError> {
109 let observation = Self {
110 satellite_id,
111 pseudorange_m,
112 pseudorange_sigma_m,
113 range_rate: None,
114 carrier_phase: None,
115 ionosphere_delay_m: 0.0,
116 troposphere_delay_m: 0.0,
117 };
118 observation.validate()?;
119 Ok(observation)
120 }
121
122 pub fn validate(&self) -> Result<(), FusionError> {
124 validate_finite(self.pseudorange_m, "pseudorange_m").map_err(FusionError::from)?;
125 validate_positive(self.pseudorange_sigma_m, "pseudorange_sigma_m")?;
126 validate_finite(self.ionosphere_delay_m, "ionosphere_delay_m")
127 .map_err(FusionError::from)?;
128 validate_finite(self.troposphere_delay_m, "troposphere_delay_m")
129 .map_err(FusionError::from)?;
130 if let Some(range_rate) = self.range_rate {
131 range_rate.validate()?;
132 }
133 if let Some(carrier_phase) = self.carrier_phase {
134 carrier_phase.validate()?;
135 }
136 Ok(())
137 }
138}
139
140#[derive(Debug, Clone, PartialEq)]
142pub struct TightGnssEpoch {
143 pub t_j2000_s: f64,
145 pub observations: Vec<TightGnssObservation>,
147}
148
149impl TightGnssEpoch {
150 pub fn new(
152 t_j2000_s: f64,
153 observations: Vec<TightGnssObservation>,
154 ) -> Result<Self, FusionError> {
155 let epoch = Self {
156 t_j2000_s,
157 observations,
158 };
159 epoch.validate()?;
160 Ok(epoch)
161 }
162
163 pub fn validate(&self) -> Result<(), FusionError> {
165 validate_finite(self.t_j2000_s, "t_j2000_s").map_err(FusionError::from)?;
166 if self.observations.is_empty() {
167 return Err(invalid_input("tight_observations", "must not be empty"));
168 }
169 let mut seen = BTreeSet::new();
170 for observation in &self.observations {
171 observation.validate()?;
172 if !seen.insert(observation.satellite_id) {
173 return Err(invalid_input(
174 "tight_observations",
175 "satellites must be unique",
176 ));
177 }
178 }
179 Ok(())
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq)]
185pub struct TightCouplingConfig {
186 pub lever_arm_body_m: [f64; 3],
188 pub light_time: bool,
190 pub sagnac: bool,
192 pub initial_clock_bias_variance_m2: f64,
194 pub initial_clock_drift_variance_m2_s2: f64,
196 pub clock_bias_random_walk_m2_s: f64,
198 pub clock_drift_random_walk_m2_s3: f64,
200 pub update_options: EkfUpdateOptions,
202}
203
204impl Default for TightCouplingConfig {
205 fn default() -> Self {
206 Self {
207 lever_arm_body_m: [0.0; 3],
208 light_time: true,
209 sagnac: true,
210 initial_clock_bias_variance_m2: 1.0e12,
211 initial_clock_drift_variance_m2_s2: 1.0e6,
212 clock_bias_random_walk_m2_s: 1.0,
213 clock_drift_random_walk_m2_s3: 1.0e-2,
214 update_options: EkfUpdateOptions::default(),
215 }
216 }
217}
218
219impl TightCouplingConfig {
220 pub fn validate(&self) -> Result<(), FusionError> {
222 validate_vec3(self.lever_arm_body_m, "tight_lever_arm_body_m")
223 .map_err(FusionError::from)?;
224 validate_nonnegative(
225 self.initial_clock_bias_variance_m2,
226 "initial_clock_bias_variance_m2",
227 )?;
228 validate_nonnegative(
229 self.initial_clock_drift_variance_m2_s2,
230 "initial_clock_drift_variance_m2_s2",
231 )?;
232 validate_nonnegative(
233 self.clock_bias_random_walk_m2_s,
234 "clock_bias_random_walk_m2_s",
235 )?;
236 validate_nonnegative(
237 self.clock_drift_random_walk_m2_s3,
238 "clock_drift_random_walk_m2_s3",
239 )?;
240 if let Some(gate) = self.update_options.innovation_gate {
241 gate.validate()?;
242 }
243 Ok(())
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq)]
249pub struct TightClockState {
250 pub bias_m: f64,
252 pub drift_m_s: f64,
254 pub covariance: [[f64; TIGHT_CLOCK_STATE_COUNT]; TIGHT_CLOCK_STATE_COUNT],
256}
257
258#[derive(Debug, Clone, PartialEq)]
260pub struct TightFilterSnapshot {
261 pub clock_bias_m: f64,
263 pub clock_drift_m_s: f64,
265 pub augmented_covariance: Vec<Vec<f64>>,
267}
268
269#[derive(Debug, Clone, PartialEq)]
270pub(super) struct TightFusionState {
271 clock_bias_m: f64,
272 clock_drift_m_s: f64,
273 augmented_covariance: Vec<Vec<f64>>,
274}
275
276impl TightFusionState {
277 pub(super) fn from_filter_state(
278 state: &InsFilterState,
279 config: TightCouplingConfig,
280 ) -> Result<Self, FusionError> {
281 config.validate()?;
282 let base_dim = state.dimension();
283 let aug_dim = augmented_dimension(base_dim);
284 let mut augmented_covariance = vec![vec![0.0; aug_dim]; aug_dim];
285 for (row, base_row) in state.covariance.iter().enumerate().take(base_dim) {
286 augmented_covariance[row][..base_dim].copy_from_slice(&base_row[..base_dim]);
287 }
288 let clock_bias_index = clock_bias_index(base_dim);
289 let clock_drift_index = clock_drift_index(base_dim);
290 augmented_covariance[clock_bias_index][clock_bias_index] =
291 config.initial_clock_bias_variance_m2;
292 augmented_covariance[clock_drift_index][clock_drift_index] =
293 config.initial_clock_drift_variance_m2_s2;
294 let tight = Self {
295 clock_bias_m: 0.0,
296 clock_drift_m_s: 0.0,
297 augmented_covariance,
298 };
299 tight.validate(base_dim)?;
300 Ok(tight)
301 }
302
303 pub(super) fn snapshot(&self) -> TightFilterSnapshot {
304 TightFilterSnapshot {
305 clock_bias_m: self.clock_bias_m,
306 clock_drift_m_s: self.clock_drift_m_s,
307 augmented_covariance: self.augmented_covariance.clone(),
308 }
309 }
310
311 pub(super) fn restore(
312 &mut self,
313 snapshot: &TightFilterSnapshot,
314 base_dim: usize,
315 ) -> Result<(), FusionError> {
316 validate_finite(snapshot.clock_bias_m, "clock_bias_m").map_err(FusionError::from)?;
317 validate_finite(snapshot.clock_drift_m_s, "clock_drift_m_s").map_err(FusionError::from)?;
318 validate_covariance_matrix(
319 &snapshot.augmented_covariance,
320 augmented_dimension(base_dim),
321 "tight_augmented_covariance",
322 )?;
323 self.clock_bias_m = snapshot.clock_bias_m;
324 self.clock_drift_m_s = snapshot.clock_drift_m_s;
325 self.augmented_covariance = snapshot.augmented_covariance.clone();
326 self.validate(base_dim)
327 }
328
329 pub(super) fn clock_state(&self, base_dim: usize) -> Result<TightClockState, FusionError> {
330 self.validate(base_dim)?;
331 let bias = clock_bias_index(base_dim);
332 let drift = clock_drift_index(base_dim);
333 Ok(TightClockState {
334 bias_m: self.clock_bias_m,
335 drift_m_s: self.clock_drift_m_s,
336 covariance: [
337 [
338 self.augmented_covariance[bias][bias],
339 self.augmented_covariance[bias][drift],
340 ],
341 [
342 self.augmented_covariance[drift][bias],
343 self.augmented_covariance[drift][drift],
344 ],
345 ],
346 })
347 }
348
349 pub(super) fn validate(&self, base_dim: usize) -> Result<(), FusionError> {
350 validate_finite(self.clock_bias_m, "clock_bias_m").map_err(FusionError::from)?;
351 validate_finite(self.clock_drift_m_s, "clock_drift_m_s").map_err(FusionError::from)?;
352 validate_covariance_matrix(
353 &self.augmented_covariance,
354 augmented_dimension(base_dim),
355 "tight_augmented_covariance",
356 )
357 }
358
359 pub(super) fn align_with_filter_state(
360 &mut self,
361 state: &InsFilterState,
362 ) -> Result<(), FusionError> {
363 state.validate()?;
364 let base_dim = state.dimension();
365 self.validate(base_dim)?;
366 let mut differs = false;
367 'outer: for row in 0..base_dim {
368 for col in 0..base_dim {
369 if self.augmented_covariance[row][col].to_bits()
370 != state.covariance[row][col].to_bits()
371 {
372 differs = true;
373 break 'outer;
374 }
375 }
376 }
377 if differs {
378 self.replace_base_covariance_and_clear_cross(&state.covariance)?;
379 }
380 Ok(())
381 }
382
383 pub(super) fn replace_base_covariance_and_clear_cross(
384 &mut self,
385 base_covariance: &[Vec<f64>],
386 ) -> Result<(), FusionError> {
387 let base_dim = base_covariance.len();
388 validate_covariance_matrix(base_covariance, base_dim, "covariance")?;
389 self.validate(base_dim)?;
390 let aug_dim = augmented_dimension(base_dim);
391 for (row, base_row) in base_covariance.iter().enumerate().take(base_dim) {
392 self.augmented_covariance[row][..base_dim].copy_from_slice(&base_row[..base_dim]);
393 }
394 for idx in 0..base_dim {
395 for clock in base_dim..aug_dim {
396 self.augmented_covariance[idx][clock] = 0.0;
397 self.augmented_covariance[clock][idx] = 0.0;
398 }
399 }
400 self.validate(base_dim)
401 }
402
403 pub(super) fn predict_covariance(
404 &mut self,
405 phi_base: &[Vec<f64>],
406 q_base: &[Vec<f64>],
407 dt_s: f64,
408 config: TightCouplingConfig,
409 ) -> Result<(), FusionError> {
410 config.validate()?;
411 validate_nonnegative(dt_s, "dt_s")?;
412 let base_dim = phi_base.len();
413 validate_square_matrix(phi_base, base_dim, "phi")?;
414 validate_covariance_matrix(q_base, base_dim, "q_d")?;
415 self.validate(base_dim)?;
416
417 let aug_dim = augmented_dimension(base_dim);
418 let mut phi = identity(aug_dim);
419 for row in 0..base_dim {
420 for col in 0..base_dim {
421 phi[row][col] = phi_base[row][col];
422 }
423 }
424 let bias = clock_bias_index(base_dim);
425 let drift = clock_drift_index(base_dim);
426 phi[bias][drift] = dt_s;
427
428 let mut q = vec![vec![0.0; aug_dim]; aug_dim];
429 for row in 0..base_dim {
430 for col in 0..base_dim {
431 q[row][col] = q_base[row][col];
432 }
433 }
434 let dt2 = dt_s * dt_s;
435 let dt3 = dt2 * dt_s;
436 q[bias][bias] += config.clock_bias_random_walk_m2_s * dt_s
437 + config.clock_drift_random_walk_m2_s3 * dt3 / 3.0;
438 q[bias][drift] += config.clock_drift_random_walk_m2_s3 * dt2 / 2.0;
439 q[drift][bias] = q[bias][drift];
440 q[drift][drift] += config.clock_drift_random_walk_m2_s3 * dt_s;
441 reproject_covariance_psd(&mut q, "tight_process_noise")?;
442
443 let left = matmul(&phi, &self.augmented_covariance)?;
444 let phi_t = super::state::transpose(&phi)?;
445 let propagated = matmul(&left, &phi_t)?;
446 let mut next = matrix_add(&propagated, &q)?;
447 symmetrize_in_place(&mut next);
448 reproject_covariance_psd(&mut next, "tight_augmented_covariance")?;
449 self.clock_bias_m += self.clock_drift_m_s * dt_s;
450 self.augmented_covariance = next;
451 self.validate(base_dim)
452 }
453
454 pub(super) fn copy_base_covariance_to_state(
455 &self,
456 state: &mut InsFilterState,
457 ) -> Result<(), FusionError> {
458 let base_dim = state.dimension();
459 self.validate(base_dim)?;
460 for row in 0..base_dim {
461 for col in 0..base_dim {
462 state.covariance[row][col] = self.augmented_covariance[row][col];
463 }
464 }
465 state.validate()
466 }
467}
468
469impl InertialFilter {
470 pub fn tight_clock_state(&self) -> Result<TightClockState, FusionError> {
472 self.tight.clock_state(self.state.dimension())
473 }
474
475 pub fn update_tight(
480 &mut self,
481 source: &dyn ObservableEphemerisSource,
482 epoch: &TightGnssEpoch,
483 ) -> Result<FusionUpdate, FusionError> {
484 if let Some(last) = self.time_sync.last_measurement_t_j2000_s() {
485 if epoch.t_j2000_s <= last {
486 return Err(invalid_input(
487 "t_j2000_s",
488 "GNSS measurement epochs must be strictly increasing",
489 ));
490 }
491 }
492 let update = self.update_tight_core(source, epoch)?;
493 let snapshot = self.snapshot();
494 self.time_sync
495 .push_tight_measurement_and_checkpoint(epoch.clone(), snapshot);
496 Ok(update)
497 }
498
499 pub(super) fn update_tight_core(
500 &mut self,
501 source: &dyn ObservableEphemerisSource,
502 epoch: &TightGnssEpoch,
503 ) -> Result<FusionUpdate, FusionError> {
504 self.tight.align_with_filter_state(&self.state)?;
505 let correction = tight_coupling_correction(
506 source,
507 &self.state,
508 &self.tight,
509 epoch,
510 self.config.tight,
511 self.last_body_rate_wrt_ecef_rps,
512 )?;
513 let rows = correction.row_count();
514 let report = apply_tight_correction(self, &correction, self.config.tight.update_options)?;
515 Ok(FusionUpdate {
516 applied: report.applied,
517 nis: report.normalized_innovation_squared,
518 rows,
519 accepted_rows: report.accepted_rows,
520 rejected_rows: report.rejected_rows,
521 ekf: report,
522 })
523 }
524}
525
526pub(super) fn tight_coupling_correction(
527 source: &dyn ObservableEphemerisSource,
528 state: &InsFilterState,
529 tight_state: &TightFusionState,
530 epoch: &TightGnssEpoch,
531 config: TightCouplingConfig,
532 body_rate_wrt_ecef_rps: [f64; 3],
533) -> Result<EkfCorrection, FusionError> {
534 state.validate()?;
535 tight_state.validate(state.dimension())?;
536 epoch.validate()?;
537 config.validate()?;
538 validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
539 if epoch.t_j2000_s != state.nominal.t_j2000_s {
540 return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
541 }
542
543 let base_dim = state.dimension();
544 let aug_dim = augmented_dimension(base_dim);
545 let clock_bias = clock_bias_index(base_dim);
546 let clock_drift = clock_drift_index(base_dim);
547 let kinematics = antenna_kinematics(state, config.lever_arm_body_m, body_rate_wrt_ecef_rps);
548 let options = TransmitTimeOptions {
549 light_time: config.light_time,
550 sagnac: config.sagnac,
551 };
552
553 let mut innovation = Vec::new();
554 let mut design = Vec::new();
555 let mut variances = Vec::new();
556
557 for observation in &epoch.observations {
558 let satellite = transmit_time_satellite_state(
559 source,
560 observation.satellite_id,
561 kinematics.antenna_position_ecef_m,
562 epoch.t_j2000_s,
563 options,
564 )
565 .map_err(map_observables_error)?;
566 let sat_clock_s = satellite
567 .clock_s
568 .ok_or_else(|| invalid_input("satellite_clock_s", "must be present"))?;
569
570 let code_prediction_m = satellite.geometric_range_m + tight_state.clock_bias_m
571 - C_M_S * sat_clock_s
572 + observation.ionosphere_delay_m
573 + observation.troposphere_delay_m;
574 let mut row = pseudorange_design_row(
575 aug_dim,
576 clock_bias,
577 satellite.los_unit,
578 kinematics.lever_arm_ecef_m,
579 );
580 innovation.push(observation.pseudorange_m - code_prediction_m);
581 design.push(row);
582 variances.push(observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
583
584 if let Some(carrier_phase) = observation.carrier_phase {
585 let phase_prediction_m = satellite.geometric_range_m + tight_state.clock_bias_m
586 - C_M_S * sat_clock_s
587 - observation.ionosphere_delay_m
588 + observation.troposphere_delay_m
589 + carrier_phase.float_ambiguity_m;
590 row = pseudorange_design_row(
591 aug_dim,
592 clock_bias,
593 satellite.los_unit,
594 kinematics.lever_arm_ecef_m,
595 );
596 innovation.push(carrier_phase.phase_range_m - phase_prediction_m);
597 design.push(row);
598 variances.push(carrier_phase.sigma_m * carrier_phase.sigma_m);
599 }
600
601 if let Some(range_rate) = observation.range_rate {
602 let velocity_observation = VelocityObservation {
603 sat: observation.satellite_id,
604 satellite_position_m: satellite.position_ecef_m,
605 satellite_velocity_m_s: satellite.velocity_m_s,
606 measured_range_rate_m_s: range_rate.measured_range_rate_m_s,
607 sigma_m_s: range_rate.sigma_m_s,
608 satellite_clock_drift_m_s: range_rate.satellite_clock_drift_m_s,
609 };
610 let receiver = ReceiverVelocityState {
611 position_m: kinematics.antenna_position_ecef_m,
612 velocity_m_s: kinematics.antenna_velocity_ecef_mps,
613 clock_drift_m_s: tight_state.clock_drift_m_s,
614 };
615 let prediction = predict_range_rate_m_s(&velocity_observation, receiver)
616 .ok_or_else(|| invalid_input("range_rate", "line of sight must be nonzero"))?;
617 let row = range_rate_design_row(
618 aug_dim,
619 clock_drift,
620 prediction.los_unit,
621 kinematics.lever_velocity_ecef_mps,
622 kinematics.gyro_bias_velocity_block,
623 );
624 innovation.push(range_rate.measured_range_rate_m_s - prediction.range_rate_m_s);
625 design.push(row);
626 variances.push(range_rate.sigma_m_s * range_rate.sigma_m_s);
627 }
628 }
629
630 validate_finite_slice(&innovation, "tight_innovation")?;
631 let measurement_covariance = diagonal_covariance(&variances)?;
632 EkfCorrection::new(innovation, design, measurement_covariance)
633}
634
635fn apply_tight_correction(
636 filter: &mut InertialFilter,
637 correction: &EkfCorrection,
638 options: EkfUpdateOptions,
639) -> Result<EkfCorrectionReport, FusionError> {
640 filter.state.validate()?;
641 let base_dim = filter.state.dimension();
642 filter.tight.validate(base_dim)?;
643 correction.validate_for_dimension(augmented_dimension(base_dim))?;
644
645 if let Some(gate) = options.innovation_gate {
646 gate.validate()?;
647 let full_s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
648 let (screened, gate_report) = screen_correction(correction, &full_s, gate)?;
649 let full_nis = normalized_innovation_squared(&full_s, &correction.innovation)?;
650 if gate_report.coasted {
651 return Ok(EkfCorrectionReport {
652 applied: false,
653 normalized_innovation_squared: full_nis,
654 accepted_rows: gate_report.accepted_rows,
655 rejected_rows: gate_report.rejected_rows,
656 innovation_gate: Some(gate_report),
657 innovation_covariance: full_s,
658 kalman_gain: vec![vec![0.0; correction.row_count()]; augmented_dimension(base_dim)],
659 dx: vec![0.0; augmented_dimension(base_dim)],
660 });
661 }
662 let accepted_rows = gate_report.accepted_rows;
663 let rejected_rows = gate_report.rejected_rows;
664 let mut report = apply_tight_correction_inner(filter, &screened)?;
665 report.accepted_rows = accepted_rows;
666 report.rejected_rows = rejected_rows;
667 report.innovation_gate = Some(gate_report);
668 return Ok(report);
669 }
670
671 apply_tight_correction_inner(filter, correction)
672}
673
674fn apply_tight_correction_inner(
675 filter: &mut InertialFilter,
676 correction: &EkfCorrection,
677) -> Result<EkfCorrectionReport, FusionError> {
678 let base_dim = filter.state.dimension();
679 let aug_dim = augmented_dimension(base_dim);
680 let s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
681 let h_t = super::state::transpose(&correction.design)?;
682 let p_h_t = matmul(&filter.tight.augmented_covariance, &h_t)?;
683 let mut kalman_gain = vec![vec![0.0; correction.row_count()]; aug_dim];
684 let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
685 for row in 0..aug_dim {
686 kalman_gain[row] = super::state::solve_spd(&s, &p_h_t[row], &mut scratch)?;
687 }
688 let dx = super::state::matvec(&kalman_gain, &correction.innovation)?;
689 let nis = normalized_innovation_squared(&s, &correction.innovation)?;
690 let covariance = joseph_covariance_update(
691 &filter.tight.augmented_covariance,
692 &correction.design,
693 &kalman_gain,
694 &correction.measurement_covariance,
695 )?;
696
697 apply_closed_loop_navigation_error(&mut filter.state.nominal, &dx[..base_dim])?;
698 apply_closed_loop_scale_error(&mut filter.state, &dx[..base_dim]);
699 filter.tight.clock_bias_m += dx[clock_bias_index(base_dim)];
700 filter.tight.clock_drift_m_s += dx[clock_drift_index(base_dim)];
701 filter.tight.augmented_covariance = covariance;
702 filter
703 .tight
704 .copy_base_covariance_to_state(&mut filter.state)?;
705 filter.state.reset_error_state();
706 filter.state.validate()?;
707 filter.tight.validate(base_dim)?;
708
709 Ok(EkfCorrectionReport {
710 applied: true,
711 normalized_innovation_squared: nis,
712 accepted_rows: correction.row_count(),
713 rejected_rows: 0,
714 innovation_gate: None,
715 innovation_covariance: s,
716 kalman_gain,
717 dx,
718 })
719}
720
721#[derive(Debug, Clone, Copy)]
722struct AntennaKinematics {
723 antenna_position_ecef_m: [f64; 3],
724 antenna_velocity_ecef_mps: [f64; 3],
725 lever_arm_ecef_m: [f64; 3],
726 lever_velocity_ecef_mps: [f64; 3],
727 gyro_bias_velocity_block: [[f64; 3]; 3],
728}
729
730fn antenna_kinematics(
731 state: &InsFilterState,
732 lever_arm_body_m: [f64; 3],
733 body_rate_wrt_ecef_rps: [f64; 3],
734) -> AntennaKinematics {
735 let c_b_e = state.nominal.attitude_body_to_ecef;
736 let lever_arm_ecef_m = mul_vec3(&c_b_e, lever_arm_body_m);
737 let antenna_position_ecef_m = add3(state.nominal.position_ecef_m, lever_arm_ecef_m);
738 let lever_velocity_body_mps = cross3(body_rate_wrt_ecef_rps, lever_arm_body_m);
739 let lever_velocity_ecef_mps = mul_vec3(&c_b_e, lever_velocity_body_mps);
740 let antenna_velocity_ecef_mps = add3(state.nominal.velocity_ecef_mps, lever_velocity_ecef_mps);
741 let gyro_bias_velocity_block = inline_rxr(&c_b_e, &skew(lever_arm_body_m));
742 AntennaKinematics {
743 antenna_position_ecef_m,
744 antenna_velocity_ecef_mps,
745 lever_arm_ecef_m,
746 lever_velocity_ecef_mps,
747 gyro_bias_velocity_block,
748 }
749}
750
751fn pseudorange_design_row(
752 aug_dim: usize,
753 clock_bias: usize,
754 los_unit: [f64; 3],
755 lever_arm_ecef_m: [f64; 3],
756) -> Vec<f64> {
757 let mut row = vec![0.0; aug_dim];
758 for axis in 0..3 {
759 row[ERROR_POSITION_INDEX + axis] = -los_unit[axis];
760 }
761 let lever_skew = skew(lever_arm_ecef_m);
762 for col in 0..3 {
763 row[ERROR_ATTITUDE_INDEX + col] = los_unit[0] * lever_skew[0][col]
764 + los_unit[1] * lever_skew[1][col]
765 + los_unit[2] * lever_skew[2][col];
766 }
767 row[clock_bias] = 1.0;
768 row
769}
770
771fn range_rate_design_row(
772 aug_dim: usize,
773 clock_drift: usize,
774 los_unit: [f64; 3],
775 lever_velocity_ecef_mps: [f64; 3],
776 gyro_bias_velocity_block: [[f64; 3]; 3],
777) -> Vec<f64> {
778 let mut row = vec![0.0; aug_dim];
779 for axis in 0..3 {
780 row[ERROR_VELOCITY_INDEX + axis] = -los_unit[axis];
781 }
782 let lever_velocity_skew = skew(lever_velocity_ecef_mps);
783 for col in 0..3 {
784 row[ERROR_ATTITUDE_INDEX + col] = los_unit[0] * lever_velocity_skew[0][col]
785 + los_unit[1] * lever_velocity_skew[1][col]
786 + los_unit[2] * lever_velocity_skew[2][col];
787 row[ERROR_GYRO_BIAS_INDEX + col] = los_unit[0] * gyro_bias_velocity_block[0][col]
788 + los_unit[1] * gyro_bias_velocity_block[1][col]
789 + los_unit[2] * gyro_bias_velocity_block[2][col];
790 }
791 row[clock_drift] = 1.0;
792 row
793}
794
795fn diagonal_covariance(variances: &[f64]) -> Result<Vec<Vec<f64>>, FusionError> {
796 if variances.is_empty() {
797 return Err(invalid_input("measurement_covariance", "must not be empty"));
798 }
799 let mut covariance = vec![vec![0.0; variances.len()]; variances.len()];
800 for (idx, variance) in variances.iter().enumerate() {
801 validate_positive(*variance, "measurement_variance")?;
802 covariance[idx][idx] = *variance;
803 }
804 Ok(covariance)
805}
806
807fn map_observables_error(error: ObservablesError) -> FusionError {
808 match error {
809 ObservablesError::NoEphemeris => invalid_input("ephemeris", "no usable satellite state"),
810 ObservablesError::InvalidInput { .. } => {
811 invalid_input("observable_state", "must be finite and in range")
812 }
813 ObservablesError::Ephemeris(_) => invalid_input("ephemeris", "satellite state failed"),
814 }
815}
816
817pub(super) const fn augmented_dimension(base_dim: usize) -> usize {
818 base_dim + TIGHT_CLOCK_STATE_COUNT
819}
820
821pub(super) const fn clock_bias_index(base_dim: usize) -> usize {
822 base_dim + TIGHT_CLOCK_BIAS_OFFSET
823}
824
825pub(super) const fn clock_drift_index(base_dim: usize) -> usize {
826 base_dim + TIGHT_CLOCK_DRIFT_OFFSET
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
840 use crate::astro::constants::earth::WGS84_A_M;
841 use crate::fusion::state::{
842 covariance_is_positive_semidefinite, ErrorStateLayout, ERROR_STATE_DIMENSION_15,
843 };
844 use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
845 use crate::inertial::state::mat3_identity;
846 use crate::inertial::{ImuSample, ImuSpec, NavState};
847 use crate::observables::{ObservableState, ObservablesError};
848 use crate::spp::{
849 Corrections, KlobucharCoeffs, Observation, SolveInputs, SppError, SurfaceMet,
850 };
851 use crate::{GnssSatelliteId, GnssSystem};
852 use nalgebra::DMatrix;
853
854 const T0: f64 = 646_229_000.0;
855 const SOD: f64 = 200.0;
856 const DOY: f64 = 176.0;
857
858 #[derive(Debug, Clone)]
859 struct LinearSource {
860 t0_j2000_s: f64,
861 states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>,
862 }
863
864 impl LinearSource {
865 fn new(t0_j2000_s: f64, states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>) -> Self {
866 Self { t0_j2000_s, states }
867 }
868 }
869
870 impl ObservableEphemerisSource for LinearSource {
871 fn observable_state_at_j2000_s(
872 &self,
873 sat: GnssSatelliteId,
874 t_j2000_s: f64,
875 ) -> Result<ObservableState, ObservablesError> {
876 let (_, position, velocity, clock_s) = self
877 .states
878 .iter()
879 .find(|(id, _, _, _)| *id == sat)
880 .ok_or(ObservablesError::NoEphemeris)?;
881 let dt_s = t_j2000_s - self.t0_j2000_s;
882 Ok(ObservableState {
883 position_ecef_m: [
884 position[0] + velocity[0] * dt_s,
885 position[1] + velocity[1] * dt_s,
886 position[2] + velocity[2] * dt_s,
887 ],
888 clock_s: Some(*clock_s),
889 })
890 }
891 }
892
893 impl crate::spp::EphemerisSource for LinearSource {
894 fn position_clock_at_j2000_s(
895 &self,
896 sat: GnssSatelliteId,
897 t_j2000_s: f64,
898 ) -> Option<([f64; 3], f64)> {
899 let (_, position, velocity, clock_s) =
900 self.states.iter().find(|(id, _, _, _)| *id == sat)?;
901 let dt_s = t_j2000_s - self.t0_j2000_s;
902 Some((
903 [
904 position[0] + velocity[0] * dt_s,
905 position[1] + velocity[1] * dt_s,
906 position[2] + velocity[2] * dt_s,
907 ],
908 *clock_s,
909 ))
910 }
911 }
912
913 fn sat(prn: u8) -> GnssSatelliteId {
914 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
915 }
916
917 fn normalized(v: [f64; 3]) -> [f64; 3] {
918 let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
919 [v[0] / n, v[1] / n, v[2] / n]
920 }
921
922 fn source_from_directions(receiver: [f64; 3], directions: &[[f64; 3]]) -> LinearSource {
923 let range_m = 22_000_000.0;
924 let states = directions
925 .iter()
926 .enumerate()
927 .map(|(idx, direction)| {
928 let unit = normalized(*direction);
929 (
930 sat((idx + 1) as u8),
931 [
932 receiver[0] + range_m * unit[0],
933 receiver[1] + range_m * unit[1],
934 receiver[2] + range_m * unit[2],
935 ],
936 [0.0; 3],
937 0.0,
938 )
939 })
940 .collect();
941 LinearSource::new(T0, states)
942 }
943
944 fn tight_epoch_from_source(
945 source: &LinearSource,
946 receiver: [f64; 3],
947 clock_m: f64,
948 sigma_m: f64,
949 ) -> TightGnssEpoch {
950 let observations = source
951 .states
952 .iter()
953 .map(|(satellite_id, _, _, _)| {
954 let prediction = transmit_time_satellite_state(
955 source,
956 *satellite_id,
957 receiver,
958 T0,
959 TransmitTimeOptions::default(),
960 )
961 .expect("satellite state");
962 TightGnssObservation::pseudorange(
963 *satellite_id,
964 prediction.geometric_range_m + clock_m,
965 sigma_m,
966 )
967 .expect("observation")
968 })
969 .collect();
970 TightGnssEpoch::new(T0, observations).expect("tight epoch")
971 }
972
973 fn solve_inputs_from_epoch(epoch: &TightGnssEpoch, initial_guess: [f64; 4]) -> SolveInputs {
974 SolveInputs {
975 observations: epoch
976 .observations
977 .iter()
978 .map(|observation| Observation {
979 satellite_id: observation.satellite_id,
980 pseudorange_m: observation.pseudorange_m,
981 })
982 .collect(),
983 t_rx_j2000_s: epoch.t_j2000_s,
984 t_rx_second_of_day_s: SOD,
985 day_of_year: DOY,
986 initial_guess,
987 corrections: Corrections::NONE,
988 klobuchar: KlobucharCoeffs {
989 alpha: [0.0; 4],
990 beta: [0.0; 4],
991 },
992 beidou_klobuchar: None,
993 galileo_nequick: None,
994 sbas_iono: None,
995 glonass_channels: std::collections::BTreeMap::new(),
996 met: SurfaceMet::default(),
997 robust: None,
998 }
999 }
1000
1001 fn zero_noise_spec() -> ImuSpec {
1002 ImuSpec::datasheet(
1003 0.0,
1004 0.0,
1005 0.0,
1006 0.0,
1007 RANDOM_WALK_BIAS_TAU_S,
1008 RANDOM_WALK_BIAS_TAU_S,
1009 None,
1010 None,
1011 )
1012 }
1013
1014 fn filter_with_config(
1015 nominal: NavState,
1016 diagonal: &[f64],
1017 tight: TightCouplingConfig,
1018 ) -> InertialFilter {
1019 let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, diagonal)
1020 .expect("state");
1021 let mut config =
1022 super::super::loose::InertialFilterConfig::new(zero_noise_spec()).expect("config");
1023 config.tight = tight;
1024 InertialFilter::with_config(state, config).expect("filter")
1025 }
1026
1027 fn tight_config_for_test() -> TightCouplingConfig {
1028 TightCouplingConfig {
1029 initial_clock_bias_variance_m2: 1.0e12,
1030 initial_clock_drift_variance_m2_s2: 1.0e6,
1031 clock_bias_random_walk_m2_s: 0.0,
1032 clock_drift_random_walk_m2_s3: 0.0,
1033 ..TightCouplingConfig::default()
1034 }
1035 }
1036
1037 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1038 assert!(
1039 (actual - expected).abs() <= tolerance,
1040 "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
1041 );
1042 }
1043
1044 fn logdet_spd(matrix: &[Vec<f64>]) -> f64 {
1045 let n = matrix.len();
1046 let flat = matrix.iter().flatten().copied().collect::<Vec<_>>();
1047 let dmatrix = DMatrix::from_row_slice(n, n, &flat);
1048 let cholesky = dmatrix.cholesky().expect("SPD matrix");
1049 2.0 * cholesky
1050 .l()
1051 .diagonal()
1052 .iter()
1053 .map(|value| value.ln())
1054 .sum::<f64>()
1055 }
1056
1057 fn position_clock_block(filter: &InertialFilter) -> Vec<Vec<f64>> {
1058 let base_dim = filter.state.dimension();
1059 let clock = clock_bias_index(base_dim);
1060 let indices = [0usize, 1, 2, clock];
1061 indices
1062 .iter()
1063 .map(|row| {
1064 indices
1065 .iter()
1066 .map(|col| filter.tight.augmented_covariance[*row][*col])
1067 .collect::<Vec<_>>()
1068 })
1069 .collect()
1070 }
1071
1072 fn snapshot_position_clock_covariance(
1073 source: &LinearSource,
1074 receiver: [f64; 3],
1075 epoch: &TightGnssEpoch,
1076 ) -> Vec<Vec<f64>> {
1077 let mut normal = DMatrix::<f64>::zeros(4, 4);
1078 for observation in &epoch.observations {
1079 let prediction = transmit_time_satellite_state(
1080 source,
1081 observation.satellite_id,
1082 receiver,
1083 epoch.t_j2000_s,
1084 TransmitTimeOptions::default(),
1085 )
1086 .expect("satellite state");
1087 let h = [
1088 -prediction.los_unit[0],
1089 -prediction.los_unit[1],
1090 -prediction.los_unit[2],
1091 1.0,
1092 ];
1093 let inv_var = 1.0 / (observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
1094 for row in 0..4 {
1095 for col in 0..4 {
1096 normal[(row, col)] += h[row] * h[col] * inv_var;
1097 }
1098 }
1099 }
1100 let covariance = normal.try_inverse().expect("full-rank snapshot");
1101 (0..4)
1102 .map(|row| (0..4).map(|col| covariance[(row, col)]).collect())
1103 .collect()
1104 }
1105
1106 #[test]
1107 fn pseudorange_only_update_matches_spp_clock_oracle_with_frozen_ins_prior() {
1108 let receiver = [WGS84_A_M, 0.0, 0.0];
1109 let directions = [
1110 [1.0, 0.0, 0.0],
1111 [0.82, 0.42, 0.39],
1112 [0.83, -0.46, 0.31],
1113 [0.90, 0.18, -0.40],
1114 [0.78, -0.25, -0.58],
1115 ];
1116 let clock_m = 12.5;
1117 let source = source_from_directions(receiver, &directions);
1118 let epoch = tight_epoch_from_source(&source, receiver, clock_m, 1.0);
1119 let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1120 let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1121
1122 let spp_position = spp.position.as_array();
1123 let nominal = NavState::new(T0, spp_position, [0.0; 3], mat3_identity()).expect("nominal");
1124 let diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1125 let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1126
1127 let update = filter.update_tight(&source, &epoch).expect("tight update");
1128
1129 assert!(update.applied);
1130 for (got, expected) in filter
1131 .state()
1132 .nominal
1133 .position_ecef_m
1134 .iter()
1135 .zip(spp_position)
1136 {
1137 assert_close(*got, expected, 1.0e-6);
1138 }
1139 let clock = filter.tight_clock_state().expect("clock");
1140 assert_close(clock.bias_m, spp.rx_clock_s * C_M_S, 1.0e-5);
1141 }
1142
1143 #[test]
1144 fn doppler_row_uses_range_rate_predictor_geometry_bits() {
1145 let receiver = [WGS84_A_M, 0.0, 0.0];
1146 let satellite_id = sat(1);
1147 let source = LinearSource::new(
1148 T0,
1149 vec![(
1150 satellite_id,
1151 [WGS84_A_M + 22_000_000.0, 1_000_000.0, 2_000_000.0],
1152 [120.0, -40.0, 30.0],
1153 0.0,
1154 )],
1155 );
1156 let sat_state = transmit_time_satellite_state(
1157 &source,
1158 satellite_id,
1159 receiver,
1160 T0,
1161 TransmitTimeOptions::default(),
1162 )
1163 .expect("satellite state");
1164 let measured_receiver = ReceiverVelocityState {
1165 position_m: receiver,
1166 velocity_m_s: [5.0, -2.0, 1.0],
1167 clock_drift_m_s: 0.25,
1168 };
1169 let velocity_observation = VelocityObservation {
1170 sat: satellite_id,
1171 satellite_position_m: sat_state.position_ecef_m,
1172 satellite_velocity_m_s: sat_state.velocity_m_s,
1173 measured_range_rate_m_s: 0.0,
1174 sigma_m_s: 0.05,
1175 satellite_clock_drift_m_s: 0.01,
1176 };
1177 let measured = predict_range_rate_m_s(&velocity_observation, measured_receiver)
1178 .expect("measured range rate")
1179 .range_rate_m_s;
1180 let observation = TightGnssObservation {
1181 satellite_id,
1182 pseudorange_m: sat_state.geometric_range_m,
1183 pseudorange_sigma_m: 2.0,
1184 range_rate: Some(TightRangeRateObservation {
1185 measured_range_rate_m_s: measured,
1186 sigma_m_s: 0.05,
1187 satellite_clock_drift_m_s: 0.01,
1188 }),
1189 carrier_phase: None,
1190 ionosphere_delay_m: 0.0,
1191 troposphere_delay_m: 0.0,
1192 };
1193 let epoch = TightGnssEpoch::new(T0, vec![observation]).expect("epoch");
1194 let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1195 let filter = filter_with_config(
1196 nominal,
1197 &[1.0; ERROR_STATE_DIMENSION_15],
1198 tight_config_for_test(),
1199 );
1200 let correction = tight_coupling_correction(
1201 &source,
1202 filter.state(),
1203 &filter.tight,
1204 &epoch,
1205 filter.config.tight,
1206 [0.0; 3],
1207 )
1208 .expect("correction");
1209 let predicted_at_nominal = predict_range_rate_m_s(
1210 &VelocityObservation {
1211 measured_range_rate_m_s: measured,
1212 ..velocity_observation
1213 },
1214 ReceiverVelocityState {
1215 position_m: receiver,
1216 velocity_m_s: [0.0; 3],
1217 clock_drift_m_s: 0.0,
1218 },
1219 )
1220 .expect("nominal range rate");
1221
1222 let doppler_row = &correction.design[1];
1223 for axis in 0..3 {
1224 assert_eq!(
1225 doppler_row[ERROR_VELOCITY_INDEX + axis].to_bits(),
1226 (-predicted_at_nominal.los_unit[axis]).to_bits()
1227 );
1228 }
1229 assert_eq!(
1230 doppler_row[clock_drift_index(filter.state.dimension())].to_bits(),
1231 1.0_f64.to_bits()
1232 );
1233 assert_eq!(
1234 correction.innovation[1].to_bits(),
1235 (measured - predicted_at_nominal.range_rate_m_s).to_bits()
1236 );
1237 }
1238
1239 #[test]
1240 fn singular_snapshot_geometry_keeps_unobserved_prior_covariance() {
1241 let receiver = [WGS84_A_M, 0.0, 0.0];
1242 let directions = [[1.0, 0.0, 0.0]; 5];
1243 let source = source_from_directions(receiver, &directions);
1244 let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
1245 let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1246 assert!(matches!(
1247 crate::spp::solve(&source, &inputs, false),
1248 Err(SppError::Singular(_))
1249 ));
1250
1251 let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1252 let mut diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
1253 diagonal[ERROR_POSITION_INDEX] = 100.0;
1254 diagonal[ERROR_POSITION_INDEX + 1] = 225.0;
1255 diagonal[ERROR_POSITION_INDEX + 2] = 400.0;
1256 let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1257 let prior_y = filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1];
1258 let prior_z = filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2];
1259
1260 let update = filter.update_tight(&source, &epoch).expect("tight update");
1261
1262 assert!(update.applied);
1263 assert!(covariance_is_positive_semidefinite(&filter.state.covariance).expect("PSD"));
1264 assert_eq!(
1265 filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1].to_bits(),
1266 prior_y.to_bits()
1267 );
1268 assert_eq!(
1269 filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2].to_bits(),
1270 prior_z.to_bits()
1271 );
1272 assert!(filter
1273 .state
1274 .nominal
1275 .position_ecef_m
1276 .iter()
1277 .all(|value| value.is_finite() && value.abs() < 1.0e8));
1278 }
1279
1280 #[test]
1281 fn high_dop_fused_covariance_has_lower_logdet_than_snapshot() {
1282 let receiver = [WGS84_A_M, 0.0, 0.0];
1283 let directions = [
1284 [0.44974122498328417, -0.8581153514788689, 0.2477314556265159],
1285 [0.20081904418348107, 0.5332143328087052, 0.8217993591994339],
1286 [0.43760604888398824, -0.4903647504582244, 0.7536865114145189],
1287 [
1288 0.2148508784686108,
1289 -0.9558725523345635,
1290 -0.20036657334663732,
1291 ],
1292 [0.30949187488876595, 0.3289789392404428, 0.8921813923827763],
1293 ];
1294 let source = source_from_directions(receiver, &directions);
1295 let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
1296 let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1297 let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1298 assert_eq!(
1299 spp.geometry_quality.tier,
1300 crate::geometry_quality::ObservabilityTier::Weak
1301 );
1302 let snapshot_covariance = snapshot_position_clock_covariance(&source, receiver, &epoch);
1303 let snapshot_logdet = logdet_spd(&snapshot_covariance);
1304
1305 let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1306 let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1307 for axis in 0..3 {
1308 diagonal[ERROR_POSITION_INDEX + axis] = 1.0e8;
1309 }
1310 let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1311
1312 filter.update_tight(&source, &epoch).expect("tight update");
1313
1314 let fused_logdet = logdet_spd(&position_clock_block(&filter));
1315 assert!(
1316 fused_logdet < snapshot_logdet,
1317 "fused {fused_logdet:.17e}, snapshot {snapshot_logdet:.17e}"
1318 );
1319 }
1320
1321 #[test]
1322 fn outage_growth_and_single_satellite_observed_direction_update() {
1323 let receiver = [WGS84_A_M, 0.0, 0.0];
1324 let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1325 let diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1326 let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
1327 .expect("state");
1328 let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
1329 let mut config = super::super::loose::InertialFilterConfig::new(spec).expect("config");
1330 config.tight = TightCouplingConfig {
1331 light_time: false,
1332 sagnac: false,
1333 initial_clock_bias_variance_m2: 100.0,
1334 initial_clock_drift_variance_m2_s2: 1.0,
1335 clock_bias_random_walk_m2_s: 4.0,
1336 clock_drift_random_walk_m2_s3: 0.25,
1337 ..TightCouplingConfig::default()
1338 };
1339 let mut filter = InertialFilter::with_config(state, config).expect("filter");
1340 let mut previous_logdet = logdet_spd(&filter.tight.augmented_covariance);
1341
1342 for step in 1..=3 {
1343 filter
1344 .propagate(ImuSample::increment(
1345 T0 + step as f64,
1346 [0.0; 3],
1347 [0.0; 3],
1348 1.0,
1349 ))
1350 .expect("propagate");
1351 let next_logdet = logdet_spd(&filter.tight.augmented_covariance);
1352 assert!(
1353 next_logdet > previous_logdet,
1354 "step {step} logdet {next_logdet:.17e} <= {previous_logdet:.17e}"
1355 );
1356 previous_logdet = next_logdet;
1357 }
1358
1359 let current_position = filter.state.nominal.position_ecef_m;
1360 let satellite_id = sat(1);
1361 let source = LinearSource::new(
1362 filter.state.nominal.t_j2000_s,
1363 vec![(
1364 satellite_id,
1365 [
1366 current_position[0] + 22_000_000.0,
1367 current_position[1],
1368 current_position[2],
1369 ],
1370 [0.0; 3],
1371 0.0,
1372 )],
1373 );
1374 let prediction = transmit_time_satellite_state(
1375 &source,
1376 satellite_id,
1377 current_position,
1378 filter.state.nominal.t_j2000_s,
1379 TransmitTimeOptions {
1380 light_time: false,
1381 sagnac: false,
1382 },
1383 )
1384 .expect("satellite state");
1385 let clock = filter.tight_clock_state().expect("clock");
1386 let epoch = TightGnssEpoch::new(
1387 filter.state.nominal.t_j2000_s,
1388 vec![TightGnssObservation::pseudorange(
1389 satellite_id,
1390 prediction.geometric_range_m + clock.bias_m,
1391 0.5,
1392 )
1393 .expect("observation")],
1394 )
1395 .expect("epoch");
1396 let pre = filter.state.covariance.clone();
1397
1398 filter
1399 .update_tight(&source, &epoch)
1400 .expect("single-sat update");
1401
1402 assert!(
1403 filter.state.covariance[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
1404 < pre[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
1405 );
1406 for axis in [1usize, 2] {
1407 assert_eq!(
1408 filter.state.covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis]
1409 .to_bits(),
1410 pre[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis].to_bits()
1411 );
1412 }
1413 }
1414}