sidereon_core/astro/events/
eclipse.rs1use crate::astro::constants::{astro::SOLAR_RADIUS_KM, earth::MEAN_EARTH_RADIUS_KM};
10use crate::astro::math::vec3;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum EclipseStatus {
15 Sunlit,
17 Penumbra,
19 Umbra,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
25pub enum EclipseError {
26 #[error("invalid eclipse input {field}: {reason}")]
27 InvalidInput {
28 field: &'static str,
29 reason: &'static str,
30 },
31}
32
33pub fn shadow_fraction(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<f64, EclipseError> {
39 validate_nonzero_vec3(sat_pos, "sat_pos")?;
40 validate_nonzero_vec3(sun_pos, "sun_pos")?;
41
42 let d_sun = vec3::norm3(sun_pos);
43
44 let sun_u = [sun_pos[0] / d_sun, sun_pos[1] / d_sun, sun_pos[2] / d_sun];
47
48 let proj = vec3::dot3(sat_pos, sun_u);
51 if proj >= 0.0 {
52 return Ok(0.0);
53 }
54
55 let perp = vec3::sub3(sat_pos, vec3::scale3(sun_u, proj));
57 let rho = vec3::norm3(perp);
58
59 let dist_behind = -proj;
61
62 let alpha_umbra = ((SOLAR_RADIUS_KM - MEAN_EARTH_RADIUS_KM) / d_sun).asin();
64 let alpha_penumbra = ((SOLAR_RADIUS_KM + MEAN_EARTH_RADIUS_KM) / d_sun).asin();
66
67 let r_umbra = MEAN_EARTH_RADIUS_KM - dist_behind * alpha_umbra.tan();
69 let r_penumbra = MEAN_EARTH_RADIUS_KM + dist_behind * alpha_penumbra.tan();
70
71 if rho >= r_penumbra {
72 Ok(0.0)
74 } else if r_umbra > 0.0 && rho <= r_umbra {
75 Ok(1.0)
77 } else if r_umbra > 0.0 {
78 Ok((r_penumbra - rho) / (r_penumbra - r_umbra))
80 } else {
81 Ok(0.0_f64.max((r_penumbra - rho) / r_penumbra))
84 }
85}
86
87pub fn status(sat_pos: [f64; 3], sun_pos: [f64; 3]) -> Result<EclipseStatus, EclipseError> {
89 let fraction = shadow_fraction(sat_pos, sun_pos)?;
90 if fraction >= 1.0 {
91 Ok(EclipseStatus::Umbra)
92 } else if fraction > 0.0 {
93 Ok(EclipseStatus::Penumbra)
94 } else {
95 Ok(EclipseStatus::Sunlit)
96 }
97}
98
99fn validate_nonzero_vec3(v: [f64; 3], field: &'static str) -> Result<(), EclipseError> {
100 if !v.iter().all(|value| value.is_finite()) {
101 return Err(invalid_eclipse_input(field, "not finite"));
102 }
103 let norm = vec3::norm3(v);
104 if norm == 0.0 {
105 return Err(invalid_eclipse_input(field, "zero vector"));
106 }
107 if !norm.is_finite() {
108 return Err(invalid_eclipse_input(field, "out of range"));
109 }
110 Ok(())
111}
112
113fn invalid_eclipse_input(field: &'static str, reason: &'static str) -> EclipseError {
114 EclipseError::InvalidInput { field, reason }
115}
116
117#[cfg(test)]
118mod tests {
119 use crate::astro::events::{CrossingDirection, EventFinder};
120
121 use super::*;
122
123 const AU_KM: f64 = 149_597_870.7;
126
127 #[test]
128 fn shadow_fraction_matches_reference_bits() {
129 let sun = [AU_KM, 0.0, 0.0];
132
133 assert_eq!(
134 shadow_fraction([7000.0, 0.0, 0.0], sun)
135 .expect("valid eclipse geometry")
136 .to_bits(),
137 0.0_f64.to_bits()
138 );
139 assert_eq!(
140 shadow_fraction([0.0, 7000.0, 0.0], sun)
141 .expect("valid eclipse geometry")
142 .to_bits(),
143 0.0_f64.to_bits()
144 );
145 assert_eq!(
146 shadow_fraction([-7000.0, 0.0, 0.0], sun)
147 .expect("valid eclipse geometry")
148 .to_bits(),
149 1.0_f64.to_bits()
150 );
151 assert_eq!(
152 shadow_fraction([-7000.0, 6370.0, 0.0], sun)
153 .expect("valid eclipse geometry")
154 .to_bits(),
155 0x3fe0_a32f_08e7_fb1f
156 );
157 assert_eq!(
158 shadow_fraction([-7000.0, 6410.0, 0.0], sun)
159 .expect("valid eclipse geometry")
160 .to_bits(),
161 0.0_f64.to_bits()
162 );
163 assert_eq!(
164 shadow_fraction([-7000.0, 6330.0, 0.0], sun)
165 .expect("valid eclipse geometry")
166 .to_bits(),
167 1.0_f64.to_bits()
168 );
169 }
170
171 #[test]
172 fn status_classifies_cones() {
173 let sun = [AU_KM, 0.0, 0.0];
174
175 assert_eq!(
176 status([7000.0, 0.0, 0.0], sun).expect("valid eclipse geometry"),
177 EclipseStatus::Sunlit
178 );
179 assert_eq!(
180 status([0.0, 7000.0, 0.0], sun).expect("valid eclipse geometry"),
181 EclipseStatus::Sunlit
182 );
183 assert_eq!(
184 status([-7000.0, 0.0, 0.0], sun).expect("valid eclipse geometry"),
185 EclipseStatus::Umbra
186 );
187 assert_eq!(
188 status([-7000.0, 6370.0, 0.0], sun).expect("valid eclipse geometry"),
189 EclipseStatus::Penumbra
190 );
191 }
192
193 #[test]
194 fn shadow_fraction_increases_toward_axis() {
195 let sun = [AU_KM, 0.0, 0.0];
196 let outside = shadow_fraction([-7000.0, 6410.0, 0.0], sun).expect("valid eclipse geometry");
197 let penumbra =
198 shadow_fraction([-7000.0, 6370.0, 0.0], sun).expect("valid eclipse geometry");
199 let umbra = shadow_fraction([-7000.0, 6330.0, 0.0], sun).expect("valid eclipse geometry");
200 let center = shadow_fraction([-7000.0, 0.0, 0.0], sun).expect("valid eclipse geometry");
201
202 assert_eq!(outside, 0.0);
203 assert!(penumbra > 0.0 && penumbra < 1.0);
204 assert_eq!(umbra, 1.0);
205 assert_eq!(center, 1.0);
206 }
207
208 #[test]
209 fn event_finder_refines_eclipse_fraction_crossings() {
210 let sun = [AU_KM, 0.0, 0.0];
211 let x_km = -7000.0;
212 let start_y_km = -7000.0;
213 let end_y_km = 7000.0;
214 let duration_seconds = 1200.0;
215 let threshold = 0.5;
216 let y_at = |time_seconds: f64| {
217 start_y_km + (end_y_km - start_y_km) * time_seconds / duration_seconds
218 };
219 let fraction_at = |time_seconds: f64| {
220 shadow_fraction([x_km, y_at(time_seconds), 0.0], sun)
221 .expect("valid synthetic eclipse geometry")
222 };
223
224 let events = EventFinder::new(0.0, duration_seconds, 60.0, 1.0e-7)
225 .expect("valid eclipse finder")
226 .find_crossings(fraction_at, threshold)
227 .expect("finite eclipse predicate");
228
229 assert_eq!(events.len(), 2);
230 assert_eq!(events[0].direction, CrossingDirection::Rising);
231 assert_eq!(events[1].direction, CrossingDirection::Falling);
232 assert_eq!(events[0].threshold, threshold);
233 assert_eq!(events[1].threshold, threshold);
234
235 let half_shadow_radius = shadow_radius_for_fraction(threshold, -x_km);
236 let expected_enter =
237 time_for_y(-half_shadow_radius, start_y_km, end_y_km, duration_seconds);
238 let expected_exit = time_for_y(half_shadow_radius, start_y_km, end_y_km, duration_seconds);
239
240 assert_close(events[0].time_seconds, expected_enter, 1.0e-6);
241 assert_close(events[1].time_seconds, expected_exit, 1.0e-6);
242 assert_close(events[0].value, threshold, 1.0e-7);
243 assert_close(events[1].value, threshold, 1.0e-7);
244 assert!(fraction_at(0.0) < threshold);
245 assert!(fraction_at(duration_seconds * 0.5) > threshold);
246 assert!(fraction_at(duration_seconds) < threshold);
247 }
248
249 fn shadow_radius_for_fraction(fraction: f64, dist_behind_km: f64) -> f64 {
250 let alpha_umbra = ((SOLAR_RADIUS_KM - MEAN_EARTH_RADIUS_KM) / AU_KM).asin();
251 let alpha_penumbra = ((SOLAR_RADIUS_KM + MEAN_EARTH_RADIUS_KM) / AU_KM).asin();
252 let r_umbra = MEAN_EARTH_RADIUS_KM - dist_behind_km * alpha_umbra.tan();
253 let r_penumbra = MEAN_EARTH_RADIUS_KM + dist_behind_km * alpha_penumbra.tan();
254 r_penumbra - fraction * (r_penumbra - r_umbra)
255 }
256
257 fn time_for_y(y_km: f64, start_y_km: f64, end_y_km: f64, duration_seconds: f64) -> f64 {
258 (y_km - start_y_km) * duration_seconds / (end_y_km - start_y_km)
259 }
260
261 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
262 assert!(
263 (actual - expected).abs() <= tolerance,
264 "{actual} differs from {expected} by more than {tolerance}"
265 );
266 }
267
268 #[test]
269 fn eclipse_helpers_reject_invalid_vectors() {
270 assert_invalid_eclipse_field(
271 shadow_fraction([0.0, 0.0, 0.0], [AU_KM, 0.0, 0.0]).unwrap_err(),
272 "sat_pos",
273 "zero vector",
274 );
275 assert_invalid_eclipse_field(
276 status([7000.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0]).unwrap_err(),
277 "sun_pos",
278 "not finite",
279 );
280 }
281
282 fn assert_invalid_eclipse_field(
283 error: EclipseError,
284 expected: &'static str,
285 expected_reason: &'static str,
286 ) {
287 let EclipseError::InvalidInput { field, reason } = error;
288 assert_eq!(field, expected);
289 assert_eq!(reason, expected_reason);
290 }
291}