1use crate::constants::{AIR_DENSITY_SEA_LEVEL, SPEED_OF_SOUND_MPS};
2
3#[derive(Debug, Clone, Copy)]
5pub struct AerodynamicJumpComponents {
6 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, }
13
14pub fn litz_crosswind_jump_moa(
32 sg: f64,
33 length_calibers: f64,
34 crosswind_from_right_mph: f64,
35 is_right_twist: bool,
36) -> f64 {
37 let y_per_mph = 0.01 * sg - 0.0024 * length_calibers + 0.032;
38 let hand = if is_right_twist { 1.0 } else { -1.0 };
39 hand * y_per_mph * crosswind_from_right_mph
40}
41
42#[allow(clippy::too_many_arguments)] pub fn calculate_aerodynamic_jump(
53 muzzle_velocity_mps: f64,
54 spin_rate_rad_s: f64,
55 crosswind_mps: f64,
56 caliber_m: f64,
57 mass_kg: f64,
58 barrel_length_m: f64,
59 twist_rate_calibers: f64,
60 is_right_twist: bool,
61 initial_yaw_rad: f64,
62 air_density_kg_m3: f64,
63) -> AerodynamicJumpComponents {
64 if muzzle_velocity_mps <= 0.0
65 || caliber_m <= 0.0
66 || mass_kg <= 0.0
67 || twist_rate_calibers <= 0.0
68 {
69 return AerodynamicJumpComponents {
70 vertical_jump_moa: 0.0,
71 horizontal_jump_moa: 0.0,
72 jump_angle_rad: 0.0,
73 magnus_component_moa: 0.0,
74 yaw_component_moa: 0.0,
75 stabilization_factor: 0.0,
76 };
77 }
78
79 let mach = muzzle_velocity_mps / SPEED_OF_SOUND_MPS;
81 let magnus_coeff = if mach < 0.8 {
82 0.25
83 } else if mach < 1.2 {
84 0.15 } else {
86 0.20
87 };
88
89 let spin_param = (spin_rate_rad_s * caliber_m / 2.0) / muzzle_velocity_mps;
91
92 let crosswind_yaw = if crosswind_mps != 0.0 {
94 (crosswind_mps / muzzle_velocity_mps).atan()
95 } else {
96 0.0
97 };
98
99 let total_yaw_rad = crosswind_yaw + initial_yaw_rad;
100
101 let area = std::f64::consts::PI * (caliber_m / 2.0).powi(2);
103 let magnus_force = 0.5
104 * air_density_kg_m3
105 * muzzle_velocity_mps.powi(2)
106 * area
107 * magnus_coeff
108 * spin_param
109 * total_yaw_rad.sin();
110
111 let exit_time = 2.0 * barrel_length_m / muzzle_velocity_mps;
113
114 let stabilization_calibers = 20.0 / (twist_rate_calibers / 10.0).sqrt();
116 let stabilization_distance = stabilization_calibers * caliber_m;
117 let stabilization_time = stabilization_distance / muzzle_velocity_mps;
118
119 let effective_time = exit_time + stabilization_time;
121
122 let dir_sign = if crosswind_mps != 0.0 {
126 crosswind_mps.signum()
127 } else {
128 total_yaw_rad.signum()
129 };
130 let vertical_sign = if is_right_twist { dir_sign } else { -dir_sign };
131
132 let magnus_accel = magnus_force / mass_kg;
134
135 let lever_factor = (barrel_length_m / caliber_m) * 0.1;
137 let magnus_enhancement = 50.0; let mut vertical_jump_m = magnus_enhancement
141 * lever_factor
142 * vertical_sign
143 * magnus_accel.abs()
144 * effective_time.powi(2);
145
146 if total_yaw_rad != 0.0 {
148 let yaw_contribution = total_yaw_rad.abs() * barrel_length_m * 0.5;
149 vertical_jump_m += vertical_sign * yaw_contribution;
150 }
151
152 let horizontal_jump_m = 0.25 * vertical_jump_m * (2.0 * total_yaw_rad).sin();
154
155 const YARDS_TO_M: f64 = 0.9144;
157 const MOA_PER_RADIAN: f64 = 3437.7467707849; let range_100y = 100.0 * YARDS_TO_M;
160 let vertical_angle_rad = vertical_jump_m / range_100y;
161 let horizontal_angle_rad = horizontal_jump_m / range_100y;
162
163 let vertical_jump_moa = vertical_angle_rad * MOA_PER_RADIAN;
164 let horizontal_jump_moa = horizontal_angle_rad * MOA_PER_RADIAN;
165
166 let total_jump_rad = (vertical_angle_rad.powi(2) + horizontal_angle_rad.powi(2)).sqrt();
168
169 let magnus_component_moa = vertical_jump_moa.abs() * 0.8;
171 let yaw_component_moa = vertical_jump_moa.abs() * 0.2;
172
173 let caliber_in = caliber_m / 0.0254;
175 let mass_grains = mass_kg * 15432.358;
176 let length_m = crate::stability::estimate_bullet_length_m(caliber_m, mass_kg);
179 let length_calibers = length_m / caliber_m;
180 let length_term = length_calibers * (1.0 + length_calibers.powi(2));
181 let denominator = twist_rate_calibers.powi(2) * caliber_in.powi(3) * length_term;
182 let sg_approx = if denominator > 0.0 {
183 30.0 * mass_grains / denominator
184 } else {
185 0.0
186 };
187 let stabilization_factor = (sg_approx / 1.5).clamp(0.0, 1.0);
188
189 AerodynamicJumpComponents {
190 vertical_jump_moa,
191 horizontal_jump_moa,
192 jump_angle_rad: total_jump_rad,
193 magnus_component_moa,
194 yaw_component_moa,
195 stabilization_factor,
196 }
197}
198
199pub fn calculate_sight_correction_for_jump(
205 jump_components: &AerodynamicJumpComponents,
206 zero_range_m: f64,
207 _sight_height_m: f64,
208) -> (f64, f64) {
209 if !zero_range_m.is_finite() || zero_range_m <= 0.0 {
211 return (0.0, 0.0);
212 }
213
214 (
215 -jump_components.vertical_jump_moa,
216 -jump_components.horizontal_jump_moa,
217 )
218}
219
220pub fn calculate_crosswind_jump_sensitivity(
222 muzzle_velocity_mps: f64,
223 spin_rate_rad_s: f64,
224 caliber_m: f64,
225 mass_kg: f64,
226 twist_rate_calibers: f64,
227 is_right_twist: bool,
228) -> f64 {
229 const MPH_TO_MPS: f64 = 0.44704;
230 let crosswind_1mph = MPH_TO_MPS;
231
232 let jump = calculate_aerodynamic_jump(
233 muzzle_velocity_mps,
234 spin_rate_rad_s,
235 crosswind_1mph,
236 caliber_m,
237 mass_kg,
238 0.6, twist_rate_calibers,
240 is_right_twist,
241 0.0, AIR_DENSITY_SEA_LEVEL,
243 );
244
245 jump.vertical_jump_moa.abs()
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_aerodynamic_jump_zero_conditions() {
254 let jump = calculate_aerodynamic_jump(
256 800.0, 1000.0, 0.0, 0.00762, 0.01134, 0.6, 32.47, true, 0.0, 1.225, );
267
268 assert_eq!(jump.vertical_jump_moa, 0.0);
269 assert!(jump.horizontal_jump_moa.abs() < 0.001);
270 }
271
272 #[test]
273 fn test_aerodynamic_jump_with_crosswind() {
274 let jump = calculate_aerodynamic_jump(
276 800.0, 17593.0, 4.4704, 0.00782, 0.01134, 0.6096, 32.47, true, 0.0, 1.225, );
287
288 assert!(jump.vertical_jump_moa > 0.0);
290 assert!(jump.stabilization_factor > 0.0);
292 }
293
294 #[test]
295 fn stabilization_factor_distinguishes_stable_and_marginal_twists() {
296 let calculate = |spin_rate_rad_s, twist_rate_calibers| {
297 calculate_aerodynamic_jump(
298 800.0,
299 spin_rate_rad_s,
300 4.4704,
301 0.00782,
302 0.01134,
303 0.6096,
304 twist_rate_calibers,
305 true,
306 0.0,
307 1.225,
308 )
309 };
310
311 let stable = calculate(17_593.0, 32.47); let marginal_twist_calibers = 14.0 / (0.00782 / 0.0254);
313 let marginal = calculate(14_135.0, marginal_twist_calibers); assert!(
316 stable.stabilization_factor > marginal.stabilization_factor,
317 "stability diagnostic saturated: stable={}, marginal={}",
318 stable.stabilization_factor,
319 marginal.stabilization_factor
320 );
321
322 let caliber_m = 0.00782;
323 let mass_kg = 0.01134;
324 let length_calibers =
325 crate::stability::estimate_bullet_length_m(caliber_m, mass_kg) / caliber_m;
326 let expected_sg = 30.0 * mass_kg * 15432.358
327 / (marginal_twist_calibers.powi(2)
328 * (caliber_m / 0.0254).powi(3)
329 * length_calibers
330 * (1.0 + length_calibers.powi(2)));
331 let expected_factor = (expected_sg / 1.5).clamp(0.0, 1.0);
332 assert!((marginal.stabilization_factor - expected_factor).abs() < 1e-12);
333 }
334
335 #[test]
336 fn test_opposite_twist_direction() {
337 let crosswind = 4.4704; let jump_right = calculate_aerodynamic_jump(
341 800.0, 17593.0, crosswind, 0.00782, 0.01134, 0.6096, 32.47, true, 0.0, 1.225,
342 );
343
344 let jump_left = calculate_aerodynamic_jump(
346 800.0, 17593.0, crosswind, 0.00782, 0.01134, 0.6096, 32.47, false, 0.0, 1.225,
347 );
348
349 assert!((jump_right.vertical_jump_moa + jump_left.vertical_jump_moa).abs() < 0.001);
351 }
352
353 #[test]
354 fn sight_correction_is_the_equal_and_opposite_jump_angle() {
355 let jump = AerodynamicJumpComponents {
356 vertical_jump_moa: 0.5,
357 horizontal_jump_moa: 0.1,
358 jump_angle_rad: 0.0001,
359 magnus_component_moa: 0.4,
360 yaw_component_moa: 0.1,
361 stabilization_factor: 0.9,
362 };
363
364 for (zero_range_m, sight_height_m) in [
365 (22.86, 0.0), (91.44, 0.05), (274.32, 0.1), ] {
369 let (vertical, horizontal) =
370 calculate_sight_correction_for_jump(&jump, zero_range_m, sight_height_m);
371
372 assert!(
373 (vertical + 0.5).abs() < 1e-12,
374 "vertical correction changed with range/height: range={zero_range_m}, height={sight_height_m}, correction={vertical}"
375 );
376 assert!(
377 (horizontal + 0.1).abs() < 1e-12,
378 "horizontal correction changed with range/height: range={zero_range_m}, height={sight_height_m}, correction={horizontal}"
379 );
380 }
381 }
382
383 #[test]
384 fn sight_correction_rejects_invalid_zero_ranges() {
385 let jump = AerodynamicJumpComponents {
386 vertical_jump_moa: 0.5,
387 horizontal_jump_moa: 0.1,
388 jump_angle_rad: 0.0001,
389 magnus_component_moa: 0.4,
390 yaw_component_moa: 0.1,
391 stabilization_factor: 0.9,
392 };
393
394 for zero_range_m in [0.0, -1.0, f64::NAN, f64::INFINITY] {
395 assert_eq!(
396 calculate_sight_correction_for_jump(&jump, zero_range_m, 0.05),
397 (0.0, 0.0),
398 "invalid zero range must be rejected: {zero_range_m}"
399 );
400 }
401 }
402
403 #[test]
404 fn test_crosswind_sensitivity() {
405 let sensitivity = calculate_crosswind_jump_sensitivity(
406 800.0, 17593.0, 0.00782, 0.01134, 32.47, true, );
413
414 assert!(sensitivity > 0.0);
416 assert!(sensitivity < 0.5);
417 }
418
419 #[test]
422 fn litz_matches_the_published_formula() {
423 let per_mph = 0.01 * 1.75 - 0.0024 * 4.0 + 0.032;
426 let got = litz_crosswind_jump_moa(1.75, 4.0, 10.0, true);
427 assert!(
428 (got - per_mph * 10.0).abs() < 1e-12,
429 "got {got}, expected {}",
430 per_mph * 10.0
431 );
432 assert!((got - 0.399).abs() < 1e-3);
434 }
435
436 #[test]
437 fn litz_is_linear_in_crosswind() {
438 let one = litz_crosswind_jump_moa(1.8, 3.5, 1.0, true);
439 let ten = litz_crosswind_jump_moa(1.8, 3.5, 10.0, true);
440 assert!((ten - 10.0 * one).abs() < 1e-12);
441 assert_eq!(litz_crosswind_jump_moa(1.8, 3.5, 0.0, true), 0.0);
442 }
443
444 #[test]
445 fn litz_sign_flips_with_wind_side_and_twist() {
446 let base = litz_crosswind_jump_moa(1.9, 4.0, 12.0, true);
448 assert!(base > 0.0);
449 assert!((litz_crosswind_jump_moa(1.9, 4.0, -12.0, true) + base).abs() < 1e-12);
451 assert!((litz_crosswind_jump_moa(1.9, 4.0, 12.0, false) + base).abs() < 1e-12);
453 }
454
455 #[test]
456 fn litz_regression_can_go_negative_outside_its_fitted_range() {
457 let per_mph = 0.01 * 1.0 - 0.0024 * 20.0 + 0.032; assert!(per_mph < 0.0);
462 let got = litz_crosswind_jump_moa(1.0, 20.0, 10.0, true);
463 assert!((got - per_mph * 10.0).abs() < 1e-12);
464 assert!(got < 0.0);
465 }
466}