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
182pub 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 if use_pitch_damping && mach > 0.0 {
203 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 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
242pub 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; let mach = velocity_mps / 343.0; let cmag = if mach < 0.8 {
260 0.25
261 } else if mach < 1.2 {
262 0.15
264 } else {
265 0.10 + 0.05 * ((mach - 1.2) / 2.0).min(1.0)
267 };
268
269 let spin_ratio = (spin_rate_rad_s * diameter_m / 2.0) / velocity_mps;
271
272 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 let magnus_accel = magnus_force / mass_kg;
287
288 0.5 * magnus_accel * time_s.powi(2)
291}
292
293pub 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
312pub 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 let muzzle_velocity = velocity_mps; let (_initial_spin_rps, initial_spin_rad_s) = calculate_spin_rate(muzzle_velocity, twist_rate);
335
336 let decay_params = SpinDecayParameters::from_bullet_type("match"); 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, 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 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 let mach = velocity_mps / 343.0; let bullet_type = "match";
367
368 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 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 let gyro_drift =
397 calculate_gyroscopic_drift(stability, yaw_rad, velocity_mps, time_s, is_twist_right);
398
399 let twist_sign = if is_twist_right { 1.0 } else { -1.0 };
404 let total_drift = twist_sign * magnus_drift + gyro_drift;
405
406 let drift_rate = if time_s > 0.0 {
408 total_drift / time_s
409 } else {
410 0.0
411 };
412
413 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, bullet_length * 0.0254, 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
444pub 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 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 derivatives[5] += spin_accel_z;
467 }
468}
469
470pub 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 let (_, initial_spin_rad_s) = calculate_spin_rate(velocity_mps, twist_rate);
485
486 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, 175.0, _caliber,
495 1.3, Some(&decay_params),
497 );
498
499 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(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 let (rps, rad_s) = calculate_spin_rate(800.0, 10.0);
528
529 assert!((rps - 3149.6).abs() < 1.0);
531 assert!((rad_s - rps * 2.0 * PI).abs() < 0.1);
532
533 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, 800.0, 19792.0, 0.308, 1.2, 1.225, );
561
562 assert!(sg > 1.0);
564 assert!(sg < 10.0); }
566
567 #[test]
568 fn test_calculate_yaw_of_repose() {
569 let (yaw, _) = calculate_yaw_of_repose(
570 2.5, 800.0, 19792.0, 10.0, 0.0, 1.225, 0.308, 1.2, 168.0, 2.33, "match", false, );
583
584 assert!(yaw.abs() > 0.0);
586 assert!(yaw.abs() < 0.1); }
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 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, 800.0, 10.0, 0.308, 1.2, true, 1.0, 1.225, 10.0, 0.0, false, );
703
704 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 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 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 let inputs = BallisticInputs {
775 muzzle_velocity: 800.0, 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 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 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 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 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 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 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 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 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 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}