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.augmented_covariance = next;
450 self.validate(base_dim)
451 }
452
453 pub(super) fn copy_base_covariance_to_state(
454 &self,
455 state: &mut InsFilterState,
456 ) -> Result<(), FusionError> {
457 let base_dim = state.dimension();
458 self.validate(base_dim)?;
459 for row in 0..base_dim {
460 for col in 0..base_dim {
461 state.covariance[row][col] = self.augmented_covariance[row][col];
462 }
463 }
464 state.validate()
465 }
466}
467
468impl InertialFilter {
469 pub fn tight_clock_state(&self) -> Result<TightClockState, FusionError> {
471 self.tight.clock_state(self.state.dimension())
472 }
473
474 pub fn update_tight(
479 &mut self,
480 source: &dyn ObservableEphemerisSource,
481 epoch: &TightGnssEpoch,
482 ) -> Result<FusionUpdate, FusionError> {
483 if let Some(last) = self.time_sync.last_measurement_t_j2000_s() {
484 if epoch.t_j2000_s <= last {
485 return Err(invalid_input(
486 "t_j2000_s",
487 "GNSS measurement epochs must be strictly increasing",
488 ));
489 }
490 }
491 let update = self.update_tight_core(source, epoch)?;
492 let snapshot = self.snapshot();
493 self.time_sync
494 .push_tight_measurement_and_checkpoint(epoch.clone(), snapshot);
495 Ok(update)
496 }
497
498 pub(super) fn update_tight_core(
499 &mut self,
500 source: &dyn ObservableEphemerisSource,
501 epoch: &TightGnssEpoch,
502 ) -> Result<FusionUpdate, FusionError> {
503 self.tight.align_with_filter_state(&self.state)?;
504 let correction = tight_coupling_correction(
505 source,
506 &self.state,
507 &self.tight,
508 epoch,
509 self.config.tight,
510 self.last_body_rate_wrt_ecef_rps,
511 )?;
512 let rows = correction.row_count();
513 let report = apply_tight_correction(self, &correction, self.config.tight.update_options)?;
514 Ok(FusionUpdate {
515 applied: report.applied,
516 nis: report.normalized_innovation_squared,
517 rows,
518 accepted_rows: report.accepted_rows,
519 rejected_rows: report.rejected_rows,
520 ekf: report,
521 })
522 }
523}
524
525pub(super) fn tight_coupling_correction(
526 source: &dyn ObservableEphemerisSource,
527 state: &InsFilterState,
528 tight_state: &TightFusionState,
529 epoch: &TightGnssEpoch,
530 config: TightCouplingConfig,
531 body_rate_wrt_ecef_rps: [f64; 3],
532) -> Result<EkfCorrection, FusionError> {
533 state.validate()?;
534 tight_state.validate(state.dimension())?;
535 epoch.validate()?;
536 config.validate()?;
537 validate_vec3(body_rate_wrt_ecef_rps, "body_rate_wrt_ecef_rps").map_err(FusionError::from)?;
538 if epoch.t_j2000_s != state.nominal.t_j2000_s {
539 return Err(invalid_input("t_j2000_s", "must equal nominal state epoch"));
540 }
541
542 let base_dim = state.dimension();
543 let aug_dim = augmented_dimension(base_dim);
544 let clock_bias = clock_bias_index(base_dim);
545 let clock_drift = clock_drift_index(base_dim);
546 let kinematics = antenna_kinematics(state, config.lever_arm_body_m, body_rate_wrt_ecef_rps);
547 let options = TransmitTimeOptions {
548 light_time: config.light_time,
549 sagnac: config.sagnac,
550 };
551
552 let mut innovation = Vec::new();
553 let mut design = Vec::new();
554 let mut variances = Vec::new();
555
556 for observation in &epoch.observations {
557 let satellite = transmit_time_satellite_state(
558 source,
559 observation.satellite_id,
560 kinematics.antenna_position_ecef_m,
561 epoch.t_j2000_s,
562 options,
563 )
564 .map_err(map_observables_error)?;
565 let sat_clock_s = satellite
566 .clock_s
567 .ok_or_else(|| invalid_input("satellite_clock_s", "must be present"))?;
568
569 let code_prediction_m = satellite.geometric_range_m + tight_state.clock_bias_m
570 - C_M_S * sat_clock_s
571 + observation.ionosphere_delay_m
572 + observation.troposphere_delay_m;
573 let mut row = pseudorange_design_row(
574 aug_dim,
575 clock_bias,
576 satellite.los_unit,
577 kinematics.lever_arm_ecef_m,
578 );
579 innovation.push(observation.pseudorange_m - code_prediction_m);
580 design.push(row);
581 variances.push(observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
582
583 if let Some(carrier_phase) = observation.carrier_phase {
584 let phase_prediction_m = satellite.geometric_range_m + tight_state.clock_bias_m
585 - C_M_S * sat_clock_s
586 - observation.ionosphere_delay_m
587 + observation.troposphere_delay_m
588 + carrier_phase.float_ambiguity_m;
589 row = pseudorange_design_row(
590 aug_dim,
591 clock_bias,
592 satellite.los_unit,
593 kinematics.lever_arm_ecef_m,
594 );
595 innovation.push(carrier_phase.phase_range_m - phase_prediction_m);
596 design.push(row);
597 variances.push(carrier_phase.sigma_m * carrier_phase.sigma_m);
598 }
599
600 if let Some(range_rate) = observation.range_rate {
601 let velocity_observation = VelocityObservation {
602 sat: observation.satellite_id,
603 satellite_position_m: satellite.position_ecef_m,
604 satellite_velocity_m_s: satellite.velocity_m_s,
605 measured_range_rate_m_s: range_rate.measured_range_rate_m_s,
606 sigma_m_s: range_rate.sigma_m_s,
607 satellite_clock_drift_m_s: range_rate.satellite_clock_drift_m_s,
608 };
609 let receiver = ReceiverVelocityState {
610 position_m: kinematics.antenna_position_ecef_m,
611 velocity_m_s: kinematics.antenna_velocity_ecef_mps,
612 clock_drift_m_s: tight_state.clock_drift_m_s,
613 };
614 let prediction = predict_range_rate_m_s(&velocity_observation, receiver)
615 .ok_or_else(|| invalid_input("range_rate", "line of sight must be nonzero"))?;
616 let row = range_rate_design_row(
617 aug_dim,
618 clock_drift,
619 prediction.los_unit,
620 kinematics.lever_velocity_ecef_mps,
621 kinematics.gyro_bias_velocity_block,
622 );
623 innovation.push(range_rate.measured_range_rate_m_s - prediction.range_rate_m_s);
624 design.push(row);
625 variances.push(range_rate.sigma_m_s * range_rate.sigma_m_s);
626 }
627 }
628
629 validate_finite_slice(&innovation, "tight_innovation")?;
630 let measurement_covariance = diagonal_covariance(&variances)?;
631 EkfCorrection::new(innovation, design, measurement_covariance)
632}
633
634fn apply_tight_correction(
635 filter: &mut InertialFilter,
636 correction: &EkfCorrection,
637 options: EkfUpdateOptions,
638) -> Result<EkfCorrectionReport, FusionError> {
639 filter.state.validate()?;
640 let base_dim = filter.state.dimension();
641 filter.tight.validate(base_dim)?;
642 correction.validate_for_dimension(augmented_dimension(base_dim))?;
643
644 if let Some(gate) = options.innovation_gate {
645 gate.validate()?;
646 let full_s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
647 let (screened, gate_report) = screen_correction(correction, &full_s, gate)?;
648 let full_nis = normalized_innovation_squared(&full_s, &correction.innovation)?;
649 if gate_report.coasted {
650 return Ok(EkfCorrectionReport {
651 applied: false,
652 normalized_innovation_squared: full_nis,
653 accepted_rows: gate_report.accepted_rows,
654 rejected_rows: gate_report.rejected_rows,
655 innovation_gate: Some(gate_report),
656 innovation_covariance: full_s,
657 kalman_gain: vec![vec![0.0; correction.row_count()]; augmented_dimension(base_dim)],
658 dx: vec![0.0; augmented_dimension(base_dim)],
659 });
660 }
661 let accepted_rows = gate_report.accepted_rows;
662 let rejected_rows = gate_report.rejected_rows;
663 let mut report = apply_tight_correction_inner(filter, &screened)?;
664 report.accepted_rows = accepted_rows;
665 report.rejected_rows = rejected_rows;
666 report.innovation_gate = Some(gate_report);
667 return Ok(report);
668 }
669
670 apply_tight_correction_inner(filter, correction)
671}
672
673fn apply_tight_correction_inner(
674 filter: &mut InertialFilter,
675 correction: &EkfCorrection,
676) -> Result<EkfCorrectionReport, FusionError> {
677 let base_dim = filter.state.dimension();
678 let aug_dim = augmented_dimension(base_dim);
679 let s = innovation_covariance(&filter.tight.augmented_covariance, correction)?;
680 let h_t = super::state::transpose(&correction.design)?;
681 let p_h_t = matmul(&filter.tight.augmented_covariance, &h_t)?;
682 let mut kalman_gain = vec![vec![0.0; correction.row_count()]; aug_dim];
683 let mut scratch = crate::astro::math::linear::FlatCholeskySolveScratch::default();
684 for row in 0..aug_dim {
685 kalman_gain[row] = super::state::solve_spd(&s, &p_h_t[row], &mut scratch)?;
686 }
687 let dx = super::state::matvec(&kalman_gain, &correction.innovation)?;
688 let nis = normalized_innovation_squared(&s, &correction.innovation)?;
689 let covariance = joseph_covariance_update(
690 &filter.tight.augmented_covariance,
691 &correction.design,
692 &kalman_gain,
693 &correction.measurement_covariance,
694 )?;
695
696 apply_closed_loop_navigation_error(&mut filter.state.nominal, &dx[..base_dim])?;
697 apply_closed_loop_scale_error(&mut filter.state, &dx[..base_dim]);
698 filter.tight.clock_bias_m += dx[clock_bias_index(base_dim)];
699 filter.tight.clock_drift_m_s += dx[clock_drift_index(base_dim)];
700 filter.tight.augmented_covariance = covariance;
701 filter
702 .tight
703 .copy_base_covariance_to_state(&mut filter.state)?;
704 filter.state.reset_error_state();
705 filter.state.validate()?;
706 filter.tight.validate(base_dim)?;
707
708 Ok(EkfCorrectionReport {
709 applied: true,
710 normalized_innovation_squared: nis,
711 accepted_rows: correction.row_count(),
712 rejected_rows: 0,
713 innovation_gate: None,
714 innovation_covariance: s,
715 kalman_gain,
716 dx,
717 })
718}
719
720#[derive(Debug, Clone, Copy)]
721struct AntennaKinematics {
722 antenna_position_ecef_m: [f64; 3],
723 antenna_velocity_ecef_mps: [f64; 3],
724 lever_arm_ecef_m: [f64; 3],
725 lever_velocity_ecef_mps: [f64; 3],
726 gyro_bias_velocity_block: [[f64; 3]; 3],
727}
728
729fn antenna_kinematics(
730 state: &InsFilterState,
731 lever_arm_body_m: [f64; 3],
732 body_rate_wrt_ecef_rps: [f64; 3],
733) -> AntennaKinematics {
734 let c_b_e = state.nominal.attitude_body_to_ecef;
735 let lever_arm_ecef_m = mul_vec3(&c_b_e, lever_arm_body_m);
736 let antenna_position_ecef_m = add3(state.nominal.position_ecef_m, lever_arm_ecef_m);
737 let lever_velocity_body_mps = cross3(body_rate_wrt_ecef_rps, lever_arm_body_m);
738 let lever_velocity_ecef_mps = mul_vec3(&c_b_e, lever_velocity_body_mps);
739 let antenna_velocity_ecef_mps = add3(state.nominal.velocity_ecef_mps, lever_velocity_ecef_mps);
740 let gyro_bias_velocity_block = inline_rxr(&c_b_e, &skew(lever_arm_body_m));
741 AntennaKinematics {
742 antenna_position_ecef_m,
743 antenna_velocity_ecef_mps,
744 lever_arm_ecef_m,
745 lever_velocity_ecef_mps,
746 gyro_bias_velocity_block,
747 }
748}
749
750fn pseudorange_design_row(
751 aug_dim: usize,
752 clock_bias: usize,
753 los_unit: [f64; 3],
754 lever_arm_ecef_m: [f64; 3],
755) -> Vec<f64> {
756 let mut row = vec![0.0; aug_dim];
757 for axis in 0..3 {
758 row[ERROR_POSITION_INDEX + axis] = -los_unit[axis];
759 }
760 let lever_skew = skew(lever_arm_ecef_m);
761 for col in 0..3 {
762 row[ERROR_ATTITUDE_INDEX + col] = los_unit[0] * lever_skew[0][col]
763 + los_unit[1] * lever_skew[1][col]
764 + los_unit[2] * lever_skew[2][col];
765 }
766 row[clock_bias] = 1.0;
767 row
768}
769
770fn range_rate_design_row(
771 aug_dim: usize,
772 clock_drift: usize,
773 los_unit: [f64; 3],
774 lever_velocity_ecef_mps: [f64; 3],
775 gyro_bias_velocity_block: [[f64; 3]; 3],
776) -> Vec<f64> {
777 let mut row = vec![0.0; aug_dim];
778 for axis in 0..3 {
779 row[ERROR_VELOCITY_INDEX + axis] = -los_unit[axis];
780 }
781 let lever_velocity_skew = skew(lever_velocity_ecef_mps);
782 for col in 0..3 {
783 row[ERROR_ATTITUDE_INDEX + col] = los_unit[0] * lever_velocity_skew[0][col]
784 + los_unit[1] * lever_velocity_skew[1][col]
785 + los_unit[2] * lever_velocity_skew[2][col];
786 row[ERROR_GYRO_BIAS_INDEX + col] = los_unit[0] * gyro_bias_velocity_block[0][col]
787 + los_unit[1] * gyro_bias_velocity_block[1][col]
788 + los_unit[2] * gyro_bias_velocity_block[2][col];
789 }
790 row[clock_drift] = 1.0;
791 row
792}
793
794fn diagonal_covariance(variances: &[f64]) -> Result<Vec<Vec<f64>>, FusionError> {
795 if variances.is_empty() {
796 return Err(invalid_input("measurement_covariance", "must not be empty"));
797 }
798 let mut covariance = vec![vec![0.0; variances.len()]; variances.len()];
799 for (idx, variance) in variances.iter().enumerate() {
800 validate_positive(*variance, "measurement_variance")?;
801 covariance[idx][idx] = *variance;
802 }
803 Ok(covariance)
804}
805
806fn map_observables_error(error: ObservablesError) -> FusionError {
807 match error {
808 ObservablesError::NoEphemeris => invalid_input("ephemeris", "no usable satellite state"),
809 ObservablesError::InvalidInput { .. } => {
810 invalid_input("observable_state", "must be finite and in range")
811 }
812 ObservablesError::Ephemeris(_) => invalid_input("ephemeris", "satellite state failed"),
813 }
814}
815
816pub(super) const fn augmented_dimension(base_dim: usize) -> usize {
817 base_dim + TIGHT_CLOCK_STATE_COUNT
818}
819
820pub(super) const fn clock_bias_index(base_dim: usize) -> usize {
821 base_dim + TIGHT_CLOCK_BIAS_OFFSET
822}
823
824pub(super) const fn clock_drift_index(base_dim: usize) -> usize {
825 base_dim + TIGHT_CLOCK_DRIFT_OFFSET
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
839 use crate::astro::constants::earth::WGS84_A_M;
840 use crate::fusion::state::{
841 covariance_is_positive_semidefinite, ErrorStateLayout, ERROR_STATE_DIMENSION_15,
842 };
843 use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
844 use crate::inertial::state::mat3_identity;
845 use crate::inertial::{ImuSample, ImuSpec, NavState};
846 use crate::observables::{ObservableState, ObservablesError};
847 use crate::spp::{
848 Corrections, KlobucharCoeffs, Observation, SolveInputs, SppError, SurfaceMet,
849 };
850 use crate::{GnssSatelliteId, GnssSystem};
851 use nalgebra::DMatrix;
852
853 const T0: f64 = 646_229_000.0;
854 const SOD: f64 = 200.0;
855 const DOY: f64 = 176.0;
856
857 #[derive(Debug, Clone)]
858 struct LinearSource {
859 t0_j2000_s: f64,
860 states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>,
861 }
862
863 impl LinearSource {
864 fn new(t0_j2000_s: f64, states: Vec<(GnssSatelliteId, [f64; 3], [f64; 3], f64)>) -> Self {
865 Self { t0_j2000_s, states }
866 }
867 }
868
869 impl ObservableEphemerisSource for LinearSource {
870 fn observable_state_at_j2000_s(
871 &self,
872 sat: GnssSatelliteId,
873 t_j2000_s: f64,
874 ) -> Result<ObservableState, ObservablesError> {
875 let (_, position, velocity, clock_s) = self
876 .states
877 .iter()
878 .find(|(id, _, _, _)| *id == sat)
879 .ok_or(ObservablesError::NoEphemeris)?;
880 let dt_s = t_j2000_s - self.t0_j2000_s;
881 Ok(ObservableState {
882 position_ecef_m: [
883 position[0] + velocity[0] * dt_s,
884 position[1] + velocity[1] * dt_s,
885 position[2] + velocity[2] * dt_s,
886 ],
887 clock_s: Some(*clock_s),
888 })
889 }
890 }
891
892 impl crate::spp::EphemerisSource for LinearSource {
893 fn position_clock_at_j2000_s(
894 &self,
895 sat: GnssSatelliteId,
896 t_j2000_s: f64,
897 ) -> Option<([f64; 3], f64)> {
898 let (_, position, velocity, clock_s) =
899 self.states.iter().find(|(id, _, _, _)| *id == sat)?;
900 let dt_s = t_j2000_s - self.t0_j2000_s;
901 Some((
902 [
903 position[0] + velocity[0] * dt_s,
904 position[1] + velocity[1] * dt_s,
905 position[2] + velocity[2] * dt_s,
906 ],
907 *clock_s,
908 ))
909 }
910 }
911
912 fn sat(prn: u8) -> GnssSatelliteId {
913 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
914 }
915
916 fn normalized(v: [f64; 3]) -> [f64; 3] {
917 let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
918 [v[0] / n, v[1] / n, v[2] / n]
919 }
920
921 fn source_from_directions(receiver: [f64; 3], directions: &[[f64; 3]]) -> LinearSource {
922 let range_m = 22_000_000.0;
923 let states = directions
924 .iter()
925 .enumerate()
926 .map(|(idx, direction)| {
927 let unit = normalized(*direction);
928 (
929 sat((idx + 1) as u8),
930 [
931 receiver[0] + range_m * unit[0],
932 receiver[1] + range_m * unit[1],
933 receiver[2] + range_m * unit[2],
934 ],
935 [0.0; 3],
936 0.0,
937 )
938 })
939 .collect();
940 LinearSource::new(T0, states)
941 }
942
943 fn tight_epoch_from_source(
944 source: &LinearSource,
945 receiver: [f64; 3],
946 clock_m: f64,
947 sigma_m: f64,
948 ) -> TightGnssEpoch {
949 let observations = source
950 .states
951 .iter()
952 .map(|(satellite_id, _, _, _)| {
953 let prediction = transmit_time_satellite_state(
954 source,
955 *satellite_id,
956 receiver,
957 T0,
958 TransmitTimeOptions::default(),
959 )
960 .expect("satellite state");
961 TightGnssObservation::pseudorange(
962 *satellite_id,
963 prediction.geometric_range_m + clock_m,
964 sigma_m,
965 )
966 .expect("observation")
967 })
968 .collect();
969 TightGnssEpoch::new(T0, observations).expect("tight epoch")
970 }
971
972 fn solve_inputs_from_epoch(epoch: &TightGnssEpoch, initial_guess: [f64; 4]) -> SolveInputs {
973 SolveInputs {
974 observations: epoch
975 .observations
976 .iter()
977 .map(|observation| Observation {
978 satellite_id: observation.satellite_id,
979 pseudorange_m: observation.pseudorange_m,
980 })
981 .collect(),
982 t_rx_j2000_s: epoch.t_j2000_s,
983 t_rx_second_of_day_s: SOD,
984 day_of_year: DOY,
985 initial_guess,
986 corrections: Corrections::NONE,
987 klobuchar: KlobucharCoeffs {
988 alpha: [0.0; 4],
989 beta: [0.0; 4],
990 },
991 beidou_klobuchar: None,
992 galileo_nequick: None,
993 sbas_iono: None,
994 glonass_channels: std::collections::BTreeMap::new(),
995 met: SurfaceMet::default(),
996 robust: None,
997 }
998 }
999
1000 fn zero_noise_spec() -> ImuSpec {
1001 ImuSpec::datasheet(
1002 0.0,
1003 0.0,
1004 0.0,
1005 0.0,
1006 RANDOM_WALK_BIAS_TAU_S,
1007 RANDOM_WALK_BIAS_TAU_S,
1008 None,
1009 None,
1010 )
1011 }
1012
1013 fn filter_with_config(
1014 nominal: NavState,
1015 diagonal: &[f64],
1016 tight: TightCouplingConfig,
1017 ) -> InertialFilter {
1018 let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, diagonal)
1019 .expect("state");
1020 let mut config =
1021 super::super::loose::InertialFilterConfig::new(zero_noise_spec()).expect("config");
1022 config.tight = tight;
1023 InertialFilter::with_config(state, config).expect("filter")
1024 }
1025
1026 fn tight_config_for_test() -> TightCouplingConfig {
1027 TightCouplingConfig {
1028 initial_clock_bias_variance_m2: 1.0e12,
1029 initial_clock_drift_variance_m2_s2: 1.0e6,
1030 clock_bias_random_walk_m2_s: 0.0,
1031 clock_drift_random_walk_m2_s3: 0.0,
1032 ..TightCouplingConfig::default()
1033 }
1034 }
1035
1036 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1037 assert!(
1038 (actual - expected).abs() <= tolerance,
1039 "actual {actual:.17e}, expected {expected:.17e}, tolerance {tolerance:.17e}"
1040 );
1041 }
1042
1043 fn logdet_spd(matrix: &[Vec<f64>]) -> f64 {
1044 let n = matrix.len();
1045 let flat = matrix.iter().flatten().copied().collect::<Vec<_>>();
1046 let dmatrix = DMatrix::from_row_slice(n, n, &flat);
1047 let cholesky = dmatrix.cholesky().expect("SPD matrix");
1048 2.0 * cholesky
1049 .l()
1050 .diagonal()
1051 .iter()
1052 .map(|value| value.ln())
1053 .sum::<f64>()
1054 }
1055
1056 fn position_clock_block(filter: &InertialFilter) -> Vec<Vec<f64>> {
1057 let base_dim = filter.state.dimension();
1058 let clock = clock_bias_index(base_dim);
1059 let indices = [0usize, 1, 2, clock];
1060 indices
1061 .iter()
1062 .map(|row| {
1063 indices
1064 .iter()
1065 .map(|col| filter.tight.augmented_covariance[*row][*col])
1066 .collect::<Vec<_>>()
1067 })
1068 .collect()
1069 }
1070
1071 fn snapshot_position_clock_covariance(
1072 source: &LinearSource,
1073 receiver: [f64; 3],
1074 epoch: &TightGnssEpoch,
1075 ) -> Vec<Vec<f64>> {
1076 let mut normal = DMatrix::<f64>::zeros(4, 4);
1077 for observation in &epoch.observations {
1078 let prediction = transmit_time_satellite_state(
1079 source,
1080 observation.satellite_id,
1081 receiver,
1082 epoch.t_j2000_s,
1083 TransmitTimeOptions::default(),
1084 )
1085 .expect("satellite state");
1086 let h = [
1087 -prediction.los_unit[0],
1088 -prediction.los_unit[1],
1089 -prediction.los_unit[2],
1090 1.0,
1091 ];
1092 let inv_var = 1.0 / (observation.pseudorange_sigma_m * observation.pseudorange_sigma_m);
1093 for row in 0..4 {
1094 for col in 0..4 {
1095 normal[(row, col)] += h[row] * h[col] * inv_var;
1096 }
1097 }
1098 }
1099 let covariance = normal.try_inverse().expect("full-rank snapshot");
1100 (0..4)
1101 .map(|row| (0..4).map(|col| covariance[(row, col)]).collect())
1102 .collect()
1103 }
1104
1105 #[test]
1106 fn pseudorange_only_update_matches_spp_clock_oracle_with_frozen_ins_prior() {
1107 let receiver = [WGS84_A_M, 0.0, 0.0];
1108 let directions = [
1109 [1.0, 0.0, 0.0],
1110 [0.82, 0.42, 0.39],
1111 [0.83, -0.46, 0.31],
1112 [0.90, 0.18, -0.40],
1113 [0.78, -0.25, -0.58],
1114 ];
1115 let clock_m = 12.5;
1116 let source = source_from_directions(receiver, &directions);
1117 let epoch = tight_epoch_from_source(&source, receiver, clock_m, 1.0);
1118 let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1119 let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1120
1121 let spp_position = spp.position.as_array();
1122 let nominal = NavState::new(T0, spp_position, [0.0; 3], mat3_identity()).expect("nominal");
1123 let diagonal = vec![0.0; ERROR_STATE_DIMENSION_15];
1124 let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1125
1126 let update = filter.update_tight(&source, &epoch).expect("tight update");
1127
1128 assert!(update.applied);
1129 for (got, expected) in filter
1130 .state()
1131 .nominal
1132 .position_ecef_m
1133 .iter()
1134 .zip(spp_position)
1135 {
1136 assert_close(*got, expected, 1.0e-6);
1137 }
1138 let clock = filter.tight_clock_state().expect("clock");
1139 assert_close(clock.bias_m, spp.rx_clock_s * C_M_S, 1.0e-5);
1140 }
1141
1142 #[test]
1143 fn doppler_row_uses_range_rate_predictor_geometry_bits() {
1144 let receiver = [WGS84_A_M, 0.0, 0.0];
1145 let satellite_id = sat(1);
1146 let source = LinearSource::new(
1147 T0,
1148 vec![(
1149 satellite_id,
1150 [WGS84_A_M + 22_000_000.0, 1_000_000.0, 2_000_000.0],
1151 [120.0, -40.0, 30.0],
1152 0.0,
1153 )],
1154 );
1155 let sat_state = transmit_time_satellite_state(
1156 &source,
1157 satellite_id,
1158 receiver,
1159 T0,
1160 TransmitTimeOptions::default(),
1161 )
1162 .expect("satellite state");
1163 let measured_receiver = ReceiverVelocityState {
1164 position_m: receiver,
1165 velocity_m_s: [5.0, -2.0, 1.0],
1166 clock_drift_m_s: 0.25,
1167 };
1168 let velocity_observation = VelocityObservation {
1169 sat: satellite_id,
1170 satellite_position_m: sat_state.position_ecef_m,
1171 satellite_velocity_m_s: sat_state.velocity_m_s,
1172 measured_range_rate_m_s: 0.0,
1173 sigma_m_s: 0.05,
1174 satellite_clock_drift_m_s: 0.01,
1175 };
1176 let measured = predict_range_rate_m_s(&velocity_observation, measured_receiver)
1177 .expect("measured range rate")
1178 .range_rate_m_s;
1179 let observation = TightGnssObservation {
1180 satellite_id,
1181 pseudorange_m: sat_state.geometric_range_m,
1182 pseudorange_sigma_m: 2.0,
1183 range_rate: Some(TightRangeRateObservation {
1184 measured_range_rate_m_s: measured,
1185 sigma_m_s: 0.05,
1186 satellite_clock_drift_m_s: 0.01,
1187 }),
1188 carrier_phase: None,
1189 ionosphere_delay_m: 0.0,
1190 troposphere_delay_m: 0.0,
1191 };
1192 let epoch = TightGnssEpoch::new(T0, vec![observation]).expect("epoch");
1193 let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1194 let filter = filter_with_config(
1195 nominal,
1196 &[1.0; ERROR_STATE_DIMENSION_15],
1197 tight_config_for_test(),
1198 );
1199 let correction = tight_coupling_correction(
1200 &source,
1201 filter.state(),
1202 &filter.tight,
1203 &epoch,
1204 filter.config.tight,
1205 [0.0; 3],
1206 )
1207 .expect("correction");
1208 let predicted_at_nominal = predict_range_rate_m_s(
1209 &VelocityObservation {
1210 measured_range_rate_m_s: measured,
1211 ..velocity_observation
1212 },
1213 ReceiverVelocityState {
1214 position_m: receiver,
1215 velocity_m_s: [0.0; 3],
1216 clock_drift_m_s: 0.0,
1217 },
1218 )
1219 .expect("nominal range rate");
1220
1221 let doppler_row = &correction.design[1];
1222 for axis in 0..3 {
1223 assert_eq!(
1224 doppler_row[ERROR_VELOCITY_INDEX + axis].to_bits(),
1225 (-predicted_at_nominal.los_unit[axis]).to_bits()
1226 );
1227 }
1228 assert_eq!(
1229 doppler_row[clock_drift_index(filter.state.dimension())].to_bits(),
1230 1.0_f64.to_bits()
1231 );
1232 assert_eq!(
1233 correction.innovation[1].to_bits(),
1234 (measured - predicted_at_nominal.range_rate_m_s).to_bits()
1235 );
1236 }
1237
1238 #[test]
1239 fn singular_snapshot_geometry_keeps_unobserved_prior_covariance() {
1240 let receiver = [WGS84_A_M, 0.0, 0.0];
1241 let directions = [[1.0, 0.0, 0.0]; 5];
1242 let source = source_from_directions(receiver, &directions);
1243 let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
1244 let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1245 assert!(matches!(
1246 crate::spp::solve(&source, &inputs, false),
1247 Err(SppError::Singular(_))
1248 ));
1249
1250 let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1251 let mut diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
1252 diagonal[ERROR_POSITION_INDEX] = 100.0;
1253 diagonal[ERROR_POSITION_INDEX + 1] = 225.0;
1254 diagonal[ERROR_POSITION_INDEX + 2] = 400.0;
1255 let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1256 let prior_y = filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1];
1257 let prior_z = filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2];
1258
1259 let update = filter.update_tight(&source, &epoch).expect("tight update");
1260
1261 assert!(update.applied);
1262 assert!(covariance_is_positive_semidefinite(&filter.state.covariance).expect("PSD"));
1263 assert_eq!(
1264 filter.state.covariance[ERROR_POSITION_INDEX + 1][ERROR_POSITION_INDEX + 1].to_bits(),
1265 prior_y.to_bits()
1266 );
1267 assert_eq!(
1268 filter.state.covariance[ERROR_POSITION_INDEX + 2][ERROR_POSITION_INDEX + 2].to_bits(),
1269 prior_z.to_bits()
1270 );
1271 assert!(filter
1272 .state
1273 .nominal
1274 .position_ecef_m
1275 .iter()
1276 .all(|value| value.is_finite() && value.abs() < 1.0e8));
1277 }
1278
1279 #[test]
1280 fn high_dop_fused_covariance_has_lower_logdet_than_snapshot() {
1281 let receiver = [WGS84_A_M, 0.0, 0.0];
1282 let directions = [
1283 [0.44974122498328417, -0.8581153514788689, 0.2477314556265159],
1284 [0.20081904418348107, 0.5332143328087052, 0.8217993591994339],
1285 [0.43760604888398824, -0.4903647504582244, 0.7536865114145189],
1286 [
1287 0.2148508784686108,
1288 -0.9558725523345635,
1289 -0.20036657334663732,
1290 ],
1291 [0.30949187488876595, 0.3289789392404428, 0.8921813923827763],
1292 ];
1293 let source = source_from_directions(receiver, &directions);
1294 let epoch = tight_epoch_from_source(&source, receiver, 0.0, 1.0);
1295 let inputs = solve_inputs_from_epoch(&epoch, [receiver[0], receiver[1], receiver[2], 0.0]);
1296 let spp = crate::spp::solve(&source, &inputs, false).expect("SPP solution");
1297 assert_eq!(
1298 spp.geometry_quality.tier,
1299 crate::geometry_quality::ObservabilityTier::Weak
1300 );
1301 let snapshot_covariance = snapshot_position_clock_covariance(&source, receiver, &epoch);
1302 let snapshot_logdet = logdet_spd(&snapshot_covariance);
1303
1304 let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1305 let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1306 for axis in 0..3 {
1307 diagonal[ERROR_POSITION_INDEX + axis] = 1.0e8;
1308 }
1309 let mut filter = filter_with_config(nominal, &diagonal, tight_config_for_test());
1310
1311 filter.update_tight(&source, &epoch).expect("tight update");
1312
1313 let fused_logdet = logdet_spd(&position_clock_block(&filter));
1314 assert!(
1315 fused_logdet < snapshot_logdet,
1316 "fused {fused_logdet:.17e}, snapshot {snapshot_logdet:.17e}"
1317 );
1318 }
1319
1320 #[test]
1321 fn outage_growth_and_single_satellite_observed_direction_update() {
1322 let receiver = [WGS84_A_M, 0.0, 0.0];
1323 let nominal = NavState::new(T0, receiver, [0.0; 3], mat3_identity()).expect("nominal");
1324 let diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
1325 let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
1326 .expect("state");
1327 let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
1328 let mut config = super::super::loose::InertialFilterConfig::new(spec).expect("config");
1329 config.tight = TightCouplingConfig {
1330 light_time: false,
1331 sagnac: false,
1332 initial_clock_bias_variance_m2: 100.0,
1333 initial_clock_drift_variance_m2_s2: 1.0,
1334 clock_bias_random_walk_m2_s: 4.0,
1335 clock_drift_random_walk_m2_s3: 0.25,
1336 ..TightCouplingConfig::default()
1337 };
1338 let mut filter = InertialFilter::with_config(state, config).expect("filter");
1339 let mut previous_logdet = logdet_spd(&filter.tight.augmented_covariance);
1340
1341 for step in 1..=3 {
1342 filter
1343 .propagate(ImuSample::increment(
1344 T0 + step as f64,
1345 [0.0; 3],
1346 [0.0; 3],
1347 1.0,
1348 ))
1349 .expect("propagate");
1350 let next_logdet = logdet_spd(&filter.tight.augmented_covariance);
1351 assert!(
1352 next_logdet > previous_logdet,
1353 "step {step} logdet {next_logdet:.17e} <= {previous_logdet:.17e}"
1354 );
1355 previous_logdet = next_logdet;
1356 }
1357
1358 let current_position = filter.state.nominal.position_ecef_m;
1359 let satellite_id = sat(1);
1360 let source = LinearSource::new(
1361 filter.state.nominal.t_j2000_s,
1362 vec![(
1363 satellite_id,
1364 [
1365 current_position[0] + 22_000_000.0,
1366 current_position[1],
1367 current_position[2],
1368 ],
1369 [0.0; 3],
1370 0.0,
1371 )],
1372 );
1373 let prediction = transmit_time_satellite_state(
1374 &source,
1375 satellite_id,
1376 current_position,
1377 filter.state.nominal.t_j2000_s,
1378 TransmitTimeOptions {
1379 light_time: false,
1380 sagnac: false,
1381 },
1382 )
1383 .expect("satellite state");
1384 let clock = filter.tight_clock_state().expect("clock");
1385 let epoch = TightGnssEpoch::new(
1386 filter.state.nominal.t_j2000_s,
1387 vec![TightGnssObservation::pseudorange(
1388 satellite_id,
1389 prediction.geometric_range_m + clock.bias_m,
1390 0.5,
1391 )
1392 .expect("observation")],
1393 )
1394 .expect("epoch");
1395 let pre = filter.state.covariance.clone();
1396
1397 filter
1398 .update_tight(&source, &epoch)
1399 .expect("single-sat update");
1400
1401 assert!(
1402 filter.state.covariance[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
1403 < pre[ERROR_POSITION_INDEX][ERROR_POSITION_INDEX]
1404 );
1405 for axis in [1usize, 2] {
1406 assert_eq!(
1407 filter.state.covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis]
1408 .to_bits(),
1409 pre[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis].to_bits()
1410 );
1411 }
1412 }
1413}