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//! - Calibrated roll-damping coefficients
6//! - Velocity-dependent decay rates
7//! - Projectile shape and surface-finish presets
8
9use std::f64::consts::PI;
10
11const MATCH_REFERENCE_DECAY_RATE_PER_SECOND: f64 = 0.025;
12const GENERAL_REFERENCE_DECAY_RATE_PER_SECOND: f64 = 0.04;
13
14/// Parameters affecting spin decay rate
15#[derive(Debug, Clone, Copy)]
16pub struct SpinDecayParameters {
17    /// Surface roughness in meters (typical: 0.1mm)
18    pub surface_roughness: f64,
19    /// Effective dimensionless roll-damping coefficient magnitude (`|C_lp|`) before applying
20    /// `form_factor`. The field name is retained for API compatibility.
21    pub skin_friction_coefficient: f64,
22    /// Shape factor for spin damping
23    pub form_factor: f64,
24}
25
26impl SpinDecayParameters {
27    /// Create default parameters
28    pub fn new() -> Self {
29        // The effective coefficient (coefficient * form factor) is calibrated against the
30        // empirical 4%/s reference decay used by update_spin_rate for a 175gr .308 ogive.
31        Self {
32            surface_roughness: 0.0001,
33            skin_friction_coefficient: 0.00363,
34            form_factor: 1.0,
35        }
36    }
37
38    /// Get typical parameters for different bullet types
39    pub fn from_bullet_type(bullet_type: &str) -> Self {
40        // Match bullets calibrate to the empirical 2.5%/s reference; the other presets calibrate
41        // to 4%/s after their shape form factor is applied.
42        match bullet_type.to_lowercase().as_str() {
43            "match" => Self {
44                surface_roughness: 0.00005,
45                skin_friction_coefficient: 0.00252,
46                form_factor: 0.9,
47            },
48            "hunting" => Self {
49                surface_roughness: 0.0001,
50                skin_friction_coefficient: 0.00363,
51                form_factor: 1.0,
52            },
53            "fmj" => Self {
54                surface_roughness: 0.00015,
55                skin_friction_coefficient: 0.00330,
56                form_factor: 1.1,
57            },
58            "cast" => Self {
59                surface_roughness: 0.0002,
60                skin_friction_coefficient: 0.00303,
61                form_factor: 1.2,
62            },
63            _ => Self::new(),
64        }
65    }
66}
67
68impl Default for SpinDecayParameters {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74/// Calculate the magnitude of the aerodynamic moment opposing spin.
75///
76/// Uses the conventional roll-damping relation
77/// `M = 1/4 * rho * V * S * d^2 * |C_lp| * |p|`, where `S` is projectile reference area.
78/// Here `C_lp` is the derivative with respect to reduced spin `p*d/(2*V)`; conventions using
79/// `p*d/V` report a coefficient smaller by a factor of two.
80/// The returned value is nonnegative; [`calculate_spin_decay_rate`] applies the direction that
81/// opposes the signed spin rate.
82pub fn calculate_spin_damping_moment(
83    spin_rate_rad_s: f64,
84    velocity_mps: f64,
85    air_density_kg_m3: f64,
86    caliber_m: f64,
87    length_m: f64,
88    decay_params: &SpinDecayParameters,
89) -> f64 {
90    if !spin_rate_rad_s.is_finite()
91        || spin_rate_rad_s == 0.0
92        || !velocity_mps.is_finite()
93        || velocity_mps <= 0.0
94        || !air_density_kg_m3.is_finite()
95        || air_density_kg_m3 <= 0.0
96        || !caliber_m.is_finite()
97        || caliber_m <= 0.0
98        || !length_m.is_finite()
99        || length_m <= 0.0
100        || !decay_params.skin_friction_coefficient.is_finite()
101        || decay_params.skin_friction_coefficient <= 0.0
102        || !decay_params.form_factor.is_finite()
103        || decay_params.form_factor <= 0.0
104    {
105        return 0.0;
106    }
107
108    let reference_area = PI * (caliber_m / 2.0).powi(2);
109    let roll_damping_coefficient =
110        decay_params.skin_friction_coefficient * decay_params.form_factor;
111
112    0.25 * air_density_kg_m3
113        * velocity_mps
114        * reference_area
115        * caliber_m.powi(2)
116        * roll_damping_coefficient
117        * spin_rate_rad_s.abs()
118}
119
120/// Calculate moment of inertia about the longitudinal axis
121pub fn calculate_moment_of_inertia(
122    mass_kg: f64,
123    caliber_m: f64,
124    _length_m: f64,
125    shape: &str,
126) -> f64 {
127    let radius = caliber_m / 2.0;
128
129    match shape {
130        "cylinder" => {
131            // Simple cylinder: I = (1/2) * m * r²
132            0.5 * mass_kg * radius.powi(2)
133        }
134        "ogive" => {
135            // Ogive shape has less mass at the edges
136            0.4 * mass_kg * radius.powi(2)
137        }
138        "boat_tail" => {
139            // Boat tail has even less mass at the rear
140            0.35 * mass_kg * radius.powi(2)
141        }
142        _ => {
143            // Default to cylinder
144            0.5 * mass_kg * radius.powi(2)
145        }
146    }
147}
148
149/// Calculate the rate of spin decay in rad/s²
150pub fn calculate_spin_decay_rate(
151    spin_rate_rad_s: f64,
152    velocity_mps: f64,
153    air_density_kg_m3: f64,
154    mass_grains: f64,
155    caliber_inches: f64,
156    length_inches: f64,
157    decay_params: &SpinDecayParameters,
158    bullet_shape: &str,
159) -> f64 {
160    // Convert units
161    let mass_kg = mass_grains * 0.00006479891; // grains to kg
162    let caliber_m = caliber_inches * 0.0254;
163    let length_m = length_inches * 0.0254;
164
165    // Calculate damping moment
166    let damping_moment = calculate_spin_damping_moment(
167        spin_rate_rad_s,
168        velocity_mps,
169        air_density_kg_m3,
170        caliber_m,
171        length_m,
172        decay_params,
173    );
174
175    // Calculate moment of inertia
176    let moment_of_inertia = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, bullet_shape);
177
178    // Apply the damping-moment magnitude opposite to either signed spin direction.
179    if moment_of_inertia > 0.0 && spin_rate_rad_s.is_finite() {
180        -spin_rate_rad_s.signum() * damping_moment / moment_of_inertia
181    } else {
182        0.0
183    }
184}
185
186/// Calculate the spin rate after accounting for decay
187///
188/// Uses an empirical model based on published ballistics data.
189/// Real bullets typically lose 5-15% of spin over a 3-second flight.
190pub fn update_spin_rate(
191    initial_spin_rad_s: f64,
192    time_elapsed_s: f64,
193    velocity_mps: f64,
194    air_density_kg_m3: f64,
195    mass_grains: f64,
196    caliber_inches: f64,
197    length_inches: f64,
198    decay_params: Option<&SpinDecayParameters>,
199) -> f64 {
200    if time_elapsed_s <= 0.0 {
201        return initial_spin_rad_s;
202    }
203
204    // Mass factor (heavier bullets retain spin better)
205    let mass_factor = (175.0 / mass_grains).sqrt(); // Normalized to 175gr
206
207    // Velocity factor (higher velocity means more decay)
208    let velocity_factor = velocity_mps / 850.0; // Normalized to 850 m/s
209
210    // Air density scaling: higher density = more aerodynamic damping
211    // Normalized to standard sea-level density (1.225 kg/m^3)
212    let density_factor = if air_density_kg_m3 > 0.0 {
213        air_density_kg_m3 / 1.225
214    } else {
215        1.0 // Standard conditions fallback
216    };
217
218    // Surface area factor from caliber and length
219    // Larger surface area relative to a reference .308 bullet = more skin-friction damping.
220    // Use sqrt of the ratio: skin-friction torque scales with surface area but rotational
221    // inertia also grows with size, so the net effect on decay rate is sub-linear.
222    // Reference: .308 caliber (0.308") x 1.3" length
223    let ref_surface = PI * 0.308 * 1.3; // reference lateral surface area (inches^2)
224    let surface_factor = if caliber_inches > 0.0 && length_inches > 0.0 {
225        let bullet_surface = PI * caliber_inches * length_inches;
226        (bullet_surface / ref_surface).sqrt()
227    } else {
228        1.0 // Standard conditions fallback
229    };
230
231    // Base decay rate per second (empirical)
232    let base_decay_rate = if let Some(params) = decay_params {
233        if params.form_factor < 1.0 {
234            // Match bullet
235            MATCH_REFERENCE_DECAY_RATE_PER_SECOND
236        } else {
237            // Hunting/FMJ bullet
238            GENERAL_REFERENCE_DECAY_RATE_PER_SECOND
239        }
240    } else {
241        GENERAL_REFERENCE_DECAY_RATE_PER_SECOND
242    };
243
244    // Adjusted decay rate blending empirical model with physical parameters.
245    // At standard conditions (sea-level density, .308 reference bullet) the
246    // density_factor and surface_factor are both 1.0, preserving legacy behavior.
247    let decay_rate_per_second =
248        base_decay_rate * mass_factor * velocity_factor * density_factor * surface_factor;
249
250    // Apply exponential decay
251    let decay_factor = (-decay_rate_per_second * time_elapsed_s).exp();
252
253    // Ensure reasonable bounds (minimum 50% retention over any flight)
254    initial_spin_rad_s * decay_factor.clamp(0.5, 1.0)
255}
256
257/// Calculate a simple correction factor for spin-dependent effects
258///
259/// This returns a value between 0 and 1 that represents the fraction
260/// of initial spin remaining.
261pub fn calculate_spin_decay_correction_factor(
262    time_elapsed_s: f64,
263    velocity_mps: f64,
264    air_density_kg_m3: f64,
265    mass_grains: f64,
266    caliber_inches: f64,
267    length_inches: f64,
268    decay_params: Option<&SpinDecayParameters>,
269) -> f64 {
270    if time_elapsed_s <= 0.0 {
271        return 1.0;
272    }
273
274    // Initial spin doesn't matter for the ratio calculation
275    let initial_spin = 1000.0; // rad/s (arbitrary reference)
276
277    let current_spin = update_spin_rate(
278        initial_spin,
279        time_elapsed_s,
280        velocity_mps,
281        air_density_kg_m3,
282        mass_grains,
283        caliber_inches,
284        length_inches,
285        decay_params,
286    );
287
288    current_spin / initial_spin
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_spin_decay_parameters() {
297        let match_params = SpinDecayParameters::from_bullet_type("match");
298        assert_eq!(match_params.form_factor, 0.9);
299        assert_eq!(match_params.surface_roughness, 0.00005);
300
301        let hunting_params = SpinDecayParameters::from_bullet_type("hunting");
302        assert_eq!(hunting_params.form_factor, 1.0);
303    }
304
305    #[test]
306    fn test_moment_of_inertia() {
307        let mass_kg = 0.01134; // 175 grains
308        let caliber_m = 0.00782; // .308 inches
309
310        let i_cylinder = calculate_moment_of_inertia(mass_kg, caliber_m, 0.033, "cylinder");
311        let i_ogive = calculate_moment_of_inertia(mass_kg, caliber_m, 0.033, "ogive");
312
313        assert!(i_cylinder > i_ogive); // Cylinder has more inertia than ogive
314    }
315
316    #[test]
317    fn test_spin_decay_realistic() {
318        // Test realistic spin decay for a .308 match bullet
319        let initial_spin = 2800.0 * 2.0 * PI; // 2800 rev/s to rad/s
320        let params = SpinDecayParameters::from_bullet_type("match");
321
322        // After 3 seconds of flight
323        let spin_after_3s = update_spin_rate(
324            initial_spin,
325            3.0,   // 3 seconds
326            750.0, // 750 m/s average velocity
327            1.2,   // air density
328            175.0, // 175 grains
329            0.308, // caliber
330            1.3,   // length
331            Some(&params),
332        );
333
334        let decay_percent = (1.0 - spin_after_3s / initial_spin) * 100.0;
335
336        // Should lose between 2% and 15% of spin
337        assert!(decay_percent > 2.0 && decay_percent < 15.0);
338    }
339
340    #[test]
341    fn test_spin_decay_bounds() {
342        let initial_spin = 1000.0;
343        let params = SpinDecayParameters::new();
344
345        // Test extreme time - should never lose more than 50%
346        let spin_long_time = update_spin_rate(
347            initial_spin,
348            100.0, // Very long flight time
349            500.0,
350            1.225,
351            150.0,
352            0.308,
353            1.2,
354            Some(&params),
355        );
356
357        assert!(spin_long_time >= initial_spin * 0.5);
358    }
359
360    #[test]
361    fn test_spin_damping_moment() {
362        let params = SpinDecayParameters::from_bullet_type("match");
363
364        // Test with typical values
365        let moment = calculate_spin_damping_moment(
366            1000.0,  // spin rate rad/s
367            800.0,   // velocity m/s
368            1.225,   // air density
369            0.00782, // caliber in meters (.308")
370            0.033,   // length in meters
371            &params,
372        );
373
374        // Moment should be positive (opposes spin)
375        assert!(moment > 0.0);
376        assert!(moment < 1.0); // Should be small for a bullet
377
378        // Test zero spin
379        let zero_moment = calculate_spin_damping_moment(0.0, 800.0, 1.225, 0.00782, 0.033, &params);
380        assert_eq!(zero_moment, 0.0);
381
382        // Test zero velocity
383        let zero_vel_moment =
384            calculate_spin_damping_moment(1000.0, 0.0, 1.225, 0.00782, 0.033, &params);
385        assert_eq!(zero_vel_moment, 0.0);
386    }
387
388    #[test]
389    fn test_spin_decay_rate() {
390        let params = SpinDecayParameters::from_bullet_type("fmj");
391
392        let decay_rate = calculate_spin_decay_rate(
393            1000.0, // spin rate rad/s
394            800.0,  // velocity m/s
395            1.225,  // air density
396            168.0,  // mass grains
397            0.308,  // caliber inches
398            1.2,    // length inches
399            &params,
400            "boat_tail",
401        );
402
403        // Decay rate should be negative (spin decreases)
404        assert!(decay_rate < 0.0);
405        assert!(decay_rate > -1000.0); // Should be reasonable magnitude
406    }
407
408    #[test]
409    fn physical_spin_decay_matches_empirical_reference_rate() {
410        let spin_rate = 17_000.0;
411        let velocity = 800.0;
412        let cases = [
413            SpinDecayParameters::from_bullet_type("match"),
414            SpinDecayParameters::from_bullet_type("hunting"),
415        ];
416
417        for params in cases {
418            let actual_rate = calculate_spin_decay_rate(
419                spin_rate, velocity, 1.225, 175.0, 0.308, 1.3, &params, "ogive",
420            );
421            let empirical_spin_after_one_second = update_spin_rate(
422                spin_rate,
423                1.0,
424                velocity,
425                1.225,
426                175.0,
427                0.308,
428                1.3,
429                Some(&params),
430            );
431            let expected_rate = (empirical_spin_after_one_second / spin_rate).ln() * spin_rate;
432
433            assert!(
434                (actual_rate - expected_rate).abs() <= expected_rate.abs() * 0.05,
435                "physical rate {actual_rate} did not match empirical reference {expected_rate}"
436            );
437        }
438    }
439
440    #[test]
441    fn roll_damping_uses_canonical_reduced_spin_moment() {
442        let params = SpinDecayParameters {
443            surface_roughness: 0.0,
444            skin_friction_coefficient: 0.01,
445            form_factor: 1.0,
446        };
447        let moment = calculate_spin_damping_moment(
448            17_000.0,
449            800.0,
450            1.225,
451            0.308 * 0.0254,
452            1.3 * 0.0254,
453            &params,
454        );
455        let expected = 1.225_300_524_995_314e-4;
456
457        assert!((moment - expected).abs() <= expected * 1e-12);
458    }
459
460    #[test]
461    fn roll_damping_moment_has_physical_scaling() {
462        let params = SpinDecayParameters::from_bullet_type("match");
463        let caliber = 0.308 * 0.0254;
464        let length = 1.3 * 0.0254;
465        let moment = |spin, velocity, density, diameter, projectile_length| {
466            calculate_spin_damping_moment(
467                spin,
468                velocity,
469                density,
470                diameter,
471                projectile_length,
472                &params,
473            )
474        };
475        let base = moment(17_000.0, 800.0, 1.225, caliber, length);
476        let doubled_coefficient_params = SpinDecayParameters {
477            skin_friction_coefficient: 2.0 * params.skin_friction_coefficient,
478            ..params
479        };
480        let doubled_form_factor_params = SpinDecayParameters {
481            form_factor: 2.0 * params.form_factor,
482            ..params
483        };
484        let cases = [
485            ("spin", moment(34_000.0, 800.0, 1.225, caliber, length), 2.0),
486            (
487                "velocity",
488                moment(17_000.0, 1_600.0, 1.225, caliber, length),
489                2.0,
490            ),
491            (
492                "density",
493                moment(17_000.0, 800.0, 2.45, caliber, length),
494                2.0,
495            ),
496            (
497                "caliber",
498                moment(17_000.0, 800.0, 1.225, 2.0 * caliber, length),
499                16.0,
500            ),
501            (
502                "length",
503                moment(17_000.0, 800.0, 1.225, caliber, 2.0 * length),
504                1.0,
505            ),
506            (
507                "coefficient",
508                calculate_spin_damping_moment(
509                    17_000.0,
510                    800.0,
511                    1.225,
512                    caliber,
513                    length,
514                    &doubled_coefficient_params,
515                ),
516                2.0,
517            ),
518            (
519                "form factor",
520                calculate_spin_damping_moment(
521                    17_000.0,
522                    800.0,
523                    1.225,
524                    caliber,
525                    length,
526                    &doubled_form_factor_params,
527                ),
528                2.0,
529            ),
530        ];
531
532        for (name, moment, expected_ratio) in cases {
533            let actual_ratio = moment / base;
534            assert!(
535                (actual_ratio - expected_ratio).abs() <= expected_ratio * 1e-12,
536                "{name} scaling was {actual_ratio}, expected {expected_ratio}"
537            );
538        }
539    }
540
541    #[test]
542    fn spin_decay_always_opposes_spin_direction() {
543        let params = SpinDecayParameters::from_bullet_type("match");
544        let positive =
545            calculate_spin_decay_rate(17_000.0, 800.0, 1.225, 175.0, 0.308, 1.3, &params, "ogive");
546        let negative =
547            calculate_spin_decay_rate(-17_000.0, 800.0, 1.225, 175.0, 0.308, 1.3, &params, "ogive");
548
549        assert!(positive < 0.0);
550        assert!(negative > 0.0);
551        assert!((positive + negative).abs() <= positive.abs() * 1e-12);
552    }
553
554    #[test]
555    fn roll_damping_rejects_nonphysical_inputs() {
556        let params = SpinDecayParameters::from_bullet_type("match");
557        let invalid_states = [
558            (0.0, 800.0, 1.225, 0.00782, 0.033),
559            (17_000.0, 0.0, 1.225, 0.00782, 0.033),
560            (17_000.0, -800.0, 1.225, 0.00782, 0.033),
561            (17_000.0, 800.0, 0.0, 0.00782, 0.033),
562            (17_000.0, 800.0, -1.225, 0.00782, 0.033),
563            (17_000.0, 800.0, 1.225, 0.0, 0.033),
564            (17_000.0, 800.0, 1.225, -0.00782, 0.033),
565            (17_000.0, 800.0, 1.225, 0.00782, 0.0),
566            (17_000.0, 800.0, 1.225, 0.00782, -0.033),
567        ];
568
569        for (spin, velocity, density, caliber, length) in invalid_states {
570            let moment =
571                calculate_spin_damping_moment(spin, velocity, density, caliber, length, &params);
572            assert_eq!(
573                moment, 0.0,
574                "nonphysical state produced damping moment {moment}"
575            );
576        }
577    }
578
579    #[test]
580    fn test_different_bullet_types() {
581        // Test all bullet type parameters
582        let types = ["match", "hunting", "fmj", "cast", "unknown"];
583
584        for bullet_type in &types {
585            let params = SpinDecayParameters::from_bullet_type(bullet_type);
586            assert!(params.surface_roughness > 0.0);
587            assert!(params.skin_friction_coefficient > 0.0);
588            assert!(params.form_factor > 0.0);
589        }
590    }
591
592    #[test]
593    fn test_moment_of_inertia_shapes() {
594        let mass_kg = 0.01;
595        let caliber_m = 0.008;
596        let length_m = 0.03;
597
598        let i_cylinder = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "cylinder");
599        let i_ogive = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
600        let i_boat_tail = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "boat_tail");
601        let i_default = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "unknown");
602
603        // Check relative magnitudes
604        assert!(i_cylinder > i_ogive);
605        assert!(i_ogive > i_boat_tail);
606        assert_eq!(i_cylinder, i_default); // Unknown defaults to cylinder
607
608        // Check absolute values are reasonable
609        assert!(i_cylinder > 0.0);
610        assert!(i_boat_tail > 0.0);
611    }
612
613    #[test]
614    fn test_spin_decay_correction_factor() {
615        let params = SpinDecayParameters::from_bullet_type("match");
616
617        // At time zero, factor should be 1.0
618        let factor_t0 = calculate_spin_decay_correction_factor(
619            0.0,
620            800.0,
621            1.225,
622            175.0,
623            0.308,
624            1.3,
625            Some(&params),
626        );
627        assert_eq!(factor_t0, 1.0);
628
629        // After some time, factor should be less than 1.0 but greater than 0.5
630        let factor_t3 = calculate_spin_decay_correction_factor(
631            3.0,
632            800.0,
633            1.225,
634            175.0,
635            0.308,
636            1.3,
637            Some(&params),
638        );
639        assert!(factor_t3 < 1.0);
640        assert!(factor_t3 > 0.5);
641
642        // Factor should decrease with time
643        let factor_t1 = calculate_spin_decay_correction_factor(
644            1.0,
645            800.0,
646            1.225,
647            175.0,
648            0.308,
649            1.3,
650            Some(&params),
651        );
652        let factor_t2 = calculate_spin_decay_correction_factor(
653            2.0,
654            800.0,
655            1.225,
656            175.0,
657            0.308,
658            1.3,
659            Some(&params),
660        );
661        assert!(factor_t1 > factor_t2);
662        assert!(factor_t2 > factor_t3);
663    }
664
665    #[test]
666    fn test_default_impl() {
667        let params1 = SpinDecayParameters::new();
668        let params2 = SpinDecayParameters::default();
669
670        assert_eq!(params1.surface_roughness, params2.surface_roughness);
671        assert_eq!(
672            params1.skin_friction_coefficient,
673            params2.skin_friction_coefficient
674        );
675        assert_eq!(params1.form_factor, params2.form_factor);
676    }
677
678    #[test]
679    fn test_mass_factor_effects() {
680        let params = SpinDecayParameters::from_bullet_type("match");
681
682        // Light bullet (55gr)
683        let spin_light =
684            update_spin_rate(1000.0, 2.0, 800.0, 1.225, 55.0, 0.224, 0.9, Some(&params));
685
686        // Heavy bullet (300gr)
687        let spin_heavy =
688            update_spin_rate(1000.0, 2.0, 800.0, 1.225, 300.0, 0.338, 1.8, Some(&params));
689
690        // Heavy bullet should retain more spin (decay less)
691        assert!(spin_heavy > spin_light);
692    }
693
694    #[test]
695    fn test_velocity_factor_effects() {
696        let params = SpinDecayParameters::from_bullet_type("hunting");
697
698        // Low velocity
699        let spin_low_vel =
700            update_spin_rate(1000.0, 2.0, 400.0, 1.225, 175.0, 0.308, 1.3, Some(&params));
701
702        // High velocity
703        let spin_high_vel =
704            update_spin_rate(1000.0, 2.0, 1200.0, 1.225, 175.0, 0.308, 1.3, Some(&params));
705
706        // Higher velocity should cause more decay (less spin remaining)
707        assert!(spin_low_vel > spin_high_vel);
708    }
709}