ballistics_engine/
spin_decay.rs

1//! Spin Decay Physics for Ballistics Calculations
2//!
3//! This module implements realistic spin decay modeling based on:
4//! - Aerodynamic torque opposing spin
5//! - Skin friction effects
6//! - Velocity-dependent decay rates
7//! - Surface roughness effects
8
9use std::f64::consts::PI;
10
11/// Parameters affecting spin decay rate
12#[derive(Debug, Clone, Copy)]
13pub struct SpinDecayParameters {
14    /// Surface roughness in meters (typical: 0.1mm)
15    pub surface_roughness: f64,
16    /// Very low for streamlined spinning bullets
17    pub skin_friction_coefficient: f64,
18    /// Shape factor for spin damping
19    pub form_factor: f64,
20}
21
22impl SpinDecayParameters {
23    /// Create default parameters
24    pub fn new() -> Self {
25        Self {
26            surface_roughness: 0.0001,
27            skin_friction_coefficient: 0.00001,
28            form_factor: 1.0,
29        }
30    }
31
32    /// Get typical parameters for different bullet types
33    pub fn from_bullet_type(bullet_type: &str) -> Self {
34        match bullet_type.to_lowercase().as_str() {
35            "match" => Self {
36                surface_roughness: 0.00005,
37                skin_friction_coefficient: 0.000008,
38                form_factor: 0.9,
39            },
40            "hunting" => Self {
41                surface_roughness: 0.0001,
42                skin_friction_coefficient: 0.00001,
43                form_factor: 1.0,
44            },
45            "fmj" => Self {
46                surface_roughness: 0.00015,
47                skin_friction_coefficient: 0.000012,
48                form_factor: 1.1,
49            },
50            "cast" => Self {
51                surface_roughness: 0.0002,
52                skin_friction_coefficient: 0.000015,
53                form_factor: 1.2,
54            },
55            _ => Self::new(),
56        }
57    }
58}
59
60impl Default for SpinDecayParameters {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66/// Calculate the aerodynamic moment opposing spin
67///
68/// Based on:
69/// - Skin friction on the bullet surface
70/// - Pressure drag effects
71/// - Magnus moment damping
72pub fn calculate_spin_damping_moment(
73    spin_rate_rad_s: f64,
74    velocity_mps: f64,
75    air_density_kg_m3: f64,
76    caliber_m: f64,
77    length_m: f64,
78    decay_params: &SpinDecayParameters,
79) -> f64 {
80    if spin_rate_rad_s == 0.0 || velocity_mps == 0.0 {
81        return 0.0;
82    }
83
84    // Reynolds number based on spin
85    let radius = caliber_m / 2.0;
86    let tangential_velocity = spin_rate_rad_s * radius;
87    let _re_spin = air_density_kg_m3 * tangential_velocity * caliber_m / 1.81e-5; // Air viscosity
88
89    // Skin friction coefficient (modified for spinning cylinder)
90    let cf = decay_params.skin_friction_coefficient;
91
92    // Surface area
93    let surface_area = PI * caliber_m * length_m;
94
95    // Tangential force due to skin friction
96    let f_tangential = 0.5 * air_density_kg_m3 * cf * surface_area * tangential_velocity.powi(2);
97
98    // Moment arm is the radius
99    let moment_skin = f_tangential * radius * decay_params.form_factor;
100
101    // Additional damping from Magnus effect
102    let spin_ratio = tangential_velocity / velocity_mps.max(1.0);
103    let magnus_damping_factor = 0.01 * spin_ratio; // Reduced empirical factor
104    let moment_magnus = magnus_damping_factor * moment_skin;
105
106    // Total damping moment (always opposes spin)
107    moment_skin + moment_magnus
108}
109
110/// Calculate moment of inertia about the longitudinal axis
111pub fn calculate_moment_of_inertia(
112    mass_kg: f64,
113    caliber_m: f64,
114    _length_m: f64,
115    shape: &str,
116) -> f64 {
117    let radius = caliber_m / 2.0;
118
119    match shape {
120        "cylinder" => {
121            // Simple cylinder: I = (1/2) * m * r²
122            0.5 * mass_kg * radius.powi(2)
123        }
124        "ogive" => {
125            // Ogive shape has less mass at the edges
126            0.4 * mass_kg * radius.powi(2)
127        }
128        "boat_tail" => {
129            // Boat tail has even less mass at the rear
130            0.35 * mass_kg * radius.powi(2)
131        }
132        _ => {
133            // Default to cylinder
134            0.5 * mass_kg * radius.powi(2)
135        }
136    }
137}
138
139/// Calculate the rate of spin decay in rad/s²
140pub fn calculate_spin_decay_rate(
141    spin_rate_rad_s: f64,
142    velocity_mps: f64,
143    air_density_kg_m3: f64,
144    mass_grains: f64,
145    caliber_inches: f64,
146    length_inches: f64,
147    decay_params: &SpinDecayParameters,
148    bullet_shape: &str,
149) -> f64 {
150    // Convert units
151    let mass_kg = mass_grains * 0.00006479891; // grains to kg
152    let caliber_m = caliber_inches * 0.0254;
153    let length_m = length_inches * 0.0254;
154
155    // Calculate damping moment
156    let damping_moment = calculate_spin_damping_moment(
157        spin_rate_rad_s,
158        velocity_mps,
159        air_density_kg_m3,
160        caliber_m,
161        length_m,
162        decay_params,
163    );
164
165    // Calculate moment of inertia
166    let moment_of_inertia = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, bullet_shape);
167
168    // Angular deceleration = -M / I
169    if moment_of_inertia > 0.0 {
170        -damping_moment / moment_of_inertia
171    } else {
172        0.0
173    }
174}
175
176/// Calculate the spin rate after accounting for decay
177///
178/// Uses an empirical model based on published ballistics data.
179/// Real bullets typically lose 5-15% of spin over a 3-second flight.
180pub fn update_spin_rate(
181    initial_spin_rad_s: f64,
182    time_elapsed_s: f64,
183    velocity_mps: f64,
184    _air_density_kg_m3: f64,
185    mass_grains: f64,
186    _caliber_inches: f64,
187    _length_inches: f64,
188    decay_params: Option<&SpinDecayParameters>,
189) -> f64 {
190    if time_elapsed_s <= 0.0 {
191        return initial_spin_rad_s;
192    }
193
194    // Mass factor (heavier bullets retain spin better)
195    let mass_factor = (175.0 / mass_grains).sqrt(); // Normalized to 175gr
196
197    // Velocity factor (higher velocity means more decay)
198    let velocity_factor = velocity_mps / 850.0; // Normalized to 850 m/s
199
200    // Base decay rate per second (empirical)
201    let base_decay_rate = if let Some(params) = decay_params {
202        if params.form_factor < 1.0 {
203            // Match bullet
204            0.025 // 2.5% per second
205        } else {
206            // Hunting/FMJ bullet
207            0.04 // 4% per second
208        }
209    } else {
210        0.04 // Default to hunting bullet
211    };
212
213    // Adjusted decay rate
214    let decay_rate_per_second = base_decay_rate * mass_factor * velocity_factor;
215
216    // Apply exponential decay
217    let decay_factor = (-decay_rate_per_second * time_elapsed_s).exp();
218
219    // Ensure reasonable bounds (minimum 50% retention over any flight)
220    initial_spin_rad_s * decay_factor.clamp(0.5, 1.0)
221}
222
223/// Calculate a simple correction factor for spin-dependent effects
224///
225/// This returns a value between 0 and 1 that represents the fraction
226/// of initial spin remaining.
227pub fn calculate_spin_decay_correction_factor(
228    time_elapsed_s: f64,
229    velocity_mps: f64,
230    air_density_kg_m3: f64,
231    mass_grains: f64,
232    caliber_inches: f64,
233    length_inches: f64,
234    decay_params: Option<&SpinDecayParameters>,
235) -> f64 {
236    if time_elapsed_s <= 0.0 {
237        return 1.0;
238    }
239
240    // Initial spin doesn't matter for the ratio calculation
241    let initial_spin = 1000.0; // rad/s (arbitrary reference)
242
243    let current_spin = update_spin_rate(
244        initial_spin,
245        time_elapsed_s,
246        velocity_mps,
247        air_density_kg_m3,
248        mass_grains,
249        caliber_inches,
250        length_inches,
251        decay_params,
252    );
253
254    current_spin / initial_spin
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_spin_decay_parameters() {
263        let match_params = SpinDecayParameters::from_bullet_type("match");
264        assert_eq!(match_params.form_factor, 0.9);
265        assert_eq!(match_params.surface_roughness, 0.00005);
266
267        let hunting_params = SpinDecayParameters::from_bullet_type("hunting");
268        assert_eq!(hunting_params.form_factor, 1.0);
269    }
270
271    #[test]
272    fn test_moment_of_inertia() {
273        let mass_kg = 0.01134; // 175 grains
274        let caliber_m = 0.00782; // .308 inches
275
276        let i_cylinder = calculate_moment_of_inertia(mass_kg, caliber_m, 0.033, "cylinder");
277        let i_ogive = calculate_moment_of_inertia(mass_kg, caliber_m, 0.033, "ogive");
278
279        assert!(i_cylinder > i_ogive); // Cylinder has more inertia than ogive
280    }
281
282    #[test]
283    fn test_spin_decay_realistic() {
284        // Test realistic spin decay for a .308 match bullet
285        let initial_spin = 2800.0 * 2.0 * PI; // 2800 rev/s to rad/s
286        let params = SpinDecayParameters::from_bullet_type("match");
287
288        // After 3 seconds of flight
289        let spin_after_3s = update_spin_rate(
290            initial_spin,
291            3.0,        // 3 seconds
292            750.0,      // 750 m/s average velocity
293            1.2,        // air density
294            175.0,      // 175 grains
295            0.308,      // caliber
296            1.3,        // length
297            Some(&params),
298        );
299
300        let decay_percent = (1.0 - spin_after_3s / initial_spin) * 100.0;
301
302        // Should lose between 2% and 15% of spin
303        assert!(decay_percent > 2.0 && decay_percent < 15.0);
304    }
305
306    #[test]
307    fn test_spin_decay_bounds() {
308        let initial_spin = 1000.0;
309        let params = SpinDecayParameters::new();
310
311        // Test extreme time - should never lose more than 50%
312        let spin_long_time = update_spin_rate(
313            initial_spin,
314            100.0, // Very long flight time
315            500.0,
316            1.225,
317            150.0,
318            0.308,
319            1.2,
320            Some(&params),
321        );
322
323        assert!(spin_long_time >= initial_spin * 0.5);
324    }
325    
326    #[test]
327    fn test_spin_damping_moment() {
328        let params = SpinDecayParameters::from_bullet_type("match");
329        
330        // Test with typical values
331        let moment = calculate_spin_damping_moment(
332            1000.0,  // spin rate rad/s
333            800.0,   // velocity m/s
334            1.225,   // air density
335            0.00782, // caliber in meters (.308")
336            0.033,   // length in meters
337            &params,
338        );
339        
340        // Moment should be positive (opposes spin)
341        assert!(moment > 0.0);
342        assert!(moment < 1.0); // Should be small for a bullet
343        
344        // Test zero spin
345        let zero_moment = calculate_spin_damping_moment(
346            0.0, 800.0, 1.225, 0.00782, 0.033, &params
347        );
348        assert_eq!(zero_moment, 0.0);
349        
350        // Test zero velocity
351        let zero_vel_moment = calculate_spin_damping_moment(
352            1000.0, 0.0, 1.225, 0.00782, 0.033, &params
353        );
354        assert_eq!(zero_vel_moment, 0.0);
355    }
356    
357    #[test]
358    fn test_spin_decay_rate() {
359        let params = SpinDecayParameters::from_bullet_type("fmj");
360        
361        let decay_rate = calculate_spin_decay_rate(
362            1000.0,  // spin rate rad/s
363            800.0,   // velocity m/s
364            1.225,   // air density
365            168.0,   // mass grains
366            0.308,   // caliber inches
367            1.2,     // length inches
368            &params,
369            "boat_tail",
370        );
371        
372        // Decay rate should be negative (spin decreases)
373        assert!(decay_rate < 0.0);
374        assert!(decay_rate > -1000.0); // Should be reasonable magnitude
375    }
376    
377    #[test]
378    fn test_different_bullet_types() {
379        // Test all bullet type parameters
380        let types = ["match", "hunting", "fmj", "cast", "unknown"];
381        
382        for bullet_type in &types {
383            let params = SpinDecayParameters::from_bullet_type(bullet_type);
384            assert!(params.surface_roughness > 0.0);
385            assert!(params.skin_friction_coefficient > 0.0);
386            assert!(params.form_factor > 0.0);
387        }
388    }
389    
390    #[test]
391    fn test_moment_of_inertia_shapes() {
392        let mass_kg = 0.01;
393        let caliber_m = 0.008;
394        let length_m = 0.03;
395        
396        let i_cylinder = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "cylinder");
397        let i_ogive = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
398        let i_boat_tail = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "boat_tail");
399        let i_default = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "unknown");
400        
401        // Check relative magnitudes
402        assert!(i_cylinder > i_ogive);
403        assert!(i_ogive > i_boat_tail);
404        assert_eq!(i_cylinder, i_default); // Unknown defaults to cylinder
405        
406        // Check absolute values are reasonable
407        assert!(i_cylinder > 0.0);
408        assert!(i_boat_tail > 0.0);
409    }
410    
411    #[test]
412    fn test_spin_decay_correction_factor() {
413        let params = SpinDecayParameters::from_bullet_type("match");
414        
415        // At time zero, factor should be 1.0
416        let factor_t0 = calculate_spin_decay_correction_factor(
417            0.0, 800.0, 1.225, 175.0, 0.308, 1.3, Some(&params)
418        );
419        assert_eq!(factor_t0, 1.0);
420        
421        // After some time, factor should be less than 1.0 but greater than 0.5
422        let factor_t3 = calculate_spin_decay_correction_factor(
423            3.0, 800.0, 1.225, 175.0, 0.308, 1.3, Some(&params)
424        );
425        assert!(factor_t3 < 1.0);
426        assert!(factor_t3 > 0.5);
427        
428        // Factor should decrease with time
429        let factor_t1 = calculate_spin_decay_correction_factor(
430            1.0, 800.0, 1.225, 175.0, 0.308, 1.3, Some(&params)
431        );
432        let factor_t2 = calculate_spin_decay_correction_factor(
433            2.0, 800.0, 1.225, 175.0, 0.308, 1.3, Some(&params)
434        );
435        assert!(factor_t1 > factor_t2);
436        assert!(factor_t2 > factor_t3);
437    }
438    
439    #[test]
440    fn test_default_impl() {
441        let params1 = SpinDecayParameters::new();
442        let params2 = SpinDecayParameters::default();
443        
444        assert_eq!(params1.surface_roughness, params2.surface_roughness);
445        assert_eq!(params1.skin_friction_coefficient, params2.skin_friction_coefficient);
446        assert_eq!(params1.form_factor, params2.form_factor);
447    }
448    
449    #[test]
450    fn test_mass_factor_effects() {
451        let params = SpinDecayParameters::from_bullet_type("match");
452        
453        // Light bullet (55gr)
454        let spin_light = update_spin_rate(
455            1000.0, 2.0, 800.0, 1.225, 55.0, 0.224, 0.9, Some(&params)
456        );
457        
458        // Heavy bullet (300gr)
459        let spin_heavy = update_spin_rate(
460            1000.0, 2.0, 800.0, 1.225, 300.0, 0.338, 1.8, Some(&params)
461        );
462        
463        // Heavy bullet should retain more spin (decay less)
464        assert!(spin_heavy > spin_light);
465    }
466    
467    #[test]
468    fn test_velocity_factor_effects() {
469        let params = SpinDecayParameters::from_bullet_type("hunting");
470        
471        // Low velocity
472        let spin_low_vel = update_spin_rate(
473            1000.0, 2.0, 400.0, 1.225, 175.0, 0.308, 1.3, Some(&params)
474        );
475        
476        // High velocity
477        let spin_high_vel = update_spin_rate(
478            1000.0, 2.0, 1200.0, 1.225, 175.0, 0.308, 1.3, Some(&params)
479        );
480        
481        // Higher velocity should cause more decay (less spin remaining)
482        assert!(spin_low_vel > spin_high_vel);
483    }
484}