1use crate::astro::frames::nutation::{
17 build_skyfield_nutation_matrix_unchecked,
18 skyfield_equation_of_the_equinoxes_complimentary_terms_unchecked,
19 skyfield_iau2000a_radians_unchecked, skyfield_mean_obliquity_radians_unchecked,
20};
21use crate::astro::frames::precession::{
22 build_icrs_to_j2000, compute_skyfield_precession_matrix_unchecked,
23};
24use crate::astro::math::mat3::{inline_mxmxm, inline_rxr, inline_tr, Mat3};
25use crate::astro::time::{civil, scales::TimeScales};
26use crate::astro::{
27 constants::astro::AU_KM,
28 constants::earth::{WGS84_A_KM, WGS84_E2, WGS84_F},
29 constants::geometry::AZIMUTH_ZENITH_EPS,
30 constants::models::proj::{
31 HALF_PI as PROJ_HALF_PI, RAD_TO_DEG as PROJ_RAD_TO_DEG, WGS84_A_M as PROJ_WGS84_A_M,
32 WGS84_B_M as PROJ_WGS84_B_M, WGS84_E2S as PROJ_WGS84_E2S, WGS84_ES as PROJ_WGS84_ES,
33 },
34 constants::time::{DAYS_PER_JULIAN_CENTURY, J2000_JD, SECONDS_PER_DAY},
35};
36
37const TAU: f64 = std::f64::consts::TAU;
38const ARCSECONDS_TO_RADIANS: f64 = 4.848_136_811_095_36e-6;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
42pub enum FrameTransformError {
43 #[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
123pub type Vec3 = (f64, f64, f64);
130
131pub struct TemeStateKm {
134 pub position_km: [f64; 3],
135 pub velocity_km_s: [f64; 3],
136}
137
138pub struct GeodeticStationKm {
140 pub latitude_deg: f64,
141 pub longitude_deg: f64,
142 pub altitude_km: f64,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq)]
152pub struct PolarMotion {
153 pub xp_rad: f64,
154 pub yp_rad: f64,
155}
156
157impl PolarMotion {
158 pub const ZERO: Self = Self {
160 xp_rad: 0.0,
161 yp_rad: 0.0,
162 };
163
164 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 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
192fn mat3_vec3_mul_fma(r: &Mat3, p: &[f64; 3]) -> [f64; 3] {
195 let mut result = [0.0_f64; 3];
196 for i in 0..3 {
197 let sum = r[i][0] * p[0];
198 let sum = libm::fma(r[i][1], p[1], sum);
199 let sum = libm::fma(r[i][2], p[2], sum);
200 result[i] = sum;
201 }
202 result
203}
204
205fn build_rot_z(angle: f64) -> Mat3 {
206 let c = libm::cos(angle);
207 let s = libm::sin(angle);
208 [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
209}
210
211pub fn polar_motion_matrix(pole: PolarMotion) -> Result<Mat3, FrameTransformError> {
217 validate_polar_motion(pole)?;
218 validate_mat3("polar_motion_matrix", polar_motion_matrix_unchecked(pole))
219}
220
221fn polar_motion_matrix_unchecked(pole: PolarMotion) -> Mat3 {
222 if pole.is_zero() {
223 return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
224 }
225
226 let cx = libm::cos(pole.xp_rad);
227 let sx = libm::sin(pole.xp_rad);
228 let cy = libm::cos(pole.yp_rad);
229 let sy = libm::sin(pole.yp_rad);
230
231 [
232 [cx, sx * sy, sx * cy],
233 [0.0, cy, -sy],
234 [-sx, cx * sy, cx * cy],
235 ]
236}
237
238fn apply_polar_motion_to_itrs_matrix(mat: Mat3, pole: PolarMotion) -> Mat3 {
239 if pole.is_zero() {
240 mat
241 } else {
242 inline_rxr(&polar_motion_matrix_unchecked(pole), &mat)
243 }
244}
245
246fn earth_rotation_angle(jd_whole: f64, ut1_fraction: f64) -> f64 {
247 let days_since_j2000 = jd_whole - J2000_JD + ut1_fraction;
248 let spins_since_j2000: f64 = {
250 let v = 0.00273781191135448 * days_since_j2000;
251 let v_stored: f64 = v;
253 v_stored
254 };
255 let th = 0.7790572732640 + spins_since_j2000;
256 let mut result = (th % 1.0 + jd_whole % 1.0 + ut1_fraction) % 1.0;
257 if result < 0.0 {
258 result += 1.0;
259 }
260 result
261}
262
263fn compute_theta_gmst1982(jd_whole: f64, ut1_fraction: f64) -> f64 {
264 let t = (jd_whole - J2000_JD + ut1_fraction) / DAYS_PER_JULIAN_CENTURY;
265 let g = 67310.54841 + (8640184.812866 + (0.093104 + (-6.2e-6) * t) * t) * t;
266 let mut theta = ((jd_whole % 1.0) + ut1_fraction + (g / SECONDS_PER_DAY) % 1.0) % 1.0 * TAU;
267 if theta < 0.0 {
268 theta += TAU;
269 }
270 theta
271}
272
273fn sidereal_time_hours(jd_whole: f64, ut1_fraction: f64, tdb_fraction: f64) -> f64 {
274 let theta = earth_rotation_angle(jd_whole, ut1_fraction);
275 let t = (jd_whole - J2000_JD + tdb_fraction) / DAYS_PER_JULIAN_CENTURY;
276 let st = 0.014506
277 + ((((-0.0000000368 * t - 0.000029956) * t - 0.00000044) * t + 1.3915817) * t
278 + 4612.156534)
279 * t;
280 let mut result = (st / 54000.0 + theta * 24.0) % 24.0;
281 if result < 0.0 {
282 result += 24.0;
283 }
284 result
285}
286
287fn gast_radians(ts: &TimeScales, dpsi: f64) -> f64 {
288 let gmst_hours = sidereal_time_hours(ts.jd_whole, ts.ut1_fraction, ts.tdb_fraction);
289 let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
290 let c_terms = skyfield_equation_of_the_equinoxes_complimentary_terms_unchecked(ts.jd_tt);
291 let eq_eq = dpsi * libm::cos(mean_ob) + c_terms;
292 let mut gast_hours = (gmst_hours + eq_eq / TAU * 24.0) % 24.0;
293 if gast_hours < 0.0 {
294 gast_hours += 24.0;
295 }
296 gast_hours / 24.0 * TAU
297}
298
299pub 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
317pub fn greenwich_mean_sidereal_time_radians_from_j2000_seconds(
323 sec: f64,
324) -> Result<f64, FrameTransformError> {
325 validate_finite("sec", sec)?;
326 let (jd_whole, ut1_fraction) = civil::split_julian_date_add_seconds(J2000_JD, 0.0, sec);
327 let mut radians = compute_theta_gmst1982(jd_whole, ut1_fraction) % TAU;
328 if radians < 0.0 {
329 radians += TAU;
330 }
331 validate_finite("gmst_radians", radians)?;
332 Ok(radians)
333}
334
335pub fn greenwich_apparent_sidereal_time_radians(
342 ts: &TimeScales,
343) -> Result<f64, FrameTransformError> {
344 validate_time_scales(ts)?;
345 let radians = greenwich_apparent_sidereal_time_radians_unchecked(ts);
346 validate_finite("gast_radians", radians)?;
347 Ok(radians)
348}
349
350fn greenwich_apparent_sidereal_time_radians_unchecked(ts: &TimeScales) -> f64 {
351 let (dpsi, _deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
352 gast_radians(ts, dpsi)
353}
354
355fn build_teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
357 let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
358 let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
359 let true_ob = mean_ob + deps;
360
361 let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
362 let p = compute_skyfield_precession_matrix_unchecked(ts.jd_tdb);
363 let b = build_icrs_to_j2000();
364
365 let m = if skyfield_compat {
368 inline_mxmxm(&n, &p, &b)
369 } else {
370 let np = inline_rxr(&n, &p);
371 inline_rxr(&np, &b)
372 };
373
374 let gast = gast_radians(ts, dpsi);
375 let theta = compute_theta_gmst1982(ts.jd_whole, ts.ut1_fraction);
376 let angle = theta - gast;
377
378 let r = build_rot_z(angle);
379 let g = inline_rxr(&r, &m);
380 inline_tr(&g)
381}
382
383pub(crate) fn teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
385 build_teme_to_gcrs_matrix(ts, skyfield_compat)
386}
387
388pub fn mat3_vec3_mul(r: &Mat3, p: &[f64; 3]) -> Result<[f64; 3], FrameTransformError> {
390 validate_mat3("matrix", *r)?;
391 validate_vec3("vector", p)?;
392 validate_array3("matrix_vector_product", mat3_vec3_mul_unchecked(r, p))
393}
394
395pub(crate) fn mat3_vec3_mul_unchecked(r: &Mat3, p: &[f64; 3]) -> [f64; 3] {
396 let mut result = [0.0_f64; 3];
397 for i in 0..3 {
398 let mut sum = 0.0;
399 for j in 0..3 {
400 sum += r[i][j] * p[j];
401 }
402 result[i] = sum;
403 }
404 result
405}
406
407pub fn teme_to_gcrs_compute(
409 state: &TemeStateKm,
410 ts: &TimeScales,
411 skyfield_compat: bool,
412) -> Result<(Vec3, Vec3), FrameTransformError> {
413 validate_time_scales(ts)?;
414 validate_vec3("position_km", &state.position_km)?;
415 validate_vec3("velocity_km_s", &state.velocity_km_s)?;
416 let (position, velocity) = teme_to_gcrs_compute_unchecked(state, ts, skyfield_compat);
417 Ok((
418 validate_tuple3("gcrs_position_km", position)?,
419 validate_tuple3("gcrs_velocity_km_s", velocity)?,
420 ))
421}
422
423fn teme_to_gcrs_compute_unchecked(
424 state: &TemeStateKm,
425 ts: &TimeScales,
426 skyfield_compat: bool,
427) -> (Vec3, Vec3) {
428 let [x, y, z] = state.position_km;
429 let [vx, vy, vz] = state.velocity_km_s;
430 let t = build_teme_to_gcrs_matrix(ts, skyfield_compat);
431
432 if skyfield_compat {
433 let r_au = [x / AU_KM, y / AU_KM, z / AU_KM];
435 let r_gcrs_au = mat3_vec3_mul_fma(&t, &r_au);
436 let r_gcrs = (
437 r_gcrs_au[0] * AU_KM,
438 r_gcrs_au[1] * AU_KM,
439 r_gcrs_au[2] * AU_KM,
440 );
441
442 let v_au_d = [
443 vx / AU_KM * SECONDS_PER_DAY,
444 vy / AU_KM * SECONDS_PER_DAY,
445 vz / AU_KM * SECONDS_PER_DAY,
446 ];
447 let v_gcrs_au_d = mat3_vec3_mul_fma(&t, &v_au_d);
448 let v_gcrs = (
449 v_gcrs_au_d[0] * AU_KM / SECONDS_PER_DAY,
450 v_gcrs_au_d[1] * AU_KM / SECONDS_PER_DAY,
451 v_gcrs_au_d[2] * AU_KM / SECONDS_PER_DAY,
452 );
453 (r_gcrs, v_gcrs)
454 } else {
455 let r_teme = [x, y, z];
457 let r_g = mat3_vec3_mul_unchecked(&t, &r_teme);
458 let v_teme = [vx, vy, vz];
459 let v_g = mat3_vec3_mul_unchecked(&t, &v_teme);
460 ((r_g[0], r_g[1], r_g[2]), (v_g[0], v_g[1], v_g[2]))
461 }
462}
463
464pub fn gcrs_to_teme_compute(
470 state: &TemeStateKm,
471 ts: &TimeScales,
472 skyfield_compat: bool,
473) -> Result<(Vec3, Vec3), FrameTransformError> {
474 validate_time_scales(ts)?;
475 validate_vec3("position_km", &state.position_km)?;
476 validate_vec3("velocity_km_s", &state.velocity_km_s)?;
477 let (position, velocity) = gcrs_to_teme_compute_unchecked(state, ts, skyfield_compat);
478 Ok((
479 validate_tuple3("teme_position_km", position)?,
480 validate_tuple3("teme_velocity_km_s", velocity)?,
481 ))
482}
483
484fn gcrs_to_teme_compute_unchecked(
485 state: &TemeStateKm,
486 ts: &TimeScales,
487 skyfield_compat: bool,
488) -> (Vec3, Vec3) {
489 let [x, y, z] = state.position_km;
490 let [vx, vy, vz] = state.velocity_km_s;
491 let t = inline_tr(&build_teme_to_gcrs_matrix(ts, skyfield_compat));
492
493 if skyfield_compat {
494 let r_au = [x / AU_KM, y / AU_KM, z / AU_KM];
495 let r_teme_au = mat3_vec3_mul_fma(&t, &r_au);
496 let r_teme = (
497 r_teme_au[0] * AU_KM,
498 r_teme_au[1] * AU_KM,
499 r_teme_au[2] * AU_KM,
500 );
501
502 let v_au_d = [
503 vx / AU_KM * SECONDS_PER_DAY,
504 vy / AU_KM * SECONDS_PER_DAY,
505 vz / AU_KM * SECONDS_PER_DAY,
506 ];
507 let v_teme_au_d = mat3_vec3_mul_fma(&t, &v_au_d);
508 let v_teme = (
509 v_teme_au_d[0] * AU_KM / SECONDS_PER_DAY,
510 v_teme_au_d[1] * AU_KM / SECONDS_PER_DAY,
511 v_teme_au_d[2] * AU_KM / SECONDS_PER_DAY,
512 );
513 (r_teme, v_teme)
514 } else {
515 let r_gcrs = [x, y, z];
516 let r_t = mat3_vec3_mul_unchecked(&t, &r_gcrs);
517 let v_gcrs = [vx, vy, vz];
518 let v_t = mat3_vec3_mul_unchecked(&t, &v_gcrs);
519 ((r_t[0], r_t[1], r_t[2]), (v_t[0], v_t[1], v_t[2]))
520 }
521}
522
523pub fn gcrs_to_true_of_date_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
532 validate_time_scales(ts)?;
533 let (matrix, _dpsi) = gcrs_to_true_of_date_matrix_parts_unchecked(ts);
534 validate_mat3("gcrs_to_true_of_date_matrix", matrix)
535}
536
537fn gcrs_to_true_of_date_matrix_parts_unchecked(ts: &TimeScales) -> (Mat3, f64) {
538 let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
539 let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
540 let true_ob = mean_ob + deps;
541
542 let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
543 let p = compute_skyfield_precession_matrix_unchecked(ts.jd_tdb);
544 let b = build_icrs_to_j2000();
545
546 (inline_mxmxm(&n, &p, &b), dpsi)
547}
548
549pub fn gcrs_to_itrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
556 validate_time_scales(ts)?;
557 validate_mat3("gcrs_to_itrs_matrix", gcrs_to_itrs_matrix_unchecked(ts))
558}
559
560fn gcrs_to_itrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
561 let (m, dpsi) = gcrs_to_true_of_date_matrix_parts_unchecked(ts);
562 let gast = gast_radians(ts, dpsi);
563
564 let r_gast = build_rot_z(-gast);
566
567 inline_rxr(&r_gast, &m)
569}
570
571pub fn gcrs_to_itrs_matrix_with_polar_motion(
577 ts: &TimeScales,
578 pole: PolarMotion,
579) -> Result<Mat3, FrameTransformError> {
580 validate_time_scales(ts)?;
581 validate_polar_motion(pole)?;
582 validate_mat3(
583 "gcrs_to_itrs_matrix",
584 gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole),
585 )
586}
587
588fn gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts: &TimeScales, pole: PolarMotion) -> Mat3 {
589 apply_polar_motion_to_itrs_matrix(gcrs_to_itrs_matrix_unchecked(ts), pole)
590}
591
592pub fn mean_of_date_to_itrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
604 validate_time_scales(ts)?;
605 validate_mat3(
606 "mean_of_date_to_itrs_matrix",
607 mean_of_date_to_itrs_matrix_unchecked(ts),
608 )
609}
610
611fn mean_of_date_to_itrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
612 let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
613 let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
614 let true_ob = mean_ob + deps;
615
616 let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
617 let gast = gast_radians(ts, dpsi);
618 let r_gast = build_rot_z(-gast);
619
620 inline_rxr(&r_gast, &n)
622}
623
624pub fn mean_of_date_to_itrs_matrix_with_polar_motion(
626 ts: &TimeScales,
627 pole: PolarMotion,
628) -> Result<Mat3, FrameTransformError> {
629 validate_time_scales(ts)?;
630 validate_polar_motion(pole)?;
631 validate_mat3(
632 "mean_of_date_to_itrs_matrix",
633 mean_of_date_to_itrs_matrix_with_polar_motion_unchecked(ts, pole),
634 )
635}
636
637fn mean_of_date_to_itrs_matrix_with_polar_motion_unchecked(
638 ts: &TimeScales,
639 pole: PolarMotion,
640) -> Mat3 {
641 apply_polar_motion_to_itrs_matrix(mean_of_date_to_itrs_matrix_unchecked(ts), pole)
642}
643
644pub fn gcrs_to_itrs_compute(
646 x: f64,
647 y: f64,
648 z: f64,
649 ts: &TimeScales,
650 skyfield_compat: bool,
651) -> Result<(f64, f64, f64), FrameTransformError> {
652 validate_vec3("gcrs_position_km", &[x, y, z])?;
653 validate_time_scales(ts)?;
654 validate_tuple3(
655 "itrs_position_km",
656 gcrs_to_itrs_compute_unchecked(x, y, z, ts, skyfield_compat),
657 )
658}
659
660fn gcrs_to_itrs_compute_unchecked(
661 x: f64,
662 y: f64,
663 z: f64,
664 ts: &TimeScales,
665 skyfield_compat: bool,
666) -> (f64, f64, f64) {
667 let mat = gcrs_to_itrs_matrix_unchecked(ts);
668
669 if skyfield_compat {
670 let pos_au = [x / AU_KM, y / AU_KM, z / AU_KM];
675 let r = mat3_vec3_mul_unchecked(&mat, &pos_au);
676 (r[0] * AU_KM, r[1] * AU_KM, r[2] * AU_KM)
677 } else {
678 let pos = [x, y, z];
679 let r = mat3_vec3_mul_unchecked(&mat, &pos);
680 (r[0], r[1], r[2])
681 }
682}
683
684pub fn gcrs_to_itrs_compute_with_polar_motion(
686 x: f64,
687 y: f64,
688 z: f64,
689 ts: &TimeScales,
690 skyfield_compat: bool,
691 pole: PolarMotion,
692) -> Result<(f64, f64, f64), FrameTransformError> {
693 validate_vec3("gcrs_position_km", &[x, y, z])?;
694 validate_time_scales(ts)?;
695 validate_polar_motion(pole)?;
696 validate_tuple3(
697 "itrs_position_km",
698 gcrs_to_itrs_compute_with_polar_motion_unchecked(x, y, z, ts, skyfield_compat, pole),
699 )
700}
701
702fn gcrs_to_itrs_compute_with_polar_motion_unchecked(
703 x: f64,
704 y: f64,
705 z: f64,
706 ts: &TimeScales,
707 skyfield_compat: bool,
708 pole: PolarMotion,
709) -> (f64, f64, f64) {
710 let mat = gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole);
711
712 if skyfield_compat {
713 let pos_au = [x / AU_KM, y / AU_KM, z / AU_KM];
714 let r = mat3_vec3_mul_unchecked(&mat, &pos_au);
715 (r[0] * AU_KM, r[1] * AU_KM, r[2] * AU_KM)
716 } else {
717 let pos = [x, y, z];
718 let r = mat3_vec3_mul_unchecked(&mat, &pos);
719 (r[0], r[1], r[2])
720 }
721}
722
723pub fn itrs_to_gcrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
732 validate_time_scales(ts)?;
733 validate_mat3("itrs_to_gcrs_matrix", itrs_to_gcrs_matrix_unchecked(ts))
734}
735
736fn itrs_to_gcrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
737 inline_tr(&gcrs_to_itrs_matrix_unchecked(ts))
738}
739
740pub fn itrs_to_gcrs_matrix_with_polar_motion(
742 ts: &TimeScales,
743 pole: PolarMotion,
744) -> Result<Mat3, FrameTransformError> {
745 validate_time_scales(ts)?;
746 validate_polar_motion(pole)?;
747 validate_mat3(
748 "itrs_to_gcrs_matrix",
749 itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts, pole),
750 )
751}
752
753fn itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts: &TimeScales, pole: PolarMotion) -> Mat3 {
754 inline_tr(&gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole))
755}
756
757pub fn itrs_to_gcrs_compute(
764 x: f64,
765 y: f64,
766 z: f64,
767 ts: &TimeScales,
768) -> Result<(f64, f64, f64), FrameTransformError> {
769 validate_vec3("itrs_position_km", &[x, y, z])?;
770 validate_time_scales(ts)?;
771 validate_tuple3(
772 "gcrs_position_km",
773 itrs_to_gcrs_compute_unchecked(x, y, z, ts),
774 )
775}
776
777fn itrs_to_gcrs_compute_unchecked(x: f64, y: f64, z: f64, ts: &TimeScales) -> (f64, f64, f64) {
778 let mat = itrs_to_gcrs_matrix_unchecked(ts);
779 let r = mat3_vec3_mul_unchecked(&mat, &[x, y, z]);
780 (r[0], r[1], r[2])
781}
782
783pub fn itrs_to_gcrs_compute_with_polar_motion(
785 x: f64,
786 y: f64,
787 z: f64,
788 ts: &TimeScales,
789 pole: PolarMotion,
790) -> Result<(f64, f64, f64), FrameTransformError> {
791 validate_vec3("itrs_position_km", &[x, y, z])?;
792 validate_time_scales(ts)?;
793 validate_polar_motion(pole)?;
794 validate_tuple3(
795 "gcrs_position_km",
796 itrs_to_gcrs_compute_with_polar_motion_unchecked(x, y, z, ts, pole),
797 )
798}
799
800fn itrs_to_gcrs_compute_with_polar_motion_unchecked(
801 x: f64,
802 y: f64,
803 z: f64,
804 ts: &TimeScales,
805 pole: PolarMotion,
806) -> (f64, f64, f64) {
807 let mat = itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts, pole);
808 let r = mat3_vec3_mul_unchecked(&mat, &[x, y, z]);
809 (r[0], r[1], r[2])
810}
811
812pub fn itrs_to_geodetic_compute(
822 x: f64,
823 y: f64,
824 z: f64,
825) -> Result<(f64, f64, f64), FrameTransformError> {
826 validate_vec3("itrs_position_km", &[x, y, z])?;
827 validate_tuple3("geodetic", itrs_to_geodetic_compute_unchecked(x, y, z))
828}
829
830fn itrs_to_geodetic_compute_unchecked(x: f64, y: f64, z: f64) -> (f64, f64, f64) {
831 let x_au = x / AU_KM;
833 let y_au = y / AU_KM;
834 let z_au = z / AU_KM;
835
836 let a_au = WGS84_A_KM / AU_KM; let r_xy = (x_au * x_au + y_au * y_au).sqrt();
838
839 let lon_raw = libm::atan2(y_au, x_au);
843 let pi = std::f64::consts::PI;
844 let mut lon_shifted = (lon_raw - pi) % TAU;
845 if lon_shifted < 0.0 {
846 lon_shifted += TAU;
847 }
848 let lon = lon_shifted - pi;
849
850 let mut lat = libm::atan2(z_au, r_xy);
852 let mut a_c = 0.0_f64;
853 let mut hyp = 0.0_f64;
854
855 for _ in 0..3 {
856 let sin_lat = libm::sin(lat);
857 let e2_sin_lat = WGS84_E2 * sin_lat;
858 a_c = a_au / (1.0 - e2_sin_lat * sin_lat).sqrt();
859 hyp = z_au + a_c * e2_sin_lat;
860 lat = libm::atan2(hyp, r_xy);
861 }
862
863 let height_au = (hyp * hyp + r_xy * r_xy).sqrt() - a_c;
865 let alt = height_au * AU_KM;
866
867 (lat * 360.0 / TAU, lon * 360.0 / TAU, alt)
870}
871
872fn proj_normal_radius_of_curvature(sinphi: f64) -> f64 {
873 if PROJ_WGS84_ES == 0.0 {
874 return PROJ_WGS84_A_M;
875 }
876 PROJ_WGS84_A_M / (1.0 - (PROJ_WGS84_ES * sinphi) * sinphi).sqrt()
877}
878
879fn proj_geocentric_radius(cosphi: f64, sinphi: f64) -> f64 {
880 libm::hypot(
881 (PROJ_WGS84_A_M * PROJ_WGS84_A_M) * cosphi,
882 (PROJ_WGS84_B_M * PROJ_WGS84_B_M) * sinphi,
883 ) / libm::hypot(PROJ_WGS84_A_M * cosphi, PROJ_WGS84_B_M * sinphi)
884}
885
886pub fn geodetic_from_ecef_proj(x: f64, y: f64, z: f64) -> Result<[f64; 3], FrameTransformError> {
894 validate_vec3("ecef_m", &[x, y, z])?;
895 validate_array3("geodetic_proj", geodetic_from_ecef_proj_unchecked(x, y, z))
896}
897
898fn geodetic_from_ecef_proj_unchecked(x: f64, y: f64, z: f64) -> [f64; 3] {
899 let p = libm::hypot(x, y);
900
901 let y_theta = z * PROJ_WGS84_A_M;
902 let x_theta = p * PROJ_WGS84_B_M;
903 let norm = libm::hypot(y_theta, x_theta);
904 let c = if norm == 0.0 { 1.0 } else { x_theta / norm };
905 let s = if norm == 0.0 { 0.0 } else { y_theta / norm };
906
907 let y_phi = z + ((((PROJ_WGS84_E2S * PROJ_WGS84_B_M) * s) * s) * s);
908 let x_phi = p - ((((PROJ_WGS84_ES * PROJ_WGS84_A_M) * c) * c) * c);
909 let norm_phi = libm::hypot(y_phi, x_phi);
910 let mut cosphi = if norm_phi == 0.0 {
911 1.0
912 } else {
913 x_phi / norm_phi
914 };
915 let mut sinphi = if norm_phi == 0.0 {
916 0.0
917 } else {
918 y_phi / norm_phi
919 };
920
921 let phi = if x_phi <= 0.0 {
922 cosphi = 0.0;
923 if z >= 0.0 {
924 sinphi = 1.0;
925 PROJ_HALF_PI
926 } else {
927 sinphi = -1.0;
928 -PROJ_HALF_PI
929 }
930 } else {
931 libm::atan(y_phi / x_phi)
932 };
933
934 let lam = libm::atan2(y, x);
935 let alt = if cosphi < 1e-6 {
936 z.abs() - proj_geocentric_radius(cosphi, sinphi)
937 } else {
938 p / cosphi - proj_normal_radius_of_curvature(sinphi)
939 };
940
941 [lam * PROJ_RAD_TO_DEG, phi * PROJ_RAD_TO_DEG, alt]
942}
943
944pub fn geodetic_to_itrs(
950 lat_deg: f64,
951 lon_deg: f64,
952 alt_km: f64,
953) -> Result<(f64, f64, f64), FrameTransformError> {
954 validate_geodetic_degrees_km(lat_deg, lon_deg, alt_km)?;
955 validate_tuple3(
956 "itrs_position_km",
957 geodetic_to_itrs_unchecked(lat_deg, lon_deg, alt_km),
958 )
959}
960
961fn geodetic_to_itrs_unchecked(lat_deg: f64, lon_deg: f64, alt_km: f64) -> (f64, f64, f64) {
962 let lat = lat_deg.to_radians();
963 let lon = lon_deg.to_radians();
964
965 let sin_lat = libm::sin(lat);
966 let cos_lat = libm::cos(lat);
967 let sin_lon = libm::sin(lon);
968 let cos_lon = libm::cos(lon);
969
970 let n = WGS84_A_KM / (1.0 - WGS84_E2 * sin_lat * sin_lat).sqrt();
971
972 let x = (n + alt_km) * cos_lat * cos_lon;
973 let y = (n + alt_km) * cos_lat * sin_lon;
974 let z = (n * (1.0 - WGS84_E2) + alt_km) * sin_lat;
975
976 (x, y, z)
977}
978
979fn geodetic_to_itrs_au(lat_deg: f64, lon_deg: f64, alt_km: f64) -> [f64; 3] {
983 let lat = lat_deg * TAU / 360.0;
984 let lon = lon_deg * TAU / 360.0;
985
986 let sinphi = libm::sin(lat);
987 let cosphi = libm::cos(lat);
988
989 let radius_au = WGS84_A_KM / AU_KM;
990 let elevation_au = alt_km / AU_KM;
991
992 let omf2 = (1.0 - WGS84_F) * (1.0 - WGS84_F);
993 let c = 1.0 / (cosphi * cosphi + sinphi * sinphi * omf2).sqrt();
994 let s = omf2 * c;
995
996 let radius_xy = radius_au * c;
997 let xy = (radius_xy + elevation_au) * cosphi;
998 let x = xy * libm::cos(lon);
999 let y = xy * libm::sin(lon);
1000
1001 let radius_z = radius_au * s;
1002 let z = (radius_z + elevation_au) * sinphi;
1003
1004 [x, y, z]
1005}
1006
1007fn ecef_to_enu_matrix(lat_deg: f64, lon_deg: f64) -> Mat3 {
1009 let lat = lat_deg.to_radians();
1010 let lon = lon_deg.to_radians();
1011
1012 let sin_lat = libm::sin(lat);
1013 let cos_lat = libm::cos(lat);
1014 let sin_lon = libm::sin(lon);
1015 let cos_lon = libm::cos(lon);
1016
1017 [
1022 [-sin_lon, cos_lon, 0.0],
1023 [-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat],
1024 [cos_lat * cos_lon, cos_lat * sin_lon, sin_lat],
1025 ]
1026}
1027
1028pub fn gcrs_to_topocentric_compute(
1032 sat_gcrs_km: [f64; 3],
1033 station: &GeodeticStationKm,
1034 ts: &TimeScales,
1035 skyfield_compat: bool,
1036) -> Result<(f64, f64, f64), FrameTransformError> {
1037 validate_vec3("sat_gcrs_km", &sat_gcrs_km)?;
1038 validate_geodetic_degrees_km(
1039 station.latitude_deg,
1040 station.longitude_deg,
1041 station.altitude_km,
1042 )?;
1043 validate_time_scales(ts)?;
1044 validate_tuple3(
1045 "topocentric",
1046 gcrs_to_topocentric_compute_unchecked(sat_gcrs_km, station, ts, skyfield_compat),
1047 )
1048}
1049
1050fn gcrs_to_topocentric_compute_unchecked(
1051 sat_gcrs_km: [f64; 3],
1052 station: &GeodeticStationKm,
1053 ts: &TimeScales,
1054 skyfield_compat: bool,
1055) -> (f64, f64, f64) {
1056 let [sat_x, sat_y, sat_z] = sat_gcrs_km;
1057 let station_lat_deg = station.latitude_deg;
1058 let station_lon_deg = station.longitude_deg;
1059 let station_alt_km = station.altitude_km;
1060 if skyfield_compat {
1061 return gcrs_to_topocentric_skyfield(
1062 sat_x,
1063 sat_y,
1064 sat_z,
1065 station_lat_deg,
1066 station_lon_deg,
1067 station_alt_km,
1068 ts,
1069 );
1070 }
1071
1072 let (sat_itrs_x, sat_itrs_y, sat_itrs_z) =
1074 gcrs_to_itrs_compute_unchecked(sat_x, sat_y, sat_z, ts, false);
1075 itrs_to_topocentric_unchecked([sat_itrs_x, sat_itrs_y, sat_itrs_z], station)
1076}
1077
1078pub fn itrs_to_topocentric(
1088 target_itrs_km: [f64; 3],
1089 station: &GeodeticStationKm,
1090) -> Result<(f64, f64, f64), FrameTransformError> {
1091 validate_vec3("target_itrs_km", &target_itrs_km)?;
1092 validate_geodetic_degrees_km(
1093 station.latitude_deg,
1094 station.longitude_deg,
1095 station.altitude_km,
1096 )?;
1097 validate_tuple3(
1098 "topocentric",
1099 itrs_to_topocentric_unchecked(target_itrs_km, station),
1100 )
1101}
1102
1103fn itrs_to_topocentric_unchecked(target_itrs_km: [f64; 3], station: &GeodeticStationKm) -> Vec3 {
1104 let [target_x, target_y, target_z] = target_itrs_km;
1105 let (stn_x, stn_y, stn_z) = geodetic_to_itrs_unchecked(
1106 station.latitude_deg,
1107 station.longitude_deg,
1108 station.altitude_km,
1109 );
1110
1111 let dx = target_x - stn_x;
1112 let dy = target_y - stn_y;
1113 let dz = target_z - stn_z;
1114
1115 let enu_mat = ecef_to_enu_matrix(station.latitude_deg, station.longitude_deg);
1116 let enu = mat3_vec3_mul_unchecked(&enu_mat, &[dx, dy, dz]);
1117 let east = enu[0];
1118 let north = enu[1];
1119 let up = enu[2];
1120
1121 let range = (east * east + north * north + up * up).sqrt();
1123
1124 let elevation = libm::asin(up / range).to_degrees();
1126
1127 let horiz_sq = east * east + north * north;
1131 let mut azimuth = if horiz_sq < AZIMUTH_ZENITH_EPS * range * range {
1132 0.0
1133 } else {
1134 libm::atan2(east, north).to_degrees()
1135 };
1136 if azimuth < 0.0 {
1137 azimuth += 360.0;
1138 }
1139
1140 (azimuth, elevation, range)
1141}
1142
1143fn gcrs_to_topocentric_skyfield(
1154 sat_x: f64,
1155 sat_y: f64,
1156 sat_z: f64,
1157 station_lat_deg: f64,
1158 station_lon_deg: f64,
1159 station_alt_km: f64,
1160 ts: &TimeScales,
1161) -> (f64, f64, f64) {
1162 let lat_rad = station_lat_deg * TAU / 360.0;
1163 let lon_rad = station_lon_deg * TAU / 360.0;
1164
1165 let cy = libm::cos(lat_rad);
1167 let sy = libm::sin(lat_rad);
1168 let r_lat: Mat3 = [[-sy, 0.0, cy], [0.0, 1.0, 0.0], [cy, 0.0, sy]];
1171
1172 let rz_neg_lon = build_rot_z(-lon_rad);
1174 let r_latlon = inline_rxr(&r_lat, &rz_neg_lon);
1175
1176 let r_itrs = gcrs_to_itrs_matrix_unchecked(ts);
1178 let r_full = inline_rxr(&r_latlon, &r_itrs);
1179
1180 let stn_itrs_au = geodetic_to_itrs_au(station_lat_deg, station_lon_deg, station_alt_km);
1183
1184 let r_itrs_t = inline_tr(&r_itrs);
1186 let stn_gcrs_au = mat3_vec3_mul_unchecked(&r_itrs_t, &stn_itrs_au);
1187
1188 let sat_au = [sat_x / AU_KM, sat_y / AU_KM, sat_z / AU_KM];
1190
1191 let diff_au = [
1193 sat_au[0] - stn_gcrs_au[0],
1194 sat_au[1] - stn_gcrs_au[1],
1195 sat_au[2] - stn_gcrs_au[2],
1196 ];
1197
1198 let enu_au = mat3_vec3_mul_unchecked(&r_full, &diff_au);
1200
1201 let ex = enu_au[0];
1203 let ey = enu_au[1];
1204 let ez = enu_au[2];
1205
1206 let r_au = (ex * ex + ey * ey + ez * ez).sqrt();
1207 let elevation_rad = libm::atan2(ez, (ex * ex + ey * ey).sqrt());
1208 let mut azimuth_rad = libm::atan2(ey, ex) % TAU;
1209 if azimuth_rad < 0.0 {
1210 azimuth_rad += TAU;
1211 }
1212
1213 let range_km = r_au * AU_KM;
1214 let elevation_deg = elevation_rad * 360.0 / TAU;
1215 let azimuth_deg = azimuth_rad * 360.0 / TAU;
1216
1217 (azimuth_deg, elevation_deg, range_km)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use crate::astro::time::scales::TimeScales;
1224
1225 fn assert_mat3_bits_eq(actual: &Mat3, expected: &Mat3) {
1226 for i in 0..3 {
1227 for j in 0..3 {
1228 assert_eq!(
1229 actual[i][j].to_bits(),
1230 expected[i][j].to_bits(),
1231 "matrix[{i}][{j}]: {} vs {}",
1232 actual[i][j],
1233 expected[i][j]
1234 );
1235 }
1236 }
1237 }
1238
1239 fn assert_vec3_bits_eq(actual: [f64; 3], expected: [f64; 3]) {
1240 for i in 0..3 {
1241 assert_eq!(
1242 actual[i].to_bits(),
1243 expected[i].to_bits(),
1244 "vector[{i}]: {} vs {}",
1245 actual[i],
1246 expected[i]
1247 );
1248 }
1249 }
1250
1251 #[test]
1252 fn itrs_to_gcrs_inverts_gcrs_to_itrs() {
1253 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1257 let (x, y, z) = (4321.0_f64, -5678.0, 3210.0);
1258
1259 let (ix, iy, iz) =
1260 gcrs_to_itrs_compute(x, y, z, &ts, false).expect("valid frame transform");
1261 assert!(((ix - x).abs() + (iy - y).abs() + (iz - z).abs()) > 100.0);
1263
1264 let (bx, by, bz) = itrs_to_gcrs_compute(ix, iy, iz, &ts).expect("valid frame transform");
1265 assert!((bx - x).abs() < 1e-9, "x {bx} vs {x}");
1266 assert!((by - y).abs() < 1e-9, "y {by} vs {y}");
1267 assert!((bz - z).abs() < 1e-9, "z {bz} vs {z}");
1268
1269 let n0 = (x * x + y * y + z * z).sqrt();
1271 let n1 = (ix * ix + iy * iy + iz * iz).sqrt();
1272 assert!((n0 - n1).abs() < 1e-9);
1273 }
1274
1275 #[test]
1276 fn polar_motion_matrix_matches_documented_convention() {
1277 let pole = PolarMotion::from_arcseconds(0.25, -0.35).expect("valid polar motion");
1278 let cx = libm::cos(pole.xp_rad);
1279 let sx = libm::sin(pole.xp_rad);
1280 let cy = libm::cos(pole.yp_rad);
1281 let sy = libm::sin(pole.yp_rad);
1282
1283 let expected = [
1284 [cx, sx * sy, sx * cy],
1285 [0.0, cy, -sy],
1286 [-sx, cx * sy, cx * cy],
1287 ];
1288 let got = polar_motion_matrix(pole).expect("valid polar motion matrix");
1289 assert_mat3_bits_eq(&got, &expected);
1290
1291 let small_angle = [
1292 [1.0, 0.0, pole.xp_rad],
1293 [0.0, 1.0, -pole.yp_rad],
1294 [-pole.xp_rad, pole.yp_rad, 1.0],
1295 ];
1296 for i in 0..3 {
1297 for j in 0..3 {
1298 assert!(
1299 (got[i][j] - small_angle[i][j]).abs() < 1.0e-11,
1300 "matrix[{i}][{j}] {} vs small-angle {}",
1301 got[i][j],
1302 small_angle[i][j]
1303 );
1304 }
1305 }
1306 }
1307
1308 #[test]
1309 fn gcrs_to_itrs_with_polar_motion_premultiplies_legacy_rotation() {
1310 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1311 let pole = PolarMotion::from_arcseconds(0.18, -0.24).expect("valid polar motion");
1312 let legacy = gcrs_to_itrs_matrix(&ts).expect("valid frame transform");
1313 let expected = inline_rxr(
1314 &polar_motion_matrix(pole).expect("valid polar motion matrix"),
1315 &legacy,
1316 );
1317 let got = gcrs_to_itrs_matrix_with_polar_motion(&ts, pole).expect("valid frame transform");
1318
1319 assert_mat3_bits_eq(&got, &expected);
1320
1321 let pos = [4321.0_f64, -5678.0, 3210.0];
1322 let actual_vec =
1323 gcrs_to_itrs_compute_with_polar_motion(pos[0], pos[1], pos[2], &ts, false, pole)
1324 .expect("valid frame transform");
1325 let expected_vec = mat3_vec3_mul(&expected, &pos).expect("finite matrix-vector product");
1326 assert_vec3_bits_eq([actual_vec.0, actual_vec.1, actual_vec.2], expected_vec);
1327
1328 let legacy_vec =
1329 gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, false).expect("valid transform");
1330 let delta = (actual_vec.0 - legacy_vec.0).abs()
1331 + (actual_vec.1 - legacy_vec.1).abs()
1332 + (actual_vec.2 - legacy_vec.2).abs();
1333 assert!(
1334 delta > 1.0e-4,
1335 "nonzero polar motion should move the vector"
1336 );
1337
1338 let inverse =
1339 itrs_to_gcrs_matrix_with_polar_motion(&ts, pole).expect("valid frame transform");
1340 assert_mat3_bits_eq(&inverse, &inline_tr(&got));
1341 }
1342
1343 #[test]
1344 fn zero_polar_motion_matches_legacy_transform_bits() {
1345 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1346 let legacy = gcrs_to_itrs_matrix(&ts).expect("valid frame transform");
1347 let zero = gcrs_to_itrs_matrix_with_polar_motion(&ts, PolarMotion::ZERO)
1348 .expect("valid frame transform");
1349 assert_mat3_bits_eq(&zero, &legacy);
1350
1351 let mean_legacy = mean_of_date_to_itrs_matrix(&ts).expect("valid frame transform");
1352 let mean_zero = mean_of_date_to_itrs_matrix_with_polar_motion(&ts, PolarMotion::ZERO)
1353 .expect("valid frame transform");
1354 assert_mat3_bits_eq(&mean_zero, &mean_legacy);
1355
1356 let pos = [4321.0_f64, -5678.0, 3210.0];
1357 for skyfield_compat in [false, true] {
1358 let legacy_vec = gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, skyfield_compat)
1359 .expect("valid frame transform");
1360 let zero_vec = gcrs_to_itrs_compute_with_polar_motion(
1361 pos[0],
1362 pos[1],
1363 pos[2],
1364 &ts,
1365 skyfield_compat,
1366 PolarMotion::ZERO,
1367 )
1368 .expect("valid frame transform");
1369 assert_vec3_bits_eq(
1370 [zero_vec.0, zero_vec.1, zero_vec.2],
1371 [legacy_vec.0, legacy_vec.1, legacy_vec.2],
1372 );
1373 }
1374
1375 let legacy_back =
1376 itrs_to_gcrs_compute(pos[0], pos[1], pos[2], &ts).expect("valid frame transform");
1377 let zero_back =
1378 itrs_to_gcrs_compute_with_polar_motion(pos[0], pos[1], pos[2], &ts, PolarMotion::ZERO)
1379 .expect("valid frame transform");
1380 assert_vec3_bits_eq(
1381 [zero_back.0, zero_back.1, zero_back.2],
1382 [legacy_back.0, legacy_back.1, legacy_back.2],
1383 );
1384 }
1385
1386 #[test]
1387 fn gcrs_to_teme_inverts_teme_to_gcrs() {
1388 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1389 let teme = TemeStateKm {
1390 position_km: [6524.834, 6862.875, 6448.296],
1391 velocity_km_s: [4.901327, 5.533756, -1.976341],
1392 };
1393
1394 for skyfield_compat in [false, true] {
1395 let (gcrs_position, gcrs_velocity) =
1396 teme_to_gcrs_compute(&teme, &ts, skyfield_compat).expect("valid transform");
1397 let gcrs = TemeStateKm {
1398 position_km: [gcrs_position.0, gcrs_position.1, gcrs_position.2],
1399 velocity_km_s: [gcrs_velocity.0, gcrs_velocity.1, gcrs_velocity.2],
1400 };
1401 let (round_position, round_velocity) =
1402 gcrs_to_teme_compute(&gcrs, &ts, skyfield_compat).expect("valid inverse");
1403
1404 let pos = [round_position.0, round_position.1, round_position.2];
1405 let vel = [round_velocity.0, round_velocity.1, round_velocity.2];
1406 for axis in 0..3 {
1407 assert!(
1408 (pos[axis] - teme.position_km[axis]).abs() <= 1.0e-9,
1409 "position axis {axis} mode {skyfield_compat}"
1410 );
1411 assert!(
1412 (vel[axis] - teme.velocity_km_s[axis]).abs() <= 1.0e-12,
1413 "velocity axis {axis} mode {skyfield_compat}"
1414 );
1415 }
1416 }
1417 }
1418
1419 #[test]
1420 fn frame_transforms_reject_nonfinite_time() {
1421 let mut ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1422 ts.jd_tt = f64::NAN;
1423
1424 assert!(greenwich_mean_sidereal_time_radians(&ts).is_err());
1425 assert!(gcrs_to_itrs_matrix(&ts).is_err());
1426 assert!(itrs_to_gcrs_compute(1.0, 2.0, 3.0, &ts).is_err());
1427 }
1428
1429 #[test]
1430 fn frame_transforms_reject_nonfinite_pole_coordinates() {
1431 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1432 assert!(PolarMotion::from_radians(f64::NAN, 0.0).is_err());
1433 assert!(PolarMotion::from_arcseconds(0.0, f64::INFINITY).is_err());
1434
1435 let pole = PolarMotion {
1436 xp_rad: f64::NAN,
1437 yp_rad: 0.0,
1438 };
1439 assert!(polar_motion_matrix(pole).is_err());
1440 assert!(gcrs_to_itrs_matrix_with_polar_motion(&ts, pole).is_err());
1441 }
1442
1443 #[test]
1444 fn frame_transforms_reject_nonfinite_vectors() {
1445 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1446 let bad_state = TemeStateKm {
1447 position_km: [1.0, f64::NAN, 3.0],
1448 velocity_km_s: [0.1, 0.2, 0.3],
1449 };
1450 assert!(teme_to_gcrs_compute(&bad_state, &ts, false).is_err());
1451 assert!(gcrs_to_itrs_compute(1.0, f64::INFINITY, 3.0, &ts, false).is_err());
1452 assert!(itrs_to_gcrs_compute(1.0, 2.0, f64::NEG_INFINITY, &ts).is_err());
1453 }
1454
1455 #[test]
1456 fn validated_frame_transform_preserves_valid_bits() {
1457 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1458 let pos = [4321.0_f64, -5678.0, 3210.0];
1459 let expected = gcrs_to_itrs_compute_unchecked(pos[0], pos[1], pos[2], &ts, true);
1460 let got =
1461 gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, true).expect("valid frame transform");
1462 assert_vec3_bits_eq([got.0, got.1, got.2], [expected.0, expected.1, expected.2]);
1463 }
1464
1465 #[test]
1466 fn geodetic_transforms_reject_invalid_coordinates() {
1467 assert!(itrs_to_geodetic_compute(f64::NAN, 0.0, 0.0).is_err());
1468 assert!(geodetic_from_ecef_proj(0.0, f64::INFINITY, 0.0).is_err());
1469 assert!(geodetic_to_itrs(90.000_001, 0.0, 0.0).is_err());
1470 assert!(geodetic_to_itrs(0.0, -180.000_001, 0.0).is_err());
1471 assert!(geodetic_to_itrs(0.0, 0.0, f64::NAN).is_err());
1472 }
1473
1474 #[test]
1475 fn topocentric_transform_rejects_invalid_coordinates() {
1476 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1477 let station = GeodeticStationKm {
1478 latitude_deg: f64::NAN,
1479 longitude_deg: 0.0,
1480 altitude_km: 0.0,
1481 };
1482 assert!(gcrs_to_topocentric_compute([7000.0, 0.0, 0.0], &station, &ts, false).is_err());
1483
1484 let station = GeodeticStationKm {
1485 latitude_deg: 0.0,
1486 longitude_deg: 181.0,
1487 altitude_km: 0.0,
1488 };
1489 assert!(gcrs_to_topocentric_compute([7000.0, 0.0, 0.0], &station, &ts, false).is_err());
1490
1491 let station = GeodeticStationKm {
1492 latitude_deg: 0.0,
1493 longitude_deg: 0.0,
1494 altitude_km: 0.0,
1495 };
1496 assert!(
1497 gcrs_to_topocentric_compute([7000.0, f64::NAN, 0.0], &station, &ts, false).is_err()
1498 );
1499 }
1500
1501 #[test]
1502 fn topocentric_azimuth_is_zero_at_station_zenith() {
1503 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1504 let station = GeodeticStationKm {
1507 latitude_deg: 0.0,
1508 longitude_deg: 0.0,
1509 altitude_km: 0.0,
1510 };
1511 let (sx, sy, sz) = geodetic_to_itrs_unchecked(0.0, 0.0, 0.0);
1512 let sat_itrs = [sx + 20_000.0, sy, sz];
1514 let r_itrs = gcrs_to_itrs_matrix_unchecked(&ts);
1516 let r_itrs_t = inline_tr(&r_itrs);
1517 let sat_gcrs = mat3_vec3_mul_unchecked(&r_itrs_t, &sat_itrs);
1518
1519 let (azimuth_deg, elevation_deg, _range_km) =
1520 gcrs_to_topocentric_compute_unchecked(sat_gcrs, &station, &ts, false);
1521 assert_eq!(azimuth_deg, 0.0);
1522 assert!(azimuth_deg.is_finite());
1523 assert!((elevation_deg - 90.0).abs() < 1e-6);
1524 }
1525
1526 #[test]
1527 fn validated_geodetic_transform_preserves_valid_bits() {
1528 let (lat, lon, alt) = (51.4779, -0.0015, 0.046);
1529 let expected = geodetic_to_itrs_unchecked(lat, lon, alt);
1530 let got = geodetic_to_itrs(lat, lon, alt).expect("valid geodetic coordinates");
1531 assert_eq!(got.0.to_bits(), expected.0.to_bits());
1532 assert_eq!(got.1.to_bits(), expected.1.to_bits());
1533 assert_eq!(got.2.to_bits(), expected.2.to_bits());
1534
1535 let expected = itrs_to_geodetic_compute_unchecked(got.0, got.1, got.2);
1536 let roundtrip =
1537 itrs_to_geodetic_compute(got.0, got.1, got.2).expect("valid ITRS coordinates");
1538 assert_eq!(roundtrip.0.to_bits(), expected.0.to_bits());
1539 assert_eq!(roundtrip.1.to_bits(), expected.1.to_bits());
1540 assert_eq!(roundtrip.2.to_bits(), expected.2.to_bits());
1541 }
1542
1543 #[test]
1544 fn sidereal_time_wrappers_are_in_range_and_consistent() {
1545 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1546 let gmst = greenwich_mean_sidereal_time_radians(&ts).expect("valid sidereal time");
1547 let gast = greenwich_apparent_sidereal_time_radians(&ts).expect("valid sidereal time");
1548
1549 assert!((0.0..TAU).contains(&gmst), "gmst {gmst}");
1551 assert!((0.0..TAU).contains(&gast), "gast {gast}");
1552
1553 let diff = (gast - gmst).rem_euclid(TAU);
1556 let eq_eq = diff.min(TAU - diff);
1557 assert!(eq_eq < 1.0e-3, "equation of equinoxes too large: {eq_eq}");
1558
1559 let gmst_hours = sidereal_time_hours(ts.jd_whole, ts.ut1_fraction, ts.tdb_fraction);
1561 assert_eq!(gmst, gmst_hours / 24.0 * TAU);
1562 }
1563
1564 #[test]
1565 fn gmst_from_j2000_seconds_matches_gmst1982_polynomial() {
1566 const GMST_TOL_RAD: f64 = 1.0e-11;
1570 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.25).expect("valid UTC instant");
1571 let sec = (ts.jd_ut1 - J2000_JD) * SECONDS_PER_DAY;
1572 let from_seconds = greenwich_mean_sidereal_time_radians_from_j2000_seconds(sec)
1573 .expect("valid sidereal time");
1574 let mut expected = compute_theta_gmst1982(ts.jd_whole, ts.ut1_fraction) % TAU;
1575 if expected < 0.0 {
1576 expected += TAU;
1577 }
1578
1579 assert!((from_seconds - expected).abs() <= GMST_TOL_RAD);
1580 }
1581}