1use crate::pitch_damping::{
2 calculate_damped_yaw_of_repose, calculate_pitch_damping_moment, PitchDampingCoefficients,
3};
4use crate::spin_decay::{update_spin_rate, SpinDecayParameters};
5use crate::BallisticInputs;
6use std::f64::consts::PI;
7
8#[derive(Debug, Clone)]
10pub struct SpinDriftComponents {
11 pub spin_rate_rps: f64, pub spin_rate_rad_s: f64, pub stability_factor: f64, pub yaw_of_repose_rad: f64, pub drift_rate_mps: f64, pub total_drift_m: f64, pub magnus_component_m: f64, pub gyroscopic_component_m: f64, pub pitch_damping_moment: f64, pub yaw_convergence_rate: f64, pub pitch_rate_rad_s: f64, }
23
24pub(crate) fn miller_stability(
29 caliber_in: f64,
30 weight_gr: f64,
31 twist_in: f64,
32 length_in: f64,
33) -> f64 {
34 if caliber_in <= 0.0 || weight_gr <= 0.0 || twist_in <= 0.0 || length_in <= 0.0 {
35 return 0.0;
36 }
37 let twist_cal = twist_in / caliber_in;
38 let l_cal = length_in / caliber_in;
39 let denom = twist_cal * twist_cal * caliber_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
40 if denom == 0.0 {
41 return 0.0;
42 }
43 30.0 * weight_gr / denom
44}
45
46pub fn litz_drift_inches(sg: f64, t_s: f64) -> f64 {
55 1.25 * (sg + 1.2) * t_s.powf(1.83)
56}
57
58pub fn litz_drift_meters(sg: f64, t_s: f64, is_twist_right: bool) -> f64 {
61 let sign = if is_twist_right { 1.0 } else { -1.0 };
62 sign * litz_drift_inches(sg, t_s) * 0.0254
63}
64
65pub fn effective_sg_from_inputs(inputs: &BallisticInputs, temp_c: f64, press_hpa: f64) -> f64 {
78 let mut eff = inputs.clone();
79 if eff.bullet_length <= 0.0 && eff.bullet_diameter > 0.0 {
80 let est = crate::stability::estimate_bullet_length_m(eff.bullet_diameter, eff.bullet_mass);
81 eff.bullet_length = if est > 0.0 {
82 est
83 } else {
84 4.5 * eff.bullet_diameter };
86 }
87 crate::stability::compute_stability_coefficient(&eff, (eff.altitude, temp_c, press_hpa, 0.0))
90}
91
92pub fn calculate_spin_rate(velocity_mps: f64, twist_rate_inches: f64) -> (f64, f64) {
94 if twist_rate_inches <= 0.0 {
95 return (0.0, 0.0);
96 }
97
98 let velocity_ips = velocity_mps * 39.3701;
100
101 let spin_rate_rps = velocity_ips / twist_rate_inches;
103
104 let spin_rate_rad_s = spin_rate_rps * 2.0 * PI;
106
107 (spin_rate_rps, spin_rate_rad_s)
108}
109
110pub fn calculate_dynamic_stability(
112 bullet_mass_grains: f64,
113 velocity_mps: f64,
114 spin_rate_rad_s: f64,
115 caliber_inches: f64,
116 length_inches: f64,
117 air_density_kg_m3: f64,
118) -> f64 {
119 if spin_rate_rad_s == 0.0 || velocity_mps == 0.0 {
120 return 0.0;
121 }
122
123 let velocity_fps = velocity_mps * 3.28084;
125
126 if caliber_inches > 0.0 {
128 let spin_rps = spin_rate_rad_s / (2.0 * PI);
130 let velocity_ips = velocity_fps * 12.0; let twist_inches = if spin_rps > 0.0 {
132 velocity_ips / spin_rps
133 } else {
134 0.0
135 };
136 let twist_calibers = if twist_inches > 0.0 {
137 twist_inches / caliber_inches
138 } else {
139 0.0
140 };
141
142 let length_calibers = if caliber_inches > 0.0 {
144 length_inches / caliber_inches
145 } else {
146 0.0
147 };
148
149 if twist_calibers == 0.0 || length_calibers == 0.0 {
155 return 0.0;
156 }
157
158 let numerator = 30.0 * bullet_mass_grains;
159 let denominator = twist_calibers.powi(2)
160 * caliber_inches.powi(3)
161 * length_calibers
162 * (1.0 + length_calibers.powi(2));
163
164 if denominator == 0.0 {
165 return 0.0;
166 }
167
168 let sg_base = numerator / denominator;
170
171 let velocity_factor = (velocity_fps / 2800.0).powf(1.0 / 3.0);
173
174 let density_factor = 1.225 / air_density_kg_m3;
179
180 sg_base * velocity_factor * density_factor
182 } else {
183 0.0
184 }
185}
186
187pub fn calculate_yaw_of_repose(
189 stability_factor: f64,
190 velocity_mps: f64,
191 spin_rate_rad_s: f64,
192 wind_velocity_mps: f64,
193 pitch_rate_rad_s: f64,
194 air_density_kg_m3: f64,
195 caliber_inches: f64,
196 length_inches: f64,
197 mass_grains: f64,
198 mach: f64,
199 bullet_type: &str,
200 use_pitch_damping: bool,
201) -> (f64, f64) {
202 if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
203 return (0.0, 0.0);
204 }
205
206 if use_pitch_damping && mach > 0.0 {
208 let damping_type = match bullet_type.to_lowercase().as_str() {
210 "match" => "match_boat_tail",
211 "hunting" => "hunting",
212 "fmj" => "fmj",
213 "vld" => "vld",
214 _ => "match_boat_tail",
215 };
216
217 return calculate_damped_yaw_of_repose(
218 stability_factor,
219 velocity_mps,
220 spin_rate_rad_s,
221 wind_velocity_mps,
222 pitch_rate_rad_s,
223 air_density_kg_m3,
224 caliber_inches,
225 length_inches,
226 mass_grains,
227 mach,
228 damping_type,
229 );
230 }
231
232 let yaw_rad = if wind_velocity_mps == 0.0 {
235 0.002 } else {
239 if velocity_mps > 0.0 {
241 (wind_velocity_mps / velocity_mps).atan()
242 } else {
243 0.0
244 }
245 };
246
247 let stability_term = (stability_factor - 1.0).max(0.0).sqrt();
249 let damping = 1.0 / (1.0 + stability_term);
250
251 (yaw_rad * damping, 0.0) }
253
254pub fn calculate_magnus_drift_component(
256 velocity_mps: f64,
257 spin_rate_rad_s: f64,
258 yaw_rad: f64,
259 air_density_kg_m3: f64,
260 caliber_inches: f64,
261 time_s: f64,
262 mass_grains: f64,
263) -> f64 {
264 let diameter_m = caliber_inches * 0.0254;
265 let mass_kg = mass_grains * 0.00006479891; let mach = velocity_mps / 343.0; let cmag = if mach < 0.8 {
272 0.25
273 } else if mach < 1.2 {
274 0.15
276 } else {
277 0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
279 };
280
281 let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
283
284 let magnus_force = if velocity_mps > 0.0 {
286 cmag * spin_ratio
287 * yaw_rad
288 * 0.5
289 * air_density_kg_m3
290 * velocity_mps.powi(2)
291 * PI
292 * (diameter_m / 2.0).powi(2)
293 } else {
294 0.0
295 };
296
297 let magnus_accel = magnus_force / mass_kg;
299
300 0.5 * magnus_accel * time_s.powi(2)
303}
304
305pub fn calculate_gyroscopic_drift(
307 stability_factor: f64,
308 _yaw_rad: f64,
309 velocity_mps: f64,
310 time_s: f64,
311 is_right_twist: bool,
312) -> f64 {
313 if stability_factor <= 1.0 || time_s <= 0.0 {
314 return 0.0;
315 }
316
317 let velocity_fps = velocity_mps * 3.28084;
319 if velocity_fps < 1125.0 {
320 return 0.0;
321 }
322
323 let sign = if is_right_twist { 1.0 } else { -1.0 };
325
326 let base_coefficient = 1.25 * (stability_factor + 1.2);
328 let time_factor = time_s.powf(1.83);
329 let drift_in = sign * base_coefficient * time_factor;
330
331 drift_in * 0.0254
334}
335
336pub fn calculate_enhanced_spin_drift(
344 bullet_mass: f64,
345 velocity_mps: f64,
346 twist_rate: f64,
347 bullet_diameter: f64,
348 bullet_length: f64,
349 is_twist_right: bool,
350 time_s: f64,
351 air_density: f64,
352 crosswind_mps: f64,
353 pitch_rate_rad_s: f64,
354 use_pitch_damping: bool,
355) -> SpinDriftComponents {
356 let muzzle_velocity = velocity_mps; let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
359
360 let decay_params = SpinDecayParameters::from_bullet_type("match"); let current_spin_rad_s = update_spin_rate(
363 initial_spin_rad_s,
364 time_s,
365 velocity_mps,
366 air_density,
367 bullet_mass, bullet_diameter,
369 bullet_length,
370 Some(&decay_params),
371 );
372
373 let spin_rps = current_spin_rad_s / (2.0 * PI);
374 let spin_rad_s = current_spin_rad_s;
375
376 let stability = calculate_dynamic_stability(
378 bullet_mass,
379 velocity_mps,
380 spin_rad_s,
381 bullet_diameter,
382 bullet_length,
383 air_density,
384 );
385
386 let mach = velocity_mps / 343.0; let bullet_type = "match";
391
392 let (yaw_rad, convergence_rate) = calculate_yaw_of_repose(
394 stability,
395 velocity_mps,
396 spin_rad_s,
397 crosswind_mps,
398 pitch_rate_rad_s,
399 air_density,
400 bullet_diameter,
401 bullet_length,
402 bullet_mass,
403 mach,
404 bullet_type,
405 use_pitch_damping,
406 );
407
408 let magnus_drift = calculate_magnus_drift_component(
410 velocity_mps,
411 spin_rad_s,
412 yaw_rad,
413 air_density,
414 bullet_diameter,
415 time_s,
416 bullet_mass,
417 );
418
419 let gyro_drift =
421 calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
422
423 let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
428 let total_drift = twist_sign * magnus_drift + gyro_drift;
429
430 let drift_rate = if time_s > 0.0 {
432 total_drift / time_s
433 } else {
434 0.0
435 };
436
437 let pitch_damping_moment = if use_pitch_damping && mach > 0.0 {
439 let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
440 calculate_pitch_damping_moment(
441 pitch_rate_rad_s,
442 velocity_mps,
443 air_density,
444 bullet_diameter * 0.0254, bullet_length * 0.0254, mach,
447 &coeffs,
448 )
449 } else {
450 0.0
451 };
452
453 SpinDriftComponents {
454 spin_rate_rps: spin_rps,
455 spin_rate_rad_s: spin_rad_s,
456 stability_factor: stability,
457 yaw_of_repose_rad: yaw_rad,
458 drift_rate_mps: drift_rate,
459 total_drift_m: total_drift,
460 magnus_component_m: magnus_drift,
461 gyroscopic_component_m: gyro_drift,
462 pitch_damping_moment,
463 yaw_convergence_rate: convergence_rate,
464 pitch_rate_rad_s,
465 }
466}
467
468pub fn apply_enhanced_spin_drift(
474 derivatives: &mut [f64; 6],
475 spin_components: &SpinDriftComponents,
476 time_s: f64,
477 _is_right_twist: bool,
478) {
479 if time_s > 0.1 {
480 let spin_accel_z = 2.0 * spin_components.drift_rate_mps / time_s;
483
484 derivatives[5] += spin_accel_z;
489 }
490}
491
492pub fn compute_enhanced_spin_drift_simple(
494 time_s: f64,
495 stability: f64,
496 velocity_mps: f64,
497 twist_rate: f64,
498 is_twist_right: bool,
499 _caliber: f64,
500) -> f64 {
501 if twist_rate <= 0.0 {
502 return 0.0;
503 }
504
505 let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
507
508 let decay_params = SpinDecayParameters::from_bullet_type("match");
510 let spin_rad_s = update_spin_rate(
511 initial_spin_rad_s,
512 time_s,
513 velocity_mps,
514 1.225, 175.0, _caliber,
517 1.3, Some(&decay_params),
519 );
520
521 let (yaw_rad, _) = calculate_yaw_of_repose(
523 stability,
524 velocity_mps,
525 spin_rad_s,
526 0.0,
527 0.0,
528 1.225,
529 _caliber,
530 1.3,
531 175.0,
532 velocity_mps / 343.0,
533 "match",
534 false,
535 );
536
537 calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right)
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn test_calculate_spin_rate() {
548 let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
550
551 assert!((rps - 3149.6).abs() < 1.0);
553 assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
554
555 let (rps_zero, rad_s_zero) = calculate_spin_rate(800.0, 0.0);
557 assert_eq!(rps_zero, 0.0);
558 assert_eq!(rad_s_zero, 0.0);
559 }
560
561 #[test]
562 fn test_calculate_dynamic_stability() {
563 let sg = calculate_dynamic_stability(
564 168.0, 800.0, 19792.0, 0.308, 1.2, 1.225, );
571
572 assert!(sg > 1.0);
574 assert!(sg < 10.0); }
576
577 #[test]
578 fn test_calculate_yaw_of_repose() {
579 let (yaw, _) = calculate_yaw_of_repose(
580 2.5, 800.0, 19792.0, 10.0, 0.0, 1.225, 0.308, 1.2, 168.0, 2.33, "match", false, );
593
594 assert!(yaw.abs() > 0.0);
596 assert!(yaw.abs() < 0.1); }
598
599 #[test]
600 fn test_enhanced_spin_drift_calculation() {
601 let components = calculate_enhanced_spin_drift(
602 168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 10.0, 0.0, false, );
614
615 assert!(components.total_drift_m.abs() > 0.0);
617 assert!(components.spin_rate_rps > 0.0);
618 assert!(components.stability_factor > 0.0);
619 }
620
621 #[test]
622 fn test_litz_drift_helpers_sign_and_magnitude() {
623 let sg = 2.0_f64;
625 let t = 1.5_f64;
626 let expected_in = 1.25 * (sg + 1.2) * t.powf(1.83);
627 assert!((litz_drift_inches(sg, t) - expected_in).abs() < 1e-12);
628 let right = litz_drift_meters(sg, t, true);
630 let left = litz_drift_meters(sg, t, false);
631 assert!((right - expected_in * 0.0254).abs() < 1e-12);
632 assert!((right + left).abs() < 1e-12, "left twist must mirror right");
633 assert!(right > 0.0 && left < 0.0);
634 }
635
636 #[test]
637 fn test_effective_sg_from_inputs_includes_velocity_term_and_length_fallback() {
638 let inputs = BallisticInputs {
642 muzzle_velocity: 800.0, bullet_mass: 175.0 * 0.00006479891,
644 bullet_diameter: 0.308 * 0.0254,
645 bullet_length: 1.24 * 0.0254,
646 twist_rate: 10.0,
647 ..Default::default()
648 };
649
650 let temp_c = 15.0;
651 let press_hpa = 1013.25;
652 let sg = effective_sg_from_inputs(&inputs, temp_c, press_hpa);
653
654 let direct =
656 crate::stability::compute_stability_coefficient(&inputs, (0.0, temp_c, press_hpa, 0.0));
657 assert!((sg - direct).abs() < 1e-12, "sg {sg} != direct {direct}");
658
659 let d_in = inputs.bullet_diameter / 0.0254;
662 let m_gr = inputs.bullet_mass / 0.00006479891;
663 let l_in = inputs.bullet_length / 0.0254;
664 let bare = miller_stability(d_in, m_gr, inputs.twist_rate, l_in);
665 let vel_corr = (inputs.muzzle_velocity * 3.28084 / 2800.0).powf(1.0 / 3.0);
666 assert!(vel_corr < 1.0, "muzzle < 2800 fps should shrink Sg");
667 assert!(
668 (sg - bare * vel_corr).abs() < 1e-6,
669 "sg {sg} != bare {bare} * vel_corr {vel_corr}"
670 );
671
672 let mut no_len = inputs.clone();
676 no_len.bullet_length = 0.0;
677 let sg_fallback = effective_sg_from_inputs(&no_len, temp_c, press_hpa);
678 let mut explicit = inputs.clone();
679 explicit.bullet_length =
680 crate::stability::estimate_bullet_length_m(inputs.bullet_diameter, inputs.bullet_mass);
681 let sg_explicit = effective_sg_from_inputs(&explicit, temp_c, press_hpa);
682 assert!(
683 (sg_fallback - sg_explicit).abs() < 1e-12,
684 "zero-length fallback {sg_fallback} != explicit estimate-length {sg_explicit}"
685 );
686 assert!(sg_fallback > 0.0);
687 let mut old_default = inputs.clone();
689 old_default.bullet_length = 4.5 * old_default.bullet_diameter;
690 let sg_old = effective_sg_from_inputs(&old_default, temp_c, press_hpa);
691 assert!(
692 (sg_fallback - sg_old).abs() > 1e-6,
693 "mass-based fallback should differ from the old 4.5-cal default"
694 );
695 }
696
697 #[test]
698 fn test_miller_stability_308_168gr() {
699 let sg = miller_stability(0.308, 168.0, 12.0, 1.215);
703 assert!(sg > 1.5 && sg < 2.0, "expected base Sg ~1.74, got {}", sg);
704 }
705
706 #[test]
707 fn test_miller_stability_invalid_inputs_zero() {
708 assert_eq!(miller_stability(0.0, 168.0, 12.0, 1.2), 0.0);
709 assert_eq!(miller_stability(0.308, 0.0, 12.0, 1.2), 0.0);
710 assert_eq!(miller_stability(0.308, 168.0, 0.0, 1.2), 0.0);
711 assert_eq!(miller_stability(0.308, 168.0, 12.0, 0.0), 0.0);
712 }
713
714 #[test]
715 fn test_opposite_twist_directions() {
716 let right_drift = calculate_enhanced_spin_drift(
718 168.0, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 0.0, 0.0, false,
719 );
720
721 let left_drift = calculate_enhanced_spin_drift(
723 168.0, 800.0, 10.0, 0.308, 1.2, false, 1.0, 1.225, 0.0, 0.0, false,
724 );
725
726 assert!(right_drift.gyroscopic_component_m * left_drift.gyroscopic_component_m < 0.0);
728 assert!(
729 (right_drift.gyroscopic_component_m.abs() - left_drift.gyroscopic_component_m.abs())
730 .abs()
731 < 0.001
732 );
733 }
734
735 #[test]
736 fn test_applied_spin_drift_flips_with_twist() {
737 let time_s = 1.0;
743 let right = calculate_enhanced_spin_drift(
744 168.0, 800.0, 10.0, 0.308, 1.2, true, time_s, 1.225, 0.0, 0.0, false,
745 );
746 let left = calculate_enhanced_spin_drift(
747 168.0, 800.0, 10.0, 0.308, 1.2, false, time_s, 1.225, 0.0, 0.0, false,
748 );
749
750 let mut d_right = [0.0_f64; 6];
751 let mut d_left = [0.0_f64; 6];
752 apply_enhanced_spin_drift(&mut d_right, &right, time_s, true);
753 apply_enhanced_spin_drift(&mut d_left, &left, time_s, false);
754
755 assert!(d_right[5].abs() > 0.0, "expected non-zero spin drift accel");
756 assert!(d_left[5].abs() > 0.0, "expected non-zero spin drift accel");
757 assert!(
758 d_right[5] * d_left[5] < 0.0,
759 "expected opposite-sign lateral accel for opposite twist, got {} and {}",
760 d_right[5],
761 d_left[5]
762 );
763 }
764}