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]` using the same per-request
620/// [`transmit_time_satellite_state`] machinery (light-time iteration + Sagnac
621/// transport), so the batch is bit-identical to calling that predictor in a loop
622/// and projecting the geometry fields; this is amortization of the call boundary
623/// only, not a different algorithm. `options.carrier_hz` is unused (ranges carry
624/// no Doppler); `options.light_time` / `options.sagnac` are honored.
625///
626/// Errors:
627/// - [`ObservablesError::InvalidInput`] with field `out` if `out.len()` differs
628///   from `requests.len()`.
629/// - The first request error (invalid input or missing ephemeris) aborts the
630///   batch and is returned; `out` is then partially written.
631pub fn predict_ranges(
632    source: &dyn ObservableEphemerisSource,
633    requests: &[RangePredictionRequest],
634    options: PredictOptions,
635    out: &mut [RangePrediction],
636) -> Result<(), ObservablesError> {
637    if out.len() != requests.len() {
638        return Err(ObservablesError::InvalidInput {
639            field: "out",
640            kind: ObservablesInputErrorKind::OutOfRange,
641        });
642    }
643    let tt_options = TransmitTimeOptions {
644        light_time: options.light_time,
645        sagnac: options.sagnac,
646    };
647    for (request, slot) in requests.iter().zip(out.iter_mut()) {
648        let state = transmit_time_satellite_state(
649            source,
650            request.sat,
651            request.receiver_ecef_m,
652            request.t_rx_j2000_s,
653            tt_options,
654        )?;
655        *slot = RangePrediction {
656            geometric_range_m: state.geometric_range_m,
657            sat_clock_s: state.clock_s,
658            transmit_time_j2000_s: state.transmit_time_j2000_s,
659            sat_pos_ecef_m: state.position_ecef_m,
660        };
661    }
662    Ok(())
663}
664
665#[derive(Debug, Clone, Copy)]
666struct SolvedTransmitTime {
667    tau_s: f64,
668    transmit_offset_us: i64,
669    transmit_time_j2000_s: f64,
670    state: ObservableState,
671    sat_rot_ecef_m: [f64; 3],
672}
673
674fn solve_transmit_time(
675    source: &dyn ObservableEphemerisSource,
676    sat: GnssSatelliteId,
677    receiver_ecef_m: [f64; 3],
678    t_rx_j2000_s: f64,
679    options: PredictOptions,
680) -> Result<SolvedTransmitTime, ObservablesError> {
681    if !options.light_time {
682        let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
683        let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
684        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
685        return Ok(SolvedTransmitTime {
686            tau_s: 0.0,
687            transmit_offset_us: 0,
688            transmit_time_j2000_s: t_rx_j2000_s,
689            state,
690            sat_rot_ecef_m: sat_rot,
691        });
692    }
693
694    let mut tau = 0.0;
695    for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
696        let transmit_offset_us = microseconds_from_tau(tau);
697        let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
698        let state = validated_state_at_j2000_s(source, sat, t_tx)?;
699        let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
700        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
701        let dx = sat_rot[0] - receiver_ecef_m[0];
702        let dy = sat_rot[1] - receiver_ecef_m[1];
703        let dz = sat_rot[2] - receiver_ecef_m[2];
704        let range = geometric_range_m([dx, dy, dz])?;
705        let new_tau = range / C_M_S;
706
707        if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
708            return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
709        }
710
711        tau = new_tau;
712    }
713
714    unreachable!("fixed transmit-time loop always returns on its last iteration")
715}
716
717fn finalize_transmit_time(
718    source: &dyn ObservableEphemerisSource,
719    sat: GnssSatelliteId,
720    t_rx_j2000_s: f64,
721    tau: f64,
722    sagnac: bool,
723) -> Result<SolvedTransmitTime, ObservablesError> {
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    validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
727    let state = validated_state_at_j2000_s(source, sat, t_tx)?;
728    let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
729    validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
730    Ok(SolvedTransmitTime {
731        tau_s: tau,
732        transmit_offset_us,
733        transmit_time_j2000_s: t_tx,
734        state,
735        sat_rot_ecef_m: sat_rot,
736    })
737}
738
739fn microseconds_from_tau(tau_s: f64) -> i64 {
740    (tau_s * MICROSECONDS_PER_SECOND).round() as i64
741}
742
743fn satellite_velocity(
744    source: &dyn ObservableEphemerisSource,
745    sat: GnssSatelliteId,
746    t_tx_j2000_s: f64,
747) -> Result<[f64; 3], ObservablesError> {
748    let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
749    let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
750    let denom = 2.0 * FD_HALF_S;
751    let velocity = [
752        (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
753        (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
754        (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
755    ];
756    validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
757}
758
759fn validate_predict_inputs(
760    receiver_ecef_m: [f64; 3],
761    t_rx_j2000_s: f64,
762    options: PredictOptions,
763) -> Result<(), ObservablesError> {
764    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
765    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
766    validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
767    Ok(())
768}
769
770fn validate_transmit_time_inputs(
771    receiver_ecef_m: [f64; 3],
772    t_rx_j2000_s: f64,
773) -> Result<(), ObservablesError> {
774    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
775    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
776    Ok(())
777}
778
779fn validated_state_at_j2000_s(
780    source: &dyn ObservableEphemerisSource,
781    sat: GnssSatelliteId,
782    t_j2000_s: f64,
783) -> Result<ObservableState, ObservablesError> {
784    let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
785    validate_observable_state(&state)?;
786    Ok(state)
787}
788
789fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
790    validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
791        .map_err(map_input_error)?;
792    if let Some(clock_s) = state.clock_s {
793        validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
794    }
795    Ok(())
796}
797
798fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
799    let range = (delta_ecef_m[0] * delta_ecef_m[0]
800        + delta_ecef_m[1] * delta_ecef_m[1]
801        + delta_ecef_m[2] * delta_ecef_m[2])
802        .sqrt();
803    validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
804}
805
806fn map_input_error(error: validate::FieldError) -> ObservablesError {
807    ObservablesError::InvalidInput {
808        field: error.field(),
809        kind: ObservablesInputErrorKind::from(&error),
810    }
811}
812
813fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
814    let sagnac = if apply {
815        SagnacRecipe::ClosedFormZRotation
816    } else {
817        SagnacRecipe::Off
818    };
819    crate::estimation::substrate::range::rotate_transmit_satellite(
820        sagnac,
821        pos,
822        tau_s,
823        OMEGA_E_DOT_RAD_S,
824    )
825}
826
827fn topocentric(
828    receiver_ecef_m: [f64; 3],
829    delta_ecef_m: [f64; 3],
830    range_m: f64,
831) -> Result<(f64, f64), ObservablesError> {
832    let (lat_deg, lon_deg, _height_km) = itrs_to_geodetic_compute(
833        receiver_ecef_m[0] / KM_TO_M,
834        receiver_ecef_m[1] / KM_TO_M,
835        receiver_ecef_m[2] / KM_TO_M,
836    )
837    .map_err(|_| ObservablesError::InvalidInput {
838        field: "receiver_ecef_m",
839        kind: ObservablesInputErrorKind::OutOfRange,
840    })?;
841    // Sidereon' application oracle pins this multiply-then-divide order.
842    let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
843    let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
844
845    let sl = lat.sin();
846    let cl = lat.cos();
847    let so = lon.sin();
848    let co = lon.cos();
849
850    let dx = delta_ecef_m[0];
851    let dy = delta_ecef_m[1];
852    let dz = delta_ecef_m[2];
853
854    let e = -so * dx + co * dy;
855    let n = -sl * co * dx - sl * so * dy + cl * dz;
856    let u = cl * co * dx + cl * so * dy + sl * dz;
857
858    // Near the zenith the horizontal projection (e, n) is pure rounding noise,
859    // so azimuth is degenerate and defined to be 0.0 (RTKLIB satazel semantics).
860    // Outside that threshold the multiply-then-divide order is pinned by the
861    // application oracle.
862    let horiz_sq = e * e + n * n;
863    let mut azimuth_deg = if horiz_sq < AZIMUTH_ZENITH_EPS * range_m * range_m {
864        0.0
865    } else {
866        e.atan2(n) * DEGREES_PER_SEMICIRCLE / PI
867    };
868    if azimuth_deg < 0.0 {
869        azimuth_deg += DEGREES_PER_CIRCLE;
870    }
871    // range_m is an ECEF norm, so at an exact zenith u/range_m can round just
872    // past 1.0 and make asin return NaN. Clamp to the valid asin domain; this
873    // only touches the previously-NaN boundary and leaves in-range values bit
874    // identical.
875    let sin_elevation = (u / range_m).clamp(-1.0, 1.0);
876    let elevation_deg = sin_elevation.asin() * DEGREES_PER_SEMICIRCLE / PI;
877
878    validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
879    validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
880    Ok((elevation_deg, azimuth_deg))
881}
882
883#[cfg(test)]
884mod public_api_tests {
885    use super::*;
886    use crate::{GnssSatelliteId, GnssSystem};
887
888    #[derive(Debug, Clone, Copy)]
889    struct StaticSource {
890        state: ObservableState,
891    }
892
893    impl ObservableEphemerisSource for StaticSource {
894        fn observable_state_at_j2000_s(
895            &self,
896            _sat: GnssSatelliteId,
897            _t_j2000_s: f64,
898        ) -> Result<ObservableState, ObservablesError> {
899            Ok(self.state)
900        }
901    }
902
903    #[test]
904    fn predict_ranges_matches_transmit_time_loop_bitwise() {
905        let source = StaticSource {
906            state: ObservableState {
907                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
908                clock_s: Some(1.25e-6),
909            },
910        };
911        let options = PredictOptions {
912            carrier_hz: F_L1_HZ,
913            light_time: true,
914            sagnac: true,
915        };
916        let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
917        let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
918        let requests = [
919            RangePredictionRequest {
920                sat: sat1,
921                receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
922                t_rx_j2000_s: 646_272_000.0,
923            },
924            RangePredictionRequest {
925                sat: sat2,
926                receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
927                t_rx_j2000_s: 646_272_060.0,
928            },
929        ];
930        let mut out = [RangePrediction {
931            geometric_range_m: 0.0,
932            sat_clock_s: None,
933            transmit_time_j2000_s: 0.0,
934            sat_pos_ecef_m: [0.0; 3],
935        }; 2];
936        predict_ranges(&source, &requests, options, &mut out).expect("batch range prediction");
937
938        let tt_options = TransmitTimeOptions {
939            light_time: options.light_time,
940            sagnac: options.sagnac,
941        };
942        for (request, got) in requests.iter().zip(out.iter()) {
943            let single = transmit_time_satellite_state(
944                &source,
945                request.sat,
946                request.receiver_ecef_m,
947                request.t_rx_j2000_s,
948                tt_options,
949            )
950            .expect("single transmit-time state");
951            assert_eq!(
952                got.geometric_range_m.to_bits(),
953                single.geometric_range_m.to_bits()
954            );
955            assert_eq!(
956                got.transmit_time_j2000_s.to_bits(),
957                single.transmit_time_j2000_s.to_bits()
958            );
959            assert_eq!(
960                got.sat_clock_s.map(f64::to_bits),
961                single.clock_s.map(f64::to_bits)
962            );
963            assert_eq!(
964                got.sat_pos_ecef_m.map(f64::to_bits),
965                single.position_ecef_m.map(f64::to_bits)
966            );
967        }
968    }
969
970    #[test]
971    fn predict_ranges_rejects_length_mismatch() {
972        let source = StaticSource {
973            state: ObservableState {
974                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
975                clock_s: None,
976            },
977        };
978        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
979        let requests = [RangePredictionRequest {
980            sat,
981            receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
982            t_rx_j2000_s: 646_272_000.0,
983        }];
984        let mut out: [RangePrediction; 0] = [];
985        let err = predict_ranges(&source, &requests, PredictOptions::default(), &mut out)
986            .expect_err("length mismatch must fail");
987        match err {
988            ObservablesError::InvalidInput { field, kind } => {
989                assert_eq!(field, "out");
990                assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
991            }
992            other => panic!("expected InvalidInput(out, OutOfRange), got {other:?}"),
993        }
994    }
995
996    #[test]
997    fn topocentric_elevation_is_ninety_at_non_equatorial_zenith() {
998        // A ~45 deg receiver (non-equatorial) with the satellite placed exactly
999        // along the receiver's recovered geodetic up, so the east/north
1000        // projection is zero and the asin argument u/range lands on the domain
1001        // boundary. For this receiver the rounding pushes u/range to just past
1002        // 1.0 (1.0000000000000002): without the clamp asin returns NaN and the
1003        // finite check turns it into an InvalidInput error. The clamp must yield
1004        // a finite ~90 deg elevation instead. This exact receiver was found by
1005        // scanning ECEF positions for the >1.0 rounding case.
1006        let rx = [
1007            4_509_179.095_483_66,
1008            275_556.225_682_215_9,
1009            4_487_348.408_865_919,
1010        ];
1011        let (lat_deg, lon_deg, _h) =
1012            itrs_to_geodetic_compute(rx[0] / KM_TO_M, rx[1] / KM_TO_M, rx[2] / KM_TO_M)
1013                .expect("receiver geodetic conversion");
1014        assert!(lat_deg.abs() > 40.0, "receiver must be non-equatorial");
1015
1016        // Reconstruct the up unit vector exactly as `topocentric` does, then put
1017        // the satellite straight overhead along it.
1018        let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1019        let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1020        let (sl, cl) = lat.sin_cos();
1021        let (so, co) = lon.sin_cos();
1022        let up = [cl * co, cl * so, sl];
1023
1024        let d = 20_000_000.0_f64;
1025        let delta = [up[0] * d, up[1] * d, up[2] * d];
1026        let range = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
1027        // Guard the regression premise: this geometry really does overshoot the
1028        // asin domain (the bug this test pins).
1029        let u = cl * co * delta[0] + cl * so * delta[1] + sl * delta[2];
1030        assert!(
1031            u / range > 1.0,
1032            "test geometry must overshoot the asin domain"
1033        );
1034
1035        let (elevation_deg, _azimuth_deg) =
1036            topocentric(rx, delta, range).expect("non-equatorial zenith must not error");
1037        assert!(elevation_deg.is_finite());
1038        assert!((elevation_deg - 90.0).abs() < 1e-9);
1039    }
1040
1041    #[test]
1042    fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
1043        let source = StaticSource {
1044            state: ObservableState {
1045                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1046                clock_s: Some(1.25e-6),
1047            },
1048        };
1049        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1050        let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
1051        let state = transmit_time_satellite_state(
1052            &source,
1053            sat,
1054            rx,
1055            646_272_000.0,
1056            TransmitTimeOptions {
1057                light_time: false,
1058                sagnac: true,
1059            },
1060        )
1061        .expect("state");
1062        let prediction = predict(
1063            &source,
1064            sat,
1065            rx,
1066            646_272_000.0,
1067            PredictOptions {
1068                carrier_hz: F_L1_HZ,
1069                light_time: false,
1070                sagnac: true,
1071            },
1072        )
1073        .expect("prediction");
1074
1075        assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
1076        assert_eq!(state.transmit_offset_us, 0);
1077        assert_eq!(
1078            state.transmit_time_j2000_s.to_bits(),
1079            646_272_000.0f64.to_bits()
1080        );
1081        assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
1082        assert_eq!(
1083            state.transmit_position_ecef_m.map(f64::to_bits),
1084            source.state.position_ecef_m.map(f64::to_bits)
1085        );
1086        assert_eq!(
1087            state.position_ecef_m.map(f64::to_bits),
1088            prediction.sat_pos_ecef_m.map(f64::to_bits)
1089        );
1090        assert_eq!(
1091            state.velocity_m_s.map(f64::to_bits),
1092            prediction.sat_velocity_m_s.map(f64::to_bits)
1093        );
1094        assert_eq!(
1095            state.geometric_range_m.to_bits(),
1096            prediction.geometric_range_m.to_bits()
1097        );
1098        assert_eq!(
1099            state.los_unit.map(f64::to_bits),
1100            prediction.los_unit.map(f64::to_bits)
1101        );
1102    }
1103}
1104
1105#[cfg(all(test, sidereon_repo_tests))]
1106mod tests {
1107    use super::*;
1108    use crate::{GnssSatelliteId, GnssSystem};
1109
1110    #[derive(Debug, Clone, Copy)]
1111    struct StaticSource {
1112        state: ObservableState,
1113    }
1114
1115    impl ObservableEphemerisSource for StaticSource {
1116        fn observable_state_at_j2000_s(
1117            &self,
1118            _sat: GnssSatelliteId,
1119            _t_j2000_s: f64,
1120        ) -> Result<ObservableState, ObservablesError> {
1121            Ok(self.state)
1122        }
1123    }
1124
1125    fn sp3_fixture() -> Sp3 {
1126        let path = concat!(
1127            env!("CARGO_MANIFEST_DIR"),
1128            "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
1129        );
1130        let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
1131        Sp3::parse(&bytes).expect("parse SP3 fixture")
1132    }
1133
1134    fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
1135        StaticSource {
1136            state: ObservableState {
1137                position_ecef_m,
1138                clock_s: Some(0.0),
1139            },
1140        }
1141    }
1142
1143    fn no_light_time_options() -> PredictOptions {
1144        PredictOptions {
1145            carrier_hz: F_L1_HZ,
1146            light_time: false,
1147            sagnac: true,
1148        }
1149    }
1150
1151    fn assert_invalid_observables_input(
1152        err: ObservablesError,
1153        field: &'static str,
1154        kind: ObservablesInputErrorKind,
1155    ) {
1156        match err {
1157            ObservablesError::InvalidInput {
1158                field: got_field,
1159                kind: got_kind,
1160            } => {
1161                assert_eq!(got_field, field);
1162                assert_eq!(got_kind, kind);
1163            }
1164            other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
1165        }
1166    }
1167
1168    #[test]
1169    fn split_julian_to_j2000_seconds_matches_orbis_time() {
1170        let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
1171        assert_eq!(t, 646_272_000.0);
1172    }
1173
1174    #[test]
1175    fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
1176        for (jd_whole, jd_fraction, field) in [
1177            (f64::NAN, 0.5, "jd_whole"),
1178            (f64::INFINITY, 0.5, "jd_whole"),
1179            (2_459_024.5, f64::NAN, "jd_fraction"),
1180            (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
1181        ] {
1182            let err = j2000_seconds_from_split(jd_whole, jd_fraction)
1183                .expect_err("non-finite split Julian date part must fail");
1184            assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
1185        }
1186    }
1187
1188    #[test]
1189    fn sp3_predict_reference_case() {
1190        let sp3 = sp3_fixture();
1191        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1192        let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
1193        let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
1194            .expect("predict observables");
1195
1196        assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
1197        assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
1198        assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
1199        assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
1200        assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
1201        assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
1202        assert_eq!(obs.transmit_offset_us, 69_288);
1203        assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
1204        assert_eq!(
1205            obs.los_unit.map(f64::to_bits),
1206            [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
1207        );
1208        assert_eq!(
1209            obs.sat_pos_ecef_m.map(f64::to_bits),
1210            [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
1211        );
1212        assert_eq!(
1213            obs.sat_velocity_m_s.map(f64::to_bits),
1214            [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
1215        );
1216    }
1217
1218    #[test]
1219    fn predict_rejects_invalid_entry_inputs() {
1220        let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
1221        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1222
1223        let err = predict(
1224            &source,
1225            sat,
1226            [f64::NAN, 0.0, 0.0],
1227            646_272_000.0,
1228            no_light_time_options(),
1229        )
1230        .expect_err("non-finite receiver position must fail");
1231        assert_invalid_observables_input(
1232            err,
1233            "receiver_ecef_m",
1234            ObservablesInputErrorKind::NonFinite,
1235        );
1236
1237        let err = predict(
1238            &source,
1239            sat,
1240            [0.0, 0.0, 0.0],
1241            f64::INFINITY,
1242            no_light_time_options(),
1243        )
1244        .expect_err("non-finite receive time must fail");
1245        assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
1246
1247        let mut options = no_light_time_options();
1248        options.carrier_hz = 0.0;
1249        let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
1250            .expect_err("non-positive carrier must fail");
1251        assert_invalid_observables_input(
1252            err,
1253            "options.carrier_hz",
1254            ObservablesInputErrorKind::NotPositive,
1255        );
1256    }
1257
1258    #[test]
1259    fn predict_rejects_invalid_source_state_and_zero_range() {
1260        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1261
1262        let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
1263        let err = predict(
1264            &source,
1265            sat,
1266            [0.0, 0.0, 0.0],
1267            646_272_000.0,
1268            no_light_time_options(),
1269        )
1270        .expect_err("non-finite ephemeris position must fail");
1271        assert_invalid_observables_input(
1272            err,
1273            "observable state position_ecef_m",
1274            ObservablesInputErrorKind::NonFinite,
1275        );
1276
1277        let source = static_source([1_000.0, 2_000.0, 3_000.0]);
1278        let err = predict(
1279            &source,
1280            sat,
1281            [1_000.0, 2_000.0, 3_000.0],
1282            646_272_000.0,
1283            no_light_time_options(),
1284        )
1285        .expect_err("zero geometric range must fail");
1286        assert_invalid_observables_input(
1287            err,
1288            "geometric_range_m",
1289            ObservablesInputErrorKind::NotPositive,
1290        );
1291    }
1292
1293    #[test]
1294    fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
1295        let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
1296            .expect_err("invalid receiver geodetic conversion must fail");
1297
1298        assert_invalid_observables_input(
1299            err,
1300            "receiver_ecef_m",
1301            ObservablesInputErrorKind::OutOfRange,
1302        );
1303    }
1304
1305    // WGS84 equatorial radius; a receiver here sits at lat=0, lon=0, height~=0,
1306    // so the geodetic up direction is +X and a satellite displaced along +X is
1307    // exactly overhead (degenerate azimuth geometry).
1308    const EQUATORIAL_RX_X_M: f64 = 6_378_137.0;
1309
1310    #[test]
1311    fn topocentric_azimuth_is_zero_at_exact_zenith() {
1312        // Satellite displaced purely radially (+X) above an equatorial receiver:
1313        // east == north == 0, so azimuth is degenerate.
1314        let (elevation_deg, azimuth_deg) = topocentric(
1315            [EQUATORIAL_RX_X_M, 0.0, 0.0],
1316            [20_000_000.0, 0.0, 0.0],
1317            20_000_000.0,
1318        )
1319        .expect("zenith topocentric must not error");
1320        assert_eq!(azimuth_deg, 0.0);
1321        assert!(azimuth_deg.is_finite());
1322        assert!((elevation_deg - 90.0).abs() < 1e-9);
1323    }
1324
1325    #[test]
1326    fn topocentric_azimuth_is_zero_just_off_zenith() {
1327        // A ~1e-9 m horizontal nudge is pure rounding-scale noise at a 20,000 km
1328        // range, so azimuth stays pinned to 0.0 (RTKLIB satazel semantics).
1329        let (_, azimuth_deg) = topocentric(
1330            [EQUATORIAL_RX_X_M, 0.0, 0.0],
1331            [20_000_000.0, 1.0e-9, 1.0e-9],
1332            20_000_000.0,
1333        )
1334        .expect("near-zenith topocentric must not error");
1335        assert_eq!(azimuth_deg, 0.0);
1336        assert!(azimuth_deg.is_finite());
1337    }
1338
1339    #[test]
1340    fn predict_azimuth_is_zero_at_exact_zenith() {
1341        let source = StaticSource {
1342            state: ObservableState {
1343                position_ecef_m: [EQUATORIAL_RX_X_M + 20_000_000.0, 0.0, 0.0],
1344                clock_s: None,
1345            },
1346        };
1347        let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
1348        let obs = predict(
1349            &source,
1350            sat,
1351            [EQUATORIAL_RX_X_M, 0.0, 0.0],
1352            0.0,
1353            PredictOptions {
1354                carrier_hz: F_L1_HZ,
1355                light_time: false,
1356                sagnac: false,
1357            },
1358        )
1359        .expect("zenith predict must not error");
1360        assert_eq!(obs.azimuth_deg, 0.0);
1361        assert!(obs.azimuth_deg.is_finite());
1362        assert!((obs.elevation_deg - 90.0).abs() < 1e-9);
1363    }
1364
1365    fn batch_test_requests() -> Vec<PredictRequest> {
1366        let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1367        let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1368        vec![
1369            (sat1, [4_027_894.0, 307_046.0, 4_919_474.0], 646_272_000.0),
1370            (sat2, [4_027_900.0, 307_050.0, 4_919_480.0], 646_272_030.0),
1371            (
1372                sat1,
1373                [1_130_000.0, -4_830_000.0, 3_994_000.0],
1374                646_272_060.0,
1375            ),
1376            (
1377                sat2,
1378                [-2_700_000.0, -4_290_000.0, 3_855_000.0],
1379                646_272_090.0,
1380            ),
1381        ]
1382    }
1383
1384    fn assert_observables_bits_eq(a: &PredictedObservables, b: &PredictedObservables) {
1385        assert_eq!(a.geometric_range_m.to_bits(), b.geometric_range_m.to_bits());
1386        assert_eq!(a.range_rate_m_s.to_bits(), b.range_rate_m_s.to_bits());
1387        assert_eq!(a.doppler_hz.to_bits(), b.doppler_hz.to_bits());
1388        assert_eq!(a.elevation_deg.to_bits(), b.elevation_deg.to_bits());
1389        assert_eq!(a.azimuth_deg.to_bits(), b.azimuth_deg.to_bits());
1390        assert_eq!(a.transmit_offset_us, b.transmit_offset_us);
1391        assert_eq!(
1392            a.transmit_time_j2000_s.to_bits(),
1393            b.transmit_time_j2000_s.to_bits()
1394        );
1395        for k in 0..3 {
1396            assert_eq!(a.los_unit[k].to_bits(), b.los_unit[k].to_bits());
1397            assert_eq!(a.sat_pos_ecef_m[k].to_bits(), b.sat_pos_ecef_m[k].to_bits());
1398            assert_eq!(
1399                a.sat_velocity_m_s[k].to_bits(),
1400                b.sat_velocity_m_s[k].to_bits()
1401            );
1402        }
1403    }
1404
1405    #[test]
1406    fn predict_batch_matches_scalar_loop_bitwise() {
1407        let source = StaticSource {
1408            state: ObservableState {
1409                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1410                clock_s: Some(1.25e-6),
1411            },
1412        };
1413        let options = PredictOptions {
1414            carrier_hz: F_L1_HZ,
1415            light_time: true,
1416            sagnac: true,
1417        };
1418        let requests = batch_test_requests();
1419        let batch = predict_batch(&source, &requests, options);
1420        assert_eq!(batch.len(), requests.len());
1421        for (entry, &(sat, rx, t)) in batch.iter().zip(requests.iter()) {
1422            let scalar = predict(&source, sat, rx, t, options);
1423            match (entry, &scalar) {
1424                (Ok(b), Ok(s)) => assert_observables_bits_eq(b, s),
1425                (Err(_), Err(_)) => {}
1426                _ => panic!("batch and scalar predict disagree on Ok/Err"),
1427            }
1428        }
1429    }
1430
1431    #[test]
1432    fn predict_batch_parallel_matches_serial_bitwise() {
1433        let source = StaticSource {
1434            state: ObservableState {
1435                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1436                clock_s: Some(1.25e-6),
1437            },
1438        };
1439        let options = PredictOptions {
1440            carrier_hz: F_L1_HZ,
1441            light_time: true,
1442            sagnac: true,
1443        };
1444        let requests = batch_test_requests();
1445        let serial = predict_batch(&source, &requests, options);
1446        let parallel = predict_batch_parallel(&source, &requests, options);
1447        assert_eq!(serial.len(), parallel.len());
1448        for (s, p) in serial.iter().zip(parallel.iter()) {
1449            match (s, p) {
1450                (Ok(a), Ok(b)) => assert_observables_bits_eq(a, b),
1451                (Err(_), Err(_)) => {}
1452                _ => panic!("serial and parallel batch disagree on Ok/Err"),
1453            }
1454        }
1455    }
1456}