apsis-rs/crates/orbit/apsis-maneuver/tests/maneuver.rs

462 lines
19 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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}");
}