pub const NEPTUNE_RADIUS_EARTH: f64 = 3.865;
pub const NEPTUNE_MASS_EARTH: f64 = 17.147;
pub const ALBEDO_NEPTUNE: f64 = 0.41;
pub const DEFAULT_SLOPE_G: f64 = 0.15;
pub fn mass_radius_neptunian(mass_earth: f64) -> f64 {
NEPTUNE_RADIUS_EARTH * (mass_earth / NEPTUNE_MASS_EARTH).powf(0.27)
}
pub fn absolute_magnitude(radius_km: f64, albedo: f64) -> f64 {
5.0 * (1329.0 / (albedo.sqrt() * 2.0 * radius_km)).log10()
}
pub fn apparent_magnitude(h: f64, r_au: f64, delta_au: f64) -> f64 {
h + 5.0 * (r_au * delta_au).log10()
}
pub fn hg_phase_factor(g: f64, phase_rad: f64) -> f64 {
let tan_half = (phase_rad / 2.0).tan();
let phi1 = (-3.332 * tan_half.powf(0.631)).exp();
let phi2 = (-1.862 * tan_half.powf(1.218)).exp();
(1.0 - g) * phi1 + g * phi2
}
pub fn phase_integral(g: f64) -> f64 {
0.290 + 0.684 * g
}
pub fn apparent_magnitude_with_phase(
h: f64,
r_au: f64,
delta_au: f64,
phase_rad: f64,
g: f64,
) -> f64 {
h + 5.0 * (r_au * delta_au).log10() - 2.5 * hg_phase_factor(g, phase_rad).log10()
}
pub fn phase_angle(r_helio: f64, delta: f64, r_earth_sun: f64) -> f64 {
let cos_alpha =
(r_helio * r_helio + delta * delta - r_earth_sun * r_earth_sun) / (2.0 * r_helio * delta);
cos_alpha.clamp(-1.0, 1.0).acos()
}
pub fn opposition_delta(r_au: f64) -> f64 {
r_au - 1.0
}
pub fn planet_apparent_magnitude(mass_earth: f64, albedo: f64, r_au: f64) -> f64 {
let radius_km = mass_radius_neptunian(mass_earth) * crate::constants::EARTH_RADIUS / 1000.0;
let h = absolute_magnitude(radius_km, albedo);
apparent_magnitude(h, r_au, opposition_delta(r_au))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_neptune_absolute_magnitude() {
let h = absolute_magnitude(24_622.0, ALBEDO_NEPTUNE);
assert!((h - (-6.87)).abs() < 0.15, "H_neptune = {h:.2}");
}
#[test]
fn test_neptune_apparent_magnitude() {
let h = absolute_magnitude(24_622.0, ALBEDO_NEPTUNE);
let m = apparent_magnitude(h, 30.1, opposition_delta(30.1));
assert!((m - 7.8).abs() < 0.3, "V_neptune = {m:.2}");
}
#[test]
fn test_mass_radius_anchored_at_neptune() {
let r = mass_radius_neptunian(NEPTUNE_MASS_EARTH);
assert!((r - NEPTUNE_RADIUS_EARTH).abs() < 1e-12);
let r10 = mass_radius_neptunian(10.0);
assert!(r10 > 3.0 && r10 < 3.6, "R(10 M⊕) = {r10:.2} R⊕");
assert!(r10 < NEPTUNE_RADIUS_EARTH);
}
#[test]
fn test_hg_phase_factor_is_one_at_opposition() {
for &g in &[0.0, 0.15, 0.25, 1.0] {
assert_eq!(hg_phase_factor(g, 0.0), 1.0, "G = {g}");
}
let (h, r, d) = (-6.87, 30.1, 29.1);
assert_eq!(
apparent_magnitude_with_phase(h, r, d, 0.0, DEFAULT_SLOPE_G),
apparent_magnitude(h, r, d)
);
}
#[test]
fn test_hg_phase_factor_monotone_decreasing() {
for &g in &[0.0, 0.15, 0.4] {
let mut prev = 1.0;
for k in 1..=120 {
let alpha = (k as f64).to_radians();
let f = hg_phase_factor(g, alpha);
assert!(f < prev, "Φ not decreasing at α = {k}° (G = {g})");
assert!(f > 0.0);
prev = f;
}
}
}
#[test]
fn test_hg_phase_factor_pinned_values() {
let f10 = hg_phase_factor(0.15, 10.0_f64.to_radians());
assert!((f10 - 0.551585).abs() < 1e-5, "Φ(10°) = {f10:.6}");
let f30 = hg_phase_factor(0.15, 30.0_f64.to_radians());
assert!((f30 - 0.302247).abs() < 1e-5, "Φ(30°) = {f30:.6}");
let v = apparent_magnitude_with_phase(3.35, 2.77, 1.77, 0.0, 0.15);
assert!((v - 6.802).abs() < 0.001, "V_ceres = {v:.3}");
}
#[test]
fn test_phase_integral_standard_values() {
assert!((phase_integral(0.15) - (0.290 + 0.684 * 0.15)).abs() < 1e-12);
assert!((phase_integral(0.0) - 0.290).abs() < 1e-12);
}
#[test]
fn test_phase_angle_geometry() {
assert!(phase_angle(500.0, 499.0, 1.0) < 1e-7);
assert!(phase_angle(500.0, 501.0, 1.0) < 1e-7);
let r = 400.0;
let delta = (r * r + 1.0_f64).sqrt();
let alpha = phase_angle(r, delta, 1.0);
assert!((alpha.cos() - r / delta).abs() < 1e-12);
let alpha_max = (1.0_f64 / r).asin();
assert!(alpha <= alpha_max + 1e-12, "α = {alpha} > {alpha_max}");
}
#[test]
fn test_phase_darkening_small_at_large_distances() {
for &(r, dm_expect) in &[(300.0, 0.0542), (500.0, 0.0393), (700.0, 0.0318)] {
let alpha_max = (1.0_f64 / r).asin();
let dm = -2.5 * hg_phase_factor(DEFAULT_SLOPE_G, alpha_max).log10();
assert!(
(dm - dm_expect).abs() < 5e-3,
"Δm(α_max) at {r} AU = {dm:.4}, expected ~{dm_expect}"
);
}
}
#[test]
fn test_hypothetical_planet_faint_at_aphelion() {
let m = planet_apparent_magnitude(6.2, 0.4, 500.0);
assert!(m > 20.0 && m < 28.0, "V_P9(500 AU) = {m:.1}");
let m2 = planet_apparent_magnitude(6.2, 0.4, 1000.0);
let dm = m2 - m;
assert!((dm - 3.01).abs() < 0.05, "Δm for 2x distance = {dm:.2}");
}
#[test]
fn test_neptune_cross_validation_against_mallama_hilton() {
use crate::jplephem::SpiceKernel;
use crate::jplephem_ext::SpiceKernelExt;
use crate::magnitudelib::planetary_magnitude;
use crate::time::Timescale;
let mut kernel = SpiceKernel::open("test_data/de421.bsp").unwrap();
let ts = Timescale::default();
let t = ts.utc((2020, 9, 11));
let earth = kernel.at("earth", &t).unwrap();
let neptune = earth
.observe("neptune barycenter", &mut kernel, &t)
.unwrap();
let v_mh = planetary_magnitude(&neptune, &t).unwrap();
assert!(v_mh.is_finite());
let obs_bary = neptune.observer_barycentric.as_ref().unwrap();
let sun_to_observer = obs_bary.position;
let sun_to_neptune = sun_to_observer + neptune.position;
let r = sun_to_neptune.norm();
let delta = neptune.position.norm();
let alpha = phase_angle(r, delta, sun_to_observer.norm());
assert!((29.0..31.0).contains(&r), "r = {r:.2} AU");
assert!((delta - (r - 1.0)).abs() < 0.05, "Δ = {delta:.2} AU");
assert!(alpha.to_degrees() < 1.9, "α = {:.3}°", alpha.to_degrees());
let radius_km =
mass_radius_neptunian(NEPTUNE_MASS_EARTH) * crate::constants::EARTH_RADIUS / 1000.0;
let h = absolute_magnitude(radius_km, ALBEDO_NEPTUNE);
let v_sb = apparent_magnitude_with_phase(h, r, delta, alpha, DEFAULT_SLOPE_G);
assert!((7.5..8.2).contains(&v_mh), "V_MH = {v_mh:.3}");
assert!((7.5..8.2).contains(&v_sb), "V_small_body = {v_sb:.3}");
let dv = v_sb - v_mh;
assert!(
dv.abs() < 0.35,
"small_body vs Mallama-Hilton Neptune V: {v_sb:.3} vs {v_mh:.3} (Δ = {dv:+.3})"
);
}
}