Skip to main content

sidereon_core/
observables.rs

1//! Forward GNSS observable prediction.
2//!
3//! This module owns the language-independent geometry behind Sidereon'
4//! `Observables.predict`: transmit-time iteration, Sagnac rotation, line of
5//! sight, range rate, Doppler, and topocentric azimuth/elevation. Ephemeris
6//! parsing and interpolation stay with their existing SP3/broadcast products.
7
8use crate::astro::frames::transforms::itrs_to_geodetic_compute;
9use std::f64::consts::PI;
10
11use crate::constants::{
12    C_M_S, DEGREES_PER_CIRCLE, DEGREES_PER_SEMICIRCLE, F_L1_HZ, J2000_JD, KM_TO_M,
13    MICROSECONDS_PER_SECOND, OBSERVABLE_TRANSMIT_TIME_ITERATIONS, OMEGA_E_DOT_RAD_S,
14    SECONDS_PER_DAY,
15};
16use crate::ephemeris::BroadcastEphemeris;
17use crate::estimation::recipe::SagnacRecipe;
18use crate::id::GnssSatelliteId;
19use crate::sp3::Sp3;
20use crate::spp::EphemerisSource;
21use crate::validate;
22use crate::Error;
23
24const FD_HALF_S: f64 = 0.5;
25
26/// Satellite state required by the observable predictor.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct ObservableState {
29    /// Satellite ECEF position in meters at the query epoch.
30    pub position_ecef_m: [f64; 3],
31    /// Satellite clock offset in seconds. SP3 clocks can be absent.
32    pub clock_s: Option<f64>,
33}
34
35/// An ephemeris product usable by [`predict`].
36pub trait ObservableEphemerisSource {
37    /// ECEF position and optional satellite clock at seconds since J2000.
38    fn observable_state_at_j2000_s(
39        &self,
40        sat: GnssSatelliteId,
41        t_j2000_s: f64,
42    ) -> Result<ObservableState, ObservablesError>;
43}
44
45impl ObservableEphemerisSource for Sp3 {
46    fn observable_state_at_j2000_s(
47        &self,
48        sat: GnssSatelliteId,
49        t_j2000_s: f64,
50    ) -> Result<ObservableState, ObservablesError> {
51        let state = self
52            .position_at_j2000_seconds(sat, t_j2000_s)
53            .map_err(ObservablesError::Ephemeris)?;
54        Ok(ObservableState {
55            position_ecef_m: state.position.as_array(),
56            clock_s: state.clock_s,
57        })
58    }
59}
60
61impl ObservableEphemerisSource for BroadcastEphemeris {
62    fn observable_state_at_j2000_s(
63        &self,
64        sat: GnssSatelliteId,
65        t_j2000_s: f64,
66    ) -> Result<ObservableState, ObservablesError> {
67        let Some((position_ecef_m, clock_s)) =
68            EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
69        else {
70            return Err(ObservablesError::NoEphemeris);
71        };
72        Ok(ObservableState {
73            position_ecef_m,
74            clock_s: Some(clock_s),
75        })
76    }
77}
78
79/// Input-validation failure category for observable prediction.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ObservablesInputErrorKind {
82    /// A floating-point input was NaN or infinite.
83    NonFinite,
84    /// A positive physical input was zero or negative.
85    NotPositive,
86    /// A non-negative physical input was negative.
87    Negative,
88    /// A finite numeric input was outside its accepted range.
89    OutOfRange,
90    /// A required input field was absent.
91    Missing,
92    /// A text field could not be parsed as a float.
93    FloatParse,
94    /// A text field could not be parsed as an integer.
95    IntParse,
96    /// A civil date field was out of range.
97    InvalidCivilDate,
98    /// A civil time field was out of range.
99    InvalidCivilTime,
100}
101
102impl core::fmt::Display for ObservablesInputErrorKind {
103    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
104        let label = match self {
105            Self::NonFinite => "not finite",
106            Self::NotPositive => "not positive",
107            Self::Negative => "negative",
108            Self::OutOfRange => "out of range",
109            Self::Missing => "missing",
110            Self::FloatParse => "invalid float",
111            Self::IntParse => "invalid integer",
112            Self::InvalidCivilDate => "invalid civil date",
113            Self::InvalidCivilTime => "invalid civil time",
114        };
115        f.write_str(label)
116    }
117}
118
119impl From<&validate::FieldError> for ObservablesInputErrorKind {
120    fn from(error: &validate::FieldError) -> Self {
121        match error {
122            validate::FieldError::Missing { .. } => Self::Missing,
123            validate::FieldError::NonFinite { .. } => Self::NonFinite,
124            validate::FieldError::NotPositive { .. } => Self::NotPositive,
125            validate::FieldError::Negative { .. } => Self::Negative,
126            validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
127            validate::FieldError::FloatParse { .. } => Self::FloatParse,
128            validate::FieldError::IntParse { .. } => Self::IntParse,
129            validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
130            validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
131        }
132    }
133}
134
135/// Error returned by the observable predictor.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum ObservablesError {
138    /// A public predictor input or ephemeris-source state was malformed,
139    /// non-finite, or outside its physical domain.
140    InvalidInput {
141        /// The invalid input field.
142        field: &'static str,
143        /// The validation failure category.
144        kind: ObservablesInputErrorKind,
145    },
146    /// The ephemeris product has no usable record for the satellite/epoch.
147    NoEphemeris,
148    /// The underlying ephemeris product returned a structured crate error.
149    Ephemeris(Error),
150}
151
152impl core::fmt::Display for ObservablesError {
153    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
154        match self {
155            Self::InvalidInput { field, kind } => {
156                write!(f, "invalid observable input {field}: {kind}")
157            }
158            Self::NoEphemeris => write!(f, "no ephemeris"),
159            Self::Ephemeris(err) => write!(f, "{err}"),
160        }
161    }
162}
163
164impl std::error::Error for ObservablesError {}
165
166/// Options controlling observable prediction.
167#[derive(Debug, Clone, Copy, PartialEq)]
168pub struct PredictOptions {
169    /// Carrier frequency used to scale Doppler, hertz.
170    pub carrier_hz: f64,
171    /// Apply fixed-point light-time / transmit-time correction.
172    pub light_time: bool,
173    /// Apply Earth-rotation Sagnac correction.
174    pub sagnac: bool,
175}
176
177impl Default for PredictOptions {
178    fn default() -> Self {
179        Self {
180            carrier_hz: F_L1_HZ,
181            light_time: true,
182            sagnac: true,
183        }
184    }
185}
186
187/// Predicted GNSS observables at one receive epoch.
188#[derive(Debug, Clone, Copy, PartialEq)]
189pub struct PredictedObservables {
190    /// Geometric range after optional Sagnac rotation, meters.
191    pub geometric_range_m: f64,
192    /// Range-rate LOS projection, meters per second.
193    pub range_rate_m_s: f64,
194    /// Doppler shift at `PredictOptions::carrier_hz`, hertz.
195    pub doppler_hz: f64,
196    /// Satellite clock offset at transmit time, seconds.
197    pub sat_clock_s: Option<f64>,
198    /// Topocentric elevation, degrees.
199    pub elevation_deg: f64,
200    /// Topocentric azimuth in `[0, 360)`, degrees.
201    pub azimuth_deg: f64,
202    /// Transmit-time offset from receive time, rounded to microseconds.
203    pub transmit_offset_us: i64,
204    /// Transmit time as seconds since J2000.
205    pub transmit_time_j2000_s: f64,
206    /// Receiver-to-satellite line-of-sight unit vector in ECEF.
207    pub los_unit: [f64; 3],
208    /// Sagnac-rotated satellite ECEF position in meters.
209    pub sat_pos_ecef_m: [f64; 3],
210    /// Sagnac-rotated satellite ECEF velocity in meters per second.
211    pub sat_velocity_m_s: [f64; 3],
212}
213
214/// Convert split Julian date to seconds since J2000.
215pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
216    validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
217    validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
218    let days_whole = jd_whole - J2000_JD;
219    validate::finite(
220        days_whole * SECONDS_PER_DAY + jd_fraction * SECONDS_PER_DAY,
221        "j2000_seconds",
222    )
223    .map_err(map_input_error)
224}
225
226/// Predict observables for `sat` from a static ECEF receiver.
227pub fn predict(
228    source: &dyn ObservableEphemerisSource,
229    sat: GnssSatelliteId,
230    receiver_ecef_m: [f64; 3],
231    t_rx_j2000_s: f64,
232    options: PredictOptions,
233) -> Result<PredictedObservables, ObservablesError> {
234    validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
235    let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
236
237    let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
238    let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
239    let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
240    let range = geometric_range_m([dx, dy, dz])?;
241    let los = [dx / range, dy / range, dz / range];
242
243    let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
244    let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
245    validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
246    let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
247    validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
248    let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
249    validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
250    let (elevation_deg, azimuth_deg) = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
251
252    Ok(PredictedObservables {
253        geometric_range_m: range,
254        range_rate_m_s: range_rate,
255        doppler_hz,
256        sat_clock_s: solved.state.clock_s,
257        elevation_deg,
258        azimuth_deg,
259        transmit_offset_us: solved.transmit_offset_us,
260        transmit_time_j2000_s: solved.transmit_time_j2000_s,
261        los_unit: los,
262        sat_pos_ecef_m: solved.sat_rot_ecef_m,
263        sat_velocity_m_s: velocity_rot,
264    })
265}
266
267#[derive(Debug, Clone, Copy)]
268struct SolvedTransmitTime {
269    tau_s: f64,
270    transmit_offset_us: i64,
271    transmit_time_j2000_s: f64,
272    state: ObservableState,
273    sat_rot_ecef_m: [f64; 3],
274}
275
276fn solve_transmit_time(
277    source: &dyn ObservableEphemerisSource,
278    sat: GnssSatelliteId,
279    receiver_ecef_m: [f64; 3],
280    t_rx_j2000_s: f64,
281    options: PredictOptions,
282) -> Result<SolvedTransmitTime, ObservablesError> {
283    if !options.light_time {
284        let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
285        let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
286        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
287        return Ok(SolvedTransmitTime {
288            tau_s: 0.0,
289            transmit_offset_us: 0,
290            transmit_time_j2000_s: t_rx_j2000_s,
291            state,
292            sat_rot_ecef_m: sat_rot,
293        });
294    }
295
296    let mut tau = 0.0;
297    for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
298        let transmit_offset_us = microseconds_from_tau(tau);
299        let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
300        let state = validated_state_at_j2000_s(source, sat, t_tx)?;
301        let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
302        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
303        let dx = sat_rot[0] - receiver_ecef_m[0];
304        let dy = sat_rot[1] - receiver_ecef_m[1];
305        let dz = sat_rot[2] - receiver_ecef_m[2];
306        let range = geometric_range_m([dx, dy, dz])?;
307        let new_tau = range / C_M_S;
308
309        if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
310            return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
311        }
312
313        tau = new_tau;
314    }
315
316    unreachable!("fixed transmit-time loop always returns on its last iteration")
317}
318
319fn finalize_transmit_time(
320    source: &dyn ObservableEphemerisSource,
321    sat: GnssSatelliteId,
322    t_rx_j2000_s: f64,
323    tau: f64,
324    sagnac: bool,
325) -> Result<SolvedTransmitTime, ObservablesError> {
326    let transmit_offset_us = microseconds_from_tau(tau);
327    let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
328    validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
329    let state = validated_state_at_j2000_s(source, sat, t_tx)?;
330    let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
331    validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
332    Ok(SolvedTransmitTime {
333        tau_s: tau,
334        transmit_offset_us,
335        transmit_time_j2000_s: t_tx,
336        state,
337        sat_rot_ecef_m: sat_rot,
338    })
339}
340
341fn microseconds_from_tau(tau_s: f64) -> i64 {
342    (tau_s * MICROSECONDS_PER_SECOND).round() as i64
343}
344
345fn satellite_velocity(
346    source: &dyn ObservableEphemerisSource,
347    sat: GnssSatelliteId,
348    t_tx_j2000_s: f64,
349) -> Result<[f64; 3], ObservablesError> {
350    let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
351    let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
352    let denom = 2.0 * FD_HALF_S;
353    let velocity = [
354        (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
355        (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
356        (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
357    ];
358    validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
359}
360
361fn validate_predict_inputs(
362    receiver_ecef_m: [f64; 3],
363    t_rx_j2000_s: f64,
364    options: PredictOptions,
365) -> Result<(), ObservablesError> {
366    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
367    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
368    validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
369    Ok(())
370}
371
372fn validated_state_at_j2000_s(
373    source: &dyn ObservableEphemerisSource,
374    sat: GnssSatelliteId,
375    t_j2000_s: f64,
376) -> Result<ObservableState, ObservablesError> {
377    let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
378    validate_observable_state(&state)?;
379    Ok(state)
380}
381
382fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
383    validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
384        .map_err(map_input_error)?;
385    if let Some(clock_s) = state.clock_s {
386        validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
387    }
388    Ok(())
389}
390
391fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
392    let range = (delta_ecef_m[0] * delta_ecef_m[0]
393        + delta_ecef_m[1] * delta_ecef_m[1]
394        + delta_ecef_m[2] * delta_ecef_m[2])
395        .sqrt();
396    validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
397}
398
399fn map_input_error(error: validate::FieldError) -> ObservablesError {
400    ObservablesError::InvalidInput {
401        field: error.field(),
402        kind: ObservablesInputErrorKind::from(&error),
403    }
404}
405
406fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
407    let sagnac = if apply {
408        SagnacRecipe::ClosedFormZRotation
409    } else {
410        SagnacRecipe::Off
411    };
412    crate::estimation::substrate::range::rotate_transmit_satellite(
413        sagnac,
414        pos,
415        tau_s,
416        OMEGA_E_DOT_RAD_S,
417    )
418}
419
420fn topocentric(
421    receiver_ecef_m: [f64; 3],
422    delta_ecef_m: [f64; 3],
423    range_m: f64,
424) -> Result<(f64, f64), ObservablesError> {
425    let (lat_deg, lon_deg, _height_km) = itrs_to_geodetic_compute(
426        receiver_ecef_m[0] / KM_TO_M,
427        receiver_ecef_m[1] / KM_TO_M,
428        receiver_ecef_m[2] / KM_TO_M,
429    )
430    .map_err(|_| ObservablesError::InvalidInput {
431        field: "receiver_ecef_m",
432        kind: ObservablesInputErrorKind::OutOfRange,
433    })?;
434    // Sidereon' application oracle pins this multiply-then-divide order.
435    let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
436    let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
437
438    let sl = lat.sin();
439    let cl = lat.cos();
440    let so = lon.sin();
441    let co = lon.cos();
442
443    let dx = delta_ecef_m[0];
444    let dy = delta_ecef_m[1];
445    let dz = delta_ecef_m[2];
446
447    let e = -so * dx + co * dy;
448    let n = -sl * co * dx - sl * so * dy + cl * dz;
449    let u = cl * co * dx + cl * so * dy + sl * dz;
450
451    // Sidereon' application oracle pins this multiply-then-divide order.
452    let mut azimuth_deg = e.atan2(n) * DEGREES_PER_SEMICIRCLE / PI;
453    if azimuth_deg < 0.0 {
454        azimuth_deg += DEGREES_PER_CIRCLE;
455    }
456    let elevation_deg = (u / range_m).asin() * DEGREES_PER_SEMICIRCLE / PI;
457
458    validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
459    validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
460    Ok((elevation_deg, azimuth_deg))
461}
462
463#[cfg(all(test, sidereon_repo_tests))]
464mod tests {
465    use super::*;
466    use crate::{GnssSatelliteId, GnssSystem};
467
468    #[derive(Debug, Clone, Copy)]
469    struct StaticSource {
470        state: ObservableState,
471    }
472
473    impl ObservableEphemerisSource for StaticSource {
474        fn observable_state_at_j2000_s(
475            &self,
476            _sat: GnssSatelliteId,
477            _t_j2000_s: f64,
478        ) -> Result<ObservableState, ObservablesError> {
479            Ok(self.state)
480        }
481    }
482
483    fn sp3_fixture() -> Sp3 {
484        let path = concat!(
485            env!("CARGO_MANIFEST_DIR"),
486            "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
487        );
488        let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
489        Sp3::parse(&bytes).expect("parse SP3 fixture")
490    }
491
492    fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
493        StaticSource {
494            state: ObservableState {
495                position_ecef_m,
496                clock_s: Some(0.0),
497            },
498        }
499    }
500
501    fn no_light_time_options() -> PredictOptions {
502        PredictOptions {
503            carrier_hz: F_L1_HZ,
504            light_time: false,
505            sagnac: true,
506        }
507    }
508
509    fn assert_invalid_observables_input(
510        err: ObservablesError,
511        field: &'static str,
512        kind: ObservablesInputErrorKind,
513    ) {
514        match err {
515            ObservablesError::InvalidInput {
516                field: got_field,
517                kind: got_kind,
518            } => {
519                assert_eq!(got_field, field);
520                assert_eq!(got_kind, kind);
521            }
522            other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
523        }
524    }
525
526    #[test]
527    fn split_julian_to_j2000_seconds_matches_orbis_time() {
528        let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
529        assert_eq!(t, 646_272_000.0);
530    }
531
532    #[test]
533    fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
534        for (jd_whole, jd_fraction, field) in [
535            (f64::NAN, 0.5, "jd_whole"),
536            (f64::INFINITY, 0.5, "jd_whole"),
537            (2_459_024.5, f64::NAN, "jd_fraction"),
538            (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
539        ] {
540            let err = j2000_seconds_from_split(jd_whole, jd_fraction)
541                .expect_err("non-finite split Julian date part must fail");
542            assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
543        }
544    }
545
546    #[test]
547    fn sp3_predict_reference_case() {
548        let sp3 = sp3_fixture();
549        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
550        let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
551        let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
552            .expect("predict observables");
553
554        assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
555        assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
556        assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
557        assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
558        assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
559        assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
560        assert_eq!(obs.transmit_offset_us, 69_288);
561        assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
562        assert_eq!(
563            obs.los_unit.map(f64::to_bits),
564            [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
565        );
566        assert_eq!(
567            obs.sat_pos_ecef_m.map(f64::to_bits),
568            [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
569        );
570        assert_eq!(
571            obs.sat_velocity_m_s.map(f64::to_bits),
572            [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
573        );
574    }
575
576    #[test]
577    fn predict_rejects_invalid_entry_inputs() {
578        let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
579        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
580
581        let err = predict(
582            &source,
583            sat,
584            [f64::NAN, 0.0, 0.0],
585            646_272_000.0,
586            no_light_time_options(),
587        )
588        .expect_err("non-finite receiver position must fail");
589        assert_invalid_observables_input(
590            err,
591            "receiver_ecef_m",
592            ObservablesInputErrorKind::NonFinite,
593        );
594
595        let err = predict(
596            &source,
597            sat,
598            [0.0, 0.0, 0.0],
599            f64::INFINITY,
600            no_light_time_options(),
601        )
602        .expect_err("non-finite receive time must fail");
603        assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
604
605        let mut options = no_light_time_options();
606        options.carrier_hz = 0.0;
607        let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
608            .expect_err("non-positive carrier must fail");
609        assert_invalid_observables_input(
610            err,
611            "options.carrier_hz",
612            ObservablesInputErrorKind::NotPositive,
613        );
614    }
615
616    #[test]
617    fn predict_rejects_invalid_source_state_and_zero_range() {
618        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
619
620        let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
621        let err = predict(
622            &source,
623            sat,
624            [0.0, 0.0, 0.0],
625            646_272_000.0,
626            no_light_time_options(),
627        )
628        .expect_err("non-finite ephemeris position must fail");
629        assert_invalid_observables_input(
630            err,
631            "observable state position_ecef_m",
632            ObservablesInputErrorKind::NonFinite,
633        );
634
635        let source = static_source([1_000.0, 2_000.0, 3_000.0]);
636        let err = predict(
637            &source,
638            sat,
639            [1_000.0, 2_000.0, 3_000.0],
640            646_272_000.0,
641            no_light_time_options(),
642        )
643        .expect_err("zero geometric range must fail");
644        assert_invalid_observables_input(
645            err,
646            "geometric_range_m",
647            ObservablesInputErrorKind::NotPositive,
648        );
649    }
650
651    #[test]
652    fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
653        let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
654            .expect_err("invalid receiver geodetic conversion must fail");
655
656        assert_invalid_observables_input(
657            err,
658            "receiver_ecef_m",
659            ObservablesInputErrorKind::OutOfRange,
660        );
661    }
662}