-
Notifications
You must be signed in to change notification settings - Fork 0
Strategy
Reimplementing NASA JEOD (JSC Engineering Orbital Dynamics)
in Rust as an engine-agnostic physics library. The physics has zero dependence on any
simulation framework; a host drives it. Bevy ECS
(astrodyn_bevy) and the standalone arena runner (astrodyn_runner) are two
interchangeable drivers, and a third-party host can drive the same pipeline with its own
storage and scheduling.
This is a deliberate departure from JEOD, whose physics carried a dependence on NASA's
Trick sim framework (Trick-generated S_define wiring, logging, and job scheduling
threaded into the physics). That coupling is the mistake we are avoiding: in astrodyn the
engine is a removable consumer at the top of the stack, never a dependency the physics
reaches back into.
- 1. Project Overview
- 2. From JEOD's Architecture to the Pipeline
- 3. Component Design
- 4. Pipeline Stages
- 5. Workspace Structure and Layering
- 6. Verification Strategy
- 6b. JEOD Invariant Tracking
- 7. JEOD Data Ingestion
- 8. Key Architectural Decisions
- 9. 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 of JEOD's physics as a library that carries no sim framework of its own. Where JEOD's physics reached into Trick (job scheduling, data recording, checkpoint/restart, Python configuration), astrodyn's physics is plain borrow-based Rust that a host owns and drives. The host supplies storage, scheduling, and I/O.
One such host is a Bevy-ECS adapter (astrodyn_bevy), where the standard JEOD↔Trick
correspondence maps cleanly onto Bevy primitives:
- 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
But that mapping is one driver's choice, not a property of the physics. astrodyn_runner
provides the same capability with a plain arena-state container and an explicit step loop,
and a third-party host could use any other storage or scheduler.
Engine independence is the primary design constraint, not an afterthought: the physics and math must not depend on any sim engine. 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 — engine-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 a host needs. 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 standalone batch host and the harness used by Tier 3 cross-validation tests. -
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.
JEOD is built on deep OOP hierarchies with manager god-objects. Reimplementing it
is not a mechanical transliteration — it requires rethinking how state flows
through the simulation. The engine-neutral half of that rethink, captured by the
astrodyn pipeline, is:
-
Manager singletons → per-stage functions + plain state. JEOD's
DynManager,GravityManager,TimeManager, andEphemerisManagerare coordinating god-objects. Inastrodyntheir state becomes plain data the host owns (e.g.SimulationTime,EphemerisData) and their behavior becomes borrow-based per-body functions (accumulate_gravity,evaluate_atmosphere,integrate_body, …) that a host calls in order. -
Deep class hierarchies → composition of focused types. A 1200-line
DynBodybecomes a handful of small, independent types (TranslationalState,RotationalState,MassProperties, …) that a host stores however it likes; there is no inheritance. -
Pointer trees → arena-indexed trees. JEOD's
RefFrameandMassBodytrees use raw pointers;astrodynexpresses the same structure with handle/index-based trees that don't assume a particular storage engine. -
Virtual dispatch → enums (or trait objects). JEOD's
GravitySource/Atmospherevirtual bases become enums for the closed model sets, orBox<dyn Trait>where user extension is wanted. -
Method-call ordering → an explicit stage order. JEOD's implicit call
sequence becomes the explicit
PIPELINE_ORDER(see §4) that every host respects.
This decomposition is independent of any runtime. How a specific host realizes
it — ECS components and systems for the Bevy adapter, an arena and a step loop for
astrodyn_runner — is the host's concern. The Bevy realization (the JEOD→ECS
mapping table, manager→Resource+System, pointer-tree→Parent/Children, etc.) is
documented on the
Bevy-Adapter page.
All of the above lands in three layers (see §5 for the full crate map):
astrodyn_dynamics, astrodyn_gravity, … (plain Rust structs, pure functions)
↕ used by
astrodyn (orchestration: composes astrodyn_* functions, re-exports types)
↕ used by
a host (astrodyn_runner, astrodyn_bevy, or your own)
The astrodyn_* crates define algorithms. astrodyn composes them into pipeline
stages and re-exports all types. A host depends only on astrodyn (plus its own
engine) — switching engines means writing a new host that calls the same
astrodyn functions.
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
}(Historical sketch — RefFrameInfo/RefFrameKind were removed in #664;
every frame node now carries a required FrameUid whose FrameClass is
the runtime taxonomy.)
// ── 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.
(Historical sketch — the SourceId generic was removed in #668: a
GravityControl now references its source by inertial-frame FrameUid
(source: FrameUid), the same value identity in every host; the runner
resolves uid → source index and the Bevy adapter uid → source entity at
their own boundaries, and the BevyGravityControls = GravityControls<Entity>
alias below no longer exists.)
// ── 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 { ... }Each core type above is stored by the host. A non-ECS host (astrodyn_runner)
keeps them in its arena directly; the Bevy adapter wraps them as
#[derive(Component)] newtypes (TranslationalStateC<P: Planet>, …) — see
Bevy-Adapter § Bevy components. Either way the
type that carries the physics is the plain astrodyn struct; the wrapper only
adds storage/engine plumbing.
A simulation step runs a fixed sequence of stages. The canonical order is the
engine-neutral PipelineStage enum / PIPELINE_ORDER in astrodyn; every host
executes the stages in this order, whether as scheduled ECS system sets or as a
straight-line step loop. The order mirrors JEOD's DynManager sequencing —
JEOD's nine init/update steps collapse to seven stages because gravity and
atmosphere share the Environment stage and frame propagation rides inside
integration as its post-step (issue
#362):
- TimeUpdate — advance TAI; derive UTC/UT1/TDB/GMST.
- EphemerisUpdate — planet positions from DE4xx; planet-fixed (RNP) rotations.
- Environment — spherical-harmonics gravity and atmospheric density at each body.
- Interaction — aerodynamic drag, SRP (flat-plate / cannonball), gravity-gradient torque.
- ForceCollection — sum the force/torque contributions into the total wrench.
- Integration — propagate state (RK4 / RKF45 / Gauss-Jackson / ABM4), sync body→frame, distance-triggered frame switch, kinematic walk from the root.
- DerivedState — orbital elements, Euler angles, geodetic coords, LVLH, solar beta, lighting.
Each stage is a borrow-based astrodyn function (or a small group of them); the
host owns the state they read and write. Multi-stage integrators (RK4's four
sub-steps, etc.) run their inner loop inside the integration stage rather than
spreading across the pipeline.
Host realizations:
-
Bevy adapter — maps the stages onto seven
AstrodynSetsystem sets inFixedUpdate; see Bevy-Adapter § Execution schedule and Integration-Groups (multi-body coordination, multi-stage integrators, the separate-group escape hatch). -
astrodyn_runner— calls the stage functions in order from itsSimulation::step()loop.
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
§1 Engine independence. 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) 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 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 — engine-agnostic orchestration. Zero Bevy dependency. Composesastrodyn_*functions into pipeline stages and provides:-
Per-body functions (primary API for any host):
accumulate_gravity(),evaluate_atmosphere(),collect_and_resolve_forces(),integrate_body(),validate_body(). All borrow-based — the host's own storage 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 host must respect.
-
Per-body functions (primary API for any host):
-
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 a host needs, 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.
Each host wires the pipeline into its own runtime. The Bevy adapter's wiring —
the single AstrodynPlugin, resource registration, and schedule-set ordering —
is documented on the
Bevy-Adapter § Plugin composition page;
astrodyn_runner instead owns an arena and calls the stage functions from its
Simulation::step() loop.
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 | Point-mass dynamics |
| SIM_dyncomp | RUN_7A | 28800s | 4x4 harmonics | Spherical harmonics |
| SIM_orbinit | RUN_0001 | instant | — | Orbital element initialization |
| SIM_Euler | RUN_inc | 86400s | GGM05C | Euler angles |
| SIM_integ_test | RUN_rk4 | 28800s | — | Integrator accuracy |
| SIM_Earth_Moon | RUN_clem | days | multi-body | 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 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>;| 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, then parent-child attachment, then multi-level trees. Test each level before proceeding. |
| Scope creep | JEOD has 714 source files; reimplementing everything is years of work | Build incrementally. Each increment is independently useful and verifiable; point-mass two-body analysis works before harmonics or multi-body land. Resist adding features ahead of need. |
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. |