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