use crate::constants::G_ACCEL_MPS2;
use crate::spin_decay::calculate_moment_of_inertia;
use std::f64::consts::PI;
#[derive(Debug, Clone, Copy)]
pub struct PitchDampingCoefficients {
pub subsonic: f64, pub transonic_low: f64, pub transonic_high: f64, pub supersonic: f64, }
impl Default for PitchDampingCoefficients {
fn default() -> Self {
Self {
subsonic: -8.0,
transonic_low: -3.0,
transonic_high: 2.0,
supersonic: -5.0,
}
}
}
impl PitchDampingCoefficients {
pub fn from_bullet_type(bullet_type: &str) -> Self {
match bullet_type.to_lowercase().as_str() {
"match_boat_tail" => Self {
subsonic: -9.0,
transonic_low: -4.0,
transonic_high: 1.0,
supersonic: -6.0,
},
"match_flat_base" => Self {
subsonic: -7.0,
transonic_low: -2.0,
transonic_high: 3.0,
supersonic: -4.0,
},
"vld" => Self {
subsonic: -10.0,
transonic_low: -5.0,
transonic_high: -1.0,
supersonic: -7.0,
},
"hunting" => Self {
subsonic: -6.0,
transonic_low: -1.0,
transonic_high: 4.0,
supersonic: -3.0,
},
"fmj" => Self {
subsonic: -7.0,
transonic_low: -2.0,
transonic_high: 2.0,
supersonic: -5.0,
},
_ => Self::default(),
}
}
}
pub fn calculate_pitch_damping_coefficient(mach: f64, coeffs: &PitchDampingCoefficients) -> f64 {
if mach < 0.8 {
coeffs.subsonic
} else if mach < 1.0 {
let t = (mach - 0.8) / 0.2;
coeffs.subsonic * (1.0 - t) + coeffs.transonic_low * t
} else if mach < 1.2 {
let t = (mach - 1.0) / 0.2;
coeffs.transonic_low * (1.0 - t) + coeffs.transonic_high * t
} else {
let t = ((mach - 1.2) / 0.8).min(1.0);
coeffs.transonic_high * (1.0 - t) + coeffs.supersonic * t
}
}
pub fn calculate_pitch_damping_moment(
pitch_rate_rad_s: f64,
velocity_mps: f64,
air_density_kg_m3: f64,
caliber_m: f64,
_length_m: f64,
mach: f64,
coeffs: &PitchDampingCoefficients,
) -> f64 {
if velocity_mps == 0.0 || pitch_rate_rad_s == 0.0 {
return 0.0;
}
let cmq = calculate_pitch_damping_coefficient(mach, coeffs);
let q = 0.5 * air_density_kg_m3 * velocity_mps.powi(2);
let s = PI * (caliber_m / 2.0).powi(2);
let d = caliber_m;
let q_nondim = pitch_rate_rad_s * d / velocity_mps;
q * s * d * cmq * q_nondim
}
pub fn calculate_transverse_moment_of_inertia(
mass_kg: f64,
caliber_m: f64,
length_m: f64,
shape: &str,
) -> f64 {
let radius = caliber_m / 2.0;
match shape {
"cylinder" => {
mass_kg * (3.0 * radius.powi(2) + length_m.powi(2)) / 12.0
}
"ogive" => {
let cylinder_i = mass_kg * (3.0 * radius.powi(2) + length_m.powi(2)) / 12.0;
0.85 * cylinder_i
}
"boat_tail" => {
let cylinder_i = mass_kg * (3.0 * radius.powi(2) + length_m.powi(2)) / 12.0;
0.80 * cylinder_i
}
_ => {
mass_kg * (3.0 * radius.powi(2) + length_m.powi(2)) / 12.0
}
}
}
pub fn calculate_angular_acceleration(moment: f64, moment_of_inertia: f64) -> f64 {
if moment_of_inertia > 0.0 {
moment / moment_of_inertia
} else {
0.0
}
}
pub(crate) fn calculate_gravity_yaw_of_repose(
stability_factor: f64,
velocity_mps: f64,
spin_rate_rad_s: f64,
mass_kg: f64,
caliber_m: f64,
length_m: f64,
) -> f64 {
if !stability_factor.is_finite()
|| stability_factor <= 1.0
|| !velocity_mps.is_finite()
|| velocity_mps <= 0.0
|| !spin_rate_rad_s.is_finite()
|| spin_rate_rad_s == 0.0
|| !mass_kg.is_finite()
|| mass_kg <= 0.0
|| !caliber_m.is_finite()
|| caliber_m <= 0.0
|| !length_m.is_finite()
|| length_m <= 0.0
{
return 0.0;
}
let axial_inertia = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
let transverse_inertia =
calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
if axial_inertia <= 0.0 || transverse_inertia <= 0.0 {
return 0.0;
}
4.0 * transverse_inertia * stability_factor * G_ACCEL_MPS2
/ (axial_inertia * spin_rate_rad_s.abs() * velocity_mps)
}
pub fn calculate_damped_yaw_of_repose(
stability_factor: f64,
velocity_mps: f64,
spin_rate_rad_s: f64,
_wind_velocity_mps: f64,
pitch_rate_rad_s: f64,
air_density_kg_m3: f64,
caliber_inches: f64,
length_inches: f64,
mass_grains: f64,
mach: f64,
bullet_type: &str,
) -> (f64, f64) {
if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
return (0.0, 0.0);
}
let caliber_m = caliber_inches * 0.0254;
let length_m = length_inches * 0.0254;
let mass_kg = mass_grains * 0.00006479891;
let equilibrium_yaw_rad = calculate_gravity_yaw_of_repose(
stability_factor,
velocity_mps,
spin_rate_rad_s,
mass_kg,
caliber_m,
length_m,
);
let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
let damping_moment = calculate_pitch_damping_moment(
pitch_rate_rad_s,
velocity_mps,
air_density_kg_m3,
caliber_m,
length_m,
mach,
&coeffs,
);
let i_transverse =
calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
let angular_accel = calculate_angular_acceleration(damping_moment, i_transverse);
let convergence_rate = if angular_accel != 0.0 && pitch_rate_rad_s != 0.0 {
-angular_accel / pitch_rate_rad_s
} else {
0.1
};
(equilibrium_yaw_rad, convergence_rate)
}
#[deprecated(
since = "0.22.18",
note = "use precession_nutation::calculate_precession_frequency; damping changes modal amplitude, not phase frequency"
)]
pub fn calculate_precession_with_damping(
spin_rate_rad_s: f64,
spin_inertia: f64,
transverse_inertia: f64,
stability_factor: f64,
) -> f64 {
crate::precession_nutation::calculate_precession_frequency(
spin_rate_rad_s,
spin_inertia,
transverse_inertia,
stability_factor,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pitch_damping_coefficient() {
let coeffs = PitchDampingCoefficients::default();
assert_eq!(calculate_pitch_damping_coefficient(0.5, &coeffs), -8.0);
let transonic = calculate_pitch_damping_coefficient(0.9, &coeffs);
assert!(transonic > -8.0 && transonic < -3.0);
let supersonic = calculate_pitch_damping_coefficient(2.0, &coeffs);
assert_eq!(supersonic, -5.0);
}
#[test]
fn test_pitch_damping_moment() {
let coeffs = PitchDampingCoefficients::default();
let moment = calculate_pitch_damping_moment(
0.1, 300.0, 1.225, 0.00782, 0.033, 0.87, &coeffs,
);
assert!(moment < 0.0);
}
#[test]
fn default_308_pitch_rate_damps_on_small_arms_timescale() {
let pitch_rate = 0.1;
let velocity = 850.0;
let density = 1.225;
let caliber = 0.308 * 0.0254;
let length = 1.3 * 0.0254;
let mass = 175.0 * 0.00006479891;
let mach = velocity / 343.0;
let coeffs = PitchDampingCoefficients::default();
let moment = calculate_pitch_damping_moment(
pitch_rate, velocity, density, caliber, length, mach, &coeffs,
);
let inertia = calculate_transverse_moment_of_inertia(mass, caliber, length, "ogive");
let angular_accel = calculate_angular_acceleration(moment, inertia);
let time_constant = (pitch_rate / angular_accel).abs();
assert!(
(0.05..=0.25).contains(&time_constant),
".308 pitch damping should settle on a small-arms timescale, got tau={time_constant}s"
);
}
#[test]
fn test_bullet_type_coefficients() {
let types = [
"match_boat_tail",
"match_flat_base",
"vld",
"hunting",
"fmj",
"unknown",
];
for bullet_type in &types {
let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
assert!(coeffs.subsonic < 0.0);
assert!(coeffs.supersonic < 0.0);
assert!(coeffs.subsonic.abs() >= 3.0);
assert!(coeffs.supersonic.abs() >= 3.0);
if *bullet_type == "vld" {
let default_coeffs = PitchDampingCoefficients::default();
assert!(coeffs.subsonic < default_coeffs.subsonic);
}
}
}
#[test]
fn test_transonic_instability() {
let coeffs = PitchDampingCoefficients::from_bullet_type("hunting");
assert!(coeffs.transonic_high > 0.0);
let mach_1_1 = calculate_pitch_damping_coefficient(1.1, &coeffs);
assert!(mach_1_1 > coeffs.transonic_low);
}
#[test]
fn test_transverse_moment_of_inertia() {
let mass_kg = 0.01134; let caliber_m = 0.00782; let length_m = 0.033;
let i_cylinder =
calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "cylinder");
let i_ogive = calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
let i_boat_tail =
calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "boat_tail");
let i_unknown =
calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "unknown");
assert!(i_cylinder > i_ogive);
assert!(i_ogive > i_boat_tail);
assert_eq!(i_cylinder, i_unknown);
assert!(i_cylinder > 0.0);
assert!(i_cylinder < 1.0); }
#[test]
fn test_angular_acceleration() {
let moment = -0.001; let inertia = 0.0001;
let accel = calculate_angular_acceleration(moment, inertia);
assert_eq!(accel, moment / inertia);
let accel_zero = calculate_angular_acceleration(moment, 0.0);
assert_eq!(accel_zero, 0.0);
}
#[test]
fn test_damped_yaw_of_repose() {
let (yaw, convergence) = calculate_damped_yaw_of_repose(
2.5, 800.0, 19000.0, 10.0, 0.01, 1.225, 0.308, 1.3, 175.0, 0.9, "match_boat_tail",
);
assert!(yaw > 0.0);
assert!(yaw < 0.1); assert!(convergence > 0.0);
let (yaw_unstable, conv_unstable) = calculate_damped_yaw_of_repose(
0.9,
800.0,
19000.0,
10.0,
0.01,
1.225,
0.308,
1.3,
175.0,
0.9,
"match_boat_tail",
);
assert_eq!(yaw_unstable, 0.0);
assert_eq!(conv_unstable, 0.0);
}
#[test]
fn damped_yaw_convergence_rate_preserves_stability_sign() {
let rate = |mach, pitch_rate_rad_s| {
calculate_damped_yaw_of_repose(
2.5,
800.0,
19_000.0,
0.0,
pitch_rate_rad_s,
1.225,
0.308,
1.3,
175.0,
mach,
"fmj",
)
.1
};
let damped = rate(1.0, 0.01);
let divergent = rate(1.2, 0.01);
assert!(damped > 0.0);
assert!(divergent < 0.0);
assert_eq!(damped.to_bits(), (-divergent).to_bits());
}
#[test]
fn crosswind_is_not_persistent_equilibrium_yaw() {
let calculate = |wind_velocity_mps| {
calculate_damped_yaw_of_repose(
2.5,
300.0,
19_000.0,
wind_velocity_mps,
0.01,
1.225,
0.308,
1.3,
175.0,
0.875,
"match_boat_tail",
)
};
let (calm_yaw, calm_rate) = calculate(0.0);
let (windy_yaw, windy_rate) = calculate(10.0);
assert!(
(windy_yaw - calm_yaw).abs() < 1e-12,
"crosswind became persistent equilibrium yaw: calm={calm_yaw} windy={windy_yaw}"
);
assert_eq!(windy_rate.to_bits(), calm_rate.to_bits());
assert!(windy_yaw.abs() < 0.003);
}
#[test]
fn gravity_yaw_of_repose_matches_classical_stability_reduction() {
let stability_factor = 2.5;
let velocity_mps = 300.0;
let spin_rate_rad_s = 19_000.0;
let mass_kg = 175.0 * 0.00006479891;
let caliber_m = 0.308 * 0.0254;
let length_m = 1.3 * 0.0254;
let actual = calculate_gravity_yaw_of_repose(
stability_factor,
velocity_mps,
spin_rate_rad_s,
mass_kg,
caliber_m,
length_m,
);
let radius_m = caliber_m / 2.0;
let axial_inertia = 0.4 * mass_kg * radius_m.powi(2);
let transverse_inertia =
0.85 * mass_kg * (3.0 * radius_m.powi(2) + length_m.powi(2)) / 12.0;
let expected = 4.0 * transverse_inertia * stability_factor * 9.80665
/ (axial_inertia * spin_rate_rad_s * velocity_mps);
assert!((actual - expected).abs() < 1e-15);
assert!((actual - 0.000226244442784).abs() < 1e-15);
}
#[test]
#[allow(deprecated)]
fn test_precession_with_damping() {
let precession = calculate_precession_with_damping(
19000.0, 0.00005, 0.0001, 2.5, );
assert!(precession > 0.0);
let precession_zero = calculate_precession_with_damping(0.0, 0.00005, 0.0001, 2.5);
assert_eq!(precession_zero, 0.0);
let precession_unstable = calculate_precession_with_damping(19000.0, 0.00005, 0.0001, 1.0);
assert_eq!(precession_unstable, 0.0);
}
#[test]
#[allow(deprecated)]
fn precession_uses_slow_epicyclic_frequency() {
let spin_rate_rad_s = 17_522.0;
let spin_inertia = 6.94e-8;
let transverse_inertia = 9.13e-7;
let stability_factor = 2.0;
let expected = crate::precession_nutation::calculate_precession_frequency(
spin_rate_rad_s,
spin_inertia,
transverse_inertia,
stability_factor,
);
let actual = calculate_precession_with_damping(
spin_rate_rad_s,
spin_inertia,
transverse_inertia,
stability_factor,
);
assert!(
(actual - expected).abs() <= expected * 1e-12,
"precession did not use the slow epicyclic rate: actual={actual} expected={expected}"
);
assert!((150.0..250.0).contains(&actual));
}
#[test]
fn test_mach_interpolation() {
let coeffs = PitchDampingCoefficients::default();
let epsilon = 1e-9;
for boundary in [0.8, 1.0, 1.2, 2.0] {
let at_boundary = calculate_pitch_damping_coefficient(boundary, &coeffs);
let below = calculate_pitch_damping_coefficient(boundary - epsilon, &coeffs);
let above = calculate_pitch_damping_coefficient(boundary + epsilon, &coeffs);
assert!((at_boundary - below).abs() < 1e-6);
assert!((at_boundary - above).abs() < 1e-6);
}
}
#[test]
fn test_pitch_damping_edge_cases() {
let coeffs = PitchDampingCoefficients::default();
let moment_zero_pitch =
calculate_pitch_damping_moment(0.0, 300.0, 1.225, 0.00782, 0.033, 0.87, &coeffs);
assert_eq!(moment_zero_pitch, 0.0);
let moment_zero_vel =
calculate_pitch_damping_moment(0.1, 0.0, 1.225, 0.00782, 0.033, 0.87, &coeffs);
assert_eq!(moment_zero_vel, 0.0);
}
#[test]
fn test_default_implementation() {
let coeffs1 = PitchDampingCoefficients::default();
let coeffs2 = PitchDampingCoefficients::from_bullet_type("unknown");
assert_eq!(coeffs1.subsonic, coeffs2.subsonic);
assert_eq!(coeffs1.transonic_low, coeffs2.transonic_low);
assert_eq!(coeffs1.transonic_high, coeffs2.transonic_high);
assert_eq!(coeffs1.supersonic, coeffs2.supersonic);
}
#[test]
fn test_transonic_jump() {
let _coeffs = PitchDampingCoefficients::from_bullet_type("hunting");
let (yaw_subsonic, _) = calculate_damped_yaw_of_repose(
2.5, 250.0, 19000.0, 10.0, 0.01, 1.225, 0.308, 1.3, 175.0, 0.7, "hunting",
);
let (yaw_transonic, _) = calculate_damped_yaw_of_repose(
2.5, 343.0, 19000.0, 10.0, 0.01, 1.225, 0.308, 1.3, 175.0, 1.0, "hunting",
);
assert!(yaw_subsonic > 0.0);
assert!(yaw_transonic > 0.0);
}
}