Skip to main content

ballistics_engine/
stability.rs

1use crate::InternalBallisticInputs as BallisticInputs;
2
3/// Calculate the gyroscopic stability coefficient (SG) for the bullet.
4///
5/// This function uses the Miller stability formula. An SG value greater than 1.5
6/// is generally considered to indicate adequate stability.
7///
8/// # Arguments
9/// * `inputs` - Ballistic input parameters
10/// * `atmo_params` - Atmospheric parameters (altitude, temp_c, pressure_hpa, density_ratio)
11///
12/// # Returns
13/// * Stability coefficient (dimensionless)
14pub fn compute_stability_coefficient(
15    inputs: &BallisticInputs,
16    atmo_params: (f64, f64, f64, f64),
17) -> f64 {
18    // Check for required parameters
19    if inputs.twist_rate == 0.0 || inputs.bullet_length == 0.0 || inputs.bullet_diameter == 0.0 {
20        return 0.0;
21    }
22
23    // Pre-calculated constants for efficiency
24    const MILLER_CONST: f64 = 30.0;
25    const TEMP_REF_K: f64 = 288.15; // 15°C
26    const PRESS_REF_HPA: f64 = 1013.25;
27
28    // Calculate intermediate values
29    // Convert twist rate from inches to meters for consistency
30    let twist_rate_m = inputs.twist_rate.abs() * 0.0254; // inches to meters
31    let twist_calibers = twist_rate_m / inputs.bullet_diameter;
32    let length_calibers = inputs.bullet_length / inputs.bullet_diameter;
33
34    // Convert units for Miller formula
35    let mass_grains = inputs.bullet_mass / 0.00006479891; // kg to grains
36    let diameter_inches = inputs.bullet_diameter / 0.0254; // meters to inches
37
38    // Miller stability formula components
39    let mass_term = MILLER_CONST * mass_grains;
40    let geom_term = twist_calibers.powi(2)
41        * diameter_inches.powi(3)
42        * length_calibers
43        * (1.0 + length_calibers.powi(2));
44
45    if geom_term == 0.0 {
46        return 0.0;
47    }
48
49    // Extract atmospheric parameters
50    let (_, temp_c, current_press_hpa, _) = atmo_params;
51    let temp_k = temp_c + 273.15;
52
53    // Atmospheric density correction factor
54    // Ratio of reference density to current density
55    let density_correction = (temp_k / TEMP_REF_K) * (PRESS_REF_HPA / current_press_hpa);
56
57    // Velocity correction factor
58    let velocity_correction = miller_velocity_correction(inputs.muzzle_velocity);
59
60    // Final stability calculation
61
62    (mass_term / geom_term) * velocity_correction * density_correction
63}
64
65/// Effective density (kg/m^3) used by the mass-based bullet-length estimate.
66///
67/// A jacketed lead-core bullet is not a solid cylinder of lead (11340 kg/m^3): the ogive/boat-tail
68/// taper and the copper jacket lower the *effective* density that relates total mass to a simple
69/// `length * frontal-area` cylinder. 7600 kg/m^3 was fit so common lead-core match bullets land at
70/// their real length / caliber ratio (see reference-bullet unit tests below). MBA-1135.
71pub const BULLET_LENGTH_RHO_EFF_KG_M3: f64 = 7600.0;
72
73/// Lower / upper bound on the estimated length-to-diameter ratio (calibers). Keeps degenerate
74/// mass/diameter combinations inside the range of real small-arms projectiles, including short
75/// handgun bullets.
76const MIN_LENGTH_CALIBERS: f64 = 1.2;
77const MAX_LENGTH_CALIBERS: f64 = 6.5;
78
79/// Gyroscopic-stability target used to synthesize a default twist when the shooter does not
80/// supply one (MBA-1135).
81///
82/// Chosen = **2.0** (see [`default_twist_inches`] docs for the reference-bullet comparison that
83/// motivated 2.0 over 1.5). A synthesized default should slightly *over*-stabilize rather than
84/// under-stabilize, and 2.0 reproduces the real factory twists of the twist-sensitive high-BC
85/// match bullets (.308/175 gr -> ~1:11", 6.5mm/140 gr -> ~1:8") most faithfully.
86pub const DEFAULT_TWIST_SG_TARGET: f64 = 2.0;
87
88const GRAINS_PER_KG: f64 = 1.0 / 0.00006479891;
89const METERS_PER_INCH: f64 = 0.0254;
90const MPS_TO_FPS: f64 = 3.28084;
91const MILLER_VEL_REF_FPS: f64 = 2800.0;
92
93/// Miller's cube-root launch-velocity correction relative to 2800 ft/s.
94///
95/// Gyroscopic stability is a launch property, so callers must pass muzzle velocity rather than
96/// re-evaluating this term from the decaying downrange airspeed.
97pub(crate) fn miller_velocity_correction(muzzle_velocity_mps: f64) -> f64 {
98    (muzzle_velocity_mps * MPS_TO_FPS / MILLER_VEL_REF_FPS).powf(1.0 / 3.0)
99}
100
101/// Estimate a bullet's length (meters) from its diameter and mass using a constant-effective-density
102/// cylinder model (MBA-1135).
103///
104/// Replaces the historical mass-blind `diameter * 4.5` fallback: two bullets of the same caliber but
105/// very different weights get very different lengths, which is what the Miller stability formula and
106/// the enhanced spin-drift / Magnus models actually need.
107///
108/// Model: `L = mass / (rho_eff * (pi/4) * d^2)` with `rho_eff = 7600 kg/m^3`, then the length/diameter
109/// ratio is clamped to `[1.2, 6.5]` calibers so pathological inputs stay physical without stretching
110/// common handgun bullets to rifle-like proportions.
111///
112/// Returns `0.0` for non-physical inputs (`mass_kg <= 0` or `diameter_m <= 0`); callers that need a
113/// non-zero length should fall back to their historical literal in that case.
114pub fn estimate_bullet_length_m(diameter_m: f64, mass_kg: f64) -> f64 {
115    if mass_kg <= 0.0 || diameter_m <= 0.0 || !mass_kg.is_finite() || !diameter_m.is_finite() {
116        return 0.0;
117    }
118
119    let frontal_area = std::f64::consts::FRAC_PI_4 * diameter_m * diameter_m;
120    let raw_length = mass_kg / (BULLET_LENGTH_RHO_EFF_KG_M3 * frontal_area);
121
122    // Clamp the length-to-diameter ratio (calibers) into a physically sensible band.
123    let length_calibers = (raw_length / diameter_m).clamp(MIN_LENGTH_CALIBERS, MAX_LENGTH_CALIBERS);
124    length_calibers * diameter_m
125}
126
127/// Synthesize a default barrel twist (inches per turn) for a bullet whose twist the shooter did not
128/// supply, by inverting the Miller stability formula for the twist that yields
129/// [`DEFAULT_TWIST_SG_TARGET`] (MBA-1135).
130///
131/// This replaces the mass-blind fixed `1:12"` fallback: heavier / longer bullets correctly demand a
132/// faster twist. The bullet length is taken from [`estimate_bullet_length_m`] (so the whole default
133/// is caliber + weight aware), the velocity term is `(v_fps / 2800)^(1/3)` at the supplied muzzle
134/// velocity, and the density correction is `1.0` (sea-level ICAO standard).
135///
136/// Inverting `Sg = 30 m_gr * vel_corr / (t_cal^2 * d_in^3 * L_cal * (1 + L_cal^2))`:
137///
138/// `t_cal = sqrt( 30 m_gr * vel_corr / (Sg_target * d_in^3 * L_cal * (1 + L_cal^2)) )`,
139/// `twist_in = t_cal * d_in`.
140///
141/// `Sg_target` was selected by comparing 1.5 vs 2.0 against common factory twists
142/// (velocities: .308/175 gr @ 2600 fps, .223/55 gr @ 3240 fps, .224/77 gr @ 2750 fps,
143/// 6.5mm/140 gr @ 2700 fps):
144///
145/// | bullet          | factory | Sg=1.5 | Sg=2.0 |
146/// |-----------------|---------|--------|--------|
147/// | .308 / 175 gr   | 1:10-12"| 1:12.9"| 1:11.2"|
148/// | .223 / 55 gr    | ~1:12"  | 1:11.7"| 1:10.1"|
149/// | .224 / 77 gr    | ~1:8"   | 1:8.4" | 1:7.2" |
150/// | 6.5mm / 140 gr  | ~1:8"   | 1:8.9" | 1:7.7" |
151///
152/// 2.0 wins: Sg=1.5 gives 1:12.9" for the 175 gr match bullet (no real .308 barrel is that slow and
153/// it under-stabilizes 175 gr), whereas 2.0's 1:11.2" / 1:8" are exactly the standard twists for the
154/// twist-sensitive high-BC bullets, and slight over-stabilization is the safe direction for a default.
155///
156/// Guards degenerate inputs (`diameter_m`, `mass_kg`, or `muzzle_velocity_mps` <= 0) by returning the
157/// historical `12.0` (1:12") fallback. Always returns a positive inches-per-turn value.
158pub fn default_twist_inches(diameter_m: f64, mass_kg: f64, muzzle_velocity_mps: f64) -> f64 {
159    const FALLBACK_TWIST_IN: f64 = 12.0;
160
161    if diameter_m <= 0.0
162        || mass_kg <= 0.0
163        || muzzle_velocity_mps <= 0.0
164        || !diameter_m.is_finite()
165        || !mass_kg.is_finite()
166        || !muzzle_velocity_mps.is_finite()
167    {
168        return FALLBACK_TWIST_IN;
169    }
170
171    let length_m = estimate_bullet_length_m(diameter_m, mass_kg);
172    if length_m <= 0.0 {
173        return FALLBACK_TWIST_IN;
174    }
175
176    let d_in = diameter_m / METERS_PER_INCH;
177    let m_gr = mass_kg * GRAINS_PER_KG;
178    let l_cal = length_m / diameter_m;
179    let velocity_correction = miller_velocity_correction(muzzle_velocity_mps);
180
181    let geom = d_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
182    let denom = DEFAULT_TWIST_SG_TARGET * geom;
183    if denom <= 0.0 {
184        return FALLBACK_TWIST_IN;
185    }
186
187    let t_cal_sq = 30.0 * m_gr * velocity_correction / denom;
188    if !t_cal_sq.is_finite() || t_cal_sq <= 0.0 {
189        return FALLBACK_TWIST_IN;
190    }
191
192    let twist_in = t_cal_sq.sqrt() * d_in;
193    if twist_in.is_finite() && twist_in > 0.0 {
194        twist_in
195    } else {
196        FALLBACK_TWIST_IN
197    }
198}
199
200/// Preserve a supplied twist (already converted to inches per turn), or synthesize the
201/// caliber/weight/velocity-aware default used by frontend argument parsers.
202#[cfg(any(target_arch = "wasm32", test))]
203pub(crate) fn resolve_twist_inches(
204    explicit_twist_inches: Option<f64>,
205    diameter_m: f64,
206    mass_kg: f64,
207    muzzle_velocity_mps: f64,
208) -> f64 {
209    explicit_twist_inches
210        .unwrap_or_else(|| default_twist_inches(diameter_m, mass_kg, muzzle_velocity_mps))
211}
212
213/// Calculate spin drift in meters using Litz approximation.
214///
215/// # Arguments
216/// * `time_s` - Time of flight in seconds
217/// * `stability` - Stability coefficient
218/// * `twist_rate` - Twist rate in inches (calibers per turn)
219/// * `is_twist_right` - True for right-hand twist, false for left-hand
220///
221/// # Returns
222/// * Spin drift in meters
223pub fn compute_spin_drift(
224    time_s: f64,
225    stability: f64,
226    twist_rate: f64,
227    is_twist_right: bool,
228) -> f64 {
229    compute_spin_drift_with_decay(time_s, stability, twist_rate, is_twist_right, None)
230}
231
232/// Calculate Litz spin drift with an optional extra retained-spin multiplier.
233///
234/// Canonical solver paths pass no multiplier because the empirical Litz time exponent already
235/// reflects real spin history. `Some` remains for source compatibility and experimental callers
236/// intentionally applying an extra scalar; canonical solvers must not use it for spin decay.
237#[allow(clippy::manual_clamp)] // max/min intentionally maps a NaN decay factor to zero.
238pub fn compute_spin_drift_with_decay(
239    time_s: f64,
240    stability: f64,
241    twist_rate: f64,
242    is_twist_right: bool,
243    decay_factor: Option<f64>, // Optional spin decay factor (0-1)
244) -> f64 {
245    if stability == 0.0 || time_s <= 0.0 || twist_rate == 0.0 {
246        return 0.0;
247    }
248
249    let sign = if is_twist_right { 1.0 } else { -1.0 };
250
251    // Litz empirical spin drift: inches = 1.25 * (SG + 1.2) * TOF^1.83.
252    // Keep this summary/API path consistent with cli_api::apply_spin_drift.
253    let scaling_factor = 1.25;
254    let base_drift = sign * scaling_factor * (stability + 1.2) * time_s.powf(1.83);
255
256    // Apply spin decay if provided
257    let effective_drift = if let Some(decay) = decay_factor {
258        base_drift * decay.max(0.0).min(1.0)
259    } else {
260        base_drift
261    };
262
263    // Convert inches to meters
264    effective_drift * 0.0254
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn create_test_inputs() -> BallisticInputs {
272        BallisticInputs {
273            muzzle_velocity: 823.0, // 2700 fps in m/s
274            bc_value: 0.5,
275            bullet_mass: 0.0109,      // 168 grains in kg
276            bullet_diameter: 0.00782, // 0.308 inches in meters
277            bullet_length: 0.033,     // in meters (1.3 inches)
278            twist_rate: 10.0,
279            ..Default::default()
280        }
281    }
282
283    #[test]
284    fn test_compute_stability_coefficient() {
285        let inputs = create_test_inputs();
286        let atmo_params = (0.0, 15.0, 1013.25, 1.0); // Standard conditions
287
288        let stability = compute_stability_coefficient(&inputs, atmo_params);
289
290        // Debug output
291        println!("Computed stability: {}", stability);
292
293        // Should be a reasonable stability value
294        assert!(stability > 0.0);
295        assert!(stability < 10.0); // Sanity check
296
297        // Test with typical values should give stability around 1.5-2.5
298        assert!(stability > 1.0);
299        assert!(stability < 3.0);
300    }
301
302    #[test]
303    fn test_compute_stability_coefficient_zero_cases() {
304        let mut inputs = create_test_inputs();
305        let atmo_params = (0.0, 15.0, 1013.25, 1.0);
306
307        // Test with zero twist rate
308        inputs.twist_rate = 0.0;
309        assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
310
311        // Test with zero bullet length
312        inputs = create_test_inputs();
313        inputs.bullet_length = 0.0;
314        assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
315
316        // Test with zero bullet diameter
317        inputs = create_test_inputs();
318        inputs.bullet_diameter = 0.0;
319        assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
320    }
321
322    #[test]
323    fn test_compute_stability_coefficient_atmospheric_effects() {
324        let inputs = create_test_inputs();
325
326        // Standard conditions
327        let standard_atmo = (0.0, 15.0, 1013.25, 1.0);
328        let standard_stability = compute_stability_coefficient(&inputs, standard_atmo);
329
330        // High altitude (lower pressure, lower temperature)
331        let high_alt_atmo = (3000.0, 5.0, 900.0, 1.0);
332        let high_alt_stability = compute_stability_coefficient(&inputs, high_alt_atmo);
333
334        // High altitude should have higher stability due to lower air density
335        assert!(high_alt_stability > standard_stability);
336
337        // Hot conditions (higher temperature)
338        let hot_atmo = (0.0, 35.0, 1013.25, 1.0);
339        let hot_stability = compute_stability_coefficient(&inputs, hot_atmo);
340
341        // Hot conditions should have higher stability due to lower air density
342        assert!(hot_stability > standard_stability);
343    }
344
345    #[test]
346    fn test_compute_spin_drift() {
347        let time_s = 1.5;
348        let stability = 2.0;
349        let twist_rate = 10.0;
350
351        // Test right-hand twist
352        let drift_right = compute_spin_drift(time_s, stability, twist_rate, true);
353        assert!(drift_right > 0.0); // Should drift to the right (positive)
354
355        // Test left-hand twist
356        let drift_left = compute_spin_drift(time_s, stability, twist_rate, false);
357        assert!(drift_left < 0.0); // Should drift to the left (negative)
358        assert!((drift_left + drift_right).abs() < 1e-10); // Should be equal magnitude
359
360        // Litz spin drift remains a small correction for this flight time.
361        assert!(drift_right.abs() < 0.25); // Less than 25cm for 1.5s flight
362    }
363
364    #[test]
365    fn test_compute_spin_drift_zero_cases() {
366        // Test with zero stability
367        assert_eq!(compute_spin_drift(1.5, 0.0, 10.0, true), 0.0);
368
369        // Test with zero time
370        assert_eq!(compute_spin_drift(0.0, 2.0, 10.0, true), 0.0);
371
372        // Test with negative time
373        assert_eq!(compute_spin_drift(-1.0, 2.0, 10.0, true), 0.0);
374
375        // Test with zero twist rate
376        assert_eq!(compute_spin_drift(1.5, 2.0, 0.0, true), 0.0);
377    }
378
379    // --- MBA-1135: mass-based length + Miller-inverse twist defaults ---
380
381    const GR_TO_KG: f64 = 0.00006479891;
382    const IN_TO_M: f64 = 0.0254;
383
384    fn len_in(diameter_in: f64, mass_gr: f64) -> f64 {
385        estimate_bullet_length_m(diameter_in * IN_TO_M, mass_gr * GR_TO_KG) / IN_TO_M
386    }
387
388    fn twist_in(diameter_in: f64, mass_gr: f64, v_fps: f64) -> f64 {
389        default_twist_inches(diameter_in * IN_TO_M, mass_gr * GR_TO_KG, v_fps * 0.3048)
390    }
391
392    #[test]
393    fn test_estimate_bullet_length_reference_bullets() {
394        // (diameter_in, mass_gr, expected_in, tolerance_in)
395        let cases = [
396            (0.308, 175.0, 1.24, 0.06), // .308 175 gr match  -> L/d ~4.0
397            (0.224, 77.0, 0.99, 0.06),  // .224 77 gr         -> long for caliber
398            (0.338, 300.0, 1.74, 0.06), // .338 300 gr
399            (0.224, 55.0, 0.72, 0.05),  // .224 55 gr varmint -> L/d ~3.2
400            (0.510, 750.0, 1.90, 0.08), // .510 750 gr
401        ];
402        for (d, m, expected, tol) in cases {
403            let got = len_in(d, m);
404            assert!(
405                (got - expected).abs() < tol,
406                ".{d}/{m}gr length: expected ~{expected}\", got {got:.4}\" (L/d {:.2})",
407                got / d
408            );
409        }
410    }
411
412    #[test]
413    fn test_estimate_bullet_length_preserves_handgun_geometry() {
414        // These common handgun bullets are shorter than the old 2.5-caliber rifle-oriented floor.
415        // The safety clamp must preserve the effective-density model for all of them.
416        for (diameter_in, mass_gr, expected_ratio) in [
417            (0.355, 115.0, 1.702_847_900_515), // 9 mm
418            (0.451, 230.0, 1.660_968_083_966), // .45 ACP
419            (0.355, 90.0, 1.332_663_574_316),  // .380 ACP
420        ] {
421            let diameter_m = diameter_in * IN_TO_M;
422            let mass_kg = mass_gr * GR_TO_KG;
423            let frontal_area = std::f64::consts::FRAC_PI_4 * diameter_m * diameter_m;
424            let model_ratio = mass_kg / (BULLET_LENGTH_RHO_EFF_KG_M3 * frontal_area) / diameter_m;
425            let estimated_ratio = estimate_bullet_length_m(diameter_m, mass_kg) / diameter_m;
426
427            assert!((estimated_ratio - model_ratio).abs() < 1e-12);
428            assert!((estimated_ratio - expected_ratio).abs() < 1e-12);
429        }
430    }
431
432    #[test]
433    fn test_estimate_bullet_length_degenerate_inputs() {
434        assert_eq!(estimate_bullet_length_m(0.00782, 0.0), 0.0);
435        assert_eq!(estimate_bullet_length_m(0.00782, -1.0), 0.0);
436        assert_eq!(estimate_bullet_length_m(0.0, 0.011), 0.0);
437        assert_eq!(estimate_bullet_length_m(-0.1, 0.011), 0.0);
438        assert_eq!(estimate_bullet_length_m(f64::NAN, 0.011), 0.0);
439    }
440
441    #[test]
442    fn test_estimate_bullet_length_clamps_ld_ratio() {
443        const EXPECTED_MIN: f64 = 1.2;
444        const EXPECTED_MAX: f64 = 6.5;
445
446        let diameter_m = 0.355 * IN_TO_M;
447        for raw_ratio in [1.19_f64, 1.20, 1.21, 6.49, 6.50, 6.51] {
448            let mass_kg = raw_ratio
449                * BULLET_LENGTH_RHO_EFF_KG_M3
450                * std::f64::consts::FRAC_PI_4
451                * diameter_m.powi(3);
452            let actual_ratio = estimate_bullet_length_m(diameter_m, mass_kg) / diameter_m;
453            let expected_ratio = raw_ratio.clamp(EXPECTED_MIN, EXPECTED_MAX);
454
455            assert!(
456                (actual_ratio - expected_ratio).abs() < 1e-12,
457                "raw L/d {raw_ratio}: expected {expected_ratio}, got {actual_ratio}"
458            );
459        }
460    }
461
462    #[test]
463    fn test_default_twist_reference_bullets() {
464        // With DEFAULT_TWIST_SG_TARGET = 2.0, these should land near common factory twists.
465        // (diameter_in, mass_gr, v_fps, expected_twist_in, tolerance_in)
466        let cases = [
467            (0.308, 175.0, 2600.0, 11.2, 1.2), // .308 175 gr -> ~1:10-12"
468            (0.224, 55.0, 3240.0, 10.1, 1.5),  // .223 55 gr  -> ~1:10-12"
469            (0.224, 77.0, 2750.0, 7.2, 1.2),   // .224 77 gr  -> ~1:7-8"
470            (0.264, 140.0, 2700.0, 7.7, 1.2),  // 6.5mm 140 gr -> ~1:8"
471        ];
472        for (d, m, v, expected, tol) in cases {
473            let got = twist_in(d, m, v);
474            assert!(got > 0.0, ".{d}/{m}gr twist must be positive, got {got}");
475            assert!(
476                (got - expected).abs() < tol,
477                ".{d}/{m}gr twist: expected ~1:{expected}\", got 1:{got:.2}\"",
478            );
479        }
480    }
481
482    #[test]
483    fn test_default_twist_yields_target_sg() {
484        // By construction, at sea-level standard the synthesized twist should reproduce
485        // DEFAULT_TWIST_SG_TARGET when the same estimated length is used for the Sg check.
486        let d_m = 0.308 * IN_TO_M;
487        let m_kg = 175.0 * GR_TO_KG;
488        let v_mps = 2600.0 * 0.3048;
489        let twist = default_twist_inches(d_m, m_kg, v_mps);
490        let inputs = BallisticInputs {
491            muzzle_velocity: v_mps,
492            bullet_mass: m_kg,
493            bullet_diameter: d_m,
494            bullet_length: estimate_bullet_length_m(d_m, m_kg),
495            twist_rate: twist,
496            ..Default::default()
497        };
498        let sg = compute_stability_coefficient(&inputs, (0.0, 15.0, 1013.25, 1.0));
499        assert!(
500            (sg - DEFAULT_TWIST_SG_TARGET).abs() < 0.02,
501            "expected Sg ~{DEFAULT_TWIST_SG_TARGET}, got {sg}"
502        );
503    }
504
505    #[test]
506    fn test_default_twist_degenerate_inputs_fall_back() {
507        assert_eq!(default_twist_inches(0.0, 0.011, 800.0), 12.0);
508        assert_eq!(default_twist_inches(0.00782, 0.0, 800.0), 12.0);
509        assert_eq!(default_twist_inches(0.00782, 0.011, 0.0), 12.0);
510        assert_eq!(default_twist_inches(f64::NAN, 0.011, 800.0), 12.0);
511    }
512
513    #[test]
514    fn test_resolve_twist_preserves_explicit_or_uses_miller_default() {
515        let diameter_m = 0.224 * IN_TO_M;
516        let mass_kg = 77.0 * GR_TO_KG;
517        let velocity_mps = 2750.0 * 0.3048;
518        let expected_default = default_twist_inches(diameter_m, mass_kg, velocity_mps);
519
520        assert_eq!(
521            resolve_twist_inches(Some(9.5), diameter_m, mass_kg, velocity_mps).to_bits(),
522            9.5_f64.to_bits()
523        );
524        assert_eq!(
525            resolve_twist_inches(None, diameter_m, mass_kg, velocity_mps).to_bits(),
526            expected_default.to_bits()
527        );
528        assert_ne!(expected_default.to_bits(), 12.0_f64.to_bits());
529
530        let metric_default = resolve_twist_inches(
531            None,
532            5.6896 * 0.001,
533            4.98951607 * 0.001,
534            838.2,
535        );
536        assert!((metric_default - expected_default).abs() < 1e-12);
537    }
538
539    #[test]
540    fn test_heavier_bullet_needs_faster_twist() {
541        // Same caliber + velocity: the heavier (longer) bullet must get a faster (smaller) twist.
542        let light = twist_in(0.224, 55.0, 2900.0);
543        let heavy = twist_in(0.224, 77.0, 2900.0);
544        assert!(heavy < light, "77gr twist {heavy} should be faster than 55gr {light}");
545    }
546
547    #[test]
548    fn test_print_reference_estimates() {
549        // Diagnostic dump for the MBA-1135 report: `cargo test print_reference_estimates -- --nocapture`.
550        println!("\n=== MBA-1135 estimate_bullet_length_m ===");
551        for (d, m) in [
552            (0.308, 175.0),
553            (0.224, 77.0),
554            (0.338, 300.0),
555            (0.224, 55.0),
556            (0.510, 750.0),
557            (0.264, 140.0),
558        ] {
559            let l = len_in(d, m);
560            println!(".{d}/{m}gr -> {l:.4}\" (L/d {:.3})", l / d);
561        }
562        println!("\n=== MBA-1135 default_twist_inches (SG=1.5 vs 2.0) ===");
563        for (d, m, v) in [
564            (0.308, 175.0, 2600.0),
565            (0.224, 55.0, 3240.0),
566            (0.224, 77.0, 2750.0),
567            (0.264, 140.0, 2700.0),
568        ] {
569            // Reconstruct both targets by scaling: t_cal ~ 1/sqrt(Sg) so twist(1.5)=twist(2.0)*sqrt(2.0/1.5).
570            let t20 = twist_in(d, m, v);
571            let t15 = t20 * (DEFAULT_TWIST_SG_TARGET / 1.5_f64).sqrt();
572            println!(".{d}/{m}gr @ {v}fps -> SG1.5: 1:{t15:.2}\"  SG2.0: 1:{t20:.2}\"");
573        }
574        println!();
575    }
576
577    #[test]
578    fn test_compute_spin_drift_scaling() {
579        let stability = 2.0;
580        let twist_rate = 10.0;
581
582        // Test time scaling
583        let drift_1s = compute_spin_drift(1.0, stability, twist_rate, true);
584        let drift_2s = compute_spin_drift(2.0, stability, twist_rate, true);
585
586        // Drift should increase with time (non-linearly due to 1.83 exponent)
587        assert!(drift_2s > drift_1s);
588
589        // Test stability scaling
590        let drift_low_stability = compute_spin_drift(1.5, 1.0, twist_rate, true);
591        let drift_high_stability = compute_spin_drift(1.5, 3.0, twist_rate, true);
592
593        // Higher stability should produce more drift
594        assert!(drift_high_stability > drift_low_stability);
595    }
596}