use crate::calculus::ephemeris::Ephemeris;
use crate::calculus::math_core::intervals;
use crate::coordinates::cartesian;
use crate::coordinates::centers::*;
use crate::coordinates::frames;
use crate::time::{JulianDate, ModifiedJulianDate, Period, MJD};
use qtty::*;
use std::f64::consts::PI;
use std::marker::PhantomData;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
const TWO_PI: f64 = 2.0 * PI;
const AU_KM: f64 = 149_597_870.700;
const PHASE_SCAN_STEP: Days = Days::new(0.5);
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PhaseThresholds {
pub half_width: Degrees,
}
impl Default for PhaseThresholds {
fn default() -> Self {
Self {
half_width: Degrees::new(22.5),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum MoonPhaseLabel {
NewMoon,
WaxingCrescent,
FirstQuarter,
WaxingGibbous,
FullMoon,
WaningGibbous,
LastQuarter,
WaningCrescent,
}
impl MoonPhaseLabel {
pub fn from_elongation(elongation: Degrees, th: &PhaseThresholds) -> Self {
let hw = th.half_width;
let mut e = elongation;
let full = Degrees::new(360.0);
while e >= full {
e -= full;
}
while e < Degrees::new(0.0) {
e += full;
}
if e < hw || e >= full - hw {
Self::NewMoon
} else if e < Degrees::new(90.0) - hw {
Self::WaxingCrescent
} else if e < Degrees::new(90.0) + hw {
Self::FirstQuarter
} else if e < Degrees::new(180.0) - hw {
Self::WaxingGibbous
} else if e < Degrees::new(180.0) + hw {
Self::FullMoon
} else if e < Degrees::new(270.0) - hw {
Self::WaningGibbous
} else if e < Degrees::new(270.0) + hw {
Self::LastQuarter
} else {
Self::WaningCrescent
}
}
pub fn is_waxing(self) -> bool {
matches!(
self,
Self::WaxingCrescent | Self::FirstQuarter | Self::WaxingGibbous
)
}
pub fn is_waning(self) -> bool {
matches!(
self,
Self::WaningGibbous | Self::LastQuarter | Self::WaningCrescent
)
}
}
impl std::fmt::Display for MoonPhaseLabel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NewMoon => write!(f, "New Moon"),
Self::WaxingCrescent => write!(f, "Waxing Crescent"),
Self::FirstQuarter => write!(f, "First Quarter"),
Self::WaxingGibbous => write!(f, "Waxing Gibbous"),
Self::FullMoon => write!(f, "Full Moon"),
Self::WaningGibbous => write!(f, "Waning Gibbous"),
Self::LastQuarter => write!(f, "Last Quarter"),
Self::WaningCrescent => write!(f, "Waning Crescent"),
}
}
}
impl From<MoonPhaseLabel> for Degrees {
fn from(lbl: MoonPhaseLabel) -> Self {
match lbl {
MoonPhaseLabel::NewMoon => Degrees::new(0.0),
MoonPhaseLabel::WaxingCrescent => Degrees::new(45.0),
MoonPhaseLabel::FirstQuarter => Degrees::new(90.0),
MoonPhaseLabel::WaxingGibbous => Degrees::new(135.0),
MoonPhaseLabel::FullMoon => Degrees::new(180.0),
MoonPhaseLabel::WaningGibbous => Degrees::new(225.0),
MoonPhaseLabel::LastQuarter => Degrees::new(270.0),
MoonPhaseLabel::WaningCrescent => Degrees::new(315.0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum PhaseKind {
NewMoon,
FirstQuarter,
FullMoon,
LastQuarter,
}
impl PhaseKind {
pub(crate) fn target_elongation(self) -> f64 {
match self {
Self::NewMoon => 0.0,
Self::FirstQuarter => PI / 2.0,
Self::FullMoon => PI,
Self::LastQuarter => 3.0 * PI / 2.0,
}
}
pub const ALL: [PhaseKind; 4] = [
PhaseKind::NewMoon,
PhaseKind::FirstQuarter,
PhaseKind::FullMoon,
PhaseKind::LastQuarter,
];
}
impl std::fmt::Display for PhaseKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NewMoon => write!(f, "New Moon"),
Self::FirstQuarter => write!(f, "First Quarter"),
Self::FullMoon => write!(f, "Full Moon"),
Self::LastQuarter => write!(f, "Last Quarter"),
}
}
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PhaseEvent {
pub mjd: ModifiedJulianDate,
pub kind: PhaseKind,
}
impl std::fmt::Display for PhaseEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} at {}", self.kind, self.mjd)
}
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MoonPhaseGeometry {
pub phase_angle: Radians,
pub illuminated_fraction: f64,
pub elongation: Radians,
pub waxing: bool,
}
impl std::fmt::Display for MoonPhaseGeometry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let phase_dir = if self.waxing { "waxing" } else { "waning" };
write!(
f,
"{} ({}, {:.1}% lit)",
self.label(),
phase_dir,
self.illuminated_fraction * 100.0
)
}
}
impl MoonPhaseGeometry {
pub fn label(&self) -> MoonPhaseLabel {
self.label_with(&PhaseThresholds::default())
}
pub fn label_with(&self, th: &PhaseThresholds) -> MoonPhaseLabel {
MoonPhaseLabel::from_elongation(self.elongation.to::<Degree>(), th)
}
pub fn illuminated_percent(&self) -> f64 {
self.illuminated_fraction * 100.0
}
}
fn moon_ecliptic_geocentric<E: Ephemeris>(jd: JulianDate) -> (f64, f64, f64) {
let moon: cartesian::Position<Geocentric, frames::EclipticMeanJ2000, Kilometer> =
E::moon_geocentric(jd);
let sph = moon.to_spherical();
(
sph.azimuth.to::<Radian>().value(), sph.polar.to::<Radian>().value(), sph.distance.value(), )
}
fn sun_ecliptic_geocentric<E: Ephemeris>(jd: JulianDate) -> (f64, f64) {
let earth = E::earth_heliocentric(jd);
let sph = earth.to_spherical();
let earth_lon = sph.azimuth.to::<Radian>().value();
let sun_lon = (earth_lon + PI).rem_euclid(TWO_PI);
let sun_dist = sph.distance.value(); (sun_lon, sun_dist)
}
fn elongation_from_longitudes(moon_lon: f64, sun_lon: f64) -> f64 {
(moon_lon - sun_lon).rem_euclid(TWO_PI)
}
fn phase_angle_from_triangle(r_moon_km: f64, r_sun_au: f64, psi: f64) -> f64 {
let r_moon_au = r_moon_km / AU_KM;
let d_sq = r_sun_au * r_sun_au + r_moon_au * r_moon_au - 2.0 * r_sun_au * r_moon_au * psi.cos();
let d = d_sq.max(0.0).sqrt();
if d < 1e-15 {
return 0.0; }
let cos_i = (d * d + r_moon_au * r_moon_au - r_sun_au * r_sun_au) / (2.0 * d * r_moon_au);
cos_i.clamp(-1.0, 1.0).acos()
}
pub fn moon_phase_geocentric<E: Ephemeris>(jd: JulianDate) -> MoonPhaseGeometry {
let (moon_lon, moon_lat, moon_dist_km) = moon_ecliptic_geocentric::<E>(jd);
let (sun_lon, sun_dist_au) = sun_ecliptic_geocentric::<E>(jd);
let psi = great_circle_elongation(sun_lon, 0.0, moon_lon, moon_lat);
let signed_elong = elongation_from_longitudes(moon_lon, sun_lon);
let i = phase_angle_from_triangle(moon_dist_km, sun_dist_au, psi);
let k = (1.0 + i.cos()) / 2.0;
MoonPhaseGeometry {
phase_angle: Radians::new(i),
illuminated_fraction: k,
elongation: Radians::new(signed_elong),
waxing: signed_elong > 0.0 && signed_elong < PI,
}
}
fn great_circle_elongation(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
let cos_sep = lat1.sin() * lat2.sin() + lat1.cos() * lat2.cos() * (lon2 - lon1).cos();
cos_sep.clamp(-1.0, 1.0).acos()
}
pub fn moon_phase_topocentric<E: Ephemeris>(
jd: JulianDate,
site: Geodetic<frames::ECEF>,
) -> MoonPhaseGeometry {
use crate::bodies::solar_system::{Moon, Sun};
let moon_topo: affn::spherical::Position<Topocentric, frames::EquatorialTrueOfDate, Kilometer> =
Moon::get_apparent_topocentric_equ(jd, site);
let sun_topo: affn::spherical::Position<
Topocentric,
frames::EquatorialTrueOfDate,
AstronomicalUnit,
> = Sun::get_apparent_topocentric_equ(jd, site);
let moon_ra = moon_topo.ra().to::<Radian>().value();
let moon_dec = moon_topo.dec().to::<Radian>().value();
let sun_ra = sun_topo.ra().to::<Radian>().value();
let sun_dec = sun_topo.dec().to::<Radian>().value();
let psi = great_circle_elongation(moon_ra, moon_dec, sun_ra, sun_dec);
let dra = (moon_ra - sun_ra).rem_euclid(TWO_PI);
let signed_elong = if dra < PI { psi } else { TWO_PI - psi };
let (_, _, moon_dist_km) = moon_ecliptic_geocentric::<E>(jd);
let (_, sun_dist_au) = sun_ecliptic_geocentric::<E>(jd);
let i = phase_angle_from_triangle(moon_dist_km, sun_dist_au, psi);
let k = (1.0 + i.cos()) / 2.0;
MoonPhaseGeometry {
phase_angle: Radians::new(i),
illuminated_fraction: k,
elongation: Radians::new(signed_elong),
waxing: signed_elong > 0.0 && signed_elong < PI,
}
}
fn elongation_at_mjd<E: Ephemeris>(mjd: ModifiedJulianDate) -> f64 {
let jd: JulianDate = mjd.into();
let (moon_lon, _, _) = moon_ecliptic_geocentric::<E>(jd);
let (sun_lon, _) = sun_ecliptic_geocentric::<E>(jd);
elongation_from_longitudes(moon_lon, sun_lon)
}
#[derive(Debug, Clone, Copy)]
pub struct PhaseSearchOpts {
pub time_tolerance: Days,
pub scan_step: Days,
}
impl Default for PhaseSearchOpts {
fn default() -> Self {
Self {
time_tolerance: Days::new(1e-9),
scan_step: PHASE_SCAN_STEP,
}
}
}
pub fn find_phase_events<E: Ephemeris>(
window: Period<MJD>,
opts: PhaseSearchOpts,
) -> Vec<PhaseEvent> {
let mut events = Vec::new();
let step = opts.scan_step;
let _tol = opts.time_tolerance;
for kind in PhaseKind::ALL {
let target = kind.target_elongation();
let f = |mjd: ModifiedJulianDate| -> Radians {
let elong = elongation_at_mjd::<E>(mjd);
Radians::new((elong - target).sin())
};
let threshold = Radians::new(0.0);
let raw_crossings = intervals::find_crossings(window, step, &f, threshold);
for mjd in raw_crossings {
let elong = elongation_at_mjd::<E>(mjd);
let diff = angle_diff(elong, target);
if diff.abs() < 0.5 {
events.push(PhaseEvent { mjd, kind });
}
}
}
events.sort_by(|a, b| a.mjd.partial_cmp(&b.mjd).unwrap());
events
}
fn angle_diff(a: f64, b: f64) -> f64 {
let d = (a - b).rem_euclid(TWO_PI);
if d > PI {
d - TWO_PI
} else {
d
}
}
pub struct MoonPhaseSeries<E: Ephemeris> {
_marker: PhantomData<E>,
}
impl<E: Ephemeris> MoonPhaseSeries<E> {
pub fn sample(
start: ModifiedJulianDate,
end: ModifiedJulianDate,
step: Days,
) -> Vec<(ModifiedJulianDate, MoonPhaseGeometry)> {
let mut results = Vec::new();
let mut t = start;
while t <= end {
let jd: JulianDate = t.into();
let geom = moon_phase_geocentric::<E>(jd);
results.push((t, geom));
t += step;
}
results
}
pub fn sample_topocentric(
start: ModifiedJulianDate,
end: ModifiedJulianDate,
step: Days,
site: Geodetic<frames::ECEF>,
) -> Vec<(ModifiedJulianDate, MoonPhaseGeometry)> {
let mut results = Vec::new();
let mut t = start;
while t <= end {
let jd: JulianDate = t.into();
let geom = moon_phase_topocentric::<E>(jd, site);
results.push((t, geom));
t += step;
}
results
}
}
fn illumination_at_mjd<E: Ephemeris>(mjd: ModifiedJulianDate) -> Radians {
let jd: JulianDate = mjd.into();
let geom = moon_phase_geocentric::<E>(jd);
Radians::new(geom.illuminated_fraction)
}
pub fn illumination_above<E: Ephemeris>(
window: Period<MJD>,
k_min: f64,
opts: PhaseSearchOpts,
) -> Vec<Period<MJD>> {
intervals::above_threshold_periods(
window,
opts.scan_step,
&illumination_at_mjd::<E>,
Radians::new(k_min),
)
}
pub fn illumination_below<E: Ephemeris>(
window: Period<MJD>,
k_max: f64,
opts: PhaseSearchOpts,
) -> Vec<Period<MJD>> {
use crate::time::complement_within;
let above = illumination_above::<E>(window, k_max, opts);
complement_within(window, &above)
}
pub fn illumination_range<E: Ephemeris>(
window: Period<MJD>,
k_min: f64,
k_max: f64,
opts: PhaseSearchOpts,
) -> Vec<Period<MJD>> {
intervals::in_range_periods(
window,
opts.scan_step,
&illumination_at_mjd::<E>,
Radians::new(k_min),
Radians::new(k_max),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calculus::ephemeris::Vsop87Ephemeris;
#[test]
fn illuminated_fraction_bounded() {
let start = JulianDate::J2000;
for i in 0..100 {
let jd = start + Days::new(i as f64 * 3.0);
let geom = moon_phase_geocentric::<Vsop87Ephemeris>(jd);
assert!(
geom.illuminated_fraction >= 0.0 && geom.illuminated_fraction <= 1.0,
"Fraction out of bounds at JD offset {}: {}",
i * 3,
geom.illuminated_fraction
);
}
}
#[test]
fn phase_angle_bounded() {
let start = JulianDate::J2000;
for i in 0..50 {
let jd = start + Days::new(i as f64 * 5.0);
let geom = moon_phase_geocentric::<Vsop87Ephemeris>(jd);
let i_val = geom.phase_angle;
assert!(
i_val >= Radians::new(0.0) && i_val <= Radians::new(PI),
"Phase angle out of [0, π] at offset {}: {}",
i * 5,
i_val
);
}
}
#[test]
fn elongation_bounded() {
let start = JulianDate::J2000;
for i in 0..50 {
let jd = start + Days::new(i as f64 * 5.0);
let geom = moon_phase_geocentric::<Vsop87Ephemeris>(jd);
let e = geom.elongation;
assert!(
e >= Radians::new(0.0) && e < Radians::new(TWO_PI),
"Elongation out of [0, 2π) at offset {}: {}",
i * 5,
e
);
}
}
#[test]
fn waxing_consistent_with_elongation() {
let start = JulianDate::J2000;
for i in 0..50 {
let jd = start + Days::new(i as f64 * 5.0);
let geom = moon_phase_geocentric::<Vsop87Ephemeris>(jd);
let e = geom.elongation;
let expected_waxing = e > Radians::new(0.0) && e < Radians::new(PI);
assert_eq!(
geom.waxing,
expected_waxing,
"Waxing flag mismatch at offset {}: elong={}, waxing={}",
i * 5,
e,
geom.waxing
);
}
}
#[test]
fn label_known_elongations() {
let th = PhaseThresholds::default();
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(0.0), &th),
MoonPhaseLabel::NewMoon
);
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(45.0), &th),
MoonPhaseLabel::WaxingCrescent
);
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(90.0), &th),
MoonPhaseLabel::FirstQuarter
);
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(135.0), &th),
MoonPhaseLabel::WaxingGibbous
);
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(180.0), &th),
MoonPhaseLabel::FullMoon
);
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(225.0), &th),
MoonPhaseLabel::WaningGibbous
);
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(270.0), &th),
MoonPhaseLabel::LastQuarter
);
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(315.0), &th),
MoonPhaseLabel::WaningCrescent
);
assert_eq!(
MoonPhaseLabel::from_elongation(Degrees::new(359.0), &th),
MoonPhaseLabel::NewMoon
);
}
#[test]
fn label_via_geometry() {
let geom = moon_phase_geocentric::<Vsop87Ephemeris>(JulianDate::J2000);
let label = geom.label();
let _ = format!("{}", label);
}
#[test]
fn find_events_in_one_synodic_month() {
let start = ModifiedJulianDate::from(JulianDate::J2000);
let end = start + Days::new(35.0);
let window = Period::new(start, end);
let events = find_phase_events::<Vsop87Ephemeris>(window, PhaseSearchOpts::default());
assert!(
!events.is_empty(),
"Should find at least one phase event in 35 days"
);
for pair in events.windows(2) {
assert!(pair[0].mjd <= pair[1].mjd, "Events not sorted");
}
}
#[test]
fn topocentric_close_to_geocentric() {
use crate::coordinates::centers::Geodetic;
use crate::coordinates::frames::ECEF;
let site = Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(51.48), Meters::new(0.0));
let jd = JulianDate::J2000;
let geo = moon_phase_geocentric::<Vsop87Ephemeris>(jd);
let topo = moon_phase_topocentric::<Vsop87Ephemeris>(jd, site);
let diff = (geo.elongation - topo.elongation).abs();
let two_deg_in_rad = Degrees::new(2.0).to::<Radian>();
assert!(
diff < two_deg_in_rad,
"Geocentric vs topocentric elongation differ by more than 2°: {} rad",
diff
);
let frac_diff = (geo.illuminated_fraction - topo.illuminated_fraction).abs();
assert!(
frac_diff < 0.01,
"Illuminated fraction geo vs topo differ by more than 1%: {}",
frac_diff
);
}
#[test]
fn series_length() {
let start = ModifiedJulianDate::from(JulianDate::J2000);
let end = start + Days::new(10.0);
let step = Days::new(1.0);
let series = MoonPhaseSeries::<Vsop87Ephemeris>::sample(start, end, step);
assert_eq!(series.len(), 11); }
}