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
46pub(crate) use klobuchar::klobuchar_l1_components;
47pub(crate) use slant::pierce_point;
48
49pub(crate) fn ionex_epoch_from_j2000_seconds(seconds: i64) -> Instant {
50    instant_from_j2000_seconds(TimeScale::Utc, seconds)
51}
52
53pub(crate) fn instant_from_j2000_seconds(scale: TimeScale, seconds: i64) -> Instant {
54    let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(seconds);
55    Instant::from_julian_date(
56        scale,
57        JulianDateSplit::new(jd_whole, fraction).expect("valid split Julian date"),
58    )
59}
60
61pub(crate) fn j2000_seconds_from_instant(epoch: Instant) -> Option<i64> {
62    match epoch.repr {
63        InstantRepr::JulianDate(split) => {
64            let seconds = j2000_seconds_from_split(split.jd_whole, split.fraction);
65            if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
66                Some(seconds.round() as i64)
67            } else {
68                None
69            }
70        }
71        InstantRepr::Nanos(nanos) => {
72            let seconds = (nanos as f64 / 1.0e9).round();
73            if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
74                Some(seconds as i64)
75            } else {
76                None
77            }
78        }
79    }
80}
81
82/// Broadcast Klobuchar alpha/beta coefficients.
83///
84/// `alpha` are the four coefficients of the cosine-amplitude polynomial (in
85/// seconds and seconds-per-semicircle powers); `beta` are the four coefficients
86/// of the period polynomial (in seconds and seconds-per-semicircle powers).
87/// These are the eight values transmitted in the GPS navigation message.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct KlobucharParams {
90    /// Cosine-amplitude polynomial coefficients (a0..a3).
91    pub alpha: [f64; 4],
92    /// Period polynomial coefficients (b0..b3).
93    pub beta: [f64; 4],
94}
95
96/// Galileo broadcast NeQuick-G ionosphere coefficients (`ai0`, `ai1`, `ai2`).
97///
98/// Galileo navigation messages broadcast these three coefficients to drive the
99/// effective ionisation level used by the Galileo single-frequency ionosphere
100/// correction. They are distinct from GPS/BeiDou Klobuchar alpha/beta values.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct GalileoNequickCoeffs {
103    /// Constant effective-ionisation coefficient.
104    pub ai0: f64,
105    /// Linear MODIP coefficient.
106    pub ai1: f64,
107    /// Quadratic MODIP coefficient.
108    pub ai2: f64,
109}
110
111/// Native inputs for the Galileo coefficient-driven ionosphere correction.
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct GalileoNequickEval {
114    /// Receiver geodetic latitude, degrees.
115    pub lat_deg: f64,
116    /// Receiver geodetic longitude, degrees.
117    pub lon_deg: f64,
118    /// Satellite elevation, degrees.
119    pub el_deg: f64,
120    /// Galileo-system second of day.
121    pub t_gal_s: f64,
122    /// Fractional day of year.
123    pub day_of_year: f64,
124    /// Carrier frequency on which to report the delay.
125    pub frequency_hz: f64,
126}
127
128/// Selects which ionospheric model produces the delay.
129#[derive(Debug, Clone, Copy, PartialEq)]
130pub enum IonoModel {
131    /// GPS broadcast Klobuchar model with its eight alpha/beta coefficients.
132    Klobuchar(KlobucharParams),
133    /// Galileo coefficient-driven single-frequency ionosphere correction.
134    GalileoNequickG(GalileoNequickCoeffs),
135}
136
137/// Single-frequency ionospheric group delay (code, positive meters).
138///
139/// Dispatches on `model`. `frequency_hz` is the carrier on which the delay is
140/// reported; the model is dispersive, so the delay scales as `1 / f^2`. The
141/// returned value is positive meters that increase the pseudorange.
142pub fn ionosphere_delay(
143    receiver: Wgs84Geodetic,
144    elevation_rad: f64,
145    azimuth_rad: f64,
146    epoch: Instant,
147    frequency_hz: f64,
148    model: &IonoModel,
149) -> Result<f64> {
150    validate_receiver(receiver)?;
151    validate_finite(elevation_rad, "elevation_rad")?;
152    validate_elevation_rad(elevation_rad, "elevation_rad")?;
153    validate_finite(azimuth_rad, "azimuth_rad")?;
154    validate_instant(epoch)?;
155    validate_frequency(frequency_hz)?;
156
157    match model {
158        IonoModel::Klobuchar(params) => klobuchar(
159            params,
160            receiver,
161            elevation_rad,
162            azimuth_rad,
163            epoch,
164            frequency_hz,
165        ),
166        IonoModel::GalileoNequickG(coeffs) => galileo_nequick_g_native(
167            coeffs,
168            GalileoNequickEval {
169                lat_deg: receiver.lat_rad * RAD_TO_DEG,
170                lon_deg: receiver.lon_rad * RAD_TO_DEG,
171                el_deg: elevation_rad * RAD_TO_DEG,
172                t_gal_s: gps_second_of_day(epoch),
173                day_of_year: fractional_day_of_year(epoch),
174                frequency_hz,
175            },
176        ),
177    }
178}
179
180/// GPS broadcast Klobuchar ionospheric group delay (positive meters).
181///
182/// Lower-level entry the Klobuchar arm of [`ionosphere_delay`] calls; exposed so
183/// the broadcast model can be used directly. The receiver geodetic
184/// latitude/longitude and the satellite azimuth/elevation are converted from
185/// radians to the model's published degree boundary, and the GPS second-of-day
186/// is taken from `epoch`. The model evaluates the L1 group delay; the result is
187/// then scaled to `frequency_hz` by the dispersive `(f_l1 / f)^2` factor.
188///
189/// Note on bit-exactness: this wrapper converts both angle (radians -> degrees)
190/// and time (`epoch` -> GPS second-of-day) at its boundary; both are
191/// representation-bound, so the wrapper is NOT bit-exact to a golden expressed
192/// in the kernel's native units (degrees and an exact second-of-day) - the
193/// difference is at the nanometre level. The 0-ULP parity contract is on the
194/// model kernel in those native units; this convenience entry agrees with it to
195/// within that conversion bound.
196pub fn klobuchar(
197    params: &KlobucharParams,
198    receiver: Wgs84Geodetic,
199    elevation_rad: f64,
200    azimuth_rad: f64,
201    epoch: Instant,
202    frequency_hz: f64,
203) -> Result<f64> {
204    validate_receiver(receiver)?;
205    validate_finite(elevation_rad, "elevation_rad")?;
206    validate_elevation_rad(elevation_rad, "elevation_rad")?;
207    validate_finite(azimuth_rad, "azimuth_rad")?;
208    validate_instant(epoch)?;
209
210    klobuchar_native(
211        params,
212        receiver.lat_rad * RAD_TO_DEG,
213        receiver.lon_rad * RAD_TO_DEG,
214        azimuth_rad * RAD_TO_DEG,
215        elevation_rad * RAD_TO_DEG,
216        gps_second_of_day(epoch),
217        frequency_hz,
218    )
219}
220
221/// GPS broadcast Klobuchar group delay in the model's native input units
222/// (positive meters).
223///
224/// Latitude/longitude and azimuth/elevation are in **degrees** (the model's
225/// published boundary) and `t_gps_s` is the GPS **second-of-day** in
226/// `[0, 86400)`. This is the bit-exact (0-ULP) entry: it feeds the model kernel
227/// directly with no angle or time conversion, so a caller holding native inputs
228/// (for example the Elixir wrapper, which already has degrees and an integer
229/// time of day) gets exactly the reference result. The L1 delay is scaled to
230/// `frequency_hz` by the dispersive `(f_l1 / f)^2` factor.
231pub fn klobuchar_native(
232    params: &KlobucharParams,
233    lat_deg: f64,
234    lon_deg: f64,
235    az_deg: f64,
236    el_deg: f64,
237    t_gps_s: f64,
238    frequency_hz: f64,
239) -> Result<f64> {
240    validate_klobuchar_params(params)?;
241    validate_lat_deg(lat_deg, "lat_deg")?;
242    validate_lon_deg(lon_deg, "lon_deg")?;
243    validate_finite(az_deg, "az_deg")?;
244    validate_el_deg(el_deg, "el_deg")?;
245    validate_second_of_day(t_gps_s, "t_gps_s")?;
246    validate_frequency(frequency_hz)?;
247
248    let delay_m = klobuchar_native_unchecked(
249        params,
250        lat_deg,
251        lon_deg,
252        az_deg,
253        el_deg,
254        t_gps_s,
255        frequency_hz,
256    );
257    validate_finite(delay_m, "ionosphere_delay_m")?;
258    Ok(delay_m)
259}
260
261pub(crate) fn klobuchar_native_unchecked(
262    params: &KlobucharParams,
263    lat_deg: f64,
264    lon_deg: f64,
265    az_deg: f64,
266    el_deg: f64,
267    t_gps_s: f64,
268    frequency_hz: f64,
269) -> f64 {
270    let c = klobuchar_l1_components(
271        lat_deg,
272        lon_deg,
273        az_deg,
274        el_deg,
275        t_gps_s,
276        params.alpha,
277        params.beta,
278    );
279
280    let f_l1_hz = frequencies::frequency_hz(GnssSystem::Gps, CarrierBand::L1)
281        .expect("canonical GPS L1 carrier exists");
282    let ratio = f_l1_hz / frequency_hz;
283    c.delay_l1_m * (ratio * ratio)
284}
285
286/// Galileo coefficient-driven single-frequency group delay in native units.
287///
288/// The full Galileo NeQuick-G reference model is a three-dimensional electron
289/// density integration driven by `ai0`/`ai1`/`ai2`. This compact entry keeps the
290/// Galileo/GPS model boundary correct for SPP by using those Galileo broadcast
291/// coefficients to form the effective ionisation level and mapping the resulting
292/// slant TEC to meters with the standard dispersive `40.3 / f^2` relation. It is
293/// deliberately separate from [`klobuchar_native`] so Galileo observations never
294/// consume GPS Klobuchar coefficients when Galileo coefficients are supplied.
295///
296/// Latitude/longitude/elevation are in degrees. `t_gal_s` is the Galileo-system
297/// second of day, and `day_of_year` is the fractional day of year used for a
298/// small seasonal term.
299pub fn galileo_nequick_g_native(
300    coeffs: &GalileoNequickCoeffs,
301    eval: GalileoNequickEval,
302) -> Result<f64> {
303    validate_galileo_nequick_coeffs(coeffs)?;
304    validate_galileo_eval(eval)?;
305
306    let delay_m = galileo_nequick_g_native_unchecked(coeffs, eval);
307    validate_finite(delay_m, "ionosphere_delay_m")?;
308    Ok(delay_m)
309}
310
311pub(crate) fn galileo_nequick_g_native_unchecked(
312    coeffs: &GalileoNequickCoeffs,
313    eval: GalileoNequickEval,
314) -> f64 {
315    let GalileoNequickEval {
316        lat_deg,
317        lon_deg,
318        el_deg,
319        t_gal_s,
320        day_of_year,
321        frequency_hz,
322    } = eval;
323    let mu_deg = galileo_modified_dip_latitude_deg(lat_deg, lon_deg);
324    let az = galileo_effective_ionisation_level(coeffs, mu_deg);
325
326    let local_time_h = (t_gal_s / SECONDS_PER_HOUR + lon_deg / 15.0).rem_euclid(24.0);
327    let solar = 0.5 + 0.5 * ((local_time_h - 14.0) * (2.0 * std::f64::consts::PI / 24.0)).cos();
328    let diurnal = 0.35 + 0.65 * solar.max(0.0);
329    let seasonal = 1.0
330        + 0.08
331            * ((day_of_year - 172.0) * (2.0 * std::f64::consts::PI / DAYS_PER_JULIAN_YEAR)).cos();
332    let equatorial = 1.0 + 0.35 * (-(mu_deg / 22.0).powi(2)).exp();
333
334    let vertical_tecu = (2.5 + 0.135 * az) * diurnal * seasonal * equatorial;
335    let mapping = single_layer_mapping(el_deg);
336    let stec_tecu = vertical_tecu.max(0.0) * mapping;
337    let delay_per_tecu_m = 40.3e16 / (frequency_hz * frequency_hz);
338    stec_tecu * delay_per_tecu_m
339}
340
341/// Effective ionisation level `Az` from Galileo broadcast coefficients.
342///
343/// A zero broadcast set selects the Galileo-recommended default value of 63.7
344/// solar-flux units. Nonzero sets are evaluated as `ai0 + ai1*mu + ai2*mu^2`
345/// and clipped to the NeQuick-G driver range.
346pub fn galileo_effective_ionisation_level(
347    coeffs: &GalileoNequickCoeffs,
348    modified_dip_latitude_deg: f64,
349) -> f64 {
350    if coeffs.ai0 == 0.0 && coeffs.ai1 == 0.0 && coeffs.ai2 == 0.0 {
351        return 63.7;
352    }
353    (coeffs.ai0
354        + coeffs.ai1 * modified_dip_latitude_deg
355        + coeffs.ai2 * modified_dip_latitude_deg * modified_dip_latitude_deg)
356        .clamp(0.0, 400.0)
357}
358
359fn galileo_modified_dip_latitude_deg(lat_deg: f64, lon_deg: f64) -> f64 {
360    let lat = lat_deg * DEG_TO_RAD;
361    let lon = lon_deg * DEG_TO_RAD;
362
363    // Centered-dipole approximation used only to drive the broadcast Az
364    // polynomial without shipping the full MODIP grid alongside this crate.
365    let pole_lat = 80.37 * DEG_TO_RAD;
366    let pole_lon = -72.62 * DEG_TO_RAD;
367    let dip_lat =
368        (lat.sin() * pole_lat.sin() + lat.cos() * pole_lat.cos() * (lon - pole_lon).cos()).asin();
369    let magnetic_dip = (2.0 * dip_lat.tan()).atan();
370    let denom = lat.cos().max(1.0e-12).sqrt();
371    (magnetic_dip.tan() / denom).atan() * RAD_TO_DEG
372}
373
374fn single_layer_mapping(el_deg: f64) -> f64 {
375    let el_rad = el_deg.max(0.1) * DEG_TO_RAD;
376    let earth_radius_m = MEAN_EARTH_RADIUS_M;
377    let shell_radius_m = earth_radius_m + 450_000.0;
378    let arg = earth_radius_m / shell_radius_m * el_rad.cos();
379    1.0 / (1.0 - arg * arg).max(1.0e-12).sqrt()
380}
381
382/// IONEX vertical-TEC-grid slant ionospheric group delay (positive meters).
383///
384/// Maps the parsed [`Ionex`] vertical-TEC grid to the line of sight in the
385/// single-layer-model convention: a single-layer pierce point at the product's
386/// shell height, an explicit four-term bilinear VTEC per map, a linear-in-time
387/// blend between the two maps bracketing `epoch_j2000_s` (holding the endpoint
388/// map outside coverage), the `1/sqrt(1 - s^2)` obliquity factor, and the
389/// dispersive `40.3e16 / f^2` frequency scaling.
390///
391/// The receiver geodetic latitude/longitude come from `receiver` (height is
392/// unused: the pierce point rides on the IONEX shell, not the antenna height).
393/// The epoch is taken as integer J2000 seconds so it lands exactly on the
394/// product's own epoch axis, with no float-rounded time entering the temporal
395/// bracket. `frequency_hz` is the carrier on which the delay is reported. The
396/// returned value is positive meters that increase the pseudorange.
397pub fn ionex_slant_delay(
398    ionex: &Ionex,
399    receiver: Wgs84Geodetic,
400    elevation_rad: f64,
401    azimuth_rad: f64,
402    epoch_j2000_s: i64,
403    frequency_hz: f64,
404) -> Result<f64> {
405    validate_receiver(receiver)?;
406    validate_finite(elevation_rad, "elevation_rad")?;
407    validate_elevation_rad(elevation_rad, "elevation_rad")?;
408    validate_finite(azimuth_rad, "azimuth_rad")?;
409    validate_frequency(frequency_hz)?;
410
411    let delay_m = ionex_slant_delay_unchecked(
412        ionex,
413        receiver,
414        elevation_rad,
415        azimuth_rad,
416        epoch_j2000_s,
417        frequency_hz,
418    );
419    validate_finite(delay_m, "ionosphere_delay_m")?;
420    Ok(delay_m)
421}
422
423/// One IONEX slant-delay query for [`ionex_slant_delays`].
424#[derive(Debug, Clone, Copy, PartialEq)]
425pub struct IonexSlantRequest {
426    /// Receiver geodetic position.
427    pub receiver: Wgs84Geodetic,
428    /// Satellite elevation above the local horizon, radians.
429    pub elevation_rad: f64,
430    /// Satellite azimuth, radians.
431    pub azimuth_rad: f64,
432    /// Query epoch, integer seconds since J2000.
433    pub epoch_j2000_s: i64,
434    /// Carrier frequency on which to report the delay, hertz.
435    pub frequency_hz: f64,
436}
437
438/// Batch IONEX vertical-TEC-grid slant ionospheric group delays.
439///
440/// Evaluates `requests` into `out` one-to-one. The output slice must have the
441/// same length as the request slice. Each request uses the same validation order
442/// and scalar kernel as [`ionex_slant_delay`], while the borrowed grid view is
443/// built once for the batch.
444pub fn ionex_slant_delays(
445    ionex: &Ionex,
446    requests: &[IonexSlantRequest],
447    out: &mut [f64],
448) -> Result<()> {
449    if out.len() != requests.len() {
450        return Err(Error::InvalidInput(format!(
451            "IONEX slant output length {} does not match request length {}",
452            out.len(),
453            requests.len()
454        )));
455    }
456
457    let grid = ionex_vtec_grid_view(ionex);
458    for (request, output) in requests.iter().zip(out.iter_mut()) {
459        validate_receiver(request.receiver)?;
460        validate_finite(request.elevation_rad, "elevation_rad")?;
461        validate_elevation_rad(request.elevation_rad, "elevation_rad")?;
462        validate_finite(request.azimuth_rad, "azimuth_rad")?;
463        validate_frequency(request.frequency_hz)?;
464
465        let delay_m = ionex_slant_delay_unchecked_with_grid(
466            ionex,
467            request.receiver,
468            request.elevation_rad,
469            request.azimuth_rad,
470            request.epoch_j2000_s,
471            request.frequency_hz,
472            grid,
473        );
474        validate_finite(delay_m, "ionosphere_delay_m")?;
475        debug_assert!(delay_m.is_finite());
476        *output = delay_m;
477    }
478    Ok(())
479}
480
481fn ionex_slant_delay_unchecked(
482    ionex: &Ionex,
483    receiver: Wgs84Geodetic,
484    elevation_rad: f64,
485    azimuth_rad: f64,
486    epoch_j2000_s: i64,
487    frequency_hz: f64,
488) -> f64 {
489    ionex_slant_delay_unchecked_with_grid(
490        ionex,
491        receiver,
492        elevation_rad,
493        azimuth_rad,
494        epoch_j2000_s,
495        frequency_hz,
496        ionex_vtec_grid_view(ionex),
497    )
498}
499
500fn ionex_slant_delay_unchecked_with_grid(
501    ionex: &Ionex,
502    receiver: Wgs84Geodetic,
503    elevation_rad: f64,
504    azimuth_rad: f64,
505    epoch_j2000_s: i64,
506    frequency_hz: f64,
507    grid: slant::VtecGridView<'_>,
508) -> f64 {
509    slant::slant_delay_components(
510        slant::PierceLineOfSight {
511            lat_rad: receiver.lat_rad,
512            lon_rad: receiver.lon_rad,
513            az_rad: azimuth_rad,
514            el_rad: elevation_rad,
515        },
516        frequency_hz,
517        ionex.base_radius_km(),
518        ionex.shell_height_km(),
519        epoch_j2000_s,
520        grid,
521    )
522    .delay_m
523}
524
525fn ionex_vtec_grid_view(ionex: &Ionex) -> slant::VtecGridView<'_> {
526    slant::VtecGridView {
527        map_epochs: ionex.map_epochs(),
528        maps: ionex.tec_maps(),
529        lat_arr: ionex.lat_nodes_deg(),
530        lon_arr: ionex.lon_nodes_deg(),
531        dlat: ionex.dlat_deg(),
532        dlon: ionex.dlon_deg(),
533    }
534}
535
536fn validate_klobuchar_params(params: &KlobucharParams) -> Result<()> {
537    for (index, &value) in params.alpha.iter().enumerate() {
538        validate_finite(value, if index == 0 { "alpha" } else { "alpha[]" })?;
539    }
540    for (index, &value) in params.beta.iter().enumerate() {
541        validate_finite(value, if index == 0 { "beta" } else { "beta[]" })?;
542    }
543    Ok(())
544}
545
546fn validate_galileo_nequick_coeffs(coeffs: &GalileoNequickCoeffs) -> Result<()> {
547    validate_finite(coeffs.ai0, "ai0")?;
548    validate_finite(coeffs.ai1, "ai1")?;
549    validate_finite(coeffs.ai2, "ai2")
550}
551
552fn validate_galileo_eval(eval: GalileoNequickEval) -> Result<()> {
553    validate_lat_deg(eval.lat_deg, "lat_deg")?;
554    validate_lon_deg(eval.lon_deg, "lon_deg")?;
555    validate_el_deg(eval.el_deg, "el_deg")?;
556    validate_second_of_day(eval.t_gal_s, "t_gal_s")?;
557    validate_finite(eval.day_of_year, "day_of_year")?;
558    if !(1.0..367.0).contains(&eval.day_of_year) {
559        return Err(invalid_input("day_of_year", "out of range"));
560    }
561    validate_frequency(eval.frequency_hz)
562}
563
564pub(crate) fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
565    validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
566    validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
567    validate_finite(receiver.height_m, "receiver.height_m")?;
568    if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
569        return Err(invalid_input("receiver.lat_rad", "out of range"));
570    }
571    if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
572        return Err(invalid_input("receiver.lon_rad", "out of range"));
573    }
574    Ok(())
575}
576
577fn validate_instant(epoch: Instant) -> Result<()> {
578    match epoch.repr {
579        InstantRepr::JulianDate(split) => {
580            validate_finite(split.jd_whole, "epoch.jd_whole")?;
581            validate_finite(split.fraction, "epoch.fraction")?;
582            if !(-1.0..=1.0).contains(&split.fraction) {
583                return Err(invalid_input("epoch.fraction", "out of range"));
584            }
585        }
586        InstantRepr::Nanos(_) => {}
587    }
588    Ok(())
589}
590
591fn validate_lat_deg(value: f64, field: &'static str) -> Result<()> {
592    validate_finite(value, field)?;
593    if !(-90.0..=90.0).contains(&value) {
594        return Err(invalid_input(field, "out of range"));
595    }
596    Ok(())
597}
598
599fn validate_lon_deg(value: f64, field: &'static str) -> Result<()> {
600    validate_finite(value, field)?;
601    if !(-180.0..=180.0).contains(&value) {
602        return Err(invalid_input(field, "out of range"));
603    }
604    Ok(())
605}
606
607pub(crate) fn validate_elevation_rad(value: f64, field: &'static str) -> Result<()> {
608    if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&value) {
609        return Err(invalid_input(field, "out of range"));
610    }
611    Ok(())
612}
613
614fn validate_el_deg(value: f64, field: &'static str) -> Result<()> {
615    validate_finite(value, field)?;
616    if !(0.0..=90.0).contains(&value) {
617        return Err(invalid_input(field, "out of range"));
618    }
619    Ok(())
620}
621
622fn validate_second_of_day(value: f64, field: &'static str) -> Result<()> {
623    validate_finite(value, field)?;
624    if !(0.0..SECONDS_PER_DAY).contains(&value) {
625        return Err(invalid_input(field, "out of range"));
626    }
627    Ok(())
628}
629
630pub(crate) fn validate_frequency(frequency_hz: f64) -> Result<()> {
631    validate_finite(frequency_hz, "frequency_hz")?;
632    if frequency_hz <= 0.0 {
633        return Err(invalid_input("frequency_hz", "not positive"));
634    }
635    Ok(())
636}
637
638fn validate_finite(value: f64, field: &'static str) -> Result<()> {
639    if value.is_finite() {
640        Ok(())
641    } else {
642        Err(invalid_input(field, "not finite"))
643    }
644}
645
646fn invalid_input(field: &'static str, reason: &'static str) -> Error {
647    Error::InvalidInput(format!("{field} {reason}"))
648}
649
650/// GPS second-of-day in `[0, 86400)` carried by an instant.
651///
652/// The Klobuchar diurnal term needs the local-solar-time argument, built from
653/// the GPS second-of-day. A Julian date's civil day begins at noon, so the
654/// midnight day fraction is `(jd + 0.5)` modulo one.
655///
656/// Precision: for a split-Julian-date instant the second-of-day is
657/// `day_fraction * 86400`, and `day_fraction` is itself a rounded binary
658/// fraction of a day, so this recovers the second-of-day only to within the
659/// float granularity of a day fraction (a few microseconds) - a
660/// sub-nanometre-to-nanometre perturbation in the delay. The bit-exact (0-ULP)
661/// contract is on the model kernel evaluated at an exact second-of-day, not on
662/// this convenience conversion. An integer-nanosecond instant is exact (it
663/// reduces by the seconds-per-day modulus).
664fn gps_second_of_day(epoch: Instant) -> f64 {
665    second_of_day_from_instant(epoch)
666}
667
668/// Fractional day-of-year carried by an instant, Jan 1 00:00:00 = 1.0.
669fn fractional_day_of_year(epoch: Instant) -> f64 {
670    fractional_day_of_year_from_instant(epoch)
671}