1use crate::gravity::Gravity;
2use crate::pitch_damping::{
3 calculate_damped_yaw_of_repose_with_gravity, calculate_gravity_yaw_of_repose_with_gravity,
4 calculate_pitch_damping_moment, PitchDampingCoefficients,
5};
6use crate::spin_decay::{update_spin_rate, SpinDecayParameters};
7use crate::BallisticInputs;
8use std::f64::consts::PI;
9
10const LITZ_TIME_EXPONENT: f64 = 1.83;
11
12#[derive(Debug, Clone)]
14pub struct SpinDriftComponents {
15 pub spin_rate_rps: f64, pub spin_rate_rad_s: f64, pub stability_factor: f64, pub yaw_of_repose_rad: f64, pub drift_rate_mps: f64, pub total_drift_m: f64, pub magnus_component_m: f64, pub gyroscopic_component_m: f64, pub pitch_damping_moment: f64, pub yaw_convergence_rate: f64, pub pitch_rate_rad_s: f64, }
27
28pub(crate) fn miller_stability(
33 caliber_in: f64,
34 weight_gr: f64,
35 twist_in: f64,
36 length_in: f64,
37) -> f64 {
38 if caliber_in <= 0.0 || weight_gr <= 0.0 || twist_in <= 0.0 || length_in <= 0.0 {
39 return 0.0;
40 }
41 let twist_cal = twist_in / caliber_in;
42 let l_cal = length_in / caliber_in;
43 let denom = twist_cal * twist_cal * caliber_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
44 if denom == 0.0 {
45 return 0.0;
46 }
47 30.0 * weight_gr / denom
48}
49
50pub fn litz_drift_inches(sg: f64, t_s: f64) -> f64 {
59 1.25 * (sg + 1.2) * t_s.powf(LITZ_TIME_EXPONENT)
60}
61
62pub fn litz_drift_meters(sg: f64, t_s: f64, is_twist_right: bool) -> f64 {
65 let sign = if is_twist_right { 1.0 } else { -1.0 };
66 sign * litz_drift_inches(sg, t_s) * 0.0254
67}
68
69pub fn effective_sg_from_inputs(inputs: &BallisticInputs, temp_c: f64, press_hpa: f64) -> f64 {
82 let mut eff = inputs.clone();
83 if eff.bullet_length <= 0.0 && eff.bullet_diameter > 0.0 {
84 let est = crate::stability::estimate_bullet_length_m(eff.bullet_diameter, eff.bullet_mass);
85 eff.bullet_length = if est > 0.0 {
86 est
87 } else {
88 4.5 * eff.bullet_diameter };
90 }
91 crate::stability::compute_stability_coefficient(&eff, (eff.altitude, temp_c, press_hpa, 0.0))
94}
95
96pub fn calculate_spin_rate(velocity_mps: f64, twist_rate_inches: f64) -> (f64, f64) {
98 if twist_rate_inches <= 0.0 {
99 return (0.0, 0.0);
100 }
101
102 let velocity_ips = velocity_mps * 39.3701;
104
105 let spin_rate_rps = velocity_ips / twist_rate_inches;
107
108 let spin_rate_rad_s = spin_rate_rps * 2.0 * PI;
110
111 (spin_rate_rps, spin_rate_rad_s)
112}
113
114pub(crate) fn calculate_magnus_spin_state(
120 muzzle_velocity_mps: f64,
121 current_velocity_mps: f64,
122 twist_rate_inches: f64,
123 caliber_m: f64,
124) -> (f64, f64) {
125 let (_, spin_rate_rad_s) = calculate_spin_rate(muzzle_velocity_mps, twist_rate_inches);
126 let spin_parameter = if current_velocity_mps > 1e-9 {
127 spin_rate_rad_s * caliber_m / (2.0 * current_velocity_mps)
128 } else {
129 0.0
130 };
131 (spin_rate_rad_s, spin_parameter)
132}
133
134pub fn calculate_dynamic_stability(
140 bullet_mass_grains: f64,
141 velocity_mps: f64,
142 spin_rate_rad_s: f64,
143 caliber_inches: f64,
144 length_inches: f64,
145 air_density_kg_m3: f64,
146) -> f64 {
147 if spin_rate_rad_s == 0.0 || velocity_mps == 0.0 || caliber_inches <= 0.0 {
148 return 0.0;
149 }
150
151 let velocity_fps = velocity_mps * 3.28084;
153
154 let spin_rps = spin_rate_rad_s / (2.0 * PI);
156 if spin_rps <= 0.0 {
157 return 0.0;
158 }
159 let velocity_ips = velocity_fps * 12.0; let twist_inches = velocity_ips / spin_rps;
161 let sg_base = miller_stability(
162 caliber_inches,
163 bullet_mass_grains,
164 twist_inches,
165 length_inches,
166 );
167 if sg_base == 0.0 {
168 return 0.0;
169 }
170
171 let velocity_factor = (velocity_fps / 2800.0).powf(1.0 / 3.0);
173
174 let density_factor = 1.225 / air_density_kg_m3;
179
180 sg_base * velocity_factor * density_factor
181}
182
183#[allow(clippy::too_many_arguments)] pub fn calculate_yaw_of_repose(
186 stability_factor: f64,
187 velocity_mps: f64,
188 spin_rate_rad_s: f64,
189 wind_velocity_mps: f64,
190 pitch_rate_rad_s: f64,
191 air_density_kg_m3: f64,
192 caliber_inches: f64,
193 length_inches: f64,
194 mass_grains: f64,
195 mach: f64,
196 bullet_type: &str,
197 use_pitch_damping: bool,
198) -> (f64, f64) {
199 calculate_yaw_of_repose_with_gravity(
200 stability_factor,
201 velocity_mps,
202 spin_rate_rad_s,
203 wind_velocity_mps,
204 pitch_rate_rad_s,
205 air_density_kg_m3,
206 caliber_inches,
207 length_inches,
208 mass_grains,
209 mach,
210 bullet_type,
211 use_pitch_damping,
212 Gravity::EARTH,
213 )
214}
215
216#[allow(clippy::too_many_arguments)]
217pub(crate) fn calculate_yaw_of_repose_with_gravity(
218 stability_factor: f64,
219 velocity_mps: f64,
220 spin_rate_rad_s: f64,
221 wind_velocity_mps: f64,
222 pitch_rate_rad_s: f64,
223 air_density_kg_m3: f64,
224 caliber_inches: f64,
225 length_inches: f64,
226 mass_grains: f64,
227 mach: f64,
228 bullet_type: &str,
229 use_pitch_damping: bool,
230 gravity: Gravity,
231) -> (f64, f64) {
232 if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
233 return (0.0, 0.0);
234 }
235
236 if use_pitch_damping && mach > 0.0 {
238 let damping_type = match bullet_type.to_lowercase().as_str() {
240 "match" => "match_boat_tail",
241 "hunting" => "hunting",
242 "fmj" => "fmj",
243 "vld" => "vld",
244 _ => "match_boat_tail",
245 };
246
247 return calculate_damped_yaw_of_repose_with_gravity(
248 stability_factor,
249 velocity_mps,
250 spin_rate_rad_s,
251 wind_velocity_mps,
252 pitch_rate_rad_s,
253 air_density_kg_m3,
254 caliber_inches,
255 length_inches,
256 mass_grains,
257 mach,
258 damping_type,
259 gravity,
260 );
261 }
262
263 let yaw_rad = calculate_gravity_yaw_of_repose_with_gravity(
267 stability_factor,
268 velocity_mps,
269 spin_rate_rad_s,
270 mass_grains * crate::constants::GRAINS_TO_KG,
271 caliber_inches * 0.0254,
272 length_inches * 0.0254,
273 gravity,
274 );
275
276 (yaw_rad, 0.0)
277}
278
279pub fn calculate_magnus_drift_component(
281 velocity_mps: f64,
282 spin_rate_rad_s: f64,
283 yaw_rad: f64,
284 air_density_kg_m3: f64,
285 caliber_inches: f64,
286 time_s: f64,
287 mass_grains: f64,
288) -> f64 {
289 let diameter_m = caliber_inches * 0.0254;
290 let mass_kg = mass_grains * crate::constants::GRAINS_TO_KG; let mach = velocity_mps / 343.0; let cmag = if mach < 0.8 {
297 0.25
298 } else if mach < 1.2 {
299 0.15
301 } else {
302 0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
304 };
305
306 let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
308
309 let magnus_force = if velocity_mps > 0.0 {
311 cmag * spin_ratio
312 * yaw_rad
313 * 0.5
314 * air_density_kg_m3
315 * velocity_mps.powi(2)
316 * PI
317 * (diameter_m / 2.0).powi(2)
318 } else {
319 0.0
320 };
321
322 let magnus_accel = magnus_force / mass_kg;
324
325 0.5 * magnus_accel * time_s.powi(2)
328}
329
330pub fn calculate_gyroscopic_drift(
336 stability_factor: f64,
337 _yaw_rad: f64,
338 _velocity_mps: f64,
339 time_s: f64,
340 is_right_twist: bool,
341) -> f64 {
342 if stability_factor <= 1.0 || time_s <= 0.0 {
343 return 0.0;
344 }
345
346 litz_drift_meters(stability_factor, time_s, is_right_twist)
347}
348
349#[allow(clippy::too_many_arguments)] pub fn calculate_enhanced_spin_drift(
358 bullet_mass: f64,
359 velocity_mps: f64,
360 twist_rate: f64,
361 bullet_diameter: f64,
362 bullet_length: f64,
363 is_twist_right: bool,
364 time_s: f64,
365 air_density: f64,
366 crosswind_mps: f64,
367 pitch_rate_rad_s: f64,
368 use_pitch_damping: bool,
369) -> SpinDriftComponents {
370 let muzzle_velocity = velocity_mps; let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
373
374 let decay_params = SpinDecayParameters::from_bullet_type("match"); let current_spin_rad_s = update_spin_rate(
377 initial_spin_rad_s,
378 time_s,
379 velocity_mps,
380 air_density,
381 bullet_mass, bullet_diameter,
383 bullet_length,
384 Some(&decay_params),
385 );
386
387 let spin_rps = current_spin_rad_s / (2.0 * PI);
388 let spin_rad_s = current_spin_rad_s;
389
390 let stability = calculate_dynamic_stability(
392 bullet_mass,
393 velocity_mps,
394 spin_rad_s,
395 bullet_diameter,
396 bullet_length,
397 air_density,
398 );
399
400 let mach = velocity_mps / 343.0; let bullet_type = "match";
405
406 let (yaw_rad, convergence_rate) = calculate_yaw_of_repose(
408 stability,
409 velocity_mps,
410 spin_rad_s,
411 crosswind_mps,
412 pitch_rate_rad_s,
413 air_density,
414 bullet_diameter,
415 bullet_length,
416 bullet_mass,
417 mach,
418 bullet_type,
419 use_pitch_damping,
420 );
421
422 let magnus_drift = calculate_magnus_drift_component(
424 velocity_mps,
425 spin_rad_s,
426 yaw_rad,
427 air_density,
428 bullet_diameter,
429 time_s,
430 bullet_mass,
431 );
432
433 let gyro_drift =
435 calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
436
437 let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
442 let total_drift = twist_sign * magnus_drift + gyro_drift;
443
444 let drift_rate = if time_s > 0.0 {
446 total_drift / time_s
447 } else {
448 0.0
449 };
450
451 let pitch_damping_moment = if use_pitch_damping && mach > 0.0 {
453 let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
454 calculate_pitch_damping_moment(
455 pitch_rate_rad_s,
456 velocity_mps,
457 air_density,
458 bullet_diameter * 0.0254, bullet_length * 0.0254, mach,
461 &coeffs,
462 )
463 } else {
464 0.0
465 };
466
467 SpinDriftComponents {
468 spin_rate_rps: spin_rps,
469 spin_rate_rad_s: spin_rad_s,
470 stability_factor: stability,
471 yaw_of_repose_rad: yaw_rad,
472 drift_rate_mps: drift_rate,
473 total_drift_m: total_drift,
474 magnus_component_m: magnus_drift,
475 gyroscopic_component_m: gyro_drift,
476 pitch_damping_moment,
477 yaw_convergence_rate: convergence_rate,
478 pitch_rate_rad_s,
479 }
480}
481
482pub fn apply_enhanced_spin_drift(
488 derivatives: &mut [f64; 6],
489 spin_components: &SpinDriftComponents,
490 time_s: f64,
491 _is_right_twist: bool,
492) {
493 if time_s > 0.1 {
494 let gyroscopic_factor = LITZ_TIME_EXPONENT * (LITZ_TIME_EXPONENT - 1.0);
498 let spin_accel_z = gyroscopic_factor * spin_components.drift_rate_mps / time_s;
499
500 derivatives[5] += spin_accel_z;
505 }
506}
507
508pub fn compute_enhanced_spin_drift_simple(
510 time_s: f64,
511 stability: f64,
512 velocity_mps: f64,
513 twist_rate: f64,
514 is_twist_right: bool,
515 _caliber: f64,
516) -> f64 {
517 if twist_rate <= 0.0 {
518 return 0.0;
519 }
520
521 let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
523
524 let decay_params = SpinDecayParameters::from_bullet_type("match");
526 let spin_rad_s = update_spin_rate(
527 initial_spin_rad_s,
528 time_s,
529 velocity_mps,
530 1.225, 175.0, _caliber,
533 1.3, Some(&decay_params),
535 );
536
537 let (yaw_rad, _) = calculate_yaw_of_repose(
539 stability,
540 velocity_mps,
541 spin_rad_s,
542 0.0,
543 0.0,
544 1.225,
545 _caliber,
546 1.3,
547 175.0,
548 velocity_mps / 343.0,
549 "match",
550 false,
551 );
552
553 calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right)
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn test_calculate_spin_rate() {
564 let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
566
567 assert!((rps - 3149.6).abs() < 1.0);
569 assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
570
571 let (rps_zero, rad_s_zero) = calculate_spin_rate(800.0, 0.0);
573 assert_eq!(rps_zero, 0.0);
574 assert_eq!(rad_s_zero, 0.0);
575 }
576
577 #[test]
578 fn magnus_spin_is_set_at_muzzle_while_spin_parameter_grows_downrange() {
579 let muzzle_velocity = 800.0;
580 let (muzzle_spin, muzzle_parameter) =
581 calculate_magnus_spin_state(muzzle_velocity, muzzle_velocity, 10.0, 0.00782);
582 let (downrange_spin, downrange_parameter) =
583 calculate_magnus_spin_state(muzzle_velocity, muzzle_velocity / 2.0, 10.0, 0.00782);
584
585 assert_eq!(downrange_spin.to_bits(), muzzle_spin.to_bits());
586 assert!((downrange_parameter / muzzle_parameter - 2.0).abs() < 1e-12);
587 }
588
589 #[test]
590 fn test_calculate_dynamic_stability() {
591 let sg = calculate_dynamic_stability(
592 168.0, 800.0, 19792.0, 0.308, 1.2, 1.225, );
599
600 assert!(sg > 1.0);
602 assert!(sg < 10.0); }
604
605 #[test]
606 fn test_calculate_yaw_of_repose() {
607 let (yaw, _) = calculate_yaw_of_repose(
608 2.5, 800.0, 19792.0, 10.0, 0.0, 1.225, 0.308, 1.2, 168.0, 2.33, "match", false, );
621
622 assert!(yaw.abs() > 0.0);
624 assert!(yaw.abs() < 0.1); }
626
627 #[test]
628 fn yaw_of_repose_increases_with_physical_spin_stability() {
629 let calculate = |stability_factor, spin_rate_rad_s, use_pitch_damping| {
630 calculate_yaw_of_repose(
631 stability_factor,
632 300.0,
633 spin_rate_rad_s,
634 0.0,
635 0.01,
636 1.225,
637 0.308,
638 1.3,
639 175.0,
640 300.0 / 343.0,
641 "match",
642 use_pitch_damping,
643 )
644 .0
645 };
646 let low_stability: f64 = 1.1;
647 let high_stability: f64 = 4.0;
648 let low_spin = 19_000.0;
649 let high_spin = low_spin * (high_stability / low_stability).sqrt();
650 let expected_ratio = (high_stability / low_stability).sqrt();
651
652 for use_pitch_damping in [false, true] {
653 let low = calculate(low_stability, low_spin, use_pitch_damping);
654 let high = calculate(high_stability, high_spin, use_pitch_damping);
655
656 assert!(high > low, "yaw decreased as physical spin/Sg increased");
657 assert!(
658 (high / low - expected_ratio).abs() <= expected_ratio * 1e-12,
659 "yaw stability ratio was {}, expected {expected_ratio}",
660 high / low
661 );
662 }
663 }
664
665 #[test]
666 fn yaw_of_repose_has_inverse_cube_velocity_scaling() {
667 let calculate = |stability_factor, velocity_mps, use_pitch_damping| {
668 calculate_yaw_of_repose(
669 stability_factor,
670 velocity_mps,
671 19_000.0,
672 0.0,
673 0.01,
674 1.225,
675 0.308,
676 1.3,
677 175.0,
678 velocity_mps / 343.0,
679 "match",
680 use_pitch_damping,
681 )
682 .0
683 };
684
685 for use_pitch_damping in [false, true] {
688 let fast = calculate(1.5, 800.0, use_pitch_damping);
689 let slow = calculate(6.0, 400.0, use_pitch_damping);
690
691 assert!((slow / fast - 8.0).abs() <= 8e-12);
692 }
693 }
694
695 #[test]
696 fn crosswind_does_not_change_yaw_of_repose_in_either_model() {
697 let calculate = |wind_velocity_mps, use_pitch_damping| {
698 calculate_yaw_of_repose(
699 2.5,
700 300.0,
701 19_000.0,
702 wind_velocity_mps,
703 0.01,
704 1.225,
705 0.308,
706 1.3,
707 175.0,
708 0.875,
709 "match",
710 use_pitch_damping,
711 )
712 .0
713 };
714
715 let simple_calm = calculate(0.0, false);
716 let simple_windy = calculate(10.0, false);
717 let damped_calm = calculate(0.0, true);
718 let damped_windy = calculate(10.0, true);
719
720 assert_eq!(simple_windy.to_bits(), simple_calm.to_bits());
721 assert_eq!(damped_windy.to_bits(), damped_calm.to_bits());
722 assert_eq!(damped_calm.to_bits(), simple_calm.to_bits());
723 assert!(simple_calm > 0.0 && simple_calm < 0.003);
724 }
725
726 #[test]
727 fn test_enhanced_spin_drift_calculation() {
728 let components = calculate_enhanced_spin_drift(
729 168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 10.0, 0.0, false, );
741
742 assert!(components.total_drift_m.abs() > 0.0);
744 assert!(components.spin_rate_rps > 0.0);
745 assert!(components.stability_factor > 0.0);
746 }
747
748 #[test]
749 fn test_litz_drift_helpers_sign_and_magnitude() {
750 let sg = 2.0_f64;
752 let t = 1.5_f64;
753 let expected_in = 1.25 * (sg + 1.2) * t.powf(1.83);
754 assert!((litz_drift_inches(sg, t) - expected_in).abs() < 1e-12);
755 let right = litz_drift_meters(sg, t, true);
757 let left = litz_drift_meters(sg, t, false);
758 assert!((right - expected_in * 0.0254).abs() < 1e-12);
759 assert!((right + left).abs() < 1e-12, "left twist must mirror right");
760 assert!(right > 0.0 && left < 0.0);
761 }
762
763 #[test]
764 fn gyroscopic_drift_is_continuous_at_legacy_velocity_threshold() {
765 let stability_factor = 2.0;
766 let time_s = 2.0;
767 let threshold_mps = 1125.0 / 3.28084;
768
769 for is_right_twist in [true, false] {
770 let expected = litz_drift_meters(stability_factor, time_s, is_right_twist);
771 for velocity_mps in [threshold_mps - 1e-6, threshold_mps, threshold_mps + 1e-6] {
772 let actual = calculate_gyroscopic_drift(
773 stability_factor,
774 0.0,
775 velocity_mps,
776 time_s,
777 is_right_twist,
778 );
779 assert_eq!(actual.to_bits(), expected.to_bits());
780 }
781 }
782 }
783
784 #[test]
785 fn gyroscopic_drift_preserves_accumulation_below_velocity_threshold() {
786 let stability_factor = 2.0;
787 let threshold_mps = 1125.0 / 3.28084;
788 let before =
789 calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps + 1e-6, 1.5, true);
790 let after =
791 calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps - 1e-6, 2.0, true);
792 let left_after =
793 calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps - 1e-6, 2.0, false);
794
795 assert_eq!(
796 before.to_bits(),
797 litz_drift_meters(stability_factor, 1.5, true).to_bits()
798 );
799 assert_eq!(
800 after.to_bits(),
801 litz_drift_meters(stability_factor, 2.0, true).to_bits()
802 );
803 assert!(after > before, "accumulated drift must not disappear");
804 assert_eq!(left_after.to_bits(), (-after).to_bits());
805 }
806
807 #[test]
808 fn test_effective_sg_from_inputs_includes_velocity_term_and_length_fallback() {
809 let inputs = BallisticInputs {
813 muzzle_velocity: 800.0, bullet_mass: 175.0 * crate::constants::GRAINS_TO_KG,
815 bullet_diameter: 0.308 * 0.0254,
816 bullet_length: 1.24 * 0.0254,
817 twist_rate: 10.0,
818 ..Default::default()
819 };
820
821 let temp_c = 15.0;
822 let press_hpa = 1013.25;
823 let sg = effective_sg_from_inputs(&inputs, temp_c, press_hpa);
824
825 let direct =
827 crate::stability::compute_stability_coefficient(&inputs, (0.0, temp_c, press_hpa, 0.0));
828 assert!((sg - direct).abs() < 1e-12, "sg {sg} != direct {direct}");
829
830 let d_in = inputs.bullet_diameter / 0.0254;
833 let m_gr = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
834 let l_in = inputs.bullet_length / 0.0254;
835 let bare = miller_stability(d_in, m_gr, inputs.twist_rate, l_in);
836 let vel_corr = (inputs.muzzle_velocity * 3.28084 / 2800.0).powf(1.0 / 3.0);
837 assert!(vel_corr < 1.0, "muzzle < 2800 fps should shrink Sg");
838 assert!(
839 (sg - bare * vel_corr).abs() < 1e-6,
840 "sg {sg} != bare {bare} * vel_corr {vel_corr}"
841 );
842
843 let mut no_len = inputs.clone();
847 no_len.bullet_length = 0.0;
848 let sg_fallback = effective_sg_from_inputs(&no_len, temp_c, press_hpa);
849 let mut explicit = inputs.clone();
850 explicit.bullet_length =
851 crate::stability::estimate_bullet_length_m(inputs.bullet_diameter, inputs.bullet_mass);
852 let sg_explicit = effective_sg_from_inputs(&explicit, temp_c, press_hpa);
853 assert!(
854 (sg_fallback - sg_explicit).abs() < 1e-12,
855 "zero-length fallback {sg_fallback} != explicit estimate-length {sg_explicit}"
856 );
857 assert!(sg_fallback > 0.0);
858 let mut old_default = inputs.clone();
860 old_default.bullet_length = 4.5 * old_default.bullet_diameter;
861 let sg_old = effective_sg_from_inputs(&old_default, temp_c, press_hpa);
862 assert!(
863 (sg_fallback - sg_old).abs() > 1e-6,
864 "mass-based fallback should differ from the old 4.5-cal default"
865 );
866 }
867
868 #[test]
869 fn test_effective_sg_preserves_short_handgun_length_estimate() {
870 let inputs = BallisticInputs {
871 muzzle_velocity: 1150.0 * 0.3048,
872 bullet_mass: 115.0 * crate::constants::GRAINS_TO_KG,
873 bullet_diameter: 0.355 * 0.0254,
874 bullet_length: 0.0,
875 twist_rate: 10.0,
876 ..Default::default()
877 };
878
879 let sg = effective_sg_from_inputs(&inputs, 15.0, 1013.25);
880 assert!(
881 (10.0..12.0).contains(&sg),
882 "expected 9 mm / 115 gr Sg near 10.9 with the modeled length, got {sg}"
883 );
884 }
885
886 #[test]
887 fn test_miller_stability_308_168gr() {
888 let sg = miller_stability(0.308, 168.0, 12.0, 1.215);
892 assert!(sg > 1.5 && sg < 2.0, "expected base Sg ~1.74, got {}", sg);
893 }
894
895 #[test]
896 fn test_miller_stability_invalid_inputs_zero() {
897 assert_eq!(miller_stability(0.0, 168.0, 12.0, 1.2), 0.0);
898 assert_eq!(miller_stability(0.308, 0.0, 12.0, 1.2), 0.0);
899 assert_eq!(miller_stability(0.308, 168.0, 0.0, 1.2), 0.0);
900 assert_eq!(miller_stability(0.308, 168.0, 12.0, 0.0), 0.0);
901 }
902
903 #[test]
904 fn test_opposite_twist_directions() {
905 let right_drift = calculate_enhanced_spin_drift(
907 168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 0.0, 0.0, false,
908 );
909
910 let left_drift = calculate_enhanced_spin_drift(
912 168.0, 800.0, 10.0, 0.308, 1.2, false, 1.0, 1.225, 0.0, 0.0, false,
913 );
914
915 assert!(right_drift.gyroscopic_component_m * left_drift.gyroscopic_component_m < 0.0);
917 assert!(
918 (right_drift.gyroscopic_component_m.abs() - left_drift.gyroscopic_component_m.abs())
919 .abs()
920 < 0.001
921 );
922 }
923
924 #[test]
925 fn test_applied_spin_drift_flips_with_twist() {
926 let time_s = 1.0;
932 let right = calculate_enhanced_spin_drift(
933 168.0, 800.0, 10.0, 0.308, 1.2, true, time_s, 1.225, 0.0, 0.0, false,
934 );
935 let left = calculate_enhanced_spin_drift(
936 168.0, 800.0, 10.0, 0.308, 1.2, false, time_s, 1.225, 0.0, 0.0, false,
937 );
938
939 let mut d_right = [0.0_f64; 6];
940 let mut d_left = [0.0_f64; 6];
941 apply_enhanced_spin_drift(&mut d_right, &right, time_s, true);
942 apply_enhanced_spin_drift(&mut d_left, &left, time_s, false);
943
944 assert!(d_right[5].abs() > 0.0, "expected non-zero spin drift accel");
945 assert!(d_left[5].abs() > 0.0, "expected non-zero spin drift accel");
946 assert!(
947 d_right[5] * d_left[5] < 0.0,
948 "expected opposite-sign lateral accel for opposite twist, got {} and {}",
949 d_right[5],
950 d_left[5]
951 );
952 }
953
954 #[test]
955 fn applied_spin_drift_uses_litz_power_law_acceleration() {
956 let time_s: f64 = 2.0;
957 let average_drift_rate_mps = 0.5;
958 let expected_magnitude = 1.83 * 0.83 * average_drift_rate_mps / time_s;
959
960 for (is_right_twist, sign) in [(true, 1.0), (false, -1.0)] {
961 let components = SpinDriftComponents {
962 spin_rate_rps: 0.0,
963 spin_rate_rad_s: 0.0,
964 stability_factor: 0.0,
965 yaw_of_repose_rad: 0.0,
966 drift_rate_mps: sign * average_drift_rate_mps,
967 total_drift_m: 0.0,
968 magnus_component_m: 0.0,
969 gyroscopic_component_m: 0.0,
970 pitch_damping_moment: 0.0,
971 yaw_convergence_rate: 0.0,
972 pitch_rate_rad_s: 0.0,
973 };
974 let mut derivatives = [0.0; 6];
975
976 apply_enhanced_spin_drift(&mut derivatives, &components, time_s, is_right_twist);
977
978 assert!(
979 (derivatives[5] - sign * expected_magnitude).abs() < 1e-12,
980 "wrong Litz acceleration for sign {sign}: {}",
981 derivatives[5]
982 );
983 }
984 }
985}