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]
30fn rad_to_deg_ref(rad: f64) -> f64 {
31    rad * DEGREES_PER_SEMICIRCLE / std::f64::consts::PI
32}
33
34/// Angle (degrees) between two vectors via the clamped cosine, in the
35/// reference operation order.
36#[inline]
37fn angle_between(
38    a: [f64; 3],
39    a_field: &'static str,
40    b: [f64; 3],
41    b_field: &'static str,
42) -> Result<f64, AngleError> {
43    validate_nonzero_vec3(a, a_field)?;
44    validate_nonzero_vec3(b, b_field)?;
45    let cos_theta = vec3::dot3(a, b) / (vec3::norm3(a) * vec3::norm3(b));
46    // Clamp into the valid cosine domain for numerical safety.
47    let cos_theta = cos_theta.clamp(-1.0, 1.0);
48    Ok(rad_to_deg_ref(cos_theta.acos()))
49}
50
51/// Angle (degrees) between the satellite nadir (toward Earth center) and the
52/// direction from the satellite to `body`.
53#[inline]
54fn nadir_body_angle(
55    sat_pos: [f64; 3],
56    body_pos: [f64; 3],
57    body_field: &'static str,
58) -> Result<f64, AngleError> {
59    validate_nonzero_vec3(sat_pos, "sat_pos")?;
60    validate_nonzero_vec3(body_pos, body_field)?;
61    let nadir = vec3::neg3(sat_pos);
62    let body_from_sat = vec3::sub3(body_pos, sat_pos);
63    angle_between(nadir, "sat_pos", body_from_sat, body_field)
64}
65
66/// Angle (degrees) between satellite nadir and the Sun direction.
67///
68/// `sat_pos` is the satellite GCRS position (km); `sun_pos` is the Sun position
69/// relative to Earth center (km).
70pub fn sun_angle(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<f64, AngleError> {
71    nadir_body_angle(sat_pos, sun_pos, "sun_pos")
72}
73
74/// Angle (degrees) between satellite nadir and the Moon direction.
75pub fn moon_angle(sat_pos: [f64; 3], moon_pos: [f64; 3]) -> Result<f64, AngleError> {
76    nadir_body_angle(sat_pos, moon_pos, "moon_pos")
77}
78
79/// Sun elevation (degrees) above the satellite's local horizontal plane.
80///
81/// Positive means the Sun is on the sunlit (zenith) side. The zenith direction
82/// is the satellite position itself, since Earth is at the GCRS origin.
83pub fn sun_elevation(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<f64, AngleError> {
84    validate_nonzero_vec3(sat_pos, "sat_pos")?;
85    validate_nonzero_vec3(sun_pos, "sun_pos")?;
86    let sun_from_sat = vec3::sub3(sun_pos, sat_pos);
87    let zenith_angle = angle_between(sat_pos, "sat_pos", sun_from_sat, "sun_pos")?;
88    Ok(RIGHT_ANGLE_DEG - zenith_angle)
89}
90
91/// Sun-satellite-observer phase angle (degrees): the angle at the satellite
92/// between the Sun and the observer.
93pub fn phase_angle(
94    sat_pos: [f64; 3],
95    sun_pos: [f64; 3],
96    observer_pos: [f64; 3],
97) -> Result<f64, AngleError> {
98    validate_nonzero_vec3(sat_pos, "sat_pos")?;
99    validate_nonzero_vec3(sun_pos, "sun_pos")?;
100    validate_nonzero_vec3(observer_pos, "observer_pos")?;
101    let sun_from_sat = vec3::sub3(sun_pos, sat_pos);
102    let observer_from_sat = vec3::sub3(observer_pos, sat_pos);
103    angle_between(sun_from_sat, "sun_pos", observer_from_sat, "observer_pos")
104}
105
106/// Angular radius (degrees) of the Earth as seen from the satellite:
107/// `asin(R_earth / |sat_pos|)`, clamped to the `asin` domain.
108pub fn earth_angular_radius(sat_pos: [f64; 3]) -> Result<f64, AngleError> {
109    validate_nonzero_vec3(sat_pos, "sat_pos")?;
110    let distance = vec3::norm3(sat_pos);
111    let ratio = (WGS84_A_KM / distance).min(1.0);
112    Ok(rad_to_deg_ref(ratio.asin()))
113}
114
115fn validate_nonzero_vec3(v: [f64; 3], field: &'static str) -> Result<(), AngleError> {
116    if !v.iter().all(|value| value.is_finite()) {
117        return Err(invalid_angle_input(field, "not finite"));
118    }
119    let norm = vec3::norm3(v);
120    if norm == 0.0 {
121        return Err(invalid_angle_input(field, "zero vector"));
122    }
123    if !norm.is_finite() {
124        return Err(invalid_angle_input(field, "out of range"));
125    }
126    Ok(())
127}
128
129fn invalid_angle_input(field: &'static str, reason: &'static str) -> AngleError {
130    AngleError::InvalidInput { field, reason }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    // Frozen bits captured from the reference (Elixir) `Sidereon.Angles`
138    // implementation. Cross-language 0-ULP equality.
139
140    #[test]
141    fn sun_angle_matches_reference_bits() {
142        let sat = [6778.0, 0.0, 0.0];
143        let skew_sat = [6778.0, 123.0, -456.0];
144        assert_eq!(
145            sun_angle(sat, [149_597_870.0, 0.0, 0.0])
146                .expect("valid angle geometry")
147                .to_bits(),
148            0x4066_8000_0000_0000
149        );
150        assert_eq!(
151            sun_angle(sat, [-149_597_870.0, 0.0, 0.0])
152                .expect("valid angle geometry")
153                .to_bits(),
154            0x0000_0000_0000_0000
155        );
156        assert_eq!(
157            sun_angle(skew_sat, [149_597_870.0, 1_000_000.0, -500_000.0])
158                .expect("valid angle geometry")
159                .to_bits(),
160            0x4066_091c_484a_7158
161        );
162    }
163
164    #[test]
165    fn moon_angle_matches_reference_bits() {
166        let sat = [6778.0, 0.0, 0.0];
167        let skew_sat = [6778.0, 123.0, -456.0];
168        assert_eq!(
169            moon_angle(sat, [200_000.0, 300_000.0, 50_000.0])
170                .expect("valid angle geometry")
171                .to_bits(),
172            0x405e_9b67_b2be_cf9b
173        );
174        assert_eq!(
175            moon_angle(skew_sat, [-384_400.0, 12_345.0, 6_789.0])
176                .expect("valid angle geometry")
177                .to_bits(),
178            0x400f_c228_50bd_874f
179        );
180    }
181
182    #[test]
183    fn sun_elevation_matches_reference_bits() {
184        let sat = [6778.0, 0.0, 0.0];
185        let skew_sat = [6778.0, 123.0, -456.0];
186        assert_eq!(
187            sun_elevation(sat, [149_597_870.0, 0.0, 0.0])
188                .expect("valid angle geometry")
189                .to_bits(),
190            0x4056_8000_0000_0000
191        );
192        assert_eq!(
193            sun_elevation(sat, [0.0, 149_597_870.0, 0.0])
194                .expect("valid angle geometry")
195                .to_bits(),
196            0xbf65_4421_f2e3_8000
197        );
198        assert_eq!(
199            sun_elevation(skew_sat, [149_597_870.0, 1_000_000.0, -500_000.0])
200                .expect("valid angle geometry")
201                .to_bits(),
202            0x4055_9238_9094_e2b1
203        );
204    }
205
206    #[test]
207    fn phase_angle_matches_reference_bits() {
208        let sat = [6778.0, 0.0, 0.0];
209        let skew_sat = [6778.0, 123.0, -456.0];
210        assert_eq!(
211            phase_angle(sat, [149_597_870.0, 1_000_000.0, 0.0], [0.0, 6378.0, 0.0],)
212                .expect("valid angle geometry")
213                .to_bits(),
214            0x4061_0b78_cc20_1866
215        );
216        assert_eq!(
217            phase_angle(
218                skew_sat,
219                [149_597_870.0, 1_000_000.0, -500_000.0],
220                [-6378.0, 100.0, 50.0]
221            )
222            .expect("valid angle geometry")
223            .to_bits(),
224            0x4066_3f01_b89b_b002
225        );
226    }
227
228    #[test]
229    fn earth_angular_radius_matches_reference_bits() {
230        assert_eq!(
231            earth_angular_radius([6778.0, 0.0, 0.0])
232                .expect("valid angle geometry")
233                .to_bits(),
234            0x4051_8e27_583c_2f41
235        );
236        assert_eq!(
237            earth_angular_radius([42_164.0, 0.0, 0.0])
238                .expect("valid angle geometry")
239                .to_bits(),
240            0x4021_66aa_1bd9_bda5
241        );
242        assert_eq!(
243            earth_angular_radius([7000.0, 1234.0, -567.0])
244                .expect("valid angle geometry")
245                .to_bits(),
246            0x404f_b89e_165a_1133
247        );
248    }
249
250    #[test]
251    fn angle_helpers_reject_invalid_vectors() {
252        assert_invalid_angle_field(
253            sun_angle([0.0, 0.0, 0.0], [149_597_870.0, 0.0, 0.0]).unwrap_err(),
254            "sat_pos",
255            "zero vector",
256        );
257        assert_invalid_angle_field(
258            moon_angle([6778.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0]).unwrap_err(),
259            "moon_pos",
260            "not finite",
261        );
262        assert_invalid_angle_field(
263            sun_elevation([6778.0, 0.0, 0.0], [6778.0, 0.0, 0.0]).unwrap_err(),
264            "sun_pos",
265            "zero vector",
266        );
267        assert_invalid_angle_field(
268            phase_angle(
269                [6778.0, 0.0, 0.0],
270                [149_597_870.0, 0.0, 0.0],
271                [6778.0, 0.0, 0.0],
272            )
273            .unwrap_err(),
274            "observer_pos",
275            "zero vector",
276        );
277        assert_invalid_angle_field(
278            earth_angular_radius([f64::INFINITY, 0.0, 0.0]).unwrap_err(),
279            "sat_pos",
280            "not finite",
281        );
282    }
283
284    fn assert_invalid_angle_field(
285        error: AngleError,
286        expected: &'static str,
287        expected_reason: &'static str,
288    ) {
289        let AngleError::InvalidInput { field, reason } = error;
290        assert_eq!(field, expected);
291        assert_eq!(reason, expected_reason);
292    }
293}