use astrodyn_quantities::aliases::Position;
use astrodyn_quantities::frame::{Planet, PlanetFixed};
use astrodyn_quantities::qty3::Qty3;
use glam::{DMat3, DVec3};
use uom::si::angle::radian;
use uom::si::f64::{Angle, Length};
use uom::si::length::meter;
const MAX_ITERATION_LIMIT: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct GeodeticState {
pub latitude: f64,
pub longitude: f64,
pub altitude: f64,
}
impl GeodeticState {
pub fn from_planet_fixed(cart: DVec3, r_eq: f64, r_pol: f64) -> Self {
cartesian_to_geodetic_impl(cart, r_eq, r_pol)
}
pub fn to_planet_fixed(&self, r_eq: f64, r_pol: f64) -> DVec3 {
geodetic_to_cartesian_impl(self, r_eq, r_pol)
}
}
pub fn compute_body_geodetic(
position: DVec3,
t_inertial_pfix: &DMat3,
r_eq: f64,
r_pol: f64,
) -> GeodeticState {
let pos_pfix = *t_inertial_pfix * position;
GeodeticState::from_planet_fixed(pos_pfix, r_eq, r_pol)
}
pub fn compute_body_geodetic_typed<P: Planet>(
position: Position<astrodyn_quantities::frame::PlanetInertial<P>>,
t_inertial_pfix: &DMat3,
r_eq: Length,
r_pol: Length,
) -> GeodeticState {
let pos_pfix = *t_inertial_pfix * position.raw_si();
GeodeticState::from_planet_fixed(pos_pfix, r_eq.get::<meter>(), r_pol.get::<meter>())
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct GeodeticStateTyped {
pub latitude: Angle,
pub longitude: Angle,
pub altitude: Length,
}
impl GeodeticStateTyped {
#[inline]
pub fn from_raw(state: GeodeticState) -> Self {
Self {
latitude: Angle::new::<radian>(state.latitude),
longitude: Angle::new::<radian>(state.longitude),
altitude: Length::new::<meter>(state.altitude),
}
}
#[inline]
pub fn into_raw(self) -> GeodeticState {
GeodeticState {
latitude: self.latitude.get::<radian>(),
longitude: self.longitude.get::<radian>(),
altitude: self.altitude.get::<meter>(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SphericalState {
pub latitude: f64,
pub longitude: f64,
pub altitude: f64,
}
pub fn cartesian_to_spherical(cart: DVec3, r_eq: f64) -> SphericalState {
let r_local = cart.length();
assert!(
r_local > r_eq * 1e-10,
"cartesian_to_spherical: position too close to planet center ({r_local} m)"
);
SphericalState {
latitude: (cart.z / r_local).asin(),
longitude: cart.y.atan2(cart.x),
altitude: r_local - r_eq,
}
}
pub fn spherical_to_cartesian(sph: &SphericalState, r_eq: f64) -> DVec3 {
let radius = r_eq + sph.altitude;
let cos_lat = sph.latitude.cos();
let sin_lat = sph.latitude.sin();
let cos_lon = sph.longitude.cos();
let sin_lon = sph.longitude.sin();
DVec3::new(
radius * cos_lat * cos_lon,
radius * cos_lat * sin_lon,
radius * sin_lat,
)
}
pub(crate) fn cartesian_to_geodetic_impl(cart: DVec3, r_eq: f64, r_pol: f64) -> GeodeticState {
assert!(
cart.x.is_finite() && cart.y.is_finite() && cart.z.is_finite(),
"cartesian_to_geodetic: input contains NaN or Inf ({cart:?})"
);
let x_ellipse_sq = cart.x * cart.x + cart.y * cart.y;
let x_ellipse = x_ellipse_sq.sqrt();
let z_ellipse = cart.z;
let r_ellipse = (x_ellipse_sq + z_ellipse * z_ellipse).sqrt();
assert!(
r_ellipse > r_eq * 1e-10,
"cartesian_to_geodetic: position too close to planet center ({r_ellipse} m)"
);
let (lat, alt) = get_elliptic_parameters(x_ellipse, z_ellipse, r_eq, r_pol);
let longitude = if x_ellipse != 0.0 {
cart.y.atan2(cart.x)
} else {
0.0
};
GeodeticState {
latitude: lat,
longitude,
altitude: alt,
}
}
fn get_elliptic_parameters(r: f64, z: f64, r_eq: f64, r_pol: f64) -> (f64, f64) {
let a = r_eq;
let b = r_pol;
let (lat, y);
if r > 0.0 {
let y0_init = (a * z / (b * r)).atan();
let ar = a * r;
let bz = b * z;
let w = (bz / ar).atan();
let c = (a * a - b * b) / (ar * ar + bz * bz).sqrt();
let mut y0 = y0_init;
let mut y_val = y0;
let mut converged = false;
for _ in 0..MAX_ITERATION_LIMIT {
let d = 2.0 * ((y0 - w).cos() - c * (2.0 * y0).cos());
assert!(
d.abs() > f64::EPSILON,
"geodetic iteration: denominator near zero (d={d})"
);
y_val = y0 - (2.0 * (y0 - w).sin() - c * (2.0 * y0).sin()) / d;
if (y_val - y0).abs() < 1.0e-12 {
converged = true;
break;
}
y0 = y_val;
}
assert!(
converged,
"geodetic iteration did not converge after {MAX_ITERATION_LIMIT} iterations"
);
y = y_val;
lat = (a * y.sin() / (b * y.cos())).atan();
} else {
y = 0.5 * z * std::f64::consts::PI / z.abs();
lat = y;
}
let alt = (r - a * y.cos()) * lat.cos() + (z - b * y.sin()) * lat.sin();
(lat, alt)
}
pub(crate) fn geodetic_to_cartesian_impl(geo: &GeodeticState, r_eq: f64, r_pol: f64) -> DVec3 {
let sin_lat = geo.latitude.sin();
let cos_lat = geo.latitude.cos();
let e_sq = 1.0 - (r_pol * r_pol) / (r_eq * r_eq);
let rc_ellipse = r_eq / (1.0 - e_sq * sin_lat * sin_lat).sqrt();
let x_ellipse = (rc_ellipse + geo.altitude) * cos_lat;
DVec3::new(
x_ellipse * geo.longitude.cos(),
x_ellipse * geo.longitude.sin(),
(rc_ellipse * (1.0 - e_sq) + geo.altitude) * sin_lat,
)
}
pub fn cartesian_to_geodetic_typed<P: Planet>(
pos: Position<PlanetFixed<P>>,
r_eq: Length,
r_pol: Length,
) -> GeodeticStateTyped {
let raw = cartesian_to_geodetic_impl(pos.raw_si(), r_eq.get::<meter>(), r_pol.get::<meter>());
GeodeticStateTyped::from_raw(raw)
}
pub fn geodetic_to_cartesian_typed<P: Planet>(
state: GeodeticStateTyped,
r_eq: Length,
r_pol: Length,
) -> Position<PlanetFixed<P>> {
let raw_state = state.into_raw();
let cart = geodetic_to_cartesian_impl(&raw_state, r_eq.get::<meter>(), r_pol.get::<meter>());
Qty3::from_raw_si(cart)
}
#[cfg(test)]
mod tests {
use super::*;
use astrodyn_quantities::ext::F64Ext;
use astrodyn_quantities::frame::Earth;
use std::f64::consts::PI;
use cartesian_to_geodetic_impl as cartesian_to_geodetic;
use geodetic_to_cartesian_impl as geodetic_to_cartesian;
const EARTH_R_EQ: f64 = 6_378_137.0; const EARTH_R_POL: f64 = EARTH_R_EQ * (1.0 - 1.0 / 298.257_223_563);
#[test]
fn spherical_equator_sea_level() {
let cart = DVec3::new(EARTH_R_EQ, 0.0, 0.0);
let sph = cartesian_to_spherical(cart, EARTH_R_EQ);
assert!((sph.latitude).abs() < 1e-15);
assert!((sph.longitude).abs() < 1e-15);
assert!((sph.altitude).abs() < 1e-6);
}
#[test]
fn spherical_round_trip() {
let original = SphericalState {
latitude: 0.7, longitude: -1.2,
altitude: 400_000.0, };
let cart = spherical_to_cartesian(&original, EARTH_R_EQ);
let recovered = cartesian_to_spherical(cart, EARTH_R_EQ);
assert!((recovered.latitude - original.latitude).abs() < 1e-12);
assert!((recovered.longitude - original.longitude).abs() < 1e-12);
assert!((recovered.altitude - original.altitude).abs() < 1e-6);
}
#[test]
fn geodetic_equator_sea_level() {
let geo = GeodeticState {
latitude: 0.0,
longitude: 0.0,
altitude: 0.0,
};
let cart = geodetic_to_cartesian(&geo, EARTH_R_EQ, EARTH_R_POL);
assert!((cart.x - EARTH_R_EQ).abs() < 1e-6);
assert!(cart.y.abs() < 1e-6);
assert!(cart.z.abs() < 1e-6);
}
#[test]
fn geodetic_north_pole() {
let geo = GeodeticState {
latitude: PI / 2.0,
longitude: 0.0,
altitude: 0.0,
};
let cart = geodetic_to_cartesian(&geo, EARTH_R_EQ, EARTH_R_POL);
assert!(cart.x.abs() < 1e-6);
assert!(cart.y.abs() < 1e-6);
assert!((cart.z - EARTH_R_POL).abs() < 1e-6);
}
#[test]
fn geodetic_south_pole() {
let geo = GeodeticState {
latitude: -PI / 2.0,
longitude: 0.0,
altitude: 0.0,
};
let cart = geodetic_to_cartesian(&geo, EARTH_R_EQ, EARTH_R_POL);
assert!(cart.x.abs() < 1e-6);
assert!(cart.y.abs() < 1e-6);
assert!((cart.z + EARTH_R_POL).abs() < 1e-6);
}
#[test]
fn geodetic_round_trip_equator() {
let original = GeodeticState {
latitude: 0.0,
longitude: 1.5,
altitude: 0.0,
};
let cart = geodetic_to_cartesian(&original, EARTH_R_EQ, EARTH_R_POL);
let recovered = cartesian_to_geodetic(cart, EARTH_R_EQ, EARTH_R_POL);
assert!((recovered.latitude - original.latitude).abs() < 1e-12);
assert!((recovered.longitude - original.longitude).abs() < 1e-12);
assert!((recovered.altitude - original.altitude).abs() < 1e-6);
}
#[test]
fn geodetic_round_trip_mid_latitude() {
let original = GeodeticState {
latitude: 0.9, longitude: -0.5,
altitude: 408_000.0, };
let cart = geodetic_to_cartesian(&original, EARTH_R_EQ, EARTH_R_POL);
let recovered = cartesian_to_geodetic(cart, EARTH_R_EQ, EARTH_R_POL);
assert!(
(recovered.latitude - original.latitude).abs() < 1e-12,
"latitude error: {}",
(recovered.latitude - original.latitude).abs()
);
assert!(
(recovered.longitude - original.longitude).abs() < 1e-12,
"longitude error: {}",
(recovered.longitude - original.longitude).abs()
);
assert!(
(recovered.altitude - original.altitude).abs() < 1e-6,
"altitude error: {} m",
(recovered.altitude - original.altitude).abs()
);
}
#[test]
fn geodetic_round_trip_poles() {
for &lat in &[PI / 2.0, -PI / 2.0] {
let original = GeodeticState {
latitude: lat,
longitude: 0.0,
altitude: 100_000.0,
};
let cart = geodetic_to_cartesian(&original, EARTH_R_EQ, EARTH_R_POL);
let recovered = cartesian_to_geodetic(cart, EARTH_R_EQ, EARTH_R_POL);
assert!(
(recovered.latitude - original.latitude).abs() < 1e-10,
"pole latitude error: {}",
(recovered.latitude - original.latitude).abs()
);
assert!(
(recovered.altitude - original.altitude).abs() < 1e-6,
"pole altitude error: {} m",
(recovered.altitude - original.altitude).abs()
);
}
}
#[test]
fn geodetic_round_trip_high_altitude() {
let original = GeodeticState {
latitude: 0.0,
longitude: 0.0,
altitude: 35_786_000.0, };
let cart = geodetic_to_cartesian(&original, EARTH_R_EQ, EARTH_R_POL);
let recovered = cartesian_to_geodetic(cart, EARTH_R_EQ, EARTH_R_POL);
assert!((recovered.latitude - original.latitude).abs() < 1e-12);
assert!((recovered.altitude - original.altitude).abs() < 1e-6);
}
#[test]
fn geodetic_round_trip_negative_altitude() {
let original = GeodeticState {
latitude: 0.5,
longitude: 1.0,
altitude: -1000.0, };
let cart = geodetic_to_cartesian(&original, EARTH_R_EQ, EARTH_R_POL);
let recovered = cartesian_to_geodetic(cart, EARTH_R_EQ, EARTH_R_POL);
assert!((recovered.latitude - original.latitude).abs() < 1e-12);
assert!((recovered.longitude - original.longitude).abs() < 1e-12);
assert!((recovered.altitude - original.altitude).abs() < 1e-6);
}
#[test]
fn geodetic_round_trip_ten_points() {
let test_cases = [
(0.0, 0.0, 0.0, "equator prime meridian"),
(PI / 2.0, 0.0, 0.0, "north pole"),
(-PI / 2.0, 0.0, 0.0, "south pole"),
(0.4838, 1.5175, 8_848.0, "Mount Everest ~27.99N 86.93E"),
(0.9, 0.5, 408_000.0, "ISS altitude"),
(-0.6, 2.5, 200_000.0, "southern hemisphere LEO"),
(0.0, PI, 35_786_000.0, "GEO at 180 longitude"),
(1.0, -1.0, -500.0, "subsurface mid-lat"),
(0.01, 3.0, 0.0, "near equator east"),
(1.55, 0.0, 10_000.0, "near pole, 10 km up"),
(-1.2, -2.8, 600_000.0, "deep south high alt"),
];
for (lat, lon, alt, label) in test_cases {
let original = GeodeticState {
latitude: lat,
longitude: lon,
altitude: alt,
};
let cart = geodetic_to_cartesian(&original, EARTH_R_EQ, EARTH_R_POL);
let recovered = cartesian_to_geodetic(cart, EARTH_R_EQ, EARTH_R_POL);
let lat_err = (recovered.latitude - original.latitude).abs();
let lon_err = if lat.abs() > 1.5 {
0.0 } else {
(recovered.longitude - original.longitude).abs()
};
let alt_err = (recovered.altitude - original.altitude).abs();
assert!(lat_err < 1e-10, "{label}: latitude error = {lat_err}");
assert!(lon_err < 1e-10, "{label}: longitude error = {lon_err}");
assert!(alt_err < 1e-6, "{label}: altitude error = {alt_err} m");
}
}
#[test]
fn typed_round_trip_iss_landmark() {
let original = GeodeticStateTyped {
latitude: 51.6.deg(),
longitude: 30.0.deg(),
altitude: 408_000.0.m(),
};
let r_eq = EARTH_R_EQ.m();
let r_pol = EARTH_R_POL.m();
let cart: Position<PlanetFixed<Earth>> = geodetic_to_cartesian_typed(original, r_eq, r_pol);
let recovered = cartesian_to_geodetic_typed(cart, r_eq, r_pol);
let lat_err =
(recovered.latitude.get::<radian>() - original.latitude.get::<radian>()).abs();
let lon_err =
(recovered.longitude.get::<radian>() - original.longitude.get::<radian>()).abs();
let alt_err = (recovered.altitude.get::<meter>() - original.altitude.get::<meter>()).abs();
assert!(lat_err < 1e-14, "lat err = {lat_err}");
assert!(lon_err < 1e-14, "lon err = {lon_err}");
assert!(alt_err < 1e-9, "alt err = {alt_err} m");
}
#[test]
fn typed_matches_f64_cartesian_to_geodetic() {
let raw_pos = DVec3::new(4_500_000.0, -2_800_000.0, 3_700_000.0);
let pos: Position<PlanetFixed<Earth>> = Qty3::from_raw_si(raw_pos);
let f64_result = cartesian_to_geodetic(raw_pos, EARTH_R_EQ, EARTH_R_POL);
let typed_result = cartesian_to_geodetic_typed(pos, EARTH_R_EQ.m(), EARTH_R_POL.m());
assert_eq!(typed_result.latitude.get::<radian>(), f64_result.latitude);
assert_eq!(typed_result.longitude.get::<radian>(), f64_result.longitude);
assert_eq!(typed_result.altitude.get::<meter>(), f64_result.altitude);
}
#[test]
fn typed_matches_f64_geodetic_to_cartesian() {
let raw = GeodeticState {
latitude: 0.9,
longitude: -0.5,
altitude: 408_000.0,
};
let typed = GeodeticStateTyped::from_raw(raw);
let f64_cart = geodetic_to_cartesian(&raw, EARTH_R_EQ, EARTH_R_POL);
let typed_cart: Position<PlanetFixed<Earth>> =
geodetic_to_cartesian_typed(typed, EARTH_R_EQ.m(), EARTH_R_POL.m());
let typed_raw = typed_cart.raw_si();
assert_eq!(typed_raw.x, f64_cart.x);
assert_eq!(typed_raw.y, f64_cart.y);
assert_eq!(typed_raw.z, f64_cart.z);
}
#[test]
fn typed_raw_conversion_round_trip() {
let raw = GeodeticState {
latitude: 0.123_456_789,
longitude: -1.987_654_321,
altitude: 12_345.678,
};
let typed = GeodeticStateTyped::from_raw(raw);
let back = typed.into_raw();
assert_eq!(raw.latitude, back.latitude);
assert_eq!(raw.longitude, back.longitude);
assert_eq!(raw.altitude, back.altitude);
}
use proptest::prelude::*;
fn arb_finite_f64() -> impl Strategy<Value = f64> {
proptest::num::f64::ANY.prop_filter("finite", |x| x.is_finite())
}
fn arb_geodetic_state() -> impl Strategy<Value = GeodeticState> {
(arb_finite_f64(), arb_finite_f64(), arb_finite_f64()).prop_map(
|(latitude, longitude, altitude)| GeodeticState {
latitude,
longitude,
altitude,
},
)
}
proptest! {
#[test]
fn round_trip_geodetic_untyped_typed_untyped(orig in arb_geodetic_state()) {
let typed = GeodeticStateTyped::from_raw(orig);
prop_assert_eq!(typed.into_raw(), orig);
}
#[test]
fn round_trip_geodetic_typed_untyped_typed(orig in arb_geodetic_state()) {
let typed = GeodeticStateTyped::from_raw(orig);
let lifted = GeodeticStateTyped::from_raw(typed.into_raw());
prop_assert_eq!(lifted, typed);
}
}
}