351 lines
12 KiB
Rust
351 lines
12 KiB
Rust
//! Tests for the Keplerian elements and their state vector conversions.
|
|
//!
|
|
//! The expected values were computed independently from the standard
|
|
//! perifocal frame expressions in extended precision. Only the point mass
|
|
//! gravity of the central body is involved, so the two should agree to
|
|
//! floating point rounding.
|
|
//!
|
|
//! 开普勒根数及其状态矢量换算的测试。
|
|
//!
|
|
//! 期望值是以扩展精度从标准近焦点系表达式独立计算的。只涉及中心天体的质点引力,
|
|
//! 故二者应在浮点舍入内一致。
|
|
|
|
use apsis_kepler::{GravityParameter, KeplerianElements, State, wrap_to_pi};
|
|
use apsis_math::Vec3;
|
|
use core::f64::consts::{PI, TAU};
|
|
|
|
const POSITION_TOLERANCE: f64 = 1e-6;
|
|
const VELOCITY_TOLERANCE: f64 = 1e-9;
|
|
|
|
fn assert_state_close(state: State, position: [f64; 3], velocity: [f64; 3]) {
|
|
let position_error = (state.position - Vec3::new(position[0], position[1], position[2])).norm();
|
|
let velocity_error = (state.velocity - Vec3::new(velocity[0], velocity[1], velocity[2])).norm();
|
|
assert!(
|
|
position_error < POSITION_TOLERANCE,
|
|
"position error {position_error}, got {}",
|
|
state.position
|
|
);
|
|
assert!(
|
|
velocity_error < VELOCITY_TOLERANCE,
|
|
"velocity error {velocity_error}, got {}",
|
|
state.velocity
|
|
);
|
|
}
|
|
|
|
fn low_orbit() -> KeplerianElements {
|
|
KeplerianElements {
|
|
semi_major_axis: 7_000.0,
|
|
eccentricity: 0.01,
|
|
inclination: 0.9,
|
|
right_ascension: 1.2,
|
|
argument_of_perigee: 0.4,
|
|
true_anomaly: 0.7,
|
|
}
|
|
}
|
|
|
|
fn elliptical_orbit() -> KeplerianElements {
|
|
KeplerianElements {
|
|
semi_major_axis: 26_600.0,
|
|
eccentricity: 0.74,
|
|
inclination: 0.1,
|
|
right_ascension: 2.0,
|
|
argument_of_perigee: 5.0,
|
|
true_anomaly: 1.1,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn conversion_matches_an_independent_computation() {
|
|
let state = low_orbit()
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("elements are valid");
|
|
assert_state_close(
|
|
state,
|
|
[-2444.845_044_819, 4331.004_606_023, 4849.169_779_458],
|
|
[-4.471_116_396_765, -5.509_080_648_689, 2.735_799_894_571],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conversion_matches_for_a_highly_elliptical_orbit() {
|
|
let state = elliptical_orbit()
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("elements are valid");
|
|
assert_state_close(
|
|
state,
|
|
[-2201.703_284_413, 8734.956_144_841, -163.848_650_213],
|
|
[-8.348_843_969_446, 1.823_413_050_670, 0.685_564_215_543],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_circular_equatorial_orbit_has_the_circular_speed() {
|
|
// At zero eccentricity, zero inclination, and zero orientation, the
|
|
// satellite sits on the first axis and moves along the second, with
|
|
// speed sqrt(mu / a).
|
|
// 在零偏心率、零倾角、零取向时,卫星位于第一轴上、沿第二轴运动,速率为
|
|
// sqrt(mu / a)。
|
|
let elements = KeplerianElements {
|
|
semi_major_axis: 6_378.137,
|
|
eccentricity: 0.0,
|
|
inclination: 0.0,
|
|
right_ascension: 0.0,
|
|
argument_of_perigee: 0.0,
|
|
true_anomaly: 0.0,
|
|
};
|
|
let state = elements
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("elements are valid");
|
|
assert!((state.position.x - 6_378.137).abs() < 1e-9);
|
|
assert!(state.position.y.abs() < 1e-9);
|
|
assert!(state.position.z.abs() < 1e-9);
|
|
|
|
let expected_speed = (GravityParameter::WGS84.value() / 6_378.137).sqrt();
|
|
assert!((state.velocity.norm() - expected_speed).abs() < 1e-9);
|
|
assert!(state.velocity.y > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn the_state_vector_lies_in_the_orbit_plane() {
|
|
let elements = low_orbit();
|
|
let state = elements
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("elements are valid");
|
|
// The position and velocity span the orbit plane, so the normal to that
|
|
// plane is perpendicular to both.
|
|
// 位置与速度张成轨道面,故该面的法线垂直于二者。
|
|
let normal = state
|
|
.position
|
|
.cross(state.velocity)
|
|
.normalize()
|
|
.expect("non-zero");
|
|
assert!(normal.dot(state.position).abs() < 1e-6);
|
|
assert!(normal.dot(state.velocity).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn radius_follows_the_conic_equation() {
|
|
let elements = low_orbit();
|
|
for true_anomaly in [-2.0, -1.0, 0.0, 1.0, 2.0, 3.0] {
|
|
let state = elements
|
|
.with_true_anomaly(true_anomaly)
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("elements are valid");
|
|
let p = elements.semi_latus_rectum();
|
|
let expected = p / (1.0 + elements.eccentricity * true_anomaly.cos());
|
|
assert!((state.position.norm() - expected).abs() < 1e-6);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn perigee_and_apogee_are_the_extremes_of_the_radius() {
|
|
let elements = elliptical_orbit();
|
|
let perigee = elements
|
|
.with_true_anomaly(0.0)
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("valid");
|
|
let apogee = elements
|
|
.with_true_anomaly(PI)
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("valid");
|
|
assert!((perigee.position.norm() - elements.perigee_radius()).abs() < 1e-6);
|
|
assert!((apogee.position.norm() - elements.apogee_radius()).abs() < 1e-6);
|
|
assert!(perigee.position.norm() < apogee.position.norm());
|
|
}
|
|
|
|
#[test]
|
|
fn the_speed_is_highest_at_perigee() {
|
|
let elements = elliptical_orbit();
|
|
let perigee = elements
|
|
.with_true_anomaly(0.0)
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("valid");
|
|
let apogee = elements
|
|
.with_true_anomaly(PI)
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("valid");
|
|
assert!(perigee.velocity.norm() > apogee.velocity.norm());
|
|
}
|
|
|
|
#[test]
|
|
fn orbital_geometry_is_consistent() {
|
|
let elements = elliptical_orbit();
|
|
let p = elements.semi_latus_rectum();
|
|
assert!((p - elements.semi_major_axis * (1.0 - elements.eccentricity.powi(2))).abs() < 1e-9);
|
|
assert!((elements.semi_minor_axis().powi(2) - elements.semi_major_axis * p).abs() < 1e-6);
|
|
assert!(
|
|
(elements.apogee_radius()
|
|
- elements.perigee_radius()
|
|
- 2.0 * elements.semi_major_axis * elements.eccentricity)
|
|
.abs()
|
|
< 1e-9
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_period_follows_keplers_third_law() {
|
|
let elements = low_orbit();
|
|
let period = elements.period(GravityParameter::WGS84);
|
|
let expected =
|
|
2.0 * PI * (elements.semi_major_axis.powi(3) / GravityParameter::WGS84.value()).sqrt();
|
|
assert!((period - expected).abs() < 1e-9);
|
|
// A 7000 km orbit has a period of about 97 minutes.
|
|
// 7000 km 轨道的周期约为 97 分钟。
|
|
assert!((period / 60.0 - 97.0).abs() < 2.0);
|
|
}
|
|
|
|
#[test]
|
|
fn state_round_trips_through_the_elements() {
|
|
for elements in [low_orbit(), elliptical_orbit()] {
|
|
let state = elements
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("elements are valid");
|
|
let recovered = KeplerianElements::from_state(&state, GravityParameter::WGS84)
|
|
.expect("state describes an ellipse");
|
|
|
|
assert!(
|
|
(recovered.semi_major_axis - elements.semi_major_axis).abs() < 1e-6,
|
|
"semi-major axis {} vs {}",
|
|
recovered.semi_major_axis,
|
|
elements.semi_major_axis
|
|
);
|
|
assert!(
|
|
(recovered.eccentricity - elements.eccentricity).abs() < 1e-9,
|
|
"eccentricity {} vs {}",
|
|
recovered.eccentricity,
|
|
elements.eccentricity
|
|
);
|
|
assert!((recovered.inclination - elements.inclination).abs() < 1e-9);
|
|
// The angles are compared modulo a full turn, because a value such
|
|
// as 5.0 radians and one reduced to -1.28 radians describe the same
|
|
// orientation.
|
|
// 各角按模一周比较,因为诸如 5.0 弧度这样的值与归约到 -1.28 弧度的值描述同一
|
|
// 取向。
|
|
assert!((wrap_to_pi(recovered.right_ascension - elements.right_ascension)).abs() < 1e-9);
|
|
assert!(
|
|
(wrap_to_pi(recovered.argument_of_perigee - elements.argument_of_perigee)).abs() < 1e-9
|
|
);
|
|
assert!((wrap_to_pi(recovered.true_anomaly - elements.true_anomaly)).abs() < 1e-7);
|
|
|
|
// The round trip must reproduce the state as well as the elements,
|
|
// which is the stronger check.
|
|
// 往返必须既复现状态也复现根数,后者是更强的检查。
|
|
let again = recovered.to_state(GravityParameter::WGS84).expect("valid");
|
|
assert!((again.position - state.position).norm() < 1e-6);
|
|
assert!((again.velocity - state.velocity).norm() < 1e-9);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_circular_orbit_round_trips() {
|
|
let elements = KeplerianElements {
|
|
semi_major_axis: 7_000.0,
|
|
eccentricity: 0.0,
|
|
inclination: 1.0,
|
|
right_ascension: 0.5,
|
|
argument_of_perigee: 0.0,
|
|
true_anomaly: 0.0,
|
|
};
|
|
let state = elements
|
|
.to_state(GravityParameter::WGS72)
|
|
.expect("elements are valid");
|
|
let recovered = KeplerianElements::from_state(&state, GravityParameter::WGS72)
|
|
.expect("state describes an ellipse");
|
|
assert!((recovered.semi_major_axis - 7_000.0).abs() < 1e-6);
|
|
assert!(recovered.eccentricity < 1e-9);
|
|
assert!((recovered.inclination - 1.0).abs() < 1e-9);
|
|
assert!((recovered.right_ascension - 0.5).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn mean_anomaly_round_trips_through_the_elements() {
|
|
let elements = elliptical_orbit();
|
|
let mean = elements.to_mean_anomaly();
|
|
let restored = elements.with_mean_anomaly(mean);
|
|
assert!((restored.true_anomaly - elements.true_anomaly).abs() < 1e-8);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_elements_are_rejected() {
|
|
let mut elements = low_orbit();
|
|
elements.eccentricity = 1.0;
|
|
assert!(!elements.is_valid());
|
|
assert!(elements.to_state(GravityParameter::WGS84).is_none());
|
|
|
|
let mut elements = low_orbit();
|
|
elements.semi_major_axis = -1.0;
|
|
assert!(!elements.is_valid());
|
|
assert!(elements.to_state(GravityParameter::WGS84).is_none());
|
|
|
|
let mut elements = low_orbit();
|
|
elements.inclination = PI + 0.1;
|
|
assert!(!elements.is_valid());
|
|
|
|
let mut elements = low_orbit();
|
|
elements.true_anomaly = f64::NAN;
|
|
assert!(!elements.is_valid());
|
|
}
|
|
|
|
#[test]
|
|
fn degenerate_states_are_rejected() {
|
|
assert_eq!(
|
|
KeplerianElements::from_state(
|
|
&State {
|
|
position: Vec3::ZERO,
|
|
velocity: Vec3::ZERO
|
|
},
|
|
GravityParameter::WGS84
|
|
),
|
|
None
|
|
);
|
|
// A purely radial state has no defined orbit plane.
|
|
// 纯径向状态没有定义的轨道面。
|
|
assert_eq!(
|
|
KeplerianElements::from_state(
|
|
&State {
|
|
position: Vec3::new(7_000.0, 0.0, 0.0),
|
|
velocity: Vec3::new(1.0, 0.0, 0.0)
|
|
},
|
|
GravityParameter::WGS84
|
|
),
|
|
None
|
|
);
|
|
// An unbound state has a non-positive semi-major axis.
|
|
// 非束缚状态的半长轴非正。
|
|
assert_eq!(
|
|
KeplerianElements::from_state(
|
|
&State {
|
|
position: Vec3::new(7_000.0, 0.0, 0.0),
|
|
velocity: Vec3::new(0.0, 20.0, 0.0)
|
|
},
|
|
GravityParameter::WGS84
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gravity_parameter_values_match_the_standards() {
|
|
assert_eq!(GravityParameter::WGS72.value(), 398_600.8);
|
|
assert_eq!(GravityParameter::WGS84.value(), 398_600.5);
|
|
assert!((GravityParameter::WGS84.sqrt() - 398_600.5_f64.sqrt()).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_revolution_returns_to_the_same_point() {
|
|
// Advancing the mean anomaly by one full turn must return the same
|
|
// position, which verifies the anomaly conversions agree.
|
|
// 把平近点角推进一整圈必须返回相同位置,这验证各近点角换算彼此一致。
|
|
let elements = elliptical_orbit();
|
|
let start = elements
|
|
.with_mean_anomaly(1.0)
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("valid");
|
|
let end = elements
|
|
.with_mean_anomaly(1.0 + TAU)
|
|
.to_state(GravityParameter::WGS84)
|
|
.expect("valid");
|
|
assert!((start.position - end.position).norm() < 1e-6);
|
|
assert!((start.velocity - end.velocity).norm() < 1e-9);
|
|
}
|