use nalgebra::Vector3;
use crate::jplephem::kernel::SpiceKernel;
use crate::jplephem_ext::SpiceKernelExt;
use crate::positions::Position;
use crate::time::Time;
use crate::{Result, StarfieldError};
impl Position {
pub fn phase_angle(&self, kernel: &mut SpiceKernel, t: &Time) -> Result<f64> {
let observer = self.require_observer()?.position;
let sun = kernel.at("sun", t)?.position;
let observer_to_target = self.position;
let sun_to_target = self.position + observer - sun;
Ok(angle_between(&observer_to_target, &sun_to_target))
}
pub fn illuminated_fraction(&self, kernel: &mut SpiceKernel, t: &Time) -> Result<f64> {
Ok(0.5 * (1.0 + self.phase_angle(kernel, t)?.cos()))
}
pub fn solar_elongation(&self, kernel: &mut SpiceKernel, t: &Time) -> Result<f64> {
let observer = self.require_observer()?.position;
let sun = kernel.at("sun", t)?.position;
let observer_to_sun = sun - observer;
Ok(angle_between(&observer_to_sun, &self.position))
}
pub(crate) fn require_observer(&self) -> Result<&Position> {
self.observer_barycentric
.as_deref()
.ok_or(StarfieldError::MissingObserver)
}
}
fn angle_between(u: &Vector3<f64>, v: &Vector3<f64>) -> f64 {
let a = u * v.norm();
let b = v * u.norm();
2.0 * (a - b).norm().atan2((a + b).norm())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::time::Timescale;
use approx::assert_relative_eq;
use std::f64::consts::PI;
fn de421_kernel() -> SpiceKernel {
SpiceKernel::open("test_data/de421.bsp").expect("Failed to open DE421")
}
fn hirise_epoch() -> Time {
Timescale::default().utc((2007, 10, 3, 0, 0, 0.0))
}
#[test]
fn test_angle_between_orthogonal() {
let u = Vector3::new(3.0, 0.0, 0.0);
let v = Vector3::new(0.0, 0.5, 0.0);
assert_relative_eq!(angle_between(&u, &v), PI / 2.0, epsilon = 1e-15);
}
#[test]
fn test_angle_between_parallel_and_antiparallel() {
let u = Vector3::new(1.0, 2.0, 3.0);
assert_relative_eq!(angle_between(&u, &(u * 2.0)), 0.0, epsilon = 1e-15);
assert_relative_eq!(angle_between(&u, &(-u)), PI, epsilon = 1e-15);
}
#[test]
fn test_earth_from_mars_at_hirise_epoch() {
let mut kernel = de421_kernel();
let t = hirise_epoch();
let mars = kernel.at("mars", &t).unwrap();
let earth = mars.observe("earth", &mut kernel, &t).unwrap();
let phase_deg = earth.phase_angle(&mut kernel, &t).unwrap().to_degrees();
assert!(
(phase_deg - 98.0).abs() < 0.5,
"Earth from Mars phase angle should be 98°, got {phase_deg}"
);
let fraction = earth.illuminated_fraction(&mut kernel, &t).unwrap();
assert!(
(fraction - 0.43).abs() < 0.01,
"Earth from Mars illuminated fraction should be 0.43, got {fraction}"
);
}
#[test]
fn test_illuminated_fraction_follows_phase_angle() {
let mut kernel = de421_kernel();
let t = hirise_epoch();
let mars = kernel.at("mars", &t).unwrap();
let earth = mars.observe("earth", &mut kernel, &t).unwrap();
let alpha = earth.phase_angle(&mut kernel, &t).unwrap();
let fraction = earth.illuminated_fraction(&mut kernel, &t).unwrap();
assert_relative_eq!(fraction, 0.5 * (1.0 + alpha.cos()), epsilon = 1e-15);
}
#[test]
fn test_sun_is_at_zero_elongation_from_itself() {
let mut kernel = de421_kernel();
let t = hirise_epoch();
let earth = kernel.at("earth", &t).unwrap();
let sun = earth.observe("sun", &mut kernel, &t).unwrap();
let elongation = sun.solar_elongation(&mut kernel, &t).unwrap();
assert!(
elongation < 1e-6,
"the Sun should be at zero elongation from itself, got {elongation} rad"
);
}
#[test]
fn test_phase_angle_and_elongation_close_the_triangle() {
let mut kernel = de421_kernel();
let t = hirise_epoch();
let earth = kernel.at("earth", &t).unwrap();
let mars = earth.observe("mars", &mut kernel, &t).unwrap();
let sun = kernel.at("sun", &t).unwrap();
let phase = mars.phase_angle(&mut kernel, &t).unwrap();
let elongation = mars.solar_elongation(&mut kernel, &t).unwrap();
let sun_to_observer = earth.position - sun.position;
let sun_to_target = earth.position + mars.position - sun.position;
let at_sun = angle_between(&sun_to_observer, &sun_to_target);
assert_relative_eq!(phase + elongation + at_sun, PI, epsilon = 1e-4);
}
#[test]
fn test_elongation_of_an_opposition_planet_is_large() {
let mut kernel = de421_kernel();
let t = Timescale::default().utc((2020, 10, 13, 23, 0, 0.0));
let earth = kernel.at("earth", &t).unwrap();
let mars = earth.observe("mars", &mut kernel, &t).unwrap();
let elongation_deg = mars.solar_elongation(&mut kernel, &t).unwrap().to_degrees();
assert!(
elongation_deg > 176.0,
"Mars at opposition should be nearly 180° from the Sun, got {elongation_deg}"
);
let phase_deg = mars.phase_angle(&mut kernel, &t).unwrap().to_degrees();
assert!(
phase_deg < 3.0,
"Mars at opposition should show almost no phase, got {phase_deg}"
);
assert!(
mars.illuminated_fraction(&mut kernel, &t).unwrap() > 0.999,
"Mars at opposition should be all but fully lit"
);
}
#[test]
fn test_missing_observer_is_an_error() {
let mut kernel = de421_kernel();
let t = hirise_epoch();
let earth = kernel.at("earth", &t).unwrap();
assert!(earth.observer_barycentric.is_none());
for result in [
earth.phase_angle(&mut kernel, &t),
earth.illuminated_fraction(&mut kernel, &t),
earth.solar_elongation(&mut kernel, &t),
] {
match result {
Err(StarfieldError::MissingObserver) => {}
other => panic!("expected MissingObserver, got {:?}", other),
}
}
}
}