use nalgebra::Vector3;
use crate::framelib::Frame;
use crate::jplephem::kernel::SpiceKernel;
use crate::planetarylib::subpoint::sun_seen_from_target;
use crate::positions::Position;
use crate::time::Time;
use crate::Result;
impl Position {
pub fn north_pole_position_angle(&self, frame: &dyn Frame, t: &Time) -> f64 {
let epoch = t.shift_days(-self.light_time);
let pole = frame.rotation_at(&epoch).row(2).transpose();
position_angle(&self.position, &pole)
}
pub fn bright_limb_position_angle(&self, kernel: &mut SpiceKernel, t: &Time) -> Result<f64> {
let body_to_sun = sun_seen_from_target(self, kernel, t)?;
Ok(position_angle(&self.position, &body_to_sun))
}
}
fn position_angle(line_of_sight: &Vector3<f64>, direction: &Vector3<f64>) -> f64 {
let u = line_of_sight.normalize();
let z = Vector3::z();
let east = z.cross(&u);
let north = z - u * z.dot(&u);
let angle = direction.dot(&east).atan2(direction.dot(&north));
if angle < 0.0 {
angle + std::f64::consts::TAU
} else {
angle
}
}
#[cfg(test)]
mod tests {
use super::super::subpoint::horizons_fixture::{rows_for, DiskRow};
use super::*;
use crate::framelib::ItrsFrame;
use crate::jplephem_ext::SpiceKernelExt;
use crate::planetarylib::IauFrame;
use crate::planetlib::Body;
use crate::time::Timescale;
use approx::assert_relative_eq;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
fn de421_kernel() -> SpiceKernel {
SpiceKernel::open("test_data/de421.bsp").expect("Failed to open DE421")
}
fn angle_error_deg(a: f64, b: f64) -> f64 {
let mut diff = (a - b) % 360.0;
if diff > 180.0 {
diff -= 360.0;
}
if diff < -180.0 {
diff += 360.0;
}
diff
}
fn sub_solar_offset_arcsec(target: &Position, kernel: &mut SpiceKernel, t: &Time) -> f64 {
let radii = crate::planetarylib::body_constants(target.target)
.unwrap()
.radii;
let angular_radius = radii[0] / (target.distance() * crate::constants::AU_KM);
let phase = target.phase_angle(kernel, t).unwrap();
(angular_radius * phase.sin()).to_degrees() * 3600.0
}
fn position_angles(row: &DiskRow) -> (f64, f64, f64) {
let mut kernel = de421_kernel();
let ts = Timescale::default();
let t = row.time(&ts);
let frame: Box<dyn Frame> = if row.target == 399 {
Box::new(ItrsFrame)
} else {
Box::new(IauFrame::from_naif_id(row.target).unwrap())
};
let observer = kernel.at(&row.center.to_string(), &t).unwrap();
let target = observer
.observe(&row.target.to_string(), &mut kernel, &t)
.unwrap();
(
target
.north_pole_position_angle(frame.as_ref(), &t)
.to_degrees(),
target
.bright_limb_position_angle(&mut kernel, &t)
.unwrap()
.to_degrees(),
sub_solar_offset_arcsec(&target, &mut kernel, &t),
)
}
#[test]
fn test_position_angle_of_the_cardinal_directions() {
let u = Vector3::new(1.0, 0.0, 0.0);
assert_relative_eq!(position_angle(&u, &Vector3::z()), 0.0, epsilon = 1e-15);
assert_relative_eq!(
position_angle(&u, &Vector3::y()),
FRAC_PI_2,
epsilon = 1e-15
);
assert_relative_eq!(position_angle(&u, &(-Vector3::z())), PI, epsilon = 1e-15);
assert_relative_eq!(
position_angle(&u, &(-Vector3::y())),
3.0 * FRAC_PI_2,
epsilon = 1e-15
);
}
#[test]
fn test_position_angle_ignores_the_radial_component() {
let u = Vector3::new(0.3, -0.7, 0.2);
let v = Vector3::new(0.1, 0.5, 0.9);
let along = position_angle(&u, &v);
assert_relative_eq!(position_angle(&u, &(v + u * 5.0)), along, epsilon = 1e-12);
assert_relative_eq!(position_angle(&u, &(v * 3.0)), along, epsilon = 1e-12);
}
#[test]
fn test_the_earths_pole_points_at_celestial_north() {
let mut kernel = de421_kernel();
let ts = Timescale::default();
let t = ts.utc((2007, 10, 3, 0, 0, 0.0));
let mars = kernel.at("mars", &t).unwrap();
let earth = mars.observe("earth", &mut kernel, &t).unwrap();
let pa = earth.north_pole_position_angle(&ItrsFrame, &t).to_degrees();
assert!(
angle_error_deg(pa, 0.0).abs() < 0.2,
"the Earth's pole should sit at position angle 0, got {pa}"
);
}
#[test]
fn test_a_full_disc_has_its_bright_limb_opposite_the_sun() {
let mut kernel = de421_kernel();
let ts = Timescale::default();
let t = ts.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 limb = mars.bright_limb_position_angle(&mut kernel, &t).unwrap();
assert!((0.0..TAU).contains(&limb));
assert!(mars.illuminated_fraction(&mut kernel, &t).unwrap() > 0.999);
}
#[test]
fn test_a_position_without_an_observer_has_no_bright_limb() {
let mut kernel = de421_kernel();
let ts = Timescale::default();
let t = ts.tdb_jd(2451545.0);
let mars = kernel.at("mars", &t).unwrap();
assert!(mars.bright_limb_position_angle(&mut kernel, &t).is_err());
}
#[test]
fn test_position_angles_match_horizons_for_an_earth_observer() {
let mut worst_pole = 0.0_f64;
let mut worst_limb = 0.0_f64;
let mut worst_displacement = 0.0_f64;
for row in rows_for(499, 399) {
let (pole, limb, offset_arcsec) = position_angles(&row);
let pole_error = angle_error_deg(pole, row.np_ang_deg).abs();
let limb_error = angle_error_deg(limb, row.sn_ang_deg).abs();
assert!(
pole_error < 0.1,
"north pole angle at JD {}: {pole} vs Horizons {} ({pole_error} deg)",
row.jd_ut,
row.np_ang_deg
);
let displacement_mas =
2.0 * (limb_error.to_radians() / 2.0).sin() * offset_arcsec * 1000.0;
assert!(
displacement_mas < 5.0,
"bright limb at JD {} puts the sub-solar point {displacement_mas} mas from \
where Horizons does",
row.jd_ut
);
if offset_arcsec > 1.0 {
assert!(
limb_error < 0.1,
"bright limb angle at JD {}: {limb} vs Horizons {} ({limb_error} deg)",
row.jd_ut,
row.sn_ang_deg
);
}
worst_pole = worst_pole.max(pole_error);
worst_limb = worst_limb.max(limb_error);
worst_displacement = worst_displacement.max(displacement_mas);
}
println!(
"worst north pole error {worst_pole:.4}°, worst bright limb error {worst_limb:.4}°, \
worst sub-solar displacement {worst_displacement:.3} mas"
);
}
#[test]
fn test_position_angle_difference_matches_horizons_for_a_mars_observer() {
let mut worst = 0.0_f64;
for row in rows_for(399, 499) {
let (pole, limb, _) = position_angles(&row);
let error = angle_error_deg(limb - pole, row.sn_ang_deg - row.np_ang_deg).abs();
assert!(
error < 0.1,
"bright limb minus north pole at JD {}: {} vs Horizons {} ({error} deg)",
row.jd_ut,
limb - pole,
row.sn_ang_deg - row.np_ang_deg
);
worst = worst.max(error);
}
println!("worst bright limb minus north pole error {worst:.4}°");
}
#[test]
fn test_the_two_views_share_one_pole_to_pole_angle() {
let mut kernel = de421_kernel();
let ts = Timescale::default();
let t = ts.utc((2007, 10, 3, 0, 0, 0.0));
let mars_frame = IauFrame::from_body(Body::Mars);
let earth = kernel.at("earth", &t).unwrap();
let mars_seen = earth.observe("mars", &mut kernel, &t).unwrap();
let from_earth = mars_seen.north_pole_position_angle(&mars_frame, &t);
let mars = kernel.at("mars", &t).unwrap();
let earth_seen = mars.observe("earth", &mut kernel, &t).unwrap();
let epoch = t.shift_days(-earth_seen.light_time);
let mars_pole = mars_frame.rotation_at(&epoch).row(2).transpose();
let earth_pole = ItrsFrame.rotation_at(&epoch).row(2).transpose();
let from_mars = position_angle(&earth_seen.position, &earth_pole)
- position_angle(&earth_seen.position, &mars_pole);
assert!(
angle_error_deg(from_earth.to_degrees(), from_mars.to_degrees()).abs() < 0.05,
"{} from the Earth against {} from Mars",
from_earth.to_degrees(),
from_mars.to_degrees()
);
}
}