use nalgebra::Vector3;
use crate::constants::AU_KM;
use crate::framelib::Frame;
use crate::positions::Position;
use crate::time::Time;
const CIRCLE_TOLERANCE: f64 = 8.0 * f64::EPSILON;
impl Position {
pub fn angular_semi_diameter(&self, radii_km: [f64; 3]) -> f64 {
let distance_km = self.position.norm() * AU_KM;
semi_angle(radii_km[0].max(radii_km[1]), distance_km)
}
pub fn apparent_ellipse(
&self,
frame: &dyn Frame,
radii_km: [f64; 3],
t: &Time,
) -> (f64, f64, f64) {
let distance_km = self.position.norm() * AU_KM;
let (east, north, line_of_sight) = sky_basis(&self.position);
let rotation = frame.rotation_at(&(t.clone() - self.light_time));
let u = rotation * east;
let v = rotation * north;
let n = rotation * line_of_sight;
let b11 = quadratic(&u, &u, radii_km);
let b12 = quadratic(&u, &v, radii_km);
let b13 = quadratic(&u, &n, radii_km);
let b22 = quadratic(&v, &v, radii_km);
let b23 = quadratic(&v, &n, radii_km);
let b33 = quadratic(&n, &n, radii_km);
let s11 = b11 - b13 * b13 / b33;
let s12 = b12 - b13 * b23 / b33;
let s22 = b22 - b23 * b23 / b33;
let mean = 0.5 * (s11 + s22);
let half_difference = 0.5 * (s11 - s22);
let radius = half_difference.hypot(s12);
let semi_major_km = 1.0 / (mean - radius).sqrt();
let semi_minor_km = 1.0 / (mean + radius).sqrt();
let lambda = mean - radius;
let (first, second) = ((s12, lambda - s11), (lambda - s22, s12));
let (east_part, north_part) = if first.0.hypot(first.1) >= second.0.hypot(second.1) {
first
} else {
second
};
let position_angle = if radius <= CIRCLE_TOLERANCE * mean {
0.0
} else {
east_part.atan2(north_part).rem_euclid(std::f64::consts::PI)
};
(
semi_angle(semi_major_km, distance_km),
semi_angle(semi_minor_km, distance_km),
position_angle,
)
}
}
fn semi_angle(radius_km: f64, distance_km: f64) -> f64 {
if distance_km <= 0.0 {
return std::f64::consts::FRAC_PI_2;
}
(radius_km / distance_km).min(1.0).asin()
}
fn quadratic(w: &Vector3<f64>, z: &Vector3<f64>, radii_km: [f64; 3]) -> f64 {
w.x * z.x / (radii_km[0] * radii_km[0])
+ w.y * z.y / (radii_km[1] * radii_km[1])
+ w.z * z.z / (radii_km[2] * radii_km[2])
}
fn sky_basis(position: &Vector3<f64>) -> (Vector3<f64>, Vector3<f64>, Vector3<f64>) {
let line_of_sight = position.normalize();
let mut east = Vector3::z().cross(&line_of_sight);
if east.norm() < 1e-12 {
east = Vector3::x().cross(&line_of_sight);
}
let east = east.normalize();
let north = line_of_sight.cross(&east);
(east, north, line_of_sight)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::framelib::ICRS;
use crate::jplephem::kernel::SpiceKernel;
use crate::jplephem_ext::SpiceKernelExt;
use crate::planetarylib::pck_frame::{rot_x, rot_z};
use crate::planetarylib::PlanetaryConstants;
use crate::planetlib::Body;
use crate::time::Timescale;
use nalgebra::Matrix3;
const RAD2ASEC: f64 = 206_264.806_247_096_36;
struct FixedFrame(Matrix3<f64>);
impl Frame for FixedFrame {
fn rotation_at(&self, _t: &Time) -> Matrix3<f64> {
self.0
}
}
fn t_j2000() -> Time {
Timescale::default().tdb_jd(2451545.0)
}
fn hirise_epoch() -> Time {
Timescale::default().utc((2007, 10, 3, 8, 30, 0.0))
}
fn moc_epoch() -> Time {
Timescale::default().utc((2003, 5, 8, 13, 0, 0.0))
}
fn at_distance(au: f64, direction: Vector3<f64>) -> Position {
Position::barycentric(direction.normalize() * au, Vector3::zeros(), 0)
}
fn de421() -> SpiceKernel {
SpiceKernel::open("test_data/de421.bsp").expect("test_data/de421.bsp")
}
#[test]
fn test_semi_diameter_is_asin_of_the_ratio() {
let p = at_distance(1.0, Vector3::x());
let expected = (6378.1366 / AU_KM).asin();
assert!((p.angular_semi_diameter(Body::Earth.radii_km()) - expected).abs() < 1e-18);
}
#[test]
fn test_semi_diameter_uses_the_larger_equatorial_radius() {
let p = at_distance(1.0, Vector3::x());
let flat = p.angular_semi_diameter([100.0, 200.0, 10.0]);
let round = p.angular_semi_diameter([200.0, 200.0, 200.0]);
assert_eq!(flat, round);
}
#[test]
fn test_semi_diameter_saturates_inside_the_body() {
let inside = at_distance(1e-9, Vector3::x());
assert_eq!(
inside.angular_semi_diameter(Body::Earth.radii_km()),
std::f64::consts::FRAC_PI_2
);
let nowhere = Position::barycentric(Vector3::zeros(), Vector3::zeros(), 0);
assert_eq!(
nowhere.angular_semi_diameter(Body::Earth.radii_km()),
std::f64::consts::FRAC_PI_2
);
}
#[test]
fn test_a_sphere_projects_to_a_circle() {
let p = at_distance(0.5, Vector3::new(0.3, -0.7, 0.4));
let frame = FixedFrame(rot_z(0.7) * rot_x(-0.3));
let (major, minor, pa) = p.apparent_ellipse(&frame, [1737.4; 3], &t_j2000());
assert!((major - minor).abs() < 1e-15);
assert!((major - p.angular_semi_diameter([1737.4; 3])).abs() < 1e-15);
assert_eq!(pa, 0.0);
}
#[test]
fn test_pole_on_view_of_an_oblate_body_is_a_circle() {
let p = at_distance(5.0, Vector3::z());
let radii = Body::Jupiter.radii_km();
let (major, minor, _) = p.apparent_ellipse(&ICRS, radii, &t_j2000());
assert!((major - minor).abs() < 1e-15);
assert!((major - p.angular_semi_diameter(radii)).abs() < 1e-15);
}
#[test]
fn test_jupiter_equator_on_reproduces_its_flattening() {
let p = at_distance(5.0, Vector3::x());
let radii = Body::Jupiter.radii_km();
let (major, minor, pa) = p.apparent_ellipse(&ICRS, radii, &t_j2000());
let flattening = (radii[0] - radii[2]) / radii[0];
assert!(
(flattening - 0.06487).abs() < 5e-6,
"flattening {flattening}"
);
assert!(
(minor / major - (1.0 - flattening)).abs() < 1e-6,
"ratio {} against {}",
minor / major,
1.0 - flattening
);
assert!((pa - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "pa {pa}");
assert!((major - p.angular_semi_diameter(radii)).abs() < 1e-15);
}
#[test]
fn test_a_tilted_pole_tilts_the_ellipse() {
let p = at_distance(5.0, Vector3::x());
let frame = FixedFrame(rot_x(30f64.to_radians()));
let (_, _, pa) = p.apparent_ellipse(&frame, Body::Jupiter.radii_km(), &t_j2000());
assert!(
(pa.to_degrees() - 120.0).abs() < 1e-9,
"position angle {} degrees",
pa.to_degrees()
);
}
#[test]
fn test_a_triaxial_body_seen_down_its_long_axis() {
let p = at_distance(1.0, Vector3::x());
let (major, minor, _) = p.apparent_ellipse(&ICRS, [3.0, 2.0, 1.0], &t_j2000());
assert!((major - (2.0f64 / AU_KM).asin()).abs() < 1e-18);
assert!((minor - (1.0f64 / AU_KM).asin()).abs() < 1e-18);
}
#[test]
fn test_earth_and_moon_from_mars_at_the_moc_epoch() {
let mut kernel = de421();
let t = moc_epoch();
let mars = kernel.at("mars", &t).unwrap();
let earth = mars.observe("earth", &mut kernel, &t).unwrap();
let earth_diameter = 2.0 * earth.angular_semi_diameter(Body::Earth.radii_km()) * RAD2ASEC;
assert!(
(earth_diameter - 18.90).abs() < 0.02,
"the Earth is {earth_diameter} arcseconds across, expected 18.90"
);
let moon = mars.observe("moon", &mut kernel, &t).unwrap();
let moon_diameter = 2.0 * moon.angular_semi_diameter(Body::Moon.radii_km()) * RAD2ASEC;
assert!(
(moon_diameter - 5.14).abs() < 0.02,
"the Moon is {moon_diameter} arcseconds across, expected 5.14"
);
}
#[test]
fn test_earth_and_moon_from_mars_at_the_hirise_epoch() {
let mut kernel = de421();
let t = hirise_epoch();
let mars = kernel.at("mars", &t).unwrap();
let earth = mars.observe("earth", &mut kernel, &t).unwrap();
let earth_diameter = 2.0 * earth.angular_semi_diameter(Body::Earth.radii_km()) * RAD2ASEC;
assert!(
(earth_diameter - 18.50).abs() < 0.02,
"the Earth is {earth_diameter} arcseconds across, expected 18.50"
);
let moon = mars.observe("moon", &mut kernel, &t).unwrap();
let moon_diameter = 2.0 * moon.angular_semi_diameter(Body::Moon.radii_km()) * RAD2ASEC;
assert!(
(moon_diameter - 5.05).abs() < 0.02,
"the Moon is {moon_diameter} arcseconds across, expected 5.05"
);
}
#[test]
fn test_the_earths_apparent_ellipse_from_mars_is_barely_flattened() {
let mut kernel = de421();
let t = hirise_epoch();
let mars = kernel.at("mars", &t).unwrap();
let earth = mars.observe("earth", &mut kernel, &t).unwrap();
let radii = Body::Earth.radii_km();
let frame = PlanetaryConstants::new().frame_for(399).unwrap();
let (major, minor, pa) = earth.apparent_ellipse(frame.as_ref(), radii, &t);
assert!((2.0 * major * RAD2ASEC - 18.50).abs() < 0.02);
let ratio = minor / major;
assert!(
(radii[2] / radii[0]..=1.0).contains(&ratio),
"axis ratio {ratio}"
);
assert!((ratio - 0.99718).abs() < 1e-5, "axis ratio {ratio}");
assert!((pa.to_degrees() - 90.0).abs() < 1.0, "position angle {pa}");
}
#[test]
fn test_an_oblate_body_matches_the_closed_form() {
let radii = Body::Jupiter.radii_km();
let (a, c) = (radii[0], radii[2]);
let distance_au = 5.0;
for degrees in [0.0f64, 7.5, 23.0, 45.0, 66.0, 90.0] {
let phi = degrees.to_radians();
let p = at_distance(distance_au, Vector3::new(phi.cos(), 0.0, phi.sin()));
let (major, minor, _) = p.apparent_ellipse(&ICRS, radii, &t_j2000());
let distance_km = distance_au * AU_KM;
let expected_minor =
((c * phi.cos()).powi(2) + (a * phi.sin()).powi(2)).sqrt() / distance_km;
assert!(
(major.sin() - a / distance_km).abs() < 1e-18,
"semi-major at {degrees}°"
);
assert!(
(minor.sin() - expected_minor).abs() < 1e-15,
"semi-minor at {degrees}°: {} against {}",
minor.sin(),
expected_minor
);
}
}
}
#[cfg(all(test, feature = "python-tests"))]
mod python_tests {
use crate::jplephem::SpiceKernel;
use crate::jplephem_ext::SpiceKernelExt;
use crate::planetlib::Body;
use crate::pybridge::{PyRustBridge, PythonResult};
use crate::time::Timescale;
#[test]
fn test_angular_diameter_matches_skyfield() {
let bridge = PyRustBridge::new().expect("Python bridge");
let python = bridge
.run_py_to_json(
r#"
from numpy import arcsin
from skyfield.api import load
import json
ts = load.timescale()
eph = load('de421.bsp')
t = ts.utc(2003, 5, 8, 13, 0, 0)
mars = eph['mars'].at(t)
out = {}
for name, radius_km in (('earth', 6378.1366), ('moon', 1737.4)):
distance_km = mars.observe(eph[name]).distance().km
out[name] = float(2.0 * arcsin(radius_km / distance_km))
rust.collect_string(json.dumps(out))
"#,
)
.expect("Skyfield angular diameter");
let inner = match PythonResult::try_from(python.as_str()).expect("Python result") {
PythonResult::String(s) => s,
other => panic!("expected a string, got {:?}", other),
};
let parsed: serde_json::Value = serde_json::from_str(&inner).expect("JSON");
let mut kernel = SpiceKernel::open("test_data/de421.bsp").unwrap();
let t = Timescale::default().utc((2003, 5, 8, 13, 0, 0.0));
let mars = kernel.at("mars", &t).unwrap();
for (name, body) in [("earth", Body::Earth), ("moon", Body::Moon)] {
let target = mars.observe(name, &mut kernel, &t).unwrap();
let rust = 2.0 * target.angular_semi_diameter(body.radii_km());
let skyfield = parsed[name].as_f64().unwrap();
let arcsec = (rust - skyfield).abs().to_degrees() * 3600.0;
assert!(arcsec < 1e-6, "{name}: {rust} against {skyfield}");
}
}
}