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    };
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                    glonass_channel: None,
1792                }
1793            })
1794            .collect();
1795        let epoch = FloatEpoch {
1796            epoch: CivilDateTime {
1797                year: 2020,
1798                month: 6,
1799                day: 24,
1800                hour: 12,
1801                minute: 0,
1802                second: 0.0,
1803            },
1804            jd_whole: 2_459_024.5,
1805            jd_fraction: 0.5,
1806            t_rx_j2000_s: 0.0,
1807            observations,
1808        };
1809        let initial_state = KinematicState {
1810            position_m: [truth[0] + 5.0, truth[1] - 4.0, truth[2] + 3.0],
1811            clock_m: 0.0,
1812            ztd_residual_m: 0.0,
1813            ambiguities_m,
1814        };
1815        let config = KinematicConfig {
1816            initial_state: initial_state.clone(),
1817            initial_covariance_m2: diagonal_covariance(initial_state.dimension(), 1.0e8),
1818            weights: MeasurementWeights {
1819                code: 1.0,
1820                phase: 100.0,
1821                elevation_weighting: false,
1822            },
1823            tropo: TroposphereOptions::disabled(),
1824            corrections: RangeCorrections::disabled(),
1825            ..KinematicConfig::default()
1826        };
1827        (source, epoch, initial_state, config)
1828    }
1829
1830    fn synthetic_kinematic_arc(
1831        truths: &[[f64; 3]],
1832        clocks_m: &[f64],
1833    ) -> (KinematicFakeSource, Vec<FloatEpoch>, BTreeMap<String, f64>) {
1834        let sats = [
1835            (1u8, [20_200_000.0, 13_000_000.0, 21_500_000.0]),
1836            (2, [-21_300_000.0, 14_500_000.0, 20_700_000.0]),
1837            (3, [15_200_000.0, -22_000_000.0, 19_500_000.0]),
1838            (4, [-18_700_000.0, -18_200_000.0, 22_000_000.0]),
1839            (5, [23_500_000.0, 3_200_000.0, -18_900_000.0]),
1840            (6, [-7_500_000.0, 25_800_000.0, -16_000_000.0]),
1841        ];
1842        let ids = sats
1843            .iter()
1844            .map(|(prn, _)| {
1845                GnssSatelliteId::new(GnssSystem::Gps, *prn).expect("valid satellite id")
1846            })
1847            .collect::<Vec<_>>();
1848        let source = KinematicFakeSource {
1849            states: ids
1850                .iter()
1851                .zip(sats.iter())
1852                .map(|(id, (_, pos))| (*id, *pos))
1853                .collect(),
1854        };
1855        let ambiguities_m = ids
1856            .iter()
1857            .enumerate()
1858            .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
1859            .collect::<BTreeMap<_, _>>();
1860        let epochs = truths
1861            .iter()
1862            .zip(clocks_m.iter())
1863            .enumerate()
1864            .map(|(epoch_idx, (truth, clock_m))| {
1865                let t_rx_j2000_s = epoch_idx as f64 * 60.0;
1866                let observations = ids
1867                    .iter()
1868                    .map(|id| {
1869                        let pred = predict(
1870                            &source,
1871                            *id,
1872                            *truth,
1873                            t_rx_j2000_s,
1874                            PredictOptions {
1875                                carrier_hz: F_L1_HZ,
1876                                light_time: true,
1877                                sagnac: true,
1878                            },
1879                        )
1880                        .expect("synthetic satellite should predict");
1881                        let code_m = pred.geometric_range_m + clock_m;
1882                        let ambiguity_m = ambiguities_m.get(&id.to_string()).copied().unwrap();
1883                        FloatObservation {
1884                            sat: *id,
1885                            satellite_id: id.to_string(),
1886                            ambiguity_id: id.to_string(),
1887                            code_m,
1888                            phase_m: code_m + ambiguity_m,
1889                            freq1_hz: 0.0,
1890                            freq2_hz: 0.0,
1891                            glonass_channel: None,
1892                        }
1893                    })
1894                    .collect();
1895                FloatEpoch {
1896                    epoch: CivilDateTime {
1897                        year: 2020,
1898                        month: 6,
1899                        day: 24,
1900                        hour: 12,
1901                        minute: epoch_idx as u8,
1902                        second: 0.0,
1903                    },
1904                    jd_whole: 2_459_024.5,
1905                    jd_fraction: 0.5 + t_rx_j2000_s / crate::constants::SECONDS_PER_DAY,
1906                    t_rx_j2000_s,
1907                    observations,
1908                }
1909            })
1910            .collect();
1911        (source, epochs, ambiguities_m)
1912    }
1913
1914    fn driver_config(initial_state: KinematicState) -> KinematicConfig {
1915        KinematicConfig {
1916            initial_covariance_m2: diagonal_covariance(initial_state.dimension(), 1.0e6),
1917            initial_state,
1918            process_noise: KinematicProcessNoise {
1919                position: KinematicPositionProcessNoise::RandomWalk {
1920                    spectral_density_m2_s: 0.0,
1921                },
1922                clock_white_m2_s: 0.0,
1923                ztd_random_walk_m2_s: 0.0,
1924                ambiguity_hold_m2_s: 0.0,
1925            },
1926            weights: MeasurementWeights {
1927                code: 1.0,
1928                phase: 100.0,
1929                elevation_weighting: false,
1930            },
1931            tropo: TroposphereOptions::disabled(),
1932            corrections: RangeCorrections::disabled(),
1933            ..KinematicConfig::default()
1934        }
1935    }
1936
1937    fn float_config_from_kinematic(config: &KinematicConfig) -> super::super::FloatSolveConfig {
1938        super::super::FloatSolveConfig {
1939            weights: config.weights,
1940            tropo: config.tropo,
1941            corrections: config.corrections.clone(),
1942            opts: super::super::FloatSolveOptions {
1943                max_iterations: 20,
1944                position_tolerance_m: 1.0e-8,
1945                clock_tolerance_m: 1.0e-8,
1946                ambiguity_tolerance_m: 1.0e-8,
1947                ztd_tolerance_m: 1.0e-8,
1948            },
1949            residual_screen: false,
1950        }
1951    }
1952
1953    fn position_at(start: [f64; 3], velocity_m_s: [f64; 3], dt_s: f64) -> [f64; 3] {
1954        [
1955            start[0] + velocity_m_s[0] * dt_s,
1956            start[1] + velocity_m_s[1] * dt_s,
1957            start[2] + velocity_m_s[2] * dt_s,
1958        ]
1959    }
1960
1961    fn position_trace(covariance_m2: [[f64; 3]; 3]) -> f64 {
1962        covariance_m2[0][0] + covariance_m2[1][1] + covariance_m2[2][2]
1963    }
1964
1965    fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
1966        let dx = a[0] - b[0];
1967        let dy = a[1] - b[1];
1968        let dz = a[2] - b[2];
1969        (dx * dx + dy * dy + dz * dz).sqrt()
1970    }
1971}