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 p3sun = 5.0 / 2.0 * (H3 - 3.0 * L3) * scsun.powi(3) + 3.0 / 2.0 * (L3 - H3) * scsun;
610    let p3mon = 5.0 / 2.0 * (H3 - 3.0 * L3) * scmon.powi(3) + 3.0 / 2.0 * (L3 - H3) * scmon;
611
612    // Term in direction of Sun/Moon vector.
613    let x2sun = 3.0 * l2 * scsun;
614    let x2mon = 3.0 * l2 * scmon;
615    let x3sun = 3.0 * L3 / 2.0 * (5.0 * scsun * scsun - 1.0);
616    let x3mon = 3.0 * L3 / 2.0 * (5.0 * scmon * scmon - 1.0);
617
618    // Factors for Sun/Moon (IAU current best estimates).
619    const MASS_RATIO_SUN: f64 = 332946.0482;
620    const MASS_RATIO_MOON: f64 = 0.0123000371;
621    const RE: f64 = SOLID_TIDE_EARTH_RADIUS_M;
622    let fac2sun = MASS_RATIO_SUN * RE * (RE / rsun).powi(3);
623    let fac2mon = MASS_RATIO_MOON * RE * (RE / rmon).powi(3);
624    let fac3sun = fac2sun * (RE / rsun);
625    let fac3mon = fac2mon * (RE / rmon);
626
627    // Total in-phase degree-2/degree-3 displacement.
628    let mut dxtide = [0.0_f64; 3];
629    for i in 0..3 {
630        dxtide[i] = fac2sun * (x2sun * xsun[i] / rsun + p2sun * xsta[i] / rsta)
631            + fac2mon * (x2mon * xmon[i] / rmon + p2mon * xsta[i] / rsta)
632            + fac3sun * (x3sun * xsun[i] / rsun + p3sun * xsta[i] / rsta)
633            + fac3mon * (x3mon * xmon[i] / rmon + p3mon * xsta[i] / rsta);
634    }
635
636    // Out-of-phase corrections (diurnal, semi-diurnal) and latitude dependence.
637    let c = out_of_phase_diurnal_correction(xsta, xsun, xmon, fac2sun, fac2mon);
638    for i in 0..3 {
639        dxtide[i] += c[i];
640    }
641    let c = out_of_phase_semidiurnal_correction(xsta, xsun, xmon, fac2sun, fac2mon);
642    for i in 0..3 {
643        dxtide[i] += c[i];
644    }
645    let c = latitude_dependence_correction(xsta, xsun, xmon, fac2sun, fac2mon);
646    for i in 0..3 {
647        dxtide[i] += c[i];
648    }
649
650    // Step 2 corrections need the date in Julian centuries (TT).
651    let (jjm0, jjm1) = gregorian_to_two_part_julian_date(year, month, day);
652    let fhrd = fhr / 24.0;
653    let mut t = ((jjm0 - J2000_JD) + jjm1 + fhrd) / DAYS_PER_JULIAN_CENTURY;
654    let dtt = tai_minus_utc_seconds(year, month, day) + TT_MINUS_TAI_S;
655    t += dtt / (SECONDS_PER_DAY * DAYS_PER_JULIAN_CENTURY);
656
657    let c = frequency_dependent_diurnal_correction(xsta, fhr, t);
658    for i in 0..3 {
659        dxtide[i] += c[i];
660    }
661    let c = frequency_dependent_long_period_correction(xsta, t);
662    for i in 0..3 {
663        dxtide[i] += c[i];
664    }
665
666    // Step 3 of the IERS routine, the permanent (zero-frequency) tide removal,
667    // is intentionally not applied, so the permanent (mean) tide deformation is
668    // retained (the ITRF/IGS conform-to-mean-tide convention; see module docs).
669    dxtide
670}
671
672/// Out-of-phase part of the Love numbers, diurnal band (ST1IDIU).
673fn out_of_phase_diurnal_correction(
674    xsta: &[f64; 3],
675    xsun: &[f64; 3],
676    xmon: &[f64; 3],
677    fac2sun: f64,
678    fac2mon: f64,
679) -> [f64; 3] {
680    const DHI: f64 = -0.0025;
681    const DLI: f64 = -0.0007;
682    let rsta = norm8(xsta);
683    let sinphi = xsta[2] / rsta;
684    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
685    let cos2phi = cosphi * cosphi - sinphi * sinphi;
686    let sinla = xsta[1] / cosphi / rsta;
687    let cosla = xsta[0] / cosphi / rsta;
688    let rmon = norm8(xmon);
689    let rsun = norm8(xsun);
690
691    let drsun =
692        -3.0 * DHI * sinphi * cosphi * fac2sun * xsun[2] * (xsun[0] * sinla - xsun[1] * cosla)
693            / (rsun * rsun);
694    let drmon =
695        -3.0 * DHI * sinphi * cosphi * fac2mon * xmon[2] * (xmon[0] * sinla - xmon[1] * cosla)
696            / (rmon * rmon);
697    let dnsun = -3.0 * DLI * cos2phi * fac2sun * xsun[2] * (xsun[0] * sinla - xsun[1] * cosla)
698        / (rsun * rsun);
699    let dnmon = -3.0 * DLI * cos2phi * fac2mon * xmon[2] * (xmon[0] * sinla - xmon[1] * cosla)
700        / (rmon * rmon);
701    let desun = -3.0 * DLI * sinphi * fac2sun * xsun[2] * (xsun[0] * cosla + xsun[1] * sinla)
702        / (rsun * rsun);
703    let demon = -3.0 * DLI * sinphi * fac2mon * xmon[2] * (xmon[0] * cosla + xmon[1] * sinla)
704        / (rmon * rmon);
705
706    let dr = drsun + drmon;
707    let dn = dnsun + dnmon;
708    let de = desun + demon;
709
710    [
711        dr * cosla * cosphi - de * sinla - dn * sinphi * cosla,
712        dr * sinla * cosphi + de * cosla - dn * sinphi * sinla,
713        dr * sinphi + dn * cosphi,
714    ]
715}
716
717/// Out-of-phase part of the Love numbers, semi-diurnal band (ST1ISEM).
718fn out_of_phase_semidiurnal_correction(
719    xsta: &[f64; 3],
720    xsun: &[f64; 3],
721    xmon: &[f64; 3],
722    fac2sun: f64,
723    fac2mon: f64,
724) -> [f64; 3] {
725    const DHI: f64 = -0.0022;
726    const DLI: f64 = -0.0007;
727    let rsta = norm8(xsta);
728    let sinphi = xsta[2] / rsta;
729    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
730    let sinla = xsta[1] / cosphi / rsta;
731    let cosla = xsta[0] / cosphi / rsta;
732    let costwola = cosla * cosla - sinla * sinla;
733    let sintwola = 2.0 * cosla * sinla;
734    let rmon = norm8(xmon);
735    let rsun = norm8(xsun);
736
737    let drsun = -3.0 / 4.0
738        * DHI
739        * cosphi
740        * cosphi
741        * fac2sun
742        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * sintwola - 2.0 * xsun[0] * xsun[1] * costwola)
743        / (rsun * rsun);
744    let drmon = -3.0 / 4.0
745        * DHI
746        * cosphi
747        * cosphi
748        * fac2mon
749        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * sintwola - 2.0 * xmon[0] * xmon[1] * costwola)
750        / (rmon * rmon);
751    let dnsun = 3.0 / 2.0
752        * DLI
753        * sinphi
754        * cosphi
755        * fac2sun
756        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * sintwola - 2.0 * xsun[0] * xsun[1] * costwola)
757        / (rsun * rsun);
758    let dnmon = 3.0 / 2.0
759        * DLI
760        * sinphi
761        * cosphi
762        * fac2mon
763        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * sintwola - 2.0 * xmon[0] * xmon[1] * costwola)
764        / (rmon * rmon);
765    let desun = -3.0 / 2.0
766        * DLI
767        * cosphi
768        * fac2sun
769        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * costwola + 2.0 * xsun[0] * xsun[1] * sintwola)
770        / (rsun * rsun);
771    let demon = -3.0 / 2.0
772        * DLI
773        * cosphi
774        * fac2mon
775        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * costwola + 2.0 * xmon[0] * xmon[1] * sintwola)
776        / (rmon * rmon);
777
778    let dr = drsun + drmon;
779    let dn = dnsun + dnmon;
780    let de = desun + demon;
781
782    [
783        dr * cosla * cosphi - de * sinla - dn * sinphi * cosla,
784        dr * sinla * cosphi + de * cosla - dn * sinphi * sinla,
785        dr * sinphi + dn * cosphi,
786    ]
787}
788
789/// Latitude dependence of the Love numbers, part L^(1) (ST1L1).
790fn latitude_dependence_correction(
791    xsta: &[f64; 3],
792    xsun: &[f64; 3],
793    xmon: &[f64; 3],
794    fac2sun: f64,
795    fac2mon: f64,
796) -> [f64; 3] {
797    const L1D: f64 = 0.0012;
798    const L1SD: f64 = 0.0024;
799    let rsta = norm8(xsta);
800    let sinphi = xsta[2] / rsta;
801    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
802    let sinla = xsta[1] / cosphi / rsta;
803    let cosla = xsta[0] / cosphi / rsta;
804    let rmon = norm8(xmon);
805    let rsun = norm8(xsun);
806
807    // Diurnal band.
808    let mut l1 = L1D;
809    let dnsun = -l1 * sinphi * sinphi * fac2sun * xsun[2] * (xsun[0] * cosla + xsun[1] * sinla)
810        / (rsun * rsun);
811    let dnmon = -l1 * sinphi * sinphi * fac2mon * xmon[2] * (xmon[0] * cosla + xmon[1] * sinla)
812        / (rmon * rmon);
813    let desun = l1
814        * sinphi
815        * (cosphi * cosphi - sinphi * sinphi)
816        * fac2sun
817        * xsun[2]
818        * (xsun[0] * sinla - xsun[1] * cosla)
819        / (rsun * rsun);
820    let demon = l1
821        * sinphi
822        * (cosphi * cosphi - sinphi * sinphi)
823        * fac2mon
824        * xmon[2]
825        * (xmon[0] * sinla - xmon[1] * cosla)
826        / (rmon * rmon);
827
828    let de = 3.0 * (desun + demon);
829    let dn = 3.0 * (dnsun + dnmon);
830
831    let mut xcorsta = [
832        -de * sinla - dn * sinphi * cosla,
833        de * cosla - dn * sinphi * sinla,
834        dn * cosphi,
835    ];
836
837    // Semi-diurnal band.
838    l1 = L1SD;
839    let costwola = cosla * cosla - sinla * sinla;
840    let sintwola = 2.0 * cosla * sinla;
841
842    let dnsun = -l1 / 2.0
843        * sinphi
844        * cosphi
845        * fac2sun
846        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * costwola + 2.0 * xsun[0] * xsun[1] * sintwola)
847        / (rsun * rsun);
848    let dnmon = -l1 / 2.0
849        * sinphi
850        * cosphi
851        * fac2mon
852        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * costwola + 2.0 * xmon[0] * xmon[1] * sintwola)
853        / (rmon * rmon);
854    let desun = -l1 / 2.0
855        * sinphi
856        * sinphi
857        * cosphi
858        * fac2sun
859        * ((xsun[0] * xsun[0] - xsun[1] * xsun[1]) * sintwola - 2.0 * xsun[0] * xsun[1] * costwola)
860        / (rsun * rsun);
861    let demon = -l1 / 2.0
862        * sinphi
863        * sinphi
864        * cosphi
865        * fac2mon
866        * ((xmon[0] * xmon[0] - xmon[1] * xmon[1]) * sintwola - 2.0 * xmon[0] * xmon[1] * costwola)
867        / (rmon * rmon);
868
869    let de = 3.0 * (desun + demon);
870    let dn = 3.0 * (dnsun + dnmon);
871
872    xcorsta[0] += -de * sinla - dn * sinphi * cosla;
873    xcorsta[1] += de * cosla - dn * sinphi * sinla;
874    xcorsta[2] += dn * cosphi;
875    xcorsta
876}
877
878/// In-phase / out-of-phase frequency-dependent corrections, diurnal band
879/// (STEP2DIU). `fhr` is UTC fractional hour, `t` is Julian centuries (TT).
880fn frequency_dependent_diurnal_correction(xsta: &[f64; 3], fhr: f64, t: f64) -> [f64; 3] {
881    // DATDI(9,31): {l, l', F, D, Omega(Ps), Adr, Adi, Anr, Ani} per wave.
882    #[rustfmt::skip]
883    const DATDI: [[f64; 9]; 31] = [
884        [-3.0, 0.0, 2.0, 0.0, 0.0, -0.01, 0.0, 0.0, 0.0],
885        [-3.0, 2.0, 0.0, 0.0, 0.0, -0.01, 0.0, 0.0, 0.0],
886        [-2.0, 0.0, 1.0, -1.0, 0.0, -0.02, 0.0, 0.0, 0.0],
887        [-2.0, 0.0, 1.0, 0.0, 0.0, -0.08, 0.0, -0.01, 0.01],
888        [-2.0, 2.0, -1.0, 0.0, 0.0, -0.02, 0.0, 0.0, 0.0],
889        [-1.0, 0.0, 0.0, -1.0, 0.0, -0.10, 0.0, 0.0, 0.0],
890        [-1.0, 0.0, 0.0, 0.0, 0.0, -0.51, 0.0, -0.02, 0.03],
891        [-1.0, 2.0, 0.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0],
892        [0.0, -2.0, 1.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0],
893        [0.0, 0.0, -1.0, 0.0, 0.0, 0.02, 0.0, 0.0, 0.0],
894        [0.0, 0.0, 1.0, 0.0, 0.0, 0.06, 0.0, 0.0, 0.0],
895        [0.0, 0.0, 1.0, 1.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        [1.0, -3.0, 0.0, 0.0, 1.0, -0.06, 0.0, 0.0, 0.0],
898        [1.0, -2.0, 0.0, -1.0, 0.0, 0.01, 0.0, 0.0, 0.0],
899        [1.0, -2.0, 0.0, 0.0, 0.0, -1.23, -0.07, 0.06, 0.01],
900        [1.0, -1.0, 0.0, 0.0, -1.0, 0.02, 0.0, 0.0, 0.0],
901        [1.0, -1.0, 0.0, 0.0, 1.0, 0.04, 0.0, 0.0, 0.0],
902        [1.0, 0.0, 0.0, -1.0, 0.0, -0.22, 0.01, 0.01, 0.0],
903        [1.0, 0.0, 0.0, 0.0, 0.0, 12.00, -0.80, -0.67, -0.03],
904        [1.0, 0.0, 0.0, 1.0, 0.0, 1.73, -0.12, -0.10, 0.0],
905        [1.0, 0.0, 0.0, 2.0, 0.0, -0.04, 0.0, 0.0, 0.0],
906        [1.0, 1.0, 0.0, 0.0, -1.0, -0.50, -0.01, 0.03, 0.0],
907        [1.0, 1.0, 0.0, 0.0, 1.0, 0.01, 0.0, 0.0, 0.0],
908        [0.0, 1.0, 0.0, 1.0, -1.0, -0.01, 0.0, 0.0, 0.0],
909        [1.0, 2.0, -2.0, 0.0, 0.0, -0.01, 0.0, 0.0, 0.0],
910        [1.0, 2.0, 0.0, 0.0, 0.0, -0.11, 0.01, 0.01, 0.0],
911        [2.0, -2.0, 1.0, 0.0, 0.0, -0.01, 0.0, 0.0, 0.0],
912        [2.0, 0.0, -1.0, 0.0, 0.0, -0.02, 0.0, 0.0, 0.0],
913        [3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
914        [3.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
915    ];
916    let mut s = 218.31664563 + (481267.88194 + (-0.0014663889 + 0.00000185139 * t) * t) * t;
917    let mut tau = fhr * 15.0
918        + 280.4606184
919        + (36000.7700536 + (0.00038793 + -0.0000000258 * t) * t) * t
920        + (-s);
921    let pr = (1.396971278 + (0.000308889 + (0.000000021 + 0.000000007 * t) * t) * t) * t;
922    s += pr;
923    let mut h = 280.46645
924        + (36000.7697489 + (0.00030322222 + (0.000000020 + -0.00000000654 * t) * t) * t) * t;
925    let mut p = 83.35324312
926        + (4069.01363525 + (-0.01032172222 + (-0.0000124991 + 0.00000005263 * t) * t) * t) * t;
927    let mut zns = 234.95544499
928        + (1934.13626197 + (-0.00207561111 + (-0.00000213944 + 0.00000001650 * t) * t) * t) * t;
929    let mut ps = 282.93734098
930        + (1.71945766667 + (0.00045688889 + (-0.00000001778 + -0.00000000334 * t) * t) * t) * t;
931
932    s = s.rem_euclid(360.0);
933    tau = tau.rem_euclid(360.0);
934    h = h.rem_euclid(360.0);
935    p = p.rem_euclid(360.0);
936    zns = zns.rem_euclid(360.0);
937    ps = ps.rem_euclid(360.0);
938
939    let rsta = (xsta[0] * xsta[0] + xsta[1] * xsta[1] + xsta[2] * xsta[2]).sqrt();
940    let sinphi = xsta[2] / rsta;
941    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
942    let cosla = xsta[0] / cosphi / rsta;
943    let sinla = xsta[1] / cosphi / rsta;
944    let zla = xsta[1].atan2(xsta[0]);
945
946    let mut xcorsta = [0.0_f64; 3];
947    for w in &DATDI {
948        let thetaf = (tau + w[0] * s + w[1] * h + w[2] * p + w[3] * zns + w[4] * ps) * DEG_TO_RAD;
949        let dr = w[5] * 2.0 * sinphi * cosphi * (thetaf + zla).sin()
950            + w[6] * 2.0 * sinphi * cosphi * (thetaf + zla).cos();
951        let dn = w[7] * (cosphi * cosphi - sinphi * sinphi) * (thetaf + zla).sin()
952            + w[8] * (cosphi * cosphi - sinphi * sinphi) * (thetaf + zla).cos();
953        let de = w[7] * sinphi * (thetaf + zla).cos() - w[8] * sinphi * (thetaf + zla).sin();
954
955        xcorsta[0] += dr * cosla * cosphi - de * sinla - dn * sinphi * cosla;
956        xcorsta[1] += dr * sinla * cosphi + de * cosla - dn * sinphi * sinla;
957        xcorsta[2] += dr * sinphi + dn * cosphi;
958    }
959    for v in &mut xcorsta {
960        *v /= KM_TO_M;
961    }
962    xcorsta
963}
964
965/// In-phase / out-of-phase frequency-dependent corrections, long-period band
966/// (STEP2LON). `t` is Julian centuries (TT).
967fn frequency_dependent_long_period_correction(xsta: &[f64; 3], t: f64) -> [f64; 3] {
968    #[rustfmt::skip]
969    const DATDI: [[f64; 9]; 5] = [
970        [0.0, 0.0, 0.0, 1.0, 0.0, 0.47, 0.23, 0.16, 0.07],
971        [0.0, 2.0, 0.0, 0.0, 0.0, -0.20, -0.12, -0.11, -0.05],
972        [1.0, 0.0, -1.0, 0.0, 0.0, -0.11, -0.08, -0.09, -0.04],
973        [2.0, 0.0, 0.0, 0.0, 0.0, -0.13, -0.11, -0.15, -0.07],
974        [2.0, 0.0, 0.0, 1.0, 0.0, -0.05, -0.05, -0.06, -0.03],
975    ];
976    let mut s = 218.31664563 + (481267.88194 + (-0.0014663889 + 0.00000185139 * t) * t) * t;
977    let pr = (1.396971278 + (0.000308889 + (0.000000021 + 0.000000007 * t) * t) * t) * t;
978    s += pr;
979    let mut h = 280.46645
980        + (36000.7697489 + (0.00030322222 + (0.000000020 + -0.00000000654 * t) * t) * t) * t;
981    let mut p = 83.35324312
982        + (4069.01363525 + (-0.01032172222 + (-0.0000124991 + 0.00000005263 * t) * t) * t) * t;
983    let mut zns = 234.95544499
984        + (1934.13626197 + (-0.00207561111 + (-0.00000213944 + 0.00000001650 * t) * t) * t) * t;
985    let mut ps = 282.93734098
986        + (1.71945766667 + (0.00045688889 + (-0.00000001778 + -0.00000000334 * t) * t) * t) * t;
987
988    let rsta = (xsta[0] * xsta[0] + xsta[1] * xsta[1] + xsta[2] * xsta[2]).sqrt();
989    let sinphi = xsta[2] / rsta;
990    let cosphi = (xsta[0] * xsta[0] + xsta[1] * xsta[1]).sqrt() / rsta;
991    let cosla = xsta[0] / cosphi / rsta;
992    let sinla = xsta[1] / cosphi / rsta;
993
994    s = s.rem_euclid(360.0);
995    h = h.rem_euclid(360.0);
996    p = p.rem_euclid(360.0);
997    zns = zns.rem_euclid(360.0);
998    ps = ps.rem_euclid(360.0);
999
1000    let mut xcorsta = [0.0_f64; 3];
1001    for w in &DATDI {
1002        let thetaf = (w[0] * s + w[1] * h + w[2] * p + w[3] * zns + w[4] * ps) * DEG_TO_RAD;
1003        let dr = w[5] * (3.0 * sinphi * sinphi - 1.0) / 2.0 * thetaf.cos()
1004            + w[7] * (3.0 * sinphi * sinphi - 1.0) / 2.0 * thetaf.sin();
1005        let dn = w[6] * (cosphi * sinphi * 2.0) * thetaf.cos()
1006            + w[8] * (cosphi * sinphi * 2.0) * thetaf.sin();
1007        let de = 0.0;
1008
1009        xcorsta[0] += dr * cosla * cosphi - de * sinla - dn * sinphi * cosla;
1010        xcorsta[1] += dr * sinla * cosphi + de * cosla - dn * sinphi * sinla;
1011        xcorsta[2] += dr * sinphi + dn * cosphi;
1012    }
1013    for v in &mut xcorsta {
1014        *v /= KM_TO_M;
1015    }
1016    xcorsta
1017}
1018
1019/// Gregorian calendar date -> (MJD epoch 2400000.5, MJD) (SOFA CAL2JD).
1020///
1021/// This is a SOFA parity adapter, deliberately NOT routed through
1022/// [`crate::astro::time::civil`]: the solid-Earth/ocean/pole tide models are
1023/// validated bit-for-bit against the IERS/SOFA reference (the
1024/// `ocean_loading_oracle` test), so the calendar-to-MJD step must reproduce
1025/// SOFA's `iauCal2jd` exactly. It is kept local under this tides-specific name
1026/// so it is not mistaken for a duplicate of the canonical civil conversions and
1027/// is never consolidated into them.
1028fn gregorian_to_two_part_julian_date(iy: i32, im: i32, id: i32) -> (f64, f64) {
1029    let my = (im - 14) / 12;
1030    let iypmy = iy + my;
1031    let djm0 = 2400000.5;
1032    let djm = ((1461 * (iypmy + 4800)) / 4 + (367 * (im - 2 - 12 * my)) / 12
1033        - (3 * ((iypmy + 4900) / 100)) / 4
1034        + id
1035        - 2432076) as f64;
1036    (djm0, djm)
1037}
1038
1039/// TAI-UTC (Delta(AT)) in seconds for the given date (SOFA DAT, post-1972
1040/// leap-second table only). The four golden dates are all post-1972; SOFA's
1041/// pre-1972 drift terms are not implemented here.
1042fn tai_minus_utc_seconds(iy: i32, im: i32, _id: i32) -> f64 {
1043    // Post-1972 leap-second table: (year, month, Delta(AT) seconds).
1044    const IDAT: [(i32, i32, f64); 28] = [
1045        (1972, 1, 10.0),
1046        (1972, 7, 11.0),
1047        (1973, 1, 12.0),
1048        (1974, 1, 13.0),
1049        (1975, 1, 14.0),
1050        (1976, 1, 15.0),
1051        (1977, 1, 16.0),
1052        (1978, 1, 17.0),
1053        (1979, 1, 18.0),
1054        (1980, 1, 19.0),
1055        (1981, 7, 20.0),
1056        (1982, 7, 21.0),
1057        (1983, 7, 22.0),
1058        (1985, 7, 23.0),
1059        (1988, 1, 24.0),
1060        (1990, 1, 25.0),
1061        (1991, 1, 26.0),
1062        (1992, 7, 27.0),
1063        (1993, 7, 28.0),
1064        (1994, 7, 29.0),
1065        (1996, 1, 30.0),
1066        (1997, 7, 31.0),
1067        (1999, 1, 32.0),
1068        (2006, 1, 33.0),
1069        (2009, 1, 34.0),
1070        (2012, 7, 35.0),
1071        (2015, 7, 36.0),
1072        (2017, 1, 37.0),
1073    ];
1074    let m = 12 * iy + im;
1075    let mut da = IDAT[0].2;
1076    for &(y, mo, d) in &IDAT {
1077        if m >= 12 * y + mo {
1078            da = d;
1079        }
1080    }
1081    da
1082}