210 lines
8.2 KiB
Rust
210 lines
8.2 KiB
Rust
//! Integration tests for sampling and maneuvering through the facade.
|
||
//!
|
||
//! 通过门面进行采样与机动的集成测试。
|
||
|
||
use apsis_kepler::GravityParameter;
|
||
use apsis_maneuver::{FiniteBurn, hohmann_transfer};
|
||
use apsis_orbit::{EphemerisSamplingError, GravityModel, ManeuverApplicationError, Orbit};
|
||
use apsis_tle::TwoLineElement;
|
||
|
||
/// A satellite in a low Earth orbit, used as the source of the track.
|
||
///
|
||
/// 一颗低轨卫星,用作轨迹的来源。
|
||
const ISS: &str = concat!(
|
||
"1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927\r\n",
|
||
"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537\r\n",
|
||
);
|
||
|
||
fn orbit() -> Orbit {
|
||
let element = TwoLineElement::parse(ISS).expect("the sample parses");
|
||
Orbit::from_element(&element, GravityModel::WGS72).expect("the orbit propagates")
|
||
}
|
||
|
||
#[test]
|
||
fn an_orbit_samples_into_an_ephemeris() {
|
||
let orbit = orbit();
|
||
// Ten minutes over a one minute grid.
|
||
// 一分钟网格上的十分钟。
|
||
let ephemeris = orbit
|
||
.sample_ephemeris(0.0, 10.0, 1.0)
|
||
.expect("the grid is usable");
|
||
assert_eq!(ephemeris.len(), 11);
|
||
// The sample times are seconds from the element epoch.
|
||
// 样本时刻是距根数历元的秒数。
|
||
assert_eq!(ephemeris.start_time(), 0.0);
|
||
assert_eq!(ephemeris.end_time(), 600.0);
|
||
|
||
// Every sample must match a direct propagation.
|
||
// 每个样本必须与直接传播一致。
|
||
for sample in ephemeris.samples() {
|
||
let direct = orbit
|
||
.propagate_minutes(sample.seconds / 60.0)
|
||
.expect("the orbit propagates");
|
||
assert!(
|
||
sample.position.distance(direct.position) < 1e-12,
|
||
"at {} s",
|
||
sample.seconds
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_interpolated_ephemeris_matches_a_direct_propagation() {
|
||
// This is the point of the ephemeris: a query between samples must agree
|
||
// with the propagator to well within the accuracy a track needs.
|
||
// 这正是星历的要点:样本之间的查询必须与传播器一致,且远在一条轨迹所需的精度
|
||
// 之内。
|
||
let orbit = orbit();
|
||
let ephemeris = orbit
|
||
.sample_ephemeris(0.0, 30.0, 1.0)
|
||
.expect("the grid is usable");
|
||
|
||
let mut worst = 0.0_f64;
|
||
// Query every ten seconds, which includes points between samples.
|
||
// 每十秒查询一次,其中含样本之间的点。
|
||
for index in 0..180 {
|
||
let seconds = f64::from(index) * 10.0;
|
||
let interpolated = ephemeris.state_at(seconds).expect("the time is covered");
|
||
let direct = orbit.propagate_minutes(seconds / 60.0).expect("propagates");
|
||
worst = worst.max(interpolated.position.distance(direct.position));
|
||
}
|
||
// A one minute Hermite step on a low Earth orbit is accurate to far
|
||
// better than a meter here.
|
||
// 低轨上一分钟的 Hermite 步在此精确到远优于一米。
|
||
assert!(worst < 1e-3, "the worst interpolation error is {worst} km");
|
||
}
|
||
|
||
#[test]
|
||
fn a_bad_grid_is_refused() {
|
||
let orbit = orbit();
|
||
assert!(matches!(
|
||
orbit.sample_ephemeris(0.0, 0.0, 1.0),
|
||
Err(EphemerisSamplingError::InvalidGrid)
|
||
));
|
||
assert!(matches!(
|
||
orbit.sample_ephemeris(0.0, 10.0, 0.0),
|
||
Err(EphemerisSamplingError::InvalidGrid)
|
||
));
|
||
assert!(matches!(
|
||
orbit.sample_ephemeris(10.0, 0.0, 1.0),
|
||
Err(EphemerisSamplingError::InvalidGrid)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn a_propagation_failure_is_reported() {
|
||
let orbit = orbit();
|
||
// Far enough ahead that the drag term brings the satellite down, which
|
||
// is a decay failure rather than a silent bad sample.
|
||
// 足够远,使阻力项把卫星带下来,即衰减失败而非无声的坏样本。
|
||
let result = orbit.sample_ephemeris(0.0, 60.0 * 24.0 * 365.0, 60.0 * 24.0);
|
||
assert!(
|
||
matches!(result, Err(EphemerisSamplingError::Propagation(_)) | Ok(_)),
|
||
"the result is {result:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_burn_raises_the_osculating_orbit() {
|
||
let orbit = orbit();
|
||
let before = orbit.osculating_elements(0.0).expect("elliptical");
|
||
let state = orbit.propagate_minutes(0.0).expect("propagates");
|
||
// Thrust along the velocity.
|
||
// 沿速度方向施加推力。
|
||
let direction = state.velocity.normalize().expect("non-zero");
|
||
let burn = FiniteBurn::along(direction, 1e-4, 30.0).expect("usable");
|
||
|
||
let after = orbit.apply_burn(0.0, burn, 500).expect("the burn applies");
|
||
assert!(
|
||
after.semi_major_axis > before.semi_major_axis,
|
||
"the axis went from {} to {}",
|
||
before.semi_major_axis,
|
||
after.semi_major_axis
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_burn_matches_a_direct_application() {
|
||
// The facade must be a thin wrapper: applying the burn through it is the
|
||
// same as propagating and applying the burn by hand.
|
||
// 门面必须是薄封装:通过它施加点火与手工传播并施加点火相同。
|
||
let orbit = orbit();
|
||
let state = orbit.propagate_minutes(5.0).expect("propagates");
|
||
let direction = state.velocity.normalize().expect("non-zero");
|
||
let burn = FiniteBurn::along(direction, 1e-4, 20.0).expect("usable");
|
||
|
||
let through_facade = orbit.apply_burn(5.0, burn, 500).expect("applies");
|
||
|
||
let kepler_state = apsis_kepler::State {
|
||
position: state.position,
|
||
velocity: state.velocity,
|
||
};
|
||
let outcome = burn
|
||
.apply(kepler_state, GravityParameter::WGS72, 500)
|
||
.expect("the burn runs");
|
||
let by_hand =
|
||
apsis_kepler::KeplerianElements::from_state(&outcome.state, GravityParameter::WGS72)
|
||
.expect("still elliptical");
|
||
|
||
assert!(
|
||
(through_facade.semi_major_axis - by_hand.semi_major_axis).abs() < 1e-9,
|
||
"{} against {}",
|
||
through_facade.semi_major_axis,
|
||
by_hand.semi_major_axis
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_escaping_burn_is_reported() {
|
||
let orbit = orbit();
|
||
let state = orbit.propagate_minutes(0.0).expect("propagates");
|
||
let direction = state.velocity.normalize().expect("non-zero");
|
||
// A burn far larger than orbital velocity.
|
||
// 远大于轨道速度的点火。
|
||
let burn = FiniteBurn::along(direction, 1e-2, 600.0).expect("usable");
|
||
let result = orbit.apply_burn(0.0, burn, 2_000);
|
||
assert!(matches!(
|
||
result,
|
||
Err(ManeuverApplicationError::NotElliptical)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn a_hohmann_transfer_plans_a_raise_the_orbit_layer_can_sample() {
|
||
// A cross-layer check that ties the maneuver and ephemeris crates to the
|
||
// propagation: plan a transfer, apply the first burn, and sample the
|
||
// resulting orbit.
|
||
// 把 maneuver 与 ephemeris crate 联系到传播的跨层检查:规划一次转移,施加第一次
|
||
// 点火,并对所得轨道采样。
|
||
let orbit = orbit();
|
||
let elements = orbit.osculating_elements(0.0).expect("elliptical");
|
||
let from = elements.semi_major_axis;
|
||
let to = from + 50.0;
|
||
let transfer = hohmann_transfer(from, to, GravityParameter::WGS72).expect("usable");
|
||
|
||
// The first burn is prograde for a raise, so thrust along the velocity.
|
||
// 抬高时第一次点火是顺行的,故沿速度方向施加推力。
|
||
let state = orbit.propagate_minutes(0.0).expect("propagates");
|
||
let direction = state.velocity.normalize().expect("non-zero");
|
||
// Turn the delta-v into an acceleration over a short burn.
|
||
// 在短点火中把 delta-v 化为加速度。
|
||
let duration = 10.0;
|
||
let burn =
|
||
FiniteBurn::along(direction, transfer.first_burn / duration, duration).expect("usable");
|
||
let after = orbit.apply_burn(0.0, burn, 1_000).expect("applies");
|
||
assert!(
|
||
after.semi_major_axis > from,
|
||
"the transfer should raise the orbit from {from} to {}",
|
||
after.semi_major_axis
|
||
);
|
||
|
||
// The osculating elements of the post burn state are the planning
|
||
// result. Turning them back into a propagatable orbit would need mean
|
||
// elements, which SGP4 requires and an osculating set does not provide,
|
||
// so the workflow stops here rather than feeding them back in.
|
||
// 点火后状态的密切根数即规划结果。要把它们转回可传播的轨道需要平根数,SGP4
|
||
// 要求平根数而密切根数集不提供,故该工作流到此为止,而不把它们送回。
|
||
assert!(after.is_valid());
|
||
assert!(after.eccentricity < 1.0);
|
||
}
|