use crate::constants::{AIR_DENSITY_SEA_LEVEL, SPEED_OF_SOUND_MPS};
#[derive(Debug, Clone, Copy)]
pub struct AerodynamicJumpComponents {
pub vertical_jump_moa: f64, pub horizontal_jump_moa: f64, pub jump_angle_rad: f64, pub magnus_component_moa: f64, pub yaw_component_moa: f64, pub stabilization_factor: f64, }
pub fn litz_crosswind_jump_moa(
sg: f64,
length_calibers: f64,
crosswind_from_right_mph: f64,
is_right_twist: bool,
) -> f64 {
let y_per_mph = 0.01 * sg - 0.0024 * length_calibers + 0.032;
let hand = if is_right_twist { 1.0 } else { -1.0 };
hand * y_per_mph * crosswind_from_right_mph
}
pub fn calculate_aerodynamic_jump(
muzzle_velocity_mps: f64,
spin_rate_rad_s: f64,
crosswind_mps: f64,
caliber_m: f64,
mass_kg: f64,
barrel_length_m: f64,
twist_rate_calibers: f64,
is_right_twist: bool,
initial_yaw_rad: f64,
air_density_kg_m3: f64,
) -> AerodynamicJumpComponents {
if muzzle_velocity_mps <= 0.0
|| caliber_m <= 0.0
|| mass_kg <= 0.0
|| twist_rate_calibers <= 0.0
{
return AerodynamicJumpComponents {
vertical_jump_moa: 0.0,
horizontal_jump_moa: 0.0,
jump_angle_rad: 0.0,
magnus_component_moa: 0.0,
yaw_component_moa: 0.0,
stabilization_factor: 0.0,
};
}
let mach = muzzle_velocity_mps / SPEED_OF_SOUND_MPS;
let magnus_coeff = if mach < 0.8 {
0.25
} else if mach < 1.2 {
0.15 } else {
0.20
};
let spin_param = (spin_rate_rad_s * caliber_m / 2.0) / muzzle_velocity_mps;
let crosswind_yaw = if crosswind_mps != 0.0 {
(crosswind_mps / muzzle_velocity_mps).atan()
} else {
0.0
};
let total_yaw_rad = crosswind_yaw + initial_yaw_rad;
let area = std::f64::consts::PI * (caliber_m / 2.0).powi(2);
let magnus_force = 0.5
* air_density_kg_m3
* muzzle_velocity_mps.powi(2)
* area
* magnus_coeff
* spin_param
* total_yaw_rad.sin();
let exit_time = 2.0 * barrel_length_m / muzzle_velocity_mps;
let stabilization_calibers = 20.0 / (twist_rate_calibers / 10.0).sqrt();
let stabilization_distance = stabilization_calibers * caliber_m;
let stabilization_time = stabilization_distance / muzzle_velocity_mps;
let effective_time = exit_time + stabilization_time;
let dir_sign = if crosswind_mps != 0.0 {
crosswind_mps.signum()
} else {
total_yaw_rad.signum()
};
let vertical_sign = if is_right_twist { dir_sign } else { -dir_sign };
let magnus_accel = magnus_force / mass_kg;
let lever_factor = (barrel_length_m / caliber_m) * 0.1;
let magnus_enhancement = 50.0;
let mut vertical_jump_m = magnus_enhancement
* lever_factor
* vertical_sign
* magnus_accel.abs()
* effective_time.powi(2);
if total_yaw_rad != 0.0 {
let yaw_contribution = total_yaw_rad.abs() * barrel_length_m * 0.5;
vertical_jump_m += vertical_sign * yaw_contribution;
}
let horizontal_jump_m = 0.25 * vertical_jump_m * (2.0 * total_yaw_rad).sin();
const YARDS_TO_M: f64 = 0.9144;
const MOA_PER_RADIAN: f64 = 3437.7467707849;
let range_100y = 100.0 * YARDS_TO_M;
let vertical_angle_rad = vertical_jump_m / range_100y;
let horizontal_angle_rad = horizontal_jump_m / range_100y;
let vertical_jump_moa = vertical_angle_rad * MOA_PER_RADIAN;
let horizontal_jump_moa = horizontal_angle_rad * MOA_PER_RADIAN;
let total_jump_rad = (vertical_angle_rad.powi(2) + horizontal_angle_rad.powi(2)).sqrt();
let magnus_component_moa = vertical_jump_moa.abs() * 0.8;
let yaw_component_moa = vertical_jump_moa.abs() * 0.2;
let caliber_in = caliber_m / 0.0254;
let mass_grains = mass_kg * 15432.358;
let length_m = crate::stability::estimate_bullet_length_m(caliber_m, mass_kg);
let length_calibers = length_m / caliber_m;
let length_term = length_calibers * (1.0 + length_calibers.powi(2));
let denominator = twist_rate_calibers.powi(2) * caliber_in.powi(3) * length_term;
let sg_approx = if denominator > 0.0 {
30.0 * mass_grains / denominator
} else {
0.0
};
let stabilization_factor = (sg_approx / 1.5).clamp(0.0, 1.0);
AerodynamicJumpComponents {
vertical_jump_moa,
horizontal_jump_moa,
jump_angle_rad: total_jump_rad,
magnus_component_moa,
yaw_component_moa,
stabilization_factor,
}
}
pub fn calculate_sight_correction_for_jump(
jump_components: &AerodynamicJumpComponents,
zero_range_m: f64,
_sight_height_m: f64,
) -> (f64, f64) {
if !zero_range_m.is_finite() || zero_range_m <= 0.0 {
return (0.0, 0.0);
}
(
-jump_components.vertical_jump_moa,
-jump_components.horizontal_jump_moa,
)
}
pub fn calculate_crosswind_jump_sensitivity(
muzzle_velocity_mps: f64,
spin_rate_rad_s: f64,
caliber_m: f64,
mass_kg: f64,
twist_rate_calibers: f64,
is_right_twist: bool,
) -> f64 {
const MPH_TO_MPS: f64 = 0.44704;
let crosswind_1mph = MPH_TO_MPS;
let jump = calculate_aerodynamic_jump(
muzzle_velocity_mps,
spin_rate_rad_s,
crosswind_1mph,
caliber_m,
mass_kg,
0.6, twist_rate_calibers,
is_right_twist,
0.0, AIR_DENSITY_SEA_LEVEL,
);
jump.vertical_jump_moa.abs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_aerodynamic_jump_zero_conditions() {
let jump = calculate_aerodynamic_jump(
800.0, 1000.0, 0.0, 0.00762, 0.01134, 0.6, 32.47, true, 0.0, 1.225, );
assert_eq!(jump.vertical_jump_moa, 0.0);
assert!(jump.horizontal_jump_moa.abs() < 0.001);
}
#[test]
fn test_aerodynamic_jump_with_crosswind() {
let jump = calculate_aerodynamic_jump(
800.0, 17593.0, 4.4704, 0.00782, 0.01134, 0.6096, 32.47, true, 0.0, 1.225, );
assert!(jump.vertical_jump_moa > 0.0);
assert!(jump.stabilization_factor > 0.0);
}
#[test]
fn stabilization_factor_distinguishes_stable_and_marginal_twists() {
let calculate = |spin_rate_rad_s, twist_rate_calibers| {
calculate_aerodynamic_jump(
800.0,
spin_rate_rad_s,
4.4704,
0.00782,
0.01134,
0.6096,
twist_rate_calibers,
true,
0.0,
1.225,
)
};
let stable = calculate(17_593.0, 32.47); let marginal_twist_calibers = 14.0 / (0.00782 / 0.0254);
let marginal = calculate(14_135.0, marginal_twist_calibers);
assert!(
stable.stabilization_factor > marginal.stabilization_factor,
"stability diagnostic saturated: stable={}, marginal={}",
stable.stabilization_factor,
marginal.stabilization_factor
);
let caliber_m = 0.00782;
let mass_kg = 0.01134;
let length_calibers =
crate::stability::estimate_bullet_length_m(caliber_m, mass_kg) / caliber_m;
let expected_sg = 30.0 * mass_kg * 15432.358
/ (marginal_twist_calibers.powi(2)
* (caliber_m / 0.0254).powi(3)
* length_calibers
* (1.0 + length_calibers.powi(2)));
let expected_factor = (expected_sg / 1.5).clamp(0.0, 1.0);
assert!((marginal.stabilization_factor - expected_factor).abs() < 1e-12);
}
#[test]
fn test_opposite_twist_direction() {
let crosswind = 4.4704;
let jump_right = calculate_aerodynamic_jump(
800.0, 17593.0, crosswind, 0.00782, 0.01134, 0.6096, 32.47, true, 0.0, 1.225,
);
let jump_left = calculate_aerodynamic_jump(
800.0, 17593.0, crosswind, 0.00782, 0.01134, 0.6096, 32.47, false, 0.0, 1.225,
);
assert!((jump_right.vertical_jump_moa + jump_left.vertical_jump_moa).abs() < 0.001);
}
#[test]
fn sight_correction_is_the_equal_and_opposite_jump_angle() {
let jump = AerodynamicJumpComponents {
vertical_jump_moa: 0.5,
horizontal_jump_moa: 0.1,
jump_angle_rad: 0.0001,
magnus_component_moa: 0.4,
yaw_component_moa: 0.1,
stabilization_factor: 0.9,
};
for (zero_range_m, sight_height_m) in [
(22.86, 0.0), (91.44, 0.05), (274.32, 0.1), ] {
let (vertical, horizontal) =
calculate_sight_correction_for_jump(&jump, zero_range_m, sight_height_m);
assert!(
(vertical + 0.5).abs() < 1e-12,
"vertical correction changed with range/height: range={zero_range_m}, height={sight_height_m}, correction={vertical}"
);
assert!(
(horizontal + 0.1).abs() < 1e-12,
"horizontal correction changed with range/height: range={zero_range_m}, height={sight_height_m}, correction={horizontal}"
);
}
}
#[test]
fn sight_correction_rejects_invalid_zero_ranges() {
let jump = AerodynamicJumpComponents {
vertical_jump_moa: 0.5,
horizontal_jump_moa: 0.1,
jump_angle_rad: 0.0001,
magnus_component_moa: 0.4,
yaw_component_moa: 0.1,
stabilization_factor: 0.9,
};
for zero_range_m in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(
calculate_sight_correction_for_jump(&jump, zero_range_m, 0.05),
(0.0, 0.0),
"invalid zero range must be rejected: {zero_range_m}"
);
}
}
#[test]
fn test_crosswind_sensitivity() {
let sensitivity = calculate_crosswind_jump_sensitivity(
800.0, 17593.0, 0.00782, 0.01134, 32.47, true, );
assert!(sensitivity > 0.0);
assert!(sensitivity < 0.5);
}
#[test]
fn litz_matches_the_published_formula() {
let per_mph = 0.01 * 1.75 - 0.0024 * 4.0 + 0.032;
let got = litz_crosswind_jump_moa(1.75, 4.0, 10.0, true);
assert!(
(got - per_mph * 10.0).abs() < 1e-12,
"got {got}, expected {}",
per_mph * 10.0
);
assert!((got - 0.399).abs() < 1e-3);
}
#[test]
fn litz_is_linear_in_crosswind() {
let one = litz_crosswind_jump_moa(1.8, 3.5, 1.0, true);
let ten = litz_crosswind_jump_moa(1.8, 3.5, 10.0, true);
assert!((ten - 10.0 * one).abs() < 1e-12);
assert_eq!(litz_crosswind_jump_moa(1.8, 3.5, 0.0, true), 0.0);
}
#[test]
fn litz_sign_flips_with_wind_side_and_twist() {
let base = litz_crosswind_jump_moa(1.9, 4.0, 12.0, true);
assert!(base > 0.0);
assert!((litz_crosswind_jump_moa(1.9, 4.0, -12.0, true) + base).abs() < 1e-12);
assert!((litz_crosswind_jump_moa(1.9, 4.0, 12.0, false) + base).abs() < 1e-12);
}
#[test]
fn litz_regression_can_go_negative_outside_its_fitted_range() {
let per_mph = 0.01 * 1.0 - 0.0024 * 20.0 + 0.032; assert!(per_mph < 0.0);
let got = litz_crosswind_jump_moa(1.0, 20.0, 10.0, true);
assert!((got - per_mph * 10.0).abs() < 1e-12);
assert!(got < 0.0);
}
}