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 contact_arm_b_inertial = facet_b_offset_from_cm_inertial + geom.contact_point_on_b;
let rel_vel = (trans_a.velocity - trans_b.velocity)
+ omega_a_inertial.cross(contact_arm_a_inertial)
- omega_b_inertial.cross(contact_arm_b_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:?}",
);
}
}