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