Skip to main content

sidereon_core/ionex/
mod.rs

1//! Ionospheric delay models.
2//!
3//! This module exposes the single-frequency ionospheric group-delay models used
4//! to correct GNSS pseudoranges. The GPS broadcast Klobuchar model and the IONEX
5//! vertical-TEC grid path are both reached through the same [`ionosphere_delay`]
6//! entry, and the IONEX grid parser is exposed directly as [`Ionex`].
7//!
8//! All delays returned are group delays and are positive: they increase the
9//! measured pseudorange (the carrier-phase advance is the negation of this
10//! value). The ionosphere is dispersive, so a delay reported on a carrier other
11//! than the model's native L1 is the L1 delay scaled by `(f_l1 / f)^2`.
12
13mod grid;
14mod klobuchar;
15mod nequick_g;
16mod nequick_g_data;
17mod samples;
18mod slant;
19mod tec_grid;
20mod write;
21
22#[cfg(all(test, sidereon_repo_tests))]
23mod tests;
24
25use crate::astro::constants::time::{DAYS_PER_JULIAN_YEAR, SECONDS_PER_DAY, SECONDS_PER_HOUR};
26use crate::astro::time::civil::{
27    fractional_day_of_year_from_instant, j2000_seconds_from_split, second_of_day_from_instant,
28    split_julian_date_from_j2000_seconds,
29};
30use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
31
32use crate::constants::{DEG_TO_RAD, MEAN_EARTH_RADIUS_M, RAD_TO_DEG};
33use crate::error::{Error, Result};
34use crate::frame::Wgs84Geodetic;
35use crate::frequencies::{self, CarrierBand};
36use crate::GnssSystem;
37
38pub use grid::Ionex;
39pub use nequick_g::{nequick_g_delay_m, nequick_g_stec_tecu, NequickGRayEval};
40pub use samples::{TecGridSamples, TecSample, TecSamplesError};
41pub use tec_grid::{
42    iono_delay_xyz as regular_tec_grid_delay_xyz, tec_xyz as regular_tec_xyz, TecGrid,
43    TecGridEpoch, TecGridEvalOptions, TecGridShellGeometry,
44};
45
46/// Policy applied when an IONEX query lands outside the product's coverage.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum IonexCoveragePolicy {
49    /// Return a typed error before exposing a held value.
50    #[default]
51    Strict,
52    /// Hold the nearest map or grid edge and return a status marker.
53    Hold,
54}
55
56/// IONEX coverage miss detected during slant-delay evaluation.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum IonexCoverageError {
59    /// Query epoch precedes the first map epoch.
60    EpochBeforeFirstMap,
61    /// Query epoch follows the last map epoch.
62    EpochAfterLastMap,
63    /// Pierce-point latitude is outside the latitude nodes.
64    LatitudeOutOfRange,
65    /// Pierce-point longitude is outside the longitude nodes.
66    LongitudeOutOfRange,
67}
68
69impl core::fmt::Display for IonexCoverageError {
70    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71        let message = match self {
72            Self::EpochBeforeFirstMap => "epoch precedes first map",
73            Self::EpochAfterLastMap => "epoch follows last map",
74            Self::LatitudeOutOfRange => "latitude outside grid",
75            Self::LongitudeOutOfRange => "longitude outside grid",
76        };
77        f.write_str(message)
78    }
79}
80
81/// Coverage status for a successful IONEX slant-delay value.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum IonexSlantDelayStatus {
84    /// The query was inside the product's temporal and spatial coverage.
85    Valid,
86    /// The value was produced by the explicit hold policy.
87    Held(IonexCoverageError),
88}
89
90/// IONEX slant-delay value with its coverage status.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct IonexSlantDelayEvaluation {
93    /// Ionospheric group delay, meters.
94    pub delay_m: f64,
95    /// Coverage status for `delay_m`.
96    pub status: IonexSlantDelayStatus,
97}
98
99pub(crate) use klobuchar::klobuchar_l1_components;
100pub(crate) use slant::pierce_point;
101
102pub(crate) fn ionex_epoch_from_j2000_seconds(seconds: i64) -> Instant {
103    instant_from_j2000_seconds(TimeScale::Utc, seconds)
104}
105
106pub(crate) fn instant_from_j2000_seconds(scale: TimeScale, seconds: i64) -> Instant {
107    let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(seconds);
108    Instant::from_julian_date(
109        scale,
110        JulianDateSplit::new(jd_whole, fraction).expect("valid split Julian date"),
111    )
112}
113
114pub(crate) fn j2000_seconds_from_instant(epoch: Instant) -> Option<i64> {
115    match epoch.repr {
116        InstantRepr::JulianDate(split) => {
117            let seconds = j2000_seconds_from_split(split.jd_whole, split.fraction);
118            if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
119                Some(seconds.round() as i64)
120            } else {
121                None
122            }
123        }
124        InstantRepr::Nanos(nanos) => {
125            let seconds = (nanos as f64 / 1.0e9).round();
126            if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
127                Some(seconds as i64)
128            } else {
129                None
130            }
131        }
132    }
133}
134
135/// Broadcast Klobuchar alpha/beta coefficients.
136///
137/// `alpha` are the four coefficients of the cosine-amplitude polynomial (in
138/// seconds and seconds-per-semicircle powers); `beta` are the four coefficients
139/// of the period polynomial (in seconds and seconds-per-semicircle powers).
140/// These are the eight values transmitted in the GPS navigation message.
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub struct KlobucharParams {
143    /// Cosine-amplitude polynomial coefficients (a0..a3).
144    pub alpha: [f64; 4],
145    /// Period polynomial coefficients (b0..b3).
146    pub beta: [f64; 4],
147}
148
149/// Galileo broadcast NeQuick-G ionosphere coefficients (`ai0`, `ai1`, `ai2`).
150///
151/// Galileo navigation messages broadcast these three coefficients to drive the
152/// effective ionisation level used by the Galileo single-frequency ionosphere
153/// correction. They are distinct from GPS/BeiDou Klobuchar alpha/beta values.
154#[derive(Debug, Clone, Copy, PartialEq)]
155pub struct GalileoNequickCoeffs {
156    /// Constant effective-ionisation coefficient.
157    pub ai0: f64,
158    /// Linear MODIP coefficient.
159    pub ai1: f64,
160    /// Quadratic MODIP coefficient.
161    pub ai2: f64,
162}
163
164/// Native inputs for the Galileo coefficient-driven ionosphere correction.
165#[derive(Debug, Clone, Copy, PartialEq)]
166pub struct GalileoNequickEval {
167    /// Receiver geodetic latitude, degrees.
168    pub lat_deg: f64,
169    /// Receiver geodetic longitude, degrees.
170    pub lon_deg: f64,
171    /// Satellite elevation, degrees.
172    pub el_deg: f64,
173    /// Galileo-system second of day.
174    pub t_gal_s: f64,
175    /// Fractional day of year.
176    pub day_of_year: f64,
177    /// Carrier frequency on which to report the delay.
178    pub frequency_hz: f64,
179}
180
181/// Selects which ionospheric model produces the delay.
182#[derive(Debug, Clone, Copy, PartialEq)]
183pub enum IonoModel {
184    /// GPS broadcast Klobuchar model with its eight alpha/beta coefficients.
185    Klobuchar(KlobucharParams),
186    /// Galileo coefficient-driven single-frequency ionosphere correction.
187    GalileoNequickG(GalileoNequickCoeffs),
188}
189
190/// Single-frequency ionospheric group delay (code, positive meters).
191///
192/// Dispatches on `model`. `frequency_hz` is the carrier on which the delay is
193/// reported; the model is dispersive, so the delay scales as `1 / f^2`. The
194/// returned value is positive meters that increase the pseudorange.
195pub fn ionosphere_delay(
196    receiver: Wgs84Geodetic,
197    elevation_rad: f64,
198    azimuth_rad: f64,
199    epoch: Instant,
200    frequency_hz: f64,
201    model: &IonoModel,
202) -> Result<f64> {
203    validate_receiver(receiver)?;
204    validate_finite(elevation_rad, "elevation_rad")?;
205    validate_elevation_rad(elevation_rad, "elevation_rad")?;
206    validate_finite(azimuth_rad, "azimuth_rad")?;
207    validate_instant(epoch)?;
208    validate_frequency(frequency_hz)?;
209
210    match model {
211        IonoModel::Klobuchar(params) => klobuchar(
212            params,
213            receiver,
214            elevation_rad,
215            azimuth_rad,
216            epoch,
217            frequency_hz,
218        ),
219        IonoModel::GalileoNequickG(coeffs) => galileo_nequick_g_native(
220            coeffs,
221            GalileoNequickEval {
222                lat_deg: receiver.lat_rad * RAD_TO_DEG,
223                lon_deg: receiver.lon_rad * RAD_TO_DEG,
224                el_deg: elevation_rad * RAD_TO_DEG,
225                t_gal_s: gps_second_of_day(epoch),
226                day_of_year: fractional_day_of_year(epoch),
227                frequency_hz,
228            },
229        ),
230    }
231}
232
233/// GPS broadcast Klobuchar ionospheric group delay (positive meters).
234///
235/// Lower-level entry the Klobuchar arm of [`ionosphere_delay`] calls; exposed so
236/// the broadcast model can be used directly. The receiver geodetic
237/// latitude/longitude and the satellite azimuth/elevation are converted from
238/// radians to the model's published degree boundary, and the GPS second-of-day
239/// is taken from `epoch`. The model evaluates the L1 group delay; the result is
240/// then scaled to `frequency_hz` by the dispersive `(f_l1 / f)^2` factor.
241///
242/// Note on bit-exactness: this wrapper converts both angle (radians -> degrees)
243/// and time (`epoch` -> GPS second-of-day) at its boundary; both are
244/// representation-bound, so the wrapper is NOT bit-exact to a golden expressed
245/// in the kernel's native units (degrees and an exact second-of-day) - the
246/// difference is at the nanometre level. The 0-ULP parity contract is on the
247/// model kernel in those native units; this convenience entry agrees with it to
248/// within that conversion bound.
249pub fn klobuchar(
250    params: &KlobucharParams,
251    receiver: Wgs84Geodetic,
252    elevation_rad: f64,
253    azimuth_rad: f64,
254    epoch: Instant,
255    frequency_hz: f64,
256) -> Result<f64> {
257    validate_receiver(receiver)?;
258    validate_finite(elevation_rad, "elevation_rad")?;
259    validate_elevation_rad(elevation_rad, "elevation_rad")?;
260    validate_finite(azimuth_rad, "azimuth_rad")?;
261    validate_instant(epoch)?;
262
263    klobuchar_native(
264        params,
265        receiver.lat_rad * RAD_TO_DEG,
266        receiver.lon_rad * RAD_TO_DEG,
267        azimuth_rad * RAD_TO_DEG,
268        elevation_rad * RAD_TO_DEG,
269        gps_second_of_day(epoch),
270        frequency_hz,
271    )
272}
273
274/// GPS broadcast Klobuchar group delay in the model's native input units
275/// (positive meters).
276///
277/// Latitude/longitude and azimuth/elevation are in **degrees** (the model's
278/// published boundary) and `t_gps_s` is the GPS **second-of-day** in
279/// `[0, 86400)`. This is the bit-exact (0-ULP) entry: it feeds the model kernel
280/// directly with no angle or time conversion, so a caller holding native inputs
281/// (for example the Elixir wrapper, which already has degrees and an integer
282/// time of day) gets exactly the reference result. The L1 delay is scaled to
283/// `frequency_hz` by the dispersive `(f_l1 / f)^2` factor.
284pub fn klobuchar_native(
285    params: &KlobucharParams,
286    lat_deg: f64,
287    lon_deg: f64,
288    az_deg: f64,
289    el_deg: f64,
290    t_gps_s: f64,
291    frequency_hz: f64,
292) -> Result<f64> {
293    validate_klobuchar_params(params)?;
294    validate_lat_deg(lat_deg, "lat_deg")?;
295    validate_lon_deg(lon_deg, "lon_deg")?;
296    validate_finite(az_deg, "az_deg")?;
297    validate_el_deg(el_deg, "el_deg")?;
298    validate_second_of_day(t_gps_s, "t_gps_s")?;
299    validate_frequency(frequency_hz)?;
300
301    let delay_m = klobuchar_native_unchecked(
302        params,
303        lat_deg,
304        lon_deg,
305        az_deg,
306        el_deg,
307        t_gps_s,
308        frequency_hz,
309    );
310    validate_finite(delay_m, "ionosphere_delay_m")?;
311    Ok(delay_m)
312}
313
314pub(crate) fn klobuchar_native_unchecked(
315    params: &KlobucharParams,
316    lat_deg: f64,
317    lon_deg: f64,
318    az_deg: f64,
319    el_deg: f64,
320    t_gps_s: f64,
321    frequency_hz: f64,
322) -> f64 {
323    let c = klobuchar_l1_components(
324        lat_deg,
325        lon_deg,
326        az_deg,
327        el_deg,
328        t_gps_s,
329        params.alpha,
330        params.beta,
331    );
332
333    let f_l1_hz = frequencies::frequency_hz(GnssSystem::Gps, CarrierBand::L1)
334        .expect("canonical GPS L1 carrier exists");
335    let ratio = f_l1_hz / frequency_hz;
336    c.delay_l1_m * (ratio * ratio)
337}
338
339/// Galileo coefficient-driven single-frequency group delay in native units.
340///
341/// The full Galileo NeQuick-G reference model is a three-dimensional electron
342/// density integration driven by `ai0`/`ai1`/`ai2`. This compact entry keeps the
343/// Galileo/GPS model boundary correct for SPP by using those Galileo broadcast
344/// coefficients to form the effective ionisation level and mapping the resulting
345/// slant TEC to meters with the standard dispersive `40.3 / f^2` relation. It is
346/// deliberately separate from [`klobuchar_native`] so Galileo observations never
347/// consume GPS Klobuchar coefficients when Galileo coefficients are supplied.
348///
349/// Latitude/longitude/elevation are in degrees. `t_gal_s` is the Galileo-system
350/// second of day, and `day_of_year` is the fractional day of year used for a
351/// small seasonal term.
352pub fn galileo_nequick_g_native(
353    coeffs: &GalileoNequickCoeffs,
354    eval: GalileoNequickEval,
355) -> Result<f64> {
356    validate_galileo_nequick_coeffs(coeffs)?;
357    validate_galileo_eval(eval)?;
358
359    let delay_m = galileo_nequick_g_native_unchecked(coeffs, eval);
360    validate_finite(delay_m, "ionosphere_delay_m")?;
361    Ok(delay_m)
362}
363
364pub(crate) fn galileo_nequick_g_native_unchecked(
365    coeffs: &GalileoNequickCoeffs,
366    eval: GalileoNequickEval,
367) -> f64 {
368    let GalileoNequickEval {
369        lat_deg,
370        lon_deg,
371        el_deg,
372        t_gal_s,
373        day_of_year,
374        frequency_hz,
375    } = eval;
376    let mu_deg = galileo_modified_dip_latitude_deg(lat_deg, lon_deg);
377    let az = galileo_effective_ionisation_level(coeffs, mu_deg);
378
379    let local_time_h = (t_gal_s / SECONDS_PER_HOUR + lon_deg / 15.0).rem_euclid(24.0);
380    let solar = 0.5 + 0.5 * libm::cos((local_time_h - 14.0) * (2.0 * std::f64::consts::PI / 24.0));
381    let diurnal = 0.35 + 0.65 * solar.max(0.0);
382    let seasonal = 1.0
383        + 0.08
384            * libm::cos(
385                (day_of_year - 172.0) * (2.0 * std::f64::consts::PI / DAYS_PER_JULIAN_YEAR),
386            );
387    let mu_ratio = mu_deg / 22.0;
388    let equatorial = 1.0 + 0.35 * libm::exp(-(mu_ratio * mu_ratio));
389
390    let vertical_tecu = (2.5 + 0.135 * az) * diurnal * seasonal * equatorial;
391    let mapping = single_layer_mapping(el_deg);
392    let stec_tecu = vertical_tecu.max(0.0) * mapping;
393    let delay_per_tecu_m = 40.3e16 / (frequency_hz * frequency_hz);
394    stec_tecu * delay_per_tecu_m
395}
396
397/// Effective ionisation level `Az` from Galileo broadcast coefficients.
398///
399/// A zero broadcast set selects the Galileo-recommended default value of 63.7
400/// solar-flux units. Nonzero sets are evaluated as `ai0 + ai1*mu + ai2*mu^2`
401/// and clipped to the NeQuick-G driver range.
402pub fn galileo_effective_ionisation_level(
403    coeffs: &GalileoNequickCoeffs,
404    modified_dip_latitude_deg: f64,
405) -> f64 {
406    if coeffs.ai0 == 0.0 && coeffs.ai1 == 0.0 && coeffs.ai2 == 0.0 {
407        return 63.7;
408    }
409    (coeffs.ai0
410        + coeffs.ai1 * modified_dip_latitude_deg
411        + coeffs.ai2 * modified_dip_latitude_deg * modified_dip_latitude_deg)
412        .clamp(0.0, 400.0)
413}
414
415fn galileo_modified_dip_latitude_deg(lat_deg: f64, lon_deg: f64) -> f64 {
416    let lat = lat_deg * DEG_TO_RAD;
417    let lon = lon_deg * DEG_TO_RAD;
418
419    // Centered-dipole approximation used only to drive the broadcast Az
420    // polynomial without shipping the full MODIP grid alongside this crate.
421    let pole_lat = 80.37 * DEG_TO_RAD;
422    let pole_lon = -72.62 * DEG_TO_RAD;
423    let dip_lat = libm::asin(
424        libm::sin(lat) * libm::sin(pole_lat)
425            + libm::cos(lat) * libm::cos(pole_lat) * libm::cos(lon - pole_lon),
426    );
427    let magnetic_dip = libm::atan(2.0 * libm::tan(dip_lat));
428    let denom = libm::cos(lat).max(1.0e-12).sqrt();
429    libm::atan(libm::tan(magnetic_dip) / denom) * RAD_TO_DEG
430}
431
432fn single_layer_mapping(el_deg: f64) -> f64 {
433    let el_rad = el_deg.max(0.1) * DEG_TO_RAD;
434    let earth_radius_m = MEAN_EARTH_RADIUS_M;
435    let shell_radius_m = earth_radius_m + 450_000.0;
436    let arg = earth_radius_m / shell_radius_m * libm::cos(el_rad);
437    1.0 / (1.0 - arg * arg).max(1.0e-12).sqrt()
438}
439
440/// IONEX vertical-TEC-grid slant ionospheric group delay (positive meters).
441///
442/// Maps the parsed [`Ionex`] vertical-TEC grid to the line of sight in the
443/// single-layer-model convention: a single-layer pierce point at the product's
444/// shell height, an explicit four-term bilinear VTEC per map, a linear-in-time
445/// blend between the two maps bracketing `epoch_j2000_s`, the
446/// `1/sqrt(1 - s^2)` obliquity factor, and the
447/// dispersive `40.3e16 / f^2` frequency scaling.
448///
449/// The receiver geodetic latitude/longitude come from `receiver` (height is
450/// unused: the pierce point rides on the IONEX shell, not the antenna height).
451/// The epoch is taken as integer J2000 seconds so it lands exactly on the
452/// product's own epoch axis, with no float-rounded time entering the temporal
453/// bracket. `frequency_hz` is the carrier on which the delay is reported. The
454/// returned value is positive meters that increase the pseudorange. This
455/// default entry uses [`IonexCoveragePolicy::Strict`].
456pub fn ionex_slant_delay(
457    ionex: &Ionex,
458    receiver: Wgs84Geodetic,
459    elevation_rad: f64,
460    azimuth_rad: f64,
461    epoch_j2000_s: i64,
462    frequency_hz: f64,
463) -> Result<f64> {
464    Ok(ionex_slant_delay_with_policy(
465        ionex,
466        receiver,
467        elevation_rad,
468        azimuth_rad,
469        epoch_j2000_s,
470        frequency_hz,
471        IonexCoveragePolicy::Strict,
472    )?
473    .delay_m)
474}
475
476/// IONEX slant delay with an explicit coverage policy.
477pub fn ionex_slant_delay_with_policy(
478    ionex: &Ionex,
479    receiver: Wgs84Geodetic,
480    elevation_rad: f64,
481    azimuth_rad: f64,
482    epoch_j2000_s: i64,
483    frequency_hz: f64,
484    policy: IonexCoveragePolicy,
485) -> Result<IonexSlantDelayEvaluation> {
486    validate_ionex_slant_inputs(receiver, elevation_rad, azimuth_rad, frequency_hz)?;
487
488    let evaluation = ionex_slant_delay_unchecked_with_policy(
489        ionex,
490        IonexSlantRequest {
491            receiver,
492            elevation_rad,
493            azimuth_rad,
494            epoch_j2000_s,
495            frequency_hz,
496        },
497        ionex_vtec_grid_view(ionex),
498        policy,
499    )?;
500    validate_finite(evaluation.delay_m, "ionosphere_delay_m")?;
501    Ok(evaluation)
502}
503
504/// One IONEX slant-delay query for [`ionex_slant_delays`].
505#[derive(Debug, Clone, Copy, PartialEq)]
506pub struct IonexSlantRequest {
507    /// Receiver geodetic position.
508    pub receiver: Wgs84Geodetic,
509    /// Satellite elevation above the local horizon, radians.
510    pub elevation_rad: f64,
511    /// Satellite azimuth, radians.
512    pub azimuth_rad: f64,
513    /// Query epoch, integer seconds since J2000.
514    pub epoch_j2000_s: i64,
515    /// Carrier frequency on which to report the delay, hertz.
516    pub frequency_hz: f64,
517}
518
519/// Batch IONEX vertical-TEC-grid slant ionospheric group delays.
520///
521/// Evaluates `requests` into `out` one-to-one. The output slice must have the
522/// same length as the request slice. Each request uses the same validation order
523/// and scalar kernel as [`ionex_slant_delay`], while the borrowed grid view is
524/// built once for the batch.
525pub fn ionex_slant_delays(
526    ionex: &Ionex,
527    requests: &[IonexSlantRequest],
528    out: &mut [f64],
529) -> Result<()> {
530    if out.len() != requests.len() {
531        return Err(Error::InvalidInput(format!(
532            "IONEX slant output length {} does not match request length {}",
533            out.len(),
534            requests.len()
535        )));
536    }
537
538    let grid = ionex_vtec_grid_view(ionex);
539    for (request, output) in requests.iter().zip(out.iter_mut()) {
540        validate_ionex_slant_request(*request)?;
541
542        let evaluation = ionex_slant_delay_unchecked_with_policy(
543            ionex,
544            *request,
545            grid,
546            IonexCoveragePolicy::Strict,
547        )?;
548        let delay_m = evaluation.delay_m;
549        validate_finite(delay_m, "ionosphere_delay_m")?;
550        debug_assert!(delay_m.is_finite());
551        *output = delay_m;
552    }
553    Ok(())
554}
555
556/// Evaluate IONEX slant delays as one result per request.
557///
558/// This keeps batch-level plumbing out of per-element failures: malformed rows
559/// and strict coverage misses are returned in their own element, matching the
560/// loud batch convention used by terrain lookups.
561pub fn ionex_slant_delay_results(
562    ionex: &Ionex,
563    requests: &[IonexSlantRequest],
564    policy: IonexCoveragePolicy,
565) -> Vec<Result<IonexSlantDelayEvaluation>> {
566    let grid = ionex_vtec_grid_view(ionex);
567    requests
568        .iter()
569        .map(|request| {
570            validate_ionex_slant_request(*request)?;
571            let evaluation =
572                ionex_slant_delay_unchecked_with_policy(ionex, *request, grid, policy)?;
573            validate_finite(evaluation.delay_m, "ionosphere_delay_m")?;
574            Ok(evaluation)
575        })
576        .collect()
577}
578
579impl Ionex {
580    /// Evaluate a batch of IONEX slant ionospheric group delays into `out`.
581    ///
582    /// This is the method form of [`ionex_slant_delays`]. Parsed products and
583    /// products built with [`Ionex::from_samples`] use the same stored grid
584    /// fields, so both routes reach the same scalar kernel in input order. Each
585    /// output element is bit-identical to the matching [`ionex_slant_delay`]
586    /// call.
587    pub fn slant_delays_batch(
588        &self,
589        requests: &[IonexSlantRequest],
590        out: &mut [f64],
591    ) -> Result<()> {
592        ionex_slant_delays(self, requests, out)
593    }
594
595    /// Evaluate a batch of IONEX slant ionospheric group delays into a new
596    /// contiguous vector.
597    ///
598    /// This convenience method allocates exactly one output vector and delegates
599    /// to [`Self::slant_delays_batch`]. Values are bit-identical to the scalar
600    /// [`ionex_slant_delay`] sequence for the same requests.
601    pub fn slant_delays_batch_vec(&self, requests: &[IonexSlantRequest]) -> Result<Vec<f64>> {
602        let mut out = vec![0.0; requests.len()];
603        self.slant_delays_batch(requests, &mut out)?;
604        Ok(out)
605    }
606
607    /// Evaluate IONEX slant ionospheric group delays as one result per request.
608    pub fn slant_delays_batch_results(
609        &self,
610        requests: &[IonexSlantRequest],
611        policy: IonexCoveragePolicy,
612    ) -> Vec<Result<IonexSlantDelayEvaluation>> {
613        ionex_slant_delay_results(self, requests, policy)
614    }
615}
616
617fn ionex_slant_delay_unchecked_with_policy(
618    ionex: &Ionex,
619    request: IonexSlantRequest,
620    grid: slant::VtecGridView<'_>,
621    policy: IonexCoveragePolicy,
622) -> Result<IonexSlantDelayEvaluation> {
623    let (components, coverage) = slant::slant_delay_components_with_policy(
624        slant::PierceLineOfSight {
625            lat_rad: request.receiver.lat_rad,
626            lon_rad: request.receiver.lon_rad,
627            az_rad: request.azimuth_rad,
628            el_rad: request.elevation_rad,
629        },
630        request.frequency_hz,
631        ionex.base_radius_km(),
632        ionex.shell_height_km(),
633        request.epoch_j2000_s,
634        grid,
635        policy,
636    )
637    .map_err(Error::IonexOutOfCoverage)?;
638    let status = match coverage {
639        Some(error) => IonexSlantDelayStatus::Held(error),
640        None => IonexSlantDelayStatus::Valid,
641    };
642    Ok(IonexSlantDelayEvaluation {
643        delay_m: components.delay_m,
644        status,
645    })
646}
647
648fn ionex_vtec_grid_view(ionex: &Ionex) -> slant::VtecGridView<'_> {
649    slant::VtecGridView {
650        map_epochs: ionex.map_epochs(),
651        maps: ionex.tec_maps(),
652        lat_arr: ionex.lat_nodes_deg(),
653        lon_arr: ionex.lon_nodes_deg(),
654        dlat: ionex.dlat_deg(),
655        dlon: ionex.dlon_deg(),
656    }
657}
658
659fn validate_ionex_slant_request(request: IonexSlantRequest) -> Result<()> {
660    validate_ionex_slant_inputs(
661        request.receiver,
662        request.elevation_rad,
663        request.azimuth_rad,
664        request.frequency_hz,
665    )
666}
667
668fn validate_ionex_slant_inputs(
669    receiver: Wgs84Geodetic,
670    elevation_rad: f64,
671    azimuth_rad: f64,
672    frequency_hz: f64,
673) -> Result<()> {
674    validate_receiver(receiver)?;
675    validate_finite(elevation_rad, "elevation_rad")?;
676    validate_elevation_rad(elevation_rad, "elevation_rad")?;
677    validate_finite(azimuth_rad, "azimuth_rad")?;
678    validate_frequency(frequency_hz)
679}
680
681fn validate_klobuchar_params(params: &KlobucharParams) -> Result<()> {
682    for (index, &value) in params.alpha.iter().enumerate() {
683        validate_finite(value, if index == 0 { "alpha" } else { "alpha[]" })?;
684    }
685    for (index, &value) in params.beta.iter().enumerate() {
686        validate_finite(value, if index == 0 { "beta" } else { "beta[]" })?;
687    }
688    Ok(())
689}
690
691fn validate_galileo_nequick_coeffs(coeffs: &GalileoNequickCoeffs) -> Result<()> {
692    validate_finite(coeffs.ai0, "ai0")?;
693    validate_finite(coeffs.ai1, "ai1")?;
694    validate_finite(coeffs.ai2, "ai2")
695}
696
697fn validate_galileo_eval(eval: GalileoNequickEval) -> Result<()> {
698    validate_lat_deg(eval.lat_deg, "lat_deg")?;
699    validate_lon_deg(eval.lon_deg, "lon_deg")?;
700    validate_el_deg(eval.el_deg, "el_deg")?;
701    validate_second_of_day(eval.t_gal_s, "t_gal_s")?;
702    validate_finite(eval.day_of_year, "day_of_year")?;
703    if !(1.0..367.0).contains(&eval.day_of_year) {
704        return Err(invalid_input("day_of_year", "out of range"));
705    }
706    validate_frequency(eval.frequency_hz)
707}
708
709pub(crate) fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
710    validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
711    validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
712    validate_finite(receiver.height_m, "receiver.height_m")?;
713    if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
714        return Err(invalid_input("receiver.lat_rad", "out of range"));
715    }
716    if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
717        return Err(invalid_input("receiver.lon_rad", "out of range"));
718    }
719    Ok(())
720}
721
722fn validate_instant(epoch: Instant) -> Result<()> {
723    match epoch.repr {
724        InstantRepr::JulianDate(split) => {
725            validate_finite(split.jd_whole, "epoch.jd_whole")?;
726            validate_finite(split.fraction, "epoch.fraction")?;
727            if !(-1.0..=1.0).contains(&split.fraction) {
728                return Err(invalid_input("epoch.fraction", "out of range"));
729            }
730        }
731        InstantRepr::Nanos(_) => {}
732    }
733    Ok(())
734}
735
736fn validate_lat_deg(value: f64, field: &'static str) -> Result<()> {
737    validate_finite(value, field)?;
738    if !(-90.0..=90.0).contains(&value) {
739        return Err(invalid_input(field, "out of range"));
740    }
741    Ok(())
742}
743
744fn validate_lon_deg(value: f64, field: &'static str) -> Result<()> {
745    validate_finite(value, field)?;
746    if !(-180.0..=180.0).contains(&value) {
747        return Err(invalid_input(field, "out of range"));
748    }
749    Ok(())
750}
751
752pub(crate) fn validate_elevation_rad(value: f64, field: &'static str) -> Result<()> {
753    if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&value) {
754        return Err(invalid_input(field, "out of range"));
755    }
756    Ok(())
757}
758
759fn validate_el_deg(value: f64, field: &'static str) -> Result<()> {
760    validate_finite(value, field)?;
761    if !(0.0..=90.0).contains(&value) {
762        return Err(invalid_input(field, "out of range"));
763    }
764    Ok(())
765}
766
767fn validate_second_of_day(value: f64, field: &'static str) -> Result<()> {
768    validate_finite(value, field)?;
769    if !(0.0..SECONDS_PER_DAY).contains(&value) {
770        return Err(invalid_input(field, "out of range"));
771    }
772    Ok(())
773}
774
775pub(crate) fn validate_frequency(frequency_hz: f64) -> Result<()> {
776    validate_finite(frequency_hz, "frequency_hz")?;
777    if frequency_hz <= 0.0 {
778        return Err(invalid_input("frequency_hz", "not positive"));
779    }
780    Ok(())
781}
782
783fn validate_finite(value: f64, field: &'static str) -> Result<()> {
784    if value.is_finite() {
785        Ok(())
786    } else {
787        Err(invalid_input(field, "not finite"))
788    }
789}
790
791fn invalid_input(field: &'static str, reason: &'static str) -> Error {
792    Error::InvalidInput(format!("{field} {reason}"))
793}
794
795/// GPS second-of-day in `[0, 86400)` carried by an instant.
796///
797/// The Klobuchar diurnal term needs the local-solar-time argument, built from
798/// the GPS second-of-day. A Julian date's civil day begins at noon, so the
799/// midnight day fraction is `(jd + 0.5)` modulo one.
800///
801/// Precision: for a split-Julian-date instant the second-of-day is
802/// `day_fraction * 86400`, and `day_fraction` is itself a rounded binary
803/// fraction of a day, so this recovers the second-of-day only to within the
804/// float granularity of a day fraction (a few microseconds) - a
805/// sub-nanometre-to-nanometre perturbation in the delay. The bit-exact (0-ULP)
806/// contract is on the model kernel evaluated at an exact second-of-day, not on
807/// this convenience conversion. An integer-nanosecond instant is exact (it
808/// reduces by the seconds-per-day modulus).
809fn gps_second_of_day(epoch: Instant) -> f64 {
810    second_of_day_from_instant(epoch)
811}
812
813/// Fractional day-of-year carried by an instant, Jan 1 00:00:00 = 1.0.
814fn fractional_day_of_year(epoch: Instant) -> f64 {
815    fractional_day_of_year_from_instant(epoch)
816}