Files
astrokit/src/dynamics/mod.rs
T
2026-10-01 18:27:06 -04:00

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());
}
}