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