1use crate::astro::constants::earth::WGS84_A_KM;
10use crate::astro::constants::units::{DEGREES_PER_CIRCLE, DEGREES_PER_SEMICIRCLE};
11use crate::astro::elements::clamp_acos;
12use crate::astro::math::vec3;
13
14const RIGHT_ANGLE_DEG: f64 = 90.0;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
19pub enum AngleError {
20 #[error("invalid angle input {field}: {reason}")]
21 InvalidInput {
22 field: &'static str,
23 reason: &'static str,
24 },
25}
26
27#[inline]
31pub fn rad_to_deg_ref(rad: f64) -> f64 {
32 rad * DEGREES_PER_SEMICIRCLE / std::f64::consts::PI
33}
34
35#[inline]
36pub(crate) fn beta_angle_from_cos_rad(cos: f64) -> f64 {
37 std::f64::consts::FRAC_PI_2 - libm::acos(cos.clamp(-1.0, 1.0))
38}
39
40#[inline]
48pub fn normalize_geodetic_lon_rad(lon_rad: f64) -> f64 {
49 if lon_rad <= -std::f64::consts::PI {
50 std::f64::consts::PI
51 } else {
52 lon_rad
53 }
54}
55
56#[inline]
59fn angle_between(
60 a: [f64; 3],
61 a_field: &'static str,
62 b: [f64; 3],
63 b_field: &'static str,
64) -> Result<f64, AngleError> {
65 validate_nonzero_vec3(a, a_field)?;
66 validate_nonzero_vec3(b, b_field)?;
67 let cos_theta = vec3::dot3(a, b) / (vec3::norm3(a) * vec3::norm3(b));
68 Ok(rad_to_deg_ref(clamp_acos(cos_theta)))
70}
71
72pub fn angular_separation(a: [f64; 3], b: [f64; 3]) -> Result<f64, AngleError> {
81 validate_nonzero_vec3(a, "a")?;
82 validate_nonzero_vec3(b, "b")?;
83 let u = vec3::unit3(a).ok_or_else(|| invalid_angle_input("a", "zero vector"))?;
84 let v = vec3::unit3(b).ok_or_else(|| invalid_angle_input("b", "zero vector"))?;
85 let sin_theta = vec3::norm3(vec3::cross3(u, v));
86 let cos_theta = vec3::dot3(u, v);
87 Ok(rad_to_deg_ref(libm::atan2(sin_theta, cos_theta)))
88}
89
90pub fn angular_separation_coords(
94 a_lon_lat_deg: (f64, f64),
95 b_lon_lat_deg: (f64, f64),
96) -> Result<f64, AngleError> {
97 validate_lon_lat_deg(a_lon_lat_deg, "a")?;
98 validate_lon_lat_deg(b_lon_lat_deg, "b")?;
99 angular_separation(
100 unit_from_lon_lat_deg(a_lon_lat_deg),
101 unit_from_lon_lat_deg(b_lon_lat_deg),
102 )
103}
104
105pub fn position_angle(
108 from_lon_lat_deg: (f64, f64),
109 to_lon_lat_deg: (f64, f64),
110) -> Result<f64, AngleError> {
111 validate_lon_lat_deg(from_lon_lat_deg, "from")?;
112 validate_lon_lat_deg(to_lon_lat_deg, "to")?;
113
114 let lon1 = reduce_lon_deg(from_lon_lat_deg.0);
115 let lat1 = from_lon_lat_deg.1.to_radians();
116 let lon2 = reduce_lon_deg(to_lon_lat_deg.0);
117 let lat2 = to_lon_lat_deg.1.to_radians();
118 let dlon = (lon2 - lon1).to_radians();
119
120 let numerator = libm::cos(lat2) * libm::sin(dlon);
121 let denominator =
122 libm::cos(lat1) * libm::sin(lat2) - libm::sin(lat1) * libm::cos(lat2) * libm::cos(dlon);
123 let pa = rad_to_deg_ref(libm::atan2(numerator, denominator)).rem_euclid(DEGREES_PER_CIRCLE);
124 Ok(if pa == DEGREES_PER_CIRCLE || pa == 0.0 {
125 0.0
126 } else {
127 pa
128 })
129}
130
131#[inline]
134fn nadir_body_angle(
135 sat_pos: [f64; 3],
136 body_pos: [f64; 3],
137 body_field: &'static str,
138) -> Result<f64, AngleError> {
139 validate_nonzero_vec3(sat_pos, "sat_pos")?;
140 validate_nonzero_vec3(body_pos, body_field)?;
141 let nadir = vec3::neg3(sat_pos);
142 let body_from_sat = vec3::sub3(body_pos, sat_pos);
143 angle_between(nadir, "sat_pos", body_from_sat, body_field)
144}
145
146pub fn sun_angle(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<f64, AngleError> {
151 nadir_body_angle(sat_pos, sun_pos, "sun_pos")
152}
153
154pub fn moon_angle(sat_pos: [f64; 3], moon_pos: [f64; 3]) -> Result<f64, AngleError> {
156 nadir_body_angle(sat_pos, moon_pos, "moon_pos")
157}
158
159pub fn sun_elevation(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<f64, AngleError> {
164 validate_nonzero_vec3(sat_pos, "sat_pos")?;
165 validate_nonzero_vec3(sun_pos, "sun_pos")?;
166 let sun_from_sat = vec3::sub3(sun_pos, sat_pos);
167 let zenith_angle = angle_between(sat_pos, "sat_pos", sun_from_sat, "sun_pos")?;
168 Ok(RIGHT_ANGLE_DEG - zenith_angle)
169}
170
171pub fn phase_angle(
174 sat_pos: [f64; 3],
175 sun_pos: [f64; 3],
176 observer_pos: [f64; 3],
177) -> Result<f64, AngleError> {
178 validate_nonzero_vec3(sat_pos, "sat_pos")?;
179 validate_nonzero_vec3(sun_pos, "sun_pos")?;
180 validate_nonzero_vec3(observer_pos, "observer_pos")?;
181 let sun_from_sat = vec3::sub3(sun_pos, sat_pos);
182 let observer_from_sat = vec3::sub3(observer_pos, sat_pos);
183 angle_between(sun_from_sat, "sun_pos", observer_from_sat, "observer_pos")
184}
185
186pub fn beta_angle(orbit_normal: [f64; 3], sun: [f64; 3]) -> Result<f64, AngleError> {
195 validate_nonzero_vec3(orbit_normal, "orbit_normal")?;
196 validate_nonzero_vec3(sun, "sun")?;
197 let cos_theta = vec3::dot3(orbit_normal, sun) / (vec3::norm3(orbit_normal) * vec3::norm3(sun));
198 Ok(rad_to_deg_ref(beta_angle_from_cos_rad(cos_theta)))
199}
200
201pub fn beta_angle_from_state(r: [f64; 3], v: [f64; 3], sun: [f64; 3]) -> Result<f64, AngleError> {
208 validate_finite_vec3(r, "r")?;
209 validate_finite_vec3(v, "v")?;
210 beta_angle(vec3::cross3(r, v), sun)
211}
212
213pub fn earth_angular_radius(sat_pos: [f64; 3]) -> Result<f64, AngleError> {
216 validate_nonzero_vec3(sat_pos, "sat_pos")?;
217 let distance = vec3::norm3(sat_pos);
218 let ratio = (WGS84_A_KM / distance).min(1.0);
219 Ok(rad_to_deg_ref(libm::asin(ratio)))
220}
221
222fn unit_from_lon_lat_deg(lon_lat_deg: (f64, f64)) -> [f64; 3] {
223 let lon = reduce_lon_deg(lon_lat_deg.0).to_radians();
224 let lat = lon_lat_deg.1.to_radians();
225 let cos_lat = libm::cos(lat);
226 [
227 cos_lat * libm::cos(lon),
228 cos_lat * libm::sin(lon),
229 libm::sin(lat),
230 ]
231}
232
233fn validate_lon_lat_deg(lon_lat_deg: (f64, f64), field: &'static str) -> Result<(), AngleError> {
234 if !lon_lat_deg.0.is_finite() || !lon_lat_deg.1.is_finite() {
235 return Err(invalid_angle_input(field, "not finite"));
236 }
237 if !(-90.0..=90.0).contains(&lon_lat_deg.1) {
238 return Err(invalid_angle_input(field, "latitude out of range"));
239 }
240 Ok(())
241}
242
243fn reduce_lon_deg(lon: f64) -> f64 {
244 let reduced = lon.rem_euclid(DEGREES_PER_CIRCLE);
245 if reduced == DEGREES_PER_CIRCLE || reduced == 0.0 {
246 0.0
247 } else {
248 reduced
249 }
250}
251
252fn validate_nonzero_vec3(v: [f64; 3], field: &'static str) -> Result<(), AngleError> {
253 if !v.iter().all(|value| value.is_finite()) {
254 return Err(invalid_angle_input(field, "not finite"));
255 }
256 let norm = vec3::norm3(v);
257 if norm == 0.0 {
258 return Err(invalid_angle_input(field, "zero vector"));
259 }
260 if !norm.is_finite() {
261 return Err(invalid_angle_input(field, "out of range"));
262 }
263 Ok(())
264}
265
266fn validate_finite_vec3(v: [f64; 3], field: &'static str) -> Result<(), AngleError> {
267 if !v.iter().all(|value| value.is_finite()) {
268 return Err(invalid_angle_input(field, "not finite"));
269 }
270 Ok(())
271}
272
273fn invalid_angle_input(field: &'static str, reason: &'static str) -> AngleError {
274 AngleError::InvalidInput { field, reason }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 struct ReferenceCase {
282 name: &'static str,
283 a_lon_lat_deg: (f64, f64),
284 b_lon_lat_deg: (f64, f64),
285 expected_sep_deg: f64,
286 expected_pa_deg: f64,
287 }
288
289 #[test]
290 fn angular_separation_and_position_angle_match_reference_cases() {
291 let cases = [
297 ReferenceCase {
298 name: "sirius->procyon",
299 a_lon_lat_deg: (101.287155333, -16.716115861),
300 b_lon_lat_deg: (114.825493028, 5.224993306),
301 expected_sep_deg: 25.7013646403623,
302 expected_pa_deg: 32.51673660099302,
303 },
304 ReferenceCase {
305 name: "sirius->canopus",
306 a_lon_lat_deg: (101.287155333, -16.716115861),
307 b_lon_lat_deg: (95.987958333, -52.695661111),
308 expected_sep_deg: 36.22078689550111,
309 expected_pa_deg: 185.43546880831877,
310 },
311 ReferenceCase {
312 name: "polaris->vega",
313 a_lon_lat_deg: (37.954561667, 89.264108889),
314 b_lon_lat_deg: (279.234734792, 38.783688958),
315 expected_sep_deg: 51.57282525600137,
316 expected_pa_deg: 299.23384966888466,
317 },
318 ReferenceCase {
319 name: "near-coincident",
320 a_lon_lat_deg: (10.0, 20.0),
321 b_lon_lat_deg: (10.0000001, 20.0000001),
322 expected_sep_deg: 1.372232571517946e-07,
323 expected_pa_deg: 43.219178406844925,
324 },
325 ReferenceCase {
326 name: "wrap-crossing",
327 a_lon_lat_deg: (359.9, -5.0),
328 b_lon_lat_deg: (0.1, 5.0),
329 expected_sep_deg: 10.001994721516857,
330 expected_pa_deg: 1.147219154245781,
331 },
332 ];
333
334 for case in cases {
335 let coord_sep =
336 angular_separation_coords(case.a_lon_lat_deg, case.b_lon_lat_deg).expect(case.name);
337 assert_close_deg(coord_sep, case.expected_sep_deg, 1.0e-9);
338
339 let vector_sep = angular_separation(
340 unit_from_lon_lat_deg(case.a_lon_lat_deg),
341 unit_from_lon_lat_deg(case.b_lon_lat_deg),
342 )
343 .expect(case.name);
344 assert_close_deg(vector_sep, coord_sep, 1.0e-9);
345
346 let pa = position_angle(case.a_lon_lat_deg, case.b_lon_lat_deg).expect(case.name);
347 assert_pa_close(pa, case.expected_pa_deg, 1.0e-9);
348 }
349 }
350
351 #[test]
352 fn position_angle_uses_north_through_east_convention() {
353 assert_pa_close(
354 position_angle((0.0, 0.0), (0.0, 10.0)).unwrap(),
355 0.0,
356 1.0e-12,
357 );
358 assert_pa_close(
359 position_angle((0.0, 0.0), (10.0, 0.0)).unwrap(),
360 90.0,
361 1.0e-12,
362 );
363 assert_pa_close(
364 position_angle((0.0, 0.0), (0.0, -10.0)).unwrap(),
365 180.0,
366 1.0e-12,
367 );
368 assert_pa_close(
369 position_angle((0.0, 0.0), (-10.0, 0.0)).unwrap(),
370 270.0,
371 1.0e-12,
372 );
373
374 let off_axis = position_angle((15.0, 20.0), (75.0, -10.0)).unwrap();
375 assert_pa_close(off_axis, 111.24565371752205, 1.0e-9);
376 }
377
378 #[test]
379 fn angular_separation_keeps_small_vector_angles_stable() {
380 let theta = 1.0e-8_f64;
381 let expected_deg = rad_to_deg_ref(theta);
382 let axis_a = [1.0, 0.0, 0.0];
383 let axis_b = [libm::cos(theta), libm::sin(theta), 0.0];
384
385 let axis_atan2 = angular_separation(axis_a, axis_b).unwrap();
386 let axis_acos = acos_separation_deg(axis_a, axis_b);
387 assert!(
388 relative_error(axis_atan2, expected_deg) <= 1.0e-9,
389 "axis atan2 actual={axis_atan2}, expected={expected_deg}"
390 );
391 assert!(
392 relative_error(axis_acos, expected_deg) >= 1.0e-3,
393 "axis acos actual={axis_acos}, expected={expected_deg}"
394 );
395
396 let oblique_u = vec3::unit3([1.0, 2.0, 3.0]).expect("nonzero vector");
397 let oblique_k =
398 vec3::unit3(vec3::cross3(oblique_u, [0.0, 0.0, 1.0])).expect("nonzero axis");
399 let oblique_v = rotate_about_axis(oblique_u, oblique_k, theta);
400
401 let oblique_atan2 = angular_separation(oblique_u, oblique_v).unwrap();
402 let oblique_acos = acos_separation_deg(oblique_u, oblique_v);
403 assert!(
404 relative_error(oblique_atan2, expected_deg) <= 1.0e-6,
405 "oblique atan2 actual={oblique_atan2}, expected={expected_deg}"
406 );
407 assert!(
408 relative_error(oblique_acos, expected_deg) >= 1.0e-3,
409 "oblique acos actual={oblique_acos}, expected={expected_deg}"
410 );
411 }
412
413 #[test]
414 fn angular_separation_coords_has_absolute_small_angle_accuracy() {
415 let sep = angular_separation_coords((0.0, 0.0), (1.0e-6, 0.0)).unwrap();
416 assert_close_deg(sep, 1.0e-6, 1.0e-9);
417 }
418
419 #[test]
420 fn angular_separation_handles_antipodal_and_near_antipodal_cases() {
421 let exact = angular_separation([1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]).unwrap();
422 assert_close_deg(exact, 180.0, 1.0e-12);
423
424 let eps_deg = 1.0e-7_f64;
425 let eps = eps_deg.to_radians();
426 let a = [1.0, 0.0, 0.0];
427 let b = [-libm::cos(eps), libm::sin(eps), 0.0];
428 let sep = angular_separation(a, b).unwrap();
429 let complement = 180.0 - sep;
430 assert!(
431 relative_error(complement, eps_deg) <= 1.0e-6,
432 "complement={complement}, expected={eps_deg}, sep={sep}"
433 );
434
435 let acos_sep = acos_separation_deg(a, b);
436 let acos_complement = 180.0 - acos_sep;
437 assert!(
438 relative_error(acos_complement, eps_deg) >= 1.0e-3,
439 "acos complement={acos_complement}, expected={eps_deg}"
440 );
441
442 assert_finite_pa(position_angle((0.0, 0.0), (180.0, 0.0)).unwrap());
443 assert_finite_pa(position_angle((0.0, 90.0), (0.0, -90.0)).unwrap());
444 }
445
446 #[test]
447 fn coincident_inputs_return_zero_separation_and_zero_position_angle() {
448 assert_eq!(
449 angular_separation([1.0, 2.0, 3.0], [1.0, 2.0, 3.0])
450 .unwrap()
451 .to_bits(),
452 0.0_f64.to_bits()
453 );
454 assert_eq!(
455 angular_separation_coords((123.0, 45.0), (123.0, 45.0))
456 .unwrap()
457 .to_bits(),
458 0.0_f64.to_bits()
459 );
460 assert_eq!(
461 position_angle((123.0, 45.0), (123.0, 45.0))
462 .unwrap()
463 .to_bits(),
464 0.0_f64.to_bits()
465 );
466 }
467
468 #[test]
469 fn pole_edge_cases_are_finite_and_documented() {
470 assert_close_deg(
471 angular_separation_coords((0.0, 90.0), (0.0, -90.0)).unwrap(),
472 180.0,
473 1.0e-12,
474 );
475 let same_north_pole = angular_separation_coords((0.0, 90.0), (123.0, 90.0)).unwrap();
476 assert!(
477 same_north_pole <= 1.0e-9,
478 "same-pole separation={same_north_pole}"
479 );
480
481 assert_pa_close(
482 position_angle((0.0, 0.0), (123.0, 90.0)).unwrap(),
483 0.0,
484 1.0e-6,
485 );
486 assert_pa_close(
487 position_angle((0.0, 0.0), (123.0, -90.0)).unwrap(),
488 180.0,
489 1.0e-6,
490 );
491
492 assert_finite_pa(position_angle((0.0, 89.999999999999), (90.0, 90.0)).unwrap());
493 assert_finite_pa(position_angle((0.0, 90.0), (45.0, 10.0)).unwrap());
494 assert_finite_pa(position_angle((0.0, 90.0), (90.0, 90.0)).unwrap());
495 }
496
497 #[test]
498 fn general_angle_helpers_reject_invalid_inputs() {
499 assert_invalid_angle_field(
500 angular_separation([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
501 "a",
502 "zero vector",
503 );
504 assert_invalid_angle_field(
505 angular_separation([1.0, 0.0, 0.0], [0.0, 0.0, 0.0]).unwrap_err(),
506 "b",
507 "zero vector",
508 );
509 assert_invalid_angle_field(
510 angular_separation([f64::NAN, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
511 "a",
512 "not finite",
513 );
514 assert_invalid_angle_field(
515 angular_separation([1.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0]).unwrap_err(),
516 "b",
517 "not finite",
518 );
519 assert_invalid_angle_field(
520 angular_separation([1.0e300, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
521 "a",
522 "out of range",
523 );
524 assert_invalid_angle_field(
525 angular_separation([1.0e-300, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
526 "a",
527 "zero vector",
528 );
529
530 assert_invalid_angle_field(
531 angular_separation_coords((0.0, 91.0), (0.0, 0.0)).unwrap_err(),
532 "a",
533 "latitude out of range",
534 );
535 assert_invalid_angle_field(
536 angular_separation_coords((0.0, 0.0), (0.0, -91.0)).unwrap_err(),
537 "b",
538 "latitude out of range",
539 );
540 assert_invalid_angle_field(
541 angular_separation_coords((f64::NAN, 0.0), (0.0, 0.0)).unwrap_err(),
542 "a",
543 "not finite",
544 );
545 assert_invalid_angle_field(
546 angular_separation_coords((0.0, 0.0), (0.0, f64::INFINITY)).unwrap_err(),
547 "b",
548 "not finite",
549 );
550
551 assert_invalid_angle_field(
552 position_angle((0.0, 91.0), (0.0, 0.0)).unwrap_err(),
553 "from",
554 "latitude out of range",
555 );
556 assert_invalid_angle_field(
557 position_angle((0.0, 0.0), (0.0, -91.0)).unwrap_err(),
558 "to",
559 "latitude out of range",
560 );
561 assert_invalid_angle_field(
562 position_angle((f64::NAN, 0.0), (0.0, 0.0)).unwrap_err(),
563 "from",
564 "not finite",
565 );
566 assert_invalid_angle_field(
567 position_angle((0.0, 0.0), (0.0, f64::INFINITY)).unwrap_err(),
568 "to",
569 "not finite",
570 );
571 }
572
573 #[test]
574 fn longitude_reduction_wraps_and_canonicalizes_zero() {
575 assert_eq!(reduce_lon_deg(-0.0).to_bits(), 0.0_f64.to_bits());
576 assert_eq!(
577 reduce_lon_deg(DEGREES_PER_CIRCLE).to_bits(),
578 0.0_f64.to_bits()
579 );
580 assert_eq!(reduce_lon_deg(720.0).to_bits(), 0.0_f64.to_bits());
581 assert_eq!(reduce_lon_deg(-10.0).to_bits(), 350.0_f64.to_bits());
582 assert_eq!(reduce_lon_deg(123.456).to_bits(), 123.456_f64.to_bits());
583
584 let sep = angular_separation_coords((359.999999, 0.0), (0.000001, 0.0)).unwrap();
585 assert_close_deg(sep, 2.0e-6, 1.0e-9);
586 }
587
588 #[test]
592 fn sun_angle_matches_reference_bits() {
593 let sat = [6778.0, 0.0, 0.0];
594 let skew_sat = [6778.0, 123.0, -456.0];
595 assert_eq!(
596 sun_angle(sat, [149_597_870.0, 0.0, 0.0])
597 .expect("valid angle geometry")
598 .to_bits(),
599 0x4066_8000_0000_0000
600 );
601 assert_eq!(
602 sun_angle(sat, [-149_597_870.0, 0.0, 0.0])
603 .expect("valid angle geometry")
604 .to_bits(),
605 0x0000_0000_0000_0000
606 );
607 assert_eq!(
608 sun_angle(skew_sat, [149_597_870.0, 1_000_000.0, -500_000.0])
609 .expect("valid angle geometry")
610 .to_bits(),
611 0x4066_091c_484a_7158
612 );
613 }
614
615 #[test]
616 fn moon_angle_matches_reference_bits() {
617 let sat = [6778.0, 0.0, 0.0];
618 let skew_sat = [6778.0, 123.0, -456.0];
619 assert_eq!(
620 moon_angle(sat, [200_000.0, 300_000.0, 50_000.0])
621 .expect("valid angle geometry")
622 .to_bits(),
623 0x405e_9b67_b2be_cf9b
624 );
625 assert_eq!(
626 moon_angle(skew_sat, [-384_400.0, 12_345.0, 6_789.0])
627 .expect("valid angle geometry")
628 .to_bits(),
629 0x400f_c228_50bd_874f
630 );
631 }
632
633 #[test]
634 fn sun_elevation_matches_reference_bits() {
635 let sat = [6778.0, 0.0, 0.0];
636 let skew_sat = [6778.0, 123.0, -456.0];
637 assert_eq!(
638 sun_elevation(sat, [149_597_870.0, 0.0, 0.0])
639 .expect("valid angle geometry")
640 .to_bits(),
641 0x4056_8000_0000_0000
642 );
643 assert_eq!(
644 sun_elevation(sat, [0.0, 149_597_870.0, 0.0])
645 .expect("valid angle geometry")
646 .to_bits(),
647 0xbf65_4421_f2e3_8000
648 );
649 assert_eq!(
650 sun_elevation(skew_sat, [149_597_870.0, 1_000_000.0, -500_000.0])
651 .expect("valid angle geometry")
652 .to_bits(),
653 0x4055_9238_9094_e2b1
654 );
655 }
656
657 #[test]
658 fn phase_angle_matches_reference_bits() {
659 let sat = [6778.0, 0.0, 0.0];
660 let skew_sat = [6778.0, 123.0, -456.0];
661 assert_eq!(
662 phase_angle(sat, [149_597_870.0, 1_000_000.0, 0.0], [0.0, 6378.0, 0.0],)
663 .expect("valid angle geometry")
664 .to_bits(),
665 0x4061_0b78_cc20_1866
666 );
667 assert_eq!(
668 phase_angle(
669 skew_sat,
670 [149_597_870.0, 1_000_000.0, -500_000.0],
671 [-6378.0, 100.0, 50.0]
672 )
673 .expect("valid angle geometry")
674 .to_bits(),
675 0x4066_3f01_b89b_b002
676 );
677 }
678
679 #[test]
680 fn beta_angle_reference_r1() {
681 let normal = normal_from_elements(51.6_f64.to_radians(), 90.0_f64.to_radians());
682 let sun = [1.0, 0.0, 0.0];
683 let beta = beta_angle(normal, sun).expect("valid beta geometry");
684 assert_close_deg(beta, 51.6, 1.0e-9);
685 }
686
687 #[test]
688 fn beta_angle_reference_r2_r3() {
689 let alpha = 90.0_f64.to_radians();
690 let delta = 23.4392911_f64.to_radians();
691 let sun = sun_from_ra_dec(alpha, delta);
692
693 for (inclination_deg, raan_deg) in [(51.64_f64, 0.0_f64), (98.0_f64, 30.0_f64)] {
694 let inclination = inclination_deg.to_radians();
695 let raan = raan_deg.to_radians();
696 let normal = normal_from_elements(inclination, raan);
697 let expected = closed_form_beta_deg(inclination, raan, alpha, delta);
698 let actual = beta_angle(normal, sun).expect("valid beta geometry");
699 assert_close_deg(actual, expected, 1.0e-9);
700 }
701 }
702
703 #[test]
704 fn beta_angle_sun_in_plane_is_zero() {
705 let normal = [0.25, -0.5, 0.75];
706 let sun = perpendicular_via_least_parallel_axis(normal);
707 let beta = beta_angle(normal, sun).expect("valid beta geometry");
708 assert_close_deg(beta, 0.0, 1.0e-12);
709 }
710
711 #[test]
712 fn beta_angle_sun_along_normal_is_ninety() {
713 let orbit_normal = [0.0, 0.0, 1.0];
714 let beta_positive = beta_angle(orbit_normal, [0.0, 0.0, 5.0]).expect("valid beta geometry");
715 let beta_negative =
716 beta_angle(orbit_normal, [0.0, 0.0, -5.0]).expect("valid beta geometry");
717 assert_close_deg(beta_positive, 90.0, 1.0e-9);
718 assert_close_deg(beta_negative, -90.0, 1.0e-9);
719 }
720
721 #[test]
722 fn beta_angle_from_state_matches_beta_angle() {
723 let r = [7000.0, -1200.0, 350.0];
724 let v = [1.25, 7.35, -0.42];
725 let sun = [149_597_870.0, 3_000_000.0, 1_000_000.0];
726 let from_state = beta_angle_from_state(r, v, sun).expect("valid beta geometry");
727 let from_normal =
728 beta_angle(vec3::cross3(r, v), sun).expect("valid beta geometry from normal");
729 assert_eq!(from_state.to_bits(), from_normal.to_bits());
730 }
731
732 #[test]
733 fn beta_angle_from_state_rejects_parallel_rv() {
734 assert_invalid_angle_field(
735 beta_angle_from_state([1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 0.0, 1.0]).unwrap_err(),
736 "orbit_normal",
737 "zero vector",
738 );
739 }
740
741 #[test]
742 fn beta_angle_rejects_invalid_vectors() {
743 assert_invalid_angle_field(
744 beta_angle([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
745 "orbit_normal",
746 "zero vector",
747 );
748 assert_invalid_angle_field(
749 beta_angle([f64::NAN, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
750 "orbit_normal",
751 "not finite",
752 );
753 assert_invalid_angle_field(
754 beta_angle([0.0, 0.0, 1.0], [0.0, 0.0, 0.0]).unwrap_err(),
755 "sun",
756 "zero vector",
757 );
758 assert_invalid_angle_field(
759 beta_angle([0.0, 0.0, 1.0], [f64::NAN, 0.0, 0.0]).unwrap_err(),
760 "sun",
761 "not finite",
762 );
763 }
764
765 #[test]
766 fn sat_yaw_beta_parity() {
767 let eps = f64::EPSILON;
768 for cos in [-1.0 - eps, -1.0, -0.5, 0.0, 0.5, 1.0, 1.0 + eps] {
769 let old = std::f64::consts::PI / 2.0 - libm::acos(cos.clamp(-1.0, 1.0));
770 assert_eq!(beta_angle_from_cos_rad(cos).to_bits(), old.to_bits());
771 }
772 }
773
774 #[test]
775 fn earth_angular_radius_matches_reference_bits() {
776 assert_eq!(
777 earth_angular_radius([6778.0, 0.0, 0.0])
778 .expect("valid angle geometry")
779 .to_bits(),
780 0x4051_8e27_583c_2f41
781 );
782 assert_eq!(
783 earth_angular_radius([42_164.0, 0.0, 0.0])
784 .expect("valid angle geometry")
785 .to_bits(),
786 0x4021_66aa_1bd9_bda5
787 );
788 assert_eq!(
789 earth_angular_radius([7000.0, 1234.0, -567.0])
790 .expect("valid angle geometry")
791 .to_bits(),
792 0x404f_b89e_165a_1133
793 );
794 }
795
796 #[test]
797 fn angle_helpers_reject_invalid_vectors() {
798 assert_invalid_angle_field(
799 sun_angle([0.0, 0.0, 0.0], [149_597_870.0, 0.0, 0.0]).unwrap_err(),
800 "sat_pos",
801 "zero vector",
802 );
803 assert_invalid_angle_field(
804 moon_angle([6778.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0]).unwrap_err(),
805 "moon_pos",
806 "not finite",
807 );
808 assert_invalid_angle_field(
809 sun_elevation([6778.0, 0.0, 0.0], [6778.0, 0.0, 0.0]).unwrap_err(),
810 "sun_pos",
811 "zero vector",
812 );
813 assert_invalid_angle_field(
814 phase_angle(
815 [6778.0, 0.0, 0.0],
816 [149_597_870.0, 0.0, 0.0],
817 [6778.0, 0.0, 0.0],
818 )
819 .unwrap_err(),
820 "observer_pos",
821 "zero vector",
822 );
823 assert_invalid_angle_field(
824 earth_angular_radius([f64::INFINITY, 0.0, 0.0]).unwrap_err(),
825 "sat_pos",
826 "not finite",
827 );
828 }
829
830 fn assert_invalid_angle_field(
831 error: AngleError,
832 expected: &'static str,
833 expected_reason: &'static str,
834 ) {
835 let AngleError::InvalidInput { field, reason } = error;
836 assert_eq!(field, expected);
837 assert_eq!(reason, expected_reason);
838 }
839
840 fn assert_close_deg(actual: f64, expected: f64, tolerance: f64) {
841 assert!(
842 (actual - expected).abs() <= tolerance,
843 "actual={actual}, expected={expected}, tolerance={tolerance}"
844 );
845 }
846
847 fn assert_pa_close(actual: f64, expected: f64, tolerance: f64) {
848 assert_finite_pa(actual);
849 let diff = circular_diff_deg(actual, expected);
850 assert!(
851 diff <= tolerance,
852 "actual={actual}, expected={expected}, diff={diff}, tolerance={tolerance}"
853 );
854 }
855
856 fn assert_finite_pa(pa: f64) {
857 assert!(pa.is_finite(), "pa={pa}");
858 assert!((0.0..DEGREES_PER_CIRCLE).contains(&pa), "pa={pa}");
859 }
860
861 fn circular_diff_deg(actual: f64, expected: f64) -> f64 {
862 let diff = (actual - expected).abs();
863 diff.min(DEGREES_PER_CIRCLE - diff)
864 }
865
866 fn relative_error(actual: f64, expected: f64) -> f64 {
867 ((actual - expected) / expected).abs()
868 }
869
870 fn acos_separation_deg(a: [f64; 3], b: [f64; 3]) -> f64 {
871 let u = vec3::unit3(a).expect("nonzero vector");
872 let v = vec3::unit3(b).expect("nonzero vector");
873 rad_to_deg_ref(libm::acos(vec3::dot3(u, v).clamp(-1.0, 1.0)))
874 }
875
876 fn rotate_about_axis(v: [f64; 3], axis: [f64; 3], theta: f64) -> [f64; 3] {
877 let cos_theta = libm::cos(theta);
878 let sin_theta = libm::sin(theta);
879 let cross = vec3::cross3(axis, v);
880 let dot = vec3::dot3(axis, v);
881 [
882 v[0] * cos_theta + cross[0] * sin_theta + axis[0] * dot * (1.0 - cos_theta),
883 v[1] * cos_theta + cross[1] * sin_theta + axis[1] * dot * (1.0 - cos_theta),
884 v[2] * cos_theta + cross[2] * sin_theta + axis[2] * dot * (1.0 - cos_theta),
885 ]
886 }
887
888 fn normal_from_elements(inclination: f64, raan: f64) -> [f64; 3] {
889 [
890 libm::sin(inclination) * libm::sin(raan),
891 -libm::sin(inclination) * libm::cos(raan),
892 libm::cos(inclination),
893 ]
894 }
895
896 fn sun_from_ra_dec(alpha: f64, delta: f64) -> [f64; 3] {
897 [
898 libm::cos(delta) * libm::cos(alpha),
899 libm::cos(delta) * libm::sin(alpha),
900 libm::sin(delta),
901 ]
902 }
903
904 fn closed_form_beta_deg(inclination: f64, raan: f64, alpha: f64, delta: f64) -> f64 {
905 let sin_beta = libm::cos(delta) * libm::sin(inclination) * libm::sin(raan - alpha)
906 + libm::sin(delta) * libm::cos(inclination);
907 rad_to_deg_ref(libm::asin(sin_beta))
908 }
909
910 fn perpendicular_via_least_parallel_axis(v: [f64; 3]) -> [f64; 3] {
911 let axis = if v[0].abs() <= v[1].abs() && v[0].abs() <= v[2].abs() {
912 [1.0, 0.0, 0.0]
913 } else if v[1].abs() <= v[2].abs() {
914 [0.0, 1.0, 0.0]
915 } else {
916 [0.0, 0.0, 1.0]
917 };
918 vec3::cross3(v, axis)
919 }
920}