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)
183pub fn calculate_yaw_of_repose(
184    stability_factor: f64,
185    velocity_mps: f64,
186    spin_rate_rad_s: f64,
187    wind_velocity_mps: f64,
188    pitch_rate_rad_s: f64,
189    air_density_kg_m3: f64,
190    caliber_inches: f64,
191    length_inches: f64,
192    mass_grains: f64,
193    mach: f64,
194    bullet_type: &str,
195    use_pitch_damping: bool,
196) -> (f64, f64) {
197    if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
198        return (0.0, 0.0);
199    }
200
201    // Use enhanced calculation with pitch damping if requested
202    if use_pitch_damping && mach > 0.0 {
203        // Map bullet types for pitch damping
204        let damping_type = match bullet_type.to_lowercase().as_str() {
205            "match" => "match_boat_tail",
206            "hunting" => "hunting",
207            "fmj" => "fmj",
208            "vld" => "vld",
209            _ => "match_boat_tail",
210        };
211
212        return calculate_damped_yaw_of_repose(
213            stability_factor,
214            velocity_mps,
215            spin_rate_rad_s,
216            wind_velocity_mps,
217            pitch_rate_rad_s,
218            air_density_kg_m3,
219            caliber_inches,
220            length_inches,
221            mass_grains,
222            mach,
223            damping_type,
224        );
225    }
226
227    // Crosswind yaw is a muzzle transient, not a persistent equilibrium. The simple and damped
228    // paths share the same gravity/gyroscopic repose angle; only the damped path reports a
229    // convergence rate.
230    let yaw_rad = calculate_gravity_yaw_of_repose(
231        stability_factor,
232        velocity_mps,
233        spin_rate_rad_s,
234        mass_grains * 0.00006479891,
235        caliber_inches * 0.0254,
236        length_inches * 0.0254,
237    );
238
239    (yaw_rad, 0.0)
240}
241
242/// Calculate Magnus effect contribution to drift
243pub fn calculate_magnus_drift_component(
244    velocity_mps: f64,
245    spin_rate_rad_s: f64,
246    yaw_rad: f64,
247    air_density_kg_m3: f64,
248    caliber_inches: f64,
249    time_s: f64,
250    mass_grains: f64,
251) -> f64 {
252    let diameter_m = caliber_inches * 0.0254;
253    let mass_kg = mass_grains * 0.00006479891; // Convert grains to kg
254
255    // Magnus force coefficient (empirical)
256    // Varies with Mach number
257    let mach = velocity_mps / 343.0; // Approximate speed of sound
258
259    let cmag = if mach < 0.8 {
260        0.25
261    } else if mach < 1.2 {
262        // Transonic reduction
263        0.15
264    } else {
265        // Supersonic
266        0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
267    };
268
269    // Spin ratio
270    let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
271
272    // Magnus force
273    let magnus_force = if velocity_mps > 0.0 {
274        cmag * spin_ratio
275            * yaw_rad
276            * 0.5
277            * air_density_kg_m3
278            * velocity_mps.powi(2)
279            * PI
280            * (diameter_m / 2.0).powi(2)
281    } else {
282        0.0
283    };
284
285    // Convert force to acceleration by dividing by mass
286    let magnus_accel = magnus_force / mass_kg;
287
288    // Drift over time (simplified - should integrate)
289
290    0.5 * magnus_accel * time_s.powi(2)
291}
292
293/// Calculate the cumulative empirical Litz gyroscopic-drift component.
294///
295/// `velocity_mps` is retained for source compatibility but intentionally does not gate the
296/// accumulated displacement. A stateless current-velocity check cannot erase or freeze drift
297/// accrued earlier in flight; live solver paths use [`litz_drift_meters`] directly.
298pub fn calculate_gyroscopic_drift(
299    stability_factor: f64,
300    _yaw_rad: f64,
301    _velocity_mps: f64,
302    time_s: f64,
303    is_right_twist: bool,
304) -> f64 {
305    if stability_factor <= 1.0 || time_s <= 0.0 {
306        return 0.0;
307    }
308
309    litz_drift_meters(stability_factor, time_s, is_right_twist)
310}
311
312/// Calculate enhanced spin drift with all components.
313///
314/// DEPRECATED (MBA-1134): this in-integration acceleration model is no longer wired into any
315/// solver path — spin drift is now the single canonical empirical Litz post-process
316/// ([`litz_drift_meters`], via [`effective_sg_from_inputs`]). Retained for backward compatibility
317/// and unit tests only. Do NOT reintroduce it into an integration loop alongside the Litz model,
318/// or lateral drift will be double-counted.
319pub fn calculate_enhanced_spin_drift(
320    bullet_mass: f64,
321    velocity_mps: f64,
322    twist_rate: f64,
323    bullet_diameter: f64,
324    bullet_length: f64,
325    is_twist_right: bool,
326    time_s: f64,
327    air_density: f64,
328    crosswind_mps: f64,
329    pitch_rate_rad_s: f64,
330    use_pitch_damping: bool,
331) -> SpinDriftComponents {
332    // Calculate initial spin rate (at muzzle)
333    let muzzle_velocity = velocity_mps; // Assuming we're passed muzzle velocity
334    let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
335
336    // Apply spin decay based on time of flight
337    let decay_params = SpinDecayParameters::from_bullet_type("match"); // Default to match for now
338    let current_spin_rad_s = update_spin_rate(
339        initial_spin_rad_s,
340        time_s,
341        velocity_mps,
342        air_density,
343        bullet_mass, // already grains (update_spin_rate wants mass_grains)
344        bullet_diameter,
345        bullet_length,
346        Some(&decay_params),
347    );
348
349    let spin_rps = current_spin_rad_s / (2.0 * PI);
350    let spin_rad_s = current_spin_rad_s;
351
352    // Calculate dynamic stability
353    let stability = calculate_dynamic_stability(
354        bullet_mass,
355        velocity_mps,
356        spin_rad_s,
357        bullet_diameter,
358        bullet_length,
359        air_density,
360    );
361
362    // Calculate Mach number for pitch damping
363    let mach = velocity_mps / 343.0; // Approximate speed of sound
364
365    // Determine bullet type (default to match for now)
366    let bullet_type = "match";
367
368    // Calculate yaw of repose with pitch damping
369    let (yaw_rad, convergence_rate) = calculate_yaw_of_repose(
370        stability,
371        velocity_mps,
372        spin_rad_s,
373        crosswind_mps,
374        pitch_rate_rad_s,
375        air_density,
376        bullet_diameter,
377        bullet_length,
378        bullet_mass,
379        mach,
380        bullet_type,
381        use_pitch_damping,
382    );
383
384    // Calculate Magnus component
385    let magnus_drift = calculate_magnus_drift_component(
386        velocity_mps,
387        spin_rad_s,
388        yaw_rad,
389        air_density,
390        bullet_diameter,
391        time_s,
392        bullet_mass,
393    );
394
395    // Calculate gyroscopic component
396    let gyro_drift =
397        calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
398
399    // Total drift. gyro_drift already carries the twist-direction sign (from
400    // calculate_gyroscopic_drift); sign the Magnus term to the SAME convention so both
401    // contributions are consistently directed before being summed. (magnus_component_m is
402    // kept unsigned below for backward compatibility.)
403    let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
404    let total_drift = twist_sign * magnus_drift + gyro_drift;
405
406    // Drift rate (derivative)
407    let drift_rate = if time_s > 0.0 {
408        total_drift / time_s
409    } else {
410        0.0
411    };
412
413    // Calculate pitch damping moment if using enhanced model
414    let pitch_damping_moment = if use_pitch_damping && mach > 0.0 {
415        let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
416        calculate_pitch_damping_moment(
417            pitch_rate_rad_s,
418            velocity_mps,
419            air_density,
420            bullet_diameter * 0.0254, // Convert to meters
421            bullet_length * 0.0254,   // Convert to meters
422            mach,
423            &coeffs,
424        )
425    } else {
426        0.0
427    };
428
429    SpinDriftComponents {
430        spin_rate_rps: spin_rps,
431        spin_rate_rad_s: spin_rad_s,
432        stability_factor: stability,
433        yaw_of_repose_rad: yaw_rad,
434        drift_rate_mps: drift_rate,
435        total_drift_m: total_drift,
436        magnus_component_m: magnus_drift,
437        gyroscopic_component_m: gyro_drift,
438        pitch_damping_moment,
439        yaw_convergence_rate: convergence_rate,
440        pitch_rate_rad_s,
441    }
442}
443
444/// Apply enhanced spin drift acceleration to derivatives.
445///
446/// DEPRECATED (MBA-1134): companion to [`calculate_enhanced_spin_drift`]; no longer called by any
447/// integration path. Spin drift is now the canonical Litz post-process ([`litz_drift_meters`]).
448/// Retained for backward compatibility and unit tests only.
449pub fn apply_enhanced_spin_drift(
450    derivatives: &mut [f64; 6],
451    spin_components: &SpinDriftComponents,
452    time_s: f64,
453    _is_right_twist: bool,
454) {
455    if time_s > 0.1 {
456        // Back out acceleration from the public average drift-rate field while preserving its
457        // legacy authority. For displacement proportional to t^n, a = n*(n-1)*drift/t^2;
458        // drift_rate_mps stores drift/t, so divide it by time once more.
459        let gyroscopic_factor = LITZ_TIME_EXPONENT * (LITZ_TIME_EXPONENT - 1.0);
460        let spin_accel_z = gyroscopic_factor * spin_components.drift_rate_mps / time_s;
461
462        // drift_rate_mps already carries the twist-direction sign (set in
463        // calculate_enhanced_spin_drift), so apply it directly. Multiplying by the twist
464        // sign again here previously CANCELED the gyroscopic sign (sign^2 = +1), so left-
465        // and right-twist barrels pushed spin drift the same way.
466        derivatives[5] += spin_accel_z;
467    }
468}
469
470/// Simplified interface for compatibility with existing code
471pub fn compute_enhanced_spin_drift_simple(
472    time_s: f64,
473    stability: f64,
474    velocity_mps: f64,
475    twist_rate: f64,
476    is_twist_right: bool,
477    _caliber: f64,
478) -> f64 {
479    if twist_rate <= 0.0 {
480        return 0.0;
481    }
482
483    // Calculate initial spin rate
484    let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
485
486    // Apply simple spin decay (assume 175gr bullet)
487    let decay_params = SpinDecayParameters::from_bullet_type("match");
488    let spin_rad_s = update_spin_rate(
489        initial_spin_rad_s,
490        time_s,
491        velocity_mps,
492        1.225, // Standard air density
493        175.0, // Standard bullet weight
494        _caliber,
495        1.3, // Standard bullet length
496        Some(&decay_params),
497    );
498
499    // Estimate yaw of repose (use simple model for compatibility)
500    let (yaw_rad, _) = calculate_yaw_of_repose(
501        stability,
502        velocity_mps,
503        spin_rad_s,
504        0.0,
505        0.0,
506        1.225,
507        _caliber,
508        1.3,
509        175.0,
510        velocity_mps / 343.0,
511        "match",
512        false,
513    );
514
515    // Calculate gyroscopic drift (primary component)
516
517    calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_calculate_spin_rate() {
526        // Test with 1:10 twist at 800 m/s
527        let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
528
529        // 800 m/s = 31496 in/s, divided by 10 = 3149.6 rps
530        assert!((rps - 3149.6).abs() < 1.0);
531        assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
532
533        // Test with zero twist rate
534        let (rps_zero, rad_s_zero) = calculate_spin_rate(800.0, 0.0);
535        assert_eq!(rps_zero, 0.0);
536        assert_eq!(rad_s_zero, 0.0);
537    }
538
539    #[test]
540    fn magnus_spin_is_set_at_muzzle_while_spin_parameter_grows_downrange() {
541        let muzzle_velocity = 800.0;
542        let (muzzle_spin, muzzle_parameter) =
543            calculate_magnus_spin_state(muzzle_velocity, muzzle_velocity, 10.0, 0.00782);
544        let (downrange_spin, downrange_parameter) =
545            calculate_magnus_spin_state(muzzle_velocity, muzzle_velocity / 2.0, 10.0, 0.00782);
546
547        assert_eq!(downrange_spin.to_bits(), muzzle_spin.to_bits());
548        assert!((downrange_parameter / muzzle_parameter - 2.0).abs() < 1e-12);
549    }
550
551    #[test]
552    fn test_calculate_dynamic_stability() {
553        let sg = calculate_dynamic_stability(
554            168.0,   // grains
555            800.0,   // m/s
556            19792.0, // rad/s (from 1:10 twist)
557            0.308,   // inches
558            1.2,     // inches
559            1.225,   // kg/m³
560        );
561
562        // Should be > 1.0 for stable bullet
563        assert!(sg > 1.0);
564        assert!(sg < 10.0); // Reasonable upper bound
565    }
566
567    #[test]
568    fn test_calculate_yaw_of_repose() {
569        let (yaw, _) = calculate_yaw_of_repose(
570            2.5,     // Sg
571            800.0,   // velocity m/s
572            19792.0, // spin rate rad/s
573            10.0,    // crosswind m/s
574            0.0,     // pitch rate
575            1.225,   // air density
576            0.308,   // caliber
577            1.2,     // length
578            168.0,   // mass
579            2.33,    // mach
580            "match", // bullet type
581            false,   // use pitch damping
582        );
583
584        // Should be small but non-zero
585        assert!(yaw.abs() > 0.0);
586        assert!(yaw.abs() < 0.1); // Less than ~6 degrees
587    }
588
589    #[test]
590    fn yaw_of_repose_increases_with_physical_spin_stability() {
591        let calculate = |stability_factor, spin_rate_rad_s, use_pitch_damping| {
592            calculate_yaw_of_repose(
593                stability_factor,
594                300.0,
595                spin_rate_rad_s,
596                0.0,
597                0.01,
598                1.225,
599                0.308,
600                1.3,
601                175.0,
602                300.0 / 343.0,
603                "match",
604                use_pitch_damping,
605            )
606            .0
607        };
608        let low_stability: f64 = 1.1;
609        let high_stability: f64 = 4.0;
610        let low_spin = 19_000.0;
611        let high_spin = low_spin * (high_stability / low_stability).sqrt();
612        let expected_ratio = (high_stability / low_stability).sqrt();
613
614        for use_pitch_damping in [false, true] {
615            let low = calculate(low_stability, low_spin, use_pitch_damping);
616            let high = calculate(high_stability, high_spin, use_pitch_damping);
617
618            assert!(high > low, "yaw decreased as physical spin/Sg increased");
619            assert!(
620                (high / low - expected_ratio).abs() <= expected_ratio * 1e-12,
621                "yaw stability ratio was {}, expected {expected_ratio}",
622                high / low
623            );
624        }
625    }
626
627    #[test]
628    fn yaw_of_repose_has_inverse_cube_velocity_scaling() {
629        let calculate = |stability_factor, velocity_mps, use_pitch_damping| {
630            calculate_yaw_of_repose(
631                stability_factor,
632                velocity_mps,
633                19_000.0,
634                0.0,
635                0.01,
636                1.225,
637                0.308,
638                1.3,
639                175.0,
640                velocity_mps / 343.0,
641                "match",
642                use_pitch_damping,
643            )
644            .0
645        };
646
647        // With spin fixed, halving velocity increases physical Sg by four. The classical
648        // reduction yaw = 4*Iy*Sg*g/(Ix*p*V) must therefore increase by 4*2 = 8 (V^-3).
649        for use_pitch_damping in [false, true] {
650            let fast = calculate(1.5, 800.0, use_pitch_damping);
651            let slow = calculate(6.0, 400.0, use_pitch_damping);
652
653            assert!((slow / fast - 8.0).abs() <= 8e-12);
654        }
655    }
656
657    #[test]
658    fn crosswind_does_not_change_yaw_of_repose_in_either_model() {
659        let calculate = |wind_velocity_mps, use_pitch_damping| {
660            calculate_yaw_of_repose(
661                2.5,
662                300.0,
663                19_000.0,
664                wind_velocity_mps,
665                0.01,
666                1.225,
667                0.308,
668                1.3,
669                175.0,
670                0.875,
671                "match",
672                use_pitch_damping,
673            )
674            .0
675        };
676
677        let simple_calm = calculate(0.0, false);
678        let simple_windy = calculate(10.0, false);
679        let damped_calm = calculate(0.0, true);
680        let damped_windy = calculate(10.0, true);
681
682        assert_eq!(simple_windy.to_bits(), simple_calm.to_bits());
683        assert_eq!(damped_windy.to_bits(), damped_calm.to_bits());
684        assert_eq!(damped_calm.to_bits(), simple_calm.to_bits());
685        assert!(simple_calm > 0.0 && simple_calm < 0.003);
686    }
687
688    #[test]
689    fn test_enhanced_spin_drift_calculation() {
690        let components = calculate_enhanced_spin_drift(
691            168.0, // mass grains
692            800.0, // velocity m/s
693            10.0,  // twist rate inches
694            0.308, // caliber inches
695            1.2,   // length inches
696            true,  // right twist
697            1.0,   // time s
698            1.225, // air density
699            10.0,  // crosswind
700            0.0,   // pitch rate
701            false, // use pitch damping
702        );
703
704        // Should produce non-zero drift
705        assert!(components.total_drift_m.abs() > 0.0);
706        assert!(components.spin_rate_rps > 0.0);
707        assert!(components.stability_factor > 0.0);
708    }
709
710    #[test]
711    fn test_litz_drift_helpers_sign_and_magnitude() {
712        // litz_drift_inches is unsigned and matches 1.25*(Sg+1.2)*t^1.83 exactly.
713        let sg = 2.0_f64;
714        let t = 1.5_f64;
715        let expected_in = 1.25 * (sg + 1.2) * t.powf(1.83);
716        assert!((litz_drift_inches(sg, t) - expected_in).abs() < 1e-12);
717        // litz_drift_meters applies the twist sign and the inch->meter conversion.
718        let right = litz_drift_meters(sg, t, true);
719        let left = litz_drift_meters(sg, t, false);
720        assert!((right - expected_in * 0.0254).abs() < 1e-12);
721        assert!((right + left).abs() < 1e-12, "left twist must mirror right");
722        assert!(right > 0.0 && left < 0.0);
723    }
724
725    #[test]
726    fn gyroscopic_drift_is_continuous_at_legacy_velocity_threshold() {
727        let stability_factor = 2.0;
728        let time_s = 2.0;
729        let threshold_mps = 1125.0 / 3.28084;
730
731        for is_right_twist in [true, false] {
732            let expected = litz_drift_meters(stability_factor, time_s, is_right_twist);
733            for velocity_mps in [threshold_mps - 1e-6, threshold_mps, threshold_mps + 1e-6] {
734                let actual = calculate_gyroscopic_drift(
735                    stability_factor,
736                    0.0,
737                    velocity_mps,
738                    time_s,
739                    is_right_twist,
740                );
741                assert_eq!(actual.to_bits(), expected.to_bits());
742            }
743        }
744    }
745
746    #[test]
747    fn gyroscopic_drift_preserves_accumulation_below_velocity_threshold() {
748        let stability_factor = 2.0;
749        let threshold_mps = 1125.0 / 3.28084;
750        let before =
751            calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps + 1e-6, 1.5, true);
752        let after =
753            calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps - 1e-6, 2.0, true);
754        let left_after =
755            calculate_gyroscopic_drift(stability_factor, 0.0, threshold_mps - 1e-6, 2.0, false);
756
757        assert_eq!(
758            before.to_bits(),
759            litz_drift_meters(stability_factor, 1.5, true).to_bits()
760        );
761        assert_eq!(
762            after.to_bits(),
763            litz_drift_meters(stability_factor, 2.0, true).to_bits()
764        );
765        assert!(after > before, "accumulated drift must not disappear");
766        assert_eq!(left_after.to_bits(), (-after).to_bits());
767    }
768
769    #[test]
770    fn test_effective_sg_from_inputs_includes_velocity_term_and_length_fallback() {
771        // effective_sg_from_inputs must (1) equal compute_stability_coefficient, (2) INCLUDE the
772        // (v/2800)^(1/3) muzzle-velocity term (so it differs from the bare geometric miller_stability),
773        // and (3) apply the 4.5-caliber length fallback when bullet_length is unset.
774        let inputs = BallisticInputs {
775            muzzle_velocity: 800.0, // 2624.7 fps -> velocity term < 1.0
776            bullet_mass: 175.0 * 0.00006479891,
777            bullet_diameter: 0.308 * 0.0254,
778            bullet_length: 1.24 * 0.0254,
779            twist_rate: 10.0,
780            ..Default::default()
781        };
782
783        let temp_c = 15.0;
784        let press_hpa = 1013.25;
785        let sg = effective_sg_from_inputs(&inputs, temp_c, press_hpa);
786
787        // (1) identical to compute_stability_coefficient on the same (length-filled) inputs.
788        let direct =
789            crate::stability::compute_stability_coefficient(&inputs, (0.0, temp_c, press_hpa, 0.0));
790        assert!((sg - direct).abs() < 1e-12, "sg {sg} != direct {direct}");
791
792        // (2) includes the velocity term: at sea-level standard the density factor is 1.0, so
793        //     sg == bare_geometric_Sg * (v_fps/2800)^(1/3).
794        let d_in = inputs.bullet_diameter / 0.0254;
795        let m_gr = inputs.bullet_mass / 0.00006479891;
796        let l_in = inputs.bullet_length / 0.0254;
797        let bare = miller_stability(d_in, m_gr, inputs.twist_rate, l_in);
798        let vel_corr = (inputs.muzzle_velocity * 3.28084 / 2800.0).powf(1.0 / 3.0);
799        assert!(vel_corr < 1.0, "muzzle < 2800 fps should shrink Sg");
800        assert!(
801            (sg - bare * vel_corr).abs() < 1e-6,
802            "sg {sg} != bare {bare} * vel_corr {vel_corr}"
803        );
804
805        // (3) MBA-1135: the zero-length fallback now uses the mass-based length estimate
806        // (crate::stability::estimate_bullet_length_m), NOT the old mass-blind 4.5-caliber
807        // length. Zero length must reproduce an explicit estimate-length input.
808        let mut no_len = inputs.clone();
809        no_len.bullet_length = 0.0;
810        let sg_fallback = effective_sg_from_inputs(&no_len, temp_c, press_hpa);
811        let mut explicit = inputs.clone();
812        explicit.bullet_length =
813            crate::stability::estimate_bullet_length_m(inputs.bullet_diameter, inputs.bullet_mass);
814        let sg_explicit = effective_sg_from_inputs(&explicit, temp_c, press_hpa);
815        assert!(
816            (sg_fallback - sg_explicit).abs() < 1e-12,
817            "zero-length fallback {sg_fallback} != explicit estimate-length {sg_explicit}"
818        );
819        assert!(sg_fallback > 0.0);
820        // And it must differ from the retired 4.5-caliber default (the whole point of MBA-1135).
821        let mut old_default = inputs.clone();
822        old_default.bullet_length = 4.5 * old_default.bullet_diameter;
823        let sg_old = effective_sg_from_inputs(&old_default, temp_c, press_hpa);
824        assert!(
825            (sg_fallback - sg_old).abs() > 1e-6,
826            "mass-based fallback should differ from the old 4.5-cal default"
827        );
828    }
829
830    #[test]
831    fn test_effective_sg_preserves_short_handgun_length_estimate() {
832        let inputs = BallisticInputs {
833            muzzle_velocity: 1150.0 * 0.3048,
834            bullet_mass: 115.0 * 0.00006479891,
835            bullet_diameter: 0.355 * 0.0254,
836            bullet_length: 0.0,
837            twist_rate: 10.0,
838            ..Default::default()
839        };
840
841        let sg = effective_sg_from_inputs(&inputs, 15.0, 1013.25);
842        assert!(
843            (10.0..12.0).contains(&sg),
844            "expected 9 mm / 115 gr Sg near 10.9 with the modeled length, got {sg}"
845        );
846    }
847
848    #[test]
849    fn test_miller_stability_308_168gr() {
850        // .308, 168 gr, 1:12 twist, ~1.215 in length -> base Sg (no velocity/density correction)
851        // Formula: Sg = 30*m / (t^2 * d^3 * l * (1+l^2)), t and l in calibers
852        // twist_cal = 12/0.308 = 38.96, l_cal = 1.215/0.308 = 3.94 -> Sg ~ 1.74
853        let sg = miller_stability(0.308, 168.0, 12.0, 1.215);
854        assert!(sg > 1.5 && sg < 2.0, "expected base Sg ~1.74, got {}", sg);
855    }
856
857    #[test]
858    fn test_miller_stability_invalid_inputs_zero() {
859        assert_eq!(miller_stability(0.0, 168.0, 12.0, 1.2), 0.0);
860        assert_eq!(miller_stability(0.308, 0.0, 12.0, 1.2), 0.0);
861        assert_eq!(miller_stability(0.308, 168.0, 0.0, 1.2), 0.0);
862        assert_eq!(miller_stability(0.308, 168.0, 12.0, 0.0), 0.0);
863    }
864
865    #[test]
866    fn test_opposite_twist_directions() {
867        // Right twist
868        let right_drift = calculate_enhanced_spin_drift(
869            168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 0.0, 0.0, false,
870        );
871
872        // Left twist
873        let left_drift = calculate_enhanced_spin_drift(
874            168.0, 800.0, 10.0, 0.308, 1.2, false, 1.0, 1.225, 0.0, 0.0, false,
875        );
876
877        // Should have opposite signs for gyroscopic component
878        assert!(right_drift.gyroscopic_component_m * left_drift.gyroscopic_component_m < 0.0);
879        assert!(
880            (right_drift.gyroscopic_component_m.abs() - left_drift.gyroscopic_component_m.abs())
881                .abs()
882                < 0.001
883        );
884    }
885
886    #[test]
887    fn test_applied_spin_drift_flips_with_twist() {
888        // Regression: the APPLIED lateral acceleration (derivatives[5]) must reverse
889        // direction with the twist hand. apply_enhanced_spin_drift previously multiplied by
890        // the twist sign a second time, canceling the gyroscopic sign so left- and right-
891        // twist barrels pushed the same way. The existing test only checks the component
892        // field, never the applied derivative.
893        let time_s = 1.0;
894        let right = calculate_enhanced_spin_drift(
895            168.0, 800.0, 10.0, 0.308, 1.2, true, time_s, 1.225, 0.0, 0.0, false,
896        );
897        let left = calculate_enhanced_spin_drift(
898            168.0, 800.0, 10.0, 0.308, 1.2, false, time_s, 1.225, 0.0, 0.0, false,
899        );
900
901        let mut d_right = [0.0_f64; 6];
902        let mut d_left = [0.0_f64; 6];
903        apply_enhanced_spin_drift(&mut d_right, &right, time_s, true);
904        apply_enhanced_spin_drift(&mut d_left, &left, time_s, false);
905
906        assert!(d_right[5].abs() > 0.0, "expected non-zero spin drift accel");
907        assert!(d_left[5].abs() > 0.0, "expected non-zero spin drift accel");
908        assert!(
909            d_right[5] * d_left[5] < 0.0,
910            "expected opposite-sign lateral accel for opposite twist, got {} and {}",
911            d_right[5],
912            d_left[5]
913        );
914    }
915
916    #[test]
917    fn applied_spin_drift_uses_litz_power_law_acceleration() {
918        let time_s: f64 = 2.0;
919        let average_drift_rate_mps = 0.5;
920        let expected_magnitude = 1.83 * 0.83 * average_drift_rate_mps / time_s;
921
922        for (is_right_twist, sign) in [(true, 1.0), (false, -1.0)] {
923            let components = SpinDriftComponents {
924                spin_rate_rps: 0.0,
925                spin_rate_rad_s: 0.0,
926                stability_factor: 0.0,
927                yaw_of_repose_rad: 0.0,
928                drift_rate_mps: sign * average_drift_rate_mps,
929                total_drift_m: 0.0,
930                magnus_component_m: 0.0,
931                gyroscopic_component_m: 0.0,
932                pitch_damping_moment: 0.0,
933                yaw_convergence_rate: 0.0,
934                pitch_rate_rad_s: 0.0,
935            };
936            let mut derivatives = [0.0; 6];
937
938            apply_enhanced_spin_drift(&mut derivatives, &components, time_s, is_right_twist);
939
940            assert!(
941                (derivatives[5] - sign * expected_magnitude).abs() < 1e-12,
942                "wrong Litz acceleration for sign {sign}: {}",
943                derivatives[5]
944            );
945        }
946    }
947}