-
Notifications
You must be signed in to change notification settings - Fork 0
Strategy
Reimplementing NASA JEOD (JSC Engineering Orbital Dynamics) in Rust, using the Bevy game engine's Entity Component System as the simulation framework — replacing NASA's Trick.
- 1. Project Overview
- 2. ECS Architecture Mapping
- 3. Component Design
- 4. System Pipeline
- 5. Plugin Architecture
- 6. Verification Strategy
- 6b. JEOD Invariant Tracking
- 7. JEOD Data Ingestion
- 8. Implementation Phases
- 9. Key Architectural Decisions
- 10. Risks and Mitigations
JEOD 5.4 is a C++ orbital dynamics library developed at NASA JSC. It models:
- Dynamics: 6-DOF rigid body propagation, multi-body attachment/detachment, mass trees
- Environment: Spherical harmonics gravity (GGM05C, GRAIL150, MRO110B2), JPL DE4xx ephemerides, MET atmosphere, time scales (TAI/UTC/UT1/TDB/TT/GMST/MET)
- Interactions: Aerodynamic drag, solar radiation pressure, gravity gradient torque, contact mechanics
- Utilities: Reference frame trees, integration methods (RK4, RKF45, Gauss-Jackson, LSODE), quaternion/orbital-element math
JEOD runs inside NASA's Trick simulation framework, which provides job scheduling, data recording, checkpoint/restart, and Python-based configuration.
A Rust reimplementation where Bevy's ECS replaces Trick as the simulation framework. Bevy provides:
- Entities in place of Trick simulation objects
- Components in place of C++ class member data
- Systems in place of Trick scheduled jobs
- Resources in place of global manager state
- Schedules in place of Trick's job ordering
- Plugins in place of Trick's S_modules
While Bevy is the primary executor, the physics and math must not depend on Bevy. The codebase is split into three layers:
-
astrodyn_*crates — Pure Rust libraries containing all physics, math, algorithms, data models, and domain types. Zero Bevy dependency. -
astrodyncrate — ECS-agnostic orchestration layer (the workspace root package,src/at workspace root). Composesastrodyn_*functions into pipeline stages, owns theVehicleBuildertypestate, provides therecipes::*catalog, and re-exports every type that ECS adapters need. Zero Bevy dependency. The parallelastrodyn_runnercrate (undercrates/astrodyn_runner/) is a non-Bevy consumer of this layer that owns its own state container — it is the test harness used by Tier 3 cross-validation tests, not part of the production path. -
astrodyn_bevycrate (crates/astrodyn_bevy/) — Thin Bevy integration layer that depends only onastrodyn+bevy. Defines component wrappers, systems that delegate toastrodynfunctions (zero math), schedule sets, and plugin registration. All Bevy glue lives in one unified crate, not separate per-domain crates.
This separation means:
- Portability: Swap Bevy for another ECS by writing a new thin glue layer. The physics code doesn't change.
-
Testability: Core algorithms are tested as pure functions — no need to spin up a
Bevy
Appfor unit tests. -
Embeddability:
astrodyn_*crates can be used in non-ECS contexts (batch trajectory computation, optimization loops, Monte Carlo analysis) without pulling in Bevy. - Stability: Physics crates are insulated from Bevy's rapid release cycle and breaking API changes.
| Concern | Trick | Bevy |
|---|---|---|
| Language | C++/Python | Rust (memory safety, no UB) |
| Architecture | OOP with manager objects | Data-oriented ECS |
| Parallelism | Manual thread management | Automatic system parallelism |
| Ecosystem | NASA-internal | Open source, active community |
| Visualization | External tools | Built-in rendering, egui integration |
| Distribution | Complex build chain | cargo build |
JEOD is built on deep OOP hierarchies with manager god-objects. The translation is not mechanical — it requires rethinking how state flows through the simulation.
JEOD (OOP) Bevy (ECS)
───────────────────────────────── ─────────────────────────────────
DynBody class (1200 lines) → ~10 focused Components on an Entity
DynManager.gravitation() → gravity_computation_system
GravityManager (singleton) → Resource + System
TimeManager (singleton) → Resource + System
RefFrame tree (pointer graph) → Entity hierarchy (Parent/Children)
BodyAction subclasses → Events or Commands
Virtual dispatch (GravitySource) → Trait objects or enum dispatch
Method call ordering → System ordering constraints
Manager Pattern → Resource + Systems
JEOD's DynManager, GravityManager, TimeManager, and EphemerisManager are
singletons that coordinate subsystems. In ECS:
- Manager state becomes a
Resource(e.g.,SimulationTime,EphemerisData) - Manager behavior becomes one or more
Systems - Manager coordination becomes system ordering via
configure_sets()
Class Hierarchy → Component Composition
JEOD: DynBody : RefFrameOwner, IntegrableObject — deep inheritance tree.
ECS: An entity gets the components it needs. No inheritance. A "DynBody" is just an entity
that has TranslationalState + RotationalState + MassProperties + etc.
Tree Structures → Bevy's Entity Hierarchy
JEOD's RefFrame tree and MassBody tree use raw pointers. Bevy has built-in
Parent/Children components that give us the same tree structure with safe entity
references.
Virtual Dispatch → Enum or Trait Objects
JEOD uses virtual base classes (GravitySource, Atmosphere, etc.) for extensibility.
In Rust: use an enum for the closed set of known models, or Box<dyn Trait> for
user-extensible models. Prefer enums where the model set is fixed (gravity, atmosphere).
Core → Orchestration → Glue Separation
All of the above mappings happen in three layers:
astrodyn_dynamics (plain Rust structs, pure functions)
↕ used by
astrodyn (orchestration: composes astrodyn_* functions, re-exports types)
↕ used by
astrodyn_bevy (derives Component/Resource, defines systems that delegate to astrodyn)
The astrodyn_* crates define algorithms. astrodyn composes them into pipeline stages
and re-exports all types. astrodyn_bevy depends only on astrodyn —
switching to another ECS means writing a new glue crate that calls the same
astrodyn functions. astrodyn_runner is a parallel non-Bevy consumer of astrodyn
that owns its own state container (used as the Tier 3 test harness, not in the
production path).
Every data type exists first as a plain Rust struct in a astrodyn_* crate, then gets
wrapped or re-derived as a Bevy component in astrodyn_bevy
(crates/astrodyn_bevy/src/components/).
// ── astrodyn_dynamics/src/state.rs (pure Rust, no Bevy) ─────────────
/// Translational state in the integration frame.
pub struct TranslationalState {
pub position: DVec3, // m
pub velocity: DVec3, // m/s
}
// ── crates/astrodyn_bevy/src/components/state.rs (Bevy glue) ────────
use bevy::prelude::*;
use astrodyn_dynamics::TranslationalState;
// Option A: newtype wrapper
#[derive(Component, Deref, DerefMut)]
pub struct TranslationalStateComponent(pub TranslationalState);
// Option B: feature-gated derive (if we control the core crate)
// #[derive(Component)] // behind #[cfg(feature = "bevy")]
// pub struct TranslationalState { ... }Option B (feature-gated derive) is preferred when practical — it avoids wrapper
boilerplate. Option A is the fallback when the core type can't carry Bevy derives
(e.g., when it contains non-Reflect fields).
These are the ECS equivalent of DynBody's member data, decomposed by access pattern —
components that are read/written together by the same systems stay together.
The structs below live in astrodyn_dynamics (pure Rust). In the Bevy layer they gain
#[derive(Component)].
// ── astrodyn_dynamics/src/state.rs ──────────────────────────────────
// All types are plain Rust. In the Bevy layer they gain #[derive(Component)].
/// Translational state in the integration frame.
pub struct TranslationalState {
pub position: DVec3, // m
pub velocity: DVec3, // m/s
}
/// Rotational state (body orientation and angular velocity).
pub struct RotationalState {
pub quaternion: DQuat, // left transformation, parent-to-body
pub ang_vel_body: DVec3, // rad/s, expressed in body frame
}
/// Rigid body mass properties.
pub struct MassProperties {
pub mass: f64, // kg
pub inertia: DMat3, // kg*m^2, in body frame
pub inertia_inverse: DMat3, // precomputed I^-1
pub center_of_mass: DVec3, // m, in structural frame
}
/// Dynamics configuration flags.
pub struct DynamicsConfig {
pub translational: bool, // integrate translation?
pub rotational: bool, // integrate rotation?
pub three_dof: bool, // translation-only mode?
}Note: IntegrationFrameRef(Entity) only exists in the Bevy layer since Entity is a
Bevy type. In a non-ECS context, the integration frame is identified by name or index.
Each interaction system writes its own force output. The force collection system reads all
of them and produces TotalForce. All types live in astrodyn_dynamics (pure Rust).
// ── astrodyn_dynamics/src/forces.rs ─────────────────────────────────
/// Gravitational acceleration and gradient at the body's position.
pub struct GravityAcceleration {
pub accel: DVec3, // m/s^2, in integration frame
pub gradient: DMat3, // 1/s^2, tidal gradient tensor
pub potential: f64, // m^2/s^2
}
/// Aerodynamic drag force and torque.
pub struct AerodynamicForce {
pub force: DVec3, // N, in body frame
pub torque: DVec3, // N*m, in body frame
}
/// Solar radiation pressure force and torque.
pub struct RadiationForce {
pub force: DVec3, // N, in body frame
pub torque: DVec3, // N*m, in body frame
}
/// Gravity gradient torque on an extended body.
pub struct GravityTorque {
pub torque: DVec3, // N*m, in body frame
}
/// Sum of all forces and torques acting on the body.
pub struct TotalForce {
pub force: DVec3, // N, in integration frame
pub torque: DVec3, // N*m, in body frame
}
/// Computed accelerations (output of F=ma).
pub struct FrameDerivatives {
pub trans_accel: DVec3, // m/s^2
pub rot_accel: DVec3, // rad/s^2
}JEOD's RefFrame tree is the backbone of all coordinate transformations. The state
types live in astrodyn_frames (pure Rust). The tree structure is ECS-specific.
// ── astrodyn_frames/src/state.rs (pure Rust) ────────────────────────
/// State of a reference frame relative to its parent frame.
/// Mirrors JEOD's RefFrameState = RefFrameTrans + RefFrameRot.
pub struct RefFrameState {
pub trans: RefFrameTrans,
pub rot: RefFrameRot,
}
pub struct RefFrameTrans {
pub position: DVec3, // m, in parent frame
pub velocity: DVec3, // m/s, in parent frame
}
pub struct RefFrameRot {
pub q_parent_this: DQuat, // left transformation quaternion
pub t_parent_this: DMat3, // transformation matrix (redundant with quat)
pub ang_vel_this: DVec3, // rad/s, in this frame
}
/// Frame identity and classification.
pub struct RefFrameInfo {
pub name: String, // "Earth.inertial", "ISS.structure", etc.
pub kind: RefFrameKind,
}
pub enum RefFrameKind {
Inertial, // non-rotating, valid as integration frame
PlanetFixed, // rotating with planet
Body, // attached to a dynamic body
}// ── crates/astrodyn_bevy/src/components/frame_tree.rs ───────────────
// The frame tree uses Bevy's built-in `ChildOf` / `Children` relationship
// (Bevy 0.18 renamed `Parent` to `ChildOf`). Marker components identify
// frame types for queries: `InertialFrameMarker`, `PlanetFixedFrameMarker`,
// `BodyFrameMarker`, `IntegrationFrameMarker`.
// Frame tree example (every node is a Bevy `Entity` with `FrameTransC` /
// `FrameRotC` / `FrameAngVelC` components and one or more frame markers):
//
// root.frame [InertialFrameMarker] ← RootFrameEntityR
// +-- Sun.frame.inertial [InertialFrameMarker]
// +-- Earth.frame.inertial [InertialFrameMarker]
// | +-- Earth.frame.pfix [PlanetFixedFrameMarker]
// | +-- ISS.frame [BodyFrameMarker] ← FrameEntityC on body
// +-- Moon.frame.inertial [InertialFrameMarker]
// +-- Mars.frame.inertial [InertialFrameMarker]The arena-shaped FrameTree lives in astrodyn_frames (used by the non-Bevy
astrodyn_runner harness). The Bevy adapter does not mirror it: state lives
exclusively on the ECS frame entities, and cross-frame queries go through the
RelativeFrameState / FrameOrigin SystemParams. Both consumers share the
read-only graph-traversal algorithms via the astrodyn::FrameStorage trait. See
the Frame-Tree-ECS-Native wiki page for the full design.
Planets and vehicles share the same state types (TranslationalState, RotationalState) — differentiated by marker components in the ECS layer.
// ── astrodyn_planet/src/lib.rs (pure Rust) ──────────────────────────
/// Planetary shape parameters (reference ellipsoid).
pub struct PlanetShape {
pub r_eq: f64, // equatorial radius, m
pub r_pol: f64, // polar radius, m
pub flattening: f64, // flattening coefficient (1/298.257 for Earth)
}
// ── astrodyn_gravity/src/source.rs (pure Rust) ──────────────────────
/// Gravity source definition.
pub struct GravitySource {
pub mu: f64, // gravitational parameter, m^3/s^2
pub model: GravityModel,
}
pub enum GravityModel {
PointMass,
SphericalHarmonics {
degree: usize,
order: usize,
radius: f64, // reference radius, m
cnm: Vec<Vec<f64>>, // cosine coefficients [n][m]
snm: Vec<Vec<f64>>, // sine coefficients [n][m]
},
}
// ── astrodyn_gravity/src/compute.rs (pure Rust) ─────────────────────
/// Pure function: compute gravity acceleration at a position.
/// No ECS dependency — callable from any context.
pub fn compute_gravity(
source: &GravitySource,
position: DVec3, // in source-centered frame
) -> GravityAcceleration { ... }Each vehicle specifies which planets affect it and how. The core type lives in
astrodyn_gravity and uses a generic identifier for the source (string name or index).
The Bevy layer maps this to Entity.
// ── astrodyn_gravity/src/controls.rs (pure Rust) ────────────────────
/// Per-vehicle specification of gravitational interactions.
pub struct GravityControls<SourceId = String> {
pub controls: Vec<GravityControl<SourceId>>,
}
pub struct GravityControl<SourceId = String> {
pub source_id: SourceId, // planet identifier (generic)
pub spherical_only: bool, // point-mass vs full harmonics
pub max_degree: Option<usize>, // truncation override
pub max_order: Option<usize>,
pub compute_gradient: bool, // tidal gradient needed?
}
// ── crates/astrodyn_bevy/src/components/gravity.rs ──────────────────
/// In Bevy, SourceId = Entity for efficient queries.
pub type BevyGravityControls = GravityControls<Entity>;Optional data that computes secondary state representations. The computation functions
live in astrodyn_math (pure Rust). The ECS layer attaches these as components and runs
systems that call the pure functions.
// ── astrodyn_math/src/orbital_elements.rs (pure Rust) ───────────────
pub struct OrbitalElements {
pub semi_major_axis: f64, // m
pub eccentricity: f64,
pub inclination: f64, // rad
pub raan: f64, // rad, right ascension of ascending node
pub arg_periapsis: f64, // rad
pub true_anomaly: f64, // rad
pub mean_anomaly: f64, // rad
pub mean_motion: f64, // rad/s
pub orbital_energy: f64, // m^2/s^2
pub ang_momentum: f64, // m^2/s
}
/// Pure function — no ECS dependency.
pub fn cartesian_to_elements(pos: DVec3, vel: DVec3, mu: f64) -> OrbitalElements { ... }
pub fn elements_to_cartesian(elems: &OrbitalElements, mu: f64) -> (DVec3, DVec3) { ... }
// ── astrodyn_math/src/orientation.rs (pure Rust) ────────────────────
pub struct EulerAngles {
pub sequence: EulerSequence,
pub ref_body_angles: DVec3, // rad
pub body_ref_angles: DVec3, // rad
}
pub fn decompose_euler(matrix: &DMat3, seq: EulerSequence) -> EulerAngles { ... }
// ── astrodyn_math/src/planet_fixed.rs (pure Rust) ───────────────────
pub struct PlanetFixedPosition {
pub latitude: f64, // rad, geodetic
pub longitude: f64, // rad
pub altitude: f64, // m, above ellipsoid
}
pub fn cartesian_to_geodetic(pos: DVec3, shape: &PlanetShape) -> PlanetFixedPosition { ... }Component wrappers live in crates/astrodyn_bevy/src/components/ (split across
state.rs, gravity.rs, frame_tree.rs, mass_tree.rs, …). Each wraps an
astrodyn type with #[derive(Component)] and a C suffix. Most are now also
parameterized by a planet phantom (e.g. TranslationalStateC<P: Planet>) so the
compiler refuses to mix bodies integrated in different planet-inertial frames:
// ── crates/astrodyn_bevy/src/components/state.rs ────────────────────
#[derive(Component, Deref, DerefMut)]
pub struct TranslationalStateC<P: Planet>(pub TranslationalState, PhantomData<P>);
#[derive(Component, Deref, DerefMut)]
pub struct RotationalStateC(pub RotationalState);
#[derive(Component, Deref, DerefMut)]
pub struct MassPropertiesC(pub MassProperties);
#[derive(Component, Deref, DerefMut)]
pub struct DynamicsConfigC(pub DynamicsConfig);
#[derive(Component, Deref, DerefMut)]
pub struct GravityControlsC(pub GravityControls<Entity>);
// ... etc.Entities are spawned with individual components (no bundle struct):
commands.spawn((
TranslationalStateC(state),
RotationalStateC(rot),
MassPropertiesC(mass),
DynamicsConfigC(config),
GravityControlsC(controls),
GravityAccelerationC(GravityAcceleration::default()),
TotalForceC(TotalForce::default()),
FrameDerivativesC(FrameDerivatives::default()),
));See also: Integration-Groups — how the Bevy schedule maps to JEOD's
JeodIntegrationGroupconcept; multi-body coordination, multi-stage integrators, and the separate-group escape hatch.
JEOD's integration loop translates to Bevy's FixedUpdate schedule, partitioned into
the seven AstrodynSet variants defined in crates/astrodyn_bevy/src/sets.rs. The
ordering matches JEOD's DynManager sequencing — JEOD's nine init/update steps
collapse to seven sets because gravity + atmosphere both run in Environment, and
frame propagation rides inside the integration system as its post-step (issue
#362).
FixedUpdate
|
|-- AstrodynSet::TimeUpdate
| '-- time_advance_system // advance TAI, compute UTC/UT1/TDB/GMST
|
|-- AstrodynSet::EphemerisUpdate // .after(TimeUpdate)
| |-- ephemeris_update_system // update planet positions from DE4xx
| '-- planet_fixed_rotation_system // update planet-fixed frame rotations (RNP)
|
|-- AstrodynSet::Environment // .after(EphemerisUpdate)
| |-- gravity_computation_system // for each body: spherical harmonics accel
| '-- atmosphere_update_system // compute density at body positions
|
|-- AstrodynSet::Interaction // .after(Environment)
| |-- aero_drag_system // F_drag = 0.5 * rho * v^2 * Cd * A
| |-- flat_plate_srp_system // solar radiation pressure (flat plate)
| |-- cannonball_srp_system // solar radiation pressure (cannonball)
| '-- gravity_torque_system // gravity gradient torque
|
|-- AstrodynSet::ForceCollection // .after(Interaction)
| |-- force_collection_system // sum all force components -> TotalForce
| '-- wrench_aggregation_system // composite-rigid-body wrench accumulation
|
|-- AstrodynSet::Integration // .after(ForceCollection)
| |-- integration_system // propagate state via RK4/GJ/ABM4
| |-- sync_body_to_frame_system // mirror body state into frame entity
| |-- frame_switch_system // distance-triggered re-parenting
| '-- propagate_state_from_root_post_integration_system // kinematic walk
|
'-- AstrodynSet::DerivedState // .after(Integration)
|-- orbital_elements_system // Cartesian -> Keplerian
|-- euler_angles_system // quaternion -> Euler angles
|-- geodetic_system // inertial -> geodetic coords
|-- lvlh_system // compute LVLH frame state
|-- solar_beta_system // solar beta angle
'-- earth_lighting_system // shadow / illumination geometry
app.configure_sets(FixedUpdate, (
AstrodynSet::TimeUpdate,
AstrodynSet::EphemerisUpdate.after(AstrodynSet::TimeUpdate),
AstrodynSet::Environment.after(AstrodynSet::EphemerisUpdate),
AstrodynSet::Interaction.after(AstrodynSet::Environment),
AstrodynSet::ForceCollection.after(AstrodynSet::Interaction),
AstrodynSet::Integration.after(AstrodynSet::ForceCollection),
AstrodynSet::DerivedState.after(AstrodynSet::Integration),
));JEOD uses multi-stage integrators (e.g., RK4 has 4 stages per timestep, each requiring a fresh force evaluation). This is handled with a resource tracking stage state:
#[derive(Resource)]
pub struct IntegrationState {
pub method: IntegrationMethod,
pub current_stage: usize,
pub total_stages: usize,
pub dt: f64,
}
pub enum IntegrationMethod {
Rk4, // 4 stages, fixed step
Rkf45 { tol: f64 }, // adaptive step
GaussJackson { order: usize }, // multi-step
}The integration_system runs the full multi-stage loop internally: for each stage it
re-evaluates forces, computes derivatives, and advances the stage. This keeps the
multi-stage logic contained rather than spreading it across the schedule.
Bevy systems are thin wrappers that query components and delegate to astrodyn_* pure
functions. This keeps the physics testable without Bevy.
// ── crates/astrodyn_bevy/src/systems/ ──────────────────────────
fn gravity_computation_system(
mut bodies: Query<(&TranslationalState, &BevyGravityControls, &mut GravityAcceleration)>,
sources: Query<(&GravitySource, &RefFrameState), With<Planet>>,
) {
for (state, controls, mut accel) in &mut bodies {
// Delegate to pure function from astrodyn_gravity
*accel = astrodyn_gravity::compute_all_gravity(
state.position, controls, |entity| sources.get(entity),
);
}
}
// ── crates/astrodyn_bevy/src/systems/ ──────────────────────────
fn integration_system(
mut bodies: Query<(
&TotalForce, &MassProperties, &DynamicsConfig,
&mut TranslationalState, &mut RotationalState,
&mut FrameDerivatives,
)>,
integ_state: Res<IntegrationState>,
) {
for (force, mass, config, mut trans, mut rot, mut derivs) in &mut bodies {
// Delegate to pure function from astrodyn_dynamics
astrodyn_dynamics::integrate_step(
&force, mass, config, &mut trans, &mut rot, &mut derivs,
integ_state.method, integ_state.dt,
);
}
}The workspace has three layers: core physics crates (astrodyn_*), the
orchestration crate astrodyn (the workspace root package under src/),
and the Bevy glue crate astrodyn_bevy (under crates/astrodyn_bevy/).
astrodyn_bevy depends only on astrodyn + bevy — see
Section 1: Portability Goal. The parallel
astrodyn_runner (under crates/astrodyn_runner/) is a non-Bevy consumer of
astrodyn used as the in-workspace test harness for Tier 3 cross-validation
and offline propagation.
astrodyn/ # workspace root
|
+-- crates/
| |
| | ── CORE LAYER (pure Rust, no Bevy dependency) ───────────────
| |
| +-- astrodyn_math/ # f64 math, quaternions, orbital elements
| | +-- src/
| | +-- lib.rs
| | +-- quaternion.rs # JEOD quaternion conventions (scalar-first)
| | +-- orbital_elements.rs # Cartesian <-> Keplerian, Kepler equation
| | +-- orientation.rs # Euler angle decomposition, rotation matrices
| | +-- planet_fixed.rs # geodetic coordinate conversions
| | +-- lvlh.rs # LVLH frame computation
| |
| +-- astrodyn_time/ # Time scales and conversions
| | +-- src/
| | +-- lib.rs
| | +-- scales.rs # TAI, UTC, UT1, TDB, TT, GMST, MET types
| | +-- converters.rs # time scale conversions (leap seconds, UT1-TAI)
| | +-- sim_time.rs # SimulationTime state struct
| |
| +-- astrodyn_frames/ # Reference frame state and transformations
| | +-- src/
| | +-- lib.rs
| | +-- state.rs # RefFrameState, RefFrameTrans, RefFrameRot
| | +-- transform.rs # relative state computation, frame composition
| | +-- tree.rs # arena-based frame tree (for non-ECS use)
| |
| +-- astrodyn_gravity/ # Gravity models and computation
| | +-- src/
| | +-- lib.rs
| | +-- source.rs # GravitySource, GravityModel
| | +-- controls.rs # GravityControls<SourceId>
| | +-- compute.rs # compute_gravity() pure function
| | +-- spherical_harmonics.rs # Legendre polynomials, coefficient evaluation
| | +-- data/ # coefficient files (binary or RON)
| | +-- earth_ggm05c.bin
| | +-- moon_grail150.bin
| | +-- mars_mro110b2.bin
| |
| +-- astrodyn_ephemeris/ # Ephemeris readers
| | +-- src/
| | +-- lib.rs
| | +-- de4xx.rs # JPL DE4xx binary reader (Chebyshev interpolation)
| |
| +-- astrodyn_atmosphere/ # Atmosphere models
| | +-- src/
| | +-- lib.rs
| | +-- model.rs # Atmosphere trait
| | +-- met.rs # Marshall Engineering Thermosphere tables
| |
| +-- astrodyn_dynamics/ # State types, integration methods, force collection
| | +-- src/
| | +-- lib.rs
| | +-- state.rs # TranslationalState, RotationalState
| | +-- mass.rs # MassProperties, composite mass computation
| | +-- forces.rs # GravityAcceleration, AeroForce, TotalForce, etc.
| | +-- integration.rs # RK4, RKF45, Gauss-Jackson, LSODE (pure functions)
| | +-- body_action.rs # initialization from orbital elements, LVLH, NED
| |
| +-- astrodyn_interactions/ # Force/torque computation
| | +-- src/
| | +-- lib.rs
| | +-- aerodynamics.rs # drag computation (pure function)
| | +-- radiation.rs # SRP computation (pure function)
| | +-- gravity_torque.rs # gradient torque (pure function)
| |
| +-- astrodyn_planet/ # Planet data and presets
| | +-- src/
| | +-- lib.rs
| | +-- shape.rs # PlanetShape
| | +-- presets.rs # Earth, Moon, Mars, Sun constants
| | +-- rnp.rs # precession, nutation, polar motion
| |
| | ── BEVY GLUE LAYER (thin, delegates to astrodyn) ───────────
| |
| +-- astrodyn_bevy/ # Bevy ECS adapter (depends on astrodyn + bevy only)
| | +-- src/
| | +-- lib.rs # AstrodynPlugin, resources, schedule set ordering
| | +-- components/ # Component wrappers (state, gravity, mass_tree, frame_tree, ...)
| | +-- systems/ # All Bevy systems delegating to astrodyn
| | +-- sets.rs # AstrodynSet schedule sets
| | +-- frame_param.rs # RelativeFrameState / FrameOrigin SystemParams
| | +-- frame_attach_system.rs # frame-attached body propagation
| | +-- mass_tree.rs # MassChildOf walks, composite_mass_system
| | +-- kinematic_propagation.rs # JEOD-style propagate_state_from_structure
| | +-- recipes.rs # Re-export of astrodyn::recipes
| | +-- validation.rs # Runtime invariant checks
| |
| | ── PARALLEL NON-BEVY CONSUMER (in-workspace test harness) ──
| |
| +-- astrodyn_runner/ # Standalone arena-state Simulation harness
| | +-- src/ # depends on astrodyn + each astrodyn_* crate it uses
| | +-- lib.rs # Simulation, SimBody, FrameTree (arena), ...
| |
| | ── VERIFICATION / FIXTURES ────────────────────────────────
| |
| +-- astrodyn_verif_jeod/ # JEOD parsers, fixtures, Tier 3 trajectory tests
| +-- astrodyn_verif_parity/ # runner ↔ Bevy parity tests + KNOWN_PARITY_GAPS
|
| ── ORCHESTRATION LAYER (workspace root, ECS-agnostic pipeline) ──
+-- src/ # the `astrodyn` crate (`Cargo.toml` at workspace root)
| +-- lib.rs # public re-exports, prelude
| +-- atmosphere.rs # evaluate_atmosphere()
| +-- gravity.rs # accumulate_gravity()
| +-- forces.rs # collect_and_resolve_forces()
| +-- integration.rs # integrate_body()
| +-- validation.rs # validate_body() -> Result<(), Vec<ValidationError>>
| +-- pipeline.rs # PipelineStage enum, PIPELINE_ORDER
| +-- frame_orchestration.rs # frame-tree compose / origin shifts
| +-- vehicle_builder.rs # typestate VehicleBuilder
| +-- recipes/ # constants, earth, mars, moon, orbital_elements, ...
| +-- ...
|
+-- xtask/ # cargo xtask regenerate-tier3 driver
+-- trick/ # Docker-based JEOD reference-CSV regen tooling
JEOD's data files are parsed inside astrodyn_verif_jeod (parsers + fixtures
were redistributed there in #387;
the older standalone jeod_test_data crate has been retired). The verbatim
NASA JEOD source mirror under
crates/astrodyn_verif_jeod/test_data/jeod_inputs/ is committed to the
repository, so all three tiers run on a fresh clone with no $JEOD_HOME.
Dependency graph:
astrodyn_quantities
(uom + phantom tags + Qty3<D, F> +
NormalizedQuat + FrameTransform<From, To>)
^
|
──────────┴──────────
| every astrodyn_* crate |
─────────────────────
|
astrodyn_math <── astrodyn_dynamics <── astrodyn_interactions
^ ^ |
| | v
astrodyn_time astrodyn_gravity astrodyn_atmosphere
^ ^
| |
astrodyn_frames astrodyn_ephemeris astrodyn_planet
|
All astrodyn_* are pure Rust |
────────────────────────────────┘
|
v
astrodyn (workspace root: composes astrodyn_* functions, zero Bevy dep)
|
┌─────┴─────────────────────────────────┐
v v
astrodyn_bevy (Bevy glue, depends on astrodyn_runner (in-workspace
astrodyn + bevy) test harness; Tier 3 + offline)
astrodyn_quantities (added in #101 Phase 0) sits at the bottom of the DAG. Every
other astrodyn_* crate depends on it for typed quantities, phantom frame/time-scale
tags, and the F64Ext facade. See §8 "Phase 8: Type-System Refactor" and
Type-System for the architecture and rationale.
The codebase has three layers:
-
astrodyn_*crates — Pure physics algorithms and data types. Zero Bevy dependency. Define per-function operations (gravity evaluation, RK4 step, drag computation, etc.). -
astrodyncrate — ECS-agnostic orchestration. Zero Bevy dependency. Composesastrodyn_*functions into pipeline stages and provides:-
Per-body functions (primary API for ECS adapters):
accumulate_gravity(),evaluate_atmosphere(),collect_and_resolve_forces(),integrate_body(),validate_body(). All borrow-based — the ECS world remains the source of truth. -
Simulationrunner (for non-ECS use): standalone struct for batch propagation, scripting, and tests. Owns state internally. -
PipelineStageenum andPIPELINE_ORDER: canonical stage ordering that any adapter must respect.
-
Per-body functions (primary API for ECS adapters):
-
astrodyn_bevycrate (crates/astrodyn_bevy/) — Thin Bevy glue. All system functions, component definitions, schedule sets, plugin registration, and validation live in a single unified crate. Each system queries components and delegates toastrodynper-body functions.
Why three layers? The original two-layer design (astrodyn_* + Bevy glue) kept
physics portable, but the orchestration logic — pipeline ordering, gravity accumulation,
frame transform composition, force contribution assembly, integration routing, and
validation — lived exclusively in Bevy system code. A non-Bevy ECS user would have
had to reverse-engineer ~10 systems to build a working simulation loop. The astrodyn
layer extracts this orchestration into a single, Bevy-free crate that any ECS (or no
ECS) can use directly.
astrodyn_bevy depends only on astrodyn and bevy — never on astrodyn_*
crates directly. astrodyn re-exports all types that ECS adapters need, making it
the single API surface for the production path (every Bevy system, every
mission crate, every downstream consumer that ships in a real simulation reads the
workspace through astrodyn and only through astrodyn; #360 scoped this rule
explicitly). The astrodyn_* and astrodyn crates have no Bevy dependency and
can be used standalone.
The workspace also contains the parallel non-Bevy consumer astrodyn_runner
(crates/astrodyn_runner/), which owns its own state container and does depend
directly on the physics crates it constructs by hand. The runner is a test
harness — Tier 3 cross-validation tests, batch propagation, scripting — and is
never published as a mission consumer. See CLAUDE.md for the full
astrodyn_bevy vs astrodyn_runner asymmetry rules.
All Bevy glue lives in a single AstrodynPlugin in
crates/astrodyn_bevy/src/lib.rs (renamed from JeodPlugin in
#392). The plugin registers
resources, configures schedule set ordering, and adds all systems inline —
there are no separate sub-plugins per domain.
pub struct AstrodynPlugin;
impl Plugin for AstrodynPlugin {
fn build(&self, app: &mut App) {
// Insert resources (SimulationTime, EphemerisData, etc.)
// Configure AstrodynSet schedule set ordering in FixedUpdate
// Add all systems (time, gravity, integration, derived states, etc.)
// each assigned to the appropriate AstrodynSet
}
}Users add AstrodynPlugin to get the full simulation pipeline. Since all systems
live in one plugin, selective opt-in is done by which components are spawned
on entities, not by choosing sub-plugins.
Test pure math with known exact solutions. No JEOD data needed. Implement these alongside each module.
#[test]
fn kepler_equation_circular() {
// For circular orbit (e=0), mean anomaly = eccentric anomaly
assert_f64_eq!(solve_kepler(0.0, PI / 4.0), PI / 4.0);
}
#[test]
fn orbital_elements_roundtrip() {
let state = CartesianState { r: dvec3(...), v: dvec3(...) };
let elems = OrbitalElements::from_cartesian(&state, MU_EARTH);
let back = elems.to_cartesian(MU_EARTH);
assert_dvec3_near!(state.r, back.r, 1e-10);
}
#[test]
fn quaternion_rotation_matrix_consistency() { ... }
#[test]
fn frame_composition_is_identity_for_self() { ... }
#[test]
fn kepler_orbit_conserves_energy() { ... }
#[test]
fn kepler_orbit_period_matches_analytical() {
// T = 2*pi*sqrt(a^3/mu) — must match to integrator precision
}Use JEOD's own test data files via the parsers in astrodyn_verif_jeod. The JEOD
input mirror lives in crates/astrodyn_verif_jeod/test_data/jeod_inputs/,
committed to the repository so all three tiers run on a fresh clone with no
$JEOD_HOME. See Section 7 for parser details.
use astrodyn_verif_jeod::{reference_states, gravity_test_cases, orbital_init_data};
#[test]
fn iss_reference_inertial_state() {
// Source: models/dynamics/body_action/verif/SIM_orbinit/
// Modified_data/ISS/reference_inertial_trans_state.py
// (read from crates/astrodyn_verif_jeod/test_data/jeod_inputs/)
let expected = reference_states("ISS", "inertial");
// expected.position = [1244540.53, 5655938.85, 3425643.22]
// expected.velocity = [-6003.833051, -1469.496044, 4590.511776]
let init = orbital_init_data("ISS", "trans_Orbit_inertial_body_set01");
let state = propagate_from_elements(&init);
assert_dvec3_near!(state.position, expected.position, 1.0); // 1m tolerance
}
#[test]
fn earth_gravity_at_known_positions() {
// Source: models/environment/gravity/verif/unit_tests/
// grav_geospherical/data/verif_out.txt
for case in gravity_test_cases() {
let result = gravity_source.compute(case.position, case.degree, case.order);
assert_dvec3_near!(result.accel, case.expected_accel, 1e-12);
assert_near!(result.potential, case.expected_potential, 1e-6);
}
}
#[test]
fn euler_angle_decomposition() {
// Source: models/dynamics/derived_state/verif/unit_tests/
// euler_derived_state_ut.cc
// 6 test cases with rotation matrix -> expected Euler angles
for case in euler_test_cases() {
let result = euler_decompose(case.matrix, case.sequence);
assert_dvec3_near!(result, case.expected_angles, 1e-14);
}
}Generate reference trajectories by running JEOD's verification sims inside a Rocky 9 Docker container with Trick 25, then compare against astrodyn propagation from identical initial conditions.
Docker workflow:
# Build the container (from astrodyn root, with trick/ and jeod/ as siblings)
docker build -f trick/Dockerfile -t jeod-trick ..
# Generate reference CSVs (runs JEOD sims, exports to test_data/)
mkdir -p test_data
docker run --rm -v $(pwd)/test_data:/output jeod-trickThe container builds Trick and JEOD from source using the exact package list from
Trick's CI (test_linux.yml Rocky 9 matrix entry), runs verification sims, and
exports ASCII CSV trajectories. The CSV files are gitignored — they are generated
locally and consumed by cargo test.
Cross-validation test pattern (implemented):
#[test]
fn tier3_cross_validate_against_jeod_dyncomp() {
let csv_path = Path::new("../../test_data/dyncomp_run2_state.csv");
assert!(csv_path.exists(), "Generate with: docker run ...");
let jeod_trajectory = load_jeod_trajectory(&csv_path);
// ... propagate using OUR ported code, collect StateLog entries ...
let report = CrossvalReport::compute("tier3_...", &our_states, &ref_states);
report.write(); // errors to target/tier3_crossval/<name>.json
// Per-component tolerances at 5% above observed max error
report.assert_position([1.37e-6, 2.154e-6, 1.826e-6]);
report.assert_velocity([1.446e-9, 2.389e-9, 1.814e-9]);
}CrossvalReport computes per-component max errors and writes them to JSON.
It has no tolerance fields — tolerances live exclusively in the test source
as literal values passed to assert_position, assert_velocity,
assert_quat_angle, assert_ang_vel, or via assert!(var < literal, "name")
for scalar extras. The report binary extracts all tolerances from source for
display — JSON contains only errors.
Key rules:
- Tests assert on missing data — never skip gracefully.
- All computation (gravity, Earth rotation, time conversion) is our own ported code. JEOD CSV data is used only for comparison, never as input to our computation. JEOD data must never be injected into intermediate computation steps — the Simulation propagates entirely under its own physics from initial conditions.
- Every Tier 3 test must exercise the full
Simulation::step()pipeline end-to-end. Tests that call per-body functions directly or evaluate static data points bypass the pipeline and must be upgraded. - Tier 3 tests are part of the definition of done for every phase, not optional.
- Tolerances are literal per-test values (5% above observed error), never runtime-computed or conditional. JEOD CSVs are static, our code is deterministic.
Two complementary Tier 3 test paths:
-
Simulation-vs-JEOD (
crates/astrodyn_verif_jeod/tests/tier3_sim_*.rs): runsastrodyn_runner::Simulation::step()from JEOD initial conditions and compares against JEOD Trick CSV output at each checkpoint. Validates theastrodynpipeline (driven by the runner harness) against NASA's reference. -
Bevy-vs-Simulation parity (
crates/astrodyn_verif_parity/tests/bevy_parity_*.rs): runs both a BevyApp(with fullAstrodynPluginpipeline) and anastrodyn_runner::Simulationfrom the same initial conditions and assertsf64::to_bits()equality. The [VerificationCaseParityExt::run_and_assert_parity] trait + the [SimulationBuilderBevyExt::populate_app] bridge (issue #389) drive both paths from oneVerificationCase. Theparity_coverage.rsmeta-test enforces the superset invarianttier3_topics ⊂ bevy_parity_topics ∪ KNOWN_PARITY_GAPS, so a newtier3_*topic that lands without either a parity wrapper or aKNOWN_PARITY_GAPSexemption fails CI.
Together: Bevy ≡ Simulation ≈ JEOD. The Bevy adapter inherits Tier 3
coverage transitively for every topic that has a runner ↔ bevy parity
sibling; the long tail (multi-planet scenarios, pre-recipe siblings,
analytical-only tests, scenarios with pre_step ephemeris updates that need a
Bevy-side SimContext impl — see issue
#395) is documented in
KNOWN_PARITY_GAPS for incremental closure.
Results:
| Phase | Run | Gravity | Pos Error (8h) | Attitude |
|---|---|---|---|---|
| 1 | RUN_2 | Point-mass | 0.4 m | — |
| 2 | RUN_3A | 4×4 harmonics + our RNP | 15.6 m | — |
| 2 | RUN_3B | 8×8 harmonics + our RNP | 28.8 m | — |
| 3 | RUN_2 | Point-mass, 6-DOF | 0.32 m | 4.21e-8 rad |
Available JEOD sims for cross-validation:
| Sim | Run | Duration | Gravity | Validates |
|---|---|---|---|---|
| SIM_dyncomp | RUN_2 | 28800s | Spherical | Phase 1: point-mass dynamics |
| SIM_dyncomp | RUN_7A | 28800s | 4x4 harmonics | Phase 2: spherical harmonics |
| SIM_orbinit | RUN_0001 | instant | — | Orbital element initialization |
| SIM_Euler | RUN_inc | 86400s | GGM05C | Phase 3: Euler angles |
| SIM_integ_test | RUN_rk4 | 28800s | — | Integrator accuracy |
| SIM_Earth_Moon | RUN_clem | days | multi-body | Phase 5: Earth-Moon dynamics |
Automated CI that tracks error budgets across all scenarios:
Scenario | Quantity | Tolerance | Phase | Status
---------------------------------|----------|-----------|-------|--------
Kepler 2-body (1 orbit) | position | 1e-6 m | 1 | [x] 0.017 m
Energy conservation (10 orbits) | energy | 1e-8 rel | 1 | [x] 3.2e-10
Period accuracy | period | 1e-4 rel | 1 | [x] 2.3e-12
ISS 24h point-mass | altitude | 1 km | 1 | [x] exact
JEOD trajectory (8h spherical) | position | 5 km | 1 | [x] 0.4 m
Orbital elements roundtrip | position | 1e-6 m | 1 | [x] <1e-6
Euler angle decomposition | angles | 1e-12 rad | 1 | [x] <1e-15
Gravity acceleration | accel | 1e-12 m/s²| 1 | [x] exact
LEO + J2 (24h) | position | 1.0 m | 2 | [ ]
ISS full gravity (24h) | position | 10.0 m | 2 | [ ]
Earth-Moon 3-body (7 days) | position | 100.0 m | 5 | [ ]
JEOD's C++ architecture enforces ~120 invariants through MessageHandler::fail() (fatal),
MessageHandler::error() (non-fatal auto-correction), structural guarantees (value members,
deleted copy constructors), and flag-gated code paths. In ECS, components are optional and
can be added/removed freely, so these invariants must be tracked and enforced explicitly.
1. Catalog — JEOD_invariants.md
A table of every known JEOD invariant, organized by section (DB=DynBody, MA=Mass, GV=Gravity, etc.). Each row has:
| Field | Purpose |
|---|---|
| Tag | Unique ID like GV.04 for cross-referencing |
| Invariant | What the invariant requires |
| Enforcement | How JEOD enforces it (fatal, error, structural, flag-gate) |
| Category | When it applies (initialization, runtime, structural, consistency, ordering) |
| Our Status | How we enforce it (enforced, partial, deferred, n/a, structural) |
2. Source tags — // JEOD_INV: XX.YY comments
Every enforcement site in our Rust source is tagged:
// JEOD_INV: GV.04 — degree <= source degree
assert!(
self.degree <= data.degree,
"Gravity field degree requested ({}) exceeds source ({})",
self.degree, data.degree
);Tag text must describe what our code does. When we diverge from JEOD's approach, note the divergence:
// JEOD_INV: DB.18 — F=ma via precomputed inverse_mass (matches JEOD MassPointState.inverse_mass)3. CI coverage — tests/invariant_coverage.rs
Bidirectional consistency test:
- Every catalog entry marked
enforced,partial, orstructural(with a file reference) must have at least one// JEOD_INV:tag in source. - Every source tag must reference a valid catalog entry.
- No duplicate IDs in the catalog.
When reading JEOD source and encountering a MessageHandler::fail(), error(), assert,
or structural guarantee not already in the catalog:
- Add a row to JEOD_invariants.md with the next tag in the section (e.g.,
DB.28). - Add
// JEOD_INV: DB.28 — descriptionat the enforcement site in our code, or mark the catalog entrydeferred/n/aif we don't enforce it yet. - Run
cargo test --test invariant_coverageto verify consistency.
If our code already enforces a JEOD invariant but lacks a // JEOD_INV tag:
- Find or create the catalog entry.
- Add the tag at the enforcement site.
- Run the coverage test.
The catalog has 287 invariants across 19 sections (AT, BA, DB, DM, DS, EP, FD,
GV, IG, IN, LV, MA, OE, PF, QT, RF, SM, TM, TS). 101 are enforced, 14 partial,
49 structural (guaranteed by the type system or Bevy ECS), 26 deferred
(typically to Phase-5 articulated-bodies work), and 97 n/a for the ECS
architecture (rationale recorded inline). Source carries roughly 460 tag sites
matching // JEOD_INV: XX.YY; the bidirectional CI test
(crates/astrodyn_bevy/tests/invariant_coverage.rs) keeps the catalog and the
tag sites consistent.
The JEOD parsers and fixtures live in astrodyn_verif_jeod
(crates/astrodyn_verif_jeod/). They were originally housed in a standalone
jeod_test_data crate; #387
redistributed them into astrodyn_verif_jeod (and per-domain regen binaries —
extract_grav_coeffs, extract_planet_pfixposn, etc. — into the owner crates).
The verbatim NASA JEOD source mirror lives under
crates/astrodyn_verif_jeod/test_data/jeod_inputs/ and is committed to the
repository, so all three test tiers run on a fresh clone with no $JEOD_HOME
set. The standard NASA JEOD_HOME env var (the older JEOD_PATH alias was
retired in #239) is required
only for fixture regeneration after a JEOD upgrade.
JEOD's data files fall into three categories:
All paths below are relative to the in-repo mirror at
crates/astrodyn_verif_jeod/test_data/jeod_inputs/ (which mirrors the upstream
JEOD layout one-for-one).
| File | Location | Format | Parser |
|---|---|---|---|
Leap_Second.dat |
models/environment/time/data/ |
# comments + whitespace columns |
Line parser, skip #
|
verif_out.txt |
models/environment/gravity/verif/unit_tests/grav_geospherical/data/ |
18 space-separated numeric fields, 40 rows |
sscanf-equivalent |
reference_*_trans_state.py |
models/dynamics/body_action/verif/SIM_orbinit/Modified_data/ISS/ |
vehicle.expected_state.trans.position = [x, y, z] |
Regex on RHS arrays |
iss_rate_def.py |
same ISS directory | return [0.002, 0.006, -0.003] |
Regex on return literal |
lvlh_rate_def.py |
same ISS directory | return -0.06556131568278 |
Regex on return literal |
earth_discrep.txt |
models/environment/spice/verif/compare/ |
Angle = 1.26e-07Axis = -0.92 0.38 0.07 |
Regex |
Orbital element, mass property, and attitude files wrap numeric data in Trick calls. A single regex handles all of them:
# What the files look like:
vehicle_reference.orb_init.inclination = trick.attach_units("degree", 51.670450765)
vehicle_reference.orb_init.semi_major_axis = trick.attach_units("km", 6732.90120152)
vehicle_reference.orb_init.eccentricity = 0.00129073350
vehicle_reference.mass_init.properties.mass = 100000.0
vehicle_reference.mass_init.properties.inertia[0] = [7e12, 0.0, 0.0]
vehicle_reference.att_init.orientation.euler_angles = trick.attach_units("degree", [77.59, -30.60, -46.10])The parser extracts (dotted_key, unit_or_none, value) tuples:
pub struct JeodPyValue {
pub key: String, // "orb_init.inclination"
pub unit: Option<String>, // Some("degree")
pub value: JeodValue, // Scalar(51.670450765) or Vec([...])
}
pub enum JeodValue {
Scalar(f64),
Vec(Vec<f64>),
Str(String),
Bool(bool),
}Files covered by this parser:
| Pattern | Count | Content |
|---|---|---|
trans_Orbit_*_body_set*.py |
~20 | Orbital elements (a, e, i, RAAN, omega, tp) |
mass.py |
per vehicle | Mass, inertia tensor, center of mass, attach points |
att_RotState_*.py |
~10 | Euler angles, quaternions |
rate_RotState_*.py (some) |
~5 | Angular velocity |
Unit conversions are trivial — the parser applies them automatically:
| JEOD Unit | Conversion |
|---|---|
"degree" |
multiply by PI/180
|
"km" |
multiply by 1000
|
"s" |
no conversion |
~30% of files contain exec() chains, eval(), or complex control flow. These are
orchestration files that wire together the data files above — they don't contain
unique data.
| File | Why unparseable |
|---|---|
single_vehicle_run.py |
exec() chains, eval("set_" + name + "_mass(...)")
|
earth.py |
Method calls: set_date_and_time(2005, 7, 28, 10, 9, 59.0)
|
system.py |
Pure Trick API calls |
run_files.py |
Dynamic file loading with exec()
|
These don't need to be parsed. The data they reference lives in the parseable files. The scenario configuration (start date, integration method, stop time) can be hardcoded in Rust test functions since there are a finite number of scenarios.
Of JEOD's 262 C++ unit test files, only 2 contain extractable numerical test vectors:
| Source | Test Cases | Content |
|---|---|---|
euler_derived_state_ut.cc |
6 | Rotation matrix → expected Euler angles |
verif_out.txt |
40 | Position → expected gravity acceleration, gradient, potential |
The rest are structural tests (empty bodies, mock verification, boolean checks) with no hardcoded numerical assertions. Not worth parsing.
For euler_derived_state_ut.cc, values can be extracted with:
regex: double\s+(\w+)\[3\]\s*=\s*\{([^}]+)\}
The parser surface lives in astrodyn_verif_jeod. By default each helper reads
from the in-repo mirror under
crates/astrodyn_verif_jeod/test_data/jeod_inputs/; callers can override with an
explicit path or $JEOD_HOME for fixture regeneration.
// crates/astrodyn_verif_jeod/src/lib.rs (and submodules)
/// Parse JEOD Python data files with optional trick.attach_units() stripping.
/// Works for orbital elements, mass properties, attitude, rate definitions.
pub fn parse_py_data(path: &Path) -> Vec<JeodPyValue>;
/// Parse reference state vectors from reference_*_trans_state.py files.
pub fn reference_states(vehicle: &str, frame: &str) -> TranslationalState;
/// Parse orbital initialization data from trans_Orbit_*.py files.
/// Returns orbital elements with units already converted (deg->rad, km->m).
pub fn orbital_init_data(vehicle: &str, init_name: &str) -> OrbitalInitData;
/// Parse gravity verification test cases from verif_out.txt.
/// Returns 40 test cases with (position, degree, order, expected accel/grad/pot).
pub fn gravity_test_cases() -> Vec<GravityTestCase>;
/// Parse Leap_Second.dat into a leap second table.
pub fn leap_second_table() -> Vec<LeapSecondEntry>;
/// Parse Euler angle test vectors from euler_derived_state_ut.cc.
pub fn euler_test_cases() -> Vec<EulerTestCase>;Goal: A dot orbiting a point mass in Bevy's FixedUpdate.
Core crates: astrodyn_math, astrodyn_dynamics (minimal), astrodyn_gravity (point mass only),
astrodyn_frames (minimal)
Bevy glue: astrodyn_bevy (crates/astrodyn_bevy/)
Deliver:
-
DVec3/DQuat/DMat3math operations (usingglamf64 types) - Orbital element ↔ Cartesian conversions with Kepler equation solver
-
TranslationalState,MassProperties,TotalForcetypes (core) + components (Bevy) - RK4 integrator as pure function + Bevy integration system
- Point-mass gravity computation + Bevy gravity system
- Minimal reference frame hierarchy (inertial root + one body frame)
-
OrbitalElementsderived state -
batch_propagation.rsexample usingastrodyn_*crates with no Bevy
Verify with:
- Kepler orbit conserves energy and angular momentum to machine precision
- Orbital period matches analytical
T = 2*pi*sqrt(a^3/mu) - Orbital elements round-trip test
Goal: J2+ spherical harmonics gravity, time system, basic ephemeris.
Core crates: astrodyn_gravity (spherical harmonics), astrodyn_time, astrodyn_ephemeris,
astrodyn_planet, astrodyn_verif_jeod
Bevy glue: astrodyn_bevy (crates/astrodyn_bevy/)
Deliver:
- Full spherical harmonics gravity engine (port of
spherical_harmonics_calc_nonspherical.cc) - Earth GGM05C, Moon GRAIL150 coefficient data
- TAI, UTC, UT1, TDB, TT time scales with converters
- Leap second table (from JEOD data)
- DE421 binary ephemeris reader
- Planet position updates from ephemeris
- Earth, Moon, Sun planet presets with shapes and gravity
Verify with:
- Tier 2 gravity tests: 40 test vectors from
verif_out.txt - LEO + J2 nodal regression rate matches analytical prediction
- Time conversion tests against known epochs
Goal: 6-DOF dynamics with rotational state and multi-body attachment.
Core crates: astrodyn_dynamics (full), astrodyn_frames (full), astrodyn_math (derived states)
Bevy glue: astrodyn_bevy (crates/astrodyn_bevy/)
Deliver:
- Rotational integration (Lie group technique for quaternion propagation)
- Force and torque collection system
- Mass tree with composite property updates on attach/detach
- Full reference frame propagation (structure → composite → core body)
- Body initialization actions (orbital elements, LVLH, NED)
- Euler angles, LVLH, NED, planet-fixed derived states
Verify with:
- Tier 2 ISS reference state tests
- Tier 2 Euler angle decomposition tests (6 vectors from
euler_derived_state_ut.cc) - Tier 3 cross-validation against JEOD's
SIM_dyncomp(6-DOF attitude: 4.21e-8 rad/8h)
Goal: Tier 2/3 cross-validation for every Phase 3 capability. No new physics.
Deliver:
- Wire planet-fixed frame into gravity pipeline (fix 15–29 m RNP residual)
- Cross-validate structure/core_body frame propagation against existing CSV data
- Docker sims: SIM_OrbElem, SIM_LVLH, SIM_NED, SIM_SolarBeta, SIM_Euler, SIM_orbinit
- Trajectory-level validation of orbital elements, LVLH, geodetic, NED, solar beta, Euler angles
- Bevy system integration test (wiring parity)
Verify with:
- Tier 3 spherical harmonics with correct RNP (target < 5 m, down from 15.6 m)
- Tier 3 frame propagation (structure/core match JEOD CSV)
- Tier 3 derived states (each validated against its own JEOD sim)
- Tier 2 body initialization (ISS reference state < 1 m)
Goal: Aerodynamic drag, radiation pressure, gravity gradient torque.
Core crates: astrodyn_atmosphere, astrodyn_interactions
Bevy glue: astrodyn_bevy (crates/astrodyn_bevy/)
Deliver:
- MET atmosphere model (density/temperature/pressure tables)
- Aerodynamic drag system (ballistic coefficient and flat-plate models)
- Solar radiation pressure system
- Gravity gradient torque system
- Solar beta angle derived state
Verify with:
- Tier 1: LEO with drag orbital decay rate matches expected behavior
- Tier 1: SRP magnitude matches analytical
P = L_sun / (4*pi*r^2*c) - Tier 1: Gravity torque on known inertia tensor matches analytical gradient torque
- Tier 2: MET atmosphere density at 400 km matches JEOD tables to < 5%
- Tier 3: Gravity torque trajectory (RUN_9A/9B) attitude < 0.01 rad/8h
- Tier 3: Drag trajectory (SIM_dyncomp with drag) position < 100 m/24h
- Tier 3: SRP trajectory position < 10 m/24h
- Tier 3: Eclipse entry/exit times match JEOD to < 10 s
Goal: Feature parity with JEOD's verified capabilities.
Crates: All — advanced features added to existing crates
Deliver:
- Advanced integrators: Gauss-Jackson, LSODE, RKF45 (adaptive step)
- Solid body tides in gravity model
- Full RNP model for Earth rotation (precession, nutation, polar motion)
- SPICE integration (via FFI to cspice, or native Rust reader)
- Contact dynamics
- Full regression suite against JEOD (Tier 4)
- Multi-body scenarios: Apollo trans-lunar, Earth-Moon, Mars
Verify with (Tier 3 required for each new physics):
- Tier 3 LEO 24h high-fidelity gravity (GGM05C deg 20 + polar motion) < 10 m
- Tier 3 LEO with drag (MET + ballistic drag) < 100 m/24h
- Tier 3 Earth-Moon multi-body (Sun/Moon differential accel) < 100 m/7d
- Tier 3 Mars orbit (MRO110B2 gravity) < 100 m/7d
- Tier 3 Gauss-Jackson trajectory < 1 m/24h
- Tier 3 RKF45 trajectory < 10 m/24h with adaptive stepping
- Tier 3 polar motion: Earth-fixed frame < 0.1 arcsecond/24h
- Tier 3 solid tides: ON vs OFF position delta matches JEOD delta to < 10%
- Tier 4 automated regression suite with error budget tracking
Goal: Full-breadth cross-validation against every major JEOD verification sim category. No new physics — validates Phase 5 capabilities across broader parameter spaces, edge cases, and specialized scenarios.
Crates: All — new reference data (Docker) and Tier 3 tests added.
Deliver:
- 9 new reference-data sims generated via the Docker pipeline: SIM_Relative, SIM_Planetary, SIM_LIGHT_CIR, SIM_5_all_inclusive, SIM_MET, SIM_7_time_reversal, SIM_orb_elem, SIM_mercury, SIM_LvlhRelative (derived- state edge-case data was reused from Phase 4b)
- Full
tier3_simulation_*andtier3_bevy_*coverage for each category (69/69 simulation/Bevy pairs, 100%) - Feature-parity audit: 15/17
astrodynpublic functions have a Bevy system counterpart; the 2 by-design exceptions arecompute_relative_state/compute_lvlh_relative_state(on-demand utilities, not per-entity systems — covered in parity tests) andintegrate_body_coupled(future-only coupled SRP thermal path)
Verify with (Tier 3):
- Earth-Moon multi-body: 0.93 m over 7 days (< 1 m target)
- Mars orbit: 3.8 m over 3 hours (< 100 m target)
- Relative dynamics: 8.0e-14 m over 100 s (< 1e-6 m target)
- Planetary derived states (LEO/GEO/polar): 1.0 m over 24 h
- Earth lighting: 1e-10 shadow geometry match across 10 geometries
- Time scales: TAI/TT/TDB < 2e-6 s, GMST < 1e-4 s over 2 h
- MET atmosphere: machine precision after TJT epoch fix
- Comprehensive orbital elements: 7 orbit families to < 1e-6 per element
- Mercury GR perihelion: 42.97 arcsec/century (~0.02% vs JEOD's ~43)
- LVLH-relative: 2.1e-14 m (< 1e-6 m target)
Goal: Close remaining open issues, harden the test suite, and document irreducible numerical differences vs JEOD. No new physics beyond one small function port that surfaced during the audit.
Crates: Docs (docs/), tests (crates/*/tests/, tests/), and a new
astrodyn_dynamics::init_from_time_periapsis function matching JEOD's
dyn_body_init_orbit.cc:295.
Deliver:
- Numerical-Differences: five catalogued irreducible differences (GCC vs LLVM FP, DE421 Anise vs cspice, SRP thermal residual, host libm on drag velocity schedule, geodetic longitude at poles)
-
Earth-Lighting-Validation: Tier 3 gap rationale and enumeration of
the 11
tier3_bevy_earth_lighting_*static-geometry tests (no propagating JEOD sim ships in JEOD 5.4, so full trajectory cross-validation is not possible upstream) - Test-suite audit: 41 fixes across 30 files; hardcoded planet constants
(
mu,R_earth, rotation rate, shadow radius) replaced withastrodyn::{EARTH, SUN, MOON}presets; inline orbit-init math replaced with the newinit_from_time_periapsisport - Showcase examples kept with the runner harness they exercise:
apollo.rs,batch_propagation.rs,leo_drag.rsundercrates/astrodyn_runner/examples/; the longerearth_moon.rs/mars_orbit.rscross-validation examples undercrates/astrodyn_verif_jeod/examples/. Bevy mission examples (kepler_orbit.rs,typed_mission.rs) live undercrates/astrodyn_bevy/examples/.
Verify with:
-
cargo nextest run --workspace: all tests pass (including the slowearth_moonrun on main-push CI) -
cargo clippy --workspace --tests -- -D warnings: clean - Phase 7 exit-gate issues closed: #6, #49, #50, #60 (#13 already closed); DynManager multi-integrable-object work split into #114 as follow-up
Goal: Shift correctness guarantees from runtime/documentation into the type
system. Mission-crate code never sees DVec3, PhantomData, or
uom::si::f64::Length::new::<kilometer> — it composes typed building blocks and
gets compiler errors in physics language when conventions are violated.
Context: The original Phase 1–7 plan closed in April 2026. Phase 8 is the
type-system refactor (issue #101), planned and shipped April 2026 across 12
sub-issues #102–#113. It is additive to the original plan: physics is unchanged
(Tier 3 baselines frozen at Phase 0 and held to within 1e-12 · magnitude
through every refactor-only phase).
Architectural outcome — three-layer facade:
┌──────────────────────────────────────────────────────────┐
│ Facade (astrodyn_bevy::prelude, astrodyn::recipes) │
│ F64Ext: 400.0.km(), 51.6.deg(), 420_000.0.kg() │
│ Concrete Component wrappers (no visible generics) │
│ Custom #[diagnostic::on_unimplemented] messages │
├──────────────────────────────────────────────────────────┤
│ Typed astrodyn_* siblings │
│ Position<F: Frame>, SecondsSince<S: TimeScale>, │
│ Quat<L, T>, NormalizedQuat, FrameTransform<From, To> │
├──────────────────────────────────────────────────────────┤
│ astrodyn_quantities (bottom of dep graph) │
│ uom re-exports, Qty3<D, F>, phantom frames/scales, │
│ NormalizedQuat witness, F64Ext / Vec3Ext / Array3Ext │
└──────────────────────────────────────────────────────────┘
Phase summary table:
| Phase | Issue | PR | Scope |
|---|---|---|---|
| 0 | #102 | #126 |
astrodyn_quantities crate, F64Ext, custom diagnostics, baselines.json capture |
| 1 | #103 | #130–#134 | Migrate leaf crates: astrodyn_time, astrodyn_planet, astrodyn_atmosphere, astrodyn_ephemeris, jeod_test_data
|
| 2 | #104 | #129, #135–#140 | Migrate astrodyn_math (commitment-point gate) |
| 3 | #105 | #141 | Migrate astrodyn_frames + astrodyn_dynamics typed siblings |
| 4 | #106 | #142 | Migrate astrodyn_gravity + astrodyn_interactions typed siblings |
| 5 | #107 | #143 | Migrate astrodyn public API + typestate VehicleBuilder
|
| 6 | #108 | #145 | Migrate astrodyn_runner + introduce astrodyn::recipes (building_blocks/scenarios/verification) |
| 7 | #109 | #147 | Tier 3 wave A: VerificationCase one-liners + ExtrasComparator dispatch |
| 8 | #110 | #146 | Tier 3 wave B: archetype-B classification + recipes::helpers/ infrastructure |
| 9 | #111 | #148 | Migrate root package + Bevy integration: typed Components + 5/20 systems |
| 10 | #112 | #149 | Purge deprecated APIs + complete typed-sibling coverage + retire sim_test_helpers
|
| 11 | #113 | (this PR) | Documentation, CI polish, compile-time budget enforcement |
Acceptance: mission code reads like physics — let altitude = 400.0.km() —
and the compiler rejects frame mismatches, scalar-vs-vector quaternion confusion,
and unit-dimensional errors at compile time. See Type-System for the
contributor primer and crates/astrodyn_bevy/examples/typed_mission.rs for the
canonical worked example.
The original Phase 1–7 plan and Phase 8 type-system refactor closed in April 2026. Subsequent work is tracked as individual GitHub issues rather than as numbered phases. Notable workstreams that have landed since:
-
Workspace rebrand (#387,
#392) — the
bevy_jeod→astrodyn/astrodyn_*rename plusJeodPlugin/JeodSet→AstrodynPlugin/AstrodynSet. Fixtures and parsers were redistributed (the standalonejeod_test_datacrate retired in favor ofastrodyn_verif_jeod). -
Schedule simplification (#362)
— JEOD's 9 init/update steps collapsed to 7
AstrodynSetvariants; multi-stage integrators run as an inner loop insideAstrodynSet::Integration. -
Frame-tree ECS-native shape (#268,
PRs #260 / #263 / #280) — the arena-shaped
FrameTreeRResource and*FrameIdCcomponents were replaced by ECS frame entities (FrameEntityC,PfixFrameEntityC,RootFrameEntityR) with state onFrameTransC/FrameRotC/FrameAngVelC. Cross-frame queries go through theRelativeFrameState/FrameOriginSystemParams. See Frame-Tree-ECS-Native. -
Articulated bodies (frame-attached integration #198 / #206; cross-integ-frame
attach #299/#312/#314/#319/#350; mass-tree dual-write #308; per-body kernels
#358/#363; vehicle phantoms on
FlatPlate/AttachEvent/LvlhRelativeState#332). -
Single-API-surface scoping (#360)
— clarified that the production-path single-API rule applies to
astrodyn_bevyand downstream mission crates only;astrodyn_runneris exempt because it owns its own state container. -
Bevy parity infrastructure (#389,
#395) —
VerificationCaseParityExt::run_and_assert_parity+SimulationBuilderBevyExt::populate_appdrive bothastrodyn_runnerand a BevyAppfrom oneVerificationCase;parity_coverage.rsenforcestier3_topics ⊂ bevy_parity_topics ∪ KNOWN_PARITY_GAPS. Follow-on work added 9 parity wrappers (#414) and migrated the deferred parity test set to the recipe trait (#406, #415); the dyncomp_run6 wrapper was reinstated (#416). -
Typed gateway (#388,
#397) — drove typed-quantity
bypass constructors out of the
astrodyngateway and discontinued untyped construction across the public surface so every mission-facing builder path is uniformly typed. Round-trip property tests now cover every typed sibling (#398 / #410). -
Single-API-surface enforcement (#390
/ #404) —
astrodyn_runnerandastrodyn_bevydeclareastrodynas their sole physics dep; thescripts/check_no_bypass_deps.shlint enforces the rule at CI time. -
Self-contained data assets (#144
/ #403) — gravity
coefficient binaries and DE/PCK ephemeris kernels are bundled into
astrodyn_gravity/astrodyn_ephemerisviainclude_bytes!, so a fresh clone needs no external download to run any test tier. Spherical harmonics recipes were promoted to the mission API as part of this work. -
Mass-tree wildcard typing (#396
/ #417),
typed
Simulation::body_mass(#408 / #409) — the typed-quantity surface continues to expand into runner internals.
Deferred items (tracked separately):
| Topic | Tracker |
|---|---|
| Session types for integrator stage ordering (design spike) | #150 |
| Capability tokens vs Bevy SystemSets (design spike) | #151 |
Branded Simulation<'sim> lifetimes (design spike) |
#152 |
| Docker CSV regeneration pipeline modernization | #153 |
Bevy Reflect impls for uom-backed types |
#154 |
Type-erase FrameTransform-shaped Bevy Components |
#155 |
pre_step hook on VerificationCaseExt
|
#156 |
EvaluationCase recipe shape for non-propagating tests |
#157 |
| Const-generic dimensional analysis | obviated by uom adoption (Phase 0) |
| Spherical harmonics evaluation performance | tracked under #65 |
| Decision | Choice | Rationale |
|---|---|---|
| ECS portability | Three-layer split: astrodyn_* (pure physics) + astrodyn (orchestration, workspace root) + astrodyn_bevy (thin Bevy glue) |
Physics algorithms in astrodyn_* are reusable anywhere. Pipeline orchestration in astrodyn codifies stage ordering, gravity accumulation, force collection, and integration routing without ECS dependency. astrodyn_bevy's systems delegate to astrodyn per-body functions. A non-Bevy ECS writes its own thin glue calling the same astrodyn functions, guaranteed bit-identical by bevy_parity_* tests. The parallel non-Bevy consumer astrodyn_runner (test harness) is allowed to depend directly on physics crates because it owns its own state container. |
| Floating-point precision |
f64 everywhere via custom components (not Bevy's Transform) |
Orbital mechanics requires ~15 significant digits. f32 loses km-scale accuracy at Earth-orbit distances. |
| Math library |
glam with f64 features (DVec3, DQuat, DMat3) + nalgebra for NxN matrices |
glam provides f64 types with no Bevy dependency (it's a standalone crate). nalgebra is better for variable-size matrices needed by spherical harmonics coefficient arrays. Both work in astrodyn_* crates. |
| Reference frame tree |
astrodyn_frames provides an arena-based tree; astrodyn maps it to Bevy's Parent/Children
|
Core tree is portable. Bevy layer adds ECS hierarchy for efficient queries. Other ECS layers can use their own hierarchy mechanism. |
| Integration loop | Custom inner loop within FixedUpdate with stage-tracking resource |
Multi-stage integrators (RK4 = 4 stages) need multiple force evaluations per timestep. An inner loop keeps this self-contained. |
| Gravity coefficient data | Binary asset files loaded at runtime via Bevy's AssetServer (or direct file I/O in non-Bevy contexts) |
Keeps multi-MB coefficient arrays out of the compiled binary. Enables runtime model swapping (e.g., switch from GGM05C to GEMT1). astrodyn_gravity provides a load_from_file() function independent of Bevy's asset system. |
| Ephemeris data | Standard JPL DE421 binary files | Well-documented format. Existing parsers available. Same files JEOD uses. astrodyn_ephemeris reads them directly; astrodyn wraps via AssetServer. |
| Plugin granularity | Separate astrodyn_* core crates per domain + one unified astrodyn_bevy glue crate |
Core crates are fine-grained for modularity and parallel compilation. Bevy glue is unified in a single AstrodynPlugin — selective behavior comes from which components are spawned, not plugin selection. Non-Bevy users depend only on astrodyn_* / astrodyn crates. |
| Quaternion convention | JEOD's left-quaternion, scalar-first [q0, q1, q2, q3]
|
Must match JEOD exactly for verification. Document any conversions needed at the glam boundary (glam uses [x, y, z, w] ordering). |
| Testing approach |
#[cfg(test)] unit tests + integration test binaries + criterion benchmarks |
Core physics tested as pure functions (no Bevy App needed). Bevy integration tested separately. Matches JEOD's tiered verification. |
| JEOD data access | Verbatim NASA JEOD source mirror committed under crates/astrodyn_verif_jeod/test_data/jeod_inputs/; parsers in astrodyn_verif_jeod; $JEOD_HOME env var only required for fixture regeneration |
All three test tiers run on a fresh clone with no $JEOD_HOME set. Tests assert on missing data rather than skipping. |
| Risk | Impact | Mitigation |
|---|---|---|
| Numerical precision drift vs. JEOD | Tests fail despite correct implementation | Use per-component tolerances at 5% above observed error. Document known precision differences between GCC and Rust's LLVM backend. Tolerances are literal values in test source — tighten after each code improvement. |
| Spherical harmonics performance | Degree-2190 GGM05C is computationally expensive | Implement with cache-friendly memory layout. Benchmark early. Provide degree/order truncation as a runtime option. Consider SIMD for inner loops. |
| JEOD verification data requires Trick | Cannot produce Tier 3 baseline trajectories without Trick installed | Start with Tier 1 (analytical) and Tier 2 (reference values) — these need no Trick. Generate Tier 3 baselines once, store as CSV. |
Bevy's FixedUpdate assumes fixed timestep |
Adaptive integrators (RKF45, LSODE) need variable dt | Use inner sub-stepping loop within FixedUpdate. The outer schedule provides a maximum dt; the integrator may take smaller steps internally. |
| Quaternion convention mismatch | Subtle rotation bugs that pass simple tests but fail complex scenarios | Document JEOD's convention (scalar-first, left-transform) explicitly. Write conversion functions at the glam boundary. Test with non-trivial rotations (not just identity or 90-degree). |
| Mass tree / attachment complexity | Rigid body attachment/detachment is intricate and error-prone | Implement incrementally: single body first (Phase 1-2), then parent-child attachment (Phase 3), then multi-level trees (Phase 5). Test each level before proceeding. |
| Scope creep | JEOD has 714 source files; reimplementing everything is years of work | Strict phasing. Each phase is independently useful and verifiable. Phase 1 alone enables two-body mission analysis. Resist adding features ahead of schedule. |
glam vs nalgebra friction |
Two math libraries with different conventions, conversion overhead | Standardize on glam for 3-vectors and quaternions (hot path). Use nalgebra only for NxN matrices in gravity coefficients and similar. Define clear boundary types. |
| Glue layer overhead | Extra indirection between ECS and physics |
astrodyn_bevy is intentionally a thin glue crate (component derives, systems that delegate to astrodyn, plugin registration). The physics code only exists once. The overhead pays for itself in testability and portability. |
| Bevy breaking changes | Bevy's rapid release cycle breaks the glue layer | Only astrodyn_bevy needs updating. Physics code in astrodyn_* and orchestration in astrodyn are untouched. Pin Bevy version in workspace; upgrade the glue crate when a new Bevy release lands. |