use crate::coords::normalize_degrees;
use crate::error::{JyotishError, Result};
use crate::moon::lunar_longitude;
use crate::sun::solar_longitude;
use serde::{Deserialize, Serialize};
use std::fmt;
const SYNODIC_MONTH: f64 = 29.530_588_853;
const QUARTER_SYNODIC: f64 = SYNODIC_MONTH / 4.0;
const BROWN_LUNATION_EPOCH: f64 = 2_423_436.403_47;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LunarPhase {
NewMoon,
FirstQuarter,
FullMoon,
LastQuarter,
}
impl LunarPhase {
pub fn target_elongation(self) -> f64 {
match self {
Self::NewMoon => 0.0,
Self::FirstQuarter => 90.0,
Self::FullMoon => 180.0,
Self::LastQuarter => 270.0,
}
}
const ALL: [LunarPhase; 4] = [
LunarPhase::NewMoon,
LunarPhase::FirstQuarter,
LunarPhase::FullMoon,
LunarPhase::LastQuarter,
];
}
impl fmt::Display for LunarPhase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 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, PartialEq)]
pub struct PhaseInfo {
pub phase: LunarPhase,
pub jd: f64,
pub elongation: f64,
}
pub fn phase_angle(jd: f64) -> f64 {
let sun_lon = solar_longitude(jd);
let moon_lon = lunar_longitude(jd);
normalize_degrees(moon_lon - sun_lon)
}
pub fn current_phase(jd: f64) -> LunarPhase {
let elong = phase_angle(jd);
if !(45.0..315.0).contains(&elong) {
LunarPhase::NewMoon
} else if elong < 135.0 {
LunarPhase::FirstQuarter
} else if elong < 225.0 {
LunarPhase::FullMoon
} else {
LunarPhase::LastQuarter
}
}
fn signed_angle_diff(elongation: f64, target: f64) -> f64 {
let mut diff = elongation - target;
if diff > 180.0 {
diff -= 360.0;
} else if diff < -180.0 {
diff += 360.0;
}
diff
}
pub fn next_phase(phase: LunarPhase, jd: f64) -> Result<PhaseInfo> {
let target = phase.target_elongation();
let step = QUARTER_SYNODIC / 2.0;
let max_steps = 14;
let mut jd_prev = jd;
let mut diff_prev = signed_angle_diff(phase_angle(jd_prev), target);
for _ in 0..max_steps {
let jd_curr = jd_prev + step;
let diff_curr = signed_angle_diff(phase_angle(jd_curr), target);
if diff_prev * diff_curr < 0.0 {
let mut lo = jd_prev;
let mut hi = jd_curr;
let mut lo_diff = diff_prev;
for _ in 0..80 {
let mid = (lo + hi) / 2.0;
let mid_diff = signed_angle_diff(phase_angle(mid), target);
if mid_diff * lo_diff > 0.0 {
lo = mid;
lo_diff = mid_diff;
} else {
hi = mid;
}
if (hi - lo) < 1e-8 {
break;
}
}
let result_jd = (lo + hi) / 2.0;
let elongation = phase_angle(result_jd);
let elong_diff = if target == 0.0 {
if elongation > 180.0 {
360.0 - elongation
} else {
elongation
}
} else {
(elongation - target).abs()
};
if elong_diff < 5.0 && result_jd > jd {
return Ok(PhaseInfo {
phase,
jd: result_jd,
elongation,
});
}
}
diff_prev = diff_curr;
jd_prev = jd_curr;
}
Err(JyotishError::InvalidParameter(
"lunar phase search did not converge within 45 days".into(),
))
}
pub fn next_four_phases(jd: f64) -> Result<Vec<PhaseInfo>> {
let mut phases = Vec::with_capacity(4);
for &phase in &LunarPhase::ALL {
phases.push(next_phase(phase, jd)?);
}
phases.sort_by(|a, b| a.jd.partial_cmp(&b.jd).unwrap_or(std::cmp::Ordering::Equal));
Ok(phases)
}
pub fn lunation_number(jd: f64) -> i32 {
((jd - BROWN_LUNATION_EPOCH) / SYNODIC_MONTH).floor() as i32
}
#[cfg(test)]
mod tests {
use super::*;
const JD_J2000: f64 = 2_451_545.0;
#[test]
fn phase_angle_range() {
for offset in 0..100 {
let jd = JD_J2000 + offset as f64;
let angle = phase_angle(jd);
assert!(
(0.0..360.0).contains(&angle),
"angle = {angle} at offset {offset}"
);
}
}
#[test]
fn phase_angle_at_j2000() {
let angle = phase_angle(JD_J2000);
assert!((0.0..360.0).contains(&angle), "angle = {angle}");
}
#[test]
fn current_phase_at_j2000() {
let phase = current_phase(JD_J2000);
match phase {
LunarPhase::NewMoon
| LunarPhase::FirstQuarter
| LunarPhase::FullMoon
| LunarPhase::LastQuarter => {}
}
}
#[test]
fn next_phase_full_moon() {
let info = next_phase(LunarPhase::FullMoon, JD_J2000).unwrap();
assert!(info.jd > JD_J2000);
assert_eq!(info.phase, LunarPhase::FullMoon);
assert!(info.jd - JD_J2000 < 30.0, "delta = {}", info.jd - JD_J2000);
assert!(
(info.elongation - 180.0).abs() < 1.0,
"elongation = {}",
info.elongation
);
}
#[test]
fn next_phase_new_moon() {
let info = next_phase(LunarPhase::NewMoon, JD_J2000).unwrap();
assert!(info.jd > JD_J2000);
assert_eq!(info.phase, LunarPhase::NewMoon);
assert!(info.jd - JD_J2000 < 30.0, "delta = {}", info.jd - JD_J2000);
let elong_from_zero = if info.elongation > 180.0 {
360.0 - info.elongation
} else {
info.elongation
};
assert!(elong_from_zero < 1.0, "elongation = {}", info.elongation);
}
#[test]
fn next_phase_first_quarter() {
let info = next_phase(LunarPhase::FirstQuarter, JD_J2000).unwrap();
assert!(info.jd > JD_J2000);
assert_eq!(info.phase, LunarPhase::FirstQuarter);
assert!(info.jd - JD_J2000 < 30.0);
assert!(
(info.elongation - 90.0).abs() < 1.0,
"elongation = {}",
info.elongation
);
}
#[test]
fn next_phase_last_quarter() {
let info = next_phase(LunarPhase::LastQuarter, JD_J2000).unwrap();
assert!(info.jd > JD_J2000);
assert_eq!(info.phase, LunarPhase::LastQuarter);
assert!(info.jd - JD_J2000 < 30.0);
assert!(
(info.elongation - 270.0).abs() < 1.0,
"elongation = {}",
info.elongation
);
}
#[test]
fn next_four_phases_chronological() {
let phases = next_four_phases(JD_J2000).unwrap();
assert_eq!(phases.len(), 4);
for i in 0..3 {
assert!(
phases[i].jd < phases[i + 1].jd,
"phase {} (jd={}) not before phase {} (jd={})",
phases[i].phase,
phases[i].jd,
phases[i + 1].phase,
phases[i + 1].jd
);
}
let span = phases[3].jd - phases[0].jd;
assert!(span < 30.0, "span = {span}");
}
#[test]
fn next_four_phases_all_distinct() {
let phases = next_four_phases(JD_J2000).unwrap();
assert!(phases.iter().any(|p| p.phase == LunarPhase::NewMoon));
assert!(phases.iter().any(|p| p.phase == LunarPhase::FirstQuarter));
assert!(phases.iter().any(|p| p.phase == LunarPhase::FullMoon));
assert!(phases.iter().any(|p| p.phase == LunarPhase::LastQuarter));
}
#[test]
fn lunation_number_at_j2000() {
let n = lunation_number(JD_J2000);
assert!((n - 951).abs() <= 1, "lunation number = {n}");
}
#[test]
fn lunation_number_at_epoch() {
let n = lunation_number(BROWN_LUNATION_EPOCH);
assert!(n == 0, "lunation number at epoch = {n}");
}
#[test]
fn lunation_number_before_epoch() {
let n = lunation_number(BROWN_LUNATION_EPOCH - SYNODIC_MONTH + 0.5);
assert_eq!(n, -1, "lunation number = {n}");
}
#[test]
fn phase_display() {
assert_eq!(LunarPhase::NewMoon.to_string(), "New Moon");
assert_eq!(LunarPhase::FirstQuarter.to_string(), "First Quarter");
assert_eq!(LunarPhase::FullMoon.to_string(), "Full Moon");
assert_eq!(LunarPhase::LastQuarter.to_string(), "Last Quarter");
}
#[test]
fn phase_serde() {
let phase = LunarPhase::FullMoon;
let json = serde_json::to_string(&phase).unwrap();
let restored: LunarPhase = serde_json::from_str(&json).unwrap();
assert_eq!(restored, phase);
}
#[test]
fn phase_target_elongations() {
assert_eq!(LunarPhase::NewMoon.target_elongation(), 0.0);
assert_eq!(LunarPhase::FirstQuarter.target_elongation(), 90.0);
assert_eq!(LunarPhase::FullMoon.target_elongation(), 180.0);
assert_eq!(LunarPhase::LastQuarter.target_elongation(), 270.0);
}
#[test]
fn consecutive_new_moons_synodic_period() {
let first = next_phase(LunarPhase::NewMoon, JD_J2000).unwrap();
let second = next_phase(LunarPhase::NewMoon, first.jd + 1.0).unwrap();
let gap = second.jd - first.jd;
assert!(
(gap - SYNODIC_MONTH).abs() < 1.0,
"gap between new moons = {gap} days"
);
}
}