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::astro::time::model::{Instant, JulianDateSplit, TimeScale};
13use crate::constants::{
14    AZIMUTH_ZENITH_EPS, C_M_S, DEGREES_PER_CIRCLE, DEGREES_PER_SEMICIRCLE, F_L1_HZ, J2000_JD,
15    KM_TO_M, MICROSECONDS_PER_SECOND, OBSERVABLE_TRANSMIT_TIME_ITERATIONS, OMEGA_E_DOT_RAD_S,
16    SECONDS_PER_DAY,
17};
18use crate::ephemeris::BroadcastEphemeris;
19use crate::estimation::recipe::SagnacRecipe;
20use crate::frame::Wgs84Geodetic;
21use crate::id::GnssSatelliteId;
22use crate::ionex::{
23    ionex_slant_delay, ionex_slant_delay_with_policy, ionosphere_delay, Ionex, IonexCoveragePolicy,
24    IonoModel,
25};
26use crate::sp3::Sp3;
27use crate::spp::EphemerisSource;
28use crate::tropo::{tropo_mapping, tropo_zenith, MappingModel, Met, TropoModel};
29use crate::validate;
30use crate::Error;
31use rayon::prelude::*;
32
33const FD_HALF_S: f64 = 0.5;
34
35/// Satellite state required by the observable predictor.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct ObservableState {
38    /// Satellite ECEF position in meters at the query epoch.
39    pub position_ecef_m: [f64; 3],
40    /// Satellite clock offset in seconds. SP3 clocks can be absent.
41    pub clock_s: Option<f64>,
42}
43
44/// Position sentinel written for a failed element in [`ObservableStateBatch`].
45///
46/// The matching [`ObservableStateBatch::element_results`] entry carries the
47/// exact scalar error. Consumers must check that result entry before using the
48/// position or clock arrays.
49pub const OBSERVABLE_STATE_MISSING_POSITION_ECEF_M: [f64; 3] = [f64::NAN; 3];
50
51/// Per-element category for a batched satellite-state query.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ObservableStateElementStatus {
54    /// The element contains a usable state.
55    Valid,
56    /// The source has no usable state for this satellite and epoch.
57    Gap,
58    /// The scalar evaluator returned an error that is not a gap.
59    Error,
60}
61
62/// Contiguous output arrays for a batched satellite-state query.
63///
64/// Element `i` of `positions_ecef_m`, `clocks_s`, and `element_results` belongs
65/// to input satellite `i`. When `element_results[i]` is `Ok(())`, the position
66/// and clock entries are the exact [`ObservableState`] returned by the scalar
67/// evaluator. When it is `Err`, `positions_ecef_m[i]` is
68/// [`OBSERVABLE_STATE_MISSING_POSITION_ECEF_M`] and `clocks_s[i]` is `None`.
69#[derive(Debug, Clone, PartialEq)]
70pub struct ObservableStateBatch {
71    /// Satellite ECEF positions in meters, one entry per input element.
72    pub positions_ecef_m: Vec<[f64; 3]>,
73    /// Satellite clock offsets in seconds, one entry per input element.
74    pub clocks_s: Vec<Option<f64>>,
75    /// Per-element scalar result, preserving the exact scalar error on failure.
76    pub element_results: Vec<Result<(), ObservablesError>>,
77}
78
79/// Per-satellite status for an emission-epoch state/media batch row.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum EmissionMediaStatus {
82    /// The row contains state, clock, ionosphere, and troposphere outputs.
83    Valid,
84    /// The ephemeris product has no usable state for this satellite and epoch.
85    Gap,
86    /// The row had a state, but its elevation was below the requested cutoff.
87    BelowElevationCutoff,
88    /// The scalar evaluator returned a non-gap error.
89    Error,
90}
91
92/// Options for [`emission_media_batch_at_j2000_s`].
93#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct EmissionMediaBatchOptions<'a> {
95    /// Carrier frequency used for ionospheric group delay, hertz.
96    pub carrier_hz: f64,
97    /// Troposphere and ionosphere correction options.
98    pub media: ObservableMediaOptions<'a>,
99    /// Optional minimum topocentric elevation, radians.
100    ///
101    /// Rows below this cutoff keep the satellite state and clock, set both media
102    /// delays to `None`, and receive [`EmissionMediaStatus::BelowElevationCutoff`].
103    pub min_elevation_rad: Option<f64>,
104}
105
106impl Default for EmissionMediaBatchOptions<'_> {
107    fn default() -> Self {
108        Self {
109            carrier_hz: F_L1_HZ,
110            media: ObservableMediaOptions::default(),
111            min_elevation_rad: None,
112        }
113    }
114}
115
116/// Contiguous per-satellite outputs for emission-epoch state and media lookup.
117///
118/// Element `i` of every vector belongs to input satellite `i`. Missing product
119/// coverage is represented loudly: the status is [`EmissionMediaStatus::Gap`],
120/// the error is retained, and every value field is `None`. No missing row is
121/// written as zero. A valid zero can still appear inside `Some(0.0)` when the
122/// selected scalar model genuinely returns zero or a correction is disabled.
123#[derive(Debug, Clone, PartialEq)]
124pub struct EmissionMediaBatch {
125    /// Satellite ECEF positions in meters, one entry per input element.
126    pub positions_ecef_m: Vec<Option<[f64; 3]>>,
127    /// Satellite clock offsets in seconds, one entry per input element.
128    pub clocks_s: Vec<Option<f64>>,
129    /// Ionospheric slant group delays in meters, one entry per input element.
130    pub ionosphere_slant_delays_m: Vec<Option<f64>>,
131    /// Tropospheric slant delays in meters, one entry per input element.
132    pub troposphere_delays_m: Vec<Option<f64>>,
133    /// Per-element typed status.
134    pub statuses: Vec<EmissionMediaStatus>,
135    /// Per-element non-success error, if any.
136    pub element_errors: Vec<Option<ObservablesError>>,
137}
138
139/// An ephemeris product usable by [`predict`].
140pub trait ObservableEphemerisSource {
141    /// ECEF position and optional satellite clock at seconds since J2000.
142    fn observable_state_at_j2000_s(
143        &self,
144        sat: GnssSatelliteId,
145        t_j2000_s: f64,
146    ) -> Result<ObservableState, ObservablesError>;
147
148    /// ECEF states for parallel satellite and epoch arrays.
149    ///
150    /// `satellites[i]` is evaluated at `epochs_j2000_s[i]`. The output is
151    /// index-aligned with the input and preserves the scalar result for every
152    /// element. A length mismatch is the only batch-level error.
153    fn observable_states_at_j2000_s(
154        &self,
155        satellites: &[GnssSatelliteId],
156        epochs_j2000_s: &[f64],
157    ) -> Result<ObservableStateBatch, ObservablesError> {
158        if satellites.len() != epochs_j2000_s.len() {
159            return Err(ObservablesError::InvalidInput {
160                field: "epochs_j2000_s",
161                kind: ObservablesInputErrorKind::OutOfRange,
162            });
163        }
164
165        let mut batch = ObservableStateBatch::with_capacity(satellites.len());
166        for (&sat, &epoch_j2000_s) in satellites.iter().zip(epochs_j2000_s.iter()) {
167            batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
168        }
169        Ok(batch)
170    }
171
172    /// ECEF states for many satellites at one shared epoch.
173    ///
174    /// The output is index-aligned with `satellites` and preserves the scalar
175    /// result for every element.
176    fn observable_states_at_shared_j2000_s(
177        &self,
178        satellites: &[GnssSatelliteId],
179        epoch_j2000_s: f64,
180    ) -> ObservableStateBatch {
181        let mut batch = ObservableStateBatch::with_capacity(satellites.len());
182        for &sat in satellites {
183            batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
184        }
185        batch
186    }
187}
188
189impl ObservableEphemerisSource for Sp3 {
190    fn observable_state_at_j2000_s(
191        &self,
192        sat: GnssSatelliteId,
193        t_j2000_s: f64,
194    ) -> Result<ObservableState, ObservablesError> {
195        let state = self
196            .position_at_j2000_seconds(sat, t_j2000_s)
197            .map_err(ObservablesError::Ephemeris)?;
198        Ok(ObservableState {
199            position_ecef_m: state.position.as_array(),
200            clock_s: state.clock_s,
201        })
202    }
203}
204
205impl ObservableEphemerisSource for BroadcastEphemeris {
206    fn observable_state_at_j2000_s(
207        &self,
208        sat: GnssSatelliteId,
209        t_j2000_s: f64,
210    ) -> Result<ObservableState, ObservablesError> {
211        let Some((position_ecef_m, clock_s)) =
212            EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
213        else {
214            return Err(ObservablesError::NoEphemeris);
215        };
216        Ok(ObservableState {
217            position_ecef_m,
218            clock_s: Some(clock_s),
219        })
220    }
221}
222
223/// Input-validation failure category for observable prediction.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum ObservablesInputErrorKind {
226    /// A floating-point input was NaN or infinite.
227    NonFinite,
228    /// A positive physical input was zero or negative.
229    NotPositive,
230    /// A non-negative physical input was negative.
231    Negative,
232    /// A finite numeric input was outside its accepted range.
233    OutOfRange,
234    /// A required input field was absent.
235    Missing,
236    /// A text field could not be parsed as a float.
237    FloatParse,
238    /// A text field could not be parsed as an integer.
239    IntParse,
240    /// A civil date field was out of range.
241    InvalidCivilDate,
242    /// A civil time field was out of range.
243    InvalidCivilTime,
244}
245
246impl core::fmt::Display for ObservablesInputErrorKind {
247    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
248        let label = match self {
249            Self::NonFinite => "not finite",
250            Self::NotPositive => "not positive",
251            Self::Negative => "negative",
252            Self::OutOfRange => "out of range",
253            Self::Missing => "missing",
254            Self::FloatParse => "invalid float",
255            Self::IntParse => "invalid integer",
256            Self::InvalidCivilDate => "invalid civil date",
257            Self::InvalidCivilTime => "invalid civil time",
258        };
259        f.write_str(label)
260    }
261}
262
263impl From<&validate::FieldError> for ObservablesInputErrorKind {
264    fn from(error: &validate::FieldError) -> Self {
265        match error {
266            validate::FieldError::Missing { .. } => Self::Missing,
267            validate::FieldError::NonFinite { .. } => Self::NonFinite,
268            validate::FieldError::NotPositive { .. } => Self::NotPositive,
269            validate::FieldError::Negative { .. } => Self::Negative,
270            validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
271            validate::FieldError::FloatParse { .. } => Self::FloatParse,
272            validate::FieldError::IntParse { .. } => Self::IntParse,
273            validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
274            validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
275        }
276    }
277}
278
279/// Error returned by the observable predictor.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum ObservablesError {
282    /// A public predictor input or ephemeris-source state was malformed,
283    /// non-finite, or outside its physical domain.
284    InvalidInput {
285        /// The invalid input field.
286        field: &'static str,
287        /// The validation failure category.
288        kind: ObservablesInputErrorKind,
289    },
290    /// The ephemeris product has no usable record for the satellite/epoch.
291    NoEphemeris,
292    /// The underlying ephemeris product returned a structured crate error.
293    Ephemeris(Error),
294    /// The selected media model returned a structured crate error.
295    Media(Error),
296}
297
298impl core::fmt::Display for ObservablesError {
299    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
300        match self {
301            Self::InvalidInput { field, kind } => {
302                write!(f, "invalid observable input {field}: {kind}")
303            }
304            Self::NoEphemeris => write!(f, "no ephemeris"),
305            Self::Ephemeris(err) => write!(f, "{err}"),
306            Self::Media(err) => write!(f, "{err}"),
307        }
308    }
309}
310
311impl std::error::Error for ObservablesError {}
312
313impl ObservableStateBatch {
314    /// Build an empty batch with capacity for `capacity` elements.
315    pub fn with_capacity(capacity: usize) -> Self {
316        Self {
317            positions_ecef_m: Vec::with_capacity(capacity),
318            clocks_s: Vec::with_capacity(capacity),
319            element_results: Vec::with_capacity(capacity),
320        }
321    }
322
323    /// Number of elements in the batch.
324    pub fn len(&self) -> usize {
325        self.element_results.len()
326    }
327
328    /// Whether the batch contains no elements.
329    pub fn is_empty(&self) -> bool {
330        self.element_results.is_empty()
331    }
332
333    /// Reconstruct element `index` as the scalar state result.
334    ///
335    /// Returns `None` when `index` is out of range.
336    pub fn element(&self, index: usize) -> Option<Result<ObservableState, &ObservablesError>> {
337        match self.element_results.get(index)? {
338            Ok(()) => Some(Ok(ObservableState {
339                position_ecef_m: self.positions_ecef_m[index],
340                clock_s: self.clocks_s[index],
341            })),
342            Err(error) => Some(Err(error)),
343        }
344    }
345
346    /// Status category for element `index`.
347    ///
348    /// Returns `None` when `index` is out of range.
349    pub fn element_status(&self, index: usize) -> Option<ObservableStateElementStatus> {
350        match self.element_results.get(index)? {
351            Ok(()) => Some(ObservableStateElementStatus::Valid),
352            Err(error) if is_observable_state_gap(error) => Some(ObservableStateElementStatus::Gap),
353            Err(_) => Some(ObservableStateElementStatus::Error),
354        }
355    }
356
357    fn push_state_result(&mut self, result: Result<ObservableState, ObservablesError>) {
358        match result {
359            Ok(state) => {
360                self.positions_ecef_m.push(state.position_ecef_m);
361                self.clocks_s.push(state.clock_s);
362                self.element_results.push(Ok(()));
363            }
364            Err(error) => {
365                self.positions_ecef_m
366                    .push(OBSERVABLE_STATE_MISSING_POSITION_ECEF_M);
367                self.clocks_s.push(None);
368                self.element_results.push(Err(error));
369            }
370        }
371    }
372}
373
374impl EmissionMediaBatch {
375    /// Build an empty batch with capacity for `capacity` elements.
376    pub fn with_capacity(capacity: usize) -> Self {
377        Self {
378            positions_ecef_m: Vec::with_capacity(capacity),
379            clocks_s: Vec::with_capacity(capacity),
380            ionosphere_slant_delays_m: Vec::with_capacity(capacity),
381            troposphere_delays_m: Vec::with_capacity(capacity),
382            statuses: Vec::with_capacity(capacity),
383            element_errors: Vec::with_capacity(capacity),
384        }
385    }
386
387    /// Number of elements in the batch.
388    pub fn len(&self) -> usize {
389        self.statuses.len()
390    }
391
392    /// Whether the batch contains no elements.
393    pub fn is_empty(&self) -> bool {
394        self.statuses.is_empty()
395    }
396
397    /// Status category for element `index`.
398    ///
399    /// Returns `None` when `index` is out of range.
400    pub fn element_status(&self, index: usize) -> Option<EmissionMediaStatus> {
401        self.statuses.get(index).copied()
402    }
403
404    fn push_valid(&mut self, state: ObservableState, media: AppliedMediaCorrections) {
405        self.positions_ecef_m.push(Some(state.position_ecef_m));
406        self.clocks_s.push(state.clock_s);
407        self.ionosphere_slant_delays_m
408            .push(Some(media.ionosphere_m));
409        self.troposphere_delays_m.push(Some(media.troposphere_m));
410        self.statuses.push(EmissionMediaStatus::Valid);
411        self.element_errors.push(None);
412    }
413
414    fn push_gap(&mut self, error: ObservablesError) {
415        self.positions_ecef_m.push(None);
416        self.clocks_s.push(None);
417        self.ionosphere_slant_delays_m.push(None);
418        self.troposphere_delays_m.push(None);
419        self.statuses.push(EmissionMediaStatus::Gap);
420        self.element_errors.push(Some(error));
421    }
422
423    fn push_below_cutoff(&mut self, state: ObservableState) {
424        self.positions_ecef_m.push(Some(state.position_ecef_m));
425        self.clocks_s.push(state.clock_s);
426        self.ionosphere_slant_delays_m.push(None);
427        self.troposphere_delays_m.push(None);
428        self.statuses
429            .push(EmissionMediaStatus::BelowElevationCutoff);
430        self.element_errors.push(None);
431    }
432
433    fn push_error(&mut self, state: Option<ObservableState>, error: ObservablesError) {
434        self.positions_ecef_m
435            .push(state.map(|state| state.position_ecef_m));
436        self.clocks_s.push(state.and_then(|state| state.clock_s));
437        self.ionosphere_slant_delays_m.push(None);
438        self.troposphere_delays_m.push(None);
439        self.statuses.push(EmissionMediaStatus::Error);
440        self.element_errors.push(Some(error));
441    }
442}
443
444/// Whether a scalar observable-state error represents a data gap.
445///
446/// This is the same classification used by ephemeris grid sampling: missing
447/// data, out-of-range precise interpolation, and unknown satellites are gaps;
448/// malformed inputs and other source errors are not.
449pub fn is_observable_state_gap(error: &ObservablesError) -> bool {
450    matches!(
451        error,
452        ObservablesError::NoEphemeris
453            | ObservablesError::Ephemeris(crate::Error::EpochOutOfRange)
454            | ObservablesError::Ephemeris(crate::Error::UnknownSatellite(_))
455    )
456}
457
458/// Options controlling observable prediction.
459#[derive(Debug, Clone, Copy, PartialEq)]
460pub struct PredictOptions {
461    /// Carrier frequency used to scale Doppler, hertz.
462    pub carrier_hz: f64,
463    /// Apply fixed-point light-time / transmit-time correction.
464    pub light_time: bool,
465    /// Apply Earth-rotation Sagnac correction.
466    pub sagnac: bool,
467}
468
469/// Options controlling transmit-time satellite-state evaluation.
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub struct TransmitTimeOptions {
472    /// Apply fixed-point light-time / transmit-time correction.
473    pub light_time: bool,
474    /// Apply Earth-rotation Sagnac correction to the returned position/velocity.
475    pub sagnac: bool,
476}
477
478impl Default for TransmitTimeOptions {
479    fn default() -> Self {
480        Self {
481            light_time: true,
482            sagnac: true,
483        }
484    }
485}
486
487impl Default for PredictOptions {
488    fn default() -> Self {
489        Self {
490            carrier_hz: F_L1_HZ,
491            light_time: true,
492            sagnac: true,
493        }
494    }
495}
496
497/// Troposphere correction settings for a predicted tracking observable.
498#[derive(Debug, Clone, Copy, PartialEq)]
499pub struct ObservableTroposphereCorrection {
500    /// Surface meteorology used by the Saastamoinen zenith delay.
501    pub met: Met,
502    /// Mapping function applied to the zenith dry and wet delays.
503    pub mapping: MappingModel,
504}
505
506impl Default for ObservableTroposphereCorrection {
507    fn default() -> Self {
508        Self {
509            met: Met::new_unchecked(1013.25, 288.15, 0.5),
510            mapping: MappingModel::Niell,
511        }
512    }
513}
514
515/// Ionosphere correction model for a predicted tracking observable.
516#[derive(Debug, Clone, Copy, PartialEq)]
517pub enum ObservableIonosphereCorrection<'a> {
518    /// Broadcast ionosphere model evaluated on the requested carrier.
519    Broadcast(IonoModel),
520    /// Parsed IONEX vertical-TEC grid evaluated on the requested carrier.
521    Ionex(&'a Ionex),
522    /// Parsed IONEX vertical-TEC grid evaluated with an explicit coverage policy.
523    IonexWithPolicy(&'a Ionex, IonexCoveragePolicy),
524}
525
526/// Optional media corrections for one predicted tracking observable.
527#[derive(Debug, Clone, Copy, PartialEq, Default)]
528pub struct ObservableMediaOptions<'a> {
529    /// Neutral-atmosphere slant delay to add to the range, if present.
530    pub troposphere: Option<ObservableTroposphereCorrection>,
531    /// Ionospheric group delay to add to the range, if present.
532    pub ionosphere: Option<ObservableIonosphereCorrection<'a>>,
533}
534
535impl ObservableMediaOptions<'_> {
536    fn is_disabled(self) -> bool {
537        self.troposphere.is_none() && self.ionosphere.is_none()
538    }
539
540    fn needs_instant(self) -> bool {
541        self.troposphere.is_some()
542            || matches!(
543                self.ionosphere,
544                Some(ObservableIonosphereCorrection::Broadcast(_))
545            )
546    }
547
548    fn needs_carrier(self) -> bool {
549        self.ionosphere.is_some()
550    }
551
552    fn needs_ionex_epoch(self) -> bool {
553        matches!(
554            self.ionosphere,
555            Some(
556                ObservableIonosphereCorrection::Ionex(_)
557                    | ObservableIonosphereCorrection::IonexWithPolicy(_, _)
558            )
559        )
560    }
561}
562
563/// Prediction options plus optional media corrections.
564#[derive(Debug, Clone, Copy, PartialEq, Default)]
565pub struct MediaPredictOptions<'a> {
566    /// Geometry, light-time, Sagnac, and carrier options.
567    pub prediction: PredictOptions,
568    /// Troposphere and ionosphere correction options.
569    pub media: ObservableMediaOptions<'a>,
570}
571
572/// Media delays applied to a predicted tracking observable.
573#[derive(Debug, Clone, Copy, PartialEq)]
574pub struct AppliedMediaCorrections {
575    /// Slant tropospheric delay in meters.
576    pub troposphere_m: f64,
577    /// Ionospheric group delay in meters on the requested carrier.
578    pub ionosphere_m: f64,
579    /// Sum of troposphere and ionosphere delays in meters.
580    pub total_m: f64,
581}
582
583impl Default for AppliedMediaCorrections {
584    fn default() -> Self {
585        Self {
586            troposphere_m: 0.0,
587            ionosphere_m: 0.0,
588            total_m: 0.0,
589        }
590    }
591}
592
593/// Predicted observables with an additional media-corrected range.
594#[derive(Debug, Clone, Copy, PartialEq)]
595pub struct MediaPredictedObservables {
596    /// Geometry, range-rate, Doppler, clock, and sky position prediction.
597    pub prediction: PredictedObservables,
598    /// Range after adding the selected media delays, meters.
599    pub range_m: f64,
600    /// Media delays applied to `range_m`.
601    pub media: AppliedMediaCorrections,
602}
603
604/// Satellite state at its signal transmit time for one receive epoch.
605///
606/// `transmit_position_ecef_m` is the ephemeris position evaluated at
607/// `transmit_time_j2000_s`. `position_ecef_m` is that position transported into
608/// the receive-time ECEF frame when [`TransmitTimeOptions::sagnac`] is enabled.
609/// `velocity_m_s` is the finite-difference ECEF velocity at transmit time with
610/// the same transport applied.
611#[derive(Debug, Clone, Copy, PartialEq)]
612pub struct TransmitTimeSatelliteState {
613    /// Signal flight time, seconds.
614    pub signal_flight_time_s: f64,
615    /// Transmit-time offset from receive time, rounded to microseconds.
616    pub transmit_offset_us: i64,
617    /// Transmit time as seconds since J2000.
618    pub transmit_time_j2000_s: f64,
619    /// Satellite clock offset at transmit time, seconds.
620    pub clock_s: Option<f64>,
621    /// Ephemeris ECEF satellite position at transmit time, metres.
622    pub transmit_position_ecef_m: [f64; 3],
623    /// Sagnac-transported ECEF satellite position, metres.
624    pub position_ecef_m: [f64; 3],
625    /// Sagnac-transported ECEF satellite velocity, metres per second.
626    pub velocity_m_s: [f64; 3],
627    /// Geometric range after optional Sagnac transport, metres.
628    pub geometric_range_m: f64,
629    /// Receiver-to-satellite line-of-sight unit vector in ECEF.
630    pub los_unit: [f64; 3],
631}
632
633/// Predicted GNSS observables at one receive epoch.
634#[derive(Debug, Clone, Copy, PartialEq)]
635pub struct PredictedObservables {
636    /// Geometric range after optional Sagnac rotation, meters.
637    pub geometric_range_m: f64,
638    /// Range-rate LOS projection, meters per second.
639    pub range_rate_m_s: f64,
640    /// Doppler shift at `PredictOptions::carrier_hz`, hertz.
641    pub doppler_hz: f64,
642    /// Satellite clock offset at transmit time, seconds.
643    pub sat_clock_s: Option<f64>,
644    /// Topocentric elevation, degrees.
645    pub elevation_deg: f64,
646    /// Topocentric azimuth in `[0, 360)`, degrees.
647    ///
648    /// At (and arbitrarily near) the receiver's zenith the azimuth is
649    /// geometrically undefined; it is defined here to be exactly `0.0` once the
650    /// horizontal line-of-sight projection falls below
651    /// [`crate::constants::AZIMUTH_ZENITH_EPS`], rather than returning rounding
652    /// noise or erroring.
653    pub azimuth_deg: f64,
654    /// Transmit-time offset from receive time, rounded to microseconds.
655    pub transmit_offset_us: i64,
656    /// Transmit time as seconds since J2000.
657    pub transmit_time_j2000_s: f64,
658    /// Receiver-to-satellite line-of-sight unit vector in ECEF.
659    pub los_unit: [f64; 3],
660    /// Sagnac-rotated satellite ECEF position in meters.
661    pub sat_pos_ecef_m: [f64; 3],
662    /// Sagnac-rotated satellite ECEF velocity in meters per second.
663    pub sat_velocity_m_s: [f64; 3],
664}
665
666/// Convert split Julian date to seconds since J2000.
667pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
668    validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
669    validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
670    validate::finite(
671        civil::j2000_seconds_from_split(jd_whole, jd_fraction),
672        "j2000_seconds",
673    )
674    .map_err(map_input_error)
675}
676
677/// Evaluate optional media range corrections at a supplied topocentric geometry.
678///
679/// This is the correction kernel used by [`predict_with_media`] and
680/// [`predict_ranges_with_media`]. Delays are positive meters and are summed in
681/// IERS TN36 range-sign convention: neutral-atmosphere slant delay first, then
682/// ionospheric group delay on `carrier_hz`.
683pub fn observable_media_corrections(
684    receiver: Wgs84Geodetic,
685    elevation_rad: f64,
686    azimuth_rad: f64,
687    t_rx_j2000_s: f64,
688    carrier_hz: f64,
689    options: ObservableMediaOptions<'_>,
690) -> Result<AppliedMediaCorrections, ObservablesError> {
691    if options.is_disabled() {
692        return Ok(AppliedMediaCorrections::default());
693    }
694    validate::finite(elevation_rad, "elevation_rad").map_err(map_input_error)?;
695    validate::finite(azimuth_rad, "azimuth_rad").map_err(map_input_error)?;
696    if options.needs_carrier() {
697        validate::finite_positive(carrier_hz, "carrier_hz").map_err(map_input_error)?;
698    }
699    let epoch = if options.needs_instant() {
700        Some(media_instant(t_rx_j2000_s)?)
701    } else {
702        None
703    };
704    let ionex_epoch_j2000_s = if options.needs_ionex_epoch() {
705        Some(rounded_j2000_seconds(t_rx_j2000_s)?)
706    } else {
707        None
708    };
709
710    let troposphere_m = match options.troposphere {
711        Some(troposphere) => {
712            let epoch = epoch.expect("troposphere media requires an epoch");
713            let zenith = tropo_zenith(TropoModel::Saastamoinen, receiver, troposphere.met)
714                .map_err(map_media_error)?;
715            let mapping = tropo_mapping(troposphere.mapping, elevation_rad, receiver, epoch)
716                .map_err(map_media_error)?;
717            let delay_m = zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet;
718            validate::finite(delay_m, "media.troposphere_m").map_err(map_input_error)?;
719            delay_m
720        }
721        None => 0.0,
722    };
723
724    let ionosphere_m = match options.ionosphere {
725        Some(ObservableIonosphereCorrection::Broadcast(model)) => {
726            let epoch = epoch.expect("broadcast ionosphere media requires an epoch");
727            let delay_m = ionosphere_delay(
728                receiver,
729                elevation_rad,
730                azimuth_rad,
731                epoch,
732                carrier_hz,
733                &model,
734            )
735            .map_err(map_media_error)?;
736            validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
737            delay_m
738        }
739        Some(ObservableIonosphereCorrection::Ionex(ionex)) => {
740            let ionex_epoch_j2000_s =
741                ionex_epoch_j2000_s.expect("IONEX media requires an integer epoch");
742            let delay_m = ionex_slant_delay(
743                ionex,
744                receiver,
745                elevation_rad,
746                azimuth_rad,
747                ionex_epoch_j2000_s,
748                carrier_hz,
749            )
750            .map_err(map_media_error)?;
751            validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
752            delay_m
753        }
754        Some(ObservableIonosphereCorrection::IonexWithPolicy(ionex, policy)) => {
755            let ionex_epoch_j2000_s =
756                ionex_epoch_j2000_s.expect("IONEX media requires an integer epoch");
757            let delay_m = ionex_slant_delay_with_policy(
758                ionex,
759                receiver,
760                elevation_rad,
761                azimuth_rad,
762                ionex_epoch_j2000_s,
763                carrier_hz,
764                policy,
765            )
766            .map_err(map_media_error)?
767            .delay_m;
768            validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
769            delay_m
770        }
771        None => 0.0,
772    };
773
774    let total_m = troposphere_m + ionosphere_m;
775    validate::finite(total_m, "media.total_m").map_err(map_input_error)?;
776    Ok(AppliedMediaCorrections {
777        troposphere_m,
778        ionosphere_m,
779        total_m,
780    })
781}
782
783/// Evaluate ECEF states for parallel satellite and epoch arrays.
784///
785/// This delegates to [`ObservableEphemerisSource::observable_states_at_j2000_s`].
786pub fn observable_states_at_j2000_s(
787    source: &dyn ObservableEphemerisSource,
788    satellites: &[GnssSatelliteId],
789    epochs_j2000_s: &[f64],
790) -> Result<ObservableStateBatch, ObservablesError> {
791    source.observable_states_at_j2000_s(satellites, epochs_j2000_s)
792}
793
794/// Evaluate ECEF states for many satellites at one shared epoch.
795///
796/// This delegates to
797/// [`ObservableEphemerisSource::observable_states_at_shared_j2000_s`].
798pub fn observable_states_at_shared_j2000_s(
799    source: &dyn ObservableEphemerisSource,
800    satellites: &[GnssSatelliteId],
801    epoch_j2000_s: f64,
802) -> ObservableStateBatch {
803    source.observable_states_at_shared_j2000_s(satellites, epoch_j2000_s)
804}
805
806/// Evaluate satellite emission-epoch states, clocks, and media delays in one call.
807///
808/// `satellites[i]` is evaluated at `emission_epochs_j2000_s[i]`. For each row,
809/// the function first calls the same scalar
810/// [`ObservableEphemerisSource::observable_state_at_j2000_s`] path used by the
811/// public state APIs. When a state is available, it derives topocentric geometry
812/// from `receiver_ecef_m` and delegates the ionosphere/troposphere work to
813/// [`observable_media_corrections`]. The output vectors are index-aligned with
814/// the inputs.
815///
816/// Missing ephemeris coverage is not zero-filled: it is returned as
817/// [`EmissionMediaStatus::Gap`] with all value fields set to `None` and the
818/// scalar gap error retained in [`EmissionMediaBatch::element_errors`]. A length
819/// mismatch is the only shape-level error.
820pub fn emission_media_batch_at_j2000_s(
821    source: &dyn ObservableEphemerisSource,
822    satellites: &[GnssSatelliteId],
823    emission_epochs_j2000_s: &[f64],
824    receiver_ecef_m: [f64; 3],
825    options: EmissionMediaBatchOptions<'_>,
826) -> Result<EmissionMediaBatch, ObservablesError> {
827    if satellites.len() != emission_epochs_j2000_s.len() {
828        return Err(ObservablesError::InvalidInput {
829            field: "emission_epochs_j2000_s",
830            kind: ObservablesInputErrorKind::OutOfRange,
831        });
832    }
833    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
834    validate_emission_media_batch_options(options)?;
835
836    let mut batch = EmissionMediaBatch::with_capacity(satellites.len());
837    for (&sat, &emission_epoch_j2000_s) in satellites.iter().zip(emission_epochs_j2000_s.iter()) {
838        let state = match source.observable_state_at_j2000_s(sat, emission_epoch_j2000_s) {
839            Ok(state) => state,
840            Err(error) if is_observable_state_gap(&error) => {
841                batch.push_gap(error);
842                continue;
843            }
844            Err(error) => {
845                batch.push_error(None, error);
846                continue;
847            }
848        };
849
850        if let Err(error) = validate_observable_state(&state) {
851            batch.push_error(Some(state), error);
852            continue;
853        }
854
855        let dx = state.position_ecef_m[0] - receiver_ecef_m[0];
856        let dy = state.position_ecef_m[1] - receiver_ecef_m[1];
857        let dz = state.position_ecef_m[2] - receiver_ecef_m[2];
858        let line_of_sight_m = [dx, dy, dz];
859        let range = match geometric_range_m(line_of_sight_m) {
860            Ok(range) => range,
861            Err(error) => {
862                batch.push_error(Some(state), error);
863                continue;
864            }
865        };
866        let topocentric = match topocentric(receiver_ecef_m, line_of_sight_m, range) {
867            Ok(topocentric) => topocentric,
868            Err(error) => {
869                batch.push_error(Some(state), error);
870                continue;
871            }
872        };
873
874        if options
875            .min_elevation_rad
876            .is_some_and(|cutoff| topocentric.elevation_rad < cutoff)
877        {
878            batch.push_below_cutoff(state);
879            continue;
880        }
881
882        let media = observable_media_corrections(
883            topocentric.receiver,
884            topocentric.elevation_rad,
885            topocentric.azimuth_rad,
886            emission_epoch_j2000_s,
887            options.carrier_hz,
888            options.media,
889        );
890        match media {
891            Ok(media) => batch.push_valid(state, media),
892            Err(error) => batch.push_error(Some(state), error),
893        }
894    }
895    Ok(batch)
896}
897
898/// Evaluate a satellite's transmit-time ECEF state for one static receiver.
899///
900/// This is the per-satellite primitive underneath observable prediction: it
901/// iterates light time, evaluates the ephemeris at the satellite's transmit
902/// epoch, applies the Sagnac/Earth-rotation transport if requested, and returns
903/// the transported position, velocity, clock, range, and line of sight without
904/// constructing Doppler or topocentric observables.
905pub fn transmit_time_satellite_state(
906    source: &dyn ObservableEphemerisSource,
907    sat: GnssSatelliteId,
908    receiver_ecef_m: [f64; 3],
909    t_rx_j2000_s: f64,
910    options: TransmitTimeOptions,
911) -> Result<TransmitTimeSatelliteState, ObservablesError> {
912    validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
913    let predict_options = PredictOptions {
914        carrier_hz: F_L1_HZ,
915        light_time: options.light_time,
916        sagnac: options.sagnac,
917    };
918    let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, predict_options)?;
919
920    let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
921    let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
922    let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
923    let range = geometric_range_m([dx, dy, dz])?;
924    let los = [dx / range, dy / range, dz / range];
925
926    let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
927    let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
928    validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
929
930    Ok(TransmitTimeSatelliteState {
931        signal_flight_time_s: solved.tau_s,
932        transmit_offset_us: solved.transmit_offset_us,
933        transmit_time_j2000_s: solved.transmit_time_j2000_s,
934        clock_s: solved.state.clock_s,
935        transmit_position_ecef_m: solved.state.position_ecef_m,
936        position_ecef_m: solved.sat_rot_ecef_m,
937        velocity_m_s: velocity_rot,
938        geometric_range_m: range,
939        los_unit: los,
940    })
941}
942
943/// Predict observables for `sat` from a static ECEF receiver.
944pub fn predict(
945    source: &dyn ObservableEphemerisSource,
946    sat: GnssSatelliteId,
947    receiver_ecef_m: [f64; 3],
948    t_rx_j2000_s: f64,
949    options: PredictOptions,
950) -> Result<PredictedObservables, ObservablesError> {
951    let (prediction, _) = predict_core(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
952    Ok(prediction)
953}
954
955/// Predict observables and add optional troposphere and ionosphere range delays.
956///
957/// The embedded [`PredictedObservables`] keeps the geometric range and range-rate
958/// fields unchanged. The corrected one-way range is reported as
959/// [`MediaPredictedObservables::range_m`]. IERS TN36 treats the neutral
960/// atmosphere and ionospheric group delay as positive additions to a code range;
961/// no media range-rate derivative is applied here.
962pub fn predict_with_media(
963    source: &dyn ObservableEphemerisSource,
964    sat: GnssSatelliteId,
965    receiver_ecef_m: [f64; 3],
966    t_rx_j2000_s: f64,
967    options: MediaPredictOptions<'_>,
968) -> Result<MediaPredictedObservables, ObservablesError> {
969    let (prediction, topocentric) = predict_core(
970        source,
971        sat,
972        receiver_ecef_m,
973        t_rx_j2000_s,
974        options.prediction,
975    )?;
976    if options.media.is_disabled() {
977        return Ok(MediaPredictedObservables {
978            range_m: prediction.geometric_range_m,
979            prediction,
980            media: AppliedMediaCorrections::default(),
981        });
982    }
983    let media = observable_media_corrections(
984        topocentric.receiver,
985        topocentric.elevation_rad,
986        topocentric.azimuth_rad,
987        t_rx_j2000_s,
988        options.prediction.carrier_hz,
989        options.media,
990    )?;
991    let range_m = prediction.geometric_range_m + media.total_m;
992    validate::finite(range_m, "range_m").map_err(map_input_error)?;
993    Ok(MediaPredictedObservables {
994        prediction,
995        range_m,
996        media,
997    })
998}
999
1000fn predict_core(
1001    source: &dyn ObservableEphemerisSource,
1002    sat: GnssSatelliteId,
1003    receiver_ecef_m: [f64; 3],
1004    t_rx_j2000_s: f64,
1005    options: PredictOptions,
1006) -> Result<(PredictedObservables, TopocentricGeometry), ObservablesError> {
1007    validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
1008    let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1009
1010    let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
1011    let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
1012    let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
1013    let range = geometric_range_m([dx, dy, dz])?;
1014    let los = [dx / range, dy / range, dz / range];
1015
1016    let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
1017    let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
1018    validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
1019    let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
1020    validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
1021    let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
1022    validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
1023    let topocentric = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
1024
1025    Ok((
1026        PredictedObservables {
1027            geometric_range_m: range,
1028            range_rate_m_s: range_rate,
1029            doppler_hz,
1030            sat_clock_s: solved.state.clock_s,
1031            elevation_deg: topocentric.elevation_deg,
1032            azimuth_deg: topocentric.azimuth_deg,
1033            transmit_offset_us: solved.transmit_offset_us,
1034            transmit_time_j2000_s: solved.transmit_time_j2000_s,
1035            los_unit: los,
1036            sat_pos_ecef_m: solved.sat_rot_ecef_m,
1037            sat_velocity_m_s: velocity_rot,
1038        },
1039        topocentric,
1040    ))
1041}
1042
1043/// One batch prediction request: the satellite to observe, the static receiver
1044/// ECEF position in meters, and the receive epoch in seconds since J2000.
1045///
1046/// Each entry is fully independent of the others; the receiver position and
1047/// epoch are per-request, so a single batch can mix many satellites, many
1048/// receivers, and many epochs in any combination.
1049pub type PredictRequest = (GnssSatelliteId, [f64; 3], f64);
1050
1051/// Predict observables for many `(satellite, receiver, epoch)` requests, serially.
1052///
1053/// Element `i` of the result is exactly what [`predict`] returns for
1054/// `requests[i]` (including its `Err`), evaluated with the shared `options`.
1055/// This is the single-threaded reference that [`predict_batch_parallel`] is
1056/// proven bit-identical against; it lets a caller predict a whole fleet/arc in
1057/// one call instead of paying per-call dispatch overhead in a host-language loop.
1058pub fn predict_batch(
1059    source: &dyn ObservableEphemerisSource,
1060    requests: &[PredictRequest],
1061    options: PredictOptions,
1062) -> Vec<Result<PredictedObservables, ObservablesError>> {
1063    requests
1064        .iter()
1065        .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1066            predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1067        })
1068        .collect()
1069}
1070
1071/// Predict media-corrected observables for many requests, serially.
1072///
1073/// Element `i` is the result of [`predict_with_media`] for `requests[i]` with
1074/// the shared options.
1075pub fn predict_batch_with_media(
1076    source: &dyn ObservableEphemerisSource,
1077    requests: &[PredictRequest],
1078    options: MediaPredictOptions<'_>,
1079) -> Vec<Result<MediaPredictedObservables, ObservablesError>> {
1080    requests
1081        .iter()
1082        .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1083            predict_with_media(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1084        })
1085        .collect()
1086}
1087
1088/// Predict observables for many `(satellite, receiver, epoch)` requests, fanning
1089/// the independent requests across a rayon thread pool.
1090///
1091/// Each request is evaluated by the same scalar [`predict`] kernel and the
1092/// indexed parallel collect preserves input order, so element `i` is
1093/// byte-for-byte identical to element `i` of [`predict_batch`]: the requests
1094/// share no mutable state and a single `predict` is internally sequential, so
1095/// throughput scales with cores while every value stays bit-exact. The
1096/// `source` must be `Sync` because it is read concurrently from every worker.
1097pub fn predict_batch_parallel(
1098    source: &(dyn ObservableEphemerisSource + Sync),
1099    requests: &[PredictRequest],
1100    options: PredictOptions,
1101) -> Vec<Result<PredictedObservables, ObservablesError>> {
1102    requests
1103        .par_iter()
1104        .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1105            predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1106        })
1107        .collect()
1108}
1109
1110/// Predict media-corrected observables for many requests in parallel.
1111///
1112/// Each worker evaluates the same scalar [`predict_with_media`] path and the
1113/// indexed parallel collect preserves request order.
1114pub fn predict_batch_with_media_parallel(
1115    source: &(dyn ObservableEphemerisSource + Sync),
1116    requests: &[PredictRequest],
1117    options: MediaPredictOptions<'_>,
1118) -> Vec<Result<MediaPredictedObservables, ObservablesError>> {
1119    requests
1120        .par_iter()
1121        .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1122            predict_with_media(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1123        })
1124        .collect()
1125}
1126
1127/// One batch range-prediction request: the satellite, the static receiver ECEF
1128/// position in meters, and the receive epoch in seconds since J2000.
1129#[derive(Debug, Clone, Copy, PartialEq)]
1130pub struct RangePredictionRequest {
1131    /// The satellite to range against.
1132    pub sat: GnssSatelliteId,
1133    /// Static receiver ECEF position, meters.
1134    pub receiver_ecef_m: [f64; 3],
1135    /// Receive epoch, seconds since J2000.
1136    pub t_rx_j2000_s: f64,
1137}
1138
1139/// The geometry-only result of one [`predict_ranges`] request.
1140///
1141/// A projection of [`transmit_time_satellite_state`]: the transmit-time geometry
1142/// a range-only consumer needs, without the Doppler / topocentric fields.
1143#[derive(Debug, Clone, Copy, PartialEq)]
1144pub struct RangePrediction {
1145    /// Geometric range after optional Sagnac transport, meters.
1146    pub geometric_range_m: f64,
1147    /// Satellite clock offset at transmit time, seconds (`None` if absent).
1148    pub sat_clock_s: Option<f64>,
1149    /// Transmit time as seconds since J2000.
1150    pub transmit_time_j2000_s: f64,
1151    /// Sagnac-transported satellite ECEF position, meters.
1152    pub sat_pos_ecef_m: [f64; 3],
1153}
1154
1155/// Range-only prediction with an additional media-corrected range.
1156#[derive(Debug, Clone, Copy, PartialEq)]
1157pub struct MediaRangePrediction {
1158    /// Geometry-only range prediction.
1159    pub prediction: RangePrediction,
1160    /// Range after adding the selected media delays, meters.
1161    pub range_m: f64,
1162    /// Media delays applied to `range_m`.
1163    pub media: AppliedMediaCorrections,
1164}
1165
1166/// Predict geometric ranges for many `(satellite, receiver, epoch)` requests in
1167/// one call, writing into a caller-provided `out` slice.
1168///
1169/// `out[i]` is filled from `requests[i]` by the range-only transmit-time kernel
1170/// [`range_prediction_at_rx`]: the same light-time iteration and Sagnac transport
1171/// as [`transmit_time_satellite_state`], projected to the range geometry. It is
1172/// therefore bit-identical to calling that predictor in a loop and reading its
1173/// geometry fields, and the whole batch is one native call over the array (no
1174/// per-request host-language dispatch).
1175///
1176/// Internally this is the vectorized hot path: it drops the finite-difference
1177/// **velocity** evaluation that [`transmit_time_satellite_state`] performs and
1178/// that a range consumer never uses, cutting the per-request ephemeris
1179/// evaluations by a third (from 6 to 4), and writes each result in a single pass
1180/// over `out`. The range values are unchanged to the bit, because the velocity
1181/// term never entered a [`RangePrediction`]; only the discarded work is removed.
1182/// `options.carrier_hz` is unused (ranges carry no Doppler);
1183/// `options.light_time` / `options.sagnac` are honored.
1184///
1185/// Errors:
1186/// - [`ObservablesError::InvalidInput`] with field `out` if `out.len()` differs
1187///   from `requests.len()`.
1188/// - The first request error (invalid input or missing ephemeris) aborts the
1189///   batch and is returned; `out` is then partially written.
1190pub fn predict_ranges(
1191    source: &dyn ObservableEphemerisSource,
1192    requests: &[RangePredictionRequest],
1193    options: PredictOptions,
1194    out: &mut [RangePrediction],
1195) -> Result<(), ObservablesError> {
1196    if out.len() != requests.len() {
1197        return Err(ObservablesError::InvalidInput {
1198            field: "out",
1199            kind: ObservablesInputErrorKind::OutOfRange,
1200        });
1201    }
1202    for (request, slot) in requests.iter().zip(out.iter_mut()) {
1203        *slot = range_prediction_at_rx(
1204            source,
1205            request.sat,
1206            request.receiver_ecef_m,
1207            request.t_rx_j2000_s,
1208            options,
1209        )?;
1210    }
1211    Ok(())
1212}
1213
1214/// Predict media-corrected ranges for many requests.
1215///
1216/// `out[i].prediction` is the same geometry-only value produced by
1217/// [`predict_ranges`] for `requests[i]`. `out[i].range_m` adds the selected
1218/// troposphere and ionosphere delays.
1219pub fn predict_ranges_with_media(
1220    source: &dyn ObservableEphemerisSource,
1221    requests: &[RangePredictionRequest],
1222    options: MediaPredictOptions<'_>,
1223    out: &mut [MediaRangePrediction],
1224) -> Result<(), ObservablesError> {
1225    if out.len() != requests.len() {
1226        return Err(ObservablesError::InvalidInput {
1227            field: "out",
1228            kind: ObservablesInputErrorKind::OutOfRange,
1229        });
1230    }
1231    for (request, slot) in requests.iter().zip(out.iter_mut()) {
1232        if options.media.is_disabled() {
1233            let prediction = range_prediction_at_rx(
1234                source,
1235                request.sat,
1236                request.receiver_ecef_m,
1237                request.t_rx_j2000_s,
1238                options.prediction,
1239            )?;
1240            *slot = MediaRangePrediction {
1241                range_m: prediction.geometric_range_m,
1242                prediction,
1243                media: AppliedMediaCorrections::default(),
1244            };
1245            continue;
1246        }
1247        let (prediction, topocentric) = range_prediction_core(
1248            source,
1249            request.sat,
1250            request.receiver_ecef_m,
1251            request.t_rx_j2000_s,
1252            options.prediction,
1253        )?;
1254        let media = observable_media_corrections(
1255            topocentric.receiver,
1256            topocentric.elevation_rad,
1257            topocentric.azimuth_rad,
1258            request.t_rx_j2000_s,
1259            options.prediction.carrier_hz,
1260            options.media,
1261        )?;
1262        let range_m = prediction.geometric_range_m + media.total_m;
1263        validate::finite(range_m, "range_m").map_err(map_input_error)?;
1264        *slot = MediaRangePrediction {
1265            prediction,
1266            range_m,
1267            media,
1268        };
1269    }
1270    Ok(())
1271}
1272
1273/// Range-only transmit-time kernel: iterate light time / Sagnac to the geometric
1274/// range at receive epoch `t_rx_j2000_s` and project just the [`RangePrediction`]
1275/// geometry.
1276///
1277/// This is [`transmit_time_satellite_state`] with the finite-difference velocity
1278/// (and its two extra ephemeris evaluations) removed, since a range prediction
1279/// never carries velocity. Every returned field is bit-identical to the
1280/// corresponding field of `transmit_time_satellite_state` for the same inputs.
1281fn range_prediction_at_rx(
1282    source: &dyn ObservableEphemerisSource,
1283    sat: GnssSatelliteId,
1284    receiver_ecef_m: [f64; 3],
1285    t_rx_j2000_s: f64,
1286    options: PredictOptions,
1287) -> Result<RangePrediction, ObservablesError> {
1288    let (prediction, _, _) =
1289        range_prediction_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1290    Ok(prediction)
1291}
1292
1293fn range_prediction_core(
1294    source: &dyn ObservableEphemerisSource,
1295    sat: GnssSatelliteId,
1296    receiver_ecef_m: [f64; 3],
1297    t_rx_j2000_s: f64,
1298    options: PredictOptions,
1299) -> Result<(RangePrediction, TopocentricGeometry), ObservablesError> {
1300    let (prediction, line_of_sight_m, range) =
1301        range_prediction_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1302    let topocentric = topocentric(receiver_ecef_m, line_of_sight_m, range)?;
1303    Ok((prediction, topocentric))
1304}
1305
1306fn range_prediction_state(
1307    source: &dyn ObservableEphemerisSource,
1308    sat: GnssSatelliteId,
1309    receiver_ecef_m: [f64; 3],
1310    t_rx_j2000_s: f64,
1311    options: PredictOptions,
1312) -> Result<(RangePrediction, [f64; 3], f64), ObservablesError> {
1313    validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
1314    let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1315    let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
1316    let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
1317    let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
1318    let line_of_sight_m = [dx, dy, dz];
1319    let range = geometric_range_m([dx, dy, dz])?;
1320    Ok((
1321        RangePrediction {
1322            geometric_range_m: range,
1323            sat_clock_s: solved.state.clock_s,
1324            transmit_time_j2000_s: solved.transmit_time_j2000_s,
1325            sat_pos_ecef_m: solved.sat_rot_ecef_m,
1326        },
1327        line_of_sight_m,
1328        range,
1329    ))
1330}
1331
1332#[derive(Debug, Clone, Copy)]
1333struct SolvedTransmitTime {
1334    tau_s: f64,
1335    transmit_offset_us: i64,
1336    transmit_time_j2000_s: f64,
1337    state: ObservableState,
1338    sat_rot_ecef_m: [f64; 3],
1339}
1340
1341fn solve_transmit_time(
1342    source: &dyn ObservableEphemerisSource,
1343    sat: GnssSatelliteId,
1344    receiver_ecef_m: [f64; 3],
1345    t_rx_j2000_s: f64,
1346    options: PredictOptions,
1347) -> Result<SolvedTransmitTime, ObservablesError> {
1348    if !options.light_time {
1349        let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
1350        let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
1351        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1352        return Ok(SolvedTransmitTime {
1353            tau_s: 0.0,
1354            transmit_offset_us: 0,
1355            transmit_time_j2000_s: t_rx_j2000_s,
1356            state,
1357            sat_rot_ecef_m: sat_rot,
1358        });
1359    }
1360
1361    let mut tau = 0.0;
1362    for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
1363        let transmit_offset_us = microseconds_from_tau(tau);
1364        let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
1365        let state = validated_state_at_j2000_s(source, sat, t_tx)?;
1366        let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
1367        validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1368        let dx = sat_rot[0] - receiver_ecef_m[0];
1369        let dy = sat_rot[1] - receiver_ecef_m[1];
1370        let dz = sat_rot[2] - receiver_ecef_m[2];
1371        let range = geometric_range_m([dx, dy, dz])?;
1372        let new_tau = range / C_M_S;
1373
1374        if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
1375            return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
1376        }
1377
1378        tau = new_tau;
1379    }
1380
1381    unreachable!("fixed transmit-time loop always returns on its last iteration")
1382}
1383
1384fn finalize_transmit_time(
1385    source: &dyn ObservableEphemerisSource,
1386    sat: GnssSatelliteId,
1387    t_rx_j2000_s: f64,
1388    tau: f64,
1389    sagnac: bool,
1390) -> Result<SolvedTransmitTime, ObservablesError> {
1391    let transmit_offset_us = microseconds_from_tau(tau);
1392    let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
1393    validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
1394    let state = validated_state_at_j2000_s(source, sat, t_tx)?;
1395    let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
1396    validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1397    Ok(SolvedTransmitTime {
1398        tau_s: tau,
1399        transmit_offset_us,
1400        transmit_time_j2000_s: t_tx,
1401        state,
1402        sat_rot_ecef_m: sat_rot,
1403    })
1404}
1405
1406fn microseconds_from_tau(tau_s: f64) -> i64 {
1407    (tau_s * MICROSECONDS_PER_SECOND).round() as i64
1408}
1409
1410fn satellite_velocity(
1411    source: &dyn ObservableEphemerisSource,
1412    sat: GnssSatelliteId,
1413    t_tx_j2000_s: f64,
1414) -> Result<[f64; 3], ObservablesError> {
1415    let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
1416    let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
1417    let denom = 2.0 * FD_HALF_S;
1418    let velocity = [
1419        (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
1420        (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
1421        (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
1422    ];
1423    validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
1424}
1425
1426fn validate_predict_inputs(
1427    receiver_ecef_m: [f64; 3],
1428    t_rx_j2000_s: f64,
1429    options: PredictOptions,
1430) -> Result<(), ObservablesError> {
1431    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
1432    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1433    validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
1434    Ok(())
1435}
1436
1437fn validate_transmit_time_inputs(
1438    receiver_ecef_m: [f64; 3],
1439    t_rx_j2000_s: f64,
1440) -> Result<(), ObservablesError> {
1441    validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
1442    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1443    Ok(())
1444}
1445
1446fn validate_emission_media_batch_options(
1447    options: EmissionMediaBatchOptions<'_>,
1448) -> Result<(), ObservablesError> {
1449    if options.media.needs_carrier() {
1450        validate::finite_positive(options.carrier_hz, "options.carrier_hz")
1451            .map_err(map_input_error)?;
1452    }
1453    if let Some(cutoff) = options.min_elevation_rad {
1454        validate::finite(cutoff, "options.min_elevation_rad").map_err(map_input_error)?;
1455        if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&cutoff) {
1456            return Err(invalid_observable_input(
1457                "options.min_elevation_rad",
1458                ObservablesInputErrorKind::OutOfRange,
1459            ));
1460        }
1461    }
1462    Ok(())
1463}
1464
1465fn validated_state_at_j2000_s(
1466    source: &dyn ObservableEphemerisSource,
1467    sat: GnssSatelliteId,
1468    t_j2000_s: f64,
1469) -> Result<ObservableState, ObservablesError> {
1470    let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
1471    validate_observable_state(&state)?;
1472    Ok(state)
1473}
1474
1475fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
1476    validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
1477        .map_err(map_input_error)?;
1478    if let Some(clock_s) = state.clock_s {
1479        validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
1480    }
1481    Ok(())
1482}
1483
1484fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
1485    let range = (delta_ecef_m[0] * delta_ecef_m[0]
1486        + delta_ecef_m[1] * delta_ecef_m[1]
1487        + delta_ecef_m[2] * delta_ecef_m[2])
1488        .sqrt();
1489    validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
1490}
1491
1492fn map_input_error(error: validate::FieldError) -> ObservablesError {
1493    ObservablesError::InvalidInput {
1494        field: error.field(),
1495        kind: ObservablesInputErrorKind::from(&error),
1496    }
1497}
1498
1499fn invalid_observable_input(
1500    field: &'static str,
1501    kind: ObservablesInputErrorKind,
1502) -> ObservablesError {
1503    ObservablesError::InvalidInput { field, kind }
1504}
1505
1506fn media_instant(t_rx_j2000_s: f64) -> Result<Instant, ObservablesError> {
1507    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1508    let days = (t_rx_j2000_s / SECONDS_PER_DAY).floor();
1509    let seconds_into_day = t_rx_j2000_s - days * SECONDS_PER_DAY;
1510    let fraction = seconds_into_day / SECONDS_PER_DAY;
1511    let split = JulianDateSplit::new(J2000_JD + days, fraction).map_err(|_| {
1512        invalid_observable_input("t_rx_j2000_s", ObservablesInputErrorKind::OutOfRange)
1513    })?;
1514    Ok(Instant::from_julian_date(TimeScale::Gpst, split))
1515}
1516
1517fn rounded_j2000_seconds(t_rx_j2000_s: f64) -> Result<i64, ObservablesError> {
1518    validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1519    let rounded = t_rx_j2000_s.round();
1520    if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
1521        return Err(invalid_observable_input(
1522            "t_rx_j2000_s",
1523            ObservablesInputErrorKind::OutOfRange,
1524        ));
1525    }
1526    Ok(rounded as i64)
1527}
1528
1529fn map_media_error(error: Error) -> ObservablesError {
1530    match error {
1531        Error::InvalidInput(message) => map_media_invalid_input(&message),
1532        Error::IonexOutOfCoverage(_) => ObservablesError::Media(error),
1533        _ => invalid_observable_input("media", ObservablesInputErrorKind::OutOfRange),
1534    }
1535}
1536
1537fn map_media_invalid_input(message: &str) -> ObservablesError {
1538    let kind = if message.ends_with("not finite") {
1539        ObservablesInputErrorKind::NonFinite
1540    } else if message.ends_with("not positive") {
1541        ObservablesInputErrorKind::NotPositive
1542    } else if message.ends_with("negative") {
1543        ObservablesInputErrorKind::Negative
1544    } else {
1545        ObservablesInputErrorKind::OutOfRange
1546    };
1547    let field = if message.starts_with("elevation_rad ") {
1548        "media.elevation_rad"
1549    } else if message.starts_with("receiver.lat_rad ") {
1550        "media.receiver.lat_rad"
1551    } else if message.starts_with("receiver.lon_rad ") {
1552        "media.receiver.lon_rad"
1553    } else if message.starts_with("receiver.height_m ") {
1554        "media.receiver.height_m"
1555    } else if message.starts_with("pressure_hpa ") {
1556        "media.pressure_hpa"
1557    } else if message.starts_with("temperature_k ") {
1558        "media.temperature_k"
1559    } else if message.starts_with("relative_humidity ") {
1560        "media.relative_humidity"
1561    } else if message.starts_with("frequency_hz ") {
1562        "media.carrier_hz"
1563    } else if message.starts_with("azimuth_rad ") {
1564        "media.azimuth_rad"
1565    } else {
1566        "media"
1567    };
1568    invalid_observable_input(field, kind)
1569}
1570
1571fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
1572    let sagnac = if apply {
1573        SagnacRecipe::ClosedFormZRotation
1574    } else {
1575        SagnacRecipe::Off
1576    };
1577    crate::estimation::substrate::range::rotate_transmit_satellite(
1578        sagnac,
1579        pos,
1580        tau_s,
1581        OMEGA_E_DOT_RAD_S,
1582    )
1583}
1584
1585#[derive(Debug, Clone, Copy, PartialEq)]
1586struct TopocentricGeometry {
1587    receiver: Wgs84Geodetic,
1588    elevation_rad: f64,
1589    azimuth_rad: f64,
1590    elevation_deg: f64,
1591    azimuth_deg: f64,
1592}
1593
1594fn topocentric(
1595    receiver_ecef_m: [f64; 3],
1596    delta_ecef_m: [f64; 3],
1597    range_m: f64,
1598) -> Result<TopocentricGeometry, ObservablesError> {
1599    let (lat_deg, lon_deg, height_km) = itrs_to_geodetic_compute(
1600        receiver_ecef_m[0] / KM_TO_M,
1601        receiver_ecef_m[1] / KM_TO_M,
1602        receiver_ecef_m[2] / KM_TO_M,
1603    )
1604    .map_err(|_| ObservablesError::InvalidInput {
1605        field: "receiver_ecef_m",
1606        kind: ObservablesInputErrorKind::OutOfRange,
1607    })?;
1608    // The application oracle pins this multiply-then-divide order.
1609    let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1610    let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1611    let receiver = Wgs84Geodetic::new(lat, lon, height_km * KM_TO_M).map_err(|_| {
1612        ObservablesError::InvalidInput {
1613            field: "receiver_ecef_m",
1614            kind: ObservablesInputErrorKind::OutOfRange,
1615        }
1616    })?;
1617
1618    let sl = lat.sin();
1619    let cl = lat.cos();
1620    let so = lon.sin();
1621    let co = lon.cos();
1622
1623    let dx = delta_ecef_m[0];
1624    let dy = delta_ecef_m[1];
1625    let dz = delta_ecef_m[2];
1626
1627    let e = -so * dx + co * dy;
1628    let n = -sl * co * dx - sl * so * dy + cl * dz;
1629    let u = cl * co * dx + cl * so * dy + sl * dz;
1630
1631    // Near the zenith the horizontal projection (e, n) is pure rounding noise,
1632    // so azimuth is degenerate and defined to be 0.0 (RTKLIB satazel semantics).
1633    // Outside that threshold the multiply-then-divide order is pinned by the
1634    // application oracle.
1635    let horiz_sq = e * e + n * n;
1636    let (azimuth_rad, mut azimuth_deg) = if horiz_sq < AZIMUTH_ZENITH_EPS * range_m * range_m {
1637        (0.0, 0.0)
1638    } else {
1639        let raw_azimuth_rad = e.atan2(n);
1640        (
1641            if raw_azimuth_rad < 0.0 {
1642                raw_azimuth_rad + 2.0 * PI
1643            } else {
1644                raw_azimuth_rad
1645            },
1646            raw_azimuth_rad * DEGREES_PER_SEMICIRCLE / PI,
1647        )
1648    };
1649    if azimuth_deg < 0.0 {
1650        azimuth_deg += DEGREES_PER_CIRCLE;
1651    }
1652    // range_m is an ECEF norm, so at an exact zenith u/range_m can round just
1653    // past 1.0 and make asin return NaN. Clamp to the valid asin domain; this
1654    // only touches the previously-NaN boundary and leaves in-range values bit
1655    // identical.
1656    let sin_elevation = (u / range_m).clamp(-1.0, 1.0);
1657    let elevation_rad = sin_elevation.asin();
1658    let elevation_deg = elevation_rad * DEGREES_PER_SEMICIRCLE / PI;
1659
1660    validate::finite(elevation_rad, "elevation_rad").map_err(map_input_error)?;
1661    validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
1662    validate::finite(azimuth_rad, "azimuth_rad").map_err(map_input_error)?;
1663    validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
1664    Ok(TopocentricGeometry {
1665        receiver,
1666        elevation_rad,
1667        azimuth_rad,
1668        elevation_deg,
1669        azimuth_deg,
1670    })
1671}
1672
1673#[cfg(test)]
1674mod public_api_tests {
1675    use super::*;
1676    use crate::{GnssSatelliteId, GnssSystem};
1677
1678    #[derive(Debug, Clone, Copy)]
1679    struct StaticSource {
1680        state: ObservableState,
1681    }
1682
1683    impl ObservableEphemerisSource for StaticSource {
1684        fn observable_state_at_j2000_s(
1685            &self,
1686            _sat: GnssSatelliteId,
1687            _t_j2000_s: f64,
1688        ) -> Result<ObservableState, ObservablesError> {
1689            Ok(self.state)
1690        }
1691    }
1692
1693    #[test]
1694    fn predict_ranges_matches_transmit_time_loop_bitwise() {
1695        let source = StaticSource {
1696            state: ObservableState {
1697                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1698                clock_s: Some(1.25e-6),
1699            },
1700        };
1701        let options = PredictOptions {
1702            carrier_hz: F_L1_HZ,
1703            light_time: true,
1704            sagnac: true,
1705        };
1706        let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1707        let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1708        let requests = [
1709            RangePredictionRequest {
1710                sat: sat1,
1711                receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1712                t_rx_j2000_s: 646_272_000.0,
1713            },
1714            RangePredictionRequest {
1715                sat: sat2,
1716                receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
1717                t_rx_j2000_s: 646_272_060.0,
1718            },
1719        ];
1720        let mut out = [RangePrediction {
1721            geometric_range_m: 0.0,
1722            sat_clock_s: None,
1723            transmit_time_j2000_s: 0.0,
1724            sat_pos_ecef_m: [0.0; 3],
1725        }; 2];
1726        predict_ranges(&source, &requests, options, &mut out).expect("batch range prediction");
1727
1728        let tt_options = TransmitTimeOptions {
1729            light_time: options.light_time,
1730            sagnac: options.sagnac,
1731        };
1732        for (request, got) in requests.iter().zip(out.iter()) {
1733            let single = transmit_time_satellite_state(
1734                &source,
1735                request.sat,
1736                request.receiver_ecef_m,
1737                request.t_rx_j2000_s,
1738                tt_options,
1739            )
1740            .expect("single transmit-time state");
1741            assert_eq!(
1742                got.geometric_range_m.to_bits(),
1743                single.geometric_range_m.to_bits()
1744            );
1745            assert_eq!(
1746                got.transmit_time_j2000_s.to_bits(),
1747                single.transmit_time_j2000_s.to_bits()
1748            );
1749            assert_eq!(
1750                got.sat_clock_s.map(f64::to_bits),
1751                single.clock_s.map(f64::to_bits)
1752            );
1753            assert_eq!(
1754                got.sat_pos_ecef_m.map(f64::to_bits),
1755                single.position_ecef_m.map(f64::to_bits)
1756            );
1757        }
1758    }
1759
1760    #[test]
1761    fn predict_ranges_batch_matches_scalar_calls_bitwise() {
1762        // Item 3: the vectorized batch kernel must be byte-identical to solving
1763        // each request in its own one-element call (no cross-request state).
1764        let source = StaticSource {
1765            state: ObservableState {
1766                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1767                clock_s: Some(1.25e-6),
1768            },
1769        };
1770        let options = PredictOptions::default();
1771        let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1772        let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1773        let requests = [
1774            RangePredictionRequest {
1775                sat: sat1,
1776                receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1777                t_rx_j2000_s: 646_272_000.0,
1778            },
1779            RangePredictionRequest {
1780                sat: sat2,
1781                receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
1782                t_rx_j2000_s: 646_272_060.0,
1783            },
1784            RangePredictionRequest {
1785                sat: sat1,
1786                receiver_ecef_m: [-2_700_000.0, -4_290_000.0, 3_855_000.0],
1787                t_rx_j2000_s: 646_272_120.0,
1788            },
1789        ];
1790        let zero = RangePrediction {
1791            geometric_range_m: 0.0,
1792            sat_clock_s: None,
1793            transmit_time_j2000_s: 0.0,
1794            sat_pos_ecef_m: [0.0; 3],
1795        };
1796
1797        let mut batch = [zero; 3];
1798        predict_ranges(&source, &requests, options, &mut batch).expect("batch ranges");
1799
1800        for (i, request) in requests.iter().enumerate() {
1801            let mut single = [zero; 1];
1802            predict_ranges(&source, std::slice::from_ref(request), options, &mut single)
1803                .expect("single range");
1804            assert_eq!(
1805                batch[i].geometric_range_m.to_bits(),
1806                single[0].geometric_range_m.to_bits()
1807            );
1808            assert_eq!(
1809                batch[i].transmit_time_j2000_s.to_bits(),
1810                single[0].transmit_time_j2000_s.to_bits()
1811            );
1812            assert_eq!(
1813                batch[i].sat_clock_s.map(f64::to_bits),
1814                single[0].sat_clock_s.map(f64::to_bits)
1815            );
1816            assert_eq!(
1817                batch[i].sat_pos_ecef_m.map(f64::to_bits),
1818                single[0].sat_pos_ecef_m.map(f64::to_bits)
1819            );
1820        }
1821    }
1822
1823    #[test]
1824    fn predict_ranges_rejects_length_mismatch() {
1825        let source = StaticSource {
1826            state: ObservableState {
1827                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1828                clock_s: None,
1829            },
1830        };
1831        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1832        let requests = [RangePredictionRequest {
1833            sat,
1834            receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1835            t_rx_j2000_s: 646_272_000.0,
1836        }];
1837        let mut out: [RangePrediction; 0] = [];
1838        let err = predict_ranges(&source, &requests, PredictOptions::default(), &mut out)
1839            .expect_err("length mismatch must fail");
1840        match err {
1841            ObservablesError::InvalidInput { field, kind } => {
1842                assert_eq!(field, "out");
1843                assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
1844            }
1845            other => panic!("expected InvalidInput(out, OutOfRange), got {other:?}"),
1846        }
1847    }
1848
1849    #[test]
1850    fn topocentric_elevation_is_ninety_at_non_equatorial_zenith() {
1851        // A ~45 deg receiver (non-equatorial) with the satellite placed exactly
1852        // along the receiver's recovered geodetic up, so the east/north
1853        // projection is zero and the asin argument u/range lands on the domain
1854        // boundary. For this receiver the rounding pushes u/range to just past
1855        // 1.0 (1.0000000000000002): without the clamp asin returns NaN and the
1856        // finite check turns it into an InvalidInput error. The clamp must yield
1857        // a finite ~90 deg elevation instead. This exact receiver was found by
1858        // scanning ECEF positions for the >1.0 rounding case.
1859        let rx = [
1860            4_509_179.095_483_66,
1861            275_556.225_682_215_9,
1862            4_487_348.408_865_919,
1863        ];
1864        let (lat_deg, lon_deg, _h) =
1865            itrs_to_geodetic_compute(rx[0] / KM_TO_M, rx[1] / KM_TO_M, rx[2] / KM_TO_M)
1866                .expect("receiver geodetic conversion");
1867        assert!(lat_deg.abs() > 40.0, "receiver must be non-equatorial");
1868
1869        // Reconstruct the up unit vector exactly as `topocentric` does, then put
1870        // the satellite straight overhead along it.
1871        let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1872        let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1873        let (sl, cl) = lat.sin_cos();
1874        let (so, co) = lon.sin_cos();
1875        let up = [cl * co, cl * so, sl];
1876
1877        let d = 20_000_000.0_f64;
1878        let delta = [up[0] * d, up[1] * d, up[2] * d];
1879        let range = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
1880        // Guard the regression premise: this geometry really does overshoot the
1881        // asin domain (the bug this test pins).
1882        let u = cl * co * delta[0] + cl * so * delta[1] + sl * delta[2];
1883        assert!(
1884            u / range > 1.0,
1885            "test geometry must overshoot the asin domain"
1886        );
1887
1888        let geometry = topocentric(rx, delta, range).expect("non-equatorial zenith must not error");
1889        assert!(geometry.elevation_deg.is_finite());
1890        assert!((geometry.elevation_deg - 90.0).abs() < 1e-9);
1891    }
1892
1893    #[test]
1894    fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
1895        let source = StaticSource {
1896            state: ObservableState {
1897                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1898                clock_s: Some(1.25e-6),
1899            },
1900        };
1901        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1902        let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
1903        let state = transmit_time_satellite_state(
1904            &source,
1905            sat,
1906            rx,
1907            646_272_000.0,
1908            TransmitTimeOptions {
1909                light_time: false,
1910                sagnac: true,
1911            },
1912        )
1913        .expect("state");
1914        let prediction = predict(
1915            &source,
1916            sat,
1917            rx,
1918            646_272_000.0,
1919            PredictOptions {
1920                carrier_hz: F_L1_HZ,
1921                light_time: false,
1922                sagnac: true,
1923            },
1924        )
1925        .expect("prediction");
1926
1927        assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
1928        assert_eq!(state.transmit_offset_us, 0);
1929        assert_eq!(
1930            state.transmit_time_j2000_s.to_bits(),
1931            646_272_000.0f64.to_bits()
1932        );
1933        assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
1934        assert_eq!(
1935            state.transmit_position_ecef_m.map(f64::to_bits),
1936            source.state.position_ecef_m.map(f64::to_bits)
1937        );
1938        assert_eq!(
1939            state.position_ecef_m.map(f64::to_bits),
1940            prediction.sat_pos_ecef_m.map(f64::to_bits)
1941        );
1942        assert_eq!(
1943            state.velocity_m_s.map(f64::to_bits),
1944            prediction.sat_velocity_m_s.map(f64::to_bits)
1945        );
1946        assert_eq!(
1947            state.geometric_range_m.to_bits(),
1948            prediction.geometric_range_m.to_bits()
1949        );
1950        assert_eq!(
1951            state.los_unit.map(f64::to_bits),
1952            prediction.los_unit.map(f64::to_bits)
1953        );
1954    }
1955}
1956
1957#[cfg(test)]
1958mod media_validation_tests {
1959    //! Provenance: IERS TN36 media corrections. The troposphere and ionosphere
1960    //! model functions are validated in their own modules; these tests prove the
1961    //! observable media wrapper reuses those functions exactly and keeps the
1962    //! default-off prediction path bit-identical.
1963
1964    use super::*;
1965    use crate::astro::time::civil::split_julian_date_from_j2000_seconds;
1966    use crate::ionex::TecGridSamples;
1967    use crate::GnssSystem;
1968
1969    const T_RX_J2000_S: f64 = 646_272_000.0;
1970    const T_RX_J2000_I64: i64 = 646_272_000;
1971
1972    #[derive(Debug, Clone, Copy)]
1973    struct StaticSource {
1974        state: ObservableState,
1975    }
1976
1977    impl ObservableEphemerisSource for StaticSource {
1978        fn observable_state_at_j2000_s(
1979            &self,
1980            _sat: GnssSatelliteId,
1981            _t_j2000_s: f64,
1982        ) -> Result<ObservableState, ObservablesError> {
1983            Ok(self.state)
1984        }
1985    }
1986
1987    fn epoch() -> Instant {
1988        let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(T_RX_J2000_I64);
1989        Instant::from_julian_date(
1990            TimeScale::Gpst,
1991            JulianDateSplit::new(jd_whole, fraction).expect("valid media epoch"),
1992        )
1993    }
1994
1995    fn receiver() -> Wgs84Geodetic {
1996        Wgs84Geodetic::new(0.0, 0.0, 0.0).expect("valid receiver")
1997    }
1998
1999    fn met() -> Met {
2000        Met::new(1013.25, 288.15, 0.5).expect("valid met")
2001    }
2002
2003    fn klobuchar_model() -> IonoModel {
2004        IonoModel::Klobuchar(crate::ionex::KlobucharParams {
2005            alpha: [0.0; 4],
2006            beta: [0.0; 4],
2007        })
2008    }
2009
2010    fn ionex() -> Ionex {
2011        let map = vec![
2012            vec![12.0, 12.0, 12.0],
2013            vec![12.0, 12.0, 12.0],
2014            vec![12.0, 12.0, 12.0],
2015        ];
2016        Ionex::from_samples(TecGridSamples {
2017            map_epochs: vec![epoch()],
2018            lat_nodes_deg: vec![90.0, 0.0, -90.0],
2019            lon_nodes_deg: vec![-180.0, 0.0, 180.0],
2020            dlat_deg: -90.0,
2021            dlon_deg: 180.0,
2022            shell_height_km: 450.0,
2023            base_radius_km: 6371.0,
2024            exponent: 0,
2025            tec_maps: vec![map],
2026            rms_maps: Vec::new(),
2027        })
2028        .expect("valid IONEX samples")
2029    }
2030
2031    fn direct_troposphere(elevation_rad: f64) -> f64 {
2032        let zenith =
2033            tropo_zenith(TropoModel::Saastamoinen, receiver(), met()).expect("zenith delay");
2034        let mapping = tropo_mapping(MappingModel::Niell, elevation_rad, receiver(), epoch())
2035            .expect("mapping");
2036        zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet
2037    }
2038
2039    fn assert_bits_eq(label: &str, got: f64, expected: f64) {
2040        assert_eq!(
2041            got.to_bits(),
2042            expected.to_bits(),
2043            "{label}: got {got:?}, expected {expected:?}"
2044        );
2045    }
2046
2047    fn assert_prediction_bits_eq(got: &PredictedObservables, expected: &PredictedObservables) {
2048        assert_bits_eq(
2049            "geometric range",
2050            got.geometric_range_m,
2051            expected.geometric_range_m,
2052        );
2053        assert_bits_eq("range-rate", got.range_rate_m_s, expected.range_rate_m_s);
2054        assert_bits_eq("Doppler", got.doppler_hz, expected.doppler_hz);
2055        assert_eq!(
2056            got.sat_clock_s.map(f64::to_bits),
2057            expected.sat_clock_s.map(f64::to_bits)
2058        );
2059        assert_bits_eq("elevation", got.elevation_deg, expected.elevation_deg);
2060        assert_bits_eq("azimuth", got.azimuth_deg, expected.azimuth_deg);
2061        assert_eq!(got.transmit_offset_us, expected.transmit_offset_us);
2062        assert_bits_eq(
2063            "transmit time",
2064            got.transmit_time_j2000_s,
2065            expected.transmit_time_j2000_s,
2066        );
2067        for k in 0..3 {
2068            assert_bits_eq("los", got.los_unit[k], expected.los_unit[k]);
2069            assert_bits_eq(
2070                "satellite position",
2071                got.sat_pos_ecef_m[k],
2072                expected.sat_pos_ecef_m[k],
2073            );
2074            assert_bits_eq(
2075                "satellite velocity",
2076                got.sat_velocity_m_s[k],
2077                expected.sat_velocity_m_s[k],
2078            );
2079        }
2080    }
2081
2082    fn assert_range_prediction_bits_eq(got: &RangePrediction, expected: &RangePrediction) {
2083        assert_bits_eq(
2084            "range geometric",
2085            got.geometric_range_m,
2086            expected.geometric_range_m,
2087        );
2088        assert_eq!(
2089            got.sat_clock_s.map(f64::to_bits),
2090            expected.sat_clock_s.map(f64::to_bits)
2091        );
2092        assert_bits_eq(
2093            "range transmit time",
2094            got.transmit_time_j2000_s,
2095            expected.transmit_time_j2000_s,
2096        );
2097        for k in 0..3 {
2098            assert_bits_eq(
2099                "range satellite position",
2100                got.sat_pos_ecef_m[k],
2101                expected.sat_pos_ecef_m[k],
2102            );
2103        }
2104    }
2105
2106    #[test]
2107    fn media_corrections_match_direct_tropo_and_klobuchar_bits() {
2108        for elevation_deg in [5.0_f64, 15.0, 90.0] {
2109            let elevation_rad = elevation_deg * PI / DEGREES_PER_SEMICIRCLE;
2110            let azimuth_rad = 0.25;
2111            let options = ObservableMediaOptions {
2112                troposphere: Some(ObservableTroposphereCorrection {
2113                    met: met(),
2114                    mapping: MappingModel::Niell,
2115                }),
2116                ionosphere: Some(ObservableIonosphereCorrection::Broadcast(klobuchar_model())),
2117            };
2118            let got = observable_media_corrections(
2119                receiver(),
2120                elevation_rad,
2121                azimuth_rad,
2122                T_RX_J2000_S,
2123                F_L1_HZ,
2124                options,
2125            )
2126            .expect("media corrections");
2127            let expected_tropo = direct_troposphere(elevation_rad);
2128            let expected_iono = ionosphere_delay(
2129                receiver(),
2130                elevation_rad,
2131                azimuth_rad,
2132                epoch(),
2133                F_L1_HZ,
2134                &klobuchar_model(),
2135            )
2136            .expect("direct Klobuchar");
2137
2138            assert_bits_eq("troposphere", got.troposphere_m, expected_tropo);
2139            assert_bits_eq("Klobuchar", got.ionosphere_m, expected_iono);
2140            assert_bits_eq("total", got.total_m, expected_tropo + expected_iono);
2141        }
2142    }
2143
2144    #[test]
2145    fn media_corrections_match_direct_ionex_bits() {
2146        let ionex = ionex();
2147        for elevation_deg in [5.0_f64, 15.0, 90.0] {
2148            let elevation_rad = elevation_deg * PI / DEGREES_PER_SEMICIRCLE;
2149            let azimuth_rad = 1.0;
2150            let got = observable_media_corrections(
2151                receiver(),
2152                elevation_rad,
2153                azimuth_rad,
2154                T_RX_J2000_S,
2155                F_L1_HZ,
2156                ObservableMediaOptions {
2157                    troposphere: None,
2158                    ionosphere: Some(ObservableIonosphereCorrection::Ionex(&ionex)),
2159                },
2160            )
2161            .expect("IONEX media correction");
2162            let expected = ionex_slant_delay(
2163                &ionex,
2164                receiver(),
2165                elevation_rad,
2166                azimuth_rad,
2167                T_RX_J2000_I64,
2168                F_L1_HZ,
2169            )
2170            .expect("direct IONEX");
2171
2172            assert_bits_eq("IONEX", got.ionosphere_m, expected);
2173            assert_bits_eq("IONEX total", got.total_m, expected);
2174        }
2175    }
2176
2177    #[test]
2178    fn default_media_prediction_matches_predict_bits() {
2179        let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2180        let rx = [6_378_137.0, 0.0, 0.0];
2181        let source = StaticSource {
2182            state: ObservableState {
2183                position_ecef_m: [26_378_137.0, 0.0, 0.0],
2184                clock_s: Some(0.0),
2185            },
2186        };
2187        let options = PredictOptions {
2188            carrier_hz: F_L1_HZ,
2189            light_time: false,
2190            sagnac: false,
2191        };
2192        let plain = predict(&source, sat, rx, T_RX_J2000_S, options).expect("plain predict");
2193        let media = predict_with_media(
2194            &source,
2195            sat,
2196            rx,
2197            T_RX_J2000_S,
2198            MediaPredictOptions {
2199                prediction: options,
2200                media: ObservableMediaOptions::default(),
2201            },
2202        )
2203        .expect("default media predict");
2204
2205        assert_prediction_bits_eq(&media.prediction, &plain);
2206        assert_bits_eq("default range", media.range_m, plain.geometric_range_m);
2207        assert_eq!(media.media, AppliedMediaCorrections::default());
2208    }
2209
2210    #[test]
2211    fn default_media_prediction_skips_media_epoch_for_large_epoch() {
2212        let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2213        let rx = [6_378_137.0, 0.0, 0.0];
2214        let source = StaticSource {
2215            state: ObservableState {
2216                position_ecef_m: [26_378_137.0, 0.0, 0.0],
2217                clock_s: Some(0.0),
2218            },
2219        };
2220        let options = PredictOptions {
2221            carrier_hz: F_L1_HZ,
2222            light_time: false,
2223            sagnac: false,
2224        };
2225        let t_rx = 1.0e20;
2226        let plain = predict(&source, sat, rx, t_rx, options).expect("plain predict");
2227        let media = predict_with_media(
2228            &source,
2229            sat,
2230            rx,
2231            t_rx,
2232            MediaPredictOptions {
2233                prediction: options,
2234                media: ObservableMediaOptions::default(),
2235            },
2236        )
2237        .expect("default media predict");
2238
2239        assert_prediction_bits_eq(&media.prediction, &plain);
2240        assert_bits_eq("default range", media.range_m, plain.geometric_range_m);
2241        assert_eq!(media.media, AppliedMediaCorrections::default());
2242    }
2243
2244    #[test]
2245    fn below_troposphere_validity_returns_typed_error() {
2246        let err = observable_media_corrections(
2247            receiver(),
2248            2.0 * PI / DEGREES_PER_SEMICIRCLE,
2249            0.0,
2250            T_RX_J2000_S,
2251            F_L1_HZ,
2252            ObservableMediaOptions {
2253                troposphere: Some(ObservableTroposphereCorrection::default()),
2254                ionosphere: None,
2255            },
2256        )
2257        .expect_err("below mapping validity must fail");
2258
2259        match err {
2260            ObservablesError::InvalidInput { field, kind } => {
2261                assert_eq!(field, "media.elevation_rad");
2262                assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
2263            }
2264            other => panic!("expected typed InvalidInput, got {other:?}"),
2265        }
2266    }
2267
2268    #[test]
2269    fn range_media_prediction_adds_direct_troposphere_bits() {
2270        let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2271        let rx = [6_378_137.0, 0.0, 0.0];
2272        let elevation_rad = core::f64::consts::FRAC_PI_2;
2273        let range_m = 20_000_000.0;
2274        let delta = [range_m, 0.0, 0.0];
2275        let source = StaticSource {
2276            state: ObservableState {
2277                position_ecef_m: [rx[0] + delta[0], rx[1] + delta[1], rx[2] + delta[2]],
2278                clock_s: Some(0.0),
2279            },
2280        };
2281        let options = MediaPredictOptions {
2282            prediction: PredictOptions {
2283                carrier_hz: f64::NAN,
2284                light_time: false,
2285                sagnac: false,
2286            },
2287            media: ObservableMediaOptions {
2288                troposphere: Some(ObservableTroposphereCorrection::default()),
2289                ionosphere: None,
2290            },
2291        };
2292        let request = [RangePredictionRequest {
2293            sat,
2294            receiver_ecef_m: rx,
2295            t_rx_j2000_s: T_RX_J2000_S,
2296        }];
2297        let zero_prediction = RangePrediction {
2298            geometric_range_m: 0.0,
2299            sat_clock_s: None,
2300            transmit_time_j2000_s: 0.0,
2301            sat_pos_ecef_m: [0.0; 3],
2302        };
2303        let mut out = [MediaRangePrediction {
2304            prediction: zero_prediction,
2305            range_m: 0.0,
2306            media: AppliedMediaCorrections::default(),
2307        }];
2308        predict_ranges_with_media(&source, &request, options, &mut out)
2309            .expect("range media prediction");
2310        let got = out[0];
2311        let expected = got.prediction.geometric_range_m + direct_troposphere(elevation_rad);
2312        assert_bits_eq("corrected range", got.range_m, expected);
2313    }
2314
2315    #[test]
2316    fn default_range_media_prediction_matches_range_bits_with_unused_carrier() {
2317        let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2318        let rx = [6_378_137.0, 0.0, 0.0];
2319        let source = StaticSource {
2320            state: ObservableState {
2321                position_ecef_m: [26_378_137.0, 0.0, 0.0],
2322                clock_s: Some(0.0),
2323            },
2324        };
2325        let options = PredictOptions {
2326            carrier_hz: f64::NAN,
2327            light_time: false,
2328            sagnac: false,
2329        };
2330        let request = [RangePredictionRequest {
2331            sat,
2332            receiver_ecef_m: rx,
2333            t_rx_j2000_s: T_RX_J2000_S,
2334        }];
2335        let zero_prediction = RangePrediction {
2336            geometric_range_m: 0.0,
2337            sat_clock_s: None,
2338            transmit_time_j2000_s: 0.0,
2339            sat_pos_ecef_m: [0.0; 3],
2340        };
2341        let mut plain = [zero_prediction];
2342        predict_ranges(&source, &request, options, &mut plain).expect("plain range");
2343        let mut media = [MediaRangePrediction {
2344            prediction: zero_prediction,
2345            range_m: 0.0,
2346            media: AppliedMediaCorrections::default(),
2347        }];
2348        predict_ranges_with_media(
2349            &source,
2350            &request,
2351            MediaPredictOptions {
2352                prediction: options,
2353                media: ObservableMediaOptions::default(),
2354            },
2355            &mut media,
2356        )
2357        .expect("default media range");
2358
2359        assert_range_prediction_bits_eq(&media[0].prediction, &plain[0]);
2360        assert_bits_eq(
2361            "default range",
2362            media[0].range_m,
2363            plain[0].geometric_range_m,
2364        );
2365        assert_eq!(media[0].media, AppliedMediaCorrections::default());
2366    }
2367}
2368
2369#[cfg(all(test, sidereon_repo_tests))]
2370mod tests {
2371    use super::*;
2372    use crate::{GnssSatelliteId, GnssSystem};
2373
2374    #[derive(Debug, Clone, Copy)]
2375    struct StaticSource {
2376        state: ObservableState,
2377    }
2378
2379    impl ObservableEphemerisSource for StaticSource {
2380        fn observable_state_at_j2000_s(
2381            &self,
2382            _sat: GnssSatelliteId,
2383            _t_j2000_s: f64,
2384        ) -> Result<ObservableState, ObservablesError> {
2385            Ok(self.state)
2386        }
2387    }
2388
2389    fn sp3_fixture() -> Sp3 {
2390        let path = concat!(
2391            env!("CARGO_MANIFEST_DIR"),
2392            "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
2393        );
2394        let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
2395        Sp3::parse(&bytes).expect("parse SP3 fixture")
2396    }
2397
2398    fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
2399        StaticSource {
2400            state: ObservableState {
2401                position_ecef_m,
2402                clock_s: Some(0.0),
2403            },
2404        }
2405    }
2406
2407    fn no_light_time_options() -> PredictOptions {
2408        PredictOptions {
2409            carrier_hz: F_L1_HZ,
2410            light_time: false,
2411            sagnac: true,
2412        }
2413    }
2414
2415    fn assert_invalid_observables_input(
2416        err: ObservablesError,
2417        field: &'static str,
2418        kind: ObservablesInputErrorKind,
2419    ) {
2420        match err {
2421            ObservablesError::InvalidInput {
2422                field: got_field,
2423                kind: got_kind,
2424            } => {
2425                assert_eq!(got_field, field);
2426                assert_eq!(got_kind, kind);
2427            }
2428            other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
2429        }
2430    }
2431
2432    #[test]
2433    fn split_julian_to_j2000_seconds_matches_orbis_time() {
2434        let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
2435        assert_eq!(t, 646_272_000.0);
2436    }
2437
2438    #[test]
2439    fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
2440        for (jd_whole, jd_fraction, field) in [
2441            (f64::NAN, 0.5, "jd_whole"),
2442            (f64::INFINITY, 0.5, "jd_whole"),
2443            (2_459_024.5, f64::NAN, "jd_fraction"),
2444            (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
2445        ] {
2446            let err = j2000_seconds_from_split(jd_whole, jd_fraction)
2447                .expect_err("non-finite split Julian date part must fail");
2448            assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
2449        }
2450    }
2451
2452    #[test]
2453    fn sp3_predict_reference_case() {
2454        let sp3 = sp3_fixture();
2455        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2456        let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
2457        let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
2458            .expect("predict observables");
2459
2460        assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
2461        assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
2462        assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
2463        assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
2464        assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
2465        assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
2466        assert_eq!(obs.transmit_offset_us, 69_288);
2467        assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
2468        assert_eq!(
2469            obs.los_unit.map(f64::to_bits),
2470            [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
2471        );
2472        assert_eq!(
2473            obs.sat_pos_ecef_m.map(f64::to_bits),
2474            [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
2475        );
2476        assert_eq!(
2477            obs.sat_velocity_m_s.map(f64::to_bits),
2478            [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
2479        );
2480    }
2481
2482    #[test]
2483    fn predict_rejects_invalid_entry_inputs() {
2484        let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
2485        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2486
2487        let err = predict(
2488            &source,
2489            sat,
2490            [f64::NAN, 0.0, 0.0],
2491            646_272_000.0,
2492            no_light_time_options(),
2493        )
2494        .expect_err("non-finite receiver position must fail");
2495        assert_invalid_observables_input(
2496            err,
2497            "receiver_ecef_m",
2498            ObservablesInputErrorKind::NonFinite,
2499        );
2500
2501        let err = predict(
2502            &source,
2503            sat,
2504            [0.0, 0.0, 0.0],
2505            f64::INFINITY,
2506            no_light_time_options(),
2507        )
2508        .expect_err("non-finite receive time must fail");
2509        assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
2510
2511        let mut options = no_light_time_options();
2512        options.carrier_hz = 0.0;
2513        let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
2514            .expect_err("non-positive carrier must fail");
2515        assert_invalid_observables_input(
2516            err,
2517            "options.carrier_hz",
2518            ObservablesInputErrorKind::NotPositive,
2519        );
2520    }
2521
2522    #[test]
2523    fn predict_rejects_invalid_source_state_and_zero_range() {
2524        let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2525
2526        let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
2527        let err = predict(
2528            &source,
2529            sat,
2530            [0.0, 0.0, 0.0],
2531            646_272_000.0,
2532            no_light_time_options(),
2533        )
2534        .expect_err("non-finite ephemeris position must fail");
2535        assert_invalid_observables_input(
2536            err,
2537            "observable state position_ecef_m",
2538            ObservablesInputErrorKind::NonFinite,
2539        );
2540
2541        let source = static_source([1_000.0, 2_000.0, 3_000.0]);
2542        let err = predict(
2543            &source,
2544            sat,
2545            [1_000.0, 2_000.0, 3_000.0],
2546            646_272_000.0,
2547            no_light_time_options(),
2548        )
2549        .expect_err("zero geometric range must fail");
2550        assert_invalid_observables_input(
2551            err,
2552            "geometric_range_m",
2553            ObservablesInputErrorKind::NotPositive,
2554        );
2555    }
2556
2557    #[test]
2558    fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
2559        let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
2560            .expect_err("invalid receiver geodetic conversion must fail");
2561
2562        assert_invalid_observables_input(
2563            err,
2564            "receiver_ecef_m",
2565            ObservablesInputErrorKind::OutOfRange,
2566        );
2567    }
2568
2569    // WGS84 equatorial radius; a receiver here sits at lat=0, lon=0, height~=0,
2570    // so the geodetic up direction is +X and a satellite displaced along +X is
2571    // exactly overhead (degenerate azimuth geometry).
2572    const EQUATORIAL_RX_X_M: f64 = 6_378_137.0;
2573
2574    #[test]
2575    fn topocentric_azimuth_is_zero_at_exact_zenith() {
2576        // Satellite displaced purely radially (+X) above an equatorial receiver:
2577        // east == north == 0, so azimuth is degenerate.
2578        let geometry = topocentric(
2579            [EQUATORIAL_RX_X_M, 0.0, 0.0],
2580            [20_000_000.0, 0.0, 0.0],
2581            20_000_000.0,
2582        )
2583        .expect("zenith topocentric must not error");
2584        assert_eq!(geometry.azimuth_deg, 0.0);
2585        assert!(geometry.azimuth_deg.is_finite());
2586        assert!((geometry.elevation_deg - 90.0).abs() < 1e-9);
2587    }
2588
2589    #[test]
2590    fn topocentric_azimuth_is_zero_just_off_zenith() {
2591        // A ~1e-9 m horizontal nudge is pure rounding-scale noise at a 20,000 km
2592        // range, so azimuth stays pinned to 0.0 (RTKLIB satazel semantics).
2593        let geometry = topocentric(
2594            [EQUATORIAL_RX_X_M, 0.0, 0.0],
2595            [20_000_000.0, 1.0e-9, 1.0e-9],
2596            20_000_000.0,
2597        )
2598        .expect("near-zenith topocentric must not error");
2599        assert_eq!(geometry.azimuth_deg, 0.0);
2600        assert!(geometry.azimuth_deg.is_finite());
2601    }
2602
2603    #[test]
2604    fn predict_azimuth_is_zero_at_exact_zenith() {
2605        let source = StaticSource {
2606            state: ObservableState {
2607                position_ecef_m: [EQUATORIAL_RX_X_M + 20_000_000.0, 0.0, 0.0],
2608                clock_s: None,
2609            },
2610        };
2611        let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2612        let obs = predict(
2613            &source,
2614            sat,
2615            [EQUATORIAL_RX_X_M, 0.0, 0.0],
2616            0.0,
2617            PredictOptions {
2618                carrier_hz: F_L1_HZ,
2619                light_time: false,
2620                sagnac: false,
2621            },
2622        )
2623        .expect("zenith predict must not error");
2624        assert_eq!(obs.azimuth_deg, 0.0);
2625        assert!(obs.azimuth_deg.is_finite());
2626        assert!((obs.elevation_deg - 90.0).abs() < 1e-9);
2627    }
2628
2629    fn batch_test_requests() -> Vec<PredictRequest> {
2630        let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2631        let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
2632        vec![
2633            (sat1, [4_027_894.0, 307_046.0, 4_919_474.0], 646_272_000.0),
2634            (sat2, [4_027_900.0, 307_050.0, 4_919_480.0], 646_272_030.0),
2635            (
2636                sat1,
2637                [1_130_000.0, -4_830_000.0, 3_994_000.0],
2638                646_272_060.0,
2639            ),
2640            (
2641                sat2,
2642                [-2_700_000.0, -4_290_000.0, 3_855_000.0],
2643                646_272_090.0,
2644            ),
2645        ]
2646    }
2647
2648    fn assert_observables_bits_eq(a: &PredictedObservables, b: &PredictedObservables) {
2649        assert_eq!(a.geometric_range_m.to_bits(), b.geometric_range_m.to_bits());
2650        assert_eq!(a.range_rate_m_s.to_bits(), b.range_rate_m_s.to_bits());
2651        assert_eq!(a.doppler_hz.to_bits(), b.doppler_hz.to_bits());
2652        assert_eq!(a.elevation_deg.to_bits(), b.elevation_deg.to_bits());
2653        assert_eq!(a.azimuth_deg.to_bits(), b.azimuth_deg.to_bits());
2654        assert_eq!(a.transmit_offset_us, b.transmit_offset_us);
2655        assert_eq!(
2656            a.transmit_time_j2000_s.to_bits(),
2657            b.transmit_time_j2000_s.to_bits()
2658        );
2659        for k in 0..3 {
2660            assert_eq!(a.los_unit[k].to_bits(), b.los_unit[k].to_bits());
2661            assert_eq!(a.sat_pos_ecef_m[k].to_bits(), b.sat_pos_ecef_m[k].to_bits());
2662            assert_eq!(
2663                a.sat_velocity_m_s[k].to_bits(),
2664                b.sat_velocity_m_s[k].to_bits()
2665            );
2666        }
2667    }
2668
2669    #[test]
2670    fn predict_batch_matches_scalar_loop_bitwise() {
2671        let source = StaticSource {
2672            state: ObservableState {
2673                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2674                clock_s: Some(1.25e-6),
2675            },
2676        };
2677        let options = PredictOptions {
2678            carrier_hz: F_L1_HZ,
2679            light_time: true,
2680            sagnac: true,
2681        };
2682        let requests = batch_test_requests();
2683        let batch = predict_batch(&source, &requests, options);
2684        assert_eq!(batch.len(), requests.len());
2685        for (entry, &(sat, rx, t)) in batch.iter().zip(requests.iter()) {
2686            let scalar = predict(&source, sat, rx, t, options);
2687            match (entry, &scalar) {
2688                (Ok(b), Ok(s)) => assert_observables_bits_eq(b, s),
2689                (Err(_), Err(_)) => {}
2690                _ => panic!("batch and scalar predict disagree on Ok/Err"),
2691            }
2692        }
2693    }
2694
2695    #[test]
2696    fn predict_batch_parallel_matches_serial_bitwise() {
2697        let source = StaticSource {
2698            state: ObservableState {
2699                position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2700                clock_s: Some(1.25e-6),
2701            },
2702        };
2703        let options = PredictOptions {
2704            carrier_hz: F_L1_HZ,
2705            light_time: true,
2706            sagnac: true,
2707        };
2708        let requests = batch_test_requests();
2709        let serial = predict_batch(&source, &requests, options);
2710        let parallel = predict_batch_parallel(&source, &requests, options);
2711        assert_eq!(serial.len(), parallel.len());
2712        for (s, p) in serial.iter().zip(parallel.iter()) {
2713            match (s, p) {
2714                (Ok(a), Ok(b)) => assert_observables_bits_eq(a, b),
2715                (Err(_), Err(_)) => {}
2716                _ => panic!("serial and parallel batch disagree on Ok/Err"),
2717            }
2718        }
2719    }
2720}