Skip to main content

sidereon_core/precise_positioning/
velocity.rs

1//! Single-epoch receiver velocity range-rate model.
2//!
3//! This leaf module starts with the deterministic Doppler/range-rate geometry
4//! shared by the later least-squares driver: each row compares a measured
5//! satellite range rate with the line-of-sight projection of satellite velocity,
6//! receiver velocity, receiver clock drift, and satellite clock drift.
7//!
8//! ```
9//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
10//! use sidereon_core::precise_positioning::{
11//!     predict_range_rate_m_s, solve_velocity, ReceiverVelocityState, VelocityConfig,
12//!     VelocityObservation,
13//! };
14//! use sidereon_core::{GnssSatelliteId, GnssSystem};
15//!
16//! let receiver_position_m = [0.0, 0.0, 0.0];
17//! let receiver_velocity_m_s = [3.0, -2.0, 0.5];
18//! let clock_drift_m_s = 0.25;
19//! let satellite_positions_m = [
20//!     [20_200_000.0, 0.0, 0.0],
21//!     [0.0, 21_100_000.0, 0.0],
22//!     [0.0, 0.0, 22_300_000.0],
23//!     [-20_900_000.0, 19_700_000.0, 21_500_000.0],
24//!     [18_600_000.0, -20_400_000.0, 23_100_000.0],
25//! ];
26//! let observations = satellite_positions_m
27//!     .iter()
28//!     .enumerate()
29//!     .map(|(idx, satellite_position_m)| {
30//!         let mut observation = VelocityObservation {
31//!             sat: GnssSatelliteId::new(GnssSystem::Gps, (idx + 1) as u8)
32//!                 .expect("valid satellite id"),
33//!             satellite_position_m: *satellite_position_m,
34//!             satellite_velocity_m_s: [0.0, 0.0, 0.0],
35//!             measured_range_rate_m_s: 0.0,
36//!             sigma_m_s: 0.1,
37//!             satellite_clock_drift_m_s: 0.0,
38//!         };
39//!         observation.measured_range_rate_m_s = predict_range_rate_m_s(
40//!             &observation,
41//!             ReceiverVelocityState {
42//!                 position_m: receiver_position_m,
43//!                 velocity_m_s: receiver_velocity_m_s,
44//!                 clock_drift_m_s,
45//!             },
46//!         )
47//!         .expect("nonzero synthetic line of sight")
48//!         .range_rate_m_s;
49//!         observation
50//!     })
51//!     .collect::<Vec<_>>();
52//!
53//! let solution = solve_velocity(
54//!     &observations,
55//!     receiver_position_m,
56//!     VelocityConfig::default(),
57//! )?;
58//! assert!((solution.velocity_m_s[0] - receiver_velocity_m_s[0]).abs() < 1.0e-9);
59//! assert!((solution.clock_drift_m_s - clock_drift_m_s).abs() < 1.0e-9);
60//! # Ok(())
61//! # }
62//! ```
63
64use crate::astro::math::robust::{huber_weight, mad_scale, HUBER_K};
65use crate::astro::math::vec3::{dot3, sub3, unit3};
66use crate::estimation::recipe::NormalRecipe;
67use crate::estimation::substrate::normal::NormalAssembler;
68use crate::estimation::substrate::rows::ResidualRow;
69use crate::validate::{self, FieldError};
70use crate::GnssSatelliteId;
71
72const DEFAULT_MINIMUM_OBSERVATIONS: usize = 4;
73const DEFAULT_ROBUST_SCALE_FLOOR_M_S: f64 = 0.05;
74const DEFAULT_ROBUST_MAX_OUTER: usize = 2;
75const DEFAULT_ROBUST_OUTER_TOL_M_S: f64 = 1.0e-6;
76const VELOCITY_UNKNOWNS: usize = 4;
77const VELOCITY_NORMAL_ASSEMBLER: NormalAssembler =
78    NormalAssembler::new(NormalRecipe::PppDenseLastTie);
79
80/// Configuration for a single-epoch Doppler/range-rate velocity solve.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct VelocityConfig {
83    /// Minimum number of observations required for the four velocity unknowns.
84    pub minimum_observations: usize,
85    /// Optional Huber/MAD robust reweighting for range-rate outliers.
86    pub robust: Option<VelocityRobustConfig>,
87}
88
89impl Default for VelocityConfig {
90    fn default() -> Self {
91        Self {
92            minimum_observations: DEFAULT_MINIMUM_OBSERVATIONS,
93            robust: None,
94        }
95    }
96}
97
98/// Opt-in robust reweighting configuration for the velocity solve.
99///
100/// When [`VelocityConfig::robust`] is `Some(_)`, the solver first runs the base
101/// weighted least-squares solve, then recomputes post-fit range-rate residuals
102/// and resolves with each inverse-sigma row weight multiplied by
103/// `sqrt(huber(residual / scale))`, where `scale` is a floored MAD estimate.
104/// The default velocity solve leaves this disabled.
105#[derive(Debug, Clone, Copy, PartialEq)]
106pub struct VelocityRobustConfig {
107    /// Huber tuning constant `k`; scaled residuals below this keep full weight.
108    pub huber_k: f64,
109    /// Floor on the MAD range-rate residual scale, in meters per second.
110    pub scale_floor_m_s: f64,
111    /// Maximum total outer solves: the base solve plus reweighted resolves.
112    pub max_outer: usize,
113    /// Outer-loop velocity-and-clock step tolerance, in meters per second.
114    pub outer_tol_m_s: f64,
115}
116
117impl Default for VelocityRobustConfig {
118    fn default() -> Self {
119        Self {
120            huber_k: HUBER_K,
121            scale_floor_m_s: DEFAULT_ROBUST_SCALE_FLOOR_M_S,
122            max_outer: DEFAULT_ROBUST_MAX_OUTER,
123            outer_tol_m_s: DEFAULT_ROBUST_OUTER_TOL_M_S,
124        }
125    }
126}
127
128/// Receiver state used by the range-rate prediction model.
129#[derive(Debug, Clone, Copy, PartialEq)]
130pub struct ReceiverVelocityState {
131    /// Receiver position in the same ECEF/ECI frame as the satellite state, in meters.
132    pub position_m: [f64; 3],
133    /// Receiver velocity in the same ECEF/ECI frame as the satellite state, in meters per second.
134    pub velocity_m_s: [f64; 3],
135    /// Receiver clock drift expressed as an equivalent range-rate bias, in meters per second.
136    pub clock_drift_m_s: f64,
137}
138
139impl ReceiverVelocityState {
140    /// Construct a receiver state with zero velocity and zero clock drift.
141    pub const fn stationary_at(position_m: [f64; 3]) -> Self {
142        Self {
143            position_m,
144            velocity_m_s: [0.0; 3],
145            clock_drift_m_s: 0.0,
146        }
147    }
148}
149
150/// One Doppler-derived range-rate observation for a satellite.
151#[derive(Debug, Clone, Copy, PartialEq)]
152pub struct VelocityObservation {
153    /// Satellite identifier.
154    pub sat: GnssSatelliteId,
155    /// Satellite position in the same ECEF/ECI frame as the receiver state, in meters.
156    pub satellite_position_m: [f64; 3],
157    /// Satellite velocity in the same ECEF/ECI frame as the receiver state, in meters per second.
158    pub satellite_velocity_m_s: [f64; 3],
159    /// Measured pseudorange rate from Doppler, in meters per second.
160    pub measured_range_rate_m_s: f64,
161    /// One-sigma uncertainty of the measured range rate, in meters per second.
162    pub sigma_m_s: f64,
163    /// Satellite clock drift expressed as an equivalent range-rate bias, in meters per second.
164    pub satellite_clock_drift_m_s: f64,
165}
166
167/// Predicted range-rate geometry for one satellite observation.
168#[derive(Debug, Clone, Copy, PartialEq)]
169pub struct RangeRatePrediction {
170    /// Unit line-of-sight vector from receiver to satellite.
171    pub los_unit: [f64; 3],
172    /// Predicted range rate in meters per second.
173    pub range_rate_m_s: f64,
174}
175
176/// Receiver velocity solve result for one Doppler/range-rate epoch.
177#[derive(Debug, Clone, PartialEq)]
178pub struct VelocitySolution {
179    /// Receiver velocity in the same frame as the observations, in meters per second.
180    pub velocity_m_s: [f64; 3],
181    /// Receiver clock drift expressed as an equivalent range-rate bias, in meters per second.
182    pub clock_drift_m_s: f64,
183    /// Root-mean-square post-fit range-rate residual in meters per second.
184    pub residual_rms_m_s: f64,
185    /// Satellite identifiers retained in the least-squares solution.
186    pub used_sats: Vec<GnssSatelliteId>,
187    /// Satellite identifiers that received a robust Huber multiplier below one.
188    pub downweighted_sats: Vec<GnssSatelliteId>,
189    /// Number of robust reweighted resolves after the base solve.
190    pub robust_iterations: usize,
191    /// Final robust residual scale in meters per second, if robust reweighting ran.
192    pub robust_scale_m_s: Option<f64>,
193}
194
195/// Error returned by the Doppler/range-rate velocity least-squares solve.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum VelocitySolveError {
198    /// Fewer observations than velocity unknowns were supplied.
199    TooFewObservations {
200        /// Minimum number of observations required by the solve.
201        required: usize,
202        /// Number of observations supplied by the caller.
203        actual: usize,
204    },
205    /// A boundary input was malformed before the normal equations could be solved.
206    InvalidInput {
207        /// Name of the malformed field.
208        field: &'static str,
209        /// Stable validation reason.
210        reason: &'static str,
211    },
212    /// The design matrix does not provide full-rank velocity and clock-drift geometry.
213    SingularGeometry,
214}
215
216impl core::fmt::Display for VelocitySolveError {
217    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
218        match self {
219            Self::TooFewObservations { required, actual } => write!(
220                f,
221                "too few velocity observations: required {required}, got {actual}"
222            ),
223            Self::InvalidInput { field, reason } => {
224                write!(f, "invalid velocity input {field}: {reason}")
225            }
226            Self::SingularGeometry => {
227                write!(f, "velocity geometry is singular")
228            }
229        }
230    }
231}
232
233impl std::error::Error for VelocitySolveError {}
234
235/// Predict the range rate for one observation and receiver state.
236///
237/// Returns `None` when the receiver and satellite positions are coincident, so
238/// a line-of-sight unit vector cannot be formed.
239pub fn predict_range_rate_m_s(
240    observation: &VelocityObservation,
241    receiver: ReceiverVelocityState,
242) -> Option<RangeRatePrediction> {
243    if !finite3(observation.satellite_position_m)
244        || !finite3(observation.satellite_velocity_m_s)
245        || !finite3(receiver.position_m)
246        || !finite3(receiver.velocity_m_s)
247        || !receiver.clock_drift_m_s.is_finite()
248        || !observation.satellite_clock_drift_m_s.is_finite()
249    {
250        return None;
251    }
252    let los_unit = unit3(sub3(observation.satellite_position_m, receiver.position_m))?;
253    let relative_velocity_m_s = sub3(observation.satellite_velocity_m_s, receiver.velocity_m_s);
254    let range_rate_m_s = dot3(los_unit, relative_velocity_m_s) + receiver.clock_drift_m_s
255        - observation.satellite_clock_drift_m_s;
256    if !finite3(los_unit) || !range_rate_m_s.is_finite() {
257        return None;
258    }
259    Some(RangeRatePrediction {
260        los_unit,
261        range_rate_m_s,
262    })
263}
264
265/// Solve receiver velocity and clock drift from precomputed range-rate observations.
266///
267/// This low-level least-squares entry point expects one epoch of satellite
268/// position/velocity observations and a known receiver position. It builds the
269/// range-rate design matrix directly from the observation geometry.
270pub fn solve_velocity_least_squares(
271    observations: &[VelocityObservation],
272    receiver_position_m: [f64; 3],
273    config: VelocityConfig,
274) -> Result<VelocitySolution, VelocitySolveError> {
275    validate_velocity_config(config)?;
276    let rows = build_velocity_rows(observations, receiver_position_m, config)?;
277    let mut solution = solve_velocity_rows(&rows)?;
278    let mut residuals = postfit_residuals_m_s(&rows, solution);
279    let mut robust_iterations = 0usize;
280    let mut robust_scale_m_s = None;
281    let mut robust_multipliers = vec![1.0; rows.len()];
282
283    if let Some(robust) = config.robust {
284        for _ in 0..robust.max_outer.saturating_sub(1) {
285            let (reweighted_rows, multipliers, scale_m_s) =
286                robust_reweighted_rows(&rows, &residuals, robust)?;
287            let next_solution = solve_velocity_rows(&reweighted_rows)?;
288            let step_m_s = solution_step_norm(solution, next_solution);
289            solution = next_solution;
290            residuals = postfit_residuals_m_s(&rows, solution);
291            robust_iterations += 1;
292            robust_scale_m_s = Some(scale_m_s);
293            robust_multipliers = multipliers;
294            if step_m_s < robust.outer_tol_m_s {
295                break;
296            }
297        }
298    }
299
300    assemble_solution(
301        observations,
302        solution,
303        &residuals,
304        &robust_multipliers,
305        robust_iterations,
306        robust_scale_m_s,
307    )
308}
309
310/// Solve receiver velocity and clock drift for one epoch of range-rate observations.
311///
312/// The observation values are pseudorange rates in meters per second, such as
313/// Doppler converted with the standard GNSS sign convention. Satellite states
314/// and the supplied receiver position must be expressed in one consistent frame.
315pub fn solve_velocity(
316    observations: &[VelocityObservation],
317    receiver_position_m: [f64; 3],
318    config: VelocityConfig,
319) -> Result<VelocitySolution, VelocitySolveError> {
320    solve_velocity_least_squares(observations, receiver_position_m, config)
321}
322
323fn build_velocity_rows(
324    observations: &[VelocityObservation],
325    receiver_position_m: [f64; 3],
326    config: VelocityConfig,
327) -> Result<Vec<ResidualRow>, VelocitySolveError> {
328    let required = config.minimum_observations.max(VELOCITY_UNKNOWNS);
329    if observations.len() < required {
330        return Err(VelocitySolveError::TooFewObservations {
331            required,
332            actual: observations.len(),
333        });
334    }
335    validate::finite_vec3(receiver_position_m, "velocity receiver position_m")
336        .map_err(invalid_input)?;
337    let receiver = ReceiverVelocityState::stationary_at(receiver_position_m);
338    let mut rows = Vec::with_capacity(observations.len());
339    for observation in observations {
340        validate_observation(observation)?;
341        let sigma_m_s =
342            validate::finite_positive(observation.sigma_m_s, "velocity observation sigma_m_s")
343                .map_err(invalid_input)?;
344        let prediction = predict_range_rate_m_s(observation, receiver)
345            .ok_or(VelocitySolveError::SingularGeometry)?;
346        let y = observation.measured_range_rate_m_s - prediction.range_rate_m_s;
347        validate::finite(y, "velocity observation residual_m_s").map_err(invalid_input)?;
348        let weight = 1.0 / sigma_m_s;
349        validate::finite_positive(weight, "velocity observation weight").map_err(invalid_input)?;
350        rows.push(ResidualRow {
351            h: vec![
352                -prediction.los_unit[0],
353                -prediction.los_unit[1],
354                -prediction.los_unit[2],
355                1.0,
356            ],
357            y,
358            weight,
359        });
360    }
361    Ok(rows)
362}
363
364fn validate_velocity_config(config: VelocityConfig) -> Result<(), VelocitySolveError> {
365    if let Some(robust) = config.robust {
366        validate::finite_positive(robust.huber_k, "velocity robust huber_k")
367            .map_err(invalid_input)?;
368        validate::finite_positive(robust.scale_floor_m_s, "velocity robust scale_floor_m_s")
369            .map_err(invalid_input)?;
370        validate::finite_positive(robust.outer_tol_m_s, "velocity robust outer_tol_m_s")
371            .map_err(invalid_input)?;
372        if robust.max_outer == 0 {
373            return Err(VelocitySolveError::InvalidInput {
374                field: "velocity robust max_outer",
375                reason: "not positive",
376            });
377        }
378    }
379    Ok(())
380}
381
382fn validate_observation(observation: &VelocityObservation) -> Result<(), VelocitySolveError> {
383    validate::finite_vec3(
384        observation.satellite_position_m,
385        "velocity observation satellite_position_m",
386    )
387    .map_err(invalid_input)?;
388    validate::finite_vec3(
389        observation.satellite_velocity_m_s,
390        "velocity observation satellite_velocity_m_s",
391    )
392    .map_err(invalid_input)?;
393    validate::finite(
394        observation.measured_range_rate_m_s,
395        "velocity observation measured_range_rate_m_s",
396    )
397    .map_err(invalid_input)?;
398    validate::finite(
399        observation.satellite_clock_drift_m_s,
400        "velocity observation satellite_clock_drift_m_s",
401    )
402    .map_err(invalid_input)?;
403    Ok(())
404}
405
406fn finite3(value: [f64; 3]) -> bool {
407    value.iter().all(|x| x.is_finite())
408}
409
410fn solve_velocity_rows(rows: &[ResidualRow]) -> Result<[f64; 4], VelocitySolveError> {
411    let solution = VELOCITY_NORMAL_ASSEMBLER
412        .solve_dense_last_tie(rows.iter().map(ResidualRow::as_weighted), VELOCITY_UNKNOWNS)
413        .ok_or(VelocitySolveError::SingularGeometry)?;
414    let solution: [f64; 4] = solution
415        .try_into()
416        .map_err(|_| VelocitySolveError::SingularGeometry)?;
417    validate_finite_slice(&solution, "velocity solution")?;
418    Ok(solution)
419}
420
421fn postfit_residuals_m_s(rows: &[ResidualRow], solution: [f64; 4]) -> Vec<f64> {
422    rows.iter()
423        .map(|row| {
424            row.y
425                - (row.h[0] * solution[0]
426                    + row.h[1] * solution[1]
427                    + row.h[2] * solution[2]
428                    + row.h[3] * solution[3])
429        })
430        .collect()
431}
432
433fn robust_reweighted_rows(
434    rows: &[ResidualRow],
435    residuals_m_s: &[f64],
436    robust: VelocityRobustConfig,
437) -> Result<(Vec<ResidualRow>, Vec<f64>, f64), VelocitySolveError> {
438    let scale_m_s =
439        mad_scale(residuals_m_s, robust.scale_floor_m_s).map_err(invalid_robust_input)?;
440    let multipliers: Vec<f64> = residuals_m_s
441        .iter()
442        .map(|residual| huber_weight(residual / scale_m_s, robust.huber_k))
443        .collect();
444    validate_finite_slice(&multipliers, "velocity robust multiplier")?;
445    let rows = rows
446        .iter()
447        .zip(multipliers.iter())
448        .map(|(row, multiplier)| {
449            let mut row = row.clone();
450            row.weight *= multiplier.sqrt();
451            validate::finite_positive(row.weight, "velocity robust row weight")
452                .map_err(invalid_input)?;
453            Ok(row)
454        })
455        .collect::<Result<Vec<_>, _>>()?;
456    Ok((rows, multipliers, scale_m_s))
457}
458
459fn solution_step_norm(previous: [f64; 4], next: [f64; 4]) -> f64 {
460    ((next[0] - previous[0]).powi(2)
461        + (next[1] - previous[1]).powi(2)
462        + (next[2] - previous[2]).powi(2)
463        + (next[3] - previous[3]).powi(2))
464    .sqrt()
465}
466
467fn assemble_solution(
468    observations: &[VelocityObservation],
469    solution: [f64; 4],
470    residuals_m_s: &[f64],
471    robust_multipliers: &[f64],
472    robust_iterations: usize,
473    robust_scale_m_s: Option<f64>,
474) -> Result<VelocitySolution, VelocitySolveError> {
475    validate_finite_slice(&solution, "velocity solution")?;
476    validate_finite_slice(residuals_m_s, "velocity residual_m_s")?;
477    validate_finite_slice(robust_multipliers, "velocity robust multiplier")?;
478    if let Some(scale_m_s) = robust_scale_m_s {
479        validate::finite_positive(scale_m_s, "velocity robust scale_m_s").map_err(invalid_input)?;
480    }
481    let residual_rms_m_s = rms(residuals_m_s);
482    validate::finite(residual_rms_m_s, "velocity residual_rms_m_s").map_err(invalid_input)?;
483    Ok(VelocitySolution {
484        velocity_m_s: [solution[0], solution[1], solution[2]],
485        clock_drift_m_s: solution[3],
486        residual_rms_m_s,
487        used_sats: observations.iter().map(|obs| obs.sat).collect(),
488        downweighted_sats: observations
489            .iter()
490            .zip(robust_multipliers.iter())
491            .filter_map(|(observation, multiplier)| (*multiplier < 1.0).then_some(observation.sat))
492            .collect(),
493        robust_iterations,
494        robust_scale_m_s,
495    })
496}
497
498fn rms(values: &[f64]) -> f64 {
499    if values.is_empty() {
500        return 0.0;
501    }
502    (values.iter().map(|value| value * value).sum::<f64>() / values.len() as f64).sqrt()
503}
504
505fn validate_finite_slice(values: &[f64], field: &'static str) -> Result<(), VelocitySolveError> {
506    for value in values {
507        validate::finite(*value, field).map_err(invalid_input)?;
508    }
509    Ok(())
510}
511
512fn invalid_input(error: FieldError) -> VelocitySolveError {
513    VelocitySolveError::InvalidInput {
514        field: error.field(),
515        reason: error.reason(),
516    }
517}
518
519fn invalid_robust_input(error: crate::astro::math::robust::RobustError) -> VelocitySolveError {
520    VelocitySolveError::InvalidInput {
521        field: error.field(),
522        reason: error.reason(),
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::GnssSystem;
530
531    fn sat() -> GnssSatelliteId {
532        GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id")
533    }
534
535    fn sat_prn(prn: u8) -> GnssSatelliteId {
536        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
537    }
538
539    fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
540        a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
541    }
542
543    fn synthetic_observations(
544        receiver_position_m: [f64; 3],
545        receiver_velocity_m_s: [f64; 3],
546        clock_drift_m_s: f64,
547    ) -> Vec<VelocityObservation> {
548        let satellite_positions_m = [
549            [20_200_000.0, 0.0, 0.0],
550            [0.0, 21_100_000.0, 0.0],
551            [0.0, 0.0, 22_300_000.0],
552            [-20_900_000.0, 19_700_000.0, 21_500_000.0],
553            [18_600_000.0, -20_400_000.0, 23_100_000.0],
554            [-18_800_000.0, -21_200_000.0, 19_400_000.0],
555            [21_900_000.0, 18_300_000.0, -20_700_000.0],
556            [-22_100_000.0, 17_900_000.0, -18_500_000.0],
557        ];
558        let satellite_velocities_m_s = [
559            [120.0, -40.0, 30.0],
560            [-80.0, 90.0, 10.0],
561            [20.0, -70.0, 105.0],
562            [35.0, 45.0, -95.0],
563            [-55.0, 65.0, 75.0],
564            [70.0, -85.0, 40.0],
565            [-25.0, -45.0, 95.0],
566            [45.0, 55.0, -65.0],
567        ];
568        satellite_positions_m
569            .into_iter()
570            .zip(satellite_velocities_m_s)
571            .enumerate()
572            .map(|(idx, (satellite_position_m, satellite_velocity_m_s))| {
573                let satellite_clock_drift_m_s = 0.01 * idx as f64;
574                let mut observation = VelocityObservation {
575                    sat: sat_prn((idx + 1) as u8),
576                    satellite_position_m,
577                    satellite_velocity_m_s,
578                    measured_range_rate_m_s: 0.0,
579                    sigma_m_s: 0.1 + 0.05 * idx as f64,
580                    satellite_clock_drift_m_s,
581                };
582                observation.measured_range_rate_m_s = predict_range_rate_m_s(
583                    &observation,
584                    ReceiverVelocityState {
585                        position_m: receiver_position_m,
586                        velocity_m_s: receiver_velocity_m_s,
587                        clock_drift_m_s,
588                    },
589                )
590                .expect("synthetic line of sight")
591                .range_rate_m_s;
592                observation
593            })
594            .collect()
595    }
596
597    #[test]
598    fn velocity_types_construct() {
599        let config = VelocityConfig::default();
600        assert_eq!(config.minimum_observations, 4);
601        assert_eq!(config.robust, None);
602
603        let robust = VelocityRobustConfig::default();
604        assert_eq!(robust.huber_k, HUBER_K);
605        assert_eq!(robust.max_outer, 2);
606
607        let receiver = ReceiverVelocityState {
608            position_m: [1.0, 2.0, 3.0],
609            velocity_m_s: [0.1, -0.2, 0.3],
610            clock_drift_m_s: 0.4,
611        };
612        assert_eq!(receiver.position_m, [1.0, 2.0, 3.0]);
613        assert_eq!(
614            ReceiverVelocityState::stationary_at([4.0, 5.0, 6.0]),
615            ReceiverVelocityState {
616                position_m: [4.0, 5.0, 6.0],
617                velocity_m_s: [0.0; 3],
618                clock_drift_m_s: 0.0,
619            }
620        );
621
622        let observation = VelocityObservation {
623            sat: sat(),
624            satellite_position_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
625            satellite_velocity_m_s: [120.0, -30.0, 80.0],
626            measured_range_rate_m_s: -425.0,
627            sigma_m_s: 0.08,
628            satellite_clock_drift_m_s: 0.02,
629        };
630        assert_eq!(observation.sat, sat());
631        assert_eq!(observation.sigma_m_s, 0.08);
632    }
633
634    #[test]
635    fn predicted_range_rate_zero_receiver_matches_los_projected_satellite_velocity() {
636        let observation = VelocityObservation {
637            sat: sat(),
638            satellite_position_m: [3.0, 4.0, 0.0],
639            satellite_velocity_m_s: [6.0, 8.0, 10.0],
640            measured_range_rate_m_s: 0.0,
641            sigma_m_s: 1.0,
642            satellite_clock_drift_m_s: 0.0,
643        };
644        let receiver = ReceiverVelocityState::stationary_at([0.0, 0.0, 0.0]);
645
646        let prediction =
647            predict_range_rate_m_s(&observation, receiver).expect("nonzero line of sight");
648
649        let expected = (3.0 / 5.0) * observation.satellite_velocity_m_s[0]
650            + (4.0 / 5.0) * observation.satellite_velocity_m_s[1];
651        assert!((prediction.range_rate_m_s - expected).abs() < 1.0e-12);
652        assert!((prediction.los_unit[0] - 3.0 / 5.0).abs() < 1.0e-12);
653        assert!((prediction.los_unit[1] - 4.0 / 5.0).abs() < 1.0e-12);
654        assert_eq!(prediction.los_unit[2], 0.0);
655    }
656
657    #[test]
658    fn predicted_range_rate_rejects_non_finite_state() {
659        let observation = VelocityObservation {
660            sat: sat(),
661            satellite_position_m: [f64::NAN, 4.0, 0.0],
662            satellite_velocity_m_s: [6.0, 8.0, 10.0],
663            measured_range_rate_m_s: 0.0,
664            sigma_m_s: 1.0,
665            satellite_clock_drift_m_s: 0.0,
666        };
667        let receiver = ReceiverVelocityState::stationary_at([0.0, 0.0, 0.0]);
668
669        assert_eq!(predict_range_rate_m_s(&observation, receiver), None);
670    }
671
672    #[test]
673    fn design_row_matches_velocity_linearization() {
674        let observation = VelocityObservation {
675            sat: sat(),
676            satellite_position_m: [3.0, 4.0, 0.0],
677            satellite_velocity_m_s: [6.0, 8.0, 10.0],
678            measured_range_rate_m_s: 42.0,
679            sigma_m_s: 0.25,
680            satellite_clock_drift_m_s: 0.5,
681        };
682
683        let rows = build_velocity_rows(
684            &[observation; 4],
685            [0.0, 0.0, 0.0],
686            VelocityConfig::default(),
687        )
688        .expect("rows");
689
690        assert!((rows[0].h[0] + 3.0 / 5.0).abs() < 1.0e-15);
691        assert!((rows[0].h[1] + 4.0 / 5.0).abs() < 1.0e-15);
692        assert_eq!(rows[0].h[2], -0.0);
693        assert_eq!(rows[0].h[3], 1.0);
694        let predicted_at_zero = dot(
695            [3.0 / 5.0, 4.0 / 5.0, 0.0],
696            observation.satellite_velocity_m_s,
697        ) - observation.satellite_clock_drift_m_s;
698        assert!(
699            (rows[0].y - (observation.measured_range_rate_m_s - predicted_at_zero)).abs() < 1.0e-12
700        );
701        assert_eq!(rows[0].weight, 4.0);
702    }
703
704    #[test]
705    fn weighted_least_squares_recovers_clean_synthetic_velocity() {
706        let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
707        let receiver_velocity_m_s = [12.25, -3.5, 0.75];
708        let clock_drift_m_s = -0.35;
709        let observations =
710            synthetic_observations(receiver_position_m, receiver_velocity_m_s, clock_drift_m_s);
711
712        let solution = solve_velocity_least_squares(
713            &observations,
714            receiver_position_m,
715            VelocityConfig::default(),
716        )
717        .expect("velocity solution");
718
719        for (got, expected) in solution
720            .velocity_m_s
721            .iter()
722            .zip(receiver_velocity_m_s.iter())
723        {
724            assert!((got - expected).abs() < 1.0e-9, "{got} != {expected}");
725        }
726        assert!((solution.clock_drift_m_s - clock_drift_m_s).abs() < 1.0e-9);
727        assert!(solution.residual_rms_m_s < 1.0e-9);
728        assert_eq!(solution.used_sats.len(), observations.len());
729        assert_eq!(solution.used_sats[0], sat_prn(1));
730        assert_eq!(solution.downweighted_sats, Vec::<GnssSatelliteId>::new());
731        assert_eq!(solution.robust_iterations, 0);
732        assert_eq!(solution.robust_scale_m_s, None);
733    }
734
735    #[test]
736    fn singular_or_underdetermined_geometry_returns_error() {
737        let receiver_position_m = [0.0, 0.0, 0.0];
738        let mut observations = synthetic_observations(receiver_position_m, [1.0, 2.0, 3.0], 0.4);
739        let too_few = observations[..3].to_vec();
740        assert_eq!(
741            solve_velocity_least_squares(&too_few, receiver_position_m, VelocityConfig::default()),
742            Err(VelocitySolveError::TooFewObservations {
743                required: 4,
744                actual: 3
745            })
746        );
747
748        for (idx, observation) in observations.iter_mut().take(4).enumerate() {
749            observation.satellite_position_m = [20_000_000.0 + idx as f64, 0.0, 0.0];
750        }
751
752        assert_eq!(
753            solve_velocity_least_squares(
754                &observations[..4],
755                receiver_position_m,
756                VelocityConfig::default()
757            ),
758            Err(VelocitySolveError::SingularGeometry)
759        );
760    }
761
762    #[test]
763    fn rejects_non_finite_velocity_inputs() {
764        let receiver_position_m = [0.0, 0.0, 0.0];
765        let mut observations = synthetic_observations(receiver_position_m, [1.0, 2.0, 3.0], 0.4);
766        observations[0].measured_range_rate_m_s = f64::NAN;
767
768        assert_eq!(
769            solve_velocity_least_squares(
770                &observations,
771                receiver_position_m,
772                VelocityConfig::default()
773            ),
774            Err(VelocitySolveError::InvalidInput {
775                field: "velocity observation measured_range_rate_m_s",
776                reason: "not finite"
777            })
778        );
779
780        observations[0].measured_range_rate_m_s = 0.0;
781        observations[0].sigma_m_s = 0.0;
782        assert_eq!(
783            solve_velocity_least_squares(
784                &observations,
785                receiver_position_m,
786                VelocityConfig::default()
787            ),
788            Err(VelocitySolveError::InvalidInput {
789                field: "velocity observation sigma_m_s",
790                reason: "not positive"
791            })
792        );
793
794        observations[0].sigma_m_s = f64::from_bits(1);
795        assert_eq!(
796            solve_velocity_least_squares(
797                &observations,
798                receiver_position_m,
799                VelocityConfig::default()
800            ),
801            Err(VelocitySolveError::InvalidInput {
802                field: "velocity observation weight",
803                reason: "not finite"
804            })
805        );
806        observations[0].sigma_m_s = 0.1;
807
808        let mut config = VelocityConfig {
809            robust: Some(VelocityRobustConfig {
810                huber_k: f64::INFINITY,
811                ..VelocityRobustConfig::default()
812            }),
813            ..VelocityConfig::default()
814        };
815        assert_eq!(
816            solve_velocity_least_squares(&observations, receiver_position_m, config),
817            Err(VelocitySolveError::InvalidInput {
818                field: "velocity robust huber_k",
819                reason: "not finite"
820            })
821        );
822
823        config.robust = Some(VelocityRobustConfig {
824            scale_floor_m_s: -1.0,
825            ..VelocityRobustConfig::default()
826        });
827        assert_eq!(
828            solve_velocity_least_squares(&observations, receiver_position_m, config),
829            Err(VelocitySolveError::InvalidInput {
830                field: "velocity robust scale_floor_m_s",
831                reason: "not positive"
832            })
833        );
834    }
835
836    #[test]
837    fn robust_velocity_rejects_zero_outer_limit_only_when_enabled() {
838        let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
839        let observations = synthetic_observations(receiver_position_m, [1.0, 2.0, 3.0], 0.4);
840
841        let mut robust = VelocityRobustConfig {
842            max_outer: 0,
843            ..VelocityRobustConfig::default()
844        };
845        assert_eq!(
846            solve_velocity_least_squares(
847                &observations,
848                receiver_position_m,
849                VelocityConfig {
850                    robust: Some(robust),
851                    ..VelocityConfig::default()
852                }
853            ),
854            Err(VelocitySolveError::InvalidInput {
855                field: "velocity robust max_outer",
856                reason: "not positive"
857            })
858        );
859
860        robust.max_outer = 1;
861        let robust_solution = solve_velocity_least_squares(
862            &observations,
863            receiver_position_m,
864            VelocityConfig {
865                robust: Some(robust),
866                ..VelocityConfig::default()
867            },
868        )
869        .expect("max_outer=1 robust velocity solution");
870        assert_eq!(robust_solution.robust_iterations, 0);
871
872        let non_robust = solve_velocity_least_squares(
873            &observations,
874            receiver_position_m,
875            VelocityConfig::default(),
876        )
877        .expect("non-robust velocity solution");
878        assert_eq!(non_robust.robust_iterations, 0);
879    }
880
881    #[test]
882    fn robust_reweighting_downweights_doppler_outlier() {
883        let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
884        let receiver_velocity_m_s = [12.25, -3.5, 0.75];
885        let clock_drift_m_s = -0.35;
886        let observations =
887            synthetic_observations(receiver_position_m, receiver_velocity_m_s, clock_drift_m_s);
888        let clean = solve_velocity_least_squares(
889            &observations,
890            receiver_position_m,
891            VelocityConfig::default(),
892        )
893        .expect("clean solution");
894
895        let mut corrupted = observations.clone();
896        let outlier_sat = corrupted[3].sat;
897        corrupted[3].measured_range_rate_m_s += 75.0;
898
899        let bare = solve_velocity_least_squares(
900            &corrupted,
901            receiver_position_m,
902            VelocityConfig::default(),
903        )
904        .expect("bare outlier solution");
905        let robust = solve_velocity_least_squares(
906            &corrupted,
907            receiver_position_m,
908            VelocityConfig {
909                robust: Some(VelocityRobustConfig {
910                    scale_floor_m_s: 0.05,
911                    max_outer: 5,
912                    outer_tol_m_s: 1.0e-12,
913                    ..VelocityRobustConfig::default()
914                }),
915                ..VelocityConfig::default()
916            },
917        )
918        .expect("robust outlier solution");
919
920        let bare_error = velocity_error_m_s(&bare.velocity_m_s, &clean.velocity_m_s);
921        let robust_error = velocity_error_m_s(&robust.velocity_m_s, &clean.velocity_m_s);
922        assert!(
923            robust_error < bare_error,
924            "robust_error={robust_error}, bare_error={bare_error}"
925        );
926        assert!(
927            robust_error < 1.0,
928            "robust velocity should stay close to truth, got {robust_error}"
929        );
930        assert!(robust.downweighted_sats.contains(&outlier_sat));
931        assert!(robust.robust_iterations >= 1);
932        assert!(robust.robust_scale_m_s.is_some());
933    }
934
935    #[test]
936    fn robust_reweighting_applies_sqrt_huber_multiplier_to_velocity_rows() {
937        let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
938        let receiver_velocity_m_s = [12.25, -3.5, 0.75];
939        let clock_drift_m_s = -0.35;
940        let mut observations =
941            synthetic_observations(receiver_position_m, receiver_velocity_m_s, clock_drift_m_s);
942        observations[3].measured_range_rate_m_s += 75.0;
943
944        let robust = VelocityRobustConfig {
945            scale_floor_m_s: 0.05,
946            max_outer: 2,
947            outer_tol_m_s: 1.0e-12,
948            ..VelocityRobustConfig::default()
949        };
950        let rows = build_velocity_rows(
951            &observations,
952            receiver_position_m,
953            VelocityConfig {
954                robust: Some(robust),
955                ..VelocityConfig::default()
956            },
957        )
958        .expect("velocity rows");
959        let base_solution = solve_velocity_rows(&rows).expect("base velocity solution");
960        let residuals = postfit_residuals_m_s(&rows, base_solution);
961        let scale_m_s =
962            mad_scale(&residuals, robust.scale_floor_m_s).expect("valid robust residual scale");
963        let multipliers: Vec<f64> = residuals
964            .iter()
965            .map(|residual| huber_weight(residual / scale_m_s, robust.huber_k))
966            .collect();
967        assert!(multipliers.iter().any(|multiplier| *multiplier < 1.0));
968
969        let intended_rows = scaled_rows(&rows, &multipliers, f64::sqrt);
970        let squared_weight_rows = scaled_rows(&rows, &multipliers, core::convert::identity);
971        let intended_solution =
972            solve_velocity_rows(&intended_rows).expect("intended robust solution");
973        let squared_weight_solution =
974            solve_velocity_rows(&squared_weight_rows).expect("squared-weight solution");
975
976        let robust_solution = solve_velocity_least_squares(
977            &observations,
978            receiver_position_m,
979            VelocityConfig {
980                robust: Some(robust),
981                ..VelocityConfig::default()
982            },
983        )
984        .expect("public robust solution");
985
986        assert_eq!(robust_solution.robust_iterations, 1);
987        assert_solution_close(
988            solution_vector(&robust_solution),
989            intended_solution,
990            1.0e-10,
991        );
992        assert!(
993            solution_delta(solution_vector(&robust_solution), squared_weight_solution) > 1.0e-3,
994            "sqrt-Huber solution should differ from the old squared-weight behavior"
995        );
996    }
997
998    #[test]
999    fn public_solve_velocity_driver_matches_low_level_solve() {
1000        let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
1001        let observations = synthetic_observations(receiver_position_m, [1.0, 2.0, 3.0], 0.4);
1002
1003        assert_eq!(
1004            solve_velocity(
1005                &observations,
1006                receiver_position_m,
1007                VelocityConfig::default()
1008            ),
1009            solve_velocity_least_squares(
1010                &observations,
1011                receiver_position_m,
1012                VelocityConfig::default()
1013            )
1014        );
1015    }
1016
1017    fn velocity_error_m_s(got: &[f64; 3], expected: &[f64; 3]) -> f64 {
1018        ((got[0] - expected[0]).powi(2)
1019            + (got[1] - expected[1]).powi(2)
1020            + (got[2] - expected[2]).powi(2))
1021        .sqrt()
1022    }
1023
1024    fn scaled_rows(
1025        rows: &[ResidualRow],
1026        multipliers: &[f64],
1027        scale_multiplier: impl Fn(f64) -> f64,
1028    ) -> Vec<ResidualRow> {
1029        rows.iter()
1030            .zip(multipliers)
1031            .map(|(row, multiplier)| {
1032                let mut row = row.clone();
1033                row.weight *= scale_multiplier(*multiplier);
1034                row
1035            })
1036            .collect()
1037    }
1038
1039    fn solution_vector(solution: &VelocitySolution) -> [f64; 4] {
1040        [
1041            solution.velocity_m_s[0],
1042            solution.velocity_m_s[1],
1043            solution.velocity_m_s[2],
1044            solution.clock_drift_m_s,
1045        ]
1046    }
1047
1048    fn assert_solution_close(actual: [f64; 4], expected: [f64; 4], tolerance: f64) {
1049        for idx in 0..4 {
1050            assert!(
1051                (actual[idx] - expected[idx]).abs() <= tolerance,
1052                "solution[{idx}] {} != {}",
1053                actual[idx],
1054                expected[idx]
1055            );
1056        }
1057    }
1058
1059    fn solution_delta(a: [f64; 4], b: [f64; 4]) -> f64 {
1060        ((a[0] - b[0]).powi(2)
1061            + (a[1] - b[1]).powi(2)
1062            + (a[2] - b[2]).powi(2)
1063            + (a[3] - b[3]).powi(2))
1064        .sqrt()
1065    }
1066}