Skip to main content

anise/almanac/
eclipse.rs

1/*
2 * ANISE Toolkit
3 * Copyright (C) 2021-onward Christopher Rabotin <christopher.rabotin@gmail.com> et al. (cf. AUTHORS.md)
4 * This Source Code Form is subject to the terms of the Mozilla Public
5 * License, v. 2.0. If a copy of the MPL was not distributed with this
6 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7 *
8 * Documentation: https://nyxspace.com/
9 */
10
11use hifitime::{Duration, Unit};
12use log::error;
13
14use crate::{
15    astro::{Aberration, Occultation},
16    constants::{frames::SUN_J2000, orientations::J2000},
17    ephemerides::EphemerisPhysicsSnafu,
18    errors::{AlmanacError, EphemerisSnafu, OrientationSnafu},
19    frames::Frame,
20    prelude::Orbit,
21};
22
23use super::Almanac;
24use crate::errors::AlmanacResult;
25
26use core::f64::consts::PI;
27use snafu::ResultExt;
28
29impl Almanac {
30    /// Computes whether the line of sight between an observer and an observed Cartesian state is obstructed by the obstructing body.
31    /// Returns true if the obstructing body is in the way, false otherwise.
32    ///
33    /// For example, if the Moon is in between a Lunar orbiter (observed) and a ground station (observer), then this function returns `true`
34    /// because the Moon (obstructing body) is indeed obstructing the line of sight.
35    ///
36    /// ```text
37    /// Observed
38    ///   o  -
39    ///    +    -
40    ///     +      -
41    ///      + ***   -
42    ///     * +    *   -
43    ///     *  + + * + + o
44    ///     *     *     Observer
45    ///       ****
46    ///```
47    ///
48    /// Key Elements:
49    /// - `o` represents the positions of the observer and observed objects.
50    /// - The dashed line connecting the observer and observed is the line of sight.
51    ///
52    /// Algorithm (source: Algorithm 35 of Vallado, 4th edition, page 308.):
53    /// - `r1` and `r2` are the transformed radii of the observed and observer objects, respectively.
54    /// - `r1sq` and `r2sq` are the squared magnitudes of these vectors.
55    /// - `r1dotr2` is the dot product of `r1` and `r2`.
56    /// - `tau` is a parameter that determines the intersection point along the line of sight.
57    /// - The condition `(1.0 - tau) * r1sq + r1dotr2 * tau <= ob_mean_eq_radius_km^2` checks if the line of sight is within the obstructing body's radius, indicating an obstruction.
58    pub fn line_of_sight_obstructed(
59        &self,
60        observer: Orbit,
61        observed: Orbit,
62        mut obstructing_body: Frame,
63        ab_corr: Option<Aberration>,
64    ) -> AlmanacResult<bool> {
65        if observer == observed {
66            return Ok(false);
67        }
68
69        if obstructing_body.mean_equatorial_radius_km().is_err() {
70            obstructing_body =
71                self.frame_info(obstructing_body)
72                    .map_err(|e| AlmanacError::GenericError {
73                        err: format!("{e} when fetching frame data for {obstructing_body}"),
74                    })?;
75        }
76
77        let ob_mean_eq_radius_km = obstructing_body
78            .mean_equatorial_radius_km()
79            .context(EphemerisPhysicsSnafu {
80                action: "fetching mean equatorial radius of obstructing body",
81            })
82            .context(EphemerisSnafu {
83                action: "computing line of sight",
84            })?;
85
86        // Convert the states to the same frame as the obstructing body (ensures we're in the same frame)
87        let r1 = self
88            .transform_to(observed, obstructing_body, ab_corr)?
89            .radius_km;
90        let r2 = self
91            .transform_to(observer, obstructing_body, ab_corr)?
92            .radius_km;
93
94        let r1sq = r1.dot(&r1);
95        let r2sq = r2.dot(&r2);
96        let r1dotr2 = r1.dot(&r2);
97
98        let tau = (r1sq - r1dotr2) / (r1sq + r2sq - 2.0 * r1dotr2);
99        if !(0.0..=1.0).contains(&tau)
100            || (1.0 - tau) * r1sq + r1dotr2 * tau > ob_mean_eq_radius_km.powi(2)
101        {
102            Ok(false)
103        } else {
104            Ok(true)
105        }
106    }
107
108    /// Computes the occultation percentage of the `back_frame` object by the `front_frame` object as seen from the observer, when according for the provided aberration correction.
109    ///
110    /// A zero percent occultation means that the back object is fully visible from the observer.
111    /// A 100%  percent occultation means that the back object is fully hidden from the observer because of the front frame (i.e. _umbra_ if the back object is the Sun).
112    /// A value in between means that the back object is partially hidden from the observser (i.e. _penumbra_ if the back object is the Sun).
113    /// Refer to the [MathSpec](https://nyxspace.com/nyxspace/MathSpec/celestial/eclipse/) for modeling details.
114    pub fn occultation(
115        &self,
116        mut back_frame: Frame,
117        mut front_frame: Frame,
118        mut observer: Orbit,
119        ab_corr: Option<Aberration>,
120    ) -> AlmanacResult<Occultation> {
121        if back_frame.mean_equatorial_radius_km().is_err() {
122            back_frame = self
123                .frame_info(back_frame)
124                .map_err(|e| AlmanacError::GenericError {
125                    err: format!("{e} when fetching {back_frame:e} frame data"),
126                })?;
127        }
128
129        if front_frame.mean_equatorial_radius_km().is_err() {
130            front_frame = self
131                .frame_info(front_frame)
132                .map_err(|e| AlmanacError::GenericError {
133                    err: format!("{e} when fetching {front_frame:e} frame data"),
134                })?;
135        }
136
137        let bobj_mean_eq_radius_km = back_frame
138            .mean_equatorial_radius_km()
139            .context(EphemerisPhysicsSnafu {
140                action: "fetching mean equatorial radius of back frame",
141            })
142            .context(EphemerisSnafu {
143                action: "computing occultation state",
144            })?;
145
146        let epoch = observer.epoch;
147
148        // If the back object's radius is zero, just call the line of sight algorithm
149        if bobj_mean_eq_radius_km < f64::EPSILON {
150            let observed = -self.transform_to(observer, back_frame, ab_corr)?;
151            let percentage =
152                if self.line_of_sight_obstructed(observer, observed, front_frame, ab_corr)? {
153                    100.0
154                } else {
155                    0.0
156                };
157            return Ok(Occultation {
158                epoch,
159                percentage,
160                back_frame,
161                front_frame,
162            });
163        }
164
165        // All of the computations happen with the observer as the center.
166        // `eb` stands for front object; `ls` stands for back object.
167        // Get the radius vector of the spacecraft to the front object
168
169        // Ensure that the observer is in the J2000 frame.
170        observer = self
171            .rotate_to(observer, observer.frame.with_orient(J2000))
172            .context(OrientationSnafu {
173                action: "computing eclipse state",
174            })?;
175        let r_eb = self
176            .transform_to(observer, front_frame.with_orient(J2000), ab_corr)?
177            .radius_km;
178
179        // Get the radius vector of the back object to the spacecraft
180        let r_ls = -self
181            .transform_to(observer, back_frame.with_orient(J2000), ab_corr)?
182            .radius_km;
183
184        // Compute the apparent radii of the back object and front object (preventing any NaN)
185        let r_ls_prime = if bobj_mean_eq_radius_km >= r_ls.norm() {
186            core::f64::consts::FRAC_PI_2
187        } else {
188            (bobj_mean_eq_radius_km / r_ls.norm()).asin()
189        };
190
191        let fobj_mean_eq_radius_km = front_frame
192            .mean_equatorial_radius_km()
193            .context(EphemerisPhysicsSnafu {
194                action: "fetching mean equatorial radius of front object",
195            })
196            .context(EphemerisSnafu {
197                action: "computing eclipse state",
198            })?;
199
200        let r_fobj_prime = if fobj_mean_eq_radius_km >= r_eb.norm() {
201            core::f64::consts::FRAC_PI_2
202        } else {
203            (fobj_mean_eq_radius_km / r_eb.norm()).asin()
204        };
205
206        // Compute the apparent separation of both circles
207        let d_prime = (-(r_ls.dot(&r_eb)) / (r_eb.norm() * r_ls.norm())).acos();
208
209        let percentage = compute_occultation_percentage(d_prime, r_ls_prime, r_fobj_prime)?;
210
211        Ok(Occultation {
212            epoch,
213            percentage,
214            back_frame,
215            front_frame,
216        })
217    }
218
219    /// Computes the solar eclipsing of the observer due to the eclipsing_frame.
220    ///
221    /// This function calls `occultation` where the back object is the Sun in the J2000 frame, and the front object
222    /// is the provided eclipsing frame.
223    ///
224    /// :type eclipsing_frame: Frame
225    /// :type observer: Orbit
226    /// :type ab_corr: Aberration, optional
227    /// :rtype: Occultation
228    pub fn solar_eclipsing(
229        &self,
230        eclipsing_frame: Frame,
231        observer: Orbit,
232        ab_corr: Option<Aberration>,
233    ) -> AlmanacResult<Occultation> {
234        self.occultation(SUN_J2000, eclipsing_frame, observer, ab_corr)
235    }
236
237    /// Computes the Beta angle (β) for a given orbital state, in degrees. A Beta angle of 0° indicates that the orbit plane is edge-on to the Sun, leading to maximum eclipse time. Conversely, a Beta angle of +90° or -90° means the orbit plane is face-on to the Sun, resulting in continuous sunlight exposure and no eclipses.
238    ///
239    /// The Beta angle (β) is defined as the angle between the orbit plane of a spacecraft and the vector from the central body (e.g., Earth) to the Sun. In simpler terms, it measures how much of the time a satellite in orbit is exposed to direct sunlight.
240    /// The mathematical formula for the Beta angle is: β=arcsin(h⋅usun​)
241    /// Where:
242    /// - h is the unit vector of the orbital momentum.
243    /// - usun​ is the unit vector pointing from the central body to the Sun.
244    ///
245    /// Original code from GMAT, <https://github.com/ChristopherRabotin/GMAT/blob/GMAT-R2022a/src/gmatutil/util/CalculationUtilities.cpp#L209-L219>
246    pub fn beta_angle_deg(&self, state: Orbit, ab_corr: Option<Aberration>) -> AlmanacResult<f64> {
247        let u_sun = self.sun_unit_vector(state.epoch, state.frame, ab_corr)?;
248        let u_hvec = state.h_hat().map_err(|e| AlmanacError::GenericError {
249            err: format!("{e}"),
250        })?;
251
252        Ok(u_hvec.dot(&u_sun).asin().to_degrees())
253    }
254
255    /// Compute the local solar time, returned as a Duration between 0 and 24 hours.
256    pub fn local_solar_time(
257        &self,
258        state: Orbit,
259        ab_corr: Option<Aberration>,
260    ) -> AlmanacResult<Duration> {
261        let u_sun = self.sun_unit_vector(state.epoch, state.frame, ab_corr)?;
262        let u_hvec = state.h_hat().map_err(|e| AlmanacError::GenericError {
263            err: format!("{e}"),
264        })?;
265
266        let u = u_sun.cross(&u_hvec);
267        let v = u_hvec.cross(&u);
268
269        let sin_theta = v.dot(&state.r_hat());
270        let cos_theta = u.dot(&state.r_hat());
271
272        let theta_rad = sin_theta.atan2(cos_theta);
273        let lst_h = (theta_rad / PI * 12.0 + 6.0).rem_euclid(24.0);
274
275        Ok(Unit::Hour * lst_h)
276    }
277
278    /// Returns the Local Time of the Ascending Node (LTAN).
279    /// This is the local time on the celestial body at which the spacecraft crosses the equator from south to north.
280    ///
281    /// The formula is from Wertz, "Spacecraft Attitude Determination and Control", page 79, equation (4-8).
282    /// LTAN (hours) = 12.0 + (RAAN_orbit - RA_sun) / 15.0
283    ///
284    /// :type orbit: Orbit
285    /// :type ab_corr: Aberration, optional
286    /// :rtype: Duration
287    pub fn ltan(&self, orbit: Orbit, ab_corr: Option<Aberration>) -> AlmanacResult<Duration> {
288        let sun_state = self.transform(SUN_J2000, orbit.frame, orbit.epoch, ab_corr)?;
289        let ra_sun_deg = sun_state.right_ascension_deg();
290        let raan_orbit_deg = orbit.raan_deg().map_err(|e| AlmanacError::GenericError {
291            err: format!("{e}"),
292        })?;
293        let ltan = (12.0 + (raan_orbit_deg - ra_sun_deg) / 15.0).rem_euclid(24.0);
294        Ok(Unit::Hour * ltan)
295    }
296
297    /// Returns the Local Time of the Descending Node (LTDN) in hours.
298    /// This is the local time on the celestial body at which the spacecraft crosses the equator from north to south.
299    ///
300    /// LTDN is 12 hours after LTAN.
301    ///
302    /// :type orbit: Orbit
303    /// :type ab_corr: Aberration, optional
304    /// :rtype: Duration
305    pub fn ltdn(&self, orbit: Orbit, ab_corr: Option<Aberration>) -> AlmanacResult<Duration> {
306        let ltan_h = self.ltan(orbit, ab_corr)?.to_unit(Unit::Hour);
307        let ltdn_h = (ltan_h + 12.0).rem_euclid(24.0);
308        Ok(Unit::Hour * ltdn_h)
309    }
310}
311
312/// Compute the occultation percentage
313fn compute_occultation_percentage(
314    d_prime: f64,
315    r_ls_prime: f64,
316    r_fobj_prime: f64,
317) -> AlmanacResult<f64> {
318    if d_prime - r_ls_prime > r_fobj_prime {
319        // If the closest point where the apparent radius of the back object _starts_ is further
320        // away than the furthest point where the front object's shadow can reach, then the light
321        // source is totally visible.
322        Ok(0.0)
323    } else if r_fobj_prime > d_prime + r_ls_prime {
324        // The back object is fully hidden by the front object, hence we're in total eclipse.
325        Ok(100.0)
326    } else if (r_ls_prime - r_fobj_prime).abs() < d_prime && d_prime < r_ls_prime + r_fobj_prime {
327        // If we have reached this point, we're in penumbra.
328        // Both circles, which represent the back object projected onto the plane and the eclipsing geoid,
329        // now overlap creating an asymmetrial lens.
330        // The following math comes from http://mathworld.wolfram.com/Circle-CircleIntersection.html
331        // and https://stackoverflow.com/questions/3349125/circle-circle-intersection-points .
332
333        // Compute the distances between the center of the eclipsing geoid and the line crossing the intersection
334        // points of both circles.
335        let d1 = (d_prime.powi(2) - r_ls_prime.powi(2) + r_fobj_prime.powi(2)) / (2.0 * d_prime);
336        let d2 = (d_prime.powi(2) + r_ls_prime.powi(2) - r_fobj_prime.powi(2)) / (2.0 * d_prime);
337
338        let shadow_area = circ_seg_area(r_fobj_prime, d1) + circ_seg_area(r_ls_prime, d2);
339        if shadow_area.is_nan() {
340            error!(
341                "Shadow area is NaN! Please file a bug with initial states, eclipsing bodies, etc."
342            );
343            return Ok(100.0);
344        }
345        // Compute the nominal area of the back object
346        let nominal_area = core::f64::consts::PI * r_ls_prime.powi(2);
347        // And return the percentage (between 0 and 1) of the eclipse.
348        Ok(100.0 * shadow_area / nominal_area)
349    } else {
350        // Annular eclipse.
351        // If r_fobj_prime is very small, then the fraction is very small: however, we note a penumbra close to 1.0 as near full back object visibility, so let's subtract one from this.
352        Ok(100.0 * r_fobj_prime.powi(2) / r_ls_prime.powi(2))
353    }
354}
355
356/// Compute the area of the circular segment of radius r and chord length d
357fn circ_seg_area(r: f64, d: f64) -> f64 {
358    r.powi(2) * (d / r).acos() - d * (r.powi(2) - d.powi(2)).sqrt()
359}
360
361#[cfg(test)]
362mod ut_los {
363    use crate::constants::frames::{EARTH_J2000, MOON_J2000};
364
365    use super::*;
366    use crate::math::Vector3;
367    use hifitime::Epoch;
368    use rstest::*;
369
370    #[fixture]
371    pub fn almanac() -> Almanac {
372        use std::path::PathBuf;
373
374        let manifest_dir =
375            PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or(".".to_string()));
376
377        Almanac::new(
378            &manifest_dir
379                .clone()
380                .join("../data/de440s.bsp")
381                .to_string_lossy(),
382        )
383        .unwrap()
384        .load(
385            &manifest_dir
386                .clone()
387                .join("../data/pck08.pca")
388                .to_string_lossy(),
389        )
390        .unwrap()
391    }
392
393    #[rstest]
394    fn los_edge_case(almanac: Almanac) {
395        let eme2k = almanac.frame_info(EARTH_J2000).unwrap();
396        let luna = almanac.frame_info(MOON_J2000).unwrap();
397
398        let dt1 = Epoch::from_gregorian_tai_hms(2020, 1, 1, 6, 7, 40);
399        let dt2 = Epoch::from_gregorian_tai_hms(2020, 1, 1, 6, 7, 50);
400        let dt3 = Epoch::from_gregorian_tai_hms(2020, 1, 1, 6, 8, 0);
401
402        let xmtr1 = Orbit::new(
403            397_477.494_485,
404            -57_258.902_156,
405            -62_857.909_437,
406            0.230_482,
407            2.331_362,
408            0.615_501,
409            dt1,
410            eme2k,
411        );
412        let rcvr1 = Orbit::new(
413            338_335.467_589,
414            -55_439.526_977,
415            -13_327.354_273,
416            0.197_141,
417            0.944_261,
418            0.337_407,
419            dt1,
420            eme2k,
421        );
422        let xmtr2 = Orbit::new(
423            397_479.756_900,
424            -57_235.586_465,
425            -62_851.758_851,
426            0.222_000,
427            2.331_768,
428            0.614_614,
429            dt2,
430            eme2k,
431        );
432        let rcvr2 = Orbit::new(
433            338_337.438_860,
434            -55_430.084_340,
435            -13_323.980_229,
436            0.197_113,
437            0.944_266,
438            0.337_402,
439            dt2,
440            eme2k,
441        );
442        let xmtr3 = Orbit::new(
443            397_481.934_480,
444            -57_212.266_970,
445            -62_845.617_185,
446            0.213_516,
447            2.332_122,
448            0.613_717,
449            dt3,
450            eme2k,
451        );
452        let rcvr3 = Orbit::new(
453            338_339.409_858,
454            -55_420.641_651,
455            -13_320.606_228,
456            0.197_086,
457            0.944_272,
458            0.337_398,
459            dt3,
460            eme2k,
461        );
462
463        assert_eq!(
464            almanac.line_of_sight_obstructed(xmtr1, rcvr1, luna, None),
465            Ok(true)
466        );
467        assert_eq!(
468            almanac.line_of_sight_obstructed(xmtr2, rcvr2, luna, None),
469            Ok(true)
470        );
471        assert_eq!(
472            almanac.line_of_sight_obstructed(xmtr3, rcvr3, luna, None),
473            Ok(true)
474        );
475
476        // Test converse
477        assert_eq!(
478            almanac.line_of_sight_obstructed(rcvr1, xmtr1, luna, None),
479            Ok(true)
480        );
481        assert_eq!(
482            almanac.line_of_sight_obstructed(rcvr2, xmtr2, luna, None),
483            Ok(true)
484        );
485        assert_eq!(
486            almanac.line_of_sight_obstructed(rcvr3, xmtr3, luna, None),
487            Ok(true)
488        );
489    }
490
491    #[rstest]
492    fn los_earth_eclipse(almanac: Almanac) {
493        let eme2k = almanac.frame_info(EARTH_J2000).unwrap();
494
495        let dt = Epoch::from_gregorian_tai_at_midnight(2020, 1, 1);
496
497        let sma = eme2k.mean_equatorial_radius_km().unwrap() + 300.0;
498
499        let sc1 = Orbit::keplerian(sma, 0.001, 0.1, 90.0, 75.0, 0.0, dt, eme2k);
500        let sc2 = Orbit::keplerian(sma + 1.0, 0.001, 0.1, 90.0, 75.0, 0.0, dt, eme2k);
501        let sc3 = Orbit::keplerian(sma, 0.001, 0.1, 90.0, 75.0, 180.0, dt, eme2k);
502
503        // Out of phase by pi.
504        assert_eq!(
505            almanac.line_of_sight_obstructed(sc1, sc3, eme2k, None),
506            Ok(true)
507        );
508
509        assert_eq!(
510            almanac.line_of_sight_obstructed(sc2, sc1, eme2k, None),
511            Ok(false)
512        );
513
514        // Nearly identical orbits in the same phasing
515        assert_eq!(
516            almanac.line_of_sight_obstructed(sc1, sc2, eme2k, None),
517            Ok(false)
518        );
519    }
520
521    #[test]
522    fn test_compute_occultation() {
523        // Case 1: External Tangency (d = rf + rb)
524        // Front object at 200 km. Radius 100 km. Apparent radius ~30 deg.
525        // Back object at 200 km. Radius 100 km. Apparent radius ~30 deg.
526        // Separation 60 deg.
527
528        let d1 = 200.0;
529        let r1 = 100.0; // Front
530        let d2 = 200.0;
531        let r2 = 100.0; // Back
532
533        let sep = PI / 3.0;
534
535        let r_obs_to_front = Vector3::new(d1, 0.0, 0.0);
536        let r_back_to_obs = Vector3::new(-d2 * sep.cos(), -d2 * sep.sin(), 0.0);
537
538        let r_ls = r_back_to_obs;
539        let r_eb = r_obs_to_front;
540
541        let bobj_mean_eq_radius_km = r2;
542        let fobj_mean_eq_radius_km = r1;
543
544        // Compute apparent radii
545        let r_ls_prime = if bobj_mean_eq_radius_km >= r_ls.norm() {
546            core::f64::consts::FRAC_PI_2
547        } else {
548            (bobj_mean_eq_radius_km / r_ls.norm()).asin()
549        };
550
551        let r_fobj_prime = if fobj_mean_eq_radius_km >= r_eb.norm() {
552            core::f64::consts::FRAC_PI_2
553        } else {
554            (fobj_mean_eq_radius_km / r_eb.norm()).asin()
555        };
556
557        // Compute apparent separation
558        let d_prime = (-(r_ls.dot(&r_eb)) / (r_eb.norm() * r_ls.norm())).acos();
559
560        let pct = compute_occultation_percentage(d_prime, r_ls_prime, r_fobj_prime).unwrap();
561
562        println!("External Tangency Percentage: {}", pct);
563        assert!(pct <= 0.001);
564
565        // Case 2: Inside Body Unit Mismatch
566        // Obs inside Front object.
567        // Front object radius 1000 km. Dist 100 km.
568        // Back object radius 10 km. Dist 10000 km.
569
570        let d_inside = 0.0005; // 0.5 m
571        let r_front_small = 0.001; // 1 m
572        let r_obs_to_front_small = Vector3::new(d_inside, 0.0, 0.0);
573        let r_back_to_obs_far = Vector3::new(-10000.0 * sep.cos(), -10000.0 * sep.sin(), 0.0); // sep 60 deg
574        let r_back_small = 10.0;
575
576        let r_ls = r_back_to_obs_far;
577        let r_eb = r_obs_to_front_small;
578
579        let bobj_mean_eq_radius_km = r_back_small;
580        let fobj_mean_eq_radius_km = r_front_small;
581
582        let r_ls_prime = if bobj_mean_eq_radius_km >= r_ls.norm() {
583            core::f64::consts::FRAC_PI_2
584        } else {
585            (bobj_mean_eq_radius_km / r_ls.norm()).asin()
586        };
587
588        let r_fobj_prime = if fobj_mean_eq_radius_km >= r_eb.norm() {
589            core::f64::consts::FRAC_PI_2
590        } else {
591            (fobj_mean_eq_radius_km / r_eb.norm()).asin()
592        };
593
594        // Compute apparent separation
595        let d_prime = (-(r_ls.dot(&r_eb)) / (r_eb.norm() * r_ls.norm())).acos();
596
597        let pct_inside = compute_occultation_percentage(d_prime, r_ls_prime, r_fobj_prime).unwrap();
598
599        println!("Inside Small Body Percentage: {}", pct_inside);
600        assert!(pct_inside >= 99.999);
601    }
602}