Skip to main content

ballistics_engine/
spin_drift.rs

1use crate::pitch_damping::{
2    calculate_damped_yaw_of_repose, calculate_pitch_damping_moment, PitchDampingCoefficients,
3};
4use crate::spin_decay::{update_spin_rate, SpinDecayParameters};
5use crate::BallisticInputs;
6use std::f64::consts::PI;
7
8/// Components of enhanced spin drift calculation
9#[derive(Debug, Clone)]
10pub struct SpinDriftComponents {
11    pub spin_rate_rps: f64,          // Revolutions per second
12    pub spin_rate_rad_s: f64,        // Radians per second
13    pub stability_factor: f64,       // Gyroscopic stability (Sg)
14    pub yaw_of_repose_rad: f64,      // Equilibrium yaw angle
15    pub drift_rate_mps: f64,         // Lateral drift rate (m/s)
16    pub total_drift_m: f64,          // Total drift at current time
17    pub magnus_component_m: f64,     // Magnus effect contribution
18    pub gyroscopic_component_m: f64, // Pure gyroscopic drift
19    pub pitch_damping_moment: f64,   // Pitch damping moment (N⋅m)
20    pub yaw_convergence_rate: f64,   // Convergence rate to equilibrium (rad/s)
21    pub pitch_rate_rad_s: f64,       // Current pitch/yaw rate (rad/s)
22}
23
24/// Base Miller gyroscopic stability factor (no velocity/density correction).
25/// All inputs imperial: caliber/length in inches, mass in grains, twist in
26/// inches-per-turn. Returns 0.0 for non-positive inputs.
27///   Sg = 30 m / (t^2 d^3 l (1 + l^2)),  t,l in calibers, d in inches, m in grains
28pub(crate) fn miller_stability(
29    caliber_in: f64,
30    weight_gr: f64,
31    twist_in: f64,
32    length_in: f64,
33) -> f64 {
34    if caliber_in <= 0.0 || weight_gr <= 0.0 || twist_in <= 0.0 || length_in <= 0.0 {
35        return 0.0;
36    }
37    let twist_cal = twist_in / caliber_in;
38    let l_cal = length_in / caliber_in;
39    let denom = twist_cal * twist_cal * caliber_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
40    if denom == 0.0 {
41        return 0.0;
42    }
43    30.0 * weight_gr / denom
44}
45
46/// Empirical Litz spin-drift MAGNITUDE in inches from the muzzle gyroscopic stability `sg`
47/// and time of flight `t_s` (seconds):
48///   `drift_inches = 1.25 * (Sg + 1.2) * t^1.83`
49///
50/// This is the single source of the Litz drift coefficient (MBA-1134). It is UNSIGNED — the
51/// caller applies the twist-direction sign (see [`litz_drift_meters`]). cli_api::apply_spin_drift
52/// and the fast / Monte-Carlo path both go through this so the three solver families stay
53/// bit-identical on the drift math.
54pub fn litz_drift_inches(sg: f64, t_s: f64) -> f64 {
55    1.25 * (sg + 1.2) * t_s.powf(1.83)
56}
57
58/// Signed Litz spin drift in METERS along McCoy Z (lateral / windage). A right-hand twist
59/// drifts to the right (+Z); a left-hand twist drifts left (-Z). MBA-1134.
60pub fn litz_drift_meters(sg: f64, t_s: f64, is_twist_right: bool) -> f64 {
61    let sign = if is_twist_right { 1.0 } else { -1.0 };
62    sign * litz_drift_inches(sg, t_s) * 0.0254
63}
64
65/// Canonical muzzle gyroscopic stability Sg for the empirical Litz spin-drift model, shared by
66/// cli_api and the fast / Monte-Carlo path so every solver family uses ONE Sg (MBA-1134, rank 31).
67///
68/// Delegates to [`crate::stability::compute_stability_coefficient`], the single source of truth
69/// for Miller Sg. That INCLUDES the `(v/2800)^(1/3)` muzzle-velocity term (matching the reported
70/// SG and the aerodynamic-jump Sg) and the linear Miller density correction `(T/T0)*(P0/P)`.
71/// `temp_c` / `press_hpa` are the resolved muzzle atmosphere.
72///
73/// `compute_stability_coefficient` returns 0.0 when `bullet_length` is unset, so this first
74/// substitutes a length estimate. MBA-1135: use the mass-based [`crate::stability::estimate_bullet_length_m`]
75/// (falling back to the historical 4.5-caliber literal only when mass is unavailable) instead of a
76/// mass-blind 4.5-caliber default, so heavier/longer bullets get a physically consistent Sg.
77pub fn effective_sg_from_inputs(inputs: &BallisticInputs, temp_c: f64, press_hpa: f64) -> f64 {
78    let mut eff = inputs.clone();
79    if eff.bullet_length <= 0.0 && eff.bullet_diameter > 0.0 {
80        let est = crate::stability::estimate_bullet_length_m(eff.bullet_diameter, eff.bullet_mass);
81        eff.bullet_length = if est > 0.0 {
82            est
83        } else {
84            4.5 * eff.bullet_diameter // 4.5 calibers, mass unavailable
85        };
86    }
87    // atmo_params = (altitude, temp_c, press_hpa, _): compute_stability_coefficient reads only
88    // temp_c and press_hpa (altitude is ignored there), so pass the resolved muzzle values.
89    crate::stability::compute_stability_coefficient(&eff, (eff.altitude, temp_c, press_hpa, 0.0))
90}
91
92/// Calculate bullet spin rate from velocity and twist rate
93pub fn calculate_spin_rate(velocity_mps: f64, twist_rate_inches: f64) -> (f64, f64) {
94    if twist_rate_inches <= 0.0 {
95        return (0.0, 0.0);
96    }
97
98    // Convert velocity to inches/second
99    let velocity_ips = velocity_mps * 39.3701;
100
101    // Calculate revolutions per second
102    let spin_rate_rps = velocity_ips / twist_rate_inches;
103
104    // Convert to radians per second
105    let spin_rate_rad_s = spin_rate_rps * 2.0 * PI;
106
107    (spin_rate_rps, spin_rate_rad_s)
108}
109
110/// Calculate dynamic gyroscopic stability factor using Miller formula
111pub fn calculate_dynamic_stability(
112    bullet_mass_grains: f64,
113    velocity_mps: f64,
114    spin_rate_rad_s: f64,
115    caliber_inches: f64,
116    length_inches: f64,
117    air_density_kg_m3: f64,
118) -> f64 {
119    if spin_rate_rad_s == 0.0 || velocity_mps == 0.0 {
120        return 0.0;
121    }
122
123    // Convert velocity to fps for Miller formula
124    let velocity_fps = velocity_mps * 3.28084;
125
126    // Calculate twist rate in calibers
127    if caliber_inches > 0.0 {
128        // Back-calculate twist rate from spin rate
129        let spin_rps = spin_rate_rad_s / (2.0 * PI);
130        let velocity_ips = velocity_fps * 12.0; // inches per second
131        let twist_inches = if spin_rps > 0.0 {
132            velocity_ips / spin_rps
133        } else {
134            0.0
135        };
136        let twist_calibers = if twist_inches > 0.0 {
137            twist_inches / caliber_inches
138        } else {
139            0.0
140        };
141
142        // Length to diameter ratio
143        let length_calibers = if caliber_inches > 0.0 {
144            length_inches / caliber_inches
145        } else {
146            0.0
147        };
148
149        // Miller stability formula (simplified)
150        // Sg = 30 * m / (t^2 * d^3 * l * (1 + l^2))
151        // Where: m = mass in grains, t = twist in calibers, d = diameter in inches
152        //        l = length in calibers
153
154        if twist_calibers == 0.0 || length_calibers == 0.0 {
155            return 0.0;
156        }
157
158        let numerator = 30.0 * bullet_mass_grains;
159        let denominator = twist_calibers.powi(2)
160            * caliber_inches.powi(3)
161            * length_calibers
162            * (1.0 + length_calibers.powi(2));
163
164        if denominator == 0.0 {
165            return 0.0;
166        }
167
168        // Base stability
169        let sg_base = numerator / denominator;
170
171        // Velocity correction (compared to standard 2800 fps)
172        let velocity_factor = (velocity_fps / 2800.0).powf(1.0 / 3.0);
173
174        // Atmospheric correction (MBA-942): canonical Miller is LINEAR in density ratio
175        // (FTP = (T/T0)*(P0/P) = rho0/rho), matching stability.rs and py_ballisticcalc. The
176        // previous sqrt(1.225/rho) under-corrected Sg by ~14% at altitude. Standard
177        // conditions: 59°F, 29.92 inHg = 1.225 kg/m³ (sqrt(1)=1, so sea level is unchanged).
178        let density_factor = 1.225 / air_density_kg_m3;
179
180        // Final stability
181        sg_base * velocity_factor * density_factor
182    } else {
183        0.0
184    }
185}
186
187/// Calculate the yaw of repose (equilibrium yaw angle)
188pub fn calculate_yaw_of_repose(
189    stability_factor: f64,
190    velocity_mps: f64,
191    spin_rate_rad_s: f64,
192    wind_velocity_mps: f64,
193    pitch_rate_rad_s: f64,
194    air_density_kg_m3: f64,
195    caliber_inches: f64,
196    length_inches: f64,
197    mass_grains: f64,
198    mach: f64,
199    bullet_type: &str,
200    use_pitch_damping: bool,
201) -> (f64, f64) {
202    if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
203        return (0.0, 0.0);
204    }
205
206    // Use enhanced calculation with pitch damping if requested
207    if use_pitch_damping && mach > 0.0 {
208        // Map bullet types for pitch damping
209        let damping_type = match bullet_type.to_lowercase().as_str() {
210            "match" => "match_boat_tail",
211            "hunting" => "hunting",
212            "fmj" => "fmj",
213            "vld" => "vld",
214            _ => "match_boat_tail",
215        };
216
217        return calculate_damped_yaw_of_repose(
218            stability_factor,
219            velocity_mps,
220            spin_rate_rad_s,
221            wind_velocity_mps,
222            pitch_rate_rad_s,
223            air_density_kg_m3,
224            caliber_inches,
225            length_inches,
226            mass_grains,
227            mach,
228            damping_type,
229        );
230    }
231
232    // Original calculation (backward compatibility)
233    // Crosswind component creates yaw
234    let yaw_rad = if wind_velocity_mps == 0.0 {
235        // No wind - use typical value for spin drift
236        // Yaw develops due to nose following curved trajectory
237        0.002 // ~0.1 degrees typical
238    } else {
239        // Wind-induced yaw
240        if velocity_mps > 0.0 {
241            (wind_velocity_mps / velocity_mps).atan()
242        } else {
243            0.0
244        }
245    };
246
247    // Damping factor based on stability with safe division
248    let stability_term = (stability_factor - 1.0).max(0.0).sqrt();
249    let damping = 1.0 / (1.0 + stability_term);
250
251    (yaw_rad * damping, 0.0) // No convergence rate in simple model
252}
253
254/// Calculate Magnus effect contribution to drift
255pub fn calculate_magnus_drift_component(
256    velocity_mps: f64,
257    spin_rate_rad_s: f64,
258    yaw_rad: f64,
259    air_density_kg_m3: f64,
260    caliber_inches: f64,
261    time_s: f64,
262    mass_grains: f64,
263) -> f64 {
264    let diameter_m = caliber_inches * 0.0254;
265    let mass_kg = mass_grains * 0.00006479891; // Convert grains to kg
266
267    // Magnus force coefficient (empirical)
268    // Varies with Mach number
269    let mach = velocity_mps / 343.0; // Approximate speed of sound
270
271    let cmag = if mach < 0.8 {
272        0.25
273    } else if mach < 1.2 {
274        // Transonic reduction
275        0.15
276    } else {
277        // Supersonic
278        0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
279    };
280
281    // Spin ratio
282    let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
283
284    // Magnus force
285    let magnus_force = if velocity_mps > 0.0 {
286        cmag * spin_ratio
287            * yaw_rad
288            * 0.5
289            * air_density_kg_m3
290            * velocity_mps.powi(2)
291            * PI
292            * (diameter_m / 2.0).powi(2)
293    } else {
294        0.0
295    };
296
297    // Convert force to acceleration by dividing by mass
298    let magnus_accel = magnus_force / mass_kg;
299
300    // Drift over time (simplified - should integrate)
301
302    0.5 * magnus_accel * time_s.powi(2)
303}
304
305/// Calculate pure gyroscopic drift (Poisson effect)
306pub fn calculate_gyroscopic_drift(
307    stability_factor: f64,
308    _yaw_rad: f64,
309    velocity_mps: f64,
310    time_s: f64,
311    is_right_twist: bool,
312) -> f64 {
313    if stability_factor <= 1.0 || time_s <= 0.0 {
314        return 0.0;
315    }
316
317    // Litz formula is not reliable for subsonic flight. Disable it.
318    let velocity_fps = velocity_mps * 3.28084;
319    if velocity_fps < 1125.0 {
320        return 0.0;
321    }
322
323    // Direction based on twist
324    let sign = if is_right_twist { 1.0 } else { -1.0 };
325
326    // Bryan Litz's empirical formula for spin drift
327    let base_coefficient = 1.25 * (stability_factor + 1.2);
328    let time_factor = time_s.powf(1.83);
329    let drift_in = sign * base_coefficient * time_factor;
330
331    // Convert to meters
332
333    drift_in * 0.0254
334}
335
336/// Calculate enhanced spin drift with all components.
337///
338/// DEPRECATED (MBA-1134): this in-integration acceleration model is no longer wired into any
339/// solver path — spin drift is now the single canonical empirical Litz post-process
340/// ([`litz_drift_meters`], via [`effective_sg_from_inputs`]). Retained for backward compatibility
341/// and unit tests only. Do NOT reintroduce it into an integration loop alongside the Litz model,
342/// or lateral drift will be double-counted.
343pub fn calculate_enhanced_spin_drift(
344    bullet_mass: f64,
345    velocity_mps: f64,
346    twist_rate: f64,
347    bullet_diameter: f64,
348    bullet_length: f64,
349    is_twist_right: bool,
350    time_s: f64,
351    air_density: f64,
352    crosswind_mps: f64,
353    pitch_rate_rad_s: f64,
354    use_pitch_damping: bool,
355) -> SpinDriftComponents {
356    // Calculate initial spin rate (at muzzle)
357    let muzzle_velocity = velocity_mps; // Assuming we're passed muzzle velocity
358    let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
359
360    // Apply spin decay based on time of flight
361    let decay_params = SpinDecayParameters::from_bullet_type("match"); // Default to match for now
362    let current_spin_rad_s = update_spin_rate(
363        initial_spin_rad_s,
364        time_s,
365        velocity_mps,
366        air_density,
367        bullet_mass, // already grains (update_spin_rate wants mass_grains)
368        bullet_diameter,
369        bullet_length,
370        Some(&decay_params),
371    );
372
373    let spin_rps = current_spin_rad_s / (2.0 * PI);
374    let spin_rad_s = current_spin_rad_s;
375
376    // Calculate dynamic stability
377    let stability = calculate_dynamic_stability(
378        bullet_mass,
379        velocity_mps,
380        spin_rad_s,
381        bullet_diameter,
382        bullet_length,
383        air_density,
384    );
385
386    // Calculate Mach number for pitch damping
387    let mach = velocity_mps / 343.0; // Approximate speed of sound
388
389    // Determine bullet type (default to match for now)
390    let bullet_type = "match";
391
392    // Calculate yaw of repose with pitch damping
393    let (yaw_rad, convergence_rate) = calculate_yaw_of_repose(
394        stability,
395        velocity_mps,
396        spin_rad_s,
397        crosswind_mps,
398        pitch_rate_rad_s,
399        air_density,
400        bullet_diameter,
401        bullet_length,
402        bullet_mass,
403        mach,
404        bullet_type,
405        use_pitch_damping,
406    );
407
408    // Calculate Magnus component
409    let magnus_drift = calculate_magnus_drift_component(
410        velocity_mps,
411        spin_rad_s,
412        yaw_rad,
413        air_density,
414        bullet_diameter,
415        time_s,
416        bullet_mass,
417    );
418
419    // Calculate gyroscopic component
420    let gyro_drift =
421        calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
422
423    // Total drift. gyro_drift already carries the twist-direction sign (from
424    // calculate_gyroscopic_drift); sign the Magnus term to the SAME convention so both
425    // contributions are consistently directed before being summed. (magnus_component_m is
426    // kept unsigned below for backward compatibility.)
427    let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
428    let total_drift = twist_sign * magnus_drift + gyro_drift;
429
430    // Drift rate (derivative)
431    let drift_rate = if time_s > 0.0 {
432        total_drift / time_s
433    } else {
434        0.0
435    };
436
437    // Calculate pitch damping moment if using enhanced model
438    let pitch_damping_moment = if use_pitch_damping && mach > 0.0 {
439        let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
440        calculate_pitch_damping_moment(
441            pitch_rate_rad_s,
442            velocity_mps,
443            air_density,
444            bullet_diameter * 0.0254, // Convert to meters
445            bullet_length * 0.0254,   // Convert to meters
446            mach,
447            &coeffs,
448        )
449    } else {
450        0.0
451    };
452
453    SpinDriftComponents {
454        spin_rate_rps: spin_rps,
455        spin_rate_rad_s: spin_rad_s,
456        stability_factor: stability,
457        yaw_of_repose_rad: yaw_rad,
458        drift_rate_mps: drift_rate,
459        total_drift_m: total_drift,
460        magnus_component_m: magnus_drift,
461        gyroscopic_component_m: gyro_drift,
462        pitch_damping_moment,
463        yaw_convergence_rate: convergence_rate,
464        pitch_rate_rad_s,
465    }
466}
467
468/// Apply enhanced spin drift acceleration to derivatives.
469///
470/// DEPRECATED (MBA-1134): companion to [`calculate_enhanced_spin_drift`]; no longer called by any
471/// integration path. Spin drift is now the canonical Litz post-process ([`litz_drift_meters`]).
472/// Retained for backward compatibility and unit tests only.
473pub fn apply_enhanced_spin_drift(
474    derivatives: &mut [f64; 6],
475    spin_components: &SpinDriftComponents,
476    time_s: f64,
477    _is_right_twist: bool,
478) {
479    if time_s > 0.1 {
480        // Calculate acceleration from drift
481        // Using second derivative of position
482        let spin_accel_z = 2.0 * spin_components.drift_rate_mps / time_s;
483
484        // drift_rate_mps already carries the twist-direction sign (set in
485        // calculate_enhanced_spin_drift), so apply it directly. Multiplying by the twist
486        // sign again here previously CANCELED the gyroscopic sign (sign^2 = +1), so left-
487        // and right-twist barrels pushed spin drift the same way.
488        derivatives[5] += spin_accel_z;
489    }
490}
491
492/// Simplified interface for compatibility with existing code
493pub fn compute_enhanced_spin_drift_simple(
494    time_s: f64,
495    stability: f64,
496    velocity_mps: f64,
497    twist_rate: f64,
498    is_twist_right: bool,
499    _caliber: f64,
500) -> f64 {
501    if twist_rate <= 0.0 {
502        return 0.0;
503    }
504
505    // Calculate initial spin rate
506    let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
507
508    // Apply simple spin decay (assume 175gr bullet)
509    let decay_params = SpinDecayParameters::from_bullet_type("match");
510    let spin_rad_s = update_spin_rate(
511        initial_spin_rad_s,
512        time_s,
513        velocity_mps,
514        1.225, // Standard air density
515        175.0, // Standard bullet weight
516        _caliber,
517        1.3, // Standard bullet length
518        Some(&decay_params),
519    );
520
521    // Estimate yaw of repose (use simple model for compatibility)
522    let (yaw_rad, _) = calculate_yaw_of_repose(
523        stability,
524        velocity_mps,
525        spin_rad_s,
526        0.0,
527        0.0,
528        1.225,
529        _caliber,
530        1.3,
531        175.0,
532        velocity_mps / 343.0,
533        "match",
534        false,
535    );
536
537    // Calculate gyroscopic drift (primary component)
538
539    calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right)
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_calculate_spin_rate() {
548        // Test with 1:10 twist at 800 m/s
549        let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
550
551        // 800 m/s = 31496 in/s, divided by 10 = 3149.6 rps
552        assert!((rps - 3149.6).abs() < 1.0);
553        assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
554
555        // Test with zero twist rate
556        let (rps_zero, rad_s_zero) = calculate_spin_rate(800.0, 0.0);
557        assert_eq!(rps_zero, 0.0);
558        assert_eq!(rad_s_zero, 0.0);
559    }
560
561    #[test]
562    fn test_calculate_dynamic_stability() {
563        let sg = calculate_dynamic_stability(
564            168.0,   // grains
565            800.0,   // m/s
566            19792.0, // rad/s (from 1:10 twist)
567            0.308,   // inches
568            1.2,     // inches
569            1.225,   // kg/m³
570        );
571
572        // Should be > 1.0 for stable bullet
573        assert!(sg > 1.0);
574        assert!(sg < 10.0); // Reasonable upper bound
575    }
576
577    #[test]
578    fn test_calculate_yaw_of_repose() {
579        let (yaw, _) = calculate_yaw_of_repose(
580            2.5,     // Sg
581            800.0,   // velocity m/s
582            19792.0, // spin rate rad/s
583            10.0,    // crosswind m/s
584            0.0,     // pitch rate
585            1.225,   // air density
586            0.308,   // caliber
587            1.2,     // length
588            168.0,   // mass
589            2.33,    // mach
590            "match", // bullet type
591            false,   // use pitch damping
592        );
593
594        // Should be small but non-zero
595        assert!(yaw.abs() > 0.0);
596        assert!(yaw.abs() < 0.1); // Less than ~6 degrees
597    }
598
599    #[test]
600    fn test_enhanced_spin_drift_calculation() {
601        let components = calculate_enhanced_spin_drift(
602            168.0, // mass grains
603            800.0, // velocity m/s
604            10.0,  // twist rate inches
605            0.308, // caliber inches
606            1.2,   // length inches
607            true,  // right twist
608            1.0,   // time s
609            1.225, // air density
610            10.0,  // crosswind
611            0.0,   // pitch rate
612            false, // use pitch damping
613        );
614
615        // Should produce non-zero drift
616        assert!(components.total_drift_m.abs() > 0.0);
617        assert!(components.spin_rate_rps > 0.0);
618        assert!(components.stability_factor > 0.0);
619    }
620
621    #[test]
622    fn test_litz_drift_helpers_sign_and_magnitude() {
623        // litz_drift_inches is unsigned and matches 1.25*(Sg+1.2)*t^1.83 exactly.
624        let sg = 2.0_f64;
625        let t = 1.5_f64;
626        let expected_in = 1.25 * (sg + 1.2) * t.powf(1.83);
627        assert!((litz_drift_inches(sg, t) - expected_in).abs() < 1e-12);
628        // litz_drift_meters applies the twist sign and the inch->meter conversion.
629        let right = litz_drift_meters(sg, t, true);
630        let left = litz_drift_meters(sg, t, false);
631        assert!((right - expected_in * 0.0254).abs() < 1e-12);
632        assert!((right + left).abs() < 1e-12, "left twist must mirror right");
633        assert!(right > 0.0 && left < 0.0);
634    }
635
636    #[test]
637    fn test_effective_sg_from_inputs_includes_velocity_term_and_length_fallback() {
638        // effective_sg_from_inputs must (1) equal compute_stability_coefficient, (2) INCLUDE the
639        // (v/2800)^(1/3) muzzle-velocity term (so it differs from the bare geometric miller_stability),
640        // and (3) apply the 4.5-caliber length fallback when bullet_length is unset.
641        let inputs = BallisticInputs {
642            muzzle_velocity: 800.0, // 2624.7 fps -> velocity term < 1.0
643            bullet_mass: 175.0 * 0.00006479891,
644            bullet_diameter: 0.308 * 0.0254,
645            bullet_length: 1.24 * 0.0254,
646            twist_rate: 10.0,
647            ..Default::default()
648        };
649
650        let temp_c = 15.0;
651        let press_hpa = 1013.25;
652        let sg = effective_sg_from_inputs(&inputs, temp_c, press_hpa);
653
654        // (1) identical to compute_stability_coefficient on the same (length-filled) inputs.
655        let direct =
656            crate::stability::compute_stability_coefficient(&inputs, (0.0, temp_c, press_hpa, 0.0));
657        assert!((sg - direct).abs() < 1e-12, "sg {sg} != direct {direct}");
658
659        // (2) includes the velocity term: at sea-level standard the density factor is 1.0, so
660        //     sg == bare_geometric_Sg * (v_fps/2800)^(1/3).
661        let d_in = inputs.bullet_diameter / 0.0254;
662        let m_gr = inputs.bullet_mass / 0.00006479891;
663        let l_in = inputs.bullet_length / 0.0254;
664        let bare = miller_stability(d_in, m_gr, inputs.twist_rate, l_in);
665        let vel_corr = (inputs.muzzle_velocity * 3.28084 / 2800.0).powf(1.0 / 3.0);
666        assert!(vel_corr < 1.0, "muzzle < 2800 fps should shrink Sg");
667        assert!(
668            (sg - bare * vel_corr).abs() < 1e-6,
669            "sg {sg} != bare {bare} * vel_corr {vel_corr}"
670        );
671
672        // (3) MBA-1135: the zero-length fallback now uses the mass-based length estimate
673        // (crate::stability::estimate_bullet_length_m), NOT the old mass-blind 4.5-caliber
674        // length. Zero length must reproduce an explicit estimate-length input.
675        let mut no_len = inputs.clone();
676        no_len.bullet_length = 0.0;
677        let sg_fallback = effective_sg_from_inputs(&no_len, temp_c, press_hpa);
678        let mut explicit = inputs.clone();
679        explicit.bullet_length =
680            crate::stability::estimate_bullet_length_m(inputs.bullet_diameter, inputs.bullet_mass);
681        let sg_explicit = effective_sg_from_inputs(&explicit, temp_c, press_hpa);
682        assert!(
683            (sg_fallback - sg_explicit).abs() < 1e-12,
684            "zero-length fallback {sg_fallback} != explicit estimate-length {sg_explicit}"
685        );
686        assert!(sg_fallback > 0.0);
687        // And it must differ from the retired 4.5-caliber default (the whole point of MBA-1135).
688        let mut old_default = inputs.clone();
689        old_default.bullet_length = 4.5 * old_default.bullet_diameter;
690        let sg_old = effective_sg_from_inputs(&old_default, temp_c, press_hpa);
691        assert!(
692            (sg_fallback - sg_old).abs() > 1e-6,
693            "mass-based fallback should differ from the old 4.5-cal default"
694        );
695    }
696
697    #[test]
698    fn test_miller_stability_308_168gr() {
699        // .308, 168 gr, 1:12 twist, ~1.215 in length -> base Sg (no velocity/density correction)
700        // Formula: Sg = 30*m / (t^2 * d^3 * l * (1+l^2)), t and l in calibers
701        // twist_cal = 12/0.308 = 38.96, l_cal = 1.215/0.308 = 3.94 -> Sg ~ 1.74
702        let sg = miller_stability(0.308, 168.0, 12.0, 1.215);
703        assert!(sg > 1.5 && sg < 2.0, "expected base Sg ~1.74, got {}", sg);
704    }
705
706    #[test]
707    fn test_miller_stability_invalid_inputs_zero() {
708        assert_eq!(miller_stability(0.0, 168.0, 12.0, 1.2), 0.0);
709        assert_eq!(miller_stability(0.308, 0.0, 12.0, 1.2), 0.0);
710        assert_eq!(miller_stability(0.308, 168.0, 0.0, 1.2), 0.0);
711        assert_eq!(miller_stability(0.308, 168.0, 12.0, 0.0), 0.0);
712    }
713
714    #[test]
715    fn test_opposite_twist_directions() {
716        // Right twist
717        let right_drift = calculate_enhanced_spin_drift(
718            168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 0.0, 0.0, false,
719        );
720
721        // Left twist
722        let left_drift = calculate_enhanced_spin_drift(
723            168.0, 800.0, 10.0, 0.308, 1.2, false, 1.0, 1.225, 0.0, 0.0, false,
724        );
725
726        // Should have opposite signs for gyroscopic component
727        assert!(right_drift.gyroscopic_component_m * left_drift.gyroscopic_component_m < 0.0);
728        assert!(
729            (right_drift.gyroscopic_component_m.abs() - left_drift.gyroscopic_component_m.abs())
730                .abs()
731                < 0.001
732        );
733    }
734
735    #[test]
736    fn test_applied_spin_drift_flips_with_twist() {
737        // Regression: the APPLIED lateral acceleration (derivatives[5]) must reverse
738        // direction with the twist hand. apply_enhanced_spin_drift previously multiplied by
739        // the twist sign a second time, canceling the gyroscopic sign so left- and right-
740        // twist barrels pushed the same way. The existing test only checks the component
741        // field, never the applied derivative.
742        let time_s = 1.0;
743        let right = calculate_enhanced_spin_drift(
744            168.0, 800.0, 10.0, 0.308, 1.2, true, time_s, 1.225, 0.0, 0.0, false,
745        );
746        let left = calculate_enhanced_spin_drift(
747            168.0, 800.0, 10.0, 0.308, 1.2, false, time_s, 1.225, 0.0, 0.0, false,
748        );
749
750        let mut d_right = [0.0_f64; 6];
751        let mut d_left = [0.0_f64; 6];
752        apply_enhanced_spin_drift(&mut d_right, &right, time_s, true);
753        apply_enhanced_spin_drift(&mut d_left, &left, time_s, false);
754
755        assert!(d_right[5].abs() > 0.0, "expected non-zero spin drift accel");
756        assert!(d_left[5].abs() > 0.0, "expected non-zero spin drift accel");
757        assert!(
758            d_right[5] * d_left[5] < 0.0,
759            "expected opposite-sign lateral accel for opposite twist, got {} and {}",
760            d_right[5],
761            d_left[5]
762        );
763    }
764}