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

189 lines
6.9 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.
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.
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.
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.
assert!(after.is_valid());
assert!(after.eccentricity < 1.0);
}