apsis-rs/crates/orbit/apsis-orbit/src/orbit.rs

288 lines
12 KiB
Rust

//! The combined orbit type.
use apsis_ephemeris::EphemerisError;
use apsis_kepler::{GravityParameter, KeplerianElements};
use apsis_maneuver::ManeuverError;
use apsis_math::Vec3;
use apsis_sgp4::{Elements, GravityModel, PropagationError, Satellite, State};
use apsis_tle::TwoLineElement;
use core::fmt;
/// A satellite orbit, held as mean elements ready for propagation.
///
/// The type pairs a prepared SGP4 satellite with the gravity model it was
/// initialised against, because the osculating element conversion needs the
/// same gravitational parameter to be consistent with the propagation.
#[derive(Debug, Clone)]
pub struct Orbit {
satellite: Satellite,
parameter: GravityParameter,
}
impl Orbit {
/// Prepares an orbit from a parsed element set and a gravity model.
///
/// The gravity model must match the one the element set was fitted
/// against, which for a catalogued satellite means
/// [`GravityModel::WGS72`]. Returns `None` when the elements are not
/// propagatable.
pub fn from_element(element: &TwoLineElement, model: GravityModel) -> Option<Self> {
Self::try_from_element(element, model).ok()
}
/// Prepares an orbit from a parsed element set and a gravity model.
///
/// Returns an error describing which element is not propagatable.
pub fn try_from_element(
element: &TwoLineElement,
model: GravityModel,
) -> Result<Self, PropagationError> {
let satellite = Satellite::try_from_element(element, model)?;
Ok(Self {
satellite,
parameter: parameter_for(model),
})
}
/// Prepares an orbit from mean elements already in model units.
pub fn from_elements(
elements: &Elements,
model: GravityModel,
) -> Result<Self, PropagationError> {
let satellite = Satellite::try_from_elements(elements, model)?;
Ok(Self {
satellite,
parameter: parameter_for(model),
})
}
/// Returns the underlying propagator.
pub fn satellite(&self) -> &Satellite {
&self.satellite
}
/// Returns the gravitational parameter paired with the gravity model.
///
/// The value is the one used for the osculating element conversion, so
/// that the conversion is consistent with the propagation.
pub fn gravity_parameter(&self) -> GravityParameter {
self.parameter
}
/// Returns the epoch of the elements as a Modified Julian Date on the
/// UTC scale.
///
/// The epoch is the value written in the element set, which is the
/// origin the propagator measures time from.
pub fn epoch_mjd(&self) -> f64 {
self.satellite.epoch_mjd()
}
/// Returns `true` when the propagator uses the deep space branch.
pub fn is_deep_space(&self) -> bool {
self.satellite.is_deep_space()
}
/// Returns the state at a number of minutes from the element epoch.
///
/// Position is in kilometers and velocity in kilometers per second, in
/// the True Equator Mean Equinox frame.
pub fn propagate_minutes(&self, minutes: f64) -> Result<State, PropagationError> {
self.satellite.propagate_minutes(minutes)
}
/// Returns the state at a Modified Julian Date on the UTC scale.
pub fn propagate_at(&self, utc_mjd: f64) -> Result<State, PropagationError> {
self.propagate_minutes((utc_mjd - self.satellite.epoch_mjd()) * 1_440.0)
}
/// Returns the osculating Keplerian elements at a number of minutes from
/// the element epoch.
///
/// The osculating elements describe the two-body orbit that matches the
/// position and velocity at that instant. They are not the mean elements
/// of the element set, and they change along the orbit because SGP4
/// includes perturbations that the two-body model does not.
///
/// Returns an error when the state cannot be propagated, or when the
/// instantaneous orbit is not elliptical, which can happen for a state
/// the propagation has pushed onto an escape trajectory.
pub fn osculating_elements(&self, minutes: f64) -> Result<KeplerianElements, PropagationError> {
let state = self.propagate_minutes(minutes)?;
// The two crates define their own state type, so the components are
// transferred explicitly rather than relying on a shared type.
let kepler_state = apsis_kepler::State {
position: state.position,
velocity: state.velocity,
};
KeplerianElements::from_state(&kepler_state, self.parameter).ok_or_else(|| {
PropagationError::new(apsis_sgp4::PropagationErrorKind::OutOfRange)
.with_detail("the instantaneous orbit is not elliptical")
})
}
/// Returns the magnitude of the state vector.
///
/// This is a convenience for the common case of wanting only the
/// distance from the center of the Earth.
pub fn radius_at(&self, minutes: f64) -> Result<f64, PropagationError> {
self.propagate_minutes(minutes)
.map(|state| state.position.norm())
}
/// Returns the position at a number of minutes from the element epoch.
pub fn position_at(&self, minutes: f64) -> Result<Vec3, PropagationError> {
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.
///
/// The two models differ slightly in their value of the gravitational
/// parameter, and using the wrong one shifts the osculating semi-major axis
/// by a small amount. The values are taken from the model definitions rather
/// than duplicated, so a change to a model propagates here.
fn parameter_for(model: GravityModel) -> GravityParameter {
// The two parameters differ by 0.3 km^3/s^2, about one part in a
// million. The comparison is on the model's defining value, which is
// exact for the constants defined in this workspace.
if model.mu == GravityModel::WGS84.mu {
GravityParameter::WGS84
} else {
GravityParameter::WGS72
}
}