Skip to content

Strategy

Test User edited this page Jun 7, 2026 · 10 revisions

astrodyn: Reimplementation 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.

Table of Contents


1. Project Overview

What is JEOD?

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.

What are we building?

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

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.

  • astrodyn crate — engine-agnostic orchestration layer (the workspace root package, src/ at workspace root). Composes astrodyn_* functions into pipeline stages, owns the VehicleBuilder typestate, provides the recipes::* catalog, and re-exports every type a host needs. Zero Bevy dependency. The parallel astrodyn_runner crate (under crates/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_bevy crate (crates/astrodyn_bevy/) — Thin Bevy integration layer that depends only on astrodyn + bevy. Defines component wrappers, systems that delegate to astrodyn functions (zero math), schedule sets, and plugin registration. All Bevy glue lives in one unified crate, not separate per-domain crates.

This separation means:

  1. Portability: Swap Bevy for another ECS by writing a new thin glue layer. The physics code doesn't change.
  2. Testability: Core algorithms are tested as pure functions — no need to spin up a Bevy App for unit tests.
  3. Embeddability: astrodyn_* crates can be used in non-ECS contexts (batch trajectory computation, optimization loops, Monte Carlo analysis) without pulling in Bevy.
  4. Stability: Physics crates are insulated from Bevy's rapid release cycle and breaking API changes.

2. From JEOD's Architecture to the Pipeline

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, and EphemerisManager are coordinating god-objects. In astrodyn their 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 DynBody becomes 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 RefFrame and MassBody trees use raw pointers; astrodyn expresses 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 / Atmosphere virtual bases become enums for the closed model sets, or Box<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.

Core → Orchestration → Host separation

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.


3. Component Design

3.1 Core vs. Bevy Split

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).

3.2 Core State Types

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.

3.3 Force/Torque Types

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
}

3.4 Reference Frame Types

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.

3.5 Planet Types

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 { ... }

3.6 Gravity Controls (Vehicle-to-Planet Link)

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>;

3.7 Derived State Types

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 { ... }

3.8 Host storage

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.


4. Pipeline Stages

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):

  1. TimeUpdate — advance TAI; derive UTC/UT1/TDB/GMST.
  2. EphemerisUpdate — planet positions from DE4xx; planet-fixed (RNP) rotations.
  3. Environment — spherical-harmonics gravity and atmospheric density at each body.
  4. Interaction — aerodynamic drag, SRP (flat-plate / cannonball), gravity-gradient torque.
  5. ForceCollection — sum the force/torque contributions into the total wrench.
  6. Integration — propagate state (RK4 / RKF45 / Gauss-Jackson / ABM4), sync body→frame, distance-triggered frame switch, kinematic walk from the root.
  7. 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 AstrodynSet system sets in FixedUpdate; 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 its Simulation::step() loop.

5. Workspace Structure and Layering

Crate Organization

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.

Three-Layer Architecture

The codebase has three layers:

  1. astrodyn_* crates — Pure physics algorithms and data types. Zero Bevy dependency. Define per-function operations (gravity evaluation, RK4 step, drag computation, etc.).

  2. astrodyn crate — engine-agnostic orchestration. Zero Bevy dependency. Composes astrodyn_* 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.
    • Simulation runner (for non-ECS use): standalone struct for batch propagation, scripting, and tests. Owns state internally.
    • PipelineStage enum and PIPELINE_ORDER: canonical stage ordering that any host must respect.
  3. astrodyn_bevy crate (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 to astrodyn per-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.

Host wiring

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.


6. Verification Strategy

Four-Tier Verification Plan

Tier 1: Analytical Unit Tests

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
}

Tier 2: Component Tests Against JEOD Reference Values

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

Tier 3: Trajectory Cross-Validation

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-trick

The 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:

  1. Simulation-vs-JEOD (crates/astrodyn_verif_jeod/tests/tier3_sim_*.rs): runs astrodyn_runner::Simulation::step() from JEOD initial conditions and compares against JEOD Trick CSV output at each checkpoint. Validates the astrodyn pipeline (driven by the runner harness) against NASA's reference.

  2. Bevy-vs-Simulation parity (crates/astrodyn_verif_parity/tests/bevy_parity_*.rs): runs both a Bevy App (with full AstrodynPlugin pipeline) and an astrodyn_runner::Simulation from the same initial conditions and asserts f64::to_bits() equality. The [VerificationCaseParityExt::run_and_assert_parity] trait + the [SimulationBuilderBevyExt::populate_app] bridge (issue #389) drive both paths from one VerificationCase. The parity_coverage.rs meta-test enforces the superset invariant tier3_topics ⊂ bevy_parity_topics ∪ KNOWN_PARITY_GAPS, so a new tier3_* topic that lands without either a parity wrapper or a KNOWN_PARITY_GAPS exemption 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

Tier 4: Regression Suite

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   | [ ]

6b. JEOD Invariant Tracking

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.

Three-part system

1. CatalogJEOD_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 coveragetests/invariant_coverage.rs

Bidirectional consistency test:

  • Every catalog entry marked enforced, partial, or structural (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.

Workflow: adding a new invariant

When reading JEOD source and encountering a MessageHandler::fail(), error(), assert, or structural guarantee not already in the catalog:

  1. Add a row to JEOD_invariants.md with the next tag in the section (e.g., DB.28).
  2. Add // JEOD_INV: DB.28 — description at the enforcement site in our code, or mark the catalog entry deferred/n/a if we don't enforce it yet.
  3. Run cargo test --test invariant_coverage to verify consistency.

Workflow: tagging an untagged enforcement site

If our code already enforces a JEOD invariant but lacks a // JEOD_INV tag:

  1. Find or create the catalog entry.
  2. Add the tag at the enforcement site.
  3. Run the coverage test.

Current state

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.


7. JEOD Data Ingestion

The astrodyn_verif_jeod crate

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.

File Parsability Assessment

JEOD's data files fall into three categories:

Directly parseable (no modification needed)

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

Parseable with trick.attach_units() stripping

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

Not parseable (orchestration logic)

~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.

C++ Unit Test Extraction

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*\{([^}]+)\}

Complete Parser Inventory

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>;

8. Key Architectural Decisions

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.

9. Risks and Mitigations

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.

Clone this wiki locally