1use crate::constants::G_ACCEL_MPS2;
10use crate::spin_decay::calculate_moment_of_inertia;
11use std::f64::consts::PI;
12
13#[derive(Debug, Clone, Copy)]
15pub struct PitchDampingCoefficients {
16 pub subsonic: f64, pub transonic_low: f64, pub transonic_high: f64, pub supersonic: f64, }
21
22impl Default for PitchDampingCoefficients {
23 fn default() -> Self {
24 Self {
28 subsonic: -8.0,
29 transonic_low: -3.0,
30 transonic_high: 2.0,
31 supersonic: -5.0,
32 }
33 }
34}
35
36impl PitchDampingCoefficients {
37 pub fn from_bullet_type(bullet_type: &str) -> Self {
39 match bullet_type.to_lowercase().as_str() {
40 "match_boat_tail" => Self {
41 subsonic: -9.0,
42 transonic_low: -4.0,
43 transonic_high: 1.0,
44 supersonic: -6.0,
45 },
46 "match_flat_base" => Self {
47 subsonic: -7.0,
48 transonic_low: -2.0,
49 transonic_high: 3.0,
50 supersonic: -4.0,
51 },
52 "vld" => Self {
53 subsonic: -10.0,
55 transonic_low: -5.0,
56 transonic_high: -1.0,
57 supersonic: -7.0,
58 },
59 "hunting" => Self {
60 subsonic: -6.0,
61 transonic_low: -1.0,
62 transonic_high: 4.0,
63 supersonic: -3.0,
64 },
65 "fmj" => Self {
66 subsonic: -7.0,
67 transonic_low: -2.0,
68 transonic_high: 2.0,
69 supersonic: -5.0,
70 },
71 _ => Self::default(),
72 }
73 }
74}
75
76pub fn calculate_pitch_damping_coefficient(mach: f64, coeffs: &PitchDampingCoefficients) -> f64 {
78 if mach < 0.8 {
79 coeffs.subsonic
81 } else if mach < 1.0 {
82 let t = (mach - 0.8) / 0.2;
85 coeffs.subsonic * (1.0 - t) + coeffs.transonic_low * t
86 } else if mach < 1.2 {
87 let t = (mach - 1.0) / 0.2;
90 coeffs.transonic_low * (1.0 - t) + coeffs.transonic_high * t
91 } else {
92 let t = ((mach - 1.2) / 0.8).min(1.0);
95 coeffs.transonic_high * (1.0 - t) + coeffs.supersonic * t
96 }
97}
98
99pub fn calculate_pitch_damping_moment(
101 pitch_rate_rad_s: f64,
102 velocity_mps: f64,
103 air_density_kg_m3: f64,
104 caliber_m: f64,
105 _length_m: f64,
106 mach: f64,
107 coeffs: &PitchDampingCoefficients,
108) -> f64 {
109 if velocity_mps == 0.0 || pitch_rate_rad_s == 0.0 {
110 return 0.0;
111 }
112
113 let cmq = calculate_pitch_damping_coefficient(mach, coeffs);
115
116 let q = 0.5 * air_density_kg_m3 * velocity_mps.powi(2);
118
119 let s = PI * (caliber_m / 2.0).powi(2);
121
122 let d = caliber_m;
124
125 let q_nondim = pitch_rate_rad_s * d / velocity_mps;
127
128 q * s * d * cmq * q_nondim
131}
132
133pub fn calculate_transverse_moment_of_inertia(
135 mass_kg: f64,
136 caliber_m: f64,
137 length_m: f64,
138 shape: &str,
139) -> f64 {
140 let radius = caliber_m / 2.0;
141
142 match shape {
143 "cylinder" => {
144 mass_kg * (3.0 * radius.powi(2) + length_m.powi(2)) / 12.0
146 }
147 "ogive" => {
148 let cylinder_i = mass_kg * (3.0 * radius.powi(2) + length_m.powi(2)) / 12.0;
151 0.85 * cylinder_i
152 }
153 "boat_tail" => {
154 let cylinder_i = mass_kg * (3.0 * radius.powi(2) + length_m.powi(2)) / 12.0;
157 0.80 * cylinder_i
158 }
159 _ => {
160 mass_kg * (3.0 * radius.powi(2) + length_m.powi(2)) / 12.0
162 }
163 }
164}
165
166pub fn calculate_angular_acceleration(moment: f64, moment_of_inertia: f64) -> f64 {
168 if moment_of_inertia > 0.0 {
169 moment / moment_of_inertia
170 } else {
171 0.0
172 }
173}
174
175pub(crate) fn calculate_gravity_yaw_of_repose(
183 stability_factor: f64,
184 velocity_mps: f64,
185 spin_rate_rad_s: f64,
186 mass_kg: f64,
187 caliber_m: f64,
188 length_m: f64,
189) -> f64 {
190 if !stability_factor.is_finite()
191 || stability_factor <= 1.0
192 || !velocity_mps.is_finite()
193 || velocity_mps <= 0.0
194 || !spin_rate_rad_s.is_finite()
195 || spin_rate_rad_s == 0.0
196 || !mass_kg.is_finite()
197 || mass_kg <= 0.0
198 || !caliber_m.is_finite()
199 || caliber_m <= 0.0
200 || !length_m.is_finite()
201 || length_m <= 0.0
202 {
203 return 0.0;
204 }
205
206 let axial_inertia = calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
207 let transverse_inertia =
208 calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
209 if axial_inertia <= 0.0 || transverse_inertia <= 0.0 {
210 return 0.0;
211 }
212
213 4.0 * transverse_inertia * stability_factor * G_ACCEL_MPS2
214 / (axial_inertia * spin_rate_rad_s.abs() * velocity_mps)
215}
216
217#[allow(clippy::too_many_arguments)] pub fn calculate_damped_yaw_of_repose(
223 stability_factor: f64,
224 velocity_mps: f64,
225 spin_rate_rad_s: f64,
226 _wind_velocity_mps: f64,
227 pitch_rate_rad_s: f64,
228 air_density_kg_m3: f64,
229 caliber_inches: f64,
230 length_inches: f64,
231 mass_grains: f64,
232 mach: f64,
233 bullet_type: &str,
234) -> (f64, f64) {
235 if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
236 return (0.0, 0.0);
237 }
238
239 let caliber_m = caliber_inches * 0.0254;
241 let length_m = length_inches * 0.0254;
242 let mass_kg = mass_grains * crate::constants::GRAINS_TO_KG;
243
244 let equilibrium_yaw_rad = calculate_gravity_yaw_of_repose(
247 stability_factor,
248 velocity_mps,
249 spin_rate_rad_s,
250 mass_kg,
251 caliber_m,
252 length_m,
253 );
254
255 let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
257
258 let damping_moment = calculate_pitch_damping_moment(
260 pitch_rate_rad_s,
261 velocity_mps,
262 air_density_kg_m3,
263 caliber_m,
264 length_m,
265 mach,
266 &coeffs,
267 );
268
269 let i_transverse =
271 calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
272
273 let angular_accel = calculate_angular_acceleration(damping_moment, i_transverse);
275
276 let convergence_rate = if angular_accel != 0.0 && pitch_rate_rad_s != 0.0 {
279 -angular_accel / pitch_rate_rad_s
280 } else {
281 0.1
282 };
283
284 (equilibrium_yaw_rad, convergence_rate)
285}
286
287#[deprecated(
293 since = "0.22.18",
294 note = "use precession_nutation::calculate_precession_frequency; damping changes modal amplitude, not phase frequency"
295)]
296pub fn calculate_precession_with_damping(
297 spin_rate_rad_s: f64,
298 spin_inertia: f64,
299 transverse_inertia: f64,
300 stability_factor: f64,
301) -> f64 {
302 crate::precession_nutation::calculate_precession_frequency(
303 spin_rate_rad_s,
304 spin_inertia,
305 transverse_inertia,
306 stability_factor,
307 )
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_pitch_damping_coefficient() {
316 let coeffs = PitchDampingCoefficients::default();
317
318 assert_eq!(calculate_pitch_damping_coefficient(0.5, &coeffs), -8.0);
320
321 let transonic = calculate_pitch_damping_coefficient(0.9, &coeffs);
323 assert!(transonic > -8.0 && transonic < -3.0);
324
325 let supersonic = calculate_pitch_damping_coefficient(2.0, &coeffs);
327 assert_eq!(supersonic, -5.0);
328 }
329
330 #[test]
331 fn test_pitch_damping_moment() {
332 let coeffs = PitchDampingCoefficients::default();
333 let moment = calculate_pitch_damping_moment(
334 0.1, 300.0, 1.225, 0.00782, 0.033, 0.87, &coeffs,
341 );
342
343 assert!(moment < 0.0);
345 }
346
347 #[test]
348 fn default_308_pitch_rate_damps_on_small_arms_timescale() {
349 let pitch_rate = 0.1;
350 let velocity = 850.0;
351 let density = 1.225;
352 let caliber = 0.308 * 0.0254;
353 let length = 1.3 * 0.0254;
354 let mass = 175.0 * crate::constants::GRAINS_TO_KG;
355 let mach = velocity / 343.0;
356 let coeffs = PitchDampingCoefficients::default();
357
358 let moment = calculate_pitch_damping_moment(
359 pitch_rate, velocity, density, caliber, length, mach, &coeffs,
360 );
361 let inertia = calculate_transverse_moment_of_inertia(mass, caliber, length, "ogive");
362 let angular_accel = calculate_angular_acceleration(moment, inertia);
363 let time_constant = (pitch_rate / angular_accel).abs();
364
365 assert!(
366 (0.05..=0.25).contains(&time_constant),
367 ".308 pitch damping should settle on a small-arms timescale, got tau={time_constant}s"
368 );
369 }
370
371 #[test]
372 fn test_bullet_type_coefficients() {
373 let types = [
374 "match_boat_tail",
375 "match_flat_base",
376 "vld",
377 "hunting",
378 "fmj",
379 "unknown",
380 ];
381
382 for bullet_type in &types {
383 let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
384
385 assert!(coeffs.subsonic < 0.0);
387
388 assert!(coeffs.supersonic < 0.0);
390
391 assert!(coeffs.subsonic.abs() >= 3.0);
393 assert!(coeffs.supersonic.abs() >= 3.0);
394
395 if *bullet_type == "vld" {
397 let default_coeffs = PitchDampingCoefficients::default();
398 assert!(coeffs.subsonic < default_coeffs.subsonic);
399 }
400 }
401 }
402
403 #[test]
404 fn test_transonic_instability() {
405 let coeffs = PitchDampingCoefficients::from_bullet_type("hunting");
406
407 assert!(coeffs.transonic_high > 0.0);
409
410 let mach_1_1 = calculate_pitch_damping_coefficient(1.1, &coeffs);
412
413 assert!(mach_1_1 > coeffs.transonic_low);
415 }
416
417 #[test]
418 fn test_transverse_moment_of_inertia() {
419 let mass_kg = 0.01134; let caliber_m = 0.00782; let length_m = 0.033; let i_cylinder =
424 calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "cylinder");
425 let i_ogive = calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
426 let i_boat_tail =
427 calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "boat_tail");
428 let i_unknown =
429 calculate_transverse_moment_of_inertia(mass_kg, caliber_m, length_m, "unknown");
430
431 assert!(i_cylinder > i_ogive);
433 assert!(i_ogive > i_boat_tail);
434 assert_eq!(i_cylinder, i_unknown);
435
436 assert!(i_cylinder > 0.0);
438 assert!(i_cylinder < 1.0); }
440
441 #[test]
442 fn test_angular_acceleration() {
443 let moment = -0.001; let inertia = 0.0001; let accel = calculate_angular_acceleration(moment, inertia);
447 assert_eq!(accel, moment / inertia);
448
449 let accel_zero = calculate_angular_acceleration(moment, 0.0);
451 assert_eq!(accel_zero, 0.0);
452 }
453
454 #[test]
455 fn test_damped_yaw_of_repose() {
456 let (yaw, convergence) = calculate_damped_yaw_of_repose(
457 2.5, 800.0, 19000.0, 10.0, 0.01, 1.225, 0.308, 1.3, 175.0, 0.9, "match_boat_tail",
468 );
469
470 assert!(yaw > 0.0);
472 assert!(yaw < 0.1); assert!(convergence > 0.0);
474
475 let (yaw_unstable, conv_unstable) = calculate_damped_yaw_of_repose(
477 0.9,
478 800.0,
479 19000.0,
480 10.0,
481 0.01,
482 1.225,
483 0.308,
484 1.3,
485 175.0,
486 0.9,
487 "match_boat_tail",
488 );
489 assert_eq!(yaw_unstable, 0.0);
490 assert_eq!(conv_unstable, 0.0);
491 }
492
493 #[test]
494 fn damped_yaw_convergence_rate_preserves_stability_sign() {
495 let rate = |mach, pitch_rate_rad_s| {
496 calculate_damped_yaw_of_repose(
497 2.5,
498 800.0,
499 19_000.0,
500 0.0,
501 pitch_rate_rad_s,
502 1.225,
503 0.308,
504 1.3,
505 175.0,
506 mach,
507 "fmj",
508 )
509 .1
510 };
511
512 let damped = rate(1.0, 0.01);
514 let divergent = rate(1.2, 0.01);
515
516 assert!(damped > 0.0);
517 assert!(divergent < 0.0);
518 assert_eq!(damped.to_bits(), (-divergent).to_bits());
519 }
520
521 #[test]
522 fn crosswind_is_not_persistent_equilibrium_yaw() {
523 let calculate = |wind_velocity_mps| {
524 calculate_damped_yaw_of_repose(
525 2.5,
526 300.0,
527 19_000.0,
528 wind_velocity_mps,
529 0.01,
530 1.225,
531 0.308,
532 1.3,
533 175.0,
534 0.875,
535 "match_boat_tail",
536 )
537 };
538
539 let (calm_yaw, calm_rate) = calculate(0.0);
540 let (windy_yaw, windy_rate) = calculate(10.0);
541
542 assert!(
543 (windy_yaw - calm_yaw).abs() < 1e-12,
544 "crosswind became persistent equilibrium yaw: calm={calm_yaw} windy={windy_yaw}"
545 );
546 assert_eq!(windy_rate.to_bits(), calm_rate.to_bits());
547 assert!(windy_yaw.abs() < 0.003);
548 }
549
550 #[test]
551 fn gravity_yaw_of_repose_matches_classical_stability_reduction() {
552 let stability_factor = 2.5;
553 let velocity_mps = 300.0;
554 let spin_rate_rad_s = 19_000.0;
555 let mass_kg = 175.0 * crate::constants::GRAINS_TO_KG;
556 let caliber_m = 0.308 * 0.0254;
557 let length_m = 1.3 * 0.0254;
558
559 let actual = calculate_gravity_yaw_of_repose(
560 stability_factor,
561 velocity_mps,
562 spin_rate_rad_s,
563 mass_kg,
564 caliber_m,
565 length_m,
566 );
567
568 let radius_m = caliber_m / 2.0;
571 let axial_inertia = 0.4 * mass_kg * radius_m.powi(2);
572 let transverse_inertia =
573 0.85 * mass_kg * (3.0 * radius_m.powi(2) + length_m.powi(2)) / 12.0;
574 let expected = 4.0 * transverse_inertia * stability_factor * 9.80665
575 / (axial_inertia * spin_rate_rad_s * velocity_mps);
576
577 assert!((actual - expected).abs() < 1e-15);
578 assert!((actual - 0.000226244442784).abs() < 1e-15);
579 }
580
581 #[test]
582 #[allow(deprecated)]
583 fn test_precession_with_damping() {
584 let precession = calculate_precession_with_damping(
585 19000.0, 0.00005, 0.0001, 2.5, );
590
591 assert!(precession > 0.0);
592
593 let precession_zero = calculate_precession_with_damping(0.0, 0.00005, 0.0001, 2.5);
595 assert_eq!(precession_zero, 0.0);
596
597 let precession_unstable = calculate_precession_with_damping(19000.0, 0.00005, 0.0001, 1.0);
599 assert_eq!(precession_unstable, 0.0);
600 }
601
602 #[test]
603 #[allow(deprecated)]
604 fn precession_uses_slow_epicyclic_frequency() {
605 let spin_rate_rad_s = 17_522.0;
606 let spin_inertia = 6.94e-8;
607 let transverse_inertia = 9.13e-7;
608 let stability_factor = 2.0;
609 let expected = crate::precession_nutation::calculate_precession_frequency(
610 spin_rate_rad_s,
611 spin_inertia,
612 transverse_inertia,
613 stability_factor,
614 );
615
616 let actual = calculate_precession_with_damping(
617 spin_rate_rad_s,
618 spin_inertia,
619 transverse_inertia,
620 stability_factor,
621 );
622
623 assert!(
624 (actual - expected).abs() <= expected * 1e-12,
625 "precession did not use the slow epicyclic rate: actual={actual} expected={expected}"
626 );
627 assert!((150.0..250.0).contains(&actual));
628 }
629
630 #[test]
631 fn test_mach_interpolation() {
632 let coeffs = PitchDampingCoefficients::default();
633
634 let epsilon = 1e-9;
636 for boundary in [0.8, 1.0, 1.2, 2.0] {
637 let at_boundary = calculate_pitch_damping_coefficient(boundary, &coeffs);
638 let below = calculate_pitch_damping_coefficient(boundary - epsilon, &coeffs);
639 let above = calculate_pitch_damping_coefficient(boundary + epsilon, &coeffs);
640
641 assert!((at_boundary - below).abs() < 1e-6);
642 assert!((at_boundary - above).abs() < 1e-6);
643 }
644 }
645
646 #[test]
647 fn test_pitch_damping_edge_cases() {
648 let coeffs = PitchDampingCoefficients::default();
649
650 let moment_zero_pitch =
652 calculate_pitch_damping_moment(0.0, 300.0, 1.225, 0.00782, 0.033, 0.87, &coeffs);
653 assert_eq!(moment_zero_pitch, 0.0);
654
655 let moment_zero_vel =
657 calculate_pitch_damping_moment(0.1, 0.0, 1.225, 0.00782, 0.033, 0.87, &coeffs);
658 assert_eq!(moment_zero_vel, 0.0);
659 }
660
661 #[test]
662 fn test_default_implementation() {
663 let coeffs1 = PitchDampingCoefficients::default();
664 let coeffs2 = PitchDampingCoefficients::from_bullet_type("unknown");
665
666 assert_eq!(coeffs1.subsonic, coeffs2.subsonic);
667 assert_eq!(coeffs1.transonic_low, coeffs2.transonic_low);
668 assert_eq!(coeffs1.transonic_high, coeffs2.transonic_high);
669 assert_eq!(coeffs1.supersonic, coeffs2.supersonic);
670 }
671
672 #[test]
673 fn test_transonic_jump() {
674 let _coeffs = PitchDampingCoefficients::from_bullet_type("hunting");
675
676 let (yaw_subsonic, _) = calculate_damped_yaw_of_repose(
678 2.5, 250.0, 19000.0, 10.0, 0.01, 1.225, 0.308, 1.3, 175.0, 0.7, "hunting",
679 );
680
681 let (yaw_transonic, _) = calculate_damped_yaw_of_repose(
682 2.5, 343.0, 19000.0, 10.0, 0.01, 1.225, 0.308, 1.3, 175.0, 1.0, "hunting",
683 );
684
685 assert!(yaw_subsonic > 0.0);
687 assert!(yaw_transonic > 0.0);
688 }
689}