Skip to main content

sidereon_core/astro/
angles.rs

1//! Satellite angular geometry against celestial bodies.
2//!
3//! Computes nadir/Sun, nadir/Moon, Sun-elevation, phase, and Earth angular
4//! radius angles from GCRS position vectors (km), returning degrees. This is
5//! the authoritative implementation; the Elixir binding is a thin marshaling
6//! layer over it, and the high-level `compute` orchestration (TLE propagation
7//! plus ephemeris lookup) stays caller-side over the already-core kernels.
8
9use crate::astro::constants::earth::WGS84_A_KM;
10use crate::astro::constants::units::DEGREES_PER_SEMICIRCLE;
11use crate::astro::math::vec3;
12
13/// A right angle in degrees: elevation is the complement of the zenith angle.
14const RIGHT_ANGLE_DEG: f64 = 90.0;
15
16/// Error while computing satellite angular geometry.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
18pub enum AngleError {
19    #[error("invalid angle input {field}: {reason}")]
20    InvalidInput {
21        field: &'static str,
22        reason: &'static str,
23    },
24}
25
26/// Radians to degrees in the reference operation order (`rad * 180 / pi`,
27/// multiply before divide), required for bit-exact parity with the prior
28/// Elixir reference rather than a single rounded `RAD_TO_DEG` constant.
29#[inline]
30pub fn rad_to_deg_ref(rad: f64) -> f64 {
31    rad * DEGREES_PER_SEMICIRCLE / std::f64::consts::PI
32}
33
34/// Snap a geodetic longitude (radians) off the `-pi` branch cut onto `+pi`.
35///
36/// WGS84 geodetic longitude is reported on the half-open interval `(-pi, pi]`;
37/// a value at exactly `-pi` denotes the same meridian as `+pi`, so it is folded
38/// up. Every other value passes through unchanged. Shared by the precise
39/// positioning, RTK, and geometry receiver-geodetic paths so the branch-cut
40/// convention is defined once.
41#[inline]
42pub fn normalize_geodetic_lon_rad(lon_rad: f64) -> f64 {
43    if lon_rad <= -std::f64::consts::PI {
44        std::f64::consts::PI
45    } else {
46        lon_rad
47    }
48}
49
50/// Angle (degrees) between two vectors via the clamped cosine, in the
51/// reference operation order.
52#[inline]
53fn angle_between(
54    a: [f64; 3],
55    a_field: &'static str,
56    b: [f64; 3],
57    b_field: &'static str,
58) -> Result<f64, AngleError> {
59    validate_nonzero_vec3(a, a_field)?;
60    validate_nonzero_vec3(b, b_field)?;
61    let cos_theta = vec3::dot3(a, b) / (vec3::norm3(a) * vec3::norm3(b));
62    // Clamp into the valid cosine domain for numerical safety.
63    let cos_theta = cos_theta.clamp(-1.0, 1.0);
64    Ok(rad_to_deg_ref(cos_theta.acos()))
65}
66
67/// Angle (degrees) between the satellite nadir (toward Earth center) and the
68/// direction from the satellite to `body`.
69#[inline]
70fn nadir_body_angle(
71    sat_pos: [f64; 3],
72    body_pos: [f64; 3],
73    body_field: &'static str,
74) -> Result<f64, AngleError> {
75    validate_nonzero_vec3(sat_pos, "sat_pos")?;
76    validate_nonzero_vec3(body_pos, body_field)?;
77    let nadir = vec3::neg3(sat_pos);
78    let body_from_sat = vec3::sub3(body_pos, sat_pos);
79    angle_between(nadir, "sat_pos", body_from_sat, body_field)
80}
81
82/// Angle (degrees) between satellite nadir and the Sun direction.
83///
84/// `sat_pos` is the satellite GCRS position (km); `sun_pos` is the Sun position
85/// relative to Earth center (km).
86pub fn sun_angle(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<f64, AngleError> {
87    nadir_body_angle(sat_pos, sun_pos, "sun_pos")
88}
89
90/// Angle (degrees) between satellite nadir and the Moon direction.
91pub fn moon_angle(sat_pos: [f64; 3], moon_pos: [f64; 3]) -> Result<f64, AngleError> {
92    nadir_body_angle(sat_pos, moon_pos, "moon_pos")
93}
94
95/// Sun elevation (degrees) above the satellite's local horizontal plane.
96///
97/// Positive means the Sun is on the sunlit (zenith) side. The zenith direction
98/// is the satellite position itself, since Earth is at the GCRS origin.
99pub fn sun_elevation(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<f64, AngleError> {
100    validate_nonzero_vec3(sat_pos, "sat_pos")?;
101    validate_nonzero_vec3(sun_pos, "sun_pos")?;
102    let sun_from_sat = vec3::sub3(sun_pos, sat_pos);
103    let zenith_angle = angle_between(sat_pos, "sat_pos", sun_from_sat, "sun_pos")?;
104    Ok(RIGHT_ANGLE_DEG - zenith_angle)
105}
106
107/// Sun-satellite-observer phase angle (degrees): the angle at the satellite
108/// between the Sun and the observer.
109pub fn phase_angle(
110    sat_pos: [f64; 3],
111    sun_pos: [f64; 3],
112    observer_pos: [f64; 3],
113) -> Result<f64, AngleError> {
114    validate_nonzero_vec3(sat_pos, "sat_pos")?;
115    validate_nonzero_vec3(sun_pos, "sun_pos")?;
116    validate_nonzero_vec3(observer_pos, "observer_pos")?;
117    let sun_from_sat = vec3::sub3(sun_pos, sat_pos);
118    let observer_from_sat = vec3::sub3(observer_pos, sat_pos);
119    angle_between(sun_from_sat, "sun_pos", observer_from_sat, "observer_pos")
120}
121
122/// Angular radius (degrees) of the Earth as seen from the satellite:
123/// `asin(R_earth / |sat_pos|)`, clamped to the `asin` domain.
124pub fn earth_angular_radius(sat_pos: [f64; 3]) -> Result<f64, AngleError> {
125    validate_nonzero_vec3(sat_pos, "sat_pos")?;
126    let distance = vec3::norm3(sat_pos);
127    let ratio = (WGS84_A_KM / distance).min(1.0);
128    Ok(rad_to_deg_ref(ratio.asin()))
129}
130
131fn validate_nonzero_vec3(v: [f64; 3], field: &'static str) -> Result<(), AngleError> {
132    if !v.iter().all(|value| value.is_finite()) {
133        return Err(invalid_angle_input(field, "not finite"));
134    }
135    let norm = vec3::norm3(v);
136    if norm == 0.0 {
137        return Err(invalid_angle_input(field, "zero vector"));
138    }
139    if !norm.is_finite() {
140        return Err(invalid_angle_input(field, "out of range"));
141    }
142    Ok(())
143}
144
145fn invalid_angle_input(field: &'static str, reason: &'static str) -> AngleError {
146    AngleError::InvalidInput { field, reason }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    // Frozen bits captured from the reference (Elixir) `Sidereon.Angles`
154    // implementation. Cross-language 0-ULP equality.
155
156    #[test]
157    fn sun_angle_matches_reference_bits() {
158        let sat = [6778.0, 0.0, 0.0];
159        let skew_sat = [6778.0, 123.0, -456.0];
160        assert_eq!(
161            sun_angle(sat, [149_597_870.0, 0.0, 0.0])
162                .expect("valid angle geometry")
163                .to_bits(),
164            0x4066_8000_0000_0000
165        );
166        assert_eq!(
167            sun_angle(sat, [-149_597_870.0, 0.0, 0.0])
168                .expect("valid angle geometry")
169                .to_bits(),
170            0x0000_0000_0000_0000
171        );
172        assert_eq!(
173            sun_angle(skew_sat, [149_597_870.0, 1_000_000.0, -500_000.0])
174                .expect("valid angle geometry")
175                .to_bits(),
176            0x4066_091c_484a_7158
177        );
178    }
179
180    #[test]
181    fn moon_angle_matches_reference_bits() {
182        let sat = [6778.0, 0.0, 0.0];
183        let skew_sat = [6778.0, 123.0, -456.0];
184        assert_eq!(
185            moon_angle(sat, [200_000.0, 300_000.0, 50_000.0])
186                .expect("valid angle geometry")
187                .to_bits(),
188            0x405e_9b67_b2be_cf9b
189        );
190        assert_eq!(
191            moon_angle(skew_sat, [-384_400.0, 12_345.0, 6_789.0])
192                .expect("valid angle geometry")
193                .to_bits(),
194            0x400f_c228_50bd_874f
195        );
196    }
197
198    #[test]
199    fn sun_elevation_matches_reference_bits() {
200        let sat = [6778.0, 0.0, 0.0];
201        let skew_sat = [6778.0, 123.0, -456.0];
202        assert_eq!(
203            sun_elevation(sat, [149_597_870.0, 0.0, 0.0])
204                .expect("valid angle geometry")
205                .to_bits(),
206            0x4056_8000_0000_0000
207        );
208        assert_eq!(
209            sun_elevation(sat, [0.0, 149_597_870.0, 0.0])
210                .expect("valid angle geometry")
211                .to_bits(),
212            0xbf65_4421_f2e3_8000
213        );
214        assert_eq!(
215            sun_elevation(skew_sat, [149_597_870.0, 1_000_000.0, -500_000.0])
216                .expect("valid angle geometry")
217                .to_bits(),
218            0x4055_9238_9094_e2b1
219        );
220    }
221
222    #[test]
223    fn phase_angle_matches_reference_bits() {
224        let sat = [6778.0, 0.0, 0.0];
225        let skew_sat = [6778.0, 123.0, -456.0];
226        assert_eq!(
227            phase_angle(sat, [149_597_870.0, 1_000_000.0, 0.0], [0.0, 6378.0, 0.0],)
228                .expect("valid angle geometry")
229                .to_bits(),
230            0x4061_0b78_cc20_1866
231        );
232        assert_eq!(
233            phase_angle(
234                skew_sat,
235                [149_597_870.0, 1_000_000.0, -500_000.0],
236                [-6378.0, 100.0, 50.0]
237            )
238            .expect("valid angle geometry")
239            .to_bits(),
240            0x4066_3f01_b89b_b002
241        );
242    }
243
244    #[test]
245    fn earth_angular_radius_matches_reference_bits() {
246        assert_eq!(
247            earth_angular_radius([6778.0, 0.0, 0.0])
248                .expect("valid angle geometry")
249                .to_bits(),
250            0x4051_8e27_583c_2f41
251        );
252        assert_eq!(
253            earth_angular_radius([42_164.0, 0.0, 0.0])
254                .expect("valid angle geometry")
255                .to_bits(),
256            0x4021_66aa_1bd9_bda5
257        );
258        assert_eq!(
259            earth_angular_radius([7000.0, 1234.0, -567.0])
260                .expect("valid angle geometry")
261                .to_bits(),
262            0x404f_b89e_165a_1133
263        );
264    }
265
266    #[test]
267    fn angle_helpers_reject_invalid_vectors() {
268        assert_invalid_angle_field(
269            sun_angle([0.0, 0.0, 0.0], [149_597_870.0, 0.0, 0.0]).unwrap_err(),
270            "sat_pos",
271            "zero vector",
272        );
273        assert_invalid_angle_field(
274            moon_angle([6778.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0]).unwrap_err(),
275            "moon_pos",
276            "not finite",
277        );
278        assert_invalid_angle_field(
279            sun_elevation([6778.0, 0.0, 0.0], [6778.0, 0.0, 0.0]).unwrap_err(),
280            "sun_pos",
281            "zero vector",
282        );
283        assert_invalid_angle_field(
284            phase_angle(
285                [6778.0, 0.0, 0.0],
286                [149_597_870.0, 0.0, 0.0],
287                [6778.0, 0.0, 0.0],
288            )
289            .unwrap_err(),
290            "observer_pos",
291            "zero vector",
292        );
293        assert_invalid_angle_field(
294            earth_angular_radius([f64::INFINITY, 0.0, 0.0]).unwrap_err(),
295            "sat_pos",
296            "not finite",
297        );
298    }
299
300    fn assert_invalid_angle_field(
301        error: AngleError,
302        expected: &'static str,
303        expected_reason: &'static str,
304    ) {
305        let AngleError::InvalidInput { field, reason } = error;
306        assert_eq!(field, expected);
307        assert_eq!(reason, expected_reason);
308    }
309}