Skip to main content

ballistics_engine/
spin_drift.rs

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