1use crate::astro::constants::{
10 astro::SOLAR_RADIUS_KM,
11 earth::{MEAN_EARTH_RADIUS_KM, WGS84_F},
12};
13use crate::astro::math::vec3;
14
15pub const WGS84_FLATTENING: f64 = WGS84_F;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum EarthShadowModel {
21 Spherical,
23 Wgs84Oblate,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum EclipseStatus {
36 Sunlit,
38 Penumbra,
40 Umbra,
42}
43
44#[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
54pub 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 let sun_u = [sun_pos[0] / d_sun, sun_pos[1] / d_sun, sun_pos[2] / d_sun];
68
69 let proj = vec3::dot3(sat_pos, sun_u);
72 if proj >= 0.0 {
73 return Ok(0.0);
74 }
75
76 let perp = vec3::sub3(sat_pos, vec3::scale3(sun_u, proj));
78 let rho = vec3::norm3(perp);
79
80 let dist_behind = -proj;
82
83 let alpha_umbra = ((SOLAR_RADIUS_KM - MEAN_EARTH_RADIUS_KM) / d_sun).asin();
85 let alpha_penumbra = ((SOLAR_RADIUS_KM + MEAN_EARTH_RADIUS_KM) / d_sun).asin();
87
88 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 Ok(0.0)
95 } else if r_umbra > 0.0 && rho <= r_umbra {
96 Ok(1.0)
98 } else if r_umbra > 0.0 {
99 Ok((r_penumbra - rho) / (r_penumbra - r_umbra))
101 } else {
102 Ok(0.0_f64.max((r_penumbra - rho) / r_penumbra))
105 }
106}
107
108pub 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
128pub 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
140pub 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 const AU_KM: f64 = 149_597_870.7;
188
189 #[test]
190 fn shadow_fraction_matches_reference_bits() {
191 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 #[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}