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