use glam::DVec3;
fn erf(x: f64) -> f64 {
let sign = x.signum();
let x = x.abs();
const P: f64 = 0.327_591_1;
const A1: f64 = 0.254_829_592;
const A2: f64 = -0.284_496_736;
const A3: f64 = 1.421_413_741;
const A4: f64 = -1.453_152_027;
const A5: f64 = 1.061_405_429;
let t = 1.0 / (1.0 + P * x);
let t2 = t * t;
let t3 = t2 * t;
let t4 = t3 * t;
let t5 = t4 * t;
let result = 1.0 - (A1 * t + A2 * t2 + A3 * t3 + A4 * t4 + A5 * t5) * (-x * x).exp();
sign * result
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AeroCoeffMethod {
Specular,
Diffuse,
Mixed {
epsilon: f64,
},
CalcCoef {
epsilon: f64,
},
}
impl AeroCoeffMethod {
#[inline]
fn clamp_epsilon(epsilon: f64) -> f64 {
epsilon.clamp(0.0, 1.0)
}
#[inline]
pub fn mixed(epsilon: f64) -> Self {
Self::Mixed {
epsilon: Self::clamp_epsilon(epsilon),
}
}
#[inline]
pub fn calc_coef(epsilon: f64) -> Self {
Self::CalcCoef {
epsilon: Self::clamp_epsilon(epsilon),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct AeroGasParams {
pub gas_const: f64,
pub temp_free_stream: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct AeroFacet {
pub area: f64,
pub normal: DVec3,
pub center_pressure: DVec3,
pub coeff_method: AeroCoeffMethod,
pub temperature: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FlatPlateAeroResult {
pub force: DVec3,
pub torque: DVec3,
}
impl Default for FlatPlateAeroResult {
fn default() -> Self {
Self {
force: DVec3::ZERO,
torque: DVec3::ZERO,
}
}
}
const M_2_SQRTPI: f64 = std::f64::consts::FRAC_2_SQRT_PI;
pub fn compute_flat_plate_aero(
facets: &[AeroFacet],
rel_vel_struct: DVec3,
dynamic_pressure: f64,
gas_params: &AeroGasParams,
center_grav: DVec3,
) -> FlatPlateAeroResult {
let rel_vel_mag = rel_vel_struct.length();
if rel_vel_mag < 1e-10 || dynamic_pressure <= 0.0 {
return FlatPlateAeroResult::default();
}
let rel_vel_hat = rel_vel_struct / rel_vel_mag;
let mut total_force = DVec3::ZERO;
let mut total_torque = DVec3::ZERO;
for facet in facets {
let facet_force = compute_single_facet(
facet,
rel_vel_mag,
rel_vel_hat,
dynamic_pressure,
gas_params,
);
if facet_force == DVec3::ZERO {
continue;
}
let cpres = facet.center_pressure - center_grav;
let facet_torque = cpres.cross(facet_force);
total_force += facet_force;
total_torque += facet_torque;
}
FlatPlateAeroResult {
force: total_force,
torque: total_torque,
}
}
fn compute_single_facet(
facet: &AeroFacet,
rel_vel_mag: f64,
rel_vel_hat: DVec3,
dynamic_pressure: f64,
gas_params: &AeroGasParams,
) -> DVec3 {
let sin_alpha = facet.normal.dot(rel_vel_hat);
if sin_alpha <= 0.0 {
return DVec3::ZERO;
}
let force_base = -dynamic_pressure * facet.area;
if !(gas_params.gas_const.is_finite()
&& gas_params.gas_const > 0.0
&& gas_params.temp_free_stream.is_finite()
&& gas_params.temp_free_stream > 0.0)
{
return DVec3::ZERO;
}
let denom = (2.0 * gas_params.gas_const * gas_params.temp_free_stream).sqrt();
if !denom.is_finite() || denom <= 0.0 {
return DVec3::ZERO;
}
let s = rel_vel_mag / denom;
let s_2 = s * s;
match facet.coeff_method {
AeroCoeffMethod::Specular => {
let exp_s2 = (-s_2).exp();
let drag_coef_spec = (2.0 * M_2_SQRTPI * s * exp_s2 + 2.0 + 4.0 * s_2) / s_2;
let force_n = force_base * drag_coef_spec * sin_alpha * sin_alpha;
facet.normal * force_n
}
AeroCoeffMethod::Diffuse => {
let exp_s2 = (-s_2).exp();
let temp_ratio = facet.temperature.max(0.0) / gas_params.temp_free_stream;
let drag_coef_diff = (M_2_SQRTPI * s * exp_s2
+ temp_ratio.sqrt() * (2.0 / M_2_SQRTPI) * s
+ 1.0
+ 2.0 * s_2)
/ s_2;
let force_t = force_base * drag_coef_diff * sin_alpha;
rel_vel_hat * force_t
}
AeroCoeffMethod::Mixed { epsilon } => {
let epsilon = epsilon.clamp(0.0, 1.0);
let exp_s2 = (-s_2).exp();
let drag_coef_spec = (2.0 * M_2_SQRTPI * s * exp_s2 + 2.0 + 4.0 * s_2) / s_2;
let temp_ratio = facet.temperature.max(0.0) / gas_params.temp_free_stream;
let drag_coef_diff = (M_2_SQRTPI * s * exp_s2
+ temp_ratio.sqrt() * (2.0 / M_2_SQRTPI) * s
+ 1.0
+ 2.0 * s_2)
/ s_2;
let force_n = epsilon * force_base * drag_coef_spec * sin_alpha * sin_alpha;
let force_t = (1.0 - epsilon) * force_base * drag_coef_diff * sin_alpha;
let vec_force_n = facet.normal * force_n;
let vec_force_t = rel_vel_hat * force_t;
vec_force_n + vec_force_t
}
AeroCoeffMethod::CalcCoef { epsilon } => {
let epsilon = epsilon.clamp(0.0, 1.0);
let one_p_epsilon = 1.0 + epsilon;
let one_m_epsilon = 1.0 - epsilon;
let s_sinalpha = s * sin_alpha;
let s_sa2 = s_sinalpha * s_sinalpha;
let exp_ssa2 = (-s_sa2).exp();
let erf_ssa = erf(s_sinalpha);
let local_temp_reflect =
one_m_epsilon * facet.temperature.max(0.0) + epsilon * gas_params.temp_free_stream;
let temp_ratio = local_temp_reflect / gas_params.temp_free_stream;
let drag_coef_norm = (M_2_SQRTPI * one_p_epsilon * s_sinalpha * exp_ssa2
+ one_m_epsilon * temp_ratio.sqrt() * (2.0 / M_2_SQRTPI) * s_sinalpha
+ one_p_epsilon * (1.0 + 2.0 * s_sa2) * erf_ssa)
/ s_2;
let force_n = force_base * drag_coef_norm;
let sin_alpha_clamped = sin_alpha.min(1.0);
let cos_alpha_sq = 1.0 - sin_alpha_clamped * sin_alpha_clamped;
if cos_alpha_sq < 1e-20 {
facet.normal * force_n
} else {
let cos_alpha = cos_alpha_sq.sqrt();
let tangent = (rel_vel_hat - facet.normal * sin_alpha_clamped) / cos_alpha;
let drag_coef_tang = (one_m_epsilon
* M_2_SQRTPI
* s
* cos_alpha
* (exp_ssa2 + (2.0 / M_2_SQRTPI) * s_sinalpha * erf_ssa))
/ s_2;
let drag_coef_tang = drag_coef_tang.abs();
let force_t = force_base * drag_coef_tang;
facet.normal * force_n + tangent * force_t
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn leo_gas_params() -> AeroGasParams {
AeroGasParams {
gas_const: 287.0,
temp_free_stream: 900.0, }
}
fn standard_conditions() -> (f64, f64, DVec3) {
let density = 1e-12; let velocity = 7600.0; let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
(dyn_press, velocity, rel_vel)
}
#[test]
fn back_facing_zero_force() {
let (dyn_press, _, rel_vel) = standard_conditions();
let gas = leo_gas_params();
for method in [
AeroCoeffMethod::Specular,
AeroCoeffMethod::Diffuse,
AeroCoeffMethod::Mixed { epsilon: 0.5 },
AeroCoeffMethod::CalcCoef { epsilon: 0.5 },
] {
let facet = AeroFacet {
area: 10.0,
normal: DVec3::new(-1.0, 0.0, 0.0), center_pressure: DVec3::ZERO,
coeff_method: method,
temperature: 300.0,
};
let result = compute_flat_plate_aero(&[facet], rel_vel, dyn_press, &gas, DVec3::ZERO);
assert_eq!(
result.force,
DVec3::ZERO,
"Back-facing should give zero force for {:?}",
method
);
}
}
#[test]
fn specular_normal_incidence() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let facet = AeroFacet {
area: 10.0,
normal: DVec3::new(1.0, 0.0, 0.0), center_pressure: DVec3::ZERO,
coeff_method: AeroCoeffMethod::Specular,
temperature: 300.0,
};
let result = compute_flat_plate_aero(&[facet], rel_vel, dyn_press, &gas, DVec3::ZERO);
let s = velocity / (2.0 * gas.gas_const * gas.temp_free_stream).sqrt();
let s_2 = s * s;
let exp_s2 = (-s_2).exp();
let cd_spec = (2.0 * M_2_SQRTPI * s * exp_s2 + 2.0 + 4.0 * s_2) / s_2;
let expected_force_x = -dyn_press * 10.0 * cd_spec;
let rel_err = (result.force.x - expected_force_x).abs() / expected_force_x.abs();
assert!(
rel_err < 1e-12,
"Specular normal force: expected {expected_force_x:.6e}, got {:.6e}",
result.force.x
);
assert!(result.force.x < 0.0, "Force should oppose motion");
assert!(result.force.y.abs() < 1e-20);
assert!(result.force.z.abs() < 1e-20);
}
#[test]
fn specular_45_degrees() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let n = DVec3::new(1.0, 1.0, 0.0).normalize();
let facet = AeroFacet {
area: 10.0,
normal: n,
center_pressure: DVec3::ZERO,
coeff_method: AeroCoeffMethod::Specular,
temperature: 300.0,
};
let result = compute_flat_plate_aero(&[facet], rel_vel, dyn_press, &gas, DVec3::ZERO);
let sin_alpha = 1.0 / 2.0_f64.sqrt();
let s = velocity / (2.0 * gas.gas_const * gas.temp_free_stream).sqrt();
let s_2 = s * s;
let exp_s2 = (-s_2).exp();
let cd_spec = (2.0 * M_2_SQRTPI * s * exp_s2 + 2.0 + 4.0 * s_2) / s_2;
let force_n = -dyn_press * 10.0 * cd_spec * sin_alpha * sin_alpha;
let expected_force = n * force_n;
let err = (result.force - expected_force).length();
let rel_err = err / expected_force.length();
assert!(
rel_err < 1e-12,
"Specular 45-deg: expected ({:.6e}, {:.6e}, {:.6e}), got ({:.6e}, {:.6e}, {:.6e})",
expected_force.x,
expected_force.y,
expected_force.z,
result.force.x,
result.force.y,
result.force.z
);
}
#[test]
fn diffuse_normal_incidence() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let facet = AeroFacet {
area: 10.0,
normal: DVec3::new(1.0, 0.0, 0.0),
center_pressure: DVec3::ZERO,
coeff_method: AeroCoeffMethod::Diffuse,
temperature: 300.0,
};
let result = compute_flat_plate_aero(&[facet], rel_vel, dyn_press, &gas, DVec3::ZERO);
let s = velocity / (2.0 * gas.gas_const * gas.temp_free_stream).sqrt();
let s_2 = s * s;
let exp_s2 = (-s_2).exp();
let temp_ratio = facet.temperature / gas.temp_free_stream;
let cd_diff = (M_2_SQRTPI * s * exp_s2
+ temp_ratio.sqrt() * (2.0 / M_2_SQRTPI) * s
+ 1.0
+ 2.0 * s_2)
/ s_2;
let expected_force_x = -dyn_press * 10.0 * cd_diff;
let rel_err = (result.force.x - expected_force_x).abs() / expected_force_x.abs();
assert!(
rel_err < 1e-12,
"Diffuse normal force: expected {expected_force_x:.6e}, got {:.6e}",
result.force.x
);
assert!(result.force.x < 0.0, "Force should oppose motion");
}
#[test]
fn mixed_epsilon_1_matches_specular() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let n = DVec3::new(1.0, 1.0, 0.0).normalize();
let facet_spec = AeroFacet {
area: 10.0,
normal: n,
center_pressure: DVec3::ZERO,
coeff_method: AeroCoeffMethod::Specular,
temperature: 300.0,
};
let facet_mixed = AeroFacet {
coeff_method: AeroCoeffMethod::Mixed { epsilon: 1.0 },
..facet_spec
};
let r_spec = compute_flat_plate_aero(&[facet_spec], rel_vel, dyn_press, &gas, DVec3::ZERO);
let r_mixed =
compute_flat_plate_aero(&[facet_mixed], rel_vel, dyn_press, &gas, DVec3::ZERO);
let err = (r_spec.force - r_mixed.force).length();
assert!(
err < 1e-20,
"Mixed(epsilon=1) should match Specular: err = {err:.6e}"
);
}
#[test]
fn mixed_epsilon_0_matches_diffuse() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let n = DVec3::new(1.0, 1.0, 0.0).normalize();
let facet_diff = AeroFacet {
area: 10.0,
normal: n,
center_pressure: DVec3::ZERO,
coeff_method: AeroCoeffMethod::Diffuse,
temperature: 300.0,
};
let facet_mixed = AeroFacet {
coeff_method: AeroCoeffMethod::Mixed { epsilon: 0.0 },
..facet_diff
};
let r_diff = compute_flat_plate_aero(&[facet_diff], rel_vel, dyn_press, &gas, DVec3::ZERO);
let r_mixed =
compute_flat_plate_aero(&[facet_mixed], rel_vel, dyn_press, &gas, DVec3::ZERO);
let err = (r_diff.force - r_mixed.force).length();
assert!(
err < 1e-20,
"Mixed(epsilon=0) should match Diffuse: err = {err:.6e}"
);
}
#[test]
fn multi_facet_accumulation_and_torque() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let facet_up = AeroFacet {
area: 10.0,
normal: DVec3::new(1.0, 0.0, 0.0),
center_pressure: DVec3::new(0.0, 5.0, 0.0),
coeff_method: AeroCoeffMethod::Specular,
temperature: 300.0,
};
let facet_down = AeroFacet {
center_pressure: DVec3::new(0.0, -5.0, 0.0),
..facet_up
};
let single = compute_flat_plate_aero(&[facet_up], rel_vel, dyn_press, &gas, DVec3::ZERO);
let both = compute_flat_plate_aero(
&[facet_up, facet_down],
rel_vel,
dyn_press,
&gas,
DVec3::ZERO,
);
let force_ratio = both.force.length() / single.force.length();
assert!(
(force_ratio - 2.0).abs() < 1e-12,
"Two facets should give 2x force, got ratio {force_ratio}"
);
assert!(
both.torque.length() < 1e-20,
"Symmetric facets should produce zero net torque, got {:?}",
both.torque
);
}
#[test]
fn single_facet_produces_torque() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let facet = AeroFacet {
area: 10.0,
normal: DVec3::new(1.0, 0.0, 0.0),
center_pressure: DVec3::new(0.0, 3.0, 0.0), coeff_method: AeroCoeffMethod::Specular,
temperature: 300.0,
};
let result = compute_flat_plate_aero(&[facet], rel_vel, dyn_press, &gas, DVec3::ZERO);
assert!(result.force.x < 0.0, "Force should be along -X");
assert!(
result.torque.z > 0.0,
"Torque Z should be positive (right-hand rule)"
);
assert!(result.torque.x.abs() < 1e-20);
assert!(result.torque.y.abs() < 1e-20);
}
#[test]
fn specular_vs_ballistic_comparison() {
let density = 1e-12;
let velocity = 7600.0;
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let s = velocity / (2.0 * gas.gas_const * gas.temp_free_stream).sqrt();
let s_2 = s * s;
let exp_s2 = (-s_2).exp();
let cd_spec = (2.0 * M_2_SQRTPI * s * exp_s2 + 2.0 + 4.0 * s_2) / s_2;
assert!(
cd_spec > 3.5 && cd_spec < 5.0,
"At LEO speeds, specular Cd should be ~4, got {cd_spec}"
);
let area = 10.0;
let flat_plate_force_mag = dyn_press * area * cd_spec;
let ballistic_force_mag = dyn_press * area * 2.2;
assert!(
flat_plate_force_mag > ballistic_force_mag,
"Specular flat-plate should exceed ballistic (Cd=2.2): {flat_plate_force_mag:.6e} vs {ballistic_force_mag:.6e}"
);
}
#[test]
fn calc_coef_normal_incidence_no_tangential() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let facet = AeroFacet {
area: 10.0,
normal: DVec3::new(1.0, 0.0, 0.0),
center_pressure: DVec3::ZERO,
coeff_method: AeroCoeffMethod::CalcCoef { epsilon: 0.5 },
temperature: 300.0,
};
let result = compute_flat_plate_aero(&[facet], rel_vel, dyn_press, &gas, DVec3::ZERO);
assert!(result.force.x < 0.0, "Force should oppose motion");
assert!(result.force.y.abs() < 1e-20, "No Y component");
assert!(result.force.z.abs() < 1e-20, "No Z component");
}
#[test]
fn calc_coef_oblique_incidence() {
let density = 1e-12;
let velocity = 7600.0;
let rel_vel = DVec3::new(velocity, 0.0, 0.0);
let dyn_press = 0.5 * density * velocity * velocity;
let gas = leo_gas_params();
let facet = AeroFacet {
area: 10.0,
normal: DVec3::new(1.0, 1.0, 0.0).normalize(),
center_pressure: DVec3::ZERO,
coeff_method: AeroCoeffMethod::CalcCoef { epsilon: 0.5 },
temperature: 300.0,
};
let result = compute_flat_plate_aero(&[facet], rel_vel, dyn_press, &gas, DVec3::ZERO);
assert!(result.force.x < 0.0, "Force X should oppose motion");
assert!(
result.force.y.abs() > 1e-30,
"Force Y should be non-zero for oblique incidence"
);
}
#[test]
fn zero_velocity_zero_force() {
let gas = leo_gas_params();
let facet = AeroFacet {
area: 10.0,
normal: DVec3::new(1.0, 0.0, 0.0),
center_pressure: DVec3::ZERO,
coeff_method: AeroCoeffMethod::Specular,
temperature: 300.0,
};
let result = compute_flat_plate_aero(&[facet], DVec3::ZERO, 0.0, &gas, DVec3::ZERO);
assert_eq!(result.force, DVec3::ZERO);
assert_eq!(result.torque, DVec3::ZERO);
}
}