use bevy::prelude::*;
use glam::{DMat3, DVec3};
use std::collections::{HashMap, HashSet};
use astrodyn::{aggregate_wrenches_via_storage, EdgeGeometry, MassStorage, Vec3Ext, Wrench};
use crate::components::{
Abm4StateC, DynamicsConfigC, FrameDerivativesC, GaussJacksonStateC, GravityAccelerationC,
KinematicChildC, MassChildOf, MassPropertiesC, RotationalStateC, StructuralTransformC,
TotalForceC,
};
use crate::mass_tree::MassTreeView;
fn reset_multi_step_history(
entity: Entity,
gj_q: &mut Query<&mut GaussJacksonStateC>,
abm_q: &mut Query<&mut Abm4StateC>,
) {
let mut gj = gj_q.get_mut(entity).ok();
let mut abm = abm_q.get_mut(entity).ok();
astrodyn::reset_integrators(
gj.as_mut().map(|g| g.0.inner_mut()),
abm.as_mut().map(|a| a.0.inner_mut()),
);
}
fn t_inertial_struct(
entity: Entity,
rot_q: &Query<&RotationalStateC>,
struct_q: &Query<&StructuralTransformC>,
) -> DMat3 {
let t_inertial_body = rot_q.get(entity).map_or(DMat3::IDENTITY, |r| {
r.0.q_inertial_body
.as_witness()
.left_quat_to_transformation()
});
let t_struct_body = struct_q
.get(entity)
.map_or(DMat3::IDENTITY, |s| *s.0.matrix_ref());
t_struct_body.transpose() * t_inertial_body
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn wrench_aggregation_system(
mut commands: Commands,
mass_q: Query<(Entity, &MassPropertiesC)>,
parents_q: Query<(Entity, &MassChildOf)>,
kinematic_q: Query<Entity, With<KinematicChildC>>,
names_q: Query<&Name>,
rot_q: Query<&RotationalStateC>,
struct_q: Query<&StructuralTransformC>,
grav_q: Query<&GravityAccelerationC>,
dyn_cfg_q: Query<&DynamicsConfigC>,
mut totals_q: Query<(Entity, &mut TotalForceC)>,
mut derivs_q: Query<(Entity, &mut FrameDerivativesC)>,
mut gj_q: Query<&mut GaussJacksonStateC>,
mut abm_q: Query<&mut Abm4StateC>,
) {
if parents_q.is_empty() {
for entity in kinematic_q.iter() {
reset_multi_step_history(entity, &mut gj_q, &mut abm_q);
commands.entity(entity).remove::<KinematicChildC>();
}
return;
}
let view = MassTreeView::from_queries(&mass_q, &parents_q, &names_q);
if view.is_empty() {
return;
}
fn rot_is_non_identity(entity: Entity, rot_q: &Query<&RotationalStateC>) -> bool {
rot_q.get(entity).is_ok_and(|r| {
let t =
r.0.q_inertial_body
.as_witness()
.left_quat_to_transformation();
!t.abs_diff_eq(DMat3::IDENTITY, 1e-12)
})
}
for root in view.iter_roots() {
let mut stack: Vec<Entity> = vec![root];
let mut members: Vec<Entity> = Vec::new();
let mut chain_has_rotation = false;
while let Some(node) = stack.pop() {
members.push(node);
if !chain_has_rotation && rot_is_non_identity(node, &rot_q) {
chain_has_rotation = true;
}
if !chain_has_rotation {
if let Ok((_, link)) = parents_q.get(node) {
if !link.t_parent_child.abs_diff_eq(DMat3::IDENTITY, 1e-12) {
chain_has_rotation = true;
}
}
}
for &child in MassStorage::children(&view, node) {
stack.push(child);
}
}
if chain_has_rotation {
for entity in &members {
assert!(
rot_q.get(*entity).is_ok(),
"wrench_aggregation_system: entity {entity:?} is in a `MassChildOf` chain \
rooted at {root:?} that contains a non-identity rotation, but it has no \
`RotationalStateC`. This is a scheduling-contract violation: \
`propagate_state_from_root_system` is supposed to run earlier in \
the tick (pre-`AstrodynSet::Environment`) and derive every kinematic \
child's `RotationalStateC` from its parent's attitude composed \
with `MassChildOf.t_parent_child`. If this assertion is firing, \
either:\n \
1. The propagation system was unscheduled (custom `App` build that \
doesn't include the `AstrodynPlugin`'s `FixedUpdate` system set, or a test \
fixture that runs `wrench_aggregation_system` directly without first \
running `propagate_state_from_root_system` after `composite_mass_system`). \
Add `propagate_state_from_root_system.before(wrench_aggregation_system)` \
to the schedule.\n \
2. Mission code bypassed the propagation pipeline (one-shot system that \
mutates `MassChildOf` and then runs the wrench walk in the same frame \
without calling propagation). Run propagation between the topology edit \
and the wrench walk.\n \
This guard is defense-in-depth — the production schedule guarantees \
`RotationalStateC` is populated by the time aggregation reads it."
);
}
}
}
let mut edges: HashMap<Entity, EdgeGeometry> = HashMap::new();
for (child, link) in parents_q.iter() {
let parent = link.parent;
let parent_composite_pos = mass_q
.get(parent)
.map(|(_, m)| m.0.center_of_mass.raw_si())
.unwrap_or(DVec3::ZERO);
let child_composite_pos = mass_q
.get(child)
.map(|(_, m)| m.0.center_of_mass.raw_si())
.unwrap_or(DVec3::ZERO);
let child_pos_in_parent_struct =
link.t_parent_child.transpose() * child_composite_pos + link.offset;
let pcm_to_ccm = child_pos_in_parent_struct - parent_composite_pos;
edges.insert(
child,
EdgeGeometry {
pcm_to_ccm,
t_parent_child: link.t_parent_child,
},
);
}
let mut wrenches: HashMap<Entity, Wrench> = HashMap::new();
for (entity, total) in totals_q.iter() {
if !view.contains(entity) {
continue;
}
let force_inertial = total.0.force.raw_si();
let torque_body = total.0.torque.raw_si();
let t_inertial_struct = t_inertial_struct(entity, &rot_q, &struct_q);
let t_struct_body = struct_q
.get(entity)
.map_or(DMat3::IDENTITY, |s| *s.0.matrix_ref());
let force_struct = t_inertial_struct * force_inertial;
let torque_struct = t_struct_body.transpose() * torque_body;
wrenches.insert(entity, Wrench::new(force_struct, torque_struct));
}
let aggregated: HashMap<Entity, Wrench> =
aggregate_wrenches_via_storage(&view, &wrenches, &edges);
let roots: HashSet<Entity> = view.iter_roots().collect();
let mut should_be_kinematic: HashSet<Entity> = HashSet::new();
for entity in view.iter_entities() {
if !roots.contains(&entity) {
should_be_kinematic.insert(entity);
}
}
for entity in &should_be_kinematic {
if kinematic_q.get(*entity).is_err() {
commands.entity(*entity).insert(KinematicChildC);
}
}
for entity in kinematic_q.iter() {
if !should_be_kinematic.contains(&entity) {
reset_multi_step_history(entity, &mut gj_q, &mut abm_q);
commands.entity(entity).remove::<KinematicChildC>();
}
}
for (entity, mut tf) in totals_q.iter_mut() {
if !view.contains(entity) {
continue;
}
if roots.contains(&entity) {
let agg = aggregated
.get(&entity)
.copied()
.unwrap_or_else(Wrench::zero);
let t_inertial_struct = t_inertial_struct(entity, &rot_q, &struct_q);
let t_struct_body = struct_q
.get(entity)
.map_or(DMat3::IDENTITY, |s| *s.0.matrix_ref());
let force_inertial = t_inertial_struct.transpose() * agg.force;
let torque_body = t_struct_body * agg.torque;
tf.0.force = force_inertial.n_at::<astrodyn::RootInertial>();
tf.0.torque = torque_body.nm_at::<astrodyn::BodyFrame<astrodyn::SelfRef>>();
} else {
tf.0.force = astrodyn::Force::<astrodyn::RootInertial>::zero();
tf.0.torque = astrodyn::Torque::<astrodyn::BodyFrame<astrodyn::SelfRef>>::zero();
}
}
let mut updated_totals: HashMap<Entity, astrodyn::TotalForce> = HashMap::new();
for (entity, tf) in totals_q.iter() {
if view.contains(entity) {
updated_totals.insert(entity, tf.0.to_untyped());
}
}
for (entity, mut fd) in derivs_q.iter_mut() {
if !view.contains(entity) {
continue;
}
if roots.contains(&entity) {
if dyn_cfg_q.get(entity).is_err() {
continue;
}
let mass = mass_q
.get(entity)
.ok()
.map(|(_, m)| astrodyn::typed_bridge::mass_typed_to_raw(&m.0));
let grav_accel = grav_q
.get(entity)
.map_or(DVec3::ZERO, |g| g.0.grav_accel.raw_si());
let total = updated_totals.get(&entity).copied().unwrap_or_default();
let rot = rot_q
.get(entity)
.ok()
.map(|r| astrodyn::typed_bridge::rot_typed_to_raw(&r.0));
let new_derivs = if let (Some(rot), Some(m)) = (rot, mass) {
astrodyn::compute_frame_derivatives(
&total,
m.inverse_mass,
grav_accel,
&m.inertia,
&m.inverse_inertia,
rot.ang_vel_body,
)
} else if let Some(m) = mass {
astrodyn::compute_translational_derivatives(total.force, m.inverse_mass, grav_accel)
} else {
astrodyn::FrameDerivatives {
trans_accel: grav_accel,
rot_accel: DVec3::ZERO,
}
};
fd.0 = new_derivs.into();
} else {
fd.0 = astrodyn::FrameDerivativesTyped::<astrodyn::RootInertial, astrodyn::SelfRef>::default();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{
DynamicsConfigC, ExternalForceC, ExternalTorqueC, FrameDerivativesC, MassChildOf,
MassPropertiesC, RotationalStateC, TotalForceC,
};
use crate::mass_tree::composite_mass_system;
use crate::systems::force_collection_system;
use crate::IntegrationDtR;
use astrodyn::MassProperties;
#[inline]
fn mp_c_from_raw(mp: MassProperties) -> MassPropertiesC {
MassPropertiesC(astrodyn::typed_bridge::mass_raw_to_self_ref(&mp))
}
#[inline]
fn rot_c_from_raw(r: astrodyn::RotationalState) -> RotationalStateC {
RotationalStateC(astrodyn::typed_bridge::rot_raw_to_self_ref(&r))
}
fn add_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
let root_frame_entity = app
.world_mut()
.spawn((
Name::new("root.frame"),
crate::components::InertialFrameMarker,
crate::components::FrameTransC::default(),
crate::components::FrameRotC::default(),
crate::components::FrameAngVelC::default(),
))
.id();
app.insert_resource(crate::RootFrameEntityR(root_frame_entity));
app
}
fn ext_force_in_root_inertial(v: DVec3) -> ExternalForceC {
ExternalForceC(v.n_at::<astrodyn::RootInertial>())
}
fn ext_torque_in_body(v: DVec3) -> ExternalTorqueC {
ExternalTorqueC(v.nm_at::<astrodyn::BodyFrame<astrodyn::SelfRef>>())
}
fn run_pipeline(app: &mut App) {
app.add_systems(
Update,
(
composite_mass_system,
force_collection_system.after(composite_mass_system),
wrench_aggregation_system.after(force_collection_system),
),
);
app.update();
}
#[test]
fn no_chains_is_noop() {
let mut app = add_test_app();
let core = MassProperties::new(10.0);
let f = DVec3::new(1.0, 2.0, 3.0);
let entity = app
.world_mut()
.spawn((
mp_c_from_raw(core),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ext_force_in_root_inertial(f),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
let tf = app
.world()
.get::<TotalForceC>(entity)
.unwrap()
.0
.to_untyped();
assert_eq!(tf.force, f, "single body external force passes through");
}
#[test]
fn child_force_appears_at_root_with_cross_term() {
let mut app = add_test_app();
let parent = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(10.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let child = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::new(parent, DVec3::new(1.0, 0.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ext_force_in_root_inertial(DVec3::new(0.0, 1.0, 0.0)),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
let root_tf = app
.world()
.get::<TotalForceC>(parent)
.unwrap()
.0
.to_untyped();
let child_tf = app
.world()
.get::<TotalForceC>(child)
.unwrap()
.0
.to_untyped();
let two_thirds = 2.0 / 3.0;
let root_force_err = (root_tf.force - DVec3::new(0.0, 1.0, 0.0)).length();
let root_torque_err = (root_tf.torque - DVec3::new(0.0, 0.0, two_thirds)).length();
assert!(root_force_err < 1e-12, "root force {:?}", root_tf.force);
assert!(root_torque_err < 1e-12, "root torque {:?}", root_tf.torque);
assert_eq!(child_tf.force, DVec3::ZERO, "child force zeroed");
assert_eq!(child_tf.torque, DVec3::ZERO, "child torque zeroed");
}
#[test]
fn pure_child_torque_aggregates_to_root() {
let mut app = add_test_app();
let parent = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(10.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let child_torque = DVec3::new(0.5, -0.25, 1.0);
let child = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::new(parent, DVec3::new(1.0, 0.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ext_torque_in_body(child_torque),
))
.id();
run_pipeline(&mut app);
let root_tf = app
.world()
.get::<TotalForceC>(parent)
.unwrap()
.0
.to_untyped();
assert_eq!(root_tf.force, DVec3::ZERO);
let err = (root_tf.torque - child_torque).length();
assert!(
err < 1e-12,
"root torque {:?}, expected {:?}",
root_tf.torque,
child_torque
);
let child_tf = app
.world()
.get::<TotalForceC>(child)
.unwrap()
.0
.to_untyped();
assert_eq!(child_tf.force, DVec3::ZERO);
assert_eq!(child_tf.torque, DVec3::ZERO);
}
#[test]
#[should_panic(expected = "contains a non-identity rotation")]
fn child_with_attach_rotation_and_no_rotational_state_panics() {
let mut app = add_test_app();
let parent = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(10.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let t_pc = DMat3::from_cols(
DVec3::new(0.0, 1.0, 0.0),
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(0.0, 0.0, 1.0),
);
let _child = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::with_rotation(parent, DVec3::new(1.0, 0.0, 0.0), t_pc),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ext_force_in_root_inertial(DVec3::new(5.0, 0.0, 0.0)),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
}
#[test]
fn child_with_attach_rotation_aggregates_correctly_when_attitude_is_explicit() {
use astrodyn::RotationalState;
let mut app = add_test_app();
let identity_rot = RotationalState::default();
let parent = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(10.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
rot_c_from_raw(identity_rot),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let t_pc = DMat3::from_cols(
DVec3::new(0.0, 1.0, 0.0),
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(0.0, 0.0, 1.0),
);
let _child = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::with_rotation(parent, DVec3::new(1.0, 0.0, 0.0), t_pc),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
rot_c_from_raw(identity_rot),
ext_force_in_root_inertial(DVec3::new(5.0, 0.0, 0.0)),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
let root_tf = app
.world()
.get::<TotalForceC>(parent)
.unwrap()
.0
.to_untyped();
let expected_force = DVec3::new(0.0, -5.0, 0.0);
let expected_torque = DVec3::new(0.0, 0.0, -10.0 / 3.0);
let f_err = (root_tf.force - expected_force).length();
let t_err = (root_tf.torque - expected_torque).length();
assert!(
f_err < 1e-12,
"root force {:?}, expected {:?}",
root_tf.force,
expected_force
);
assert!(
t_err < 1e-12,
"root torque {:?}, expected {:?}",
root_tf.torque,
expected_torque
);
}
#[test]
fn unrelated_identity_chain_does_not_require_rotational_state_when_another_chain_is_rotated() {
use astrodyn::RotationalState;
let mut app = add_test_app();
let identity_rot = RotationalState::default();
let rot_parent = app
.world_mut()
.spawn((
Name::new("rotated_parent"),
mp_c_from_raw(MassProperties::new(10.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
rot_c_from_raw(identity_rot),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let t_pc = DMat3::from_cols(
DVec3::new(0.0, 1.0, 0.0),
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(0.0, 0.0, 1.0),
);
let _rot_child = app
.world_mut()
.spawn((
Name::new("rotated_child"),
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::with_rotation(rot_parent, DVec3::new(1.0, 0.0, 0.0), t_pc),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
rot_c_from_raw(identity_rot),
ext_force_in_root_inertial(DVec3::new(5.0, 0.0, 0.0)),
ExternalTorqueC::default(),
))
.id();
let id_parent = app
.world_mut()
.spawn((
Name::new("identity_parent"),
mp_c_from_raw(MassProperties::new(8.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ext_force_in_root_inertial(DVec3::new(0.0, 0.0, 1.0)),
ExternalTorqueC::default(),
))
.id();
let _id_child = app
.world_mut()
.spawn((
Name::new("identity_child"),
mp_c_from_raw(MassProperties::new(2.0)),
MassChildOf::new(id_parent, DVec3::new(0.0, 1.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ext_force_in_root_inertial(DVec3::new(1.0, 0.0, 0.0)),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
let rot_root_tf = app
.world()
.get::<TotalForceC>(rot_parent)
.unwrap()
.0
.to_untyped();
let id_root_tf = app
.world()
.get::<TotalForceC>(id_parent)
.unwrap()
.0
.to_untyped();
let rot_force_err = (rot_root_tf.force - DVec3::new(0.0, -5.0, 0.0)).length();
let rot_torque_err = (rot_root_tf.torque - DVec3::new(0.0, 0.0, -10.0 / 3.0)).length();
assert!(
rot_force_err < 1e-12,
"rotated root force {:?}",
rot_root_tf.force
);
assert!(
rot_torque_err < 1e-12,
"rotated root torque {:?}",
rot_root_tf.torque
);
let id_force_err = (id_root_tf.force - DVec3::new(1.0, 0.0, 1.0)).length();
let id_torque_err = (id_root_tf.torque - DVec3::new(0.0, 0.0, -0.8)).length();
assert!(
id_force_err < 1e-12,
"identity root force {:?}",
id_root_tf.force
);
assert!(
id_torque_err < 1e-12,
"identity root torque {:?}",
id_root_tf.torque
);
}
#[test]
fn parent_and_two_children_sum() {
let mut app = add_test_app();
let parent = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(10.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ext_force_in_root_inertial(DVec3::new(0.0, 0.0, 1.0)),
ExternalTorqueC::default(),
))
.id();
let _a = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::new(parent, DVec3::new(0.0, 1.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ext_force_in_root_inertial(DVec3::new(1.0, 0.0, 0.0)),
ExternalTorqueC::default(),
))
.id();
let _b = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::new(parent, DVec3::new(0.0, -1.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ext_force_in_root_inertial(DVec3::new(1.0, 0.0, 0.0)),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
let root_tf = app
.world()
.get::<TotalForceC>(parent)
.unwrap()
.0
.to_untyped();
let expected_force = DVec3::new(2.0, 0.0, 1.0);
let expected_torque = DVec3::ZERO;
let f_err = (root_tf.force - expected_force).length();
let t_err = (root_tf.torque - expected_torque).length();
assert!(f_err < 1e-12, "force {:?}", root_tf.force);
assert!(t_err < 1e-12, "torque {:?}", root_tf.torque);
}
#[test]
fn rotated_parent_attitude_routes_cross_term_through_parent_struct() {
use astrodyn::RotationalState;
let mut app = add_test_app();
let parent_q = astrodyn::JeodQuat::left_quat_from_eigen_rotation(
std::f64::consts::FRAC_PI_6, DVec3::Z,
);
let parent_rot_state = RotationalState {
quaternion: parent_q,
ang_vel_body: DVec3::ZERO,
};
let parent = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(10.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
rot_c_from_raw(parent_rot_state),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let _child = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::new(parent, DVec3::new(1.0, 0.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
rot_c_from_raw(parent_rot_state),
ext_force_in_root_inertial(DVec3::new(1.0, 0.0, 0.0)),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
let root_tf = app
.world()
.get::<TotalForceC>(parent)
.unwrap()
.0
.to_untyped();
let expected_force = DVec3::new(1.0, 0.0, 0.0);
let expected_torque = DVec3::new(0.0, 0.0, -1.0 / 3.0);
let f_err = (root_tf.force - expected_force).length();
let t_err = (root_tf.torque - expected_torque).length();
assert!(
f_err < 1e-12,
"root force {:?}, expected {:?}",
root_tf.force,
expected_force
);
assert!(
t_err < 1e-12,
"root torque {:?}, expected {:?}",
root_tf.torque,
expected_torque
);
}
#[test]
fn child_translational_state_does_not_drift_under_gravity() {
use crate::PlanetBundle;
use astrodyn::recipes::{constants, orbital_elements, vehicle};
use astrodyn::{
GravityControl, GravityGradient, IntegratorType, TranslationalState, VehicleBuilder,
EARTH,
};
use bevy::time::Fixed;
use std::time::Duration;
const DT: f64 = 1.0;
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.insert_resource(Time::<Fixed>::from_seconds(DT));
app.insert_resource(IntegrationDtR(DT));
app.add_plugins(crate::AstrodynPlugin);
let earth = app
.world_mut()
.spawn(PlanetBundle::<astrodyn::Earth>::point_mass("Earth", &EARTH))
.id();
let parent_cfg = VehicleBuilder::new()
.from_orbital_elements(orbital_elements::iss(), constants::mu_ggm05c())
.three_dof_point_mass(vehicle::iss_mass())
.with_integrator(IntegratorType::Rk4)
.gravity(GravityControl::new_spherical(
0_usize,
GravityGradient::Skip,
))
.build();
let parent = {
use crate::VehicleConfigBevyExt;
let mut cmds = app.world_mut().commands();
let p = parent_cfg.spawn_bevy::<astrodyn::Earth>(&mut cmds, &[earth]);
app.world_mut().flush();
p
};
let parent_pos = astrodyn::typed_bridge::trans_typed_to_raw(
&app.world()
.get::<crate::TranslationalStateC<astrodyn::Earth>>(parent)
.unwrap()
.0,
)
.position;
let child = app
.world_mut()
.spawn((
Name::new("child"),
mp_c_from_raw(MassProperties::new(100.0)),
MassChildOf::new(parent, DVec3::new(0.5, 0.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
crate::TranslationalStateC::<astrodyn::Earth>::from_untyped(TranslationalState {
position: parent_pos,
velocity: DVec3::ZERO,
}),
crate::GravityControlsC(astrodyn::GravityControls::<Entity> {
controls: vec![GravityControl::new_spherical(earth, GravityGradient::Skip)],
}),
crate::GravityAccelerationC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
for _ in 0..5 {
app.world_mut()
.resource_mut::<Time<Fixed>>()
.advance_by(Duration::from_secs_f64(DT));
app.world_mut().run_schedule(FixedUpdate);
}
let child_pos = astrodyn::typed_bridge::trans_typed_to_raw(
&app.world()
.get::<crate::TranslationalStateC<astrodyn::Earth>>(child)
.unwrap()
.0,
)
.position;
let drift = (child_pos - parent_pos).length();
assert!(
drift < 1e-6,
"kinematic child drifted {drift:.3e} m under gravity over 5 steps; \
expected ~0 (KinematicChildC marker should keep integration_system \
from advancing it)"
);
let parent_pos_after = astrodyn::typed_bridge::trans_typed_to_raw(
&app.world()
.get::<crate::TranslationalStateC<astrodyn::Earth>>(parent)
.unwrap()
.0,
)
.position;
let parent_drift = (parent_pos_after - parent_pos).length();
assert!(
parent_drift > 1.0,
"parent did not integrate (drift {parent_drift:.3e} m); test setup broken"
);
assert!(
app.world().entity(child).contains::<KinematicChildC>(),
"child {child:?} should carry KinematicChildC after wrench aggregation"
);
}
#[test]
fn kinematic_child_with_derivative_srp_gets_no_srp_and_temps_dont_advance() {
use crate::components::{
FlatPlateConfigC, RadiationForceC, SunMarker, TranslationalStateC,
};
use astrodyn::{
FlatPlate, FlatPlateParams, FlatPlateState, FlatPlateThermal, MassProperties,
ThermalIntegrationOrder, TranslationalState, Vec3Ext,
};
let mut app = add_test_app();
app.world_mut().spawn((
SunMarker,
TranslationalStateC::<astrodyn::Earth>::from_untyped(TranslationalState {
position: DVec3::new(1.496e11, 0.0, 0.0),
velocity: DVec3::ZERO,
}),
));
let parent = app
.world_mut()
.spawn((
Name::new("parent"),
mp_c_from_raw(MassProperties::new(10.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
TranslationalStateC::<astrodyn::Earth>::from_untyped(TranslationalState {
position: DVec3::new(7.0e6, 0.0, 0.0),
velocity: DVec3::ZERO,
}),
))
.id();
let plates = vec![(
FlatPlate {
area: 10.0,
normal: -DVec3::X, position: DVec3::ZERO.m_at::<astrodyn::StructuralFrame<astrodyn::SelfRef>>(),
},
FlatPlateParams {
albedo: 0.3,
diffuse: 0.3,
},
FlatPlateThermal {
emissivity: 0.5,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
},
)];
let initial_temp = 270.0;
let child = app
.world_mut()
.spawn((
Name::new("child_appendage"),
mp_c_from_raw(MassProperties::new(1.0)),
MassChildOf::new(parent, DVec3::new(1.0, 0.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
TranslationalStateC::<astrodyn::Earth>::from_untyped(TranslationalState {
position: DVec3::new(7.0e6, 0.0, 0.0),
velocity: DVec3::ZERO,
}),
RadiationForceC::default(),
FlatPlateConfigC(FlatPlateState {
plates,
temperatures: vec![initial_temp],
t_pow4_cached: vec![initial_temp.powi(4)],
integration_order: ThermalIntegrationOrder::DerivativeRk4,
..Default::default()
}),
))
.id();
use bevy::time::Fixed;
use std::time::Duration;
const DT: f64 = 1.0;
app.insert_resource(Time::<Fixed>::from_seconds(DT));
app.insert_resource(IntegrationDtR(DT));
app.add_systems(
Update,
(
composite_mass_system,
crate::systems::flat_plate_srp_system::<astrodyn::Earth>
.after(composite_mass_system),
force_collection_system
.after(crate::systems::flat_plate_srp_system::<astrodyn::Earth>),
wrench_aggregation_system.after(force_collection_system),
),
);
for _ in 0..2 {
app.world_mut()
.resource_mut::<Time<Fixed>>()
.advance_by(Duration::from_secs_f64(DT));
app.update();
}
assert!(
app.world().entity(child).contains::<KinematicChildC>(),
"child must be marked KinematicChildC after first tick"
);
let rf = app
.world()
.get::<RadiationForceC>(child)
.expect("child should have RadiationForceC");
let force_mag = rf.force.length();
assert!(
force_mag < 1e-12,
"kinematic child SRP force must be zero (kinematic children are excluded from SRP \
until propagate_state_from_root_system lands); got |force|={force_mag:.3e} N — \
cleanup arm of flat_plate_srp_system did not fire, leaking stale SRP up the \
wrench walk"
);
let fc = app
.world()
.get::<FlatPlateConfigC>(child)
.expect("child should have FlatPlateConfigC");
assert!(
fc.0.stage_inputs.is_none(),
"kinematic child stage_inputs must be cleared by the cleanup arm; left over from \
pre-kinematic ticks would be consumed when the entity later demotes back to root"
);
let temp_delta = (fc.0.temperatures[0] - initial_temp).abs();
assert!(
temp_delta < 1e-12,
"kinematic child plate temperatures must stay frozen this PR (no SRP for kinematic \
children until propagate_state_from_root_system lands); got Δ={temp_delta:.3e} K \
at {} K — temperature integration ran for an entity that should have been excluded",
fc.0.temperatures[0]
);
}
#[test]
fn detached_child_gj_history_resets_before_resuming_integration() {
use crate::components::{GaussJacksonStateC, IntegratorTypeC};
use astrodyn::{
GaussJacksonConfig, GaussJacksonState, IntegratorType, MassProperties,
TranslationalState,
};
let mut app = add_test_app();
let parent_mass = MassProperties::new(10.0);
let parent = app
.world_mut()
.spawn((
Name::new("parent"),
mp_c_from_raw(parent_mass),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let gj_cfg = GaussJacksonConfig::default();
let mut primed_gj = GaussJacksonState::new(gj_cfg);
primed_gj.mark_topology_dirty();
let child = app
.world_mut()
.spawn((
Name::new("child"),
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::new(parent, DVec3::new(1.0, 0.0, 0.0)),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
IntegratorTypeC(IntegratorType::GaussJackson(gj_cfg)),
GaussJacksonStateC(primed_gj),
crate::TranslationalStateC::<astrodyn::Earth>::from_untyped(
TranslationalState::default(),
),
))
.id();
run_pipeline(&mut app);
assert!(
app.world().entity(child).contains::<KinematicChildC>(),
"child must carry KinematicChildC while attached"
);
assert!(
app.world()
.get::<GaussJacksonStateC>(child)
.unwrap()
.0
.is_topology_dirty(),
"GJ state should remain untouched while child is still kinematic"
);
app.world_mut().entity_mut(child).remove::<MassChildOf>();
app.update();
app.update();
assert!(
!app.world().entity(child).contains::<KinematicChildC>(),
"child must be demoted to root once MassChildOf is removed"
);
let gj_after = &app.world().get::<GaussJacksonStateC>(child).unwrap().0;
assert!(
!gj_after.is_topology_dirty(),
"detach must clear topology_dirty (IG.37): GaussJacksonStateC.is_topology_dirty \
still true after MassChildOf removal"
);
assert!(
gj_after.is_priming(),
"detach must reset GJ history to priming (IG.37): GaussJacksonStateC \
still reports is_priming() == false after MassChildOf removal — its \
stale predictor history would be applied to the body's standalone \
dynamics on the next integration step"
);
}
}