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::astro::time::civil;
12use crate::constants::{
13    C_M_S, DEGREES_PER_CIRCLE, DEGREES_PER_SEMICIRCLE, F_L1_HZ, KM_TO_M, MICROSECONDS_PER_SECOND,
14    OBSERVABLE_TRANSMIT_TIME_ITERATIONS, OMEGA_E_DOT_RAD_S,
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
177/// Options controlling transmit-time satellite-state evaluation.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub struct TransmitTimeOptions {
180    /// Apply fixed-point light-time / transmit-time correction.
181    pub light_time: bool,
182    /// Apply Earth-rotation Sagnac correction to the returned position/velocity.
183    pub sagnac: bool,
184}
185
186impl Default for TransmitTimeOptions {
187    fn default() -> Self {
188        Self {
189            light_time: true,
190            sagnac: true,
191        }
192    }
193}
194
195impl Default for PredictOptions {
196    fn default() -> Self {
197        Self {
198            carrier_hz: F_L1_HZ,
199            light_time: true,
200            sagnac: true,
201        }
202    }
203}
204
205/// Satellite state at its signal transmit time for one receive epoch.
206///
207/// `transmit_position_ecef_m` is the ephemeris position evaluated at
208/// `transmit_time_j2000_s`. `position_ecef_m` is that position transported into
209/// the receive-time ECEF frame when [`TransmitTimeOptions::sagnac`] is enabled.
210/// `velocity_m_s` is the finite-difference ECEF velocity at transmit time with
211/// the same transport applied.
212#[derive(Debug, Clone, Copy, PartialEq)]
213pub struct TransmitTimeSatelliteState {
214    /// Signal flight time, seconds.
215    pub signal_flight_time_s: f64,
216    /// Transmit-time offset from receive time, rounded to microseconds.
217    pub transmit_offset_us: i64,
218    /// Transmit time as seconds since J2000.
219    pub transmit_time_j2000_s: f64,
220    /// Satellite clock offset at transmit time, seconds.
221    pub clock_s: Option<f64>,
222    /// Ephemeris ECEF satellite position at transmit time, metres.
223    pub transmit_position_ecef_m: [f64; 3],
224    /// Sagnac-transported ECEF satellite position, metres.
225    pub position_ecef_m: [f64; 3],
226    /// Sagnac-transported ECEF satellite velocity, metres per second.
227    pub velocity_m_s: [f64; 3],
228    /// Geometric range after optional Sagnac transport, metres.
229    pub geometric_range_m: f64,
230    /// Receiver-to-satellite line-of-sight unit vector in ECEF.
231    pub los_unit: [f64; 3],
232}
233
234/// Predicted GNSS observables at one receive epoch.
235#[derive(Debug, Clone, Copy, PartialEq)]
236pub struct PredictedObservables {
237    /// Geometric range after optional Sagnac rotation, meters.
238    pub geometric_range_m: f64,
239    /// Range-rate LOS projection, meters per second.
240    pub range_rate_m_s: f64,
241    /// Doppler shift at `PredictOptions::carrier_hz`, hertz.
242    pub doppler_hz: f64,
243    /// Satellite clock offset at transmit time, seconds.
244    pub sat_clock_s: Option<f64>,
245    /// Topocentric elevation, degrees.
246    pub elevation_deg: f64,
247    /// Topocentric azimuth in `[0, 360)`, degrees.
248    pub azimuth_deg: f64,
249    /// Transmit-time offset from receive time, rounded to microseconds.
250    pub transmit_offset_us: i64,
251    /// Transmit time as seconds since J2000.
252    pub transmit_time_j2000_s: f64,
253    /// Receiver-to-satellite line-of-sight unit vector in ECEF.
254    pub los_unit: [f64; 3],
255    /// Sagnac-rotated satellite ECEF position in meters.
256    pub sat_pos_ecef_m: [f64; 3],
257    /// Sagnac-rotated satellite ECEF velocity in meters per second.
258    pub sat_velocity_m_s: [f64; 3],
259}
260
261/// Convert split Julian date to seconds since J2000.
262pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
263    validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
264    validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
265    validate::finite(
266        civil::j2000_seconds_from_split(jd_whole, jd_fraction),
267        "j2000_seconds",
268    )
269    .map_err(map_input_error)
270}
271
272/// Evaluate a satellite's transmit-time ECEF state for one static receiver.
273///
274/// This is the per-satellite primitive underneath observable prediction: it
275/// iterates light time, evaluates the ephemeris at the satellite's transmit
276/// epoch, applies the Sagnac/Earth-rotation transport if requested, and returns
277/// the transported position, velocity, clock, range, and line of sight without
278/// constructing Doppler or topocentric observables.
279pub fn transmit_time_satellite_state(
280    source: &dyn ObservableEphemerisSource,
281    sat: GnssSatelliteId,
282    receiver_ecef_m: [f64; 3],
283    t_rx_j2000_s: f64,
284    options: TransmitTimeOptions,
285) -> Result<TransmitTimeSatelliteState, ObservablesError> {
286    validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
287    let predict_options = PredictOptions {
288        carrier_hz: F_L1_HZ,
289        light_time: options.light_time,
290        sagnac: options.sagnac,
291    };
292    let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, predict_options)?;
293
294    let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
295    let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
296    let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
297    let range = geometric_range_m([dx, dy, dz])?;
298    let los = [dx / range, dy / range, dz / range];
299
300    let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
301    let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
302    validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
303
304    Ok(TransmitTimeSatelliteState {
305        signal_flight_time_s: solved.tau_s,
306        transmit_offset_us: solved.transmit_offset_us,
307        transmit_time_j2000_s: solved.transmit_time_j2000_s,
308        clock_s: solved.state.clock_s,
309        transmit_position_ecef_m: solved.state.position_ecef_m,
310        position_ecef_m: solved.sat_rot_ecef_m,
311        velocity_m_s: velocity_rot,
312        geometric_range_m: range,
313        los_unit: los,
314    })
315}
316
317/// Predict observables for `sat` from a static ECEF receiver.
318pub fn predict(
319    source: &dyn ObservableEphemerisSource,
320    sat: GnssSatelliteId,
321    receiver_ecef_m: [f64; 3],
322    t_rx_j2000_s: f64,
323    options: PredictOptions,
324) -> Result<PredictedObservables, ObservablesError> {
325    validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
326    let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
327
328    let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
329    let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
330    let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
331    let range = geometric_range_m([dx, dy, dz])?;
332    let los = [dx / range, dy / range, dz / range];
333
334    let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
335    let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
336    validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
337    let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
338    validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
339    let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
340    validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
341    let (elevation_deg, azimuth_deg) = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
342
343    Ok(PredictedObservables {
344        geometric_range_m: range,
345        range_rate_m_s: range_rate,
346        doppler_hz,
347        sat_clock_s: solved.state.clock_s,
348        elevation_deg,
349        azimuth_deg,
350        transmit_offset_us: solved.transmit_offset_us,
351        transmit_time_j2000_s: solved.transmit_time_j2000_s,
352        los_unit: los,
353        sat_pos_ecef_m: solved.sat_rot_ecef_m,
354        sat_velocity_m_s: velocity_rot,
355    })
356}
357
358#[derive(Debug, Clone, Copy)]
359struct SolvedTransmitTime {
360    tau_s: f64,
361    transmit_offset_us: i64,
362    transmit_time_j2000_s: f64,
363    state: ObservableState,
364    sat_rot_ecef_m: [f64; 3],
365}
366
367fn solve_transmit_time(
368    source: &dyn ObservableEphemerisSource,
369    sat: GnssSatelliteId,
370    receiver_ecef_m: [f64; 3],
371    t_rx_j2000_s: f64,
372    options: PredictOptions,
373) -> Result<SolvedTransmitTime, ObservablesError> {
374    if !options.light_time {
375        let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
376        let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
377        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
378        return Ok(SolvedTransmitTime {
379            tau_s: 0.0,
380            transmit_offset_us: 0,
381            transmit_time_j2000_s: t_rx_j2000_s,
382            state,
383            sat_rot_ecef_m: sat_rot,
384        });
385    }
386
387    let mut tau = 0.0;
388    for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
389        let transmit_offset_us = microseconds_from_tau(tau);
390        let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
391        let state = validated_state_at_j2000_s(source, sat, t_tx)?;
392        let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
393        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
394        let dx = sat_rot[0] - receiver_ecef_m[0];
395        let dy = sat_rot[1] - receiver_ecef_m[1];
396        let dz = sat_rot[2] - receiver_ecef_m[2];
397        let range = geometric_range_m([dx, dy, dz])?;
398        let new_tau = range / C_M_S;
399
400        if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
401            return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
402        }
403
404        tau = new_tau;
405    }
406
407    unreachable!("fixed transmit-time loop always returns on its last iteration")
408}
409
410fn finalize_transmit_time(
411    source: &dyn ObservableEphemerisSource,
412    sat: GnssSatelliteId,
413    t_rx_j2000_s: f64,
414    tau: f64,
415    sagnac: bool,
416) -> Result<SolvedTransmitTime, ObservablesError> {
417    let transmit_offset_us = microseconds_from_tau(tau);
418    let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
419    validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
420    let state = validated_state_at_j2000_s(source, sat, t_tx)?;
421    let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
422    validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
423    Ok(SolvedTransmitTime {
424        tau_s: tau,
425        transmit_offset_us,
426        transmit_time_j2000_s: t_tx,
427        state,
428        sat_rot_ecef_m: sat_rot,
429    })
430}
431
432fn microseconds_from_tau(tau_s: f64) -> i64 {
433    (tau_s * MICROSECONDS_PER_SECOND).round() as i64
434}
435
436fn satellite_velocity(
437    source: &dyn ObservableEphemerisSource,
438    sat: GnssSatelliteId,
439    t_tx_j2000_s: f64,
440) -> Result<[f64; 3], ObservablesError> {
441    let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
442    let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
443    let denom = 2.0 * FD_HALF_S;
444    let velocity = [
445        (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
446        (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
447        (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
448    ];
449    validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
450}
451
452fn validate_predict_inputs(
453    receiver_ecef_m: [f64; 3],
454    t_rx_j2000_s: f64,
455    options: PredictOptions,
456) -> Result<(), ObservablesError> {
457    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
458    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
459    validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
460    Ok(())
461}
462
463fn validate_transmit_time_inputs(
464    receiver_ecef_m: [f64; 3],
465    t_rx_j2000_s: f64,
466) -> Result<(), ObservablesError> {
467    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
468    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
469    Ok(())
470}
471
472fn validated_state_at_j2000_s(
473    source: &dyn ObservableEphemerisSource,
474    sat: GnssSatelliteId,
475    t_j2000_s: f64,
476) -> Result<ObservableState, ObservablesError> {
477    let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
478    validate_observable_state(&state)?;
479    Ok(state)
480}
481
482fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
483    validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
484        .map_err(map_input_error)?;
485    if let Some(clock_s) = state.clock_s {
486        validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
487    }
488    Ok(())
489}
490
491fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
492    let range = (delta_ecef_m[0] * delta_ecef_m[0]
493        + delta_ecef_m[1] * delta_ecef_m[1]
494        + delta_ecef_m[2] * delta_ecef_m[2])
495        .sqrt();
496    validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
497}
498
499fn map_input_error(error: validate::FieldError) -> ObservablesError {
500    ObservablesError::InvalidInput {
501        field: error.field(),
502        kind: ObservablesInputErrorKind::from(&error),
503    }
504}
505
506fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
507    let sagnac = if apply {
508        SagnacRecipe::ClosedFormZRotation
509    } else {
510        SagnacRecipe::Off
511    };
512    crate::estimation::substrate::range::rotate_transmit_satellite(
513        sagnac,
514        pos,
515        tau_s,
516        OMEGA_E_DOT_RAD_S,
517    )
518}
519
520fn topocentric(
521    receiver_ecef_m: [f64; 3],
522    delta_ecef_m: [f64; 3],
523    range_m: f64,
524) -> Result<(f64, f64), ObservablesError> {
525    let (lat_deg, lon_deg, _height_km) = itrs_to_geodetic_compute(
526        receiver_ecef_m[0] / KM_TO_M,
527        receiver_ecef_m[1] / KM_TO_M,
528        receiver_ecef_m[2] / KM_TO_M,
529    )
530    .map_err(|_| ObservablesError::InvalidInput {
531        field: "receiver_ecef_m",
532        kind: ObservablesInputErrorKind::OutOfRange,
533    })?;
534    // Sidereon' application oracle pins this multiply-then-divide order.
535    let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
536    let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
537
538    let sl = lat.sin();
539    let cl = lat.cos();
540    let so = lon.sin();
541    let co = lon.cos();
542
543    let dx = delta_ecef_m[0];
544    let dy = delta_ecef_m[1];
545    let dz = delta_ecef_m[2];
546
547    let e = -so * dx + co * dy;
548    let n = -sl * co * dx - sl * so * dy + cl * dz;
549    let u = cl * co * dx + cl * so * dy + sl * dz;
550
551    // Sidereon' application oracle pins this multiply-then-divide order.
552    let mut azimuth_deg = e.atan2(n) * DEGREES_PER_SEMICIRCLE / PI;
553    if azimuth_deg < 0.0 {
554        azimuth_deg += DEGREES_PER_CIRCLE;
555    }
556    let elevation_deg = (u / range_m).asin() * DEGREES_PER_SEMICIRCLE / PI;
557
558    validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
559    validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
560    Ok((elevation_deg, azimuth_deg))
561}
562
563#[cfg(test)]
564mod public_api_tests {
565    use super::*;
566    use crate::{GnssSatelliteId, GnssSystem};
567
568    #[derive(Debug, Clone, Copy)]
569    struct StaticSource {
570        state: ObservableState,
571    }
572
573    impl ObservableEphemerisSource for StaticSource {
574        fn observable_state_at_j2000_s(
575            &self,
576            _sat: GnssSatelliteId,
577            _t_j2000_s: f64,
578        ) -> Result<ObservableState, ObservablesError> {
579            Ok(self.state)
580        }
581    }
582
583    #[test]
584    fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
585        let source = StaticSource {
586            state: ObservableState {
587                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
588                clock_s: Some(1.25e-6),
589            },
590        };
591        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
592        let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
593        let state = transmit_time_satellite_state(
594            &source,
595            sat,
596            rx,
597            646_272_000.0,
598            TransmitTimeOptions {
599                light_time: false,
600                sagnac: true,
601            },
602        )
603        .expect("state");
604        let prediction = predict(
605            &source,
606            sat,
607            rx,
608            646_272_000.0,
609            PredictOptions {
610                carrier_hz: F_L1_HZ,
611                light_time: false,
612                sagnac: true,
613            },
614        )
615        .expect("prediction");
616
617        assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
618        assert_eq!(state.transmit_offset_us, 0);
619        assert_eq!(
620            state.transmit_time_j2000_s.to_bits(),
621            646_272_000.0f64.to_bits()
622        );
623        assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
624        assert_eq!(
625            state.transmit_position_ecef_m.map(f64::to_bits),
626            source.state.position_ecef_m.map(f64::to_bits)
627        );
628        assert_eq!(
629            state.position_ecef_m.map(f64::to_bits),
630            prediction.sat_pos_ecef_m.map(f64::to_bits)
631        );
632        assert_eq!(
633            state.velocity_m_s.map(f64::to_bits),
634            prediction.sat_velocity_m_s.map(f64::to_bits)
635        );
636        assert_eq!(
637            state.geometric_range_m.to_bits(),
638            prediction.geometric_range_m.to_bits()
639        );
640        assert_eq!(
641            state.los_unit.map(f64::to_bits),
642            prediction.los_unit.map(f64::to_bits)
643        );
644    }
645}
646
647#[cfg(all(test, sidereon_repo_tests))]
648mod tests {
649    use super::*;
650    use crate::{GnssSatelliteId, GnssSystem};
651
652    #[derive(Debug, Clone, Copy)]
653    struct StaticSource {
654        state: ObservableState,
655    }
656
657    impl ObservableEphemerisSource for StaticSource {
658        fn observable_state_at_j2000_s(
659            &self,
660            _sat: GnssSatelliteId,
661            _t_j2000_s: f64,
662        ) -> Result<ObservableState, ObservablesError> {
663            Ok(self.state)
664        }
665    }
666
667    fn sp3_fixture() -> Sp3 {
668        let path = concat!(
669            env!("CARGO_MANIFEST_DIR"),
670            "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
671        );
672        let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
673        Sp3::parse(&bytes).expect("parse SP3 fixture")
674    }
675
676    fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
677        StaticSource {
678            state: ObservableState {
679                position_ecef_m,
680                clock_s: Some(0.0),
681            },
682        }
683    }
684
685    fn no_light_time_options() -> PredictOptions {
686        PredictOptions {
687            carrier_hz: F_L1_HZ,
688            light_time: false,
689            sagnac: true,
690        }
691    }
692
693    fn assert_invalid_observables_input(
694        err: ObservablesError,
695        field: &'static str,
696        kind: ObservablesInputErrorKind,
697    ) {
698        match err {
699            ObservablesError::InvalidInput {
700                field: got_field,
701                kind: got_kind,
702            } => {
703                assert_eq!(got_field, field);
704                assert_eq!(got_kind, kind);
705            }
706            other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
707        }
708    }
709
710    #[test]
711    fn split_julian_to_j2000_seconds_matches_orbis_time() {
712        let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
713        assert_eq!(t, 646_272_000.0);
714    }
715
716    #[test]
717    fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
718        for (jd_whole, jd_fraction, field) in [
719            (f64::NAN, 0.5, "jd_whole"),
720            (f64::INFINITY, 0.5, "jd_whole"),
721            (2_459_024.5, f64::NAN, "jd_fraction"),
722            (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
723        ] {
724            let err = j2000_seconds_from_split(jd_whole, jd_fraction)
725                .expect_err("non-finite split Julian date part must fail");
726            assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
727        }
728    }
729
730    #[test]
731    fn sp3_predict_reference_case() {
732        let sp3 = sp3_fixture();
733        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
734        let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
735        let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
736            .expect("predict observables");
737
738        assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
739        assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
740        assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
741        assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
742        assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
743        assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
744        assert_eq!(obs.transmit_offset_us, 69_288);
745        assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
746        assert_eq!(
747            obs.los_unit.map(f64::to_bits),
748            [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
749        );
750        assert_eq!(
751            obs.sat_pos_ecef_m.map(f64::to_bits),
752            [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
753        );
754        assert_eq!(
755            obs.sat_velocity_m_s.map(f64::to_bits),
756            [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
757        );
758    }
759
760    #[test]
761    fn predict_rejects_invalid_entry_inputs() {
762        let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
763        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
764
765        let err = predict(
766            &source,
767            sat,
768            [f64::NAN, 0.0, 0.0],
769            646_272_000.0,
770            no_light_time_options(),
771        )
772        .expect_err("non-finite receiver position must fail");
773        assert_invalid_observables_input(
774            err,
775            "receiver_ecef_m",
776            ObservablesInputErrorKind::NonFinite,
777        );
778
779        let err = predict(
780            &source,
781            sat,
782            [0.0, 0.0, 0.0],
783            f64::INFINITY,
784            no_light_time_options(),
785        )
786        .expect_err("non-finite receive time must fail");
787        assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
788
789        let mut options = no_light_time_options();
790        options.carrier_hz = 0.0;
791        let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
792            .expect_err("non-positive carrier must fail");
793        assert_invalid_observables_input(
794            err,
795            "options.carrier_hz",
796            ObservablesInputErrorKind::NotPositive,
797        );
798    }
799
800    #[test]
801    fn predict_rejects_invalid_source_state_and_zero_range() {
802        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
803
804        let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
805        let err = predict(
806            &source,
807            sat,
808            [0.0, 0.0, 0.0],
809            646_272_000.0,
810            no_light_time_options(),
811        )
812        .expect_err("non-finite ephemeris position must fail");
813        assert_invalid_observables_input(
814            err,
815            "observable state position_ecef_m",
816            ObservablesInputErrorKind::NonFinite,
817        );
818
819        let source = static_source([1_000.0, 2_000.0, 3_000.0]);
820        let err = predict(
821            &source,
822            sat,
823            [1_000.0, 2_000.0, 3_000.0],
824            646_272_000.0,
825            no_light_time_options(),
826        )
827        .expect_err("zero geometric range must fail");
828        assert_invalid_observables_input(
829            err,
830            "geometric_range_m",
831            ObservablesInputErrorKind::NotPositive,
832        );
833    }
834
835    #[test]
836    fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
837        let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
838            .expect_err("invalid receiver geodetic conversion must fail");
839
840        assert_invalid_observables_input(
841            err,
842            "receiver_ecef_m",
843            ObservablesInputErrorKind::OutOfRange,
844        );
845    }
846}