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}