1#![allow(dead_code)]
9
10#[derive(Debug, Clone)]
12pub struct StabilityParameters {
13 pub nose_shape_factor: f64,
15 pub boat_tail_factor: f64,
17 pub plastic_tip_factor: f64,
20 pub cop_adjustment: f64,
22}
23
24impl StabilityParameters {
25 pub fn for_bullet_type(bullet_type: &str, has_boat_tail: bool, _has_plastic_tip: bool) -> Self {
26 match bullet_type.to_lowercase().as_str() {
27 "match" | "bthp" => Self {
28 nose_shape_factor: 0.95,
29 boat_tail_factor: if has_boat_tail { 0.94 } else { 1.0 },
30 plastic_tip_factor: 1.0,
31 cop_adjustment: 0.98,
32 },
33 "vld" | "very_low_drag" => Self {
34 nose_shape_factor: 0.88,
35 boat_tail_factor: if has_boat_tail { 0.92 } else { 1.0 },
36 plastic_tip_factor: 1.0,
37 cop_adjustment: 0.96,
38 },
39 "hybrid" => Self {
40 nose_shape_factor: 0.91,
41 boat_tail_factor: if has_boat_tail { 0.93 } else { 1.0 },
42 plastic_tip_factor: 1.0,
43 cop_adjustment: 0.97,
44 },
45 "hunting" => Self {
46 nose_shape_factor: 0.98,
47 boat_tail_factor: if has_boat_tail { 0.95 } else { 1.0 },
48 plastic_tip_factor: 1.0,
49 cop_adjustment: 0.99,
50 },
51 _ => Self::default(),
52 }
53 }
54
55 #[allow(clippy::should_implement_trait)] pub fn default() -> Self {
61 <Self as Default>::default()
62 }
63}
64
65impl Default for StabilityParameters {
66 fn default() -> Self {
67 Self {
68 nose_shape_factor: 1.0,
69 boat_tail_factor: 1.0,
70 plastic_tip_factor: 1.0,
71 cop_adjustment: 1.0,
72 }
73 }
74}
75
76#[allow(clippy::too_many_arguments)] pub fn calculate_advanced_stability(
87 mass_grains: f64,
88 velocity_fps: f64,
89 twist_rate_inches: f64,
90 caliber_inches: f64,
91 length_inches: f64,
92 air_density_kg_m3: f64,
93 temperature_k: f64,
94 bullet_type: &str,
95 has_boat_tail: bool,
96 has_plastic_tip: bool,
97) -> f64 {
98 if twist_rate_inches == 0.0 || caliber_inches == 0.0 || length_inches == 0.0 {
99 return 0.0;
100 }
101
102 let params = StabilityParameters::for_bullet_type(bullet_type, has_boat_tail, has_plastic_tip);
103
104 let sg_base = calculate_miller_refined(
106 mass_grains,
107 twist_rate_inches,
108 caliber_inches,
109 length_inches,
110 params.nose_shape_factor,
111 );
112
113 let sg_velocity_corrected = apply_velocity_correction(sg_base, velocity_fps);
115
116 let sg_atmosphere_corrected =
118 apply_atmospheric_correction(sg_velocity_corrected, air_density_kg_m3, temperature_k);
119
120 let sg_boat_tail = sg_atmosphere_corrected * params.boat_tail_factor;
122
123 sg_boat_tail * params.cop_adjustment
125}
126
127pub fn apply_courtney_miller_plastic_tip_correction(
140 uncorrected_sg: f64,
141 caliber_inches: f64,
142 total_length_inches: f64,
143 tip_length_inches: f64,
144) -> f64 {
145 if !caliber_inches.is_finite()
146 || !total_length_inches.is_finite()
147 || !tip_length_inches.is_finite()
148 || caliber_inches <= 0.0
149 || total_length_inches <= 0.0
150 || tip_length_inches <= 0.0
151 || tip_length_inches >= total_length_inches
152 {
153 return uncorrected_sg;
154 }
155
156 let total_length_calibers = total_length_inches / caliber_inches;
157 let metal_length_calibers = (total_length_inches - tip_length_inches) / caliber_inches;
158 let correction = (1.0 + total_length_calibers.powi(2)) / (1.0 + metal_length_calibers.powi(2));
159
160 uncorrected_sg * correction
161}
162
163fn calculate_miller_refined(
165 mass_grains: f64,
166 twist_rate_inches: f64,
167 caliber_inches: f64,
168 length_inches: f64,
169 nose_shape_factor: f64,
170) -> f64 {
171 let twist_calibers = twist_rate_inches / caliber_inches;
173 let length_calibers = length_inches / caliber_inches;
174
175 const MILLER_CONSTANT: f64 = 30.0;
177
178 let inertia_factor = 1.0 + length_calibers.powi(2);
181
182 let numerator = MILLER_CONSTANT * mass_grains * nose_shape_factor;
184 let denominator =
185 twist_calibers.powi(2) * caliber_inches.powi(3) * length_calibers * inertia_factor;
186
187 if denominator == 0.0 {
188 return 0.0;
189 }
190
191 numerator / denominator
192}
193
194fn apply_velocity_correction(sg_base: f64, velocity_fps: f64) -> f64 {
196 const VELOCITY_REFERENCE: f64 = 2800.0;
197
198 let velocity_factor = (velocity_fps / VELOCITY_REFERENCE).powf(1.0 / 3.0);
199 sg_base * velocity_factor
200}
201
202fn apply_atmospheric_correction(sg: f64, air_density_kg_m3: f64, _temperature_k: f64) -> f64 {
204 const STD_DENSITY: f64 = 1.225; if !air_density_kg_m3.is_finite() || air_density_kg_m3 <= 0.0 {
211 return 0.0;
212 }
213
214 sg * (STD_DENSITY / air_density_kg_m3)
215}
216
217#[deprecated(
227 since = "0.22.18",
228 note = "does not compute aerodynamic dynamic stability; retained as a neutral static-stability pass-through"
229)]
230pub fn calculate_dynamic_stability(
231 static_stability: f64,
232 _velocity_mps: f64,
233 _spin_rate_rad_s: f64,
234 _yaw_angle_rad: f64,
235 _caliber_m: f64,
236 _mass_kg: f64,
237) -> f64 {
238 static_stability
239}
240
241pub fn predict_stability_at_distance(
246 initial_stability: f64,
247 initial_velocity_fps: f64,
248 current_velocity_fps: f64,
249 spin_decay_factor: f64,
250) -> f64 {
251 if initial_velocity_fps == 0.0 || current_velocity_fps == 0.0 {
252 return initial_stability;
253 }
254
255 let velocity_ratio = current_velocity_fps / initial_velocity_fps;
257
258 let stability_ratio = (spin_decay_factor / velocity_ratio).powi(2);
263
264 initial_stability * stability_ratio
265}
266
267pub fn check_trajectory_stability(
269 muzzle_stability: f64,
270 muzzle_velocity_fps: f64,
271 terminal_velocity_fps: f64,
272 spin_decay_factor: f64,
273) -> (bool, f64, String) {
274 let terminal_stability = predict_stability_at_distance(
275 muzzle_stability,
276 muzzle_velocity_fps,
277 terminal_velocity_fps,
278 spin_decay_factor,
279 );
280
281 let is_stable = terminal_stability >= 1.3; let status = if terminal_stability < 1.0 {
284 "UNSTABLE - Bullet will tumble".to_string()
285 } else if terminal_stability < 1.3 {
286 "MARGINAL - May experience accuracy issues".to_string()
287 } else if terminal_stability < 1.5 {
288 "ADEQUATE - Acceptable for most conditions".to_string()
289 } else if terminal_stability < 2.5 {
290 "GOOD - Optimal stability".to_string()
291 } else {
292 "OVER-STABILIZED - May reduce BC slightly".to_string()
293 };
294
295 (is_stable, terminal_stability, status)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_advanced_stability() {
304 let stability = calculate_advanced_stability(
306 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false, );
317
318 println!("Calculated stability: {}", stability);
319
320 assert!(stability > 1.3);
322 assert!(
323 stability < 2.5,
324 "Stability {} exceeds upper bound",
325 stability
326 );
327 }
328
329 #[test]
330 fn test_stability_prediction() {
331 let (is_stable, terminal_sg, status) = check_trajectory_stability(
332 2.2, 2700.0, 1900.0, 0.98, );
337
338 println!(
339 "is_stable: {}, terminal_sg: {}, status: {}",
340 is_stable, terminal_sg, status
341 );
342
343 assert!(
344 is_stable,
345 "Expected stable trajectory but got: is_stable={}, terminal_sg={}, status={}",
346 is_stable, terminal_sg, status
347 );
348 assert!(
349 terminal_sg > 2.2,
350 "SG must grow as velocity decays faster than spin: {terminal_sg}"
351 );
352 assert!(status.contains("OVER-STABILIZED"));
353 }
354
355 #[test]
356 fn test_stability_parameters_bullet_types() {
357 let match_params = StabilityParameters::for_bullet_type("match", true, false);
358 let vld_params = StabilityParameters::for_bullet_type("vld", true, false);
359 let hunting_params = StabilityParameters::for_bullet_type("hunting", true, true);
360 let default_params = StabilityParameters::for_bullet_type("unknown", false, false);
361
362 assert!(vld_params.nose_shape_factor < match_params.nose_shape_factor);
364
365 assert_eq!(hunting_params.plastic_tip_factor, 1.0);
368
369 assert_eq!(default_params.nose_shape_factor, 1.0);
371 assert_eq!(default_params.boat_tail_factor, 1.0);
372 }
373
374 #[test]
375 fn plastic_tip_flag_never_reduces_advanced_stability() {
376 let calculate = |has_plastic_tip| {
377 calculate_advanced_stability(
378 178.0,
379 2800.0,
380 10.0,
381 0.308,
382 1.420,
383 1.225,
384 288.15,
385 "hunting",
386 false,
387 has_plastic_tip,
388 )
389 };
390
391 let untipped = calculate(false);
392 let tipped = calculate(true);
393 assert_eq!(
394 tipped.to_bits(),
395 untipped.to_bits(),
396 "a Boolean-only plastic-tip flag must not apply an invented correction"
397 );
398 }
399
400 #[test]
401 fn courtney_miller_correction_uses_metal_length_only_in_inertia_term() {
402 let caliber_inches = 0.224_f64;
404 let total_length_inches = 0.868_f64;
405 let tip_length_inches = total_length_inches - 0.738;
406 let uncorrected_sg = 1.0_f64;
407
408 let total_length_calibers = total_length_inches / caliber_inches;
409 let metal_length_calibers = (total_length_inches - tip_length_inches) / caliber_inches;
410 let expected = uncorrected_sg * (1.0 + total_length_calibers.powi(2))
411 / (1.0 + metal_length_calibers.powi(2));
412 let corrected = apply_courtney_miller_plastic_tip_correction(
413 uncorrected_sg,
414 caliber_inches,
415 total_length_inches,
416 tip_length_inches,
417 );
418
419 assert!((corrected - expected).abs() < 1e-12);
420 assert!((corrected - 1.351).abs() < 0.001);
421 assert!(corrected > uncorrected_sg);
422 }
423
424 #[test]
425 fn courtney_miller_correction_requires_physical_tip_geometry() {
426 let uncorrected_sg = 1.5_f64;
427 for (caliber_inches, total_length_inches, tip_length_inches) in [
428 (0.0, 0.868, 0.130),
429 (-0.224, 0.868, 0.130),
430 (f64::NAN, 0.868, 0.130),
431 (0.224, 0.0, 0.130),
432 (0.224, f64::INFINITY, 0.130),
433 (0.224, 0.868, 0.0),
434 (0.224, 0.868, -0.130),
435 (0.224, 0.868, 0.868),
436 (0.224, 0.868, 0.900),
437 (0.224, 0.868, f64::NAN),
438 ] {
439 let corrected = apply_courtney_miller_plastic_tip_correction(
440 uncorrected_sg,
441 caliber_inches,
442 total_length_inches,
443 tip_length_inches,
444 );
445 assert_eq!(corrected.to_bits(), uncorrected_sg.to_bits());
446 }
447 }
448
449 #[test]
450 fn test_stability_edge_cases() {
451 let zero_twist = calculate_advanced_stability(
453 168.0, 2700.0, 0.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
454 );
455 assert_eq!(zero_twist, 0.0);
456
457 let zero_caliber = calculate_advanced_stability(
459 168.0, 2700.0, 10.0, 0.0, 1.24, 1.225, 288.15, "match", true, false,
460 );
461 assert_eq!(zero_caliber, 0.0);
462
463 let zero_length = calculate_advanced_stability(
465 168.0, 2700.0, 10.0, 0.308, 0.0, 1.225, 288.15, "match", true, false,
466 );
467 assert_eq!(zero_length, 0.0);
468 }
469
470 #[test]
471 fn test_velocity_correction() {
472 let high_vel = calculate_advanced_stability(
474 168.0, 3000.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
475 );
476 let low_vel = calculate_advanced_stability(
477 168.0, 2000.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
478 );
479
480 assert!(
481 high_vel > low_vel,
482 "Higher velocity ({}) should give higher stability than lower velocity ({})",
483 high_vel,
484 low_vel
485 );
486 }
487
488 #[test]
489 fn velocity_correction_is_continuous_at_1400_fps() {
490 let sg_base = 2.0;
491 let epsilon = 1e-6;
492 let below = apply_velocity_correction(sg_base, 1400.0 - epsilon);
493 let at_boundary = apply_velocity_correction(sg_base, 1400.0);
494 let above = apply_velocity_correction(sg_base, 1400.0 + epsilon);
495
496 assert!(
497 (below - at_boundary).abs() <= 1e-8,
498 "velocity correction jumped from {below} to {at_boundary} at 1400 fps"
499 );
500 assert!((above - at_boundary).abs() <= 1e-8);
501 }
502
503 #[test]
504 fn subsonic_velocity_uses_canonical_miller_cube_root() {
505 let sg_base = 2.0_f64;
506 let velocity_fps = 1050.0_f64;
507 let expected = sg_base * (velocity_fps / 2800.0).powf(1.0 / 3.0);
508 let actual = apply_velocity_correction(sg_base, velocity_fps);
509
510 assert!(
511 (actual - expected).abs() <= expected * 1e-12,
512 "subsonic correction was {actual}, expected Miller value {expected}"
513 );
514 }
515
516 #[test]
517 fn advanced_stability_is_continuous_above_3000_fps() {
518 let calculate = |velocity_fps| {
519 calculate_advanced_stability(
520 55.0,
521 velocity_fps,
522 12.0,
523 0.224,
524 0.75,
525 1.225,
526 288.15,
527 "unknown",
528 false,
529 false,
530 )
531 };
532
533 let at_threshold = calculate(3000.0);
534 let just_above = calculate(3000.0 + 1e-6);
535 let relative_change = (just_above / at_threshold - 1.0).abs();
536 assert!(
537 relative_change < 1e-8,
538 "Sg jumped by {:.3}% immediately above 3000 fps",
539 relative_change * 100.0
540 );
541
542 let high_velocity = calculate(4000.0);
543 let expected_ratio = (4000.0_f64 / 3000.0).powf(1.0 / 3.0);
544 assert!(
545 (high_velocity / at_threshold - expected_ratio).abs() < 1e-12,
546 "high-velocity Sg did not follow Miller cube-root scaling"
547 );
548 }
549
550 #[test]
551 fn test_atmospheric_correction() {
552 let sea_level = calculate_advanced_stability(
554 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
555 );
556 let high_altitude = calculate_advanced_stability(
557 168.0, 2700.0, 10.0, 0.308, 1.24, 1.0, 288.15, "match", true, false,
558 );
559
560 assert!(
561 high_altitude > sea_level,
562 "High altitude ({}) should have higher stability than sea level ({})",
563 high_altitude,
564 sea_level
565 );
566 }
567
568 #[test]
569 fn atmospheric_correction_is_only_inverse_density_ratio() {
570 let sg = 2.0_f64;
571 let temperature_k = 308.15; for (density, expected) in [(1.225, 2.0), (1.0, 2.45), (0.6125, 4.0)] {
574 let actual = apply_atmospheric_correction(sg, density, temperature_k);
575 assert!(
576 (actual - expected).abs() <= expected * 1e-12,
577 "rho {density}: expected {expected}, got {actual}"
578 );
579 }
580 }
581
582 #[test]
583 fn advanced_stability_does_not_double_count_temperature_at_fixed_density() {
584 let calculate = |temperature_k| {
585 calculate_advanced_stability(
586 168.0,
587 2800.0,
588 10.0,
589 0.308,
590 1.24,
591 1.0,
592 temperature_k,
593 "unknown",
594 false,
595 false,
596 )
597 };
598
599 let cold = calculate(253.15);
600 let standard = calculate(288.15);
601 let hot = calculate(308.15);
602 let unknown = calculate(f64::NAN);
603 assert_eq!(cold.to_bits(), standard.to_bits());
604 assert_eq!(hot.to_bits(), standard.to_bits());
605 assert_eq!(unknown.to_bits(), standard.to_bits());
606 }
607
608 #[test]
609 fn atmospheric_correction_rejects_nonphysical_density() {
610 for density in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
611 let actual = apply_atmospheric_correction(2.0, density, 288.15);
612 assert_eq!(
613 actual.to_bits(),
614 0.0_f64.to_bits(),
615 "density {density} produced {actual}"
616 );
617 }
618 }
619
620 #[test]
621 #[allow(deprecated)]
622 fn legacy_dynamic_stability_is_neutral_without_aerodynamic_derivatives() {
623 let legacy: fn(f64, f64, f64, f64, f64, f64) -> f64 = calculate_dynamic_stability;
624 let ancillary_states = [
625 (800.0, 1500.0, 0.0, 0.00782, 0.0109),
626 (800.0, 1500.0, 0.5, 0.00782, 0.0109),
627 (0.0, 0.0, 1.0, 0.00782, 0.0109),
628 (f64::NAN, -20_000.0, f64::NAN, -0.009, f64::INFINITY),
629 ];
630
631 for static_sg in [
632 0.0,
633 -0.0,
634 1.5,
635 -1.0,
636 f64::INFINITY,
637 f64::NEG_INFINITY,
638 f64::NAN,
639 ] {
640 for (velocity_mps, spin_rate_rad_s, yaw_angle_rad, caliber_m, mass_kg) in
641 ancillary_states
642 {
643 let actual = legacy(
644 static_sg,
645 velocity_mps,
646 spin_rate_rad_s,
647 yaw_angle_rad,
648 caliber_m,
649 mass_kg,
650 );
651 assert_eq!(
652 actual.to_bits(),
653 static_sg.to_bits(),
654 "legacy API invented a dynamic correction without aerodynamic derivatives"
655 );
656 }
657 }
658 }
659
660 #[test]
661 fn test_predict_stability_at_distance() {
662 let initial_sg = 1.8;
663 let initial_vel = 2800.0;
664 let current_vel = 2000.0;
665 let spin_decay = 0.97;
666
667 let predicted =
668 predict_stability_at_distance(initial_sg, initial_vel, current_vel, spin_decay);
669 let expected = initial_sg * (spin_decay / (current_vel / initial_vel)).powi(2);
670
671 assert!((predicted - expected).abs() < 1e-12);
672 assert!(
673 predicted > initial_sg,
674 "retaining 97% spin while losing velocity must increase SG: {predicted}"
675 );
676
677 let slower = predict_stability_at_distance(initial_sg, initial_vel, 1400.0, spin_decay);
678 assert!(
679 slower > predicted,
680 "SG must increase monotonically as velocity falls at fixed spin retention"
681 );
682 }
683
684 #[test]
685 fn test_predict_stability_edge_cases() {
686 let zero_initial = predict_stability_at_distance(1.5, 0.0, 2000.0, 0.97);
688 assert_eq!(zero_initial, 1.5);
689
690 let zero_current = predict_stability_at_distance(1.5, 2800.0, 0.0, 0.97);
692 assert_eq!(zero_current, 1.5);
693 }
694
695 #[test]
696 fn test_trajectory_stability_status_messages() {
697 let (is_stable, sg, status) = check_trajectory_stability(0.8, 2700.0, 2700.0, 1.0);
700 assert!(!is_stable);
701 assert!(sg < 1.0);
702 assert!(status.contains("UNSTABLE"));
703
704 let (is_stable, sg, status) = check_trajectory_stability(1.15, 2700.0, 2700.0, 1.0);
706 assert!(!is_stable);
707 assert!((1.0..1.3).contains(&sg));
708 assert!(status.contains("MARGINAL"));
709
710 let (_, sg, status) = check_trajectory_stability(4.0, 2700.0, 2700.0, 1.0);
712 assert!(sg > 2.5);
713 assert!(status.contains("OVER-STABILIZED"));
714 }
715
716 #[test]
717 fn test_different_calibers_stability() {
718 let large_caliber = calculate_advanced_stability(
720 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
721 );
722 let small_caliber = calculate_advanced_stability(
723 90.0, 2700.0, 8.0, 0.264, 1.15, 1.225, 288.15, "match", true, false,
724 );
725
726 assert!(large_caliber > 0.0);
728 assert!(small_caliber > 0.0);
729 }
730
731 #[test]
732 fn test_boat_tail_vs_flat_base() {
733 let boat_tail = calculate_advanced_stability(
734 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
735 );
736 let flat_base = calculate_advanced_stability(
737 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", false, false,
738 );
739
740 assert!(flat_base > boat_tail);
743 }
744}