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#[derive(Debug, Clone)]
13pub struct SpinDriftComponents {
14 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, }
26
27pub(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
49pub fn litz_drift_inches(sg: f64, t_s: f64) -> f64 {
58 1.25 * (sg + 1.2) * t_s.powf(LITZ_TIME_EXPONENT)
59}
60
61pub 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
68pub 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 };
89 }
90 crate::stability::compute_stability_coefficient(&eff, (eff.altitude, temp_c, press_hpa, 0.0))
93}
94
95pub 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 let velocity_ips = velocity_mps * 39.3701;
103
104 let spin_rate_rps = velocity_ips / twist_rate_inches;
106
107 let spin_rate_rad_s = spin_rate_rps * 2.0 * PI;
109
110 (spin_rate_rps, spin_rate_rad_s)
111}
112
113pub(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
133pub 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 let velocity_fps = velocity_mps * 3.28084;
152
153 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; 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 let velocity_factor = (velocity_fps / 2800.0).powf(1.0 / 3.0);
172
173 let density_factor = 1.225 / air_density_kg_m3;
178
179 sg_base * velocity_factor * density_factor
180}
181
182#[allow(clippy::too_many_arguments)] pub 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 if use_pitch_damping && mach > 0.0 {
204 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 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
243pub 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; let mach = velocity_mps / 343.0; let cmag = if mach < 0.8 {
261 0.25
262 } else if mach < 1.2 {
263 0.15
265 } else {
266 0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
268 };
269
270 let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
272
273 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 let magnus_accel = magnus_force / mass_kg;
288
289 0.5 * magnus_accel * time_s.powi(2)
292}
293
294pub 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#[allow(clippy::too_many_arguments)] pub 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 let muzzle_velocity = velocity_mps; let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
337
338 let decay_params = SpinDecayParameters::from_bullet_type("match"); 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, 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 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 let mach = velocity_mps / 343.0; let bullet_type = "match";
369
370 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 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 let gyro_drift =
399 calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
400
401 let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
406 let total_drift = twist_sign * magnus_drift + gyro_drift;
407
408 let drift_rate = if time_s > 0.0 {
410 total_drift / time_s
411 } else {
412 0.0
413 };
414
415 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, bullet_length * 0.0254, 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
446pub 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 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 derivatives[5] += spin_accel_z;
469 }
470}
471
472pub 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 let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
487
488 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, 175.0, _caliber,
497 1.3, Some(&decay_params),
499 );
500
501 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(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 let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
530
531 assert!((rps - 3149.6).abs() < 1.0);
533 assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
534
535 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, 800.0, 19792.0, 0.308, 1.2, 1.225, );
563
564 assert!(sg > 1.0);
566 assert!(sg < 10.0); }
568
569 #[test]
570 fn test_calculate_yaw_of_repose() {
571 let (yaw, _) = calculate_yaw_of_repose(
572 2.5, 800.0, 19792.0, 10.0, 0.0, 1.225, 0.308, 1.2, 168.0, 2.33, "match", false, );
585
586 assert!(yaw.abs() > 0.0);
588 assert!(yaw.abs() < 0.1); }
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 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, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 10.0, 0.0, false, );
705
706 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 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 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 let inputs = BallisticInputs {
777 muzzle_velocity: 800.0, 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 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 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 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 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 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 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 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 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 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}