Skip to main content

ballistics_engine/
spin_drift.rs

1use crate::pitch_damping::{
2    calculate_damped_yaw_of_repose, calculate_pitch_damping_moment, PitchDampingCoefficients,
3};
4use crate::spin_decay::{update_spin_rate, SpinDecayParameters};
5use std::f64::consts::PI;
6
7/// Components of enhanced spin drift calculation
8#[derive(Debug, Clone)]
9pub struct SpinDriftComponents {
10    pub spin_rate_rps: f64,          // Revolutions per second
11    pub spin_rate_rad_s: f64,        // Radians per second
12    pub stability_factor: f64,       // Gyroscopic stability (Sg)
13    pub yaw_of_repose_rad: f64,      // Equilibrium yaw angle
14    pub drift_rate_mps: f64,         // Lateral drift rate (m/s)
15    pub total_drift_m: f64,          // Total drift at current time
16    pub magnus_component_m: f64,     // Magnus effect contribution
17    pub gyroscopic_component_m: f64, // Pure gyroscopic drift
18    pub pitch_damping_moment: f64,   // Pitch damping moment (N⋅m)
19    pub yaw_convergence_rate: f64,   // Convergence rate to equilibrium (rad/s)
20    pub pitch_rate_rad_s: f64,       // Current pitch/yaw rate (rad/s)
21}
22
23/// Base Miller gyroscopic stability factor (no velocity/density correction).
24/// All inputs imperial: caliber/length in inches, mass in grains, twist in
25/// inches-per-turn. Returns 0.0 for non-positive inputs.
26///   Sg = 30 m / (t^2 d^3 l (1 + l^2)),  t,l in calibers, d in inches, m in grains
27pub(crate) fn miller_stability(
28    caliber_in: f64,
29    weight_gr: f64,
30    twist_in: f64,
31    length_in: f64,
32) -> f64 {
33    if caliber_in <= 0.0 || weight_gr <= 0.0 || twist_in <= 0.0 || length_in <= 0.0 {
34        return 0.0;
35    }
36    let twist_cal = twist_in / caliber_in;
37    let l_cal = length_in / caliber_in;
38    let denom = twist_cal * twist_cal * caliber_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
39    if denom == 0.0 {
40        return 0.0;
41    }
42    30.0 * weight_gr / denom
43}
44
45/// Calculate bullet spin rate from velocity and twist rate
46pub fn calculate_spin_rate(velocity_mps: f64, twist_rate_inches: f64) -> (f64, f64) {
47    if twist_rate_inches <= 0.0 {
48        return (0.0, 0.0);
49    }
50
51    // Convert velocity to inches/second
52    let velocity_ips = velocity_mps * 39.3701;
53
54    // Calculate revolutions per second
55    let spin_rate_rps = velocity_ips / twist_rate_inches;
56
57    // Convert to radians per second
58    let spin_rate_rad_s = spin_rate_rps * 2.0 * PI;
59
60    (spin_rate_rps, spin_rate_rad_s)
61}
62
63/// Calculate dynamic gyroscopic stability factor using Miller formula
64pub fn calculate_dynamic_stability(
65    bullet_mass_grains: f64,
66    velocity_mps: f64,
67    spin_rate_rad_s: f64,
68    caliber_inches: f64,
69    length_inches: f64,
70    air_density_kg_m3: f64,
71) -> f64 {
72    if spin_rate_rad_s == 0.0 || velocity_mps == 0.0 {
73        return 0.0;
74    }
75
76    // Convert velocity to fps for Miller formula
77    let velocity_fps = velocity_mps * 3.28084;
78
79    // Calculate twist rate in calibers
80    if caliber_inches > 0.0 {
81        // Back-calculate twist rate from spin rate
82        let spin_rps = spin_rate_rad_s / (2.0 * PI);
83        let velocity_ips = velocity_fps * 12.0; // inches per second
84        let twist_inches = if spin_rps > 0.0 {
85            velocity_ips / spin_rps
86        } else {
87            0.0
88        };
89        let twist_calibers = if twist_inches > 0.0 {
90            twist_inches / caliber_inches
91        } else {
92            0.0
93        };
94
95        // Length to diameter ratio
96        let length_calibers = if caliber_inches > 0.0 {
97            length_inches / caliber_inches
98        } else {
99            0.0
100        };
101
102        // Miller stability formula (simplified)
103        // Sg = 30 * m / (t^2 * d^3 * l * (1 + l^2))
104        // Where: m = mass in grains, t = twist in calibers, d = diameter in inches
105        //        l = length in calibers
106
107        if twist_calibers == 0.0 || length_calibers == 0.0 {
108            return 0.0;
109        }
110
111        let numerator = 30.0 * bullet_mass_grains;
112        let denominator = twist_calibers.powi(2)
113            * caliber_inches.powi(3)
114            * length_calibers
115            * (1.0 + length_calibers.powi(2));
116
117        if denominator == 0.0 {
118            return 0.0;
119        }
120
121        // Base stability
122        let sg_base = numerator / denominator;
123
124        // Velocity correction (compared to standard 2800 fps)
125        let velocity_factor = (velocity_fps / 2800.0).powf(1.0 / 3.0);
126
127        // Atmospheric correction
128        // Standard conditions: 59°F, 29.92 inHg = 1.225 kg/m³
129        let density_factor = (1.225 / air_density_kg_m3).sqrt();
130
131        // Final stability
132        sg_base * velocity_factor * density_factor
133    } else {
134        0.0
135    }
136}
137
138/// Calculate the yaw of repose (equilibrium yaw angle)
139pub fn calculate_yaw_of_repose(
140    stability_factor: f64,
141    velocity_mps: f64,
142    spin_rate_rad_s: f64,
143    wind_velocity_mps: f64,
144    pitch_rate_rad_s: f64,
145    air_density_kg_m3: f64,
146    caliber_inches: f64,
147    length_inches: f64,
148    mass_grains: f64,
149    mach: f64,
150    bullet_type: &str,
151    use_pitch_damping: bool,
152) -> (f64, f64) {
153    if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
154        return (0.0, 0.0);
155    }
156
157    // Use enhanced calculation with pitch damping if requested
158    if use_pitch_damping && mach > 0.0 {
159        // Map bullet types for pitch damping
160        let damping_type = match bullet_type.to_lowercase().as_str() {
161            "match" => "match_boat_tail",
162            "hunting" => "hunting",
163            "fmj" => "fmj",
164            "vld" => "vld",
165            _ => "match_boat_tail",
166        };
167
168        return calculate_damped_yaw_of_repose(
169            stability_factor,
170            velocity_mps,
171            spin_rate_rad_s,
172            wind_velocity_mps,
173            pitch_rate_rad_s,
174            air_density_kg_m3,
175            caliber_inches,
176            length_inches,
177            mass_grains,
178            mach,
179            damping_type,
180        );
181    }
182
183    // Original calculation (backward compatibility)
184    // Crosswind component creates yaw
185    let yaw_rad = if wind_velocity_mps == 0.0 {
186        // No wind - use typical value for spin drift
187        // Yaw develops due to nose following curved trajectory
188        0.002 // ~0.1 degrees typical
189    } else {
190        // Wind-induced yaw
191        if velocity_mps > 0.0 {
192            (wind_velocity_mps / velocity_mps).atan()
193        } else {
194            0.0
195        }
196    };
197
198    // Damping factor based on stability with safe division
199    let stability_term = (stability_factor - 1.0).max(0.0).sqrt();
200    let damping = 1.0 / (1.0 + stability_term);
201
202    (yaw_rad * damping, 0.0) // No convergence rate in simple model
203}
204
205/// Calculate Magnus effect contribution to drift
206pub fn calculate_magnus_drift_component(
207    velocity_mps: f64,
208    spin_rate_rad_s: f64,
209    yaw_rad: f64,
210    air_density_kg_m3: f64,
211    caliber_inches: f64,
212    time_s: f64,
213    mass_grains: f64,
214) -> f64 {
215    let diameter_m = caliber_inches * 0.0254;
216    let mass_kg = mass_grains * 0.00006479891; // Convert grains to kg
217
218    // Magnus force coefficient (empirical)
219    // Varies with Mach number
220    let mach = velocity_mps / 343.0; // Approximate speed of sound
221
222    let cmag = if mach < 0.8 {
223        0.25
224    } else if mach < 1.2 {
225        // Transonic reduction
226        0.15
227    } else {
228        // Supersonic
229        0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
230    };
231
232    // Spin ratio
233    let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
234
235    // Magnus force
236    let magnus_force = if velocity_mps > 0.0 {
237        cmag * spin_ratio
238            * yaw_rad
239            * 0.5
240            * air_density_kg_m3
241            * velocity_mps.powi(2)
242            * PI
243            * (diameter_m / 2.0).powi(2)
244    } else {
245        0.0
246    };
247
248    // Convert force to acceleration by dividing by mass
249    let magnus_accel = magnus_force / mass_kg;
250
251    // Drift over time (simplified - should integrate)
252
253    0.5 * magnus_accel * time_s.powi(2)
254}
255
256/// Calculate pure gyroscopic drift (Poisson effect)
257pub fn calculate_gyroscopic_drift(
258    stability_factor: f64,
259    _yaw_rad: f64,
260    velocity_mps: f64,
261    time_s: f64,
262    is_right_twist: bool,
263) -> f64 {
264    if stability_factor <= 1.0 || time_s <= 0.0 {
265        return 0.0;
266    }
267
268    // Litz formula is not reliable for subsonic flight. Disable it.
269    let velocity_fps = velocity_mps * 3.28084;
270    if velocity_fps < 1125.0 {
271        return 0.0;
272    }
273
274    // Direction based on twist
275    let sign = if is_right_twist { 1.0 } else { -1.0 };
276
277    // Bryan Litz's empirical formula for spin drift
278    let base_coefficient = 1.25 * (stability_factor + 1.2);
279    let time_factor = time_s.powf(1.83);
280    let drift_in = sign * base_coefficient * time_factor;
281
282    // Convert to meters
283
284    drift_in * 0.0254
285}
286
287/// Calculate enhanced spin drift with all components
288pub fn calculate_enhanced_spin_drift(
289    bullet_mass: f64,
290    velocity_mps: f64,
291    twist_rate: f64,
292    bullet_diameter: f64,
293    bullet_length: f64,
294    is_twist_right: bool,
295    time_s: f64,
296    air_density: f64,
297    crosswind_mps: f64,
298    pitch_rate_rad_s: f64,
299    use_pitch_damping: bool,
300) -> SpinDriftComponents {
301    // Calculate initial spin rate (at muzzle)
302    let muzzle_velocity = velocity_mps; // Assuming we're passed muzzle velocity
303    let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
304
305    // Apply spin decay based on time of flight
306    let decay_params = SpinDecayParameters::from_bullet_type("match"); // Default to match for now
307    let current_spin_rad_s = update_spin_rate(
308        initial_spin_rad_s,
309        time_s,
310        velocity_mps,
311        air_density,
312        bullet_mass, // already grains (update_spin_rate wants mass_grains)
313        bullet_diameter,
314        bullet_length,
315        Some(&decay_params),
316    );
317
318    let spin_rps = current_spin_rad_s / (2.0 * PI);
319    let spin_rad_s = current_spin_rad_s;
320
321    // Calculate dynamic stability
322    let stability = calculate_dynamic_stability(
323        bullet_mass,
324        velocity_mps,
325        spin_rad_s,
326        bullet_diameter,
327        bullet_length,
328        air_density,
329    );
330
331    // Calculate Mach number for pitch damping
332    let mach = velocity_mps / 343.0; // Approximate speed of sound
333
334    // Determine bullet type (default to match for now)
335    let bullet_type = "match";
336
337    // Calculate yaw of repose with pitch damping
338    let (yaw_rad, convergence_rate) = calculate_yaw_of_repose(
339        stability,
340        velocity_mps,
341        spin_rad_s,
342        crosswind_mps,
343        pitch_rate_rad_s,
344        air_density,
345        bullet_diameter,
346        bullet_length,
347        bullet_mass,
348        mach,
349        bullet_type,
350        use_pitch_damping,
351    );
352
353    // Calculate Magnus component
354    let magnus_drift = calculate_magnus_drift_component(
355        velocity_mps,
356        spin_rad_s,
357        yaw_rad,
358        air_density,
359        bullet_diameter,
360        time_s,
361        bullet_mass,
362    );
363
364    // Calculate gyroscopic component
365    let gyro_drift =
366        calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
367
368    // Total drift. gyro_drift already carries the twist-direction sign (from
369    // calculate_gyroscopic_drift); sign the Magnus term to the SAME convention so both
370    // contributions are consistently directed before being summed. (magnus_component_m is
371    // kept unsigned below for backward compatibility.)
372    let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
373    let total_drift = twist_sign * magnus_drift + gyro_drift;
374
375    // Drift rate (derivative)
376    let drift_rate = if time_s > 0.0 {
377        total_drift / time_s
378    } else {
379        0.0
380    };
381
382    // Calculate pitch damping moment if using enhanced model
383    let pitch_damping_moment = if use_pitch_damping && mach > 0.0 {
384        let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
385        calculate_pitch_damping_moment(
386            pitch_rate_rad_s,
387            velocity_mps,
388            air_density,
389            bullet_diameter * 0.0254, // Convert to meters
390            bullet_length * 0.0254,   // Convert to meters
391            mach,
392            &coeffs,
393        )
394    } else {
395        0.0
396    };
397
398    SpinDriftComponents {
399        spin_rate_rps: spin_rps,
400        spin_rate_rad_s: spin_rad_s,
401        stability_factor: stability,
402        yaw_of_repose_rad: yaw_rad,
403        drift_rate_mps: drift_rate,
404        total_drift_m: total_drift,
405        magnus_component_m: magnus_drift,
406        gyroscopic_component_m: gyro_drift,
407        pitch_damping_moment,
408        yaw_convergence_rate: convergence_rate,
409        pitch_rate_rad_s,
410    }
411}
412
413/// Apply enhanced spin drift acceleration to derivatives
414pub fn apply_enhanced_spin_drift(
415    derivatives: &mut [f64; 6],
416    spin_components: &SpinDriftComponents,
417    time_s: f64,
418    _is_right_twist: bool,
419) {
420    if time_s > 0.1 {
421        // Calculate acceleration from drift
422        // Using second derivative of position
423        let spin_accel_z = 2.0 * spin_components.drift_rate_mps / time_s;
424
425        // drift_rate_mps already carries the twist-direction sign (set in
426        // calculate_enhanced_spin_drift), so apply it directly. Multiplying by the twist
427        // sign again here previously CANCELED the gyroscopic sign (sign^2 = +1), so left-
428        // and right-twist barrels pushed spin drift the same way.
429        derivatives[5] += spin_accel_z;
430    }
431}
432
433/// Simplified interface for compatibility with existing code
434pub fn compute_enhanced_spin_drift_simple(
435    time_s: f64,
436    stability: f64,
437    velocity_mps: f64,
438    twist_rate: f64,
439    is_twist_right: bool,
440    _caliber: f64,
441) -> f64 {
442    if twist_rate <= 0.0 {
443        return 0.0;
444    }
445
446    // Calculate initial spin rate
447    let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
448
449    // Apply simple spin decay (assume 175gr bullet)
450    let decay_params = SpinDecayParameters::from_bullet_type("match");
451    let spin_rad_s = update_spin_rate(
452        initial_spin_rad_s,
453        time_s,
454        velocity_mps,
455        1.225, // Standard air density
456        175.0, // Standard bullet weight
457        _caliber,
458        1.3, // Standard bullet length
459        Some(&decay_params),
460    );
461
462    // Estimate yaw of repose (use simple model for compatibility)
463    let (yaw_rad, _) = calculate_yaw_of_repose(
464        stability,
465        velocity_mps,
466        spin_rad_s,
467        0.0,
468        0.0,
469        1.225,
470        _caliber,
471        1.3,
472        175.0,
473        velocity_mps / 343.0,
474        "match",
475        false,
476    );
477
478    // Calculate gyroscopic drift (primary component)
479
480    calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right)
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn test_calculate_spin_rate() {
489        // Test with 1:10 twist at 800 m/s
490        let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
491
492        // 800 m/s = 31496 in/s, divided by 10 = 3149.6 rps
493        assert!((rps - 3149.6).abs() < 1.0);
494        assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
495
496        // Test with zero twist rate
497        let (rps_zero, rad_s_zero) = calculate_spin_rate(800.0, 0.0);
498        assert_eq!(rps_zero, 0.0);
499        assert_eq!(rad_s_zero, 0.0);
500    }
501
502    #[test]
503    fn test_calculate_dynamic_stability() {
504        let sg = calculate_dynamic_stability(
505            168.0,   // grains
506            800.0,   // m/s
507            19792.0, // rad/s (from 1:10 twist)
508            0.308,   // inches
509            1.2,     // inches
510            1.225,   // kg/m³
511        );
512
513        // Should be > 1.0 for stable bullet
514        assert!(sg > 1.0);
515        assert!(sg < 10.0); // Reasonable upper bound
516    }
517
518    #[test]
519    fn test_calculate_yaw_of_repose() {
520        let (yaw, _) = calculate_yaw_of_repose(
521            2.5,     // Sg
522            800.0,   // velocity m/s
523            19792.0, // spin rate rad/s
524            10.0,    // crosswind m/s
525            0.0,     // pitch rate
526            1.225,   // air density
527            0.308,   // caliber
528            1.2,     // length
529            168.0,   // mass
530            2.33,    // mach
531            "match", // bullet type
532            false,   // use pitch damping
533        );
534
535        // Should be small but non-zero
536        assert!(yaw.abs() > 0.0);
537        assert!(yaw.abs() < 0.1); // Less than ~6 degrees
538    }
539
540    #[test]
541    fn test_enhanced_spin_drift_calculation() {
542        let components = calculate_enhanced_spin_drift(
543            168.0, // mass grains
544            800.0, // velocity m/s
545            10.0,  // twist rate inches
546            0.308, // caliber inches
547            1.2,   // length inches
548            true,  // right twist
549            1.0,   // time s
550            1.225, // air density
551            10.0,  // crosswind
552            0.0,   // pitch rate
553            false, // use pitch damping
554        );
555
556        // Should produce non-zero drift
557        assert!(components.total_drift_m.abs() > 0.0);
558        assert!(components.spin_rate_rps > 0.0);
559        assert!(components.stability_factor > 0.0);
560    }
561
562    #[test]
563    fn test_miller_stability_308_168gr() {
564        // .308, 168 gr, 1:12 twist, ~1.215 in length -> base Sg (no velocity/density correction)
565        // Formula: Sg = 30*m / (t^2 * d^3 * l * (1+l^2)), t and l in calibers
566        // twist_cal = 12/0.308 = 38.96, l_cal = 1.215/0.308 = 3.94 -> Sg ~ 1.74
567        let sg = miller_stability(0.308, 168.0, 12.0, 1.215);
568        assert!(sg > 1.5 && sg < 2.0, "expected base Sg ~1.74, got {}", sg);
569    }
570
571    #[test]
572    fn test_miller_stability_invalid_inputs_zero() {
573        assert_eq!(miller_stability(0.0, 168.0, 12.0, 1.2), 0.0);
574        assert_eq!(miller_stability(0.308, 0.0, 12.0, 1.2), 0.0);
575        assert_eq!(miller_stability(0.308, 168.0, 0.0, 1.2), 0.0);
576        assert_eq!(miller_stability(0.308, 168.0, 12.0, 0.0), 0.0);
577    }
578
579    #[test]
580    fn test_opposite_twist_directions() {
581        // Right twist
582        let right_drift = calculate_enhanced_spin_drift(
583            168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 0.0, 0.0, false,
584        );
585
586        // Left twist
587        let left_drift = calculate_enhanced_spin_drift(
588            168.0, 800.0, 10.0, 0.308, 1.2, false, 1.0, 1.225, 0.0, 0.0, false,
589        );
590
591        // Should have opposite signs for gyroscopic component
592        assert!(right_drift.gyroscopic_component_m * left_drift.gyroscopic_component_m < 0.0);
593        assert!(
594            (right_drift.gyroscopic_component_m.abs() - left_drift.gyroscopic_component_m.abs())
595                .abs()
596                < 0.001
597        );
598    }
599
600    #[test]
601    fn test_applied_spin_drift_flips_with_twist() {
602        // Regression: the APPLIED lateral acceleration (derivatives[5]) must reverse
603        // direction with the twist hand. apply_enhanced_spin_drift previously multiplied by
604        // the twist sign a second time, canceling the gyroscopic sign so left- and right-
605        // twist barrels pushed the same way. The existing test only checks the component
606        // field, never the applied derivative.
607        let time_s = 1.0;
608        let right = calculate_enhanced_spin_drift(
609            168.0, 800.0, 10.0, 0.308, 1.2, true, time_s, 1.225, 0.0, 0.0, false,
610        );
611        let left = calculate_enhanced_spin_drift(
612            168.0, 800.0, 10.0, 0.308, 1.2, false, time_s, 1.225, 0.0, 0.0, false,
613        );
614
615        let mut d_right = [0.0_f64; 6];
616        let mut d_left = [0.0_f64; 6];
617        apply_enhanced_spin_drift(&mut d_right, &right, time_s, true);
618        apply_enhanced_spin_drift(&mut d_left, &left, time_s, false);
619
620        assert!(d_right[5].abs() > 0.0, "expected non-zero spin drift accel");
621        assert!(d_left[5].abs() > 0.0, "expected non-zero spin drift accel");
622        assert!(
623            d_right[5] * d_left[5] < 0.0,
624            "expected opposite-sign lateral accel for opposite twist, got {} and {}",
625            d_right[5],
626            d_left[5]
627        );
628    }
629}