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