1use std::collections::{BTreeMap, BTreeSet};
4
5use crate::ambiguity::AmbiguityId;
6use crate::astro::math::linear::{invert_matrix_last_tie, matmul, matrix_sub, transpose};
7use crate::estimation::recipe::NormalRecipe;
8use crate::observables::ObservableEphemerisSource;
9
10use super::rows::{build_rows, AmbiguityBinding, PppRowError};
11use super::{
12 estimates_ztd, FloatEpoch, FloatSolveError, FloatState, MeasurementWeights, MissingCorrection,
13 NoEphemerisReason, RangeCorrections, TroposphereOptions,
14};
15
16const BASE_STATE_DIMENSION: usize = 5;
17const CLOCK_INDEX: usize = 3;
18const ZTD_INDEX: usize = 4;
19const COVARIANCE_SYMMETRY_ABS_TOLERANCE_M2: f64 = 1.0e-9;
22const COVARIANCE_SYMMETRY_REL_TOLERANCE: f64 = 1.0e-12;
23const COVARIANCE_PSD_ABS_TOLERANCE_M2: f64 = 1.0e-9;
26const COVARIANCE_PSD_REL_TOLERANCE: f64 = 1.0e-12;
27
28#[derive(Debug, Clone, PartialEq)]
34pub struct KinematicState {
35 pub position_m: [f64; 3],
37 pub clock_m: f64,
39 pub ztd_residual_m: f64,
41 pub ambiguities_m: BTreeMap<String, f64>,
44}
45
46impl KinematicState {
47 pub fn dimension(&self) -> usize {
49 BASE_STATE_DIMENSION + self.ambiguities_m.len()
50 }
51}
52
53impl Default for KinematicState {
54 fn default() -> Self {
55 Self {
56 position_m: [0.0; 3],
57 clock_m: 0.0,
58 ztd_residual_m: 0.0,
59 ambiguities_m: BTreeMap::new(),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum KinematicPositionProcessNoise {
67 RandomWalk {
69 spectral_density_m2_s: f64,
71 },
72 WhiteNoiseAcceleration {
74 spectral_density_m2_s3: f64,
76 },
77}
78
79impl Default for KinematicPositionProcessNoise {
80 fn default() -> Self {
81 Self::RandomWalk {
82 spectral_density_m2_s: 1.0,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, Default, PartialEq)]
89pub enum KinematicMotionModel {
90 #[default]
92 Hold,
93 ConstantVelocity {
95 velocity_m_s: [f64; 3],
97 },
98}
99
100#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct KinematicProcessNoise {
103 pub position: KinematicPositionProcessNoise,
105 pub clock_white_m2_s: f64,
107 pub ztd_random_walk_m2_s: f64,
109 pub ambiguity_hold_m2_s: f64,
111}
112
113impl Default for KinematicProcessNoise {
114 fn default() -> Self {
115 Self {
116 position: KinematicPositionProcessNoise::default(),
117 clock_white_m2_s: 100.0,
118 ztd_random_walk_m2_s: 1.0e-6,
119 ambiguity_hold_m2_s: 0.0,
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq)]
126pub struct KinematicConfig {
127 pub initial_state: KinematicState,
129 pub initial_covariance_m2: Vec<Vec<f64>>,
132 pub motion: KinematicMotionModel,
134 pub process_noise: KinematicProcessNoise,
136 pub new_ambiguity_variance_m2: f64,
139 pub weights: MeasurementWeights,
141 pub tropo: TroposphereOptions,
143 pub corrections: RangeCorrections,
145}
146
147#[derive(Debug, Clone, PartialEq)]
149pub struct KinematicUpdateSummary {
150 pub innovation_rms_m: f64,
152 pub used_sats: Vec<String>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum KinematicEpochStatus {
159 Updated,
161}
162
163#[derive(Debug, Clone, PartialEq)]
165pub struct KinematicEpochSolution {
166 pub position_m: [f64; 3],
168 pub clock_m: f64,
170 pub ztd_residual_m: f64,
172 pub ambiguities_m: BTreeMap<String, f64>,
174 pub position_covariance_m2: [[f64; 3]; 3],
176 pub used_sats: Vec<String>,
178 pub innovation_rms_m: f64,
180 pub status: KinematicEpochStatus,
182}
183
184impl Default for KinematicConfig {
185 fn default() -> Self {
186 Self {
187 initial_state: KinematicState::default(),
188 initial_covariance_m2: diagonal_covariance(BASE_STATE_DIMENSION, 1.0e8),
189 motion: KinematicMotionModel::default(),
190 process_noise: KinematicProcessNoise::default(),
191 new_ambiguity_variance_m2: 1.0e8,
192 weights: MeasurementWeights {
193 code: 1.0,
194 phase: 100.0,
195 elevation_weighting: false,
196 },
197 tropo: TroposphereOptions::disabled(),
198 corrections: RangeCorrections::disabled(),
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq)]
205pub enum KinematicSolveError {
206 NoEphemeris {
208 satellite_id: String,
210 reason: NoEphemerisReason,
212 },
213 SingularGeometry,
215 InvalidSolveOption {
217 field: &'static str,
219 reason: &'static str,
221 },
222 InvalidInput {
224 field: &'static str,
226 reason: &'static str,
228 },
229 MissingCorrection {
231 satellite_id: String,
233 correction: MissingCorrection,
235 },
236}
237
238impl core::fmt::Display for KinematicSolveError {
239 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
240 match self {
241 Self::NoEphemeris {
242 satellite_id,
243 reason,
244 } => write!(
245 f,
246 "missing kinematic PPP ephemeris for satellite {satellite_id}: {reason}"
247 ),
248 Self::SingularGeometry => write!(f, "kinematic PPP geometry is singular"),
249 Self::InvalidSolveOption { field, reason } => {
250 write!(f, "invalid kinematic PPP solve option {field}: {reason}")
251 }
252 Self::InvalidInput { field, reason } => {
253 write!(f, "invalid kinematic PPP input {field}: {reason}")
254 }
255 Self::MissingCorrection {
256 satellite_id,
257 correction,
258 } => write!(
259 f,
260 "missing kinematic PPP correction for satellite {satellite_id}: {correction}"
261 ),
262 }
263 }
264}
265
266impl std::error::Error for KinematicSolveError {}
267
268pub fn predict_kinematic_state(
275 state: &mut KinematicState,
276 covariance_m2: &mut Vec<Vec<f64>>,
277 dt_s: f64,
278 active_ambiguity_ids: &[String],
279 config: &KinematicConfig,
280) -> Result<(), KinematicSolveError> {
281 validate_predict_inputs(state, covariance_m2, dt_s, config)?;
282 align_ambiguities(
283 state,
284 covariance_m2,
285 active_ambiguity_ids,
286 config.new_ambiguity_variance_m2,
287 );
288 propagate_mean(state, dt_s, config.motion);
289 inflate_covariance(covariance_m2, dt_s, config.process_noise);
290 symmetrize(covariance_m2);
291 Ok(())
292}
293
294pub fn solve_kinematic_ppp(
300 source: &dyn ObservableEphemerisSource,
301 epochs: &[FloatEpoch],
302 config: KinematicConfig,
303) -> Result<Vec<KinematicEpochSolution>, KinematicSolveError> {
304 validate_kinematic_solve_inputs(epochs, &config)?;
305 let mut state = config.initial_state.clone();
306 let mut covariance_m2 = config.initial_covariance_m2.clone();
307 let mut solutions = Vec::with_capacity(epochs.len());
308 let mut previous_t_rx_j2000_s = epochs[0].t_rx_j2000_s;
309
310 for (epoch_idx, epoch) in epochs.iter().enumerate() {
311 let dt_s = if epoch_idx == 0 {
312 0.0
313 } else {
314 epoch.t_rx_j2000_s - previous_t_rx_j2000_s
315 };
316 let active_ambiguity_ids = epoch
317 .observations
318 .iter()
319 .map(|obs| obs.ambiguity_id.clone())
320 .collect::<Vec<_>>();
321 predict_kinematic_state(
322 &mut state,
323 &mut covariance_m2,
324 dt_s,
325 &active_ambiguity_ids,
326 &config,
327 )?;
328 let update =
329 correct_kinematic_state(source, epoch, &mut state, &mut covariance_m2, &config)?;
330 solutions.push(KinematicEpochSolution {
331 position_m: state.position_m,
332 clock_m: state.clock_m,
333 ztd_residual_m: state.ztd_residual_m,
334 ambiguities_m: state.ambiguities_m.clone(),
335 position_covariance_m2: position_covariance_block(&covariance_m2),
336 used_sats: update.used_sats,
337 innovation_rms_m: update.innovation_rms_m,
338 status: KinematicEpochStatus::Updated,
339 });
340 previous_t_rx_j2000_s = epoch.t_rx_j2000_s;
341 }
342
343 Ok(solutions)
344}
345
346pub fn correct_kinematic_state(
352 source: &dyn ObservableEphemerisSource,
353 epoch: &FloatEpoch,
354 state: &mut KinematicState,
355 covariance_m2: &mut Vec<Vec<f64>>,
356 config: &KinematicConfig,
357) -> Result<KinematicUpdateSummary, KinematicSolveError> {
358 validate_state(state)?;
359 validate_covariance_shape_and_values(covariance_m2, state.dimension())?;
360 validate_measurement_config(config)?;
361 let float_state = float_state_from_kinematic(state);
362 let corrections = &config.corrections;
363 let ctx = super::ModelContext {
364 source,
365 weights: config.weights,
366 tropo: config.tropo,
367 corrections,
368 normal: NormalRecipe::PppDenseLastTie,
369 estimate_residual_ionosphere: false,
370 };
371 let ambiguity_ids = state
372 .ambiguities_m
373 .keys()
374 .cloned()
375 .map(AmbiguityId::new)
376 .collect::<Vec<_>>();
377 let binding = AmbiguityBinding::Estimated {
378 ids: &ambiguity_ids,
379 values: &float_state.ambiguities_m,
380 };
381 let rows = build_rows(ctx, std::slice::from_ref(epoch), &binding, &float_state)
382 .map_err(kinematic_error_from_row)?;
383 let update = build_measurement_update(&rows, covariance_m2, config)?;
384 apply_state_delta(state, &update.dx)?;
385 *covariance_m2 = update.covariance_m2;
386 symmetrize(covariance_m2);
387 validate_state(state)?;
388 validate_covariance_shape_and_values(covariance_m2, state.dimension())?;
389 let innovation_rms_m = innovation_rms(&rows);
390 validate_finite(innovation_rms_m, "kinematic PPP update innovation_rms_m")?;
391
392 Ok(KinematicUpdateSummary {
393 innovation_rms_m,
394 used_sats: epoch
395 .observations
396 .iter()
397 .map(|obs| obs.satellite_id.clone())
398 .collect(),
399 })
400}
401
402fn diagonal_covariance(dimension: usize, variance_m2: f64) -> Vec<Vec<f64>> {
403 let mut covariance_m2 = vec![vec![0.0; dimension]; dimension];
404 for (idx, row) in covariance_m2.iter_mut().enumerate() {
405 row[idx] = variance_m2;
406 }
407 covariance_m2
408}
409
410struct MeasurementUpdate {
411 dx: Vec<f64>,
412 covariance_m2: Vec<Vec<f64>>,
413}
414
415fn validate_kinematic_solve_inputs(
416 epochs: &[FloatEpoch],
417 config: &KinematicConfig,
418) -> Result<(), KinematicSolveError> {
419 if epochs.is_empty() {
420 return Err(KinematicSolveError::InvalidInput {
421 field: "kinematic PPP epochs",
422 reason: "must not be empty",
423 });
424 }
425 validate_state(&config.initial_state)?;
426 validate_covariance_shape_and_values(
427 &config.initial_covariance_m2,
428 config.initial_state.dimension(),
429 )?;
430 validate_config_for_predict(config)?;
431 validate_measurement_config(config)?;
432 validate_ordered_epochs(epochs)
433}
434
435fn validate_ordered_epochs(epochs: &[FloatEpoch]) -> Result<(), KinematicSolveError> {
436 let mut previous_t_rx_j2000_s = None;
437 for epoch in epochs {
438 super::validate_epoch(epoch).map_err(kinematic_error_from_float)?;
439 if epoch.observations.is_empty() {
440 return Err(KinematicSolveError::InvalidInput {
441 field: "kinematic PPP epoch observations",
442 reason: "must not be empty",
443 });
444 }
445 if let Some(previous) = previous_t_rx_j2000_s {
446 if epoch.t_rx_j2000_s < previous {
447 return Err(KinematicSolveError::InvalidInput {
448 field: "kinematic PPP epochs",
449 reason: "must be ordered by non-decreasing receiver time",
450 });
451 }
452 }
453 previous_t_rx_j2000_s = Some(epoch.t_rx_j2000_s);
454 }
455 Ok(())
456}
457
458fn validate_measurement_config(config: &KinematicConfig) -> Result<(), KinematicSolveError> {
459 super::validate_measurement_weights(config.weights).map_err(kinematic_error_from_float)?;
460 super::validate_troposphere_options(config.tropo).map_err(kinematic_error_from_float)?;
461 if super::estimates_tropo_gradients(config.tropo) {
462 return Err(KinematicSolveError::InvalidInput {
463 field: "kinematic PPP tropo gradients",
464 reason: "static PPP only",
465 });
466 }
467 super::validate_range_corrections(&config.corrections).map_err(kinematic_error_from_float)
468}
469
470fn validate_predict_inputs(
471 state: &KinematicState,
472 covariance_m2: &[Vec<f64>],
473 dt_s: f64,
474 config: &KinematicConfig,
475) -> Result<(), KinematicSolveError> {
476 validate_finite_nonnegative(dt_s, "kinematic PPP predict dt_s")?;
477 validate_state(state)?;
478 validate_covariance_shape_and_values(covariance_m2, state.dimension())?;
479 validate_config_for_predict(config)?;
480 Ok(())
481}
482
483fn validate_state(state: &KinematicState) -> Result<(), KinematicSolveError> {
484 validate_vec3(state.position_m, "kinematic PPP state position_m")?;
485 validate_finite(state.clock_m, "kinematic PPP state clock_m")?;
486 validate_finite(state.ztd_residual_m, "kinematic PPP state ztd_residual_m")?;
487 for value in state.ambiguities_m.values() {
488 validate_finite(*value, "kinematic PPP state ambiguities_m")?;
489 }
490 Ok(())
491}
492
493fn validate_covariance_shape_and_values(
494 covariance_m2: &[Vec<f64>],
495 dimension: usize,
496) -> Result<(), KinematicSolveError> {
497 if covariance_m2.len() != dimension {
498 return Err(KinematicSolveError::InvalidInput {
499 field: "kinematic PPP covariance row count",
500 reason: "must match state dimension",
501 });
502 }
503 for (row_idx, row) in covariance_m2.iter().enumerate() {
504 if row.len() != dimension {
505 return Err(KinematicSolveError::InvalidInput {
506 field: "kinematic PPP covariance column count",
507 reason: "must match state dimension",
508 });
509 }
510 for entry in row {
511 validate_finite(*entry, "kinematic PPP covariance_m2")?;
512 }
513 if row[row_idx] < 0.0 {
514 return Err(KinematicSolveError::InvalidInput {
515 field: "kinematic PPP covariance variance",
516 reason: "must be non-negative",
517 });
518 }
519 }
520 for (row_idx, row) in covariance_m2.iter().enumerate() {
521 for (col_idx, value) in row.iter().enumerate().skip(row_idx + 1) {
522 if !covariance_entries_symmetric(*value, covariance_m2[col_idx][row_idx]) {
523 return Err(KinematicSolveError::InvalidInput {
524 field: "kinematic PPP covariance symmetry",
525 reason: "must be symmetric within tolerance",
526 });
527 }
528 }
529 }
530 validate_covariance_positive_semidefinite(covariance_m2)?;
531 Ok(())
532}
533
534fn covariance_entries_symmetric(a: f64, b: f64) -> bool {
535 let scale = a.abs().max(b.abs()).max(1.0);
536 (a - b).abs()
537 <= COVARIANCE_SYMMETRY_ABS_TOLERANCE_M2.max(COVARIANCE_SYMMETRY_REL_TOLERANCE * scale)
538}
539
540fn validate_covariance_positive_semidefinite(
541 covariance_m2: &[Vec<f64>],
542) -> Result<(), KinematicSolveError> {
543 if covariance_is_positive_semidefinite(covariance_m2) {
544 Ok(())
545 } else {
546 Err(KinematicSolveError::InvalidInput {
547 field: "kinematic PPP covariance positive semidefinite",
548 reason: "must be positive semidefinite within tolerance",
549 })
550 }
551}
552
553#[allow(clippy::needless_range_loop)]
554fn covariance_is_positive_semidefinite(covariance_m2: &[Vec<f64>]) -> bool {
555 let dimension = covariance_m2.len();
556 let tolerance = covariance_psd_tolerance(covariance_m2);
557 let mut lower = vec![vec![0.0; dimension]; dimension];
558
559 for row_idx in 0..dimension {
560 for col_idx in 0..=row_idx {
561 let mut residual = covariance_m2[row_idx][col_idx];
562 for prev_idx in 0..col_idx {
563 residual -= lower[row_idx][prev_idx] * lower[col_idx][prev_idx];
564 }
565
566 if row_idx == col_idx {
567 if !residual.is_finite() || residual < -tolerance {
568 return false;
569 }
570 if residual > 0.0 {
571 lower[row_idx][col_idx] = residual.sqrt();
572 }
573 } else if lower[col_idx][col_idx] > 0.0 {
574 lower[row_idx][col_idx] = residual / lower[col_idx][col_idx];
575 } else if residual.abs() > tolerance {
576 return false;
577 }
578 }
579 }
580
581 true
582}
583
584fn covariance_psd_tolerance(covariance_m2: &[Vec<f64>]) -> f64 {
585 let max_entry = covariance_m2
586 .iter()
587 .flat_map(|row| row.iter())
588 .fold(1.0_f64, |max_entry, value| max_entry.max(value.abs()));
589 COVARIANCE_PSD_ABS_TOLERANCE_M2.max(COVARIANCE_PSD_REL_TOLERANCE * max_entry)
590}
591
592fn validate_config_for_predict(config: &KinematicConfig) -> Result<(), KinematicSolveError> {
593 validate_motion(config.motion)?;
594 validate_process_noise(config.process_noise)?;
595 validate_finite_nonnegative(
596 config.new_ambiguity_variance_m2,
597 "kinematic PPP new_ambiguity_variance_m2",
598 )
599}
600
601fn validate_motion(motion: KinematicMotionModel) -> Result<(), KinematicSolveError> {
602 match motion {
603 KinematicMotionModel::Hold => Ok(()),
604 KinematicMotionModel::ConstantVelocity { velocity_m_s } => {
605 validate_vec3(velocity_m_s, "kinematic PPP motion velocity_m_s")
606 }
607 }
608}
609
610fn validate_process_noise(noise: KinematicProcessNoise) -> Result<(), KinematicSolveError> {
611 match noise.position {
612 KinematicPositionProcessNoise::RandomWalk {
613 spectral_density_m2_s,
614 } => validate_finite_nonnegative(
615 spectral_density_m2_s,
616 "kinematic PPP position random-walk spectral_density_m2_s",
617 )?,
618 KinematicPositionProcessNoise::WhiteNoiseAcceleration {
619 spectral_density_m2_s3,
620 } => validate_finite_nonnegative(
621 spectral_density_m2_s3,
622 "kinematic PPP position acceleration spectral_density_m2_s3",
623 )?,
624 }
625 validate_finite_nonnegative(noise.clock_white_m2_s, "kinematic PPP clock_white_m2_s")?;
626 validate_finite_nonnegative(
627 noise.ztd_random_walk_m2_s,
628 "kinematic PPP ztd_random_walk_m2_s",
629 )?;
630 validate_finite_nonnegative(
631 noise.ambiguity_hold_m2_s,
632 "kinematic PPP ambiguity_hold_m2_s",
633 )
634}
635
636fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), KinematicSolveError> {
637 for entry in value {
638 validate_finite(entry, field)?;
639 }
640 Ok(())
641}
642
643fn validate_finite_nonnegative(value: f64, field: &'static str) -> Result<(), KinematicSolveError> {
644 validate_finite(value, field)?;
645 if value < 0.0 {
646 return Err(KinematicSolveError::InvalidInput {
647 field,
648 reason: "must be non-negative",
649 });
650 }
651 Ok(())
652}
653
654fn validate_finite(value: f64, field: &'static str) -> Result<(), KinematicSolveError> {
655 if value.is_finite() {
656 Ok(())
657 } else {
658 Err(KinematicSolveError::InvalidInput {
659 field,
660 reason: "must be finite",
661 })
662 }
663}
664
665fn validate_finite_slice(values: &[f64], field: &'static str) -> Result<(), KinematicSolveError> {
666 for value in values {
667 validate_finite(*value, field)?;
668 }
669 Ok(())
670}
671
672fn validate_finite_matrix(
673 matrix: &[Vec<f64>],
674 field: &'static str,
675) -> Result<(), KinematicSolveError> {
676 for row in matrix {
677 validate_finite_slice(row, field)?;
678 }
679 Ok(())
680}
681
682fn kinematic_error_from_row(error: PppRowError) -> KinematicSolveError {
683 kinematic_error_from_float(error.into_float())
684}
685
686fn kinematic_error_from_float(error: FloatSolveError) -> KinematicSolveError {
687 match error {
688 FloatSolveError::NoEphemeris {
689 satellite_id,
690 reason,
691 } => KinematicSolveError::NoEphemeris {
692 satellite_id,
693 reason,
694 },
695 FloatSolveError::SingularGeometry => KinematicSolveError::SingularGeometry,
696 FloatSolveError::InvalidSolveOption { field, reason } => {
697 KinematicSolveError::InvalidSolveOption { field, reason }
698 }
699 FloatSolveError::InvalidInput { field, reason } => {
700 KinematicSolveError::InvalidInput { field, reason }
701 }
702 FloatSolveError::InsufficientObservationsAfterElevationCutoff { .. } => {
703 KinematicSolveError::SingularGeometry
704 }
705 FloatSolveError::MissingCorrection {
706 satellite_id,
707 correction,
708 } => KinematicSolveError::MissingCorrection {
709 satellite_id,
710 correction,
711 },
712 FloatSolveError::InvalidClockCount { .. } => KinematicSolveError::InvalidInput {
713 field: "kinematic PPP clock state",
714 reason: "must contain exactly one receiver clock",
715 },
716 FloatSolveError::MissingAmbiguity(_) => KinematicSolveError::InvalidInput {
717 field: "kinematic PPP ambiguity state",
718 reason: "must include every active ambiguity",
719 },
720 }
721}
722
723fn align_ambiguities(
724 state: &mut KinematicState,
725 covariance_m2: &mut Vec<Vec<f64>>,
726 active_ambiguity_ids: &[String],
727 new_ambiguity_variance_m2: f64,
728) {
729 let old_keys = ambiguity_keys(state);
730 let new_keys = active_ambiguity_ids
731 .iter()
732 .cloned()
733 .collect::<BTreeSet<_>>()
734 .into_iter()
735 .collect::<Vec<_>>();
736 if old_keys == new_keys {
737 return;
738 }
739
740 let old_index_by_key = old_keys
741 .iter()
742 .enumerate()
743 .map(|(idx, key)| (key.clone(), BASE_STATE_DIMENSION + idx))
744 .collect::<BTreeMap<_, _>>();
745 let new_dimension = BASE_STATE_DIMENSION + new_keys.len();
746 let mut next_covariance_m2 = vec![vec![0.0; new_dimension]; new_dimension];
747
748 for row in 0..BASE_STATE_DIMENSION {
749 for col in 0..BASE_STATE_DIMENSION {
750 next_covariance_m2[row][col] = covariance_m2[row][col];
751 }
752 }
753
754 for (new_ambiguity_idx, new_key) in new_keys.iter().enumerate() {
755 let new_idx = BASE_STATE_DIMENSION + new_ambiguity_idx;
756 if let Some(&old_idx) = old_index_by_key.get(new_key) {
757 copy_retained_ambiguity_covariance(
758 covariance_m2,
759 &mut next_covariance_m2,
760 old_idx,
761 new_idx,
762 &new_keys,
763 &old_index_by_key,
764 );
765 } else {
766 next_covariance_m2[new_idx][new_idx] = new_ambiguity_variance_m2;
767 }
768 }
769
770 state.ambiguities_m = new_keys
771 .into_iter()
772 .map(|key| {
773 let value = state.ambiguities_m.get(&key).copied().unwrap_or(0.0);
774 (key, value)
775 })
776 .collect();
777 *covariance_m2 = next_covariance_m2;
778}
779
780fn copy_retained_ambiguity_covariance(
781 old_covariance_m2: &[Vec<f64>],
782 next_covariance_m2: &mut [Vec<f64>],
783 old_idx: usize,
784 new_idx: usize,
785 new_keys: &[String],
786 old_index_by_key: &BTreeMap<String, usize>,
787) {
788 for base_idx in 0..BASE_STATE_DIMENSION {
789 next_covariance_m2[new_idx][base_idx] = old_covariance_m2[old_idx][base_idx];
790 next_covariance_m2[base_idx][new_idx] = old_covariance_m2[base_idx][old_idx];
791 }
792 for (other_new_ambiguity_idx, other_key) in new_keys.iter().enumerate() {
793 if let Some(&other_old_idx) = old_index_by_key.get(other_key) {
794 let other_new_idx = BASE_STATE_DIMENSION + other_new_ambiguity_idx;
795 next_covariance_m2[new_idx][other_new_idx] = old_covariance_m2[old_idx][other_old_idx];
796 }
797 }
798}
799
800fn ambiguity_keys(state: &KinematicState) -> Vec<String> {
801 state.ambiguities_m.keys().cloned().collect()
802}
803
804fn propagate_mean(state: &mut KinematicState, dt_s: f64, motion: KinematicMotionModel) {
805 match motion {
806 KinematicMotionModel::Hold => {}
807 KinematicMotionModel::ConstantVelocity { velocity_m_s } => {
808 for (position, velocity) in state.position_m.iter_mut().zip(velocity_m_s) {
809 *position += velocity * dt_s;
810 }
811 }
812 }
813}
814
815fn build_measurement_update(
816 rows: &[super::normal::Row],
817 covariance_m2: &[Vec<f64>],
818 config: &KinematicConfig,
819) -> Result<MeasurementUpdate, KinematicSolveError> {
820 if rows.is_empty() {
821 return Err(KinematicSolveError::InvalidInput {
822 field: "kinematic PPP epoch observations",
823 reason: "must not be empty",
824 });
825 }
826 let dimension = covariance_m2.len();
827 let h = kinematic_design_matrix(rows, dimension, config)?;
828 let innovation = rows
829 .iter()
830 .map(|row| {
831 validate_finite(row.y, "kinematic PPP innovation_m")?;
832 Ok(row.y)
833 })
834 .collect::<Result<Vec<_>, _>>()?;
835 let measurement_variance = rows
836 .iter()
837 .map(|row| {
838 validate_finite_nonnegative(row.weight, "kinematic PPP measurement weight")?;
839 if row.weight <= 0.0 {
840 return Err(KinematicSolveError::InvalidInput {
841 field: "kinematic PPP measurement weight",
842 reason: "must be positive",
843 });
844 }
845 let variance = 1.0 / (row.weight * row.weight);
846 validate_finite_nonnegative(variance, "kinematic PPP measurement variance")?;
847 Ok(variance)
848 })
849 .collect::<Result<Vec<_>, _>>()?;
850
851 let h_t = transpose(&h).ok_or(KinematicSolveError::SingularGeometry)?;
852 let hp = matmul(&h, covariance_m2).ok_or(KinematicSolveError::SingularGeometry)?;
853 let mut innovation_covariance =
854 matmul(&hp, &h_t).ok_or(KinematicSolveError::SingularGeometry)?;
855 for (idx, variance) in measurement_variance.iter().enumerate() {
856 innovation_covariance[idx][idx] += variance;
857 }
858 let innovation_covariance_inverse = invert_matrix_last_tie(&innovation_covariance)
859 .ok_or(KinematicSolveError::SingularGeometry)?;
860 let p_h_t = matmul(covariance_m2, &h_t).ok_or(KinematicSolveError::SingularGeometry)?;
861 let mut kalman_gain = matmul(&p_h_t, &innovation_covariance_inverse)
862 .ok_or(KinematicSolveError::SingularGeometry)?;
863 let ztd_estimated = estimates_ztd(config.tropo);
864 if !ztd_estimated {
865 kalman_gain[ZTD_INDEX].fill(0.0);
866 }
867 let dx = matvec(&kalman_gain, &innovation)?;
868 validate_finite_slice(&dx, "kinematic PPP state update")?;
869 let mut covariance_update = joseph_covariance(
870 covariance_m2,
871 &h,
872 &kalman_gain,
873 &measurement_variance,
874 dimension,
875 )?;
876 if !ztd_estimated {
877 restore_frozen_ztd_covariance(&mut covariance_update, covariance_m2);
878 }
879 validate_finite_matrix(&covariance_update, "kinematic PPP covariance update")?;
880 Ok(MeasurementUpdate {
881 dx,
882 covariance_m2: covariance_update,
883 })
884}
885
886fn restore_frozen_ztd_covariance(covariance_m2: &mut [Vec<f64>], prior_covariance_m2: &[Vec<f64>]) {
887 covariance_m2[ZTD_INDEX][..prior_covariance_m2.len()]
888 .copy_from_slice(&prior_covariance_m2[ZTD_INDEX]);
889 for (row_idx, row) in covariance_m2.iter_mut().enumerate() {
890 row[ZTD_INDEX] = prior_covariance_m2[row_idx][ZTD_INDEX];
891 }
892}
893
894fn kinematic_design_matrix(
895 rows: &[super::normal::Row],
896 dimension: usize,
897 config: &KinematicConfig,
898) -> Result<Vec<Vec<f64>>, KinematicSolveError> {
899 rows.iter()
900 .map(|row| kinematic_design_row(row, dimension, config))
901 .collect()
902}
903
904fn kinematic_design_row(
905 row: &super::normal::Row,
906 dimension: usize,
907 config: &KinematicConfig,
908) -> Result<Vec<f64>, KinematicSolveError> {
909 let ztd_estimated = estimates_ztd(config.tropo);
910 let ztd_cols = usize::from(ztd_estimated);
911 let static_ambiguity_start = 4 + ztd_cols;
912 let expected_static_dim = static_ambiguity_start + dimension - BASE_STATE_DIMENSION;
913 if row.h.len() != expected_static_dim {
914 return Err(KinematicSolveError::InvalidInput {
915 field: "kinematic PPP design row",
916 reason: "static PPP row dimension does not match kinematic state",
917 });
918 }
919 let mut h = vec![0.0; dimension];
920 h[..3].copy_from_slice(&row.h[..3]);
921 h[CLOCK_INDEX] = row.h[3];
922 if ztd_estimated {
923 h[ZTD_INDEX] = row.h[4];
924 }
925 let ambiguity_count = dimension - BASE_STATE_DIMENSION;
926 h[BASE_STATE_DIMENSION..BASE_STATE_DIMENSION + ambiguity_count]
927 .copy_from_slice(&row.h[static_ambiguity_start..static_ambiguity_start + ambiguity_count]);
928 validate_finite_slice(&h, "kinematic PPP design row")?;
929 Ok(h)
930}
931
932fn matvec(matrix: &[Vec<f64>], vector: &[f64]) -> Result<Vec<f64>, KinematicSolveError> {
933 matrix
934 .iter()
935 .map(|row| {
936 if row.len() != vector.len() {
937 return Err(KinematicSolveError::SingularGeometry);
938 }
939 Ok(row.iter().zip(vector).map(|(a, b)| a * b).sum())
940 })
941 .collect()
942}
943
944fn joseph_covariance(
945 covariance_m2: &[Vec<f64>],
946 h: &[Vec<f64>],
947 kalman_gain: &[Vec<f64>],
948 measurement_variance: &[f64],
949 dimension: usize,
950) -> Result<Vec<Vec<f64>>, KinematicSolveError> {
951 let kh = matmul(kalman_gain, h).ok_or(KinematicSolveError::SingularGeometry)?;
952 let identity_minus_kh =
953 matrix_sub(&identity(dimension), &kh).ok_or(KinematicSolveError::SingularGeometry)?;
954 let left =
955 matmul(&identity_minus_kh, covariance_m2).ok_or(KinematicSolveError::SingularGeometry)?;
956 let right = transpose(&identity_minus_kh).ok_or(KinematicSolveError::SingularGeometry)?;
957 let stabilized = matmul(&left, &right).ok_or(KinematicSolveError::SingularGeometry)?;
958 let kr = scale_columns(kalman_gain, measurement_variance)?;
959 let k_t = transpose(kalman_gain).ok_or(KinematicSolveError::SingularGeometry)?;
960 let noise = matmul(&kr, &k_t).ok_or(KinematicSolveError::SingularGeometry)?;
961 matrix_add(&stabilized, &noise).ok_or(KinematicSolveError::SingularGeometry)
962}
963
964fn identity(dimension: usize) -> Vec<Vec<f64>> {
965 let mut matrix = vec![vec![0.0; dimension]; dimension];
966 for (idx, row) in matrix.iter_mut().enumerate() {
967 row[idx] = 1.0;
968 }
969 matrix
970}
971
972fn scale_columns(
973 matrix: &[Vec<f64>],
974 scales: &[f64],
975) -> Result<Vec<Vec<f64>>, KinematicSolveError> {
976 matrix
977 .iter()
978 .map(|row| {
979 if row.len() != scales.len() {
980 return Err(KinematicSolveError::SingularGeometry);
981 }
982 Ok(row
983 .iter()
984 .zip(scales)
985 .map(|(value, scale)| value * scale)
986 .collect())
987 })
988 .collect()
989}
990
991fn matrix_add(a: &[Vec<f64>], b: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
992 if a.len() != b.len() {
993 return None;
994 }
995 let mut out = Vec::with_capacity(a.len());
996 for (row_a, row_b) in a.iter().zip(b) {
997 if row_a.len() != row_b.len() {
998 return None;
999 }
1000 out.push(row_a.iter().zip(row_b).map(|(x, y)| x + y).collect());
1001 }
1002 Some(out)
1003}
1004
1005fn apply_state_delta(state: &mut KinematicState, dx: &[f64]) -> Result<(), KinematicSolveError> {
1006 if dx.len() != state.dimension() {
1007 return Err(KinematicSolveError::SingularGeometry);
1008 }
1009 for (position, delta) in state.position_m.iter_mut().zip(&dx[..3]) {
1010 *position += delta;
1011 }
1012 state.clock_m += dx[CLOCK_INDEX];
1013 state.ztd_residual_m += dx[ZTD_INDEX];
1014 for (idx, value) in state.ambiguities_m.values_mut().enumerate() {
1015 *value += dx[BASE_STATE_DIMENSION + idx];
1016 }
1017 Ok(())
1018}
1019
1020fn float_state_from_kinematic(state: &KinematicState) -> FloatState {
1021 FloatState {
1022 position_m: state.position_m,
1023 clocks_m: vec![state.clock_m],
1024 ambiguities_m: state.ambiguities_m.clone(),
1025 ztd_m: state.ztd_residual_m,
1026 tropo_gradient_north_m: 0.0,
1027 tropo_gradient_east_m: 0.0,
1028 residual_ionosphere_m: BTreeMap::new(),
1029 }
1030}
1031
1032fn innovation_rms(rows: &[super::normal::Row]) -> f64 {
1033 if rows.is_empty() {
1034 return 0.0;
1035 }
1036 let mean_square = rows.iter().map(|row| row.y * row.y).sum::<f64>() / rows.len() as f64;
1037 mean_square.sqrt()
1038}
1039
1040fn position_covariance_block(covariance_m2: &[Vec<f64>]) -> [[f64; 3]; 3] {
1041 [
1042 [
1043 covariance_m2[0][0],
1044 covariance_m2[0][1],
1045 covariance_m2[0][2],
1046 ],
1047 [
1048 covariance_m2[1][0],
1049 covariance_m2[1][1],
1050 covariance_m2[1][2],
1051 ],
1052 [
1053 covariance_m2[2][0],
1054 covariance_m2[2][1],
1055 covariance_m2[2][2],
1056 ],
1057 ]
1058}
1059
1060fn inflate_covariance(
1061 covariance_m2: &mut [Vec<f64>],
1062 dt_s: f64,
1063 process_noise: KinematicProcessNoise,
1064) {
1065 let position_variance_m2 = match process_noise.position {
1066 KinematicPositionProcessNoise::RandomWalk {
1067 spectral_density_m2_s,
1068 } => spectral_density_m2_s * dt_s,
1069 KinematicPositionProcessNoise::WhiteNoiseAcceleration {
1070 spectral_density_m2_s3,
1071 } => spectral_density_m2_s3 * dt_s.powi(3) / 3.0,
1072 };
1073
1074 for (idx, row) in covariance_m2.iter_mut().enumerate().take(3) {
1075 row[idx] += position_variance_m2;
1076 }
1077 covariance_m2[CLOCK_INDEX][CLOCK_INDEX] += process_noise.clock_white_m2_s * dt_s;
1078 covariance_m2[ZTD_INDEX][ZTD_INDEX] += process_noise.ztd_random_walk_m2_s * dt_s;
1079 for (idx, row) in covariance_m2
1080 .iter_mut()
1081 .enumerate()
1082 .skip(BASE_STATE_DIMENSION)
1083 {
1084 row[idx] += process_noise.ambiguity_hold_m2_s * dt_s;
1085 }
1086}
1087
1088#[allow(clippy::needless_range_loop)]
1089fn symmetrize(covariance_m2: &mut [Vec<f64>]) {
1090 for row in 0..covariance_m2.len() {
1091 for col in 0..row {
1092 let average = 0.5 * (covariance_m2[row][col] + covariance_m2[col][row]);
1093 covariance_m2[row][col] = average;
1094 covariance_m2[col][row] = average;
1095 }
1096 }
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::super::FloatObservation;
1102 use super::*;
1103 use crate::constants::F_L1_HZ;
1104 use crate::estimation::substrate::rows::ResidualRow;
1105 use crate::observables::{predict, ObservableState, ObservablesError, PredictOptions};
1106 use crate::ppp_corrections::CivilDateTime;
1107 use crate::{GnssSatelliteId, GnssSystem};
1108
1109 struct KinematicFakeSource {
1110 states: BTreeMap<GnssSatelliteId, [f64; 3]>,
1111 }
1112
1113 impl ObservableEphemerisSource for KinematicFakeSource {
1114 fn observable_state_at_j2000_s(
1115 &self,
1116 sat: GnssSatelliteId,
1117 _t_j2000_s: f64,
1118 ) -> Result<ObservableState, ObservablesError> {
1119 let position_ecef_m = self
1120 .states
1121 .get(&sat)
1122 .copied()
1123 .ok_or(ObservablesError::NoEphemeris)?;
1124 Ok(ObservableState {
1125 position_ecef_m,
1126 clock_s: Some(0.0),
1127 })
1128 }
1129 }
1130
1131 #[test]
1132 fn kinematic_types_construct_and_default_config_is_well_formed() {
1133 let mut ambiguities_m = BTreeMap::new();
1134 ambiguities_m.insert("G07#1".to_string(), 12.5);
1135 let state = KinematicState {
1136 position_m: [1.0, 2.0, 3.0],
1137 clock_m: 4.0,
1138 ztd_residual_m: 0.12,
1139 ambiguities_m,
1140 };
1141 assert_eq!(state.dimension(), 6);
1142
1143 let config = KinematicConfig {
1144 initial_covariance_m2: diagonal_covariance(state.dimension(), 25.0),
1145 initial_state: state,
1146 motion: KinematicMotionModel::ConstantVelocity {
1147 velocity_m_s: [1.0, 0.0, 0.0],
1148 },
1149 process_noise: KinematicProcessNoise {
1150 position: KinematicPositionProcessNoise::WhiteNoiseAcceleration {
1151 spectral_density_m2_s3: 0.25,
1152 },
1153 clock_white_m2_s: 2.0,
1154 ztd_random_walk_m2_s: 1.0e-5,
1155 ambiguity_hold_m2_s: 1.0e-8,
1156 },
1157 new_ambiguity_variance_m2: 1.0e6,
1158 weights: MeasurementWeights {
1159 code: 0.5,
1160 phase: 50.0,
1161 elevation_weighting: true,
1162 },
1163 tropo: TroposphereOptions::disabled(),
1164 corrections: RangeCorrections::disabled(),
1165 };
1166 assert!(config_is_well_formed(&config));
1167
1168 let default = KinematicConfig::default();
1169 assert!(config_is_well_formed(&default));
1170 assert_eq!(default.initial_state.dimension(), BASE_STATE_DIMENSION);
1171 }
1172
1173 fn config_is_well_formed(config: &KinematicConfig) -> bool {
1174 let dimension = config.initial_state.dimension();
1175 config.initial_covariance_m2.len() == dimension
1176 && config
1177 .initial_covariance_m2
1178 .iter()
1179 .all(|row| row.len() == dimension && row.iter().all(|entry| entry.is_finite()))
1180 && motion_is_well_formed(config.motion)
1181 && process_noise_is_well_formed(config.process_noise)
1182 && config.new_ambiguity_variance_m2.is_finite()
1183 && config.new_ambiguity_variance_m2 >= 0.0
1184 && config.weights.code.is_finite()
1185 && config.weights.code > 0.0
1186 && config.weights.phase.is_finite()
1187 && config.weights.phase > 0.0
1188 }
1189
1190 fn process_noise_is_well_formed(process_noise: KinematicProcessNoise) -> bool {
1191 position_noise_is_well_formed(process_noise.position)
1192 && process_noise.clock_white_m2_s.is_finite()
1193 && process_noise.clock_white_m2_s >= 0.0
1194 && process_noise.ztd_random_walk_m2_s.is_finite()
1195 && process_noise.ztd_random_walk_m2_s >= 0.0
1196 && process_noise.ambiguity_hold_m2_s.is_finite()
1197 && process_noise.ambiguity_hold_m2_s >= 0.0
1198 }
1199
1200 fn motion_is_well_formed(motion: KinematicMotionModel) -> bool {
1201 match motion {
1202 KinematicMotionModel::Hold => true,
1203 KinematicMotionModel::ConstantVelocity { velocity_m_s } => {
1204 velocity_m_s.iter().all(|entry| entry.is_finite())
1205 }
1206 }
1207 }
1208
1209 fn position_noise_is_well_formed(position: KinematicPositionProcessNoise) -> bool {
1210 match position {
1211 KinematicPositionProcessNoise::RandomWalk {
1212 spectral_density_m2_s,
1213 } => spectral_density_m2_s.is_finite() && spectral_density_m2_s >= 0.0,
1214 KinematicPositionProcessNoise::WhiteNoiseAcceleration {
1215 spectral_density_m2_s3,
1216 } => spectral_density_m2_s3.is_finite() && spectral_density_m2_s3 >= 0.0,
1217 }
1218 }
1219
1220 #[test]
1221 fn zero_dt_predict_keeps_mean_and_covariance_when_ambiguities_are_unchanged() {
1222 let mut state = state_with_ambiguities(["G07#1"]);
1223 let mut covariance_m2 = diagonal_covariance(state.dimension(), 4.0);
1224 let before_state = state.clone();
1225 let before_covariance_m2 = covariance_m2.clone();
1226 let config = KinematicConfig {
1227 motion: KinematicMotionModel::ConstantVelocity {
1228 velocity_m_s: [3.0, -2.0, 1.0],
1229 },
1230 process_noise: KinematicProcessNoise {
1231 position: KinematicPositionProcessNoise::RandomWalk {
1232 spectral_density_m2_s: 20.0,
1233 },
1234 clock_white_m2_s: 30.0,
1235 ztd_random_walk_m2_s: 40.0,
1236 ambiguity_hold_m2_s: 50.0,
1237 },
1238 initial_state: state.clone(),
1239 initial_covariance_m2: covariance_m2.clone(),
1240 ..KinematicConfig::default()
1241 };
1242
1243 predict_kinematic_state(
1244 &mut state,
1245 &mut covariance_m2,
1246 0.0,
1247 &["G07#1".to_string()],
1248 &config,
1249 )
1250 .expect("zero-dt predict should succeed");
1251
1252 assert_eq!(state, before_state);
1253 assert_eq!(covariance_m2, before_covariance_m2);
1254 }
1255
1256 #[test]
1257 fn predict_covariance_stays_symmetric_psd() {
1258 let mut state = state_with_ambiguities(["G07#1", "G08#1"]);
1259 let mut covariance_m2 = diagonal_covariance(state.dimension(), 10.0);
1260 covariance_m2[0][BASE_STATE_DIMENSION] = 0.5;
1261 covariance_m2[BASE_STATE_DIMENSION][0] = 0.5;
1262 let config = KinematicConfig {
1263 process_noise: KinematicProcessNoise {
1264 position: KinematicPositionProcessNoise::WhiteNoiseAcceleration {
1265 spectral_density_m2_s3: 0.3,
1266 },
1267 clock_white_m2_s: 0.2,
1268 ztd_random_walk_m2_s: 0.1,
1269 ambiguity_hold_m2_s: 0.05,
1270 },
1271 initial_state: state.clone(),
1272 initial_covariance_m2: covariance_m2.clone(),
1273 ..KinematicConfig::default()
1274 };
1275
1276 predict_kinematic_state(
1277 &mut state,
1278 &mut covariance_m2,
1279 5.0,
1280 &["G07#1".to_string(), "G08#1".to_string()],
1281 &config,
1282 )
1283 .expect("predict should succeed");
1284
1285 assert!(is_symmetric(&covariance_m2));
1286 assert!(is_psd(&covariance_m2));
1287 }
1288
1289 #[test]
1290 fn initial_covariance_rejects_asymmetry_and_negative_variance() {
1291 let (source, epoch, initial_state, mut config) = single_epoch_update_fixture();
1292 let epochs = vec![epoch];
1293 let dimension = initial_state.dimension();
1294
1295 let mut asymmetric = diagonal_covariance(dimension, 25.0);
1296 asymmetric[0][1] = 0.25;
1297 config.initial_covariance_m2 = asymmetric;
1298 let err = solve_kinematic_ppp(&source, &epochs, config.clone())
1299 .expect_err("asymmetric covariance should be rejected");
1300 assert_invalid_kinematic_input(
1301 err,
1302 "kinematic PPP covariance symmetry",
1303 "must be symmetric within tolerance",
1304 );
1305
1306 let mut negative_variance = diagonal_covariance(dimension, 25.0);
1307 negative_variance[2][2] = -1.0;
1308 config.initial_covariance_m2 = negative_variance;
1309 let err = solve_kinematic_ppp(&source, &epochs, config)
1310 .expect_err("negative covariance variance should be rejected");
1311 assert_invalid_kinematic_input(
1312 err,
1313 "kinematic PPP covariance variance",
1314 "must be non-negative",
1315 );
1316 }
1317
1318 #[test]
1319 fn initial_covariance_rejects_symmetric_indefinite_matrix() {
1320 let (source, epoch, initial_state, mut config) = single_epoch_update_fixture();
1321 let epochs = vec![epoch];
1322 config.initial_covariance_m2 = indefinite_covariance(initial_state.dimension());
1323
1324 let err = solve_kinematic_ppp(&source, &epochs, config)
1325 .expect_err("indefinite initial covariance should be rejected");
1326
1327 assert_invalid_kinematic_input(
1328 err,
1329 "kinematic PPP covariance positive semidefinite",
1330 "must be positive semidefinite within tolerance",
1331 );
1332 }
1333
1334 #[test]
1335 fn covariance_validation_accepts_symmetric_psd_unchanged() {
1336 let dimension = state_with_ambiguities(["G07#1"]).dimension();
1337 let mut covariance_m2 = diagonal_covariance(dimension, 4.0);
1338 covariance_m2[0][1] = 0.25;
1339 covariance_m2[1][0] = 0.25;
1340 let original = covariance_m2.clone();
1341
1342 validate_covariance_shape_and_values(&covariance_m2, dimension)
1343 .expect("symmetric PSD covariance should be accepted");
1344
1345 assert_eq!(covariance_m2, original);
1346 }
1347
1348 #[test]
1349 fn predict_rejects_symmetric_indefinite_covariance() {
1350 let (_, epoch, mut state, config) = single_epoch_update_fixture();
1351 let mut covariance_m2 = indefinite_covariance(state.dimension());
1352 let active_ambiguity_ids = epoch
1353 .observations
1354 .iter()
1355 .map(|obs| obs.ambiguity_id.clone())
1356 .collect::<Vec<_>>();
1357
1358 let err = predict_kinematic_state(
1359 &mut state,
1360 &mut covariance_m2,
1361 0.0,
1362 &active_ambiguity_ids,
1363 &config,
1364 )
1365 .expect_err("indefinite mutable covariance should be rejected");
1366
1367 assert_invalid_kinematic_input(
1368 err,
1369 "kinematic PPP covariance positive semidefinite",
1370 "must be positive semidefinite within tolerance",
1371 );
1372 }
1373
1374 #[test]
1375 fn predict_adds_and_removes_ambiguities_without_orphaned_covariance_entries() {
1376 let mut state = state_with_ambiguities(["G07#1"]);
1377 let mut covariance_m2 = diagonal_covariance(state.dimension(), 3.0);
1378 let config = KinematicConfig {
1379 new_ambiguity_variance_m2: 99.0,
1380 initial_state: state.clone(),
1381 initial_covariance_m2: covariance_m2.clone(),
1382 ..KinematicConfig::default()
1383 };
1384
1385 predict_kinematic_state(
1386 &mut state,
1387 &mut covariance_m2,
1388 1.0,
1389 &["G07#1".to_string(), "G08#1".to_string()],
1390 &config,
1391 )
1392 .expect("adding ambiguity should succeed");
1393
1394 assert_eq!(state.dimension(), BASE_STATE_DIMENSION + 2);
1395 assert_eq!(covariance_m2.len(), state.dimension());
1396 assert!(covariance_m2
1397 .iter()
1398 .all(|row| row.len() == state.dimension()));
1399 assert!(state.ambiguities_m.contains_key("G08#1"));
1400 assert_eq!(
1401 covariance_m2[BASE_STATE_DIMENSION + 1][BASE_STATE_DIMENSION + 1],
1402 99.0
1403 );
1404 assert!(is_symmetric(&covariance_m2));
1405
1406 predict_kinematic_state(
1407 &mut state,
1408 &mut covariance_m2,
1409 1.0,
1410 &["G08#1".to_string()],
1411 &config,
1412 )
1413 .expect("removing ambiguity should succeed");
1414
1415 assert_eq!(state.dimension(), BASE_STATE_DIMENSION + 1);
1416 assert_eq!(covariance_m2.len(), state.dimension());
1417 assert!(covariance_m2
1418 .iter()
1419 .all(|row| row.len() == state.dimension()));
1420 assert!(!state.ambiguities_m.contains_key("G07#1"));
1421 assert!(state.ambiguities_m.contains_key("G08#1"));
1422 assert!(is_symmetric(&covariance_m2));
1423 }
1424
1425 fn state_with_ambiguities<const N: usize>(ids: [&str; N]) -> KinematicState {
1426 KinematicState {
1427 position_m: [1.0, 2.0, 3.0],
1428 clock_m: 4.0,
1429 ztd_residual_m: 0.5,
1430 ambiguities_m: ids
1431 .into_iter()
1432 .enumerate()
1433 .map(|(idx, id)| (id.to_string(), idx as f64 + 10.0))
1434 .collect(),
1435 }
1436 }
1437
1438 fn is_symmetric(covariance_m2: &[Vec<f64>]) -> bool {
1439 covariance_m2.iter().enumerate().all(|(row_idx, row)| {
1440 row.iter()
1441 .enumerate()
1442 .all(|(col_idx, value)| (*value - covariance_m2[col_idx][row_idx]).abs() <= 1.0e-12)
1443 })
1444 }
1445
1446 fn assert_invalid_kinematic_input(
1447 error: KinematicSolveError,
1448 field: &'static str,
1449 reason: &'static str,
1450 ) {
1451 assert_eq!(error, KinematicSolveError::InvalidInput { field, reason });
1452 }
1453
1454 fn indefinite_covariance(dimension: usize) -> Vec<Vec<f64>> {
1455 let mut covariance_m2 = diagonal_covariance(dimension, 25.0);
1456 covariance_m2[0][0] = 1.0;
1457 covariance_m2[1][1] = 1.0;
1458 covariance_m2[0][1] = 2.0;
1459 covariance_m2[1][0] = 2.0;
1460 covariance_m2
1461 }
1462
1463 #[allow(clippy::needless_range_loop)]
1464 fn is_psd(covariance_m2: &[Vec<f64>]) -> bool {
1465 let n = covariance_m2.len();
1466 let mut lower = vec![vec![0.0; n]; n];
1467 for row in 0..n {
1468 for col in 0..=row {
1469 let mut sum = covariance_m2[row][col];
1470 for k in 0..col {
1471 sum -= lower[row][k] * lower[col][k];
1472 }
1473 if row == col {
1474 if sum < -1.0e-10 {
1475 return false;
1476 }
1477 lower[row][col] = sum.max(0.0).sqrt();
1478 } else if lower[col][col] > 1.0e-12 {
1479 lower[row][col] = sum / lower[col][col];
1480 }
1481 }
1482 }
1483 true
1484 }
1485
1486 #[test]
1487 fn update_pulls_position_toward_static_float_solution() {
1488 let (source, epoch, initial_state, config) = single_epoch_update_fixture();
1489 let static_solution = super::super::solve_float_epoch(
1490 &source,
1491 epoch.clone(),
1492 float_state_from_kinematic(&initial_state),
1493 super::super::FloatSolveConfig {
1494 weights: config.weights,
1495 tropo: config.tropo,
1496 corrections: config.corrections.clone(),
1497 opts: super::super::FloatSolveOptions {
1498 max_iterations: 20,
1499 position_tolerance_m: 1.0e-8,
1500 clock_tolerance_m: 1.0e-8,
1501 ambiguity_tolerance_m: 1.0e-8,
1502 ztd_tolerance_m: 1.0e-8,
1503 },
1504 elevation_cutoff_deg: None,
1505 residual_screen: false,
1506 estimate_residual_ionosphere: false,
1507 },
1508 )
1509 .expect("static float solve should converge");
1510
1511 let mut state = initial_state.clone();
1512 let mut covariance_m2 = config.initial_covariance_m2.clone();
1513 predict_kinematic_state(
1514 &mut state,
1515 &mut covariance_m2,
1516 0.0,
1517 &epoch
1518 .observations
1519 .iter()
1520 .map(|obs| obs.ambiguity_id.clone())
1521 .collect::<Vec<_>>(),
1522 &config,
1523 )
1524 .expect("predict should succeed");
1525 let before = distance(state.position_m, static_solution.position_m);
1526 let update =
1527 correct_kinematic_state(&source, &epoch, &mut state, &mut covariance_m2, &config)
1528 .expect("measurement update should succeed");
1529 let after = distance(state.position_m, static_solution.position_m);
1530
1531 assert!(after < before);
1532 assert!(after < 1.0);
1533 assert!(update.innovation_rms_m.is_finite());
1534 assert!(update.innovation_rms_m > 0.0);
1535 assert_eq!(update.used_sats.len(), epoch.observations.len());
1536 }
1537
1538 #[test]
1539 fn update_covariance_remains_symmetric_psd() {
1540 let (source, epoch, mut state, config) = single_epoch_update_fixture();
1541 let mut covariance_m2 = config.initial_covariance_m2.clone();
1542
1543 correct_kinematic_state(&source, &epoch, &mut state, &mut covariance_m2, &config)
1544 .expect("measurement update should succeed");
1545
1546 assert!(is_symmetric(&covariance_m2));
1547 assert!(is_psd(&covariance_m2));
1548 }
1549
1550 #[test]
1551 fn update_rejects_non_finite_internal_measurement_variance() {
1552 let (source, epoch, mut state, mut config) = single_epoch_update_fixture();
1553 config.weights.code = f64::MIN_POSITIVE;
1554 config.weights.phase = f64::MIN_POSITIVE;
1555 let mut covariance_m2 = config.initial_covariance_m2.clone();
1556
1557 let err = correct_kinematic_state(&source, &epoch, &mut state, &mut covariance_m2, &config)
1558 .expect_err("overflowed measurement variance must be rejected");
1559
1560 assert_eq!(
1561 err,
1562 KinematicSolveError::InvalidInput {
1563 field: "kinematic PPP measurement variance",
1564 reason: "must be finite",
1565 }
1566 );
1567 }
1568
1569 #[test]
1570 fn disabled_ztd_estimation_freezes_ztd_state_and_cross_covariance() {
1571 let mut state = KinematicState {
1572 position_m: [0.0, 0.0, 0.0],
1573 clock_m: 0.0,
1574 ztd_residual_m: 0.42,
1575 ambiguities_m: BTreeMap::new(),
1576 };
1577 let mut covariance_m2 = diagonal_covariance(state.dimension(), 4.0);
1578 covariance_m2[ZTD_INDEX][ZTD_INDEX] = 9.0;
1579 covariance_m2[0][ZTD_INDEX] = 0.25;
1580 covariance_m2[ZTD_INDEX][0] = 0.25;
1581 covariance_m2[CLOCK_INDEX][ZTD_INDEX] = -0.125;
1582 covariance_m2[ZTD_INDEX][CLOCK_INDEX] = -0.125;
1583 let prior_state = state.clone();
1584 let prior_covariance_m2 = covariance_m2.clone();
1585 let row = ResidualRow {
1586 h: vec![1.0, 0.0, 0.0, 0.0],
1587 y: 10.0,
1588 weight: 1.0,
1589 };
1590 let config = KinematicConfig {
1591 tropo: TroposphereOptions::disabled(),
1592 ..KinematicConfig::default()
1593 };
1594
1595 let update = build_measurement_update(&[row], &covariance_m2, &config)
1596 .expect("measurement update should be well conditioned");
1597 apply_state_delta(&mut state, &update.dx).expect("state delta should apply");
1598
1599 assert!(state.position_m[0] > prior_state.position_m[0]);
1600 assert!(update.covariance_m2[0][0] < prior_covariance_m2[0][0]);
1601 assert_eq!(state.ztd_residual_m, prior_state.ztd_residual_m);
1602 assert_eq!(
1603 update.covariance_m2[ZTD_INDEX],
1604 prior_covariance_m2[ZTD_INDEX]
1605 );
1606 for (row_idx, row) in update.covariance_m2.iter().enumerate() {
1607 assert_eq!(row[ZTD_INDEX], prior_covariance_m2[row_idx][ZTD_INDEX]);
1608 }
1609 }
1610
1611 #[test]
1612 fn enabled_ztd_estimation_updates_ztd_state() {
1613 let covariance_m2 = diagonal_covariance(BASE_STATE_DIMENSION, 4.0);
1614 let row = ResidualRow {
1615 h: vec![1.0, 0.0, 0.0, 0.0, 1.0],
1616 y: 10.0,
1617 weight: 1.0,
1618 };
1619 let mut tropo = TroposphereOptions::disabled();
1620 tropo.enabled = true;
1621 tropo.estimate_ztd = true;
1622 let config = KinematicConfig {
1623 tropo,
1624 ..KinematicConfig::default()
1625 };
1626
1627 let update = build_measurement_update(&[row], &covariance_m2, &config)
1628 .expect("measurement update should be well conditioned");
1629
1630 assert!(update.dx[ZTD_INDEX] > 0.0);
1631 assert_ne!(
1632 update.covariance_m2[ZTD_INDEX][ZTD_INDEX],
1633 covariance_m2[ZTD_INDEX][ZTD_INDEX]
1634 );
1635 }
1636
1637 #[test]
1638 fn singular_innovation_covariance_is_reported() {
1639 let (source, epoch, mut state, mut config) = single_epoch_update_fixture();
1640 config.weights = MeasurementWeights {
1641 code: 1.0e300,
1642 phase: 1.0e300,
1643 elevation_weighting: false,
1644 };
1645 let mut covariance_m2 = vec![vec![0.0; state.dimension()]; state.dimension()];
1646
1647 let err = correct_kinematic_state(&source, &epoch, &mut state, &mut covariance_m2, &config)
1648 .expect_err("singular innovation covariance should error");
1649
1650 assert_eq!(err, KinematicSolveError::SingularGeometry);
1651 }
1652
1653 #[test]
1654 fn driver_static_arc_converges_to_static_float_solution() {
1655 let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
1656 let truths = vec![truth; 6];
1657 let clocks = [12.5, -8.25, 4.0, 1.5, -2.0, 6.75];
1658 let (source, epochs, ambiguities_m) = synthetic_kinematic_arc(&truths, &clocks);
1659 let initial_state = KinematicState {
1660 position_m: [truth[0] + 5.0, truth[1] - 4.0, truth[2] + 3.0],
1661 clock_m: -20.0,
1662 ztd_residual_m: 0.0,
1663 ambiguities_m: ambiguities_m.clone(),
1664 };
1665 let config = driver_config(initial_state.clone());
1666 let static_solution = super::super::solve_float_epochs(
1667 &source,
1668 &epochs,
1669 FloatState {
1670 position_m: initial_state.position_m,
1671 clocks_m: vec![initial_state.clock_m; epochs.len()],
1672 ambiguities_m,
1673 ztd_m: 0.0,
1674 tropo_gradient_north_m: 0.0,
1675 tropo_gradient_east_m: 0.0,
1676 residual_ionosphere_m: BTreeMap::new(),
1677 },
1678 float_config_from_kinematic(&config),
1679 )
1680 .expect("static float solve should converge");
1681
1682 let solutions =
1683 solve_kinematic_ppp(&source, &epochs, config).expect("kinematic solve should succeed");
1684 let last = solutions.last().expect("kinematic solution");
1685 let penultimate = &solutions[solutions.len() - 2];
1686
1687 assert_eq!(solutions.len(), epochs.len());
1688 assert_eq!(last.status, KinematicEpochStatus::Updated);
1689 assert!(distance(last.position_m, static_solution.position_m) < 0.05);
1690 assert!(distance(penultimate.position_m, static_solution.position_m) < 0.10);
1691 assert!(
1692 position_trace(last.position_covariance_m2)
1693 < position_trace(solutions[0].position_covariance_m2)
1694 );
1695 }
1696
1697 #[test]
1698 fn driver_constant_velocity_track_is_recovered() {
1699 let start = [3_512_900.0, 780_500.0, 5_248_700.0];
1700 let velocity_m_s = [0.45, -0.20, 0.15];
1701 let truths = (0..6)
1702 .map(|idx| position_at(start, velocity_m_s, idx as f64 * 60.0))
1703 .collect::<Vec<_>>();
1704 let clocks = [5.0, 5.5, 6.0, 6.5, 7.0, 7.5];
1705 let (source, epochs, ambiguities_m) = synthetic_kinematic_arc(&truths, &clocks);
1706 let initial_state = KinematicState {
1707 position_m: [start[0] + 3.0, start[1] - 2.0, start[2] + 1.0],
1708 clock_m: 0.0,
1709 ztd_residual_m: 0.0,
1710 ambiguities_m,
1711 };
1712 let config = KinematicConfig {
1713 motion: KinematicMotionModel::ConstantVelocity { velocity_m_s },
1714 ..driver_config(initial_state)
1715 };
1716
1717 let solutions =
1718 solve_kinematic_ppp(&source, &epochs, config).expect("kinematic solve should succeed");
1719
1720 for (solution, truth) in solutions.iter().zip(truths.iter()).skip(1) {
1721 assert!(distance(solution.position_m, *truth) < 0.25);
1722 assert!(solution.innovation_rms_m.is_finite());
1723 assert_eq!(solution.used_sats.len(), epochs[0].observations.len());
1724 }
1725 }
1726
1727 #[test]
1728 fn driver_position_covariance_trace_decreases_over_static_arc() {
1729 let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
1730 let truths = vec![truth; 5];
1731 let clocks = [12.5, -8.25, 4.0, 1.5, -2.0];
1732 let (source, epochs, ambiguities_m) = synthetic_kinematic_arc(&truths, &clocks);
1733 let initial_state = KinematicState {
1734 position_m: [truth[0] + 5.0, truth[1] - 4.0, truth[2] + 3.0],
1735 clock_m: -20.0,
1736 ztd_residual_m: 0.0,
1737 ambiguities_m,
1738 };
1739
1740 let solutions = solve_kinematic_ppp(&source, &epochs, driver_config(initial_state))
1741 .expect("kinematic solve should succeed");
1742 let traces = solutions
1743 .iter()
1744 .map(|solution| position_trace(solution.position_covariance_m2))
1745 .collect::<Vec<_>>();
1746
1747 assert!(traces.windows(2).all(|trace| trace[1] <= trace[0] + 1.0e-8));
1748 assert!(traces.last().copied().unwrap() < traces[0] * 0.5);
1749 }
1750
1751 fn single_epoch_update_fixture() -> (
1752 KinematicFakeSource,
1753 FloatEpoch,
1754 KinematicState,
1755 KinematicConfig,
1756 ) {
1757 let sats = [
1758 (1u8, [20_200_000.0, 13_000_000.0, 21_500_000.0]),
1759 (2, [-21_300_000.0, 14_500_000.0, 20_700_000.0]),
1760 (3, [15_200_000.0, -22_000_000.0, 19_500_000.0]),
1761 (4, [-18_200_000.0, -16_000_000.0, 21_000_000.0]),
1762 (5, [22_000_000.0, -12_000_000.0, 20_200_000.0]),
1763 ];
1764 let ids = sats
1765 .iter()
1766 .map(|(prn, _)| {
1767 GnssSatelliteId::new(GnssSystem::Gps, *prn).expect("valid satellite id")
1768 })
1769 .collect::<Vec<_>>();
1770 let source = KinematicFakeSource {
1771 states: ids
1772 .iter()
1773 .zip(sats.iter())
1774 .map(|(id, (_, pos))| (*id, *pos))
1775 .collect(),
1776 };
1777 let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
1778 let clock_m = 12.5;
1779 let ambiguities_m = ids
1780 .iter()
1781 .enumerate()
1782 .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
1783 .collect::<BTreeMap<_, _>>();
1784 let observations = ids
1785 .iter()
1786 .map(|id| {
1787 let pred = predict(
1788 &source,
1789 *id,
1790 truth,
1791 0.0,
1792 PredictOptions {
1793 carrier_hz: F_L1_HZ,
1794 light_time: true,
1795 sagnac: true,
1796 },
1797 )
1798 .expect("synthetic satellite should predict");
1799 let code_m = pred.geometric_range_m + clock_m;
1800 let ambiguity_m = ambiguities_m.get(&id.to_string()).copied().unwrap();
1801 FloatObservation {
1802 sat: *id,
1803 satellite_id: id.to_string(),
1804 ambiguity_id: id.to_string(),
1805 code_m,
1806 phase_m: code_m + ambiguity_m,
1807 freq1_hz: 0.0,
1808 freq2_hz: 0.0,
1809 glonass_channel: None,
1810 }
1811 })
1812 .collect();
1813 let epoch = FloatEpoch {
1814 epoch: CivilDateTime {
1815 year: 2020,
1816 month: 6,
1817 day: 24,
1818 hour: 12,
1819 minute: 0,
1820 second: 0.0,
1821 },
1822 jd_whole: 2_459_024.5,
1823 jd_fraction: 0.5,
1824 t_rx_j2000_s: 0.0,
1825 observations,
1826 };
1827 let initial_state = KinematicState {
1828 position_m: [truth[0] + 5.0, truth[1] - 4.0, truth[2] + 3.0],
1829 clock_m: 0.0,
1830 ztd_residual_m: 0.0,
1831 ambiguities_m,
1832 };
1833 let config = KinematicConfig {
1834 initial_state: initial_state.clone(),
1835 initial_covariance_m2: diagonal_covariance(initial_state.dimension(), 1.0e8),
1836 weights: MeasurementWeights {
1837 code: 1.0,
1838 phase: 100.0,
1839 elevation_weighting: false,
1840 },
1841 tropo: TroposphereOptions::disabled(),
1842 corrections: RangeCorrections::disabled(),
1843 ..KinematicConfig::default()
1844 };
1845 (source, epoch, initial_state, config)
1846 }
1847
1848 fn synthetic_kinematic_arc(
1849 truths: &[[f64; 3]],
1850 clocks_m: &[f64],
1851 ) -> (KinematicFakeSource, Vec<FloatEpoch>, BTreeMap<String, f64>) {
1852 let sats = [
1853 (1u8, [20_200_000.0, 13_000_000.0, 21_500_000.0]),
1854 (2, [-21_300_000.0, 14_500_000.0, 20_700_000.0]),
1855 (3, [15_200_000.0, -22_000_000.0, 19_500_000.0]),
1856 (4, [-18_700_000.0, -18_200_000.0, 22_000_000.0]),
1857 (5, [23_500_000.0, 3_200_000.0, -18_900_000.0]),
1858 (6, [-7_500_000.0, 25_800_000.0, -16_000_000.0]),
1859 ];
1860 let ids = sats
1861 .iter()
1862 .map(|(prn, _)| {
1863 GnssSatelliteId::new(GnssSystem::Gps, *prn).expect("valid satellite id")
1864 })
1865 .collect::<Vec<_>>();
1866 let source = KinematicFakeSource {
1867 states: ids
1868 .iter()
1869 .zip(sats.iter())
1870 .map(|(id, (_, pos))| (*id, *pos))
1871 .collect(),
1872 };
1873 let ambiguities_m = ids
1874 .iter()
1875 .enumerate()
1876 .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
1877 .collect::<BTreeMap<_, _>>();
1878 let epochs = truths
1879 .iter()
1880 .zip(clocks_m.iter())
1881 .enumerate()
1882 .map(|(epoch_idx, (truth, clock_m))| {
1883 let t_rx_j2000_s = epoch_idx as f64 * 60.0;
1884 let observations = ids
1885 .iter()
1886 .map(|id| {
1887 let pred = predict(
1888 &source,
1889 *id,
1890 *truth,
1891 t_rx_j2000_s,
1892 PredictOptions {
1893 carrier_hz: F_L1_HZ,
1894 light_time: true,
1895 sagnac: true,
1896 },
1897 )
1898 .expect("synthetic satellite should predict");
1899 let code_m = pred.geometric_range_m + clock_m;
1900 let ambiguity_m = ambiguities_m.get(&id.to_string()).copied().unwrap();
1901 FloatObservation {
1902 sat: *id,
1903 satellite_id: id.to_string(),
1904 ambiguity_id: id.to_string(),
1905 code_m,
1906 phase_m: code_m + ambiguity_m,
1907 freq1_hz: 0.0,
1908 freq2_hz: 0.0,
1909 glonass_channel: None,
1910 }
1911 })
1912 .collect();
1913 FloatEpoch {
1914 epoch: CivilDateTime {
1915 year: 2020,
1916 month: 6,
1917 day: 24,
1918 hour: 12,
1919 minute: epoch_idx as u8,
1920 second: 0.0,
1921 },
1922 jd_whole: 2_459_024.5,
1923 jd_fraction: 0.5 + t_rx_j2000_s / crate::constants::SECONDS_PER_DAY,
1924 t_rx_j2000_s,
1925 observations,
1926 }
1927 })
1928 .collect();
1929 (source, epochs, ambiguities_m)
1930 }
1931
1932 fn driver_config(initial_state: KinematicState) -> KinematicConfig {
1933 KinematicConfig {
1934 initial_covariance_m2: diagonal_covariance(initial_state.dimension(), 1.0e6),
1935 initial_state,
1936 process_noise: KinematicProcessNoise {
1937 position: KinematicPositionProcessNoise::RandomWalk {
1938 spectral_density_m2_s: 0.0,
1939 },
1940 clock_white_m2_s: 0.0,
1941 ztd_random_walk_m2_s: 0.0,
1942 ambiguity_hold_m2_s: 0.0,
1943 },
1944 weights: MeasurementWeights {
1945 code: 1.0,
1946 phase: 100.0,
1947 elevation_weighting: false,
1948 },
1949 tropo: TroposphereOptions::disabled(),
1950 corrections: RangeCorrections::disabled(),
1951 ..KinematicConfig::default()
1952 }
1953 }
1954
1955 fn float_config_from_kinematic(config: &KinematicConfig) -> super::super::FloatSolveConfig {
1956 super::super::FloatSolveConfig {
1957 weights: config.weights,
1958 tropo: config.tropo,
1959 corrections: config.corrections.clone(),
1960 opts: super::super::FloatSolveOptions {
1961 max_iterations: 20,
1962 position_tolerance_m: 1.0e-8,
1963 clock_tolerance_m: 1.0e-8,
1964 ambiguity_tolerance_m: 1.0e-8,
1965 ztd_tolerance_m: 1.0e-8,
1966 },
1967 elevation_cutoff_deg: None,
1968 residual_screen: false,
1969 estimate_residual_ionosphere: false,
1970 }
1971 }
1972
1973 fn position_at(start: [f64; 3], velocity_m_s: [f64; 3], dt_s: f64) -> [f64; 3] {
1974 [
1975 start[0] + velocity_m_s[0] * dt_s,
1976 start[1] + velocity_m_s[1] * dt_s,
1977 start[2] + velocity_m_s[2] * dt_s,
1978 ]
1979 }
1980
1981 fn position_trace(covariance_m2: [[f64; 3]; 3]) -> f64 {
1982 covariance_m2[0][0] + covariance_m2[1][1] + covariance_m2[2][2]
1983 }
1984
1985 fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
1986 let dx = a[0] - b[0];
1987 let dy = a[1] - b[1];
1988 let dz = a[2] - b[2];
1989 (dx * dx + dy * dy + dz * dz).sqrt()
1990 }
1991}