use astrodyn_dynamics::{MassProperties, RotationalState, TranslationalState};
use astrodyn_interactions::{
compute_contact_force_from_geometry, compute_contact_geometry, ContactFacet, FlatPlate,
FlatPlateParams, FlatPlateThermal,
};
use astrodyn_quantities::aliases::Position;
use astrodyn_quantities::frame::{RootInertial, StructuralFrame, Vehicle};
use glam::{DMat3, DVec3};
use crate::integrable::IntegrableObject;
#[derive(Debug, Clone, Copy)]
pub struct FlatPlateStageInputs<V: Vehicle> {
pub sun_position: Position<RootInertial>,
pub illum_factor: f64,
pub center_grav: Position<StructuralFrame<V>>,
}
impl<V: Vehicle> Default for FlatPlateStageInputs<V> {
fn default() -> Self {
Self {
sun_position: Position::<RootInertial>::default(),
illum_factor: 0.0,
center_grav: Position::<StructuralFrame<V>>::default(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ThermalIntegrationOrder {
#[default]
Scheduled,
DerivativeFirstOrder,
DerivativeRk4,
}
#[derive(Debug, Clone)]
pub struct FlatPlateState<V: Vehicle> {
pub plates: Vec<(FlatPlate<V>, FlatPlateParams, FlatPlateThermal)>,
pub temperatures: Vec<f64>,
pub t_pow4_cached: Vec<f64>,
pub integration_order: ThermalIntegrationOrder,
pub stage_inputs: Option<FlatPlateStageInputs<V>>,
pub scratch: FlatPlateScratch,
}
impl<V: Vehicle> Default for FlatPlateState<V> {
fn default() -> Self {
Self {
plates: Vec::new(),
temperatures: Vec::new(),
t_pow4_cached: Vec::new(),
integration_order: ThermalIntegrationOrder::default(),
stage_inputs: None,
scratch: FlatPlateScratch::default(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FlatPlateScratch {
temps_snapshot: Vec<f64>,
t_pow4_snapshot: Vec<f64>,
}
impl<V: Vehicle> FlatPlateState<V> {
pub fn integrate_temperatures(&mut self, temp_dots_k1: &[f64], dt: f64) {
for (i, (plate, _params, thermal)) in self.plates.iter().enumerate() {
let (new_temp, new_t_pow4) = astrodyn_interactions::integrate_plate_temperature_euler(
self.temperatures[i],
self.t_pow4_cached[i],
temp_dots_k1[i],
plate.area,
thermal.emissivity,
thermal.heat_capacity_per_area,
dt,
);
self.temperatures[i] = new_temp;
self.t_pow4_cached[i] = new_t_pow4;
}
}
pub fn finalize_rk4_temperatures(
&mut self,
k1_tdots: &[f64],
k2_tdots: &[f64],
k3_tdots: &[f64],
k4_tdots: &[f64],
dt: f64,
) {
let n = self.temperatures.len();
for i in 0..n {
let area = self.plates[i].0.area;
let emissivity = self.plates[i].2.emissivity;
let heat_capacity_per_area = self.plates[i].2.heat_capacity_per_area;
let (new_temp, new_t_pow4) = astrodyn_interactions::integrate_plate_temperature_rk4(
self.scratch.temps_snapshot[i],
self.scratch.t_pow4_snapshot[i],
k1_tdots[i],
k2_tdots[i],
k3_tdots[i],
k4_tdots[i],
area,
emissivity,
heat_capacity_per_area,
dt,
);
self.temperatures[i] = new_temp;
self.t_pow4_cached[i] = new_t_pow4;
}
}
}
impl<V: Vehicle> IntegrableObject for FlatPlateState<V> {
fn n_states(&self) -> usize {
self.temperatures.len()
}
fn snapshot(&mut self) {
self.scratch.temps_snapshot.clear();
self.scratch
.temps_snapshot
.extend_from_slice(&self.temperatures);
self.scratch.t_pow4_snapshot.clear();
self.scratch
.t_pow4_snapshot
.extend_from_slice(&self.t_pow4_cached);
}
fn advance_intermediate(&mut self, deriv: &[f64], h: f64) {
let n = self.temperatures.len();
assert_eq!(
self.t_pow4_cached.len(),
n,
"t_pow4_cached length must equal temperatures length",
);
assert_eq!(
self.scratch.temps_snapshot.len(),
n,
"temps_snapshot length must equal temperatures length; \
call IntegrableObject::snapshot before advance_intermediate",
);
assert_eq!(
deriv.len(),
n,
"advance_intermediate derivative length must equal temperature count",
);
#[allow(clippy::needless_range_loop)]
for i in 0..n {
let new_t = (self.scratch.temps_snapshot[i] + deriv[i] * h).max(0.0);
self.temperatures[i] = new_t;
self.t_pow4_cached[i] = new_t * new_t * new_t * new_t;
}
}
fn finalize_rk4(&mut self, k1: &[f64], k2: &[f64], k3: &[f64], k4: &[f64], dt: f64) {
self.finalize_rk4_temperatures(k1, k2, k3, k4, dt);
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ContactPairEval {
pub force_on_a: DVec3,
pub torque_a_body: DVec3,
pub torque_b_body: DVec3,
}
#[allow(clippy::too_many_arguments)]
pub fn evaluate_contact_pair(
facet_a: &ContactFacet,
facet_b: &ContactFacet,
trans_a: &TranslationalState,
trans_b: &TranslationalState,
rot_a: Option<&RotationalState>,
rot_b: Option<&RotationalState>,
t_struct_body_a: DMat3,
t_struct_body_b: DMat3,
mass_a: Option<&MassProperties>,
mass_b: Option<&MassProperties>,
) -> Option<ContactPairEval> {
let t_inertial_body_a = rot_a.map_or(DMat3::IDENTITY, |r| {
r.quaternion.left_quat_to_transformation()
});
let t_inertial_body_b = rot_b.map_or(DMat3::IDENTITY, |r| {
r.quaternion.left_quat_to_transformation()
});
let t_inertial_struct_a =
astrodyn_dynamics::compute_t_inertial_struct(&t_struct_body_a, &t_inertial_body_a);
let t_inertial_struct_b =
astrodyn_dynamics::compute_t_inertial_struct(&t_struct_body_b, &t_inertial_body_b);
let t_inertial_from_struct_a = t_inertial_struct_a.transpose();
let t_inertial_from_struct_b = t_inertial_struct_b.transpose();
let facet_a_world = rotate_facet(facet_a, &t_inertial_from_struct_a);
let facet_b_world = rotate_facet(facet_b, &t_inertial_from_struct_b);
let r_cm_a_struct = mass_a.map_or(DVec3::ZERO, |m| m.position);
let r_cm_b_struct = mass_b.map_or(DVec3::ZERO, |m| m.position);
let facet_a_offset_from_cm_inertial =
t_inertial_from_struct_a * (facet_a.shape.reference_position() - r_cm_a_struct);
let facet_b_offset_from_cm_inertial =
t_inertial_from_struct_b * (facet_b.shape.reference_position() - r_cm_b_struct);
let a_ref_inertial = trans_a.position + facet_a_offset_from_cm_inertial;
let b_ref_inertial = trans_b.position + facet_b_offset_from_cm_inertial;
let rel_pos = a_ref_inertial - b_ref_inertial;
let geom = compute_contact_geometry(&facet_a_world, &facet_b_world, rel_pos)?;
let contact_arm_a_inertial = facet_a_offset_from_cm_inertial + geom.contact_point_on_a;
let omega_a_inertial = rot_a.map_or(DVec3::ZERO, |r| {
t_inertial_body_a.transpose() * r.ang_vel_body
});
let omega_b_inertial = rot_b.map_or(DVec3::ZERO, |r| {
t_inertial_body_b.transpose() * r.ang_vel_body
});
let rel_vel = (trans_a.velocity - trans_b.velocity) - omega_a_inertial.cross(rel_pos)
+ (omega_b_inertial - omega_a_inertial).cross(contact_arm_a_inertial);
let contact =
compute_contact_force_from_geometry(&facet_a_world, &facet_b_world, &geom, rel_vel);
let force_on_a = contact.force;
let arm_a_inertial = facet_a_offset_from_cm_inertial + contact.contact_point_on_a;
let arm_b_inertial = facet_b_offset_from_cm_inertial + contact.contact_point_on_b;
let torque_a_inertial = arm_a_inertial.cross(force_on_a);
let torque_b_inertial = arm_b_inertial.cross(-force_on_a);
let torque_a_body = t_inertial_body_a * torque_a_inertial;
let torque_b_body = t_inertial_body_b * torque_b_inertial;
Some(ContactPairEval {
force_on_a,
torque_a_body,
torque_b_body,
})
}
#[derive(Debug, Clone, Copy, Default)]
pub struct GroundContactPairEval {
pub force_on_a: DVec3,
pub torque_a_body: DVec3,
}
#[allow(clippy::too_many_arguments)]
pub fn evaluate_ground_contact_pair(
vehicle_facet: &ContactFacet,
ground_facet: &astrodyn_interactions::GroundFacet,
trans_a: &TranslationalState,
rot_a: &RotationalState,
t_struct_body_a: DMat3,
mass_a: &MassProperties,
t_inertial_pfix: DMat3,
phase: astrodyn_interactions::Phase,
) -> Option<GroundContactPairEval> {
let t_inertial_body_a = rot_a.quaternion.left_quat_to_transformation();
let geom = astrodyn_interactions::compute_ground_contact_geometry(
vehicle_facet,
ground_facet,
trans_a.position,
t_inertial_body_a,
t_struct_body_a,
t_inertial_pfix,
phase,
)?;
let r_cm_struct = mass_a.position;
let facet_offset_from_cm_body =
t_struct_body_a * (vehicle_facet.shape.reference_position() - r_cm_struct);
let arm_body = facet_offset_from_cm_body + geom.contact_point_on_a;
let v_inertial_in_body = t_inertial_body_a * trans_a.velocity;
let rel_vel_body = rot_a.ang_vel_body.cross(arm_body) + v_inertial_in_body;
let ground_facet_synth = ContactFacet::point(DVec3::ZERO, 0.0, ground_facet.material);
let contact = compute_contact_force_from_geometry(
vehicle_facet,
&ground_facet_synth,
&geom,
rel_vel_body,
);
let force_on_a_inertial = t_inertial_body_a.transpose() * contact.force;
let torque_a_body = arm_body.cross(contact.force);
Some(GroundContactPairEval {
force_on_a: force_on_a_inertial,
torque_a_body,
})
}
fn rotate_facet(facet: &ContactFacet, t_inertial_from_struct: &DMat3) -> ContactFacet {
use astrodyn_interactions::ContactShape;
let shape = match facet.shape {
ContactShape::Point { position, radius } => ContactShape::Point {
position: *t_inertial_from_struct * position,
radius,
},
ContactShape::Line { start, end, radius } => ContactShape::Line {
start: *t_inertial_from_struct * start,
end: *t_inertial_from_struct * end,
radius,
},
};
ContactFacet {
shape,
material: facet.material,
}
}
#[cfg(test)]
mod tests {
use super::*;
use astrodyn_interactions::ContactMaterial;
use astrodyn_math::JeodQuat;
fn scenario_material(stiffness: f64, damping: f64, mu: f64) -> ContactMaterial {
ContactMaterial::jeod_spring(stiffness, damping, mu)
}
#[test]
fn evaluate_contact_pair_uses_omega_cross_contact_arm_in_rel_vel() {
let mat = scenario_material(1000.0, 0.0, 0.5);
let facet = astrodyn_interactions::ContactFacet::point(DVec3::ZERO, 1.0, mat);
let mass = MassProperties::with_inertia(
100.0,
DMat3::from_cols(
DVec3::new(40.0, 0.0, 0.0),
DVec3::new(0.0, 40.0, 0.0),
DVec3::new(0.0, 0.0, 40.0),
),
DVec3::ZERO,
);
let trans_a = TranslationalState {
position: DVec3::ZERO,
velocity: DVec3::ZERO,
};
let trans_b = TranslationalState {
position: DVec3::new(1.8, 0.5, 0.0),
velocity: DVec3::ZERO,
};
let rot_zero = RotationalState {
quaternion: JeodQuat::identity(),
ang_vel_body: DVec3::ZERO,
};
let baseline = evaluate_contact_pair(
&facet,
&facet,
&trans_a,
&trans_b,
Some(&rot_zero),
Some(&rot_zero),
DMat3::IDENTITY,
DMat3::IDENTITY,
Some(&mass),
Some(&mass),
)
.expect("in contact");
let u_ab = DVec3::new(1.8, 0.5, 0.0).normalize();
let force_parallel_to_normal = baseline.force_on_a.dot(-u_ab);
assert!(
force_parallel_to_normal > 0.0 && baseline.force_on_a.cross(-u_ab).length() < 1e-9,
"baseline (ω=0) force should be purely along the normal; got {:?}",
baseline.force_on_a,
);
let omega_z = 1.0_f64;
let rot_a_spinning = RotationalState {
quaternion: JeodQuat::identity(),
ang_vel_body: DVec3::new(0.0, 0.0, omega_z),
};
let spinning = evaluate_contact_pair(
&facet,
&facet,
&trans_a,
&trans_b,
Some(&rot_a_spinning),
Some(&rot_zero),
DMat3::IDENTITY,
DMat3::IDENTITY,
Some(&mass),
Some(&mass),
)
.expect("in contact");
let normal_component_baseline = baseline.force_on_a.dot(-u_ab);
let normal_component_spinning = spinning.force_on_a.dot(-u_ab);
assert!(
(normal_component_baseline - normal_component_spinning).abs() < 1e-9,
"spinning case should have the same normal force; \
baseline={normal_component_baseline}, spinning={normal_component_spinning}",
);
let tangent_component = spinning.force_on_a - normal_component_spinning * (-u_ab);
assert!(
tangent_component.length() > 1e-3,
"ω × r_arm kinematic term did not propagate into rel_vel: \
spinning force is still axial with tangent magnitude {}",
tangent_component.length(),
);
let r_arm = 1.0 * u_ab;
let rel_vel = DVec3::new(0.0, 0.0, omega_z).cross(r_arm);
let expected_tangent_dir = (-rel_vel).normalize();
let got_tangent_dir = tangent_component.normalize();
assert!(
got_tangent_dir.dot(expected_tangent_dir) > 0.99,
"friction direction should oppose ω-induced slip; \
expected {expected_tangent_dir:?}, got {got_tangent_dir:?}",
);
}
#[test]
fn evaluate_contact_pair_is_frame_covariant() {
let radius: f64 = 1.0;
let mat = scenario_material(3502.5, 70.05, 0.05);
let facet = astrodyn_interactions::ContactFacet::point(DVec3::ZERO, radius, mat);
let mass = MassProperties::with_inertia(
100.0,
DMat3::from_diagonal(DVec3::new(40.0, 40.0, 40.0)),
DVec3::ZERO,
);
let q_a_base =
JeodQuat::left_quat_from_eigen_rotation(0.37, DVec3::new(1.0, 2.0, 3.0).normalize());
let q_b_base =
JeodQuat::left_quat_from_eigen_rotation(-0.81, DVec3::new(-2.0, 1.0, -1.5).normalize());
let trans_a_base = TranslationalState {
position: DVec3::new(0.1, -0.2, 0.3),
velocity: DVec3::new(0.05, -0.1, 0.02),
};
let trans_b_base = TranslationalState {
position: DVec3::new(1.9, 0.3, 0.1),
velocity: DVec3::new(-0.07, 0.04, -0.01),
};
let rot_a_base = RotationalState {
quaternion: q_a_base,
ang_vel_body: DVec3::new(0.04, -0.02, 0.03),
};
let rot_b_base = RotationalState {
quaternion: q_b_base,
ang_vel_body: DVec3::new(-0.03, 0.01, -0.02),
};
let t_struct_body = DMat3::IDENTITY;
let baseline = evaluate_contact_pair(
&facet,
&facet,
&trans_a_base,
&trans_b_base,
Some(&rot_a_base),
Some(&rot_b_base),
t_struct_body,
t_struct_body,
Some(&mass),
Some(&mass),
)
.expect("non-trivial overlap by construction");
let r_axis = DVec3::new(0.7, -0.5, 0.2).normalize();
let r_angle = 1.3_f64;
let r_inertial_to_rotated = DMat3::from_axis_angle(r_axis, r_angle);
let r_t = r_inertial_to_rotated.transpose();
let trans_a_rot = TranslationalState {
position: r_inertial_to_rotated * trans_a_base.position,
velocity: r_inertial_to_rotated * trans_a_base.velocity,
};
let trans_b_rot = TranslationalState {
position: r_inertial_to_rotated * trans_b_base.position,
velocity: r_inertial_to_rotated * trans_b_base.velocity,
};
let t_inertial_body_a_base = q_a_base.left_quat_to_transformation();
let t_inertial_body_b_base = q_b_base.left_quat_to_transformation();
let t_inertial_body_a_rot = t_inertial_body_a_base * r_t;
let t_inertial_body_b_rot = t_inertial_body_b_base * r_t;
let q_a_rot = JeodQuat::left_quat_from_transformation(&t_inertial_body_a_rot);
let q_b_rot = JeodQuat::left_quat_from_transformation(&t_inertial_body_b_rot);
let rot_a_rot = RotationalState {
quaternion: q_a_rot,
ang_vel_body: rot_a_base.ang_vel_body,
};
let rot_b_rot = RotationalState {
quaternion: q_b_rot,
ang_vel_body: rot_b_base.ang_vel_body,
};
let rotated = evaluate_contact_pair(
&facet,
&facet,
&trans_a_rot,
&trans_b_rot,
Some(&rot_a_rot),
Some(&rot_b_rot),
t_struct_body,
t_struct_body,
Some(&mass),
Some(&mass),
)
.expect("non-trivial overlap by construction");
let expected = r_inertial_to_rotated * baseline.force_on_a;
let diff = rotated.force_on_a - expected;
let mag_ref = baseline
.force_on_a
.length()
.max(rotated.force_on_a.length());
println!(
"frame covariance: baseline={:?} rotated={:?} R*baseline={:?} \
|diff|={:.3e} |F|={:.3e} rel={:.3e}",
baseline.force_on_a,
rotated.force_on_a,
expected,
diff.length(),
mag_ref,
diff.length() / mag_ref.max(f64::MIN_POSITIVE),
);
assert!(
diff.length() < 1.0e-10,
"contact force is not frame-covariant: rotating every input by R produced \
a force that differs from R·force_baseline by |diff|={:.3e} N.",
diff.length(),
);
}
#[test]
#[should_panic(expected = "temps_snapshot length must equal temperatures length")]
fn in_32_panics_on_advance_intermediate_without_snapshot() {
use crate::SelfRef;
use astrodyn_interactions::{FlatPlate, FlatPlateParams, FlatPlateThermal};
use astrodyn_quantities::ext::Vec3Ext;
use astrodyn_quantities::frame::StructuralFrame;
let plate = FlatPlate::<SelfRef> {
area: 1.0,
normal: DVec3::new(1.0, 0.0, 0.0),
position: DVec3::ZERO.m_at::<StructuralFrame<SelfRef>>(),
};
let params = FlatPlateParams {
albedo: 0.0,
diffuse: 0.0,
};
let thermal = FlatPlateThermal {
emissivity: 1.0,
heat_capacity_per_area: 50.0,
thermal_power_dump: 0.0,
};
let mut state = FlatPlateState::<SelfRef> {
plates: vec![(plate, params, thermal)],
temperatures: vec![300.0],
t_pow4_cached: vec![300.0_f64.powi(4)],
..Default::default()
};
state.advance_intermediate(&[0.0], 0.1);
}
}