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::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] {
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
212pub 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 let spins_since_j2000: f64 = {
251 let v = 0.00273781191135448 * days_since_j2000;
252 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
300pub 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
318pub 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
338fn 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 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
366pub(crate) fn teme_to_gcrs_matrix(ts: &TimeScales, skyfield_compat: bool) -> Mat3 {
368 build_teme_to_gcrs_matrix(ts, skyfield_compat)
369}
370
371pub 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
390pub 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 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 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
447pub 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 let m = inline_mxmxm(&n, &p, &b);
473
474 let gast = gast_radians(ts, dpsi);
475
476 let r_gast = build_rot_z(-gast);
478
479 inline_rxr(&r_gast, &m)
481}
482
483pub 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
504pub 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 inline_rxr(&r_gast, &n)
534}
535
536pub 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
556pub 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 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
596pub 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
635pub 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
652pub 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
669pub 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
695pub 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
724pub 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 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; let r_xy = (x_au * x_au + y_au * y_au).sqrt();
750
751 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 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 let height_au = (hyp * hyp + r_xy * r_xy).sqrt() - a_c;
777 let alt = height_au * AU_KM;
778
779 (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
796pub 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
854pub 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
889fn 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
917fn 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 [
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
938pub 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 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
988pub 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 let range = (east * east + north * north + up * up).sqrt();
1033
1034 let elevation = (up / range).asin().to_degrees();
1036
1037 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
1053fn 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 let cy = lat_rad.cos();
1077 let sy = lat_rad.sin();
1078 let r_lat: Mat3 = [[-sy, 0.0, cy], [0.0, 1.0, 0.0], [cy, 0.0, sy]];
1081
1082 let rz_neg_lon = build_rot_z(-lon_rad);
1084 let r_latlon = inline_rxr(&r_lat, &rz_neg_lon);
1085
1086 let r_itrs = gcrs_to_itrs_matrix_unchecked(ts);
1088 let r_full = inline_rxr(&r_latlon, &r_itrs);
1089
1090 let stn_itrs_au = geodetic_to_itrs_au(station_lat_deg, station_lon_deg, station_alt_km);
1093
1094 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 let sat_au = [sat_x / AU_KM, sat_y / AU_KM, sat_z / AU_KM];
1100
1101 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 let enu_au = mat3_vec3_mul_unchecked(&r_full, &diff_au);
1110
1111 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 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 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 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 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 let sat_itrs = [sx + 20_000.0, sy, sz];
1391 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 assert!((0.0..TAU).contains(&gmst), "gmst {gmst}");
1428 assert!((0.0..TAU).contains(&gast), "gast {gast}");
1429
1430 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 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}