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    AZIMUTH_ZENITH_EPS, C_M_S, DEGREES_PER_CIRCLE, DEGREES_PER_SEMICIRCLE, F_L1_HZ, KM_TO_M,
14    MICROSECONDS_PER_SECOND, 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;
23use rayon::prelude::*;
24
25const FD_HALF_S: f64 = 0.5;
26
27/// Satellite state required by the observable predictor.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct ObservableState {
30    /// Satellite ECEF position in meters at the query epoch.
31    pub position_ecef_m: [f64; 3],
32    /// Satellite clock offset in seconds. SP3 clocks can be absent.
33    pub clock_s: Option<f64>,
34}
35
36/// An ephemeris product usable by [`predict`].
37pub trait ObservableEphemerisSource {
38    /// ECEF position and optional satellite clock at seconds since J2000.
39    fn observable_state_at_j2000_s(
40        &self,
41        sat: GnssSatelliteId,
42        t_j2000_s: f64,
43    ) -> Result<ObservableState, ObservablesError>;
44}
45
46impl ObservableEphemerisSource for Sp3 {
47    fn observable_state_at_j2000_s(
48        &self,
49        sat: GnssSatelliteId,
50        t_j2000_s: f64,
51    ) -> Result<ObservableState, ObservablesError> {
52        let state = self
53            .position_at_j2000_seconds(sat, t_j2000_s)
54            .map_err(ObservablesError::Ephemeris)?;
55        Ok(ObservableState {
56            position_ecef_m: state.position.as_array(),
57            clock_s: state.clock_s,
58        })
59    }
60}
61
62impl ObservableEphemerisSource for BroadcastEphemeris {
63    fn observable_state_at_j2000_s(
64        &self,
65        sat: GnssSatelliteId,
66        t_j2000_s: f64,
67    ) -> Result<ObservableState, ObservablesError> {
68        let Some((position_ecef_m, clock_s)) =
69            EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
70        else {
71            return Err(ObservablesError::NoEphemeris);
72        };
73        Ok(ObservableState {
74            position_ecef_m,
75            clock_s: Some(clock_s),
76        })
77    }
78}
79
80/// Input-validation failure category for observable prediction.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ObservablesInputErrorKind {
83    /// A floating-point input was NaN or infinite.
84    NonFinite,
85    /// A positive physical input was zero or negative.
86    NotPositive,
87    /// A non-negative physical input was negative.
88    Negative,
89    /// A finite numeric input was outside its accepted range.
90    OutOfRange,
91    /// A required input field was absent.
92    Missing,
93    /// A text field could not be parsed as a float.
94    FloatParse,
95    /// A text field could not be parsed as an integer.
96    IntParse,
97    /// A civil date field was out of range.
98    InvalidCivilDate,
99    /// A civil time field was out of range.
100    InvalidCivilTime,
101}
102
103impl core::fmt::Display for ObservablesInputErrorKind {
104    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
105        let label = match self {
106            Self::NonFinite => "not finite",
107            Self::NotPositive => "not positive",
108            Self::Negative => "negative",
109            Self::OutOfRange => "out of range",
110            Self::Missing => "missing",
111            Self::FloatParse => "invalid float",
112            Self::IntParse => "invalid integer",
113            Self::InvalidCivilDate => "invalid civil date",
114            Self::InvalidCivilTime => "invalid civil time",
115        };
116        f.write_str(label)
117    }
118}
119
120impl From<&validate::FieldError> for ObservablesInputErrorKind {
121    fn from(error: &validate::FieldError) -> Self {
122        match error {
123            validate::FieldError::Missing { .. } => Self::Missing,
124            validate::FieldError::NonFinite { .. } => Self::NonFinite,
125            validate::FieldError::NotPositive { .. } => Self::NotPositive,
126            validate::FieldError::Negative { .. } => Self::Negative,
127            validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
128            validate::FieldError::FloatParse { .. } => Self::FloatParse,
129            validate::FieldError::IntParse { .. } => Self::IntParse,
130            validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
131            validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
132        }
133    }
134}
135
136/// Error returned by the observable predictor.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum ObservablesError {
139    /// A public predictor input or ephemeris-source state was malformed,
140    /// non-finite, or outside its physical domain.
141    InvalidInput {
142        /// The invalid input field.
143        field: &'static str,
144        /// The validation failure category.
145        kind: ObservablesInputErrorKind,
146    },
147    /// The ephemeris product has no usable record for the satellite/epoch.
148    NoEphemeris,
149    /// The underlying ephemeris product returned a structured crate error.
150    Ephemeris(Error),
151}
152
153impl core::fmt::Display for ObservablesError {
154    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
155        match self {
156            Self::InvalidInput { field, kind } => {
157                write!(f, "invalid observable input {field}: {kind}")
158            }
159            Self::NoEphemeris => write!(f, "no ephemeris"),
160            Self::Ephemeris(err) => write!(f, "{err}"),
161        }
162    }
163}
164
165impl std::error::Error for ObservablesError {}
166
167/// Options controlling observable prediction.
168#[derive(Debug, Clone, Copy, PartialEq)]
169pub struct PredictOptions {
170    /// Carrier frequency used to scale Doppler, hertz.
171    pub carrier_hz: f64,
172    /// Apply fixed-point light-time / transmit-time correction.
173    pub light_time: bool,
174    /// Apply Earth-rotation Sagnac correction.
175    pub sagnac: bool,
176}
177
178/// Options controlling transmit-time satellite-state evaluation.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct TransmitTimeOptions {
181    /// Apply fixed-point light-time / transmit-time correction.
182    pub light_time: bool,
183    /// Apply Earth-rotation Sagnac correction to the returned position/velocity.
184    pub sagnac: bool,
185}
186
187impl Default for TransmitTimeOptions {
188    fn default() -> Self {
189        Self {
190            light_time: true,
191            sagnac: true,
192        }
193    }
194}
195
196impl Default for PredictOptions {
197    fn default() -> Self {
198        Self {
199            carrier_hz: F_L1_HZ,
200            light_time: true,
201            sagnac: true,
202        }
203    }
204}
205
206/// Satellite state at its signal transmit time for one receive epoch.
207///
208/// `transmit_position_ecef_m` is the ephemeris position evaluated at
209/// `transmit_time_j2000_s`. `position_ecef_m` is that position transported into
210/// the receive-time ECEF frame when [`TransmitTimeOptions::sagnac`] is enabled.
211/// `velocity_m_s` is the finite-difference ECEF velocity at transmit time with
212/// the same transport applied.
213#[derive(Debug, Clone, Copy, PartialEq)]
214pub struct TransmitTimeSatelliteState {
215    /// Signal flight time, seconds.
216    pub signal_flight_time_s: f64,
217    /// Transmit-time offset from receive time, rounded to microseconds.
218    pub transmit_offset_us: i64,
219    /// Transmit time as seconds since J2000.
220    pub transmit_time_j2000_s: f64,
221    /// Satellite clock offset at transmit time, seconds.
222    pub clock_s: Option<f64>,
223    /// Ephemeris ECEF satellite position at transmit time, metres.
224    pub transmit_position_ecef_m: [f64; 3],
225    /// Sagnac-transported ECEF satellite position, metres.
226    pub position_ecef_m: [f64; 3],
227    /// Sagnac-transported ECEF satellite velocity, metres per second.
228    pub velocity_m_s: [f64; 3],
229    /// Geometric range after optional Sagnac transport, metres.
230    pub geometric_range_m: f64,
231    /// Receiver-to-satellite line-of-sight unit vector in ECEF.
232    pub los_unit: [f64; 3],
233}
234
235/// Predicted GNSS observables at one receive epoch.
236#[derive(Debug, Clone, Copy, PartialEq)]
237pub struct PredictedObservables {
238    /// Geometric range after optional Sagnac rotation, meters.
239    pub geometric_range_m: f64,
240    /// Range-rate LOS projection, meters per second.
241    pub range_rate_m_s: f64,
242    /// Doppler shift at `PredictOptions::carrier_hz`, hertz.
243    pub doppler_hz: f64,
244    /// Satellite clock offset at transmit time, seconds.
245    pub sat_clock_s: Option<f64>,
246    /// Topocentric elevation, degrees.
247    pub elevation_deg: f64,
248    /// Topocentric azimuth in `[0, 360)`, degrees.
249    ///
250    /// At (and arbitrarily near) the receiver's zenith the azimuth is
251    /// geometrically undefined; it is defined here to be exactly `0.0` once the
252    /// horizontal line-of-sight projection falls below
253    /// [`crate::constants::AZIMUTH_ZENITH_EPS`], rather than returning rounding
254    /// noise or erroring.
255    pub azimuth_deg: f64,
256    /// Transmit-time offset from receive time, rounded to microseconds.
257    pub transmit_offset_us: i64,
258    /// Transmit time as seconds since J2000.
259    pub transmit_time_j2000_s: f64,
260    /// Receiver-to-satellite line-of-sight unit vector in ECEF.
261    pub los_unit: [f64; 3],
262    /// Sagnac-rotated satellite ECEF position in meters.
263    pub sat_pos_ecef_m: [f64; 3],
264    /// Sagnac-rotated satellite ECEF velocity in meters per second.
265    pub sat_velocity_m_s: [f64; 3],
266}
267
268/// Convert split Julian date to seconds since J2000.
269pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
270    validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
271    validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
272    validate::finite(
273        civil::j2000_seconds_from_split(jd_whole, jd_fraction),
274        "j2000_seconds",
275    )
276    .map_err(map_input_error)
277}
278
279/// Evaluate a satellite's transmit-time ECEF state for one static receiver.
280///
281/// This is the per-satellite primitive underneath observable prediction: it
282/// iterates light time, evaluates the ephemeris at the satellite's transmit
283/// epoch, applies the Sagnac/Earth-rotation transport if requested, and returns
284/// the transported position, velocity, clock, range, and line of sight without
285/// constructing Doppler or topocentric observables.
286pub fn transmit_time_satellite_state(
287    source: &dyn ObservableEphemerisSource,
288    sat: GnssSatelliteId,
289    receiver_ecef_m: [f64; 3],
290    t_rx_j2000_s: f64,
291    options: TransmitTimeOptions,
292) -> Result<TransmitTimeSatelliteState, ObservablesError> {
293    validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
294    let predict_options = PredictOptions {
295        carrier_hz: F_L1_HZ,
296        light_time: options.light_time,
297        sagnac: options.sagnac,
298    };
299    let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, predict_options)?;
300
301    let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
302    let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
303    let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
304    let range = geometric_range_m([dx, dy, dz])?;
305    let los = [dx / range, dy / range, dz / range];
306
307    let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
308    let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
309    validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
310
311    Ok(TransmitTimeSatelliteState {
312        signal_flight_time_s: solved.tau_s,
313        transmit_offset_us: solved.transmit_offset_us,
314        transmit_time_j2000_s: solved.transmit_time_j2000_s,
315        clock_s: solved.state.clock_s,
316        transmit_position_ecef_m: solved.state.position_ecef_m,
317        position_ecef_m: solved.sat_rot_ecef_m,
318        velocity_m_s: velocity_rot,
319        geometric_range_m: range,
320        los_unit: los,
321    })
322}
323
324/// Predict observables for `sat` from a static ECEF receiver.
325pub fn predict(
326    source: &dyn ObservableEphemerisSource,
327    sat: GnssSatelliteId,
328    receiver_ecef_m: [f64; 3],
329    t_rx_j2000_s: f64,
330    options: PredictOptions,
331) -> Result<PredictedObservables, ObservablesError> {
332    validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
333    let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
334
335    let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
336    let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
337    let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
338    let range = geometric_range_m([dx, dy, dz])?;
339    let los = [dx / range, dy / range, dz / range];
340
341    let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
342    let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
343    validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
344    let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
345    validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
346    let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
347    validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
348    let (elevation_deg, azimuth_deg) = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
349
350    Ok(PredictedObservables {
351        geometric_range_m: range,
352        range_rate_m_s: range_rate,
353        doppler_hz,
354        sat_clock_s: solved.state.clock_s,
355        elevation_deg,
356        azimuth_deg,
357        transmit_offset_us: solved.transmit_offset_us,
358        transmit_time_j2000_s: solved.transmit_time_j2000_s,
359        los_unit: los,
360        sat_pos_ecef_m: solved.sat_rot_ecef_m,
361        sat_velocity_m_s: velocity_rot,
362    })
363}
364
365/// One batch prediction request: the satellite to observe, the static receiver
366/// ECEF position in meters, and the receive epoch in seconds since J2000.
367///
368/// Each entry is fully independent of the others; the receiver position and
369/// epoch are per-request, so a single batch can mix many satellites, many
370/// receivers, and many epochs in any combination.
371pub type PredictRequest = (GnssSatelliteId, [f64; 3], f64);
372
373/// Predict observables for many `(satellite, receiver, epoch)` requests, serially.
374///
375/// Element `i` of the result is exactly what [`predict`] returns for
376/// `requests[i]` (including its `Err`), evaluated with the shared `options`.
377/// This is the single-threaded reference that [`predict_batch_parallel`] is
378/// proven bit-identical against; it lets a caller predict a whole fleet/arc in
379/// one call instead of paying per-call dispatch overhead in a host-language loop.
380pub fn predict_batch(
381    source: &dyn ObservableEphemerisSource,
382    requests: &[PredictRequest],
383    options: PredictOptions,
384) -> Vec<Result<PredictedObservables, ObservablesError>> {
385    requests
386        .iter()
387        .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
388            predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
389        })
390        .collect()
391}
392
393/// Predict observables for many `(satellite, receiver, epoch)` requests, fanning
394/// the independent requests across a rayon thread pool.
395///
396/// Each request is evaluated by the same scalar [`predict`] kernel and the
397/// indexed parallel collect preserves input order, so element `i` is
398/// byte-for-byte identical to element `i` of [`predict_batch`]: the requests
399/// share no mutable state and a single `predict` is internally sequential, so
400/// throughput scales with cores while every value stays bit-exact. The
401/// `source` must be `Sync` because it is read concurrently from every worker.
402pub fn predict_batch_parallel(
403    source: &(dyn ObservableEphemerisSource + Sync),
404    requests: &[PredictRequest],
405    options: PredictOptions,
406) -> Vec<Result<PredictedObservables, ObservablesError>> {
407    requests
408        .par_iter()
409        .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
410            predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
411        })
412        .collect()
413}
414
415/// One batch range-prediction request: the satellite, the static receiver ECEF
416/// position in meters, and the receive epoch in seconds since J2000.
417#[derive(Debug, Clone, Copy, PartialEq)]
418pub struct RangePredictionRequest {
419    /// The satellite to range against.
420    pub sat: GnssSatelliteId,
421    /// Static receiver ECEF position, meters.
422    pub receiver_ecef_m: [f64; 3],
423    /// Receive epoch, seconds since J2000.
424    pub t_rx_j2000_s: f64,
425}
426
427/// The geometry-only result of one [`predict_ranges`] request.
428///
429/// A projection of [`transmit_time_satellite_state`]: the transmit-time geometry
430/// a range-only consumer needs, without the Doppler / topocentric fields.
431#[derive(Debug, Clone, Copy, PartialEq)]
432pub struct RangePrediction {
433    /// Geometric range after optional Sagnac transport, meters.
434    pub geometric_range_m: f64,
435    /// Satellite clock offset at transmit time, seconds (`None` if absent).
436    pub sat_clock_s: Option<f64>,
437    /// Transmit time as seconds since J2000.
438    pub transmit_time_j2000_s: f64,
439    /// Sagnac-transported satellite ECEF position, meters.
440    pub sat_pos_ecef_m: [f64; 3],
441}
442
443/// Predict geometric ranges for many `(satellite, receiver, epoch)` requests in
444/// one call, writing into a caller-provided `out` slice.
445///
446/// `out[i]` is filled from `requests[i]` using the same per-request
447/// [`transmit_time_satellite_state`] machinery (light-time iteration + Sagnac
448/// transport), so the batch is bit-identical to calling that predictor in a loop
449/// and projecting the geometry fields; this is amortization of the call boundary
450/// only, not a different algorithm. `options.carrier_hz` is unused (ranges carry
451/// no Doppler); `options.light_time` / `options.sagnac` are honored.
452///
453/// Errors:
454/// - [`ObservablesError::InvalidInput`] with field `out` if `out.len()` differs
455///   from `requests.len()`.
456/// - The first request error (invalid input or missing ephemeris) aborts the
457///   batch and is returned; `out` is then partially written.
458pub fn predict_ranges(
459    source: &dyn ObservableEphemerisSource,
460    requests: &[RangePredictionRequest],
461    options: PredictOptions,
462    out: &mut [RangePrediction],
463) -> Result<(), ObservablesError> {
464    if out.len() != requests.len() {
465        return Err(ObservablesError::InvalidInput {
466            field: "out",
467            kind: ObservablesInputErrorKind::OutOfRange,
468        });
469    }
470    let tt_options = TransmitTimeOptions {
471        light_time: options.light_time,
472        sagnac: options.sagnac,
473    };
474    for (request, slot) in requests.iter().zip(out.iter_mut()) {
475        let state = transmit_time_satellite_state(
476            source,
477            request.sat,
478            request.receiver_ecef_m,
479            request.t_rx_j2000_s,
480            tt_options,
481        )?;
482        *slot = RangePrediction {
483            geometric_range_m: state.geometric_range_m,
484            sat_clock_s: state.clock_s,
485            transmit_time_j2000_s: state.transmit_time_j2000_s,
486            sat_pos_ecef_m: state.position_ecef_m,
487        };
488    }
489    Ok(())
490}
491
492#[derive(Debug, Clone, Copy)]
493struct SolvedTransmitTime {
494    tau_s: f64,
495    transmit_offset_us: i64,
496    transmit_time_j2000_s: f64,
497    state: ObservableState,
498    sat_rot_ecef_m: [f64; 3],
499}
500
501fn solve_transmit_time(
502    source: &dyn ObservableEphemerisSource,
503    sat: GnssSatelliteId,
504    receiver_ecef_m: [f64; 3],
505    t_rx_j2000_s: f64,
506    options: PredictOptions,
507) -> Result<SolvedTransmitTime, ObservablesError> {
508    if !options.light_time {
509        let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
510        let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
511        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
512        return Ok(SolvedTransmitTime {
513            tau_s: 0.0,
514            transmit_offset_us: 0,
515            transmit_time_j2000_s: t_rx_j2000_s,
516            state,
517            sat_rot_ecef_m: sat_rot,
518        });
519    }
520
521    let mut tau = 0.0;
522    for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
523        let transmit_offset_us = microseconds_from_tau(tau);
524        let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
525        let state = validated_state_at_j2000_s(source, sat, t_tx)?;
526        let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
527        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
528        let dx = sat_rot[0] - receiver_ecef_m[0];
529        let dy = sat_rot[1] - receiver_ecef_m[1];
530        let dz = sat_rot[2] - receiver_ecef_m[2];
531        let range = geometric_range_m([dx, dy, dz])?;
532        let new_tau = range / C_M_S;
533
534        if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
535            return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
536        }
537
538        tau = new_tau;
539    }
540
541    unreachable!("fixed transmit-time loop always returns on its last iteration")
542}
543
544fn finalize_transmit_time(
545    source: &dyn ObservableEphemerisSource,
546    sat: GnssSatelliteId,
547    t_rx_j2000_s: f64,
548    tau: f64,
549    sagnac: bool,
550) -> Result<SolvedTransmitTime, ObservablesError> {
551    let transmit_offset_us = microseconds_from_tau(tau);
552    let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
553    validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
554    let state = validated_state_at_j2000_s(source, sat, t_tx)?;
555    let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
556    validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
557    Ok(SolvedTransmitTime {
558        tau_s: tau,
559        transmit_offset_us,
560        transmit_time_j2000_s: t_tx,
561        state,
562        sat_rot_ecef_m: sat_rot,
563    })
564}
565
566fn microseconds_from_tau(tau_s: f64) -> i64 {
567    (tau_s * MICROSECONDS_PER_SECOND).round() as i64
568}
569
570fn satellite_velocity(
571    source: &dyn ObservableEphemerisSource,
572    sat: GnssSatelliteId,
573    t_tx_j2000_s: f64,
574) -> Result<[f64; 3], ObservablesError> {
575    let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
576    let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
577    let denom = 2.0 * FD_HALF_S;
578    let velocity = [
579        (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
580        (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
581        (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
582    ];
583    validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
584}
585
586fn validate_predict_inputs(
587    receiver_ecef_m: [f64; 3],
588    t_rx_j2000_s: f64,
589    options: PredictOptions,
590) -> Result<(), ObservablesError> {
591    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
592    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
593    validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
594    Ok(())
595}
596
597fn validate_transmit_time_inputs(
598    receiver_ecef_m: [f64; 3],
599    t_rx_j2000_s: f64,
600) -> Result<(), ObservablesError> {
601    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
602    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
603    Ok(())
604}
605
606fn validated_state_at_j2000_s(
607    source: &dyn ObservableEphemerisSource,
608    sat: GnssSatelliteId,
609    t_j2000_s: f64,
610) -> Result<ObservableState, ObservablesError> {
611    let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
612    validate_observable_state(&state)?;
613    Ok(state)
614}
615
616fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
617    validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
618        .map_err(map_input_error)?;
619    if let Some(clock_s) = state.clock_s {
620        validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
621    }
622    Ok(())
623}
624
625fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
626    let range = (delta_ecef_m[0] * delta_ecef_m[0]
627        + delta_ecef_m[1] * delta_ecef_m[1]
628        + delta_ecef_m[2] * delta_ecef_m[2])
629        .sqrt();
630    validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
631}
632
633fn map_input_error(error: validate::FieldError) -> ObservablesError {
634    ObservablesError::InvalidInput {
635        field: error.field(),
636        kind: ObservablesInputErrorKind::from(&error),
637    }
638}
639
640fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
641    let sagnac = if apply {
642        SagnacRecipe::ClosedFormZRotation
643    } else {
644        SagnacRecipe::Off
645    };
646    crate::estimation::substrate::range::rotate_transmit_satellite(
647        sagnac,
648        pos,
649        tau_s,
650        OMEGA_E_DOT_RAD_S,
651    )
652}
653
654fn topocentric(
655    receiver_ecef_m: [f64; 3],
656    delta_ecef_m: [f64; 3],
657    range_m: f64,
658) -> Result<(f64, f64), ObservablesError> {
659    let (lat_deg, lon_deg, _height_km) = itrs_to_geodetic_compute(
660        receiver_ecef_m[0] / KM_TO_M,
661        receiver_ecef_m[1] / KM_TO_M,
662        receiver_ecef_m[2] / KM_TO_M,
663    )
664    .map_err(|_| ObservablesError::InvalidInput {
665        field: "receiver_ecef_m",
666        kind: ObservablesInputErrorKind::OutOfRange,
667    })?;
668    // Sidereon' application oracle pins this multiply-then-divide order.
669    let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
670    let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
671
672    let sl = lat.sin();
673    let cl = lat.cos();
674    let so = lon.sin();
675    let co = lon.cos();
676
677    let dx = delta_ecef_m[0];
678    let dy = delta_ecef_m[1];
679    let dz = delta_ecef_m[2];
680
681    let e = -so * dx + co * dy;
682    let n = -sl * co * dx - sl * so * dy + cl * dz;
683    let u = cl * co * dx + cl * so * dy + sl * dz;
684
685    // Near the zenith the horizontal projection (e, n) is pure rounding noise,
686    // so azimuth is degenerate and defined to be 0.0 (RTKLIB satazel semantics).
687    // Outside that threshold the multiply-then-divide order is pinned by the
688    // application oracle.
689    let horiz_sq = e * e + n * n;
690    let mut azimuth_deg = if horiz_sq < AZIMUTH_ZENITH_EPS * range_m * range_m {
691        0.0
692    } else {
693        e.atan2(n) * DEGREES_PER_SEMICIRCLE / PI
694    };
695    if azimuth_deg < 0.0 {
696        azimuth_deg += DEGREES_PER_CIRCLE;
697    }
698    // range_m is an ECEF norm, so at an exact zenith u/range_m can round just
699    // past 1.0 and make asin return NaN. Clamp to the valid asin domain; this
700    // only touches the previously-NaN boundary and leaves in-range values bit
701    // identical.
702    let sin_elevation = (u / range_m).clamp(-1.0, 1.0);
703    let elevation_deg = sin_elevation.asin() * DEGREES_PER_SEMICIRCLE / PI;
704
705    validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
706    validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
707    Ok((elevation_deg, azimuth_deg))
708}
709
710#[cfg(test)]
711mod public_api_tests {
712    use super::*;
713    use crate::{GnssSatelliteId, GnssSystem};
714
715    #[derive(Debug, Clone, Copy)]
716    struct StaticSource {
717        state: ObservableState,
718    }
719
720    impl ObservableEphemerisSource for StaticSource {
721        fn observable_state_at_j2000_s(
722            &self,
723            _sat: GnssSatelliteId,
724            _t_j2000_s: f64,
725        ) -> Result<ObservableState, ObservablesError> {
726            Ok(self.state)
727        }
728    }
729
730    #[test]
731    fn predict_ranges_matches_transmit_time_loop_bitwise() {
732        let source = StaticSource {
733            state: ObservableState {
734                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
735                clock_s: Some(1.25e-6),
736            },
737        };
738        let options = PredictOptions {
739            carrier_hz: F_L1_HZ,
740            light_time: true,
741            sagnac: true,
742        };
743        let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
744        let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
745        let requests = [
746            RangePredictionRequest {
747                sat: sat1,
748                receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
749                t_rx_j2000_s: 646_272_000.0,
750            },
751            RangePredictionRequest {
752                sat: sat2,
753                receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
754                t_rx_j2000_s: 646_272_060.0,
755            },
756        ];
757        let mut out = [RangePrediction {
758            geometric_range_m: 0.0,
759            sat_clock_s: None,
760            transmit_time_j2000_s: 0.0,
761            sat_pos_ecef_m: [0.0; 3],
762        }; 2];
763        predict_ranges(&source, &requests, options, &mut out).expect("batch range prediction");
764
765        let tt_options = TransmitTimeOptions {
766            light_time: options.light_time,
767            sagnac: options.sagnac,
768        };
769        for (request, got) in requests.iter().zip(out.iter()) {
770            let single = transmit_time_satellite_state(
771                &source,
772                request.sat,
773                request.receiver_ecef_m,
774                request.t_rx_j2000_s,
775                tt_options,
776            )
777            .expect("single transmit-time state");
778            assert_eq!(
779                got.geometric_range_m.to_bits(),
780                single.geometric_range_m.to_bits()
781            );
782            assert_eq!(
783                got.transmit_time_j2000_s.to_bits(),
784                single.transmit_time_j2000_s.to_bits()
785            );
786            assert_eq!(
787                got.sat_clock_s.map(f64::to_bits),
788                single.clock_s.map(f64::to_bits)
789            );
790            assert_eq!(
791                got.sat_pos_ecef_m.map(f64::to_bits),
792                single.position_ecef_m.map(f64::to_bits)
793            );
794        }
795    }
796
797    #[test]
798    fn predict_ranges_rejects_length_mismatch() {
799        let source = StaticSource {
800            state: ObservableState {
801                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
802                clock_s: None,
803            },
804        };
805        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
806        let requests = [RangePredictionRequest {
807            sat,
808            receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
809            t_rx_j2000_s: 646_272_000.0,
810        }];
811        let mut out: [RangePrediction; 0] = [];
812        let err = predict_ranges(&source, &requests, PredictOptions::default(), &mut out)
813            .expect_err("length mismatch must fail");
814        match err {
815            ObservablesError::InvalidInput { field, kind } => {
816                assert_eq!(field, "out");
817                assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
818            }
819            other => panic!("expected InvalidInput(out, OutOfRange), got {other:?}"),
820        }
821    }
822
823    #[test]
824    fn topocentric_elevation_is_ninety_at_non_equatorial_zenith() {
825        // A ~45 deg receiver (non-equatorial) with the satellite placed exactly
826        // along the receiver's recovered geodetic up, so the east/north
827        // projection is zero and the asin argument u/range lands on the domain
828        // boundary. For this receiver the rounding pushes u/range to just past
829        // 1.0 (1.0000000000000002): without the clamp asin returns NaN and the
830        // finite check turns it into an InvalidInput error. The clamp must yield
831        // a finite ~90 deg elevation instead. This exact receiver was found by
832        // scanning ECEF positions for the >1.0 rounding case.
833        let rx = [
834            4_509_179.095_483_66,
835            275_556.225_682_215_9,
836            4_487_348.408_865_919,
837        ];
838        let (lat_deg, lon_deg, _h) =
839            itrs_to_geodetic_compute(rx[0] / KM_TO_M, rx[1] / KM_TO_M, rx[2] / KM_TO_M)
840                .expect("receiver geodetic conversion");
841        assert!(lat_deg.abs() > 40.0, "receiver must be non-equatorial");
842
843        // Reconstruct the up unit vector exactly as `topocentric` does, then put
844        // the satellite straight overhead along it.
845        let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
846        let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
847        let (sl, cl) = lat.sin_cos();
848        let (so, co) = lon.sin_cos();
849        let up = [cl * co, cl * so, sl];
850
851        let d = 20_000_000.0_f64;
852        let delta = [up[0] * d, up[1] * d, up[2] * d];
853        let range = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
854        // Guard the regression premise: this geometry really does overshoot the
855        // asin domain (the bug this test pins).
856        let u = cl * co * delta[0] + cl * so * delta[1] + sl * delta[2];
857        assert!(
858            u / range > 1.0,
859            "test geometry must overshoot the asin domain"
860        );
861
862        let (elevation_deg, _azimuth_deg) =
863            topocentric(rx, delta, range).expect("non-equatorial zenith must not error");
864        assert!(elevation_deg.is_finite());
865        assert!((elevation_deg - 90.0).abs() < 1e-9);
866    }
867
868    #[test]
869    fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
870        let source = StaticSource {
871            state: ObservableState {
872                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
873                clock_s: Some(1.25e-6),
874            },
875        };
876        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
877        let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
878        let state = transmit_time_satellite_state(
879            &source,
880            sat,
881            rx,
882            646_272_000.0,
883            TransmitTimeOptions {
884                light_time: false,
885                sagnac: true,
886            },
887        )
888        .expect("state");
889        let prediction = predict(
890            &source,
891            sat,
892            rx,
893            646_272_000.0,
894            PredictOptions {
895                carrier_hz: F_L1_HZ,
896                light_time: false,
897                sagnac: true,
898            },
899        )
900        .expect("prediction");
901
902        assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
903        assert_eq!(state.transmit_offset_us, 0);
904        assert_eq!(
905            state.transmit_time_j2000_s.to_bits(),
906            646_272_000.0f64.to_bits()
907        );
908        assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
909        assert_eq!(
910            state.transmit_position_ecef_m.map(f64::to_bits),
911            source.state.position_ecef_m.map(f64::to_bits)
912        );
913        assert_eq!(
914            state.position_ecef_m.map(f64::to_bits),
915            prediction.sat_pos_ecef_m.map(f64::to_bits)
916        );
917        assert_eq!(
918            state.velocity_m_s.map(f64::to_bits),
919            prediction.sat_velocity_m_s.map(f64::to_bits)
920        );
921        assert_eq!(
922            state.geometric_range_m.to_bits(),
923            prediction.geometric_range_m.to_bits()
924        );
925        assert_eq!(
926            state.los_unit.map(f64::to_bits),
927            prediction.los_unit.map(f64::to_bits)
928        );
929    }
930}
931
932#[cfg(all(test, sidereon_repo_tests))]
933mod tests {
934    use super::*;
935    use crate::{GnssSatelliteId, GnssSystem};
936
937    #[derive(Debug, Clone, Copy)]
938    struct StaticSource {
939        state: ObservableState,
940    }
941
942    impl ObservableEphemerisSource for StaticSource {
943        fn observable_state_at_j2000_s(
944            &self,
945            _sat: GnssSatelliteId,
946            _t_j2000_s: f64,
947        ) -> Result<ObservableState, ObservablesError> {
948            Ok(self.state)
949        }
950    }
951
952    fn sp3_fixture() -> Sp3 {
953        let path = concat!(
954            env!("CARGO_MANIFEST_DIR"),
955            "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
956        );
957        let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
958        Sp3::parse(&bytes).expect("parse SP3 fixture")
959    }
960
961    fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
962        StaticSource {
963            state: ObservableState {
964                position_ecef_m,
965                clock_s: Some(0.0),
966            },
967        }
968    }
969
970    fn no_light_time_options() -> PredictOptions {
971        PredictOptions {
972            carrier_hz: F_L1_HZ,
973            light_time: false,
974            sagnac: true,
975        }
976    }
977
978    fn assert_invalid_observables_input(
979        err: ObservablesError,
980        field: &'static str,
981        kind: ObservablesInputErrorKind,
982    ) {
983        match err {
984            ObservablesError::InvalidInput {
985                field: got_field,
986                kind: got_kind,
987            } => {
988                assert_eq!(got_field, field);
989                assert_eq!(got_kind, kind);
990            }
991            other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
992        }
993    }
994
995    #[test]
996    fn split_julian_to_j2000_seconds_matches_orbis_time() {
997        let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
998        assert_eq!(t, 646_272_000.0);
999    }
1000
1001    #[test]
1002    fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
1003        for (jd_whole, jd_fraction, field) in [
1004            (f64::NAN, 0.5, "jd_whole"),
1005            (f64::INFINITY, 0.5, "jd_whole"),
1006            (2_459_024.5, f64::NAN, "jd_fraction"),
1007            (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
1008        ] {
1009            let err = j2000_seconds_from_split(jd_whole, jd_fraction)
1010                .expect_err("non-finite split Julian date part must fail");
1011            assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
1012        }
1013    }
1014
1015    #[test]
1016    fn sp3_predict_reference_case() {
1017        let sp3 = sp3_fixture();
1018        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1019        let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
1020        let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
1021            .expect("predict observables");
1022
1023        assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
1024        assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
1025        assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
1026        assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
1027        assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
1028        assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
1029        assert_eq!(obs.transmit_offset_us, 69_288);
1030        assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
1031        assert_eq!(
1032            obs.los_unit.map(f64::to_bits),
1033            [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
1034        );
1035        assert_eq!(
1036            obs.sat_pos_ecef_m.map(f64::to_bits),
1037            [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
1038        );
1039        assert_eq!(
1040            obs.sat_velocity_m_s.map(f64::to_bits),
1041            [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
1042        );
1043    }
1044
1045    #[test]
1046    fn predict_rejects_invalid_entry_inputs() {
1047        let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
1048        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1049
1050        let err = predict(
1051            &source,
1052            sat,
1053            [f64::NAN, 0.0, 0.0],
1054            646_272_000.0,
1055            no_light_time_options(),
1056        )
1057        .expect_err("non-finite receiver position must fail");
1058        assert_invalid_observables_input(
1059            err,
1060            "receiver_ecef_m",
1061            ObservablesInputErrorKind::NonFinite,
1062        );
1063
1064        let err = predict(
1065            &source,
1066            sat,
1067            [0.0, 0.0, 0.0],
1068            f64::INFINITY,
1069            no_light_time_options(),
1070        )
1071        .expect_err("non-finite receive time must fail");
1072        assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
1073
1074        let mut options = no_light_time_options();
1075        options.carrier_hz = 0.0;
1076        let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
1077            .expect_err("non-positive carrier must fail");
1078        assert_invalid_observables_input(
1079            err,
1080            "options.carrier_hz",
1081            ObservablesInputErrorKind::NotPositive,
1082        );
1083    }
1084
1085    #[test]
1086    fn predict_rejects_invalid_source_state_and_zero_range() {
1087        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1088
1089        let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
1090        let err = predict(
1091            &source,
1092            sat,
1093            [0.0, 0.0, 0.0],
1094            646_272_000.0,
1095            no_light_time_options(),
1096        )
1097        .expect_err("non-finite ephemeris position must fail");
1098        assert_invalid_observables_input(
1099            err,
1100            "observable state position_ecef_m",
1101            ObservablesInputErrorKind::NonFinite,
1102        );
1103
1104        let source = static_source([1_000.0, 2_000.0, 3_000.0]);
1105        let err = predict(
1106            &source,
1107            sat,
1108            [1_000.0, 2_000.0, 3_000.0],
1109            646_272_000.0,
1110            no_light_time_options(),
1111        )
1112        .expect_err("zero geometric range must fail");
1113        assert_invalid_observables_input(
1114            err,
1115            "geometric_range_m",
1116            ObservablesInputErrorKind::NotPositive,
1117        );
1118    }
1119
1120    #[test]
1121    fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
1122        let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
1123            .expect_err("invalid receiver geodetic conversion must fail");
1124
1125        assert_invalid_observables_input(
1126            err,
1127            "receiver_ecef_m",
1128            ObservablesInputErrorKind::OutOfRange,
1129        );
1130    }
1131
1132    // WGS84 equatorial radius; a receiver here sits at lat=0, lon=0, height~=0,
1133    // so the geodetic up direction is +X and a satellite displaced along +X is
1134    // exactly overhead (degenerate azimuth geometry).
1135    const EQUATORIAL_RX_X_M: f64 = 6_378_137.0;
1136
1137    #[test]
1138    fn topocentric_azimuth_is_zero_at_exact_zenith() {
1139        // Satellite displaced purely radially (+X) above an equatorial receiver:
1140        // east == north == 0, so azimuth is degenerate.
1141        let (elevation_deg, azimuth_deg) = topocentric(
1142            [EQUATORIAL_RX_X_M, 0.0, 0.0],
1143            [20_000_000.0, 0.0, 0.0],
1144            20_000_000.0,
1145        )
1146        .expect("zenith topocentric must not error");
1147        assert_eq!(azimuth_deg, 0.0);
1148        assert!(azimuth_deg.is_finite());
1149        assert!((elevation_deg - 90.0).abs() < 1e-9);
1150    }
1151
1152    #[test]
1153    fn topocentric_azimuth_is_zero_just_off_zenith() {
1154        // A ~1e-9 m horizontal nudge is pure rounding-scale noise at a 20,000 km
1155        // range, so azimuth stays pinned to 0.0 (RTKLIB satazel semantics).
1156        let (_, azimuth_deg) = topocentric(
1157            [EQUATORIAL_RX_X_M, 0.0, 0.0],
1158            [20_000_000.0, 1.0e-9, 1.0e-9],
1159            20_000_000.0,
1160        )
1161        .expect("near-zenith topocentric must not error");
1162        assert_eq!(azimuth_deg, 0.0);
1163        assert!(azimuth_deg.is_finite());
1164    }
1165
1166    #[test]
1167    fn predict_azimuth_is_zero_at_exact_zenith() {
1168        let source = StaticSource {
1169            state: ObservableState {
1170                position_ecef_m: [EQUATORIAL_RX_X_M + 20_000_000.0, 0.0, 0.0],
1171                clock_s: None,
1172            },
1173        };
1174        let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
1175        let obs = predict(
1176            &source,
1177            sat,
1178            [EQUATORIAL_RX_X_M, 0.0, 0.0],
1179            0.0,
1180            PredictOptions {
1181                carrier_hz: F_L1_HZ,
1182                light_time: false,
1183                sagnac: false,
1184            },
1185        )
1186        .expect("zenith predict must not error");
1187        assert_eq!(obs.azimuth_deg, 0.0);
1188        assert!(obs.azimuth_deg.is_finite());
1189        assert!((obs.elevation_deg - 90.0).abs() < 1e-9);
1190    }
1191
1192    fn batch_test_requests() -> Vec<PredictRequest> {
1193        let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1194        let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1195        vec![
1196            (sat1, [4_027_894.0, 307_046.0, 4_919_474.0], 646_272_000.0),
1197            (sat2, [4_027_900.0, 307_050.0, 4_919_480.0], 646_272_030.0),
1198            (
1199                sat1,
1200                [1_130_000.0, -4_830_000.0, 3_994_000.0],
1201                646_272_060.0,
1202            ),
1203            (
1204                sat2,
1205                [-2_700_000.0, -4_290_000.0, 3_855_000.0],
1206                646_272_090.0,
1207            ),
1208        ]
1209    }
1210
1211    fn assert_observables_bits_eq(a: &PredictedObservables, b: &PredictedObservables) {
1212        assert_eq!(a.geometric_range_m.to_bits(), b.geometric_range_m.to_bits());
1213        assert_eq!(a.range_rate_m_s.to_bits(), b.range_rate_m_s.to_bits());
1214        assert_eq!(a.doppler_hz.to_bits(), b.doppler_hz.to_bits());
1215        assert_eq!(a.elevation_deg.to_bits(), b.elevation_deg.to_bits());
1216        assert_eq!(a.azimuth_deg.to_bits(), b.azimuth_deg.to_bits());
1217        assert_eq!(a.transmit_offset_us, b.transmit_offset_us);
1218        assert_eq!(
1219            a.transmit_time_j2000_s.to_bits(),
1220            b.transmit_time_j2000_s.to_bits()
1221        );
1222        for k in 0..3 {
1223            assert_eq!(a.los_unit[k].to_bits(), b.los_unit[k].to_bits());
1224            assert_eq!(a.sat_pos_ecef_m[k].to_bits(), b.sat_pos_ecef_m[k].to_bits());
1225            assert_eq!(
1226                a.sat_velocity_m_s[k].to_bits(),
1227                b.sat_velocity_m_s[k].to_bits()
1228            );
1229        }
1230    }
1231
1232    #[test]
1233    fn predict_batch_matches_scalar_loop_bitwise() {
1234        let source = StaticSource {
1235            state: ObservableState {
1236                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1237                clock_s: Some(1.25e-6),
1238            },
1239        };
1240        let options = PredictOptions {
1241            carrier_hz: F_L1_HZ,
1242            light_time: true,
1243            sagnac: true,
1244        };
1245        let requests = batch_test_requests();
1246        let batch = predict_batch(&source, &requests, options);
1247        assert_eq!(batch.len(), requests.len());
1248        for (entry, &(sat, rx, t)) in batch.iter().zip(requests.iter()) {
1249            let scalar = predict(&source, sat, rx, t, options);
1250            match (entry, &scalar) {
1251                (Ok(b), Ok(s)) => assert_observables_bits_eq(b, s),
1252                (Err(_), Err(_)) => {}
1253                _ => panic!("batch and scalar predict disagree on Ok/Err"),
1254            }
1255        }
1256    }
1257
1258    #[test]
1259    fn predict_batch_parallel_matches_serial_bitwise() {
1260        let source = StaticSource {
1261            state: ObservableState {
1262                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1263                clock_s: Some(1.25e-6),
1264            },
1265        };
1266        let options = PredictOptions {
1267            carrier_hz: F_L1_HZ,
1268            light_time: true,
1269            sagnac: true,
1270        };
1271        let requests = batch_test_requests();
1272        let serial = predict_batch(&source, &requests, options);
1273        let parallel = predict_batch_parallel(&source, &requests, options);
1274        assert_eq!(serial.len(), parallel.len());
1275        for (s, p) in serial.iter().zip(parallel.iter()) {
1276            match (s, p) {
1277                (Ok(a), Ok(b)) => assert_observables_bits_eq(a, b),
1278                (Err(_), Err(_)) => {}
1279                _ => panic!("serial and parallel batch disagree on Ok/Err"),
1280            }
1281        }
1282    }
1283}