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::scales::TimeScales;
26use crate::astro::{
27    constants::astro::AU_KM,
28    constants::earth::{WGS84_A_KM, WGS84_E2, WGS84_F},
29    constants::models::proj::{
30        HALF_PI as PROJ_HALF_PI, RAD_TO_DEG as PROJ_RAD_TO_DEG, WGS84_A_M as PROJ_WGS84_A_M,
31        WGS84_B_M as PROJ_WGS84_B_M, WGS84_E2S as PROJ_WGS84_E2S, WGS84_ES as PROJ_WGS84_ES,
32    },
33    constants::time::{DAYS_PER_JULIAN_CENTURY, J2000_JD, SECONDS_PER_DAY},
34};
35
36const TAU: f64 = std::f64::consts::TAU;
37const ARCSECONDS_TO_RADIANS: f64 = 4.848_136_811_095_36e-6;
38
39/// Error returned when public frame-transform inputs are outside the valid domain.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
41pub enum FrameTransformError {
42    /// A transform input was non-finite or otherwise invalid.
43    #[error("invalid frame transform {field}: {reason}")]
44    InvalidInput {
45        field: &'static str,
46        reason: &'static str,
47    },
48}
49
50fn invalid_input(field: &'static str, reason: &'static str) -> FrameTransformError {
51    FrameTransformError::InvalidInput { field, reason }
52}
53
54fn validate_finite(field: &'static str, value: f64) -> Result<(), FrameTransformError> {
55    if value.is_finite() {
56        Ok(())
57    } else {
58        Err(invalid_input(field, "must be finite"))
59    }
60}
61
62fn validate_vec3(field: &'static str, values: &[f64; 3]) -> Result<(), FrameTransformError> {
63    for value in values {
64        if !value.is_finite() {
65            return Err(invalid_input(field, "components must be finite"));
66        }
67    }
68    Ok(())
69}
70
71fn validate_tuple3(field: &'static str, values: Vec3) -> Result<Vec3, FrameTransformError> {
72    if values.0.is_finite() && values.1.is_finite() && values.2.is_finite() {
73        Ok(values)
74    } else {
75        Err(invalid_input(field, "components must be finite"))
76    }
77}
78
79fn validate_array3(field: &'static str, values: [f64; 3]) -> Result<[f64; 3], FrameTransformError> {
80    validate_vec3(field, &values)?;
81    Ok(values)
82}
83
84fn validate_mat3(field: &'static str, values: Mat3) -> Result<Mat3, FrameTransformError> {
85    for row in &values {
86        validate_vec3(field, row)?;
87    }
88    Ok(values)
89}
90
91fn validate_time_scales(ts: &TimeScales) -> Result<(), FrameTransformError> {
92    validate_finite("jd_whole", ts.jd_whole)?;
93    validate_finite("ut1_fraction", ts.ut1_fraction)?;
94    validate_finite("tt_fraction", ts.tt_fraction)?;
95    validate_finite("tdb_fraction", ts.tdb_fraction)?;
96    validate_finite("jd_ut1", ts.jd_ut1)?;
97    validate_finite("jd_tt", ts.jd_tt)?;
98    validate_finite("jd_tdb", ts.jd_tdb)
99}
100
101fn validate_polar_motion(pole: PolarMotion) -> Result<(), FrameTransformError> {
102    validate_finite("xp_rad", pole.xp_rad)?;
103    validate_finite("yp_rad", pole.yp_rad)
104}
105
106fn validate_geodetic_degrees_km(
107    latitude_deg: f64,
108    longitude_deg: f64,
109    altitude_km: f64,
110) -> Result<(), FrameTransformError> {
111    validate_finite("latitude_deg", latitude_deg)?;
112    if !(-90.0..=90.0).contains(&latitude_deg) {
113        return Err(invalid_input("latitude_deg", "must be in [-90, 90]"));
114    }
115    validate_finite("longitude_deg", longitude_deg)?;
116    if !(-180.0..=180.0).contains(&longitude_deg) {
117        return Err(invalid_input("longitude_deg", "must be in [-180, 180]"));
118    }
119    validate_finite("altitude_km", altitude_km)
120}
121
122/// A bare Cartesian triple (km or km/s depending on context).
123///
124/// This is the internal compute-layer return shape. Typed input structs
125/// ([`TemeStateKm`], [`GeodeticStationKm`]) bundle the public entry points'
126/// arguments, but the numerics below operate on raw triples to preserve the
127/// original operation order exactly.
128pub type Vec3 = (f64, f64, f64);
129
130/// TEME-frame position and velocity (km, km/s): the input to
131/// [`teme_to_gcrs_compute`].
132pub struct TemeStateKm {
133    pub position_km: [f64; 3],
134    pub velocity_km_s: [f64; 3],
135}
136
137/// Geodetic ground-station position (WGS84) for topocentric look angles.
138pub struct GeodeticStationKm {
139    pub latitude_deg: f64,
140    pub longitude_deg: f64,
141    pub altitude_km: f64,
142}
143
144/// Polar-motion coordinates of the Celestial Intermediate Pole.
145///
146/// `xp_rad` and `yp_rad` are radians. The embedded EOP table currently carries
147/// UT1-UTC only, so the historical transforms use [`PolarMotion::ZERO`] by
148/// default. Precision callers with pole coordinates should use the explicit
149/// `*_with_polar_motion` entry points below.
150#[derive(Debug, Clone, Copy, PartialEq)]
151pub struct PolarMotion {
152    pub xp_rad: f64,
153    pub yp_rad: f64,
154}
155
156impl PolarMotion {
157    /// No polar-motion rotation; preserves the historical transform exactly.
158    pub const ZERO: Self = Self {
159        xp_rad: 0.0,
160        yp_rad: 0.0,
161    };
162
163    /// Construct polar-motion coordinates from radians.
164    pub fn from_radians(xp_rad: f64, yp_rad: f64) -> Result<Self, FrameTransformError> {
165        validate_finite("xp_rad", xp_rad)?;
166        validate_finite("yp_rad", yp_rad)?;
167        Ok(Self { xp_rad, yp_rad })
168    }
169
170    /// Construct polar-motion coordinates from arcseconds.
171    pub fn from_arcseconds(xp_arcsec: f64, yp_arcsec: f64) -> Result<Self, FrameTransformError> {
172        validate_finite("xp_arcsec", xp_arcsec)?;
173        validate_finite("yp_arcsec", yp_arcsec)?;
174        Self::from_radians(
175            xp_arcsec * ARCSECONDS_TO_RADIANS,
176            yp_arcsec * ARCSECONDS_TO_RADIANS,
177        )
178    }
179
180    fn is_zero(self) -> bool {
181        self.xp_rad == 0.0 && self.yp_rad == 0.0
182    }
183}
184
185impl Default for PolarMotion {
186    fn default() -> Self {
187        Self::ZERO
188    }
189}
190
191/// Final matrix-vector multiply using explicit FMA.
192/// This matches numpy's vectorized behavior and is the ONLY place
193/// where f64::mul_add() should be used.
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 = f64::mul_add(r[i][1], p[1], sum);
199        let sum = f64::mul_add(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 = angle.cos();
207    let s = angle.sin();
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 = pole.xp_rad.cos();
227    let sx = pole.xp_rad.sin();
228    let cy = pole.yp_rad.cos();
229    let sy = pole.yp_rad.sin();
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 * mean_ob.cos() + 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/// Greenwich Apparent Sidereal Time for an instant, radians in `[0, 2pi)`.
318///
319/// GMST plus the equation of the equinoxes (nutation in longitude projected on
320/// the true equator, with the IAU 2000 complementary terms). A thin wrapper over
321/// the existing private GAST computation; bit-identical to the value the
322/// GCRS<->ITRS transforms apply.
323pub fn greenwich_apparent_sidereal_time_radians(
324    ts: &TimeScales,
325) -> Result<f64, FrameTransformError> {
326    validate_time_scales(ts)?;
327    let radians = greenwich_apparent_sidereal_time_radians_unchecked(ts);
328    validate_finite("gast_radians", radians)?;
329    Ok(radians)
330}
331
332fn greenwich_apparent_sidereal_time_radians_unchecked(ts: &TimeScales) -> f64 {
333    let (dpsi, _deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
334    gast_radians(ts, dpsi)
335}
336
337/// Build the TEME->GCRS rotation matrix T from time scales.
338fn build_teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
339    let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
340    let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
341    let true_ob = mean_ob + deps;
342
343    let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
344    let p = compute_skyfield_precession_matrix_unchecked(ts.jd_tdb);
345    let b = build_icrs_to_j2000();
346
347    // Skyfield uses Kahan-compensated triple product (matching numpy einsum).
348    // Direct mode uses standard sequential multiply (more precise).
349    let m = if skyfield_compat {
350        inline_mxmxm(&n, &p, &b)
351    } else {
352        let np = inline_rxr(&n, &p);
353        inline_rxr(&np, &b)
354    };
355
356    let gast = gast_radians(ts, dpsi);
357    let theta = compute_theta_gmst1982(ts.jd_whole, ts.ut1_fraction);
358    let angle = theta - gast;
359
360    let r = build_rot_z(angle);
361    let g = inline_rxr(&r, &m);
362    inline_tr(&g)
363}
364
365/// Build the TEME->GCRS rotation matrix T from time scales.
366pub(crate) fn teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
367    build_teme_to_gcrs_matrix(ts, skyfield_compat)
368}
369
370/// Standard (non-FMA) matrix-vector multiply.
371pub fn mat3_vec3_mul(r: &Mat3, p: &[f64; 3]) -> Result<[f64; 3], FrameTransformError> {
372    validate_mat3("matrix", *r)?;
373    validate_vec3("vector", p)?;
374    validate_array3("matrix_vector_product", mat3_vec3_mul_unchecked(r, p))
375}
376
377pub(crate) fn mat3_vec3_mul_unchecked(r: &Mat3, p: &[f64; 3]) -> [f64; 3] {
378    let mut result = [0.0_f64; 3];
379    for i in 0..3 {
380        let mut sum = 0.0;
381        for j in 0..3 {
382            sum += r[i][j] * p[j];
383        }
384        result[i] = sum;
385    }
386    result
387}
388
389/// Core TEME->GCRS transform. Returns ((px,py,pz), (vx,vy,vz)).
390pub fn teme_to_gcrs_compute(
391    state: &TemeStateKm,
392    ts: &TimeScales,
393    skyfield_compat: bool,
394) -> Result<(Vec3, Vec3), FrameTransformError> {
395    validate_time_scales(ts)?;
396    validate_vec3("position_km", &state.position_km)?;
397    validate_vec3("velocity_km_s", &state.velocity_km_s)?;
398    let (position, velocity) = teme_to_gcrs_compute_unchecked(state, ts, skyfield_compat);
399    Ok((
400        validate_tuple3("gcrs_position_km", position)?,
401        validate_tuple3("gcrs_velocity_km_s", velocity)?,
402    ))
403}
404
405fn teme_to_gcrs_compute_unchecked(
406    state: &TemeStateKm,
407    ts: &TimeScales,
408    skyfield_compat: bool,
409) -> (Vec3, Vec3) {
410    let [x, y, z] = state.position_km;
411    let [vx, vy, vz] = state.velocity_km_s;
412    let t = build_teme_to_gcrs_matrix(ts, skyfield_compat);
413
414    if skyfield_compat {
415        // AU/day scaling + FMA multiply matching Skyfield's _at() path.
416        let r_au = [x / AU_KM, y / AU_KM, z / AU_KM];
417        let r_gcrs_au = mat3_vec3_mul_fma(&t, &r_au);
418        let r_gcrs = (
419            r_gcrs_au[0] * AU_KM,
420            r_gcrs_au[1] * AU_KM,
421            r_gcrs_au[2] * AU_KM,
422        );
423
424        let v_au_d = [
425            vx / AU_KM * SECONDS_PER_DAY,
426            vy / AU_KM * SECONDS_PER_DAY,
427            vz / AU_KM * SECONDS_PER_DAY,
428        ];
429        let v_gcrs_au_d = mat3_vec3_mul_fma(&t, &v_au_d);
430        let v_gcrs = (
431            v_gcrs_au_d[0] * AU_KM / SECONDS_PER_DAY,
432            v_gcrs_au_d[1] * AU_KM / SECONDS_PER_DAY,
433            v_gcrs_au_d[2] * AU_KM / SECONDS_PER_DAY,
434        );
435        (r_gcrs, v_gcrs)
436    } else {
437        // Direct km/s multiply -- no AU round-trip, no FMA.
438        let r_teme = [x, y, z];
439        let r_g = mat3_vec3_mul_unchecked(&t, &r_teme);
440        let v_teme = [vx, vy, vz];
441        let v_g = mat3_vec3_mul_unchecked(&t, &v_teme);
442        ((r_g[0], r_g[1], r_g[2]), (v_g[0], v_g[1], v_g[2]))
443    }
444}
445
446// ---------------------------------------------------------------------------
447// GCRS -> ITRS (Earth-fixed / ECEF)
448// ---------------------------------------------------------------------------
449
450/// Build the historical GCRS->ITRS rotation matrix for a given time.
451///
452/// This combines precession, nutation, and Earth rotation with zero polar
453/// motion, preserving the original bit-exact path. Use
454/// [`gcrs_to_itrs_matrix_with_polar_motion`] when `xp`/`yp` pole coordinates are
455/// available.
456pub fn gcrs_to_itrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
457    validate_time_scales(ts)?;
458    validate_mat3("gcrs_to_itrs_matrix", gcrs_to_itrs_matrix_unchecked(ts))
459}
460
461fn gcrs_to_itrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
462    let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
463    let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
464    let true_ob = mean_ob + deps;
465
466    let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
467    let p = compute_skyfield_precession_matrix_unchecked(ts.jd_tdb);
468    let b = build_icrs_to_j2000();
469
470    // Celestial-to-terrestrial: combine precession, nutation, frame bias
471    let m = inline_mxmxm(&n, &p, &b);
472
473    let gast = gast_radians(ts, dpsi);
474
475    // GAST rotation takes us from true-equator-equinox to ITRS
476    let r_gast = build_rot_z(-gast);
477
478    // GCRS->ITRS = R_z(-GAST) * (N * P * B)
479    inline_rxr(&r_gast, &m)
480}
481
482/// Build the GCRS->ITRS rotation matrix with explicit polar motion.
483///
484/// The embedded Earth-orientation table supplies UT1-UTC but not `xp`/`yp`, so
485/// callers that do not have pole coordinates should pass [`PolarMotion::ZERO`]
486/// or use [`gcrs_to_itrs_matrix`].
487pub fn gcrs_to_itrs_matrix_with_polar_motion(
488    ts: &TimeScales,
489    pole: PolarMotion,
490) -> Result<Mat3, FrameTransformError> {
491    validate_time_scales(ts)?;
492    validate_polar_motion(pole)?;
493    validate_mat3(
494        "gcrs_to_itrs_matrix",
495        gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole),
496    )
497}
498
499fn gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts: &TimeScales, pole: PolarMotion) -> Mat3 {
500    apply_polar_motion_to_itrs_matrix(gcrs_to_itrs_matrix_unchecked(ts), pole)
501}
502
503/// Rotation from the **mean equator and equinox of date** to ITRS, i.e.
504/// `R_z(-GAST) * N` (nutation + Earth rotation, *without* precession or frame
505/// bias).
506///
507/// This is [`gcrs_to_itrs_matrix`] with the precession (`P`) and frame-bias
508/// (`B`) factors removed. Use it for vectors that are already referred to the
509/// mean equator/equinox of date (for example the low-precision analytic Sun/Moon
510/// series in [`crate::astro::bodies::sun_moon`], whose mean longitude and obliquity are
511/// of-date), so precession is not applied a second time. It mirrors the
512/// `eci2ecef` (GMST/GAST + nutation) rotation those series are designed to be
513/// consumed with, but uses the crate's IAU 2000A nutation and GAST.
514pub fn mean_of_date_to_itrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
515    validate_time_scales(ts)?;
516    validate_mat3(
517        "mean_of_date_to_itrs_matrix",
518        mean_of_date_to_itrs_matrix_unchecked(ts),
519    )
520}
521
522fn mean_of_date_to_itrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
523    let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
524    let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
525    let true_ob = mean_ob + deps;
526
527    let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
528    let gast = gast_radians(ts, dpsi);
529    let r_gast = build_rot_z(-gast);
530
531    // mean-of-date -> ITRS = R_z(-GAST) * N
532    inline_rxr(&r_gast, &n)
533}
534
535/// Mean-of-date to ITRS rotation with explicit polar motion.
536pub fn mean_of_date_to_itrs_matrix_with_polar_motion(
537    ts: &TimeScales,
538    pole: PolarMotion,
539) -> Result<Mat3, FrameTransformError> {
540    validate_time_scales(ts)?;
541    validate_polar_motion(pole)?;
542    validate_mat3(
543        "mean_of_date_to_itrs_matrix",
544        mean_of_date_to_itrs_matrix_with_polar_motion_unchecked(ts, pole),
545    )
546}
547
548fn mean_of_date_to_itrs_matrix_with_polar_motion_unchecked(
549    ts: &TimeScales,
550    pole: PolarMotion,
551) -> Mat3 {
552    apply_polar_motion_to_itrs_matrix(mean_of_date_to_itrs_matrix_unchecked(ts), pole)
553}
554
555/// Core GCRS->ITRS transform. Returns (x, y, z) in km.
556pub fn gcrs_to_itrs_compute(
557    x: f64,
558    y: f64,
559    z: f64,
560    ts: &TimeScales,
561    skyfield_compat: bool,
562) -> Result<(f64, f64, f64), FrameTransformError> {
563    validate_vec3("gcrs_position_km", &[x, y, z])?;
564    validate_time_scales(ts)?;
565    validate_tuple3(
566        "itrs_position_km",
567        gcrs_to_itrs_compute_unchecked(x, y, z, ts, skyfield_compat),
568    )
569}
570
571fn gcrs_to_itrs_compute_unchecked(
572    x: f64,
573    y: f64,
574    z: f64,
575    ts: &TimeScales,
576    skyfield_compat: bool,
577) -> (f64, f64, f64) {
578    let mat = gcrs_to_itrs_matrix_unchecked(ts);
579
580    if skyfield_compat {
581        // Skyfield: mxv(R, pos_au) in AU, then convert to km.
582        // For ITRS, scalar (non-FMA) multiply matches einsum's rounding.
583        // (Unlike TEME->GCRS where FMA is needed -- the difference is due to
584        // the specific matrix/vector values and how rounding interacts.)
585        let pos_au = [x / AU_KM, y / AU_KM, z / AU_KM];
586        let r = mat3_vec3_mul_unchecked(&mat, &pos_au);
587        (r[0] * AU_KM, r[1] * AU_KM, r[2] * AU_KM)
588    } else {
589        let pos = [x, y, z];
590        let r = mat3_vec3_mul_unchecked(&mat, &pos);
591        (r[0], r[1], r[2])
592    }
593}
594
595/// Core GCRS->ITRS transform with explicit polar motion.
596pub fn gcrs_to_itrs_compute_with_polar_motion(
597    x: f64,
598    y: f64,
599    z: f64,
600    ts: &TimeScales,
601    skyfield_compat: bool,
602    pole: PolarMotion,
603) -> Result<(f64, f64, f64), FrameTransformError> {
604    validate_vec3("gcrs_position_km", &[x, y, z])?;
605    validate_time_scales(ts)?;
606    validate_polar_motion(pole)?;
607    validate_tuple3(
608        "itrs_position_km",
609        gcrs_to_itrs_compute_with_polar_motion_unchecked(x, y, z, ts, skyfield_compat, pole),
610    )
611}
612
613fn gcrs_to_itrs_compute_with_polar_motion_unchecked(
614    x: f64,
615    y: f64,
616    z: f64,
617    ts: &TimeScales,
618    skyfield_compat: bool,
619    pole: PolarMotion,
620) -> (f64, f64, f64) {
621    let mat = gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole);
622
623    if skyfield_compat {
624        let pos_au = [x / AU_KM, y / AU_KM, z / AU_KM];
625        let r = mat3_vec3_mul_unchecked(&mat, &pos_au);
626        (r[0] * AU_KM, r[1] * AU_KM, r[2] * AU_KM)
627    } else {
628        let pos = [x, y, z];
629        let r = mat3_vec3_mul_unchecked(&mat, &pos);
630        (r[0], r[1], r[2])
631    }
632}
633
634// ---------------------------------------------------------------------------
635// ITRS -> GCRS (Earth-fixed / ECEF back to inertial)
636// ---------------------------------------------------------------------------
637
638/// Build the ITRS->GCRS rotation matrix for a given time.
639///
640/// This is the transpose of [`gcrs_to_itrs_matrix`]: the same precession,
641/// nutation, frame-bias, and Earth-rotation pipeline, taken the other way.
642pub fn itrs_to_gcrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
643    validate_time_scales(ts)?;
644    validate_mat3("itrs_to_gcrs_matrix", itrs_to_gcrs_matrix_unchecked(ts))
645}
646
647fn itrs_to_gcrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
648    inline_tr(&gcrs_to_itrs_matrix_unchecked(ts))
649}
650
651/// Build the ITRS->GCRS rotation matrix with explicit polar motion.
652pub fn itrs_to_gcrs_matrix_with_polar_motion(
653    ts: &TimeScales,
654    pole: PolarMotion,
655) -> Result<Mat3, FrameTransformError> {
656    validate_time_scales(ts)?;
657    validate_polar_motion(pole)?;
658    validate_mat3(
659        "itrs_to_gcrs_matrix",
660        itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts, pole),
661    )
662}
663
664fn itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts: &TimeScales, pole: PolarMotion) -> Mat3 {
665    inline_tr(&gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole))
666}
667
668/// Core ITRS->GCRS transform. Returns (x, y, z) in km.
669///
670/// Uses the plain (non-FMA, no AU round-trip) km path. The Skyfield AU-scaled
671/// `mul_add` path is reserved for the GCRS->ITRS / TEME->GCRS directions that
672/// carry the 0-ULP parity contract; this reverse direction is an ordinary
673/// matrix-vector product.
674pub fn itrs_to_gcrs_compute(
675    x: f64,
676    y: f64,
677    z: f64,
678    ts: &TimeScales,
679) -> Result<(f64, f64, f64), FrameTransformError> {
680    validate_vec3("itrs_position_km", &[x, y, z])?;
681    validate_time_scales(ts)?;
682    validate_tuple3(
683        "gcrs_position_km",
684        itrs_to_gcrs_compute_unchecked(x, y, z, ts),
685    )
686}
687
688fn itrs_to_gcrs_compute_unchecked(x: f64, y: f64, z: f64, ts: &TimeScales) -> (f64, f64, f64) {
689    let mat = itrs_to_gcrs_matrix_unchecked(ts);
690    let r = mat3_vec3_mul_unchecked(&mat, &[x, y, z]);
691    (r[0], r[1], r[2])
692}
693
694/// Core ITRS->GCRS transform with explicit polar motion.
695pub fn itrs_to_gcrs_compute_with_polar_motion(
696    x: f64,
697    y: f64,
698    z: f64,
699    ts: &TimeScales,
700    pole: PolarMotion,
701) -> Result<(f64, f64, f64), FrameTransformError> {
702    validate_vec3("itrs_position_km", &[x, y, z])?;
703    validate_time_scales(ts)?;
704    validate_polar_motion(pole)?;
705    validate_tuple3(
706        "gcrs_position_km",
707        itrs_to_gcrs_compute_with_polar_motion_unchecked(x, y, z, ts, pole),
708    )
709}
710
711fn itrs_to_gcrs_compute_with_polar_motion_unchecked(
712    x: f64,
713    y: f64,
714    z: f64,
715    ts: &TimeScales,
716    pole: PolarMotion,
717) -> (f64, f64, f64) {
718    let mat = itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts, pole);
719    let r = mat3_vec3_mul_unchecked(&mat, &[x, y, z]);
720    (r[0], r[1], r[2])
721}
722
723// ---------------------------------------------------------------------------
724// ITRS -> Geodetic (WGS84 lat/lon/alt)
725// ---------------------------------------------------------------------------
726
727/// Convert ECEF/ITRS (km) to geodetic coordinates.
728/// Returns (latitude_deg, longitude_deg, altitude_km).
729///
730/// Replicates Skyfield's exact algorithm (wgs84.subpoint / _compute_latitude)
731/// which works in AU with exactly 3 iterations.
732pub fn itrs_to_geodetic_compute(
733    x: f64,
734    y: f64,
735    z: f64,
736) -> Result<(f64, f64, f64), FrameTransformError> {
737    validate_vec3("itrs_position_km", &[x, y, z])?;
738    validate_tuple3("geodetic", itrs_to_geodetic_compute_unchecked(x, y, z))
739}
740
741fn itrs_to_geodetic_compute_unchecked(x: f64, y: f64, z: f64) -> (f64, f64, f64) {
742    // Convert to AU to match Skyfield's computation path.
743    let x_au = x / AU_KM;
744    let y_au = y / AU_KM;
745    let z_au = z / AU_KM;
746
747    let a_au = WGS84_A_KM / AU_KM; // Earth equatorial radius in AU
748    let r_xy = (x_au * x_au + y_au * y_au).sqrt();
749
750    // Longitude: match Skyfield's exact normalization:
751    // (arctan2(y, x) - pi) % tau - pi
752    // Python's % always returns positive; Rust's can be negative.
753    let lon_raw = y_au.atan2(x_au);
754    let pi = std::f64::consts::PI;
755    let mut lon_shifted = (lon_raw - pi) % TAU;
756    if lon_shifted < 0.0 {
757        lon_shifted += TAU;
758    }
759    let lon = lon_shifted - pi;
760
761    // Latitude: 3 iterations matching Skyfield exactly
762    let mut lat = z_au.atan2(r_xy);
763    let mut a_c = 0.0_f64;
764    let mut hyp = 0.0_f64;
765
766    for _ in 0..3 {
767        let sin_lat = lat.sin();
768        let e2_sin_lat = WGS84_E2 * sin_lat;
769        a_c = a_au / (1.0 - e2_sin_lat * sin_lat).sqrt();
770        hyp = z_au + a_c * e2_sin_lat;
771        lat = hyp.atan2(r_xy);
772    }
773
774    // Elevation in AU, then convert to km
775    let height_au = (hyp * hyp + r_xy * r_xy).sqrt() - a_c;
776    let alt = height_au * AU_KM;
777
778    // Skyfield's Angle.degrees uses: radians * 360.0 / tau
779    // This gives different rounding than radians * (180.0 / PI).
780    (lat * 360.0 / TAU, lon * 360.0 / TAU, alt)
781}
782
783fn proj_normal_radius_of_curvature(sinphi: f64) -> f64 {
784    if PROJ_WGS84_ES == 0.0 {
785        return PROJ_WGS84_A_M;
786    }
787    PROJ_WGS84_A_M / (1.0 - (PROJ_WGS84_ES * sinphi) * sinphi).sqrt()
788}
789
790fn proj_geocentric_radius(cosphi: f64, sinphi: f64) -> f64 {
791    ((PROJ_WGS84_A_M * PROJ_WGS84_A_M) * cosphi).hypot((PROJ_WGS84_B_M * PROJ_WGS84_B_M) * sinphi)
792        / (PROJ_WGS84_A_M * cosphi).hypot(PROJ_WGS84_B_M * sinphi)
793}
794
795/// Convert ECEF meters to `(longitude_degrees, latitude_degrees, altitude_m)`.
796///
797/// This is an additive PROJ parity variant and does not replace
798/// [`itrs_to_geodetic_compute`]. It matches pyproj 3.6.1 / PROJ 9.3.0 for
799/// `EPSG:4978 -> EPSG:4979` with `always_xy=True`; its Tier 1 bit fixture is
800/// `crates/sidereon-core/tests/fixtures/geodetic/geodetic_proj.json`, generated
801/// by `crates/sidereon-core/fixtures-generators/generate_geodetic_proj.py`.
802pub fn geodetic_from_ecef_proj(x: f64, y: f64, z: f64) -> Result<[f64; 3], FrameTransformError> {
803    validate_vec3("ecef_m", &[x, y, z])?;
804    validate_array3("geodetic_proj", geodetic_from_ecef_proj_unchecked(x, y, z))
805}
806
807fn geodetic_from_ecef_proj_unchecked(x: f64, y: f64, z: f64) -> [f64; 3] {
808    let p = x.hypot(y);
809
810    let y_theta = z * PROJ_WGS84_A_M;
811    let x_theta = p * PROJ_WGS84_B_M;
812    let norm = y_theta.hypot(x_theta);
813    let c = if norm == 0.0 { 1.0 } else { x_theta / norm };
814    let s = if norm == 0.0 { 0.0 } else { y_theta / norm };
815
816    let y_phi = z + ((((PROJ_WGS84_E2S * PROJ_WGS84_B_M) * s) * s) * s);
817    let x_phi = p - ((((PROJ_WGS84_ES * PROJ_WGS84_A_M) * c) * c) * c);
818    let norm_phi = y_phi.hypot(x_phi);
819    let mut cosphi = if norm_phi == 0.0 {
820        1.0
821    } else {
822        x_phi / norm_phi
823    };
824    let mut sinphi = if norm_phi == 0.0 {
825        0.0
826    } else {
827        y_phi / norm_phi
828    };
829
830    let phi = if x_phi <= 0.0 {
831        cosphi = 0.0;
832        if z >= 0.0 {
833            sinphi = 1.0;
834            PROJ_HALF_PI
835        } else {
836            sinphi = -1.0;
837            -PROJ_HALF_PI
838        }
839    } else {
840        (y_phi / x_phi).atan()
841    };
842
843    let lam = y.atan2(x);
844    let alt = if cosphi < 1e-6 {
845        z.abs() - proj_geocentric_radius(cosphi, sinphi)
846    } else {
847        p / cosphi - proj_normal_radius_of_curvature(sinphi)
848    };
849
850    [lam * PROJ_RAD_TO_DEG, phi * PROJ_RAD_TO_DEG, alt]
851}
852
853// ---------------------------------------------------------------------------
854// Topocentric (az/el/range) from ground station to satellite
855// ---------------------------------------------------------------------------
856
857/// Convert geodetic (lat_deg, lon_deg, alt_km) to ECEF/ITRS (km).
858pub fn geodetic_to_itrs(
859    lat_deg: f64,
860    lon_deg: f64,
861    alt_km: f64,
862) -> Result<(f64, f64, f64), FrameTransformError> {
863    validate_geodetic_degrees_km(lat_deg, lon_deg, alt_km)?;
864    validate_tuple3(
865        "itrs_position_km",
866        geodetic_to_itrs_unchecked(lat_deg, lon_deg, alt_km),
867    )
868}
869
870fn geodetic_to_itrs_unchecked(lat_deg: f64, lon_deg: f64, alt_km: f64) -> (f64, f64, f64) {
871    let lat = lat_deg.to_radians();
872    let lon = lon_deg.to_radians();
873
874    let sin_lat = lat.sin();
875    let cos_lat = lat.cos();
876    let sin_lon = lon.sin();
877    let cos_lon = lon.cos();
878
879    let n = WGS84_A_KM / (1.0 - WGS84_E2 * sin_lat * sin_lat).sqrt();
880
881    let x = (n + alt_km) * cos_lat * cos_lon;
882    let y = (n + alt_km) * cos_lat * sin_lon;
883    let z = (n * (1.0 - WGS84_E2) + alt_km) * sin_lat;
884
885    (x, y, z)
886}
887
888/// Compute station ECEF/ITRS position directly in AU.
889/// Matches Skyfield's Geoid.latlon which works in AU from the start,
890/// avoiding the km->AU_KM division that introduces 1 ULP rounding.
891fn geodetic_to_itrs_au(lat_deg: f64, lon_deg: f64, alt_km: f64) -> [f64; 3] {
892    let lat = lat_deg * TAU / 360.0;
893    let lon = lon_deg * TAU / 360.0;
894
895    let sinphi = lat.sin();
896    let cosphi = lat.cos();
897
898    let radius_au = WGS84_A_KM / AU_KM;
899    let elevation_au = alt_km / AU_KM;
900
901    let omf2 = (1.0 - WGS84_F) * (1.0 - WGS84_F);
902    let c = 1.0 / (cosphi * cosphi + sinphi * sinphi * omf2).sqrt();
903    let s = omf2 * c;
904
905    let radius_xy = radius_au * c;
906    let xy = (radius_xy + elevation_au) * cosphi;
907    let x = xy * lon.cos();
908    let y = xy * lon.sin();
909
910    let radius_z = radius_au * s;
911    let z = (radius_z + elevation_au) * sinphi;
912
913    [x, y, z]
914}
915
916/// Build the ECEF->ENU rotation matrix for a given geodetic position.
917fn ecef_to_enu_matrix(lat_deg: f64, lon_deg: f64) -> Mat3 {
918    let lat = lat_deg.to_radians();
919    let lon = lon_deg.to_radians();
920
921    let sin_lat = lat.sin();
922    let cos_lat = lat.cos();
923    let sin_lon = lon.sin();
924    let cos_lon = lon.cos();
925
926    // ENU rotation matrix:
927    // E = [-sin(lon),           cos(lon),          0       ]
928    // N = [-sin(lat)*cos(lon), -sin(lat)*sin(lon), cos(lat)]
929    // U = [ cos(lat)*cos(lon),  cos(lat)*sin(lon), sin(lat)]
930    [
931        [-sin_lon, cos_lon, 0.0],
932        [-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat],
933        [cos_lat * cos_lon, cos_lat * sin_lon, sin_lat],
934    ]
935}
936
937/// Compute topocentric az/el/range from a ground station to a satellite.
938///
939/// Returns (azimuth_deg, elevation_deg, range_km).
940pub fn gcrs_to_topocentric_compute(
941    sat_gcrs_km: [f64; 3],
942    station: &GeodeticStationKm,
943    ts: &TimeScales,
944    skyfield_compat: bool,
945) -> Result<(f64, f64, f64), FrameTransformError> {
946    validate_vec3("sat_gcrs_km", &sat_gcrs_km)?;
947    validate_geodetic_degrees_km(
948        station.latitude_deg,
949        station.longitude_deg,
950        station.altitude_km,
951    )?;
952    validate_time_scales(ts)?;
953    validate_tuple3(
954        "topocentric",
955        gcrs_to_topocentric_compute_unchecked(sat_gcrs_km, station, ts, skyfield_compat),
956    )
957}
958
959fn gcrs_to_topocentric_compute_unchecked(
960    sat_gcrs_km: [f64; 3],
961    station: &GeodeticStationKm,
962    ts: &TimeScales,
963    skyfield_compat: bool,
964) -> (f64, f64, f64) {
965    let [sat_x, sat_y, sat_z] = sat_gcrs_km;
966    let station_lat_deg = station.latitude_deg;
967    let station_lon_deg = station.longitude_deg;
968    let station_alt_km = station.altitude_km;
969    if skyfield_compat {
970        return gcrs_to_topocentric_skyfield(
971            sat_x,
972            sat_y,
973            sat_z,
974            station_lat_deg,
975            station_lon_deg,
976            station_alt_km,
977            ts,
978        );
979    }
980
981    // Standard path: GCRS->ITRS->subtract->ENU
982    let (sat_itrs_x, sat_itrs_y, sat_itrs_z) =
983        gcrs_to_itrs_compute_unchecked(sat_x, sat_y, sat_z, ts, false);
984
985    let (stn_x, stn_y, stn_z) =
986        geodetic_to_itrs_unchecked(station_lat_deg, station_lon_deg, station_alt_km);
987
988    let dx = sat_itrs_x - stn_x;
989    let dy = sat_itrs_y - stn_y;
990    let dz = sat_itrs_z - stn_z;
991
992    let enu_mat = ecef_to_enu_matrix(station_lat_deg, station_lon_deg);
993    let enu = mat3_vec3_mul_unchecked(&enu_mat, &[dx, dy, dz]);
994    let east = enu[0];
995    let north = enu[1];
996    let up = enu[2];
997
998    // Range
999    let range = (east * east + north * north + up * up).sqrt();
1000
1001    // Elevation
1002    let elevation = (up / range).asin().to_degrees();
1003
1004    // Azimuth (measured clockwise from north)
1005    let mut azimuth = east.atan2(north).to_degrees();
1006    if azimuth < 0.0 {
1007        azimuth += 360.0;
1008    }
1009
1010    (azimuth, elevation, range)
1011}
1012
1013/// Skyfield-compatible topocentric: stays in GCRS AU the entire time.
1014///
1015/// Replicates Skyfield's altaz computation:
1016/// 1. R_lat = rot_y(lat)[::-1]  (row-reversed Y rotation)
1017/// 2. R_latlon = mxm(R_lat, rot_z(-lon))
1018/// 3. R_full = mxm(R_latlon, itrs_rotation)
1019/// 4. station_gcrs_au = transpose(itrs_rotation) * station_itrs_au
1020/// 5. diff_au = sat_gcrs_au - station_gcrs_au
1021/// 6. enu_au = mxv(R_full, diff_au)
1022/// 7. to_spherical(enu_au) -> (range_au, elevation_rad, azimuth_rad)
1023fn gcrs_to_topocentric_skyfield(
1024    sat_x: f64,
1025    sat_y: f64,
1026    sat_z: f64,
1027    station_lat_deg: f64,
1028    station_lon_deg: f64,
1029    station_alt_km: f64,
1030    ts: &TimeScales,
1031) -> (f64, f64, f64) {
1032    let lat_rad = station_lat_deg * TAU / 360.0;
1033    let lon_rad = station_lon_deg * TAU / 360.0;
1034
1035    // Build R_lat = rot_y(lat)[::-1]  (rows reversed)
1036    let cy = lat_rad.cos();
1037    let sy = lat_rad.sin();
1038    // rot_y(lat) = [[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]]
1039    // [::-1] reverses rows: [[-sy, 0, cy], [0, 1, 0], [cy, 0, sy]]
1040    let r_lat: Mat3 = [[-sy, 0.0, cy], [0.0, 1.0, 0.0], [cy, 0.0, sy]];
1041
1042    // R_latlon = mxm(R_lat, rot_z(-lon))
1043    let rz_neg_lon = build_rot_z(-lon_rad);
1044    let r_latlon = inline_rxr(&r_lat, &rz_neg_lon);
1045
1046    // R_full = mxm(R_latlon, itrs_rotation)
1047    let r_itrs = gcrs_to_itrs_matrix_unchecked(ts);
1048    let r_full = inline_rxr(&r_latlon, &r_itrs);
1049
1050    // Station ITRS position directly in AU, matching Skyfield's Geoid.latlon
1051    // which computes in AU from the start (not km then / AU_KM).
1052    let stn_itrs_au = geodetic_to_itrs_au(station_lat_deg, station_lon_deg, station_alt_km);
1053
1054    // Station GCRS AU = transpose(R_itrs) * station_itrs_au
1055    let r_itrs_t = inline_tr(&r_itrs);
1056    let stn_gcrs_au = mat3_vec3_mul_unchecked(&r_itrs_t, &stn_itrs_au);
1057
1058    // Satellite GCRS in AU
1059    let sat_au = [sat_x / AU_KM, sat_y / AU_KM, sat_z / AU_KM];
1060
1061    // Difference vector in GCRS AU
1062    let diff_au = [
1063        sat_au[0] - stn_gcrs_au[0],
1064        sat_au[1] - stn_gcrs_au[1],
1065        sat_au[2] - stn_gcrs_au[2],
1066    ];
1067
1068    // Rotate to ENU-ish frame: mxv(R_full, diff_au)
1069    let enu_au = mat3_vec3_mul_unchecked(&r_full, &diff_au);
1070
1071    // to_spherical: r, theta (elevation), phi (azimuth)
1072    let ex = enu_au[0];
1073    let ey = enu_au[1];
1074    let ez = enu_au[2];
1075
1076    let r_au = (ex * ex + ey * ey + ez * ez).sqrt();
1077    let elevation_rad = ez.atan2((ex * ex + ey * ey).sqrt());
1078    let mut azimuth_rad = ey.atan2(ex) % TAU;
1079    if azimuth_rad < 0.0 {
1080        azimuth_rad += TAU;
1081    }
1082
1083    let range_km = r_au * AU_KM;
1084    let elevation_deg = elevation_rad * 360.0 / TAU;
1085    let azimuth_deg = azimuth_rad * 360.0 / TAU;
1086
1087    (azimuth_deg, elevation_deg, range_km)
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093    use crate::astro::time::scales::TimeScales;
1094
1095    fn assert_mat3_bits_eq(actual: &Mat3, expected: &Mat3) {
1096        for i in 0..3 {
1097            for j in 0..3 {
1098                assert_eq!(
1099                    actual[i][j].to_bits(),
1100                    expected[i][j].to_bits(),
1101                    "matrix[{i}][{j}]: {} vs {}",
1102                    actual[i][j],
1103                    expected[i][j]
1104                );
1105            }
1106        }
1107    }
1108
1109    fn assert_vec3_bits_eq(actual: [f64; 3], expected: [f64; 3]) {
1110        for i in 0..3 {
1111            assert_eq!(
1112                actual[i].to_bits(),
1113                expected[i].to_bits(),
1114                "vector[{i}]: {} vs {}",
1115                actual[i],
1116                expected[i]
1117            );
1118        }
1119    }
1120
1121    #[test]
1122    fn itrs_to_gcrs_inverts_gcrs_to_itrs() {
1123        // On a real epoch and a real-magnitude ECI vector, ITRS->GCRS recovers
1124        // the GCRS->ITRS input bit-for-bit on the plain (non-Skyfield) km path:
1125        // the two directions must be exact transposes, not just approximately so.
1126        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1127        let (x, y, z) = (4321.0_f64, -5678.0, 3210.0);
1128
1129        let (ix, iy, iz) =
1130            gcrs_to_itrs_compute(x, y, z, &ts, false).expect("valid frame transform");
1131        // The rotation actually moved the vector (it is not a no-op).
1132        assert!(((ix - x).abs() + (iy - y).abs() + (iz - z).abs()) > 100.0);
1133
1134        let (bx, by, bz) = itrs_to_gcrs_compute(ix, iy, iz, &ts).expect("valid frame transform");
1135        assert!((bx - x).abs() < 1e-9, "x {bx} vs {x}");
1136        assert!((by - y).abs() < 1e-9, "y {by} vs {y}");
1137        assert!((bz - z).abs() < 1e-9, "z {bz} vs {z}");
1138
1139        // Magnitude is preserved by the rotation.
1140        let n0 = (x * x + y * y + z * z).sqrt();
1141        let n1 = (ix * ix + iy * iy + iz * iz).sqrt();
1142        assert!((n0 - n1).abs() < 1e-9);
1143    }
1144
1145    #[test]
1146    fn polar_motion_matrix_matches_documented_convention() {
1147        let pole = PolarMotion::from_arcseconds(0.25, -0.35).expect("valid polar motion");
1148        let cx = pole.xp_rad.cos();
1149        let sx = pole.xp_rad.sin();
1150        let cy = pole.yp_rad.cos();
1151        let sy = pole.yp_rad.sin();
1152
1153        let expected = [
1154            [cx, sx * sy, sx * cy],
1155            [0.0, cy, -sy],
1156            [-sx, cx * sy, cx * cy],
1157        ];
1158        let got = polar_motion_matrix(pole).expect("valid polar motion matrix");
1159        assert_mat3_bits_eq(&got, &expected);
1160
1161        let small_angle = [
1162            [1.0, 0.0, pole.xp_rad],
1163            [0.0, 1.0, -pole.yp_rad],
1164            [-pole.xp_rad, pole.yp_rad, 1.0],
1165        ];
1166        for i in 0..3 {
1167            for j in 0..3 {
1168                assert!(
1169                    (got[i][j] - small_angle[i][j]).abs() < 1.0e-11,
1170                    "matrix[{i}][{j}] {} vs small-angle {}",
1171                    got[i][j],
1172                    small_angle[i][j]
1173                );
1174            }
1175        }
1176    }
1177
1178    #[test]
1179    fn gcrs_to_itrs_with_polar_motion_premultiplies_legacy_rotation() {
1180        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1181        let pole = PolarMotion::from_arcseconds(0.18, -0.24).expect("valid polar motion");
1182        let legacy = gcrs_to_itrs_matrix(&ts).expect("valid frame transform");
1183        let expected = inline_rxr(
1184            &polar_motion_matrix(pole).expect("valid polar motion matrix"),
1185            &legacy,
1186        );
1187        let got = gcrs_to_itrs_matrix_with_polar_motion(&ts, pole).expect("valid frame transform");
1188
1189        assert_mat3_bits_eq(&got, &expected);
1190
1191        let pos = [4321.0_f64, -5678.0, 3210.0];
1192        let actual_vec =
1193            gcrs_to_itrs_compute_with_polar_motion(pos[0], pos[1], pos[2], &ts, false, pole)
1194                .expect("valid frame transform");
1195        let expected_vec = mat3_vec3_mul(&expected, &pos).expect("finite matrix-vector product");
1196        assert_vec3_bits_eq([actual_vec.0, actual_vec.1, actual_vec.2], expected_vec);
1197
1198        let legacy_vec =
1199            gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, false).expect("valid transform");
1200        let delta = (actual_vec.0 - legacy_vec.0).abs()
1201            + (actual_vec.1 - legacy_vec.1).abs()
1202            + (actual_vec.2 - legacy_vec.2).abs();
1203        assert!(
1204            delta > 1.0e-4,
1205            "nonzero polar motion should move the vector"
1206        );
1207
1208        let inverse =
1209            itrs_to_gcrs_matrix_with_polar_motion(&ts, pole).expect("valid frame transform");
1210        assert_mat3_bits_eq(&inverse, &inline_tr(&got));
1211    }
1212
1213    #[test]
1214    fn zero_polar_motion_matches_legacy_transform_bits() {
1215        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1216        let legacy = gcrs_to_itrs_matrix(&ts).expect("valid frame transform");
1217        let zero = gcrs_to_itrs_matrix_with_polar_motion(&ts, PolarMotion::ZERO)
1218            .expect("valid frame transform");
1219        assert_mat3_bits_eq(&zero, &legacy);
1220
1221        let mean_legacy = mean_of_date_to_itrs_matrix(&ts).expect("valid frame transform");
1222        let mean_zero = mean_of_date_to_itrs_matrix_with_polar_motion(&ts, PolarMotion::ZERO)
1223            .expect("valid frame transform");
1224        assert_mat3_bits_eq(&mean_zero, &mean_legacy);
1225
1226        let pos = [4321.0_f64, -5678.0, 3210.0];
1227        for skyfield_compat in [false, true] {
1228            let legacy_vec = gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, skyfield_compat)
1229                .expect("valid frame transform");
1230            let zero_vec = gcrs_to_itrs_compute_with_polar_motion(
1231                pos[0],
1232                pos[1],
1233                pos[2],
1234                &ts,
1235                skyfield_compat,
1236                PolarMotion::ZERO,
1237            )
1238            .expect("valid frame transform");
1239            assert_vec3_bits_eq(
1240                [zero_vec.0, zero_vec.1, zero_vec.2],
1241                [legacy_vec.0, legacy_vec.1, legacy_vec.2],
1242            );
1243        }
1244
1245        let legacy_back =
1246            itrs_to_gcrs_compute(pos[0], pos[1], pos[2], &ts).expect("valid frame transform");
1247        let zero_back =
1248            itrs_to_gcrs_compute_with_polar_motion(pos[0], pos[1], pos[2], &ts, PolarMotion::ZERO)
1249                .expect("valid frame transform");
1250        assert_vec3_bits_eq(
1251            [zero_back.0, zero_back.1, zero_back.2],
1252            [legacy_back.0, legacy_back.1, legacy_back.2],
1253        );
1254    }
1255
1256    #[test]
1257    fn frame_transforms_reject_nonfinite_time() {
1258        let mut ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1259        ts.jd_tt = f64::NAN;
1260
1261        assert!(greenwich_mean_sidereal_time_radians(&ts).is_err());
1262        assert!(gcrs_to_itrs_matrix(&ts).is_err());
1263        assert!(itrs_to_gcrs_compute(1.0, 2.0, 3.0, &ts).is_err());
1264    }
1265
1266    #[test]
1267    fn frame_transforms_reject_nonfinite_pole_coordinates() {
1268        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1269        assert!(PolarMotion::from_radians(f64::NAN, 0.0).is_err());
1270        assert!(PolarMotion::from_arcseconds(0.0, f64::INFINITY).is_err());
1271
1272        let pole = PolarMotion {
1273            xp_rad: f64::NAN,
1274            yp_rad: 0.0,
1275        };
1276        assert!(polar_motion_matrix(pole).is_err());
1277        assert!(gcrs_to_itrs_matrix_with_polar_motion(&ts, pole).is_err());
1278    }
1279
1280    #[test]
1281    fn frame_transforms_reject_nonfinite_vectors() {
1282        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1283        let bad_state = TemeStateKm {
1284            position_km: [1.0, f64::NAN, 3.0],
1285            velocity_km_s: [0.1, 0.2, 0.3],
1286        };
1287        assert!(teme_to_gcrs_compute(&bad_state, &ts, false).is_err());
1288        assert!(gcrs_to_itrs_compute(1.0, f64::INFINITY, 3.0, &ts, false).is_err());
1289        assert!(itrs_to_gcrs_compute(1.0, 2.0, f64::NEG_INFINITY, &ts).is_err());
1290    }
1291
1292    #[test]
1293    fn validated_frame_transform_preserves_valid_bits() {
1294        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1295        let pos = [4321.0_f64, -5678.0, 3210.0];
1296        let expected = gcrs_to_itrs_compute_unchecked(pos[0], pos[1], pos[2], &ts, true);
1297        let got =
1298            gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, true).expect("valid frame transform");
1299        assert_vec3_bits_eq([got.0, got.1, got.2], [expected.0, expected.1, expected.2]);
1300    }
1301
1302    #[test]
1303    fn geodetic_transforms_reject_invalid_coordinates() {
1304        assert!(itrs_to_geodetic_compute(f64::NAN, 0.0, 0.0).is_err());
1305        assert!(geodetic_from_ecef_proj(0.0, f64::INFINITY, 0.0).is_err());
1306        assert!(geodetic_to_itrs(90.000_001, 0.0, 0.0).is_err());
1307        assert!(geodetic_to_itrs(0.0, -180.000_001, 0.0).is_err());
1308        assert!(geodetic_to_itrs(0.0, 0.0, f64::NAN).is_err());
1309    }
1310
1311    #[test]
1312    fn topocentric_transform_rejects_invalid_coordinates() {
1313        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1314        let station = GeodeticStationKm {
1315            latitude_deg: f64::NAN,
1316            longitude_deg: 0.0,
1317            altitude_km: 0.0,
1318        };
1319        assert!(gcrs_to_topocentric_compute([7000.0, 0.0, 0.0], &station, &ts, false).is_err());
1320
1321        let station = GeodeticStationKm {
1322            latitude_deg: 0.0,
1323            longitude_deg: 181.0,
1324            altitude_km: 0.0,
1325        };
1326        assert!(gcrs_to_topocentric_compute([7000.0, 0.0, 0.0], &station, &ts, false).is_err());
1327
1328        let station = GeodeticStationKm {
1329            latitude_deg: 0.0,
1330            longitude_deg: 0.0,
1331            altitude_km: 0.0,
1332        };
1333        assert!(
1334            gcrs_to_topocentric_compute([7000.0, f64::NAN, 0.0], &station, &ts, false).is_err()
1335        );
1336    }
1337
1338    #[test]
1339    fn validated_geodetic_transform_preserves_valid_bits() {
1340        let (lat, lon, alt) = (51.4779, -0.0015, 0.046);
1341        let expected = geodetic_to_itrs_unchecked(lat, lon, alt);
1342        let got = geodetic_to_itrs(lat, lon, alt).expect("valid geodetic coordinates");
1343        assert_eq!(got.0.to_bits(), expected.0.to_bits());
1344        assert_eq!(got.1.to_bits(), expected.1.to_bits());
1345        assert_eq!(got.2.to_bits(), expected.2.to_bits());
1346
1347        let expected = itrs_to_geodetic_compute_unchecked(got.0, got.1, got.2);
1348        let roundtrip =
1349            itrs_to_geodetic_compute(got.0, got.1, got.2).expect("valid ITRS coordinates");
1350        assert_eq!(roundtrip.0.to_bits(), expected.0.to_bits());
1351        assert_eq!(roundtrip.1.to_bits(), expected.1.to_bits());
1352        assert_eq!(roundtrip.2.to_bits(), expected.2.to_bits());
1353    }
1354
1355    #[test]
1356    fn sidereal_time_wrappers_are_in_range_and_consistent() {
1357        let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1358        let gmst = greenwich_mean_sidereal_time_radians(&ts).expect("valid sidereal time");
1359        let gast = greenwich_apparent_sidereal_time_radians(&ts).expect("valid sidereal time");
1360
1361        // Both land in [0, 2pi).
1362        assert!((0.0..TAU).contains(&gmst), "gmst {gmst}");
1363        assert!((0.0..TAU).contains(&gast), "gast {gast}");
1364
1365        // The equation of the equinoxes is a small (sub-arcminute) offset, so the
1366        // apparent and mean sidereal times stay close (handle the seam at 2pi).
1367        let diff = (gast - gmst).rem_euclid(TAU);
1368        let eq_eq = diff.min(TAU - diff);
1369        assert!(eq_eq < 1.0e-3, "equation of equinoxes too large: {eq_eq}");
1370
1371        // The mean wrapper equals the underlying private hours computation exactly.
1372        let gmst_hours = sidereal_time_hours(ts.jd_whole, ts.ut1_fraction, ts.tdb_fraction);
1373        assert_eq!(gmst, gmst_hours / 24.0 * TAU);
1374    }
1375}