use crate::astro::constants::{MU_EARTH, RE_EARTH};
use crate::astro::error::PropagationError;
use crate::astro::forces::r#trait::ForceModel;
use crate::astro::propagator::api::PropagationContext;
use crate::astro::state::CartesianState;
use nalgebra::Vector3;
pub const EGM96_MU_KM3_S2: f64 = 398_600.441_5;
pub const EGM96_REFERENCE_RADIUS_KM: f64 = 6_378.136_3;
pub const EGM96_EMBEDDED_MAX_DEGREE: u16 = 36;
pub const EGM96_EMBEDDED_MAX_ORDER: u16 = 36;
pub const EGM96_DEGREE_ORDER_36: &str = include_str!("egm96_to_36.txt");
#[derive(Debug, Clone, Copy, PartialEq)]
struct CoefficientPair {
c: f64,
s: f64,
}
impl CoefficientPair {
const ZERO: Self = Self { c: 0.0, s: 0.0 };
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SphericalHarmonicCoefficient {
pub degree: u16,
pub order: u16,
pub c: f64,
pub s: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SphericalHarmonicGravityConfig {
pub mu_km3_s2: f64,
pub reference_radius_km: f64,
pub max_degree: u16,
pub max_order: u16,
}
impl SphericalHarmonicGravityConfig {
pub fn egm96(max_degree: u16, max_order: u16) -> Result<Self, PropagationError> {
validate_degree_order(max_degree, max_order)?;
if max_degree > EGM96_EMBEDDED_MAX_DEGREE || max_order > EGM96_EMBEDDED_MAX_ORDER {
return Err(PropagationError::InvalidInput(
"EGM96 embedded degree/order must not exceed 36".to_string(),
));
}
Ok(Self {
mu_km3_s2: EGM96_MU_KM3_S2,
reference_radius_km: EGM96_REFERENCE_RADIUS_KM,
max_degree,
max_order,
})
}
pub fn earth(max_degree: u16, max_order: u16) -> Result<Self, PropagationError> {
validate_degree_order(max_degree, max_order)?;
if max_degree > EGM96_EMBEDDED_MAX_DEGREE || max_order > EGM96_EMBEDDED_MAX_ORDER {
return Err(PropagationError::InvalidInput(
"EGM96 embedded degree/order must not exceed 36".to_string(),
));
}
Ok(Self {
mu_km3_s2: MU_EARTH,
reference_radius_km: RE_EARTH,
max_degree,
max_order,
})
}
pub fn build(self) -> Result<SphericalHarmonicGravity, PropagationError> {
SphericalHarmonicGravity::from_normalized_ascii_table(
self.mu_km3_s2,
self.reference_radius_km,
self.max_degree,
self.max_order,
EGM96_DEGREE_ORDER_36,
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SphericalHarmonicGravity {
mu_km3_s2: f64,
reference_radius_km: f64,
max_degree: u16,
max_order: u16,
coefficients: Vec<CoefficientPair>,
has_non_zonal_terms: bool,
}
impl Default for SphericalHarmonicGravity {
fn default() -> Self {
Self::egm96_degree_order_36().expect("embedded EGM96 degree/order 36 is valid")
}
}
impl SphericalHarmonicGravity {
pub fn egm96_degree_order_36() -> Result<Self, PropagationError> {
SphericalHarmonicGravityConfig::egm96(EGM96_EMBEDDED_MAX_DEGREE, EGM96_EMBEDDED_MAX_ORDER)?
.build()
}
pub fn egm96_truncated(max_degree: u16, max_order: u16) -> Result<Self, PropagationError> {
SphericalHarmonicGravityConfig::egm96(max_degree, max_order)?.build()
}
pub fn earth_egm96_truncated(
max_degree: u16,
max_order: u16,
) -> Result<Self, PropagationError> {
SphericalHarmonicGravityConfig::earth(max_degree, max_order)?.build()
}
pub fn from_normalized_coefficients(
mu_km3_s2: f64,
reference_radius_km: f64,
max_degree: u16,
max_order: u16,
coefficients: &[SphericalHarmonicCoefficient],
) -> Result<Self, PropagationError> {
validate_finite_positive(mu_km3_s2, "mu_km3_s2")?;
validate_finite_positive(reference_radius_km, "reference_radius_km")?;
validate_degree_order(max_degree, max_order)?;
let len = triangular_len(max_degree);
let mut dense = vec![CoefficientPair::ZERO; len];
let mut seen = vec![false; len];
let mut has_non_zonal_terms = false;
for coefficient in coefficients {
validate_coefficient(*coefficient)?;
if coefficient.degree < 2
|| coefficient.degree > max_degree
|| coefficient.order > max_order
{
continue;
}
let idx = coefficient_index(coefficient.degree, coefficient.order);
if seen[idx] {
return Err(PropagationError::InvalidInput(format!(
"duplicate spherical harmonic coefficient {},{}",
coefficient.degree, coefficient.order
)));
}
seen[idx] = true;
dense[idx] = CoefficientPair {
c: coefficient.c,
s: coefficient.s,
};
if coefficient.order > 0 && (coefficient.c != 0.0 || coefficient.s != 0.0) {
has_non_zonal_terms = true;
}
}
Ok(Self {
mu_km3_s2,
reference_radius_km,
max_degree,
max_order,
coefficients: dense,
has_non_zonal_terms,
})
}
pub fn from_normalized_ascii_table(
mu_km3_s2: f64,
reference_radius_km: f64,
max_degree: u16,
max_order: u16,
table: &str,
) -> Result<Self, PropagationError> {
let coefficients = parse_ascii_coefficients(table)?;
Self::from_normalized_coefficients(
mu_km3_s2,
reference_radius_km,
max_degree,
max_order,
&coefficients,
)
}
pub fn mu_km3_s2(&self) -> f64 {
self.mu_km3_s2
}
pub fn reference_radius_km(&self) -> f64 {
self.reference_radius_km
}
pub fn max_degree(&self) -> u16 {
self.max_degree
}
pub fn max_order(&self) -> u16 {
self.max_order
}
pub fn coefficient(&self, degree: u16, order: u16) -> Option<SphericalHarmonicCoefficient> {
if degree > self.max_degree || order > degree || order > self.max_order {
return None;
}
let pair = self.coefficients[coefficient_index(degree, order)];
Some(SphericalHarmonicCoefficient {
degree,
order,
c: pair.c,
s: pair.s,
})
}
pub fn body_fixed_acceleration_km_s2(
&self,
position_body_fixed_km: [f64; 3],
) -> Result<[f64; 3], PropagationError> {
validate_vec3(position_body_fixed_km, "position_body_fixed_km")?;
let x = position_body_fixed_km[0];
let y = position_body_fixed_km[1];
let z = position_body_fixed_km[2];
let rho2 = x * x + y * y;
let r2 = rho2 + z * z;
if r2 == 0.0 {
return Err(PropagationError::NumericalFailure(
"zero position magnitude".to_string(),
));
}
let r = r2.sqrt();
let rho = rho2.sqrt();
let sin_lat = z / r;
let cos_lat = rho / r;
if rho == 0.0 {
return Ok(self.pole_acceleration_km_s2(r, sin_lat));
}
let legendre = normalized_legendre(self.max_degree, self.max_order, sin_lat, cos_lat)?;
let (cos_m, sin_m) = longitude_trig(self.max_order, x, y, rho)?;
let mut radial_accel = 0.0;
let mut lat_accel = 0.0;
let mut lon_accel = 0.0;
let mut radius_power = 1.0;
let mu_over_r2 = self.mu_km3_s2 / r2;
for degree in 1..=self.max_degree {
radius_power *= self.reference_radius_km / r;
if degree < 2 {
continue;
}
let factor = mu_over_r2 * radius_power;
let order_limit = degree.min(self.max_order);
let mut w = 0.0;
let mut d_w_d_s = 0.0;
let mut d_w_d_lambda = 0.0;
for order in 0..=order_limit {
let coefficient = self.coefficients[coefficient_index(degree, order)];
if coefficient.c == 0.0 && coefficient.s == 0.0 {
continue;
}
let idx = coefficient_index(degree, order);
let trig = coefficient.c * cos_m[usize::from(order)]
+ coefficient.s * sin_m[usize::from(order)];
w += legendre.p[idx] * trig;
d_w_d_s += legendre.dp_ds[idx] * trig;
if order > 0 {
let order_f64 = f64::from(order);
d_w_d_lambda += legendre.p[idx]
* order_f64
* (coefficient.s * cos_m[usize::from(order)]
- coefficient.c * sin_m[usize::from(order)]);
}
}
radial_accel -= f64::from(degree + 1) * factor * w;
lat_accel += factor * cos_lat * d_w_d_s;
if d_w_d_lambda != 0.0 {
if cos_lat == 0.0 {
return Err(PropagationError::NumericalFailure(
"longitude derivative is singular at the pole".to_string(),
));
}
lon_accel += factor * d_w_d_lambda / cos_lat;
}
}
if rho == 0.0 {
return Ok([0.0, 0.0, radial_accel * sin_lat]);
}
let er = [x / r, y / r, z / r];
let e_lat = [-sin_lat * x / rho, -sin_lat * y / rho, cos_lat];
let e_lon = [-y / rho, x / rho, 0.0];
Ok([
radial_accel * er[0] + lat_accel * e_lat[0] + lon_accel * e_lon[0],
radial_accel * er[1] + lat_accel * e_lat[1] + lon_accel * e_lon[1],
radial_accel * er[2] + lat_accel * e_lat[2] + lon_accel * e_lon[2],
])
}
fn pole_acceleration_km_s2(&self, r: f64, sin_lat: f64) -> [f64; 3] {
let mut radial_accel = 0.0;
let mut transverse_accel = [0.0_f64; 2];
let mut radius_power = 1.0;
let mu_over_r2 = self.mu_km3_s2 / (r * r);
let is_south_pole = sin_lat < 0.0;
for degree in 1..=self.max_degree {
radius_power *= self.reference_radius_km / r;
if degree < 2 {
continue;
}
let factor = mu_over_r2 * radius_power;
let zonal = self.coefficients[coefficient_index(degree, 0)];
if zonal.c != 0.0 {
let pole_sign = if is_south_pole && degree % 2 == 1 {
-1.0
} else {
1.0
};
let p_n0 = pole_sign * f64::from(2 * degree + 1).sqrt();
radial_accel -= f64::from(degree + 1) * factor * p_n0 * zonal.c;
}
if self.max_order >= 1 {
let tesseral = self.coefficients[coefficient_index(degree, 1)];
if tesseral.c != 0.0 || tesseral.s != 0.0 {
let pole_sign = if is_south_pole && degree % 2 == 0 {
-1.0
} else {
1.0
};
let q_n1 = pole_sign
* (f64::from(degree) * f64::from(degree + 1) * f64::from(2 * degree + 1)
/ 2.0)
.sqrt();
transverse_accel[0] += factor * q_n1 * tesseral.c;
transverse_accel[1] += factor * q_n1 * tesseral.s;
}
}
}
[
transverse_accel[0],
transverse_accel[1],
radial_accel * sin_lat,
]
}
}
impl ForceModel for SphericalHarmonicGravity {
fn acceleration(
&self,
state: &CartesianState,
ctx: &PropagationContext,
) -> Result<Vector3<f64>, PropagationError> {
let orientation = match ctx.body_fixed_frame_provider() {
Some(provider) => Some(
provider
.orientation_at_tdb_seconds(state.epoch_tdb_seconds)
.map_err(|error| {
PropagationError::ForceModelFailure(format!(
"body-fixed frame evaluation failed: {error}"
))
})?,
),
None => {
if self.has_non_zonal_terms {
return Err(PropagationError::InvalidInput(
"non-zonal spherical-harmonic gravity requires a body-fixed frame provider"
.to_string(),
));
}
None
}
};
let position_body_fixed_km = match orientation {
Some(orientation) => orientation
.gcrf_to_itrf_position_km(state.position_array())
.map_err(|error| {
PropagationError::ForceModelFailure(format!(
"body-fixed position rotation failed: {error}"
))
})?,
None => state.position_array(),
};
let accel_body_fixed = self.body_fixed_acceleration_km_s2(position_body_fixed_km)?;
let accel_gcrf = match orientation {
Some(orientation) => orientation
.itrf_to_gcrf_position_km(accel_body_fixed)
.map_err(|error| {
PropagationError::ForceModelFailure(format!(
"inertial acceleration rotation failed: {error}"
))
})?,
None => accel_body_fixed,
};
Ok(Vector3::new(accel_gcrf[0], accel_gcrf[1], accel_gcrf[2]))
}
}
struct LegendreValues {
p: Vec<f64>,
dp_ds: Vec<f64>,
}
fn parse_ascii_coefficients(
table: &str,
) -> Result<Vec<SphericalHarmonicCoefficient>, PropagationError> {
let mut coefficients = Vec::new();
for (line_idx, line) in table.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let mut fields = trimmed.split_whitespace();
let first = fields.next().expect("trimmed line has a first field");
let degree_token =
if first.eq_ignore_ascii_case("gfc") || first.eq_ignore_ascii_case("gcoef") {
fields
.next()
.ok_or_else(|| table_error(line_idx, "missing degree"))?
} else {
first
};
let order_token = fields
.next()
.ok_or_else(|| table_error(line_idx, "missing order"))?;
let c_token = fields
.next()
.ok_or_else(|| table_error(line_idx, "missing Cnm"))?;
let s_token = fields
.next()
.ok_or_else(|| table_error(line_idx, "missing Snm"))?;
let degree = parse_u16_token(degree_token, line_idx, "degree")?;
let order = parse_u16_token(order_token, line_idx, "order")?;
let c = parse_f64_token(c_token, line_idx, "Cnm")?;
let s = parse_f64_token(s_token, line_idx, "Snm")?;
coefficients.push(SphericalHarmonicCoefficient {
degree,
order,
c,
s,
});
}
Ok(coefficients)
}
fn normalized_legendre(
max_degree: u16,
max_order: u16,
sin_lat: f64,
cos_lat: f64,
) -> Result<LegendreValues, PropagationError> {
let len = triangular_len(max_degree);
let mut p = vec![0.0; len];
let mut dp_ds = vec![0.0; len];
p[0] = 1.0;
if cos_lat == 0.0 && max_order > 0 {
return Err(PropagationError::NumericalFailure(
"associated Legendre derivative is singular at the pole".to_string(),
));
}
let du_ds = if max_order > 0 {
-sin_lat / cos_lat
} else {
0.0
};
initialize_low_degree_legendre(
max_degree, max_order, sin_lat, cos_lat, du_ds, &mut p, &mut dp_ds,
);
for degree in 4..=max_degree {
let diag_order = degree;
if diag_order <= max_order {
let coeff = ((f64::from(2 * degree + 1)) / f64::from(2 * degree)).sqrt();
let idx = coefficient_index(degree, diag_order);
let prev = coefficient_index(degree - 1, diag_order - 1);
p[idx] = coeff * cos_lat * p[prev];
dp_ds[idx] = coeff * (du_ds * p[prev] + cos_lat * dp_ds[prev]);
}
let order_limit = (degree - 1).min(max_order);
for order in 0..=order_limit {
let idx = coefficient_index(degree, order);
if degree == order + 1 {
let coeff = f64::from(2 * degree + 1).sqrt();
let prev = coefficient_index(degree - 1, order);
p[idx] = coeff * sin_lat * p[prev];
dp_ds[idx] = coeff * (p[prev] + sin_lat * dp_ds[prev]);
} else {
let a = ((f64::from(2 * degree + 1) * f64::from(2 * degree - 1))
/ (f64::from(degree - order) * f64::from(degree + order)))
.sqrt();
let b = ((f64::from(2 * degree + 1)
* f64::from(degree + order - 1)
* f64::from(degree - order - 1))
/ (f64::from(2 * degree - 3)
* f64::from(degree - order)
* f64::from(degree + order)))
.sqrt();
let prev1 = coefficient_index(degree - 1, order);
let prev2 = coefficient_index(degree - 2, order);
p[idx] = a * sin_lat * p[prev1] - b * p[prev2];
dp_ds[idx] = a * (p[prev1] + sin_lat * dp_ds[prev1]) - b * dp_ds[prev2];
}
}
}
Ok(LegendreValues { p, dp_ds })
}
fn initialize_low_degree_legendre(
max_degree: u16,
max_order: u16,
sin_lat: f64,
cos_lat: f64,
du_ds: f64,
p: &mut [f64],
dp_ds: &mut [f64],
) {
let sqrt3 = 3.0_f64.sqrt();
if max_degree >= 1 {
let idx = coefficient_index(1, 0);
p[idx] = sqrt3 * sin_lat;
dp_ds[idx] = sqrt3;
if max_order >= 1 {
let idx = coefficient_index(1, 1);
p[idx] = sqrt3 * cos_lat;
dp_ds[idx] = sqrt3 * du_ds;
}
}
let sqrt5 = 5.0_f64.sqrt();
let sqrt15 = 15.0_f64.sqrt();
if max_degree >= 2 {
let idx = coefficient_index(2, 0);
p[idx] = 0.5 * sqrt5 * (3.0 * sin_lat * sin_lat - 1.0);
dp_ds[idx] = 3.0 * sqrt5 * sin_lat;
if max_order >= 1 {
let idx = coefficient_index(2, 1);
p[idx] = sqrt15 * sin_lat * cos_lat;
dp_ds[idx] = sqrt15 * (cos_lat + sin_lat * du_ds);
}
if max_order >= 2 {
let idx = coefficient_index(2, 2);
p[idx] = 0.5 * sqrt15 * cos_lat * cos_lat;
dp_ds[idx] = sqrt15 * cos_lat * du_ds;
}
}
if max_degree >= 3 {
let sqrt7 = 7.0_f64.sqrt();
let sqrt42 = 42.0_f64.sqrt();
let sqrt70 = 70.0_f64.sqrt();
let sqrt105 = 105.0_f64.sqrt();
let sin2 = sin_lat * sin_lat;
let cos2 = cos_lat * cos_lat;
let idx = coefficient_index(3, 0);
p[idx] = 0.5 * sqrt7 * (5.0 * sin_lat * sin2 - 3.0 * sin_lat);
dp_ds[idx] = 0.5 * sqrt7 * (15.0 * sin2 - 3.0);
if max_order >= 1 {
let idx = coefficient_index(3, 1);
let term = 5.0 * sin2 - 1.0;
p[idx] = 0.25 * sqrt42 * cos_lat * term;
dp_ds[idx] = 0.25 * sqrt42 * (du_ds * term + cos_lat * 10.0 * sin_lat);
}
if max_order >= 2 {
let idx = coefficient_index(3, 2);
p[idx] = 0.5 * sqrt105 * sin_lat * cos2;
dp_ds[idx] = 0.5 * sqrt105 * (cos2 + 2.0 * sin_lat * cos_lat * du_ds);
}
if max_order >= 3 {
let idx = coefficient_index(3, 3);
p[idx] = 0.25 * sqrt70 * cos_lat * cos2;
dp_ds[idx] = 0.75 * sqrt70 * cos2 * du_ds;
}
}
}
fn longitude_trig(
max_order: u16,
x: f64,
y: f64,
rho: f64,
) -> Result<(Vec<f64>, Vec<f64>), PropagationError> {
let len = usize::from(max_order) + 1;
let mut cos_m = vec![0.0; len];
let mut sin_m = vec![0.0; len];
cos_m[0] = 1.0;
if max_order == 0 {
return Ok((cos_m, sin_m));
}
if rho == 0.0 {
return Err(PropagationError::NumericalFailure(
"longitude is undefined at the pole".to_string(),
));
}
let cos_1 = x / rho;
let sin_1 = y / rho;
cos_m[1] = cos_1;
sin_m[1] = sin_1;
for order in 2..=usize::from(max_order) {
cos_m[order] = cos_m[order - 1] * cos_1 - sin_m[order - 1] * sin_1;
sin_m[order] = sin_m[order - 1] * cos_1 + cos_m[order - 1] * sin_1;
}
Ok((cos_m, sin_m))
}
fn validate_coefficient(coefficient: SphericalHarmonicCoefficient) -> Result<(), PropagationError> {
if coefficient.order > coefficient.degree {
return Err(PropagationError::InvalidInput(format!(
"coefficient order {} exceeds degree {}",
coefficient.order, coefficient.degree
)));
}
validate_finite(coefficient.c, "coefficient.c")?;
validate_finite(coefficient.s, "coefficient.s")
}
fn validate_degree_order(max_degree: u16, max_order: u16) -> Result<(), PropagationError> {
if max_degree < 2 {
return Err(PropagationError::InvalidInput(
"max_degree must be at least 2".to_string(),
));
}
if max_order > max_degree {
return Err(PropagationError::InvalidInput(
"max_order must not exceed max_degree".to_string(),
));
}
Ok(())
}
fn validate_finite_positive(value: f64, field: &'static str) -> Result<(), PropagationError> {
validate_finite(value, field)?;
if value <= 0.0 {
return Err(PropagationError::InvalidInput(format!(
"{field} must be positive"
)));
}
Ok(())
}
fn validate_finite(value: f64, field: &'static str) -> Result<(), PropagationError> {
if value.is_finite() {
Ok(())
} else {
Err(PropagationError::InvalidInput(format!(
"{field} must be finite"
)))
}
}
fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), PropagationError> {
if value.iter().all(|component| component.is_finite()) {
Ok(())
} else {
Err(PropagationError::InvalidInput(format!(
"{field} components must be finite"
)))
}
}
fn parse_u16_token(
token: &str,
line_idx: usize,
field: &'static str,
) -> Result<u16, PropagationError> {
token
.parse::<u16>()
.map_err(|_| table_error(line_idx, field))
}
fn parse_f64_token(
token: &str,
line_idx: usize,
field: &'static str,
) -> Result<f64, PropagationError> {
let normalized;
let parsed = if token.contains('D') || token.contains('d') {
normalized = token.replace(['D', 'd'], "E");
normalized.parse::<f64>()
} else {
token.parse::<f64>()
};
parsed.map_err(|_| table_error(line_idx, field))
}
fn table_error(line_idx: usize, reason: &'static str) -> PropagationError {
PropagationError::InvalidInput(format!("coefficient table line {}: {reason}", line_idx + 1))
}
fn triangular_len(max_degree: u16) -> usize {
let n = usize::from(max_degree);
(n + 1) * (n + 2) / 2
}
fn coefficient_index(degree: u16, order: u16) -> usize {
let degree = usize::from(degree);
degree * (degree + 1) / 2 + usize::from(order)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astro::constants::{
J2_EARTH, J3_EARTH, J4_EARTH, J5_EARTH, J6_EARTH, MU_EARTH, RE_EARTH,
};
use crate::astro::forces::ForceModel;
use crate::astro::forces::{ZonalCoefficients, ZonalDegrees, ZonalGravity};
use crate::astro::frames::TdbEarthOrientationProvider;
use crate::astro::propagator::{ForceModelKind, IntegratorKind, IntegratorOptions};
use crate::astro::propagator::{PropagationContext, StatePropagator};
use crate::astro::state::CartesianState;
use std::sync::Arc;
fn assert_close(actual: f64, expected: f64, tolerance: f64) {
assert!(
(actual - expected).abs() <= tolerance,
"actual={actual} expected={expected} diff={}",
actual - expected
);
}
fn assert_close_vec(actual: [f64; 3], expected: [f64; 3], tolerance: f64) {
for axis in 0..3 {
assert_close(actual[axis], expected[axis], tolerance);
}
}
fn zonal_coefficients(max_degree: u16) -> Vec<SphericalHarmonicCoefficient> {
let values = [
(2_u16, J2_EARTH),
(3_u16, J3_EARTH),
(4_u16, J4_EARTH),
(5_u16, J5_EARTH),
(6_u16, J6_EARTH),
];
values
.into_iter()
.filter(|(degree, _)| *degree <= max_degree)
.map(|(degree, j)| SphericalHarmonicCoefficient {
degree,
order: 0,
c: -j / f64::from(2 * degree + 1).sqrt(),
s: 0.0,
})
.collect()
}
fn zonal_gravity(max_degree: u8) -> ZonalGravity {
ZonalGravity::new(
MU_EARTH,
RE_EARTH,
ZonalDegrees::through(max_degree).expect("zonal degree"),
ZonalCoefficients::default(),
)
}
#[test]
fn egm96_coefficients_pin_published_values() {
let gravity = SphericalHarmonicGravity::egm96_degree_order_36().expect("EGM96");
let c20 = gravity.coefficient(2, 0).expect("C20");
assert_eq!(c20.c.to_bits(), (-0.484_165_371_736e-3_f64).to_bits());
assert_eq!(c20.s.to_bits(), 0.0_f64.to_bits());
let c21 = gravity.coefficient(2, 1).expect("C21");
assert_eq!(c21.c.to_bits(), (-0.186_987_635_955e-9_f64).to_bits());
assert_eq!(c21.s.to_bits(), 0.119_528_012_031e-8_f64.to_bits());
let c22 = gravity.coefficient(2, 2).expect("C22");
assert_eq!(c22.c.to_bits(), 0.243_914_352_398e-5_f64.to_bits());
assert_eq!(c22.s.to_bits(), (-0.140_016_683_654e-5_f64).to_bits());
let c36 = gravity.coefficient(36, 36).expect("C36");
assert_eq!(c36.c.to_bits(), 0.460_146_465_720e-8_f64.to_bits());
assert_eq!(c36.s.to_bits(), (-0.594_245_336_314e-8_f64).to_bits());
}
#[test]
fn legendre_recursion_matches_low_degree_polynomial_bits() {
let legendre =
normalized_legendre(3, 3, 0.3125, 0.949_917_759_598_166_5).expect("Legendre values");
let expected = [
(0_u16, 0_u16, 1.0_f64),
(1, 0, 0.541_265_877_365_274_1),
(1, 1, 1.645_305_822_636_022_9),
(2, 0, -0.790_484_968_608_324_2),
(2, 1, 1.149_692_394_746_987_3),
(2, 2, 1.747_381_158_152_174_5),
(3, 0, -1.038_341_121_224_689_8),
(3, 1, -0.787_556_991_899_063_4),
(3, 2, 1.444_729_996_909_586_8),
(3, 3, 1.792_862_776_822_135_2),
];
for (degree, order, expected_value) in expected {
assert_eq!(
legendre.p[coefficient_index(degree, order)].to_bits(),
expected_value.to_bits(),
"degree={degree} order={order}"
);
}
}
#[test]
fn zonal_only_matches_landed_closed_forms() {
let states = [
CartesianState::new(0.0, [7000.0, -1210.0, 1300.0], [0.0, 0.0, 0.0]),
CartesianState::new(0.0, [-4200.0, 5320.0, 2500.0], [0.0, 0.0, 0.0]),
CartesianState::new(0.0, [1200.0, -6800.0, -1700.0], [0.0, 0.0, 0.0]),
];
for max_degree in 2_u16..=6 {
let gravity = SphericalHarmonicGravity::from_normalized_coefficients(
MU_EARTH,
RE_EARTH,
max_degree,
0,
&zonal_coefficients(max_degree),
)
.expect("zonal harmonic gravity");
let zonal = zonal_gravity(max_degree as u8);
for state in states {
let actual = gravity
.acceleration(&state, &PropagationContext::default())
.expect("harmonic acceleration");
let expected = zonal
.acceleration(&state, &PropagationContext::default())
.expect("zonal acceleration");
assert_close_vec(
[actual.x, actual.y, actual.z],
[expected.x, expected.y, expected.z],
2.0e-20,
);
}
}
}
#[test]
fn degree_two_matches_closed_form_tensor() {
let coefficients = [
SphericalHarmonicCoefficient {
degree: 2,
order: 0,
c: -J2_EARTH / 5.0_f64.sqrt(),
s: 0.0,
},
SphericalHarmonicCoefficient {
degree: 2,
order: 1,
c: -1.25e-9,
s: 2.75e-9,
},
SphericalHarmonicCoefficient {
degree: 2,
order: 2,
c: 2.4e-6,
s: -1.4e-6,
},
];
let gravity = SphericalHarmonicGravity::from_normalized_coefficients(
MU_EARTH,
RE_EARTH,
2,
2,
&coefficients,
)
.expect("degree two gravity");
let position = [7033.0, -1221.0, 1329.0];
let actual = gravity
.body_fixed_acceleration_km_s2(position)
.expect("body acceleration");
let expected = degree_two_closed_form(position, MU_EARTH, RE_EARTH, &coefficients);
assert_close_vec(actual, expected, 5.0e-21);
}
#[test]
fn degree_two_pole_limit_matches_closed_form_tensor() {
let coefficients = [
SphericalHarmonicCoefficient {
degree: 2,
order: 0,
c: -J2_EARTH / 5.0_f64.sqrt(),
s: 0.0,
},
SphericalHarmonicCoefficient {
degree: 2,
order: 1,
c: -1.25e-9,
s: 2.75e-9,
},
SphericalHarmonicCoefficient {
degree: 2,
order: 2,
c: 2.4e-6,
s: -1.4e-6,
},
];
let gravity = SphericalHarmonicGravity::from_normalized_coefficients(
MU_EARTH,
RE_EARTH,
2,
2,
&coefficients,
)
.expect("degree two gravity");
let position = [0.0, 0.0, 7000.0];
let actual = gravity
.body_fixed_acceleration_km_s2(position)
.expect("pole acceleration");
let expected = degree_two_closed_form(position, MU_EARTH, RE_EARTH, &coefficients);
assert_close_vec(actual, expected, 5.0e-21);
}
#[test]
fn ascii_loader_accepts_gfc_rows_and_truncates() {
let table = "\
gfc 0 0 1.0 0.0 0.0 0.0
gfc 2 0 -0.484165371736D-03 0.000000000000D+00 0.0 0.0
gfc 2 1 -0.186987635955D-09 0.119528012031D-08 0.0 0.0
gfc 3 0 0.957161207093D-06 0.000000000000D+00 0.0 0.0
";
let gravity = SphericalHarmonicGravity::from_normalized_ascii_table(
EGM96_MU_KM3_S2,
EGM96_REFERENCE_RADIUS_KM,
2,
0,
table,
)
.expect("ASCII loader");
assert!(gravity.coefficient(3, 0).is_none());
assert_eq!(
gravity.coefficient(2, 0).expect("C20").c.to_bits(),
(-0.484_165_371_736e-3_f64).to_bits()
);
assert_eq!(gravity.max_order(), 0);
}
#[test]
fn leo_n8_propagation_is_bounded_and_separates_from_j2() {
let initial = CartesianState::new(
0.0,
[6_878.137, 0.0, 0.0],
[0.0, (MU_EARTH / 6_878.137_f64).sqrt(), 0.9],
);
let options = IntegratorOptions {
initial_step: 10.0,
..IntegratorOptions::default()
};
let j2_prop = StatePropagator {
initial,
force_model: ForceModelKind::two_body_j2(),
integrator: IntegratorKind::Rk4,
options,
drag: None,
space_weather: None,
};
let n8_prop = StatePropagator {
initial,
force_model: ForceModelKind::earth_phase_b(8, 8, None).expect("phase B"),
integrator: IntegratorKind::Rk4,
options,
drag: None,
space_weather: None,
};
let span = 900.0;
let j2_final = j2_prop.propagate_to(span).expect("J2").final_state;
let ctx = PropagationContext::new()
.with_body_fixed_frame_provider(Arc::new(TdbEarthOrientationProvider::new()));
let n8_final = n8_prop
.propagate_to_with_context(span, &ctx)
.expect("N8")
.final_state;
let diff = (n8_final.position_km - j2_final.position_km).norm();
assert!(n8_final.position_km.norm() > RE_EARTH + 450.0);
assert!(diff > 1.0e-3, "diff={diff}");
assert!(diff < 5.0, "diff={diff}");
}
#[test]
fn non_zonal_force_requires_body_fixed_provider() {
let state = CartesianState::new(0.0, [7000.0, -1210.0, 1300.0], [1.0, 7.2, 0.5]);
let gravity = SphericalHarmonicGravity::earth_egm96_truncated(2, 2).expect("EGM96");
let err = gravity
.acceleration(&state, &PropagationContext::default())
.expect_err("non-zonal model should require a frame provider");
match err {
PropagationError::InvalidInput(message) => {
assert!(message.contains("body-fixed frame provider"), "{message}");
}
other => panic!("expected invalid input, got {other:?}"),
}
}
#[test]
fn existing_j2_output_is_unchanged_when_unselected() {
let state = CartesianState::new(0.0, [7000.0, -1210.0, 1300.0], [1.0, 7.2, 0.5]);
let options = IntegratorOptions {
initial_step: 10.0,
..IntegratorOptions::default()
};
let propagator = StatePropagator {
initial: state,
force_model: ForceModelKind::two_body_j2(),
integrator: IntegratorKind::Rk4,
options,
drag: None,
space_weather: None,
};
let selected = propagator
.propagate_to(120.0)
.expect("selected")
.final_state;
assert_eq!(
[
selected.position_km.x.to_bits(),
selected.position_km.y.to_bits(),
selected.position_km.z.to_bits(),
selected.velocity_km_s.x.to_bits(),
selected.velocity_km_s.y.to_bits(),
selected.velocity_km_s.z.to_bits(),
],
[
4_664_491_415_796_994_328,
13_868_042_735_578_520_184,
4_653_651_704_383_841_626,
4_591_955_084_532_107_724,
4_619_903_849_988_711_109,
4_599_620_700_962_266_984,
]
);
}
fn degree_two_closed_form(
position: [f64; 3],
mu: f64,
radius: f64,
coefficients: &[SphericalHarmonicCoefficient],
) -> [f64; 3] {
let x = position[0];
let y = position[1];
let z = position[2];
let r2 = x * x + y * y + z * z;
let r = r2.sqrt();
let r5 = r2 * r2 * r;
let r7 = r5 * r2;
let mut c20 = 0.0;
let mut c21 = 0.0;
let mut s21 = 0.0;
let mut c22 = 0.0;
let mut s22 = 0.0;
for coefficient in coefficients {
match (coefficient.degree, coefficient.order) {
(2, 0) => c20 = coefficient.c,
(2, 1) => {
c21 = coefficient.c;
s21 = coefficient.s;
}
(2, 2) => {
c22 = coefficient.c;
s22 = coefficient.s;
}
_ => {}
}
}
let sqrt5 = 5.0_f64.sqrt();
let sqrt15 = 15.0_f64.sqrt();
let a = 0.5 * sqrt5 * c20;
let b = sqrt15;
let d = 0.5 * sqrt15;
let q = a * (2.0 * z * z - x * x - y * y)
+ b * z * (c21 * x + s21 * y)
+ d * (c22 * (x * x - y * y) + 2.0 * s22 * x * y);
let dq = [
-2.0 * a * x + b * c21 * z + 2.0 * d * (c22 * x + s22 * y),
-2.0 * a * y + b * s21 * z + 2.0 * d * (-c22 * y + s22 * x),
4.0 * a * z + b * (c21 * x + s21 * y),
];
let scale = mu * radius * radius;
[
scale * (dq[0] / r5 - 5.0 * q * x / r7),
scale * (dq[1] / r5 - 5.0 * q * y / r7),
scale * (dq[2] / r5 - 5.0 * q * z / r7),
]
}
}