Skip to main content

ballistics_engine/
aerodynamic_jump.rs

1use crate::constants::{AIR_DENSITY_SEA_LEVEL, SPEED_OF_SOUND_MPS};
2
3/// Components of aerodynamic jump calculation
4#[derive(Debug, Clone, Copy)]
5pub struct AerodynamicJumpComponents {
6    pub vertical_jump_moa: f64,    // Vertical displacement in MOA at 100 yards
7    pub horizontal_jump_moa: f64,  // Horizontal displacement in MOA at 100 yards
8    pub jump_angle_rad: f64,       // Total angular displacement in radians
9    pub magnus_component_moa: f64, // Magnus effect contribution
10    pub yaw_component_moa: f64,    // Initial yaw contribution
11    pub stabilization_factor: f64, // How quickly projectile stabilizes (0-1)
12}
13
14/// Bryan Litz's crosswind aerodynamic-jump estimator ("Applied Ballistics").
15///
16/// Linear regression for the VERTICAL jump a crosswind imparts to a
17/// spin-stabilized bullet, in MOA per mph of crosswind:
18///
19/// ```text
20///   Y = 0.01*Sg - 0.0024*L + 0.032        [MOA per mph]
21/// ```
22///
23/// where `sg` is the gyroscopic (Miller) stability factor and `length_calibers`
24/// is the bullet length in calibers. The returned value is the SIGNED vertical
25/// jump in MOA for the supplied crosswind and twist hand: per Litz a right-twist
26/// bullet jumps UP for a crosswind from the right and DOWN for one from the left,
27/// so `crosswind_from_right_mph` is positive for a wind coming from the right.
28///
29/// This is a regression valid mainly near `Sg ~ 1.75`; accuracy degrades for
30/// bullets well outside the fitted data set. See MBA-959.
31pub 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/// Legacy heuristic aerodynamic-jump model. NOTE: the trajectory solver uses the
43/// validated [`litz_crosswind_jump_moa`] estimator instead; this self-calibrated
44/// model (with a hand-tuned `magnus_enhancement` factor and a known horizontal-sign
45/// quirk) is retained only for backward compatibility with external callers.
46///
47/// Calculate aerodynamic jump for a spinning projectile.
48///
49/// Aerodynamic jump is the displacement of the projectile's trajectory
50/// as it transitions from constrained motion in the barrel to free flight.
51#[allow(clippy::too_many_arguments)] // Public compatibility API; callers already supply scalars.
52pub 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    // Calculate Magnus force coefficient
80    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 // Reduced in transonic
85    } else {
86        0.20
87    };
88
89    // Spin parameter (non-dimensional)
90    let spin_param = (spin_rate_rad_s * caliber_m / 2.0) / muzzle_velocity_mps;
91
92    // Effective yaw angle during muzzle exit
93    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    // Magnus force during barrel exit
102    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    // Time for projectile to clear muzzle
112    let exit_time = 2.0 * barrel_length_m / muzzle_velocity_mps;
113
114    // Stabilization distance
115    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    // Total effective time
120    let effective_time = exit_time + stabilization_time;
121
122    // Calculate jump displacement. Direction comes from the crosswind, falling back to the yaw
123    // direction when there is no crosswind — signum(0.0) == +1.0 would otherwise impose a phantom
124    // positive direction for pure-yaw (no-wind) inputs.
125    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    // Magnus acceleration
133    let magnus_accel = magnus_force / mass_kg;
134
135    // Enhanced calculation accounting for barrel exit dynamics
136    let lever_factor = (barrel_length_m / caliber_m) * 0.1;
137    let magnus_enhancement = 50.0; // Calibrated to match empirical data
138
139    // Vertical displacement
140    let mut vertical_jump_m = magnus_enhancement
141        * lever_factor
142        * vertical_sign
143        * magnus_accel.abs()
144        * effective_time.powi(2);
145
146    // Add yaw-induced component
147    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    // Horizontal component (smaller effect)
153    let horizontal_jump_m = 0.25 * vertical_jump_m * (2.0 * total_yaw_rad).sin();
154
155    // Convert to MOA at 100 yards
156    const YARDS_TO_M: f64 = 0.9144;
157    const MOA_PER_RADIAN: f64 = 3437.7467707849; // 1 / 0.0002908882
158
159    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    // Total jump angle
167    let total_jump_rad = (vertical_angle_rad.powi(2) + horizontal_angle_rad.powi(2)).sqrt();
168
169    // Component breakdown
170    let magnus_component_moa = vertical_jump_moa.abs() * 0.8;
171    let yaw_component_moa = vertical_jump_moa.abs() * 0.2;
172
173    // Stabilization factor
174    let caliber_in = caliber_m / 0.0254;
175    let mass_grains = mass_kg * 15432.358;
176    // This backward-compatible signature predates a bullet-length argument, so use the engine's
177    // canonical mass/caliber estimate rather than dropping Miller's length term entirely.
178    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
199/// Calculate sight corrections needed to compensate for aerodynamic jump
200///
201/// Aerodynamic jump is an angular muzzle departure, so its equal-and-opposite
202/// sight correction is independent of zero range and sight height. Those
203/// parameters remain in the public signature for backward compatibility.
204pub fn calculate_sight_correction_for_jump(
205    jump_components: &AerodynamicJumpComponents,
206    zero_range_m: f64,
207    _sight_height_m: f64,
208) -> (f64, f64) {
209    // Preserve the public helper's established invalid-range behavior.
210    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
220/// Calculate sensitivity to crosswind for aerodynamic jump (MOA per mph)
221pub 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, // Typical 24" barrel
239        twist_rate_calibers,
240        is_right_twist,
241        0.0, // No initial yaw
242        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        // Test with no crosswind
255        let jump = calculate_aerodynamic_jump(
256            800.0,   // velocity
257            1000.0,  // spin rate
258            0.0,     // no crosswind
259            0.00762, // .30 cal
260            0.01134, // 175gr
261            0.6,     // barrel length
262            32.47,   // twist rate in calibers
263            true,    // right twist
264            0.0,     // no initial yaw
265            1.225,   // air density
266        );
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        // Test with 10 mph right crosswind
275        let jump = calculate_aerodynamic_jump(
276            800.0,   // velocity
277            17593.0, // spin rate for 1:10 twist
278            4.4704,  // 10 mph crosswind
279            0.00782, // .308 cal
280            0.01134, // 175gr
281            0.6096,  // 24" barrel
282            32.47,   // twist rate in calibers
283            true,    // right twist
284            0.0,     // no initial yaw
285            1.225,   // air density
286        );
287
288        // Right twist + right wind should give positive (upward) jump
289        assert!(jump.vertical_jump_moa > 0.0);
290        // Just check that we have some stabilization
291        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); // 1:10 twist
312        let marginal_twist_calibers = 14.0 / (0.00782 / 0.0254);
313        let marginal = calculate(14_135.0, marginal_twist_calibers); // 1:14 twist
314
315        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; // 10 mph
338
339        // Right twist
340        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        // Left twist
345        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        // Opposite twist should give opposite vertical jump
350        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),  // 25 yards
366            (91.44, 0.05), // 100 yards, 2-inch sight height
367            (274.32, 0.1), // 300 yards, tall sight
368        ] {
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,   // velocity
407            17593.0, // spin rate
408            0.00782, // caliber
409            0.01134, // mass
410            32.47,   // twist rate
411            true,    // right twist
412        );
413
414        // Should be positive and reasonable (typically 0.01-0.1 MOA/mph)
415        assert!(sensitivity > 0.0);
416        assert!(sensitivity < 0.5);
417    }
418
419    // ---- Litz crosswind aerodynamic-jump estimator (the canonical solver model) ----
420
421    #[test]
422    fn litz_matches_the_published_formula() {
423        // Y = 0.01*Sg - 0.0024*L + 0.032 [MOA/mph], scaled by crosswind and twist hand.
424        // Sg = 1.75, L = 4.0 -> 0.0175 - 0.0096 + 0.032 = 0.0399 MOA/mph.
425        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        // Sanity: a few tenths of an MOA at 10 mph.
433        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        // Wind from the right + right twist -> up (positive).
447        let base = litz_crosswind_jump_moa(1.9, 4.0, 12.0, true);
448        assert!(base > 0.0);
449        // Reversing the wind side flips the sign, same magnitude.
450        assert!((litz_crosswind_jump_moa(1.9, 4.0, -12.0, true) + base).abs() < 1e-12);
451        // Flipping the twist hand flips the sign.
452        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        // The estimator is a faithful linear fit (not clamped): a very long, marginally
458        // stable bullet drives 0.01*Sg - 0.0024*L + 0.032 below zero, reversing the jump.
459        // This is the extrapolation regime — see MBA-959.
460        let per_mph = 0.01 * 1.0 - 0.0024 * 20.0 + 0.032; // = -0.006
461        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}