use nalgebra::Vector3;
use std::f64::consts::PI;
use crate::constants::{
AU_M, DAY_S, EARTH_ANGVEL, EARTH_RADIUS, IERS_2010_INVERSE_EARTH_FLATTENING,
};
use crate::jplephem_ext::SpiceKernelExt;
use crate::positions::{Position, PositionKind};
use crate::time::Time;
#[derive(Debug, Clone)]
pub struct Geoid {
pub name: &'static str,
pub radius: f64,
pub inverse_flattening: f64,
one_minus_flattening_squared: f64,
}
impl Geoid {
pub const fn new(name: &'static str, radius: f64, inverse_flattening: f64) -> Self {
let f = 1.0 / inverse_flattening;
let omf = 1.0 - f;
Geoid {
name,
radius,
inverse_flattening,
one_minus_flattening_squared: omf * omf,
}
}
pub fn latlon(
&self,
latitude_degrees: f64,
longitude_degrees: f64,
elevation_m: f64,
) -> GeographicPosition {
let lat = latitude_degrees * PI / 180.0;
let lon = longitude_degrees * PI / 180.0;
let sinphi = lat.sin();
let cosphi = lat.cos();
let c =
1.0 / (cosphi * cosphi + sinphi * sinphi * self.one_minus_flattening_squared).sqrt();
let s = self.one_minus_flattening_squared * c;
let radius_au = self.radius / AU_M;
let elevation_au = elevation_m / AU_M;
let xy = (radius_au * c + elevation_au) * cosphi;
let x = xy * lon.cos();
let y = xy * lon.sin();
let z = (radius_au * s + elevation_au) * sinphi;
GeographicPosition {
latitude: lat,
longitude: lon,
elevation_m,
itrs_xyz: Vector3::new(x, y, z),
}
}
}
pub const WGS84: Geoid = Geoid::new("WGS84", 6_378_137.0, 298.257_223_563);
pub const IERS2010: Geoid =
Geoid::new("IERS2010", EARTH_RADIUS, IERS_2010_INVERSE_EARTH_FLATTENING);
#[derive(Debug, Clone)]
pub struct GeographicPosition {
pub latitude: f64,
pub longitude: f64,
pub elevation_m: f64,
pub itrs_xyz: Vector3<f64>,
}
impl GeographicPosition {
pub fn at(
&self,
time: &Time,
kernel: &mut crate::jplephem::kernel::SpiceKernel,
) -> crate::jplephem::errors::Result<Position> {
let earth = kernel.at("earth", time)?;
let ct = time.ct_matrix();
let gcrs_pos = ct * self.itrs_xyz;
let angvel_au_day = EARTH_ANGVEL * DAY_S; let itrs_vel = Vector3::new(
-angvel_au_day * self.itrs_xyz.y,
angvel_au_day * self.itrs_xyz.x,
0.0,
);
let gcrs_vel = ct * itrs_vel;
Ok(Position::barycentric(
earth.position + gcrs_pos,
earth.velocity + gcrs_vel,
399,
))
}
pub fn altaz(&self, apparent: &Position, time: &Time) -> (f64, f64, f64) {
assert_eq!(
apparent.kind,
PositionKind::Apparent,
"altaz() requires an Apparent position"
);
let c = time.c_matrix();
let itrs = c * apparent.position;
let (alt, az) = self.itrs_to_horizon(&itrs);
(alt * 180.0 / PI, az * 180.0 / PI, apparent.distance())
}
pub(crate) fn itrs_to_horizon(&self, itrs_direction: &Vector3<f64>) -> (f64, f64) {
let slat = self.latitude.sin();
let clat = self.latitude.cos();
let slon = self.longitude.sin();
let clon = self.longitude.cos();
let south = slat * clon * itrs_direction.x + slat * slon * itrs_direction.y
- clat * itrs_direction.z;
let east = -slon * itrs_direction.x + clon * itrs_direction.y;
let up = clat * clon * itrs_direction.x
+ clat * slon * itrs_direction.y
+ slat * itrs_direction.z;
let r_horiz = (south * south + east * east).sqrt();
let alt = up.atan2(r_horiz);
let mut az = east.atan2(-south);
if az < 0.0 {
az += 2.0 * PI;
}
(alt, az)
}
pub fn lst_hours(&self, time: &Time) -> f64 {
let gast = time.gast();
let lst = gast + self.longitude * 12.0 / PI;
lst.rem_euclid(24.0)
}
pub fn refract(altitude_degrees: f64, temperature_c: f64, pressure_mbar: f64) -> f64 {
crate::earthlib::refraction(altitude_degrees, temperature_c, pressure_mbar)
}
}
impl std::fmt::Display for GeographicPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let lat_d = self.latitude * 180.0 / PI;
let lon_d = self.longitude * 180.0 / PI;
let ns = if lat_d >= 0.0 { "N" } else { "S" };
let ew = if lon_d >= 0.0 { "E" } else { "W" };
write!(
f,
"{:.4}° {}, {:.4}° {}, {:.1} m",
lat_d.abs(),
ns,
lon_d.abs(),
ew,
self.elevation_m
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jplephem::kernel::SpiceKernel;
use crate::jplephem_ext::SpiceKernelExt;
use approx::assert_relative_eq;
#[test]
fn test_wgs84_constants() {
assert_relative_eq!(WGS84.radius, 6_378_137.0);
assert_relative_eq!(WGS84.inverse_flattening, 298.257_223_563);
}
#[test]
fn test_iers2010_constants() {
assert_relative_eq!(IERS2010.radius, EARTH_RADIUS);
assert_relative_eq!(
IERS2010.inverse_flattening,
IERS_2010_INVERSE_EARTH_FLATTENING
);
}
#[test]
fn test_latlon_equator_prime_meridian() {
let pos = WGS84.latlon(0.0, 0.0, 0.0);
let expected_x = WGS84.radius / AU_M;
assert_relative_eq!(pos.itrs_xyz.x, expected_x, epsilon = 1e-10);
assert_relative_eq!(pos.itrs_xyz.y, 0.0, epsilon = 1e-15);
assert_relative_eq!(pos.itrs_xyz.z, 0.0, epsilon = 1e-15);
}
#[test]
fn test_latlon_equator_90e() {
let pos = WGS84.latlon(0.0, 90.0, 0.0);
let expected_y = WGS84.radius / AU_M;
assert_relative_eq!(pos.itrs_xyz.x, 0.0, epsilon = 1e-10);
assert_relative_eq!(pos.itrs_xyz.y, expected_y, epsilon = 1e-10);
assert_relative_eq!(pos.itrs_xyz.z, 0.0, epsilon = 1e-15);
}
#[test]
fn test_latlon_north_pole() {
let pos = WGS84.latlon(90.0, 0.0, 0.0);
assert_relative_eq!(pos.itrs_xyz.x, 0.0, epsilon = 1e-15);
assert_relative_eq!(pos.itrs_xyz.y, 0.0, epsilon = 1e-15);
let polar_radius_au = pos.itrs_xyz.z;
let equatorial_radius_au = WGS84.radius / AU_M;
assert!(
polar_radius_au < equatorial_radius_au,
"Polar radius {} should be less than equatorial radius {}",
polar_radius_au,
equatorial_radius_au
);
let expected_polar_m = WGS84.radius * (1.0 - 1.0 / WGS84.inverse_flattening);
assert_relative_eq!(
polar_radius_au * AU_M,
expected_polar_m,
epsilon = 100.0 );
}
#[test]
fn test_latlon_with_elevation() {
let pos_ground = WGS84.latlon(0.0, 0.0, 0.0);
let pos_high = WGS84.latlon(0.0, 0.0, 1000.0);
let diff_m = (pos_high.itrs_xyz.x - pos_ground.itrs_xyz.x) * AU_M;
assert_relative_eq!(diff_m, 1000.0, epsilon = 0.01);
}
#[test]
fn test_latlon_symmetry() {
let north = WGS84.latlon(45.0, 0.0, 0.0);
let south = WGS84.latlon(-45.0, 0.0, 0.0);
assert_relative_eq!(north.itrs_xyz.x, south.itrs_xyz.x, epsilon = 1e-15);
assert_relative_eq!(north.itrs_xyz.z, -south.itrs_xyz.z, epsilon = 1e-15);
}
#[test]
fn test_display() {
let pos = WGS84.latlon(42.3583, -71.0603, 43.0);
let s = format!("{}", pos);
assert!(s.contains("N"));
assert!(s.contains("W"));
}
#[test]
fn test_refraction_at_horizon() {
let r = GeographicPosition::refract(0.0, 10.0, 1010.0);
assert!(
r > 0.4 && r < 0.7,
"Horizon refraction should be ~0.57°, got {}",
r
);
}
#[test]
fn test_refraction_at_zenith() {
let r = GeographicPosition::refract(90.0, 10.0, 1010.0);
assert!(r < 0.01, "Zenith refraction should be ~0, got {}", r);
}
#[test]
fn test_refraction_below_horizon() {
let r = GeographicPosition::refract(-5.0, 10.0, 1010.0);
assert_relative_eq!(r, 0.0);
}
#[test]
fn test_horizon_rotation_up() {
let pos = WGS84.latlon(0.0, 0.0, 0.0);
let up_itrs = Vector3::new(1.0, 0.0, 0.0);
let (alt, _az) = pos.itrs_to_horizon(&up_itrs);
assert_relative_eq!(alt, PI / 2.0, epsilon = 0.01);
}
#[test]
fn test_horizon_rotation_north_at_equator() {
let pos = WGS84.latlon(0.0, 0.0, 0.0);
let north_itrs = Vector3::new(0.0, 0.0, 1.0);
let (alt, az) = pos.itrs_to_horizon(&north_itrs);
assert_relative_eq!(alt, 0.0, epsilon = 0.01);
assert_relative_eq!(az, 0.0, epsilon = 0.01);
}
fn de421_kernel() -> SpiceKernel {
SpiceKernel::open("test_data/de421.bsp").expect("Failed to open DE421")
}
fn j2000_time() -> Time {
crate::time::Timescale::default().tdb_jd(2451545.0)
}
#[test]
fn test_observer_at_returns_barycentric() {
let mut kernel = de421_kernel();
let t = j2000_time();
let boston = WGS84.latlon(42.3583, -71.0603, 43.0);
let observer = boston.at(&t, &mut kernel).unwrap();
assert_eq!(observer.kind, PositionKind::Barycentric);
}
#[test]
fn test_observer_near_earth() {
let mut kernel = de421_kernel();
let t = j2000_time();
let earth = kernel.at("earth", &t).unwrap();
let boston = WGS84.latlon(42.3583, -71.0603, 43.0);
let observer = boston.at(&t, &mut kernel).unwrap();
let offset_au = (observer.position - earth.position).norm();
let offset_m = offset_au * AU_M;
assert!(
offset_m > 6_000_000.0 && offset_m < 6_500_000.0,
"Observer offset from Earth center should be ~6371 km, got {} m",
offset_m
);
}
#[test]
fn test_observer_full_pipeline() {
let mut kernel = de421_kernel();
let t = j2000_time();
let boston = WGS84.latlon(42.3583, -71.0603, 43.0);
let observer = boston.at(&t, &mut kernel).unwrap();
let mars_astro = observer.observe("mars", &mut kernel, &t).unwrap();
let mars_app = mars_astro.apparent(&mut kernel, &t).unwrap();
let (ra, dec, dist) = mars_app.radec(None);
assert!(ra >= 0.0 && ra < 24.0, "RA out of range: {}", ra);
assert!(dec >= -90.0 && dec <= 90.0, "Dec out of range: {}", dec);
assert!(dist > 0.0, "Distance should be positive");
}
#[test]
fn test_altaz_produces_valid_coordinates() {
let mut kernel = de421_kernel();
let t = j2000_time();
let boston = WGS84.latlon(42.3583, -71.0603, 43.0);
let observer = boston.at(&t, &mut kernel).unwrap();
let mars_astro = observer.observe("mars", &mut kernel, &t).unwrap();
let mars_app = mars_astro.apparent(&mut kernel, &t).unwrap();
let (alt, az, dist) = boston.altaz(&mars_app, &t);
assert!(
alt >= -90.0 && alt <= 90.0,
"Altitude should be in [-90, 90], got {}",
alt
);
assert!(
az >= 0.0 && az < 360.0,
"Azimuth should be in [0, 360), got {}",
az
);
assert!(dist > 0.0, "Distance should be positive");
}
#[test]
fn test_lst_in_valid_range() {
let t = j2000_time();
let boston = WGS84.latlon(42.3583, -71.0603, 43.0);
let lst = boston.lst_hours(&t);
assert!(
lst >= 0.0 && lst < 24.0,
"LST should be in [0, 24), got {}",
lst
);
}
#[test]
fn test_lst_east_of_greenwich_is_ahead() {
let t = j2000_time();
let greenwich = WGS84.latlon(51.4769, 0.0, 0.0);
let tokyo = WGS84.latlon(35.6762, 139.6503, 0.0);
let lst_g = greenwich.lst_hours(&t);
let lst_t = tokyo.lst_hours(&t);
let diff = (lst_t - lst_g + 24.0) % 24.0;
assert!(
diff > 8.0 && diff < 11.0,
"Tokyo LST should be ~9.3h ahead of Greenwich, got {}h",
diff
);
}
#[test]
fn test_antipodal_observers_differ() {
let mut kernel = de421_kernel();
let t = j2000_time();
let pos1 = WGS84.latlon(0.0, 0.0, 0.0);
let pos2 = WGS84.latlon(0.0, 180.0, 0.0);
let obs1 = pos1.at(&t, &mut kernel).unwrap();
let obs2 = pos2.at(&t, &mut kernel).unwrap();
let diff_au = (obs1.position - obs2.position).norm();
let diff_m = diff_au * AU_M;
assert!(
diff_m > 12_000_000.0 && diff_m < 13_000_000.0,
"Antipodal observers should differ by ~12742 km, got {} m",
diff_m
);
}
}
#[cfg(feature = "python-tests")]
mod python_tests;