Skip to main content

sidereon_core/astro/frames/
nutation.rs

1//! IAU 2000A nutation computation, ported from the C++ Skyfield-compatible
2//! implementation.
3//!
4//! Originally `pub(crate)` inside `orbis_nif`; now public in the core crate so
5//! a Rust-only consumer can reach the transform substrate without Rustler or
6//! the BEAM. The numerics, summation order, and transcendental sequence are
7//! preserved exactly so the existing Skyfield 0-ULP parity holds.
8//!
9//! All arithmetic uses plain operators (no `f64::mul_add`) so that
10//! rounding matches CPython / Skyfield compiled without FMA contraction.
11
12use crate::astro::constants::time::{DAYS_PER_JULIAN_CENTURY, J2000_JD};
13use crate::astro::constants::units::ARCSEC_TO_RAD;
14use crate::astro::data::iau2000a::*;
15use crate::astro::math::mat3::Mat3;
16
17use std::f64::consts::PI;
18
19const TAU: f64 = 2.0 * PI;
20const ASEC360: f64 = 1_296_000.0;
21const TENTH_USEC_2_RAD: f64 = ARCSEC_TO_RAD / 1.0e7;
22
23/// Error returned when public nutation inputs are outside the valid domain.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
25pub enum NutationError {
26    /// A nutation input was non-finite or otherwise invalid.
27    #[error("invalid nutation {field}: {reason}")]
28    InvalidInput {
29        field: &'static str,
30        reason: &'static str,
31    },
32}
33
34fn invalid_input(field: &'static str, reason: &'static str) -> NutationError {
35    NutationError::InvalidInput { field, reason }
36}
37
38fn validate_finite(field: &'static str, value: f64) -> Result<(), NutationError> {
39    if value.is_finite() {
40        Ok(())
41    } else {
42        Err(invalid_input(field, "must be finite"))
43    }
44}
45
46fn validate_finite_values<const N: usize>(
47    field: &'static str,
48    values: [f64; N],
49) -> Result<[f64; N], NutationError> {
50    if values.iter().all(|value| value.is_finite()) {
51        Ok(values)
52    } else {
53        Err(invalid_input(field, "components must be finite"))
54    }
55}
56
57// ---------------------------------------------------------------------------
58// Helpers
59// ---------------------------------------------------------------------------
60
61fn positive_fmod(value: f64, modulus: f64) -> f64 {
62    let mut result = value % modulus;
63    if result < 0.0 {
64        result += modulus;
65    }
66    result
67}
68
69// ---------------------------------------------------------------------------
70// Fundamental arguments (lunisolar)
71// ---------------------------------------------------------------------------
72
73/// Compute the five Delaunay fundamental arguments of nutation theory
74/// for a given Julian-century offset `t` from J2000.0 TDB.
75pub fn skyfield_fundamental_arguments(t: f64) -> Result<[f64; 5], NutationError> {
76    validate_finite("t", t)?;
77    validate_finite_values(
78        "fundamental_arguments",
79        skyfield_fundamental_arguments_unchecked(t),
80    )
81}
82
83fn skyfield_fundamental_arguments_unchecked(t: f64) -> [f64; 5] {
84    const FA0: [f64; 5] = [
85        485868.249036,
86        1287104.79305,
87        335779.526232,
88        1072260.70369,
89        450160.398036,
90    ];
91    const FA1: [f64; 5] = [
92        1717915923.2178,
93        129596581.0481,
94        1739527262.8478,
95        1602961601.2090,
96        -6962890.5431,
97    ];
98    const FA2: [f64; 5] = [31.8792, -0.5532, -12.7512, -6.3706, 7.4722];
99    const FA3: [f64; 5] = [0.051635, 0.000136, -0.001037, 0.006593, 0.007702];
100    const FA4: [f64; 5] = [
101        -0.00024470,
102        -0.00001149,
103        0.00000417,
104        -0.00003169,
105        -0.00005939,
106    ];
107
108    let mut args = [0.0_f64; 5];
109    for i in 0..5 {
110        let mut value = FA4[i] * t;
111        value += FA3[i];
112        value *= t;
113        value += FA2[i];
114        value *= t;
115        value += FA1[i];
116        value *= t;
117        value += FA0[i];
118        value %= ASEC360;
119        args[i] = value * ARCSEC_TO_RAD;
120    }
121    args
122}
123
124// ---------------------------------------------------------------------------
125// IAU 2000A nutation in longitude and obliquity
126// ---------------------------------------------------------------------------
127
128/// Compute nutation in longitude (`dpsi`) and obliquity (`deps`) in radians
129/// using the full IAU 2000A model.
130///
131/// `jd_tt` is the Julian date on the TT time scale.
132pub fn skyfield_iau2000a_radians(jd_tt: f64) -> Result<(f64, f64), NutationError> {
133    validate_finite("jd_tt", jd_tt)?;
134    let values = skyfield_iau2000a_radians_unchecked(jd_tt);
135    validate_finite("dpsi", values.0)?;
136    validate_finite("deps", values.1)?;
137    Ok(values)
138}
139
140pub(crate) fn skyfield_iau2000a_radians_unchecked(jd_tt: f64) -> (f64, f64) {
141    const ANOMALY_CONSTANT: [f64; 14] = [
142        2.35555598,
143        6.24006013,
144        1.627905234,
145        5.198466741,
146        2.18243920,
147        4.402608842,
148        3.176146697,
149        1.753470314,
150        6.203480913,
151        0.599546497,
152        0.874016757,
153        5.481293871,
154        5.321159000,
155        0.02438175,
156    ];
157    const ANOMALY_COEFFICIENT: [f64; 14] = [
158        8328.6914269554,
159        628.301955,
160        8433.466158131,
161        7771.3771468121,
162        -33.757045,
163        2608.7903141574,
164        1021.3285546211,
165        628.3075849991,
166        334.0612426700,
167        52.9690962641,
168        21.3299104960,
169        7.4781598567,
170        3.8127774000,
171        0.00000538691,
172    ];
173
174    let t = (jd_tt - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
175
176    let fundamental_args = skyfield_fundamental_arguments_unchecked(t);
177
178    let mut dpsi = 0.0_f64;
179    let mut deps = 0.0_f64;
180
181    // Lunisolar nutation (678 terms)
182    for row in 0..678 {
183        let mut arg = 0.0_f64;
184        for i in 0..5 {
185            arg += (NALS_T[row][i] as f64) * fundamental_args[i];
186        }
187
188        let sarg = arg.sin();
189        let carg = arg.cos();
190
191        dpsi += sarg * LUNISOLAR_LONGITUDE_COEFFICIENTS[row][0];
192        dpsi += sarg * LUNISOLAR_LONGITUDE_COEFFICIENTS[row][1] * t;
193        dpsi += carg * LUNISOLAR_LONGITUDE_COEFFICIENTS[row][2];
194
195        deps += carg * LUNISOLAR_OBLIQUITY_COEFFICIENTS[row][0];
196        deps += carg * LUNISOLAR_OBLIQUITY_COEFFICIENTS[row][1] * t;
197        deps += sarg * LUNISOLAR_OBLIQUITY_COEFFICIENTS[row][2];
198    }
199
200    // Planetary arguments
201    let mut planetary_args = [0.0_f64; 14];
202    for i in 0..14 {
203        planetary_args[i] = ANOMALY_CONSTANT[i] + ANOMALY_COEFFICIENT[i] * t;
204    }
205    planetary_args[13] *= t;
206
207    // Planetary nutation (687 terms)
208    for row in 0..687 {
209        let mut arg = 0.0_f64;
210        for i in 0..14 {
211            arg += (NAPL_T[row][i] as f64) * planetary_args[i];
212        }
213
214        let sarg = arg.sin();
215        let carg = arg.cos();
216
217        dpsi += sarg * NUTATION_COEFFICIENTS_LONGITUDE[row][0];
218        dpsi += carg * NUTATION_COEFFICIENTS_LONGITUDE[row][1];
219
220        deps += sarg * NUTATION_COEFFICIENTS_OBLIQUITY[row][0];
221        deps += carg * NUTATION_COEFFICIENTS_OBLIQUITY[row][1];
222    }
223
224    (dpsi * TENTH_USEC_2_RAD, deps * TENTH_USEC_2_RAD)
225}
226
227// ---------------------------------------------------------------------------
228// Mean obliquity
229// ---------------------------------------------------------------------------
230
231/// Mean obliquity of the ecliptic in radians for the given TDB Julian date.
232pub fn skyfield_mean_obliquity_radians(jd_tdb: f64) -> Result<f64, NutationError> {
233    validate_finite("jd_tdb", jd_tdb)?;
234    let value = skyfield_mean_obliquity_radians_unchecked(jd_tdb);
235    validate_finite("mean_obliquity_radians", value)?;
236    Ok(value)
237}
238
239pub(crate) fn skyfield_mean_obliquity_radians_unchecked(jd_tdb: f64) -> f64 {
240    let t = (jd_tdb - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
241    let epsilon = ((((-0.0000000434 * t - 0.000000576) * t + 0.00200340) * t - 0.0001831) * t
242        - 46.836769)
243        * t
244        + 84381.406;
245    epsilon * ARCSEC_TO_RAD
246}
247
248// ---------------------------------------------------------------------------
249// Nutation matrix
250// ---------------------------------------------------------------------------
251
252/// Build the 3x3 nutation rotation matrix from mean obliquity, true obliquity,
253/// and the nutation in longitude (psi).
254pub fn build_skyfield_nutation_matrix(
255    mean_obliquity_radians: f64,
256    true_obliquity_radians: f64,
257    psi_radians: f64,
258) -> Result<Mat3, NutationError> {
259    validate_finite("mean_obliquity_radians", mean_obliquity_radians)?;
260    validate_finite("true_obliquity_radians", true_obliquity_radians)?;
261    validate_finite("psi_radians", psi_radians)?;
262    validate_mat3(
263        "nutation_matrix",
264        build_skyfield_nutation_matrix_unchecked(
265            mean_obliquity_radians,
266            true_obliquity_radians,
267            psi_radians,
268        ),
269    )
270}
271
272pub(crate) fn build_skyfield_nutation_matrix_unchecked(
273    mean_obliquity_radians: f64,
274    true_obliquity_radians: f64,
275    psi_radians: f64,
276) -> Mat3 {
277    let cobm = mean_obliquity_radians.cos();
278    let sobm = mean_obliquity_radians.sin();
279    let cobt = true_obliquity_radians.cos();
280    let sobt = true_obliquity_radians.sin();
281    let cpsi = psi_radians.cos();
282    let spsi = psi_radians.sin();
283
284    [
285        [cpsi, -spsi * cobm, -spsi * sobm],
286        [
287            spsi * cobt,
288            cpsi * cobm * cobt + sobm * sobt,
289            cpsi * sobm * cobt - cobm * sobt,
290        ],
291        [
292            spsi * sobt,
293            cpsi * cobm * sobt - sobm * cobt,
294            cpsi * sobm * sobt + cobm * cobt,
295        ],
296    ]
297}
298
299fn validate_mat3(field: &'static str, mat: Mat3) -> Result<Mat3, NutationError> {
300    if mat.iter().flatten().all(|value| value.is_finite()) {
301        Ok(mat)
302    } else {
303        Err(invalid_input(field, "components must be finite"))
304    }
305}
306
307// ---------------------------------------------------------------------------
308// Equation of the equinoxes -- complementary terms
309// ---------------------------------------------------------------------------
310
311/// Complementary terms of the equation of the equinoxes, in radians.
312///
313/// `jd_tt` is the Julian date on the TT time scale.
314pub fn skyfield_equation_of_the_equinoxes_complimentary_terms(
315    jd_tt: f64,
316) -> Result<f64, NutationError> {
317    validate_finite("jd_tt", jd_tt)?;
318    let value = skyfield_equation_of_the_equinoxes_complimentary_terms_unchecked(jd_tt);
319    validate_finite("equation_of_the_equinoxes_terms", value)?;
320    Ok(value)
321}
322
323pub(crate) fn skyfield_equation_of_the_equinoxes_complimentary_terms_unchecked(jd_tt: f64) -> f64 {
324    const KE1: [i32; 14] = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
325
326    #[rustfmt::skip]
327    const KE0_T: [[i32; 14]; 33] = [
328        [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
329        [0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0],
330        [0, 0, 2, -2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
331        [0, 0, 2, -2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
332        [0, 0, 2, -2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0],
333        [0, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
334        [0, 0, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
335        [0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
336        [0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
337        [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
338        [1, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
339        [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
340        [0, 1, 2, -2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
341        [0, 1, 2, -2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
342        [0, 0, 4, -4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0],
343        [0, 0, 1, -1, 1, 0, -8, 12, 0, 0, 0, 0, 0, 0],
344        [0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
345        [0, 0, 2, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0],
346        [1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
347        [1, 0, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
348        [0, 0, 2, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
349        [0, 1, -2, 2, -3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
350        [0, 1, -2, 2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
351        [0, 0, 0, 0, 0, 0, 8, -13, 0, 0, 0, 0, 0, -1],
352        [0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
353        [2, 0, -2, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
354        [1, 0, 0, -2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
355        [0, 1, 2, -2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0],
356        [1, 0, 0, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
357        [0, 0, 4, -2, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0],
358        [0, 0, 2, -2, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0],
359        [1, 0, -2, 0, -3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
360        [1, 0, -2, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
361    ];
362
363    #[rustfmt::skip]
364    const SE0_T_0: [f64; 33] = [
365        0.00264096, 0.00006352, 0.00001175, 0.00001121, -0.00000455, 0.00000202,
366        0.00000198, -0.00000172, -0.00000141, -0.00000126, -0.00000063, -0.00000063,
367        0.00000046, 0.00000045, 0.00000036, -0.00000024, 0.00000032, 0.00000028,
368        0.00000027, 0.00000026, -0.00000021, 0.00000019, 0.00000018, -0.00000010,
369        0.00000015, -0.00000014, 0.00000014, -0.00000014, 0.00000014, 0.00000013,
370        -0.00000011, 0.00000011, 0.00000011,
371    ];
372
373    #[rustfmt::skip]
374    const SE0_T_1: [f64; 33] = [
375        -0.00000039, -0.00000002, 0.00000001, 0.00000001, 0.0, 0.0,
376        0.0, 0.0, -0.00000001, -0.00000001, 0.0, 0.0,
377        0.0, 0.0, 0.0, -0.00000012, 0.0, 0.0,
378        0.0, 0.0, 0.0, 0.0, 0.0, 0.00000005,
379        0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
380        0.0, 0.0, 0.0,
381    ];
382
383    const SE1_0: f64 = -0.00000087;
384
385    let t = (jd_tt - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
386
387    // Compute the 14 fundamental arguments (the same ones used for the
388    // equation-of-equinoxes complementary terms in the C++ code).
389    let mut fa = [0.0_f64; 14];
390
391    fa[0] = (485868.249036
392        + (715923.2178 + (31.8792 + (0.051635 + (-0.00024470) * t) * t) * t) * t)
393        * ARCSEC_TO_RAD
394        + positive_fmod(1325.0 * t, 1.0) * TAU;
395
396    fa[1] = (1287104.793048
397        + (1292581.0481 + (-0.5532 + (0.000136 + (-0.00001149) * t) * t) * t) * t)
398        * ARCSEC_TO_RAD
399        + positive_fmod(99.0 * t, 1.0) * TAU;
400
401    fa[2] = (335779.526232
402        + (295262.8478 + (-12.7512 + (-0.001037 + (0.00000417) * t) * t) * t) * t)
403        * ARCSEC_TO_RAD
404        + positive_fmod(1342.0 * t, 1.0) * TAU;
405
406    fa[3] = (1072260.703692
407        + (1105601.2090 + (-6.3706 + (0.006593 + (-0.00003169) * t) * t) * t) * t)
408        * ARCSEC_TO_RAD
409        + positive_fmod(1236.0 * t, 1.0) * TAU;
410
411    fa[4] = (450160.398036
412        + (-482890.5431 + (7.4722 + (0.007702 + (-0.00005939) * t) * t) * t) * t)
413        * ARCSEC_TO_RAD
414        + positive_fmod(-5.0 * t, 1.0) * TAU;
415
416    fa[5] = 4.402608842 + 2608.7903141574 * t;
417    fa[6] = 3.176146697 + 1021.3285546211 * t;
418    fa[7] = 1.753470314 + 628.3075849991 * t;
419    fa[8] = 6.203480913 + 334.0612426700 * t;
420    fa[9] = 0.599546497 + 52.9690962641 * t;
421    fa[10] = 0.874016757 + 21.3299104960 * t;
422    fa[11] = 5.481293872 + 7.4781598567 * t;
423    fa[12] = 5.311886287 + 3.8133035638 * t;
424    fa[13] = (0.024381750 + 0.00000538691 * t) * t;
425
426    for angle in fa.iter_mut() {
427        *angle = positive_fmod(*angle, TAU);
428    }
429
430    // Single t-dependent term
431    let mut a = 0.0_f64;
432    for i in 0..14 {
433        a += (KE1[i] as f64) * fa[i];
434    }
435    let mut c_terms = SE1_0 * a.sin();
436    c_terms *= t;
437
438    // Constant terms (33 rows)
439    for row in 0..33 {
440        let mut arg = 0.0_f64;
441        for i in 0..14 {
442            arg += (KE0_T[row][i] as f64) * fa[i];
443        }
444        c_terms += SE0_T_0[row] * arg.sin();
445        c_terms += SE0_T_1[row] * arg.cos();
446    }
447
448    c_terms * ARCSEC_TO_RAD
449}
450
451#[cfg(test)]
452mod tests {
453    use super::{
454        skyfield_fundamental_arguments, skyfield_iau2000a_radians, skyfield_mean_obliquity_radians,
455        NutationError,
456    };
457
458    #[test]
459    fn public_nutation_helpers_reject_non_finite_times() {
460        assert_eq!(
461            skyfield_fundamental_arguments(f64::NAN),
462            Err(NutationError::InvalidInput {
463                field: "t",
464                reason: "must be finite"
465            })
466        );
467        assert_eq!(
468            skyfield_iau2000a_radians(f64::INFINITY),
469            Err(NutationError::InvalidInput {
470                field: "jd_tt",
471                reason: "must be finite"
472            })
473        );
474        assert_eq!(
475            skyfield_mean_obliquity_radians(f64::NEG_INFINITY),
476            Err(NutationError::InvalidInput {
477                field: "jd_tdb",
478                reason: "must be finite"
479            })
480        );
481    }
482}