Skip to main content

sidereon_core/astro/bodies/
observe.rs

1//! Ground-site observation convenience helpers for the Sun and Moon.
2//!
3//! These round out the "observe the sky from a site" lens by wiring the
4//! analytic Sun/Moon ephemeris ([`crate::astro::bodies::sun_moon::sun_moon_ecef`])
5//! into the existing observation geometry rather than re-deriving any math:
6//!
7//! - Topocentric azimuth/elevation/range of the Sun or Moon from a ground site
8//!   reuses the station-to-target ENU reduction
9//!   [`crate::astro::frames::transforms::itrs_to_topocentric`] (the same one the
10//!   satellite look-angle path uses).
11//! - The Moon's illuminated fraction reuses the Sun-target-observer phase angle
12//!   [`crate::astro::angles::phase_angle`] that the satellite visual-magnitude and
13//!   eclipse geometry already consume.
14//!
15//! Precision follows the underlying analytic series (sub-degree positions, see
16//! [`crate::astro::bodies::sun_moon`]); this is a planning/visualization lens, not
17//! an almanac-grade reduction.
18
19use crate::astro::angles::{phase_angle, AngleError};
20use crate::astro::bodies::sun_moon::{sun_moon_ecef, SunMoonError};
21use crate::astro::constants::units::M_PER_KM;
22use crate::astro::frames::transforms::{
23    geodetic_to_itrs, itrs_to_topocentric, FrameTransformError, GeodeticStationKm,
24};
25use crate::astro::passes::UtcInstant;
26
27/// Error returned by ground-site Sun/Moon observation helpers.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
29pub enum BodyObservationError {
30    /// The analytic Sun/Moon ephemeris rejected the instant or produced a
31    /// non-finite vector.
32    #[error("Sun/Moon ephemeris failed: {0}")]
33    Ephemeris(#[from] SunMoonError),
34    /// The station-to-body topocentric reduction rejected an input.
35    #[error("topocentric reduction failed: {0}")]
36    FrameTransform(#[from] FrameTransformError),
37    /// The phase-angle geometry rejected an input (e.g. a degenerate vector).
38    #[error("phase-angle geometry failed: {0}")]
39    Angle(#[from] AngleError),
40}
41
42/// Topocentric look angle of a body from a ground site.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct BodyAzEl {
45    /// Azimuth, degrees clockwise from north on `[0, 360)`.
46    pub azimuth_deg: f64,
47    /// Elevation above the local horizon, degrees on `[-90, 90]`.
48    pub elevation_deg: f64,
49    /// Slant range from the site to the body, kilometres.
50    pub range_km: f64,
51}
52
53/// The Moon's illuminated state as seen from a ground site.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct MoonIllumination {
56    /// Sunlit fraction of the lunar disk on `[0, 1]` (0 = new, 1 = full).
57    pub illuminated_fraction: f64,
58    /// Sun-Moon-observer phase angle, degrees on `[0, 180]` (0 = full).
59    pub phase_angle_deg: f64,
60}
61
62/// Topocentric azimuth/elevation/range of the Sun from a ground site at an
63/// instant.
64///
65/// Reuses [`sun_moon_ecef`] for the Earth-fixed Sun vector and
66/// [`itrs_to_topocentric`] for the station-to-target ENU reduction, so the
67/// azimuth/elevation convention matches the satellite look-angle path.
68pub fn sun_az_el(
69    station: &GeodeticStationKm,
70    time: UtcInstant,
71) -> Result<BodyAzEl, BodyObservationError> {
72    let sun_ecef_m = sun_moon_ecef(&time.time_scales())?.sun;
73    body_az_el(station, sun_ecef_m)
74}
75
76/// Topocentric azimuth/elevation/range of the Moon from a ground site at an
77/// instant.
78///
79/// The Earth-fixed Moon vector from [`sun_moon_ecef`] is geocentric, so the
80/// station-to-target subtraction in [`itrs_to_topocentric`] applies the
81/// topocentric (diurnal) parallax that matters for the nearby Moon.
82pub fn moon_az_el(
83    station: &GeodeticStationKm,
84    time: UtcInstant,
85) -> Result<BodyAzEl, BodyObservationError> {
86    let moon_ecef_m = sun_moon_ecef(&time.time_scales())?.moon;
87    body_az_el(station, moon_ecef_m)
88}
89
90fn body_az_el(
91    station: &GeodeticStationKm,
92    body_ecef_m: [f64; 3],
93) -> Result<BodyAzEl, BodyObservationError> {
94    let body_ecef_km = [
95        body_ecef_m[0] / M_PER_KM,
96        body_ecef_m[1] / M_PER_KM,
97        body_ecef_m[2] / M_PER_KM,
98    ];
99    let (azimuth_deg, elevation_deg, range_km) = itrs_to_topocentric(body_ecef_km, station)?;
100    Ok(BodyAzEl {
101        azimuth_deg,
102        elevation_deg,
103        range_km,
104    })
105}
106
107/// Illuminated fraction of the Moon as seen from a ground site at an instant.
108///
109/// The Sun-Moon-observer phase angle is the existing
110/// [`phase_angle`] (the satellite Sun-target-observer geometry) evaluated with
111/// the Moon as the target and the site as the observer, both from the Earth-fixed
112/// Sun/Moon vectors of [`sun_moon_ecef`]. The illuminated fraction follows from
113/// the half-angle relation `k = (1 + cos(phase)) / 2`. Topocentric and geocentric
114/// fractions differ only negligibly; the site-relative phase angle is used for
115/// consistency with the other site helpers in this module.
116pub fn moon_illumination(
117    station: &GeodeticStationKm,
118    time: UtcInstant,
119) -> Result<MoonIllumination, BodyObservationError> {
120    let sun_moon = sun_moon_ecef(&time.time_scales())?;
121    let sun_km = scale_m_to_km(sun_moon.sun);
122    let moon_km = scale_m_to_km(sun_moon.moon);
123    let (stn_x, stn_y, stn_z) = geodetic_to_itrs(
124        station.latitude_deg,
125        station.longitude_deg,
126        station.altitude_km,
127    )?;
128    let observer_km = [stn_x, stn_y, stn_z];
129
130    let phase_angle_deg = phase_angle(moon_km, sun_km, observer_km)?;
131    let illuminated_fraction = (1.0 + phase_angle_deg.to_radians().cos()) / 2.0;
132    Ok(MoonIllumination {
133        illuminated_fraction,
134        phase_angle_deg,
135    })
136}
137
138fn scale_m_to_km(v: [f64; 3]) -> [f64; 3] {
139    [v[0] / M_PER_KM, v[1] / M_PER_KM, v[2] / M_PER_KM]
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // Reference site: Royal Observatory, Greenwich (WGS84), altitude ~46 m.
147    fn greenwich() -> GeodeticStationKm {
148        GeodeticStationKm {
149            latitude_deg: 51.4769,
150            longitude_deg: 0.0,
151            altitude_km: 0.046,
152        }
153    }
154
155    #[test]
156    fn sun_az_el_at_solar_transit_is_due_south_and_high() {
157        // Apparent solar upper transit at Greenwich on 2024-06-20 is 12:01:42 UTC
158        // (Skyfield de421 meridian_transits). At that instant the Sun is on the
159        // meridian: Skyfield gives az 180.000 deg, alt 61.960 deg, range
160        // 1.52011e8 km (near aphelion). The low-precision analytic series is held
161        // to 0.5 deg in az/el and 5.0e5 km in range.
162        let time = UtcInstant::from_utc(2024, 6, 20, 12, 1, 42, 0).expect("valid UTC");
163        let look = sun_az_el(&greenwich(), time).expect("valid sun geometry");
164        assert!(
165            (look.azimuth_deg - 180.0).abs() < 0.5,
166            "sun azimuth {}",
167            look.azimuth_deg
168        );
169        assert!(
170            (look.elevation_deg - 61.960).abs() < 0.5,
171            "sun elevation {}",
172            look.elevation_deg
173        );
174        assert!(
175            (look.range_km - 1.52011e8).abs() < 5.0e5,
176            "sun range {}",
177            look.range_km
178        );
179    }
180
181    #[test]
182    fn moon_illumination_tracks_full_moon() {
183        // Full moon of 2024-04-23 23:49 UTC. Skyfield's (de421) geocentric
184        // illuminated fraction at this instant is 0.9998 (phase angle 1.68 deg).
185        // The low-precision analytic series is held to a coarse tolerance:
186        // fraction within 0.02, phase angle below 11 deg.
187        let time = UtcInstant::from_utc(2024, 4, 23, 23, 49, 0, 0).expect("valid UTC");
188        let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
189        assert!(
190            (illum.illuminated_fraction - 0.998).abs() < 0.02,
191            "full-moon fraction {}",
192            illum.illuminated_fraction
193        );
194        assert!(
195            illum.phase_angle_deg < 11.0,
196            "full-moon phase angle {}",
197            illum.phase_angle_deg
198        );
199    }
200
201    #[test]
202    fn moon_illumination_tracks_new_moon() {
203        // New moon of 2024-04-08 18:21 UTC (the total-eclipse new moon). Skyfield's
204        // (de421) geocentric fraction is 0.0000 (phase angle 179.65 deg). Held to
205        // fraction within 0.02 and phase angle above 168 deg.
206        let time = UtcInstant::from_utc(2024, 4, 8, 18, 21, 0, 0).expect("valid UTC");
207        let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
208        assert!(
209            illum.illuminated_fraction < 0.02,
210            "new-moon fraction {}",
211            illum.illuminated_fraction
212        );
213        assert!(
214            illum.phase_angle_deg > 168.0,
215            "new-moon phase angle {}",
216            illum.phase_angle_deg
217        );
218    }
219
220    #[test]
221    fn moon_illumination_near_first_quarter() {
222        // First quarter of 2024-04-15 19:13 UTC: about half the disk lit. Skyfield's
223        // (de421) geocentric fraction is 0.5013 (phase angle 89.85 deg). Held to
224        // fraction within 0.05.
225        let time = UtcInstant::from_utc(2024, 4, 15, 19, 13, 0, 0).expect("valid UTC");
226        let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
227        assert!(
228            (illum.illuminated_fraction - 0.50).abs() < 0.05,
229            "first-quarter fraction {}",
230            illum.illuminated_fraction
231        );
232    }
233
234    #[test]
235    fn moon_az_el_matches_reference_at_transit() {
236        // Moon upper transit at Greenwich on 2024-04-23 is 23:55:59 UTC. At that
237        // instant Skyfield (de421, apparent topocentric altaz) gives az 180.000
238        // deg, alt 23.120 deg, range 397206 km. The low-precision analytic series
239        // is held to 0.3 deg in az/el and 1000 km in range; the band check also
240        // confirms the parallax-corrected station subtraction ran.
241        let time = UtcInstant::from_utc(2024, 4, 23, 23, 55, 59, 0).expect("valid UTC");
242        let look = moon_az_el(&greenwich(), time).expect("valid moon geometry");
243        assert!(
244            (look.azimuth_deg - 180.0).abs() < 0.3,
245            "moon azimuth {}",
246            look.azimuth_deg
247        );
248        assert!(
249            (look.elevation_deg - 23.120).abs() < 0.3,
250            "moon elevation {}",
251            look.elevation_deg
252        );
253        assert!(
254            (look.range_km - 397_206.0).abs() < 1000.0,
255            "moon range {}",
256            look.range_km
257        );
258    }
259}