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