Added State Layouts and a few niceties like examples, git hooks, etc

This commit is contained in:
connor committed 2026-09-30 11:07:20 -04:00
1 parent 5d9cd798b1
commit 0087f831fb
5 files changed
+618 -4

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# Enable with: git config core.hooksPath .githooks
# Skip a single commit with: git commit --no-verify
set -e
echo "pre-commit: cargo fmt --check"
cargo fmt --check
echo "pre-commit: cargo clippy"
cargo clippy --all-targets --all-features -- -D warnings
echo "pre-commit: cargo test"
cargo test --quiet
echo "pre-commit: cargo doc"
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --quiet
echo "pre-commit: US spelling"
staged=$(git diff --cached --name-only --diff-filter=ACM -- '*.rs' '*.md' '*.toml')
if [ -n "$staged" ]; then
# shellcheck disable=SC2086
if git grep -wniE -e 'metre|metres|kilometre|kilometres' \
-e 'licence|defence|programme|catalogue|grey' \
-e 'manoeuvre|manoeuvres|manoeuvred|manoeuvring' \
-e 'normalise|normalised|normalises|normalising|normalisation' \
-e 'stabilise|stabilised|stabilises|stabilising|stabilisation' \
-e 'recognise|recognised|recognises|recognising' \
-e 'monomorphise|monomorphised|monomorphises|monomorphising' \
-e 'serialise|serialised|serialises|serialising|serialisation' \
-e 'behaviour|behaviours|colour|colours|centre|centres' \
-e 'analyse|analysed|analyses|analysing' \
-e 'organise|organised|organisation|optimise|optimised|optimisation' \
-e 'initialise|initialised|initialisation|utilise|utilised' \
-e 'modelling|modelled|labelled|cancelled|travelled|fuelled' \
-e 'favour|favours|honour|honours|neighbour|neighbours' \
--cached -- $staged; then
echo
echo "pre-commit: British spellings found above. This project uses US English."
exit 1
fi
fi
echo "pre-commit: ok"
+61
View File
@@ -0,0 +1,61 @@
//! Two-body propagation over one orbital period.
//!
//! Run with `cargo run --example two_body`.
use astrokit::state::{HasPosition, HasVelocity, Orbit};
use nalgebra::{SVector, Vector6};
use ordinary_diffeq::prelude::*;
use std::f64::consts::PI;
/// Earth's gravitational parameter, m^3/s^2.
const MU: f64 = 3.986004415e14;
/// Semi-major axis of the test orbit, m.
const SMA: f64 = 6.7781363e6;
/// The two-body derivative.
fn two_body<T: Scalar, const D: usize, L: HasPosition<D> + HasVelocity<D>>(
_t: T,
y: SVector<T, D>,
&(mu,): &(T,),
) -> SVector<T, D> {
let r = L::position(&y);
let mut dy = SVector::<T, D>::zeros();
L::set_position(&mut dy, L::velocity(&y));
L::set_velocity(&mut dy, -(r * mu) / r.norm().powi(3));
dy
}
fn main() {
let period = 2.0 * PI * (SMA.powi(3) / MU).sqrt();
let y0 = Vector6::new(
4.263868426884883e6,
5.146189057155391e6,
1.1310208421331816e6,
-5923.454461876975,
4496.802639690076,
1870.3893008991558,
);
let ode = ODE::new(&two_body::<f64, 6, Orbit>, 0.0, period, y0, (MU,));
let vern7 = Vern7::new().a_tol(1e-12).r_tol(1e-12);
let mut problem = Problem::new(ode, vern7, PIController::default());
let solution = problem.solve();
let end = solution.states.last().unwrap();
let position_error = (Orbit::position(end) - Orbit::position(&y0)).norm();
let velocity_error = (Orbit::velocity(end) - Orbit::velocity(&y0)).norm();
println!("period: {period:.3} s");
println!("steps: {}", solution.states.len() - 1);
println!("position error: {position_error:.3e} m");
println!("velocity error: {velocity_error:.3e} m/s");
// Dense output: the state at any time in the arc, not just at a step.
let half = solution.interpolate(period / 2.0);
println!(
"altitude at half a period: {:.3} km",
(Orbit::position(&half).norm() - 6.378137e6) / 1e3
);
}
+16 -3
View File
@@ -15,7 +15,7 @@ The crate is still being built to a milestone plan:
four measurement types, with a validation suite against Orekit reference four measurement types, with a validation suite against Orekit reference
trajectories. trajectories.
- **M2** — general-purpose completion: higher-degree gravity fields, more - **M2** — general-purpose completion: higher-degree gravity fields, more
density models, box-wing SRP and eclipses, manoeuvres, covariance propagation, density models, box-wing SRP and eclipses, maneuvers, covariance propagation,
relative motion, TLE and CCSDS I/O. relative motion, TLE and CCSDS I/O.
The tables under [Validation](#validation) and [Performance](#performance) are The tables under [Validation](#validation) and [Performance](#performance) are
@@ -25,12 +25,13 @@ the acceptance criteria.
- Everything is SI - Everything is SI
- Time is typed, using `hifitime` - Time is typed, using `hifitime`
- US English spelling
## Foundations ## Foundations
These are depended on, not reimplemented. These are depended on, not reimplemented.
| Crate | Licence | Provides | | Crate | License | Provides |
|---|---|---| |---|---|---|
| [`hifitime`](https://crates.io/crates/hifitime) 4.3 | MPL-2.0 | TAI/TT/ET/TDB/UTC/GPS time scales, leap seconds | | [`hifitime`](https://crates.io/crates/hifitime) 4.3 | MPL-2.0 | TAI/TT/ET/TDB/UTC/GPS time scales, leap seconds |
| [`anise`](https://crates.io/crates/anise) 0.10 | MPL-2.0 | Reference frames, planetary ephemerides, SPICE-format kernels | | [`anise`](https://crates.io/crates/anise) 0.10 | MPL-2.0 | Reference frames, planetary ephemerides, SPICE-format kernels |
@@ -75,6 +76,18 @@ figures, so the references are measured here rather than cited.
| Full-force LEO, 1 day | not yet measured | — | — | — | | Full-force LEO, 1 day | not yet measured | — | — | — |
| Ensemble of N states, scaling vs. cores | not yet measured | — | — | — | | Ensemble of N states, scaling vs. cores | not yet measured | — | — | — |
## Licence ## Development
Enable the pre-commit hook once per clone:
```sh
git config core.hooksPath .githooks
```
It runs `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D
warnings`, `cargo test`, `cargo doc` with warnings denied, and a US English
spelling check. `git commit --no-verify` skips it.
## License
MIT. See [LICENSE](./LICENSE). MIT. See [LICENSE](./LICENSE).
+3 -1
View File
@@ -2,12 +2,14 @@
//! //!
//! # Conventions //! # Conventions
//! //!
//! - Everything is SI: metres, seconds, kilograms, radians. //! - Everything is SI: meters, seconds, kilograms, radians.
//! - Times are always typed using hifitime //! - Times are always typed using hifitime
// Needed to build docs // Needed to build docs
#![cfg_attr(feature = "autodiff", doc = include_str!("../readme.md"))] #![cfg_attr(feature = "autodiff", doc = include_str!("../readme.md"))]
#![warn(missing_docs)] #![warn(missing_docs)]
pub mod state;
/// The types you want in scope to set up and run a propagation. /// The types you want in scope to set up and run a propagation.
pub mod prelude {} pub mod prelude {}
+494
View File
@@ -0,0 +1,494 @@
//! State layouts: which quantities an integrated state vector holds, and where.
//!
//! The state dimension has to be a compile-time constant, because `num-dual`'s
//! vector duals are only efficient at a `Const<N>`. Summing layer widths in type
//! position would need `generic_const_exprs`, which is not going to stabilize,
//! so [`define_state!`](crate::define_state) does the sum at macro-expansion time instead.
//!
//! Dynamics should be written against the capability traits rather than a
//! concrete layout, so that they work for any state carrying what they need:
//!
//! ```
//! use astrokit::state::{HasPosition, HasVelocity};
//! use nalgebra::{SVector, Vector3};
//! use ordinary_diffeq::scalar::Scalar;
//!
//! /// Specific angular momentum, in m^2/s.
//! fn angular_momentum<T: Scalar, const D: usize, L: HasPosition<D> + HasVelocity<D>>(
//! y: &SVector<T, D>,
//! ) -> Vector3<T> {
//! L::position(y).cross(&L::velocity(y))
//! }
//! ```
use core::fmt::Debug;
use nalgebra::{SVector, Vector3};
use ordinary_diffeq::scalar::Scalar;
/// One named span of a [`Layout`]. A component's offset is the sum of the widths
/// preceding it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Component {
/// The component's name, as written at the layout's definition site.
pub name: &'static str,
/// How many state vector elements the component occupies.
pub width: usize,
}
/// A state layout of dimension `D`.
///
/// Layouts are zero-sized marker types, so the accessors monomorphize to a
/// direct offset. Define one with [`define_state!`](crate::define_state) rather than by hand.
pub trait Layout<const D: usize>: Copy + Clone + Debug + Default {
/// The layout's name, as written at its definition site
const NAME: &'static str;
/// The components, in index order.
const COMPONENTS: &'static [Component];
}
/// Defines a capability trait for a three-element component.
macro_rules! vector_capability {
($trait:ident, $index:ident, $get:ident, $set:ident, $what:expr, $unit:expr) => {
#[doc = concat!("Layouts carrying ", $what, ".")]
pub trait $trait<const D: usize>: Layout<D> {
#[doc = concat!("Index of the first of the three ", $what, " elements.")]
const $index: usize;
#[doc = concat!("The ", $what, ", in ", $unit, ".")]
///
/// A derivative vector shares the state's layout, so this reads the
/// corresponding derivative when applied to one.
#[inline]
fn $get<T: Scalar>(y: &SVector<T, D>) -> Vector3<T> {
y.fixed_rows::<3>(Self::$index).into_owned()
}
#[doc = concat!("Writes the ", $what, ", in ", $unit, ".")]
#[inline]
fn $set<T: Scalar>(y: &mut SVector<T, D>, value: Vector3<T>) {
y.fixed_rows_mut::<3>(Self::$index).copy_from(&value);
}
}
};
}
/// Defines a capability trait for a one-element component.
macro_rules! scalar_capability {
($trait:ident, $index:ident, $get:ident, $set:ident, $what:expr, $unit:expr) => {
#[doc = concat!("Layouts carrying ", $what, ".")]
pub trait $trait<const D: usize>: Layout<D> {
#[doc = concat!("Index of the ", $what, " element.")]
const $index: usize;
#[doc = concat!("The ", $what, ", in ", $unit, ".")]
///
/// A derivative vector shares the state's layout, so this reads the
/// corresponding derivative when applied to one.
#[inline]
fn $get<T: Scalar>(y: &SVector<T, D>) -> T {
y[Self::$index]
}
#[doc = concat!("Writes the ", $what, ", in ", $unit, ".")]
#[inline]
fn $set<T: Scalar>(y: &mut SVector<T, D>, value: T) {
y[Self::$index] = value;
}
}
};
}
vector_capability!(
HasPosition,
POSITION,
position,
set_position,
"an inertial position",
"meters"
);
vector_capability!(
HasVelocity,
VELOCITY,
velocity,
set_velocity,
"an inertial velocity",
"meters per second"
);
vector_capability!(
HasAttitude,
ATTITUDE,
attitude,
set_attitude,
"an attitude, as Modified Rodrigues Parameters",
"dimensionless units"
);
vector_capability!(
HasAngularRate,
ANGULAR_RATE,
angular_rate,
set_angular_rate,
"a body angular rate",
"radians per second"
);
scalar_capability!(HasMass, MASS, mass, set_mass, "a mass", "kilograms");
scalar_capability!(
HasEnergy,
ENERGY,
energy,
set_energy,
"a power-system stored energy",
"joules"
);
/// Defines a state layout: a zero-sized type implementing [`Layout`] plus one
/// capability trait per recognized component.
///
/// ```
/// use astrokit::define_state;
/// use astrokit::state::{HasMass, HasPosition, HasVelocity, Layout};
///
/// define_state! {
/// /// Translational state plus mass and an estimated drag coefficient.
/// pub struct OrbitMassCd {
/// position,
/// velocity,
/// mass,
/// scalar drag_coefficient,
/// }
/// }
///
/// assert_eq!(<OrbitMassCd as Layout<8>>::NAME, "OrbitMassCd");
/// assert_eq!(<OrbitMassCd as HasPosition<8>>::POSITION, 0);
/// assert_eq!(<OrbitMassCd as HasVelocity<8>>::VELOCITY, 3);
/// assert_eq!(<OrbitMassCd as HasMass<8>>::MASS, 6);
/// assert_eq!(OrbitMassCd::drag_coefficient, 7);
/// ```
///
/// # Recognized components
///
/// | Component | Width | Capability |
/// |---|---|---|
/// | `position` | 3 | [`HasPosition`] |
/// | `velocity` | 3 | [`HasVelocity`] |
/// | `attitude` | 3 | [`HasAttitude`] |
/// | `angular_rate` | 3 | [`HasAngularRate`] |
/// | `mass` | 1 | [`HasMass`] |
/// | `energy` | 1 | [`HasEnergy`] |
/// | `scalar <name>` | 1 | none — an associated const `<name>` |
/// | `vector3 <name>` | 3 | none — an associated const `<name>` |
///
/// `scalar` and `vector3` cover quantities this crate has no opinion about,
/// which in practice means a filter's estimated parameters. They get an index
/// constant and no capability trait.
///
/// Components are laid out in the order written; the dimension is their total
/// width.
#[macro_export]
macro_rules! define_state {
(
$(#[$meta:meta])*
$vis:vis struct $name:ident {
$( $kind:tt $($label:ident)? ),+ $(,)?
}
) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
$vis struct $name;
impl $crate::state::Layout<{ 0 $( + $crate::__state_width!($kind) )+ }> for $name {
const NAME: &'static str = ::core::stringify!($name);
const COMPONENTS: &'static [$crate::state::Component] = &[
$( $crate::state::Component {
name: $crate::__state_label!($kind $($label)?),
width: $crate::__state_width!($kind),
} ),+
];
}
$crate::__state_capabilities! {
@layout $name
@dim { 0 $( + $crate::__state_width!($kind) )+ }
@offset { 0 }
@rest [ $( $kind $($label)? ),+ ]
}
};
}
/// The width of one recognized component. Not public API.
#[doc(hidden)]
#[macro_export]
macro_rules! __state_width {
(position) => {
3
};
(velocity) => {
3
};
(attitude) => {
3
};
(angular_rate) => {
3
};
(mass) => {
1
};
(energy) => {
1
};
(scalar) => {
1
};
(vector3) => {
3
};
}
/// The name of one component. Not public API.
#[doc(hidden)]
#[macro_export]
macro_rules! __state_label {
(scalar $label:ident) => {
::core::stringify!($label)
};
(vector3 $label:ident) => {
::core::stringify!($label)
};
($kind:tt) => {
::core::stringify!($kind)
};
}
/// Walks the component list carrying the running offset, emitting one capability
/// impl per component. Not public API.
#[doc(hidden)]
#[macro_export]
macro_rules! __state_capabilities {
(@layout $name:ident @dim $dim:tt @offset $offset:tt @rest []) => {};
(
@layout $name:ident
@dim { $($dim:tt)* }
@offset { $($offset:tt)* }
@rest [ $kind:tt $($label:ident)? $(, $($rest:tt)*)? ]
) => {
$crate::__state_one! { $name, { $($dim)* }, { $($offset)* }, $kind $($label)? }
$crate::__state_capabilities! {
@layout $name
@dim { $($dim)* }
@offset { $($offset)* + $crate::__state_width!($kind) }
@rest [ $( $($rest)* )? ]
}
};
}
/// Emits the capability impl for a single component. Not public API.
#[doc(hidden)]
#[macro_export]
macro_rules! __state_one {
($name:ident, { $($dim:tt)* }, { $($offset:tt)* }, position) => {
impl $crate::state::HasPosition<{ $($dim)* }> for $name {
const POSITION: usize = $($offset)*;
}
};
($name:ident, { $($dim:tt)* }, { $($offset:tt)* }, velocity) => {
impl $crate::state::HasVelocity<{ $($dim)* }> for $name {
const VELOCITY: usize = $($offset)*;
}
};
($name:ident, { $($dim:tt)* }, { $($offset:tt)* }, attitude) => {
impl $crate::state::HasAttitude<{ $($dim)* }> for $name {
const ATTITUDE: usize = $($offset)*;
}
};
($name:ident, { $($dim:tt)* }, { $($offset:tt)* }, angular_rate) => {
impl $crate::state::HasAngularRate<{ $($dim)* }> for $name {
const ANGULAR_RATE: usize = $($offset)*;
}
};
($name:ident, { $($dim:tt)* }, { $($offset:tt)* }, mass) => {
impl $crate::state::HasMass<{ $($dim)* }> for $name {
const MASS: usize = $($offset)*;
}
};
($name:ident, { $($dim:tt)* }, { $($offset:tt)* }, energy) => {
impl $crate::state::HasEnergy<{ $($dim)* }> for $name {
const ENERGY: usize = $($offset)*;
}
};
($name:ident, { $($dim:tt)* }, { $($offset:tt)* }, scalar $label:ident) => {
impl $name {
#[doc = concat!("Index of the `", ::core::stringify!($label), "` element.")]
#[allow(non_upper_case_globals)]
pub const $label: usize = $($offset)*;
}
};
($name:ident, { $($dim:tt)* }, { $($offset:tt)* }, vector3 $label:ident) => {
impl $name {
#[doc = concat!(
"Index of the first of the three `", ::core::stringify!($label), "` elements."
)]
#[allow(non_upper_case_globals)]
pub const $label: usize = $($offset)*;
}
};
}
define_state! {
/// Position and velocity. All that orbit propagation without mass or
/// attitude needs.
pub struct Orbit { position, velocity }
}
define_state! {
/// Translational state plus mass, for a maneuver that expends propellant.
pub struct OrbitMass { position, velocity, mass }
}
define_state! {
/// Position, velocity, attitude, angular rate, mass and stored energy.
///
/// The layout low-thrust modeling needs: thrust direction depends on the
/// attitude, the mass changes enough to matter, and available power bounds
/// the thrust.
pub struct Full {
position,
velocity,
attitude,
angular_rate,
mass,
energy,
}
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::{Vector1, Vector6};
#[test]
fn test_builtin_layout_dimensions_and_offsets() {
assert_eq!(<Orbit as Layout<6>>::NAME, "Orbit");
assert_eq!(<Orbit as HasPosition<6>>::POSITION, 0);
assert_eq!(<Orbit as HasVelocity<6>>::VELOCITY, 3);
assert_eq!(<OrbitMass as HasMass<7>>::MASS, 6);
assert_eq!(<Full as HasPosition<14>>::POSITION, 0);
assert_eq!(<Full as HasVelocity<14>>::VELOCITY, 3);
assert_eq!(<Full as HasAttitude<14>>::ATTITUDE, 6);
assert_eq!(<Full as HasAngularRate<14>>::ANGULAR_RATE, 9);
assert_eq!(<Full as HasMass<14>>::MASS, 12);
assert_eq!(<Full as HasEnergy<14>>::ENERGY, 13);
}
#[test]
fn test_components_describe_the_layout() {
let names: Vec<_> = <Full as Layout<14>>::COMPONENTS
.iter()
.map(|c| c.name)
.collect();
assert_eq!(
names,
[
"position",
"velocity",
"attitude",
"angular_rate",
"mass",
"energy"
]
);
let total: usize = <Full as Layout<14>>::COMPONENTS
.iter()
.map(|c| c.width)
.sum();
assert_eq!(total, 14);
}
#[test]
fn test_accessors_round_trip() {
let mut y = Vector6::<f64>::zeros();
Orbit::set_position(&mut y, Vector3::new(1.0, 2.0, 3.0));
Orbit::set_velocity(&mut y, Vector3::new(4.0, 5.0, 6.0));
assert_eq!(y, Vector6::new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0));
assert_eq!(Orbit::position(&y), Vector3::new(1.0, 2.0, 3.0));
assert_eq!(Orbit::velocity(&y), Vector3::new(4.0, 5.0, 6.0));
}
/// A function bounded on capabilities compiles once and works for every
/// layout that has them.
#[test]
fn test_capability_bounds_are_layout_agnostic() {
fn speed<T: Scalar, const D: usize, L: HasVelocity<D>>(y: &SVector<T, D>) -> T {
L::velocity(y).norm()
}
let mut orbit = Vector6::<f64>::zeros();
Orbit::set_velocity(&mut orbit, Vector3::new(3.0, 4.0, 0.0));
assert_eq!(speed::<f64, 6, Orbit>(&orbit), 5.0);
let mut full = SVector::<f64, 14>::zeros();
Full::set_velocity(&mut full, Vector3::new(0.0, 5.0, 12.0));
assert_eq!(speed::<f64, 14, Full>(&full), 13.0);
}
/// The `Orbit + k estimated parameters` case the filters will need.
#[test]
fn test_user_defined_layout_with_estimated_parameters() {
define_state! {
/// Orbit plus an estimated drag coefficient and a range bias.
struct OrbitCdBias {
position,
velocity,
scalar drag_coefficient,
scalar range_bias,
}
}
assert_eq!(<OrbitCdBias as Layout<8>>::NAME, "OrbitCdBias");
assert_eq!(<OrbitCdBias as HasPosition<8>>::POSITION, 0);
assert_eq!(OrbitCdBias::drag_coefficient, 6);
assert_eq!(OrbitCdBias::range_bias, 7);
}
#[test]
fn test_layout_of_a_single_component() {
define_state! {
/// Degenerate but legal: one scalar.
struct MassOnly { mass }
}
assert_eq!(<MassOnly as Layout<1>>::NAME, "MassOnly");
assert_eq!(<MassOnly as HasMass<1>>::MASS, 0);
let mut y = Vector1::<f64>::zeros();
MassOnly::set_mass(&mut y, 42.0);
assert_eq!(MassOnly::mass(&y), 42.0);
}
/// Accessors have to work at a dual scalar, or the layout is unusable in the
/// differentiated path.
#[cfg(feature = "autodiff")]
#[test]
fn test_accessors_at_a_dual_scalar() {
use approx::assert_relative_eq;
use num_dual::Dual64;
let mut y = Vector6::<Dual64>::zeros();
let seeded = Dual64::from_re(3.0).derivative();
Orbit::set_position(
&mut y,
Vector3::new(seeded, Dual64::lift(4.0), Dual64::lift(0.0)),
);
// d|r|/dx = x/|r| = 3/5
let range = Orbit::position(&y).norm();
assert_relative_eq!(range.re, 5.0, max_relative = 1e-15);
assert_relative_eq!(range.eps, 0.6, max_relative = 1e-15);
}
}