Skip to main content

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    // Air density scaling: higher density = more aerodynamic damping
201    // Normalized to standard sea-level density (1.225 kg/m^3)
202    let density_factor = if air_density_kg_m3 > 0.0 {
203        air_density_kg_m3 / 1.225
204    } else {
205        1.0 // Standard conditions fallback
206    };
207
208    // Surface area factor from caliber and length
209    // Larger surface area relative to a reference .308 bullet = more skin-friction damping.
210    // Use sqrt of the ratio: skin-friction torque scales with surface area but rotational
211    // inertia also grows with size, so the net effect on decay rate is sub-linear.
212    // Reference: .308 caliber (0.308") x 1.3" length
213    let ref_surface = PI * 0.308 * 1.3; // reference lateral surface area (inches^2)
214    let surface_factor = if caliber_inches > 0.0 && length_inches > 0.0 {
215        let bullet_surface = PI * caliber_inches * length_inches;
216        (bullet_surface / ref_surface).sqrt()
217    } else {
218        1.0 // Standard conditions fallback
219    };
220
221    // Base decay rate per second (empirical)
222    let base_decay_rate = if let Some(params) = decay_params {
223        if params.form_factor < 1.0 {
224            // Match bullet
225            0.025 // 2.5% per second
226        } else {
227            // Hunting/FMJ bullet
228            0.04 // 4% per second
229        }
230    } else {
231        0.04 // Default to hunting bullet
232    };
233
234    // Adjusted decay rate blending empirical model with physical parameters.
235    // At standard conditions (sea-level density, .308 reference bullet) the
236    // density_factor and surface_factor are both 1.0, preserving legacy behavior.
237    let decay_rate_per_second =
238        base_decay_rate * mass_factor * velocity_factor * density_factor * surface_factor;
239
240    // Apply exponential decay
241    let decay_factor = (-decay_rate_per_second * time_elapsed_s).exp();
242
243    // Ensure reasonable bounds (minimum 50% retention over any flight)
244    initial_spin_rad_s * decay_factor.clamp(0.5, 1.0)
245}
246
247/// Calculate a simple correction factor for spin-dependent effects
248///
249/// This returns a value between 0 and 1 that represents the fraction
250/// of initial spin remaining.
251pub fn calculate_spin_decay_correction_factor(
252    time_elapsed_s: f64,
253    velocity_mps: f64,
254    air_density_kg_m3: f64,
255    mass_grains: f64,
256    caliber_inches: f64,
257    length_inches: f64,
258    decay_params: Option<&SpinDecayParameters>,
259) -> f64 {
260    if time_elapsed_s <= 0.0 {
261        return 1.0;
262    }
263
264    // Initial spin doesn't matter for the ratio calculation
265    let initial_spin = 1000.0; // rad/s (arbitrary reference)
266
267    let current_spin = update_spin_rate(
268        initial_spin,
269        time_elapsed_s,
270        velocity_mps,
271        air_density_kg_m3,
272        mass_grains,
273        caliber_inches,
274        length_inches,
275        decay_params,
276    );
277
278    current_spin / initial_spin
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_spin_decay_parameters() {
287        let match_params = SpinDecayParameters::from_bullet_type("match");
288        assert_eq!(match_params.form_factor, 0.9);
289        assert_eq!(match_params.surface_roughness, 0.00005);
290
291        let hunting_params = SpinDecayParameters::from_bullet_type("hunting");
292        assert_eq!(hunting_params.form_factor, 1.0);
293    }
294
295    #[test]
296    fn test_moment_of_inertia() {
297        let mass_kg = 0.01134; // 175 grains
298        let caliber_m = 0.00782; // .308 inches
299
300        let i_cylinder = calculate_moment_of_inertia(mass_kg, caliber_m, 0.033, "cylinder");
301        let i_ogive = calculate_moment_of_inertia(mass_kg, caliber_m, 0.033, "ogive");
302
303        assert!(i_cylinder > i_ogive); // Cylinder has more inertia than ogive
304    }
305
306    #[test]
307    fn test_spin_decay_realistic() {
308        // Test realistic spin decay for a .308 match bullet
309        let initial_spin = 2800.0 * 2.0 * PI; // 2800 rev/s to rad/s
310        let params = SpinDecayParameters::from_bullet_type("match");
311
312        // After 3 seconds of flight
313        let spin_after_3s = update_spin_rate(
314            initial_spin,
315            3.0,   // 3 seconds
316            750.0, // 750 m/s average velocity
317            1.2,   // air density
318            175.0, // 175 grains
319            0.308, // caliber
320            1.3,   // length
321            Some(&params),
322        );
323
324        let decay_percent = (1.0 - spin_after_3s / initial_spin) * 100.0;
325
326        // Should lose between 2% and 15% of spin
327        assert!(decay_percent > 2.0 && decay_percent < 15.0);
328    }
329
330    #[test]
331    fn test_spin_decay_bounds() {
332        let initial_spin = 1000.0;
333        let params = SpinDecayParameters::new();
334
335        // Test extreme time - should never lose more than 50%
336        let spin_long_time = update_spin_rate(
337            initial_spin,
338            100.0, // Very long flight time
339            500.0,
340            1.225,
341            150.0,
342            0.308,
343            1.2,
344            Some(&params),
345        );
346
347        assert!(spin_long_time >= initial_spin * 0.5);
348    }
349
350    #[test]
351    fn test_spin_damping_moment() {
352        let params = SpinDecayParameters::from_bullet_type("match");
353
354        // Test with typical values
355        let moment = calculate_spin_damping_moment(
356            1000.0,  // spin rate rad/s
357            800.0,   // velocity m/s
358            1.225,   // air density
359            0.00782, // caliber in meters (.308")
360            0.033,   // length in meters
361            &params,
362        );
363
364        // Moment should be positive (opposes spin)
365        assert!(moment > 0.0);
366        assert!(moment < 1.0); // Should be small for a bullet
367
368        // Test zero spin
369        let zero_moment = calculate_spin_damping_moment(0.0, 800.0, 1.225, 0.00782, 0.033, &params);
370        assert_eq!(zero_moment, 0.0);
371
372        // Test zero velocity
373        let zero_vel_moment =
374            calculate_spin_damping_moment(1000.0, 0.0, 1.225, 0.00782, 0.033, &params);
375        assert_eq!(zero_vel_moment, 0.0);
376    }
377
378    #[test]
379    fn test_spin_decay_rate() {
380        let params = SpinDecayParameters::from_bullet_type("fmj");
381
382        let decay_rate = calculate_spin_decay_rate(
383            1000.0, // spin rate rad/s
384            800.0,  // velocity m/s
385            1.225,  // air density
386            168.0,  // mass grains
387            0.308,  // caliber inches
388            1.2,    // length inches
389            &params,
390            "boat_tail",
391        );
392
393        // Decay rate should be negative (spin decreases)
394        assert!(decay_rate < 0.0);
395        assert!(decay_rate > -1000.0); // Should be reasonable magnitude
396    }
397
398    #[test]
399    fn test_different_bullet_types() {
400        // Test all bullet type parameters
401        let types = ["match", "hunting", "fmj", "cast", "unknown"];
402
403        for bullet_type in &types {
404            let params = SpinDecayParameters::from_bullet_type(bullet_type);
405            assert!(params.surface_roughness > 0.0);
406            assert!(params.skin_friction_coefficient > 0.0);
407            assert!(params.form_factor > 0.0);
408        }
409    }
410
411    #[test]
412    fn test_moment_of_inertia_shapes() {
413        let mass_kg = 0.01;
414        let caliber_m = 0.008;
415        let length_m = 0.03;
416
417        let i_cylinder = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "cylinder");
418        let i_ogive = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
419        let i_boat_tail = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "boat_tail");
420        let i_default = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "unknown");
421
422        // Check relative magnitudes
423        assert!(i_cylinder > i_ogive);
424        assert!(i_ogive > i_boat_tail);
425        assert_eq!(i_cylinder, i_default); // Unknown defaults to cylinder
426
427        // Check absolute values are reasonable
428        assert!(i_cylinder > 0.0);
429        assert!(i_boat_tail > 0.0);
430    }
431
432    #[test]
433    fn test_spin_decay_correction_factor() {
434        let params = SpinDecayParameters::from_bullet_type("match");
435
436        // At time zero, factor should be 1.0
437        let factor_t0 = calculate_spin_decay_correction_factor(
438            0.0,
439            800.0,
440            1.225,
441            175.0,
442            0.308,
443            1.3,
444            Some(&params),
445        );
446        assert_eq!(factor_t0, 1.0);
447
448        // After some time, factor should be less than 1.0 but greater than 0.5
449        let factor_t3 = calculate_spin_decay_correction_factor(
450            3.0,
451            800.0,
452            1.225,
453            175.0,
454            0.308,
455            1.3,
456            Some(&params),
457        );
458        assert!(factor_t3 < 1.0);
459        assert!(factor_t3 > 0.5);
460
461        // Factor should decrease with time
462        let factor_t1 = calculate_spin_decay_correction_factor(
463            1.0,
464            800.0,
465            1.225,
466            175.0,
467            0.308,
468            1.3,
469            Some(&params),
470        );
471        let factor_t2 = calculate_spin_decay_correction_factor(
472            2.0,
473            800.0,
474            1.225,
475            175.0,
476            0.308,
477            1.3,
478            Some(&params),
479        );
480        assert!(factor_t1 > factor_t2);
481        assert!(factor_t2 > factor_t3);
482    }
483
484    #[test]
485    fn test_default_impl() {
486        let params1 = SpinDecayParameters::new();
487        let params2 = SpinDecayParameters::default();
488
489        assert_eq!(params1.surface_roughness, params2.surface_roughness);
490        assert_eq!(
491            params1.skin_friction_coefficient,
492            params2.skin_friction_coefficient
493        );
494        assert_eq!(params1.form_factor, params2.form_factor);
495    }
496
497    #[test]
498    fn test_mass_factor_effects() {
499        let params = SpinDecayParameters::from_bullet_type("match");
500
501        // Light bullet (55gr)
502        let spin_light =
503            update_spin_rate(1000.0, 2.0, 800.0, 1.225, 55.0, 0.224, 0.9, Some(&params));
504
505        // Heavy bullet (300gr)
506        let spin_heavy =
507            update_spin_rate(1000.0, 2.0, 800.0, 1.225, 300.0, 0.338, 1.8, Some(&params));
508
509        // Heavy bullet should retain more spin (decay less)
510        assert!(spin_heavy > spin_light);
511    }
512
513    #[test]
514    fn test_velocity_factor_effects() {
515        let params = SpinDecayParameters::from_bullet_type("hunting");
516
517        // Low velocity
518        let spin_low_vel =
519            update_spin_rate(1000.0, 2.0, 400.0, 1.225, 175.0, 0.308, 1.3, Some(&params));
520
521        // High velocity
522        let spin_high_vel =
523            update_spin_rate(1000.0, 2.0, 1200.0, 1.225, 175.0, 0.308, 1.3, Some(&params));
524
525        // Higher velocity should cause more decay (less spin remaining)
526        assert!(spin_low_vel > spin_high_vel);
527    }
528}