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 pub fn default() -> Self {
56 Self {
57 nose_shape_factor: 1.0,
58 boat_tail_factor: 1.0,
59 plastic_tip_factor: 1.0,
60 cop_adjustment: 1.0,
61 }
62 }
63}
64
65pub fn calculate_advanced_stability(
75 mass_grains: f64,
76 velocity_fps: f64,
77 twist_rate_inches: f64,
78 caliber_inches: f64,
79 length_inches: f64,
80 air_density_kg_m3: f64,
81 temperature_k: f64,
82 bullet_type: &str,
83 has_boat_tail: bool,
84 has_plastic_tip: bool,
85) -> f64 {
86 if twist_rate_inches == 0.0 || caliber_inches == 0.0 || length_inches == 0.0 {
87 return 0.0;
88 }
89
90 let params = StabilityParameters::for_bullet_type(bullet_type, has_boat_tail, has_plastic_tip);
91
92 let sg_base = calculate_miller_refined(
94 mass_grains,
95 twist_rate_inches,
96 caliber_inches,
97 length_inches,
98 params.nose_shape_factor,
99 );
100
101 let sg_velocity_corrected = apply_velocity_correction(sg_base, velocity_fps);
103
104 let sg_atmosphere_corrected =
106 apply_atmospheric_correction(sg_velocity_corrected, air_density_kg_m3, temperature_k);
107
108 let sg_boat_tail = sg_atmosphere_corrected * params.boat_tail_factor;
110
111 sg_boat_tail * params.cop_adjustment
113}
114
115pub fn apply_courtney_miller_plastic_tip_correction(
128 uncorrected_sg: f64,
129 caliber_inches: f64,
130 total_length_inches: f64,
131 tip_length_inches: f64,
132) -> f64 {
133 if !caliber_inches.is_finite()
134 || !total_length_inches.is_finite()
135 || !tip_length_inches.is_finite()
136 || caliber_inches <= 0.0
137 || total_length_inches <= 0.0
138 || tip_length_inches <= 0.0
139 || tip_length_inches >= total_length_inches
140 {
141 return uncorrected_sg;
142 }
143
144 let total_length_calibers = total_length_inches / caliber_inches;
145 let metal_length_calibers = (total_length_inches - tip_length_inches) / caliber_inches;
146 let correction = (1.0 + total_length_calibers.powi(2)) / (1.0 + metal_length_calibers.powi(2));
147
148 uncorrected_sg * correction
149}
150
151fn calculate_miller_refined(
153 mass_grains: f64,
154 twist_rate_inches: f64,
155 caliber_inches: f64,
156 length_inches: f64,
157 nose_shape_factor: f64,
158) -> f64 {
159 let twist_calibers = twist_rate_inches / caliber_inches;
161 let length_calibers = length_inches / caliber_inches;
162
163 const MILLER_CONSTANT: f64 = 30.0;
165
166 let inertia_factor = 1.0 + length_calibers.powi(2);
169
170 let numerator = MILLER_CONSTANT * mass_grains * nose_shape_factor;
172 let denominator =
173 twist_calibers.powi(2) * caliber_inches.powi(3) * length_calibers * inertia_factor;
174
175 if denominator == 0.0 {
176 return 0.0;
177 }
178
179 numerator / denominator
180}
181
182fn apply_velocity_correction(sg_base: f64, velocity_fps: f64) -> f64 {
184 const VELOCITY_REFERENCE: f64 = 2800.0;
185
186 let velocity_factor = (velocity_fps / VELOCITY_REFERENCE).powf(1.0 / 3.0);
187 sg_base * velocity_factor
188}
189
190fn apply_atmospheric_correction(sg: f64, air_density_kg_m3: f64, _temperature_k: f64) -> f64 {
192 const STD_DENSITY: f64 = 1.225; if !air_density_kg_m3.is_finite() || air_density_kg_m3 <= 0.0 {
199 return 0.0;
200 }
201
202 sg * (STD_DENSITY / air_density_kg_m3)
203}
204
205#[deprecated(
215 since = "0.22.18",
216 note = "does not compute aerodynamic dynamic stability; retained as a neutral static-stability pass-through"
217)]
218pub fn calculate_dynamic_stability(
219 static_stability: f64,
220 _velocity_mps: f64,
221 _spin_rate_rad_s: f64,
222 _yaw_angle_rad: f64,
223 _caliber_m: f64,
224 _mass_kg: f64,
225) -> f64 {
226 static_stability
227}
228
229pub fn predict_stability_at_distance(
234 initial_stability: f64,
235 initial_velocity_fps: f64,
236 current_velocity_fps: f64,
237 spin_decay_factor: f64,
238) -> f64 {
239 if initial_velocity_fps == 0.0 || current_velocity_fps == 0.0 {
240 return initial_stability;
241 }
242
243 let velocity_ratio = current_velocity_fps / initial_velocity_fps;
245
246 let stability_ratio = (spin_decay_factor / velocity_ratio).powi(2);
251
252 initial_stability * stability_ratio
253}
254
255pub fn check_trajectory_stability(
257 muzzle_stability: f64,
258 muzzle_velocity_fps: f64,
259 terminal_velocity_fps: f64,
260 spin_decay_factor: f64,
261) -> (bool, f64, String) {
262 let terminal_stability = predict_stability_at_distance(
263 muzzle_stability,
264 muzzle_velocity_fps,
265 terminal_velocity_fps,
266 spin_decay_factor,
267 );
268
269 let is_stable = terminal_stability >= 1.3; let status = if terminal_stability < 1.0 {
272 "UNSTABLE - Bullet will tumble".to_string()
273 } else if terminal_stability < 1.3 {
274 "MARGINAL - May experience accuracy issues".to_string()
275 } else if terminal_stability < 1.5 {
276 "ADEQUATE - Acceptable for most conditions".to_string()
277 } else if terminal_stability < 2.5 {
278 "GOOD - Optimal stability".to_string()
279 } else {
280 "OVER-STABILIZED - May reduce BC slightly".to_string()
281 };
282
283 (is_stable, terminal_stability, status)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn test_advanced_stability() {
292 let stability = calculate_advanced_stability(
294 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false, );
305
306 println!("Calculated stability: {}", stability);
307
308 assert!(stability > 1.3);
310 assert!(
311 stability < 2.5,
312 "Stability {} exceeds upper bound",
313 stability
314 );
315 }
316
317 #[test]
318 fn test_stability_prediction() {
319 let (is_stable, terminal_sg, status) = check_trajectory_stability(
320 2.2, 2700.0, 1900.0, 0.98, );
325
326 println!(
327 "is_stable: {}, terminal_sg: {}, status: {}",
328 is_stable, terminal_sg, status
329 );
330
331 assert!(
332 is_stable,
333 "Expected stable trajectory but got: is_stable={}, terminal_sg={}, status={}",
334 is_stable, terminal_sg, status
335 );
336 assert!(
337 terminal_sg > 2.2,
338 "SG must grow as velocity decays faster than spin: {terminal_sg}"
339 );
340 assert!(status.contains("OVER-STABILIZED"));
341 }
342
343 #[test]
344 fn test_stability_parameters_bullet_types() {
345 let match_params = StabilityParameters::for_bullet_type("match", true, false);
346 let vld_params = StabilityParameters::for_bullet_type("vld", true, false);
347 let hunting_params = StabilityParameters::for_bullet_type("hunting", true, true);
348 let default_params = StabilityParameters::for_bullet_type("unknown", false, false);
349
350 assert!(vld_params.nose_shape_factor < match_params.nose_shape_factor);
352
353 assert_eq!(hunting_params.plastic_tip_factor, 1.0);
356
357 assert_eq!(default_params.nose_shape_factor, 1.0);
359 assert_eq!(default_params.boat_tail_factor, 1.0);
360 }
361
362 #[test]
363 fn plastic_tip_flag_never_reduces_advanced_stability() {
364 let calculate = |has_plastic_tip| {
365 calculate_advanced_stability(
366 178.0,
367 2800.0,
368 10.0,
369 0.308,
370 1.420,
371 1.225,
372 288.15,
373 "hunting",
374 false,
375 has_plastic_tip,
376 )
377 };
378
379 let untipped = calculate(false);
380 let tipped = calculate(true);
381 assert_eq!(
382 tipped.to_bits(),
383 untipped.to_bits(),
384 "a Boolean-only plastic-tip flag must not apply an invented correction"
385 );
386 }
387
388 #[test]
389 fn courtney_miller_correction_uses_metal_length_only_in_inertia_term() {
390 let caliber_inches = 0.224_f64;
392 let total_length_inches = 0.868_f64;
393 let tip_length_inches = total_length_inches - 0.738;
394 let uncorrected_sg = 1.0_f64;
395
396 let total_length_calibers = total_length_inches / caliber_inches;
397 let metal_length_calibers = (total_length_inches - tip_length_inches) / caliber_inches;
398 let expected = uncorrected_sg * (1.0 + total_length_calibers.powi(2))
399 / (1.0 + metal_length_calibers.powi(2));
400 let corrected = apply_courtney_miller_plastic_tip_correction(
401 uncorrected_sg,
402 caliber_inches,
403 total_length_inches,
404 tip_length_inches,
405 );
406
407 assert!((corrected - expected).abs() < 1e-12);
408 assert!((corrected - 1.351).abs() < 0.001);
409 assert!(corrected > uncorrected_sg);
410 }
411
412 #[test]
413 fn courtney_miller_correction_requires_physical_tip_geometry() {
414 let uncorrected_sg = 1.5_f64;
415 for (caliber_inches, total_length_inches, tip_length_inches) in [
416 (0.0, 0.868, 0.130),
417 (-0.224, 0.868, 0.130),
418 (f64::NAN, 0.868, 0.130),
419 (0.224, 0.0, 0.130),
420 (0.224, f64::INFINITY, 0.130),
421 (0.224, 0.868, 0.0),
422 (0.224, 0.868, -0.130),
423 (0.224, 0.868, 0.868),
424 (0.224, 0.868, 0.900),
425 (0.224, 0.868, f64::NAN),
426 ] {
427 let corrected = apply_courtney_miller_plastic_tip_correction(
428 uncorrected_sg,
429 caliber_inches,
430 total_length_inches,
431 tip_length_inches,
432 );
433 assert_eq!(corrected.to_bits(), uncorrected_sg.to_bits());
434 }
435 }
436
437 #[test]
438 fn test_stability_edge_cases() {
439 let zero_twist = calculate_advanced_stability(
441 168.0, 2700.0, 0.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
442 );
443 assert_eq!(zero_twist, 0.0);
444
445 let zero_caliber = calculate_advanced_stability(
447 168.0, 2700.0, 10.0, 0.0, 1.24, 1.225, 288.15, "match", true, false,
448 );
449 assert_eq!(zero_caliber, 0.0);
450
451 let zero_length = calculate_advanced_stability(
453 168.0, 2700.0, 10.0, 0.308, 0.0, 1.225, 288.15, "match", true, false,
454 );
455 assert_eq!(zero_length, 0.0);
456 }
457
458 #[test]
459 fn test_velocity_correction() {
460 let high_vel = calculate_advanced_stability(
462 168.0, 3000.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
463 );
464 let low_vel = calculate_advanced_stability(
465 168.0, 2000.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
466 );
467
468 assert!(
469 high_vel > low_vel,
470 "Higher velocity ({}) should give higher stability than lower velocity ({})",
471 high_vel,
472 low_vel
473 );
474 }
475
476 #[test]
477 fn velocity_correction_is_continuous_at_1400_fps() {
478 let sg_base = 2.0;
479 let epsilon = 1e-6;
480 let below = apply_velocity_correction(sg_base, 1400.0 - epsilon);
481 let at_boundary = apply_velocity_correction(sg_base, 1400.0);
482 let above = apply_velocity_correction(sg_base, 1400.0 + epsilon);
483
484 assert!(
485 (below - at_boundary).abs() <= 1e-8,
486 "velocity correction jumped from {below} to {at_boundary} at 1400 fps"
487 );
488 assert!((above - at_boundary).abs() <= 1e-8);
489 }
490
491 #[test]
492 fn subsonic_velocity_uses_canonical_miller_cube_root() {
493 let sg_base = 2.0_f64;
494 let velocity_fps = 1050.0_f64;
495 let expected = sg_base * (velocity_fps / 2800.0).powf(1.0 / 3.0);
496 let actual = apply_velocity_correction(sg_base, velocity_fps);
497
498 assert!(
499 (actual - expected).abs() <= expected * 1e-12,
500 "subsonic correction was {actual}, expected Miller value {expected}"
501 );
502 }
503
504 #[test]
505 fn advanced_stability_is_continuous_above_3000_fps() {
506 let calculate = |velocity_fps| {
507 calculate_advanced_stability(
508 55.0,
509 velocity_fps,
510 12.0,
511 0.224,
512 0.75,
513 1.225,
514 288.15,
515 "unknown",
516 false,
517 false,
518 )
519 };
520
521 let at_threshold = calculate(3000.0);
522 let just_above = calculate(3000.0 + 1e-6);
523 let relative_change = (just_above / at_threshold - 1.0).abs();
524 assert!(
525 relative_change < 1e-8,
526 "Sg jumped by {:.3}% immediately above 3000 fps",
527 relative_change * 100.0
528 );
529
530 let high_velocity = calculate(4000.0);
531 let expected_ratio = (4000.0_f64 / 3000.0).powf(1.0 / 3.0);
532 assert!(
533 (high_velocity / at_threshold - expected_ratio).abs() < 1e-12,
534 "high-velocity Sg did not follow Miller cube-root scaling"
535 );
536 }
537
538 #[test]
539 fn test_atmospheric_correction() {
540 let sea_level = calculate_advanced_stability(
542 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
543 );
544 let high_altitude = calculate_advanced_stability(
545 168.0, 2700.0, 10.0, 0.308, 1.24, 1.0, 288.15, "match", true, false,
546 );
547
548 assert!(
549 high_altitude > sea_level,
550 "High altitude ({}) should have higher stability than sea level ({})",
551 high_altitude,
552 sea_level
553 );
554 }
555
556 #[test]
557 fn atmospheric_correction_is_only_inverse_density_ratio() {
558 let sg = 2.0_f64;
559 let temperature_k = 308.15; for (density, expected) in [(1.225, 2.0), (1.0, 2.45), (0.6125, 4.0)] {
562 let actual = apply_atmospheric_correction(sg, density, temperature_k);
563 assert!(
564 (actual - expected).abs() <= expected * 1e-12,
565 "rho {density}: expected {expected}, got {actual}"
566 );
567 }
568 }
569
570 #[test]
571 fn advanced_stability_does_not_double_count_temperature_at_fixed_density() {
572 let calculate = |temperature_k| {
573 calculate_advanced_stability(
574 168.0,
575 2800.0,
576 10.0,
577 0.308,
578 1.24,
579 1.0,
580 temperature_k,
581 "unknown",
582 false,
583 false,
584 )
585 };
586
587 let cold = calculate(253.15);
588 let standard = calculate(288.15);
589 let hot = calculate(308.15);
590 let unknown = calculate(f64::NAN);
591 assert_eq!(cold.to_bits(), standard.to_bits());
592 assert_eq!(hot.to_bits(), standard.to_bits());
593 assert_eq!(unknown.to_bits(), standard.to_bits());
594 }
595
596 #[test]
597 fn atmospheric_correction_rejects_nonphysical_density() {
598 for density in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
599 let actual = apply_atmospheric_correction(2.0, density, 288.15);
600 assert_eq!(
601 actual.to_bits(),
602 0.0_f64.to_bits(),
603 "density {density} produced {actual}"
604 );
605 }
606 }
607
608 #[test]
609 #[allow(deprecated)]
610 fn legacy_dynamic_stability_is_neutral_without_aerodynamic_derivatives() {
611 let legacy: fn(f64, f64, f64, f64, f64, f64) -> f64 = calculate_dynamic_stability;
612 let ancillary_states = [
613 (800.0, 1500.0, 0.0, 0.00782, 0.0109),
614 (800.0, 1500.0, 0.5, 0.00782, 0.0109),
615 (0.0, 0.0, 1.0, 0.00782, 0.0109),
616 (f64::NAN, -20_000.0, f64::NAN, -0.009, f64::INFINITY),
617 ];
618
619 for static_sg in [
620 0.0,
621 -0.0,
622 1.5,
623 -1.0,
624 f64::INFINITY,
625 f64::NEG_INFINITY,
626 f64::NAN,
627 ] {
628 for (velocity_mps, spin_rate_rad_s, yaw_angle_rad, caliber_m, mass_kg) in
629 ancillary_states
630 {
631 let actual = legacy(
632 static_sg,
633 velocity_mps,
634 spin_rate_rad_s,
635 yaw_angle_rad,
636 caliber_m,
637 mass_kg,
638 );
639 assert_eq!(
640 actual.to_bits(),
641 static_sg.to_bits(),
642 "legacy API invented a dynamic correction without aerodynamic derivatives"
643 );
644 }
645 }
646 }
647
648 #[test]
649 fn test_predict_stability_at_distance() {
650 let initial_sg = 1.8;
651 let initial_vel = 2800.0;
652 let current_vel = 2000.0;
653 let spin_decay = 0.97;
654
655 let predicted =
656 predict_stability_at_distance(initial_sg, initial_vel, current_vel, spin_decay);
657 let expected = initial_sg * (spin_decay / (current_vel / initial_vel)).powi(2);
658
659 assert!((predicted - expected).abs() < 1e-12);
660 assert!(
661 predicted > initial_sg,
662 "retaining 97% spin while losing velocity must increase SG: {predicted}"
663 );
664
665 let slower = predict_stability_at_distance(initial_sg, initial_vel, 1400.0, spin_decay);
666 assert!(
667 slower > predicted,
668 "SG must increase monotonically as velocity falls at fixed spin retention"
669 );
670 }
671
672 #[test]
673 fn test_predict_stability_edge_cases() {
674 let zero_initial = predict_stability_at_distance(1.5, 0.0, 2000.0, 0.97);
676 assert_eq!(zero_initial, 1.5);
677
678 let zero_current = predict_stability_at_distance(1.5, 2800.0, 0.0, 0.97);
680 assert_eq!(zero_current, 1.5);
681 }
682
683 #[test]
684 fn test_trajectory_stability_status_messages() {
685 let (is_stable, sg, status) = check_trajectory_stability(0.8, 2700.0, 2700.0, 1.0);
688 assert!(!is_stable);
689 assert!(sg < 1.0);
690 assert!(status.contains("UNSTABLE"));
691
692 let (is_stable, sg, status) = check_trajectory_stability(1.15, 2700.0, 2700.0, 1.0);
694 assert!(!is_stable);
695 assert!((1.0..1.3).contains(&sg));
696 assert!(status.contains("MARGINAL"));
697
698 let (_, sg, status) = check_trajectory_stability(4.0, 2700.0, 2700.0, 1.0);
700 assert!(sg > 2.5);
701 assert!(status.contains("OVER-STABILIZED"));
702 }
703
704 #[test]
705 fn test_different_calibers_stability() {
706 let large_caliber = calculate_advanced_stability(
708 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
709 );
710 let small_caliber = calculate_advanced_stability(
711 90.0, 2700.0, 8.0, 0.264, 1.15, 1.225, 288.15, "match", true, false,
712 );
713
714 assert!(large_caliber > 0.0);
716 assert!(small_caliber > 0.0);
717 }
718
719 #[test]
720 fn test_boat_tail_vs_flat_base() {
721 let boat_tail = calculate_advanced_stability(
722 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", true, false,
723 );
724 let flat_base = calculate_advanced_stability(
725 168.0, 2700.0, 10.0, 0.308, 1.24, 1.225, 288.15, "match", false, false,
726 );
727
728 assert!(flat_base > boat_tail);
731 }
732}