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 - libm::acos(cos.clamp(-1.0, 1.0))
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(libm::atan2(sin_theta, 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);
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/// Angle (degrees) between the satellite nadir (toward Earth center) and the
132/// direction from the satellite to `body`.
133#[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
146/// Angle (degrees) between satellite nadir and the Sun direction.
147///
148/// `sat_pos` is the satellite GCRS position (km); `sun_pos` is the Sun position
149/// relative to Earth center (km).
150pub 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
154/// Angle (degrees) between satellite nadir and the Moon direction.
155pub 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
159/// Sun elevation (degrees) above the satellite's local horizontal plane.
160///
161/// Positive means the Sun is on the sunlit (zenith) side. The zenith direction
162/// is the satellite position itself, since Earth is at the GCRS origin.
163pub 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
171/// Sun-satellite-observer phase angle (degrees): the angle at the satellite
172/// between the Sun and the observer.
173pub 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
186/// Solar beta angle (degrees, signed, in [-90, 90]): the elevation of the Sun
187/// above the orbit plane, `beta = 90 - angle(orbit_normal, sun)`, computed in
188/// the normative `pi/2 - acos(clamp(cos))` operation order.
189///
190/// `orbit_normal` is the orbit-plane normal, for example `r x v`; `sun` is the
191/// Earth-to-Sun vector. Both must be in the same inertial frame. Only their
192/// directions matter. Sign is positive when the Sun is on the `+orbit_normal`
193/// side of the plane.
194pub 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
201/// Solar beta angle (degrees) from an inertial Cartesian state.
202///
203/// Computes the orbit normal as `r x v` and delegates to [`beta_angle`]. `r`,
204/// `v`, and `sun` must all be in the same inertial frame. Returns
205/// `AngleError::InvalidInput { field: "orbit_normal", reason: "zero vector" }`
206/// when `r` and `v` are parallel, since no orbit plane is defined.
207pub 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
213/// Angular radius (degrees) of the Earth as seen from the satellite:
214/// `asin(R_earth / |sat_pos|)`, clamped to the `asin` domain.
215pub 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        // Frozen canonical table generated with astropy.coordinates.
292        // angular_separation and position_angle from astropy 7.2.0 on
293        // Python 3.14.6. Inputs are ICRS/J2000 degrees
294        // (lon1, lat1, lon2, lat2); outputs are degrees. Skyfield 1.49
295        // separation_from cross-checked the star separations within 1e-9 deg.
296        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    // Frozen bits captured from the reference Elixir angles implementation.
589    // Cross-language 0-ULP equality.
590
591    #[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}