diff --git a/crates/astrodyn_dynamics/src/mass_body.rs b/crates/astrodyn_dynamics/src/mass_body.rs index ac8525c2..9eaa2cff 100644 --- a/crates/astrodyn_dynamics/src/mass_body.rs +++ b/crates/astrodyn_dynamics/src/mass_body.rs @@ -962,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/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..9129b4ce 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 @@ -30,11 +30,15 @@ //! - 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; `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_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 @@ -287,8 +291,10 @@ 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; + // `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 = [ @@ -491,6 +497,57 @@ 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 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 \ + 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. First run where the **root body's own** +/// struct→body transform is non-identity, so it is the case that distinguishes +/// the struct and body frames in `recompute_composites`. +/// +/// 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(), + MassProperties::with_inertia(1.0, parent_core_body, DVec3::ZERO).with_t_parent_this(t), + ); + 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 +1197,15 @@ 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 `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); + } { let (tree, ids) = build_run_10(); let reference = load_reference("attach_mass_10_mass.out"); 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) 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).