Skip to main content

sidereon_core/tides/
mod.rs

1//! Solid-earth tide station displacement (IERS Conventions, Chapter 7).
2//!
3//! [`solid_earth_tide`] computes the tidal displacement of an Earth-fixed (ITRF)
4//! GNSS station caused by the lunar and solar gravitational attraction. It is a
5//! derived work of the IERS Conventions (2010) reference routine
6//! `DEHANTTIDEINEL.F` (and its companion routines `ST1IDIU`, `ST1ISEM`,
7//! `ST1L1`, `STEP2DIU`, `STEP2LON`, `CAL2JD`, `DAT`), reproduced here in Rust.
8//!
9//! How this derived work is based upon and differs from the original Software:
10//!
11//! * It is a line-for-line Rust translation of the in-phase degree-2/degree-3
12//!   displacement, the out-of-phase corrections (`ST1IDIU`, `ST1ISEM`), the
13//!   latitude-dependence correction (`ST1L1`), and the frequency-dependent
14//!   step-2 diurnal/long-period band corrections (`STEP2DIU`, `STEP2LON`),
15//!   evaluating the identical Love/Shida numbers, Doodson/argument tables, and
16//!   leap-second table.
17//! * It keeps the permanent (mean) tide deformation: the original routine's
18//!   commented-out "Step 3" permanent-tide removal is left disabled, matching
19//!   the ITRF/IGS conform-to-mean-tide convention.
20//! * The routine names are changed from the IERS originals (per the IERS
21//!   Conventions Software License), and the Fortran subroutine structure is
22//!   inlined into private helpers.
23//! * The bundled IERS routine incorporates the SOFA `CAL2JD` and `DAT`
24//!   companion routines. Their Rust translations are likewise private and
25//!   renamed, while retaining SOFA-compatible numerical behavior.
26//!
27//! The Sun and Moon geocentric positions are inputs (metres, ECEF/ITRF); the
28//! caller supplies them, e.g. from [`crate::astro::bodies::sun_moon_ecef`].
29//!
30//! This Sidereon derived work is neither distributed by nor endorsed by the
31//! IERS Conventions Center.
32//!
33//! IERS Conventions Software License: permission is granted to use this software
34//! for any purpose, including commercial applications, free of charge, and to
35//! distribute derived works subject to its conditions. The intact notice ships
36//! as `IERS-CONVENTIONS-SOFTWARE-LICENSE.txt`. Results obtained with this
37//! software acknowledge use of the IERS Conventions software.
38
39#[cfg(all(test, sidereon_repo_tests))]
40mod tests;
41
42mod ocean;
43mod pole;
44pub use ocean::{
45    ocean_tide_loading, parse_ocean_loading_blq_block, parse_ocean_loading_blq_blocks,
46    OceanLoadingBlq, OceanLoadingBlqBlock, OceanTideConstituent, NUM_OCEAN_CONSTITUENTS,
47    OCEAN_LOADING_CONSTITUENTS,
48};
49pub use pole::solid_earth_pole_tide;
50
51use crate::astro::bodies::{sun_moon_ecef_with_polar_motion, SunMoonError};
52use crate::astro::constants::models::iers::SOLID_TIDE_EARTH_RADIUS_M;
53use crate::astro::constants::time::{
54    DAYS_PER_JULIAN_CENTURY, J2000_JD, SECONDS_PER_DAY, TT_MINUS_TAI_S,
55};
56use crate::astro::constants::units::{ARCSEC_TO_RAD, DEG_TO_RAD, KM_TO_M};
57use crate::astro::frames::transforms::{FrameTransformError, PolarMotion};
58use crate::astro::math::vec3::{dot3_ref as dot, norm3_ref as norm8};
59use crate::astro::time::{CoverageError, TimeScaleInputErrorKind, TimeScales};
60use crate::frame::{geodetic_to_itrf, ItrfPositionM, Wgs84Geodetic};
61use crate::validate::{self, FieldError};
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum TideInputErrorKind {
65    Missing,
66    NonFinite,
67    NotPositive,
68    Negative,
69    OutOfRange,
70    FloatParse,
71    IntParse,
72    InvalidCivilDate,
73    InvalidCivilTime,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum BlqParseErrorKind {
78    Empty,
79    MissingStation,
80    MissingCoefficientRows {
81        station: String,
82        expected: usize,
83        found: usize,
84    },
85    TooManyCoefficientRows {
86        station: String,
87    },
88    WrongColumnCount {
89        expected: usize,
90        found: usize,
91    },
92    InvalidNumber {
93        token: String,
94    },
95    NonFiniteNumber {
96        token: String,
97    },
98    UnsupportedConstituent {
99        constituent: String,
100    },
101    DuplicateConstituent {
102        constituent: String,
103    },
104    MultipleBlocks {
105        found: usize,
106    },
107}
108
109impl core::fmt::Display for BlqParseErrorKind {
110    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
111        match self {
112            Self::Empty => f.write_str("empty BLQ block"),
113            Self::MissingStation => f.write_str("missing station identifier"),
114            Self::MissingCoefficientRows {
115                station,
116                expected,
117                found,
118            } => write!(
119                f,
120                "station {station} has {found} coefficient rows, expected {expected}"
121            ),
122            Self::TooManyCoefficientRows { station } => {
123                write!(f, "station {station} has more than 6 coefficient rows")
124            }
125            Self::WrongColumnCount { expected, found } => {
126                write!(
127                    f,
128                    "coefficient row has {found} columns, expected {expected}"
129                )
130            }
131            Self::InvalidNumber { token } => write!(f, "invalid number {token:?}"),
132            Self::NonFiniteNumber { token } => write!(f, "non-finite number {token:?}"),
133            Self::UnsupportedConstituent { constituent } => {
134                write!(f, "unsupported constituent {constituent}")
135            }
136            Self::DuplicateConstituent { constituent } => {
137                write!(f, "duplicate constituent {constituent}")
138            }
139            Self::MultipleBlocks { found } => {
140                write!(f, "expected one BLQ station block, found {found}")
141            }
142        }
143    }
144}
145
146impl core::fmt::Display for TideInputErrorKind {
147    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
148        f.write_str(match self {
149            Self::Missing => "missing",
150            Self::NonFinite => "not finite",
151            Self::NotPositive => "not positive",
152            Self::Negative => "negative",
153            Self::OutOfRange => "out of range",
154            Self::FloatParse => "invalid float",
155            Self::IntParse => "invalid integer",
156            Self::InvalidCivilDate => "invalid civil date",
157            Self::InvalidCivilTime => "invalid civil time",
158        })
159    }
160}
161
162impl From<&FieldError> for TideInputErrorKind {
163    fn from(error: &FieldError) -> Self {
164        match error {
165            FieldError::Missing { .. } => Self::Missing,
166            FieldError::NonFinite { .. } => Self::NonFinite,
167            FieldError::NotPositive { .. } => Self::NotPositive,
168            FieldError::Negative { .. } => Self::Negative,
169            FieldError::OutOfRange { .. } => Self::OutOfRange,
170            FieldError::FloatParse { .. } => Self::FloatParse,
171            FieldError::IntParse { .. } => Self::IntParse,
172            FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
173            FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
174        }
175    }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
179pub enum TideError {
180    #[error("invalid solid-earth tide input {field}: {kind}")]
181    InvalidInput {
182        field: &'static str,
183        kind: TideInputErrorKind,
184    },
185    #[error("station displacement time-scale conversion failed: {0}")]
186    TimeScale(#[from] CoverageError),
187    #[error("station displacement frame transform failed: {0}")]
188    FrameTransform(#[from] FrameTransformError),
189    #[error("station displacement Sun/Moon evaluation failed: {0}")]
190    SunMoon(#[from] SunMoonError),
191    #[error("missing station displacement input {field}")]
192    MissingInput { field: &'static str },
193    #[error("invalid BLQ block at line {line}: {kind}")]
194    BlqParse {
195        line: usize,
196        kind: BlqParseErrorKind,
197    },
198}
199
200fn invalid_tide_input(error: FieldError) -> TideError {
201    TideError::InvalidInput {
202        field: error.field(),
203        kind: (&error).into(),
204    }
205}
206
207fn map_time_input(error: CoverageError) -> TideError {
208    match error {
209        CoverageError::InvalidInput { field, kind } => TideError::InvalidInput {
210            field,
211            kind: tide_kind_from_time_kind(kind),
212        },
213        other => TideError::TimeScale(other),
214    }
215}
216
217fn tide_kind_from_time_kind(kind: TimeScaleInputErrorKind) -> TideInputErrorKind {
218    match kind {
219        TimeScaleInputErrorKind::Missing => TideInputErrorKind::Missing,
220        TimeScaleInputErrorKind::NonFinite => TideInputErrorKind::NonFinite,
221        TimeScaleInputErrorKind::NotPositive => TideInputErrorKind::NotPositive,
222        TimeScaleInputErrorKind::Negative => TideInputErrorKind::Negative,
223        TimeScaleInputErrorKind::OutOfRange => TideInputErrorKind::OutOfRange,
224        TimeScaleInputErrorKind::FloatParse => TideInputErrorKind::FloatParse,
225        TimeScaleInputErrorKind::IntParse => TideInputErrorKind::IntParse,
226        TimeScaleInputErrorKind::InvalidCivilDate => TideInputErrorKind::InvalidCivilDate,
227        TimeScaleInputErrorKind::InvalidCivilTime => TideInputErrorKind::InvalidCivilTime,
228    }
229}
230
231/// Station position accepted by the high-level displacement API.
232#[derive(Debug, Clone, Copy, PartialEq)]
233pub enum StationDisplacementPosition {
234    /// ITRF/ECEF metres.
235    Ecef(ItrfPositionM),
236    /// WGS84 geodetic radians/metres. Converted to ITRF through the public
237    /// frame conversion path before any tide model is evaluated.
238    Geodetic(Wgs84Geodetic),
239}
240
241impl From<ItrfPositionM> for StationDisplacementPosition {
242    fn from(value: ItrfPositionM) -> Self {
243        Self::Ecef(value)
244    }
245}
246
247impl From<Wgs84Geodetic> for StationDisplacementPosition {
248    fn from(value: Wgs84Geodetic) -> Self {
249        Self::Geodetic(value)
250    }
251}
252
253impl StationDisplacementPosition {
254    /// Construct from raw ITRF/ECEF metre components.
255    pub fn from_ecef_m(position_m: [f64; 3]) -> Result<Self, TideError> {
256        let position =
257            ItrfPositionM::new(position_m[0], position_m[1], position_m[2]).map_err(|error| {
258                match error {
259                    crate::frame::FrameValueError::InvalidInput { field, reason: _ } => {
260                        TideError::InvalidInput {
261                            field,
262                            kind: TideInputErrorKind::NonFinite,
263                        }
264                    }
265                }
266            })?;
267        Ok(Self::Ecef(position))
268    }
269
270    fn ecef_m(self) -> Result<[f64; 3], TideError> {
271        match self {
272            Self::Ecef(position) => Ok(position.as_array()),
273            Self::Geodetic(position) => Ok(geodetic_to_itrf(position)?.as_array()),
274        }
275    }
276}
277
278/// IERS polar-motion coordinates of the epoch, in arcseconds.
279#[derive(Debug, Clone, Copy, PartialEq)]
280pub struct StationPolarMotion {
281    pub xp_arcsec: f64,
282    pub yp_arcsec: f64,
283}
284
285impl StationPolarMotion {
286    pub const fn from_arcseconds(xp_arcsec: f64, yp_arcsec: f64) -> Self {
287        Self {
288            xp_arcsec,
289            yp_arcsec,
290        }
291    }
292
293    fn polar_motion(self) -> Result<PolarMotion, TideError> {
294        Ok(PolarMotion::from_radians(
295            self.xp_arcsec * ARCSEC_TO_RAD,
296            self.yp_arcsec * ARCSEC_TO_RAD,
297        )?)
298    }
299}
300
301/// UTC epoch for station displacement evaluation.
302#[derive(Debug, Clone, Copy, PartialEq)]
303pub struct StationDisplacementEpoch {
304    pub year: i32,
305    pub month: u8,
306    pub day: u8,
307    pub hour: u8,
308    pub minute: u8,
309    pub second: f64,
310    /// Optional IERS polar motion for pole tide and polar-motion-aware Sun/Moon
311    /// rotation.
312    pub polar_motion: Option<StationPolarMotion>,
313}
314
315impl StationDisplacementEpoch {
316    pub const fn from_utc(
317        year: i32,
318        month: u8,
319        day: u8,
320        hour: u8,
321        minute: u8,
322        second: f64,
323    ) -> Self {
324        Self {
325            year,
326            month,
327            day,
328            hour,
329            minute,
330            second,
331            polar_motion: None,
332        }
333    }
334
335    pub const fn with_polar_motion_arcsec(mut self, xp_arcsec: f64, yp_arcsec: f64) -> Self {
336        self.polar_motion = Some(StationPolarMotion::from_arcseconds(xp_arcsec, yp_arcsec));
337        self
338    }
339
340    fn time_scales(self) -> Result<TimeScales, TideError> {
341        TimeScales::from_utc(
342            self.year,
343            i32::from(self.month),
344            i32::from(self.day),
345            i32::from(self.hour),
346            i32::from(self.minute),
347            self.second,
348        )
349        .map_err(map_time_input)
350    }
351
352    fn validate_utc(self) -> Result<(), TideError> {
353        validate::civil_datetime_with_second_policy(
354            i64::from(self.year),
355            i64::from(self.month),
356            i64::from(self.day),
357            i64::from(self.hour),
358            i64::from(self.minute),
359            self.second,
360            validate::CivilSecondPolicy::Continuous,
361        )
362        .map(|_| ())
363        .map_err(invalid_tide_input)
364    }
365
366    fn fractional_hour(self) -> f64 {
367        f64::from(self.hour) + f64::from(self.minute) / 60.0 + self.second / 3600.0
368    }
369}
370
371/// Switches for the high-level station displacement entry.
372#[derive(Debug, Clone, Copy, PartialEq)]
373pub struct StationDisplacementOptions<'a> {
374    /// Apply the IERS solid Earth tide station displacement.
375    pub solid_earth_tide: bool,
376    /// Apply the IERS pole tide station displacement. Each epoch must carry
377    /// polar motion when this is true.
378    pub pole_tide: bool,
379    /// Optional ocean-loading BLQ coefficients supplied by the caller.
380    pub ocean_loading: Option<&'a OceanLoadingBlq>,
381}
382
383impl Default for StationDisplacementOptions<'_> {
384    fn default() -> Self {
385        Self {
386            solid_earth_tide: true,
387            pole_tide: false,
388            ocean_loading: None,
389        }
390    }
391}
392
393/// Component-resolved station displacement in ITRF/ECEF metres.
394#[derive(Debug, Clone, Copy, PartialEq)]
395pub struct StationDisplacement {
396    /// Sum of all enabled component displacements, in ITRF/ECEF metres.
397    pub ecef_m: [f64; 3],
398    pub solid_earth_tide_ecef_m: Option<[f64; 3]>,
399    pub pole_tide_ecef_m: Option<[f64; 3]>,
400    pub ocean_loading_ecef_m: Option<[f64; 3]>,
401}
402
403impl StationDisplacement {
404    fn zero() -> Self {
405        Self {
406            ecef_m: [0.0; 3],
407            solid_earth_tide_ecef_m: None,
408            pole_tide_ecef_m: None,
409            ocean_loading_ecef_m: None,
410        }
411    }
412
413    fn add_component(total: &mut [f64; 3], component: [f64; 3]) {
414        for i in 0..3 {
415            total[i] += component[i];
416        }
417    }
418}
419
420/// Evaluate the enabled station displacement corrections, returning ITRF/ECEF
421/// metre components.
422///
423/// The solid Earth tide path uses IERS Conventions (2010), Chapter 7 station
424/// displacement with the permanent tide retained. The low-level
425/// [`solid_earth_tide`] routine ships the in-phase degree-2 and degree-3
426/// displacement, the step-1 out-of-phase and latitude-dependence corrections,
427/// and the step-2 diurnal/long-period frequency corrections; it leaves the
428/// optional step-3 permanent-tide removal disabled for ITRF/IGS use. Sun/Moon
429/// positions are generated through the same Earth-fixed analytic ephemeris path
430/// used by the tide-force lane, including caller-supplied polar motion when the
431/// epoch carries it.
432pub fn station_displacement_ecef_m(
433    position: StationDisplacementPosition,
434    epoch: StationDisplacementEpoch,
435    options: StationDisplacementOptions<'_>,
436) -> Result<StationDisplacement, TideError> {
437    let receiver_ecef_m = position.ecef_m()?;
438    epoch.validate_utc()?;
439    let fhr = epoch.fractional_hour();
440    let mut displacement = StationDisplacement::zero();
441
442    if options.solid_earth_tide {
443        let ts = epoch.time_scales()?;
444        let polar_motion = epoch
445            .polar_motion
446            .map(StationPolarMotion::polar_motion)
447            .transpose()?
448            .unwrap_or_default();
449        let sun_moon = sun_moon_ecef_with_polar_motion(&ts, polar_motion)?;
450        let solid = solid_earth_tide(
451            &receiver_ecef_m,
452            epoch.year,
453            i32::from(epoch.month),
454            i32::from(epoch.day),
455            fhr,
456            &sun_moon.sun,
457            &sun_moon.moon,
458        )?;
459        StationDisplacement::add_component(&mut displacement.ecef_m, solid);
460        displacement.solid_earth_tide_ecef_m = Some(solid);
461    }
462
463    if options.pole_tide {
464        let polar = epoch.polar_motion.ok_or(TideError::MissingInput {
465            field: "polar motion",
466        })?;
467        let pole = solid_earth_pole_tide(
468            &receiver_ecef_m,
469            epoch.year,
470            i32::from(epoch.month),
471            i32::from(epoch.day),
472            fhr,
473            polar.xp_arcsec,
474            polar.yp_arcsec,
475        )?;
476        StationDisplacement::add_component(&mut displacement.ecef_m, pole);
477        displacement.pole_tide_ecef_m = Some(pole);
478    }
479
480    if let Some(blq) = options.ocean_loading {
481        let ocean = ocean_tide_loading(
482            &receiver_ecef_m,
483            epoch.year,
484            i32::from(epoch.month),
485            i32::from(epoch.day),
486            fhr,
487            blq,
488        )?;
489        StationDisplacement::add_component(&mut displacement.ecef_m, ocean);
490        displacement.ocean_loading_ecef_m = Some(ocean);
491    }
492
493    Ok(displacement)
494}
495
496/// Evaluate station displacement for many epochs. Each element is equivalent to
497/// a scalar [`station_displacement_ecef_m`] call for the same position, epoch,
498/// and options, so per-epoch failures stay local to their output row.
499pub fn station_displacement_ecef_m_batch(
500    position: StationDisplacementPosition,
501    epochs: &[StationDisplacementEpoch],
502    options: StationDisplacementOptions<'_>,
503) -> Vec<Result<StationDisplacement, TideError>> {
504    epochs
505        .iter()
506        .map(|&epoch| station_displacement_ecef_m(position, epoch, options))
507        .collect()
508}
509
510/// Solid-earth tide displacement of an ITRF station, in metres (ECEF).
511///
512/// Arguments mirror the IERS reference routine:
513/// * `xsta` - geocentric station position (m, ITRF).
514/// * `year`, `month`, `day` - UTC calendar date.
515/// * `fhr` - UTC fractional hour of the day (hour + min/60 + sec/3600).
516/// * `xsun` - geocentric Sun position (m, ECEF).
517/// * `xmon` - geocentric Moon position (m, ECEF).
518///
519/// Returns the displacement vector `dxtide` (m, geocentric ITRF). The permanent
520/// (mean) tide deformation is retained (ITRF/IGS convention).
521///
522/// Returns [`TideError`] when inputs are non-finite or geometrically
523/// degenerate: the station vector must be non-zero and non-polar, and Sun/Moon
524/// vectors must be non-zero.
525pub fn solid_earth_tide(
526    xsta: &[f64; 3],
527    year: i32,
528    month: i32,
529    day: i32,
530    fhr: f64,
531    xsun: &[f64; 3],
532    xmon: &[f64; 3],
533) -> Result<[f64; 3], TideError> {
534    validate_tide_domain(xsta, year, month, day, fhr, xsun, xmon)?;
535    Ok(solid_earth_tide_unchecked(
536        xsta, year, month, day, fhr, xsun, xmon,
537    ))
538}
539
540fn validate_tide_domain(
541    xsta: &[f64; 3],
542    year: i32,
543    month: i32,
544    day: i32,
545    fhr: f64,
546    xsun: &[f64; 3],
547    xmon: &[f64; 3],
548) -> Result<(), TideError> {
549    validate::finite_vec3(*xsta, "station position").map_err(invalid_tide_input)?;
550    validate::civil_datetime_with_second_policy(
551        i64::from(year),
552        i64::from(month),
553        i64::from(day),
554        0,
555        0,
556        0.0,
557        validate::CivilSecondPolicy::Continuous,
558    )
559    .map_err(invalid_tide_input)?;
560    validate::finite_in_range_exclusive_upper(fhr, 0.0, 24.0, "fractional hour")
561        .map_err(invalid_tide_input)?;
562    validate::finite_vec3(*xsun, "sun position").map_err(invalid_tide_input)?;
563    validate::finite_vec3(*xmon, "moon position").map_err(invalid_tide_input)?;
564
565    validate::finite_positive(norm8(xsta), "station radius").map_err(invalid_tide_input)?;
566    let station_horizontal_radius = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt();
567    validate::finite_positive(station_horizontal_radius, "station horizontal radius")
568        .map_err(invalid_tide_input)?;
569    validate::finite_positive(norm8(xsun), "sun radius").map_err(invalid_tide_input)?;
570    validate::finite_positive(norm8(xmon), "moon radius").map_err(invalid_tide_input)?;
571
572    Ok(())
573}
574
575fn solid_earth_tide_unchecked(
576    xsta: &[f64; 3],
577    year: i32,
578    month: i32,
579    day: i32,
580    fhr: f64,
581    xsun: &[f64; 3],
582    xmon: &[f64; 3],
583) -> [f64; 3] {
584    // Nominal second- and third-degree Love and Shida numbers.
585    const H20: f64 = 0.6078;
586    const L20: f64 = 0.0847;
587    const H3: f64 = 0.292;
588    const L3: f64 = 0.015;
589
590    // Scalar product of station vector with Sun/Moon vector (SPROD).
591    let rsta = norm8(xsta);
592    let rsun = norm8(xsun);
593    let rmon = norm8(xmon);
594    let scs = dot(xsta, xsun);
595    let scm = dot(xsta, xmon);
596    let scsun = scs / rsta / rsun;
597    let scmon = scm / rsta / rmon;
598
599    // Latitude-corrected H2 and L2.
600    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
601    let h2 = H20 - 0.0006 * (1.0 - 3.0 / 2.0 * cosphi * cosphi);
602    let l2 = L20 + 0.0002 * (1.0 - 3.0 / 2.0 * cosphi * cosphi);
603
604    // P2 term.
605    let p2sun = 3.0 * (h2 / 2.0 - l2) * scsun * scsun - h2 / 2.0;
606    let p2mon = 3.0 * (h2 / 2.0 - l2) * scmon * scmon - h2 / 2.0;
607
608    // P3 term.
609    let scsun3 = scsun * scsun * scsun;
610    let scmon3 = scmon * scmon * scmon;
611    let p3sun = 5.0 / 2.0 * (H3 - 3.0 * L3) * scsun3 + 3.0 / 2.0 * (L3 - H3) * scsun;
612    let p3mon = 5.0 / 2.0 * (H3 - 3.0 * L3) * scmon3 + 3.0 / 2.0 * (L3 - H3) * scmon;
613
614    // Term in direction of Sun/Moon vector.
615    let x2sun = 3.0 * l2 * scsun;
616    let x2mon = 3.0 * l2 * scmon;
617    let x3sun = 3.0 * L3 / 2.0 * (5.0 * scsun * scsun - 1.0);
618    let x3mon = 3.0 * L3 / 2.0 * (5.0 * scmon * scmon - 1.0);
619
620    // Factors for Sun/Moon (IAU current best estimates).
621    const MASS_RATIO_SUN: f64 = 332946.0482;
622    const MASS_RATIO_MOON: f64 = 0.0123000371;
623    const RE: f64 = SOLID_TIDE_EARTH_RADIUS_M;
624    let re_over_rsun = RE / rsun;
625    let re_over_rmon = RE / rmon;
626    let fac2sun = MASS_RATIO_SUN * RE * re_over_rsun * re_over_rsun * re_over_rsun;
627    let fac2mon = MASS_RATIO_MOON * RE * re_over_rmon * re_over_rmon * re_over_rmon;
628    let fac3sun = fac2sun * (RE / rsun);
629    let fac3mon = fac2mon * (RE / rmon);
630
631    // Total in-phase degree-2/degree-3 displacement.
632    let mut dxtide = [0.0_f64; 3];
633    for i in 0..3 {
634        dxtide[i] = fac2sun * (x2sun * xsun[i] / rsun + p2sun * xsta[i] / rsta)
635            + fac2mon * (x2mon * xmon[i] / rmon + p2mon * xsta[i] / rsta)
636            + fac3sun * (x3sun * xsun[i] / rsun + p3sun * xsta[i] / rsta)
637            + fac3mon * (x3mon * xmon[i] / rmon + p3mon * xsta[i] / rsta);
638    }
639
640    // Out-of-phase corrections (diurnal, semi-diurnal) and latitude dependence.
641    let c = out_of_phase_diurnal_correction(xsta, xsun, xmon, fac2sun, fac2mon);
642    for i in 0..3 {
643        dxtide[i] += c[i];
644    }
645    let c = out_of_phase_semidiurnal_correction(xsta, xsun, xmon, fac2sun, fac2mon);
646    for i in 0..3 {
647        dxtide[i] += c[i];
648    }
649    let c = latitude_dependence_correction(xsta, xsun, xmon, fac2sun, fac2mon);
650    for i in 0..3 {
651        dxtide[i] += c[i];
652    }
653
654    // Step 2 corrections need the date in Julian centuries (TT).
655    let (jjm0, jjm1) = gregorian_to_two_part_julian_date(year, month, day);
656    let fhrd = fhr / 24.0;
657    let mut t = ((jjm0 - J2000_JD) + jjm1 + fhrd) / DAYS_PER_JULIAN_CENTURY;
658    let dtt = tai_minus_utc_seconds(year, month, day) + TT_MINUS_TAI_S;
659    t += dtt / (SECONDS_PER_DAY * DAYS_PER_JULIAN_CENTURY);
660
661    let c = frequency_dependent_diurnal_correction(xsta, fhr, t);
662    for i in 0..3 {
663        dxtide[i] += c[i];
664    }
665    let c = frequency_dependent_long_period_correction(xsta, t);
666    for i in 0..3 {
667        dxtide[i] += c[i];
668    }
669
670    // Step 3 of the IERS routine, the permanent (zero-frequency) tide removal,
671    // is intentionally not applied, so the permanent (mean) tide deformation is
672    // retained (the ITRF/IGS conform-to-mean-tide convention; see module docs).
673    dxtide
674}
675
676/// Out-of-phase part of the Love numbers, diurnal band (ST1IDIU).
677fn out_of_phase_diurnal_correction(
678    xsta: &[f64; 3],
679    xsun: &[f64; 3],
680    xmon: &[f64; 3],
681    fac2sun: f64,
682    fac2mon: f64,
683) -> [f64; 3] {
684    const DHI: f64 = -0.0025;
685    const DLI: f64 = -0.0007;
686    let rsta = norm8(xsta);
687    let sinphi = xsta[2] / rsta;
688    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
689    let cos2phi = cosphi * cosphi - sinphi * sinphi;
690    let sinla = xsta[1] / cosphi / rsta;
691    let cosla = xsta[0] / cosphi / rsta;
692    let rmon = norm8(xmon);
693    let rsun = norm8(xsun);
694
695    let drsun =
696        -3.0 * DHI * sinphi * cosphi * fac2sun * xsun[2] * (xsun[0] * sinla - xsun[1] * cosla)
697            / (rsun * rsun);
698    let drmon =
699        -3.0 * DHI * sinphi * cosphi * fac2mon * xmon[2] * (xmon[0] * sinla - xmon[1] * cosla)
700            / (rmon * rmon);
701    let dnsun = -3.0 * DLI * cos2phi * fac2sun * xsun[2] * (xsun[0] * sinla - xsun[1] * cosla)
702        / (rsun * rsun);
703    let dnmon = -3.0 * DLI * cos2phi * fac2mon * xmon[2] * (xmon[0] * sinla - xmon[1] * cosla)
704        / (rmon * rmon);
705    let desun = -3.0 * DLI * sinphi * fac2sun * xsun[2] * (xsun[0] * cosla + xsun[1] * sinla)
706        / (rsun * rsun);
707    let demon = -3.0 * DLI * sinphi * fac2mon * xmon[2] * (xmon[0] * cosla + xmon[1] * sinla)
708        / (rmon * rmon);
709
710    let dr = drsun + drmon;
711    let dn = dnsun + dnmon;
712    let de = desun + demon;
713
714    [
715        dr * cosla * cosphi - de * sinla - dn * sinphi * cosla,
716        dr * sinla * cosphi + de * cosla - dn * sinphi * sinla,
717        dr * sinphi + dn * cosphi,
718    ]
719}
720
721/// Out-of-phase part of the Love numbers, semi-diurnal band (ST1ISEM).
722fn out_of_phase_semidiurnal_correction(
723    xsta: &[f64; 3],
724    xsun: &[f64; 3],
725    xmon: &[f64; 3],
726    fac2sun: f64,
727    fac2mon: f64,
728) -> [f64; 3] {
729    const DHI: f64 = -0.0022;
730    const DLI: f64 = -0.0007;
731    let rsta = norm8(xsta);
732    let sinphi = xsta[2] / rsta;
733    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
734    let sinla = xsta[1] / cosphi / rsta;
735    let cosla = xsta[0] / cosphi / rsta;
736    let costwola = cosla * cosla - sinla * sinla;
737    let sintwola = 2.0 * cosla * sinla;
738    let rmon = norm8(xmon);
739    let rsun = norm8(xsun);
740
741    let drsun = -3.0 / 4.0
742        * DHI
743        * cosphi
744        * cosphi
745        * fac2sun
746        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * sintwola - 2.0 * xsun[0] * xsun[1] * costwola)
747        / (rsun * rsun);
748    let drmon = -3.0 / 4.0
749        * DHI
750        * cosphi
751        * cosphi
752        * fac2mon
753        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * sintwola - 2.0 * xmon[0] * xmon[1] * costwola)
754        / (rmon * rmon);
755    let dnsun = 3.0 / 2.0
756        * DLI
757        * sinphi
758        * cosphi
759        * fac2sun
760        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * sintwola - 2.0 * xsun[0] * xsun[1] * costwola)
761        / (rsun * rsun);
762    let dnmon = 3.0 / 2.0
763        * DLI
764        * sinphi
765        * cosphi
766        * fac2mon
767        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * sintwola - 2.0 * xmon[0] * xmon[1] * costwola)
768        / (rmon * rmon);
769    let desun = -3.0 / 2.0
770        * DLI
771        * cosphi
772        * fac2sun
773        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * costwola + 2.0 * xsun[0] * xsun[1] * sintwola)
774        / (rsun * rsun);
775    let demon = -3.0 / 2.0
776        * DLI
777        * cosphi
778        * fac2mon
779        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * costwola + 2.0 * xmon[0] * xmon[1] * sintwola)
780        / (rmon * rmon);
781
782    let dr = drsun + drmon;
783    let dn = dnsun + dnmon;
784    let de = desun + demon;
785
786    [
787        dr * cosla * cosphi - de * sinla - dn * sinphi * cosla,
788        dr * sinla * cosphi + de * cosla - dn * sinphi * sinla,
789        dr * sinphi + dn * cosphi,
790    ]
791}
792
793/// Latitude dependence of the Love numbers, part L^(1) (ST1L1).
794fn latitude_dependence_correction(
795    xsta: &[f64; 3],
796    xsun: &[f64; 3],
797    xmon: &[f64; 3],
798    fac2sun: f64,
799    fac2mon: f64,
800) -> [f64; 3] {
801    const L1D: f64 = 0.0012;
802    const L1SD: f64 = 0.0024;
803    let rsta = norm8(xsta);
804    let sinphi = xsta[2] / rsta;
805    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
806    let sinla = xsta[1] / cosphi / rsta;
807    let cosla = xsta[0] / cosphi / rsta;
808    let rmon = norm8(xmon);
809    let rsun = norm8(xsun);
810
811    // Diurnal band.
812    let mut l1 = L1D;
813    let dnsun = -l1 * sinphi * sinphi * fac2sun * xsun[2] * (xsun[0] * cosla + xsun[1] * sinla)
814        / (rsun * rsun);
815    let dnmon = -l1 * sinphi * sinphi * fac2mon * xmon[2] * (xmon[0] * cosla + xmon[1] * sinla)
816        / (rmon * rmon);
817    let desun = l1
818        * sinphi
819        * (cosphi * cosphi - sinphi * sinphi)
820        * fac2sun
821        * xsun[2]
822        * (xsun[0] * sinla - xsun[1] * cosla)
823        / (rsun * rsun);
824    let demon = l1
825        * sinphi
826        * (cosphi * cosphi - sinphi * sinphi)
827        * fac2mon
828        * xmon[2]
829        * (xmon[0] * sinla - xmon[1] * cosla)
830        / (rmon * rmon);
831
832    let de = 3.0 * (desun + demon);
833    let dn = 3.0 * (dnsun + dnmon);
834
835    let mut xcorsta = [
836        -de * sinla - dn * sinphi * cosla,
837        de * cosla - dn * sinphi * sinla,
838        dn * cosphi,
839    ];
840
841    // Semi-diurnal band.
842    l1 = L1SD;
843    let costwola = cosla * cosla - sinla * sinla;
844    let sintwola = 2.0 * cosla * sinla;
845
846    let dnsun = -l1 / 2.0
847        * sinphi
848        * cosphi
849        * fac2sun
850        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * costwola + 2.0 * xsun[0] * xsun[1] * sintwola)
851        / (rsun * rsun);
852    let dnmon = -l1 / 2.0
853        * sinphi
854        * cosphi
855        * fac2mon
856        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * costwola + 2.0 * xmon[0] * xmon[1] * sintwola)
857        / (rmon * rmon);
858    let desun = -l1 / 2.0
859        * sinphi
860        * sinphi
861        * cosphi
862        * fac2sun
863        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * sintwola - 2.0 * xsun[0] * xsun[1] * costwola)
864        / (rsun * rsun);
865    let demon = -l1 / 2.0
866        * sinphi
867        * sinphi
868        * cosphi
869        * fac2mon
870        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * sintwola - 2.0 * xmon[0] * xmon[1] * costwola)
871        / (rmon * rmon);
872
873    let de = 3.0 * (desun + demon);
874    let dn = 3.0 * (dnsun + dnmon);
875
876    xcorsta[0] += -de * sinla - dn * sinphi * cosla;
877    xcorsta[1] += de * cosla - dn * sinphi * sinla;
878    xcorsta[2] += dn * cosphi;
879    xcorsta
880}
881
882/// In-phase / out-of-phase frequency-dependent corrections, diurnal band
883/// (STEP2DIU). `fhr` is UTC fractional hour, `t` is Julian centuries (TT).
884fn frequency_dependent_diurnal_correction(xsta: &[f64; 3], fhr: f64, t: f64) -> [f64; 3] {
885    // DATDI(9,31): {l, l', F, D, Omega(Ps), Adr, Adi, Anr, Ani} per wave.
886    #[rustfmt::skip]
887    const DATDI: [[f64; 9]; 31] = [
888        [-3.0, 0.0, 2.0, 0.0, 0.0, -0.01, 0.0, 0.0, 0.0],
889        [-3.0, 2.0, 0.0, 0.0, 0.0, -0.01, 0.0, 0.0, 0.0],
890        [-2.0, 0.0, 1.0, -1.0, 0.0, -0.02, 0.0, 0.0, 0.0],
891        [-2.0, 0.0, 1.0, 0.0, 0.0, -0.08, 0.0, -0.01, 0.01],
892        [-2.0, 2.0, -1.0, 0.0, 0.0, -0.02, 0.0, 0.0, 0.0],
893        [-1.0, 0.0, 0.0, -1.0, 0.0, -0.10, 0.0, 0.0, 0.0],
894        [-1.0, 0.0, 0.0, 0.0, 0.0, -0.51, 0.0, -0.02, 0.03],
895        [-1.0, 2.0, 0.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0],
896        [0.0, -2.0, 1.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0],
897        [0.0, 0.0, -1.0, 0.0, 0.0, 0.02, 0.0, 0.0, 0.0],
898        [0.0, 0.0, 1.0, 0.0, 0.0, 0.06, 0.0, 0.0, 0.0],
899        [0.0, 0.0, 1.0, 1.0, 0.0, 0.01, 0.0, 0.0, 0.0],
900        [0.0, 2.0, -1.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0],
901        [1.0, -3.0, 0.0, 0.0, 1.0, -0.06, 0.0, 0.0, 0.0],
902        [1.0, -2.0, 0.0, -1.0, 0.0, 0.01, 0.0, 0.0, 0.0],
903        [1.0, -2.0, 0.0, 0.0, 0.0, -1.23, -0.07, 0.06, 0.01],
904        [1.0, -1.0, 0.0, 0.0, -1.0, 0.02, 0.0, 0.0, 0.0],
905        [1.0, -1.0, 0.0, 0.0, 1.0, 0.04, 0.0, 0.0, 0.0],
906        [1.0, 0.0, 0.0, -1.0, 0.0, -0.22, 0.01, 0.01, 0.0],
907        [1.0, 0.0, 0.0, 0.0, 0.0, 12.00, -0.80, -0.67, -0.03],
908        [1.0, 0.0, 0.0, 1.0, 0.0, 1.73, -0.12, -0.10, 0.0],
909        [1.0, 0.0, 0.0, 2.0, 0.0, -0.04, 0.0, 0.0, 0.0],
910        [1.0, 1.0, 0.0, 0.0, -1.0, -0.50, -0.01, 0.03, 0.0],
911        [1.0, 1.0, 0.0, 0.0, 1.0, 0.01, 0.0, 0.0, 0.0],
912        [0.0, 1.0, 0.0, 1.0, -1.0, -0.01, 0.0, 0.0, 0.0],
913        [1.0, 2.0, -2.0, 0.0, 0.0, -0.01, 0.0, 0.0, 0.0],
914        [1.0, 2.0, 0.0, 0.0, 0.0, -0.11, 0.01, 0.01, 0.0],
915        [2.0, -2.0, 1.0, 0.0, 0.0, -0.01, 0.0, 0.0, 0.0],
916        [2.0, 0.0, -1.0, 0.0, 0.0, -0.02, 0.0, 0.0, 0.0],
917        [3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
918        [3.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
919    ];
920    let mut s = 218.31664563 + (481267.88194 + (-0.0014663889 + 0.00000185139 * t) * t) * t;
921    let mut tau = fhr * 15.0
922        + 280.4606184
923        + (36000.7700536 + (0.00038793 + -0.0000000258 * t) * t) * t
924        + (-s);
925    let pr = (1.396971278 + (0.000308889 + (0.000000021 + 0.000000007 * t) * t) * t) * t;
926    s += pr;
927    let mut h = 280.46645
928        + (36000.7697489 + (0.00030322222 + (0.000000020 + -0.00000000654 * t) * t) * t) * t;
929    let mut p = 83.35324312
930        + (4069.01363525 + (-0.01032172222 + (-0.0000124991 + 0.00000005263 * t) * t) * t) * t;
931    let mut zns = 234.95544499
932        + (1934.13626197 + (-0.00207561111 + (-0.00000213944 + 0.00000001650 * t) * t) * t) * t;
933    let mut ps = 282.93734098
934        + (1.71945766667 + (0.00045688889 + (-0.00000001778 + -0.00000000334 * t) * t) * t) * t;
935
936    s = s.rem_euclid(360.0);
937    tau = tau.rem_euclid(360.0);
938    h = h.rem_euclid(360.0);
939    p = p.rem_euclid(360.0);
940    zns = zns.rem_euclid(360.0);
941    ps = ps.rem_euclid(360.0);
942
943    let rsta = (xsta[0] * xsta[0] + xsta[1] * xsta[1] + xsta[2] * xsta[2]).sqrt();
944    let sinphi = xsta[2] / rsta;
945    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
946    let cosla = xsta[0] / cosphi / rsta;
947    let sinla = xsta[1] / cosphi / rsta;
948    let zla = libm::atan2(xsta[1], xsta[0]);
949
950    let mut xcorsta = [0.0_f64; 3];
951    for w in &DATDI {
952        let thetaf = (tau + w[0] * s + w[1] * h + w[2] * p + w[3] * zns + w[4] * ps) * DEG_TO_RAD;
953        let angle = thetaf + zla;
954        let sin_angle = libm::sin(angle);
955        let cos_angle = libm::cos(angle);
956        let dr =
957            w[5] * 2.0 * sinphi * cosphi * sin_angle + w[6] * 2.0 * sinphi * cosphi * cos_angle;
958        let dn = w[7] * (cosphi * cosphi - sinphi * sinphi) * sin_angle
959            + w[8] * (cosphi * cosphi - sinphi * sinphi) * cos_angle;
960        let de = w[7] * sinphi * cos_angle - w[8] * sinphi * sin_angle;
961
962        xcorsta[0] += dr * cosla * cosphi - de * sinla - dn * sinphi * cosla;
963        xcorsta[1] += dr * sinla * cosphi + de * cosla - dn * sinphi * sinla;
964        xcorsta[2] += dr * sinphi + dn * cosphi;
965    }
966    for v in &mut xcorsta {
967        *v /= KM_TO_M;
968    }
969    xcorsta
970}
971
972/// In-phase / out-of-phase frequency-dependent corrections, long-period band
973/// (STEP2LON). `t` is Julian centuries (TT).
974fn frequency_dependent_long_period_correction(xsta: &[f64; 3], t: f64) -> [f64; 3] {
975    #[rustfmt::skip]
976    const DATDI: [[f64; 9]; 5] = [
977        [0.0, 0.0, 0.0, 1.0, 0.0, 0.47, 0.23, 0.16, 0.07],
978        [0.0, 2.0, 0.0, 0.0, 0.0, -0.20, -0.12, -0.11, -0.05],
979        [1.0, 0.0, -1.0, 0.0, 0.0, -0.11, -0.08, -0.09, -0.04],
980        [2.0, 0.0, 0.0, 0.0, 0.0, -0.13, -0.11, -0.15, -0.07],
981        [2.0, 0.0, 0.0, 1.0, 0.0, -0.05, -0.05, -0.06, -0.03],
982    ];
983    let mut s = 218.31664563 + (481267.88194 + (-0.0014663889 + 0.00000185139 * t) * t) * t;
984    let pr = (1.396971278 + (0.000308889 + (0.000000021 + 0.000000007 * t) * t) * t) * t;
985    s += pr;
986    let mut h = 280.46645
987        + (36000.7697489 + (0.00030322222 + (0.000000020 + -0.00000000654 * t) * t) * t) * t;
988    let mut p = 83.35324312
989        + (4069.01363525 + (-0.01032172222 + (-0.0000124991 + 0.00000005263 * t) * t) * t) * t;
990    let mut zns = 234.95544499
991        + (1934.13626197 + (-0.00207561111 + (-0.00000213944 + 0.00000001650 * t) * t) * t) * t;
992    let mut ps = 282.93734098
993        + (1.71945766667 + (0.00045688889 + (-0.00000001778 + -0.00000000334 * t) * t) * t) * t;
994
995    let rsta = (xsta[0] * xsta[0] + xsta[1] * xsta[1] + xsta[2] * xsta[2]).sqrt();
996    let sinphi = xsta[2] / rsta;
997    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
998    let cosla = xsta[0] / cosphi / rsta;
999    let sinla = xsta[1] / cosphi / rsta;
1000
1001    s = s.rem_euclid(360.0);
1002    h = h.rem_euclid(360.0);
1003    p = p.rem_euclid(360.0);
1004    zns = zns.rem_euclid(360.0);
1005    ps = ps.rem_euclid(360.0);
1006
1007    let mut xcorsta = [0.0_f64; 3];
1008    for w in &DATDI {
1009        let thetaf = (w[0] * s + w[1] * h + w[2] * p + w[3] * zns + w[4] * ps) * DEG_TO_RAD;
1010        let sin_theta = libm::sin(thetaf);
1011        let cos_theta = libm::cos(thetaf);
1012        let dr = w[5] * (3.0 * sinphi * sinphi - 1.0) / 2.0 * cos_theta
1013            + w[7] * (3.0 * sinphi * sinphi - 1.0) / 2.0 * sin_theta;
1014        let dn =
1015            w[6] * (cosphi * sinphi * 2.0) * cos_theta + w[8] * (cosphi * sinphi * 2.0) * sin_theta;
1016        let de = 0.0;
1017
1018        xcorsta[0] += dr * cosla * cosphi - de * sinla - dn * sinphi * cosla;
1019        xcorsta[1] += dr * sinla * cosphi + de * cosla - dn * sinphi * sinla;
1020        xcorsta[2] += dr * sinphi + dn * cosphi;
1021    }
1022    for v in &mut xcorsta {
1023        *v /= KM_TO_M;
1024    }
1025    xcorsta
1026}
1027
1028/// Gregorian calendar date -> (MJD epoch 2400000.5, MJD) (SOFA CAL2JD).
1029///
1030/// This is a SOFA parity adapter, deliberately NOT routed through
1031/// [`crate::astro::time::civil`]: the solid-Earth/ocean/pole tide models are
1032/// validated bit-for-bit against the IERS/SOFA reference (the
1033/// `ocean_loading_oracle` test), so the calendar-to-MJD step must reproduce
1034/// SOFA's `iauCal2jd` exactly. It is kept local under this tides-specific name
1035/// so it is not mistaken for a duplicate of the canonical civil conversions and
1036/// is never consolidated into them.
1037fn gregorian_to_two_part_julian_date(iy: i32, im: i32, id: i32) -> (f64, f64) {
1038    let my = (im - 14) / 12;
1039    let iypmy = iy + my;
1040    let djm0 = 2400000.5;
1041    let djm = ((1461 * (iypmy + 4800)) / 4 + (367 * (im - 2 - 12 * my)) / 12
1042        - (3 * ((iypmy + 4900) / 100)) / 4
1043        + id
1044        - 2432076) as f64;
1045    (djm0, djm)
1046}
1047
1048/// TAI-UTC (Delta(AT)) in seconds for the given date (SOFA DAT, post-1972
1049/// leap-second table only). The four golden dates are all post-1972; SOFA's
1050/// pre-1972 drift terms are not implemented here.
1051fn tai_minus_utc_seconds(iy: i32, im: i32, _id: i32) -> f64 {
1052    // Post-1972 leap-second table: (year, month, Delta(AT) seconds).
1053    const IDAT: [(i32, i32, f64); 28] = [
1054        (1972, 1, 10.0),
1055        (1972, 7, 11.0),
1056        (1973, 1, 12.0),
1057        (1974, 1, 13.0),
1058        (1975, 1, 14.0),
1059        (1976, 1, 15.0),
1060        (1977, 1, 16.0),
1061        (1978, 1, 17.0),
1062        (1979, 1, 18.0),
1063        (1980, 1, 19.0),
1064        (1981, 7, 20.0),
1065        (1982, 7, 21.0),
1066        (1983, 7, 22.0),
1067        (1985, 7, 23.0),
1068        (1988, 1, 24.0),
1069        (1990, 1, 25.0),
1070        (1991, 1, 26.0),
1071        (1992, 7, 27.0),
1072        (1993, 7, 28.0),
1073        (1994, 7, 29.0),
1074        (1996, 1, 30.0),
1075        (1997, 7, 31.0),
1076        (1999, 1, 32.0),
1077        (2006, 1, 33.0),
1078        (2009, 1, 34.0),
1079        (2012, 7, 35.0),
1080        (2015, 7, 36.0),
1081        (2017, 1, 37.0),
1082    ];
1083    let m = 12 * iy + im;
1084    let mut da = IDAT[0].2;
1085    for &(y, mo, d) in &IDAT {
1086        if m >= 12 * y + mo {
1087            da = d;
1088        }
1089    }
1090    da
1091}