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