Skip to main content

sidereon_core/astro/
angles.rs

1//! Angular geometry helpers for sky directions and satellite body geometry.
2//!
3//! Computes general angular separation between arbitrary directions or
4//! `(lon, lat)` / `(RA, Dec)` pairs, position angle measured North through East,
5//! and satellite nadir/Sun, nadir/Moon, Sun-elevation, phase, solar beta, and
6//! Earth angular radius angles. Public angle-producing helpers in this module
7//! return degrees unless their names state otherwise.
8
9use 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
14/// A right angle in degrees: elevation is the complement of the zenith angle.
15const RIGHT_ANGLE_DEG: f64 = 90.0;
16
17/// Error while computing satellite angular geometry.
18#[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/// Radians to degrees in the reference operation order (`rad * 180 / pi`,
28/// multiply before divide), required for bit-exact parity with the prior
29/// Elixir reference rather than a single rounded `RAD_TO_DEG` constant.
30#[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/// Snap a geodetic longitude (radians) off the `-pi` branch cut onto `+pi`.
41///
42/// WGS84 geodetic longitude is reported on the half-open interval `(-pi, pi]`;
43/// a value at exactly `-pi` denotes the same meridian as `+pi`, so it is folded
44/// up. Every other value passes through unchanged. Shared by the precise
45/// positioning, RTK, and geometry receiver-geodetic paths so the branch-cut
46/// convention is defined once.
47#[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/// Angle (degrees) between two vectors via the clamped cosine, in the
57/// reference operation order.
58#[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    // Clamp into the valid cosine domain for numerical safety.
69    Ok(rad_to_deg_ref(clamp_acos(cos_theta)))
70}
71
72/// On-sky angle (degrees) between two direction vectors, via the stable
73/// `atan2(|a x b|, a . b)` form.
74///
75/// Vectors need not be normalized, but each must be finite, non-zero, and have
76/// a finite positive squared norm under `dot3`. Extreme magnitudes whose squared
77/// norm overflows to infinity or underflows to zero are rejected with
78/// `AngleError::InvalidInput`. Fields `"a"` / `"b"` identify the offending
79/// argument.
80pub 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
90/// On-sky angle (degrees) between two `(lon, lat)` / `(RA, Dec)` pairs in
91/// degrees. The second tuple component is the latitude or declination in
92/// `[-90, 90]`.
93pub 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
105/// Position angle (degrees, `[0, 360)`) of `to` as seen from `from`, measured
106/// from North (`+lat`) through East (`+lon`). Inputs are `(lon, lat)` in degrees.
107pub 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).to_radians();
115    let lat1 = from_lon_lat_deg.1.to_radians();
116    let lon2 = reduce_lon_deg(to_lon_lat_deg.0).to_radians();
117    let lat2 = to_lon_lat_deg.1.to_radians();
118    let dlon = lon2 - lon1;
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/// Angle (degrees) between the satellite nadir (toward Earth center) and the
131/// direction from the satellite to `body`.
132#[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
145/// Angle (degrees) between satellite nadir and the Sun direction.
146///
147/// `sat_pos` is the satellite GCRS position (km); `sun_pos` is the Sun position
148/// relative to Earth center (km).
149pub 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
153/// Angle (degrees) between satellite nadir and the Moon direction.
154pub 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
158/// Sun elevation (degrees) above the satellite's local horizontal plane.
159///
160/// Positive means the Sun is on the sunlit (zenith) side. The zenith direction
161/// is the satellite position itself, since Earth is at the GCRS origin.
162pub 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
170/// Sun-satellite-observer phase angle (degrees): the angle at the satellite
171/// between the Sun and the observer.
172pub 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
185/// Solar beta angle (degrees, signed, in [-90, 90]): the elevation of the Sun
186/// above the orbit plane, `beta = 90 - angle(orbit_normal, sun)`, computed in
187/// the normative `pi/2 - acos(clamp(cos))` operation order.
188///
189/// `orbit_normal` is the orbit-plane normal, for example `r x v`; `sun` is the
190/// Earth-to-Sun vector. Both must be in the same inertial frame. Only their
191/// directions matter. Sign is positive when the Sun is on the `+orbit_normal`
192/// side of the plane.
193pub 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
200/// Solar beta angle (degrees) from an inertial Cartesian state.
201///
202/// Computes the orbit normal as `r x v` and delegates to [`beta_angle`]. `r`,
203/// `v`, and `sun` must all be in the same inertial frame. Returns
204/// `AngleError::InvalidInput { field: "orbit_normal", reason: "zero vector" }`
205/// when `r` and `v` are parallel, since no orbit plane is defined.
206pub 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
212/// Angular radius (degrees) of the Earth as seen from the satellite:
213/// `asin(R_earth / |sat_pos|)`, clamped to the `asin` domain.
214pub 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    }
282
283    #[test]
284    fn angular_separation_matches_reference_catalog_cases() {
285        // Reference values frozen from astropy 7.2.0 and skyfield 1.49 on
286        // Python 3.12.13. Coordinates are ICRS, epoch J2000.0. Mizar, Alcor,
287        // Betelgeuse, and Rigel coordinates are SIMBAD ICRS J2000 entries.
288        // Astropy SkyCoord.separation produced expected_sep_deg; Skyfield
289        // separation_from cross-checked each value within 1e-9 deg.
290        let cases = [
291            ReferenceCase {
292                name: "Mizar-Alcor",
293                a_lon_lat_deg: (200.98141866666666, 54.925_351_972_222_22),
294                b_lon_lat_deg: (201.306_407_638_75, 54.987_959_661_388_89),
295                expected_sep_deg: 0.19682972435842,
296            },
297            ReferenceCase {
298                name: "Betelgeuse-Rigel",
299                a_lon_lat_deg: (88.792_939, 7.407_064),
300                b_lon_lat_deg: (78.634_467_083_333_33, -8.201_638_361_111_11),
301                expected_sep_deg: 18.605960601325172,
302            },
303            ReferenceCase {
304                name: "large angle pair",
305                a_lon_lat_deg: (12.3456789012345, -45.678_901_234_567_8),
306                b_lon_lat_deg: (278.765_432_109_876_5, 62.3456789012345),
307                expected_sep_deg: 130.84062807863208,
308            },
309        ];
310
311        for case in cases {
312            let coord_sep =
313                angular_separation_coords(case.a_lon_lat_deg, case.b_lon_lat_deg).expect(case.name);
314            assert_close_deg(coord_sep, case.expected_sep_deg, 1.0e-9);
315
316            let vector_sep = angular_separation(
317                unit_from_lon_lat_deg(case.a_lon_lat_deg),
318                unit_from_lon_lat_deg(case.b_lon_lat_deg),
319            )
320            .expect(case.name);
321            assert_close_deg(vector_sep, coord_sep, 1.0e-9);
322        }
323    }
324
325    #[test]
326    fn position_angle_uses_north_through_east_convention() {
327        assert_pa_close(
328            position_angle((0.0, 0.0), (0.0, 10.0)).unwrap(),
329            0.0,
330            1.0e-12,
331        );
332        assert_pa_close(
333            position_angle((0.0, 0.0), (10.0, 0.0)).unwrap(),
334            90.0,
335            1.0e-12,
336        );
337        assert_pa_close(
338            position_angle((0.0, 0.0), (0.0, -10.0)).unwrap(),
339            180.0,
340            1.0e-12,
341        );
342        assert_pa_close(
343            position_angle((0.0, 0.0), (-10.0, 0.0)).unwrap(),
344            270.0,
345            1.0e-12,
346        );
347
348        let off_axis = position_angle((15.0, 20.0), (75.0, -10.0)).unwrap();
349        assert_pa_close(off_axis, 111.24565371752205, 1.0e-9);
350    }
351
352    #[test]
353    fn angular_separation_keeps_small_vector_angles_stable() {
354        let theta = 1.0e-8_f64;
355        let expected_deg = rad_to_deg_ref(theta);
356        let axis_a = [1.0, 0.0, 0.0];
357        let axis_b = [theta.cos(), theta.sin(), 0.0];
358
359        let axis_atan2 = angular_separation(axis_a, axis_b).unwrap();
360        let axis_acos = acos_separation_deg(axis_a, axis_b);
361        assert!(
362            relative_error(axis_atan2, expected_deg) <= 1.0e-9,
363            "axis atan2 actual={axis_atan2}, expected={expected_deg}"
364        );
365        assert!(
366            relative_error(axis_acos, expected_deg) >= 1.0e-3,
367            "axis acos actual={axis_acos}, expected={expected_deg}"
368        );
369
370        let oblique_u = vec3::unit3([1.0, 2.0, 3.0]).expect("nonzero vector");
371        let oblique_k =
372            vec3::unit3(vec3::cross3(oblique_u, [0.0, 0.0, 1.0])).expect("nonzero axis");
373        let oblique_v = rotate_about_axis(oblique_u, oblique_k, theta);
374
375        let oblique_atan2 = angular_separation(oblique_u, oblique_v).unwrap();
376        let oblique_acos = acos_separation_deg(oblique_u, oblique_v);
377        assert!(
378            relative_error(oblique_atan2, expected_deg) <= 1.0e-6,
379            "oblique atan2 actual={oblique_atan2}, expected={expected_deg}"
380        );
381        assert!(
382            relative_error(oblique_acos, expected_deg) >= 1.0e-3,
383            "oblique acos actual={oblique_acos}, expected={expected_deg}"
384        );
385    }
386
387    #[test]
388    fn angular_separation_coords_has_absolute_small_angle_accuracy() {
389        let sep = angular_separation_coords((0.0, 0.0), (1.0e-6, 0.0)).unwrap();
390        assert_close_deg(sep, 1.0e-6, 1.0e-9);
391    }
392
393    #[test]
394    fn angular_separation_handles_antipodal_and_near_antipodal_cases() {
395        let exact = angular_separation([1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]).unwrap();
396        assert_close_deg(exact, 180.0, 1.0e-12);
397
398        let eps_deg = 1.0e-7_f64;
399        let eps = eps_deg.to_radians();
400        let a = [1.0, 0.0, 0.0];
401        let b = [-eps.cos(), eps.sin(), 0.0];
402        let sep = angular_separation(a, b).unwrap();
403        let complement = 180.0 - sep;
404        assert!(
405            relative_error(complement, eps_deg) <= 1.0e-6,
406            "complement={complement}, expected={eps_deg}, sep={sep}"
407        );
408
409        let acos_sep = acos_separation_deg(a, b);
410        let acos_complement = 180.0 - acos_sep;
411        assert!(
412            relative_error(acos_complement, eps_deg) >= 1.0e-3,
413            "acos complement={acos_complement}, expected={eps_deg}"
414        );
415
416        assert_finite_pa(position_angle((0.0, 0.0), (180.0, 0.0)).unwrap());
417        assert_finite_pa(position_angle((0.0, 90.0), (0.0, -90.0)).unwrap());
418    }
419
420    #[test]
421    fn coincident_inputs_return_zero_separation_and_zero_position_angle() {
422        assert_eq!(
423            angular_separation([1.0, 2.0, 3.0], [1.0, 2.0, 3.0])
424                .unwrap()
425                .to_bits(),
426            0.0_f64.to_bits()
427        );
428        assert_eq!(
429            angular_separation_coords((123.0, 45.0), (123.0, 45.0))
430                .unwrap()
431                .to_bits(),
432            0.0_f64.to_bits()
433        );
434        assert_eq!(
435            position_angle((123.0, 45.0), (123.0, 45.0))
436                .unwrap()
437                .to_bits(),
438            0.0_f64.to_bits()
439        );
440    }
441
442    #[test]
443    fn pole_edge_cases_are_finite_and_documented() {
444        assert_close_deg(
445            angular_separation_coords((0.0, 90.0), (0.0, -90.0)).unwrap(),
446            180.0,
447            1.0e-12,
448        );
449        let same_north_pole = angular_separation_coords((0.0, 90.0), (123.0, 90.0)).unwrap();
450        assert!(
451            same_north_pole <= 1.0e-9,
452            "same-pole separation={same_north_pole}"
453        );
454
455        assert_pa_close(
456            position_angle((0.0, 0.0), (123.0, 90.0)).unwrap(),
457            0.0,
458            1.0e-6,
459        );
460        assert_pa_close(
461            position_angle((0.0, 0.0), (123.0, -90.0)).unwrap(),
462            180.0,
463            1.0e-6,
464        );
465
466        assert_finite_pa(position_angle((0.0, 89.999999999999), (90.0, 90.0)).unwrap());
467        assert_finite_pa(position_angle((0.0, 90.0), (45.0, 10.0)).unwrap());
468        assert_finite_pa(position_angle((0.0, 90.0), (90.0, 90.0)).unwrap());
469    }
470
471    #[test]
472    fn general_angle_helpers_reject_invalid_inputs() {
473        assert_invalid_angle_field(
474            angular_separation([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
475            "a",
476            "zero vector",
477        );
478        assert_invalid_angle_field(
479            angular_separation([1.0, 0.0, 0.0], [0.0, 0.0, 0.0]).unwrap_err(),
480            "b",
481            "zero vector",
482        );
483        assert_invalid_angle_field(
484            angular_separation([f64::NAN, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
485            "a",
486            "not finite",
487        );
488        assert_invalid_angle_field(
489            angular_separation([1.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0]).unwrap_err(),
490            "b",
491            "not finite",
492        );
493        assert_invalid_angle_field(
494            angular_separation([1.0e300, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
495            "a",
496            "out of range",
497        );
498        assert_invalid_angle_field(
499            angular_separation([1.0e-300, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
500            "a",
501            "zero vector",
502        );
503
504        assert_invalid_angle_field(
505            angular_separation_coords((0.0, 91.0), (0.0, 0.0)).unwrap_err(),
506            "a",
507            "latitude out of range",
508        );
509        assert_invalid_angle_field(
510            angular_separation_coords((0.0, 0.0), (0.0, -91.0)).unwrap_err(),
511            "b",
512            "latitude out of range",
513        );
514        assert_invalid_angle_field(
515            angular_separation_coords((f64::NAN, 0.0), (0.0, 0.0)).unwrap_err(),
516            "a",
517            "not finite",
518        );
519        assert_invalid_angle_field(
520            angular_separation_coords((0.0, 0.0), (0.0, f64::INFINITY)).unwrap_err(),
521            "b",
522            "not finite",
523        );
524
525        assert_invalid_angle_field(
526            position_angle((0.0, 91.0), (0.0, 0.0)).unwrap_err(),
527            "from",
528            "latitude out of range",
529        );
530        assert_invalid_angle_field(
531            position_angle((0.0, 0.0), (0.0, -91.0)).unwrap_err(),
532            "to",
533            "latitude out of range",
534        );
535        assert_invalid_angle_field(
536            position_angle((f64::NAN, 0.0), (0.0, 0.0)).unwrap_err(),
537            "from",
538            "not finite",
539        );
540        assert_invalid_angle_field(
541            position_angle((0.0, 0.0), (0.0, f64::INFINITY)).unwrap_err(),
542            "to",
543            "not finite",
544        );
545    }
546
547    #[test]
548    fn longitude_reduction_wraps_and_canonicalizes_zero() {
549        assert_eq!(reduce_lon_deg(-0.0).to_bits(), 0.0_f64.to_bits());
550        assert_eq!(
551            reduce_lon_deg(DEGREES_PER_CIRCLE).to_bits(),
552            0.0_f64.to_bits()
553        );
554        assert_eq!(reduce_lon_deg(720.0).to_bits(), 0.0_f64.to_bits());
555        assert_eq!(reduce_lon_deg(-10.0).to_bits(), 350.0_f64.to_bits());
556        assert_eq!(reduce_lon_deg(123.456).to_bits(), 123.456_f64.to_bits());
557
558        let sep = angular_separation_coords((359.999999, 0.0), (0.000001, 0.0)).unwrap();
559        assert_close_deg(sep, 2.0e-6, 1.0e-9);
560    }
561
562    // Frozen bits captured from the reference Elixir angles implementation.
563    // Cross-language 0-ULP equality.
564
565    #[test]
566    fn sun_angle_matches_reference_bits() {
567        let sat = [6778.0, 0.0, 0.0];
568        let skew_sat = [6778.0, 123.0, -456.0];
569        assert_eq!(
570            sun_angle(sat, [149_597_870.0, 0.0, 0.0])
571                .expect("valid angle geometry")
572                .to_bits(),
573            0x4066_8000_0000_0000
574        );
575        assert_eq!(
576            sun_angle(sat, [-149_597_870.0, 0.0, 0.0])
577                .expect("valid angle geometry")
578                .to_bits(),
579            0x0000_0000_0000_0000
580        );
581        assert_eq!(
582            sun_angle(skew_sat, [149_597_870.0, 1_000_000.0, -500_000.0])
583                .expect("valid angle geometry")
584                .to_bits(),
585            0x4066_091c_484a_7158
586        );
587    }
588
589    #[test]
590    fn moon_angle_matches_reference_bits() {
591        let sat = [6778.0, 0.0, 0.0];
592        let skew_sat = [6778.0, 123.0, -456.0];
593        assert_eq!(
594            moon_angle(sat, [200_000.0, 300_000.0, 50_000.0])
595                .expect("valid angle geometry")
596                .to_bits(),
597            0x405e_9b67_b2be_cf9b
598        );
599        assert_eq!(
600            moon_angle(skew_sat, [-384_400.0, 12_345.0, 6_789.0])
601                .expect("valid angle geometry")
602                .to_bits(),
603            0x400f_c228_50bd_874f
604        );
605    }
606
607    #[test]
608    fn sun_elevation_matches_reference_bits() {
609        let sat = [6778.0, 0.0, 0.0];
610        let skew_sat = [6778.0, 123.0, -456.0];
611        assert_eq!(
612            sun_elevation(sat, [149_597_870.0, 0.0, 0.0])
613                .expect("valid angle geometry")
614                .to_bits(),
615            0x4056_8000_0000_0000
616        );
617        assert_eq!(
618            sun_elevation(sat, [0.0, 149_597_870.0, 0.0])
619                .expect("valid angle geometry")
620                .to_bits(),
621            0xbf65_4421_f2e3_8000
622        );
623        assert_eq!(
624            sun_elevation(skew_sat, [149_597_870.0, 1_000_000.0, -500_000.0])
625                .expect("valid angle geometry")
626                .to_bits(),
627            0x4055_9238_9094_e2b1
628        );
629    }
630
631    #[test]
632    fn phase_angle_matches_reference_bits() {
633        let sat = [6778.0, 0.0, 0.0];
634        let skew_sat = [6778.0, 123.0, -456.0];
635        assert_eq!(
636            phase_angle(sat, [149_597_870.0, 1_000_000.0, 0.0], [0.0, 6378.0, 0.0],)
637                .expect("valid angle geometry")
638                .to_bits(),
639            0x4061_0b78_cc20_1866
640        );
641        assert_eq!(
642            phase_angle(
643                skew_sat,
644                [149_597_870.0, 1_000_000.0, -500_000.0],
645                [-6378.0, 100.0, 50.0]
646            )
647            .expect("valid angle geometry")
648            .to_bits(),
649            0x4066_3f01_b89b_b002
650        );
651    }
652
653    #[test]
654    fn beta_angle_reference_r1() {
655        let normal = normal_from_elements(51.6_f64.to_radians(), 90.0_f64.to_radians());
656        let sun = [1.0, 0.0, 0.0];
657        let beta = beta_angle(normal, sun).expect("valid beta geometry");
658        assert_close_deg(beta, 51.6, 1.0e-9);
659    }
660
661    #[test]
662    fn beta_angle_reference_r2_r3() {
663        let alpha = 90.0_f64.to_radians();
664        let delta = 23.4392911_f64.to_radians();
665        let sun = sun_from_ra_dec(alpha, delta);
666
667        for (inclination_deg, raan_deg) in [(51.64_f64, 0.0_f64), (98.0_f64, 30.0_f64)] {
668            let inclination = inclination_deg.to_radians();
669            let raan = raan_deg.to_radians();
670            let normal = normal_from_elements(inclination, raan);
671            let expected = closed_form_beta_deg(inclination, raan, alpha, delta);
672            let actual = beta_angle(normal, sun).expect("valid beta geometry");
673            assert_close_deg(actual, expected, 1.0e-9);
674        }
675    }
676
677    #[test]
678    fn beta_angle_sun_in_plane_is_zero() {
679        let normal = [0.25, -0.5, 0.75];
680        let sun = perpendicular_via_least_parallel_axis(normal);
681        let beta = beta_angle(normal, sun).expect("valid beta geometry");
682        assert_close_deg(beta, 0.0, 1.0e-12);
683    }
684
685    #[test]
686    fn beta_angle_sun_along_normal_is_ninety() {
687        let orbit_normal = [0.0, 0.0, 1.0];
688        let beta_positive = beta_angle(orbit_normal, [0.0, 0.0, 5.0]).expect("valid beta geometry");
689        let beta_negative =
690            beta_angle(orbit_normal, [0.0, 0.0, -5.0]).expect("valid beta geometry");
691        assert_close_deg(beta_positive, 90.0, 1.0e-9);
692        assert_close_deg(beta_negative, -90.0, 1.0e-9);
693    }
694
695    #[test]
696    fn beta_angle_from_state_matches_beta_angle() {
697        let r = [7000.0, -1200.0, 350.0];
698        let v = [1.25, 7.35, -0.42];
699        let sun = [149_597_870.0, 3_000_000.0, 1_000_000.0];
700        let from_state = beta_angle_from_state(r, v, sun).expect("valid beta geometry");
701        let from_normal =
702            beta_angle(vec3::cross3(r, v), sun).expect("valid beta geometry from normal");
703        assert_eq!(from_state.to_bits(), from_normal.to_bits());
704    }
705
706    #[test]
707    fn beta_angle_from_state_rejects_parallel_rv() {
708        assert_invalid_angle_field(
709            beta_angle_from_state([1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 0.0, 1.0]).unwrap_err(),
710            "orbit_normal",
711            "zero vector",
712        );
713    }
714
715    #[test]
716    fn beta_angle_rejects_invalid_vectors() {
717        assert_invalid_angle_field(
718            beta_angle([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
719            "orbit_normal",
720            "zero vector",
721        );
722        assert_invalid_angle_field(
723            beta_angle([f64::NAN, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err(),
724            "orbit_normal",
725            "not finite",
726        );
727        assert_invalid_angle_field(
728            beta_angle([0.0, 0.0, 1.0], [0.0, 0.0, 0.0]).unwrap_err(),
729            "sun",
730            "zero vector",
731        );
732        assert_invalid_angle_field(
733            beta_angle([0.0, 0.0, 1.0], [f64::NAN, 0.0, 0.0]).unwrap_err(),
734            "sun",
735            "not finite",
736        );
737    }
738
739    #[test]
740    fn sat_yaw_beta_parity() {
741        let eps = f64::EPSILON;
742        for cos in [-1.0 - eps, -1.0, -0.5, 0.0, 0.5, 1.0, 1.0 + eps] {
743            let old = std::f64::consts::PI / 2.0 - cos.clamp(-1.0, 1.0).acos();
744            assert_eq!(beta_angle_from_cos_rad(cos).to_bits(), old.to_bits());
745        }
746    }
747
748    #[test]
749    fn earth_angular_radius_matches_reference_bits() {
750        assert_eq!(
751            earth_angular_radius([6778.0, 0.0, 0.0])
752                .expect("valid angle geometry")
753                .to_bits(),
754            0x4051_8e27_583c_2f41
755        );
756        assert_eq!(
757            earth_angular_radius([42_164.0, 0.0, 0.0])
758                .expect("valid angle geometry")
759                .to_bits(),
760            0x4021_66aa_1bd9_bda5
761        );
762        assert_eq!(
763            earth_angular_radius([7000.0, 1234.0, -567.0])
764                .expect("valid angle geometry")
765                .to_bits(),
766            0x404f_b89e_165a_1133
767        );
768    }
769
770    #[test]
771    fn angle_helpers_reject_invalid_vectors() {
772        assert_invalid_angle_field(
773            sun_angle([0.0, 0.0, 0.0], [149_597_870.0, 0.0, 0.0]).unwrap_err(),
774            "sat_pos",
775            "zero vector",
776        );
777        assert_invalid_angle_field(
778            moon_angle([6778.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0]).unwrap_err(),
779            "moon_pos",
780            "not finite",
781        );
782        assert_invalid_angle_field(
783            sun_elevation([6778.0, 0.0, 0.0], [6778.0, 0.0, 0.0]).unwrap_err(),
784            "sun_pos",
785            "zero vector",
786        );
787        assert_invalid_angle_field(
788            phase_angle(
789                [6778.0, 0.0, 0.0],
790                [149_597_870.0, 0.0, 0.0],
791                [6778.0, 0.0, 0.0],
792            )
793            .unwrap_err(),
794            "observer_pos",
795            "zero vector",
796        );
797        assert_invalid_angle_field(
798            earth_angular_radius([f64::INFINITY, 0.0, 0.0]).unwrap_err(),
799            "sat_pos",
800            "not finite",
801        );
802    }
803
804    fn assert_invalid_angle_field(
805        error: AngleError,
806        expected: &'static str,
807        expected_reason: &'static str,
808    ) {
809        let AngleError::InvalidInput { field, reason } = error;
810        assert_eq!(field, expected);
811        assert_eq!(reason, expected_reason);
812    }
813
814    fn assert_close_deg(actual: f64, expected: f64, tolerance: f64) {
815        assert!(
816            (actual - expected).abs() <= tolerance,
817            "actual={actual}, expected={expected}, tolerance={tolerance}"
818        );
819    }
820
821    fn assert_pa_close(actual: f64, expected: f64, tolerance: f64) {
822        assert_finite_pa(actual);
823        let diff = circular_diff_deg(actual, expected);
824        assert!(
825            diff <= tolerance,
826            "actual={actual}, expected={expected}, diff={diff}, tolerance={tolerance}"
827        );
828    }
829
830    fn assert_finite_pa(pa: f64) {
831        assert!(pa.is_finite(), "pa={pa}");
832        assert!((0.0..DEGREES_PER_CIRCLE).contains(&pa), "pa={pa}");
833    }
834
835    fn circular_diff_deg(actual: f64, expected: f64) -> f64 {
836        let diff = (actual - expected).abs();
837        diff.min(DEGREES_PER_CIRCLE - diff)
838    }
839
840    fn relative_error(actual: f64, expected: f64) -> f64 {
841        ((actual - expected) / expected).abs()
842    }
843
844    fn acos_separation_deg(a: [f64; 3], b: [f64; 3]) -> f64 {
845        let u = vec3::unit3(a).expect("nonzero vector");
846        let v = vec3::unit3(b).expect("nonzero vector");
847        rad_to_deg_ref(vec3::dot3(u, v).clamp(-1.0, 1.0).acos())
848    }
849
850    fn rotate_about_axis(v: [f64; 3], axis: [f64; 3], theta: f64) -> [f64; 3] {
851        let cos_theta = theta.cos();
852        let sin_theta = theta.sin();
853        let cross = vec3::cross3(axis, v);
854        let dot = vec3::dot3(axis, v);
855        [
856            v[0] * cos_theta + cross[0] * sin_theta + axis[0] * dot * (1.0 - cos_theta),
857            v[1] * cos_theta + cross[1] * sin_theta + axis[1] * dot * (1.0 - cos_theta),
858            v[2] * cos_theta + cross[2] * sin_theta + axis[2] * dot * (1.0 - cos_theta),
859        ]
860    }
861
862    fn normal_from_elements(inclination: f64, raan: f64) -> [f64; 3] {
863        [
864            inclination.sin() * raan.sin(),
865            -inclination.sin() * raan.cos(),
866            inclination.cos(),
867        ]
868    }
869
870    fn sun_from_ra_dec(alpha: f64, delta: f64) -> [f64; 3] {
871        [
872            delta.cos() * alpha.cos(),
873            delta.cos() * alpha.sin(),
874            delta.sin(),
875        ]
876    }
877
878    fn closed_form_beta_deg(inclination: f64, raan: f64, alpha: f64, delta: f64) -> f64 {
879        let sin_beta = delta.cos() * inclination.sin() * (raan - alpha).sin()
880            + delta.sin() * inclination.cos();
881        rad_to_deg_ref(sin_beta.asin())
882    }
883
884    fn perpendicular_via_least_parallel_axis(v: [f64; 3]) -> [f64; 3] {
885        let axis = if v[0].abs() <= v[1].abs() && v[0].abs() <= v[2].abs() {
886            [1.0, 0.0, 0.0]
887        } else if v[1].abs() <= v[2].abs() {
888            [0.0, 1.0, 0.0]
889        } else {
890            [0.0, 0.0, 1.0]
891        };
892        vec3::cross3(v, axis)
893    }
894}