Skip to main content

ballistics_engine/
stability_advanced.rs

1// Advanced stability calculations using refined Miller formula and modern corrections
2// Based on:
3// - Don Miller's refined stability formula (2005)
4// - Courtney-Miller's plastic-tip stability correction (2012)
5// - Bryan Litz's stability refinements
6//
7// NOTE: Some advanced stability functions are experimental and kept for future use.
8#![allow(dead_code)]
9
10/// Advanced stability parameters for different bullet types
11#[derive(Debug, Clone)]
12pub struct StabilityParameters {
13    /// Shape factor for nose profile (1.0 for tangent, 0.9 for secant)
14    pub nose_shape_factor: f64,
15    /// Boat tail effectiveness factor
16    pub boat_tail_factor: f64,
17    /// Legacy plastic-tip multiplier, retained at a neutral value for source compatibility.
18    /// Use [`apply_courtney_miller_plastic_tip_correction`] with measured tip geometry.
19    pub plastic_tip_factor: f64,
20    /// Center of pressure adjustment
21    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    /// Return the legacy default stability parameters.
56    ///
57    /// This inherent constructor is retained in addition to [`Default`] for source compatibility
58    /// with callers that invoke it through a fully qualified inherent-method path.
59    #[allow(clippy::should_implement_trait)] // The trait is implemented below; this preserves API.
60    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/// Calculate advanced Miller stability with modern corrections.
77///
78/// `has_plastic_tip` is retained for source compatibility, but a Boolean cannot provide the tip
79/// length required by the Courtney-Miller correction. No plastic-tip scalar is applied here; use
80/// [`apply_courtney_miller_plastic_tip_correction`] on this result when tip length is known.
81///
82/// `air_density_kg_m3` is the resolved density for the complete atmospheric state. The
83/// `temperature_k` argument is retained for source compatibility but is not applied separately;
84/// callers changing temperature must supply the corresponding density.
85#[allow(clippy::too_many_arguments)] // Public compatibility API; grouping would be breaking.
86pub 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    // Calculate base Miller stability
105    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    // Apply velocity correction (Miller's refined formula)
114    let sg_velocity_corrected = apply_velocity_correction(sg_base, velocity_fps);
115
116    // Apply atmospheric corrections
117    let sg_atmosphere_corrected =
118        apply_atmospheric_correction(sg_velocity_corrected, air_density_kg_m3, temperature_k);
119
120    // Apply boat tail correction if applicable
121    let sg_boat_tail = sg_atmosphere_corrected * params.boat_tail_factor;
122
123    // Apply center of pressure adjustment
124    sg_boat_tail * params.cop_adjustment
125}
126
127/// Apply the [Courtney-Miller correction] for a plastic-tipped bullet to a Miller stability value.
128///
129/// The original Miller denominator uses total length `L` in both `L * (1 + L^2)`. For a
130/// plastic-tipped bullet, Courtney and Miller retain total length in the leading aerodynamic term
131/// and use metal length only in the inertia term: `L * (1 + L_m^2)`. Therefore this multiplies the
132/// uncorrected full-length stability by `(1 + L^2) / (1 + L_m^2)`, where lengths are in calibers and
133/// `L_m = (total_length_inches - tip_length_inches) / caliber_inches`.
134///
135/// Returns `uncorrected_sg` unchanged when the geometry is non-finite or when
136/// `0 < tip_length_inches < total_length_inches` is not satisfied.
137///
138/// [Courtney-Miller correction]: https://arxiv.org/abs/1410.5340
139pub 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
163/// Miller's refined stability formula (2005 version)
164fn 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    // Convert to calibers
172    let twist_calibers = twist_rate_inches / caliber_inches;
173    let length_calibers = length_inches / caliber_inches;
174
175    // Miller's constant (refined from original 30)
176    const MILLER_CONSTANT: f64 = 30.0;
177
178    // Calculate moment of inertia factor
179    // For modern bullets: (1 + L²) where L is length in calibers
180    let inertia_factor = 1.0 + length_calibers.powi(2);
181
182    // Base Miller formula with nose shape correction
183    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
194/// Velocity correction using the engine's canonical Miller cube-root approximation.
195fn 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
202/// Atmospheric correction for non-standard conditions
203fn apply_atmospheric_correction(sg: f64, air_density_kg_m3: f64, _temperature_k: f64) -> f64 {
204    // Standard atmosphere at sea level
205    const STD_DENSITY: f64 = 1.225; // kg/m³
206
207    // Density altitude correction (MBA-942): canonical Miller is LINEAR in density ratio
208    // (rho0/rho), matching stability.rs and py_ballisticcalc. Density already encodes the
209    // temperature/pressure state, so applying `temperature_k` again would double-count it.
210    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/// Legacy compatibility shim that returns `static_stability` unchanged.
218///
219/// This signature cannot calculate the aerodynamic dynamic-stability factor from the literature:
220/// that requires lift, drag, pitch-moment, pitch-damping, angle-of-attack-rate derivatives, and
221/// projectile inertia radii that are not present here. The former yaw and spin multipliers were
222/// unsupported and have been removed.
223///
224/// [`crate::spin_drift::calculate_dynamic_stability`] is a different Miller gyroscopic-stability
225/// calculation and is not a replacement for an aerodynamic dynamic-stability model.
226#[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
241/// Predict stability over trajectory with velocity and spin decay.
242///
243/// `spin_decay_factor` is the current spin rate divided by muzzle spin rate, typically 0.95-0.98
244/// because axial spin decays much more slowly than forward velocity.
245pub 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    // Velocity ratio
256    let velocity_ratio = current_velocity_fps / initial_velocity_fps;
257
258    // At otherwise fixed aerodynamic conditions, gyroscopic stability follows
259    // Sg ∝ spin_rate² / velocity². `spin_decay_factor` is already the independent spin-rate
260    // retention ratio; multiplying it by velocity_ratio would incorrectly force spin to decay in
261    // lockstep with forward speed and invert the downrange trend (MBA-1161).
262    let stability_ratio = (spin_decay_factor / velocity_ratio).powi(2);
263
264    initial_stability * stability_ratio
265}
266
267/// Check if bullet will remain stable throughout trajectory
268pub 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; // Minimum for adequate stability
282
283    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        // Test with .308 168gr Match bullet
305        let stability = calculate_advanced_stability(
306            168.0,   // mass in grains
307            2700.0,  // velocity in fps
308            10.0,    // twist rate in inches
309            0.308,   // caliber in inches
310            1.24,    // length in inches
311            1.225,   // air density
312            288.15,  // temperature in K
313            "match", // bullet type
314            true,    // has boat tail
315            false,   // no plastic tip
316        );
317
318        println!("Calculated stability: {}", stability);
319
320        // Should give stability around 1.4-1.8 for typical .308 Match
321        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,    // muzzle stability
333            2700.0, // muzzle velocity
334            1900.0, // terminal velocity
335            0.98,   // independent spin retention
336        );
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        // VLD should have lower nose_shape_factor (more streamlined)
363        assert!(vld_params.nose_shape_factor < match_params.nose_shape_factor);
364
365        // A Boolean cannot determine the metal length needed by Courtney-Miller, so the legacy
366        // scalar stays neutral and callers use the geometry-aware correction helper.
367        assert_eq!(hunting_params.plastic_tip_factor, 1.0);
368
369        // Default should have all factors at 1.0
370        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        // Published 60 gr V-MAX measurements: total length 0.868", metal length 0.738".
403        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        // Zero twist rate should return 0
452        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        // Zero caliber should return 0
458        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        // Zero length should return 0
464        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        // Higher velocity should give higher stability
473        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        // Higher altitude (lower density) should increase stability
553        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; // Deliberately non-standard to catch a second temperature term.
572
573        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        // Zero initial velocity should return initial stability
687        let zero_initial = predict_stability_at_distance(1.5, 0.0, 2000.0, 0.97);
688        assert_eq!(zero_initial, 1.5);
689
690        // Zero current velocity should return initial stability
691        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        // Use identical muzzle/terminal velocity and full spin retention to isolate status
698        // thresholds from the downrange stability correction.
699        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        // Marginal (1.0 - 1.3)
705        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        // Over-stabilized (> 2.5)
711        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        // Smaller caliber with same twist should be less stable
719        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        // Both should produce valid stability values
727        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        // Flat base should have slightly higher stability factor applied
741        // (boat_tail_factor < 1.0 for boat tails)
742        assert!(flat_base > boat_tail);
743    }
744}