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