use crate::pitch_damping::{
calculate_damped_yaw_of_repose, calculate_pitch_damping_moment, PitchDampingCoefficients,
};
use crate::spin_decay::{update_spin_rate, SpinDecayParameters};
use crate::BallisticInputs;
use std::f64::consts::PI;
#[derive(Debug, Clone)]
pub struct SpinDriftComponents {
pub spin_rate_rps: f64, pub spin_rate_rad_s: f64, pub stability_factor: f64, pub yaw_of_repose_rad: f64, pub drift_rate_mps: f64, pub total_drift_m: f64, pub magnus_component_m: f64, pub gyroscopic_component_m: f64, pub pitch_damping_moment: f64, pub yaw_convergence_rate: f64, pub pitch_rate_rad_s: f64, }
pub(crate) fn miller_stability(
caliber_in: f64,
weight_gr: f64,
twist_in: f64,
length_in: f64,
) -> f64 {
if caliber_in <= 0.0 || weight_gr <= 0.0 || twist_in <= 0.0 || length_in <= 0.0 {
return 0.0;
}
let twist_cal = twist_in / caliber_in;
let l_cal = length_in / caliber_in;
let denom = twist_cal * twist_cal * caliber_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
if denom == 0.0 {
return 0.0;
}
30.0 * weight_gr / denom
}
pub fn litz_drift_inches(sg: f64, t_s: f64) -> f64 {
1.25 * (sg + 1.2) * t_s.powf(1.83)
}
pub fn litz_drift_meters(sg: f64, t_s: f64, is_twist_right: bool) -> f64 {
let sign = if is_twist_right { 1.0 } else { -1.0 };
sign * litz_drift_inches(sg, t_s) * 0.0254
}
pub fn effective_sg_from_inputs(inputs: &BallisticInputs, temp_c: f64, press_hpa: f64) -> f64 {
let mut eff = inputs.clone();
if eff.bullet_length <= 0.0 && eff.bullet_diameter > 0.0 {
let est = crate::stability::estimate_bullet_length_m(eff.bullet_diameter, eff.bullet_mass);
eff.bullet_length = if est > 0.0 {
est
} else {
4.5 * eff.bullet_diameter };
}
crate::stability::compute_stability_coefficient(&eff, (eff.altitude, temp_c, press_hpa, 0.0))
}
pub fn calculate_spin_rate(velocity_mps: f64, twist_rate_inches: f64) -> (f64, f64) {
if twist_rate_inches <= 0.0 {
return (0.0, 0.0);
}
let velocity_ips = velocity_mps * 39.3701;
let spin_rate_rps = velocity_ips / twist_rate_inches;
let spin_rate_rad_s = spin_rate_rps * 2.0 * PI;
(spin_rate_rps, spin_rate_rad_s)
}
pub fn calculate_dynamic_stability(
bullet_mass_grains: f64,
velocity_mps: f64,
spin_rate_rad_s: f64,
caliber_inches: f64,
length_inches: f64,
air_density_kg_m3: f64,
) -> f64 {
if spin_rate_rad_s == 0.0 || velocity_mps == 0.0 {
return 0.0;
}
let velocity_fps = velocity_mps * 3.28084;
if caliber_inches > 0.0 {
let spin_rps = spin_rate_rad_s / (2.0 * PI);
let velocity_ips = velocity_fps * 12.0; let twist_inches = if spin_rps > 0.0 {
velocity_ips / spin_rps
} else {
0.0
};
let twist_calibers = if twist_inches > 0.0 {
twist_inches / caliber_inches
} else {
0.0
};
let length_calibers = if caliber_inches > 0.0 {
length_inches / caliber_inches
} else {
0.0
};
if twist_calibers == 0.0 || length_calibers == 0.0 {
return 0.0;
}
let numerator = 30.0 * bullet_mass_grains;
let denominator = twist_calibers.powi(2)
* caliber_inches.powi(3)
* length_calibers
* (1.0 + length_calibers.powi(2));
if denominator == 0.0 {
return 0.0;
}
let sg_base = numerator / denominator;
let velocity_factor = (velocity_fps / 2800.0).powf(1.0 / 3.0);
let density_factor = 1.225 / air_density_kg_m3;
sg_base * velocity_factor * density_factor
} else {
0.0
}
}
pub fn calculate_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,
use_pitch_damping: bool,
) -> (f64, f64) {
if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
return (0.0, 0.0);
}
if use_pitch_damping && mach > 0.0 {
let damping_type = match bullet_type.to_lowercase().as_str() {
"match" => "match_boat_tail",
"hunting" => "hunting",
"fmj" => "fmj",
"vld" => "vld",
_ => "match_boat_tail",
};
return calculate_damped_yaw_of_repose(
stability_factor,
velocity_mps,
spin_rate_rad_s,
wind_velocity_mps,
pitch_rate_rad_s,
air_density_kg_m3,
caliber_inches,
length_inches,
mass_grains,
mach,
damping_type,
);
}
let yaw_rad = if wind_velocity_mps == 0.0 {
0.002 } else {
if velocity_mps > 0.0 {
(wind_velocity_mps / velocity_mps).atan()
} else {
0.0
}
};
let stability_term = (stability_factor - 1.0).max(0.0).sqrt();
let damping = 1.0 / (1.0 + stability_term);
(yaw_rad * damping, 0.0) }
pub fn calculate_magnus_drift_component(
velocity_mps: f64,
spin_rate_rad_s: f64,
yaw_rad: f64,
air_density_kg_m3: f64,
caliber_inches: f64,
time_s: f64,
mass_grains: f64,
) -> f64 {
let diameter_m = caliber_inches * 0.0254;
let mass_kg = mass_grains * 0.00006479891;
let mach = velocity_mps / 343.0;
let cmag = if mach < 0.8 {
0.25
} else if mach < 1.2 {
0.15
} else {
0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
};
let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
let magnus_force = if velocity_mps > 0.0 {
cmag * spin_ratio
* yaw_rad
* 0.5
* air_density_kg_m3
* velocity_mps.powi(2)
* PI
* (diameter_m / 2.0).powi(2)
} else {
0.0
};
let magnus_accel = magnus_force / mass_kg;
0.5 * magnus_accel * time_s.powi(2)
}
pub fn calculate_gyroscopic_drift(
stability_factor: f64,
_yaw_rad: f64,
velocity_mps: f64,
time_s: f64,
is_right_twist: bool,
) -> f64 {
if stability_factor <= 1.0 || time_s <= 0.0 {
return 0.0;
}
let velocity_fps = velocity_mps * 3.28084;
if velocity_fps < 1125.0 {
return 0.0;
}
let sign = if is_right_twist { 1.0 } else { -1.0 };
let base_coefficient = 1.25 * (stability_factor + 1.2);
let time_factor = time_s.powf(1.83);
let drift_in = sign * base_coefficient * time_factor;
drift_in * 0.0254
}
pub fn calculate_enhanced_spin_drift(
bullet_mass: f64,
velocity_mps: f64,
twist_rate: f64,
bullet_diameter: f64,
bullet_length: f64,
is_twist_right: bool,
time_s: f64,
air_density: f64,
crosswind_mps: f64,
pitch_rate_rad_s: f64,
use_pitch_damping: bool,
) -> SpinDriftComponents {
let muzzle_velocity = velocity_mps; let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
let decay_params = SpinDecayParameters::from_bullet_type("match"); let current_spin_rad_s = update_spin_rate(
initial_spin_rad_s,
time_s,
velocity_mps,
air_density,
bullet_mass, bullet_diameter,
bullet_length,
Some(&decay_params),
);
let spin_rps = current_spin_rad_s / (2.0 * PI);
let spin_rad_s = current_spin_rad_s;
let stability = calculate_dynamic_stability(
bullet_mass,
velocity_mps,
spin_rad_s,
bullet_diameter,
bullet_length,
air_density,
);
let mach = velocity_mps / 343.0;
let bullet_type = "match";
let (yaw_rad, convergence_rate) = calculate_yaw_of_repose(
stability,
velocity_mps,
spin_rad_s,
crosswind_mps,
pitch_rate_rad_s,
air_density,
bullet_diameter,
bullet_length,
bullet_mass,
mach,
bullet_type,
use_pitch_damping,
);
let magnus_drift = calculate_magnus_drift_component(
velocity_mps,
spin_rad_s,
yaw_rad,
air_density,
bullet_diameter,
time_s,
bullet_mass,
);
let gyro_drift =
calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
let total_drift = twist_sign * magnus_drift + gyro_drift;
let drift_rate = if time_s > 0.0 {
total_drift / time_s
} else {
0.0
};
let pitch_damping_moment = if use_pitch_damping && mach > 0.0 {
let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
calculate_pitch_damping_moment(
pitch_rate_rad_s,
velocity_mps,
air_density,
bullet_diameter * 0.0254, bullet_length * 0.0254, mach,
&coeffs,
)
} else {
0.0
};
SpinDriftComponents {
spin_rate_rps: spin_rps,
spin_rate_rad_s: spin_rad_s,
stability_factor: stability,
yaw_of_repose_rad: yaw_rad,
drift_rate_mps: drift_rate,
total_drift_m: total_drift,
magnus_component_m: magnus_drift,
gyroscopic_component_m: gyro_drift,
pitch_damping_moment,
yaw_convergence_rate: convergence_rate,
pitch_rate_rad_s,
}
}
pub fn apply_enhanced_spin_drift(
derivatives: &mut [f64; 6],
spin_components: &SpinDriftComponents,
time_s: f64,
_is_right_twist: bool,
) {
if time_s > 0.1 {
let spin_accel_z = 2.0 * spin_components.drift_rate_mps / time_s;
derivatives[5] += spin_accel_z;
}
}
pub fn compute_enhanced_spin_drift_simple(
time_s: f64,
stability: f64,
velocity_mps: f64,
twist_rate: f64,
is_twist_right: bool,
_caliber: f64,
) -> f64 {
if twist_rate <= 0.0 {
return 0.0;
}
let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
let decay_params = SpinDecayParameters::from_bullet_type("match");
let spin_rad_s = update_spin_rate(
initial_spin_rad_s,
time_s,
velocity_mps,
1.225, 175.0, _caliber,
1.3, Some(&decay_params),
);
let (yaw_rad, _) = calculate_yaw_of_repose(
stability,
velocity_mps,
spin_rad_s,
0.0,
0.0,
1.225,
_caliber,
1.3,
175.0,
velocity_mps / 343.0,
"match",
false,
);
calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_spin_rate() {
let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
assert!((rps - 3149.6).abs() < 1.0);
assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
let (rps_zero, rad_s_zero) = calculate_spin_rate(800.0, 0.0);
assert_eq!(rps_zero, 0.0);
assert_eq!(rad_s_zero, 0.0);
}
#[test]
fn test_calculate_dynamic_stability() {
let sg = calculate_dynamic_stability(
168.0, 800.0, 19792.0, 0.308, 1.2, 1.225, );
assert!(sg > 1.0);
assert!(sg < 10.0); }
#[test]
fn test_calculate_yaw_of_repose() {
let (yaw, _) = calculate_yaw_of_repose(
2.5, 800.0, 19792.0, 10.0, 0.0, 1.225, 0.308, 1.2, 168.0, 2.33, "match", false, );
assert!(yaw.abs() > 0.0);
assert!(yaw.abs() < 0.1); }
#[test]
fn test_enhanced_spin_drift_calculation() {
let components = calculate_enhanced_spin_drift(
168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 10.0, 0.0, false, );
assert!(components.total_drift_m.abs() > 0.0);
assert!(components.spin_rate_rps > 0.0);
assert!(components.stability_factor > 0.0);
}
#[test]
fn test_litz_drift_helpers_sign_and_magnitude() {
let sg = 2.0_f64;
let t = 1.5_f64;
let expected_in = 1.25 * (sg + 1.2) * t.powf(1.83);
assert!((litz_drift_inches(sg, t) - expected_in).abs() < 1e-12);
let right = litz_drift_meters(sg, t, true);
let left = litz_drift_meters(sg, t, false);
assert!((right - expected_in * 0.0254).abs() < 1e-12);
assert!((right + left).abs() < 1e-12, "left twist must mirror right");
assert!(right > 0.0 && left < 0.0);
}
#[test]
fn test_effective_sg_from_inputs_includes_velocity_term_and_length_fallback() {
let inputs = BallisticInputs {
muzzle_velocity: 800.0, bullet_mass: 175.0 * 0.00006479891,
bullet_diameter: 0.308 * 0.0254,
bullet_length: 1.24 * 0.0254,
twist_rate: 10.0,
..Default::default()
};
let temp_c = 15.0;
let press_hpa = 1013.25;
let sg = effective_sg_from_inputs(&inputs, temp_c, press_hpa);
let direct =
crate::stability::compute_stability_coefficient(&inputs, (0.0, temp_c, press_hpa, 0.0));
assert!((sg - direct).abs() < 1e-12, "sg {sg} != direct {direct}");
let d_in = inputs.bullet_diameter / 0.0254;
let m_gr = inputs.bullet_mass / 0.00006479891;
let l_in = inputs.bullet_length / 0.0254;
let bare = miller_stability(d_in, m_gr, inputs.twist_rate, l_in);
let vel_corr = (inputs.muzzle_velocity * 3.28084 / 2800.0).powf(1.0 / 3.0);
assert!(vel_corr < 1.0, "muzzle < 2800 fps should shrink Sg");
assert!(
(sg - bare * vel_corr).abs() < 1e-6,
"sg {sg} != bare {bare} * vel_corr {vel_corr}"
);
let mut no_len = inputs.clone();
no_len.bullet_length = 0.0;
let sg_fallback = effective_sg_from_inputs(&no_len, temp_c, press_hpa);
let mut explicit = inputs.clone();
explicit.bullet_length =
crate::stability::estimate_bullet_length_m(inputs.bullet_diameter, inputs.bullet_mass);
let sg_explicit = effective_sg_from_inputs(&explicit, temp_c, press_hpa);
assert!(
(sg_fallback - sg_explicit).abs() < 1e-12,
"zero-length fallback {sg_fallback} != explicit estimate-length {sg_explicit}"
);
assert!(sg_fallback > 0.0);
let mut old_default = inputs.clone();
old_default.bullet_length = 4.5 * old_default.bullet_diameter;
let sg_old = effective_sg_from_inputs(&old_default, temp_c, press_hpa);
assert!(
(sg_fallback - sg_old).abs() > 1e-6,
"mass-based fallback should differ from the old 4.5-cal default"
);
}
#[test]
fn test_miller_stability_308_168gr() {
let sg = miller_stability(0.308, 168.0, 12.0, 1.215);
assert!(sg > 1.5 && sg < 2.0, "expected base Sg ~1.74, got {}", sg);
}
#[test]
fn test_miller_stability_invalid_inputs_zero() {
assert_eq!(miller_stability(0.0, 168.0, 12.0, 1.2), 0.0);
assert_eq!(miller_stability(0.308, 0.0, 12.0, 1.2), 0.0);
assert_eq!(miller_stability(0.308, 168.0, 0.0, 1.2), 0.0);
assert_eq!(miller_stability(0.308, 168.0, 12.0, 0.0), 0.0);
}
#[test]
fn test_opposite_twist_directions() {
let right_drift = calculate_enhanced_spin_drift(
168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 0.0, 0.0, false,
);
let left_drift = calculate_enhanced_spin_drift(
168.0, 800.0, 10.0, 0.308, 1.2, false, 1.0, 1.225, 0.0, 0.0, false,
);
assert!(right_drift.gyroscopic_component_m * left_drift.gyroscopic_component_m < 0.0);
assert!(
(right_drift.gyroscopic_component_m.abs() - left_drift.gyroscopic_component_m.abs())
.abs()
< 0.001
);
}
#[test]
fn test_applied_spin_drift_flips_with_twist() {
let time_s = 1.0;
let right = calculate_enhanced_spin_drift(
168.0, 800.0, 10.0, 0.308, 1.2, true, time_s, 1.225, 0.0, 0.0, false,
);
let left = calculate_enhanced_spin_drift(
168.0, 800.0, 10.0, 0.308, 1.2, false, time_s, 1.225, 0.0, 0.0, false,
);
let mut d_right = [0.0_f64; 6];
let mut d_left = [0.0_f64; 6];
apply_enhanced_spin_drift(&mut d_right, &right, time_s, true);
apply_enhanced_spin_drift(&mut d_left, &left, time_s, false);
assert!(d_right[5].abs() > 0.0, "expected non-zero spin drift accel");
assert!(d_left[5].abs() > 0.0, "expected non-zero spin drift accel");
assert!(
d_right[5] * d_left[5] < 0.0,
"expected opposite-sign lateral accel for opposite twist, got {} and {}",
d_right[5],
d_left[5]
);
}
}