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.
51pub 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    // Calculate Magnus force coefficient
79    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 // Reduced in transonic
84    } else {
85        0.20
86    };
87
88    // Spin parameter (non-dimensional)
89    let spin_param = (spin_rate_rad_s * caliber_m / 2.0) / muzzle_velocity_mps;
90
91    // Effective yaw angle during muzzle exit
92    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    // Magnus force during barrel exit
101    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    // Time for projectile to clear muzzle
111    let exit_time = 2.0 * barrel_length_m / muzzle_velocity_mps;
112
113    // Stabilization distance
114    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    // Total effective time
119    let effective_time = exit_time + stabilization_time;
120
121    // Calculate jump displacement. Direction comes from the crosswind, falling back to the yaw
122    // direction when there is no crosswind — signum(0.0) == +1.0 would otherwise impose a phantom
123    // positive direction for pure-yaw (no-wind) inputs.
124    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    // Magnus acceleration
132    let magnus_accel = magnus_force / mass_kg;
133
134    // Enhanced calculation accounting for barrel exit dynamics
135    let lever_factor = (barrel_length_m / caliber_m) * 0.1;
136    let magnus_enhancement = 50.0; // Calibrated to match empirical data
137
138    // Vertical displacement
139    let mut vertical_jump_m = magnus_enhancement
140        * lever_factor
141        * vertical_sign
142        * magnus_accel.abs()
143        * effective_time.powi(2);
144
145    // Add yaw-induced component
146    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    // Horizontal component (smaller effect)
152    let horizontal_jump_m = 0.25 * vertical_jump_m * (2.0 * total_yaw_rad).sin();
153
154    // Convert to MOA at 100 yards
155    const YARDS_TO_M: f64 = 0.9144;
156    const MOA_PER_RADIAN: f64 = 3437.7467707849; // 1 / 0.0002908882
157
158    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    // Total jump angle
166    let total_jump_rad = (vertical_angle_rad.powi(2) + horizontal_angle_rad.powi(2)).sqrt();
167
168    // Component breakdown
169    let magnus_component_moa = vertical_jump_moa.abs() * 0.8;
170    let yaw_component_moa = vertical_jump_moa.abs() * 0.2;
171
172    // Stabilization factor
173    let caliber_in = caliber_m / 0.0254;
174    let mass_grains = mass_kg * 15432.358;
175    // This backward-compatible signature predates a bullet-length argument, so use the engine's
176    // canonical mass/caliber estimate rather than dropping Miller's length term entirely.
177    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
198/// Calculate sight corrections needed to compensate for aerodynamic jump
199///
200/// Aerodynamic jump is an angular muzzle departure, so its equal-and-opposite
201/// sight correction is independent of zero range and sight height. Those
202/// parameters remain in the public signature for backward compatibility.
203pub fn calculate_sight_correction_for_jump(
204    jump_components: &AerodynamicJumpComponents,
205    zero_range_m: f64,
206    _sight_height_m: f64,
207) -> (f64, f64) {
208    // Preserve the public helper's established invalid-range behavior.
209    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
219/// Calculate sensitivity to crosswind for aerodynamic jump (MOA per mph)
220pub 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, // Typical 24" barrel
238        twist_rate_calibers,
239        is_right_twist,
240        0.0, // No initial yaw
241        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        // Test with no crosswind
254        let jump = calculate_aerodynamic_jump(
255            800.0,   // velocity
256            1000.0,  // spin rate
257            0.0,     // no crosswind
258            0.00762, // .30 cal
259            0.01134, // 175gr
260            0.6,     // barrel length
261            32.47,   // twist rate in calibers
262            true,    // right twist
263            0.0,     // no initial yaw
264            1.225,   // air density
265        );
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        // Test with 10 mph right crosswind
274        let jump = calculate_aerodynamic_jump(
275            800.0,   // velocity
276            17593.0, // spin rate for 1:10 twist
277            4.4704,  // 10 mph crosswind
278            0.00782, // .308 cal
279            0.01134, // 175gr
280            0.6096,  // 24" barrel
281            32.47,   // twist rate in calibers
282            true,    // right twist
283            0.0,     // no initial yaw
284            1.225,   // air density
285        );
286
287        // Right twist + right wind should give positive (upward) jump
288        assert!(jump.vertical_jump_moa > 0.0);
289        // Just check that we have some stabilization
290        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); // 1:10 twist
311        let marginal_twist_calibers = 14.0 / (0.00782 / 0.0254);
312        let marginal = calculate(14_135.0, marginal_twist_calibers); // 1:14 twist
313
314        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; // 10 mph
337
338        // Right twist
339        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        // Left twist
344        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        // Opposite twist should give opposite vertical jump
349        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),  // 25 yards
365            (91.44, 0.05), // 100 yards, 2-inch sight height
366            (274.32, 0.1), // 300 yards, tall sight
367        ] {
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,   // velocity
406            17593.0, // spin rate
407            0.00782, // caliber
408            0.01134, // mass
409            32.47,   // twist rate
410            true,    // right twist
411        );
412
413        // Should be positive and reasonable (typically 0.01-0.1 MOA/mph)
414        assert!(sensitivity > 0.0);
415        assert!(sensitivity < 0.5);
416    }
417
418    // ---- Litz crosswind aerodynamic-jump estimator (the canonical solver model) ----
419
420    #[test]
421    fn litz_matches_the_published_formula() {
422        // Y = 0.01*Sg - 0.0024*L + 0.032 [MOA/mph], scaled by crosswind and twist hand.
423        // Sg = 1.75, L = 4.0 -> 0.0175 - 0.0096 + 0.032 = 0.0399 MOA/mph.
424        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        // Sanity: a few tenths of an MOA at 10 mph.
432        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        // Wind from the right + right twist -> up (positive).
446        let base = litz_crosswind_jump_moa(1.9, 4.0, 12.0, true);
447        assert!(base > 0.0);
448        // Reversing the wind side flips the sign, same magnitude.
449        assert!((litz_crosswind_jump_moa(1.9, 4.0, -12.0, true) + base).abs() < 1e-12);
450        // Flipping the twist hand flips the sign.
451        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        // The estimator is a faithful linear fit (not clamped): a very long, marginally
457        // stable bullet drives 0.01*Sg - 0.0024*L + 0.032 below zero, reversing the jump.
458        // This is the extrapolation regime — see MBA-959.
459        let per_mph = 0.01 * 1.0 - 0.0024 * 20.0 + 0.032; // = -0.006
460        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}