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