Skip to main content

ballistics_engine/
spin_drift.rs

1use crate::gravity::Gravity;
2use crate::pitch_damping::{
3    calculate_damped_yaw_of_repose_with_gravity, calculate_gravity_yaw_of_repose_with_gravity,
4    calculate_pitch_damping_moment, PitchDampingCoefficients,
5};
6use crate::spin_decay::{update_spin_rate, SpinDecayParameters};
7use crate::BallisticInputs;
8use std::f64::consts::PI;
9
10const LITZ_TIME_EXPONENT: f64 = 1.83;
11
12/// Components of enhanced spin drift calculation
13#[derive(Debug, Clone)]
14pub struct SpinDriftComponents {
15    pub spin_rate_rps: f64,          // Revolutions per second
16    pub spin_rate_rad_s: f64,        // Radians per second
17    pub stability_factor: f64,       // Gyroscopic stability (Sg)
18    pub yaw_of_repose_rad: f64,      // Equilibrium yaw angle
19    pub drift_rate_mps: f64,         // Lateral drift rate (m/s)
20    pub total_drift_m: f64,          // Total drift at current time
21    pub magnus_component_m: f64,     // Magnus effect contribution
22    pub gyroscopic_component_m: f64, // Pure gyroscopic drift
23    pub pitch_damping_moment: f64,   // Pitch damping moment (N⋅m)
24    pub yaw_convergence_rate: f64,   // Signed convergence rate (1/s; negative is divergent)
25    pub pitch_rate_rad_s: f64,       // Current pitch/yaw rate (rad/s)
26}
27
28/// Base Miller gyroscopic stability factor (no velocity/density correction).
29/// All inputs imperial: caliber/length in inches, mass in grains, twist in
30/// inches-per-turn. Returns 0.0 for non-positive inputs.
31///   Sg = 30 m / (t^2 d^3 l (1 + l^2)),  t,l in calibers, d in inches, m in grains
32pub(crate) fn miller_stability(
33    caliber_in: f64,
34    weight_gr: f64,
35    twist_in: f64,
36    length_in: f64,
37) -> f64 {
38    if caliber_in <= 0.0 || weight_gr <= 0.0 || twist_in <= 0.0 || length_in <= 0.0 {
39        return 0.0;
40    }
41    let twist_cal = twist_in / caliber_in;
42    let l_cal = length_in / caliber_in;
43    let denom = twist_cal * twist_cal * caliber_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
44    if denom == 0.0 {
45        return 0.0;
46    }
47    30.0 * weight_gr / denom
48}
49
50/// Empirical Litz spin-drift MAGNITUDE in inches from the muzzle gyroscopic stability `sg`
51/// and time of flight `t_s` (seconds):
52///   `drift_inches = 1.25 * (Sg + 1.2) * t^1.83`
53///
54/// This is the single source of the Litz drift coefficient (MBA-1134). It is UNSIGNED — the
55/// caller applies the twist-direction sign (see [`litz_drift_meters`]). cli_api::apply_spin_drift
56/// and the fast / Monte-Carlo path both go through this so the three solver families stay
57/// bit-identical on the drift math.
58pub fn litz_drift_inches(sg: f64, t_s: f64) -> f64 {
59    1.25 * (sg + 1.2) * t_s.powf(LITZ_TIME_EXPONENT)
60}
61
62/// Signed Litz spin drift in METERS along McCoy Z (lateral / windage). A right-hand twist
63/// drifts to the right (+Z); a left-hand twist drifts left (-Z). MBA-1134.
64pub fn litz_drift_meters(sg: f64, t_s: f64, is_twist_right: bool) -> f64 {
65    let sign = if is_twist_right { 1.0 } else { -1.0 };
66    sign * litz_drift_inches(sg, t_s) * 0.0254
67}
68
69/// Canonical muzzle gyroscopic stability Sg for the empirical Litz spin-drift model, shared by
70/// cli_api and the fast / Monte-Carlo path so every solver family uses ONE Sg (MBA-1134, rank 31).
71///
72/// Delegates to [`crate::stability::compute_stability_coefficient`], the single source of truth
73/// for Miller Sg. That INCLUDES the `(v/2800)^(1/3)` muzzle-velocity term (matching the reported
74/// SG and the aerodynamic-jump Sg) and the linear Miller density correction `(T/T0)*(P0/P)`.
75/// `temp_c` / `press_hpa` are the resolved muzzle atmosphere.
76///
77/// `compute_stability_coefficient` returns 0.0 when `bullet_length` is unset, so this first
78/// substitutes a length estimate. MBA-1135: use the mass-based [`crate::stability::estimate_bullet_length_m`]
79/// (falling back to the historical 4.5-caliber literal only when mass is unavailable) instead of a
80/// mass-blind 4.5-caliber default, so heavier/longer bullets get a physically consistent Sg.
81pub fn effective_sg_from_inputs(inputs: &BallisticInputs, temp_c: f64, press_hpa: f64) -> f64 {
82    let mut eff = inputs.clone();
83    if eff.bullet_length <= 0.0 && eff.bullet_diameter > 0.0 {
84        let est = crate::stability::estimate_bullet_length_m(eff.bullet_diameter, eff.bullet_mass);
85        eff.bullet_length = if est > 0.0 {
86            est
87        } else {
88            4.5 * eff.bullet_diameter // 4.5 calibers, mass unavailable
89        };
90    }
91    // atmo_params = (altitude, temp_c, press_hpa, _): compute_stability_coefficient reads only
92    // temp_c and press_hpa (altitude is ignored there), so pass the resolved muzzle values.
93    crate::stability::compute_stability_coefficient(&eff, (eff.altitude, temp_c, press_hpa, 0.0))
94}
95
96/// Calculate bullet spin rate from velocity and twist rate
97pub fn calculate_spin_rate(velocity_mps: f64, twist_rate_inches: f64) -> (f64, f64) {
98    if twist_rate_inches <= 0.0 {
99        return (0.0, 0.0);
100    }
101
102    // Convert velocity to inches/second
103    let velocity_ips = velocity_mps * 39.3701;
104
105    // Calculate revolutions per second
106    let spin_rate_rps = velocity_ips / twist_rate_inches;
107
108    // Convert to radians per second
109    let spin_rate_rad_s = spin_rate_rps * 2.0 * PI;
110
111    (spin_rate_rps, spin_rate_rad_s)
112}
113
114/// Return muzzle-set spin rate and the dimensionless spin parameter at the current airspeed.
115///
116/// These acceleration kernels do not integrate roll as a state, so spin is held at its launch
117/// value while translational airspeed changes. Modeling spin decay would require carrying roll
118/// rate through the integrator rather than re-deriving it from current velocity.
119pub(crate) fn calculate_magnus_spin_state(
120    muzzle_velocity_mps: f64,
121    current_velocity_mps: f64,
122    twist_rate_inches: f64,
123    caliber_m: f64,
124) -> (f64, f64) {
125    let (_, spin_rate_rad_s) = calculate_spin_rate(muzzle_velocity_mps, twist_rate_inches);
126    let spin_parameter = if current_velocity_mps > 1e-9 {
127        spin_rate_rad_s * caliber_m / (2.0 * current_velocity_mps)
128    } else {
129        0.0
130    };
131    (spin_rate_rad_s, spin_parameter)
132}
133
134/// Calculate current gyroscopic stability from retained spin using the Miller formula.
135///
136/// The effective twist is back-calculated from the supplied roll rate and current airspeed, then
137/// corrected for current velocity and density. Callers that do not integrate roll should pass the
138/// muzzle-set spin rate while allowing `velocity_mps` and `air_density_kg_m3` to evolve.
139pub fn calculate_dynamic_stability(
140    bullet_mass_grains: f64,
141    velocity_mps: f64,
142    spin_rate_rad_s: f64,
143    caliber_inches: f64,
144    length_inches: f64,
145    air_density_kg_m3: f64,
146) -> f64 {
147    if spin_rate_rad_s == 0.0 || velocity_mps == 0.0 || caliber_inches <= 0.0 {
148        return 0.0;
149    }
150
151    // Convert velocity to fps for Miller formula
152    let velocity_fps = velocity_mps * 3.28084;
153
154    // Back-calculate the effective twist from the retained spin and current velocity.
155    let spin_rps = spin_rate_rad_s / (2.0 * PI);
156    if spin_rps <= 0.0 {
157        return 0.0;
158    }
159    let velocity_ips = velocity_fps * 12.0; // inches per second
160    let twist_inches = velocity_ips / spin_rps;
161    let sg_base = miller_stability(
162        caliber_inches,
163        bullet_mass_grains,
164        twist_inches,
165        length_inches,
166    );
167    if sg_base == 0.0 {
168        return 0.0;
169    }
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    sg_base * velocity_factor * density_factor
181}
182
183/// Calculate the yaw of repose (equilibrium yaw angle)
184#[allow(clippy::too_many_arguments)] // Public compatibility API; grouping would be breaking.
185pub fn calculate_yaw_of_repose(
186    stability_factor: f64,
187    velocity_mps: f64,
188    spin_rate_rad_s: f64,
189    wind_velocity_mps: f64,
190    pitch_rate_rad_s: f64,
191    air_density_kg_m3: f64,
192    caliber_inches: f64,
193    length_inches: f64,
194    mass_grains: f64,
195    mach: f64,
196    bullet_type: &str,
197    use_pitch_damping: bool,
198) -> (f64, f64) {
199    calculate_yaw_of_repose_with_gravity(
200        stability_factor,
201        velocity_mps,
202        spin_rate_rad_s,
203        wind_velocity_mps,
204        pitch_rate_rad_s,
205        air_density_kg_m3,
206        caliber_inches,
207        length_inches,
208        mass_grains,
209        mach,
210        bullet_type,
211        use_pitch_damping,
212        Gravity::EARTH,
213    )
214}
215
216#[allow(clippy::too_many_arguments)]
217pub(crate) fn calculate_yaw_of_repose_with_gravity(
218    stability_factor: f64,
219    velocity_mps: f64,
220    spin_rate_rad_s: f64,
221    wind_velocity_mps: f64,
222    pitch_rate_rad_s: f64,
223    air_density_kg_m3: f64,
224    caliber_inches: f64,
225    length_inches: f64,
226    mass_grains: f64,
227    mach: f64,
228    bullet_type: &str,
229    use_pitch_damping: bool,
230    gravity: Gravity,
231) -> (f64, f64) {
232    if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
233        return (0.0, 0.0);
234    }
235
236    // Use enhanced calculation with pitch damping if requested
237    if use_pitch_damping && mach > 0.0 {
238        // Map bullet types for pitch damping
239        let damping_type = match bullet_type.to_lowercase().as_str() {
240            "match" => "match_boat_tail",
241            "hunting" => "hunting",
242            "fmj" => "fmj",
243            "vld" => "vld",
244            _ => "match_boat_tail",
245        };
246
247        return calculate_damped_yaw_of_repose_with_gravity(
248            stability_factor,
249            velocity_mps,
250            spin_rate_rad_s,
251            wind_velocity_mps,
252            pitch_rate_rad_s,
253            air_density_kg_m3,
254            caliber_inches,
255            length_inches,
256            mass_grains,
257            mach,
258            damping_type,
259            gravity,
260        );
261    }
262
263    // Crosswind yaw is a muzzle transient, not a persistent equilibrium. The simple and damped
264    // paths share the same gravity/gyroscopic repose angle; only the damped path reports a
265    // convergence rate.
266    let yaw_rad = calculate_gravity_yaw_of_repose_with_gravity(
267        stability_factor,
268        velocity_mps,
269        spin_rate_rad_s,
270        mass_grains * crate::constants::GRAINS_TO_KG,
271        caliber_inches * 0.0254,
272        length_inches * 0.0254,
273        gravity,
274    );
275
276    (yaw_rad, 0.0)
277}
278
279/// Calculate Magnus effect contribution to drift
280pub fn calculate_magnus_drift_component(
281    velocity_mps: f64,
282    spin_rate_rad_s: f64,
283    yaw_rad: f64,
284    air_density_kg_m3: f64,
285    caliber_inches: f64,
286    time_s: f64,
287    mass_grains: f64,
288) -> f64 {
289    let diameter_m = caliber_inches * 0.0254;
290    let mass_kg = mass_grains * crate::constants::GRAINS_TO_KG; // Convert grains to kg
291
292    // Magnus force coefficient (empirical)
293    // Varies with Mach number
294    let mach = velocity_mps / 343.0; // Approximate speed of sound
295
296    let cmag = if mach < 0.8 {
297        0.25
298    } else if mach < 1.2 {
299        // Transonic reduction
300        0.15
301    } else {
302        // Supersonic
303        0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
304    };
305
306    // Spin ratio
307    let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
308
309    // Magnus force
310    let magnus_force = if velocity_mps > 0.0 {
311        cmag * spin_ratio
312            * yaw_rad
313            * 0.5
314            * air_density_kg_m3
315            * velocity_mps.powi(2)
316            * PI
317            * (diameter_m / 2.0).powi(2)
318    } else {
319        0.0
320    };
321
322    // Convert force to acceleration by dividing by mass
323    let magnus_accel = magnus_force / mass_kg;
324
325    // Drift over time (simplified - should integrate)
326
327    0.5 * magnus_accel * time_s.powi(2)
328}
329
330/// Calculate the cumulative empirical Litz gyroscopic-drift component.
331///
332/// `velocity_mps` is retained for source compatibility but intentionally does not gate the
333/// accumulated displacement. A stateless current-velocity check cannot erase or freeze drift
334/// accrued earlier in flight; live solver paths use [`litz_drift_meters`] directly.
335pub fn calculate_gyroscopic_drift(
336    stability_factor: f64,
337    _yaw_rad: f64,
338    _velocity_mps: f64,
339    time_s: f64,
340    is_right_twist: bool,
341) -> f64 {
342    if stability_factor <= 1.0 || time_s <= 0.0 {
343        return 0.0;
344    }
345
346    litz_drift_meters(stability_factor, time_s, is_right_twist)
347}
348
349/// Calculate enhanced spin drift with all components.
350///
351/// DEPRECATED (MBA-1134): this in-integration acceleration model is no longer wired into any
352/// solver path — spin drift is now the single canonical empirical Litz post-process
353/// ([`litz_drift_meters`], via [`effective_sg_from_inputs`]). Retained for backward compatibility
354/// and unit tests only. Do NOT reintroduce it into an integration loop alongside the Litz model,
355/// or lateral drift will be double-counted.
356#[allow(clippy::too_many_arguments)] // Deprecated compatibility API; preserve its signature.
357pub fn calculate_enhanced_spin_drift(
358    bullet_mass: f64,
359    velocity_mps: f64,
360    twist_rate: f64,
361    bullet_diameter: f64,
362    bullet_length: f64,
363    is_twist_right: bool,
364    time_s: f64,
365    air_density: f64,
366    crosswind_mps: f64,
367    pitch_rate_rad_s: f64,
368    use_pitch_damping: bool,
369) -> SpinDriftComponents {
370    // Calculate initial spin rate (at muzzle)
371    let muzzle_velocity = velocity_mps; // Assuming we're passed muzzle velocity
372    let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
373
374    // Apply spin decay based on time of flight
375    let decay_params = SpinDecayParameters::from_bullet_type("match"); // Default to match for now
376    let current_spin_rad_s = update_spin_rate(
377        initial_spin_rad_s,
378        time_s,
379        velocity_mps,
380        air_density,
381        bullet_mass, // already grains (update_spin_rate wants mass_grains)
382        bullet_diameter,
383        bullet_length,
384        Some(&decay_params),
385    );
386
387    let spin_rps = current_spin_rad_s / (2.0 * PI);
388    let spin_rad_s = current_spin_rad_s;
389
390    // Calculate dynamic stability
391    let stability = calculate_dynamic_stability(
392        bullet_mass,
393        velocity_mps,
394        spin_rad_s,
395        bullet_diameter,
396        bullet_length,
397        air_density,
398    );
399
400    // Calculate Mach number for pitch damping
401    let mach = velocity_mps / 343.0; // Approximate speed of sound
402
403    // Determine bullet type (default to match for now)
404    let bullet_type = "match";
405
406    // Calculate yaw of repose with pitch damping
407    let (yaw_rad, convergence_rate) = calculate_yaw_of_repose(
408        stability,
409        velocity_mps,
410        spin_rad_s,
411        crosswind_mps,
412        pitch_rate_rad_s,
413        air_density,
414        bullet_diameter,
415        bullet_length,
416        bullet_mass,
417        mach,
418        bullet_type,
419        use_pitch_damping,
420    );
421
422    // Calculate Magnus component
423    let magnus_drift = calculate_magnus_drift_component(
424        velocity_mps,
425        spin_rad_s,
426        yaw_rad,
427        air_density,
428        bullet_diameter,
429        time_s,
430        bullet_mass,
431    );
432
433    // Calculate gyroscopic component
434    let gyro_drift =
435        calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
436
437    // Total drift. gyro_drift already carries the twist-direction sign (from
438    // calculate_gyroscopic_drift); sign the Magnus term to the SAME convention so both
439    // contributions are consistently directed before being summed. (magnus_component_m is
440    // kept unsigned below for backward compatibility.)
441    let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
442    let total_drift = twist_sign * magnus_drift + gyro_drift;
443
444    // Drift rate (derivative)
445    let drift_rate = if time_s > 0.0 {
446        total_drift / time_s
447    } else {
448        0.0
449    };
450
451    // Calculate pitch damping moment if using enhanced model
452    let pitch_damping_moment = if use_pitch_damping && mach > 0.0 {
453        let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
454        calculate_pitch_damping_moment(
455            pitch_rate_rad_s,
456            velocity_mps,
457            air_density,
458            bullet_diameter * 0.0254, // Convert to meters
459            bullet_length * 0.0254,   // Convert to meters
460            mach,
461            &coeffs,
462        )
463    } else {
464        0.0
465    };
466
467    SpinDriftComponents {
468        spin_rate_rps: spin_rps,
469        spin_rate_rad_s: spin_rad_s,
470        stability_factor: stability,
471        yaw_of_repose_rad: yaw_rad,
472        drift_rate_mps: drift_rate,
473        total_drift_m: total_drift,
474        magnus_component_m: magnus_drift,
475        gyroscopic_component_m: gyro_drift,
476        pitch_damping_moment,
477        yaw_convergence_rate: convergence_rate,
478        pitch_rate_rad_s,
479    }
480}
481
482/// Apply enhanced spin drift acceleration to derivatives.
483///
484/// DEPRECATED (MBA-1134): companion to [`calculate_enhanced_spin_drift`]; no longer called by any
485/// integration path. Spin drift is now the canonical Litz post-process ([`litz_drift_meters`]).
486/// Retained for backward compatibility and unit tests only.
487pub fn apply_enhanced_spin_drift(
488    derivatives: &mut [f64; 6],
489    spin_components: &SpinDriftComponents,
490    time_s: f64,
491    _is_right_twist: bool,
492) {
493    if time_s > 0.1 {
494        // Back out acceleration from the public average drift-rate field while preserving its
495        // legacy authority. For displacement proportional to t^n, a = n*(n-1)*drift/t^2;
496        // drift_rate_mps stores drift/t, so divide it by time once more.
497        let gyroscopic_factor = LITZ_TIME_EXPONENT * (LITZ_TIME_EXPONENT - 1.0);
498        let spin_accel_z = gyroscopic_factor * spin_components.drift_rate_mps / time_s;
499
500        // drift_rate_mps already carries the twist-direction sign (set in
501        // calculate_enhanced_spin_drift), so apply it directly. Multiplying by the twist
502        // sign again here previously CANCELED the gyroscopic sign (sign^2 = +1), so left-
503        // and right-twist barrels pushed spin drift the same way.
504        derivatives[5] += spin_accel_z;
505    }
506}
507
508/// Simplified interface for compatibility with existing code
509pub fn compute_enhanced_spin_drift_simple(
510    time_s: f64,
511    stability: f64,
512    velocity_mps: f64,
513    twist_rate: f64,
514    is_twist_right: bool,
515    _caliber: f64,
516) -> f64 {
517    if twist_rate <= 0.0 {
518        return 0.0;
519    }
520
521    // Calculate initial spin rate
522    let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
523
524    // Apply simple spin decay (assume 175gr bullet)
525    let decay_params = SpinDecayParameters::from_bullet_type("match");
526    let spin_rad_s = update_spin_rate(
527        initial_spin_rad_s,
528        time_s,
529        velocity_mps,
530        1.225, // Standard air density
531        175.0, // Standard bullet weight
532        _caliber,
533        1.3, // Standard bullet length
534        Some(&decay_params),
535    );
536
537    // Estimate yaw of repose (use simple model for compatibility)
538    let (yaw_rad, _) = calculate_yaw_of_repose(
539        stability,
540        velocity_mps,
541        spin_rad_s,
542        0.0,
543        0.0,
544        1.225,
545        _caliber,
546        1.3,
547        175.0,
548        velocity_mps / 343.0,
549        "match",
550        false,
551    );
552
553    // Calculate gyroscopic drift (primary component)
554
555    calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right)
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn test_calculate_spin_rate() {
564        // Test with 1:10 twist at 800 m/s
565        let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
566
567        // 800 m/s = 31496 in/s, divided by 10 = 3149.6 rps
568        assert!((rps - 3149.6).abs() < 1.0);
569        assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
570
571        // Test with zero twist rate
572        let (rps_zero, rad_s_zero) = calculate_spin_rate(800.0, 0.0);
573        assert_eq!(rps_zero, 0.0);
574        assert_eq!(rad_s_zero, 0.0);
575    }
576
577    #[test]
578    fn magnus_spin_is_set_at_muzzle_while_spin_parameter_grows_downrange() {
579        let muzzle_velocity = 800.0;
580        let (muzzle_spin, muzzle_parameter) =
581            calculate_magnus_spin_state(muzzle_velocity, muzzle_velocity, 10.0, 0.00782);
582        let (downrange_spin, downrange_parameter) =
583            calculate_magnus_spin_state(muzzle_velocity, muzzle_velocity / 2.0, 10.0, 0.00782);
584
585        assert_eq!(downrange_spin.to_bits(), muzzle_spin.to_bits());
586        assert!((downrange_parameter / muzzle_parameter - 2.0).abs() < 1e-12);
587    }
588
589    #[test]
590    fn test_calculate_dynamic_stability() {
591        let sg = calculate_dynamic_stability(
592            168.0,   // grains
593            800.0,   // m/s
594            19792.0, // rad/s (from 1:10 twist)
595            0.308,   // inches
596            1.2,     // inches
597            1.225,   // kg/m³
598        );
599
600        // Should be > 1.0 for stable bullet
601        assert!(sg > 1.0);
602        assert!(sg < 10.0); // Reasonable upper bound
603    }
604
605    #[test]
606    fn test_calculate_yaw_of_repose() {
607        let (yaw, _) = calculate_yaw_of_repose(
608            2.5,     // Sg
609            800.0,   // velocity m/s
610            19792.0, // spin rate rad/s
611            10.0,    // crosswind m/s
612            0.0,     // pitch rate
613            1.225,   // air density
614            0.308,   // caliber
615            1.2,     // length
616            168.0,   // mass
617            2.33,    // mach
618            "match", // bullet type
619            false,   // use pitch damping
620        );
621
622        // Should be small but non-zero
623        assert!(yaw.abs() > 0.0);
624        assert!(yaw.abs() < 0.1); // Less than ~6 degrees
625    }
626
627    #[test]
628    fn yaw_of_repose_increases_with_physical_spin_stability() {
629        let calculate = |stability_factor, spin_rate_rad_s, use_pitch_damping| {
630            calculate_yaw_of_repose(
631                stability_factor,
632                300.0,
633                spin_rate_rad_s,
634                0.0,
635                0.01,
636                1.225,
637                0.308,
638                1.3,
639                175.0,
640                300.0 / 343.0,
641                "match",
642                use_pitch_damping,
643            )
644            .0
645        };
646        let low_stability: f64 = 1.1;
647        let high_stability: f64 = 4.0;
648        let low_spin = 19_000.0;
649        let high_spin = low_spin * (high_stability / low_stability).sqrt();
650        let expected_ratio = (high_stability / low_stability).sqrt();
651
652        for use_pitch_damping in [false, true] {
653            let low = calculate(low_stability, low_spin, use_pitch_damping);
654            let high = calculate(high_stability, high_spin, use_pitch_damping);
655
656            assert!(high > low, "yaw decreased as physical spin/Sg increased");
657            assert!(
658                (high / low - expected_ratio).abs() <= expected_ratio * 1e-12,
659                "yaw stability ratio was {}, expected {expected_ratio}",
660                high / low
661            );
662        }
663    }
664
665    #[test]
666    fn yaw_of_repose_has_inverse_cube_velocity_scaling() {
667        let calculate = |stability_factor, velocity_mps, use_pitch_damping| {
668            calculate_yaw_of_repose(
669                stability_factor,
670                velocity_mps,
671                19_000.0,
672                0.0,
673                0.01,
674                1.225,
675                0.308,
676                1.3,
677                175.0,
678                velocity_mps / 343.0,
679                "match",
680                use_pitch_damping,
681            )
682            .0
683        };
684
685        // With spin fixed, halving velocity increases physical Sg by four. The classical
686        // reduction yaw = 4*Iy*Sg*g/(Ix*p*V) must therefore increase by 4*2 = 8 (V^-3).
687        for use_pitch_damping in [false, true] {
688            let fast = calculate(1.5, 800.0, use_pitch_damping);
689            let slow = calculate(6.0, 400.0, use_pitch_damping);
690
691            assert!((slow / fast - 8.0).abs() <= 8e-12);
692        }
693    }
694
695    #[test]
696    fn crosswind_does_not_change_yaw_of_repose_in_either_model() {
697        let calculate = |wind_velocity_mps, use_pitch_damping| {
698            calculate_yaw_of_repose(
699                2.5,
700                300.0,
701                19_000.0,
702                wind_velocity_mps,
703                0.01,
704                1.225,
705                0.308,
706                1.3,
707                175.0,
708                0.875,
709                "match",
710                use_pitch_damping,
711            )
712            .0
713        };
714
715        let simple_calm = calculate(0.0, false);
716        let simple_windy = calculate(10.0, false);
717        let damped_calm = calculate(0.0, true);
718        let damped_windy = calculate(10.0, true);
719
720        assert_eq!(simple_windy.to_bits(), simple_calm.to_bits());
721        assert_eq!(damped_windy.to_bits(), damped_calm.to_bits());
722        assert_eq!(damped_calm.to_bits(), simple_calm.to_bits());
723        assert!(simple_calm > 0.0 && simple_calm < 0.003);
724    }
725
726    #[test]
727    fn test_enhanced_spin_drift_calculation() {
728        let components = calculate_enhanced_spin_drift(
729            168.0, // mass grains
730            800.0, // velocity m/s
731            10.0,  // twist rate inches
732            0.308, // caliber inches
733            1.2,   // length inches
734            true,  // right twist
735            1.0,   // time s
736            1.225, // air density
737            10.0,  // crosswind
738            0.0,   // pitch rate
739            false, // use pitch damping
740        );
741
742        // Should produce non-zero drift
743        assert!(components.total_drift_m.abs() > 0.0);
744        assert!(components.spin_rate_rps > 0.0);
745        assert!(components.stability_factor > 0.0);
746    }
747
748    #[test]
749    fn test_litz_drift_helpers_sign_and_magnitude() {
750        // litz_drift_inches is unsigned and matches 1.25*(Sg+1.2)*t^1.83 exactly.
751        let sg = 2.0_f64;
752        let t = 1.5_f64;
753        let expected_in = 1.25 * (sg + 1.2) * t.powf(1.83);
754        assert!((litz_drift_inches(sg, t) - expected_in).abs() < 1e-12);
755        // litz_drift_meters applies the twist sign and the inch->meter conversion.
756        let right = litz_drift_meters(sg, t, true);
757        let left = litz_drift_meters(sg, t, false);
758        assert!((right - expected_in * 0.0254).abs() < 1e-12);
759        assert!((right + left).abs() < 1e-12, "left twist must mirror right");
760        assert!(right > 0.0 && left < 0.0);
761    }
762
763    #[test]
764    fn gyroscopic_drift_is_continuous_at_legacy_velocity_threshold() {
765        let stability_factor = 2.0;
766        let time_s = 2.0;
767        let threshold_mps = 1125.0 / 3.28084;
768
769        for is_right_twist in [true, false] {
770            let expected = litz_drift_meters(stability_factor, time_s, is_right_twist);
771            for velocity_mps in [threshold_mps - 1e-6, threshold_mps, threshold_mps + 1e-6] {
772                let actual = calculate_gyroscopic_drift(
773                    stability_factor,
774                    0.0,
775                    velocity_mps,
776                    time_s,
777                    is_right_twist,
778                );
779                assert_eq!(actual.to_bits(), expected.to_bits());
780            }
781        }
782    }
783
784    #[test]
785    fn gyroscopic_drift_preserves_accumulation_below_velocity_threshold() {
786        let stability_factor = 2.0;
787        let threshold_mps = 1125.0 / 3.28084;
788        let before =
789            calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps + 1e-6, 1.5, true);
790        let after =
791            calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps - 1e-6, 2.0, true);
792        let left_after =
793            calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps - 1e-6, 2.0, false);
794
795        assert_eq!(
796            before.to_bits(),
797            litz_drift_meters(stability_factor, 1.5, true).to_bits()
798        );
799        assert_eq!(
800            after.to_bits(),
801            litz_drift_meters(stability_factor, 2.0, true).to_bits()
802        );
803        assert!(after > before, "accumulated drift must not disappear");
804        assert_eq!(left_after.to_bits(), (-after).to_bits());
805    }
806
807    #[test]
808    fn test_effective_sg_from_inputs_includes_velocity_term_and_length_fallback() {
809        // effective_sg_from_inputs must (1) equal compute_stability_coefficient, (2) INCLUDE the
810        // (v/2800)^(1/3) muzzle-velocity term (so it differs from the bare geometric miller_stability),
811        // and (3) apply the 4.5-caliber length fallback when bullet_length is unset.
812        let inputs = BallisticInputs {
813            muzzle_velocity: 800.0, // 2624.7 fps -> velocity term < 1.0
814            bullet_mass: 175.0 * crate::constants::GRAINS_TO_KG,
815            bullet_diameter: 0.308 * 0.0254,
816            bullet_length: 1.24 * 0.0254,
817            twist_rate: 10.0,
818            ..Default::default()
819        };
820
821        let temp_c = 15.0;
822        let press_hpa = 1013.25;
823        let sg = effective_sg_from_inputs(&inputs, temp_c, press_hpa);
824
825        // (1) identical to compute_stability_coefficient on the same (length-filled) inputs.
826        let direct =
827            crate::stability::compute_stability_coefficient(&inputs, (0.0, temp_c, press_hpa, 0.0));
828        assert!((sg - direct).abs() < 1e-12, "sg {sg} != direct {direct}");
829
830        // (2) includes the velocity term: at sea-level standard the density factor is 1.0, so
831        //     sg == bare_geometric_Sg * (v_fps/2800)^(1/3).
832        let d_in = inputs.bullet_diameter / 0.0254;
833        let m_gr = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
834        let l_in = inputs.bullet_length / 0.0254;
835        let bare = miller_stability(d_in, m_gr, inputs.twist_rate, l_in);
836        let vel_corr = (inputs.muzzle_velocity * 3.28084 / 2800.0).powf(1.0 / 3.0);
837        assert!(vel_corr < 1.0, "muzzle < 2800 fps should shrink Sg");
838        assert!(
839            (sg - bare * vel_corr).abs() < 1e-6,
840            "sg {sg} != bare {bare} * vel_corr {vel_corr}"
841        );
842
843        // (3) MBA-1135: the zero-length fallback now uses the mass-based length estimate
844        // (crate::stability::estimate_bullet_length_m), NOT the old mass-blind 4.5-caliber
845        // length. Zero length must reproduce an explicit estimate-length input.
846        let mut no_len = inputs.clone();
847        no_len.bullet_length = 0.0;
848        let sg_fallback = effective_sg_from_inputs(&no_len, temp_c, press_hpa);
849        let mut explicit = inputs.clone();
850        explicit.bullet_length =
851            crate::stability::estimate_bullet_length_m(inputs.bullet_diameter, inputs.bullet_mass);
852        let sg_explicit = effective_sg_from_inputs(&explicit, temp_c, press_hpa);
853        assert!(
854            (sg_fallback - sg_explicit).abs() < 1e-12,
855            "zero-length fallback {sg_fallback} != explicit estimate-length {sg_explicit}"
856        );
857        assert!(sg_fallback > 0.0);
858        // And it must differ from the retired 4.5-caliber default (the whole point of MBA-1135).
859        let mut old_default = inputs.clone();
860        old_default.bullet_length = 4.5 * old_default.bullet_diameter;
861        let sg_old = effective_sg_from_inputs(&old_default, temp_c, press_hpa);
862        assert!(
863            (sg_fallback - sg_old).abs() > 1e-6,
864            "mass-based fallback should differ from the old 4.5-cal default"
865        );
866    }
867
868    #[test]
869    fn test_effective_sg_preserves_short_handgun_length_estimate() {
870        let inputs = BallisticInputs {
871            muzzle_velocity: 1150.0 * 0.3048,
872            bullet_mass: 115.0 * crate::constants::GRAINS_TO_KG,
873            bullet_diameter: 0.355 * 0.0254,
874            bullet_length: 0.0,
875            twist_rate: 10.0,
876            ..Default::default()
877        };
878
879        let sg = effective_sg_from_inputs(&inputs, 15.0, 1013.25);
880        assert!(
881            (10.0..12.0).contains(&sg),
882            "expected 9 mm / 115 gr Sg near 10.9 with the modeled length, got {sg}"
883        );
884    }
885
886    #[test]
887    fn test_miller_stability_308_168gr() {
888        // .308, 168 gr, 1:12 twist, ~1.215 in length -> base Sg (no velocity/density correction)
889        // Formula: Sg = 30*m / (t^2 * d^3 * l * (1+l^2)), t and l in calibers
890        // twist_cal = 12/0.308 = 38.96, l_cal = 1.215/0.308 = 3.94 -> Sg ~ 1.74
891        let sg = miller_stability(0.308, 168.0, 12.0, 1.215);
892        assert!(sg > 1.5 && sg < 2.0, "expected base Sg ~1.74, got {}", sg);
893    }
894
895    #[test]
896    fn test_miller_stability_invalid_inputs_zero() {
897        assert_eq!(miller_stability(0.0, 168.0, 12.0, 1.2), 0.0);
898        assert_eq!(miller_stability(0.308, 0.0, 12.0, 1.2), 0.0);
899        assert_eq!(miller_stability(0.308, 168.0, 0.0, 1.2), 0.0);
900        assert_eq!(miller_stability(0.308, 168.0, 12.0, 0.0), 0.0);
901    }
902
903    #[test]
904    fn test_opposite_twist_directions() {
905        // Right twist
906        let right_drift = calculate_enhanced_spin_drift(
907            168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 0.0, 0.0, false,
908        );
909
910        // Left twist
911        let left_drift = calculate_enhanced_spin_drift(
912            168.0, 800.0, 10.0, 0.308, 1.2, false, 1.0, 1.225, 0.0, 0.0, false,
913        );
914
915        // Should have opposite signs for gyroscopic component
916        assert!(right_drift.gyroscopic_component_m * left_drift.gyroscopic_component_m < 0.0);
917        assert!(
918            (right_drift.gyroscopic_component_m.abs() - left_drift.gyroscopic_component_m.abs())
919                .abs()
920                < 0.001
921        );
922    }
923
924    #[test]
925    fn test_applied_spin_drift_flips_with_twist() {
926        // Regression: the APPLIED lateral acceleration (derivatives[5]) must reverse
927        // direction with the twist hand. apply_enhanced_spin_drift previously multiplied by
928        // the twist sign a second time, canceling the gyroscopic sign so left- and right-
929        // twist barrels pushed the same way. The existing test only checks the component
930        // field, never the applied derivative.
931        let time_s = 1.0;
932        let right = calculate_enhanced_spin_drift(
933            168.0, 800.0, 10.0, 0.308, 1.2, true, time_s, 1.225, 0.0, 0.0, false,
934        );
935        let left = calculate_enhanced_spin_drift(
936            168.0, 800.0, 10.0, 0.308, 1.2, false, time_s, 1.225, 0.0, 0.0, false,
937        );
938
939        let mut d_right = [0.0_f64; 6];
940        let mut d_left = [0.0_f64; 6];
941        apply_enhanced_spin_drift(&mut d_right, &right, time_s, true);
942        apply_enhanced_spin_drift(&mut d_left, &left, time_s, false);
943
944        assert!(d_right[5].abs() > 0.0, "expected non-zero spin drift accel");
945        assert!(d_left[5].abs() > 0.0, "expected non-zero spin drift accel");
946        assert!(
947            d_right[5] * d_left[5] < 0.0,
948            "expected opposite-sign lateral accel for opposite twist, got {} and {}",
949            d_right[5],
950            d_left[5]
951        );
952    }
953
954    #[test]
955    fn applied_spin_drift_uses_litz_power_law_acceleration() {
956        let time_s: f64 = 2.0;
957        let average_drift_rate_mps = 0.5;
958        let expected_magnitude = 1.83 * 0.83 * average_drift_rate_mps / time_s;
959
960        for (is_right_twist, sign) in [(true, 1.0), (false, -1.0)] {
961            let components = SpinDriftComponents {
962                spin_rate_rps: 0.0,
963                spin_rate_rad_s: 0.0,
964                stability_factor: 0.0,
965                yaw_of_repose_rad: 0.0,
966                drift_rate_mps: sign * average_drift_rate_mps,
967                total_drift_m: 0.0,
968                magnus_component_m: 0.0,
969                gyroscopic_component_m: 0.0,
970                pitch_damping_moment: 0.0,
971                yaw_convergence_rate: 0.0,
972                pitch_rate_rad_s: 0.0,
973            };
974            let mut derivatives = [0.0; 6];
975
976            apply_enhanced_spin_drift(&mut derivatives, &components, time_s, is_right_twist);
977
978            assert!(
979                (derivatives[5] - sign * expected_magnitude).abs() < 1e-12,
980                "wrong Litz acceleration for sign {sign}: {}",
981                derivatives[5]
982            );
983        }
984    }
985}