537 lines
19 KiB
Rust
537 lines
19 KiB
Rust
//! The models that contribute to a state's derivative.
|
|
//!
|
|
//! A [`DynamicsModel`] returns a [`Contribution`] with five channels — force,
|
|
//! acceleration, torque, mass rate and power — all defaulting to zero, so a model
|
|
//! implements only what it affects. A solar array contributes power and nothing
|
|
//! else.
|
|
//!
|
|
//! # Specification and instantiation are separate
|
|
//!
|
|
//! A trait object is tied to one scalar: a `Vec<Box<dyn DynamicsModel<f64, ..>>>`
|
|
//! cannot be propagated in a dual. So what a script composes is [`Dynamics`] —
|
|
//! plain data, serializable, scalar-free — and [`Dynamics::build`] instantiates
|
|
//! trait objects at whatever scalar is being propagated:
|
|
//!
|
|
//! ```
|
|
//! use astrokit::dynamics::{build, Dynamics};
|
|
//! use astrokit::state::Orbit;
|
|
//!
|
|
//! let spec = vec![Dynamics::TwoBody { body: astrokit::body::Body::EARTH }];
|
|
//!
|
|
//! // The same spec, at two scalars.
|
|
//! let plain = build::<f64, 6, Orbit>(&spec);
|
|
//! # #[cfg(feature = "autodiff")]
|
|
//! let dual = build::<num_dual::DualSVec64<6>, 6, Orbit>(&spec);
|
|
//! ```
|
|
//!
|
|
//! The spec is also what gets serialized into a run's provenance, so a
|
|
//! propagation's force composition is recoverable from the record rather than
|
|
//! hidden behind a mode flag.
|
|
//!
|
|
//! A side benefit: because a model is constructed at the propagated scalar, it
|
|
//! stores its constants already converted. [`models::TwoBody`] holds its body's
|
|
//! `mu` as a `T`, so `T::lift` happens once at build time and never in the
|
|
//! derivative.
|
|
//!
|
|
//! # Force or acceleration
|
|
//!
|
|
//! Both channels exist because models divide cleanly between them. Gravity and
|
|
//! third-body are accelerations: mass cancels exactly, and making them return a
|
|
//! force so assembly can divide it back out would be wasted work and would invent
|
|
//! a mass dependence the physics does not have. Drag, SRP and thrust are forces.
|
|
//! Assembly sums them as `acceleration + force / mass`, which mirrors what the
|
|
//! rotational side will do with torque and the inertia tensor.
|
|
|
|
use core::fmt;
|
|
use std::sync::Arc;
|
|
|
|
use anise::almanac::Almanac;
|
|
use hifitime::Epoch;
|
|
use nalgebra::{Matrix3, Vector3};
|
|
use ordinary_diffeq::scalar::Scalar;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::ops::{Add, AddAssign};
|
|
|
|
use crate::body::Body;
|
|
use crate::frames::Frame;
|
|
use crate::gravity::GravityModel;
|
|
use crate::spacecraft::Spacecraft;
|
|
use crate::state::{HasPosition, HasVelocity, Layout, State};
|
|
use crate::time::UniformEpoch;
|
|
|
|
pub mod models;
|
|
|
|
/// What one [`DynamicsModel`] contributes at an instant.
|
|
///
|
|
/// Every channel defaults to zero. Build one with [`Contribution::default`] and
|
|
/// struct update syntax, or [`Contribution::from_force`] /
|
|
/// [`Contribution::from_acceleration`] for the common cases.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Contribution<T: Scalar> {
|
|
/// Inertial force, N. Divided by mass during assembly.
|
|
pub force: Vector3<T>,
|
|
/// Inertial acceleration, m/s^2, for models in which mass cancels exactly.
|
|
pub acceleration: Vector3<T>,
|
|
/// Body-frame torque, N m.
|
|
pub torque: Vector3<T>,
|
|
/// Rate of change of mass, kg/s. Negative when propellant is spent.
|
|
pub mass_rate: T,
|
|
/// Net power, W. Positive into the power system.
|
|
pub power: T,
|
|
}
|
|
|
|
impl<T: Scalar> Default for Contribution<T> {
|
|
fn default() -> Self {
|
|
Self {
|
|
force: Vector3::zeros(),
|
|
acceleration: Vector3::zeros(),
|
|
torque: Vector3::zeros(),
|
|
mass_rate: T::lift(0.0),
|
|
power: T::lift(0.0),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: Scalar> Contribution<T> {
|
|
/// A contribution that is only a force, in newtons.
|
|
#[inline]
|
|
pub fn from_force(force: Vector3<T>) -> Self {
|
|
Self {
|
|
force,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// A contribution that is only an acceleration, in m/s^2.
|
|
#[inline]
|
|
pub fn from_acceleration(acceleration: Vector3<T>) -> Self {
|
|
Self {
|
|
acceleration,
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: Scalar> AddAssign for Contribution<T> {
|
|
#[inline]
|
|
fn add_assign(&mut self, rhs: Self) {
|
|
self.force += rhs.force;
|
|
self.acceleration += rhs.acceleration;
|
|
self.torque += rhs.torque;
|
|
self.mass_rate += rhs.mass_rate;
|
|
self.power += rhs.power;
|
|
}
|
|
}
|
|
|
|
impl<T: Scalar> Add for Contribution<T> {
|
|
type Output = Self;
|
|
|
|
#[inline]
|
|
fn add(mut self, rhs: Self) -> Self {
|
|
self += rhs;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// What a [`DynamicsModel`] needs that is not in the state vector.
|
|
///
|
|
/// Owns its spacecraft rather than borrowing one. A borrow would put a lifetime on
|
|
/// every signature that carries a context, and the clone is one per propagation
|
|
/// rather than one per derivative evaluation. The almanac is the exception, since one
|
|
/// serves every propagation in a run and is large.
|
|
#[derive(Clone)]
|
|
pub struct Context {
|
|
/// The epoch the propagation started from, in uniform time. The `t` a model
|
|
/// receives is seconds after this.
|
|
pub epoch: UniformEpoch,
|
|
/// The frame the state is expressed in, and so the central body a model can
|
|
/// assume without being told.
|
|
pub frame: Frame,
|
|
/// The vehicle, when one has been given.
|
|
///
|
|
/// Absent for a propagation of a point mass, which needs no geometry. A model
|
|
/// that does need one — drag, radiation pressure, anything with a mass rate —
|
|
/// says so rather than assuming a default vehicle.
|
|
spacecraft: Option<Spacecraft>,
|
|
/// The vehicle's inverse inertia, computed once when it was attached.
|
|
///
|
|
/// Private and derived, so it cannot fall out of step with `spacecraft`. A 3x3
|
|
/// inverse is cheap but not free, and the rotational derivative needs it on
|
|
/// every evaluation.
|
|
inverse_inertia: Option<Matrix3<f64>>,
|
|
/// The loaded kernels, for a model that needs an orientation or an ephemeris.
|
|
///
|
|
/// Shared rather than owned: one almanac serves every propagation in a run, and
|
|
/// cloning the `Arc` is what gets handed to each. Absent for a propagation whose
|
|
/// models need neither, which point-mass gravity does not.
|
|
almanac: Option<Arc<Almanac>>,
|
|
}
|
|
|
|
impl fmt::Debug for Context {
|
|
/// An [`Almanac`] has no `Debug`, and its contents are not what a reader of a
|
|
/// context wants anyway, so it shows as present or absent.
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("Context")
|
|
.field("epoch", &self.epoch)
|
|
.field("frame", &self.frame)
|
|
.field("spacecraft", &self.spacecraft)
|
|
.field("almanac", &self.almanac.is_some())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Context {
|
|
/// A context anchored at `epoch`, in `frame`.
|
|
///
|
|
/// The epoch is normalized to the uniform scale a propagation runs in, so it
|
|
/// may be given in any scale.
|
|
pub fn new(epoch: Epoch, frame: Frame) -> Self {
|
|
Self {
|
|
epoch: UniformEpoch::new(epoch),
|
|
frame,
|
|
spacecraft: None,
|
|
inverse_inertia: None,
|
|
almanac: None,
|
|
}
|
|
}
|
|
|
|
/// The same context with kernels attached.
|
|
pub fn with_almanac(mut self, almanac: Arc<Almanac>) -> Self {
|
|
self.almanac = Some(almanac);
|
|
self
|
|
}
|
|
|
|
/// The loaded kernels, if any were attached.
|
|
///
|
|
/// A model that needs one and does not find it is a propagation that was set up
|
|
/// wrong, and [`Propagation::run`](crate::propagation::Propagation::run) checks
|
|
/// for that before integrating, where it can still say so usefully.
|
|
pub fn almanac(&self) -> Option<&Almanac> {
|
|
self.almanac.as_deref()
|
|
}
|
|
|
|
/// The same context with a vehicle attached.
|
|
pub fn with_spacecraft(mut self, spacecraft: Spacecraft) -> Self {
|
|
self.inverse_inertia = spacecraft.inertia.try_inverse();
|
|
self.spacecraft = Some(spacecraft);
|
|
self
|
|
}
|
|
|
|
/// The vehicle, if one was attached.
|
|
pub fn spacecraft(&self) -> Option<&Spacecraft> {
|
|
self.spacecraft.as_ref()
|
|
}
|
|
|
|
/// The vehicle's inertia tensor and its inverse, if one was attached and the
|
|
/// tensor is invertible.
|
|
pub fn inertia(&self) -> Option<(&Matrix3<f64>, &Matrix3<f64>)> {
|
|
self.spacecraft
|
|
.as_ref()
|
|
.zip(self.inverse_inertia.as_ref())
|
|
.map(|(spacecraft, inverse)| (&spacecraft.inertia, inverse))
|
|
}
|
|
|
|
/// The vehicle's battery, if it has one.
|
|
pub fn battery(&self) -> Option<crate::spacecraft::Battery> {
|
|
self.spacecraft.as_ref().and_then(|s| s.battery)
|
|
}
|
|
|
|
/// The mass to divide a force by, in kilograms.
|
|
///
|
|
/// The state's own mass when the layout carries one, since that is what is
|
|
/// being integrated, and the vehicle's otherwise.
|
|
pub fn mass<T: Scalar>(&self, from_state: Option<T>) -> Option<T> {
|
|
from_state.or_else(|| self.spacecraft.as_ref().map(|s| T::lift(s.mass)))
|
|
}
|
|
|
|
/// The epoch `t` seconds after the start of the propagation.
|
|
///
|
|
/// `t` is uniform seconds, which is what the integrator advances, so this goes
|
|
/// through [`crate::time::advance`] rather than adding a `Duration` directly
|
|
///
|
|
/// `t` is projected to its real part: an epoch is a real quantity, and there is
|
|
/// no such thing as a dual date. A propagation differentiated with respect to
|
|
/// its initial state is unaffected, since the time grid does not depend on the
|
|
/// state. It does mean sensitivities with respect to epoch itself are not
|
|
/// available this way.
|
|
pub fn epoch_at<T: Scalar>(&self, t: T) -> Epoch {
|
|
(self.epoch + t.re()).epoch()
|
|
}
|
|
|
|
/// The propagation time, in uniform seconds, at which `epoch` occurs.
|
|
///
|
|
/// The inverse of [`Context::epoch_at`], for turning a measurement schedule
|
|
/// into integrator stop times.
|
|
pub fn seconds_at(&self, epoch: Epoch) -> f64 {
|
|
(UniformEpoch::new(epoch) - self.epoch).to_seconds()
|
|
}
|
|
}
|
|
|
|
/// A model contributing to the state derivative.
|
|
///
|
|
/// Generic at the trait level rather than the method level, so the methods stay
|
|
/// non-generic and the trait stays object-safe: `Box<dyn DynamicsModel<T, D, L>>`
|
|
/// works, which is what lets a propagation hold a heterogeneous force list.
|
|
///
|
|
/// Implement it for the capabilities the model actually needs.
|
|
/// [`models::TwoBody`] requires only `L: HasPosition<D>`, so it works for every
|
|
/// layout with a position.
|
|
pub trait DynamicsModel<T: Scalar, const D: usize, L: Layout<D>> {
|
|
/// This model's contribution `t` seconds after the propagation epoch.
|
|
///
|
|
/// Taking a [`State`] rather than a bare vector means `L` is inferred at the
|
|
/// call site, and lets a model read components by name.
|
|
fn contribution(&self, t: T, y: &State<T, D, L>, context: &Context) -> Contribution<T>;
|
|
|
|
/// A short name, for provenance records and diagnostics.
|
|
fn name(&self) -> &'static str;
|
|
}
|
|
|
|
/// A dynamics as plain, serializable, scalar-free data.
|
|
///
|
|
/// This is what a script composes and what a run's provenance records. [`build`]
|
|
/// turns a list of these into trait objects at the scalar being propagated.
|
|
///
|
|
/// The variants are re-exported by the prelude, so a script writes
|
|
/// `TwoBody { mu }` rather than `Dynamics::TwoBody { mu }`.
|
|
#[non_exhaustive]
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub enum Dynamics {
|
|
/// Point-mass gravity of a body.
|
|
///
|
|
/// Named explicitly rather than taken from the propagation's frame, because an
|
|
/// n-body composition needs several and only one of them is the center.
|
|
TwoBody {
|
|
/// The attracting body.
|
|
body: Body,
|
|
},
|
|
/// Gravity of a body as a spherical harmonic expansion.
|
|
///
|
|
/// Replaces [`Dynamics::TwoBody`] for the same body rather than adding to it: the
|
|
/// point mass is the degree-zero term of this expansion, so composing both counts
|
|
/// the monopole twice.
|
|
///
|
|
/// The field is named rather than implied, because two Earth fields truncated to
|
|
/// the same degree can differ by far more than a validation tolerance. The body
|
|
/// comes from the field, so the two cannot disagree.
|
|
Gravity {
|
|
/// Which published coefficient set.
|
|
field: GravityModel,
|
|
/// Degree to truncate at.
|
|
degree: usize,
|
|
/// Order to truncate at, at most `degree`.
|
|
order: usize,
|
|
},
|
|
/// Gravity of a body other than the one the propagation is centered on.
|
|
ThirdBody {
|
|
/// The perturbing body. Must not be the frame's center.
|
|
body: Body,
|
|
},
|
|
/// What sunlight does: radiation pressure, and power if the layout is tracking it.
|
|
///
|
|
/// Needs a [`Spacecraft`] for its surface, and the eclipse geometry comes from the
|
|
/// frame's center. A faceted surface or a fixed solar array also needs a layout
|
|
/// carrying an attitude, since both depend on which way the spacecraft is pointing.
|
|
Sunshine,
|
|
}
|
|
|
|
impl Dynamics {
|
|
/// Instantiates this model at the scalar `T` and layout `L`.
|
|
pub fn build<T, const D: usize, L>(&self) -> Box<dyn DynamicsModel<T, D, L>>
|
|
where
|
|
T: Scalar + 'static,
|
|
L: HasPosition<D> + HasVelocity<D> + 'static,
|
|
{
|
|
match *self {
|
|
Self::TwoBody { body } => Box::new(models::TwoBody::new(body)),
|
|
Self::Gravity {
|
|
field,
|
|
degree,
|
|
order,
|
|
} => {
|
|
let built = field
|
|
.field(degree, order)
|
|
.expect("truncation beyond the coefficients; Propagation::run checks");
|
|
Box::new(models::SphericalHarmonic::new(field.body(), built))
|
|
}
|
|
Self::ThirdBody { body } => Box::new(models::ThirdBody::new(body)),
|
|
Self::Sunshine => Box::new(models::Sunshine::new()),
|
|
}
|
|
}
|
|
|
|
/// Whether this model reads an orientation or an ephemeris, and so needs kernels.
|
|
///
|
|
/// [`Propagation::run`](crate::propagation::Propagation::run) uses this to say so
|
|
/// before integrating, where it can still be useful about it.
|
|
pub fn needs_almanac(&self) -> bool {
|
|
match self {
|
|
Self::TwoBody { .. } => false,
|
|
Self::Gravity { .. } | Self::ThirdBody { .. } | Self::Sunshine => true,
|
|
}
|
|
}
|
|
|
|
/// The truncation this spec asks for, if it is a field.
|
|
///
|
|
/// For the same pre-flight check: a truncation the coefficients cannot support is
|
|
/// worth rejecting before a propagation starts rather than inside a derivative.
|
|
pub fn field_truncation(&self) -> Option<(GravityModel, usize, usize)> {
|
|
match *self {
|
|
Self::Gravity {
|
|
field,
|
|
degree,
|
|
order,
|
|
} => Some((field, degree, order)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Whether this model reads the spacecraft's geometry, and so needs one.
|
|
pub fn needs_spacecraft(&self) -> bool {
|
|
matches!(self, Self::Sunshine)
|
|
}
|
|
|
|
/// The perturbing body, if this spec is a third body.
|
|
///
|
|
/// For the same pre-flight check as the others: a third body that is also the
|
|
/// central body has no separation to divide by, and is worth refusing before a
|
|
/// propagation starts.
|
|
pub fn third_body(&self) -> Option<Body> {
|
|
match *self {
|
|
Self::ThirdBody { body } => Some(body),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Instantiates a whole force list at the scalar `T` and layout `L`.
|
|
pub fn build<T, const D: usize, L>(specs: &[Dynamics]) -> Vec<Box<dyn DynamicsModel<T, D, L>>>
|
|
where
|
|
T: Scalar + 'static,
|
|
L: HasPosition<D> + HasVelocity<D> + 'static,
|
|
{
|
|
specs.iter().map(Dynamics::build).collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::state::{Full, Orbit};
|
|
|
|
#[test]
|
|
fn test_contribution_defaults_to_zero() {
|
|
let c = Contribution::<f64>::default();
|
|
assert_eq!(c.force, Vector3::zeros());
|
|
assert_eq!(c.acceleration, Vector3::zeros());
|
|
assert_eq!(c.torque, Vector3::zeros());
|
|
assert_eq!(c.mass_rate, 0.0);
|
|
assert_eq!(c.power, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_contributions_accumulate_per_channel() {
|
|
let a = Contribution {
|
|
acceleration: Vector3::new(1.0, 0.0, 0.0),
|
|
power: 50.0,
|
|
..Default::default()
|
|
};
|
|
let b = Contribution {
|
|
force: Vector3::new(0.0, 2.0, 0.0),
|
|
mass_rate: -0.01,
|
|
..Default::default()
|
|
};
|
|
|
|
let total = a + b;
|
|
assert_eq!(total.acceleration, Vector3::new(1.0, 0.0, 0.0));
|
|
assert_eq!(total.force, Vector3::new(0.0, 2.0, 0.0));
|
|
assert_eq!(total.mass_rate, -0.01);
|
|
assert_eq!(total.power, 50.0);
|
|
assert_eq!(total.torque, Vector3::zeros());
|
|
}
|
|
|
|
#[test]
|
|
fn test_spec_round_trips_through_json() {
|
|
let spec = vec![Dynamics::TwoBody { body: Body::EARTH }];
|
|
let json = serde_json::to_string(&spec).unwrap();
|
|
let back: Vec<Dynamics> = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(spec, back);
|
|
}
|
|
|
|
/// The property the spec/instantiation split exists for: one spec value,
|
|
/// built at two different scalars.
|
|
#[test]
|
|
fn test_one_spec_builds_at_two_scalars() {
|
|
let spec = vec![Dynamics::TwoBody { body: Body::EARTH }];
|
|
|
|
let plain = build::<f64, 6, Orbit>(&spec);
|
|
assert_eq!(plain.len(), 1);
|
|
assert_eq!(plain[0].name(), "two_body");
|
|
|
|
#[cfg(feature = "autodiff")]
|
|
{
|
|
let dual = build::<num_dual::DualSVec64<6>, 6, Orbit>(&spec);
|
|
assert_eq!(dual.len(), 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_context_advances_an_epoch() {
|
|
let epoch = Epoch::from_gregorian_utc_hms(2026, 1, 1, 0, 0, 0);
|
|
let context = Context::new(epoch, Frame::EARTH_J2000);
|
|
assert_eq!(context.epoch_at(0.0), epoch);
|
|
assert_eq!(
|
|
context.epoch_at(3600.0),
|
|
Epoch::from_gregorian_utc_hms(2026, 1, 1, 1, 0, 0)
|
|
);
|
|
assert_eq!(context.seconds_at(context.epoch_at(3600.0)), 3600.0);
|
|
}
|
|
|
|
/// The integrator advances uniform seconds, so an arc spanning a leap second
|
|
/// has to land where the integrator thinks it did, not where a wall clock says.
|
|
#[test]
|
|
fn test_context_time_is_uniform_across_a_leap_second() {
|
|
let context = Context::new(
|
|
Epoch::from_gregorian_utc_hms(2016, 12, 31, 12, 0, 0),
|
|
Frame::EARTH_J2000,
|
|
);
|
|
|
|
let after_a_day = context.epoch_at(86400.0);
|
|
assert_eq!(
|
|
after_a_day.to_tai_seconds() - context.epoch.epoch().to_tai_seconds(),
|
|
86400.0
|
|
);
|
|
// Which is one second before the next day's noon UTC, the leap second
|
|
// having been inserted in between.
|
|
assert_eq!(
|
|
context.seconds_at(Epoch::from_gregorian_utc_hms(2017, 1, 1, 12, 0, 0)),
|
|
86401.0
|
|
);
|
|
}
|
|
|
|
/// A model bounded on a capability works for any layout that has it.
|
|
#[test]
|
|
fn test_two_body_acceleration_is_layout_agnostic() {
|
|
let context = Context::new(
|
|
Epoch::from_gregorian_utc_hms(2026, 1, 1, 0, 0, 0),
|
|
Frame::EARTH_J2000,
|
|
);
|
|
let mu = 3.986004415e14;
|
|
let r = Vector3::new(7.0e6, 0.0, 0.0);
|
|
|
|
let model = models::TwoBody::<f64>::new(Body::EARTH);
|
|
let on_orbit = Orbit.with_position(r);
|
|
let on_full = Full.with_position(r).with_mass(500.0);
|
|
|
|
let a = model.contribution(0.0, &on_orbit, &context);
|
|
let b = model.contribution(0.0, &on_full, &context);
|
|
|
|
assert_eq!(a.acceleration, b.acceleration);
|
|
approx::assert_relative_eq!(
|
|
a.acceleration[0],
|
|
-mu / (7.0e6 * 7.0e6),
|
|
max_relative = 1e-15
|
|
);
|
|
// Gravity is an acceleration, not a force: mass cancels exactly.
|
|
assert_eq!(a.force, Vector3::zeros());
|
|
}
|
|
}
|