use crate::astro::angles::{phase_angle, AngleError};
use crate::astro::bodies::sun_moon::{sun_moon_ecef, SunMoonError};
use crate::astro::constants::units::M_PER_KM;
use crate::astro::frames::transforms::{
geodetic_to_itrs, itrs_to_topocentric, FrameTransformError, GeodeticStationKm,
};
use crate::astro::passes::UtcInstant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum BodyObservationError {
#[error("Sun/Moon ephemeris failed: {0}")]
Ephemeris(#[from] SunMoonError),
#[error("topocentric reduction failed: {0}")]
FrameTransform(#[from] FrameTransformError),
#[error("phase-angle geometry failed: {0}")]
Angle(#[from] AngleError),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BodyAzEl {
pub azimuth_deg: f64,
pub elevation_deg: f64,
pub range_km: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MoonIllumination {
pub illuminated_fraction: f64,
pub phase_angle_deg: f64,
}
pub fn sun_az_el(
station: &GeodeticStationKm,
time: UtcInstant,
) -> Result<BodyAzEl, BodyObservationError> {
let sun_ecef_m = sun_moon_ecef(&time.time_scales())?.sun;
body_az_el(station, sun_ecef_m)
}
pub fn moon_az_el(
station: &GeodeticStationKm,
time: UtcInstant,
) -> Result<BodyAzEl, BodyObservationError> {
let moon_ecef_m = sun_moon_ecef(&time.time_scales())?.moon;
body_az_el(station, moon_ecef_m)
}
fn body_az_el(
station: &GeodeticStationKm,
body_ecef_m: [f64; 3],
) -> Result<BodyAzEl, BodyObservationError> {
let body_ecef_km = [
body_ecef_m[0] / M_PER_KM,
body_ecef_m[1] / M_PER_KM,
body_ecef_m[2] / M_PER_KM,
];
let (azimuth_deg, elevation_deg, range_km) = itrs_to_topocentric(body_ecef_km, station)?;
Ok(BodyAzEl {
azimuth_deg,
elevation_deg,
range_km,
})
}
pub fn moon_illumination(
station: &GeodeticStationKm,
time: UtcInstant,
) -> Result<MoonIllumination, BodyObservationError> {
let sun_moon = sun_moon_ecef(&time.time_scales())?;
let sun_km = scale_m_to_km(sun_moon.sun);
let moon_km = scale_m_to_km(sun_moon.moon);
let (stn_x, stn_y, stn_z) = geodetic_to_itrs(
station.latitude_deg,
station.longitude_deg,
station.altitude_km,
)?;
let observer_km = [stn_x, stn_y, stn_z];
let phase_angle_deg = phase_angle(moon_km, sun_km, observer_km)?;
let illuminated_fraction = (1.0 + phase_angle_deg.to_radians().cos()) / 2.0;
Ok(MoonIllumination {
illuminated_fraction,
phase_angle_deg,
})
}
fn scale_m_to_km(v: [f64; 3]) -> [f64; 3] {
[v[0] / M_PER_KM, v[1] / M_PER_KM, v[2] / M_PER_KM]
}
#[cfg(test)]
mod tests {
use super::*;
fn greenwich() -> GeodeticStationKm {
GeodeticStationKm {
latitude_deg: 51.4769,
longitude_deg: 0.0,
altitude_km: 0.046,
}
}
#[test]
fn sun_az_el_at_solar_transit_is_due_south_and_high() {
let time = UtcInstant::from_utc(2024, 6, 20, 12, 1, 42, 0).expect("valid UTC");
let look = sun_az_el(&greenwich(), time).expect("valid sun geometry");
assert!(
(look.azimuth_deg - 180.0).abs() < 0.5,
"sun azimuth {}",
look.azimuth_deg
);
assert!(
(look.elevation_deg - 61.960).abs() < 0.5,
"sun elevation {}",
look.elevation_deg
);
assert!(
(look.range_km - 1.52011e8).abs() < 5.0e5,
"sun range {}",
look.range_km
);
}
#[test]
fn moon_illumination_tracks_full_moon() {
let time = UtcInstant::from_utc(2024, 4, 23, 23, 49, 0, 0).expect("valid UTC");
let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
assert!(
(illum.illuminated_fraction - 0.998).abs() < 0.02,
"full-moon fraction {}",
illum.illuminated_fraction
);
assert!(
illum.phase_angle_deg < 11.0,
"full-moon phase angle {}",
illum.phase_angle_deg
);
}
#[test]
fn moon_illumination_tracks_new_moon() {
let time = UtcInstant::from_utc(2024, 4, 8, 18, 21, 0, 0).expect("valid UTC");
let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
assert!(
illum.illuminated_fraction < 0.02,
"new-moon fraction {}",
illum.illuminated_fraction
);
assert!(
illum.phase_angle_deg > 168.0,
"new-moon phase angle {}",
illum.phase_angle_deg
);
}
#[test]
fn moon_illumination_near_first_quarter() {
let time = UtcInstant::from_utc(2024, 4, 15, 19, 13, 0, 0).expect("valid UTC");
let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
assert!(
(illum.illuminated_fraction - 0.50).abs() < 0.05,
"first-quarter fraction {}",
illum.illuminated_fraction
);
}
#[test]
fn moon_az_el_matches_reference_at_transit() {
let time = UtcInstant::from_utc(2024, 4, 23, 23, 55, 59, 0).expect("valid UTC");
let look = moon_az_el(&greenwich(), time).expect("valid moon geometry");
assert!(
(look.azimuth_deg - 180.0).abs() < 0.3,
"moon azimuth {}",
look.azimuth_deg
);
assert!(
(look.elevation_deg - 23.120).abs() < 0.3,
"moon elevation {}",
look.elevation_deg
);
assert!(
(look.range_km - 397_206.0).abs() < 1000.0,
"moon range {}",
look.range_km
);
}
}