orbit: add maneuver models and wire the orbit facade

This commit is contained in:
ApsisRS contributors 2026-09-26 23:17:16 +08:00
parent fc23571d4a
commit c256dc0da2
12 changed files with 1426 additions and 1 deletions

7
Cargo.lock generated
View File

@ -166,6 +166,11 @@ version = "0.1.0"
[[package]] [[package]]
name = "apsis-maneuver" name = "apsis-maneuver"
version = "0.1.0" version = "0.1.0"
dependencies = [
"apsis-core",
"apsis-kepler",
"apsis-math",
]
[[package]] [[package]]
name = "apsis-math" name = "apsis-math"
@ -193,7 +198,9 @@ name = "apsis-orbit"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apsis-core", "apsis-core",
"apsis-ephemeris",
"apsis-kepler", "apsis-kepler",
"apsis-maneuver",
"apsis-math", "apsis-math",
"apsis-sgp4", "apsis-sgp4",
"apsis-time", "apsis-time",

View File

@ -11,3 +11,6 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true
apsis-kepler.workspace = true

View File

@ -0,0 +1,279 @@
//! The finite burn.
use crate::error::{ManeuverError, ManeuverErrorKind};
use apsis_kepler::{GravityParameter, KeplerianElements, State};
use apsis_math::Vec3;
/// A maneuver that applies a constant acceleration for a duration.
///
/// A finite burn is the model for a thruster that is on for long enough that
/// its duration cannot be ignored: an electric thruster, whose burn may last
/// a large fraction of an orbit, or any burn where the spacecraft moves
/// appreciably while thrusting.
///
/// The acceleration is held constant in an inertial frame chosen by the
/// caller. That is the simplification this model makes: a real thruster
/// fires along a body axis that rotates as the spacecraft turns to hold a
/// direction, and modelling that requires the attitude history, which is not
/// part of this crate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FiniteBurn {
/// The acceleration vector, in kilometers per second squared.
acceleration: Vec3,
/// The duration, in seconds.
duration: f64,
}
impl FiniteBurn {
/// Creates a burn from an acceleration and a duration.
///
/// Returns an error when a value is not finite, when the duration is not
/// positive, or when the acceleration is zero.
pub fn new(acceleration: Vec3, duration: f64) -> Result<Self, ManeuverError> {
if !acceleration.is_finite() || !duration.is_finite() {
return Err(ManeuverError::new(ManeuverErrorKind::NotFinite));
}
if duration <= 0.0 {
return Err(ManeuverError::new(ManeuverErrorKind::NonPositiveDuration)
.with_detail(format!("duration {duration}")));
}
if acceleration.norm() == 0.0 {
return Err(
ManeuverError::new(ManeuverErrorKind::NonPositiveAcceleration)
.with_detail("the acceleration is zero"),
);
}
Ok(Self {
acceleration,
duration,
})
}
/// Creates a burn of a given acceleration magnitude along a direction.
///
/// The direction is normalized, so the acceleration magnitude is fixed
/// whatever the length of the direction vector.
pub fn along(direction: Vec3, acceleration: f64, duration: f64) -> Result<Self, ManeuverError> {
if !acceleration.is_finite() || acceleration <= 0.0 {
return Err(
ManeuverError::new(ManeuverErrorKind::NonPositiveAcceleration)
.with_detail(format!("acceleration {acceleration}")),
);
}
let unit = direction.normalize().ok_or_else(|| {
ManeuverError::new(ManeuverErrorKind::InvalidState)
.with_detail("the direction is zero or not finite")
})?;
Self::new(unit * acceleration, duration)
}
/// Returns the acceleration vector, in kilometers per second squared.
pub const fn acceleration(self) -> Vec3 {
self.acceleration
}
/// Returns the magnitude of the acceleration, in kilometers per second
/// squared.
pub fn acceleration_magnitude(self) -> f64 {
self.acceleration.norm()
}
/// Returns the duration, in seconds.
pub const fn duration(self) -> f64 {
self.duration
}
/// Returns the velocity change the acceleration would give in free space,
/// in kilometers per second.
///
/// This is the acceleration times the duration, the delta-v the burn
/// would deliver without gravity acting during it. Under gravity the
/// achieved change is close to this for a short burn and differs from it
/// for a long one, which is the difference the finite model exists to
/// capture.
pub fn nominal_delta_v(self) -> f64 {
self.acceleration.norm() * self.duration
}
/// Returns the impulse equivalent to the nominal change.
///
/// This is the impulsive burn the finite one approximates as its
/// duration shrinks, and it is what [`FiniteBurn::apply`] returns in the
/// limit of a constant gravity field.
pub fn equivalent_impulse(self) -> Result<crate::impulse::Impulse, ManeuverError> {
crate::impulse::Impulse::new(self.acceleration * self.duration)
}
/// Applies the burn to a state under two-body gravity.
///
/// The position and velocity are integrated forward over the duration
/// with the constant acceleration added to gravity. The exact solution
/// of constant acceleration plus point mass gravity has no closed form,
/// so the integration is a fixed step fourth order Runge-Kutta, which is
/// accurate for the short arcs a burn occupies.
///
/// The achieved velocity change is measured against a coast of the same
/// duration, not against the initial velocity. That distinction matters:
/// gravity changes the velocity during the burn even with no thrust, so
/// the difference between the final and the initial velocity includes
/// the natural rotation of the velocity vector. Comparing against the
/// coast isolates the contribution of the thrust, which is what the
/// burn bought; it also makes the result converge on the nominal change
/// as the duration shrinks, which the difference from the initial
/// velocity would not.
///
/// Returns the state at the end of the burn together with the achieved
/// velocity change.
pub fn apply(
self,
state: State,
parameter: GravityParameter,
steps: usize,
) -> Result<BurnOutcome, ManeuverError> {
if !state.position.is_finite() || !state.velocity.is_finite() {
return Err(ManeuverError::new(ManeuverErrorKind::InvalidState));
}
if steps == 0 {
return Err(ManeuverError::new(ManeuverErrorKind::InvalidState)
.with_detail("the step count is zero"));
}
let mu = parameter.value();
let step = self.duration / steps as f64;
// The two-body acceleration at a position.
let gravity = |position: Vec3| -> Vec3 {
let radius = position.norm();
if radius == 0.0 {
return Vec3::ZERO;
}
position * (-mu / (radius * radius * radius))
};
// The thrust added to gravity, which is what the burn integrates.
let thrust_and_gravity = |position: Vec3| -> Vec3 { gravity(position) + self.acceleration };
// The burn trajectory.
let mut position = state.position;
let mut velocity = state.velocity;
for _ in 0..steps {
let (new_position, new_velocity) =
rk4_step(position, velocity, step, &thrust_and_gravity);
position = new_position;
velocity = new_velocity;
}
// The coast trajectory over the same duration, with the same
// integrator and step, so the only difference is the thrust.
let mut coast_position = state.position;
let mut coast_velocity = state.velocity;
for _ in 0..steps {
let (new_position, new_velocity) =
rk4_step(coast_position, coast_velocity, step, &gravity);
coast_position = new_position;
coast_velocity = new_velocity;
}
if !position.is_finite() || !velocity.is_finite() {
return Err(ManeuverError::new(ManeuverErrorKind::InvalidState)
.with_detail("the integration diverged"));
}
Ok(BurnOutcome {
state: State { position, velocity },
achieved_delta_v: velocity - coast_velocity,
nominal_delta_v: self.nominal_delta_v(),
duration: self.duration,
})
}
/// Applies the burn to a set of Keplerian elements.
///
/// The elements are converted to a state, the burn is applied to it, and
/// the result is converted back. Returns an error when the elements are
/// not valid, when the integration fails, or when the resulting state is
/// no longer an elliptical orbit, which happens when the burn is large
/// enough to escape.
pub fn apply_to_elements(
self,
elements: KeplerianElements,
parameter: GravityParameter,
steps: usize,
) -> Result<BurnOutcome, ManeuverError> {
let state = elements.to_state(parameter).ok_or_else(|| {
ManeuverError::new(ManeuverErrorKind::InvalidState)
.with_detail("the elements are not a valid elliptical orbit")
})?;
self.apply(state, parameter, steps)
}
}
/// What a finite burn produced.
///
/// The outcome keeps the final state, the change the burn achieved, and the
/// change the acceleration would have given in free space, so a caller can
/// see how much gravity cost or added during the burn.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BurnOutcome {
/// The state at the end of the burn.
pub state: State,
/// The velocity change actually achieved, in kilometers per second.
pub achieved_delta_v: Vec3,
/// The velocity change the acceleration would give in free space, in
/// kilometers per second.
pub nominal_delta_v: f64,
/// The burn duration, in seconds.
pub duration: f64,
}
impl BurnOutcome {
/// Returns the magnitude of the achieved velocity change.
pub fn achieved_magnitude(&self) -> f64 {
self.achieved_delta_v.norm()
}
/// Returns the difference between the achieved and nominal change, in
/// kilometers per second.
///
/// A negative value means gravity turned the velocity against the thrust
/// during the burn, so the burn bought less than its free space delta-v.
pub fn gravity_loss(&self) -> f64 {
self.achieved_magnitude() - self.nominal_delta_v
}
/// Returns the elements at the end of the burn.
///
/// Returns `None` when the final state is not an elliptical orbit, which
/// happens when the burn is large enough to escape.
pub fn elements(&self, parameter: GravityParameter) -> Option<KeplerianElements> {
KeplerianElements::from_state(&self.state, parameter)
}
}
/// One classical Runge-Kutta fourth order step of the two-body equations
/// with a constant acceleration added.
///
/// The state is `(position, velocity)` and the equations are the usual
/// `dr/dt = v` and `dv/dt = a(r)`, with `a` combining thrust and gravity.
/// The four stages are the standard ones; writing them out keeps the loop
/// allocation free.
fn rk4_step(
position: Vec3,
velocity: Vec3,
step: f64,
acceleration: &impl Fn(Vec3) -> Vec3,
) -> (Vec3, Vec3) {
let k1_v = acceleration(position);
let k1_r = velocity;
let k2_v = acceleration(position + k1_r * (step / 2.0));
let k2_r = velocity + k1_v * (step / 2.0);
let k3_v = acceleration(position + k2_r * (step / 2.0));
let k3_r = velocity + k2_v * (step / 2.0);
let k4_v = acceleration(position + k3_r * step);
let k4_r = velocity + k3_v * step;
let new_position = position + (k1_r + k2_r * 2.0 + k3_r * 2.0 + k4_r) * (step / 6.0);
let new_velocity = velocity + (k1_v + k2_v * 2.0 + k3_v * 2.0 + k4_v) * (step / 6.0);
(new_position, new_velocity)
}

View File

@ -0,0 +1,76 @@
//! Errors reported while building or applying a maneuver.
use core::fmt;
/// The category of a maneuver error.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ManeuverErrorKind {
/// A velocity change, acceleration, or duration is not finite.
NotFinite,
/// The duration of a burn is not positive.
NonPositiveDuration,
/// The acceleration of a burn is not positive.
NonPositiveAcceleration,
/// A radius is not finite or not positive.
InvalidRadius,
/// The two radii of a transfer are equal, so there is nothing to do.
DegenerateTransfer,
/// A state is missing the data the operation needs.
InvalidState,
}
impl fmt::Display for ManeuverErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::NotFinite => "a value is not finite",
Self::NonPositiveDuration => "the duration is not positive",
Self::NonPositiveAcceleration => "the acceleration is not positive",
Self::InvalidRadius => "the radius is not usable",
Self::DegenerateTransfer => "the two radii are equal",
Self::InvalidState => "the state is not usable",
};
f.write_str(text)
}
}
/// A failure while building or applying a maneuver.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManeuverError {
kind: ManeuverErrorKind,
detail: Option<String>,
}
impl ManeuverError {
/// Creates an error with the given category.
pub fn new(kind: ManeuverErrorKind) -> Self {
Self { kind, detail: None }
}
/// Attaches a description of the value that caused the failure.
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
/// Returns the category of this failure.
pub const fn kind(&self) -> ManeuverErrorKind {
self.kind
}
/// Returns the detail text attached to this error, if any.
pub fn detail(&self) -> Option<&str> {
self.detail.as_deref()
}
}
impl fmt::Display for ManeuverError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.detail {
Some(detail) => write!(f, "{}: {detail}", self.kind),
None => write!(f, "{}", self.kind),
}
}
}
impl core::error::Error for ManeuverError {}

View File

@ -0,0 +1,135 @@
//! The two impulse Hohmann transfer between coplanar circular orbits.
use crate::error::{ManeuverError, ManeuverErrorKind};
use apsis_kepler::GravityParameter;
/// A two impulse transfer between two coplanar circular orbits.
///
/// The transfer is the classic Hohmann maneuver: a burn onto an ellipse
/// whose perigee touches the lower orbit and whose apogee touches the upper
/// one, a coast for half the ellipse's period, and a second burn onto the
/// destination circle. It is the cheapest two impulse transfer between two
/// coplanar circular orbits in the impulsive model, which is why it is the
/// reference every other transfer is measured against.
///
/// The burns are signed along the direction of motion: a positive value
/// raises the orbit and a negative one lowers it, so the same type covers a
/// climb and a descent. The total is the sum of the magnitudes, which is the
/// quantity a propellant budget uses.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HohmannTransfer {
/// The radius of the orbit the transfer starts from, in kilometers.
pub from_radius: f64,
/// The radius of the orbit the transfer arrives at, in kilometers.
pub to_radius: f64,
/// The semi-major axis of the transfer ellipse, in kilometers.
pub transfer_semi_major_axis: f64,
/// The first burn, along the direction of motion, in kilometers per
/// second.
pub first_burn: f64,
/// The second burn, along the direction of motion, in kilometers per
/// second.
pub second_burn: f64,
/// The coast time between the burns, in seconds.
pub transfer_time: f64,
}
impl HohmannTransfer {
/// Returns the total velocity change, in kilometers per second.
///
/// This is the sum of the magnitudes of the two burns, which is what the
/// transfer costs whatever its direction.
pub fn total_delta_v(&self) -> f64 {
self.first_burn.abs() + self.second_burn.abs()
}
/// Returns `true` when the transfer raises the orbit.
pub fn is_raising(&self) -> bool {
self.to_radius > self.from_radius
}
/// Returns the velocity change of the first burn, unsigned.
pub fn first_burn_magnitude(&self) -> f64 {
self.first_burn.abs()
}
/// Returns the velocity change of the second burn, unsigned.
pub fn second_burn_magnitude(&self) -> f64 {
self.second_burn.abs()
}
/// Returns the mean radius the transfer is symmetric about, in
/// kilometers.
///
/// This is the semi-major axis of the ellipse, which is the arithmetic
/// mean of the two orbit radii.
pub fn mean_radius(&self) -> f64 {
self.transfer_semi_major_axis
}
}
/// Returns the two impulse transfer between two circular orbits.
///
/// The radii are in kilometers and must be finite and positive. The two may
/// be given in either order: a larger second radius is a climb and a smaller
/// one a descent.
///
/// Returns an error when a radius is not usable or when the two are equal,
/// because a transfer between an orbit and itself is not a maneuver and is
/// almost always a mistake in a plan.
///
/// The burns follow from the vis-viva equation. At the first radius the
/// circular speed is `sqrt(mu / r1)` and the speed on the transfer ellipse
/// is `sqrt(mu (2 / r1 - 1 / a))`, where `a` is the semi-major axis of the
/// ellipse, so the first burn is the difference. The second burn is the
/// difference between the circular speed at the destination and the speed on
/// the ellipse there, taken the other way round because the ellipse arrives
/// slower than the circle it must join. The coast time is half the period of
/// the ellipse.
pub fn hohmann_transfer(
from_radius: f64,
to_radius: f64,
parameter: GravityParameter,
) -> Result<HohmannTransfer, ManeuverError> {
for radius in [from_radius, to_radius] {
if !radius.is_finite() || radius <= 0.0 {
return Err(ManeuverError::new(ManeuverErrorKind::InvalidRadius)
.with_detail(format!("radius {radius}")));
}
}
if from_radius == to_radius {
return Err(ManeuverError::new(ManeuverErrorKind::DegenerateTransfer)
.with_detail(format!("both radii are {from_radius}")));
}
let mu = parameter.value();
let transfer_semi_major_axis = (from_radius + to_radius) / 2.0;
// The circular speeds at each radius.
let circular_from = (mu / from_radius).sqrt();
let circular_to = (mu / to_radius).sqrt();
// The speeds on the transfer ellipse at each radius, from the vis-viva
// equation `v^2 = mu (2 / r - 1 / a)`.
let transfer_from = (mu * (2.0 / from_radius - 1.0 / transfer_semi_major_axis)).sqrt();
let transfer_to = (mu * (2.0 / to_radius - 1.0 / transfer_semi_major_axis)).sqrt();
// The first burn accelerates onto the ellipse; the second accelerates
// from the ellipse speed up to the circular speed at the destination.
// Both are along the direction of motion, and the sign follows from
// which speed is larger.
let first_burn = transfer_from - circular_from;
let second_burn = circular_to - transfer_to;
// Half the period of the transfer ellipse.
let transfer_time = core::f64::consts::PI * (transfer_semi_major_axis.powi(3) / mu).sqrt();
Ok(HohmannTransfer {
from_radius,
to_radius,
transfer_semi_major_axis,
first_burn,
second_burn,
transfer_time,
})
}

View File

@ -0,0 +1,99 @@
//! The impulsive maneuver.
use crate::error::{ManeuverError, ManeuverErrorKind};
use apsis_math::Vec3;
/// An instantaneous change in velocity.
///
/// An impulse is a velocity change applied in zero time, so it changes the
/// velocity of a state but not its position. That is the model for a burn
/// short compared with the orbital period, and it is how a delta-v budget is
/// written.
///
/// The change is stored as a vector in the same frame as the state it will
/// be applied to, so the caller is responsible for expressing it in that
/// frame. The magnitude is the delta-v, the quantity a propellant budget is
/// measured in.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Impulse {
delta_v: Vec3,
}
impl Impulse {
/// Creates an impulse from a velocity change vector, in kilometers per
/// second.
///
/// Returns an error when a component is not finite or when the vector is
/// zero, because a zero change is not a maneuver and is almost always a
/// mistake in a plan.
pub fn new(delta_v: Vec3) -> Result<Self, ManeuverError> {
if !delta_v.is_finite() {
return Err(ManeuverError::new(ManeuverErrorKind::NotFinite)
.with_detail("the velocity change is not finite"));
}
if delta_v.norm() == 0.0 {
return Err(ManeuverError::new(ManeuverErrorKind::InvalidState)
.with_detail("the velocity change is zero"));
}
Ok(Self { delta_v })
}
/// Creates an impulse of a given magnitude along a direction.
///
/// The direction is normalized, so the magnitude is the delta-v whatever
/// the length of the direction vector. Returns an error when the
/// magnitude is not positive and finite or when the direction is zero or
/// not finite.
pub fn along(direction: Vec3, magnitude: f64) -> Result<Self, ManeuverError> {
if !magnitude.is_finite() || magnitude <= 0.0 {
return Err(
ManeuverError::new(ManeuverErrorKind::NonPositiveAcceleration)
.with_detail(format!("magnitude {magnitude}")),
);
}
let unit = direction.normalize().ok_or_else(|| {
ManeuverError::new(ManeuverErrorKind::InvalidState)
.with_detail("the direction is zero or not finite")
})?;
Self::new(unit * magnitude)
}
/// Returns the velocity change vector, in kilometers per second.
pub const fn delta_v(self) -> Vec3 {
self.delta_v
}
/// Returns the magnitude of the change, in kilometers per second.
///
/// This is the delta-v the burn costs.
pub fn magnitude(self) -> f64 {
self.delta_v.norm()
}
/// Returns a copy of the change scaled by a factor.
///
/// A factor of zero produces a zero impulse, which [`Impulse::new`]
/// rejects, so this returns an error for it rather than a value that
/// cannot be built.
pub fn scaled(self, factor: f64) -> Result<Self, ManeuverError> {
Self::new(self.delta_v * factor)
}
/// Returns a copy of the change reversed, which undoes the burn.
pub fn reversed(self) -> Self {
Self {
delta_v: -self.delta_v,
}
}
/// Applies the impulse to a velocity, returning the new velocity.
pub fn apply_to_velocity(self, velocity: Vec3) -> Vec3 {
velocity + self.delta_v
}
}
impl core::fmt::Display for Impulse {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "impulse of {:.6} km/s", self.magnitude())
}
}

View File

@ -1 +1,66 @@
//! Impulsive and finite orbital maneuver models. //! Impulsive and finite orbital maneuver models.
//!
//! A maneuver changes an orbit by applying thrust. The two models here are
//! the two ends of the spectrum.
//!
//! An impulsive maneuver, [`Impulse`], applies a velocity change
//! instantaneously, so the position is unchanged and only the velocity
//! jumps. This is the model for a chemical burn, whose duration is short
//! compared with the orbital period, and it is the model every textbook
//! delta-v budget is written in. It is exact in the limit of zero burn time
//! and the source of the closed form results for transfers between two
//! orbits.
//!
//! A finite maneuver, [`FiniteBurn`], applies a constant acceleration over a
//! duration, so the position drifts during the burn and the achieved
//! velocity change is less than the thrust would give in free space when
//! the thrust has to fight gravity. It is the model for an electric
//! thruster, whose burn may last a large fraction of an orbit, and for any
//! burn where the short duration approximation is not good enough.
//!
//! The two agree in the limit: a finite burn of acceleration `a` over a
//! duration `t` delivers the impulse `a t`, so a [`FiniteBurn`] with a
//! vanishing duration converges on the matching [`Impulse`]. The tests check
//! that limit, which is what ties the two models together.
//!
//! The impulsive model also provides the classic two impulse transfer
//! between two coplanar circular orbits, [`hohmann_transfer`], which is the
//! reference every low thrust result is compared against. It gives the two
//! burns and the total delta-v for a transfer between two circular orbits of
//! given radii, together with the transfer time, the half period of the
//! transfer ellipse.
//!
//! Conventions match the rest of the orbit layer: lengths are in kilometers,
//! velocities in kilometers per second, and angles in radians.
//!
//! ```
//! use apsis_maneuver::{Impulse, hohmann_transfer};
//! use apsis_kepler::GravityParameter;
//! use apsis_math::Vec3;
//!
//! // An impulsive burn straight along the velocity.
//! let impulse = Impulse::new(Vec3::new(0.0, 1.0, 0.0)).expect("the change is usable");
//! assert!((impulse.magnitude() - 1.0).abs() < 1e-12);
//!
//! // The two burns that raise a circular orbit from 7000 to 8000 km.
//! let transfer = hohmann_transfer(7_000.0, 8_000.0, GravityParameter::WGS84)
//! .expect("the radii are usable");
//! assert!(transfer.first_burn > 0.0);
//! assert!(transfer.total_delta_v() > 0.0);
//! ```
#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod burn;
mod error;
mod hohmann;
mod impulse;
pub use burn::{BurnOutcome, FiniteBurn};
pub use error::{ManeuverError, ManeuverErrorKind};
pub use hohmann::{HohmannTransfer, hohmann_transfer};
pub use impulse::Impulse;
pub use apsis_kepler::{GravityParameter, KeplerianElements};
pub use apsis_math::Vec3;

View File

@ -0,0 +1,420 @@
//! 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.
#[test]
fn a_hohmann_transfer_raises_an_orbit() {
// Raise from 7000 to 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.
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.
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.
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`.
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}");
}

View File

@ -17,3 +17,5 @@ apsis-time.workspace = true
apsis-tle.workspace = true apsis-tle.workspace = true
apsis-sgp4.workspace = true apsis-sgp4.workspace = true
apsis-kepler.workspace = true apsis-kepler.workspace = true
apsis-ephemeris.workspace = true
apsis-maneuver.workspace = true

View File

@ -20,6 +20,16 @@
//! different and wrong answer; the conversions here are explicit about which //! different and wrong answer; the conversions here are explicit about which
//! direction they go. //! direction they go.
//! //!
//! Two further operations are built on the propagation and exposed here
//! because they need the same gravity parameter to stay consistent:
//!
//! - [`Orbit::sample_ephemeris`] records the track on a time grid so that it
//! can be queried by interpolation instead of by propagation, which is
//! what a coverage or access search wants.
//! - [`Orbit::apply_burn`] applies a finite maneuver to the osculating state
//! and returns the resulting elements, which is what a maneuver plan
//! produces.
//!
//! ``` //! ```
//! use apsis_orbit::{GravityModel, Orbit}; //! use apsis_orbit::{GravityModel, Orbit};
//! use apsis_tle::TwoLineElement; //! use apsis_tle::TwoLineElement;
@ -45,9 +55,14 @@
mod orbit; mod orbit;
pub use orbit::Orbit; pub use orbit::{EphemerisSamplingError, ManeuverApplicationError, Orbit};
pub use apsis_ephemeris::{Ephemeris, EphemerisError, EphemerisErrorKind, Sample, Sampler};
pub use apsis_kepler::{GravityParameter, KeplerianElements}; pub use apsis_kepler::{GravityParameter, KeplerianElements};
pub use apsis_maneuver::{
BurnOutcome, FiniteBurn, HohmannTransfer, Impulse, ManeuverError, ManeuverErrorKind,
hohmann_transfer,
};
pub use apsis_sgp4::{ pub use apsis_sgp4::{
Elements, GravityModel, PropagationError, PropagationErrorKind, Satellite, State, Elements, GravityModel, PropagationError, PropagationErrorKind, Satellite, State,
}; };

View File

@ -1,9 +1,12 @@
//! The combined orbit type. //! The combined orbit type.
use apsis_ephemeris::EphemerisError;
use apsis_kepler::{GravityParameter, KeplerianElements}; use apsis_kepler::{GravityParameter, KeplerianElements};
use apsis_maneuver::ManeuverError;
use apsis_math::Vec3; use apsis_math::Vec3;
use apsis_sgp4::{Elements, GravityModel, PropagationError, Satellite, State}; use apsis_sgp4::{Elements, GravityModel, PropagationError, Satellite, State};
use apsis_tle::TwoLineElement; use apsis_tle::TwoLineElement;
use core::fmt;
/// A satellite orbit, held as mean elements ready for propagation. /// A satellite orbit, held as mean elements ready for propagation.
/// ///
@ -131,8 +134,141 @@ impl Orbit {
pub fn position_at(&self, minutes: f64) -> Result<Vec3, PropagationError> { pub fn position_at(&self, minutes: f64) -> Result<Vec3, PropagationError> {
self.propagate_minutes(minutes).map(|state| state.position) self.propagate_minutes(minutes).map(|state| state.position)
} }
/// Samples the orbit into an ephemeris over a range of minutes.
///
/// The propagation is the expensive part of a track, so a table built
/// once and queried many times is worth the memory. The sample times are
/// minutes from the element epoch, matching the rest of this type, while
/// the returned ephemeris keeps seconds, matching `apsis-ephemeris`.
///
/// The table is built with position and velocity from the propagator,
/// so the Hermite interpolation has exact endpoint slopes and the error
/// between samples is tiny; there is no need to use the position only
/// route except for a source that has no velocity.
///
/// Returns an error when the grid is not usable or when a sample cannot
/// be propagated, which happens when the elements have decayed or the
/// range leaves the region where the model is defined.
pub fn sample_ephemeris(
&self,
start_minutes: f64,
end_minutes: f64,
step_minutes: f64,
) -> Result<apsis_ephemeris::Ephemeris, EphemerisSamplingError> {
let sampler = apsis_ephemeris::Sampler::new(
start_minutes * 60.0,
end_minutes * 60.0,
step_minutes * 60.0,
);
if !sampler.is_valid() {
return Err(EphemerisSamplingError::InvalidGrid);
}
let mut failure = None;
let ephemeris = sampler.sample(|seconds| {
// The minutes are recovered from the seconds the sampler uses,
// so a non-integer step stays consistent with the grid.
match self.propagate_minutes(seconds / 60.0) {
Ok(state) => apsis_ephemeris::Sample::new(seconds, state.position, state.velocity),
Err(error) => {
// The sampler cannot report the propagation failure, so
// it is recorded and the sample is filled with a value
// the table constructor will then reject.
failure = Some(error);
apsis_ephemeris::Sample::new(
seconds,
Vec3::new(f64::NAN, f64::NAN, f64::NAN),
Vec3::new(f64::NAN, f64::NAN, f64::NAN),
)
}
}
});
if let Some(error) = failure {
return Err(EphemerisSamplingError::Propagation(error));
}
ephemeris.map_err(EphemerisSamplingError::Ephemeris)
}
/// Applies a finite burn and returns the resulting osculating elements.
///
/// The burn is applied to the osculating state at the given time, so the
/// manoeuvre is described relative to the orbit the satellite is
/// actually on rather than the mean elements. The resulting elements are
/// the osculating elements of the post burn state, which is what a
/// following propagation needs. Because the underlying propagator is
/// built for one element set, this is a planning result rather than a
/// new propagator; a caller that wants to propagate the new orbit starts
/// a fresh `Orbit` from the returned elements.
pub fn apply_burn(
&self,
minutes: f64,
burn: apsis_maneuver::FiniteBurn,
steps: usize,
) -> Result<KeplerianElements, ManeuverApplicationError> {
let state = self
.propagate_minutes(minutes)
.map_err(ManeuverApplicationError::Propagation)?;
let kepler_state = apsis_kepler::State {
position: state.position,
velocity: state.velocity,
};
let outcome = burn
.apply(kepler_state, self.parameter, steps)
.map_err(ManeuverApplicationError::Maneuver)?;
KeplerianElements::from_state(&outcome.state, self.parameter)
.ok_or(ManeuverApplicationError::NotElliptical)
}
} }
/// A failure while sampling an orbit into an ephemeris.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EphemerisSamplingError {
/// The requested grid is not usable: the step is not positive or the
/// range is empty or reversed.
InvalidGrid,
/// A sample could not be propagated.
Propagation(PropagationError),
/// The table rejected the samples, which should not happen for a valid
/// grid and is reported rather than hidden.
Ephemeris(EphemerisError),
}
impl fmt::Display for EphemerisSamplingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidGrid => f.write_str("the sampling grid is not usable"),
Self::Propagation(error) => write!(f, "a sample could not be propagated: {error}"),
Self::Ephemeris(error) => write!(f, "the ephemeris rejected the samples: {error}"),
}
}
}
impl core::error::Error for EphemerisSamplingError {}
/// A failure while applying a burn to an orbit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManeuverApplicationError {
/// The state before the burn could not be propagated.
Propagation(PropagationError),
/// The burn itself was not usable.
Maneuver(ManeuverError),
/// The post burn state is not an elliptical orbit, which happens when
/// the burn is large enough to escape.
NotElliptical,
}
impl fmt::Display for ManeuverApplicationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Propagation(error) => write!(f, "the state could not be propagated: {error}"),
Self::Maneuver(error) => write!(f, "the burn is not usable: {error}"),
Self::NotElliptical => f.write_str("the post burn orbit is not elliptical"),
}
}
}
impl core::error::Error for ManeuverApplicationError {}
/// Returns the gravitational parameter that matches a gravity model. /// Returns the gravitational parameter that matches a gravity model.
/// ///
/// The two models differ slightly in their value of the gravitational /// The two models differ slightly in their value of the gravitational

View File

@ -0,0 +1,188 @@
//! 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);
}