#![allow(dead_code)]
#[derive(Debug, Clone)]
pub struct StabilityParameters {
pub nose_shape_factor: f64,
pub boat_tail_factor: f64,
pub plastic_tip_factor: f64,
pub cop_adjustment: f64,
}
impl StabilityParameters {
pub fn for_bullet_type(bullet_type: &str, has_boat_tail: bool, _has_plastic_tip: bool) -> Self {
match bullet_type.to_lowercase().as_str() {
"match" | "bthp" => Self {
nose_shape_factor: 0.95,
boat_tail_factor: if has_boat_tail { 0.94 } else { 1.0 },
plastic_tip_factor: 1.0,
cop_adjustment: 0.98,
},
"vld" | "very_low_drag" => Self {
nose_shape_factor: 0.88,
boat_tail_factor: if has_boat_tail { 0.92 } else { 1.0 },
plastic_tip_factor: 1.0,
cop_adjustment: 0.96,
},
"hybrid" => Self {
nose_shape_factor: 0.91,
boat_tail_factor: if has_boat_tail { 0.93 } else { 1.0 },
plastic_tip_factor: 1.0,
cop_adjustment: 0.97,
},
"hunting" => Self {
nose_shape_factor: 0.98,
boat_tail_factor: if has_boat_tail { 0.95 } else { 1.0 },
plastic_tip_factor: 1.0,
cop_adjustment: 0.99,
},
_ => Self::default(),
}
}
pub fn default() -> Self {
Self {
nose_shape_factor: 1.0,
boat_tail_factor: 1.0,
plastic_tip_factor: 1.0,
cop_adjustment: 1.0,
}
}
}
pub fn calculate_advanced_stability(
mass_grains: f64,
velocity_fps: f64,
twist_rate_inches: f64,
caliber_inches: f64,
length_inches: f64,
air_density_kg_m3: f64,
temperature_k: f64,
bullet_type: &str,
has_boat_tail: bool,
has_plastic_tip: bool,
) -> f64 {
if twist_rate_inches == 0.0 || caliber_inches == 0.0 || length_inches == 0.0 {
return 0.0;
}
let params = StabilityParameters::for_bullet_type(bullet_type, has_boat_tail, has_plastic_tip);
let sg_base = calculate_miller_refined(
mass_grains,
twist_rate_inches,
caliber_inches,
length_inches,
params.nose_shape_factor,
);
let sg_velocity_corrected = apply_velocity_correction(sg_base, velocity_fps);
let sg_atmosphere_corrected =
apply_atmospheric_correction(sg_velocity_corrected, air_density_kg_m3, temperature_k);
let sg_boat_tail = sg_atmosphere_corrected * params.boat_tail_factor;
sg_boat_tail * params.cop_adjustment
}
pub fn apply_courtney_miller_plastic_tip_correction(
uncorrected_sg: f64,
caliber_inches: f64,
total_length_inches: f64,
tip_length_inches: f64,
) -> f64 {
if !caliber_inches.is_finite()
|| !total_length_inches.is_finite()
|| !tip_length_inches.is_finite()
|| caliber_inches <= 0.0
|| total_length_inches <= 0.0
|| tip_length_inches <= 0.0
|| tip_length_inches >= total_length_inches
{
return uncorrected_sg;
}
let total_length_calibers = total_length_inches / caliber_inches;
let metal_length_calibers = (total_length_inches - tip_length_inches) / caliber_inches;
let correction = (1.0 + total_length_calibers.powi(2)) / (1.0 + metal_length_calibers.powi(2));
uncorrected_sg * correction
}
fn calculate_miller_refined(
mass_grains: f64,
twist_rate_inches: f64,
caliber_inches: f64,
length_inches: f64,
nose_shape_factor: f64,
) -> f64 {
let twist_calibers = twist_rate_inches / caliber_inches;
let length_calibers = length_inches / caliber_inches;
const MILLER_CONSTANT: f64 = 30.0;
let inertia_factor = 1.0 + length_calibers.powi(2);
let numerator = MILLER_CONSTANT * mass_grains * nose_shape_factor;
let denominator =
twist_calibers.powi(2) * caliber_inches.powi(3) * length_calibers * inertia_factor;
if denominator == 0.0 {
return 0.0;
}
numerator / denominator
}
fn apply_velocity_correction(sg_base: f64, velocity_fps: f64) -> f64 {
const VELOCITY_REFERENCE: f64 = 2800.0;
let velocity_factor = (velocity_fps / VELOCITY_REFERENCE).powf(1.0 / 3.0);
sg_base * velocity_factor
}
fn apply_atmospheric_correction(sg: f64, air_density_kg_m3: f64, _temperature_k: f64) -> f64 {
const STD_DENSITY: f64 = 1.225;
if !air_density_kg_m3.is_finite() || air_density_kg_m3 <= 0.0 {
return 0.0;
}
sg * (STD_DENSITY / air_density_kg_m3)
}
#[deprecated(
since = "0.22.18",
note = "does not compute aerodynamic dynamic stability; retained as a neutral static-stability pass-through"
)]
pub fn calculate_dynamic_stability(
static_stability: f64,
_velocity_mps: f64,
_spin_rate_rad_s: f64,
_yaw_angle_rad: f64,
_caliber_m: f64,
_mass_kg: f64,
) -> f64 {
static_stability
}
pub fn predict_stability_at_distance(
initial_stability: f64,
initial_velocity_fps: f64,
current_velocity_fps: f64,
spin_decay_factor: f64,
) -> f64 {
if initial_velocity_fps == 0.0 || current_velocity_fps == 0.0 {
return initial_stability;
}
let velocity_ratio = current_velocity_fps / initial_velocity_fps;
let stability_ratio = (spin_decay_factor / velocity_ratio).powi(2);
initial_stability * stability_ratio
}
pub fn check_trajectory_stability(
muzzle_stability: f64,
muzzle_velocity_fps: f64,
terminal_velocity_fps: f64,
spin_decay_factor: f64,
) -> (bool, f64, String) {
let terminal_stability = predict_stability_at_distance(
muzzle_stability,
muzzle_velocity_fps,
terminal_velocity_fps,
spin_decay_factor,
);
let is_stable = terminal_stability >= 1.3;
let status = if terminal_stability < 1.0 {
"UNSTABLE - Bullet will tumble".to_string()
} else if terminal_stability < 1.3 {
"MARGINAL - May experience accuracy issues".to_string()
} else if terminal_stability < 1.5 {
"ADEQUATE - Acceptable for most conditions".to_string()
} else if terminal_stability < 2.5 {
"GOOD - Optimal stability".to_string()
} else {
"OVER-STABILIZED - May reduce BC slightly".to_string()
};
(is_stable, terminal_stability, status)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_advanced_stability() {
let stability = calculate_advanced_stability(
168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false, );
println!("Calculated stability: {}", stability);
assert!(stability > 1.3);
assert!(
stability < 2.5,
"Stability {} exceeds upper bound",
stability
);
}
#[test]
fn test_stability_prediction() {
let (is_stable, terminal_sg, status) = check_trajectory_stability(
2.2, 2700.0, 1900.0, 0.98, );
println!(
"is_stable: {}, terminal_sg: {}, status: {}",
is_stable, terminal_sg, status
);
assert!(
is_stable,
"Expected stable trajectory but got: is_stable={}, terminal_sg={}, status={}",
is_stable, terminal_sg, status
);
assert!(
terminal_sg > 2.2,
"SG must grow as velocity decays faster than spin: {terminal_sg}"
);
assert!(status.contains("OVER-STABILIZED"));
}
#[test]
fn test_stability_parameters_bullet_types() {
let match_params = StabilityParameters::for_bullet_type("match", true, false);
let vld_params = StabilityParameters::for_bullet_type("vld", true, false);
let hunting_params = StabilityParameters::for_bullet_type("hunting", true, true);
let default_params = StabilityParameters::for_bullet_type("unknown", false, false);
assert!(vld_params.nose_shape_factor < match_params.nose_shape_factor);
assert_eq!(hunting_params.plastic_tip_factor, 1.0);
assert_eq!(default_params.nose_shape_factor, 1.0);
assert_eq!(default_params.boat_tail_factor, 1.0);
}
#[test]
fn plastic_tip_flag_never_reduces_advanced_stability() {
let calculate = |has_plastic_tip| {
calculate_advanced_stability(
178.0,
2800.0,
10.0,
0.308,
1.420,
1.225,
288.15,
"hunting",
false,
has_plastic_tip,
)
};
let untipped = calculate(false);
let tipped = calculate(true);
assert_eq!(
tipped.to_bits(),
untipped.to_bits(),
"a Boolean-only plastic-tip flag must not apply an invented correction"
);
}
#[test]
fn courtney_miller_correction_uses_metal_length_only_in_inertia_term() {
let caliber_inches = 0.224_f64;
let total_length_inches = 0.868_f64;
let tip_length_inches = total_length_inches - 0.738;
let uncorrected_sg = 1.0_f64;
let total_length_calibers = total_length_inches / caliber_inches;
let metal_length_calibers = (total_length_inches - tip_length_inches) / caliber_inches;
let expected = uncorrected_sg * (1.0 + total_length_calibers.powi(2))
/ (1.0 + metal_length_calibers.powi(2));
let corrected = apply_courtney_miller_plastic_tip_correction(
uncorrected_sg,
caliber_inches,
total_length_inches,
tip_length_inches,
);
assert!((corrected - expected).abs() < 1e-12);
assert!((corrected - 1.351).abs() < 0.001);
assert!(corrected > uncorrected_sg);
}
#[test]
fn courtney_miller_correction_requires_physical_tip_geometry() {
let uncorrected_sg = 1.5_f64;
for (caliber_inches, total_length_inches, tip_length_inches) in [
(0.0, 0.868, 0.130),
(-0.224, 0.868, 0.130),
(f64::NAN, 0.868, 0.130),
(0.224, 0.0, 0.130),
(0.224, f64::INFINITY, 0.130),
(0.224, 0.868, 0.0),
(0.224, 0.868, -0.130),
(0.224, 0.868, 0.868),
(0.224, 0.868, 0.900),
(0.224, 0.868, f64::NAN),
] {
let corrected = apply_courtney_miller_plastic_tip_correction(
uncorrected_sg,
caliber_inches,
total_length_inches,
tip_length_inches,
);
assert_eq!(corrected.to_bits(), uncorrected_sg.to_bits());
}
}
#[test]
fn test_stability_edge_cases() {
let zero_twist = calculate_advanced_stability(
168.0, 2700.0, 0.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
);
assert_eq!(zero_twist, 0.0);
let zero_caliber = calculate_advanced_stability(
168.0, 2700.0, 10.0, 0.0, 1.24, 1.225, 288.15, "match", true, false,
);
assert_eq!(zero_caliber, 0.0);
let zero_length = calculate_advanced_stability(
168.0, 2700.0, 10.0, 0.308, 0.0, 1.225, 288.15, "match", true, false,
);
assert_eq!(zero_length, 0.0);
}
#[test]
fn test_velocity_correction() {
let high_vel = calculate_advanced_stability(
168.0, 3000.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
);
let low_vel = calculate_advanced_stability(
168.0, 2000.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
);
assert!(
high_vel > low_vel,
"Higher velocity ({}) should give higher stability than lower velocity ({})",
high_vel,
low_vel
);
}
#[test]
fn velocity_correction_is_continuous_at_1400_fps() {
let sg_base = 2.0;
let epsilon = 1e-6;
let below = apply_velocity_correction(sg_base, 1400.0 - epsilon);
let at_boundary = apply_velocity_correction(sg_base, 1400.0);
let above = apply_velocity_correction(sg_base, 1400.0 + epsilon);
assert!(
(below - at_boundary).abs() <= 1e-8,
"velocity correction jumped from {below} to {at_boundary} at 1400 fps"
);
assert!((above - at_boundary).abs() <= 1e-8);
}
#[test]
fn subsonic_velocity_uses_canonical_miller_cube_root() {
let sg_base = 2.0_f64;
let velocity_fps = 1050.0_f64;
let expected = sg_base * (velocity_fps / 2800.0).powf(1.0 / 3.0);
let actual = apply_velocity_correction(sg_base, velocity_fps);
assert!(
(actual - expected).abs() <= expected * 1e-12,
"subsonic correction was {actual}, expected Miller value {expected}"
);
}
#[test]
fn advanced_stability_is_continuous_above_3000_fps() {
let calculate = |velocity_fps| {
calculate_advanced_stability(
55.0,
velocity_fps,
12.0,
0.224,
0.75,
1.225,
288.15,
"unknown",
false,
false,
)
};
let at_threshold = calculate(3000.0);
let just_above = calculate(3000.0 + 1e-6);
let relative_change = (just_above / at_threshold - 1.0).abs();
assert!(
relative_change < 1e-8,
"Sg jumped by {:.3}% immediately above 3000 fps",
relative_change * 100.0
);
let high_velocity = calculate(4000.0);
let expected_ratio = (4000.0_f64 / 3000.0).powf(1.0 / 3.0);
assert!(
(high_velocity / at_threshold - expected_ratio).abs() < 1e-12,
"high-velocity Sg did not follow Miller cube-root scaling"
);
}
#[test]
fn test_atmospheric_correction() {
let sea_level = calculate_advanced_stability(
168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
);
let high_altitude = calculate_advanced_stability(
168.0, 2700.0, 10.0, 0.308, 1.24, 1.0, 288.15, "match", true, false,
);
assert!(
high_altitude > sea_level,
"High altitude ({}) should have higher stability than sea level ({})",
high_altitude,
sea_level
);
}
#[test]
fn atmospheric_correction_is_only_inverse_density_ratio() {
let sg = 2.0_f64;
let temperature_k = 308.15;
for (density, expected) in [(1.225, 2.0), (1.0, 2.45), (0.6125, 4.0)] {
let actual = apply_atmospheric_correction(sg, density, temperature_k);
assert!(
(actual - expected).abs() <= expected * 1e-12,
"rho {density}: expected {expected}, got {actual}"
);
}
}
#[test]
fn advanced_stability_does_not_double_count_temperature_at_fixed_density() {
let calculate = |temperature_k| {
calculate_advanced_stability(
168.0,
2800.0,
10.0,
0.308,
1.24,
1.0,
temperature_k,
"unknown",
false,
false,
)
};
let cold = calculate(253.15);
let standard = calculate(288.15);
let hot = calculate(308.15);
let unknown = calculate(f64::NAN);
assert_eq!(cold.to_bits(), standard.to_bits());
assert_eq!(hot.to_bits(), standard.to_bits());
assert_eq!(unknown.to_bits(), standard.to_bits());
}
#[test]
fn atmospheric_correction_rejects_nonphysical_density() {
for density in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let actual = apply_atmospheric_correction(2.0, density, 288.15);
assert_eq!(
actual.to_bits(),
0.0_f64.to_bits(),
"density {density} produced {actual}"
);
}
}
#[test]
#[allow(deprecated)]
fn legacy_dynamic_stability_is_neutral_without_aerodynamic_derivatives() {
let legacy: fn(f64, f64, f64, f64, f64, f64) -> f64 = calculate_dynamic_stability;
let ancillary_states = [
(800.0, 1500.0, 0.0, 0.00782, 0.0109),
(800.0, 1500.0, 0.5, 0.00782, 0.0109),
(0.0, 0.0, 1.0, 0.00782, 0.0109),
(f64::NAN, -20_000.0, f64::NAN, -0.009, f64::INFINITY),
];
for static_sg in [
0.0,
-0.0,
1.5,
-1.0,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NAN,
] {
for (velocity_mps, spin_rate_rad_s, yaw_angle_rad, caliber_m, mass_kg) in
ancillary_states
{
let actual = legacy(
static_sg,
velocity_mps,
spin_rate_rad_s,
yaw_angle_rad,
caliber_m,
mass_kg,
);
assert_eq!(
actual.to_bits(),
static_sg.to_bits(),
"legacy API invented a dynamic correction without aerodynamic derivatives"
);
}
}
}
#[test]
fn test_predict_stability_at_distance() {
let initial_sg = 1.8;
let initial_vel = 2800.0;
let current_vel = 2000.0;
let spin_decay = 0.97;
let predicted =
predict_stability_at_distance(initial_sg, initial_vel, current_vel, spin_decay);
let expected = initial_sg * (spin_decay / (current_vel / initial_vel)).powi(2);
assert!((predicted - expected).abs() < 1e-12);
assert!(
predicted > initial_sg,
"retaining 97% spin while losing velocity must increase SG: {predicted}"
);
let slower = predict_stability_at_distance(initial_sg, initial_vel, 1400.0, spin_decay);
assert!(
slower > predicted,
"SG must increase monotonically as velocity falls at fixed spin retention"
);
}
#[test]
fn test_predict_stability_edge_cases() {
let zero_initial = predict_stability_at_distance(1.5, 0.0, 2000.0, 0.97);
assert_eq!(zero_initial, 1.5);
let zero_current = predict_stability_at_distance(1.5, 2800.0, 0.0, 0.97);
assert_eq!(zero_current, 1.5);
}
#[test]
fn test_trajectory_stability_status_messages() {
let (is_stable, sg, status) = check_trajectory_stability(0.8, 2700.0, 2700.0, 1.0);
assert!(!is_stable);
assert!(sg < 1.0);
assert!(status.contains("UNSTABLE"));
let (is_stable, sg, status) = check_trajectory_stability(1.15, 2700.0, 2700.0, 1.0);
assert!(!is_stable);
assert!((1.0..1.3).contains(&sg));
assert!(status.contains("MARGINAL"));
let (_, sg, status) = check_trajectory_stability(4.0, 2700.0, 2700.0, 1.0);
assert!(sg > 2.5);
assert!(status.contains("OVER-STABILIZED"));
}
#[test]
fn test_different_calibers_stability() {
let large_caliber = calculate_advanced_stability(
168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
);
let small_caliber = calculate_advanced_stability(
90.0, 2700.0, 8.0, 0.264, 1.15, 1.225, 288.15, "match", true, false,
);
assert!(large_caliber > 0.0);
assert!(small_caliber > 0.0);
}
#[test]
fn test_boat_tail_vs_flat_base() {
let boat_tail = calculate_advanced_stability(
168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
);
let flat_base = calculate_advanced_stability(
168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", false, false,
);
assert!(flat_base > boat_tail);
}
}