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    // mass_kg -> grains uses the kilograms->grains factor (15432.358), NOT the grams->grains
175    // factor (15.432); the latter made sg_approx ~1000x too small so stabilization_factor
176    // collapsed to ~0 for every real projectile. (Simplified Sg: length terms omitted.)
177    let sg_approx =
178        30.0 * mass_kg * 15432.358 / (twist_rate_calibers.powi(2) * caliber_in.powi(3));
179    let stabilization_factor = (sg_approx / 1.5).min(1.0);
180
181    AerodynamicJumpComponents {
182        vertical_jump_moa,
183        horizontal_jump_moa,
184        jump_angle_rad: total_jump_rad,
185        magnus_component_moa,
186        yaw_component_moa,
187        stabilization_factor,
188    }
189}
190
191/// Calculate sight corrections needed to compensate for aerodynamic jump
192// `!(x > 0.0)` is used intentionally instead of `x <= 0.0`: the negated form is also true for
193// NaN (NaN comparisons are always false), so it correctly rejects NaN as well as non-positive
194// inputs. `x <= 0.0` would let NaN through. Hence the clippy allow below.
195#[allow(clippy::neg_cmp_op_on_partial_ord)]
196pub fn calculate_sight_correction_for_jump(
197    jump_components: &AerodynamicJumpComponents,
198    zero_range_m: f64,
199    sight_height_m: f64,
200) -> (f64, f64) {
201    // Guard a non-positive (or NaN) zero range (public API): 91.44 / 0 would be Inf, poisoning the
202    // returned corrections.
203    if !(zero_range_m > 0.0) {
204        return (0.0, 0.0);
205    }
206    // Range factor
207    let range_factor = 91.44 / zero_range_m; // 100 yards / zero range
208
209    // Sight corrections (opposite of jump direction)
210    let mut vertical_correction = -jump_components.vertical_jump_moa * range_factor;
211    let horizontal_correction = -jump_components.horizontal_jump_moa * range_factor;
212
213    // Account for sight height
214    let sight_factor = 1.0 + (sight_height_m / 0.05);
215    vertical_correction *= sight_factor;
216
217    (vertical_correction, horizontal_correction)
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 test_opposite_twist_direction() {
296        let crosswind = 4.4704; // 10 mph
297
298        // Right twist
299        let jump_right = calculate_aerodynamic_jump(
300            800.0, 17593.0, crosswind, 0.00782, 0.01134, 0.6096, 32.47, true, 0.0, 1.225,
301        );
302
303        // Left twist
304        let jump_left = calculate_aerodynamic_jump(
305            800.0, 17593.0, crosswind, 0.00782, 0.01134, 0.6096, 32.47, false, 0.0, 1.225,
306        );
307
308        // Opposite twist should give opposite vertical jump
309        assert!((jump_right.vertical_jump_moa + jump_left.vertical_jump_moa).abs() < 0.001);
310    }
311
312    #[test]
313    fn test_sight_correction() {
314        let jump = AerodynamicJumpComponents {
315            vertical_jump_moa: 0.5,
316            horizontal_jump_moa: 0.1,
317            jump_angle_rad: 0.0001,
318            magnus_component_moa: 0.4,
319            yaw_component_moa: 0.1,
320            stabilization_factor: 0.9,
321        };
322
323        let (vert, horiz) = calculate_sight_correction_for_jump(
324            &jump, 274.32, // 300 yards
325            0.05,   // 2" sight height
326        );
327
328        // Corrections should be opposite of jump
329        assert!(vert < 0.0);
330        assert!(horiz < 0.0);
331    }
332
333    #[test]
334    fn test_crosswind_sensitivity() {
335        let sensitivity = calculate_crosswind_jump_sensitivity(
336            800.0,   // velocity
337            17593.0, // spin rate
338            0.00782, // caliber
339            0.01134, // mass
340            32.47,   // twist rate
341            true,    // right twist
342        );
343
344        // Should be positive and reasonable (typically 0.01-0.1 MOA/mph)
345        assert!(sensitivity > 0.0);
346        assert!(sensitivity < 0.5);
347    }
348
349    // ---- Litz crosswind aerodynamic-jump estimator (the canonical solver model) ----
350
351    #[test]
352    fn litz_matches_the_published_formula() {
353        // Y = 0.01*Sg - 0.0024*L + 0.032 [MOA/mph], scaled by crosswind and twist hand.
354        // Sg = 1.75, L = 4.0 -> 0.0175 - 0.0096 + 0.032 = 0.0399 MOA/mph.
355        let per_mph = 0.01 * 1.75 - 0.0024 * 4.0 + 0.032;
356        let got = litz_crosswind_jump_moa(1.75, 4.0, 10.0, true);
357        assert!(
358            (got - per_mph * 10.0).abs() < 1e-12,
359            "got {got}, expected {}",
360            per_mph * 10.0
361        );
362        // Sanity: a few tenths of an MOA at 10 mph.
363        assert!((got - 0.399).abs() < 1e-3);
364    }
365
366    #[test]
367    fn litz_is_linear_in_crosswind() {
368        let one = litz_crosswind_jump_moa(1.8, 3.5, 1.0, true);
369        let ten = litz_crosswind_jump_moa(1.8, 3.5, 10.0, true);
370        assert!((ten - 10.0 * one).abs() < 1e-12);
371        assert_eq!(litz_crosswind_jump_moa(1.8, 3.5, 0.0, true), 0.0);
372    }
373
374    #[test]
375    fn litz_sign_flips_with_wind_side_and_twist() {
376        // Wind from the right + right twist -> up (positive).
377        let base = litz_crosswind_jump_moa(1.9, 4.0, 12.0, true);
378        assert!(base > 0.0);
379        // Reversing the wind side flips the sign, same magnitude.
380        assert!((litz_crosswind_jump_moa(1.9, 4.0, -12.0, true) + base).abs() < 1e-12);
381        // Flipping the twist hand flips the sign.
382        assert!((litz_crosswind_jump_moa(1.9, 4.0, 12.0, false) + base).abs() < 1e-12);
383    }
384
385    #[test]
386    fn litz_regression_can_go_negative_outside_its_fitted_range() {
387        // The estimator is a faithful linear fit (not clamped): a very long, marginally
388        // stable bullet drives 0.01*Sg - 0.0024*L + 0.032 below zero, reversing the jump.
389        // This is the extrapolation regime — see MBA-959.
390        let per_mph = 0.01 * 1.0 - 0.0024 * 20.0 + 0.032; // = -0.006
391        assert!(per_mph < 0.0);
392        let got = litz_crosswind_jump_moa(1.0, 20.0, 10.0, true);
393        assert!((got - per_mph * 10.0).abs() < 1e-12);
394        assert!(got < 0.0);
395    }
396}