Skip to main content

sidereon_core/astro/frames/
transforms.rs

1//! Coordinate transformation pipeline.
2//!
3//! TEME -> GCRS replicates Skyfield's exact computation path including AU/day
4//! unit scaling for bit-exact (0 ULP) parity.
5//!
6//! Also provides GCRS -> ITRS, ITRS -> geodetic (WGS84), and topocentric
7//! (az/el/range) transformations.
8//!
9//! The pure compute functions live here in the core crate; the Rustler
10//! decode/encode shims that used to wrap them stay in `orbis_nif` as glue, so
11//! no domain formula lives in the NIF layer. The numerics, summation order,
12//! transcendental sequence, and the single sanctioned `mul_add` site
13//! (`mat3_vec3_mul_fma`) are preserved exactly so the existing Skyfield 0-ULP
14//! parity holds.
15
16use crate::astro::frames::nutation::{
17    build_skyfield_nutation_matrix_unchecked,
18    skyfield_equation_of_the_equinoxes_complimentary_terms_unchecked,
19    skyfield_iau2000a_radians_unchecked, skyfield_mean_obliquity_radians_unchecked,
20};
21use crate::astro::frames::precession::{
22    build_icrs_to_j2000, compute_skyfield_precession_matrix_unchecked,
23};
24use crate::astro::math::mat3::{inline_mxmxm, inline_rxr, inline_tr, Mat3};
25use crate::astro::time::{civil, scales::TimeScales};
26use crate::astro::{
27    constants::astro::AU_KM,
28    constants::earth::{WGS84_A_KM, WGS84_E2, WGS84_F},
29    constants::geometry::AZIMUTH_ZENITH_EPS,
30    constants::models::proj::{
31        HALF_PI as PROJ_HALF_PI, RAD_TO_DEG as PROJ_RAD_TO_DEG, WGS84_A_M as PROJ_WGS84_A_M,
32        WGS84_B_M as PROJ_WGS84_B_M, WGS84_E2S as PROJ_WGS84_E2S, WGS84_ES as PROJ_WGS84_ES,
33    },
34    constants::time::{DAYS_PER_JULIAN_CENTURY, J2000_JD, SECONDS_PER_DAY},
35};
36
37const TAU: f64 = std::f64::consts::TAU;
38const ARCSECONDS_TO_RADIANS: f64 = 4.848_136_811_095_36e-6;
39
40/// Error returned when public frame-transform inputs are outside the valid domain.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
42pub enum FrameTransformError {
43    /// A transform input was non-finite or otherwise invalid.
44    #[error("invalid frame transform {field}: {reason}")]
45    InvalidInput {
46        field: &'static str,
47        reason: &'static str,
48    },
49}
50
51fn invalid_input(field: &'static str, reason: &'static str) -> FrameTransformError {
52    FrameTransformError::InvalidInput { field, reason }
53}
54
55fn validate_finite(field: &'static str, value: f64) -> Result<(), FrameTransformError> {
56    if value.is_finite() {
57        Ok(())
58    } else {
59        Err(invalid_input(field, "must be finite"))
60    }
61}
62
63fn validate_vec3(field: &'static str, values: &[f64; 3]) -> Result<(), FrameTransformError> {
64    for value in values {
65        if !value.is_finite() {
66            return Err(invalid_input(field, "components must be finite"));
67        }
68    }
69    Ok(())
70}
71
72fn validate_tuple3(field: &'static str, values: Vec3) -> Result<Vec3, FrameTransformError> {
73    if values.0.is_finite() && values.1.is_finite() && values.2.is_finite() {
74        Ok(values)
75    } else {
76        Err(invalid_input(field, "components must be finite"))
77    }
78}
79
80fn validate_array3(field: &'static str, values: [f64; 3]) -> Result<[f64; 3], FrameTransformError> {
81    validate_vec3(field, &values)?;
82    Ok(values)
83}
84
85fn validate_mat3(field: &'static str, values: Mat3) -> Result<Mat3, FrameTransformError> {
86    for row in &values {
87        validate_vec3(field, row)?;
88    }
89    Ok(values)
90}
91
92fn validate_time_scales(ts: &TimeScales) -> Result<(), FrameTransformError> {
93    validate_finite("jd_whole", ts.jd_whole)?;
94    validate_finite("ut1_fraction", ts.ut1_fraction)?;
95    validate_finite("tt_fraction", ts.tt_fraction)?;
96    validate_finite("tdb_fraction", ts.tdb_fraction)?;
97    validate_finite("jd_ut1", ts.jd_ut1)?;
98    validate_finite("jd_tt", ts.jd_tt)?;
99    validate_finite("jd_tdb", ts.jd_tdb)
100}
101
102fn validate_polar_motion(pole: PolarMotion) -> Result<(), FrameTransformError> {
103    validate_finite("xp_rad", pole.xp_rad)?;
104    validate_finite("yp_rad", pole.yp_rad)
105}
106
107fn validate_geodetic_degrees_km(
108    latitude_deg: f64,
109    longitude_deg: f64,
110    altitude_km: f64,
111) -> Result<(), FrameTransformError> {
112    validate_finite("latitude_deg", latitude_deg)?;
113    if !(-90.0..=90.0).contains(&latitude_deg) {
114        return Err(invalid_input("latitude_deg", "must be in [-90, 90]"));
115    }
116    validate_finite("longitude_deg", longitude_deg)?;
117    if !(-180.0..=180.0).contains(&longitude_deg) {
118        return Err(invalid_input("longitude_deg", "must be in [-180, 180]"));
119    }
120    validate_finite("altitude_km", altitude_km)
121}
122
123/// A bare Cartesian triple (km or km/s depending on context).
124///
125/// This is the internal compute-layer return shape. Typed input structs
126/// ([`TemeStateKm`], [`GeodeticStationKm`]) bundle the public entry points'
127/// arguments, but the numerics below operate on raw triples to preserve the
128/// original operation order exactly.
129pub type Vec3 = (f64, f64, f64);
130
131/// TEME-frame position and velocity (km, km/s): the input to
132/// [`teme_to_gcrs_compute`].
133pub struct TemeStateKm {
134    pub position_km: [f64; 3],
135    pub velocity_km_s: [f64; 3],
136}
137
138/// Geodetic ground-station position (WGS84) for topocentric look angles.
139pub struct GeodeticStationKm {
140    pub latitude_deg: f64,
141    pub longitude_deg: f64,
142    pub altitude_km: f64,
143}
144
145/// Polar-motion coordinates of the Celestial Intermediate Pole.
146///
147/// `xp_rad` and `yp_rad` are radians. The embedded EOP table currently carries
148/// UT1-UTC only, so the historical transforms use [`PolarMotion::ZERO`] by
149/// default. Precision callers with pole coordinates should use the explicit
150/// `*_with_polar_motion` entry points below.
151#[derive(Debug, Clone, Copy, PartialEq)]
152pub struct PolarMotion {
153    pub xp_rad: f64,
154    pub yp_rad: f64,
155}
156
157impl PolarMotion {
158    /// No polar-motion rotation; preserves the historical transform exactly.
159    pub const ZERO: Self = Self {
160        xp_rad: 0.0,
161        yp_rad: 0.0,
162    };
163
164    /// Construct polar-motion coordinates from radians.
165    pub fn from_radians(xp_rad: f64, yp_rad: f64) -> Result<Self, FrameTransformError> {
166        validate_finite("xp_rad", xp_rad)?;
167        validate_finite("yp_rad", yp_rad)?;
168        Ok(Self { xp_rad, yp_rad })
169    }
170
171    /// Construct polar-motion coordinates from arcseconds.
172    pub fn from_arcseconds(xp_arcsec: f64, yp_arcsec: f64) -> Result<Self, FrameTransformError> {
173        validate_finite("xp_arcsec", xp_arcsec)?;
174        validate_finite("yp_arcsec", yp_arcsec)?;
175        Self::from_radians(
176            xp_arcsec * ARCSECONDS_TO_RADIANS,
177            yp_arcsec * ARCSECONDS_TO_RADIANS,
178        )
179    }
180
181    fn is_zero(self) -> bool {
182        self.xp_rad == 0.0 && self.yp_rad == 0.0
183    }
184}
185
186impl Default for PolarMotion {
187    fn default() -> Self {
188        Self::ZERO
189    }
190}
191
192/// Final matrix-vector multiply using explicit FMA.
193/// This matches numpy's vectorized behavior.
194fn mat3_vec3_mul_fma(r: &Mat3, p: &[f64; 3]) -> [f64; 3] {
195    let mut result = [0.0_f64; 3];
196    for i in 0..3 {
197        let sum = r[i][0] * p[0];
198        let sum = libm::fma(r[i][1], p[1], sum);
199        let sum = libm::fma(r[i][2], p[2], sum);
200        result[i] = sum;
201    }
202    result
203}
204
205fn build_rot_z(angle: f64) -> Mat3 {
206    let c = libm::cos(angle);
207    let s = libm::sin(angle);
208    [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
209}
210
211/// IERS polar-motion matrix, omitting the tiny TIO locator term `s'`.
212///
213/// The matrix maps TIRS pseudo-Earth-fixed coordinates to ITRS:
214/// `W = R_y(xp) * R_x(yp)`, whose small-angle form is
215/// `[[1, 0, xp], [0, 1, -yp], [-xp, yp, 1]]`.
216pub fn polar_motion_matrix(pole: PolarMotion) -> Result<Mat3, FrameTransformError> {
217    validate_polar_motion(pole)?;
218    validate_mat3("polar_motion_matrix", polar_motion_matrix_unchecked(pole))
219}
220
221fn polar_motion_matrix_unchecked(pole: PolarMotion) -> Mat3 {
222    if pole.is_zero() {
223        return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
224    }
225
226    let cx = libm::cos(pole.xp_rad);
227    let sx = libm::sin(pole.xp_rad);
228    let cy = libm::cos(pole.yp_rad);
229    let sy = libm::sin(pole.yp_rad);
230
231    [
232        [cx, sx * sy, sx * cy],
233        [0.0, cy, -sy],
234        [-sx, cx * sy, cx * cy],
235    ]
236}
237
238fn apply_polar_motion_to_itrs_matrix(mat: Mat3, pole: PolarMotion) -> Mat3 {
239    if pole.is_zero() {
240        mat
241    } else {
242        inline_rxr(&polar_motion_matrix_unchecked(pole), &mat)
243    }
244}
245
246fn earth_rotation_angle(jd_whole: f64, ut1_fraction: f64) -> f64 {
247    let days_since_j2000 = jd_whole - J2000_JD + ut1_fraction;
248    // Force separate rounded operations to match Skyfield/Python's path.
249    let spins_since_j2000: f64 = {
250        let v = 0.00273781191135448 * days_since_j2000;
251        // Use black_box-like pattern to prevent optimization
252        let v_stored: f64 = v;
253        v_stored
254    };
255    let th = 0.7790572732640 + spins_since_j2000;
256    let mut result = (th % 1.0 + jd_whole % 1.0 + ut1_fraction) % 1.0;
257    if result < 0.0 {
258        result += 1.0;
259    }
260    result
261}
262
263fn compute_theta_gmst1982(jd_whole: f64, ut1_fraction: f64) -> f64 {
264    let t = (jd_whole - J2000_JD + ut1_fraction) / DAYS_PER_JULIAN_CENTURY;
265    let g = 67310.54841 + (8640184.812866 + (0.093104 + (-6.2e-6) * t) * t) * t;
266    let mut theta = ((jd_whole % 1.0) + ut1_fraction + (g / SECONDS_PER_DAY) % 1.0) % 1.0 * TAU;
267    if theta < 0.0 {
268        theta += TAU;
269    }
270    theta
271}
272
273fn sidereal_time_hours(jd_whole: f64, ut1_fraction: f64, tdb_fraction: f64) -> f64 {
274    let theta = earth_rotation_angle(jd_whole, ut1_fraction);
275    let t = (jd_whole - J2000_JD + tdb_fraction) / DAYS_PER_JULIAN_CENTURY;
276    let st = 0.014506
277        + ((((-0.0000000368 * t - 0.000029956) * t - 0.00000044) * t + 1.3915817) * t
278            + 4612.156534)
279            * t;
280    let mut result = (st / 54000.0 + theta * 24.0) % 24.0;
281    if result < 0.0 {
282        result += 24.0;
283    }
284    result
285}
286
287fn gast_radians(ts: &TimeScales, dpsi: f64) -> f64 {
288    let gmst_hours = sidereal_time_hours(ts.jd_whole, ts.ut1_fraction, ts.tdb_fraction);
289    let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
290    let c_terms = skyfield_equation_of_the_equinoxes_complimentary_terms_unchecked(ts.jd_tt);
291    let eq_eq = dpsi * libm::cos(mean_ob) + c_terms;
292    let mut gast_hours = (gmst_hours + eq_eq / TAU * 24.0) % 24.0;
293    if gast_hours < 0.0 {
294        gast_hours += 24.0;
295    }
296    gast_hours / 24.0 * TAU
297}
298
299/// Greenwich Mean Sidereal Time for an instant, radians in `[0, 2pi)`.
300///
301/// The IAU-1982 GMST used internally by the frame pipeline, surfaced as a public
302/// entry point. This is a thin wrapper over the existing private sidereal-time
303/// computation: it adds no new numerics, so the value is bit-identical to the
304/// quantity the transforms consume.
305pub fn greenwich_mean_sidereal_time_radians(ts: &TimeScales) -> Result<f64, FrameTransformError> {
306    validate_time_scales(ts)?;
307    let radians = greenwich_mean_sidereal_time_radians_unchecked(ts);
308    validate_finite("gmst_radians", radians)?;
309    Ok(radians)
310}
311
312fn greenwich_mean_sidereal_time_radians_unchecked(ts: &TimeScales) -> f64 {
313    let hours = sidereal_time_hours(ts.jd_whole, ts.ut1_fraction, ts.tdb_fraction);
314    hours / 24.0 * TAU
315}
316
317/// IAU-1982 GMST in radians from continuous seconds past J2000.
318///
319/// The input epoch is treated as UT1 for this thin drag helper. It reuses the
320/// same `compute_theta_gmst1982` polynomial as
321/// [`greenwich_mean_sidereal_time_radians`] and adds no new sidereal-time math.
322pub fn greenwich_mean_sidereal_time_radians_from_j2000_seconds(
323    sec: f64,
324) -> Result<f64, FrameTransformError> {
325    validate_finite("sec", sec)?;
326    let (jd_whole, ut1_fraction) = civil::split_julian_date_add_seconds(J2000_JD, 0.0, sec);
327    let mut radians = compute_theta_gmst1982(jd_whole, ut1_fraction) % TAU;
328    if radians < 0.0 {
329        radians += TAU;
330    }
331    validate_finite("gmst_radians", radians)?;
332    Ok(radians)
333}
334
335/// Greenwich Apparent Sidereal Time for an instant, radians in `[0, 2pi)`.
336///
337/// GMST plus the equation of the equinoxes (nutation in longitude projected on
338/// the true equator, with the IAU 2000 complementary terms). A thin wrapper over
339/// the existing private GAST computation; bit-identical to the value the
340/// GCRS<->ITRS transforms apply.
341pub fn greenwich_apparent_sidereal_time_radians(
342    ts: &TimeScales,
343) -> Result<f64, FrameTransformError> {
344    validate_time_scales(ts)?;
345    let radians = greenwich_apparent_sidereal_time_radians_unchecked(ts);
346    validate_finite("gast_radians", radians)?;
347    Ok(radians)
348}
349
350fn greenwich_apparent_sidereal_time_radians_unchecked(ts: &TimeScales) -> f64 {
351    let (dpsi, _deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
352    gast_radians(ts, dpsi)
353}
354
355/// Build the TEME->GCRS rotation matrix T from time scales.
356fn build_teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
357    let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
358    let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
359    let true_ob = mean_ob + deps;
360
361    let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
362    let p = compute_skyfield_precession_matrix_unchecked(ts.jd_tdb);
363    let b = build_icrs_to_j2000();
364
365    // Skyfield uses Kahan-compensated triple product (matching numpy einsum).
366    // Direct mode uses standard sequential multiply (more precise).
367    let m = if skyfield_compat {
368        inline_mxmxm(&n, &p, &b)
369    } else {
370        let np = inline_rxr(&n, &p);
371        inline_rxr(&np, &b)
372    };
373
374    let gast = gast_radians(ts, dpsi);
375    let theta = compute_theta_gmst1982(ts.jd_whole, ts.ut1_fraction);
376    let angle = theta - gast;
377
378    let r = build_rot_z(angle);
379    let g = inline_rxr(&r, &m);
380    inline_tr(&g)
381}
382
383/// Build the TEME->GCRS rotation matrix T from time scales.
384pub(crate) fn teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
385    build_teme_to_gcrs_matrix(ts, skyfield_compat)
386}
387
388/// Standard (non-FMA) matrix-vector multiply.
389pub fn mat3_vec3_mul(r: &Mat3, p: &[f64; 3]) -> Result<[f64; 3], FrameTransformError> {
390    validate_mat3("matrix", *r)?;
391    validate_vec3("vector", p)?;
392    validate_array3("matrix_vector_product", mat3_vec3_mul_unchecked(r, p))
393}
394
395pub(crate) fn mat3_vec3_mul_unchecked(r: &Mat3, p: &[f64; 3]) -> [f64; 3] {
396    let mut result = [0.0_f64; 3];
397    for i in 0..3 {
398        let mut sum = 0.0;
399        for j in 0..3 {
400            sum += r[i][j] * p[j];
401        }
402        result[i] = sum;
403    }
404    result
405}
406
407/// Core TEME->GCRS transform. Returns ((px,py,pz), (vx,vy,vz)).
408pub fn teme_to_gcrs_compute(
409    state: &TemeStateKm,
410    ts: &TimeScales,
411    skyfield_compat: bool,
412) -> Result<(Vec3, Vec3), FrameTransformError> {
413    validate_time_scales(ts)?;
414    validate_vec3("position_km", &state.position_km)?;
415    validate_vec3("velocity_km_s", &state.velocity_km_s)?;
416    let (position, velocity) = teme_to_gcrs_compute_unchecked(state, ts, skyfield_compat);
417    Ok((
418        validate_tuple3("gcrs_position_km", position)?,
419        validate_tuple3("gcrs_velocity_km_s", velocity)?,
420    ))
421}
422
423fn teme_to_gcrs_compute_unchecked(
424    state: &TemeStateKm,
425    ts: &TimeScales,
426    skyfield_compat: bool,
427) -> (Vec3, Vec3) {
428    let [x, y, z] = state.position_km;
429    let [vx, vy, vz] = state.velocity_km_s;
430    let t = build_teme_to_gcrs_matrix(ts, skyfield_compat);
431
432    if skyfield_compat {
433        // AU/day scaling + FMA multiply matching Skyfield's _at() path.
434        let r_au = [x / AU_KM, y / AU_KM, z / AU_KM];
435        let r_gcrs_au = mat3_vec3_mul_fma(&t, &r_au);
436        let r_gcrs = (
437            r_gcrs_au[0] * AU_KM,
438            r_gcrs_au[1] * AU_KM,
439            r_gcrs_au[2] * AU_KM,
440        );
441
442        let v_au_d = [
443            vx / AU_KM * SECONDS_PER_DAY,
444            vy / AU_KM * SECONDS_PER_DAY,
445            vz / AU_KM * SECONDS_PER_DAY,
446        ];
447        let v_gcrs_au_d = mat3_vec3_mul_fma(&t, &v_au_d);
448        let v_gcrs = (
449            v_gcrs_au_d[0] * AU_KM / SECONDS_PER_DAY,
450            v_gcrs_au_d[1] * AU_KM / SECONDS_PER_DAY,
451            v_gcrs_au_d[2] * AU_KM / SECONDS_PER_DAY,
452        );
453        (r_gcrs, v_gcrs)
454    } else {
455        // Direct km/s multiply -- no AU round-trip, no FMA.
456        let r_teme = [x, y, z];
457        let r_g = mat3_vec3_mul_unchecked(&t, &r_teme);
458        let v_teme = [vx, vy, vz];
459        let v_g = mat3_vec3_mul_unchecked(&t, &v_teme);
460        ((r_g[0], r_g[1], r_g[2]), (v_g[0], v_g[1], v_g[2]))
461    }
462}
463
464/// Core GCRS->TEME transform. Returns ((px,py,pz), (vx,vy,vz)).
465///
466/// This is the rotational inverse of [`teme_to_gcrs_compute`]: it uses the
467/// transpose of the same orthogonal matrix and adds no new precession or
468/// nutation math.
469pub fn gcrs_to_teme_compute(
470    state: &TemeStateKm,
471    ts: &TimeScales,
472    skyfield_compat: bool,
473) -> Result<(Vec3, Vec3), FrameTransformError> {
474    validate_time_scales(ts)?;
475    validate_vec3("position_km", &state.position_km)?;
476    validate_vec3("velocity_km_s", &state.velocity_km_s)?;
477    let (position, velocity) = gcrs_to_teme_compute_unchecked(state, ts, skyfield_compat);
478    Ok((
479        validate_tuple3("teme_position_km", position)?,
480        validate_tuple3("teme_velocity_km_s", velocity)?,
481    ))
482}
483
484fn gcrs_to_teme_compute_unchecked(
485    state: &TemeStateKm,
486    ts: &TimeScales,
487    skyfield_compat: bool,
488) -> (Vec3, Vec3) {
489    let [x, y, z] = state.position_km;
490    let [vx, vy, vz] = state.velocity_km_s;
491    let t = inline_tr(&build_teme_to_gcrs_matrix(ts, skyfield_compat));
492
493    if skyfield_compat {
494        let r_au = [x / AU_KM, y / AU_KM, z / AU_KM];
495        let r_teme_au = mat3_vec3_mul_fma(&t, &r_au);
496        let r_teme = (
497            r_teme_au[0] * AU_KM,
498            r_teme_au[1] * AU_KM,
499            r_teme_au[2] * AU_KM,
500        );
501
502        let v_au_d = [
503            vx / AU_KM * SECONDS_PER_DAY,
504            vy / AU_KM * SECONDS_PER_DAY,
505            vz / AU_KM * SECONDS_PER_DAY,
506        ];
507        let v_teme_au_d = mat3_vec3_mul_fma(&t, &v_au_d);
508        let v_teme = (
509            v_teme_au_d[0] * AU_KM / SECONDS_PER_DAY,
510            v_teme_au_d[1] * AU_KM / SECONDS_PER_DAY,
511            v_teme_au_d[2] * AU_KM / SECONDS_PER_DAY,
512        );
513        (r_teme, v_teme)
514    } else {
515        let r_gcrs = [x, y, z];
516        let r_t = mat3_vec3_mul_unchecked(&t, &r_gcrs);
517        let v_gcrs = [vx, vy, vz];
518        let v_t = mat3_vec3_mul_unchecked(&t, &v_gcrs);
519        ((r_t[0], r_t[1], r_t[2]), (v_t[0], v_t[1], v_t[2]))
520    }
521}
522
523// ---------------------------------------------------------------------------
524// GCRS -> ITRS (Earth-fixed / ECEF)
525// ---------------------------------------------------------------------------
526
527/// GCRS to true equator and equinox of date rotation.
528///
529/// This is the Earth-rotation-free `N * P * B` product already used inside the
530/// GCRS to ITRS construction: nutation, precession, and frame bias.
531pub fn gcrs_to_true_of_date_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
532    validate_time_scales(ts)?;
533    let (matrix, _dpsi) = gcrs_to_true_of_date_matrix_parts_unchecked(ts);
534    validate_mat3("gcrs_to_true_of_date_matrix", matrix)
535}
536
537fn gcrs_to_true_of_date_matrix_parts_unchecked(ts: &TimeScales) -> (Mat3, f64) {
538    let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
539    let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
540    let true_ob = mean_ob + deps;
541
542    let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
543    let p = compute_skyfield_precession_matrix_unchecked(ts.jd_tdb);
544    let b = build_icrs_to_j2000();
545
546    (inline_mxmxm(&n, &p, &b), dpsi)
547}
548
549/// Build the historical GCRS->ITRS rotation matrix for a given time.
550///
551/// This combines precession, nutation, and Earth rotation with zero polar
552/// motion, preserving the original bit-exact path. Use
553/// [`gcrs_to_itrs_matrix_with_polar_motion`] when `xp`/`yp` pole coordinates are
554/// available.
555pub fn gcrs_to_itrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
556    validate_time_scales(ts)?;
557    validate_mat3("gcrs_to_itrs_matrix", gcrs_to_itrs_matrix_unchecked(ts))
558}
559
560fn gcrs_to_itrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
561    let (m, dpsi) = gcrs_to_true_of_date_matrix_parts_unchecked(ts);
562    let gast = gast_radians(ts, dpsi);
563
564    // GAST rotation takes us from true-equator-equinox to ITRS
565    let r_gast = build_rot_z(-gast);
566
567    // GCRS->ITRS = R_z(-GAST) * (N * P * B)
568    inline_rxr(&r_gast, &m)
569}
570
571/// Build the GCRS->ITRS rotation matrix with explicit polar motion.
572///
573/// The embedded Earth-orientation table supplies UT1-UTC but not `xp`/`yp`, so
574/// callers that do not have pole coordinates should pass [`PolarMotion::ZERO`]
575/// or use [`gcrs_to_itrs_matrix`].
576pub fn gcrs_to_itrs_matrix_with_polar_motion(
577    ts: &TimeScales,
578    pole: PolarMotion,
579) -> Result<Mat3, FrameTransformError> {
580    validate_time_scales(ts)?;
581    validate_polar_motion(pole)?;
582    validate_mat3(
583        "gcrs_to_itrs_matrix",
584        gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole),
585    )
586}
587
588fn gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts: &TimeScales, pole: PolarMotion) -> Mat3 {
589    apply_polar_motion_to_itrs_matrix(gcrs_to_itrs_matrix_unchecked(ts), pole)
590}
591
592/// Rotation from the **mean equator and equinox of date** to ITRS, i.e.
593/// `R_z(-GAST) * N` (nutation + Earth rotation, *without* precession or frame
594/// bias).
595///
596/// This is [`gcrs_to_itrs_matrix`] with the precession (`P`) and frame-bias
597/// (`B`) factors removed. Use it for vectors that are already referred to the
598/// mean equator/equinox of date (for example the low-precision analytic Sun/Moon
599/// series in [`crate::astro::bodies::sun_moon`], whose mean longitude and obliquity are
600/// of-date), so precession is not applied a second time. It mirrors the
601/// `eci2ecef` (GMST/GAST + nutation) rotation those series are designed to be
602/// consumed with, but uses the crate's IAU 2000A nutation and GAST.
603pub fn mean_of_date_to_itrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
604    validate_time_scales(ts)?;
605    validate_mat3(
606        "mean_of_date_to_itrs_matrix",
607        mean_of_date_to_itrs_matrix_unchecked(ts),
608    )
609}
610
611fn mean_of_date_to_itrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
612    let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
613    let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
614    let true_ob = mean_ob + deps;
615
616    let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
617    let gast = gast_radians(ts, dpsi);
618    let r_gast = build_rot_z(-gast);
619
620    // mean-of-date -> ITRS = R_z(-GAST) * N
621    inline_rxr(&r_gast, &n)
622}
623
624/// Mean-of-date to ITRS rotation with explicit polar motion.
625pub fn mean_of_date_to_itrs_matrix_with_polar_motion(
626    ts: &TimeScales,
627    pole: PolarMotion,
628) -> Result<Mat3, FrameTransformError> {
629    validate_time_scales(ts)?;
630    validate_polar_motion(pole)?;
631    validate_mat3(
632        "mean_of_date_to_itrs_matrix",
633        mean_of_date_to_itrs_matrix_with_polar_motion_unchecked(ts, pole),
634    )
635}
636
637fn mean_of_date_to_itrs_matrix_with_polar_motion_unchecked(
638    ts: &TimeScales,
639    pole: PolarMotion,
640) -> Mat3 {
641    apply_polar_motion_to_itrs_matrix(mean_of_date_to_itrs_matrix_unchecked(ts), pole)
642}
643
644/// Core GCRS->ITRS transform. Returns (x, y, z) in km.
645pub fn gcrs_to_itrs_compute(
646    x: f64,
647    y: f64,
648    z: f64,
649    ts: &TimeScales,
650    skyfield_compat: bool,
651) -> Result<(f64, f64, f64), FrameTransformError> {
652    validate_vec3("gcrs_position_km", &[x, y, z])?;
653    validate_time_scales(ts)?;
654    validate_tuple3(
655        "itrs_position_km",
656        gcrs_to_itrs_compute_unchecked(x, y, z, ts, skyfield_compat),
657    )
658}
659
660fn gcrs_to_itrs_compute_unchecked(
661    x: f64,
662    y: f64,
663    z: f64,
664    ts: &TimeScales,
665    skyfield_compat: bool,
666) -> (f64, f64, f64) {
667    let mat = gcrs_to_itrs_matrix_unchecked(ts);
668
669    if skyfield_compat {
670        // Skyfield: mxv(R, pos_au) in AU, then convert to km.
671        // For ITRS, scalar (non-FMA) multiply matches einsum's rounding.
672        // (Unlike TEME->GCRS where FMA is needed -- the difference is due to
673        // the specific matrix/vector values and how rounding interacts.)
674        let pos_au = [x / AU_KM, y / AU_KM, z / AU_KM];
675        let r = mat3_vec3_mul_unchecked(&mat, &pos_au);
676        (r[0] * AU_KM, r[1] * AU_KM, r[2] * AU_KM)
677    } else {
678        let pos = [x, y, z];
679        let r = mat3_vec3_mul_unchecked(&mat, &pos);
680        (r[0], r[1], r[2])
681    }
682}
683
684/// Core GCRS->ITRS transform with explicit polar motion.
685pub fn gcrs_to_itrs_compute_with_polar_motion(
686    x: f64,
687    y: f64,
688    z: f64,
689    ts: &TimeScales,
690    skyfield_compat: bool,
691    pole: PolarMotion,
692) -> Result<(f64, f64, f64), FrameTransformError> {
693    validate_vec3("gcrs_position_km", &[x, y, z])?;
694    validate_time_scales(ts)?;
695    validate_polar_motion(pole)?;
696    validate_tuple3(
697        "itrs_position_km",
698        gcrs_to_itrs_compute_with_polar_motion_unchecked(x, y, z, ts, skyfield_compat, pole),
699    )
700}
701
702fn gcrs_to_itrs_compute_with_polar_motion_unchecked(
703    x: f64,
704    y: f64,
705    z: f64,
706    ts: &TimeScales,
707    skyfield_compat: bool,
708    pole: PolarMotion,
709) -> (f64, f64, f64) {
710    let mat = gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole);
711
712    if skyfield_compat {
713        let pos_au = [x / AU_KM, y / AU_KM, z / AU_KM];
714        let r = mat3_vec3_mul_unchecked(&mat, &pos_au);
715        (r[0] * AU_KM, r[1] * AU_KM, r[2] * AU_KM)
716    } else {
717        let pos = [x, y, z];
718        let r = mat3_vec3_mul_unchecked(&mat, &pos);
719        (r[0], r[1], r[2])
720    }
721}
722
723// ---------------------------------------------------------------------------
724// ITRS -> GCRS (Earth-fixed / ECEF back to inertial)
725// ---------------------------------------------------------------------------
726
727/// Build the ITRS->GCRS rotation matrix for a given time.
728///
729/// This is the transpose of [`gcrs_to_itrs_matrix`]: the same precession,
730/// nutation, frame-bias, and Earth-rotation pipeline, taken the other way.
731pub fn itrs_to_gcrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
732    validate_time_scales(ts)?;
733    validate_mat3("itrs_to_gcrs_matrix", itrs_to_gcrs_matrix_unchecked(ts))
734}
735
736fn itrs_to_gcrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
737    inline_tr(&gcrs_to_itrs_matrix_unchecked(ts))
738}
739
740/// Build the ITRS->GCRS rotation matrix with explicit polar motion.
741pub fn itrs_to_gcrs_matrix_with_polar_motion(
742    ts: &TimeScales,
743    pole: PolarMotion,
744) -> Result<Mat3, FrameTransformError> {
745    validate_time_scales(ts)?;
746    validate_polar_motion(pole)?;
747    validate_mat3(
748        "itrs_to_gcrs_matrix",
749        itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts, pole),
750    )
751}
752
753fn itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts: &TimeScales, pole: PolarMotion) -> Mat3 {
754    inline_tr(&gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole))
755}
756
757/// Core ITRS->GCRS transform. Returns (x, y, z) in km.
758///
759/// Uses the plain (non-FMA, no AU round-trip) km path. The Skyfield AU-scaled
760/// `mul_add` path is reserved for the GCRS->ITRS / TEME->GCRS directions that
761/// carry the 0-ULP parity contract; this reverse direction is an ordinary
762/// matrix-vector product.
763pub fn itrs_to_gcrs_compute(
764    x: f64,
765    y: f64,
766    z: f64,
767    ts: &TimeScales,
768) -> Result<(f64, f64, f64), FrameTransformError> {
769    validate_vec3("itrs_position_km", &[x, y, z])?;
770    validate_time_scales(ts)?;
771    validate_tuple3(
772        "gcrs_position_km",
773        itrs_to_gcrs_compute_unchecked(x, y, z, ts),
774    )
775}
776
777fn itrs_to_gcrs_compute_unchecked(x: f64, y: f64, z: f64, ts: &TimeScales) -> (f64, f64, f64) {
778    let mat = itrs_to_gcrs_matrix_unchecked(ts);
779    let r = mat3_vec3_mul_unchecked(&mat, &[x, y, z]);
780    (r[0], r[1], r[2])
781}
782
783/// Core ITRS->GCRS transform with explicit polar motion.
784pub fn itrs_to_gcrs_compute_with_polar_motion(
785    x: f64,
786    y: f64,
787    z: f64,
788    ts: &TimeScales,
789    pole: PolarMotion,
790) -> Result<(f64, f64, f64), FrameTransformError> {
791    validate_vec3("itrs_position_km", &[x, y, z])?;
792    validate_time_scales(ts)?;
793    validate_polar_motion(pole)?;
794    validate_tuple3(
795        "gcrs_position_km",
796        itrs_to_gcrs_compute_with_polar_motion_unchecked(x, y, z, ts, pole),
797    )
798}
799
800fn itrs_to_gcrs_compute_with_polar_motion_unchecked(
801    x: f64,
802    y: f64,
803    z: f64,
804    ts: &TimeScales,
805    pole: PolarMotion,
806) -> (f64, f64, f64) {
807    let mat = itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts, pole);
808    let r = mat3_vec3_mul_unchecked(&mat, &[x, y, z]);
809    (r[0], r[1], r[2])
810}
811
812// ---------------------------------------------------------------------------
813// ITRS -> Geodetic (WGS84 lat/lon/alt)
814// ---------------------------------------------------------------------------
815
816/// Convert ECEF/ITRS (km) to geodetic coordinates.
817/// Returns (latitude_deg, longitude_deg, altitude_km).
818///
819/// Replicates Skyfield's exact algorithm (wgs84.subpoint / _compute_latitude)
820/// which works in AU with exactly 3 iterations.
821pub fn itrs_to_geodetic_compute(
822    x: f64,
823    y: f64,
824    z: f64,
825) -> Result<(f64, f64, f64), FrameTransformError> {
826    validate_vec3("itrs_position_km", &[x, y, z])?;
827    validate_tuple3("geodetic", itrs_to_geodetic_compute_unchecked(x, y, z))
828}
829
830fn itrs_to_geodetic_compute_unchecked(x: f64, y: f64, z: f64) -> (f64, f64, f64) {
831    // Convert to AU to match Skyfield's computation path.
832    let x_au = x / AU_KM;
833    let y_au = y / AU_KM;
834    let z_au = z / AU_KM;
835
836    let a_au = WGS84_A_KM / AU_KM; // Earth equatorial radius in AU
837    let r_xy = (x_au * x_au + y_au * y_au).sqrt();
838
839    // Longitude: match Skyfield's exact normalization:
840    // (arctan2(y, x) - pi) % tau - pi
841    // Python's % always returns positive; Rust's can be negative.
842    let lon_raw = libm::atan2(y_au, x_au);
843    let pi = std::f64::consts::PI;
844    let mut lon_shifted = (lon_raw - pi) % TAU;
845    if lon_shifted < 0.0 {
846        lon_shifted += TAU;
847    }
848    let lon = lon_shifted - pi;
849
850    // Latitude: 3 iterations matching Skyfield exactly
851    let mut lat = libm::atan2(z_au, r_xy);
852    let mut a_c = 0.0_f64;
853    let mut hyp = 0.0_f64;
854
855    for _ in 0..3 {
856        let sin_lat = libm::sin(lat);
857        let e2_sin_lat = WGS84_E2 * sin_lat;
858        a_c = a_au / (1.0 - e2_sin_lat * sin_lat).sqrt();
859        hyp = z_au + a_c * e2_sin_lat;
860        lat = libm::atan2(hyp, r_xy);
861    }
862
863    // Elevation in AU, then convert to km
864    let height_au = (hyp * hyp + r_xy * r_xy).sqrt() - a_c;
865    let alt = height_au * AU_KM;
866
867    // Skyfield's Angle.degrees uses: radians * 360.0 / tau
868    // This gives different rounding than radians * (180.0 / PI).
869    (lat * 360.0 / TAU, lon * 360.0 / TAU, alt)
870}
871
872fn proj_normal_radius_of_curvature(sinphi: f64) -> f64 {
873    if PROJ_WGS84_ES == 0.0 {
874        return PROJ_WGS84_A_M;
875    }
876    PROJ_WGS84_A_M / (1.0 - (PROJ_WGS84_ES * sinphi) * sinphi).sqrt()
877}
878
879fn proj_geocentric_radius(cosphi: f64, sinphi: f64) -> f64 {
880    libm::hypot(
881        (PROJ_WGS84_A_M * PROJ_WGS84_A_M) * cosphi,
882        (PROJ_WGS84_B_M * PROJ_WGS84_B_M) * sinphi,
883    ) / libm::hypot(PROJ_WGS84_A_M * cosphi, PROJ_WGS84_B_M * sinphi)
884}
885
886/// Convert ECEF meters to `(longitude_degrees, latitude_degrees, altitude_m)`.
887///
888/// This is an additive PROJ parity variant and does not replace
889/// [`itrs_to_geodetic_compute`]. It matches pyproj 3.6.1 / PROJ 9.3.0 for
890/// `EPSG:4978 -> EPSG:4979` with `always_xy=True`; its Tier 1 bit fixture is
891/// `crates/sidereon-core/tests/fixtures/geodetic/geodetic_proj.json`, generated
892/// by `crates/sidereon-core/fixtures-generators/generate_geodetic_proj.py`.
893pub fn geodetic_from_ecef_proj(x: f64, y: f64, z: f64) -> Result<[f64; 3], FrameTransformError> {
894    validate_vec3("ecef_m", &[x, y, z])?;
895    validate_array3("geodetic_proj", geodetic_from_ecef_proj_unchecked(x, y, z))
896}
897
898fn geodetic_from_ecef_proj_unchecked(x: f64, y: f64, z: f64) -> [f64; 3] {
899    let p = libm::hypot(x, y);
900
901    let y_theta = z * PROJ_WGS84_A_M;
902    let x_theta = p * PROJ_WGS84_B_M;
903    let norm = libm::hypot(y_theta, x_theta);
904    let c = if norm == 0.0 { 1.0 } else { x_theta / norm };
905    let s = if norm == 0.0 { 0.0 } else { y_theta / norm };
906
907    let y_phi = z + ((((PROJ_WGS84_E2S * PROJ_WGS84_B_M) * s) * s) * s);
908    let x_phi = p - ((((PROJ_WGS84_ES * PROJ_WGS84_A_M) * c) * c) * c);
909    let norm_phi = libm::hypot(y_phi, x_phi);
910    let mut cosphi = if norm_phi == 0.0 {
911        1.0
912    } else {
913        x_phi / norm_phi
914    };
915    let mut sinphi = if norm_phi == 0.0 {
916        0.0
917    } else {
918        y_phi / norm_phi
919    };
920
921    let phi = if x_phi <= 0.0 {
922        cosphi = 0.0;
923        if z >= 0.0 {
924            sinphi = 1.0;
925            PROJ_HALF_PI
926        } else {
927            sinphi = -1.0;
928            -PROJ_HALF_PI
929        }
930    } else {
931        libm::atan(y_phi / x_phi)
932    };
933
934    let lam = libm::atan2(y, x);
935    let alt = if cosphi < 1e-6 {
936        z.abs() - proj_geocentric_radius(cosphi, sinphi)
937    } else {
938        p / cosphi - proj_normal_radius_of_curvature(sinphi)
939    };
940
941    [lam * PROJ_RAD_TO_DEG, phi * PROJ_RAD_TO_DEG, alt]
942}
943
944// ---------------------------------------------------------------------------
945// Topocentric (az/el/range) from ground station to satellite
946// ---------------------------------------------------------------------------
947
948/// Convert geodetic (lat_deg, lon_deg, alt_km) to ECEF/ITRS (km).
949pub fn geodetic_to_itrs(
950    lat_deg: f64,
951    lon_deg: f64,
952    alt_km: f64,
953) -> Result<(f64, f64, f64), FrameTransformError> {
954    validate_geodetic_degrees_km(lat_deg, lon_deg, alt_km)?;
955    validate_tuple3(
956        "itrs_position_km",
957        geodetic_to_itrs_unchecked(lat_deg, lon_deg, alt_km),
958    )
959}
960
961fn geodetic_to_itrs_unchecked(lat_deg: f64, lon_deg: f64, alt_km: f64) -> (f64, f64, f64) {
962    let lat = lat_deg.to_radians();
963    let lon = lon_deg.to_radians();
964
965    let sin_lat = libm::sin(lat);
966    let cos_lat = libm::cos(lat);
967    let sin_lon = libm::sin(lon);
968    let cos_lon = libm::cos(lon);
969
970    let n = WGS84_A_KM / (1.0 - WGS84_E2 * sin_lat * sin_lat).sqrt();
971
972    let x = (n + alt_km) * cos_lat * cos_lon;
973    let y = (n + alt_km) * cos_lat * sin_lon;
974    let z = (n * (1.0 - WGS84_E2) + alt_km) * sin_lat;
975
976    (x, y, z)
977}
978
979/// Compute station ECEF/ITRS position directly in AU.
980/// Matches Skyfield's Geoid.latlon which works in AU from the start,
981/// avoiding the km->AU_KM division that introduces 1 ULP rounding.
982fn geodetic_to_itrs_au(lat_deg: f64, lon_deg: f64, alt_km: f64) -> [f64; 3] {
983    let lat = lat_deg * TAU / 360.0;
984    let lon = lon_deg * TAU / 360.0;
985
986    let sinphi = libm::sin(lat);
987    let cosphi = libm::cos(lat);
988
989    let radius_au = WGS84_A_KM / AU_KM;
990    let elevation_au = alt_km / AU_KM;
991
992    let omf2 = (1.0 - WGS84_F) * (1.0 - WGS84_F);
993    let c = 1.0 / (cosphi * cosphi + sinphi * sinphi * omf2).sqrt();
994    let s = omf2 * c;
995
996    let radius_xy = radius_au * c;
997    let xy = (radius_xy + elevation_au) * cosphi;
998    let x = xy * libm::cos(lon);
999    let y = xy * libm::sin(lon);
1000
1001    let radius_z = radius_au * s;
1002    let z = (radius_z + elevation_au) * sinphi;
1003
1004    [x, y, z]
1005}
1006
1007/// Build the ECEF->ENU rotation matrix for a given geodetic position.
1008fn ecef_to_enu_matrix(lat_deg: f64, lon_deg: f64) -> Mat3 {
1009    let lat = lat_deg.to_radians();
1010    let lon = lon_deg.to_radians();
1011
1012    let sin_lat = libm::sin(lat);
1013    let cos_lat = libm::cos(lat);
1014    let sin_lon = libm::sin(lon);
1015    let cos_lon = libm::cos(lon);
1016
1017    // ENU rotation matrix:
1018    // E = [-sin(lon),           cos(lon),          0       ]
1019    // N = [-sin(lat)*cos(lon), -sin(lat)*sin(lon), cos(lat)]
1020    // U = [ cos(lat)*cos(lon),  cos(lat)*sin(lon), sin(lat)]
1021    [
1022        [-sin_lon, cos_lon, 0.0],
1023        [-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat],
1024        [cos_lat * cos_lon, cos_lat * sin_lon, sin_lat],
1025    ]
1026}
1027
1028/// Compute topocentric az/el/range from a ground station to a satellite.
1029///
1030/// Returns (azimuth_deg, elevation_deg, range_km).
1031pub fn gcrs_to_topocentric_compute(
1032    sat_gcrs_km: [f64; 3],
1033    station: &GeodeticStationKm,
1034    ts: &TimeScales,
1035    skyfield_compat: bool,
1036) -> Result<(f64, f64, f64), FrameTransformError> {
1037    validate_vec3("sat_gcrs_km", &sat_gcrs_km)?;
1038    validate_geodetic_degrees_km(
1039        station.latitude_deg,
1040        station.longitude_deg,
1041        station.altitude_km,
1042    )?;
1043    validate_time_scales(ts)?;
1044    validate_tuple3(
1045        "topocentric",
1046        gcrs_to_topocentric_compute_unchecked(sat_gcrs_km, station, ts, skyfield_compat),
1047    )
1048}
1049
1050fn gcrs_to_topocentric_compute_unchecked(
1051    sat_gcrs_km: [f64; 3],
1052    station: &GeodeticStationKm,
1053    ts: &TimeScales,
1054    skyfield_compat: bool,
1055) -> (f64, f64, f64) {
1056    let [sat_x, sat_y, sat_z] = sat_gcrs_km;
1057    let station_lat_deg = station.latitude_deg;
1058    let station_lon_deg = station.longitude_deg;
1059    let station_alt_km = station.altitude_km;
1060    if skyfield_compat {
1061        return gcrs_to_topocentric_skyfield(
1062            sat_x,
1063            sat_y,
1064            sat_z,
1065            station_lat_deg,
1066            station_lon_deg,
1067            station_alt_km,
1068            ts,
1069        );
1070    }
1071
1072    // Standard path: GCRS->ITRS->ENU topocentric reduction.
1073    let (sat_itrs_x, sat_itrs_y, sat_itrs_z) =
1074        gcrs_to_itrs_compute_unchecked(sat_x, sat_y, sat_z, ts, false);
1075    itrs_to_topocentric_unchecked([sat_itrs_x, sat_itrs_y, sat_itrs_z], station)
1076}
1077
1078/// Topocentric az/el/range from a station to an Earth-fixed (ITRS/ECEF) target.
1079///
1080/// `target_itrs_km` is the target's geocentric position in the Earth-fixed
1081/// (ITRS/ECEF) frame, km. Returns `(azimuth_deg, elevation_deg, range_km)`,
1082/// azimuth measured clockwise from north on `[0, 360)` and elevation on
1083/// `[-90, 90]`. This is the same station-to-target ENU reduction the satellite
1084/// look-angle path uses, exposed for callers that already hold an Earth-fixed
1085/// target vector (for example the analytic Sun/Moon ECEF positions from
1086/// [`crate::astro::bodies::sun_moon::sun_moon_ecef`]).
1087pub fn itrs_to_topocentric(
1088    target_itrs_km: [f64; 3],
1089    station: &GeodeticStationKm,
1090) -> Result<(f64, f64, f64), FrameTransformError> {
1091    validate_vec3("target_itrs_km", &target_itrs_km)?;
1092    validate_geodetic_degrees_km(
1093        station.latitude_deg,
1094        station.longitude_deg,
1095        station.altitude_km,
1096    )?;
1097    validate_tuple3(
1098        "topocentric",
1099        itrs_to_topocentric_unchecked(target_itrs_km, station),
1100    )
1101}
1102
1103fn itrs_to_topocentric_unchecked(target_itrs_km: [f64; 3], station: &GeodeticStationKm) -> Vec3 {
1104    let [target_x, target_y, target_z] = target_itrs_km;
1105    let (stn_x, stn_y, stn_z) = geodetic_to_itrs_unchecked(
1106        station.latitude_deg,
1107        station.longitude_deg,
1108        station.altitude_km,
1109    );
1110
1111    let dx = target_x - stn_x;
1112    let dy = target_y - stn_y;
1113    let dz = target_z - stn_z;
1114
1115    let enu_mat = ecef_to_enu_matrix(station.latitude_deg, station.longitude_deg);
1116    let enu = mat3_vec3_mul_unchecked(&enu_mat, &[dx, dy, dz]);
1117    let east = enu[0];
1118    let north = enu[1];
1119    let up = enu[2];
1120
1121    // Range
1122    let range = (east * east + north * north + up * up).sqrt();
1123
1124    // Elevation
1125    let elevation = libm::asin(up / range).to_degrees();
1126
1127    // Azimuth (measured clockwise from north). At (and arbitrarily near) the
1128    // station zenith the east and north components are pure rounding residuals,
1129    // so azimuth is degenerate and defined to be 0.0 (RTKLIB satazel semantics).
1130    let horiz_sq = east * east + north * north;
1131    let mut azimuth = if horiz_sq < AZIMUTH_ZENITH_EPS * range * range {
1132        0.0
1133    } else {
1134        libm::atan2(east, north).to_degrees()
1135    };
1136    if azimuth < 0.0 {
1137        azimuth += 360.0;
1138    }
1139
1140    (azimuth, elevation, range)
1141}
1142
1143/// Skyfield-compatible topocentric: stays in GCRS AU the entire time.
1144///
1145/// Replicates Skyfield's altaz computation:
1146/// 1. R_lat = rot_y(lat)[::-1]  (row-reversed Y rotation)
1147/// 2. R_latlon = mxm(R_lat, rot_z(-lon))
1148/// 3. R_full = mxm(R_latlon, itrs_rotation)
1149/// 4. station_gcrs_au = transpose(itrs_rotation) * station_itrs_au
1150/// 5. diff_au = sat_gcrs_au - station_gcrs_au
1151/// 6. enu_au = mxv(R_full, diff_au)
1152/// 7. to_spherical(enu_au) -> (range_au, elevation_rad, azimuth_rad)
1153fn gcrs_to_topocentric_skyfield(
1154    sat_x: f64,
1155    sat_y: f64,
1156    sat_z: f64,
1157    station_lat_deg: f64,
1158    station_lon_deg: f64,
1159    station_alt_km: f64,
1160    ts: &TimeScales,
1161) -> (f64, f64, f64) {
1162    let lat_rad = station_lat_deg * TAU / 360.0;
1163    let lon_rad = station_lon_deg * TAU / 360.0;
1164
1165    // Build R_lat = rot_y(lat)[::-1]  (rows reversed)
1166    let cy = libm::cos(lat_rad);
1167    let sy = libm::sin(lat_rad);
1168    // rot_y(lat) = [[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]]
1169    // [::-1] reverses rows: [[-sy, 0, cy], [0, 1, 0], [cy, 0, sy]]
1170    let r_lat: Mat3 = [[-sy, 0.0, cy], [0.0, 1.0, 0.0], [cy, 0.0, sy]];
1171
1172    // R_latlon = mxm(R_lat, rot_z(-lon))
1173    let rz_neg_lon = build_rot_z(-lon_rad);
1174    let r_latlon = inline_rxr(&r_lat, &rz_neg_lon);
1175
1176    // R_full = mxm(R_latlon, itrs_rotation)
1177    let r_itrs = gcrs_to_itrs_matrix_unchecked(ts);
1178    let r_full = inline_rxr(&r_latlon, &r_itrs);
1179
1180    // Station ITRS position directly in AU, matching Skyfield's Geoid.latlon
1181    // which computes in AU from the start (not km then / AU_KM).
1182    let stn_itrs_au = geodetic_to_itrs_au(station_lat_deg, station_lon_deg, station_alt_km);
1183
1184    // Station GCRS AU = transpose(R_itrs) * station_itrs_au
1185    let r_itrs_t = inline_tr(&r_itrs);
1186    let stn_gcrs_au = mat3_vec3_mul_unchecked(&r_itrs_t, &stn_itrs_au);
1187
1188    // Satellite GCRS in AU
1189    let sat_au = [sat_x / AU_KM, sat_y / AU_KM, sat_z / AU_KM];
1190
1191    // Difference vector in GCRS AU
1192    let diff_au = [
1193        sat_au[0] - stn_gcrs_au[0],
1194        sat_au[1] - stn_gcrs_au[1],
1195        sat_au[2] - stn_gcrs_au[2],
1196    ];
1197
1198    // Rotate to ENU-ish frame: mxv(R_full, diff_au)
1199    let enu_au = mat3_vec3_mul_unchecked(&r_full, &diff_au);
1200
1201    // to_spherical: r, theta (elevation), phi (azimuth)
1202    let ex = enu_au[0];
1203    let ey = enu_au[1];
1204    let ez = enu_au[2];
1205
1206    let r_au = (ex * ex + ey * ey + ez * ez).sqrt();
1207    let elevation_rad = libm::atan2(ez, (ex * ex + ey * ey).sqrt());
1208    let mut azimuth_rad = libm::atan2(ey, ex) % TAU;
1209    if azimuth_rad < 0.0 {
1210        azimuth_rad += TAU;
1211    }
1212
1213    let range_km = r_au * AU_KM;
1214    let elevation_deg = elevation_rad * 360.0 / TAU;
1215    let azimuth_deg = azimuth_rad * 360.0 / TAU;
1216
1217    (azimuth_deg, elevation_deg, range_km)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223    use crate::astro::time::scales::TimeScales;
1224
1225    fn assert_mat3_bits_eq(actual: &Mat3, expected: &Mat3) {
1226        for i in 0..3 {
1227            for j in 0..3 {
1228                assert_eq!(
1229                    actual[i][j].to_bits(),
1230                    expected[i][j].to_bits(),
1231                    "matrix[{i}][{j}]: {} vs {}",
1232                    actual[i][j],
1233                    expected[i][j]
1234                );
1235            }
1236        }
1237    }
1238
1239    fn assert_vec3_bits_eq(actual: [f64; 3], expected: [f64; 3]) {
1240        for i in 0..3 {
1241            assert_eq!(
1242                actual[i].to_bits(),
1243                expected[i].to_bits(),
1244                "vector[{i}]: {} vs {}",
1245                actual[i],
1246                expected[i]
1247            );
1248        }
1249    }
1250
1251    #[test]
1252    fn itrs_to_gcrs_inverts_gcrs_to_itrs() {
1253        // On a real epoch and a real-magnitude ECI vector, ITRS->GCRS recovers
1254        // the GCRS->ITRS input bit-for-bit on the plain (non-Skyfield) km path:
1255        // the two directions must be exact transposes, not just approximately so.
1256        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1257        let (x, y, z) = (4321.0_f64, -5678.0, 3210.0);
1258
1259        let (ix, iy, iz) =
1260            gcrs_to_itrs_compute(x, y, z, &ts, false).expect("valid frame transform");
1261        // The rotation actually moved the vector (it is not a no-op).
1262        assert!(((ix - x).abs() + (iy - y).abs() + (iz - z).abs()) > 100.0);
1263
1264        let (bx, by, bz) = itrs_to_gcrs_compute(ix, iy, iz, &ts).expect("valid frame transform");
1265        assert!((bx - x).abs() < 1e-9, "x {bx} vs {x}");
1266        assert!((by - y).abs() < 1e-9, "y {by} vs {y}");
1267        assert!((bz - z).abs() < 1e-9, "z {bz} vs {z}");
1268
1269        // Magnitude is preserved by the rotation.
1270        let n0 = (x * x + y * y + z * z).sqrt();
1271        let n1 = (ix * ix + iy * iy + iz * iz).sqrt();
1272        assert!((n0 - n1).abs() < 1e-9);
1273    }
1274
1275    #[test]
1276    fn polar_motion_matrix_matches_documented_convention() {
1277        let pole = PolarMotion::from_arcseconds(0.25, -0.35).expect("valid polar motion");
1278        let cx = libm::cos(pole.xp_rad);
1279        let sx = libm::sin(pole.xp_rad);
1280        let cy = libm::cos(pole.yp_rad);
1281        let sy = libm::sin(pole.yp_rad);
1282
1283        let expected = [
1284            [cx, sx * sy, sx * cy],
1285            [0.0, cy, -sy],
1286            [-sx, cx * sy, cx * cy],
1287        ];
1288        let got = polar_motion_matrix(pole).expect("valid polar motion matrix");
1289        assert_mat3_bits_eq(&got, &expected);
1290
1291        let small_angle = [
1292            [1.0, 0.0, pole.xp_rad],
1293            [0.0, 1.0, -pole.yp_rad],
1294            [-pole.xp_rad, pole.yp_rad, 1.0],
1295        ];
1296        for i in 0..3 {
1297            for j in 0..3 {
1298                assert!(
1299                    (got[i][j] - small_angle[i][j]).abs() < 1.0e-11,
1300                    "matrix[{i}][{j}] {} vs small-angle {}",
1301                    got[i][j],
1302                    small_angle[i][j]
1303                );
1304            }
1305        }
1306    }
1307
1308    #[test]
1309    fn gcrs_to_itrs_with_polar_motion_premultiplies_legacy_rotation() {
1310        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1311        let pole = PolarMotion::from_arcseconds(0.18, -0.24).expect("valid polar motion");
1312        let legacy = gcrs_to_itrs_matrix(&ts).expect("valid frame transform");
1313        let expected = inline_rxr(
1314            &polar_motion_matrix(pole).expect("valid polar motion matrix"),
1315            &legacy,
1316        );
1317        let got = gcrs_to_itrs_matrix_with_polar_motion(&ts, pole).expect("valid frame transform");
1318
1319        assert_mat3_bits_eq(&got, &expected);
1320
1321        let pos = [4321.0_f64, -5678.0, 3210.0];
1322        let actual_vec =
1323            gcrs_to_itrs_compute_with_polar_motion(pos[0], pos[1], pos[2], &ts, false, pole)
1324                .expect("valid frame transform");
1325        let expected_vec = mat3_vec3_mul(&expected, &pos).expect("finite matrix-vector product");
1326        assert_vec3_bits_eq([actual_vec.0, actual_vec.1, actual_vec.2], expected_vec);
1327
1328        let legacy_vec =
1329            gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, false).expect("valid transform");
1330        let delta = (actual_vec.0 - legacy_vec.0).abs()
1331            + (actual_vec.1 - legacy_vec.1).abs()
1332            + (actual_vec.2 - legacy_vec.2).abs();
1333        assert!(
1334            delta > 1.0e-4,
1335            "nonzero polar motion should move the vector"
1336        );
1337
1338        let inverse =
1339            itrs_to_gcrs_matrix_with_polar_motion(&ts, pole).expect("valid frame transform");
1340        assert_mat3_bits_eq(&inverse, &inline_tr(&got));
1341    }
1342
1343    #[test]
1344    fn zero_polar_motion_matches_legacy_transform_bits() {
1345        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1346        let legacy = gcrs_to_itrs_matrix(&ts).expect("valid frame transform");
1347        let zero = gcrs_to_itrs_matrix_with_polar_motion(&ts, PolarMotion::ZERO)
1348            .expect("valid frame transform");
1349        assert_mat3_bits_eq(&zero, &legacy);
1350
1351        let mean_legacy = mean_of_date_to_itrs_matrix(&ts).expect("valid frame transform");
1352        let mean_zero = mean_of_date_to_itrs_matrix_with_polar_motion(&ts, PolarMotion::ZERO)
1353            .expect("valid frame transform");
1354        assert_mat3_bits_eq(&mean_zero, &mean_legacy);
1355
1356        let pos = [4321.0_f64, -5678.0, 3210.0];
1357        for skyfield_compat in [false, true] {
1358            let legacy_vec = gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, skyfield_compat)
1359                .expect("valid frame transform");
1360            let zero_vec = gcrs_to_itrs_compute_with_polar_motion(
1361                pos[0],
1362                pos[1],
1363                pos[2],
1364                &ts,
1365                skyfield_compat,
1366                PolarMotion::ZERO,
1367            )
1368            .expect("valid frame transform");
1369            assert_vec3_bits_eq(
1370                [zero_vec.0, zero_vec.1, zero_vec.2],
1371                [legacy_vec.0, legacy_vec.1, legacy_vec.2],
1372            );
1373        }
1374
1375        let legacy_back =
1376            itrs_to_gcrs_compute(pos[0], pos[1], pos[2], &ts).expect("valid frame transform");
1377        let zero_back =
1378            itrs_to_gcrs_compute_with_polar_motion(pos[0], pos[1], pos[2], &ts, PolarMotion::ZERO)
1379                .expect("valid frame transform");
1380        assert_vec3_bits_eq(
1381            [zero_back.0, zero_back.1, zero_back.2],
1382            [legacy_back.0, legacy_back.1, legacy_back.2],
1383        );
1384    }
1385
1386    #[test]
1387    fn gcrs_to_teme_inverts_teme_to_gcrs() {
1388        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1389        let teme = TemeStateKm {
1390            position_km: [6524.834, 6862.875, 6448.296],
1391            velocity_km_s: [4.901327, 5.533756, -1.976341],
1392        };
1393
1394        for skyfield_compat in [false, true] {
1395            let (gcrs_position, gcrs_velocity) =
1396                teme_to_gcrs_compute(&teme, &ts, skyfield_compat).expect("valid transform");
1397            let gcrs = TemeStateKm {
1398                position_km: [gcrs_position.0, gcrs_position.1, gcrs_position.2],
1399                velocity_km_s: [gcrs_velocity.0, gcrs_velocity.1, gcrs_velocity.2],
1400            };
1401            let (round_position, round_velocity) =
1402                gcrs_to_teme_compute(&gcrs, &ts, skyfield_compat).expect("valid inverse");
1403
1404            let pos = [round_position.0, round_position.1, round_position.2];
1405            let vel = [round_velocity.0, round_velocity.1, round_velocity.2];
1406            for axis in 0..3 {
1407                assert!(
1408                    (pos[axis] - teme.position_km[axis]).abs() <= 1.0e-9,
1409                    "position axis {axis} mode {skyfield_compat}"
1410                );
1411                assert!(
1412                    (vel[axis] - teme.velocity_km_s[axis]).abs() <= 1.0e-12,
1413                    "velocity axis {axis} mode {skyfield_compat}"
1414                );
1415            }
1416        }
1417    }
1418
1419    #[test]
1420    fn frame_transforms_reject_nonfinite_time() {
1421        let mut ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1422        ts.jd_tt = f64::NAN;
1423
1424        assert!(greenwich_mean_sidereal_time_radians(&ts).is_err());
1425        assert!(gcrs_to_itrs_matrix(&ts).is_err());
1426        assert!(itrs_to_gcrs_compute(1.0, 2.0, 3.0, &ts).is_err());
1427    }
1428
1429    #[test]
1430    fn frame_transforms_reject_nonfinite_pole_coordinates() {
1431        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1432        assert!(PolarMotion::from_radians(f64::NAN, 0.0).is_err());
1433        assert!(PolarMotion::from_arcseconds(0.0, f64::INFINITY).is_err());
1434
1435        let pole = PolarMotion {
1436            xp_rad: f64::NAN,
1437            yp_rad: 0.0,
1438        };
1439        assert!(polar_motion_matrix(pole).is_err());
1440        assert!(gcrs_to_itrs_matrix_with_polar_motion(&ts, pole).is_err());
1441    }
1442
1443    #[test]
1444    fn frame_transforms_reject_nonfinite_vectors() {
1445        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1446        let bad_state = TemeStateKm {
1447            position_km: [1.0, f64::NAN, 3.0],
1448            velocity_km_s: [0.1, 0.2, 0.3],
1449        };
1450        assert!(teme_to_gcrs_compute(&bad_state, &ts, false).is_err());
1451        assert!(gcrs_to_itrs_compute(1.0, f64::INFINITY, 3.0, &ts, false).is_err());
1452        assert!(itrs_to_gcrs_compute(1.0, 2.0, f64::NEG_INFINITY, &ts).is_err());
1453    }
1454
1455    #[test]
1456    fn validated_frame_transform_preserves_valid_bits() {
1457        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1458        let pos = [4321.0_f64, -5678.0, 3210.0];
1459        let expected = gcrs_to_itrs_compute_unchecked(pos[0], pos[1], pos[2], &ts, true);
1460        let got =
1461            gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, true).expect("valid frame transform");
1462        assert_vec3_bits_eq([got.0, got.1, got.2], [expected.0, expected.1, expected.2]);
1463    }
1464
1465    #[test]
1466    fn geodetic_transforms_reject_invalid_coordinates() {
1467        assert!(itrs_to_geodetic_compute(f64::NAN, 0.0, 0.0).is_err());
1468        assert!(geodetic_from_ecef_proj(0.0, f64::INFINITY, 0.0).is_err());
1469        assert!(geodetic_to_itrs(90.000_001, 0.0, 0.0).is_err());
1470        assert!(geodetic_to_itrs(0.0, -180.000_001, 0.0).is_err());
1471        assert!(geodetic_to_itrs(0.0, 0.0, f64::NAN).is_err());
1472    }
1473
1474    #[test]
1475    fn topocentric_transform_rejects_invalid_coordinates() {
1476        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1477        let station = GeodeticStationKm {
1478            latitude_deg: f64::NAN,
1479            longitude_deg: 0.0,
1480            altitude_km: 0.0,
1481        };
1482        assert!(gcrs_to_topocentric_compute([7000.0, 0.0, 0.0], &station, &ts, false).is_err());
1483
1484        let station = GeodeticStationKm {
1485            latitude_deg: 0.0,
1486            longitude_deg: 181.0,
1487            altitude_km: 0.0,
1488        };
1489        assert!(gcrs_to_topocentric_compute([7000.0, 0.0, 0.0], &station, &ts, false).is_err());
1490
1491        let station = GeodeticStationKm {
1492            latitude_deg: 0.0,
1493            longitude_deg: 0.0,
1494            altitude_km: 0.0,
1495        };
1496        assert!(
1497            gcrs_to_topocentric_compute([7000.0, f64::NAN, 0.0], &station, &ts, false).is_err()
1498        );
1499    }
1500
1501    #[test]
1502    fn topocentric_azimuth_is_zero_at_station_zenith() {
1503        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1504        // Equatorial station: geodetic up coincides with the geocentric radial,
1505        // so a satellite displaced radially outward is exactly overhead.
1506        let station = GeodeticStationKm {
1507            latitude_deg: 0.0,
1508            longitude_deg: 0.0,
1509            altitude_km: 0.0,
1510        };
1511        let (sx, sy, sz) = geodetic_to_itrs_unchecked(0.0, 0.0, 0.0);
1512        // Satellite ITRS position 20,000 km straight up (+X at lat0/lon0).
1513        let sat_itrs = [sx + 20_000.0, sy, sz];
1514        // Express it in GCRS so the standard path rotates it back to `sat_itrs`.
1515        let r_itrs = gcrs_to_itrs_matrix_unchecked(&ts);
1516        let r_itrs_t = inline_tr(&r_itrs);
1517        let sat_gcrs = mat3_vec3_mul_unchecked(&r_itrs_t, &sat_itrs);
1518
1519        let (azimuth_deg, elevation_deg, _range_km) =
1520            gcrs_to_topocentric_compute_unchecked(sat_gcrs, &station, &ts, false);
1521        assert_eq!(azimuth_deg, 0.0);
1522        assert!(azimuth_deg.is_finite());
1523        assert!((elevation_deg - 90.0).abs() < 1e-6);
1524    }
1525
1526    #[test]
1527    fn validated_geodetic_transform_preserves_valid_bits() {
1528        let (lat, lon, alt) = (51.4779, -0.0015, 0.046);
1529        let expected = geodetic_to_itrs_unchecked(lat, lon, alt);
1530        let got = geodetic_to_itrs(lat, lon, alt).expect("valid geodetic coordinates");
1531        assert_eq!(got.0.to_bits(), expected.0.to_bits());
1532        assert_eq!(got.1.to_bits(), expected.1.to_bits());
1533        assert_eq!(got.2.to_bits(), expected.2.to_bits());
1534
1535        let expected = itrs_to_geodetic_compute_unchecked(got.0, got.1, got.2);
1536        let roundtrip =
1537            itrs_to_geodetic_compute(got.0, got.1, got.2).expect("valid ITRS coordinates");
1538        assert_eq!(roundtrip.0.to_bits(), expected.0.to_bits());
1539        assert_eq!(roundtrip.1.to_bits(), expected.1.to_bits());
1540        assert_eq!(roundtrip.2.to_bits(), expected.2.to_bits());
1541    }
1542
1543    #[test]
1544    fn sidereal_time_wrappers_are_in_range_and_consistent() {
1545        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1546        let gmst = greenwich_mean_sidereal_time_radians(&ts).expect("valid sidereal time");
1547        let gast = greenwich_apparent_sidereal_time_radians(&ts).expect("valid sidereal time");
1548
1549        // Both land in [0, 2pi).
1550        assert!((0.0..TAU).contains(&gmst), "gmst {gmst}");
1551        assert!((0.0..TAU).contains(&gast), "gast {gast}");
1552
1553        // The equation of the equinoxes is a small (sub-arcminute) offset, so the
1554        // apparent and mean sidereal times stay close (handle the seam at 2pi).
1555        let diff = (gast - gmst).rem_euclid(TAU);
1556        let eq_eq = diff.min(TAU - diff);
1557        assert!(eq_eq < 1.0e-3, "equation of equinoxes too large: {eq_eq}");
1558
1559        // The mean wrapper matches the Skyfield-parity IAU-1982 sidereal time.
1560        let gmst_hours = sidereal_time_hours(ts.jd_whole, ts.ut1_fraction, ts.tdb_fraction);
1561        assert_eq!(gmst, gmst_hours / 24.0 * TAU);
1562    }
1563
1564    #[test]
1565    fn gmst_from_j2000_seconds_matches_gmst1982_polynomial() {
1566        // The seconds-based accessor reproduces the GMST-1982 polynomial the TEME
1567        // sidereal rotation uses, not the public Skyfield-parity GMST. Tolerance
1568        // allows only the roundoff from reducing the UT1 split to one f64.
1569        const GMST_TOL_RAD: f64 = 1.0e-11;
1570        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.25).expect("valid UTC instant");
1571        let sec = (ts.jd_ut1 - J2000_JD) * SECONDS_PER_DAY;
1572        let from_seconds = greenwich_mean_sidereal_time_radians_from_j2000_seconds(sec)
1573            .expect("valid sidereal time");
1574        let mut expected = compute_theta_gmst1982(ts.jd_whole, ts.ut1_fraction) % TAU;
1575        if expected < 0.0 {
1576            expected += TAU;
1577        }
1578
1579        assert!((from_seconds - expected).abs() <= GMST_TOL_RAD);
1580    }
1581}