Skip to main content

sidereon_core/
velocity.rs

1//! Receiver velocity and clock-drift solve from GNSS range-rate observations.
2//!
3//! This module owns the language-independent inverse of the observable range
4//! rate model. The caller supplies a known receiver position plus one epoch of
5//! pseudorange-rate or Doppler observations; the core builds the deterministic
6//! normal equations and returns receiver velocity, clock drift, residuals, and
7//! used-satellite ordering.
8
9use std::collections::BTreeSet;
10
11use crate::astro::math::linear::{
12    dot4, invert_4x4_cofactor, mat4_vec4, normal_matrix_4_unweighted_row_outer,
13};
14use crate::astro::math::vec3;
15
16use crate::constants::{C_M_S, F_L1_HZ};
17use crate::id::GnssSatelliteId;
18use crate::observables::{predict, ObservableEphemerisSource, PredictOptions};
19
20/// Observation value convention for [`solve`].
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum VelocityObservable {
23    /// Observation values are pseudorange rates in meters per second.
24    RangeRate,
25    /// Observation values are Doppler shifts in hertz and will be converted
26    /// with the observation's `carrier_hz`.
27    Doppler,
28}
29
30/// One satellite observation for the velocity solve.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct VelocityObservation {
33    /// Satellite identifier.
34    pub satellite_id: GnssSatelliteId,
35    /// Pseudorange rate in m/s or Doppler in Hz, depending on
36    /// [`VelocitySolveOptions::observable`].
37    pub value: f64,
38    /// Carrier frequency in hertz. Used only for Doppler observations.
39    pub carrier_hz: f64,
40    /// Satellite clock drift in seconds per second.
41    pub sat_clock_drift_s_s: f64,
42}
43
44/// Options controlling the velocity solve.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct VelocitySolveOptions {
47    /// Observation value convention.
48    pub observable: VelocityObservable,
49    /// Apply fixed-point light-time correction in the geometry substrate.
50    pub light_time: bool,
51    /// Apply Earth-rotation Sagnac correction in the geometry substrate.
52    pub sagnac: bool,
53}
54
55impl Default for VelocitySolveOptions {
56    fn default() -> Self {
57        Self {
58            observable: VelocityObservable::RangeRate,
59            light_time: true,
60            sagnac: true,
61        }
62    }
63}
64
65/// Receiver velocity solve result.
66#[derive(Debug, Clone, PartialEq)]
67#[non_exhaustive]
68pub struct VelocitySolution {
69    /// Receiver ECEF velocity in meters per second.
70    pub velocity_m_s: [f64; 3],
71    /// Receiver speed in meters per second.
72    pub speed_m_s: f64,
73    /// Receiver clock drift in seconds per second.
74    pub clock_drift_s_s: f64,
75    /// Unit-variance covariance of `[vx, vy, vz, clock_drift]`.
76    ///
77    /// The first three states are ECEF velocity in metres per second. The final
78    /// state is receiver clock drift in seconds per second. Scale this matrix by
79    /// the range-rate observation variance when all rows share a common
80    /// standard deviation.
81    pub state_covariance: [[f64; 4]; 4],
82    /// Post-fit range-rate residuals in meters per second, in `used_sats` order.
83    pub residuals_m_s: Vec<(GnssSatelliteId, f64)>,
84    /// Satellites contributing rows, in input order after unusable geometry is
85    /// dropped.
86    pub used_sats: Vec<GnssSatelliteId>,
87}
88
89/// Error returned by the velocity solve.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum VelocityError {
92    /// No observation entries were supplied.
93    NoObservations,
94    /// Fewer than four usable satellites remained after geometry lookup.
95    TooFewSatellites { used: usize, required: usize },
96    /// The 4x4 normal matrix is singular.
97    SingularGeometry,
98    /// A satellite appears more than once in the input observations.
99    DuplicateObservation { satellite_id: GnssSatelliteId },
100    /// Doppler conversion needs a positive finite carrier frequency.
101    InvalidCarrier { satellite_id: GnssSatelliteId },
102    /// A scalar conversion helper received a malformed input.
103    InvalidInput {
104        field: &'static str,
105        reason: &'static str,
106    },
107    /// An observation carries a non-finite measurement or satellite-clock drift.
108    InvalidObservation { satellite_id: GnssSatelliteId },
109    /// The receiver state or receive epoch is non-finite.
110    InvalidReceiverState,
111}
112
113impl core::fmt::Display for VelocityError {
114    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
115        match self {
116            Self::NoObservations => write!(f, "no observations"),
117            Self::TooFewSatellites { used, required } => {
118                write!(f, "too few satellites: {used}, required {required}")
119            }
120            Self::SingularGeometry => write!(f, "singular geometry"),
121            Self::DuplicateObservation { satellite_id } => {
122                write!(f, "duplicate observation for {satellite_id}")
123            }
124            Self::InvalidCarrier { satellite_id } => {
125                write!(f, "invalid carrier for {satellite_id}")
126            }
127            Self::InvalidInput { field, reason } => {
128                write!(f, "invalid velocity input {field}: {reason}")
129            }
130            Self::InvalidObservation { satellite_id } => {
131                write!(f, "invalid observation for {satellite_id}")
132            }
133            Self::InvalidReceiverState => write!(f, "invalid receiver state"),
134        }
135    }
136}
137
138impl std::error::Error for VelocityError {}
139
140#[derive(Debug, Clone, Copy)]
141struct Row {
142    sat: GnssSatelliteId,
143    h: [f64; 4],
144    y: f64,
145}
146
147/// Convert a Doppler shift in hertz to pseudorange rate in meters per second.
148pub fn doppler_to_range_rate(doppler_hz: f64, carrier_hz: f64) -> Result<f64, VelocityError> {
149    let doppler_hz = velocity_finite(doppler_hz, "doppler_hz")?;
150    let carrier_hz = velocity_positive(carrier_hz, "carrier_hz")?;
151    velocity_finite_output(-doppler_hz * C_M_S / carrier_hz, "range_rate_m_s")
152}
153
154/// Convert a pseudorange rate in meters per second to Doppler shift in hertz.
155pub fn range_rate_to_doppler(range_rate_m_s: f64, carrier_hz: f64) -> Result<f64, VelocityError> {
156    let range_rate_m_s = velocity_finite(range_rate_m_s, "range_rate_m_s")?;
157    let carrier_hz = velocity_positive(carrier_hz, "carrier_hz")?;
158    velocity_finite_output(-range_rate_m_s * carrier_hz / C_M_S, "doppler_hz")
159}
160
161/// Solve receiver velocity and clock drift from one epoch of observations.
162pub fn solve(
163    source: &dyn ObservableEphemerisSource,
164    observations: &[VelocityObservation],
165    receiver_ecef_m: [f64; 3],
166    t_rx_j2000_s: f64,
167    options: VelocitySolveOptions,
168) -> Result<VelocitySolution, VelocityError> {
169    if observations.is_empty() {
170        return Err(VelocityError::NoObservations);
171    }
172
173    validate_receiver_state(receiver_ecef_m, t_rx_j2000_s)?;
174    ensure_no_duplicates(observations)?;
175    validate_observations(observations)?;
176    let rows = build_rows(source, observations, receiver_ecef_m, t_rx_j2000_s, options)?;
177    if rows.len() < 4 {
178        return Err(VelocityError::TooFewSatellites {
179            used: rows.len(),
180            required: 4,
181        });
182    }
183
184    let (x, normal_inverse) = solve_normal_equations(&rows)?;
185    assemble_solution(x, normal_inverse, &rows)
186}
187
188fn validate_receiver_state(
189    receiver_ecef_m: [f64; 3],
190    t_rx_j2000_s: f64,
191) -> Result<(), VelocityError> {
192    if receiver_ecef_m.iter().all(|value| value.is_finite()) && t_rx_j2000_s.is_finite() {
193        Ok(())
194    } else {
195        Err(VelocityError::InvalidReceiverState)
196    }
197}
198
199fn ensure_no_duplicates(observations: &[VelocityObservation]) -> Result<(), VelocityError> {
200    let mut seen = BTreeSet::new();
201    for obs in observations {
202        if !seen.insert(obs.satellite_id) {
203            return Err(VelocityError::DuplicateObservation {
204                satellite_id: obs.satellite_id,
205            });
206        }
207    }
208    Ok(())
209}
210
211fn validate_observations(observations: &[VelocityObservation]) -> Result<(), VelocityError> {
212    for obs in observations {
213        if !(obs.value.is_finite() && obs.sat_clock_drift_s_s.is_finite()) {
214            return Err(VelocityError::InvalidObservation {
215                satellite_id: obs.satellite_id,
216            });
217        }
218    }
219    Ok(())
220}
221
222fn velocity_finite(x: f64, field: &'static str) -> Result<f64, VelocityError> {
223    if x.is_finite() {
224        Ok(x)
225    } else {
226        Err(VelocityError::InvalidInput {
227            field,
228            reason: "not finite",
229        })
230    }
231}
232
233fn velocity_positive(x: f64, field: &'static str) -> Result<f64, VelocityError> {
234    let x = velocity_finite(x, field)?;
235    if x > 0.0 {
236        Ok(x)
237    } else {
238        Err(VelocityError::InvalidInput {
239            field,
240            reason: "not positive",
241        })
242    }
243}
244
245fn velocity_finite_output(value: f64, field: &'static str) -> Result<f64, VelocityError> {
246    if value.is_finite() {
247        Ok(value)
248    } else {
249        Err(VelocityError::InvalidInput {
250            field,
251            reason: "out of range",
252        })
253    }
254}
255
256fn build_rows(
257    source: &dyn ObservableEphemerisSource,
258    observations: &[VelocityObservation],
259    receiver_ecef_m: [f64; 3],
260    t_rx_j2000_s: f64,
261    options: VelocitySolveOptions,
262) -> Result<Vec<Row>, VelocityError> {
263    let predict_options = PredictOptions {
264        carrier_hz: F_L1_HZ,
265        light_time: options.light_time,
266        sagnac: options.sagnac,
267    };
268    let mut rows = Vec::with_capacity(observations.len());
269
270    for obs in observations {
271        let rho_dot_m_s = match options.observable {
272            VelocityObservable::RangeRate => obs.value,
273            VelocityObservable::Doppler => {
274                if !(obs.carrier_hz.is_finite() && obs.carrier_hz > 0.0) {
275                    return Err(VelocityError::InvalidCarrier {
276                        satellite_id: obs.satellite_id,
277                    });
278                }
279                doppler_to_range_rate(obs.value, obs.carrier_hz).map_err(|error| match error {
280                    VelocityError::InvalidInput {
281                        field: "carrier_hz",
282                        ..
283                    } => VelocityError::InvalidCarrier {
284                        satellite_id: obs.satellite_id,
285                    },
286                    _ => VelocityError::InvalidObservation {
287                        satellite_id: obs.satellite_id,
288                    },
289                })?
290            }
291        };
292
293        let Ok(predicted) = predict(
294            source,
295            obs.satellite_id,
296            receiver_ecef_m,
297            t_rx_j2000_s,
298            predict_options,
299        ) else {
300            continue;
301        };
302
303        let [ex, ey, ez] = predicted.los_unit;
304        let y = rho_dot_m_s - predicted.range_rate_m_s + C_M_S * obs.sat_clock_drift_s_s;
305        if ![ex, ey, ez, predicted.range_rate_m_s, y]
306            .iter()
307            .all(|value| value.is_finite())
308        {
309            return Err(VelocityError::InvalidInput {
310                field: "velocity row",
311                reason: "out of range",
312            });
313        }
314        rows.push(Row {
315            sat: obs.satellite_id,
316            h: [-ex, -ey, -ez, 1.0],
317            y,
318        });
319    }
320
321    Ok(rows)
322}
323
324#[allow(clippy::needless_range_loop)] // Index loops pin the normal-equation accumulation order.
325fn solve_normal_equations(rows: &[Row]) -> Result<([f64; 4], [[f64; 4]; 4]), VelocityError> {
326    let mut aty = [0.0_f64; 4];
327
328    for row in rows {
329        for i in 0..4 {
330            aty[i] += row.h[i] * row.y;
331        }
332    }
333    let row_h: Vec<[f64; 4]> = rows.iter().map(|row| row.h).collect();
334    let ata = normal_matrix_4_unweighted_row_outer(&row_h);
335
336    let inv = invert_4x4_cofactor(&ata).ok_or(VelocityError::SingularGeometry)?;
337    let solution = mat4_vec4(&inv, &aty);
338    if solution.iter().all(|value| value.is_finite()) {
339        Ok((solution, inv))
340    } else {
341        Err(VelocityError::InvalidInput {
342            field: "velocity solution",
343            reason: "out of range",
344        })
345    }
346}
347
348fn assemble_solution(
349    x: [f64; 4],
350    normal_inverse: [[f64; 4]; 4],
351    rows: &[Row],
352) -> Result<VelocitySolution, VelocityError> {
353    let velocity_m_s = [x[0], x[1], x[2]];
354    let speed_m_s = vec3::norm3(velocity_m_s);
355    let clock_drift_s_s = x[3] / C_M_S;
356    let state_covariance = velocity_state_covariance(normal_inverse);
357    let residuals_m_s: Vec<_> = rows
358        .iter()
359        .map(|row| (row.sat, row.y - hx(&row.h, &x)))
360        .collect();
361    if !velocity_m_s.iter().all(|value| value.is_finite())
362        || !speed_m_s.is_finite()
363        || !clock_drift_s_s.is_finite()
364        || !state_covariance
365            .iter()
366            .flatten()
367            .all(|value| value.is_finite())
368        || !residuals_m_s
369            .iter()
370            .all(|(_, residual)| residual.is_finite())
371    {
372        return Err(VelocityError::InvalidInput {
373            field: "velocity solution",
374            reason: "out of range",
375        });
376    }
377    let used_sats = rows.iter().map(|row| row.sat).collect();
378    Ok(VelocitySolution {
379        velocity_m_s,
380        speed_m_s,
381        clock_drift_s_s,
382        state_covariance,
383        residuals_m_s,
384        used_sats,
385    })
386}
387
388fn velocity_state_covariance(normal_inverse: [[f64; 4]; 4]) -> [[f64; 4]; 4] {
389    let scale = [1.0, 1.0, 1.0, 1.0 / C_M_S];
390    let mut out = [[0.0_f64; 4]; 4];
391    for i in 0..4 {
392        for j in 0..4 {
393            out[i][j] = normal_inverse[i][j] * scale[i] * scale[j];
394        }
395    }
396    out
397}
398
399fn hx(h: &[f64; 4], x: &[f64; 4]) -> f64 {
400    dot4(h, x)
401}
402
403#[cfg(all(test, sidereon_repo_tests))]
404mod tests {
405    use super::*;
406    use crate::ephemeris::Sp3;
407    use crate::observables::{
408        j2000_seconds_from_split, predict, ObservableState, ObservablesError,
409    };
410    use crate::{GnssSatelliteId, GnssSystem};
411
412    const T_RX_J2000_S: f64 = 646_272_000.0;
413    const RECEIVER: [f64; 3] = [4_500_000.0, 500_000.0, 4_500_000.0];
414    const V_TRUE: [f64; 3] = [12.0, -7.0, 3.0];
415    const DRIFT_TRUE: f64 = 1.0e-9;
416
417    fn sp3_fixture() -> Sp3 {
418        let path = concat!(
419            env!("CARGO_MANIFEST_DIR"),
420            "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
421        );
422        let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
423        Sp3::parse(&bytes).expect("parse SP3 fixture")
424    }
425
426    fn visible_gps(sp3: &Sp3) -> Vec<GnssSatelliteId> {
427        let planning = PredictOptions {
428            light_time: false,
429            ..PredictOptions::default()
430        };
431        sp3.satellites()
432            .iter()
433            .copied()
434            .filter(|sat| sat.system == GnssSystem::Gps)
435            .filter(|sat| {
436                predict(sp3, *sat, RECEIVER, T_RX_J2000_S, planning)
437                    .map(|obs| obs.elevation_deg >= 5.0)
438                    .unwrap_or(false)
439            })
440            .collect()
441    }
442
443    fn synth_range_rate(sp3: &Sp3, sat: GnssSatelliteId, v_true: [f64; 3], drift: f64) -> f64 {
444        let obs = predict(sp3, sat, RECEIVER, T_RX_J2000_S, PredictOptions::default())
445            .expect("predict synthetic observation");
446        let e_dot_vtrue =
447            obs.los_unit[0] * v_true[0] + obs.los_unit[1] * v_true[1] + obs.los_unit[2] * v_true[2];
448        obs.range_rate_m_s - e_dot_vtrue + C_M_S * drift
449    }
450
451    fn synth_observations(sp3: &Sp3, sats: &[GnssSatelliteId]) -> Vec<VelocityObservation> {
452        sats.iter()
453            .map(|&sat| VelocityObservation {
454                satellite_id: sat,
455                value: synth_range_rate(sp3, sat, V_TRUE, DRIFT_TRUE),
456                carrier_hz: F_L1_HZ,
457                sat_clock_drift_s_s: 0.0,
458            })
459            .collect()
460    }
461
462    #[derive(Debug, Clone, Copy)]
463    struct StaticVelocitySource {
464        state: ObservableState,
465    }
466
467    impl ObservableEphemerisSource for StaticVelocitySource {
468        fn observable_state_at_j2000_s(
469            &self,
470            _sat: GnssSatelliteId,
471            _t_j2000_s: f64,
472        ) -> Result<ObservableState, ObservablesError> {
473            Ok(self.state)
474        }
475    }
476
477    fn static_velocity_source(position_ecef_m: [f64; 3]) -> StaticVelocitySource {
478        StaticVelocitySource {
479            state: ObservableState {
480                position_ecef_m,
481                clock_s: Some(0.0),
482            },
483        }
484    }
485
486    #[test]
487    fn split_epoch_constant_matches_orbis_velocity_fixture() {
488        assert_eq!(
489            j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date"),
490            T_RX_J2000_S
491        );
492    }
493
494    #[test]
495    fn range_rate_solve_has_frozen_bits_golden() {
496        let sp3 = sp3_fixture();
497        let sats = visible_gps(&sp3);
498        assert!(sats.len() >= 4);
499        let observations = synth_observations(&sp3, &sats);
500
501        let solution = solve(
502            &sp3,
503            &observations,
504            RECEIVER,
505            T_RX_J2000_S,
506            VelocitySolveOptions::default(),
507        )
508        .expect("solve velocity");
509
510        assert_eq!(
511            solution.velocity_m_s.map(f64::to_bits),
512            [0x4028000000000000, 0xc01c000000000016, 0x4007ffffffffff00]
513        );
514        assert_eq!(solution.speed_m_s.to_bits(), 0x402c6ce322982a37);
515        assert_eq!(solution.clock_drift_s_s.to_bits(), 0x3e112e0be826d2ee);
516        assert_eq!(
517            solution
518                .used_sats
519                .iter()
520                .map(ToString::to_string)
521                .collect::<Vec<_>>(),
522            ["G07", "G08", "G10", "G16", "G18", "G20", "G21", "G26", "G27"]
523        );
524        assert_eq!(
525            solution
526                .residuals_m_s
527                .iter()
528                .map(|(_, residual)| residual.to_bits())
529                .collect::<Vec<_>>(),
530            [
531                0xbd01000000000000,
532                0xbd24000000000000,
533                0x3cfc000000000000,
534                0xbd16000000000000,
535                0xbd1a800000000000,
536                0x3cf0000000000000,
537                0xbd14000000000000,
538                0x3d31800000000000,
539                0x3d18000000000000,
540            ]
541        );
542    }
543
544    #[test]
545    fn doppler_path_has_frozen_bits_with_per_sat_carriers() {
546        let sp3 = sp3_fixture();
547        let sats = visible_gps(&sp3);
548        let range_rate_observations = synth_observations(&sp3, &sats);
549        let doppler_observations: Vec<_> = range_rate_observations
550            .iter()
551            .enumerate()
552            .map(|(idx, obs)| {
553                let k = (idx % 14) as i8 - 7;
554                let carrier_hz =
555                    crate::frequencies::rinex_band_frequency_hz(GnssSystem::Glonass, '1', Some(k))
556                        .expect("canonical GLONASS G1 channel carrier exists");
557                VelocityObservation {
558                    value: range_rate_to_doppler(obs.value, carrier_hz)
559                        .expect("valid range-rate conversion"),
560                    carrier_hz,
561                    ..*obs
562                }
563            })
564            .collect();
565
566        let range_rate = solve(
567            &sp3,
568            &range_rate_observations,
569            RECEIVER,
570            T_RX_J2000_S,
571            VelocitySolveOptions::default(),
572        )
573        .expect("range-rate solve");
574        let doppler = solve(
575            &sp3,
576            &doppler_observations,
577            RECEIVER,
578            T_RX_J2000_S,
579            VelocitySolveOptions {
580                observable: VelocityObservable::Doppler,
581                ..VelocitySolveOptions::default()
582            },
583        )
584        .expect("doppler solve");
585
586        assert_eq!(
587            range_rate.velocity_m_s.map(f64::to_bits),
588            [0x4028000000000000, 0xc01c000000000016, 0x4007ffffffffff00]
589        );
590        assert_eq!(
591            doppler.velocity_m_s.map(f64::to_bits),
592            [0x402800000000000c, 0xc01c00000000000f, 0x4007ffffffffff60]
593        );
594        assert_eq!(doppler.speed_m_s.to_bits(), 0x402c6ce322982a44);
595        assert_eq!(doppler.clock_drift_s_s.to_bits(), 0x3e112e0be826d4b8);
596        assert_eq!(
597            doppler
598                .residuals_m_s
599                .iter()
600                .map(|(_, residual)| residual.to_bits())
601                .collect::<Vec<_>>(),
602            [
603                0x3d24c00000000000,
604                0xbd2b000000000000,
605                0xbd00000000000000,
606                0xbd00000000000000,
607                0xbd0b000000000000,
608                0x3d06000000000000,
609                0x0000000000000000,
610                0x3d40c00000000000,
611                0x3d22000000000000,
612            ]
613        );
614    }
615
616    #[test]
617    fn validates_core_error_cases() {
618        let sp3 = sp3_fixture();
619        let sats = visible_gps(&sp3);
620        let mut observations = synth_observations(&sp3, &sats);
621        let first = observations[0].satellite_id;
622
623        assert_eq!(
624            solve(
625                &sp3,
626                &[],
627                RECEIVER,
628                T_RX_J2000_S,
629                VelocitySolveOptions::default()
630            ),
631            Err(VelocityError::NoObservations)
632        );
633
634        assert_eq!(
635            solve(
636                &sp3,
637                &observations[..3],
638                RECEIVER,
639                T_RX_J2000_S,
640                VelocitySolveOptions::default()
641            ),
642            Err(VelocityError::TooFewSatellites {
643                used: 3,
644                required: 4
645            })
646        );
647
648        observations[1].satellite_id = first;
649        assert_eq!(
650            solve(
651                &sp3,
652                &observations,
653                RECEIVER,
654                T_RX_J2000_S,
655                VelocitySolveOptions::default()
656            ),
657            Err(VelocityError::DuplicateObservation {
658                satellite_id: first
659            })
660        );
661
662        let invalid_carrier = [VelocityObservation {
663            satellite_id: first,
664            value: 1.0,
665            carrier_hz: -1.0,
666            sat_clock_drift_s_s: 0.0,
667        }];
668        assert_eq!(
669            solve(
670                &sp3,
671                &invalid_carrier,
672                RECEIVER,
673                T_RX_J2000_S,
674                VelocitySolveOptions {
675                    observable: VelocityObservable::Doppler,
676                    ..VelocitySolveOptions::default()
677                }
678            ),
679            Err(VelocityError::InvalidCarrier {
680                satellite_id: first
681            })
682        );
683    }
684
685    #[test]
686    fn rejects_non_finite_velocity_inputs() {
687        let sp3 = sp3_fixture();
688        let sats = visible_gps(&sp3);
689        let mut observations = synth_observations(&sp3, &sats);
690        let first = observations[0].satellite_id;
691
692        observations[0].value = f64::NAN;
693        assert_eq!(
694            solve(
695                &sp3,
696                &observations,
697                RECEIVER,
698                T_RX_J2000_S,
699                VelocitySolveOptions::default()
700            ),
701            Err(VelocityError::InvalidObservation {
702                satellite_id: first
703            })
704        );
705
706        observations[0].value = 0.0;
707        observations[0].sat_clock_drift_s_s = f64::NAN;
708        assert_eq!(
709            solve(
710                &sp3,
711                &observations,
712                RECEIVER,
713                T_RX_J2000_S,
714                VelocitySolveOptions::default()
715            ),
716            Err(VelocityError::InvalidObservation {
717                satellite_id: first
718            })
719        );
720
721        observations[0].sat_clock_drift_s_s = 0.0;
722        let mut bad_receiver = RECEIVER;
723        bad_receiver[0] = f64::NAN;
724        assert_eq!(
725            solve(
726                &sp3,
727                &observations,
728                bad_receiver,
729                T_RX_J2000_S,
730                VelocitySolveOptions::default()
731            ),
732            Err(VelocityError::InvalidReceiverState)
733        );
734
735        assert_eq!(
736            solve(
737                &sp3,
738                &observations,
739                RECEIVER,
740                f64::NAN,
741                VelocitySolveOptions::default()
742            ),
743            Err(VelocityError::InvalidReceiverState)
744        );
745    }
746
747    #[test]
748    fn conversion_helpers_reject_invalid_domains() {
749        assert_eq!(
750            doppler_to_range_rate(f64::NAN, F_L1_HZ),
751            Err(VelocityError::InvalidInput {
752                field: "doppler_hz",
753                reason: "not finite"
754            })
755        );
756        assert_eq!(
757            range_rate_to_doppler(f64::INFINITY, F_L1_HZ),
758            Err(VelocityError::InvalidInput {
759                field: "range_rate_m_s",
760                reason: "not finite"
761            })
762        );
763
764        for carrier_hz in [f64::NAN, f64::INFINITY] {
765            assert_eq!(
766                doppler_to_range_rate(1.0, carrier_hz),
767                Err(VelocityError::InvalidInput {
768                    field: "carrier_hz",
769                    reason: "not finite"
770                })
771            );
772            assert_eq!(
773                range_rate_to_doppler(1.0, carrier_hz),
774                Err(VelocityError::InvalidInput {
775                    field: "carrier_hz",
776                    reason: "not finite"
777                })
778            );
779        }
780
781        for carrier_hz in [0.0, -1.0] {
782            assert_eq!(
783                doppler_to_range_rate(1.0, carrier_hz),
784                Err(VelocityError::InvalidInput {
785                    field: "carrier_hz",
786                    reason: "not positive"
787                })
788            );
789            assert_eq!(
790                range_rate_to_doppler(1.0, carrier_hz),
791                Err(VelocityError::InvalidInput {
792                    field: "carrier_hz",
793                    reason: "not positive"
794                })
795            );
796        }
797    }
798
799    #[test]
800    fn solve_rejects_non_finite_internal_rows() {
801        let source = static_velocity_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
802        let observations: Vec<_> = (1..=4)
803            .map(|prn| VelocityObservation {
804                satellite_id: GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid sat"),
805                value: 0.0,
806                carrier_hz: F_L1_HZ,
807                sat_clock_drift_s_s: f64::MAX,
808            })
809            .collect();
810
811        assert_eq!(
812            solve(
813                &source,
814                &observations,
815                [0.0, 0.0, 0.0],
816                646_272_000.0,
817                VelocitySolveOptions {
818                    light_time: false,
819                    sagnac: false,
820                    ..VelocitySolveOptions::default()
821                }
822            ),
823            Err(VelocityError::InvalidInput {
824                field: "velocity row",
825                reason: "out of range",
826            })
827        );
828    }
829}