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
7/// Legacy experimental coefficients retained for API compatibility.
8///
9/// [`calculate_advanced_spin_drift`] no longer applies these values because doing so would
10/// reweight the already calibrated Litz total-drift fit.
11#[derive(Debug, Clone)]
12pub struct SpinDriftCoefficients {
13    /// Litz coefficient for gyroscopic drift (typically 0.8-1.5)
14    pub litz_coefficient: f64,
15    /// McCoy's aerodynamic jump factor
16    pub mccoy_jump_factor: f64,
17    /// Courtney's transonic adjustment
18    pub transonic_factor: f64,
19    /// Yaw damping coefficient
20    pub yaw_damping: f64,
21}
22
23impl SpinDriftCoefficients {
24    /// Get coefficients for specific bullet types based on empirical data
25    pub fn for_bullet_type(bullet_type: &str) -> Self {
26        match bullet_type.to_lowercase().as_str() {
27            "match" | "bthp" | "boat_tail" => Self {
28                litz_coefficient: 1.25,
29                mccoy_jump_factor: 0.85,
30                transonic_factor: 0.75,
31                yaw_damping: 0.92,
32            },
33            "vld" | "very_low_drag" => Self {
34                litz_coefficient: 1.15,
35                mccoy_jump_factor: 0.78,
36                transonic_factor: 0.68,
37                yaw_damping: 0.88,
38            },
39            "hybrid" | "hybrid_ogive" => Self {
40                litz_coefficient: 1.20,
41                mccoy_jump_factor: 0.82,
42                transonic_factor: 0.72,
43                yaw_damping: 0.90,
44            },
45            "flat_base" | "fb" => Self {
46                litz_coefficient: 1.35,
47                mccoy_jump_factor: 0.95,
48                transonic_factor: 0.85,
49                yaw_damping: 0.95,
50            },
51            _ => Self::default(),
52        }
53    }
54
55    /// Return the legacy default coefficient set.
56    ///
57    /// This inherent constructor is retained in addition to [`Default`] for source compatibility
58    /// with callers that invoke it through a fully qualified inherent-method path.
59    #[allow(clippy::should_implement_trait)] // The trait is implemented below; this preserves API.
60    pub fn default() -> Self {
61        <Self as Default>::default()
62    }
63}
64
65impl Default for SpinDriftCoefficients {
66    fn default() -> Self {
67        Self {
68            litz_coefficient: 1.25,
69            mccoy_jump_factor: 0.85,
70            transonic_factor: 0.75,
71            yaw_damping: 0.92,
72        }
73    }
74}
75
76/// Calculate signed spin drift using the calibrated Litz total-drift fit.
77///
78/// The Litz `1.25 * (Sg + 1.2) * TOF^1.83` relation already fits total observed drift, including
79/// the effects of velocity loss and yaw damping. The additional state arguments are retained for
80/// API compatibility, but they must not be stacked onto the fitted total as independent
81/// multipliers. Atmospheric effects belong in the supplied `stability_factor` and time of flight.
82/// Muzzle velocity and density must remain finite and positive solely to preserve the legacy
83/// invalid-input contract; within that valid domain they do not rescale the result.
84#[allow(clippy::too_many_arguments)] // Public compatibility API; grouping would be breaking.
85pub fn calculate_advanced_spin_drift(
86    stability_factor: f64,
87    time_of_flight_s: f64,
88    _velocity_mps: f64,
89    muzzle_velocity_mps: f64,
90    _spin_rate_rad_s: f64,
91    _caliber_m: f64,
92    _mass_kg: f64,
93    air_density_kg_m3: f64,
94    is_right_twist: bool,
95    _bullet_type: &str,
96) -> f64 {
97    if !stability_factor.is_finite()
98        || stability_factor <= 1.0
99        || !time_of_flight_s.is_finite()
100        || time_of_flight_s <= 0.0
101        || !muzzle_velocity_mps.is_finite()
102        || muzzle_velocity_mps <= 0.0
103        || !air_density_kg_m3.is_finite()
104        || air_density_kg_m3 <= 0.0
105    {
106        return 0.0;
107    }
108
109    crate::spin_drift::litz_drift_meters(stability_factor, time_of_flight_s, is_right_twist)
110}
111
112/// Estimate flat-fire yaw of repose for the representative projectile in
113/// [`crate::precession_nutation::PrecessionNutationParams::default`].
114///
115/// Retained for source compatibility. Crosswind is a transient and is ignored; density must
116/// already be represented in the supplied local `stability_factor`; and caliber alone cannot
117/// determine the missing inertia ratio. Use
118/// [`crate::precession_nutation::calculate_limit_cycle_yaw_with_inertias`] for a
119/// projectile-specific result.
120#[deprecated(
121    since = "0.22.18",
122    note = "use precession_nutation::calculate_limit_cycle_yaw_with_inertias"
123)]
124pub fn calculate_advanced_yaw_of_repose(
125    stability_factor: f64,
126    velocity_mps: f64,
127    _crosswind_mps: f64,
128    spin_rate_rad_s: f64,
129    _air_density_kg_m3: f64,
130    _caliber_m: f64,
131) -> f64 {
132    let reference = crate::precession_nutation::PrecessionNutationParams::default();
133    crate::precession_nutation::calculate_limit_cycle_yaw_with_inertias(
134        velocity_mps,
135        spin_rate_rad_s,
136        stability_factor,
137        reference.spin_inertia,
138        reference.transverse_inertia,
139    )
140}
141
142/// Data-driven correction factor (placeholder for ML integration)
143pub fn apply_ml_correction(
144    base_drift: f64,
145    stability: f64,
146    mach: f64,
147    time_s: f64,
148    caliber_inches: f64,
149    mass_grains: f64,
150) -> f64 {
151    // This function would integrate with ML models trained on real-world data
152    // For now, returns the base drift unmodified
153    //
154    // In production, this would:
155    // 1. Extract features: [stability, mach, time_s, caliber_inches, mass_grains]
156    // 2. Pass to trained neural network or gradient boosting model
157    // 3. Return correction factor (typically 0.8-1.2)
158    // 4. Multiply base_drift by correction factor
159
160    // Placeholder implementation with simple heuristics
161    let mut correction = 1.0;
162
163    // Known adjustments from field data
164    if stability > 2.5 && mach < 1.0 {
165        correction *= 0.92; // Over-stabilized subsonic tends to drift less
166    }
167
168    if time_s > 2.0 && mach < 0.9 {
169        correction *= 1.08; // Long flight subsonic needs more correction
170    }
171
172    if caliber_inches < 0.264 && mass_grains < 100.0 {
173        correction *= 0.88; // Light, small caliber bullets drift less
174    }
175
176    base_drift * correction
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_advanced_spin_drift() {
185        // Test with typical .308 Match bullet
186        let drift = calculate_advanced_spin_drift(
187            1.5,     // stability
188            1.2,     // time of flight
189            600.0,   // current velocity m/s
190            850.0,   // muzzle velocity m/s
191            1500.0,  // spin rate rad/s
192            0.00308, // caliber in meters
193            0.0108,  // mass in kg (168 grains)
194            1.225,   // air density
195            true,    // right twist
196            "match", // bullet type
197        );
198
199        // Should give reasonable drift (2-8 inches at 1000 yards typical)
200        assert!(drift > 0.0);
201        assert!(drift < 0.3); // Less than 12 inches in meters
202    }
203
204    #[test]
205    fn advanced_spin_drift_matches_canonical_litz_total() {
206        let cases = [
207            (1.50, 0.25, 850.0, 850.0),
208            (1.74, 1.75, 440.0, 800.0),
209            (1.90, 1.40, 343.0, 850.0),
210        ];
211
212        for (stability, time, velocity, muzzle_velocity) in cases {
213            for is_right_twist in [false, true] {
214                let expected =
215                    crate::spin_drift::litz_drift_meters(stability, time, is_right_twist);
216                let actual = calculate_advanced_spin_drift(
217                    stability,
218                    time,
219                    velocity,
220                    muzzle_velocity,
221                    0.0,
222                    0.308 * 0.0254,
223                    175.0 * crate::constants::GRAINS_TO_KG,
224                    1.225,
225                    is_right_twist,
226                    "match",
227                );
228
229                assert_eq!(
230                    actual.to_bits(),
231                    expected.to_bits(),
232                    "advanced API changed calibrated Litz total: actual={actual} expected={expected}"
233                );
234            }
235        }
236    }
237
238    #[test]
239    fn litz_total_ignores_legacy_refinement_arguments() {
240        let stability = 1.74;
241        let time = 1.75;
242        let expected = crate::spin_drift::litz_drift_meters(stability, time, true);
243        let legacy_states = [
244            (440.0, 800.0, 0.0, 0.00782, 0.01134, 1.225, "match"),
245            (343.0, 900.0, 19_000.0, 0.00556, 0.00500, 1.000, "vld"),
246            (
247                100.0,
248                700.0,
249                -25_000.0,
250                0.01270,
251                0.04860,
252                0.900,
253                "flat_base",
254            ),
255            (
256                1_000.0, 1_200.0, 2_000.0, 0.02000, 0.10000, 1.400, "unknown",
257            ),
258        ];
259
260        for (velocity, muzzle_velocity, spin, caliber, mass, density, bullet_type) in legacy_states
261        {
262            let actual = calculate_advanced_spin_drift(
263                stability,
264                time,
265                velocity,
266                muzzle_velocity,
267                spin,
268                caliber,
269                mass,
270                density,
271                true,
272                bullet_type,
273            );
274            assert_eq!(actual.to_bits(), expected.to_bits());
275        }
276    }
277
278    #[test]
279    fn advanced_spin_drift_has_no_fixed_jump_intercept() {
280        let time = 1e-9;
281        let expected = crate::spin_drift::litz_drift_meters(1.74, time, true);
282        let actual = calculate_advanced_spin_drift(
283            1.74,
284            time,
285            800.0,
286            800.0,
287            17_000.0,
288            0.308 * 0.0254,
289            175.0 * crate::constants::GRAINS_TO_KG,
290            1.225,
291            true,
292            "match",
293        );
294
295        assert_eq!(actual.to_bits(), expected.to_bits());
296    }
297
298    #[test]
299    fn test_spin_drift_direction() {
300        // Right twist should produce positive drift
301        let right_drift = calculate_advanced_spin_drift(
302            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
303        );
304
305        // Left twist should produce negative drift
306        let left_drift = calculate_advanced_spin_drift(
307            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, false, "match",
308        );
309
310        assert!(right_drift > 0.0, "Right twist should give positive drift");
311        assert!(left_drift < 0.0, "Left twist should give negative drift");
312        assert!(
313            (right_drift.abs() - left_drift.abs()).abs() < 0.001,
314            "Magnitude should be equal"
315        );
316    }
317
318    #[test]
319    fn test_spin_drift_edge_cases() {
320        // Zero time should give zero drift
321        let zero_time = calculate_advanced_spin_drift(
322            1.5, 0.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
323        );
324        assert_eq!(zero_time, 0.0);
325
326        // Zero stability should give zero drift
327        let zero_stability = calculate_advanced_spin_drift(
328            0.0, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
329        );
330        assert_eq!(zero_stability, 0.0);
331
332        // Zero muzzle velocity should give zero drift
333        let zero_muzzle_vel = calculate_advanced_spin_drift(
334            1.5, 1.0, 700.0, 0.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
335        );
336        assert_eq!(zero_muzzle_vel, 0.0);
337
338        // Zero air density should give zero drift
339        let zero_density = calculate_advanced_spin_drift(
340            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 0.0, true, "match",
341        );
342        assert_eq!(zero_density, 0.0);
343    }
344
345    #[test]
346    fn test_spin_drift_coefficients_bullet_types() {
347        let match_coeffs = SpinDriftCoefficients::for_bullet_type("match");
348        let vld_coeffs = SpinDriftCoefficients::for_bullet_type("vld");
349        let flat_base_coeffs = SpinDriftCoefficients::for_bullet_type("flat_base");
350        let default_coeffs = SpinDriftCoefficients::for_bullet_type("unknown");
351
352        // VLD should have lower Litz coefficient
353        assert!(vld_coeffs.litz_coefficient < match_coeffs.litz_coefficient);
354
355        // Flat base should have higher Litz coefficient
356        assert!(flat_base_coeffs.litz_coefficient > match_coeffs.litz_coefficient);
357
358        // Default should match match type
359        assert_eq!(
360            default_coeffs.litz_coefficient,
361            match_coeffs.litz_coefficient
362        );
363    }
364
365    #[test]
366    fn test_spin_drift_increases_with_time() {
367        let drift_short = calculate_advanced_spin_drift(
368            1.5, 0.5, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
369        );
370        let drift_medium = calculate_advanced_spin_drift(
371            1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
372        );
373        let drift_long = calculate_advanced_spin_drift(
374            1.5, 2.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
375        );
376
377        assert!(
378            drift_medium > drift_short,
379            "Drift should increase with time"
380        );
381        assert!(drift_long > drift_medium, "Drift should increase with time");
382    }
383
384    #[test]
385    #[allow(deprecated)]
386    fn test_advanced_yaw_of_repose() {
387        let yaw = calculate_advanced_yaw_of_repose(
388            1.5,     // stability
389            800.0,   // velocity m/s
390            5.0,     // crosswind m/s
391            1500.0,  // spin rate rad/s
392            1.225,   // air density
393            0.00782, // caliber m
394        );
395
396        // Should give small angle in radians
397        assert!(yaw.abs() < 0.1, "Yaw should be small angle, got {}", yaw);
398    }
399
400    #[test]
401    #[allow(deprecated)]
402    fn advanced_yaw_of_repose_matches_gravity_gyroscopic_scaling() {
403        let calculate = |stability, velocity, crosswind, spin, density, caliber| {
404            calculate_advanced_yaw_of_repose(stability, velocity, crosswind, spin, density, caliber)
405        };
406
407        let reference = crate::precession_nutation::PrecessionNutationParams::default();
408        let expected = crate::precession_nutation::calculate_limit_cycle_yaw_with_inertias(
409            850.0,
410            17_522.0,
411            2.5,
412            reference.spin_inertia,
413            reference.transverse_inertia,
414        );
415        let actual = calculate(2.5, 850.0, 0.0, 17_522.0, 1.225, 0.00782);
416        assert_eq!(actual.to_bits(), expected.to_bits());
417
418        for crosswind in [-10.0, 10.0] {
419            assert_eq!(
420                calculate(2.5, 850.0, crosswind, 17_522.0, 1.225, 0.00782).to_bits(),
421                actual.to_bits()
422            );
423        }
424
425        let fixed_sg_fast = calculate(2.5, 800.0, 0.0, 17_522.0, 1.225, 0.00782);
426        let fixed_sg_slow = calculate(2.5, 400.0, 0.0, 17_522.0, 1.225, 0.00782);
427        assert!((fixed_sg_slow / fixed_sg_fast - 2.0).abs() < 2e-12);
428
429        let physical_fast = calculate(1.5, 800.0, 0.0, 17_522.0, 1.225, 0.00782);
430        let physical_slow = calculate(6.0, 400.0, 0.0, 17_522.0, 1.225, 0.00782);
431        assert!((physical_slow / physical_fast - 8.0).abs() < 8e-12);
432
433        let dense = calculate(1.5, 300.0, 0.0, 17_522.0, 1.225, 0.00782);
434        let thin_same_sg = calculate(1.5, 300.0, 0.0, 17_522.0, 0.6125, 0.00782);
435        let thin_coupled_sg = calculate(3.0, 300.0, 0.0, 17_522.0, 0.6125, 0.00782);
436        assert_eq!(thin_same_sg.to_bits(), dense.to_bits());
437        assert!((thin_coupled_sg / dense - 2.0).abs() < 2e-12);
438    }
439
440    #[test]
441    #[allow(deprecated)]
442    fn test_yaw_of_repose_edge_cases() {
443        // Zero stability should give zero yaw
444        let zero_stability =
445            calculate_advanced_yaw_of_repose(0.5, 800.0, 5.0, 1500.0, 1.225, 0.00782);
446        assert_eq!(zero_stability, 0.0);
447
448        // Zero velocity should give zero yaw
449        let zero_velocity = calculate_advanced_yaw_of_repose(1.5, 0.0, 5.0, 1500.0, 1.225, 0.00782);
450        assert_eq!(zero_velocity, 0.0);
451
452        let zero_spin = calculate_advanced_yaw_of_repose(1.5, 800.0, 5.0, 0.0, 1.225, 0.00782);
453        assert_eq!(zero_spin, 0.0);
454
455        let positive_spin =
456            calculate_advanced_yaw_of_repose(1.5, 800.0, 5.0, 1500.0, 1.225, 0.00782);
457        let negative_spin =
458            calculate_advanced_yaw_of_repose(1.5, 800.0, 5.0, -1500.0, 1.225, 0.00782);
459        assert_eq!(negative_spin.to_bits(), positive_spin.to_bits());
460
461        let at_boundary = calculate_advanced_yaw_of_repose(1.0, 800.0, 0.0, 1500.0, 1.225, 0.00782);
462        let below_boundary = calculate_advanced_yaw_of_repose(
463            1.0_f64.next_down(),
464            800.0,
465            0.0,
466            1500.0,
467            1.225,
468            0.00782,
469        );
470        assert!(at_boundary > 0.0);
471        assert_eq!(below_boundary, 0.0);
472
473        // No crosswind should still give small yaw (trajectory curvature)
474        let no_wind = calculate_advanced_yaw_of_repose(1.5, 800.0, 0.0, 1500.0, 1.225, 0.00782);
475        assert!(
476            no_wind > 0.0,
477            "Should have natural yaw from trajectory curvature"
478        );
479    }
480
481    #[test]
482    fn test_ml_correction_placeholder() {
483        // Test the ML correction placeholder function
484        let base_drift = 0.1;
485        let corrected = apply_ml_correction(base_drift, 1.5, 2.5, 1.0, 0.308, 168.0);
486
487        // Should return reasonable multiplied value
488        assert!(corrected > 0.0);
489
490        // Test specific heuristics
491        // Over-stabilized subsonic
492        let over_stab_subsonic = apply_ml_correction(0.1, 3.0, 0.8, 1.0, 0.308, 168.0);
493        assert!(
494            over_stab_subsonic < 0.1,
495            "Over-stabilized subsonic should drift less"
496        );
497
498        // Long flight subsonic
499        let long_subsonic = apply_ml_correction(0.1, 1.5, 0.85, 2.5, 0.308, 168.0);
500        assert!(
501            long_subsonic > 0.1,
502            "Long subsonic flight should need more correction"
503        );
504
505        // Light small caliber
506        let light_small = apply_ml_correction(0.1, 1.5, 2.5, 1.0, 0.224, 55.0);
507        assert!(light_small < 0.1, "Light small caliber should drift less");
508    }
509}