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
149pub 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 let mass_kg = mass_grains * 0.00006479891; let caliber_m = caliber_inches * 0.0254;
163 let length_m = length_inches * 0.0254;
164
165 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 let moment_of_inertia = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, bullet_shape);
177
178 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
186pub 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 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 {
213 air_density_kg_m3 / 1.225
214 } else {
215 1.0 };
217
218 let ref_surface = PI * 0.308 * 1.3; 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 };
230
231 let base_decay_rate = if let Some(params) = decay_params {
233 if params.form_factor < 1.0 {
234 MATCH_REFERENCE_DECAY_RATE_PER_SECOND
236 } else {
237 GENERAL_REFERENCE_DECAY_RATE_PER_SECOND
239 }
240 } else {
241 GENERAL_REFERENCE_DECAY_RATE_PER_SECOND
242 };
243
244 let decay_rate_per_second =
248 base_decay_rate * mass_factor * velocity_factor * density_factor * surface_factor;
249
250 let decay_factor = (-decay_rate_per_second * time_elapsed_s).exp();
252
253 initial_spin_rad_s * decay_factor.clamp(0.5, 1.0)
255}
256
257pub 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 let initial_spin = 1000.0; 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; let caliber_m = 0.00782; 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); }
315
316 #[test]
317 fn test_spin_decay_realistic() {
318 let initial_spin = 2800.0 * 2.0 * PI; let params = SpinDecayParameters::from_bullet_type("match");
321
322 let spin_after_3s = update_spin_rate(
324 initial_spin,
325 3.0, 750.0, 1.2, 175.0, 0.308, 1.3, Some(¶ms),
332 );
333
334 let decay_percent = (1.0 - spin_after_3s / initial_spin) * 100.0;
335
336 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 let spin_long_time = update_spin_rate(
347 initial_spin,
348 100.0, 500.0,
350 1.225,
351 150.0,
352 0.308,
353 1.2,
354 Some(¶ms),
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 let moment = calculate_spin_damping_moment(
366 1000.0, 800.0, 1.225, 0.00782, 0.033, ¶ms,
372 );
373
374 assert!(moment > 0.0);
376 assert!(moment < 1.0); let zero_moment = calculate_spin_damping_moment(0.0, 800.0, 1.225, 0.00782, 0.033, ¶ms);
380 assert_eq!(zero_moment, 0.0);
381
382 let zero_vel_moment =
384 calculate_spin_damping_moment(1000.0, 0.0, 1.225, 0.00782, 0.033, ¶ms);
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, 800.0, 1.225, 168.0, 0.308, 1.2, ¶ms,
400 "boat_tail",
401 );
402
403 assert!(decay_rate < 0.0);
405 assert!(decay_rate > -1000.0); }
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, ¶ms, "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(¶ms),
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 ¶ms,
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 ¶ms,
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, ¶ms, "ogive");
546 let negative =
547 calculate_spin_decay_rate(-17_000.0, 800.0, 1.225, 175.0, 0.308, 1.3, ¶ms, "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, ¶ms);
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 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 assert!(i_cylinder > i_ogive);
605 assert!(i_ogive > i_boat_tail);
606 assert_eq!(i_cylinder, i_default); 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 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(¶ms),
626 );
627 assert_eq!(factor_t0, 1.0);
628
629 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(¶ms),
638 );
639 assert!(factor_t3 < 1.0);
640 assert!(factor_t3 > 0.5);
641
642 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(¶ms),
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(¶ms),
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 let spin_light =
684 update_spin_rate(1000.0, 2.0, 800.0, 1.225, 55.0, 0.224, 0.9, Some(¶ms));
685
686 let spin_heavy =
688 update_spin_rate(1000.0, 2.0, 800.0, 1.225, 300.0, 0.338, 1.8, Some(¶ms));
689
690 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 let spin_low_vel =
700 update_spin_rate(1000.0, 2.0, 400.0, 1.225, 175.0, 0.308, 1.3, Some(¶ms));
701
702 let spin_high_vel =
704 update_spin_rate(1000.0, 2.0, 1200.0, 1.225, 175.0, 0.308, 1.3, Some(¶ms));
705
706 assert!(spin_low_vel > spin_high_vel);
708 }
709}