Skip to main content

sidereon_core/
observables.rs

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