Skip to main content

sidereon_core/precise_positioning/
kinematic.rs

1//! Kinematic PPP EKF public state and configuration types.
2
3use 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;
19// Covariance symmetry validation allows a tiny absolute floor in m^2 plus
20// relative scaling for large entries.
21const COVARIANCE_SYMMETRY_ABS_TOLERANCE_M2: f64 = 1.0e-9;
22const COVARIANCE_SYMMETRY_REL_TOLERANCE: f64 = 1.0e-12;
23// PSD validation allows the Cholesky residuals to dip slightly below zero
24// from roundoff while still rejecting materially indefinite covariance.
25const COVARIANCE_PSD_ABS_TOLERANCE_M2: f64 = 1.0e-9;
26const COVARIANCE_PSD_REL_TOLERANCE: f64 = 1.0e-12;
27
28/// Receiver state carried by the sequential kinematic PPP filter.
29///
30/// The state vector order is ECEF position, receiver clock range bias, zenith
31/// wet-delay residual, then carrier-phase float ambiguities in the map's sorted
32/// key order.
33#[derive(Debug, Clone, PartialEq)]
34pub struct KinematicState {
35    /// Receiver ECEF position `[x, y, z]`, in metres.
36    pub position_m: [f64; 3],
37    /// Receiver clock range bias, in metres.
38    pub clock_m: f64,
39    /// Zenith wet troposphere delay residual, in metres.
40    pub ztd_residual_m: f64,
41    /// Carrier-phase float ambiguity estimates, in metres, keyed by the static
42    /// PPP observation ambiguity id.
43    pub ambiguities_m: BTreeMap<String, f64>,
44}
45
46impl KinematicState {
47    /// Return the covariance/state-vector dimension implied by this state.
48    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/// Position process-noise model used by the kinematic PPP predict step.
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum KinematicPositionProcessNoise {
67    /// Position random walk with spectral density in square metres per second.
68    RandomWalk {
69        /// Random-walk spectral density, in `m^2/s`.
70        spectral_density_m2_s: f64,
71    },
72    /// White-noise acceleration model with spectral density in `m^2/s^3`.
73    WhiteNoiseAcceleration {
74        /// Acceleration spectral density, in `m^2/s^3`.
75        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/// Deterministic receiver-position propagation model for the predict step.
88#[derive(Debug, Clone, Copy, Default, PartialEq)]
89pub enum KinematicMotionModel {
90    /// Hold the receiver position fixed between measurement updates.
91    #[default]
92    Hold,
93    /// Propagate receiver position with a configured ECEF velocity.
94    ConstantVelocity {
95        /// Receiver ECEF velocity `[vx, vy, vz]`, in metres per second.
96        velocity_m_s: [f64; 3],
97    },
98}
99
100/// Process-noise spectral densities for the kinematic PPP EKF.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct KinematicProcessNoise {
103    /// Position process-noise model.
104    pub position: KinematicPositionProcessNoise,
105    /// Receiver clock white-noise spectral density, in `m^2/s`.
106    pub clock_white_m2_s: f64,
107    /// Zenith wet-delay random-walk spectral density, in `m^2/s`.
108    pub ztd_random_walk_m2_s: f64,
109    /// Ambiguity hold spectral density, in `m^2/s`.
110    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/// Configuration for a sequential kinematic PPP EKF solve.
125#[derive(Debug, Clone, PartialEq)]
126pub struct KinematicConfig {
127    /// Initial receiver state estimate.
128    pub initial_state: KinematicState,
129    /// Initial covariance matrix, in square metres, matching the initial state
130    /// vector dimension and ambiguity key order.
131    pub initial_covariance_m2: Vec<Vec<f64>>,
132    /// Deterministic motion model used during EKF prediction.
133    pub motion: KinematicMotionModel,
134    /// Process-noise spectral densities used during EKF prediction.
135    pub process_noise: KinematicProcessNoise,
136    /// Initial variance, in square metres, assigned to ambiguities first seen
137    /// after filter initialization.
138    pub new_ambiguity_variance_m2: f64,
139    /// Code/phase measurement weights reused from the static PPP options.
140    pub weights: MeasurementWeights,
141    /// Troposphere modelling and ZTD-estimation controls reused from static PPP.
142    pub tropo: TroposphereOptions,
143    /// Precomputed range corrections reused from static PPP.
144    pub corrections: RangeCorrections,
145}
146
147/// Summary of one kinematic PPP EKF measurement update.
148#[derive(Debug, Clone, PartialEq)]
149pub struct KinematicUpdateSummary {
150    /// Root-mean-square prefit innovation residual, in metres.
151    pub innovation_rms_m: f64,
152    /// Public satellite ids used by the measurement update.
153    pub used_sats: Vec<String>,
154}
155
156/// Per-epoch status returned by the kinematic PPP EKF driver.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum KinematicEpochStatus {
159    /// The epoch completed the EKF predict and measurement-update steps.
160    Updated,
161}
162
163/// One epoch returned by [`solve_kinematic_ppp`].
164#[derive(Debug, Clone, PartialEq)]
165pub struct KinematicEpochSolution {
166    /// Receiver ECEF position `[x, y, z]`, in metres.
167    pub position_m: [f64; 3],
168    /// Receiver clock range bias, in metres.
169    pub clock_m: f64,
170    /// Zenith wet troposphere delay residual, in metres.
171    pub ztd_residual_m: f64,
172    /// Carrier-phase float ambiguity estimates, in metres.
173    pub ambiguities_m: BTreeMap<String, f64>,
174    /// ECEF position covariance block, in square metres.
175    pub position_covariance_m2: [[f64; 3]; 3],
176    /// Public satellite ids used by the measurement update.
177    pub used_sats: Vec<String>,
178    /// Root-mean-square prefit innovation residual, in metres.
179    pub innovation_rms_m: f64,
180    /// Per-epoch filter status.
181    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/// Kinematic PPP EKF solve errors.
204#[derive(Debug, Clone, PartialEq)]
205pub enum KinematicSolveError {
206    /// Ephemeris or satellite clock data were unavailable for an observation.
207    NoEphemeris {
208        /// Public satellite token, e.g. `"G07"`.
209        satellite_id: String,
210        /// Specific ephemeris failure reason.
211        reason: NoEphemerisReason,
212    },
213    /// The EKF geometry or innovation covariance was singular.
214    SingularGeometry,
215    /// A solve option was outside the supported finite range.
216    InvalidSolveOption {
217        /// Option field name.
218        field: &'static str,
219        /// Validation failure reason.
220        reason: &'static str,
221    },
222    /// An input state, covariance, epoch, observation, or correction was invalid.
223    InvalidInput {
224        /// Input field name.
225        field: &'static str,
226        /// Validation failure reason.
227        reason: &'static str,
228    },
229    /// A required PPP range correction was unavailable for an observation.
230    MissingCorrection {
231        /// Public satellite token, e.g. `"G07"`.
232        satellite_id: String,
233        /// Missing correction class.
234        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
268/// Predict one kinematic PPP EKF state and covariance forward by `dt_s`.
269///
270/// The function updates the state in place, resizes ambiguity states to the
271/// sorted `active_ambiguity_ids`, assigns configured variance to new
272/// ambiguities, drops inactive ambiguities, applies the configured motion model,
273/// and inflates the covariance by process noise scaled by elapsed time.
274pub 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
294/// Solve an ordered sequence of epochs with the kinematic PPP EKF.
295///
296/// The driver initializes from [`KinematicConfig`], then for each epoch performs
297/// a time update using elapsed receiver time followed by a measurement update
298/// from the shared static PPP row builder.
299pub 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
346/// Correct one kinematic PPP EKF state with a single epoch of code/phase rows.
347///
348/// This uses the same shared PPP model row builder as the static float solver,
349/// then applies an EKF measurement update with diagonal measurement covariance
350/// derived from those rows' inverse-sigma weights.
351pub 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}