Skip to main content

sidereon_core/astro/events/
eclipse.rs

1//! Conical Earth-shadow (eclipse) geometry.
2//!
3//! Determines whether a satellite is in full sunlight, penumbra, or umbra
4//! using a conical shadow model: the penumbra and umbra cones cast by Earth
5//! given the Sun's position, and the satellite's perpendicular distance from
6//! the Earth-Sun shadow axis. This is the authoritative implementation; the
7//! Elixir binding is a thin marshaling layer over it.
8
9use crate::astro::constants::{
10    astro::SOLAR_RADIUS_KM,
11    earth::{MEAN_EARTH_RADIUS_KM, WGS84_F},
12};
13use crate::astro::math::vec3;
14
15/// Public eclipse alias for WGS84 flattening `1 / 298.257223563`.
16pub const WGS84_FLATTENING: f64 = WGS84_F;
17
18/// Earth figure used for conical eclipse geometry.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum EarthShadowModel {
21    /// Spherical Earth with [`MEAN_EARTH_RADIUS_KM`].
22    Spherical,
23    /// WGS84 oblate Earth approximation by polar-axis scaling.
24    ///
25    /// The z components of the satellite and Sun vectors are scaled by
26    /// `1 / (1 - WGS84_FLATTENING)` before evaluating the same conical shadow
27    /// geometry. Equatorial geometries are therefore bit-identical to the
28    /// spherical model, while polar grazing geometries see the smaller polar
29    /// radius implied by the flattening.
30    Wgs84Oblate,
31}
32
33/// Illumination state of a satellite relative to Earth's conical shadow.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum EclipseStatus {
36    /// Full sunlight.
37    Sunlit,
38    /// Partial shadow between the umbra and penumbra cones.
39    Penumbra,
40    /// Full shadow inside the umbra cone.
41    Umbra,
42}
43
44/// Error while computing conical eclipse geometry.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
46pub enum EclipseError {
47    #[error("invalid eclipse input {field}: {reason}")]
48    InvalidInput {
49        field: &'static str,
50        reason: &'static str,
51    },
52}
53
54/// Shadow fraction in `[0.0, 1.0]`: `0.0` is full sunlight, `1.0` is full
55/// umbra, intermediate values are penumbra.
56///
57/// `sat_pos` is the satellite GCRS position (km); `sun_pos` is the vector from
58/// Earth center to the Sun (km).
59pub fn shadow_fraction(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<f64, EclipseError> {
60    validate_nonzero_vec3(sat_pos, "sat_pos")?;
61    validate_nonzero_vec3(sun_pos, "sun_pos")?;
62
63    let d_sun = vec3::norm3(sun_pos);
64
65    // Unit vector from Earth to Sun. The division form (not reciprocal-multiply
66    // via `vec3::unit3`) is required to stay bit-exact with the reference.
67    let sun_u = [sun_pos[0] / d_sun, sun_pos[1] / d_sun, sun_pos[2] / d_sun];
68
69    // Project the satellite onto the Earth-Sun line. Positive points toward the
70    // Sun; a satellite on the sunlit side cannot be in shadow.
71    let proj = vec3::dot3(sat_pos, sun_u);
72    if proj >= 0.0 {
73        return Ok(0.0);
74    }
75
76    // Perpendicular distance from the satellite to the shadow axis.
77    let perp = vec3::sub3(sat_pos, vec3::scale3(sun_u, proj));
78    let rho = vec3::norm3(perp);
79
80    // Distance behind Earth along the shadow axis (positive).
81    let dist_behind = -proj;
82
83    // Umbra cone (converging): sin(alpha) = (R_sun - R_earth) / d_sun.
84    let alpha_umbra = ((SOLAR_RADIUS_KM - MEAN_EARTH_RADIUS_KM) / d_sun).asin();
85    // Penumbra cone (diverging): sin(alpha) = (R_sun + R_earth) / d_sun.
86    let alpha_penumbra = ((SOLAR_RADIUS_KM + MEAN_EARTH_RADIUS_KM) / d_sun).asin();
87
88    // Cone radii at the satellite's distance behind Earth.
89    let r_umbra = MEAN_EARTH_RADIUS_KM - dist_behind * alpha_umbra.tan();
90    let r_penumbra = MEAN_EARTH_RADIUS_KM + dist_behind * alpha_penumbra.tan();
91
92    if rho >= r_penumbra {
93        // Beyond the penumbra cone: full sunlight.
94        Ok(0.0)
95    } else if r_umbra > 0.0 && rho <= r_umbra {
96        // Inside the umbra cone: full shadow.
97        Ok(1.0)
98    } else if r_umbra > 0.0 {
99        // Penumbra region: linear interpolation between the cone radii.
100        Ok((r_penumbra - rho) / (r_penumbra - r_umbra))
101    } else {
102        // Past the umbra tip (antumbra): penumbra still applies, fraction
103        // decreasing from the axis.
104        Ok(0.0_f64.max((r_penumbra - rho) / r_penumbra))
105    }
106}
107
108/// Shadow fraction with an explicit Earth figure model.
109///
110/// [`EarthShadowModel::Spherical`] delegates to [`shadow_fraction`] and is
111/// bit-identical to the default API. [`EarthShadowModel::Wgs84Oblate`] applies
112/// the WGS84 polar-axis scaling before evaluating the same conical model.
113pub fn shadow_fraction_with_model(
114    sat_pos: [f64; 3],
115    sun_pos: [f64; 3],
116    model: EarthShadowModel,
117) -> Result<f64, EclipseError> {
118    match model {
119        EarthShadowModel::Spherical => shadow_fraction(sat_pos, sun_pos),
120        EarthShadowModel::Wgs84Oblate => {
121            let sat_pos = scale_polar_axis(sat_pos);
122            let sun_pos = scale_polar_axis(sun_pos);
123            shadow_fraction(sat_pos, sun_pos)
124        }
125    }
126}
127
128/// Classify the satellite's eclipse status from its [`shadow_fraction`].
129pub fn status(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<EclipseStatus, EclipseError> {
130    let fraction = shadow_fraction(sat_pos, sun_pos)?;
131    if fraction >= 1.0 {
132        Ok(EclipseStatus::Umbra)
133    } else if fraction > 0.0 {
134        Ok(EclipseStatus::Penumbra)
135    } else {
136        Ok(EclipseStatus::Sunlit)
137    }
138}
139
140/// Classify eclipse status with an explicit Earth figure model.
141pub fn status_with_model(
142    sat_pos: [f64; 3],
143    sun_pos: [f64; 3],
144    model: EarthShadowModel,
145) -> Result<EclipseStatus, EclipseError> {
146    let fraction = shadow_fraction_with_model(sat_pos, sun_pos, model)?;
147    if fraction >= 1.0 {
148        Ok(EclipseStatus::Umbra)
149    } else if fraction > 0.0 {
150        Ok(EclipseStatus::Penumbra)
151    } else {
152        Ok(EclipseStatus::Sunlit)
153    }
154}
155
156fn scale_polar_axis(v: [f64; 3]) -> [f64; 3] {
157    let z_scale = 1.0 / (1.0 - WGS84_FLATTENING);
158    [v[0], v[1], v[2] * z_scale]
159}
160
161fn validate_nonzero_vec3(v: [f64; 3], field: &'static str) -> Result<(), EclipseError> {
162    if !v.iter().all(|value| value.is_finite()) {
163        return Err(invalid_eclipse_input(field, "not finite"));
164    }
165    let norm = vec3::norm3(v);
166    if norm == 0.0 {
167        return Err(invalid_eclipse_input(field, "zero vector"));
168    }
169    if !norm.is_finite() {
170        return Err(invalid_eclipse_input(field, "out of range"));
171    }
172    Ok(())
173}
174
175fn invalid_eclipse_input(field: &'static str, reason: &'static str) -> EclipseError {
176    EclipseError::InvalidInput { field, reason }
177}
178
179#[cfg(test)]
180mod tests {
181    use crate::astro::events::{CrossingDirection, EventFinder};
182
183    use super::*;
184
185    // Approximate Sun-Earth distance, ~1 AU in km, matching the reference
186    // oracle inputs.
187    const AU_KM: f64 = 149_597_870.7;
188
189    #[test]
190    fn shadow_fraction_matches_reference_bits() {
191        // Frozen bits captured from the reference (Elixir) conical-shadow
192        // implementation. Cross-language 0-ULP equality.
193        let sun = [AU_KM, 0.0, 0.0];
194
195        assert_eq!(
196            shadow_fraction([7000.0, 0.0, 0.0], sun)
197                .expect("valid eclipse geometry")
198                .to_bits(),
199            0.0_f64.to_bits()
200        );
201        assert_eq!(
202            shadow_fraction([0.0, 7000.0, 0.0], sun)
203                .expect("valid eclipse geometry")
204                .to_bits(),
205            0.0_f64.to_bits()
206        );
207        assert_eq!(
208            shadow_fraction([-7000.0, 0.0, 0.0], sun)
209                .expect("valid eclipse geometry")
210                .to_bits(),
211            1.0_f64.to_bits()
212        );
213        assert_eq!(
214            shadow_fraction([-7000.0, 6370.0, 0.0], sun)
215                .expect("valid eclipse geometry")
216                .to_bits(),
217            0x3fe0_a32f_08e7_fb1f
218        );
219        assert_eq!(
220            shadow_fraction([-7000.0, 6410.0, 0.0], sun)
221                .expect("valid eclipse geometry")
222                .to_bits(),
223            0.0_f64.to_bits()
224        );
225        assert_eq!(
226            shadow_fraction([-7000.0, 6330.0, 0.0], sun)
227                .expect("valid eclipse geometry")
228                .to_bits(),
229            1.0_f64.to_bits()
230        );
231    }
232
233    /// Oracle provenance: WGS84 flattening is the published inverse flattening
234    /// `1 / 298.257223563`; the oblate expected value is the same conical
235    /// formula as `shadow_fraction` after applying the documented polar-axis
236    /// scaling.
237    #[test]
238    fn oblate_shadow_model_preserves_equatorial_bits_and_changes_polar_grazing() {
239        assert_eq!(WGS84_FLATTENING.to_bits(), 0x3f6b_775a_84f3_e128);
240
241        let sun = [AU_KM, 0.0, 0.0];
242        for sat in [
243            [7000.0, 0.0, 0.0],
244            [-7000.0, 0.0, 0.0],
245            [-7000.0, 6370.0, 0.0],
246        ] {
247            let default = shadow_fraction(sat, sun).expect("valid eclipse geometry");
248            let spherical = shadow_fraction_with_model(sat, sun, EarthShadowModel::Spherical)
249                .expect("valid spherical geometry");
250            let oblate = shadow_fraction_with_model(sat, sun, EarthShadowModel::Wgs84Oblate)
251                .expect("valid oblate geometry");
252            assert_eq!(spherical.to_bits(), default.to_bits());
253            assert_eq!(oblate.to_bits(), default.to_bits());
254        }
255
256        let polar_grazing = [-7000.0, 0.0, 6370.0];
257        let spherical = shadow_fraction_with_model(polar_grazing, sun, EarthShadowModel::Spherical)
258            .expect("valid spherical geometry");
259        let oblate = shadow_fraction_with_model(polar_grazing, sun, EarthShadowModel::Wgs84Oblate)
260            .expect("valid oblate geometry");
261        assert_eq!(spherical.to_bits(), 0x3fe0_a32f_08e7_fb1f);
262        assert_eq!(oblate.to_bits(), 0x3fc8_7576_4827_2b98);
263        assert!(
264            oblate < spherical,
265            "polar-axis flattening must reduce the grazing shadow fraction"
266        );
267    }
268
269    #[test]
270    fn status_classifies_cones() {
271        let sun = [AU_KM, 0.0, 0.0];
272
273        assert_eq!(
274            status([7000.0, 0.0, 0.0], sun).expect("valid eclipse geometry"),
275            EclipseStatus::Sunlit
276        );
277        assert_eq!(
278            status([0.0, 7000.0, 0.0], sun).expect("valid eclipse geometry"),
279            EclipseStatus::Sunlit
280        );
281        assert_eq!(
282            status([-7000.0, 0.0, 0.0], sun).expect("valid eclipse geometry"),
283            EclipseStatus::Umbra
284        );
285        assert_eq!(
286            status([-7000.0, 6370.0, 0.0], sun).expect("valid eclipse geometry"),
287            EclipseStatus::Penumbra
288        );
289    }
290
291    #[test]
292    fn shadow_fraction_increases_toward_axis() {
293        let sun = [AU_KM, 0.0, 0.0];
294        let outside = shadow_fraction([-7000.0, 6410.0, 0.0], sun).expect("valid eclipse geometry");
295        let penumbra =
296            shadow_fraction([-7000.0, 6370.0, 0.0], sun).expect("valid eclipse geometry");
297        let umbra = shadow_fraction([-7000.0, 6330.0, 0.0], sun).expect("valid eclipse geometry");
298        let center = shadow_fraction([-7000.0, 0.0, 0.0], sun).expect("valid eclipse geometry");
299
300        assert_eq!(outside, 0.0);
301        assert!(penumbra > 0.0 && penumbra < 1.0);
302        assert_eq!(umbra, 1.0);
303        assert_eq!(center, 1.0);
304    }
305
306    #[test]
307    fn event_finder_refines_eclipse_fraction_crossings() {
308        let sun = [AU_KM, 0.0, 0.0];
309        let x_km = -7000.0;
310        let start_y_km = -7000.0;
311        let end_y_km = 7000.0;
312        let duration_seconds = 1200.0;
313        let threshold = 0.5;
314        let y_at = |time_seconds: f64| {
315            start_y_km + (end_y_km - start_y_km) * time_seconds / duration_seconds
316        };
317        let fraction_at = |time_seconds: f64| {
318            shadow_fraction([x_km, y_at(time_seconds), 0.0], sun)
319                .expect("valid synthetic eclipse geometry")
320        };
321
322        let events = EventFinder::new(0.0, duration_seconds, 60.0, 1.0e-7)
323            .expect("valid eclipse finder")
324            .find_crossings(fraction_at, threshold)
325            .expect("finite eclipse predicate");
326
327        assert_eq!(events.len(), 2);
328        assert_eq!(events[0].direction, CrossingDirection::Rising);
329        assert_eq!(events[1].direction, CrossingDirection::Falling);
330        assert_eq!(events[0].threshold, threshold);
331        assert_eq!(events[1].threshold, threshold);
332
333        let half_shadow_radius = shadow_radius_for_fraction(threshold, -x_km);
334        let expected_enter =
335            time_for_y(-half_shadow_radius, start_y_km, end_y_km, duration_seconds);
336        let expected_exit = time_for_y(half_shadow_radius, start_y_km, end_y_km, duration_seconds);
337
338        assert_close(events[0].time_seconds, expected_enter, 1.0e-6);
339        assert_close(events[1].time_seconds, expected_exit, 1.0e-6);
340        assert_close(events[0].value, threshold, 1.0e-7);
341        assert_close(events[1].value, threshold, 1.0e-7);
342        assert!(fraction_at(0.0) < threshold);
343        assert!(fraction_at(duration_seconds * 0.5) > threshold);
344        assert!(fraction_at(duration_seconds) < threshold);
345    }
346
347    fn shadow_radius_for_fraction(fraction: f64, dist_behind_km: f64) -> f64 {
348        let alpha_umbra = ((SOLAR_RADIUS_KM - MEAN_EARTH_RADIUS_KM) / AU_KM).asin();
349        let alpha_penumbra = ((SOLAR_RADIUS_KM + MEAN_EARTH_RADIUS_KM) / AU_KM).asin();
350        let r_umbra = MEAN_EARTH_RADIUS_KM - dist_behind_km * alpha_umbra.tan();
351        let r_penumbra = MEAN_EARTH_RADIUS_KM + dist_behind_km * alpha_penumbra.tan();
352        r_penumbra - fraction * (r_penumbra - r_umbra)
353    }
354
355    fn time_for_y(y_km: f64, start_y_km: f64, end_y_km: f64, duration_seconds: f64) -> f64 {
356        (y_km - start_y_km) * duration_seconds / (end_y_km - start_y_km)
357    }
358
359    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
360        assert!(
361            (actual - expected).abs() <= tolerance,
362            "{actual} differs from {expected} by more than {tolerance}"
363        );
364    }
365
366    #[test]
367    fn eclipse_helpers_reject_invalid_vectors() {
368        assert_invalid_eclipse_field(
369            shadow_fraction([0.0, 0.0, 0.0], [AU_KM, 0.0, 0.0]).unwrap_err(),
370            "sat_pos",
371            "zero vector",
372        );
373        assert_invalid_eclipse_field(
374            status([7000.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0]).unwrap_err(),
375            "sun_pos",
376            "not finite",
377        );
378    }
379
380    fn assert_invalid_eclipse_field(
381        error: EclipseError,
382        expected: &'static str,
383        expected_reason: &'static str,
384    ) {
385        let EclipseError::InvalidInput { field, reason } = error;
386        assert_eq!(field, expected);
387        assert_eq!(reason, expected_reason);
388    }
389}