From 6ddc4af60919bdf67aba55dfd8fbc38b22a07836 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 25 May 2026 13:03:15 -0700 Subject: [PATCH 1/3] =?UTF-8?q?Add=20SIM=5Fverif=5Fattach=5Fmass=20RUN=5F0?= =?UTF-8?q?9=20(non-identity=20root=20orientation)=20(18=E2=86=9219/21)=20?= =?UTF-8?q?(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUN_09 = parent (StructCG inertia + non-identity parent_mass_orientation_optionA struct→body) + child1 (Body), offset attach [-1,0,0]. It's the first attach_mass RUN with a non-identity root struct→body orientation. Key finding — JEOD's mass print uses a MIXED frame convention for the composite of an oriented body: the composite CoM (`C.M.P. CM vector`) stays in the STRUCT frame, but the composite inertia (`C.M.P. Ib tensor`) is in the BODY frame. Our `recompute_composites` keeps both in the struct frame, and the two inertias are related by the single rotation `I_body = T_parent_this·I·Tᵀ` (frame covariance of the composite combination). So no kernel change is needed: `check_body_rotated` rotates only our composite inertia by `T_parent_this` before comparing, leaving mass + CoM unrotated. Verified element-wise against JEOD's `.out` (trace-preserving rotation; all 20 RUNs pass). Adds `build_run_09`, the `parent_option_a` matrix (verbatim from JEOD Modified_data), `check_body_rotated`, the RUN_09 regen entry, and the committed `.out`. Tolerances at the existing JEOD `%20lf` print-precision floor. Remaining attach_mass gaps (tracked in #99): RUN_08/108 (child attached to two parents — topology resolution) and RUN_109 (named-point attach + the non-identity root orientation). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test_data/attach_mass_09_mass.out | 101 +++++++++++++ .../tests/tier3_sim_attach_mass.rs | 143 +++++++++++++++++- trick/generate_references.sh | 2 + 3 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 crates/astrodyn_verif_jeod/test_data/attach_mass_09_mass.out diff --git a/crates/astrodyn_verif_jeod/test_data/attach_mass_09_mass.out b/crates/astrodyn_verif_jeod/test_data/attach_mass_09_mass.out new file mode 100644 index 00000000..f7051504 --- /dev/null +++ b/crates/astrodyn_verif_jeod/test_data/attach_mass_09_mass.out @@ -0,0 +1,101 @@ + + +============================================================= + +Parent +------------------------------------------------------------- + +Body Area +Offset Vector [m]: + 0.000000 0.000000 0.000000 +T_struct_struct [-]: + 1.000000 0.000000 0.000000 + 0.000000 1.000000 0.000000 + 0.000000 0.000000 1.000000 +------------------------------------------------------------- +Mass Properties +M.P. CM vector [m]: + 0.000000 0.000000 0.000000 +M.P. Mass [kg]: + 1.000000 +M.P. Ib tensor [kgM2]: + 0.354167 -0.025516 0.025516 + -0.025516 0.239583 -0.156250 + 0.025516 -0.156250 0.239583 +M.P. T_struct_body [q]: + 0.866025 -0.500000 0.000000 + 0.353553 0.612372 0.707107 + -0.353553 -0.612372 0.707107 +------------------------------------------------------------- +Composite Mass Properties +C.M.P. CM vector [m]: + -0.500000 0.000000 0.000000 +C.M.P. Mass [kg]: + 2.000000 +C.M.P. Ib tensor [kgM2]: + 0.833333 -0.204124 0.204124 + -0.204124 0.916667 -0.250000 + 0.204124 -0.250000 0.916667 +C.M.P. T_struct_body [q]: + 0.866025 -0.500000 0.000000 + 0.353553 0.612372 0.707107 + -0.353553 -0.612372 0.707107 +------------------------------------------------------------- +Derived Items +C.M.P. Inverse mass [1/kg]: + 0.500000 +C.M.P. Inverse inertia tensor [1/(kgM2)]: + 0.000000 0.000000 0.000000 + 0.000000 0.000000 0.000000 + 0.000000 0.000000 0.000000 +------------------------------------------------------------- + +============================================================= + +Child1 +------------------------------------------------------------- + +Body Area +Offset Vector [m]: + -1.000000 0.000000 0.000000 +T_struct_struct [-]: + 1.000000 0.000000 0.000000 + 0.000000 1.000000 0.000000 + 0.000000 0.000000 1.000000 +------------------------------------------------------------- +Mass Properties +M.P. CM vector [m]: + 0.000000 0.000000 0.000000 +M.P. Mass [kg]: + 1.000000 +M.P. Ib tensor [kgM2]: + 0.333333 0.000000 0.000000 + 0.000000 0.416667 0.000000 + 0.000000 0.000000 0.083333 +M.P. T_struct_body [q]: + 1.000000 0.000000 0.000000 + 0.000000 1.000000 0.000000 + 0.000000 0.000000 1.000000 +------------------------------------------------------------- +Composite Mass Properties +C.M.P. CM vector [m]: + 0.000000 0.000000 0.000000 +C.M.P. Mass [kg]: + 1.000000 +C.M.P. Ib tensor [kgM2]: + 0.333333 0.000000 0.000000 + 0.000000 0.416667 0.000000 + 0.000000 0.000000 0.083333 +C.M.P. T_struct_body [q]: + 1.000000 0.000000 0.000000 + 0.000000 1.000000 0.000000 + 0.000000 0.000000 1.000000 +------------------------------------------------------------- +Derived Items +C.M.P. Inverse mass [1/kg]: + 1.000000 +C.M.P. Inverse inertia tensor [1/(kgM2)]: + 0.000000 0.000000 0.000000 + 0.000000 0.000000 0.000000 + 0.000000 0.000000 0.000000 +------------------------------------------------------------- \ No newline at end of file diff --git a/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs b/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs index e448e091..4b7bd8be 100644 --- a/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs +++ b/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs @@ -30,11 +30,14 @@ //! - RUN_107: parent (`StructCG`) + child1 (`SpecCG`) via named points //! - RUN_110: named-point attach of 3 children then runtime detach of child2 //! - RUN_111: named-point + offset attach, runtime reattach of child2 +//! - RUN_09: non-identity parent struct→body orientation. JEOD reports the +//! composite inertia (`C.M.P. Ib`) in the parent **body** frame while the +//! composite CoM stays in the struct frame; the test rotates our struct-frame +//! composite inertia by `T_parent_this` (`I_body = T·I·Tᵀ`) for the comparison. //! //! Note: RUN_08/RUN_108 (a child attached to two parents in different body -//! actions) and RUN_09/RUN_109 (a non-identity structure→body transform on -//! the root, which JEOD reports composites in body frame for) are not yet -//! covered — see the PR description for the blockers. +//! actions) and RUN_109 (named-point attach combined with the non-identity +//! root orientation) are not yet covered — see #99 / the PR description. //! //! Supplements the analytical tests in //! `crates/astrodyn_dynamics/tests/tier3_mass_attach_detach.rs` with direct @@ -339,6 +342,58 @@ fn check_body( ); } +/// Like [`check_body`] but compares the composite **inertia** rotated into the +/// body frame by `t_parent_this` (struct→body). JEOD's mass print uses a mixed +/// convention for a body with a non-identity struct→body orientation: the +/// composite CoM (`C.M.P. CM vector`) is in the **struct** frame (so it matches +/// our `recompute_composites` directly), but the inertia (`C.M.P. Ib tensor`) +/// is in the **body** frame. Our composite inertia is in the struct frame, and +/// the two are related by the single rotation `I_body = T·I_struct·Tᵀ` (frame +/// covariance). Mass and CoM are compared unrotated; only the inertia rotates. +#[allow(clippy::too_many_arguments)] +fn check_body_rotated( + run: &str, + body_label: &str, + tree: &MassTree, + id: MassBodyId, + reference: &PrintedBody, + t_parent_this: DMat3, + tol_mass: f64, + tol_com: f64, + tol_inertia: f64, + max_errors: &mut MaxErrors, +) { + let comp = &tree.get(id).composite_properties; + let inertia_body = t_parent_this * comp.inertia * t_parent_this.transpose(); + + let mass_err = (comp.mass - reference.composite_mass).abs(); + let com_err = (comp.position - reference.composite_cm).length(); + let inertia_err = [ + (inertia_body.x_axis - reference.composite_inertia.x_axis).length(), + (inertia_body.y_axis - reference.composite_inertia.y_axis).length(), + (inertia_body.z_axis - reference.composite_inertia.z_axis).length(), + ] + .into_iter() + .fold(0.0_f64, f64::max); + + max_errors.mass = max_errors.mass.max(mass_err); + max_errors.com = max_errors.com.max(com_err); + max_errors.inertia = max_errors.inertia.max(inertia_err); + + assert!( + mass_err < tol_mass, + "[{run}:{body_label}] composite mass diff {mass_err:.3e} >= tol {tol_mass:.3e}" + ); + assert!( + com_err < tol_com, + "[{run}:{body_label}] composite CoM diff {com_err:.3e} >= tol {tol_com:.3e}" + ); + assert!( + inertia_err < tol_inertia, + "[{run}:{body_label}] composite inertia (body-frame) diff {inertia_err:.3e} >= tol {tol_inertia:.3e}" + ); +} + struct MaxErrors { mass: f64, com: f64, @@ -491,6 +546,47 @@ fn build_run_07() -> (MassTree, [(String, MassBodyId); 2]) { (tree, [("Parent".into(), parent), ("Child1".into(), child1)]) } +/// Parent struct→body orientation from `parent_mass_orientation_optionA` +/// (`Modified_data/parent_mass.py`), JEOD `T_parent_this` (row-major in the +/// `.py`, transposed into glam column-major here). Used by RUN_09: JEOD +/// reports the root's composite in this body frame. +#[allow( + clippy::approx_constant, + reason = "0.70710678118655 is the verbatim optionA matrix value from JEOD's \ + Modified_data/parent_mass.py; substituting FRAC_1_SQRT_2 would diverge \ + from the transcribed JEOD source matrix" +)] +fn parent_option_a() -> DMat3 { + // Row-major JEOD matrix → DMat3::from_cols takes columns. + DMat3::from_cols( + DVec3::new(0.8660254, 0.35355339059327, -0.35355339059327), + DVec3::new(-0.5, 0.61237243569579, -0.61237243569579), + DVec3::new(0.0, 0.70710678118655, 0.70710678118655), + ) +} + +/// RUN_09: parent (`StructCG` option B inertia + **non-identity struct→body +/// orientation** `optionA`) + child1 (`Body` option C) attached at offset +/// [-1, 0, 0], identity attach. The struct-frame composite our +/// `recompute_composites` produces is frame-covariant with JEOD's: JEOD +/// reports the root composite in the parent **body** frame, so the validation +/// rotates our struct-frame composite by `T_parent_this` (see the RUN_09 +/// block in `tier3_sim_attach_mass`). The parent orientation does not enter +/// the struct-frame composite, so the tree is built without it. +fn build_run_09() -> (MassTree, [(String, MassBodyId); 2]) { + let mut tree = MassTree::new(); + let parent = tree.add_root( + "Parent".into(), + mass_struct_cg_spec(1.0, DVec3::ZERO, box_inertia_diag()), + ); + let child1 = tree.add_body( + "Child1".into(), + mass_body_spec(1.0, DVec3::ZERO, box_inertia_diag()), + ); + tree.attach(child1, parent, DVec3::new(-1.0, 0.0, 0.0), DMat3::IDENTITY); + (tree, [("Parent".into(), parent), ("Child1".into(), child1)]) +} + /// RUN_10: parent (StructCG spec option B), child1 (Body spec option C), /// child2 (Struct spec option B), child3 (Body spec default). Attach all /// three to parent at init, then runtime-detach child2 at t=1s, print tree @@ -1140,6 +1236,47 @@ fn tier3_sim_attach_mass() { let reference = load_reference("attach_mass_07_mass.out"); validate_run("RUN_07", &tree, &ids, &reference, &mut errors); } + { + // RUN_09: the root parent has a non-identity struct→body orientation + // (optionA), so JEOD reports its composite in the parent body frame. + // Rotate our struct-frame composite by T_parent_this for the parent; + // Child1 is a leaf with identity orientation (compared unrotated). + let (tree, ids) = build_run_09(); + let reference = load_reference("attach_mass_09_mass.out"); + let parent_ref = reference + .bodies + .iter() + .find(|b| b.name == "Parent") + .expect("RUN_09 reference: Parent"); + let child_ref = reference + .bodies + .iter() + .find(|b| b.name == "Child1") + .expect("RUN_09 reference: Child1"); + check_body_rotated( + "RUN_09", + "Parent", + &tree, + ids[0].1, + parent_ref, + parent_option_a(), + TOL_MASS, + TOL_COM, + TOL_INERTIA, + &mut errors, + ); + check_body( + "RUN_09", + "Child1", + &tree, + ids[1].1, + child_ref, + TOL_MASS, + TOL_COM, + TOL_INERTIA, + &mut errors, + ); + } { let (tree, ids) = build_run_10(); let reference = load_reference("attach_mass_10_mass.out"); diff --git a/trick/generate_references.sh b/trick/generate_references.sh index 62ec75a7..d822515f 100755 --- a/trick/generate_references.sh +++ b/trick/generate_references.sh @@ -2035,6 +2035,8 @@ run_attach_mass_group() { "SET_test/RUN_05:attach_mass_05" "SET_test/RUN_06:attach_mass_06" "SET_test/RUN_07:attach_mass_07" + # Non-identity parent struct→body orientation (composite reported in body frame). + "SET_test/RUN_09:attach_mass_09" # Runtime detach (trick.add_read at t=1s, stop at t=2s). "SET_test/RUN_10:attach_mass_10" # Runtime reattach (trick.add_read at t=1s, stop at t=2s). From 08c46d9264fcb72f0cfc7b27e508562566592584 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 25 May 2026 15:09:14 -0700 Subject: [PATCH 2/3] test(tier3): model RUN_09 root orientation faithfully via production accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUN_09 previously sidestepped the non-identity root struct→body orientation: it built the parent's mass tree with identity orientation and reconstructed JEOD's body-frame composite inertia inside a bespoke `check_body_rotated` test helper. That worked numerically (the rotation is exact) but kept the JEOD `print_tree` frame convention in test code and never recorded the real orientation on the body. Move the conversion into production and make the scenario faithful: - Add `MassTree::composite_inertia_in_body`, mirroring the struct→body rotation JEOD's `MassBody::print_tree` (`mass_print_body.cc`) applies to the `C.M.P. Ib tensor` line. A bit-exact no-op for identity-oriented bodies. - Set the parent's real `t_parent_this` (optionA) in `build_run_09`. The struct-frame composite is unaffected (the kernel does not fold the root's own orientation into it), so this also exercises that invariant. - Route `composite_errors` through the accessor, delete `check_body_rotated`, and collapse the RUN_09 block to the standard `validate_run` like every other run. - Fix the module docstring count (17 → 18) flagged in review. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/astrodyn_dynamics/src/mass_body.rs | 24 ++++ .../tests/tier3_sim_attach_mass.rs | 136 +++++------------- 2 files changed, 57 insertions(+), 103 deletions(-) diff --git a/crates/astrodyn_dynamics/src/mass_body.rs b/crates/astrodyn_dynamics/src/mass_body.rs index ac8525c2..25f74dda 100644 --- a/crates/astrodyn_dynamics/src/mass_body.rs +++ b/crates/astrodyn_dynamics/src/mass_body.rs @@ -125,6 +125,30 @@ impl MassTree { &mut self.nodes[id] } + /// Composite inertia of `id` expressed in its **body** frame — the + /// convention JEOD's `MassBody::print_tree` uses for the `C.M.P. Ib tensor` + /// line (`mass_print_body.cc`). + /// + /// [`recompute_composites`](Self::recompute_composites) accumulates every + /// composite in the **structural** frame: the core inertia plus struct-frame + /// parallel-axis terms (`calc_composite_inertia`), and the composite CoM is + /// likewise struct-frame. JEOD stores composites the same way but, when + /// printing, rotates the inertia into the body frame by the composite body + /// point's struct→body transform — the single rotation + /// `I_body = T · I_struct · Tᵀ`. For the common case of a body whose + /// structural and body frames coincide (`t_parent_this == IDENTITY`) this is + /// a bit-exact no-op and returns the struct-frame inertia unchanged; it only + /// does work for a body with a non-identity orientation (e.g. SIM_Apollo's + /// CM/LES/DM/Ascent modules, or `SIM_verif_attach_mass` RUN_09). + /// + /// Mass and CoM are unaffected — JEOD reports both in the struct frame + /// (`C.M.P. CM vector`); only the inertia carries the body-frame rotation. + pub fn composite_inertia_in_body(&self, id: MassBodyId) -> DMat3 { + let body = self.get(id); + let t = body.composite_properties.t_parent_this; + t * body.composite_properties.inertia * t.transpose() + } + /// Parent of the given body, or `None` for a root. pub fn parent(&self, id: MassBodyId) -> Option { self.parent[id] diff --git a/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs b/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs index 4b7bd8be..3a7c8fa9 100644 --- a/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs +++ b/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs @@ -1,6 +1,6 @@ //! Tier 3: SIM_verif_attach_mass — mass tree attach/detach cross-validation. //! -//! Reproduces 17 representative runs from JEOD's `SIM_verif_attach_mass` +//! Reproduces 18 representative runs from JEOD's `SIM_verif_attach_mass` //! (``models/dynamics/body_action/verif/SIM_verif_attach_mass/SET_test/``) //! and cross-validates our `MassTree` composite mass, center of mass, and //! inertia tensor against the `mass.out` files produced by JEOD's @@ -290,14 +290,18 @@ fn child1_spec_b() -> MassProperties { /// error into a single scalar while staying sensitive to any one column /// drifting — it is *not* a strict per-element max delta. fn composite_errors(tree: &MassTree, id: MassBodyId, reference: &PrintedBody) -> (f64, f64, f64) { - let body = tree.get(id); - let comp = &body.composite_properties; + let comp = &tree.get(id).composite_properties; + // JEOD's print_tree reports the composite inertia in the body frame while + // keeping mass/CoM in the struct frame; `composite_inertia_in_body` applies + // the body's struct→body rotation (a bit-exact no-op for the identity- + // orientation runs, the real rotation for RUN_09's oriented parent). + let inertia_body = tree.composite_inertia_in_body(id); let mass_err = (comp.mass - reference.composite_mass).abs(); let com_err = (comp.position - reference.composite_cm).length(); let inertia_err = [ - (comp.inertia.x_axis - reference.composite_inertia.x_axis).length(), - (comp.inertia.y_axis - reference.composite_inertia.y_axis).length(), - (comp.inertia.z_axis - reference.composite_inertia.z_axis).length(), + (inertia_body.x_axis - reference.composite_inertia.x_axis).length(), + (inertia_body.y_axis - reference.composite_inertia.y_axis).length(), + (inertia_body.z_axis - reference.composite_inertia.z_axis).length(), ] .iter() .copied() @@ -342,58 +346,6 @@ fn check_body( ); } -/// Like [`check_body`] but compares the composite **inertia** rotated into the -/// body frame by `t_parent_this` (struct→body). JEOD's mass print uses a mixed -/// convention for a body with a non-identity struct→body orientation: the -/// composite CoM (`C.M.P. CM vector`) is in the **struct** frame (so it matches -/// our `recompute_composites` directly), but the inertia (`C.M.P. Ib tensor`) -/// is in the **body** frame. Our composite inertia is in the struct frame, and -/// the two are related by the single rotation `I_body = T·I_struct·Tᵀ` (frame -/// covariance). Mass and CoM are compared unrotated; only the inertia rotates. -#[allow(clippy::too_many_arguments)] -fn check_body_rotated( - run: &str, - body_label: &str, - tree: &MassTree, - id: MassBodyId, - reference: &PrintedBody, - t_parent_this: DMat3, - tol_mass: f64, - tol_com: f64, - tol_inertia: f64, - max_errors: &mut MaxErrors, -) { - let comp = &tree.get(id).composite_properties; - let inertia_body = t_parent_this * comp.inertia * t_parent_this.transpose(); - - let mass_err = (comp.mass - reference.composite_mass).abs(); - let com_err = (comp.position - reference.composite_cm).length(); - let inertia_err = [ - (inertia_body.x_axis - reference.composite_inertia.x_axis).length(), - (inertia_body.y_axis - reference.composite_inertia.y_axis).length(), - (inertia_body.z_axis - reference.composite_inertia.z_axis).length(), - ] - .into_iter() - .fold(0.0_f64, f64::max); - - max_errors.mass = max_errors.mass.max(mass_err); - max_errors.com = max_errors.com.max(com_err); - max_errors.inertia = max_errors.inertia.max(inertia_err); - - assert!( - mass_err < tol_mass, - "[{run}:{body_label}] composite mass diff {mass_err:.3e} >= tol {tol_mass:.3e}" - ); - assert!( - com_err < tol_com, - "[{run}:{body_label}] composite CoM diff {com_err:.3e} >= tol {tol_com:.3e}" - ); - assert!( - inertia_err < tol_inertia, - "[{run}:{body_label}] composite inertia (body-frame) diff {inertia_err:.3e} >= tol {tol_inertia:.3e}" - ); -} - struct MaxErrors { mass: f64, com: f64, @@ -548,8 +500,9 @@ fn build_run_07() -> (MassTree, [(String, MassBodyId); 2]) { /// Parent struct→body orientation from `parent_mass_orientation_optionA` /// (`Modified_data/parent_mass.py`), JEOD `T_parent_this` (row-major in the -/// `.py`, transposed into glam column-major here). Used by RUN_09: JEOD -/// reports the root's composite in this body frame. +/// `.py`, transposed into glam column-major here). Used by RUN_09 as the +/// parent's [`MassProperties::t_parent_this`]; JEOD reports the root's +/// composite in this body frame. #[allow( clippy::approx_constant, reason = "0.70710678118655 is the verbatim optionA matrix value from JEOD's \ @@ -567,17 +520,26 @@ fn parent_option_a() -> DMat3 { /// RUN_09: parent (`StructCG` option B inertia + **non-identity struct→body /// orientation** `optionA`) + child1 (`Body` option C) attached at offset -/// [-1, 0, 0], identity attach. The struct-frame composite our -/// `recompute_composites` produces is frame-covariant with JEOD's: JEOD -/// reports the root composite in the parent **body** frame, so the validation -/// rotates our struct-frame composite by `T_parent_this` (see the RUN_09 -/// block in `tier3_sim_attach_mass`). The parent orientation does not enter -/// the struct-frame composite, so the tree is built without it. +/// [-1, 0, 0], identity attach. First run where the **root body's own** +/// struct→body transform is non-identity. +/// +/// `recompute_composites` accumulates the composite in the struct frame and +/// does not fold in the root's own orientation, so the struct-frame composite +/// is identical whether or not `t_parent_this` is set. We set it anyway — it +/// is the faithful scenario, and it exercises that the kernel correctly leaves +/// the struct-frame composite untouched by the root orientation. JEOD reports +/// the composite inertia in the parent body frame; `MassTree::composite_inertia_in_body` +/// applies that struct→body rotation in the comparison (see `composite_errors`). +/// +/// Note the `StructCG` spec defines the inertia in struct axes, so the core +/// inertia stays struct-frame here — exactly what the struct-frame composite +/// accumulation consumes; the body-frame view is reconstructed on read. fn build_run_09() -> (MassTree, [(String, MassBodyId); 2]) { let mut tree = MassTree::new(); let parent = tree.add_root( "Parent".into(), - mass_struct_cg_spec(1.0, DVec3::ZERO, box_inertia_diag()), + mass_struct_cg_spec(1.0, DVec3::ZERO, box_inertia_diag()) + .with_t_parent_this(parent_option_a()), ); let child1 = tree.add_body( "Child1".into(), @@ -1238,44 +1200,12 @@ fn tier3_sim_attach_mass() { } { // RUN_09: the root parent has a non-identity struct→body orientation - // (optionA), so JEOD reports its composite in the parent body frame. - // Rotate our struct-frame composite by T_parent_this for the parent; - // Child1 is a leaf with identity orientation (compared unrotated). + // (optionA), so JEOD reports its composite inertia in the parent body + // frame. `check_body` (via `composite_inertia_in_body`) applies that + // struct→body rotation; Child1 is identity-oriented (no-op rotation). let (tree, ids) = build_run_09(); let reference = load_reference("attach_mass_09_mass.out"); - let parent_ref = reference - .bodies - .iter() - .find(|b| b.name == "Parent") - .expect("RUN_09 reference: Parent"); - let child_ref = reference - .bodies - .iter() - .find(|b| b.name == "Child1") - .expect("RUN_09 reference: Child1"); - check_body_rotated( - "RUN_09", - "Parent", - &tree, - ids[0].1, - parent_ref, - parent_option_a(), - TOL_MASS, - TOL_COM, - TOL_INERTIA, - &mut errors, - ); - check_body( - "RUN_09", - "Child1", - &tree, - ids[1].1, - child_ref, - TOL_MASS, - TOL_COM, - TOL_INERTIA, - &mut errors, - ); + validate_run("RUN_09", &tree, &ids, &reference, &mut errors); } { let (tree, ids) = build_run_10(); From 48fd667584193c781b3629a6ce0786ce7714ed5c Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 25 May 2026 19:29:21 -0700 Subject: [PATCH 3/3] fix(mass): compute composite inertia in the body frame (JEOD-faithful) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JEOD stores `core_properties.inertia` in the body frame (`mass_properties_init.cc:103/119` rotate the StructCG/Struct specs struct→body at init) and computes the composite in the body frame — "the core and composite masses share a common body frame" (`mass_calc_composite_inertia.cc`), with the cm-to-cm offsets rotated by `composite_properties.T_parent_this` (`mass_update.cc:101/107`). Our `calc_composite_inertia` accumulated in the struct frame instead, which is correct only when a body's own struct→body orientation is identity. The divergence was masked everywhere: the sole non-identity orientation in the test suite is Apollo's `yaw_180`, which is inertia-invariant on its diagonal tensors. RUN_09 (the first general, non-180° orientation) exposed it — and previously papered over it with a test-only rotation of the struct-frame composite, which only matched because its core inertia was stored struct-frame (contrary to JEOD). Rewrite `calc_composite_inertia` to compute in the body frame: body-frame core unrotated, parallel-axis offsets rotated struct→body by `t_parent_this`, each child rotated child-body→parent-body (`r = T·Sᵀ·T_childᵀ`). Reduces exactly to the prior struct-frame code when every `t_parent_this` is identity, and is bit-identical for Apollo's yaw_180+diagonal case. No consumer changes: `composite_properties.inertia` now genuinely holds the body-frame tensor that `sync_body_mass_from_tree` and the rotational integrator already expect. - RUN_09 rebuilt with a body-frame core (the StructCG init rotation) and compares `composite_properties.inertia` directly against JEOD's `C.M.P. Ib tensor`; the test-only rotation accessor is removed. - New `runner_attach_composite_inertia_is_body_frame` drives a general orientation through the full `attach → sync_body_mass_from_tree → integrate` pipeline, cross-checking the body-frame composite via an independent frame-invariance derivation (+ sensitivity guard + a torque-free propagation smoke). - Catalog MA.25 (composite inertia in the body frame) + `// JEOD_INV` source tag. Verified: tier3_sim_attach_mass (RUN_09 direct), full dynamics + attach/detach + Apollo trajectory suite (365 tests), the new analytical test, invariant_coverage, and the mass/attach/detach/Apollo/dyncomp bevy↔runner parity wrappers (109 tests) — all green; fmt/clippy/rustdoc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/astrodyn_dynamics/src/mass_body.rs | 92 ++++++------ .../tests/runner_attach_detach_momentum.rs | 140 ++++++++++++++++++ .../tests/tier3_sim_attach_mass.rs | 59 ++++---- docs/JEOD_invariants.md | 1 + 4 files changed, 217 insertions(+), 75 deletions(-) diff --git a/crates/astrodyn_dynamics/src/mass_body.rs b/crates/astrodyn_dynamics/src/mass_body.rs index 25f74dda..9eaa2cff 100644 --- a/crates/astrodyn_dynamics/src/mass_body.rs +++ b/crates/astrodyn_dynamics/src/mass_body.rs @@ -125,30 +125,6 @@ impl MassTree { &mut self.nodes[id] } - /// Composite inertia of `id` expressed in its **body** frame — the - /// convention JEOD's `MassBody::print_tree` uses for the `C.M.P. Ib tensor` - /// line (`mass_print_body.cc`). - /// - /// [`recompute_composites`](Self::recompute_composites) accumulates every - /// composite in the **structural** frame: the core inertia plus struct-frame - /// parallel-axis terms (`calc_composite_inertia`), and the composite CoM is - /// likewise struct-frame. JEOD stores composites the same way but, when - /// printing, rotates the inertia into the body frame by the composite body - /// point's struct→body transform — the single rotation - /// `I_body = T · I_struct · Tᵀ`. For the common case of a body whose - /// structural and body frames coincide (`t_parent_this == IDENTITY`) this is - /// a bit-exact no-op and returns the struct-frame inertia unchanged; it only - /// does work for a body with a non-identity orientation (e.g. SIM_Apollo's - /// CM/LES/DM/Ascent modules, or `SIM_verif_attach_mass` RUN_09). - /// - /// Mass and CoM are unaffected — JEOD reports both in the struct frame - /// (`C.M.P. CM vector`); only the inertia carries the body-frame rotation. - pub fn composite_inertia_in_body(&self, id: MassBodyId) -> DMat3 { - let body = self.get(id); - let t = body.composite_properties.t_parent_this; - t * body.composite_properties.inertia * t.transpose() - } - /// Parent of the given body, or `None` for a root. pub fn parent(&self, id: MassBodyId) -> Option { self.parent[id] @@ -986,34 +962,60 @@ impl MassTree { } } - /// Composite inertia — port of JEOD `mass_calc_composite_inertia.cc`. + /// Composite inertia — port of JEOD `mass_calc_composite_inertia.cc` + /// (with the body-frame offset transforms of `mass_update.cc:99-107`). /// - /// Starts with the core body's inertia shifted to the composite CoM via - /// the parallel axis theorem, then adds each child's composite inertia - /// (rotated to this body's structural frame) plus the child's parallel - /// axis contribution. + /// Computed in **this body's body frame**: JEOD's comment is "the core and + /// composite masses share a common body frame," and + /// `composite_properties.T_parent_this == core_properties.T_parent_this` + /// (`mass.cc:203`). The core inertia is already body-frame (the + /// `StructCG`/`Struct` init specs are rotated struct→body in + /// `mass_properties_init.cc:103/119`), so it enters unrotated; the + /// parallel-axis offsets are differences of struct-frame CoMs rotated into + /// the body frame by `T = t_parent_this` (`mass_update.cc:101/107`). Each + /// child's composite inertia, expressed in the *child's* body frame, is + /// rotated into this body frame by `composite_wrt_pbdy.T_parent_this` + /// (`mass_attach.cc:519`); for a direct child the body→body rotation is + /// `r = T · Sᵀ · T_childᵀ` (`S = structure_point.t_parent_this`, + /// parent-struct→child-struct; `T_child = child composite t_parent_this`), + /// and the rotated tensor is `r · I · rᵀ`. + /// + /// Reduces to the pure struct-frame computation (`Sᵀ · I · S`, struct + /// offsets) when every `t_parent_this` is identity — the common case and + /// every attach-mass RUN except RUN_09. For `yaw_180` + diagonal inertia + /// (Apollo) the body-frame result is bit-identical to the struct-frame one. fn calc_composite_inertia(&mut self, id: MassBodyId) { let cm = self.nodes[id].composite_properties.position; - - // Core contribution: inertia + point-mass shift from core CoM to - // composite CoM (JEOD mass_calc_composite_inertia.cc lines 61-64). + // JEOD_INV: MA.25 — composite inertia in the body frame: body-frame core + // unrotated, offsets rotated struct→body by `t_parent_this`, children + // rotated child-body→parent-body. + // Struct→body for this composite body (shares the core's body frame). + let t_parent = self.nodes[id].composite_properties.t_parent_this; + + // Core contribution: body-frame inertia (unrotated) plus the point-mass + // shift from the core CoM to the composite CoM, the offset rotated + // struct→body (JEOD mass_update.cc:107 + mass_calc_composite_inertia.cc:61-64). let core = &self.nodes[id].core_properties; - let core_offset = core.position - cm; - let mut composite_inertia = core.inertia + point_mass_inertia(core.mass, core_offset); + let core_offset_body = t_parent * (core.position - cm); + let mut composite_inertia = core.inertia + point_mass_inertia(core.mass, core_offset_body); - // Child contributions (lines 67-84). + // Child contributions (JEOD mass_calc_composite_inertia.cc:67-84). for &cid in &self.children[id] { let child = &self.nodes[cid]; - let child_offset = child.composite_wrt_pstr.position - cm; - - // Rotate child's composite inertia from child struct frame to - // parent struct frame: T^T * I_child * T - // This is JEOD's transpose_transform_matrix. - let t = child.structure_point.t_parent_this; - let rotated_inertia = t.transpose() * child.composite_properties.inertia * t; - - composite_inertia += - rotated_inertia + point_mass_inertia(child.composite_properties.mass, child_offset); + // Offset in this body's body frame (JEOD mass_update.cc:101). + let child_offset_body = t_parent * (child.composite_wrt_pstr.position - cm); + + // Rotate the child's composite inertia from the child's body frame + // into this body's body frame: `r = child_body → parent_body + // = T · Sᵀ · T_childᵀ`, rotated tensor `r · I · rᵀ` (JEOD's + // `transpose_transform_matrix(composite_wrt_pbdy.T_parent_this, …)`). + let s = child.structure_point.t_parent_this; + let t_child = child.composite_properties.t_parent_this; + let r = t_parent * s.transpose() * t_child.transpose(); + let rotated_inertia = r * child.composite_properties.inertia * r.transpose(); + + composite_inertia += rotated_inertia + + point_mass_inertia(child.composite_properties.mass, child_offset_body); } self.nodes[id].composite_properties.inertia = composite_inertia; diff --git a/crates/astrodyn_runner/tests/runner_attach_detach_momentum.rs b/crates/astrodyn_runner/tests/runner_attach_detach_momentum.rs index 8641bbed..2b8f6810 100644 --- a/crates/astrodyn_runner/tests/runner_attach_detach_momentum.rs +++ b/crates/astrodyn_runner/tests/runner_attach_detach_momentum.rs @@ -1193,3 +1193,143 @@ fn from_builder_preserves_attached_bodies_initial_state() { got {parent_composite_mass}, expected {total_core_mass}" ); } + +/// Inertia of a point mass `m` at offset `r` (parallel-axis term), +/// computed independently of the kernel's `point_mass_inertia`. +fn point_mass(m: f64, r: DVec3) -> DMat3 { + let outer = DMat3::from_cols(r * r.x, r * r.y, r * r.z); + DMat3::from_diagonal(DVec3::splat(r.length_squared())) * m - outer * m +} + +/// Max per-column L2 distance between two matrices. +fn mat3_max_col_diff(a: DMat3, b: DMat3) -> f64 { + let d = a - b; + [d.x_axis, d.y_axis, d.z_axis] + .into_iter() + .map(|c| c.length()) + .fold(0.0_f64, f64::max) +} + +/// A composite whose **parent has a non-identity, non-180° struct→body +/// orientation** must carry its inertia in the body frame end-to-end: +/// `recompute_composites` builds the composite in the parent body frame and +/// `sync_body_mass_from_tree` hands it to the integrated body unchanged, so the +/// rotational integrator (Euler's equation, body-frame `inertia · ω`) consumes +/// a body-frame tensor. +/// +/// `tier3_sim_attach_mass::RUN_09` cross-validates the body-frame composite +/// *value* against JEOD's `mass.out`. This test guards the full Simulation +/// `attach → sync_body_mass_from_tree → body.mass → integrate` pipeline for a +/// **general** orientation — the case every existing trajectory scenario is +/// blind to, because the only non-identity orientations in the suite are +/// Apollo's `yaw_180`, which is inertia-invariant on its diagonal tensors. +/// +/// The reference is derived independently via the frame-invariance identity: +/// the same composite built in the **struct** frame (core rotated body→struct +/// by `Sᵀ·I·S`, struct-frame parallel-axis offsets) and conjugated by `S` +/// equals the body-frame composite. A sensitivity guard asserts the pipeline +/// result is *not* the unrotated struct composite, so a regression that dropped +/// the body-frame rotation fails loudly rather than silently. +#[test] +fn runner_attach_composite_inertia_is_body_frame() { + // General struct→body rotation (0.5 rad about a tilted axis): a yaw_180 or + // identity would hide the struct/body distinction this test exists to pin. + let s = DMat3::from_axis_angle(DVec3::new(1.0, 2.0, 3.0).normalize(), 0.5); + + // Parent: asymmetric inertia in the BODY frame (a StructCG init would have + // already rotated it to body), with the non-identity struct→body transform. + let parent_body_inertia = DMat3::from_diagonal(DVec3::new(150.0, 200.0, 250.0)); + let parent_mass = SimMassProperties::with_inertia(2.0, parent_body_inertia, DVec3::ZERO) + .with_t_parent_this(s); + // Child: identity orientation, body-frame inertia, attached at an off-axis + // struct offset so the composite is genuinely asymmetric (off-diagonal). + let child_body_inertia = DMat3::from_diagonal(DVec3::new(40.0, 50.0, 60.0)); + let child_mass = SimMassProperties::with_inertia(1.0, child_body_inertia, DVec3::ZERO); + let offset = DVec3::new(2.0, 1.0, -0.5); + + let omega0 = DVec3::new(0.05, 0.02, -0.01); + let parent_trans = TranslationalState { + position: DVec3::new(7e6, 0.0, 0.0), + velocity: DVec3::new(0.0, 7600.0, 0.0), + }; + let parent_rot = Some(RotationalState { + quaternion: JeodQuat::identity(), + ang_vel_body: omega0, + }); + let child_trans = TranslationalState { + position: DVec3::new(7e6, 0.0, 0.0) + offset, + velocity: DVec3::new(0.0, 7600.0, 0.0), + }; + let child_rot = Some(RotationalState { + quaternion: JeodQuat::identity(), + ang_vel_body: omega0, + }); + + let (mut sim, parent_idx, child_idx, _pid, _cid) = build_pair( + 0.5, + parent_mass, + parent_trans, + parent_rot, + child_mass, + child_trans, + child_rot, + ); + sim.attach(child_idx, parent_idx, offset, DMat3::IDENTITY); + + // Pipeline result: the integrated parent's composite inertia, body-frame. + let pipeline_body = sim + .body_mass(parent_idx) + .expect("parent mass after attach") + .inertia + .as_dmat3(); + + // Independent reference: build the composite in the struct frame and + // conjugate by S. Composite CoM (struct) = m_c/(m_p+m_c) along the offset. + let cm_struct = offset * (child_mass.mass / (parent_mass.mass + child_mass.mass)); + let parent_core_struct = s.transpose() * parent_body_inertia * s; + let struct_composite = parent_core_struct + + point_mass(parent_mass.mass, -cm_struct) + + child_body_inertia + + point_mass(child_mass.mass, offset - cm_struct); + let expected_body = s * struct_composite * s.transpose(); + + assert!( + mat3_max_col_diff(pipeline_body, expected_body) < 1e-9, + "pipeline composite inertia must be the body-frame composite \ + (S·I_struct·Sᵀ); got {pipeline_body:?}, expected {expected_body:?}" + ); + // Sensitivity: for this general S the body-frame composite differs + // substantially from the unrotated struct composite — proves the + // struct→body rotation actually happened along the full pipeline. + assert!( + mat3_max_col_diff(pipeline_body, struct_composite) > 1.0, + "body-frame composite must differ from the struct-frame composite for a \ + general orientation (else the body-frame rotation was dropped)" + ); + + // Smoke: the integrator propagates torque-free (mu = 0, no external torque) + // with the body-frame inertia. Body-frame angular momentum magnitude + // |I·ω| is conserved for a torque-free rigid body. + let omega_after_attach = sim + .body(parent_idx) + .rot + .expect("parent rot after attach") + .ang_vel_body + .raw_si(); + let h0 = (pipeline_body * omega_after_attach).length(); + for _ in 0..200 { + sim.step().expect("torque-free step"); + } + let omega_final = sim + .body(parent_idx) + .rot + .expect("parent rot after stepping") + .ang_vel_body + .raw_si(); + let hf = (pipeline_body * omega_final).length(); + assert!( + omega_final.is_finite() && (hf - h0).abs() <= 1e-6 * h0.max(1.0), + "torque-free body-frame angular momentum |I·ω| must be conserved: \ + |H0|={h0}, |Hf|={hf}" + ); +} diff --git a/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs b/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs index 3a7c8fa9..9129b4ce 100644 --- a/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs +++ b/crates/astrodyn_verif_jeod/tests/tier3_sim_attach_mass.rs @@ -32,8 +32,9 @@ //! - RUN_111: named-point + offset attach, runtime reattach of child2 //! - RUN_09: non-identity parent struct→body orientation. JEOD reports the //! composite inertia (`C.M.P. Ib`) in the parent **body** frame while the -//! composite CoM stays in the struct frame; the test rotates our struct-frame -//! composite inertia by `T_parent_this` (`I_body = T·I·Tᵀ`) for the comparison. +//! composite CoM stays in the struct frame; `recompute_composites` produces +//! the composite in the body frame (the parent's `StructCG` inertia is rotated +//! struct→body at init), so both compare directly against `mass.out`. //! //! Note: RUN_08/RUN_108 (a child attached to two parents in different body //! actions) and RUN_109 (named-point attach combined with the non-identity @@ -291,17 +292,15 @@ fn child1_spec_b() -> MassProperties { /// drifting — it is *not* a strict per-element max delta. fn composite_errors(tree: &MassTree, id: MassBodyId, reference: &PrintedBody) -> (f64, f64, f64) { let comp = &tree.get(id).composite_properties; - // JEOD's print_tree reports the composite inertia in the body frame while - // keeping mass/CoM in the struct frame; `composite_inertia_in_body` applies - // the body's struct→body rotation (a bit-exact no-op for the identity- - // orientation runs, the real rotation for RUN_09's oriented parent). - let inertia_body = tree.composite_inertia_in_body(id); + // `recompute_composites` stores the composite inertia in the body frame + // (JEOD's `C.M.P. Ib tensor` convention) and the CoM in the struct frame + // (`C.M.P. CM vector`), so both compare directly against `mass.out`. let mass_err = (comp.mass - reference.composite_mass).abs(); let com_err = (comp.position - reference.composite_cm).length(); let inertia_err = [ - (inertia_body.x_axis - reference.composite_inertia.x_axis).length(), - (inertia_body.y_axis - reference.composite_inertia.y_axis).length(), - (inertia_body.z_axis - reference.composite_inertia.z_axis).length(), + (comp.inertia.x_axis - reference.composite_inertia.x_axis).length(), + (comp.inertia.y_axis - reference.composite_inertia.y_axis).length(), + (comp.inertia.z_axis - reference.composite_inertia.z_axis).length(), ] .iter() .copied() @@ -500,9 +499,9 @@ fn build_run_07() -> (MassTree, [(String, MassBodyId); 2]) { /// Parent struct→body orientation from `parent_mass_orientation_optionA` /// (`Modified_data/parent_mass.py`), JEOD `T_parent_this` (row-major in the -/// `.py`, transposed into glam column-major here). Used by RUN_09 as the -/// parent's [`MassProperties::t_parent_this`]; JEOD reports the root's -/// composite in this body frame. +/// `.py`, transposed into glam column-major here). Used by RUN_09 both as the +/// parent's [`MassProperties::t_parent_this`] and to rotate its `StructCG` +/// inertia struct→body; JEOD reports the root's composite in this body frame. #[allow( clippy::approx_constant, reason = "0.70710678118655 is the verbatim optionA matrix value from JEOD's \ @@ -521,25 +520,25 @@ fn parent_option_a() -> DMat3 { /// RUN_09: parent (`StructCG` option B inertia + **non-identity struct→body /// orientation** `optionA`) + child1 (`Body` option C) attached at offset /// [-1, 0, 0], identity attach. First run where the **root body's own** -/// struct→body transform is non-identity. +/// struct→body transform is non-identity, so it is the case that distinguishes +/// the struct and body frames in `recompute_composites`. /// -/// `recompute_composites` accumulates the composite in the struct frame and -/// does not fold in the root's own orientation, so the struct-frame composite -/// is identical whether or not `t_parent_this` is set. We set it anyway — it -/// is the faithful scenario, and it exercises that the kernel correctly leaves -/// the struct-frame composite untouched by the root orientation. JEOD reports -/// the composite inertia in the parent body frame; `MassTree::composite_inertia_in_body` -/// applies that struct→body rotation in the comparison (see `composite_errors`). -/// -/// Note the `StructCG` spec defines the inertia in struct axes, so the core -/// inertia stays struct-frame here — exactly what the struct-frame composite -/// accumulation consumes; the body-frame view is reconstructed on read. +/// The `StructCG` spec gives the inertia in struct axes; JEOD's init +/// (`mass_properties_init.cc:103`) rotates it struct→body via `T_parent_this`, +/// so the stored **core** inertia is `T · box_diag · Tᵀ` (= JEOD's `M.P. Ib` +/// for the oriented parent). `recompute_composites` then accumulates the +/// composite in the parent body frame, so `composite_properties.inertia` +/// matches JEOD's `C.M.P. Ib tensor` directly (the CoM stays struct-frame, +/// matching `C.M.P. CM vector`). fn build_run_09() -> (MassTree, [(String, MassBodyId); 2]) { let mut tree = MassTree::new(); + // StructCG init: rotate the struct-axes option-B inertia into the body + // frame via the parent's struct→body transform (JEOD mass_properties_init.cc). + let t = parent_option_a(); + let parent_core_body = t * box_inertia_diag() * t.transpose(); let parent = tree.add_root( "Parent".into(), - mass_struct_cg_spec(1.0, DVec3::ZERO, box_inertia_diag()) - .with_t_parent_this(parent_option_a()), + MassProperties::with_inertia(1.0, parent_core_body, DVec3::ZERO).with_t_parent_this(t), ); let child1 = tree.add_body( "Child1".into(), @@ -1200,9 +1199,9 @@ fn tier3_sim_attach_mass() { } { // RUN_09: the root parent has a non-identity struct→body orientation - // (optionA), so JEOD reports its composite inertia in the parent body - // frame. `check_body` (via `composite_inertia_in_body`) applies that - // struct→body rotation; Child1 is identity-oriented (no-op rotation). + // (optionA), so `recompute_composites` produces the composite inertia + // in the parent body frame (JEOD's `C.M.P. Ib` convention) — compared + // directly. Child1 is identity-oriented. let (tree, ids) = build_run_09(); let reference = load_reference("attach_mass_09_mass.out"); validate_run("RUN_09", &tree, &ids, &reference, &mut errors); diff --git a/docs/JEOD_invariants.md b/docs/JEOD_invariants.md index 172919ea..2c46b5d3 100644 --- a/docs/JEOD_invariants.md +++ b/docs/JEOD_invariants.md @@ -481,6 +481,7 @@ MA.10–MA.21 gap fill. Source: `../jeod/models/dynamics/mass/src/`. | MA.22 | Detach-on-drop is safe: destroying a still-attached body must not leave dangling parent pointers (`mass_body.cc:94-108` pattern — `mass_children.remove()` is resilient) | structural | structural | n/a (Rust's ownership model: references don't outlive owners; `MassBodyStore` is an arena of values, so a freed `MassBodyId` cannot produce a dangling pointer) | | MA.23 | Composite-property reads at detach see the live (pre-detach) composite, not a downstream cache (JEOD reads `parent->mass.composite_properties` straight from `MassBody` in `mass_attach.cc::detach_update_properties`; the value member is the canonical store, no cache layer can shadow it). | structural | consistency | structural (`astrodyn_bevy::staging_system` reads `parent_pre_composite_props` from the `MassTreeR` arena via `tree.get(tree_root_id).composite_properties` — the same arena `Simulation::detach_subtree` reads — instead of from the entity's `MassPropertiesC`, which the ECS-tree fast path in `composite_mass_system` may have just reverted to its `CoreMassPropertiesC` cache during the same tick. The arena is the canonical store by construction (no parallel cache path to choose between at the read site), so both adapters key the parent-side CoM-shift formula off the same pre-detach composite, keeping post-detach parent state bit-identical. No runtime guard exists to drive — the invariant is the read-site selection.) | | MA.24 | A pending mass-tree mutation message (JEOD's `MassBody::add_mass_body` / `MassBody::detach` invocations triggered by `body_attach`/`body_detach` actions in `mass_attach.cc`) presupposes a live mass-tree arena to mutate. JEOD's structural guarantee: `MassBody` is a value member of `DynBody`, so the arena is always present at the call site — there is no "no arena" branch. Our adapters keep that invariant by routing every attach/detach through a resource (`MassTreeR` for the Bevy adapter; `Simulation::mass_tree` for the runner). A staged event observed without the arena is therefore a misconfiguration: the mutation would silently drop and the targeted body would propagate unattached, which is the "wrong physics that still runs" failure the Fail Loudly rule forbids. | structural | runtime | enforced (`astrodyn_bevy::staging_system` in `crates/astrodyn_bevy/src/systems/integration.rs` panics with a diagnostic that names both fix paths — insert `MassTreeR(MassTree::new())` directly or call `SimulationBuilder::register_in_mass_tree` + `SimulationBuilderBevyExt::populate_app` — when an `AttachEvent` or `DetachEvent` arrives while `MassTreeR` is absent. Runner side: `Simulation::mass_tree` is a non-`Option` value member, so the analogous case is unreachable by construction.) | +| MA.25 | Composite inertia expressed in the body frame (core and composite share a common body frame) | structural | consistency | structural (`mass_body.rs` `calc_composite_inertia` computes the composite in the body frame per JEOD `mass_calc_composite_inertia.cc` + `mass_update.cc:99-107`: the body-frame core inertia enters unrotated, the struct-frame CoM offsets are rotated struct→body by `composite_properties.t_parent_this`, and each child's composite is rotated child-body→parent-body. Reduces to the struct-frame sum (`Sᵀ·I·S`, struct offsets) when every `t_parent_this` is identity. Cross-validated against JEOD `mass.out` by `tier3_sim_attach_mass` RUN_09 and end-to-end through the Simulation `attach → sync_body_mass_from_tree → integrate` pipeline by `runner_attach_composite_inertia_is_body_frame`) | ## Section DM: DynManager (gap fill)