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