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