use tracing::instrument;
use crate::error::{FalakError, Result};
pub const J2000_JD: f64 = 2_451_545.0;
pub const UNIX_EPOCH_JD: f64 = 2_440_587.5;
pub const MJD_OFFSET: f64 = 2_400_000.5;
pub const SECONDS_PER_DAY: f64 = 86_400.0;
pub const SECONDS_PER_SIDEREAL_DAY: f64 = 86_164.090_5;
pub const EARTH_ROTATION_RATE: f64 = std::f64::consts::TAU / SECONDS_PER_SIDEREAL_DAY;
pub const DAYS_PER_JULIAN_CENTURY: f64 = 36_525.0;
#[must_use = "returns the computed Julian Date"]
#[instrument(level = "trace")]
pub fn calendar_to_jd(year: i32, month: u32, day: f64) -> Result<f64> {
if !(1..=12).contains(&month) {
return Err(FalakError::InvalidParameter(
format!("month must be 1–12, got {month}").into(),
));
}
let (y, m) = if month <= 2 {
(year as f64 - 1.0, month as f64 + 12.0)
} else {
(year as f64, month as f64)
};
let a = (y / 100.0).floor();
let b = 2.0 - a + (a / 4.0).floor();
Ok((365.25 * (y + 4716.0)).floor() + (30.6001 * (m + 1.0)).floor() + day + b - 1524.5)
}
#[must_use]
pub fn jd_to_calendar(jd: f64) -> (i32, u32, f64) {
let jd = jd + 0.5;
let z = jd.floor();
let f = jd - z;
let a = if z < 2_299_161.0 {
z
} else {
let alpha = ((z - 1_867_216.25) / 36_524.25).floor();
z + 1.0 + alpha - (alpha / 4.0).floor()
};
let b = a + 1524.0;
let c = ((b - 122.1) / 365.25).floor();
let d = (365.25 * c).floor();
let e = ((b - d) / 30.6001).floor();
let day = b - d - (30.6001 * e).floor() + f;
let month = if e < 14.0 { e - 1.0 } else { e - 13.0 };
let year = if month > 2.0 { c - 4716.0 } else { c - 4715.0 };
(year as i32, month as u32, day)
}
#[must_use]
#[inline]
pub fn jd_to_mjd(jd: f64) -> f64 {
jd - MJD_OFFSET
}
#[must_use]
#[inline]
pub fn mjd_to_jd(mjd: f64) -> f64 {
mjd + MJD_OFFSET
}
#[must_use]
#[inline]
pub fn unix_to_jd(unix_seconds: f64) -> f64 {
UNIX_EPOCH_JD + unix_seconds / SECONDS_PER_DAY
}
#[must_use]
#[inline]
pub fn jd_to_unix(jd: f64) -> f64 {
(jd - UNIX_EPOCH_JD) * SECONDS_PER_DAY
}
#[must_use]
#[inline]
pub fn julian_centuries_since_j2000(jd: f64) -> f64 {
(jd - J2000_JD) / DAYS_PER_JULIAN_CENTURY
}
#[must_use]
#[inline]
pub fn gmst(jd_ut1: f64) -> f64 {
let jd_0h = (jd_ut1 + 0.5).floor() - 0.5;
let frac_day = jd_ut1 - jd_0h;
let t0 = (jd_0h - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
let gmst_0h_sec =
24_110.548_41 + 8_640_184.812_866 * t0 + 0.093_104 * t0 * t0 - 6.2e-6 * t0 * t0 * t0;
let gmst_total_sec = gmst_0h_sec + frac_day * SECONDS_PER_DAY * 1.002_737_909_350_795;
let gmst_rad = (gmst_total_sec / SECONDS_PER_DAY) * std::f64::consts::TAU;
gmst_rad.rem_euclid(std::f64::consts::TAU)
}
#[must_use = "returns the computed day of year"]
#[instrument(level = "trace")]
pub fn day_of_year(year: i32, month: u32, day: u32) -> Result<u32> {
if !(1..=12).contains(&month) {
return Err(FalakError::InvalidParameter(
format!("month must be 1–12, got {month}").into(),
));
}
let is_leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
let days_in_month: [u32; 12] = if is_leap {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let max_day = days_in_month[month as usize - 1];
if day == 0 || day > max_day {
return Err(FalakError::InvalidParameter(
format!("day must be 1–{max_day} for month {month}, got {day}").into(),
));
}
let mut doy = day;
for &dim in &days_in_month[..(month as usize - 1)] {
doy += dim;
}
Ok(doy)
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct PlanetaryPosition {
pub longitude: f64,
pub latitude: f64,
pub distance: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum Planet {
Mercury,
Venus,
Earth,
Mars,
Jupiter,
Saturn,
}
#[must_use]
pub fn planetary_position(planet: Planet, jd: f64) -> PlanetaryPosition {
let t = julian_centuries_since_j2000(jd);
let (l0, l_rate, wp0, wp_rate, a, e0, e_rate) = match planet {
Planet::Mercury => (
252.251,
149_472.675,
77.456,
0.160,
0.387_098,
0.205_630,
0.000_02,
),
Planet::Venus => (
181.980, 58_517.816, 131.564, 0.009, 0.723_332, 0.006_773, -0.000_05,
),
Planet::Earth => (
100.464, 35_999.373, 102.937, 0.032, 1.000_000, 0.016_709, -0.000_04,
),
Planet::Mars => (
355.433, 19_140.299, 336.060, 0.443, 1.523_688, 0.093_405, 0.000_09,
),
Planet::Jupiter => (
34.351, 3_034.906, 14.331, 0.216, 5.202_561, 0.048_498, 0.000_16,
),
Planet::Saturn => (
50.077, 1_222.114, 93.057, 0.891, 9.554_909, 0.055_509, -0.000_35,
),
};
let mean_lon_deg = l0 + l_rate * t;
let lon_peri_deg = wp0 + wp_rate * t;
let ecc = e0 + e_rate * t;
let m = (mean_lon_deg - lon_peri_deg)
.to_radians()
.rem_euclid(std::f64::consts::TAU);
let mut ea = m + ecc * m.sin();
for _ in 0..10 {
let f = ea - ecc * ea.sin() - m;
let fp = 1.0 - ecc * ea.cos();
if fp.abs() < 1e-30 || f.abs() < 1e-12 {
break;
}
ea -= f / fp;
}
let factor = ((1.0 + ecc) / (1.0 - ecc)).sqrt();
let nu = 2.0 * (factor * (ea / 2.0).tan()).atan();
let distance = a * (1.0 - ecc * ea.cos());
let longitude = (lon_peri_deg.to_radians() + nu).rem_euclid(std::f64::consts::TAU);
PlanetaryPosition {
longitude,
latitude: 0.0, distance,
}
}
#[must_use]
#[inline]
pub fn ecliptic_to_cartesian(pos: &PlanetaryPosition) -> [f64; 3] {
let r = pos.distance;
[
r * pos.longitude.cos() * pos.latitude.cos(),
r * pos.longitude.sin() * pos.latitude.cos(),
r * pos.latitude.sin(),
]
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct LunarPosition {
pub longitude: f64,
pub latitude: f64,
pub distance_km: f64,
}
#[must_use]
pub fn lunar_position(jd: f64) -> LunarPosition {
let t = julian_centuries_since_j2000(jd);
let l_prime = 218.316_447_7 + 481_267.881_343_6 * t;
let m_moon = 134.963_396_4 + 477_198.867_505_5 * t;
let m_sun = 357.529_109_2 + 35_999.050_290_9 * t;
let d = 297.850_192_1 + 445_267.111_403_4 * t;
let f = 93.272_095_0 + 483_202.017_523_3 * t;
let m_moon_r = m_moon.to_radians();
let m_sun_r = m_sun.to_radians();
let d_r = d.to_radians();
let f_r = f.to_radians();
let mut lon_pert = 6.289 * m_moon_r.sin();
lon_pert += 1.274 * (2.0 * d_r - m_moon_r).sin();
lon_pert += 0.658 * (2.0 * d_r).sin();
lon_pert += 0.214 * (2.0 * m_moon_r).sin();
lon_pert -= 0.186 * m_sun_r.sin();
lon_pert -= 0.114 * (2.0 * f_r).sin();
let longitude = (l_prime + lon_pert)
.to_radians()
.rem_euclid(std::f64::consts::TAU);
let mut lat_pert = 5.128 * f_r.sin();
lat_pert += 0.281 * (m_moon_r + f_r).sin();
lat_pert += 0.278 * (m_moon_r - f_r).sin();
let latitude = lat_pert.to_radians();
let mut dist = 385_000.56;
dist -= 20_905.36 * m_moon_r.cos();
dist -= 3_699.11 * (2.0 * d_r - m_moon_r).cos();
dist -= 2_955.97 * (2.0 * d_r).cos();
LunarPosition {
longitude,
latitude,
distance_km: dist,
}
}
#[must_use]
#[inline]
pub fn lunar_to_cartesian_metres(pos: &LunarPosition) -> [f64; 3] {
let r = pos.distance_km * 1000.0;
[
r * pos.longitude.cos() * pos.latitude.cos(),
r * pos.longitude.sin() * pos.latitude.cos(),
r * pos.latitude.sin(),
]
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct RiseTransitSet {
pub rise: Option<f64>,
pub transit: Option<f64>,
pub set: Option<f64>,
}
#[must_use = "returns rise/transit/set times"]
#[instrument(level = "trace")]
pub fn rise_transit_set(
jd_0h: f64,
ra: f64,
dec: f64,
observer_lat: f64,
observer_lon: f64,
horizon_elev: f64,
) -> Result<RiseTransitSet> {
let sin_h0 = horizon_elev.sin();
let cos_lat = observer_lat.cos();
let sin_lat = observer_lat.sin();
let cos_dec = dec.cos();
let sin_dec = dec.sin();
let denom = cos_lat * cos_dec;
if denom.abs() < 1e-15 {
return if sin_lat * sin_dec > 0.0 {
Ok(RiseTransitSet {
rise: None,
transit: Some(transit_time(jd_0h, ra, observer_lon)),
set: None,
})
} else {
Ok(RiseTransitSet {
rise: None,
transit: None,
set: None,
})
};
}
let cos_h0 = (sin_h0 - sin_lat * sin_dec) / denom;
if cos_h0 < -1.0 {
return Ok(RiseTransitSet {
rise: None,
transit: Some(transit_time(jd_0h, ra, observer_lon)),
set: None,
});
}
if cos_h0 > 1.0 {
return Ok(RiseTransitSet {
rise: None,
transit: None,
set: None,
});
}
let h0 = cos_h0.acos();
let gmst_0h = gmst(jd_0h);
let m0 = (ra - observer_lon - gmst_0h) / std::f64::consts::TAU;
let m0 = m0.rem_euclid(1.0);
let m1 = (m0 - h0 / std::f64::consts::TAU).rem_euclid(1.0);
let m2 = (m0 + h0 / std::f64::consts::TAU).rem_euclid(1.0);
Ok(RiseTransitSet {
rise: Some(jd_0h + m1),
transit: Some(jd_0h + m0),
set: Some(jd_0h + m2),
})
}
fn transit_time(jd_0h: f64, ra: f64, observer_lon: f64) -> f64 {
let gmst_0h = gmst(jd_0h);
let m0 = (ra - observer_lon - gmst_0h) / std::f64::consts::TAU;
jd_0h + m0.rem_euclid(1.0)
}
pub const STANDARD_REFRACTION: f64 = -0.009_890_199_5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum EclipseState {
Sunlit,
Penumbra,
Umbra,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct EclipseInfo {
pub state: EclipseState,
pub shadow_fraction: f64,
}
#[must_use]
#[inline]
pub fn eclipse_cylindrical(sat_pos: [f64; 3], sun_pos: [f64; 3], body_radius: f64) -> EclipseInfo {
let sun_mag =
(sun_pos[0] * sun_pos[0] + sun_pos[1] * sun_pos[1] + sun_pos[2] * sun_pos[2]).sqrt();
if sun_mag < 1e-10 {
return EclipseInfo {
state: EclipseState::Sunlit,
shadow_fraction: 0.0,
};
}
let sun_hat = [
sun_pos[0] / sun_mag,
sun_pos[1] / sun_mag,
sun_pos[2] / sun_mag,
];
let sat_dot_sun = sat_pos[0] * sun_hat[0] + sat_pos[1] * sun_hat[1] + sat_pos[2] * sun_hat[2];
if sat_dot_sun > 0.0 {
return EclipseInfo {
state: EclipseState::Sunlit,
shadow_fraction: 0.0,
};
}
let perp = [
sat_pos[0] - sat_dot_sun * sun_hat[0],
sat_pos[1] - sat_dot_sun * sun_hat[1],
sat_pos[2] - sat_dot_sun * sun_hat[2],
];
let perp_dist = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
if perp_dist < body_radius {
EclipseInfo {
state: EclipseState::Umbra,
shadow_fraction: 1.0,
}
} else {
EclipseInfo {
state: EclipseState::Sunlit,
shadow_fraction: 0.0,
}
}
}
#[must_use]
#[inline]
pub fn eclipse_conical(
sat_pos: [f64; 3],
sun_pos: [f64; 3],
body_radius: f64,
sun_radius: f64,
) -> EclipseInfo {
let sat_mag =
(sat_pos[0] * sat_pos[0] + sat_pos[1] * sat_pos[1] + sat_pos[2] * sat_pos[2]).sqrt();
let sun_mag =
(sun_pos[0] * sun_pos[0] + sun_pos[1] * sun_pos[1] + sun_pos[2] * sun_pos[2]).sqrt();
if sat_mag < 1e-10 || sun_mag < 1e-10 {
return EclipseInfo {
state: EclipseState::Sunlit,
shadow_fraction: 0.0,
};
}
let theta_body = (body_radius / sat_mag).asin(); let theta_sun = (sun_radius / sun_mag).asin();
let to_sun = [
sun_pos[0] - sat_pos[0],
sun_pos[1] - sat_pos[1],
sun_pos[2] - sat_pos[2],
];
let to_sun_mag = (to_sun[0] * to_sun[0] + to_sun[1] * to_sun[1] + to_sun[2] * to_sun[2]).sqrt();
if to_sun_mag < 1e-10 {
return EclipseInfo {
state: EclipseState::Sunlit,
shadow_fraction: 0.0,
};
}
let cos_sep = -(sat_pos[0] * to_sun[0] + sat_pos[1] * to_sun[1] + sat_pos[2] * to_sun[2])
/ (sat_mag * to_sun_mag);
let sep = cos_sep.clamp(-1.0, 1.0).acos();
if sep > theta_body + theta_sun {
EclipseInfo {
state: EclipseState::Sunlit,
shadow_fraction: 0.0,
}
} else if sep < theta_body - theta_sun && theta_body > theta_sun {
EclipseInfo {
state: EclipseState::Umbra,
shadow_fraction: 1.0,
}
} else if sep < theta_sun - theta_body && theta_sun > theta_body {
let frac = (theta_body / theta_sun).powi(2);
EclipseInfo {
state: EclipseState::Penumbra,
shadow_fraction: frac,
}
} else {
let frac = overlap_fraction(sep, theta_body, theta_sun);
EclipseInfo {
state: EclipseState::Penumbra,
shadow_fraction: frac,
}
}
}
fn overlap_fraction(sep: f64, r1: f64, r2: f64) -> f64 {
if sep >= r1 + r2 {
return 0.0;
}
if sep <= (r1 - r2).abs() {
return r1.min(r2).powi(2) / r2.powi(2);
}
let d = sep;
let cos_a1 = (d * d + r1 * r1 - r2 * r2) / (2.0 * d * r1);
let cos_a2 = (d * d + r2 * r2 - r1 * r1) / (2.0 * d * r2);
let a1 = cos_a1.clamp(-1.0, 1.0).acos();
let a2 = cos_a2.clamp(-1.0, 1.0).acos();
let overlap = r1 * r1 * (a1 - a1.sin() * a1.cos()) + r2 * r2 * (a2 - a2.sin() * a2.cos());
let sun_area = std::f64::consts::PI * r2 * r2;
if sun_area > 0.0 {
(overlap / sun_area).clamp(0.0, 1.0)
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn j2000_epoch() {
let jd = calendar_to_jd(2000, 1, 1.5).unwrap();
assert!(
(jd - J2000_JD).abs() < 1e-6,
"J2000: {} vs {}",
jd,
J2000_JD
);
}
#[test]
fn known_date_sputnik() {
let jd = calendar_to_jd(1957, 10, 4.0).unwrap();
assert!((jd - 2_436_115.5).abs() < 0.5, "Sputnik JD: {}", jd);
}
#[test]
fn calendar_roundtrip() {
let jd = calendar_to_jd(2024, 7, 15.75).unwrap();
let (y, m, d) = jd_to_calendar(jd);
assert_eq!(y, 2024);
assert_eq!(m, 7);
assert!((d - 15.75).abs() < 1e-10, "day: {d}");
}
#[test]
fn calendar_roundtrip_leap() {
let jd = calendar_to_jd(2024, 2, 29.0).unwrap();
let (y, m, d) = jd_to_calendar(jd);
assert_eq!(y, 2024);
assert_eq!(m, 2);
assert!((d - 29.0).abs() < 1e-10);
}
#[test]
fn calendar_invalid_month() {
assert!(calendar_to_jd(2024, 0, 1.0).is_err());
assert!(calendar_to_jd(2024, 13, 1.0).is_err());
}
#[test]
fn mjd_roundtrip() {
let jd = 2_460_000.5;
let mjd = jd_to_mjd(jd);
let jd2 = mjd_to_jd(mjd);
assert!((jd - jd2).abs() < 1e-15);
}
#[test]
fn unix_epoch() {
let jd = unix_to_jd(0.0);
assert!((jd - UNIX_EPOCH_JD).abs() < 1e-10, "unix epoch JD: {}", jd);
}
#[test]
fn unix_roundtrip() {
let ts = 1_700_000_000.0; let jd = unix_to_jd(ts);
let ts2 = jd_to_unix(jd);
assert!((ts - ts2).abs() < 0.01, "unix roundtrip: {ts} vs {ts2}");
}
#[test]
fn gmst_range() {
for day_offset in 0..365 {
let jd = J2000_JD + day_offset as f64;
let g = gmst(jd);
assert!(
(0.0..std::f64::consts::TAU).contains(&g),
"GMST out of range at JD {jd}: {g}"
);
}
}
#[test]
fn gmst_monotonic_over_day() {
let jd_base = J2000_JD + 100.0; let g1 = gmst(jd_base);
let g2 = gmst(jd_base + 0.25); let diff = (g2 - g1).rem_euclid(std::f64::consts::TAU);
assert!(
(diff - std::f64::consts::FRAC_PI_2).abs() < 0.05,
"6h should ≈ π/2 rad: diff={diff}"
);
}
#[test]
fn gmst_j2000() {
let g = gmst(J2000_JD);
let g_deg = g.to_degrees();
assert!((g_deg - 280.46).abs() < 0.1, "GMST at J2000: {g_deg}°");
}
#[test]
fn doy_jan1() {
assert_eq!(day_of_year(2024, 1, 1).unwrap(), 1);
}
#[test]
fn doy_leap_dec31() {
assert_eq!(day_of_year(2024, 12, 31).unwrap(), 366);
}
#[test]
fn doy_non_leap_dec31() {
assert_eq!(day_of_year(2023, 12, 31).unwrap(), 365);
}
#[test]
fn doy_march1_leap() {
assert_eq!(day_of_year(2024, 3, 1).unwrap(), 61);
}
#[test]
fn doy_invalid() {
assert!(day_of_year(2024, 0, 1).is_err());
assert!(day_of_year(2024, 1, 0).is_err());
}
#[test]
fn doy_feb30_rejected() {
assert!(day_of_year(2024, 2, 30).is_err());
assert!(day_of_year(2024, 2, 29).is_ok());
assert!(day_of_year(2023, 2, 29).is_err());
}
#[test]
fn doy_apr31_rejected() {
assert!(day_of_year(2024, 4, 31).is_err());
assert!(day_of_year(2024, 4, 30).is_ok());
}
#[test]
fn centuries_at_j2000() {
assert!((julian_centuries_since_j2000(J2000_JD)).abs() < 1e-15);
}
#[test]
fn centuries_one_century() {
let jd = J2000_JD + DAYS_PER_JULIAN_CENTURY;
assert!((julian_centuries_since_j2000(jd) - 1.0).abs() < 1e-12);
}
#[test]
fn earth_distance_1au() {
let pos = planetary_position(Planet::Earth, J2000_JD);
assert!(
(pos.distance - 1.0).abs() < 0.02,
"Earth distance: {} AU",
pos.distance
);
}
#[test]
fn mercury_closer_than_earth() {
let m = planetary_position(Planet::Mercury, J2000_JD);
let e = planetary_position(Planet::Earth, J2000_JD);
assert!(m.distance < e.distance, "Mercury should be closer to Sun");
}
#[test]
fn jupiter_further_than_mars() {
let j = planetary_position(Planet::Jupiter, J2000_JD);
let m = planetary_position(Planet::Mars, J2000_JD);
assert!(
j.distance > m.distance,
"Jupiter should be further than Mars"
);
}
#[test]
fn planet_longitude_range() {
for planet in [
Planet::Mercury,
Planet::Venus,
Planet::Earth,
Planet::Mars,
Planet::Jupiter,
Planet::Saturn,
] {
let pos = planetary_position(planet, J2000_JD + 500.0);
assert!(
(0.0..std::f64::consts::TAU).contains(&pos.longitude),
"{planet:?} longitude out of range: {}",
pos.longitude
);
}
}
#[test]
fn ecliptic_to_cartesian_roundtrip() {
let pos = planetary_position(Planet::Earth, J2000_JD);
let cart = ecliptic_to_cartesian(&pos);
let r = (cart[0] * cart[0] + cart[1] * cart[1] + cart[2] * cart[2]).sqrt();
assert!(
(r - pos.distance).abs() < 1e-10,
"cartesian distance: {r} vs {}",
pos.distance
);
}
#[test]
fn lunar_distance_range() {
let pos = lunar_position(J2000_JD);
assert!(
pos.distance_km > 350_000.0 && pos.distance_km < 410_000.0,
"lunar distance: {} km",
pos.distance_km
);
}
#[test]
fn lunar_latitude_range() {
let pos = lunar_position(J2000_JD);
assert!(
pos.latitude.abs() < 6.0_f64.to_radians(),
"lunar latitude: {}°",
pos.latitude.to_degrees()
);
}
#[test]
fn lunar_cartesian_distance() {
let pos = lunar_position(J2000_JD);
let cart = lunar_to_cartesian_metres(&pos);
let r = (cart[0] * cart[0] + cart[1] * cart[1] + cart[2] * cart[2]).sqrt();
assert!(
(r - pos.distance_km * 1000.0).abs() < 1.0,
"cartesian: {r} vs {}",
pos.distance_km * 1000.0
);
}
#[test]
fn lunar_position_varies() {
let p1 = lunar_position(J2000_JD);
let p2 = lunar_position(J2000_JD + 15.0); let diff = (p2.longitude - p1.longitude).rem_euclid(std::f64::consts::TAU);
assert!(
diff > 2.0,
"lunar longitude should change over 15 days: {diff} rad"
);
}
const R_EARTH: f64 = 6_378_137.0;
const R_SUN: f64 = 6.957e8;
const AU: f64 = 1.495_978_707e11;
#[test]
fn eclipse_cylindrical_sunlit() {
let sat = [R_EARTH + 400e3, 0.0, 0.0];
let sun = [AU, 0.0, 0.0]; let info = eclipse_cylindrical(sat, sun, R_EARTH);
assert_eq!(info.state, EclipseState::Sunlit);
assert!((info.shadow_fraction - 0.0).abs() < 1e-10);
}
#[test]
fn eclipse_cylindrical_umbra() {
let sat = [-(R_EARTH + 400e3), 0.0, 0.0];
let sun = [AU, 0.0, 0.0];
let info = eclipse_cylindrical(sat, sun, R_EARTH);
assert_eq!(info.state, EclipseState::Umbra);
assert!((info.shadow_fraction - 1.0).abs() < 1e-10);
}
#[test]
fn eclipse_cylindrical_edge() {
let sat = [-1e7, R_EARTH * 2.0, 0.0]; let sun = [AU, 0.0, 0.0];
let info = eclipse_cylindrical(sat, sun, R_EARTH);
assert_eq!(info.state, EclipseState::Sunlit);
}
#[test]
fn eclipse_conical_sunlit() {
let sat = [R_EARTH + 400e3, 0.0, 0.0];
let sun = [AU, 0.0, 0.0];
let info = eclipse_conical(sat, sun, R_EARTH, R_SUN);
assert_eq!(info.state, EclipseState::Sunlit);
}
#[test]
fn eclipse_conical_umbra() {
let sat = [-(R_EARTH + 400e3), 0.0, 0.0];
let sun = [AU, 0.0, 0.0];
let info = eclipse_conical(sat, sun, R_EARTH, R_SUN);
assert_eq!(info.state, EclipseState::Umbra);
assert!((info.shadow_fraction - 1.0).abs() < 1e-10);
}
#[test]
fn eclipse_conical_geo_shadow() {
let r_geo = 42_164e3;
let sat = [-r_geo, 0.0, 0.0];
let sun = [AU, 0.0, 0.0];
let info = eclipse_conical(sat, sun, R_EARTH, R_SUN);
assert!(
info.state == EclipseState::Umbra || info.state == EclipseState::Penumbra,
"GEO satellite behind Earth should be eclipsed: {:?}",
info.state
);
assert_eq!(info.state, EclipseState::Umbra);
}
#[test]
fn eclipse_cylindrical_vs_conical_agreement() {
let sun = [AU, 0.0, 0.0];
let sat_sunlit = [R_EARTH + 400e3, 0.0, 0.0];
let cyl = eclipse_cylindrical(sat_sunlit, sun, R_EARTH);
let con = eclipse_conical(sat_sunlit, sun, R_EARTH, R_SUN);
assert_eq!(cyl.state, EclipseState::Sunlit);
assert_eq!(con.state, EclipseState::Sunlit);
let sat_shadow = [-(R_EARTH + 400e3), 0.0, 0.0];
let cyl = eclipse_cylindrical(sat_shadow, sun, R_EARTH);
let con = eclipse_conical(sat_shadow, sun, R_EARTH, R_SUN);
assert_eq!(cyl.state, EclipseState::Umbra);
assert_eq!(con.state, EclipseState::Umbra);
}
#[test]
fn eclipse_penumbra_region() {
let sun = [AU, 0.0, 0.0];
let offset = R_EARTH * 1.001; let sat = [-(R_EARTH + 400e3), offset, 0.0];
let cyl = eclipse_cylindrical(sat, sun, R_EARTH);
assert_eq!(cyl.state, EclipseState::Sunlit);
let con = eclipse_conical(sat, sun, R_EARTH, R_SUN);
assert!(
con.state == EclipseState::Sunlit || con.state == EclipseState::Penumbra,
"edge case should be sunlit or penumbra: {:?}",
con.state
);
}
#[test]
fn rise_transit_set_sun_at_equinox() {
let jd_0h = calendar_to_jd(2024, 3, 20.0).unwrap();
let lat = 51.5_f64.to_radians();
let lon = 0.0;
let ra = 0.0;
let dec = 0.0;
let rts = rise_transit_set(jd_0h, ra, dec, lat, lon, STANDARD_REFRACTION).unwrap();
assert!(rts.rise.is_some(), "Sun should rise at 51.5°N");
assert!(rts.transit.is_some(), "Sun should transit");
assert!(rts.set.is_some(), "Sun should set at 51.5°N");
let transit_frac = rts.transit.unwrap() - jd_0h;
assert!(
(transit_frac - 0.5).abs() < 0.1,
"transit at {transit_frac:.3} days, expected ~0.5"
);
assert!(rts.rise.unwrap() < rts.transit.unwrap());
assert!(rts.set.unwrap() > rts.transit.unwrap());
}
#[test]
fn rise_transit_set_circumpolar() {
let jd_0h = calendar_to_jd(2024, 6, 21.0).unwrap();
let lat = 70.0_f64.to_radians();
let dec = 80.0_f64.to_radians();
let ra = 1.0;
let rts = rise_transit_set(jd_0h, ra, dec, lat, 0.0, 0.0).unwrap();
assert!(rts.rise.is_none(), "circumpolar body should not rise");
assert!(rts.set.is_none(), "circumpolar body should not set");
assert!(rts.transit.is_some(), "circumpolar body should transit");
}
#[test]
fn rise_transit_set_never_rises() {
let jd_0h = calendar_to_jd(2024, 6, 21.0).unwrap();
let lat = 70.0_f64.to_radians();
let dec = -80.0_f64.to_radians();
let ra = 1.0;
let rts = rise_transit_set(jd_0h, ra, dec, lat, 0.0, 0.0).unwrap();
assert!(rts.rise.is_none());
assert!(rts.set.is_none());
assert!(rts.transit.is_none());
}
#[test]
fn rise_transit_set_equatorial_observer() {
let jd_0h = calendar_to_jd(2024, 1, 15.0).unwrap();
let lat = 0.0;
let dec = 30.0_f64.to_radians();
let rts = rise_transit_set(jd_0h, 3.0, dec, lat, 0.0, 0.0).unwrap();
assert!(rts.rise.is_some());
assert!(rts.set.is_some());
assert!(rts.transit.is_some());
let mut day_frac = rts.set.unwrap() - rts.rise.unwrap();
if day_frac < 0.0 {
day_frac += 1.0; }
assert!(
day_frac > 0.3 && day_frac < 0.7,
"day fraction={day_frac:.3}, expected ~0.5"
);
}
#[test]
fn standard_refraction_value() {
let deg = STANDARD_REFRACTION.to_degrees();
assert!(
(deg + 0.5667).abs() < 0.001,
"standard refraction = {deg}°, expected -0.5667°"
);
}
}