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::scales::TimeScales;
26use crate::astro::{
27 constants::astro::AU_KM,
28 constants::earth::{WGS84_A_KM, WGS84_E2, WGS84_F},
29 constants::models::proj::{
30 HALF_PI as PROJ_HALF_PI, RAD_TO_DEG as PROJ_RAD_TO_DEG, WGS84_A_M as PROJ_WGS84_A_M,
31 WGS84_B_M as PROJ_WGS84_B_M, WGS84_E2S as PROJ_WGS84_E2S, WGS84_ES as PROJ_WGS84_ES,
32 },
33 constants::time::{DAYS_PER_JULIAN_CENTURY, J2000_JD, SECONDS_PER_DAY},
34};
35
36const TAU: f64 = std::f64::consts::TAU;
37const ARCSECONDS_TO_RADIANS: f64 = 4.848_136_811_095_36e-6;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
41pub enum FrameTransformError {
42 #[error("invalid frame transform {field}: {reason}")]
44 InvalidInput {
45 field: &'static str,
46 reason: &'static str,
47 },
48}
49
50fn invalid_input(field: &'static str, reason: &'static str) -> FrameTransformError {
51 FrameTransformError::InvalidInput { field, reason }
52}
53
54fn validate_finite(field: &'static str, value: f64) -> Result<(), FrameTransformError> {
55 if value.is_finite() {
56 Ok(())
57 } else {
58 Err(invalid_input(field, "must be finite"))
59 }
60}
61
62fn validate_vec3(field: &'static str, values: &[f64; 3]) -> Result<(), FrameTransformError> {
63 for value in values {
64 if !value.is_finite() {
65 return Err(invalid_input(field, "components must be finite"));
66 }
67 }
68 Ok(())
69}
70
71fn validate_tuple3(field: &'static str, values: Vec3) -> Result<Vec3, FrameTransformError> {
72 if values.0.is_finite() && values.1.is_finite() && values.2.is_finite() {
73 Ok(values)
74 } else {
75 Err(invalid_input(field, "components must be finite"))
76 }
77}
78
79fn validate_array3(field: &'static str, values: [f64; 3]) -> Result<[f64; 3], FrameTransformError> {
80 validate_vec3(field, &values)?;
81 Ok(values)
82}
83
84fn validate_mat3(field: &'static str, values: Mat3) -> Result<Mat3, FrameTransformError> {
85 for row in &values {
86 validate_vec3(field, row)?;
87 }
88 Ok(values)
89}
90
91fn validate_time_scales(ts: &TimeScales) -> Result<(), FrameTransformError> {
92 validate_finite("jd_whole", ts.jd_whole)?;
93 validate_finite("ut1_fraction", ts.ut1_fraction)?;
94 validate_finite("tt_fraction", ts.tt_fraction)?;
95 validate_finite("tdb_fraction", ts.tdb_fraction)?;
96 validate_finite("jd_ut1", ts.jd_ut1)?;
97 validate_finite("jd_tt", ts.jd_tt)?;
98 validate_finite("jd_tdb", ts.jd_tdb)
99}
100
101fn validate_polar_motion(pole: PolarMotion) -> Result<(), FrameTransformError> {
102 validate_finite("xp_rad", pole.xp_rad)?;
103 validate_finite("yp_rad", pole.yp_rad)
104}
105
106fn validate_geodetic_degrees_km(
107 latitude_deg: f64,
108 longitude_deg: f64,
109 altitude_km: f64,
110) -> Result<(), FrameTransformError> {
111 validate_finite("latitude_deg", latitude_deg)?;
112 if !(-90.0..=90.0).contains(&latitude_deg) {
113 return Err(invalid_input("latitude_deg", "must be in [-90, 90]"));
114 }
115 validate_finite("longitude_deg", longitude_deg)?;
116 if !(-180.0..=180.0).contains(&longitude_deg) {
117 return Err(invalid_input("longitude_deg", "must be in [-180, 180]"));
118 }
119 validate_finite("altitude_km", altitude_km)
120}
121
122pub type Vec3 = (f64, f64, f64);
129
130pub struct TemeStateKm {
133 pub position_km: [f64; 3],
134 pub velocity_km_s: [f64; 3],
135}
136
137pub struct GeodeticStationKm {
139 pub latitude_deg: f64,
140 pub longitude_deg: f64,
141 pub altitude_km: f64,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq)]
151pub struct PolarMotion {
152 pub xp_rad: f64,
153 pub yp_rad: f64,
154}
155
156impl PolarMotion {
157 pub const ZERO: Self = Self {
159 xp_rad: 0.0,
160 yp_rad: 0.0,
161 };
162
163 pub fn from_radians(xp_rad: f64, yp_rad: f64) -> Result<Self, FrameTransformError> {
165 validate_finite("xp_rad", xp_rad)?;
166 validate_finite("yp_rad", yp_rad)?;
167 Ok(Self { xp_rad, yp_rad })
168 }
169
170 pub fn from_arcseconds(xp_arcsec: f64, yp_arcsec: f64) -> Result<Self, FrameTransformError> {
172 validate_finite("xp_arcsec", xp_arcsec)?;
173 validate_finite("yp_arcsec", yp_arcsec)?;
174 Self::from_radians(
175 xp_arcsec * ARCSECONDS_TO_RADIANS,
176 yp_arcsec * ARCSECONDS_TO_RADIANS,
177 )
178 }
179
180 fn is_zero(self) -> bool {
181 self.xp_rad == 0.0 && self.yp_rad == 0.0
182 }
183}
184
185impl Default for PolarMotion {
186 fn default() -> Self {
187 Self::ZERO
188 }
189}
190
191fn mat3_vec3_mul_fma(r: &Mat3, p: &[f64; 3]) -> [f64; 3] {
195 let mut result = [0.0_f64; 3];
196 for i in 0..3 {
197 let sum = r[i][0] * p[0];
198 let sum = f64::mul_add(r[i][1], p[1], sum);
199 let sum = f64::mul_add(r[i][2], p[2], sum);
200 result[i] = sum;
201 }
202 result
203}
204
205fn build_rot_z(angle: f64) -> Mat3 {
206 let c = angle.cos();
207 let s = angle.sin();
208 [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
209}
210
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 = pole.xp_rad.cos();
227 let sx = pole.xp_rad.sin();
228 let cy = pole.yp_rad.cos();
229 let sy = pole.yp_rad.sin();
230
231 [
232 [cx, sx * sy, sx * cy],
233 [0.0, cy, -sy],
234 [-sx, cx * sy, cx * cy],
235 ]
236}
237
238fn apply_polar_motion_to_itrs_matrix(mat: Mat3, pole: PolarMotion) -> Mat3 {
239 if pole.is_zero() {
240 mat
241 } else {
242 inline_rxr(&polar_motion_matrix_unchecked(pole), &mat)
243 }
244}
245
246fn earth_rotation_angle(jd_whole: f64, ut1_fraction: f64) -> f64 {
247 let days_since_j2000 = jd_whole - J2000_JD + ut1_fraction;
248 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 * mean_ob.cos() + c_terms;
292 let mut gast_hours = (gmst_hours + eq_eq / TAU * 24.0) % 24.0;
293 if gast_hours < 0.0 {
294 gast_hours += 24.0;
295 }
296 gast_hours / 24.0 * TAU
297}
298
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_apparent_sidereal_time_radians(
324 ts: &TimeScales,
325) -> Result<f64, FrameTransformError> {
326 validate_time_scales(ts)?;
327 let radians = greenwich_apparent_sidereal_time_radians_unchecked(ts);
328 validate_finite("gast_radians", radians)?;
329 Ok(radians)
330}
331
332fn greenwich_apparent_sidereal_time_radians_unchecked(ts: &TimeScales) -> f64 {
333 let (dpsi, _deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
334 gast_radians(ts, dpsi)
335}
336
337fn build_teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
339 let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
340 let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
341 let true_ob = mean_ob + deps;
342
343 let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
344 let p = compute_skyfield_precession_matrix_unchecked(ts.jd_tdb);
345 let b = build_icrs_to_j2000();
346
347 let m = if skyfield_compat {
350 inline_mxmxm(&n, &p, &b)
351 } else {
352 let np = inline_rxr(&n, &p);
353 inline_rxr(&np, &b)
354 };
355
356 let gast = gast_radians(ts, dpsi);
357 let theta = compute_theta_gmst1982(ts.jd_whole, ts.ut1_fraction);
358 let angle = theta - gast;
359
360 let r = build_rot_z(angle);
361 let g = inline_rxr(&r, &m);
362 inline_tr(&g)
363}
364
365pub(crate) fn teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
367 build_teme_to_gcrs_matrix(ts, skyfield_compat)
368}
369
370pub fn mat3_vec3_mul(r: &Mat3, p: &[f64; 3]) -> Result<[f64; 3], FrameTransformError> {
372 validate_mat3("matrix", *r)?;
373 validate_vec3("vector", p)?;
374 validate_array3("matrix_vector_product", mat3_vec3_mul_unchecked(r, p))
375}
376
377pub(crate) fn mat3_vec3_mul_unchecked(r: &Mat3, p: &[f64; 3]) -> [f64; 3] {
378 let mut result = [0.0_f64; 3];
379 for i in 0..3 {
380 let mut sum = 0.0;
381 for j in 0..3 {
382 sum += r[i][j] * p[j];
383 }
384 result[i] = sum;
385 }
386 result
387}
388
389pub fn teme_to_gcrs_compute(
391 state: &TemeStateKm,
392 ts: &TimeScales,
393 skyfield_compat: bool,
394) -> Result<(Vec3, Vec3), FrameTransformError> {
395 validate_time_scales(ts)?;
396 validate_vec3("position_km", &state.position_km)?;
397 validate_vec3("velocity_km_s", &state.velocity_km_s)?;
398 let (position, velocity) = teme_to_gcrs_compute_unchecked(state, ts, skyfield_compat);
399 Ok((
400 validate_tuple3("gcrs_position_km", position)?,
401 validate_tuple3("gcrs_velocity_km_s", velocity)?,
402 ))
403}
404
405fn teme_to_gcrs_compute_unchecked(
406 state: &TemeStateKm,
407 ts: &TimeScales,
408 skyfield_compat: bool,
409) -> (Vec3, Vec3) {
410 let [x, y, z] = state.position_km;
411 let [vx, vy, vz] = state.velocity_km_s;
412 let t = build_teme_to_gcrs_matrix(ts, skyfield_compat);
413
414 if skyfield_compat {
415 let r_au = [x / AU_KM, y / AU_KM, z / AU_KM];
417 let r_gcrs_au = mat3_vec3_mul_fma(&t, &r_au);
418 let r_gcrs = (
419 r_gcrs_au[0] * AU_KM,
420 r_gcrs_au[1] * AU_KM,
421 r_gcrs_au[2] * AU_KM,
422 );
423
424 let v_au_d = [
425 vx / AU_KM * SECONDS_PER_DAY,
426 vy / AU_KM * SECONDS_PER_DAY,
427 vz / AU_KM * SECONDS_PER_DAY,
428 ];
429 let v_gcrs_au_d = mat3_vec3_mul_fma(&t, &v_au_d);
430 let v_gcrs = (
431 v_gcrs_au_d[0] * AU_KM / SECONDS_PER_DAY,
432 v_gcrs_au_d[1] * AU_KM / SECONDS_PER_DAY,
433 v_gcrs_au_d[2] * AU_KM / SECONDS_PER_DAY,
434 );
435 (r_gcrs, v_gcrs)
436 } else {
437 let r_teme = [x, y, z];
439 let r_g = mat3_vec3_mul_unchecked(&t, &r_teme);
440 let v_teme = [vx, vy, vz];
441 let v_g = mat3_vec3_mul_unchecked(&t, &v_teme);
442 ((r_g[0], r_g[1], r_g[2]), (v_g[0], v_g[1], v_g[2]))
443 }
444}
445
446pub fn gcrs_to_itrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
457 validate_time_scales(ts)?;
458 validate_mat3("gcrs_to_itrs_matrix", gcrs_to_itrs_matrix_unchecked(ts))
459}
460
461fn gcrs_to_itrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
462 let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
463 let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
464 let true_ob = mean_ob + deps;
465
466 let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
467 let p = compute_skyfield_precession_matrix_unchecked(ts.jd_tdb);
468 let b = build_icrs_to_j2000();
469
470 let m = inline_mxmxm(&n, &p, &b);
472
473 let gast = gast_radians(ts, dpsi);
474
475 let r_gast = build_rot_z(-gast);
477
478 inline_rxr(&r_gast, &m)
480}
481
482pub fn gcrs_to_itrs_matrix_with_polar_motion(
488 ts: &TimeScales,
489 pole: PolarMotion,
490) -> Result<Mat3, FrameTransformError> {
491 validate_time_scales(ts)?;
492 validate_polar_motion(pole)?;
493 validate_mat3(
494 "gcrs_to_itrs_matrix",
495 gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole),
496 )
497}
498
499fn gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts: &TimeScales, pole: PolarMotion) -> Mat3 {
500 apply_polar_motion_to_itrs_matrix(gcrs_to_itrs_matrix_unchecked(ts), pole)
501}
502
503pub fn mean_of_date_to_itrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
515 validate_time_scales(ts)?;
516 validate_mat3(
517 "mean_of_date_to_itrs_matrix",
518 mean_of_date_to_itrs_matrix_unchecked(ts),
519 )
520}
521
522fn mean_of_date_to_itrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
523 let (dpsi, deps) = skyfield_iau2000a_radians_unchecked(ts.jd_tt);
524 let mean_ob = skyfield_mean_obliquity_radians_unchecked(ts.jd_tdb);
525 let true_ob = mean_ob + deps;
526
527 let n = build_skyfield_nutation_matrix_unchecked(mean_ob, true_ob, dpsi);
528 let gast = gast_radians(ts, dpsi);
529 let r_gast = build_rot_z(-gast);
530
531 inline_rxr(&r_gast, &n)
533}
534
535pub fn mean_of_date_to_itrs_matrix_with_polar_motion(
537 ts: &TimeScales,
538 pole: PolarMotion,
539) -> Result<Mat3, FrameTransformError> {
540 validate_time_scales(ts)?;
541 validate_polar_motion(pole)?;
542 validate_mat3(
543 "mean_of_date_to_itrs_matrix",
544 mean_of_date_to_itrs_matrix_with_polar_motion_unchecked(ts, pole),
545 )
546}
547
548fn mean_of_date_to_itrs_matrix_with_polar_motion_unchecked(
549 ts: &TimeScales,
550 pole: PolarMotion,
551) -> Mat3 {
552 apply_polar_motion_to_itrs_matrix(mean_of_date_to_itrs_matrix_unchecked(ts), pole)
553}
554
555pub fn gcrs_to_itrs_compute(
557 x: f64,
558 y: f64,
559 z: f64,
560 ts: &TimeScales,
561 skyfield_compat: bool,
562) -> Result<(f64, f64, f64), FrameTransformError> {
563 validate_vec3("gcrs_position_km", &[x, y, z])?;
564 validate_time_scales(ts)?;
565 validate_tuple3(
566 "itrs_position_km",
567 gcrs_to_itrs_compute_unchecked(x, y, z, ts, skyfield_compat),
568 )
569}
570
571fn gcrs_to_itrs_compute_unchecked(
572 x: f64,
573 y: f64,
574 z: f64,
575 ts: &TimeScales,
576 skyfield_compat: bool,
577) -> (f64, f64, f64) {
578 let mat = gcrs_to_itrs_matrix_unchecked(ts);
579
580 if skyfield_compat {
581 let pos_au = [x / AU_KM, y / AU_KM, z / AU_KM];
586 let r = mat3_vec3_mul_unchecked(&mat, &pos_au);
587 (r[0] * AU_KM, r[1] * AU_KM, r[2] * AU_KM)
588 } else {
589 let pos = [x, y, z];
590 let r = mat3_vec3_mul_unchecked(&mat, &pos);
591 (r[0], r[1], r[2])
592 }
593}
594
595pub fn gcrs_to_itrs_compute_with_polar_motion(
597 x: f64,
598 y: f64,
599 z: f64,
600 ts: &TimeScales,
601 skyfield_compat: bool,
602 pole: PolarMotion,
603) -> Result<(f64, f64, f64), FrameTransformError> {
604 validate_vec3("gcrs_position_km", &[x, y, z])?;
605 validate_time_scales(ts)?;
606 validate_polar_motion(pole)?;
607 validate_tuple3(
608 "itrs_position_km",
609 gcrs_to_itrs_compute_with_polar_motion_unchecked(x, y, z, ts, skyfield_compat, pole),
610 )
611}
612
613fn gcrs_to_itrs_compute_with_polar_motion_unchecked(
614 x: f64,
615 y: f64,
616 z: f64,
617 ts: &TimeScales,
618 skyfield_compat: bool,
619 pole: PolarMotion,
620) -> (f64, f64, f64) {
621 let mat = gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole);
622
623 if skyfield_compat {
624 let pos_au = [x / AU_KM, y / AU_KM, z / AU_KM];
625 let r = mat3_vec3_mul_unchecked(&mat, &pos_au);
626 (r[0] * AU_KM, r[1] * AU_KM, r[2] * AU_KM)
627 } else {
628 let pos = [x, y, z];
629 let r = mat3_vec3_mul_unchecked(&mat, &pos);
630 (r[0], r[1], r[2])
631 }
632}
633
634pub fn itrs_to_gcrs_matrix(ts: &TimeScales) -> Result<Mat3, FrameTransformError> {
643 validate_time_scales(ts)?;
644 validate_mat3("itrs_to_gcrs_matrix", itrs_to_gcrs_matrix_unchecked(ts))
645}
646
647fn itrs_to_gcrs_matrix_unchecked(ts: &TimeScales) -> Mat3 {
648 inline_tr(&gcrs_to_itrs_matrix_unchecked(ts))
649}
650
651pub fn itrs_to_gcrs_matrix_with_polar_motion(
653 ts: &TimeScales,
654 pole: PolarMotion,
655) -> Result<Mat3, FrameTransformError> {
656 validate_time_scales(ts)?;
657 validate_polar_motion(pole)?;
658 validate_mat3(
659 "itrs_to_gcrs_matrix",
660 itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts, pole),
661 )
662}
663
664fn itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts: &TimeScales, pole: PolarMotion) -> Mat3 {
665 inline_tr(&gcrs_to_itrs_matrix_with_polar_motion_unchecked(ts, pole))
666}
667
668pub fn itrs_to_gcrs_compute(
675 x: f64,
676 y: f64,
677 z: f64,
678 ts: &TimeScales,
679) -> Result<(f64, f64, f64), FrameTransformError> {
680 validate_vec3("itrs_position_km", &[x, y, z])?;
681 validate_time_scales(ts)?;
682 validate_tuple3(
683 "gcrs_position_km",
684 itrs_to_gcrs_compute_unchecked(x, y, z, ts),
685 )
686}
687
688fn itrs_to_gcrs_compute_unchecked(x: f64, y: f64, z: f64, ts: &TimeScales) -> (f64, f64, f64) {
689 let mat = itrs_to_gcrs_matrix_unchecked(ts);
690 let r = mat3_vec3_mul_unchecked(&mat, &[x, y, z]);
691 (r[0], r[1], r[2])
692}
693
694pub fn itrs_to_gcrs_compute_with_polar_motion(
696 x: f64,
697 y: f64,
698 z: f64,
699 ts: &TimeScales,
700 pole: PolarMotion,
701) -> Result<(f64, f64, f64), FrameTransformError> {
702 validate_vec3("itrs_position_km", &[x, y, z])?;
703 validate_time_scales(ts)?;
704 validate_polar_motion(pole)?;
705 validate_tuple3(
706 "gcrs_position_km",
707 itrs_to_gcrs_compute_with_polar_motion_unchecked(x, y, z, ts, pole),
708 )
709}
710
711fn itrs_to_gcrs_compute_with_polar_motion_unchecked(
712 x: f64,
713 y: f64,
714 z: f64,
715 ts: &TimeScales,
716 pole: PolarMotion,
717) -> (f64, f64, f64) {
718 let mat = itrs_to_gcrs_matrix_with_polar_motion_unchecked(ts, pole);
719 let r = mat3_vec3_mul_unchecked(&mat, &[x, y, z]);
720 (r[0], r[1], r[2])
721}
722
723pub fn itrs_to_geodetic_compute(
733 x: f64,
734 y: f64,
735 z: f64,
736) -> Result<(f64, f64, f64), FrameTransformError> {
737 validate_vec3("itrs_position_km", &[x, y, z])?;
738 validate_tuple3("geodetic", itrs_to_geodetic_compute_unchecked(x, y, z))
739}
740
741fn itrs_to_geodetic_compute_unchecked(x: f64, y: f64, z: f64) -> (f64, f64, f64) {
742 let x_au = x / AU_KM;
744 let y_au = y / AU_KM;
745 let z_au = z / AU_KM;
746
747 let a_au = WGS84_A_KM / AU_KM; let r_xy = (x_au * x_au + y_au * y_au).sqrt();
749
750 let lon_raw = y_au.atan2(x_au);
754 let pi = std::f64::consts::PI;
755 let mut lon_shifted = (lon_raw - pi) % TAU;
756 if lon_shifted < 0.0 {
757 lon_shifted += TAU;
758 }
759 let lon = lon_shifted - pi;
760
761 let mut lat = z_au.atan2(r_xy);
763 let mut a_c = 0.0_f64;
764 let mut hyp = 0.0_f64;
765
766 for _ in 0..3 {
767 let sin_lat = lat.sin();
768 let e2_sin_lat = WGS84_E2 * sin_lat;
769 a_c = a_au / (1.0 - e2_sin_lat * sin_lat).sqrt();
770 hyp = z_au + a_c * e2_sin_lat;
771 lat = hyp.atan2(r_xy);
772 }
773
774 let height_au = (hyp * hyp + r_xy * r_xy).sqrt() - a_c;
776 let alt = height_au * AU_KM;
777
778 (lat * 360.0 / TAU, lon * 360.0 / TAU, alt)
781}
782
783fn proj_normal_radius_of_curvature(sinphi: f64) -> f64 {
784 if PROJ_WGS84_ES == 0.0 {
785 return PROJ_WGS84_A_M;
786 }
787 PROJ_WGS84_A_M / (1.0 - (PROJ_WGS84_ES * sinphi) * sinphi).sqrt()
788}
789
790fn proj_geocentric_radius(cosphi: f64, sinphi: f64) -> f64 {
791 ((PROJ_WGS84_A_M * PROJ_WGS84_A_M) * cosphi).hypot((PROJ_WGS84_B_M * PROJ_WGS84_B_M) * sinphi)
792 / (PROJ_WGS84_A_M * cosphi).hypot(PROJ_WGS84_B_M * sinphi)
793}
794
795pub fn geodetic_from_ecef_proj(x: f64, y: f64, z: f64) -> Result<[f64; 3], FrameTransformError> {
803 validate_vec3("ecef_m", &[x, y, z])?;
804 validate_array3("geodetic_proj", geodetic_from_ecef_proj_unchecked(x, y, z))
805}
806
807fn geodetic_from_ecef_proj_unchecked(x: f64, y: f64, z: f64) -> [f64; 3] {
808 let p = x.hypot(y);
809
810 let y_theta = z * PROJ_WGS84_A_M;
811 let x_theta = p * PROJ_WGS84_B_M;
812 let norm = y_theta.hypot(x_theta);
813 let c = if norm == 0.0 { 1.0 } else { x_theta / norm };
814 let s = if norm == 0.0 { 0.0 } else { y_theta / norm };
815
816 let y_phi = z + ((((PROJ_WGS84_E2S * PROJ_WGS84_B_M) * s) * s) * s);
817 let x_phi = p - ((((PROJ_WGS84_ES * PROJ_WGS84_A_M) * c) * c) * c);
818 let norm_phi = y_phi.hypot(x_phi);
819 let mut cosphi = if norm_phi == 0.0 {
820 1.0
821 } else {
822 x_phi / norm_phi
823 };
824 let mut sinphi = if norm_phi == 0.0 {
825 0.0
826 } else {
827 y_phi / norm_phi
828 };
829
830 let phi = if x_phi <= 0.0 {
831 cosphi = 0.0;
832 if z >= 0.0 {
833 sinphi = 1.0;
834 PROJ_HALF_PI
835 } else {
836 sinphi = -1.0;
837 -PROJ_HALF_PI
838 }
839 } else {
840 (y_phi / x_phi).atan()
841 };
842
843 let lam = y.atan2(x);
844 let alt = if cosphi < 1e-6 {
845 z.abs() - proj_geocentric_radius(cosphi, sinphi)
846 } else {
847 p / cosphi - proj_normal_radius_of_curvature(sinphi)
848 };
849
850 [lam * PROJ_RAD_TO_DEG, phi * PROJ_RAD_TO_DEG, alt]
851}
852
853pub fn geodetic_to_itrs(
859 lat_deg: f64,
860 lon_deg: f64,
861 alt_km: f64,
862) -> Result<(f64, f64, f64), FrameTransformError> {
863 validate_geodetic_degrees_km(lat_deg, lon_deg, alt_km)?;
864 validate_tuple3(
865 "itrs_position_km",
866 geodetic_to_itrs_unchecked(lat_deg, lon_deg, alt_km),
867 )
868}
869
870fn geodetic_to_itrs_unchecked(lat_deg: f64, lon_deg: f64, alt_km: f64) -> (f64, f64, f64) {
871 let lat = lat_deg.to_radians();
872 let lon = lon_deg.to_radians();
873
874 let sin_lat = lat.sin();
875 let cos_lat = lat.cos();
876 let sin_lon = lon.sin();
877 let cos_lon = lon.cos();
878
879 let n = WGS84_A_KM / (1.0 - WGS84_E2 * sin_lat * sin_lat).sqrt();
880
881 let x = (n + alt_km) * cos_lat * cos_lon;
882 let y = (n + alt_km) * cos_lat * sin_lon;
883 let z = (n * (1.0 - WGS84_E2) + alt_km) * sin_lat;
884
885 (x, y, z)
886}
887
888fn geodetic_to_itrs_au(lat_deg: f64, lon_deg: f64, alt_km: f64) -> [f64; 3] {
892 let lat = lat_deg * TAU / 360.0;
893 let lon = lon_deg * TAU / 360.0;
894
895 let sinphi = lat.sin();
896 let cosphi = lat.cos();
897
898 let radius_au = WGS84_A_KM / AU_KM;
899 let elevation_au = alt_km / AU_KM;
900
901 let omf2 = (1.0 - WGS84_F) * (1.0 - WGS84_F);
902 let c = 1.0 / (cosphi * cosphi + sinphi * sinphi * omf2).sqrt();
903 let s = omf2 * c;
904
905 let radius_xy = radius_au * c;
906 let xy = (radius_xy + elevation_au) * cosphi;
907 let x = xy * lon.cos();
908 let y = xy * lon.sin();
909
910 let radius_z = radius_au * s;
911 let z = (radius_z + elevation_au) * sinphi;
912
913 [x, y, z]
914}
915
916fn ecef_to_enu_matrix(lat_deg: f64, lon_deg: f64) -> Mat3 {
918 let lat = lat_deg.to_radians();
919 let lon = lon_deg.to_radians();
920
921 let sin_lat = lat.sin();
922 let cos_lat = lat.cos();
923 let sin_lon = lon.sin();
924 let cos_lon = lon.cos();
925
926 [
931 [-sin_lon, cos_lon, 0.0],
932 [-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat],
933 [cos_lat * cos_lon, cos_lat * sin_lon, sin_lat],
934 ]
935}
936
937pub fn gcrs_to_topocentric_compute(
941 sat_gcrs_km: [f64; 3],
942 station: &GeodeticStationKm,
943 ts: &TimeScales,
944 skyfield_compat: bool,
945) -> Result<(f64, f64, f64), FrameTransformError> {
946 validate_vec3("sat_gcrs_km", &sat_gcrs_km)?;
947 validate_geodetic_degrees_km(
948 station.latitude_deg,
949 station.longitude_deg,
950 station.altitude_km,
951 )?;
952 validate_time_scales(ts)?;
953 validate_tuple3(
954 "topocentric",
955 gcrs_to_topocentric_compute_unchecked(sat_gcrs_km, station, ts, skyfield_compat),
956 )
957}
958
959fn gcrs_to_topocentric_compute_unchecked(
960 sat_gcrs_km: [f64; 3],
961 station: &GeodeticStationKm,
962 ts: &TimeScales,
963 skyfield_compat: bool,
964) -> (f64, f64, f64) {
965 let [sat_x, sat_y, sat_z] = sat_gcrs_km;
966 let station_lat_deg = station.latitude_deg;
967 let station_lon_deg = station.longitude_deg;
968 let station_alt_km = station.altitude_km;
969 if skyfield_compat {
970 return gcrs_to_topocentric_skyfield(
971 sat_x,
972 sat_y,
973 sat_z,
974 station_lat_deg,
975 station_lon_deg,
976 station_alt_km,
977 ts,
978 );
979 }
980
981 let (sat_itrs_x, sat_itrs_y, sat_itrs_z) =
983 gcrs_to_itrs_compute_unchecked(sat_x, sat_y, sat_z, ts, false);
984
985 let (stn_x, stn_y, stn_z) =
986 geodetic_to_itrs_unchecked(station_lat_deg, station_lon_deg, station_alt_km);
987
988 let dx = sat_itrs_x - stn_x;
989 let dy = sat_itrs_y - stn_y;
990 let dz = sat_itrs_z - stn_z;
991
992 let enu_mat = ecef_to_enu_matrix(station_lat_deg, station_lon_deg);
993 let enu = mat3_vec3_mul_unchecked(&enu_mat, &[dx, dy, dz]);
994 let east = enu[0];
995 let north = enu[1];
996 let up = enu[2];
997
998 let range = (east * east + north * north + up * up).sqrt();
1000
1001 let elevation = (up / range).asin().to_degrees();
1003
1004 let mut azimuth = east.atan2(north).to_degrees();
1006 if azimuth < 0.0 {
1007 azimuth += 360.0;
1008 }
1009
1010 (azimuth, elevation, range)
1011}
1012
1013fn gcrs_to_topocentric_skyfield(
1024 sat_x: f64,
1025 sat_y: f64,
1026 sat_z: f64,
1027 station_lat_deg: f64,
1028 station_lon_deg: f64,
1029 station_alt_km: f64,
1030 ts: &TimeScales,
1031) -> (f64, f64, f64) {
1032 let lat_rad = station_lat_deg * TAU / 360.0;
1033 let lon_rad = station_lon_deg * TAU / 360.0;
1034
1035 let cy = lat_rad.cos();
1037 let sy = lat_rad.sin();
1038 let r_lat: Mat3 = [[-sy, 0.0, cy], [0.0, 1.0, 0.0], [cy, 0.0, sy]];
1041
1042 let rz_neg_lon = build_rot_z(-lon_rad);
1044 let r_latlon = inline_rxr(&r_lat, &rz_neg_lon);
1045
1046 let r_itrs = gcrs_to_itrs_matrix_unchecked(ts);
1048 let r_full = inline_rxr(&r_latlon, &r_itrs);
1049
1050 let stn_itrs_au = geodetic_to_itrs_au(station_lat_deg, station_lon_deg, station_alt_km);
1053
1054 let r_itrs_t = inline_tr(&r_itrs);
1056 let stn_gcrs_au = mat3_vec3_mul_unchecked(&r_itrs_t, &stn_itrs_au);
1057
1058 let sat_au = [sat_x / AU_KM, sat_y / AU_KM, sat_z / AU_KM];
1060
1061 let diff_au = [
1063 sat_au[0] - stn_gcrs_au[0],
1064 sat_au[1] - stn_gcrs_au[1],
1065 sat_au[2] - stn_gcrs_au[2],
1066 ];
1067
1068 let enu_au = mat3_vec3_mul_unchecked(&r_full, &diff_au);
1070
1071 let ex = enu_au[0];
1073 let ey = enu_au[1];
1074 let ez = enu_au[2];
1075
1076 let r_au = (ex * ex + ey * ey + ez * ez).sqrt();
1077 let elevation_rad = ez.atan2((ex * ex + ey * ey).sqrt());
1078 let mut azimuth_rad = ey.atan2(ex) % TAU;
1079 if azimuth_rad < 0.0 {
1080 azimuth_rad += TAU;
1081 }
1082
1083 let range_km = r_au * AU_KM;
1084 let elevation_deg = elevation_rad * 360.0 / TAU;
1085 let azimuth_deg = azimuth_rad * 360.0 / TAU;
1086
1087 (azimuth_deg, elevation_deg, range_km)
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use crate::astro::time::scales::TimeScales;
1094
1095 fn assert_mat3_bits_eq(actual: &Mat3, expected: &Mat3) {
1096 for i in 0..3 {
1097 for j in 0..3 {
1098 assert_eq!(
1099 actual[i][j].to_bits(),
1100 expected[i][j].to_bits(),
1101 "matrix[{i}][{j}]: {} vs {}",
1102 actual[i][j],
1103 expected[i][j]
1104 );
1105 }
1106 }
1107 }
1108
1109 fn assert_vec3_bits_eq(actual: [f64; 3], expected: [f64; 3]) {
1110 for i in 0..3 {
1111 assert_eq!(
1112 actual[i].to_bits(),
1113 expected[i].to_bits(),
1114 "vector[{i}]: {} vs {}",
1115 actual[i],
1116 expected[i]
1117 );
1118 }
1119 }
1120
1121 #[test]
1122 fn itrs_to_gcrs_inverts_gcrs_to_itrs() {
1123 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1127 let (x, y, z) = (4321.0_f64, -5678.0, 3210.0);
1128
1129 let (ix, iy, iz) =
1130 gcrs_to_itrs_compute(x, y, z, &ts, false).expect("valid frame transform");
1131 assert!(((ix - x).abs() + (iy - y).abs() + (iz - z).abs()) > 100.0);
1133
1134 let (bx, by, bz) = itrs_to_gcrs_compute(ix, iy, iz, &ts).expect("valid frame transform");
1135 assert!((bx - x).abs() < 1e-9, "x {bx} vs {x}");
1136 assert!((by - y).abs() < 1e-9, "y {by} vs {y}");
1137 assert!((bz - z).abs() < 1e-9, "z {bz} vs {z}");
1138
1139 let n0 = (x * x + y * y + z * z).sqrt();
1141 let n1 = (ix * ix + iy * iy + iz * iz).sqrt();
1142 assert!((n0 - n1).abs() < 1e-9);
1143 }
1144
1145 #[test]
1146 fn polar_motion_matrix_matches_documented_convention() {
1147 let pole = PolarMotion::from_arcseconds(0.25, -0.35).expect("valid polar motion");
1148 let cx = pole.xp_rad.cos();
1149 let sx = pole.xp_rad.sin();
1150 let cy = pole.yp_rad.cos();
1151 let sy = pole.yp_rad.sin();
1152
1153 let expected = [
1154 [cx, sx * sy, sx * cy],
1155 [0.0, cy, -sy],
1156 [-sx, cx * sy, cx * cy],
1157 ];
1158 let got = polar_motion_matrix(pole).expect("valid polar motion matrix");
1159 assert_mat3_bits_eq(&got, &expected);
1160
1161 let small_angle = [
1162 [1.0, 0.0, pole.xp_rad],
1163 [0.0, 1.0, -pole.yp_rad],
1164 [-pole.xp_rad, pole.yp_rad, 1.0],
1165 ];
1166 for i in 0..3 {
1167 for j in 0..3 {
1168 assert!(
1169 (got[i][j] - small_angle[i][j]).abs() < 1.0e-11,
1170 "matrix[{i}][{j}] {} vs small-angle {}",
1171 got[i][j],
1172 small_angle[i][j]
1173 );
1174 }
1175 }
1176 }
1177
1178 #[test]
1179 fn gcrs_to_itrs_with_polar_motion_premultiplies_legacy_rotation() {
1180 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1181 let pole = PolarMotion::from_arcseconds(0.18, -0.24).expect("valid polar motion");
1182 let legacy = gcrs_to_itrs_matrix(&ts).expect("valid frame transform");
1183 let expected = inline_rxr(
1184 &polar_motion_matrix(pole).expect("valid polar motion matrix"),
1185 &legacy,
1186 );
1187 let got = gcrs_to_itrs_matrix_with_polar_motion(&ts, pole).expect("valid frame transform");
1188
1189 assert_mat3_bits_eq(&got, &expected);
1190
1191 let pos = [4321.0_f64, -5678.0, 3210.0];
1192 let actual_vec =
1193 gcrs_to_itrs_compute_with_polar_motion(pos[0], pos[1], pos[2], &ts, false, pole)
1194 .expect("valid frame transform");
1195 let expected_vec = mat3_vec3_mul(&expected, &pos).expect("finite matrix-vector product");
1196 assert_vec3_bits_eq([actual_vec.0, actual_vec.1, actual_vec.2], expected_vec);
1197
1198 let legacy_vec =
1199 gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, false).expect("valid transform");
1200 let delta = (actual_vec.0 - legacy_vec.0).abs()
1201 + (actual_vec.1 - legacy_vec.1).abs()
1202 + (actual_vec.2 - legacy_vec.2).abs();
1203 assert!(
1204 delta > 1.0e-4,
1205 "nonzero polar motion should move the vector"
1206 );
1207
1208 let inverse =
1209 itrs_to_gcrs_matrix_with_polar_motion(&ts, pole).expect("valid frame transform");
1210 assert_mat3_bits_eq(&inverse, &inline_tr(&got));
1211 }
1212
1213 #[test]
1214 fn zero_polar_motion_matches_legacy_transform_bits() {
1215 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1216 let legacy = gcrs_to_itrs_matrix(&ts).expect("valid frame transform");
1217 let zero = gcrs_to_itrs_matrix_with_polar_motion(&ts, PolarMotion::ZERO)
1218 .expect("valid frame transform");
1219 assert_mat3_bits_eq(&zero, &legacy);
1220
1221 let mean_legacy = mean_of_date_to_itrs_matrix(&ts).expect("valid frame transform");
1222 let mean_zero = mean_of_date_to_itrs_matrix_with_polar_motion(&ts, PolarMotion::ZERO)
1223 .expect("valid frame transform");
1224 assert_mat3_bits_eq(&mean_zero, &mean_legacy);
1225
1226 let pos = [4321.0_f64, -5678.0, 3210.0];
1227 for skyfield_compat in [false, true] {
1228 let legacy_vec = gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, skyfield_compat)
1229 .expect("valid frame transform");
1230 let zero_vec = gcrs_to_itrs_compute_with_polar_motion(
1231 pos[0],
1232 pos[1],
1233 pos[2],
1234 &ts,
1235 skyfield_compat,
1236 PolarMotion::ZERO,
1237 )
1238 .expect("valid frame transform");
1239 assert_vec3_bits_eq(
1240 [zero_vec.0, zero_vec.1, zero_vec.2],
1241 [legacy_vec.0, legacy_vec.1, legacy_vec.2],
1242 );
1243 }
1244
1245 let legacy_back =
1246 itrs_to_gcrs_compute(pos[0], pos[1], pos[2], &ts).expect("valid frame transform");
1247 let zero_back =
1248 itrs_to_gcrs_compute_with_polar_motion(pos[0], pos[1], pos[2], &ts, PolarMotion::ZERO)
1249 .expect("valid frame transform");
1250 assert_vec3_bits_eq(
1251 [zero_back.0, zero_back.1, zero_back.2],
1252 [legacy_back.0, legacy_back.1, legacy_back.2],
1253 );
1254 }
1255
1256 #[test]
1257 fn frame_transforms_reject_nonfinite_time() {
1258 let mut ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1259 ts.jd_tt = f64::NAN;
1260
1261 assert!(greenwich_mean_sidereal_time_radians(&ts).is_err());
1262 assert!(gcrs_to_itrs_matrix(&ts).is_err());
1263 assert!(itrs_to_gcrs_compute(1.0, 2.0, 3.0, &ts).is_err());
1264 }
1265
1266 #[test]
1267 fn frame_transforms_reject_nonfinite_pole_coordinates() {
1268 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1269 assert!(PolarMotion::from_radians(f64::NAN, 0.0).is_err());
1270 assert!(PolarMotion::from_arcseconds(0.0, f64::INFINITY).is_err());
1271
1272 let pole = PolarMotion {
1273 xp_rad: f64::NAN,
1274 yp_rad: 0.0,
1275 };
1276 assert!(polar_motion_matrix(pole).is_err());
1277 assert!(gcrs_to_itrs_matrix_with_polar_motion(&ts, pole).is_err());
1278 }
1279
1280 #[test]
1281 fn frame_transforms_reject_nonfinite_vectors() {
1282 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1283 let bad_state = TemeStateKm {
1284 position_km: [1.0, f64::NAN, 3.0],
1285 velocity_km_s: [0.1, 0.2, 0.3],
1286 };
1287 assert!(teme_to_gcrs_compute(&bad_state, &ts, false).is_err());
1288 assert!(gcrs_to_itrs_compute(1.0, f64::INFINITY, 3.0, &ts, false).is_err());
1289 assert!(itrs_to_gcrs_compute(1.0, 2.0, f64::NEG_INFINITY, &ts).is_err());
1290 }
1291
1292 #[test]
1293 fn validated_frame_transform_preserves_valid_bits() {
1294 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1295 let pos = [4321.0_f64, -5678.0, 3210.0];
1296 let expected = gcrs_to_itrs_compute_unchecked(pos[0], pos[1], pos[2], &ts, true);
1297 let got =
1298 gcrs_to_itrs_compute(pos[0], pos[1], pos[2], &ts, true).expect("valid frame transform");
1299 assert_vec3_bits_eq([got.0, got.1, got.2], [expected.0, expected.1, expected.2]);
1300 }
1301
1302 #[test]
1303 fn geodetic_transforms_reject_invalid_coordinates() {
1304 assert!(itrs_to_geodetic_compute(f64::NAN, 0.0, 0.0).is_err());
1305 assert!(geodetic_from_ecef_proj(0.0, f64::INFINITY, 0.0).is_err());
1306 assert!(geodetic_to_itrs(90.000_001, 0.0, 0.0).is_err());
1307 assert!(geodetic_to_itrs(0.0, -180.000_001, 0.0).is_err());
1308 assert!(geodetic_to_itrs(0.0, 0.0, f64::NAN).is_err());
1309 }
1310
1311 #[test]
1312 fn topocentric_transform_rejects_invalid_coordinates() {
1313 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1314 let station = GeodeticStationKm {
1315 latitude_deg: f64::NAN,
1316 longitude_deg: 0.0,
1317 altitude_km: 0.0,
1318 };
1319 assert!(gcrs_to_topocentric_compute([7000.0, 0.0, 0.0], &station, &ts, false).is_err());
1320
1321 let station = GeodeticStationKm {
1322 latitude_deg: 0.0,
1323 longitude_deg: 181.0,
1324 altitude_km: 0.0,
1325 };
1326 assert!(gcrs_to_topocentric_compute([7000.0, 0.0, 0.0], &station, &ts, false).is_err());
1327
1328 let station = GeodeticStationKm {
1329 latitude_deg: 0.0,
1330 longitude_deg: 0.0,
1331 altitude_km: 0.0,
1332 };
1333 assert!(
1334 gcrs_to_topocentric_compute([7000.0, f64::NAN, 0.0], &station, &ts, false).is_err()
1335 );
1336 }
1337
1338 #[test]
1339 fn validated_geodetic_transform_preserves_valid_bits() {
1340 let (lat, lon, alt) = (51.4779, -0.0015, 0.046);
1341 let expected = geodetic_to_itrs_unchecked(lat, lon, alt);
1342 let got = geodetic_to_itrs(lat, lon, alt).expect("valid geodetic coordinates");
1343 assert_eq!(got.0.to_bits(), expected.0.to_bits());
1344 assert_eq!(got.1.to_bits(), expected.1.to_bits());
1345 assert_eq!(got.2.to_bits(), expected.2.to_bits());
1346
1347 let expected = itrs_to_geodetic_compute_unchecked(got.0, got.1, got.2);
1348 let roundtrip =
1349 itrs_to_geodetic_compute(got.0, got.1, got.2).expect("valid ITRS coordinates");
1350 assert_eq!(roundtrip.0.to_bits(), expected.0.to_bits());
1351 assert_eq!(roundtrip.1.to_bits(), expected.1.to_bits());
1352 assert_eq!(roundtrip.2.to_bits(), expected.2.to_bits());
1353 }
1354
1355 #[test]
1356 fn sidereal_time_wrappers_are_in_range_and_consistent() {
1357 let ts = TimeScales::from_utc(2020, 6, 24, 12, 34, 56.0).expect("valid UTC instant");
1358 let gmst = greenwich_mean_sidereal_time_radians(&ts).expect("valid sidereal time");
1359 let gast = greenwich_apparent_sidereal_time_radians(&ts).expect("valid sidereal time");
1360
1361 assert!((0.0..TAU).contains(&gmst), "gmst {gmst}");
1363 assert!((0.0..TAU).contains(&gast), "gast {gast}");
1364
1365 let diff = (gast - gmst).rem_euclid(TAU);
1368 let eq_eq = diff.min(TAU - diff);
1369 assert!(eq_eq < 1.0e-3, "equation of equinoxes too large: {eq_eq}");
1370
1371 let gmst_hours = sidereal_time_hours(ts.jd_whole, ts.ut1_fraction, ts.tdb_fraction);
1373 assert_eq!(gmst, gmst_hours / 24.0 * TAU);
1374 }
1375}