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