1use 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#[derive(Debug, Clone, Copy)]
16pub struct SpinDecayParameters {
17 pub surface_roughness: f64,
19 pub skin_friction_coefficient: f64,
22 pub form_factor: f64,
24}
25
26impl SpinDecayParameters {
27 pub fn new() -> Self {
29 Self {
32 surface_roughness: 0.0001,
33 skin_friction_coefficient: 0.00363,
34 form_factor: 1.0,
35 }
36 }
37
38 pub fn from_bullet_type(bullet_type: &str) -> Self {
40 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
74pub 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
120pub 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 0.5 * mass_kg * radius.powi(2)
133 }
134 "ogive" => {
135 0.4 * mass_kg * radius.powi(2)
137 }
138 "boat_tail" => {
139 0.35 * mass_kg * radius.powi(2)
141 }
142 _ => {
143 0.5 * mass_kg * radius.powi(2)
145 }
146 }
147}
148
149#[allow(clippy::too_many_arguments)] pub fn calculate_spin_decay_rate(
152 spin_rate_rad_s: f64,
153 velocity_mps: f64,
154 air_density_kg_m3: f64,
155 mass_grains: f64,
156 caliber_inches: f64,
157 length_inches: f64,
158 decay_params: &SpinDecayParameters,
159 bullet_shape: &str,
160) -> f64 {
161 let mass_kg = mass_grains * 0.00006479891; let caliber_m = caliber_inches * 0.0254;
164 let length_m = length_inches * 0.0254;
165
166 let damping_moment = calculate_spin_damping_moment(
168 spin_rate_rad_s,
169 velocity_mps,
170 air_density_kg_m3,
171 caliber_m,
172 length_m,
173 decay_params,
174 );
175
176 let moment_of_inertia = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, bullet_shape);
178
179 if moment_of_inertia > 0.0 && spin_rate_rad_s.is_finite() {
181 -spin_rate_rad_s.signum() * damping_moment / moment_of_inertia
182 } else {
183 0.0
184 }
185}
186
187#[allow(clippy::too_many_arguments)] pub fn update_spin_rate(
193 initial_spin_rad_s: f64,
194 time_elapsed_s: f64,
195 velocity_mps: f64,
196 air_density_kg_m3: f64,
197 mass_grains: f64,
198 caliber_inches: f64,
199 length_inches: f64,
200 decay_params: Option<&SpinDecayParameters>,
201) -> f64 {
202 if time_elapsed_s <= 0.0 {
203 return initial_spin_rad_s;
204 }
205
206 let mass_factor = (175.0 / mass_grains).sqrt(); let velocity_factor = velocity_mps / 850.0; let density_factor = if air_density_kg_m3 > 0.0 {
215 air_density_kg_m3 / 1.225
216 } else {
217 1.0 };
219
220 let ref_surface = PI * 0.308 * 1.3; let surface_factor = if caliber_inches > 0.0 && length_inches > 0.0 {
227 let bullet_surface = PI * caliber_inches * length_inches;
228 (bullet_surface / ref_surface).sqrt()
229 } else {
230 1.0 };
232
233 let base_decay_rate = if let Some(params) = decay_params {
235 if params.form_factor < 1.0 {
236 MATCH_REFERENCE_DECAY_RATE_PER_SECOND
238 } else {
239 GENERAL_REFERENCE_DECAY_RATE_PER_SECOND
241 }
242 } else {
243 GENERAL_REFERENCE_DECAY_RATE_PER_SECOND
244 };
245
246 let decay_rate_per_second =
250 base_decay_rate * mass_factor * velocity_factor * density_factor * surface_factor;
251
252 let decay_factor = (-decay_rate_per_second * time_elapsed_s).exp();
254
255 initial_spin_rad_s * decay_factor.clamp(0.5, 1.0)
257}
258
259pub fn calculate_spin_decay_correction_factor(
264 time_elapsed_s: f64,
265 velocity_mps: f64,
266 air_density_kg_m3: f64,
267 mass_grains: f64,
268 caliber_inches: f64,
269 length_inches: f64,
270 decay_params: Option<&SpinDecayParameters>,
271) -> f64 {
272 if time_elapsed_s <= 0.0 {
273 return 1.0;
274 }
275
276 let initial_spin = 1000.0; let current_spin = update_spin_rate(
280 initial_spin,
281 time_elapsed_s,
282 velocity_mps,
283 air_density_kg_m3,
284 mass_grains,
285 caliber_inches,
286 length_inches,
287 decay_params,
288 );
289
290 current_spin / initial_spin
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_spin_decay_parameters() {
299 let match_params = SpinDecayParameters::from_bullet_type("match");
300 assert_eq!(match_params.form_factor, 0.9);
301 assert_eq!(match_params.surface_roughness, 0.00005);
302
303 let hunting_params = SpinDecayParameters::from_bullet_type("hunting");
304 assert_eq!(hunting_params.form_factor, 1.0);
305 }
306
307 #[test]
308 fn test_moment_of_inertia() {
309 let mass_kg = 0.01134; let caliber_m = 0.00782; let i_cylinder = calculate_moment_of_inertia(mass_kg, caliber_m, 0.033, "cylinder");
313 let i_ogive = calculate_moment_of_inertia(mass_kg, caliber_m, 0.033, "ogive");
314
315 assert!(i_cylinder > i_ogive); }
317
318 #[test]
319 fn test_spin_decay_realistic() {
320 let initial_spin = 2800.0 * 2.0 * PI; let params = SpinDecayParameters::from_bullet_type("match");
323
324 let spin_after_3s = update_spin_rate(
326 initial_spin,
327 3.0, 750.0, 1.2, 175.0, 0.308, 1.3, Some(¶ms),
334 );
335
336 let decay_percent = (1.0 - spin_after_3s / initial_spin) * 100.0;
337
338 assert!(decay_percent > 2.0 && decay_percent < 15.0);
340 }
341
342 #[test]
343 fn test_spin_decay_bounds() {
344 let initial_spin = 1000.0;
345 let params = SpinDecayParameters::new();
346
347 let spin_long_time = update_spin_rate(
349 initial_spin,
350 100.0, 500.0,
352 1.225,
353 150.0,
354 0.308,
355 1.2,
356 Some(¶ms),
357 );
358
359 assert!(spin_long_time >= initial_spin * 0.5);
360 }
361
362 #[test]
363 fn test_spin_damping_moment() {
364 let params = SpinDecayParameters::from_bullet_type("match");
365
366 let moment = calculate_spin_damping_moment(
368 1000.0, 800.0, 1.225, 0.00782, 0.033, ¶ms,
374 );
375
376 assert!(moment > 0.0);
378 assert!(moment < 1.0); let zero_moment = calculate_spin_damping_moment(0.0, 800.0, 1.225, 0.00782, 0.033, ¶ms);
382 assert_eq!(zero_moment, 0.0);
383
384 let zero_vel_moment =
386 calculate_spin_damping_moment(1000.0, 0.0, 1.225, 0.00782, 0.033, ¶ms);
387 assert_eq!(zero_vel_moment, 0.0);
388 }
389
390 #[test]
391 fn test_spin_decay_rate() {
392 let params = SpinDecayParameters::from_bullet_type("fmj");
393
394 let decay_rate = calculate_spin_decay_rate(
395 1000.0, 800.0, 1.225, 168.0, 0.308, 1.2, ¶ms,
402 "boat_tail",
403 );
404
405 assert!(decay_rate < 0.0);
407 assert!(decay_rate > -1000.0); }
409
410 #[test]
411 fn physical_spin_decay_matches_empirical_reference_rate() {
412 let spin_rate = 17_000.0;
413 let velocity = 800.0;
414 let cases = [
415 SpinDecayParameters::from_bullet_type("match"),
416 SpinDecayParameters::from_bullet_type("hunting"),
417 ];
418
419 for params in cases {
420 let actual_rate = calculate_spin_decay_rate(
421 spin_rate, velocity, 1.225, 175.0, 0.308, 1.3, ¶ms, "ogive",
422 );
423 let empirical_spin_after_one_second = update_spin_rate(
424 spin_rate,
425 1.0,
426 velocity,
427 1.225,
428 175.0,
429 0.308,
430 1.3,
431 Some(¶ms),
432 );
433 let expected_rate = (empirical_spin_after_one_second / spin_rate).ln() * spin_rate;
434
435 assert!(
436 (actual_rate - expected_rate).abs() <= expected_rate.abs() * 0.05,
437 "physical rate {actual_rate} did not match empirical reference {expected_rate}"
438 );
439 }
440 }
441
442 #[test]
443 fn roll_damping_uses_canonical_reduced_spin_moment() {
444 let params = SpinDecayParameters {
445 surface_roughness: 0.0,
446 skin_friction_coefficient: 0.01,
447 form_factor: 1.0,
448 };
449 let moment = calculate_spin_damping_moment(
450 17_000.0,
451 800.0,
452 1.225,
453 0.308 * 0.0254,
454 1.3 * 0.0254,
455 ¶ms,
456 );
457 let expected = 1.225_300_524_995_314e-4;
458
459 assert!((moment - expected).abs() <= expected * 1e-12);
460 }
461
462 #[test]
463 fn roll_damping_moment_has_physical_scaling() {
464 let params = SpinDecayParameters::from_bullet_type("match");
465 let caliber = 0.308 * 0.0254;
466 let length = 1.3 * 0.0254;
467 let moment = |spin, velocity, density, diameter, projectile_length| {
468 calculate_spin_damping_moment(
469 spin,
470 velocity,
471 density,
472 diameter,
473 projectile_length,
474 ¶ms,
475 )
476 };
477 let base = moment(17_000.0, 800.0, 1.225, caliber, length);
478 let doubled_coefficient_params = SpinDecayParameters {
479 skin_friction_coefficient: 2.0 * params.skin_friction_coefficient,
480 ..params
481 };
482 let doubled_form_factor_params = SpinDecayParameters {
483 form_factor: 2.0 * params.form_factor,
484 ..params
485 };
486 let cases = [
487 ("spin", moment(34_000.0, 800.0, 1.225, caliber, length), 2.0),
488 (
489 "velocity",
490 moment(17_000.0, 1_600.0, 1.225, caliber, length),
491 2.0,
492 ),
493 (
494 "density",
495 moment(17_000.0, 800.0, 2.45, caliber, length),
496 2.0,
497 ),
498 (
499 "caliber",
500 moment(17_000.0, 800.0, 1.225, 2.0 * caliber, length),
501 16.0,
502 ),
503 (
504 "length",
505 moment(17_000.0, 800.0, 1.225, caliber, 2.0 * length),
506 1.0,
507 ),
508 (
509 "coefficient",
510 calculate_spin_damping_moment(
511 17_000.0,
512 800.0,
513 1.225,
514 caliber,
515 length,
516 &doubled_coefficient_params,
517 ),
518 2.0,
519 ),
520 (
521 "form factor",
522 calculate_spin_damping_moment(
523 17_000.0,
524 800.0,
525 1.225,
526 caliber,
527 length,
528 &doubled_form_factor_params,
529 ),
530 2.0,
531 ),
532 ];
533
534 for (name, moment, expected_ratio) in cases {
535 let actual_ratio = moment / base;
536 assert!(
537 (actual_ratio - expected_ratio).abs() <= expected_ratio * 1e-12,
538 "{name} scaling was {actual_ratio}, expected {expected_ratio}"
539 );
540 }
541 }
542
543 #[test]
544 fn spin_decay_always_opposes_spin_direction() {
545 let params = SpinDecayParameters::from_bullet_type("match");
546 let positive =
547 calculate_spin_decay_rate(17_000.0, 800.0, 1.225, 175.0, 0.308, 1.3, ¶ms, "ogive");
548 let negative =
549 calculate_spin_decay_rate(-17_000.0, 800.0, 1.225, 175.0, 0.308, 1.3, ¶ms, "ogive");
550
551 assert!(positive < 0.0);
552 assert!(negative > 0.0);
553 assert!((positive + negative).abs() <= positive.abs() * 1e-12);
554 }
555
556 #[test]
557 fn roll_damping_rejects_nonphysical_inputs() {
558 let params = SpinDecayParameters::from_bullet_type("match");
559 let invalid_states = [
560 (0.0, 800.0, 1.225, 0.00782, 0.033),
561 (17_000.0, 0.0, 1.225, 0.00782, 0.033),
562 (17_000.0, -800.0, 1.225, 0.00782, 0.033),
563 (17_000.0, 800.0, 0.0, 0.00782, 0.033),
564 (17_000.0, 800.0, -1.225, 0.00782, 0.033),
565 (17_000.0, 800.0, 1.225, 0.0, 0.033),
566 (17_000.0, 800.0, 1.225, -0.00782, 0.033),
567 (17_000.0, 800.0, 1.225, 0.00782, 0.0),
568 (17_000.0, 800.0, 1.225, 0.00782, -0.033),
569 ];
570
571 for (spin, velocity, density, caliber, length) in invalid_states {
572 let moment =
573 calculate_spin_damping_moment(spin, velocity, density, caliber, length, ¶ms);
574 assert_eq!(
575 moment, 0.0,
576 "nonphysical state produced damping moment {moment}"
577 );
578 }
579 }
580
581 #[test]
582 fn test_different_bullet_types() {
583 let types = ["match", "hunting", "fmj", "cast", "unknown"];
585
586 for bullet_type in &types {
587 let params = SpinDecayParameters::from_bullet_type(bullet_type);
588 assert!(params.surface_roughness > 0.0);
589 assert!(params.skin_friction_coefficient > 0.0);
590 assert!(params.form_factor > 0.0);
591 }
592 }
593
594 #[test]
595 fn test_moment_of_inertia_shapes() {
596 let mass_kg = 0.01;
597 let caliber_m = 0.008;
598 let length_m = 0.03;
599
600 let i_cylinder = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "cylinder");
601 let i_ogive = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
602 let i_boat_tail = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "boat_tail");
603 let i_default = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "unknown");
604
605 assert!(i_cylinder > i_ogive);
607 assert!(i_ogive > i_boat_tail);
608 assert_eq!(i_cylinder, i_default); assert!(i_cylinder > 0.0);
612 assert!(i_boat_tail > 0.0);
613 }
614
615 #[test]
616 fn test_spin_decay_correction_factor() {
617 let params = SpinDecayParameters::from_bullet_type("match");
618
619 let factor_t0 = calculate_spin_decay_correction_factor(
621 0.0,
622 800.0,
623 1.225,
624 175.0,
625 0.308,
626 1.3,
627 Some(¶ms),
628 );
629 assert_eq!(factor_t0, 1.0);
630
631 let factor_t3 = calculate_spin_decay_correction_factor(
633 3.0,
634 800.0,
635 1.225,
636 175.0,
637 0.308,
638 1.3,
639 Some(¶ms),
640 );
641 assert!(factor_t3 < 1.0);
642 assert!(factor_t3 > 0.5);
643
644 let factor_t1 = calculate_spin_decay_correction_factor(
646 1.0,
647 800.0,
648 1.225,
649 175.0,
650 0.308,
651 1.3,
652 Some(¶ms),
653 );
654 let factor_t2 = calculate_spin_decay_correction_factor(
655 2.0,
656 800.0,
657 1.225,
658 175.0,
659 0.308,
660 1.3,
661 Some(¶ms),
662 );
663 assert!(factor_t1 > factor_t2);
664 assert!(factor_t2 > factor_t3);
665 }
666
667 #[test]
668 fn test_default_impl() {
669 let params1 = SpinDecayParameters::new();
670 let params2 = SpinDecayParameters::default();
671
672 assert_eq!(params1.surface_roughness, params2.surface_roughness);
673 assert_eq!(
674 params1.skin_friction_coefficient,
675 params2.skin_friction_coefficient
676 );
677 assert_eq!(params1.form_factor, params2.form_factor);
678 }
679
680 #[test]
681 fn test_mass_factor_effects() {
682 let params = SpinDecayParameters::from_bullet_type("match");
683
684 let spin_light =
686 update_spin_rate(1000.0, 2.0, 800.0, 1.225, 55.0, 0.224, 0.9, Some(¶ms));
687
688 let spin_heavy =
690 update_spin_rate(1000.0, 2.0, 800.0, 1.225, 300.0, 0.338, 1.8, Some(¶ms));
691
692 assert!(spin_heavy > spin_light);
694 }
695
696 #[test]
697 fn test_velocity_factor_effects() {
698 let params = SpinDecayParameters::from_bullet_type("hunting");
699
700 let spin_low_vel =
702 update_spin_rate(1000.0, 2.0, 400.0, 1.225, 175.0, 0.308, 1.3, Some(¶ms));
703
704 let spin_high_vel =
706 update_spin_rate(1000.0, 2.0, 1200.0, 1.225, 175.0, 0.308, 1.3, Some(¶ms));
707
708 assert!(spin_low_vel > spin_high_vel);
710 }
711}