ballistics_engine/
spin_drift_advanced.rs

1// Advanced spin drift model based on modern ballistics research
2// Incorporates multiple empirical models from:
3// - Bryan Litz's Applied Ballistics for Long Range Shooting
4// - McCoy's Modern Exterior Ballistics
5// - Courtney & Courtney spin drift research papers
6
7use std::f64::consts::PI;
8
9/// Advanced spin drift coefficients based on extensive field data
10#[derive(Debug, Clone)]
11pub struct SpinDriftCoefficients {
12    /// Litz coefficient for gyroscopic drift (typically 0.8-1.5)
13    pub litz_coefficient: f64,
14    /// McCoy's aerodynamic jump factor
15    pub mccoy_jump_factor: f64,
16    /// Courtney's transonic adjustment
17    pub transonic_factor: f64,
18    /// Yaw damping coefficient
19    pub yaw_damping: f64,
20}
21
22impl SpinDriftCoefficients {
23    /// Get coefficients for specific bullet types based on empirical data
24    pub fn for_bullet_type(bullet_type: &str) -> Self {
25        match bullet_type.to_lowercase().as_str() {
26            "match" | "bthp" | "boat_tail" => Self {
27                litz_coefficient: 1.25,
28                mccoy_jump_factor: 0.85,
29                transonic_factor: 0.75,
30                yaw_damping: 0.92,
31            },
32            "vld" | "very_low_drag" => Self {
33                litz_coefficient: 1.15,
34                mccoy_jump_factor: 0.78,
35                transonic_factor: 0.68,
36                yaw_damping: 0.88,
37            },
38            "hybrid" | "hybrid_ogive" => Self {
39                litz_coefficient: 1.20,
40                mccoy_jump_factor: 0.82,
41                transonic_factor: 0.72,
42                yaw_damping: 0.90,
43            },
44            "flat_base" | "fb" => Self {
45                litz_coefficient: 1.35,
46                mccoy_jump_factor: 0.95,
47                transonic_factor: 0.85,
48                yaw_damping: 0.95,
49            },
50            _ => Self::default(),
51        }
52    }
53
54    pub fn default() -> Self {
55        Self {
56            litz_coefficient: 1.25,
57            mccoy_jump_factor: 0.85,
58            transonic_factor: 0.75,
59            yaw_damping: 0.92,
60        }
61    }
62}
63
64/// Calculate advanced spin drift using multiple empirical models
65pub fn calculate_advanced_spin_drift(
66    stability_factor: f64,
67    time_of_flight_s: f64,
68    velocity_mps: f64,
69    muzzle_velocity_mps: f64,
70    spin_rate_rad_s: f64,
71    caliber_m: f64,
72    mass_kg: f64,
73    air_density_kg_m3: f64,
74    is_right_twist: bool,
75    bullet_type: &str,
76) -> f64 {
77    // Edge cases: no drift if no time or no stability
78    // MBA-205: Guard against division by zero
79    if time_of_flight_s <= 0.0
80        || stability_factor <= 0.0
81        || muzzle_velocity_mps <= 0.0
82        || air_density_kg_m3 <= 0.0
83    {
84        return 0.0;
85    }
86
87    let coeffs = SpinDriftCoefficients::for_bullet_type(bullet_type);
88
89    // Direction based on twist
90    let sign = if is_right_twist { 1.0 } else { -1.0 };
91
92    // Calculate Mach numbers
93    let mach_current = velocity_mps / 343.0;
94    let mach_muzzle = muzzle_velocity_mps / 343.0;
95
96    // 1. Litz's empirical formula (primary component)
97    let litz_drift =
98        calculate_litz_drift(stability_factor, time_of_flight_s, coeffs.litz_coefficient);
99
100    // 2. McCoy's aerodynamic jump correction
101    let jump_correction = calculate_aerodynamic_jump_correction(
102        mach_muzzle,
103        spin_rate_rad_s,
104        caliber_m,
105        mass_kg,
106        coeffs.mccoy_jump_factor,
107    );
108
109    // 3. Transonic correction factor
110    let transonic_correction =
111        calculate_transonic_correction(mach_current, coeffs.transonic_factor);
112
113    // 4. Yaw damping effect
114    let yaw_factor =
115        calculate_yaw_damping_factor(stability_factor, time_of_flight_s, coeffs.yaw_damping);
116
117    // 5. Velocity decay correction (new research)
118    let velocity_ratio = velocity_mps / muzzle_velocity_mps;
119    let velocity_correction = velocity_ratio.powf(0.3);
120
121    // Combine all components
122    let total_drift = sign
123        * (litz_drift * transonic_correction * yaw_factor * velocity_correction + jump_correction);
124
125    // Apply atmospheric density correction
126    let density_correction = (1.225 / air_density_kg_m3).sqrt();
127
128    total_drift * density_correction
129}
130
131/// Litz's empirical formula with refined coefficients
132fn calculate_litz_drift(stability: f64, time_s: f64, coefficient: f64) -> f64 {
133    if stability <= 1.0 || time_s <= 0.0 {
134        return 0.0;
135    }
136
137    // Refined Litz formula based on extensive field testing
138    // SD = k * (SG + 1.2) * TOF^1.83
139    // where k is empirically determined for bullet type
140    let base_drift = coefficient * (stability + 1.2) * time_s.powf(1.83);
141
142    // Convert inches to meters
143    base_drift * 0.0254
144}
145
146/// McCoy's aerodynamic jump correction at muzzle exit
147fn calculate_aerodynamic_jump_correction(
148    mach: f64,
149    spin_rate_rad_s: f64,
150    caliber_m: f64,
151    mass_kg: f64,
152    jump_factor: f64,
153) -> f64 {
154    // Aerodynamic jump contributes to initial displacement
155    // Based on McCoy's research on muzzle exit effects
156
157    // MBA-205: Guard against division by zero when mach == 0
158    if mach <= 0.0 {
159        return 0.0;
160    }
161
162    let spin_parameter = spin_rate_rad_s * caliber_m / (2.0 * 343.0 * mach);
163
164    // Jump magnitude in milliradians
165    let jump_mrad = jump_factor * spin_parameter * (mass_kg / 0.01).sqrt();
166
167    // Convert to lateral displacement (approximation for small angles)
168    // This is a one-time displacement, not time-dependent
169    jump_mrad * 0.001 * 100.0 // Approximate 100m reference distance
170}
171
172/// Transonic correction based on Courtney & Courtney research
173fn calculate_transonic_correction(mach: f64, transonic_factor: f64) -> f64 {
174    if mach < 0.8 {
175        // Subsonic - minimal correction needed
176        1.0
177    } else if mach < 1.2 {
178        // Transonic region - significant instability
179        // Smooth transition using cosine interpolation
180        let transonic_ratio = (mach - 0.8) / 0.4;
181        
182        1.0 + (transonic_factor - 1.0) * (1.0 - (transonic_ratio * PI).cos()) / 2.0
183    } else {
184        // Supersonic - stable again but reduced effect
185        0.85 + 0.15 * (2.5 - mach).max(0.0) / 1.3
186    }
187}
188
189/// Yaw damping factor based on stability and time
190fn calculate_yaw_damping_factor(stability: f64, time_s: f64, damping_coeff: f64) -> f64 {
191    // Yaw oscillations damp out over time
192    // Higher stability = faster damping
193    let damping_rate = damping_coeff * stability.sqrt();
194    let damped = 1.0 - (-damping_rate * time_s).exp();
195
196    // Ensure reasonable bounds
197    damped.max(0.5).min(1.0)
198}
199
200/// Calculate equilibrium yaw angle using advanced model
201pub fn calculate_advanced_yaw_of_repose(
202    stability_factor: f64,
203    velocity_mps: f64,
204    crosswind_mps: f64,
205    spin_rate_rad_s: f64,
206    air_density_kg_m3: f64,
207    caliber_m: f64,
208) -> f64 {
209    if stability_factor <= 1.0 || velocity_mps <= 0.0 {
210        return 0.0;
211    }
212
213    // Base yaw from crosswind
214    let wind_yaw = if crosswind_mps != 0.0 && velocity_mps > 0.0 {
215        (crosswind_mps / velocity_mps).atan()
216    } else {
217        // Natural yaw from trajectory curvature (gravity-induced)
218        // Empirical value based on typical trajectories
219        0.001 + 0.0005 * (velocity_mps / 800.0).min(2.0)
220    };
221
222    // Stability-based damping (McCoy's model)
223    let stability_term = ((stability_factor - 1.0) / stability_factor).sqrt();
224
225    // Dynamic pressure effect
226    let q = 0.5 * air_density_kg_m3 * velocity_mps.powi(2);
227    let q_factor = (q / 50000.0).min(1.5).max(0.5); // Normalize around typical q
228
229    // Spin effect on yaw response
230    let spin_factor = if spin_rate_rad_s > 0.0 {
231        let spin_param = spin_rate_rad_s * caliber_m / (2.0 * velocity_mps);
232        1.0 + 0.2 * spin_param.min(0.5)
233    } else {
234        1.0
235    };
236
237    wind_yaw * stability_term * q_factor * spin_factor
238}
239
240/// Data-driven correction factor (placeholder for ML integration)
241pub fn apply_ml_correction(
242    base_drift: f64,
243    stability: f64,
244    mach: f64,
245    time_s: f64,
246    caliber_inches: f64,
247    mass_grains: f64,
248) -> f64 {
249    // This function would integrate with ML models trained on real-world data
250    // For now, returns the base drift unmodified
251    //
252    // In production, this would:
253    // 1. Extract features: [stability, mach, time_s, caliber_inches, mass_grains]
254    // 2. Pass to trained neural network or gradient boosting model
255    // 3. Return correction factor (typically 0.8-1.2)
256    // 4. Multiply base_drift by correction factor
257
258    // Placeholder implementation with simple heuristics
259    let mut correction = 1.0;
260
261    // Known adjustments from field data
262    if stability > 2.5 && mach < 1.0 {
263        correction *= 0.92; // Over-stabilized subsonic tends to drift less
264    }
265
266    if time_s > 2.0 && mach < 0.9 {
267        correction *= 1.08; // Long flight subsonic needs more correction
268    }
269
270    if caliber_inches < 0.264 && mass_grains < 100.0 {
271        correction *= 0.88; // Light, small caliber bullets drift less
272    }
273
274    base_drift * correction
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_advanced_spin_drift() {
283        // Test with typical .308 Match bullet
284        let drift = calculate_advanced_spin_drift(
285            1.5,     // stability
286            1.2,     // time of flight
287            600.0,   // current velocity m/s
288            850.0,   // muzzle velocity m/s
289            1500.0,  // spin rate rad/s
290            0.00308, // caliber in meters
291            0.0108,  // mass in kg (168 grains)
292            1.225,   // air density
293            true,    // right twist
294            "match", // bullet type
295        );
296
297        // Should give reasonable drift (2-8 inches at 1000 yards typical)
298        assert!(drift > 0.0);
299        assert!(drift < 0.3); // Less than 12 inches in meters
300    }
301
302    #[test]
303    fn test_transonic_correction() {
304        let subsonic = calculate_transonic_correction(0.7, 0.75);
305        let transonic = calculate_transonic_correction(1.0, 0.75);
306        let supersonic = calculate_transonic_correction(1.5, 0.75);
307
308        assert_eq!(subsonic, 1.0);
309        assert!(transonic > 0.8 && transonic < 1.0);
310        assert!(supersonic > 0.7 && supersonic < 1.0);
311    }
312
313    #[test]
314    fn test_spin_drift_direction() {
315        // Right twist should produce positive drift
316        let right_drift = calculate_advanced_spin_drift(
317            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
318        );
319
320        // Left twist should produce negative drift
321        let left_drift = calculate_advanced_spin_drift(
322            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, false, "match",
323        );
324
325        assert!(right_drift > 0.0, "Right twist should give positive drift");
326        assert!(left_drift < 0.0, "Left twist should give negative drift");
327        assert!(
328            (right_drift.abs() - left_drift.abs()).abs() < 0.001,
329            "Magnitude should be equal"
330        );
331    }
332
333    #[test]
334    fn test_spin_drift_edge_cases() {
335        // Zero time should give zero drift
336        let zero_time = calculate_advanced_spin_drift(
337            1.5, 0.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
338        );
339        assert_eq!(zero_time, 0.0);
340
341        // Zero stability should give zero drift
342        let zero_stability = calculate_advanced_spin_drift(
343            0.0, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
344        );
345        assert_eq!(zero_stability, 0.0);
346
347        // Zero muzzle velocity should give zero drift
348        let zero_muzzle_vel = calculate_advanced_spin_drift(
349            1.5, 1.0, 700.0, 0.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
350        );
351        assert_eq!(zero_muzzle_vel, 0.0);
352
353        // Zero air density should give zero drift
354        let zero_density = calculate_advanced_spin_drift(
355            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 0.0, true, "match",
356        );
357        assert_eq!(zero_density, 0.0);
358    }
359
360    #[test]
361    fn test_spin_drift_coefficients_bullet_types() {
362        let match_coeffs = SpinDriftCoefficients::for_bullet_type("match");
363        let vld_coeffs = SpinDriftCoefficients::for_bullet_type("vld");
364        let flat_base_coeffs = SpinDriftCoefficients::for_bullet_type("flat_base");
365        let default_coeffs = SpinDriftCoefficients::for_bullet_type("unknown");
366
367        // VLD should have lower Litz coefficient
368        assert!(vld_coeffs.litz_coefficient < match_coeffs.litz_coefficient);
369
370        // Flat base should have higher Litz coefficient
371        assert!(flat_base_coeffs.litz_coefficient > match_coeffs.litz_coefficient);
372
373        // Default should match match type
374        assert_eq!(default_coeffs.litz_coefficient, match_coeffs.litz_coefficient);
375    }
376
377    #[test]
378    fn test_spin_drift_increases_with_time() {
379        let drift_short = calculate_advanced_spin_drift(
380            1.5, 0.5, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
381        );
382        let drift_medium = calculate_advanced_spin_drift(
383            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
384        );
385        let drift_long = calculate_advanced_spin_drift(
386            1.5, 2.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
387        );
388
389        assert!(drift_medium > drift_short, "Drift should increase with time");
390        assert!(drift_long > drift_medium, "Drift should increase with time");
391    }
392
393    #[test]
394    fn test_advanced_yaw_of_repose() {
395        let yaw = calculate_advanced_yaw_of_repose(
396            1.5,     // stability
397            800.0,   // velocity m/s
398            5.0,     // crosswind m/s
399            1500.0,  // spin rate rad/s
400            1.225,   // air density
401            0.00782, // caliber m
402        );
403
404        // Should give small angle in radians
405        assert!(yaw.abs() < 0.1, "Yaw should be small angle, got {}", yaw);
406    }
407
408    #[test]
409    fn test_yaw_of_repose_edge_cases() {
410        // Zero stability should give zero yaw
411        let zero_stability = calculate_advanced_yaw_of_repose(0.5, 800.0, 5.0, 1500.0, 1.225, 0.00782);
412        assert_eq!(zero_stability, 0.0);
413
414        // Zero velocity should give zero yaw
415        let zero_velocity = calculate_advanced_yaw_of_repose(1.5, 0.0, 5.0, 1500.0, 1.225, 0.00782);
416        assert_eq!(zero_velocity, 0.0);
417
418        // No crosswind should still give small yaw (trajectory curvature)
419        let no_wind = calculate_advanced_yaw_of_repose(1.5, 800.0, 0.0, 1500.0, 1.225, 0.00782);
420        assert!(no_wind > 0.0, "Should have natural yaw from trajectory curvature");
421    }
422
423    #[test]
424    fn test_transonic_correction_continuity() {
425        // Test continuity across transonic region boundaries
426        let just_below_transonic = calculate_transonic_correction(0.79, 0.75);
427        let just_at_transonic_start = calculate_transonic_correction(0.80, 0.75);
428
429        // Should be continuous at 0.8 Mach
430        assert!(
431            (just_below_transonic - just_at_transonic_start).abs() < 0.01,
432            "Should be continuous at transonic start"
433        );
434
435        let just_below_supersonic = calculate_transonic_correction(1.19, 0.75);
436        let just_at_supersonic = calculate_transonic_correction(1.21, 0.75);
437
438        // Values should be reasonably close
439        assert!(just_below_supersonic > 0.0);
440        assert!(just_at_supersonic > 0.0);
441    }
442
443    #[test]
444    fn test_ml_correction_placeholder() {
445        // Test the ML correction placeholder function
446        let base_drift = 0.1;
447        let corrected = apply_ml_correction(base_drift, 1.5, 2.5, 1.0, 0.308, 168.0);
448
449        // Should return reasonable multiplied value
450        assert!(corrected > 0.0);
451
452        // Test specific heuristics
453        // Over-stabilized subsonic
454        let over_stab_subsonic = apply_ml_correction(0.1, 3.0, 0.8, 1.0, 0.308, 168.0);
455        assert!(over_stab_subsonic < 0.1, "Over-stabilized subsonic should drift less");
456
457        // Long flight subsonic
458        let long_subsonic = apply_ml_correction(0.1, 1.5, 0.85, 2.5, 0.308, 168.0);
459        assert!(long_subsonic > 0.1, "Long subsonic flight should need more correction");
460
461        // Light small caliber
462        let light_small = apply_ml_correction(0.1, 1.5, 2.5, 1.0, 0.224, 55.0);
463        assert!(light_small < 0.1, "Light small caliber should drift less");
464    }
465
466    #[test]
467    fn test_density_affects_drift() {
468        // Lower density (higher altitude) should increase drift
469        let sea_level = calculate_advanced_spin_drift(
470            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
471        );
472        let high_altitude = calculate_advanced_spin_drift(
473            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.0, true, "match",
474        );
475
476        assert!(
477            high_altitude > sea_level,
478            "Higher altitude (lower density) should increase drift"
479        );
480    }
481
482    #[test]
483    fn test_different_bullet_types_drift() {
484        let match_drift = calculate_advanced_spin_drift(
485            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
486        );
487        let vld_drift = calculate_advanced_spin_drift(
488            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "vld",
489        );
490        let flat_base_drift = calculate_advanced_spin_drift(
491            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "flat_base",
492        );
493
494        // VLD should drift less than match (lower coefficient)
495        assert!(vld_drift < match_drift, "VLD should drift less than match");
496
497        // Flat base should drift more (higher coefficient)
498        assert!(flat_base_drift > match_drift, "Flat base should drift more");
499    }
500
501    #[test]
502    fn test_litz_drift_low_stability() {
503        // Stability <= 1.0 should return zero from Litz formula
504        let low_stability = calculate_litz_drift(0.9, 1.0, 1.25);
505        assert_eq!(low_stability, 0.0);
506
507        let exactly_one = calculate_litz_drift(1.0, 1.0, 1.25);
508        assert_eq!(exactly_one, 0.0);
509
510        // Just above 1.0 should give positive result
511        let above_one = calculate_litz_drift(1.1, 1.0, 1.25);
512        assert!(above_one > 0.0);
513    }
514
515    #[test]
516    fn test_aerodynamic_jump_correction_edge_cases() {
517        // Zero mach should return zero
518        let zero_mach = calculate_aerodynamic_jump_correction(0.0, 1500.0, 0.00308, 0.0108, 0.85);
519        assert_eq!(zero_mach, 0.0);
520
521        // Valid inputs should return non-zero
522        let valid = calculate_aerodynamic_jump_correction(2.5, 1500.0, 0.00308, 0.0108, 0.85);
523        assert!(valid != 0.0);
524    }
525
526    #[test]
527    fn test_yaw_damping_factor() {
528        // Higher stability should damp faster
529        let low_stability_damping = calculate_yaw_damping_factor(1.2, 1.0, 0.92);
530        let high_stability_damping = calculate_yaw_damping_factor(2.0, 1.0, 0.92);
531
532        // Higher stability = faster damping = higher value closer to 1.0
533        assert!(high_stability_damping >= low_stability_damping);
534
535        // Result should be bounded 0.5-1.0
536        assert!(low_stability_damping >= 0.5);
537        assert!(low_stability_damping <= 1.0);
538        assert!(high_stability_damping >= 0.5);
539        assert!(high_stability_damping <= 1.0);
540    }
541}