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    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
65/// Calculate advanced Miller stability with modern corrections.
66///
67/// `has_plastic_tip` is retained for source compatibility, but a Boolean cannot provide the tip
68/// length required by the Courtney-Miller correction. No plastic-tip scalar is applied here; use
69/// [`apply_courtney_miller_plastic_tip_correction`] on this result when tip length is known.
70///
71/// `air_density_kg_m3` is the resolved density for the complete atmospheric state. The
72/// `temperature_k` argument is retained for source compatibility but is not applied separately;
73/// callers changing temperature must supply the corresponding density.
74pub 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    // Calculate base Miller stability
93    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    // Apply velocity correction (Miller's refined formula)
102    let sg_velocity_corrected = apply_velocity_correction(sg_base, velocity_fps);
103
104    // Apply atmospheric corrections
105    let sg_atmosphere_corrected =
106        apply_atmospheric_correction(sg_velocity_corrected, air_density_kg_m3, temperature_k);
107
108    // Apply boat tail correction if applicable
109    let sg_boat_tail = sg_atmosphere_corrected * params.boat_tail_factor;
110
111    // Apply center of pressure adjustment
112    sg_boat_tail * params.cop_adjustment
113}
114
115/// Apply the [Courtney-Miller correction] for a plastic-tipped bullet to a Miller stability value.
116///
117/// The original Miller denominator uses total length `L` in both `L * (1 + L^2)`. For a
118/// plastic-tipped bullet, Courtney and Miller retain total length in the leading aerodynamic term
119/// and use metal length only in the inertia term: `L * (1 + L_m^2)`. Therefore this multiplies the
120/// uncorrected full-length stability by `(1 + L^2) / (1 + L_m^2)`, where lengths are in calibers and
121/// `L_m = (total_length_inches - tip_length_inches) / caliber_inches`.
122///
123/// Returns `uncorrected_sg` unchanged when the geometry is non-finite or when
124/// `0 < tip_length_inches < total_length_inches` is not satisfied.
125///
126/// [Courtney-Miller correction]: https://arxiv.org/abs/1410.5340
127pub 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
151/// Miller's refined stability formula (2005 version)
152fn 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    // Convert to calibers
160    let twist_calibers = twist_rate_inches / caliber_inches;
161    let length_calibers = length_inches / caliber_inches;
162
163    // Miller's constant (refined from original 30)
164    const MILLER_CONSTANT: f64 = 30.0;
165
166    // Calculate moment of inertia factor
167    // For modern bullets: (1 + L²) where L is length in calibers
168    let inertia_factor = 1.0 + length_calibers.powi(2);
169
170    // Base Miller formula with nose shape correction
171    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
182/// Velocity correction using the engine's canonical Miller cube-root approximation.
183fn 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
190/// Atmospheric correction for non-standard conditions
191fn apply_atmospheric_correction(sg: f64, air_density_kg_m3: f64, _temperature_k: f64) -> f64 {
192    // Standard atmosphere at sea level
193    const STD_DENSITY: f64 = 1.225; // kg/m³
194
195    // Density altitude correction (MBA-942): canonical Miller is LINEAR in density ratio
196    // (rho0/rho), matching stability.rs and py_ballisticcalc. Density already encodes the
197    // temperature/pressure state, so applying `temperature_k` again would double-count it.
198    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/// Legacy compatibility shim that returns `static_stability` unchanged.
206///
207/// This signature cannot calculate the aerodynamic dynamic-stability factor from the literature:
208/// that requires lift, drag, pitch-moment, pitch-damping, angle-of-attack-rate derivatives, and
209/// projectile inertia radii that are not present here. The former yaw and spin multipliers were
210/// unsupported and have been removed.
211///
212/// [`crate::spin_drift::calculate_dynamic_stability`] is a different Miller gyroscopic-stability
213/// calculation and is not a replacement for an aerodynamic dynamic-stability model.
214#[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
229/// Predict stability over trajectory with velocity and spin decay.
230///
231/// `spin_decay_factor` is the current spin rate divided by muzzle spin rate, typically 0.95-0.98
232/// because axial spin decays much more slowly than forward velocity.
233pub 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    // Velocity ratio
244    let velocity_ratio = current_velocity_fps / initial_velocity_fps;
245
246    // At otherwise fixed aerodynamic conditions, gyroscopic stability follows
247    // Sg ∝ spin_rate² / velocity². `spin_decay_factor` is already the independent spin-rate
248    // retention ratio; multiplying it by velocity_ratio would incorrectly force spin to decay in
249    // lockstep with forward speed and invert the downrange trend (MBA-1161).
250    let stability_ratio = (spin_decay_factor / velocity_ratio).powi(2);
251
252    initial_stability * stability_ratio
253}
254
255/// Check if bullet will remain stable throughout trajectory
256pub 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; // Minimum for adequate stability
270
271    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        // Test with .308 168gr Match bullet
293        let stability = calculate_advanced_stability(
294            168.0,   // mass in grains
295            2700.0,  // velocity in fps
296            10.0,    // twist rate in inches
297            0.308,   // caliber in inches
298            1.24,    // length in inches
299            1.225,   // air density
300            288.15,  // temperature in K
301            "match", // bullet type
302            true,    // has boat tail
303            false,   // no plastic tip
304        );
305
306        println!("Calculated stability: {}", stability);
307
308        // Should give stability around 1.4-1.8 for typical .308 Match
309        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,    // muzzle stability
321            2700.0, // muzzle velocity
322            1900.0, // terminal velocity
323            0.98,   // independent spin retention
324        );
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        // VLD should have lower nose_shape_factor (more streamlined)
351        assert!(vld_params.nose_shape_factor < match_params.nose_shape_factor);
352
353        // A Boolean cannot determine the metal length needed by Courtney-Miller, so the legacy
354        // scalar stays neutral and callers use the geometry-aware correction helper.
355        assert_eq!(hunting_params.plastic_tip_factor, 1.0);
356
357        // Default should have all factors at 1.0
358        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        // Published 60 gr V-MAX measurements: total length 0.868", metal length 0.738".
391        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        // Zero twist rate should return 0
440        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        // Zero caliber should return 0
446        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        // Zero length should return 0
452        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        // Higher velocity should give higher stability
461        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        // Higher altitude (lower density) should increase stability
541        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; // Deliberately non-standard to catch a second temperature term.
560
561        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        // Zero initial velocity should return initial stability
675        let zero_initial = predict_stability_at_distance(1.5, 0.0, 2000.0, 0.97);
676        assert_eq!(zero_initial, 1.5);
677
678        // Zero current velocity should return initial stability
679        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        // Use identical muzzle/terminal velocity and full spin retention to isolate status
686        // thresholds from the downrange stability correction.
687        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        // Marginal (1.0 - 1.3)
693        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        // Over-stabilized (> 2.5)
699        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        // Smaller caliber with same twist should be less stable
707        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        // Both should produce valid stability values
715        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        // Flat base should have slightly higher stability factor applied
729        // (boat_tail_factor < 1.0 for boat tails)
730        assert!(flat_base > boat_tail);
731    }
732}