use nalgebra::Vector3;
use crate::constants::{C_AUDAY, TAU};
use crate::framelib::Frame;
use crate::jplephem::kernel::SpiceKernel;
use crate::jplephem_ext::SpiceKernelExt;
use crate::positions::Position;
use crate::time::Time;
use crate::Result;
const SUN_LIGHT_TIME_ITERATIONS: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SubPoint {
pub lon_rad: f64,
pub lat_rad: f64,
pub planetocentric: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LongitudeSense {
East,
West,
}
impl LongitudeSense {
pub fn for_body(naif_id: i32) -> Self {
let body = if (1..=9).contains(&naif_id) {
naif_id * 100 + 99
} else {
naif_id
};
match body {
199 | 499 | 599 | 699 | 899 => LongitudeSense::West,
_ => LongitudeSense::East,
}
}
}
impl SubPoint {
pub fn longitude_in(&self, sense: LongitudeSense) -> f64 {
match sense {
LongitudeSense::East => self.lon_rad,
LongitudeSense::West => wrap(-self.lon_rad),
}
}
pub fn to_planetographic(&self, radii_km: [f64; 3]) -> SubPoint {
if !self.planetocentric {
return *self;
}
SubPoint {
lon_rad: self.lon_rad,
lat_rad: scale_latitude(self.lat_rad, flattening_factor(radii_km)),
planetocentric: false,
}
}
pub fn to_planetocentric(&self, radii_km: [f64; 3]) -> SubPoint {
if self.planetocentric {
return *self;
}
SubPoint {
lon_rad: self.lon_rad,
lat_rad: scale_latitude(self.lat_rad, 1.0 / flattening_factor(radii_km)),
planetocentric: true,
}
}
fn from_body_fixed(direction: Vector3<f64>, radii_km: [f64; 3]) -> SubPoint {
let r = direction.norm();
let lat = if r > 0.0 {
(direction.z / r).asin()
} else {
0.0
};
SubPoint {
lon_rad: wrap(direction.y.atan2(direction.x)),
lat_rad: lat,
planetocentric: true,
}
.to_planetographic(radii_km)
}
}
impl Position {
pub fn sub_observer_point(&self, frame: &dyn Frame, radii_km: [f64; 3], t: &Time) -> SubPoint {
let epoch = t.shift_days(-self.light_time);
SubPoint::from_body_fixed(frame.rotation_at(&epoch) * -self.position, radii_km)
}
pub fn sub_solar_point(
&self,
frame: &dyn Frame,
radii_km: [f64; 3],
kernel: &mut SpiceKernel,
t: &Time,
) -> Result<SubPoint> {
let epoch = t.shift_days(-self.light_time);
let body_to_sun = sun_seen_from_target(self, kernel, t)?;
Ok(SubPoint::from_body_fixed(
frame.rotation_at(&epoch) * body_to_sun,
radii_km,
))
}
}
pub(crate) fn sun_seen_from_target(
position: &Position,
kernel: &mut SpiceKernel,
t: &Time,
) -> Result<Vector3<f64>> {
let observer = position.require_observer()?;
let epoch = t.shift_days(-position.light_time);
let body = observer.position + position.position;
let mut sun = kernel.at("sun", &epoch)?.position;
for _ in 0..SUN_LIGHT_TIME_ITERATIONS {
let light_time = (body - sun).norm() / C_AUDAY;
sun = kernel.at("sun", &epoch.shift_days(-light_time))?.position;
}
Ok(sun - body)
}
fn flattening_factor(radii_km: [f64; 3]) -> f64 {
(radii_km[0] / radii_km[2]).powi(2)
}
fn scale_latitude(lat_rad: f64, factor: f64) -> f64 {
(factor * lat_rad.tan()).atan()
}
fn wrap(angle: f64) -> f64 {
let wrapped = angle % TAU;
if wrapped < 0.0 {
wrapped + TAU
} else {
wrapped
}
}
#[cfg(test)]
pub(crate) mod horizons_fixture {
use crate::time::{Time, Timescale};
const ROWS: &str = include_str!("horizons_disk_orientation.csv");
pub(crate) const QUANTITIES: &str = "14,15,16,17";
#[derive(Debug, Clone, Copy)]
pub(crate) struct DiskRow {
pub target: i32,
pub center: i32,
pub jd_ut: f64,
pub obs_sub_lon_deg: f64,
pub obs_sub_lat_deg: f64,
pub sun_sub_lon_deg: f64,
pub sun_sub_lat_deg: f64,
pub sn_ang_deg: f64,
pub np_ang_deg: f64,
}
impl DiskRow {
pub(crate) fn time(&self, ts: &Timescale) -> Time {
ts.utc(ts.jd_to_calendar(self.jd_ut))
}
}
pub(crate) fn rows() -> Vec<DiskRow> {
ROWS.lines()
.filter(|line| {
!line.starts_with('#') && !line.is_empty() && !line.starts_with("target")
})
.map(|line| {
let f: Vec<f64> = line
.split(',')
.map(|v| v.trim().parse().expect("fixture holds numbers"))
.collect();
assert_eq!(f.len(), 9, "fixture row {line:?} has the wrong width");
DiskRow {
target: f[0] as i32,
center: f[1] as i32,
jd_ut: f[2],
obs_sub_lon_deg: f[3],
obs_sub_lat_deg: f[4],
sun_sub_lon_deg: f[5],
sun_sub_lat_deg: f[6],
sn_ang_deg: f[7],
np_ang_deg: f[8],
}
})
.collect()
}
pub(crate) fn rows_for(target: i32, center: i32) -> Vec<DiskRow> {
rows()
.into_iter()
.filter(|row| row.target == target && row.center == center)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::horizons_fixture::{rows_for, DiskRow, QUANTITIES};
use super::*;
use crate::framelib::ItrsFrame;
use crate::horizons::parser;
use crate::horizons::{Center, Command, EphemerisRequest, HorizonsClient, TimeSpec};
use crate::planetarylib::IauFrame;
use crate::planetlib::Body;
use crate::time::Timescale;
use approx::assert_relative_eq;
fn de421_kernel() -> SpiceKernel {
SpiceKernel::open("test_data/de421.bsp").expect("Failed to open DE421")
}
fn frame_and_radii(naif_id: i32) -> (Box<dyn Frame>, [f64; 3]) {
let radii = crate::planetarylib::body_constants(naif_id).unwrap().radii;
if naif_id == 399 {
(Box::new(ItrsFrame), radii)
} else {
(Box::new(IauFrame::from_naif_id(naif_id).unwrap()), radii)
}
}
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_points(row: &DiskRow) -> (SubPoint, SubPoint) {
let mut kernel = de421_kernel();
let ts = Timescale::default();
let t = row.time(&ts);
let (frame, radii) = frame_and_radii(row.target);
let observer = kernel.at(&row.center.to_string(), &t).unwrap();
let target = observer
.observe(&row.target.to_string(), &mut kernel, &t)
.unwrap();
let sub_observer = target.sub_observer_point(frame.as_ref(), radii, &t);
let sub_solar = target
.sub_solar_point(frame.as_ref(), radii, &mut kernel, &t)
.unwrap();
(sub_observer, sub_solar)
}
#[test]
fn test_longitude_sense_of_every_body() {
for id in [199, 499, 599, 699, 899] {
assert_eq!(LongitudeSense::for_body(id), LongitudeSense::West, "{id}");
}
for id in [10, 299, 301, 399, 799, 999] {
assert_eq!(LongitudeSense::for_body(id), LongitudeSense::East, "{id}");
}
assert_eq!(LongitudeSense::for_body(4), LongitudeSense::West);
assert_eq!(LongitudeSense::for_body(7), LongitudeSense::East);
}
#[test]
fn test_longitude_in_west_is_the_complement() {
let point = SubPoint {
lon_rad: 1.0,
lat_rad: 0.0,
planetocentric: true,
};
assert_relative_eq!(
point.longitude_in(LongitudeSense::West),
TAU - 1.0,
epsilon = 1e-15
);
assert_relative_eq!(point.longitude_in(LongitudeSense::East), 1.0);
let prime = SubPoint {
lon_rad: 0.0,
..point
};
assert_relative_eq!(prime.longitude_in(LongitudeSense::West), 0.0);
}
#[test]
fn test_latitude_conversions_round_trip() {
let radii = Body::Mars.radii_km();
for lat_deg in [-89.0_f64, -45.0, -1.0, 0.0, 23.5, 60.0, 89.9] {
let centric = SubPoint {
lon_rad: 0.5,
lat_rad: lat_deg.to_radians(),
planetocentric: true,
};
let graphic = centric.to_planetographic(radii);
assert!(!graphic.planetocentric);
assert_eq!(graphic.lon_rad, centric.lon_rad);
assert!(
graphic.lat_rad.abs() >= centric.lat_rad.abs(),
"the oblate latitude is the larger one"
);
assert_relative_eq!(
graphic.to_planetocentric(radii).lat_rad,
centric.lat_rad,
epsilon = 1e-14
);
assert_eq!(graphic.to_planetographic(radii), graphic);
}
}
#[test]
fn test_a_spherical_body_has_equal_latitudes() {
let radii = [1000.0, 1000.0, 1000.0];
let point = SubPoint {
lon_rad: 0.0,
lat_rad: 0.7,
planetocentric: true,
};
assert_relative_eq!(point.to_planetographic(radii).lat_rad, 0.7, epsilon = 1e-15);
}
#[test]
fn test_sub_observer_point_of_a_hand_built_geometry() {
let radii = [1.0, 1.0, 1.0];
let ts = Timescale::default();
let t = ts.tdb_jd(2451545.0);
let observer = Position::barycentric(Vector3::new(1.0, 0.0, 0.0), Vector3::zeros(), 0);
let target = Position::astrometric(
Vector3::new(-1.0, 0.0, 0.0),
Vector3::zeros(),
&observer,
0,
0.0,
);
let point = target.sub_observer_point(&crate::framelib::ICRS, radii, &t);
assert_relative_eq!(point.lon_rad, 0.0, epsilon = 1e-12);
assert_relative_eq!(point.lat_rad, 0.0, epsilon = 1e-12);
}
#[test]
fn test_sub_solar_point_faces_the_sun() {
let mut kernel = de421_kernel();
let ts = Timescale::default();
let t = ts.utc((2007, 10, 3, 0, 0, 0.0));
let radii = Body::Mars.radii_km();
let frame = IauFrame::from_body(Body::Mars);
let sun = kernel.at("sun", &t).unwrap();
let mars = sun.observe("mars", &mut kernel, &t).unwrap();
let sub_observer = mars.sub_observer_point(&frame, radii, &t);
let sub_solar = mars
.sub_solar_point(&frame, radii, &mut kernel, &t)
.unwrap();
assert!(
angle_error_deg(
sub_observer.lon_rad.to_degrees(),
sub_solar.lon_rad.to_degrees()
)
.abs()
< 0.01,
"sub-observer {:?} and sub-solar {:?} should coincide",
sub_observer,
sub_solar
);
assert!((sub_observer.lat_rad - sub_solar.lat_rad).abs() < 1e-4);
}
#[test]
fn test_a_position_without_an_observer_cannot_have_a_sub_solar_point() {
let mut kernel = de421_kernel();
let ts = Timescale::default();
let t = ts.tdb_jd(2451545.0);
let mars = kernel.at("mars", &t).unwrap();
let frame = IauFrame::from_body(Body::Mars);
assert!(mars
.sub_solar_point(&frame, Body::Mars.radii_km(), &mut kernel, &t)
.is_err());
}
#[test]
fn test_sub_points_match_horizons() {
let mut worst_lon = 0.0_f64;
let mut worst_lat = 0.0_f64;
for row in rows_for(499, 399).into_iter().chain(rows_for(399, 499)) {
let (sub_observer, sub_solar) = sub_points(&row);
let sense = LongitudeSense::for_body(row.target);
for (label, point, lon_deg, lat_deg) in [
(
"sub-observer",
sub_observer,
row.obs_sub_lon_deg,
row.obs_sub_lat_deg,
),
(
"sub-solar",
sub_solar,
row.sun_sub_lon_deg,
row.sun_sub_lat_deg,
),
] {
let lon_error =
angle_error_deg(point.longitude_in(sense).to_degrees(), lon_deg).abs();
let lat_error = (point.lat_rad.to_degrees() - lat_deg).abs();
assert!(
lon_error < 0.05,
"{label} longitude of {} from {} at JD {}: {} vs Horizons {} ({} deg)",
row.target,
row.center,
row.jd_ut,
point.longitude_in(sense).to_degrees(),
lon_deg,
lon_error
);
assert!(
lat_error < 0.05,
"{label} latitude of {} from {} at JD {}: {} vs Horizons {} ({} deg)",
row.target,
row.center,
row.jd_ut,
point.lat_rad.to_degrees(),
lat_deg,
lat_error
);
worst_lon = worst_lon.max(lon_error);
worst_lat = worst_lat.max(lat_error);
}
}
println!("worst longitude error {worst_lon:.4}°, worst latitude error {worst_lat:.4}°");
}
#[test]
fn test_planetocentric_latitude_is_not_what_horizons_reports() {
let radii = Body::Mars.radii_km();
let row = rows_for(499, 399)[0];
let (sub_observer, _) = sub_points(&row);
let centric = sub_observer.to_planetocentric(radii);
assert!(
(centric.lat_rad.to_degrees() - row.obs_sub_lat_deg).abs() > 0.1,
"the planetocentric and planetodetic latitudes should be distinguishable"
);
}
#[test]
#[ignore = "queries the JPL Horizons API over the network"]
fn test_horizons_fixture_is_current() {
let client = HorizonsClient::new().unwrap();
for (target, center) in [(499, 399), (399, 499)] {
let rows = rows_for(target, center);
let mut request = EphemerisRequest::observer(
Command::MajorBody(target),
Center::BodyCenter(center),
TimeSpec::JulianDayList(rows.iter().map(|row| row.jd_ut).collect()),
);
request.quantities = Some(QUANTITIES.to_string());
request.cal_format = Some("JD".to_string());
let response = client.query(&request).unwrap();
let result = response.result.unwrap();
let names = parser::extract_column_names(&result);
let block = parser::extract_ephemeris_block(&result).unwrap();
let fetched = parser::parse_observer_rows(block, &names).unwrap();
assert_eq!(fetched.len(), rows.len());
for (row, fetched) in rows.iter().zip(fetched) {
let field = |name: &str| -> f64 {
fetched
.fields
.iter()
.find(|(key, _)| key == name)
.unwrap_or_else(|| panic!("Horizons returned no column {name}"))
.1
.parse()
.unwrap()
};
assert_relative_eq!(fetched.jd, row.jd_ut, epsilon = 1e-6);
assert_relative_eq!(field("ObsSub-LON"), row.obs_sub_lon_deg, epsilon = 1e-4);
assert_relative_eq!(field("ObsSub-LAT"), row.obs_sub_lat_deg, epsilon = 1e-4);
assert_relative_eq!(field("SunSub-LON"), row.sun_sub_lon_deg, epsilon = 1e-4);
assert_relative_eq!(field("SunSub-LAT"), row.sun_sub_lat_deg, epsilon = 1e-4);
assert_relative_eq!(field("SN.ang"), row.sn_ang_deg, epsilon = 1e-2);
assert_relative_eq!(field("NP.ang"), row.np_ang_deg, epsilon = 1e-2);
}
}
}
}