462 lines
19 KiB
Rust
462 lines
19 KiB
Rust
//! Tests for the maneuver models.
|
||
//!
|
||
//! 机动模型的测试。
|
||
|
||
use apsis_kepler::{GravityParameter, KeplerianElements};
|
||
use apsis_maneuver::{FiniteBurn, Impulse, ManeuverErrorKind, hohmann_transfer};
|
||
use apsis_math::Vec3;
|
||
|
||
/// A circular orbit at a given radius, in the equatorial plane.
|
||
///
|
||
/// 给定半径、位于赤道面内的圆轨道。
|
||
fn circular(radius: f64) -> KeplerianElements {
|
||
KeplerianElements {
|
||
semi_major_axis: radius,
|
||
eccentricity: 0.0,
|
||
inclination: 0.0,
|
||
right_ascension: 0.0,
|
||
argument_of_perigee: 0.0,
|
||
true_anomaly: 0.0,
|
||
}
|
||
}
|
||
|
||
// The impulsive model.
|
||
// 脉冲模型。
|
||
|
||
#[test]
|
||
fn an_impulse_reports_its_change() {
|
||
let impulse = Impulse::new(Vec3::new(0.0, 1.0, 0.0)).expect("usable");
|
||
assert_eq!(impulse.delta_v(), Vec3::new(0.0, 1.0, 0.0));
|
||
assert!((impulse.magnitude() - 1.0).abs() < 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn a_zero_or_non_finite_impulse_is_refused() {
|
||
let error = Impulse::new(Vec3::ZERO).expect_err("a zero change is not a maneuver");
|
||
assert_eq!(error.kind(), ManeuverErrorKind::InvalidState);
|
||
assert!(Impulse::new(Vec3::new(f64::NAN, 0.0, 0.0)).is_err());
|
||
assert!(Impulse::new(Vec3::new(0.0, f64::INFINITY, 0.0)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn an_impulse_can_be_built_from_a_direction_and_a_magnitude() {
|
||
// The direction need not be a unit vector.
|
||
// 方向不必是单位矢量。
|
||
let impulse = Impulse::along(Vec3::new(0.0, 3.0, 0.0), 0.5).expect("usable");
|
||
assert!((impulse.magnitude() - 0.5).abs() < 1e-12);
|
||
assert!((impulse.delta_v().y - 0.5).abs() < 1e-12);
|
||
assert!(impulse.delta_v().z.abs() < 1e-12);
|
||
|
||
// A zero direction and a bad magnitude are refused.
|
||
// 零方向与坏幅度被拒绝。
|
||
assert!(Impulse::along(Vec3::ZERO, 1.0).is_err());
|
||
assert!(Impulse::along(Vec3::X, 0.0).is_err());
|
||
assert!(Impulse::along(Vec3::X, -1.0).is_err());
|
||
assert!(Impulse::along(Vec3::X, f64::NAN).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn an_impulse_can_be_scaled_and_reversed() {
|
||
let impulse = Impulse::new(Vec3::new(1.0, 0.0, 0.0)).expect("usable");
|
||
let doubled = impulse.scaled(2.0).expect("usable");
|
||
assert!((doubled.magnitude() - 2.0).abs() < 1e-12);
|
||
let reversed = impulse.reversed();
|
||
assert!((reversed.delta_v().x + 1.0).abs() < 1e-12);
|
||
// Scaling by zero produces a zero change, which is refused.
|
||
// 按零缩放产生零变化,会被拒绝。
|
||
assert!(impulse.scaled(0.0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn an_impulse_changes_the_velocity_but_not_the_position() {
|
||
let impulse = Impulse::new(Vec3::new(0.0, 0.7, 0.0)).expect("usable");
|
||
let velocity = Vec3::new(7.5, 0.0, 0.0);
|
||
let changed = impulse.apply_to_velocity(velocity);
|
||
assert_eq!(changed, Vec3::new(7.5, 0.7, 0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn an_impulse_displays_its_magnitude() {
|
||
let impulse = Impulse::new(Vec3::new(0.0, 1.234_5, 0.0)).expect("usable");
|
||
let text = impulse.to_string();
|
||
assert!(text.contains("1.234"), "{text}");
|
||
}
|
||
|
||
// The Hohmann transfer.
|
||
// Hohmann 转移。
|
||
|
||
#[test]
|
||
fn a_hohmann_transfer_raises_an_orbit() {
|
||
// Raise from 7000 to 8000 km.
|
||
// 从 7000 升到 8000 km。
|
||
let transfer =
|
||
hohmann_transfer(7_000.0, 8_000.0, GravityParameter::WGS84).expect("the radii are usable");
|
||
assert!(transfer.is_raising());
|
||
// Raising an orbit takes two prograde burns.
|
||
// 抬高轨道需要两次顺行点火。
|
||
assert!(transfer.first_burn > 0.0);
|
||
assert!(transfer.second_burn > 0.0);
|
||
assert_eq!(transfer.transfer_semi_major_axis, 7_500.0);
|
||
assert!(transfer.total_delta_v() > 0.0);
|
||
assert_eq!(
|
||
transfer.total_delta_v(),
|
||
transfer.first_burn + transfer.second_burn
|
||
);
|
||
assert_eq!(transfer.mean_radius(), 7_500.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_hohmann_transfer_lowers_an_orbit() {
|
||
// The same two radii in the other order is a descent, and the burns are
|
||
// retrograde.
|
||
// 两个半径反序即为下降,两次点火都是逆行。
|
||
let transfer =
|
||
hohmann_transfer(8_000.0, 7_000.0, GravityParameter::WGS84).expect("the radii are usable");
|
||
assert!(!transfer.is_raising());
|
||
assert!(transfer.first_burn < 0.0);
|
||
assert!(transfer.second_burn < 0.0);
|
||
// The cost is the same either way.
|
||
// 两种方向的代价相同。
|
||
let raised =
|
||
hohmann_transfer(7_000.0, 8_000.0, GravityParameter::WGS84).expect("the radii are usable");
|
||
assert!((transfer.total_delta_v() - raised.total_delta_v()).abs() < 1e-12);
|
||
assert!((transfer.transfer_time - raised.transfer_time).abs() < 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn the_first_burn_matches_the_vis_viva_speeds() {
|
||
// The first burn is the transfer ellipse speed at the lower radius less
|
||
// the circular speed there. Checking it against the vis-viva equation
|
||
// directly pins the formula.
|
||
// 第一次点火是转移椭圆在下半径处的速率减去那里的圆轨道速率。用活力公式直接核对
|
||
// 它即可钉住该公式。
|
||
let transfer =
|
||
hohmann_transfer(7_000.0, 9_000.0, GravityParameter::WGS84).expect("the radii are usable");
|
||
let mu = GravityParameter::WGS84.value();
|
||
let circular = (mu / 7_000.0).sqrt();
|
||
let ellipse = (mu * (2.0 / 7_000.0 - 1.0 / 8_000.0)).sqrt();
|
||
assert!((transfer.first_burn - (ellipse - circular)).abs() < 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn the_transfer_time_is_half_the_ellipse_period() {
|
||
let transfer =
|
||
hohmann_transfer(7_000.0, 8_000.0, GravityParameter::WGS84).expect("the radii are usable");
|
||
let half_period =
|
||
circular(transfer.transfer_semi_major_axis).period(GravityParameter::WGS84) / 2.0;
|
||
assert!((transfer.transfer_time - half_period).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn a_hohmann_transfer_matches_a_known_leo_to_geo_case() {
|
||
// The well documented case: a transfer from a 300 km circular parking
|
||
// orbit to geostationary altitude. The total is about 3.89 km/s, which
|
||
// is the figure quoted for a standard LEO to GEO chemical transfer.
|
||
// 有充分记载的用例:从 300 km 圆停泊轨道转移到地球静止高度。总计约 3.89 km/s,
|
||
// 这是标准 LEO 到 GEO 化学转移所引用的数字。
|
||
let from = 6_378.137 + 300.0;
|
||
let to = 42_164.0;
|
||
let transfer = hohmann_transfer(from, to, GravityParameter::WGS84).expect("usable");
|
||
assert!(
|
||
(transfer.total_delta_v() - 3.89).abs() < 0.02,
|
||
"the total is {} km/s",
|
||
transfer.total_delta_v()
|
||
);
|
||
// The transfer ellipse touches both orbits: its perigee is the parking
|
||
// radius and its apogee the destination, so its eccentricity follows
|
||
// from the two radii.
|
||
// 转移椭圆与两个轨道都相切:其近地点是停泊半径,远地点是目的地,故其偏心率由
|
||
// 这两个半径得出。
|
||
let eccentricity = (to - from) / (to + from);
|
||
let perigee = transfer.transfer_semi_major_axis * (1.0 - eccentricity);
|
||
let apogee = transfer.transfer_semi_major_axis * (1.0 + eccentricity);
|
||
assert!((perigee - from).abs() < 1e-9);
|
||
assert!((apogee - to).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn a_degenerate_transfer_is_refused() {
|
||
let error = hohmann_transfer(7_000.0, 7_000.0, GravityParameter::WGS84)
|
||
.expect_err("equal radii are not a transfer");
|
||
assert_eq!(error.kind(), ManeuverErrorKind::DegenerateTransfer);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unusable_radius_is_refused() {
|
||
for (from, to) in [
|
||
(0.0, 8_000.0),
|
||
(-1.0, 8_000.0),
|
||
(7_000.0, 0.0),
|
||
(7_000.0, f64::NAN),
|
||
(f64::INFINITY, 8_000.0),
|
||
] {
|
||
let error = hohmann_transfer(from, to, GravityParameter::WGS84)
|
||
.expect_err("the radius is not usable");
|
||
assert_eq!(error.kind(), ManeuverErrorKind::InvalidRadius);
|
||
}
|
||
}
|
||
|
||
// The finite burn.
|
||
// 有限点火。
|
||
|
||
#[test]
|
||
fn a_finite_burn_reports_its_acceleration_and_duration() {
|
||
let burn = FiniteBurn::new(Vec3::new(0.0, 0.001, 0.0), 100.0).expect("usable");
|
||
assert!((burn.acceleration_magnitude() - 0.001).abs() < 1e-15);
|
||
assert_eq!(burn.duration(), 100.0);
|
||
// The nominal change is the acceleration times the duration.
|
||
// 标称变化是加速度乘以时长。
|
||
assert!((burn.nominal_delta_v() - 0.1).abs() < 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn a_finite_burn_can_be_built_from_a_direction() {
|
||
let burn = FiniteBurn::along(Vec3::new(0.0, 5.0, 0.0), 0.002, 50.0).expect("usable");
|
||
assert!((burn.acceleration_magnitude() - 0.002).abs() < 1e-15);
|
||
assert!((burn.nominal_delta_v() - 0.1).abs() < 1e-12);
|
||
assert!(FiniteBurn::along(Vec3::ZERO, 0.001, 10.0).is_err());
|
||
assert!(FiniteBurn::along(Vec3::X, 0.0, 10.0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn an_unusable_burn_is_refused() {
|
||
assert!(FiniteBurn::new(Vec3::ZERO, 10.0).is_err());
|
||
assert!(FiniteBurn::new(Vec3::X, 0.0).is_err());
|
||
assert!(FiniteBurn::new(Vec3::X, -1.0).is_err());
|
||
assert!(FiniteBurn::new(Vec3::new(f64::NAN, 0.0, 0.0), 10.0).is_err());
|
||
assert!(FiniteBurn::new(Vec3::X, f64::NAN).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn a_finite_burn_agrees_with_its_equivalent_impulse_in_free_space() {
|
||
// With gravity switched off the finite burn must deliver exactly the
|
||
// nominal change: it is a constant acceleration, so the velocity change
|
||
// is the acceleration times the duration.
|
||
// 在引力关掉时,有限点火必须恰好给出标称变化:它是常值加速度,故速度变化是
|
||
// 加速度乘以时长。
|
||
//
|
||
// The two-body model always has gravity, so instead the burn is applied
|
||
// far from a mass, where gravity in the equation is negligible because
|
||
// the central body was given a vanishing parameter.
|
||
// 二体模型总有引力,故改为在远离质量处施加点火,那里方程中的引力可忽略,因为
|
||
// 中心天体被赋予了一个趋于零的参数。
|
||
let burn = FiniteBurn::new(Vec3::new(0.0, 0.001, 0.0), 100.0).expect("usable");
|
||
let state = apsis_kepler::State {
|
||
position: Vec3::new(1.0e9, 0.0, 0.0),
|
||
velocity: Vec3::ZERO,
|
||
};
|
||
// A negligible gravitational parameter leaves only the thrust.
|
||
// 可忽略的引力参数只留下推力。
|
||
let outcome = burn
|
||
.apply(state, GravityParameter(0.0), 100)
|
||
.expect("the burn runs");
|
||
assert!(
|
||
(outcome.achieved_magnitude() - 0.1).abs() < 1e-9,
|
||
"the achieved change is {}",
|
||
outcome.achieved_magnitude()
|
||
);
|
||
assert!((outcome.nominal_delta_v - 0.1).abs() < 1e-12);
|
||
// There is no gravity to lose anything to.
|
||
// 没有引力可损失任何东西。
|
||
assert!(outcome.gravity_loss().abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn a_finite_burn_converges_on_the_matching_impulse() {
|
||
// This is the property that ties the two models together. A finite burn
|
||
// of acceleration `a` over a duration `t` delivers the impulse `a t` in
|
||
// the limit of a shrinking duration, because the gravity deflection
|
||
// during the burn vanishes with the duration.
|
||
// 这正是把两个模型联系起来的性质。加速度为 `a`、时长为 `t` 的有限点火在时长
|
||
// 趋于零的极限下给出冲量 `a t`,因为点火期间的引力偏折随时长消失。
|
||
let parameter = GravityParameter::WGS84;
|
||
let elements = circular(7_000.0);
|
||
let state = elements.to_state(parameter).expect("valid");
|
||
|
||
// The thrust direction is along the velocity, which for this orbit is
|
||
// the second axis.
|
||
// 推力方向沿速度,对该轨道而言即第二轴。
|
||
let direction = state.velocity.normalize().expect("the speed is non-zero");
|
||
let target_delta_v = 0.01;
|
||
|
||
let achieved_for = |duration: f64| {
|
||
// Hold the nominal delta-v fixed by scaling the acceleration, so
|
||
// every burn is compared at the same impulse.
|
||
// 通过缩放加速度保持标称 delta-v 不变,使每次点火都在相同冲量下比较。
|
||
let acceleration = target_delta_v / duration;
|
||
let burn = FiniteBurn::along(direction, acceleration, duration).expect("usable");
|
||
burn.apply(state, parameter, 2_000)
|
||
.expect("the burn runs")
|
||
.achieved_magnitude()
|
||
};
|
||
|
||
// A long burn loses some of the change to gravity; a short one keeps
|
||
// nearly all of it.
|
||
// 长点火把部分变化损失给引力;短点火几乎全部保留。
|
||
let long = achieved_for(600.0);
|
||
let short = achieved_for(6.0);
|
||
let long_error = (long - target_delta_v).abs();
|
||
let short_error = (short - target_delta_v).abs();
|
||
assert!(
|
||
short_error < long_error,
|
||
"the short burn error {short_error} should be below the long one {long_error}"
|
||
);
|
||
assert!(
|
||
short_error < target_delta_v * 1e-3,
|
||
"the short burn should be within a thousandth of the impulse, got {short_error}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_finite_burn_delivers_less_than_nominal_against_gravity() {
|
||
// A long prograde burn on a circular orbit: gravity turns the velocity
|
||
// while the burn runs, so the achieved change differs from the free
|
||
// space value. The sign of the difference depends on the geometry; the
|
||
// point of the test is that the two are not equal, which is why the
|
||
// finite model exists.
|
||
// 圆轨道上的长顺行点火:点火进行时引力使速度转向,故所达到的变化不同于自由空间
|
||
// 值。差值的符号取决于几何;本测试的要点是二者不相等,这正是有限模型存在的
|
||
// 原因。
|
||
let parameter = GravityParameter::WGS84;
|
||
let elements = circular(7_000.0);
|
||
let state = elements.to_state(parameter).expect("valid");
|
||
let direction = state.velocity.normalize().expect("the speed is non-zero");
|
||
// A burn lasting a tenth of the orbital period.
|
||
// 持续轨道周期十分之一的点火。
|
||
let duration = elements.period(parameter) / 10.0;
|
||
let acceleration = 1e-5;
|
||
let burn = FiniteBurn::along(direction, acceleration, duration).expect("usable");
|
||
let outcome = burn.apply(state, parameter, 1_000).expect("the burn runs");
|
||
|
||
assert!(
|
||
outcome.gravity_loss().abs() > 1e-9,
|
||
"the loss is {}",
|
||
outcome.gravity_loss()
|
||
);
|
||
// The position moved during the burn.
|
||
// 点火期间位置移动了。
|
||
assert!(outcome.state.position.distance(state.position) > 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_finite_burn_raises_the_orbit_when_prograde() {
|
||
// A prograde burn adds energy, so the semi-major axis grows.
|
||
// 顺行点火增加能量,故半长轴增大。
|
||
let parameter = GravityParameter::WGS84;
|
||
let elements = circular(7_000.0);
|
||
let state = elements.to_state(parameter).expect("valid");
|
||
let direction = state.velocity.normalize().expect("non-zero");
|
||
let burn = FiniteBurn::along(direction, 1e-4, 60.0).expect("usable");
|
||
let outcome = burn.apply(state, parameter, 1_000).expect("the burn runs");
|
||
let after = outcome.elements(parameter).expect("still elliptical");
|
||
assert!(
|
||
after.semi_major_axis > elements.semi_major_axis,
|
||
"the axis went from {} to {}",
|
||
elements.semi_major_axis,
|
||
after.semi_major_axis
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_finite_burn_lowers_the_orbit_when_retrograde() {
|
||
// The mirror image: a retrograde burn removes energy.
|
||
// 镜像情形:逆行点火移除能量。
|
||
let parameter = GravityParameter::WGS84;
|
||
let elements = circular(7_000.0);
|
||
let state = elements.to_state(parameter).expect("valid");
|
||
let direction = -state.velocity.normalize().expect("non-zero");
|
||
let burn = FiniteBurn::along(direction, 1e-4, 60.0).expect("usable");
|
||
let outcome = burn.apply(state, parameter, 1_000).expect("the burn runs");
|
||
let after = outcome.elements(parameter).expect("still elliptical");
|
||
assert!(after.semi_major_axis < elements.semi_major_axis);
|
||
}
|
||
|
||
#[test]
|
||
fn a_finite_burn_can_be_applied_to_elements() {
|
||
let parameter = GravityParameter::WGS84;
|
||
let elements = circular(7_000.0);
|
||
let state = elements.to_state(parameter).expect("valid");
|
||
let direction = state.velocity.normalize().expect("non-zero");
|
||
let burn = FiniteBurn::along(direction, 1e-4, 60.0).expect("usable");
|
||
|
||
let from_state = burn.apply(state, parameter, 500).expect("the burn runs");
|
||
let from_elements = burn
|
||
.apply_to_elements(elements, parameter, 500)
|
||
.expect("the burn runs");
|
||
// Both routes must give the same state.
|
||
// 两条路径必须给出相同状态。
|
||
assert!(
|
||
from_state
|
||
.state
|
||
.position
|
||
.distance(from_elements.state.position)
|
||
< 1e-9
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_burn_on_invalid_elements_is_refused() {
|
||
let bad = KeplerianElements {
|
||
semi_major_axis: -1.0,
|
||
..circular(7_000.0)
|
||
};
|
||
let burn = FiniteBurn::new(Vec3::new(0.0, 1e-4, 0.0), 60.0).expect("usable");
|
||
let error = burn
|
||
.apply_to_elements(bad, GravityParameter::WGS84, 100)
|
||
.expect_err("the elements are invalid");
|
||
assert_eq!(error.kind(), ManeuverErrorKind::InvalidState);
|
||
}
|
||
|
||
#[test]
|
||
fn a_burn_with_no_steps_is_refused() {
|
||
let parameter = GravityParameter::WGS84;
|
||
let state = circular(7_000.0).to_state(parameter).expect("valid");
|
||
let burn = FiniteBurn::new(Vec3::new(0.0, 1e-4, 0.0), 60.0).expect("usable");
|
||
let error = burn
|
||
.apply(state, parameter, 0)
|
||
.expect_err("no steps cannot integrate");
|
||
assert_eq!(error.kind(), ManeuverErrorKind::InvalidState);
|
||
}
|
||
|
||
#[test]
|
||
fn a_large_burn_can_escape_and_is_reported() {
|
||
// A burn hard enough to exceed escape velocity leaves no elliptical
|
||
// elements, which the outcome reports by returning `None`.
|
||
// 强度足以超过逃逸速度的点火不留下椭圆根数,结果通过返回 `None` 报告这一点。
|
||
let parameter = GravityParameter::WGS84;
|
||
let state = circular(7_000.0).to_state(parameter).expect("valid");
|
||
let direction = state.velocity.normalize().expect("non-zero");
|
||
let burn = FiniteBurn::along(direction, 1e-2, 600.0).expect("usable");
|
||
let outcome = burn.apply(state, parameter, 2_000).expect("the burn runs");
|
||
assert!(outcome.elements(parameter).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn the_equivalent_impulse_matches_the_nominal_change() {
|
||
let burn = FiniteBurn::new(Vec3::new(0.0, 0.001, 0.0), 100.0).expect("usable");
|
||
let impulse = burn.equivalent_impulse().expect("the change is non-zero");
|
||
assert!((impulse.magnitude() - burn.nominal_delta_v()).abs() < 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn the_outcome_reports_the_achieved_magnitude() {
|
||
let parameter = GravityParameter::WGS84;
|
||
let state = circular(7_000.0).to_state(parameter).expect("valid");
|
||
let direction = state.velocity.normalize().expect("non-zero");
|
||
let burn = FiniteBurn::along(direction, 1e-4, 60.0).expect("usable");
|
||
let outcome = burn.apply(state, parameter, 500).expect("the burn runs");
|
||
assert!(outcome.achieved_magnitude() > 0.0);
|
||
assert_eq!(outcome.duration, 60.0);
|
||
assert_eq!(
|
||
outcome.achieved_delta_v.norm(),
|
||
outcome.achieved_magnitude()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_error_displays_its_kind() {
|
||
let error =
|
||
hohmann_transfer(7_000.0, 7_000.0, GravityParameter::WGS84).expect_err("degenerate");
|
||
assert!(error.to_string().contains("radii"), "{error}");
|
||
}
|