1use crate::constants::G_ACCEL_MPS2;
12use crate::pitch_damping::{calculate_pitch_damping_moment, PitchDampingCoefficients};
13
14pub(crate) fn projectile_moments_of_inertia(
20 mass_kg: f64,
21 caliber_m: f64,
22 length_m: f64,
23) -> (f64, f64) {
24 if !mass_kg.is_finite()
25 || mass_kg <= 0.0
26 || !caliber_m.is_finite()
27 || caliber_m <= 0.0
28 || !length_m.is_finite()
29 || length_m <= 0.0
30 {
31 return (0.0, 0.0);
32 }
33
34 let spin_inertia =
35 crate::spin_decay::calculate_moment_of_inertia(mass_kg, caliber_m, length_m, "ogive");
36 let transverse_inertia = crate::pitch_damping::calculate_transverse_moment_of_inertia(
37 mass_kg, caliber_m, length_m, "ogive",
38 );
39
40 if spin_inertia.is_finite()
41 && spin_inertia > 0.0
42 && transverse_inertia.is_finite()
43 && transverse_inertia > 0.0
44 {
45 (spin_inertia, transverse_inertia)
46 } else {
47 (0.0, 0.0)
48 }
49}
50
51#[derive(Debug, Clone, Copy)]
53pub struct AngularState {
54 pub pitch_angle: f64, pub yaw_angle: f64, pub pitch_rate: f64, pub yaw_rate: f64, pub precession_angle: f64, pub nutation_phase: f64, }
61
62#[derive(Debug, Clone)]
64pub struct PrecessionNutationParams {
65 pub mass_kg: f64,
67 pub caliber_m: f64,
68 pub length_m: f64,
69 pub spin_rate_rad_s: f64,
70
71 pub spin_inertia: f64, pub transverse_inertia: f64, pub velocity_mps: f64,
77 pub air_density_kg_m3: f64,
78 pub mach: f64,
79
80 pub pitch_damping_coeff: f64,
82 pub nutation_damping_factor: f64, }
84
85impl Default for PrecessionNutationParams {
86 fn default() -> Self {
87 Self {
88 mass_kg: 0.01134, caliber_m: 0.00782,
90 length_m: 0.033,
91 spin_rate_rad_s: 17522.0,
92 spin_inertia: 6.94e-8,
93 transverse_inertia: 9.13e-7,
94 velocity_mps: 850.0,
95 air_density_kg_m3: 1.225,
96 mach: 2.48,
97 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
98 nutation_damping_factor: 0.05,
99 }
100 }
101}
102
103pub fn epicyclic_frequencies(
112 spin_inertia: f64,
113 transverse_inertia: f64,
114 spin_rate_rad_s: f64,
115 stability_factor: f64,
116) -> (f64, f64) {
117 if stability_factor <= 1.0 || transverse_inertia == 0.0 {
118 return (0.0, 0.0);
119 }
120 let arm = (spin_inertia * spin_rate_rad_s) / (2.0 * transverse_inertia);
122 let disc = (1.0 - 1.0 / stability_factor).sqrt();
123 (arm * (1.0 + disc), arm * (1.0 - disc)) }
125
126pub fn calculate_precession_frequency(
129 spin_rate_rad_s: f64,
130 spin_inertia: f64,
131 transverse_inertia: f64,
132 stability_factor: f64,
133) -> f64 {
134 epicyclic_frequencies(
135 spin_inertia,
136 transverse_inertia,
137 spin_rate_rad_s,
138 stability_factor,
139 )
140 .1
141}
142
143pub fn calculate_nutation_frequency(
146 spin_rate_rad_s: f64,
147 spin_inertia: f64,
148 transverse_inertia: f64,
149 stability_factor: f64,
150) -> f64 {
151 epicyclic_frequencies(
152 spin_inertia,
153 transverse_inertia,
154 spin_rate_rad_s,
155 stability_factor,
156 )
157 .0
158}
159
160pub fn calculate_nutation_amplitude(
162 initial_disturbance_rad: f64,
163 time_s: f64,
164 nutation_frequency: f64,
165 damping_factor: f64,
166 spin_rate_rad_s: f64,
167) -> f64 {
168 if nutation_frequency == 0.0 || spin_rate_rad_s == 0.0 {
169 return 0.0;
170 }
171
172 let damping_rate = damping_factor * nutation_frequency;
174
175 let amplitude = initial_disturbance_rad * (-damping_rate * time_s).exp();
177
178 amplitude.min(0.1) }
181
182pub fn calculate_combined_angular_motion(
184 params: &PrecessionNutationParams,
185 angular_state: &AngularState,
186 time_s: f64,
187 dt: f64,
188 initial_disturbance: f64,
189) -> AngularState {
190 if params.transverse_inertia == 0.0
192 || params.velocity_mps == 0.0
193 || params.length_m == 0.0
194 || !params.air_density_kg_m3.is_finite()
195 || params.air_density_kg_m3 <= 0.0
196 {
197 return *angular_state;
199 }
200
201 let caliber_in = params.caliber_m / 0.0254;
205 let length_in = params.length_m / 0.0254;
206 let mass_gr = params.mass_kg / crate::constants::GRAINS_TO_KG;
207 let stability = crate::spin_drift::calculate_dynamic_stability(
208 mass_gr,
209 params.velocity_mps,
210 params.spin_rate_rad_s.abs(),
211 caliber_in,
212 length_in,
213 params.air_density_kg_m3,
214 );
215
216 let omega_p = calculate_precession_frequency(
218 params.spin_rate_rad_s,
219 params.spin_inertia,
220 params.transverse_inertia,
221 stability,
222 );
223 let omega_n = calculate_nutation_frequency(
224 params.spin_rate_rad_s,
225 params.spin_inertia,
226 params.transverse_inertia,
227 stability,
228 );
229
230 let nutation_amp = calculate_nutation_amplitude(
232 initial_disturbance,
233 time_s,
234 omega_n,
235 params.nutation_damping_factor,
236 params.spin_rate_rad_s,
237 );
238
239 let new_precession_angle = angular_state.precession_angle + omega_p * dt;
241
242 let new_nutation_phase = angular_state.nutation_phase + omega_n * dt;
244
245 let pitch_moment = calculate_pitch_damping_moment(
247 angular_state.pitch_rate,
248 params.velocity_mps,
249 params.air_density_kg_m3,
250 params.caliber_m,
251 params.length_m,
252 params.mach,
253 &PitchDampingCoefficients {
254 subsonic: params.pitch_damping_coeff,
255 ..Default::default()
256 },
257 );
258
259 let pitch_accel = pitch_moment / params.transverse_inertia;
261
262 let new_pitch_rate = angular_state.pitch_rate + pitch_accel * dt;
264
265 let coning_amp = initial_disturbance;
271 let total_yaw =
272 coning_amp * new_precession_angle.cos() + nutation_amp * new_nutation_phase.sin();
273 let damping_rate = params.nutation_damping_factor * omega_n;
274 let new_yaw_rate = -coning_amp * omega_p * new_precession_angle.sin()
275 + nutation_amp
276 * (omega_n * new_nutation_phase.cos() - damping_rate * new_nutation_phase.sin());
277
278 let new_pitch = angular_state.pitch_angle + new_pitch_rate * dt;
280
281 AngularState {
282 pitch_angle: new_pitch,
283 yaw_angle: total_yaw,
284 pitch_rate: new_pitch_rate,
285 yaw_rate: new_yaw_rate,
286 precession_angle: new_precession_angle,
287 nutation_phase: new_nutation_phase,
288 }
289}
290
291pub fn calculate_epicyclic_motion(
293 spin_inertia: f64,
294 transverse_inertia: f64,
295 spin_rate_rad_s: f64,
296 stability_factor: f64,
297 time_s: f64,
298 initial_yaw_rad: f64,
299) -> (f64, f64) {
300 if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
302 return (initial_yaw_rad, initial_yaw_rad);
304 }
305
306 let (omega_fast, omega_slow) = epicyclic_frequencies(
308 spin_inertia,
309 transverse_inertia,
310 spin_rate_rad_s,
311 stability_factor,
312 );
313
314 let frequency_split = omega_fast - omega_slow;
318 if frequency_split == 0.0 {
319 return (0.0, initial_yaw_rad);
320 }
321 let slow_amplitude = initial_yaw_rad * omega_fast / frequency_split;
322 let initial_fast_amplitude = initial_yaw_rad - slow_amplitude;
323
324 let damping_factor = 0.1; let fast_amplitude = initial_fast_amplitude * (-damping_factor * omega_fast * time_s).exp();
327
328 let slow_phase = omega_slow * time_s;
330 let fast_phase = omega_fast * time_s;
331
332 let yaw = slow_amplitude * slow_phase.cos() + fast_amplitude * fast_phase.cos();
334 let pitch = slow_amplitude * slow_phase.sin() + fast_amplitude * fast_phase.sin();
335
336 (pitch, yaw)
337}
338
339pub fn calculate_limit_cycle_yaw_with_inertias(
346 velocity_mps: f64,
347 spin_rate_rad_s: f64,
348 stability_factor: f64,
349 spin_inertia: f64,
350 transverse_inertia: f64,
351) -> f64 {
352 if !velocity_mps.is_finite()
353 || velocity_mps <= 0.0
354 || !spin_rate_rad_s.is_finite()
355 || spin_rate_rad_s == 0.0
356 || !stability_factor.is_finite()
357 || stability_factor < 1.0
358 || !spin_inertia.is_finite()
359 || spin_inertia <= 0.0
360 || !transverse_inertia.is_finite()
361 || transverse_inertia <= 0.0
362 {
363 return 0.0;
364 }
365
366 4.0 * transverse_inertia * stability_factor * G_ACCEL_MPS2
367 / (spin_inertia * spin_rate_rad_s.abs() * velocity_mps)
368}
369
370#[deprecated(
377 since = "0.22.18",
378 note = "use calculate_limit_cycle_yaw_with_inertias for projectile-specific yaw of repose"
379)]
380pub fn calculate_limit_cycle_yaw(
381 velocity_mps: f64,
382 spin_rate_rad_s: f64,
383 stability_factor: f64,
384 _crosswind_mps: f64,
385) -> f64 {
386 let reference = PrecessionNutationParams::default();
387 calculate_limit_cycle_yaw_with_inertias(
388 velocity_mps,
389 spin_rate_rad_s,
390 stability_factor,
391 reference.spin_inertia,
392 reference.transverse_inertia,
393 )
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn projectile_inertias_match_reference_and_geometry_scaling() {
402 let mass = 0.01134;
403 let caliber = 0.00782;
404 let length = 0.033;
405 let (spin, transverse) = projectile_moments_of_inertia(mass, caliber, length);
406
407 assert!((spin / 6.94e-8 - 1.0).abs() < 0.01);
408 assert!((transverse / 9.13e-7 - 1.0).abs() < 0.01);
409
410 let (double_mass_spin, double_mass_transverse) =
411 projectile_moments_of_inertia(2.0 * mass, caliber, length);
412 assert!((double_mass_spin / spin - 2.0).abs() < 1e-12);
413 assert!((double_mass_transverse / transverse - 2.0).abs() < 1e-12);
414
415 let (double_caliber_spin, double_caliber_transverse) =
416 projectile_moments_of_inertia(mass, 2.0 * caliber, length);
417 assert!((double_caliber_spin / spin - 4.0).abs() < 1e-12);
418 assert!(double_caliber_transverse > transverse);
419
420 let (double_length_spin, double_length_transverse) =
421 projectile_moments_of_inertia(mass, caliber, 2.0 * length);
422 assert!((double_length_spin / spin - 1.0).abs() < 1e-12);
423 assert!(double_length_transverse / transverse > 3.5);
424 }
425
426 #[test]
427 fn projectile_inertias_reject_invalid_geometry() {
428 let invalid = [0.0, -1.0, f64::NAN, f64::INFINITY];
429
430 for value in invalid {
431 assert_eq!(
432 projectile_moments_of_inertia(value, 0.00782, 0.033),
433 (0.0, 0.0)
434 );
435 assert_eq!(
436 projectile_moments_of_inertia(0.01134, value, 0.033),
437 (0.0, 0.0)
438 );
439 assert_eq!(
440 projectile_moments_of_inertia(0.01134, 0.00782, value),
441 (0.0, 0.0)
442 );
443 }
444
445 assert_eq!(
446 projectile_moments_of_inertia(f64::MAX, f64::MAX, f64::MAX),
447 (0.0, 0.0)
448 );
449 }
450
451 #[test]
452 fn test_mba941_epicyclic_relations_and_limits() {
453 let (ix, iy, p) = (6.94e-8_f64, 9.13e-7_f64, 17522.0_f64);
458 let arm = ix * p / (2.0 * iy);
459 for &sg in &[1.5_f64, 2.5, 5.0, 50.0] {
460 let (fast, slow) = epicyclic_frequencies(ix, iy, p, sg);
461 assert!(fast > slow && slow > 0.0, "expect fast>slow>0 at Sg={sg}");
462 assert!(
463 ((fast + slow) - 2.0 * arm).abs() < 1e-6 * arm,
464 "sum != Ix p / Iy at Sg={sg}"
465 );
466 assert!(
467 (fast * slow - arm * arm / sg).abs() < 1e-6 * arm * arm,
468 "product != arm^2 / Sg at Sg={sg}"
469 );
470 }
471 let (f1, s1) = epicyclic_frequencies(ix, iy, p, 1.0 + 1e-9);
473 assert!((f1 - arm).abs() < 1e-3 * arm && (s1 - arm).abs() < 1e-3 * arm);
474 let (f2, s2) = epicyclic_frequencies(ix, iy, p, 1.0e6);
476 assert!(s2 < 1e-3 * arm, "slow precession should vanish at high Sg");
477 assert!(
478 (f2 - 2.0 * arm).abs() < 1e-3 * arm,
479 "fast -> Ix p / Iy at high Sg"
480 );
481 assert_eq!(epicyclic_frequencies(ix, iy, p, 0.9), (0.0, 0.0));
483 }
484
485 #[test]
486 fn test_precession_frequency() {
487 let freq = calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 2.5);
489 let nut = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 2.5);
490 assert!(
492 freq > 0.0 && freq < nut,
493 "precession {freq} should satisfy 0 < freq < nutation {nut}"
494 );
495 assert_eq!(
497 calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9),
498 0.0
499 );
500 }
501
502 #[test]
503 fn test_nutation_frequency() {
504 let freq = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.5);
507 assert!(
508 (900.0..1200.0).contains(&freq),
509 "nutation freq {freq} rad/s out of expected band"
510 );
511 }
512
513 #[test]
514 fn test_nutation_damping() {
515 let initial = 0.01;
516 let freq = 3000.0;
517
518 let amp_0 = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, 17522.0);
520 let amp_1 = calculate_nutation_amplitude(initial, 0.1, freq, 0.05, 17522.0);
521
522 assert_eq!(amp_0, initial);
523 assert!(amp_1 < amp_0);
524 assert!(amp_1 > 0.0);
525 }
526
527 #[test]
528 fn test_precession_edge_cases() {
529 assert_eq!(
531 calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9),
532 0.0
533 );
534 assert_eq!(
535 calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 1.0),
536 0.0
537 );
538 assert_eq!(
540 calculate_precession_frequency(17522.0, 6.94e-8, 0.0, 2.0),
541 0.0
542 );
543 }
544
545 #[test]
546 fn test_nutation_edge_cases() {
547 let freq_unstable = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9);
549 assert_eq!(freq_unstable, 0.0);
550
551 let freq_marginal = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.0);
553 assert_eq!(freq_marginal, 0.0);
554
555 let freq_zero_inertia = calculate_nutation_frequency(17522.0, 6.94e-8, 0.0, 2.0);
557 assert_eq!(freq_zero_inertia, 0.0);
558 }
559
560 #[test]
561 fn test_nutation_amplitude_bounds() {
562 let initial = 0.5; let freq = 3000.0;
564 let spin = 17522.0;
565
566 let amp = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, spin);
568 assert!(amp <= 0.1); let amp_zero_freq = calculate_nutation_amplitude(initial, 1.0, 0.0, 0.05, spin);
572 assert_eq!(amp_zero_freq, 0.0);
573
574 let amp_zero_spin = calculate_nutation_amplitude(initial, 1.0, freq, 0.05, 0.0);
576 assert_eq!(amp_zero_spin, 0.0);
577 }
578
579 #[test]
580 fn test_epicyclic_motion() {
581 let (pitch, yaw) = calculate_epicyclic_motion(
582 6.94e-8, 9.13e-7, 17522.0, 2.5, 0.1, 0.01, );
589
590 let (omega_fast, omega_slow) = epicyclic_frequencies(6.94e-8, 9.13e-7, 17522.0, 2.5);
592 let frequency_split = omega_fast - omega_slow;
593 let slow_amplitude = 0.01 * omega_fast / frequency_split;
594 let fast_amplitude = 0.01 - slow_amplitude;
595 let bound = slow_amplitude.abs() + fast_amplitude.abs() + 1e-9;
596 assert!(pitch.abs() <= bound, "pitch {pitch} exceeds bound {bound}");
597 assert!(yaw.abs() <= bound, "yaw {yaw} exceeds bound {bound}");
598
599 let (pitch_unstable, yaw_unstable) =
601 calculate_epicyclic_motion(6.94e-8, 9.13e-7, 17522.0, 0.9, 0.1, 0.01);
602 assert_eq!(pitch_unstable, 0.01);
603 assert_eq!(yaw_unstable, 0.01);
604 }
605
606 #[test]
607 fn epicyclic_motion_satisfies_supplied_initial_conditions() {
608 let calculate = |time_s| {
609 calculate_epicyclic_motion(
610 6.94e-8, 9.13e-7, 17522.0, 2.5, time_s, 0.01, )
616 };
617
618 let (initial_pitch, initial_yaw) = calculate(0.0);
619 assert!(initial_pitch.abs() < 1e-15);
620 assert!((initial_yaw - 0.01).abs() < 1e-14);
621
622 let h = 1e-7;
625 let pitch_h = calculate(h).0;
626 let pitch_2h = calculate(2.0 * h).0;
627 let initial_pitch_rate = (4.0 * pitch_h - pitch_2h) / (2.0 * h);
628 assert!(initial_pitch_rate.abs() < 1e-6);
629
630 assert_eq!(
631 calculate_epicyclic_motion(0.0, 9.13e-7, 17522.0, 2.5, 0.1, 0.01),
632 (0.0, 0.01)
633 );
634 assert_eq!(
635 calculate_epicyclic_motion(6.94e-8, 0.0, 17522.0, 2.5, 0.1, 0.01),
636 (0.0, 0.01)
637 );
638 }
639
640 #[test]
641 #[allow(deprecated)]
642 fn limit_cycle_yaw_excludes_crosswind_and_fabricated_instability_step() {
643 let calm = calculate_limit_cycle_yaw(850.0, 17522.0, 2.5, 0.0);
644 let crosswind = calculate_limit_cycle_yaw(850.0, 17522.0, 2.5, 10.0);
645 assert_eq!(crosswind.to_bits(), calm.to_bits());
646 assert!(calm > 0.0);
647
648 let at_boundary = calculate_limit_cycle_yaw(850.0, 17522.0, 1.0, 0.0);
650 let above_boundary = calculate_limit_cycle_yaw(850.0, 17522.0, 1.0_f64.next_up(), 0.0);
651 assert!(at_boundary > 0.0);
652 assert!(((above_boundary - at_boundary) / at_boundary).abs() < 1e-12);
653 assert_eq!(
654 calculate_limit_cycle_yaw(850.0, 17522.0, 1.0_f64.next_down(), 0.0),
655 0.0
656 );
657
658 let low_sg: f64 = 1.1;
661 let high_sg: f64 = 4.0;
662 let low_spin = 10_000.0;
663 let high_spin = low_spin * (high_sg / low_sg).sqrt();
664 let low = calculate_limit_cycle_yaw(850.0, low_spin, low_sg, 0.0);
665 let high = calculate_limit_cycle_yaw(850.0, high_spin, high_sg, 0.0);
666 assert!(high > low);
667 let expected_ratio = (high_sg / low_sg).sqrt();
668 assert!((high / low - expected_ratio).abs() < expected_ratio * 1e-12);
669 }
670
671 #[test]
672 fn inertia_aware_limit_cycle_yaw_matches_gravity_gyroscopic_balance() {
673 let velocity_mps = 850.0;
674 let spin_rate_rad_s = 17522.0;
675 let stability_factor = 2.5;
676 let spin_inertia = 6.94e-8;
677 let transverse_inertia = 9.13e-7;
678 let actual = calculate_limit_cycle_yaw_with_inertias(
679 velocity_mps,
680 spin_rate_rad_s,
681 stability_factor,
682 spin_inertia,
683 transverse_inertia,
684 );
685 let expected = 4.0 * transverse_inertia * stability_factor * 9.80665
686 / (spin_inertia * spin_rate_rad_s * velocity_mps);
687
688 assert!((actual - expected).abs() < 1e-15);
689 assert_eq!(
690 calculate_limit_cycle_yaw_with_inertias(
691 velocity_mps,
692 spin_rate_rad_s,
693 0.9,
694 spin_inertia,
695 transverse_inertia,
696 ),
697 0.0
698 );
699 assert_eq!(
700 calculate_limit_cycle_yaw_with_inertias(
701 velocity_mps,
702 spin_rate_rad_s,
703 stability_factor,
704 0.0,
705 transverse_inertia,
706 ),
707 0.0
708 );
709 }
710
711 #[test]
712 fn test_combined_angular_motion() {
713 let params = PrecessionNutationParams::default();
714 let initial_state = AngularState {
715 pitch_angle: 0.001,
716 yaw_angle: 0.002,
717 pitch_rate: 0.01,
718 yaw_rate: 0.01,
719 precession_angle: 0.0,
720 nutation_phase: 0.0,
721 };
722
723 let new_state = calculate_combined_angular_motion(
724 ¶ms,
725 &initial_state,
726 0.1, 0.001, 0.001, );
730
731 assert!(
734 new_state.nutation_phase != initial_state.nutation_phase
735 || new_state.precession_angle != initial_state.precession_angle
736 );
737
738 assert!(new_state.pitch_angle.abs() < 1.0);
740 assert!(new_state.yaw_angle.abs() < 1.0);
741 }
742
743 #[test]
744 fn combined_motion_yaw_rate_is_derivative_of_yaw() {
745 let params = PrecessionNutationParams::default();
746 let disturbance = 0.001;
747 let time = 0.02;
748 let h = 1e-7;
749 let state = |precession_angle, nutation_phase| AngularState {
750 pitch_angle: 0.0,
751 yaw_angle: 0.0,
752 pitch_rate: 0.0,
753 yaw_rate: 0.0,
754 precession_angle,
755 nutation_phase,
756 };
757
758 let phase_step =
760 calculate_combined_angular_motion(¶ms, &state(0.0, 0.0), time, 1.0, disturbance);
761 let omega_p = phase_step.precession_angle;
762 let omega_n = phase_step.nutation_phase;
763
764 let center_state = state(0.0, std::f64::consts::FRAC_PI_2);
767 let center =
768 calculate_combined_angular_motion(¶ms, ¢er_state, time, 0.0, disturbance);
769 let before_state = state(-omega_p * h, std::f64::consts::FRAC_PI_2 - omega_n * h);
770 let before =
771 calculate_combined_angular_motion(¶ms, &before_state, time - h, 0.0, disturbance);
772 let after_state = state(omega_p * h, std::f64::consts::FRAC_PI_2 + omega_n * h);
773 let after =
774 calculate_combined_angular_motion(¶ms, &after_state, time + h, 0.0, disturbance);
775 let finite_difference = (after.yaw_angle - before.yaw_angle) / (2.0 * h);
776
777 assert!(
778 (center.yaw_rate - finite_difference).abs() < 1e-8,
779 "yaw_rate={} finite_difference={finite_difference}",
780 center.yaw_rate
781 );
782 }
783
784 #[test]
785 fn combined_motion_applies_velocity_and_density_to_stability() {
786 let velocity_mps = 1_000.0;
787 let spin_rate_rad_s = 15_095.0;
788 let params = PrecessionNutationParams {
789 mass_kg: 0.01134,
790 caliber_m: 0.00782,
791 length_m: 0.033,
792 spin_rate_rad_s,
793 spin_inertia: 6.94e-8,
794 transverse_inertia: 9.13e-7,
795 velocity_mps,
796 air_density_kg_m3: 1.0,
797 mach: velocity_mps / 343.0,
798 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
799 nutation_damping_factor: 0.05,
800 };
801 let initial_state = AngularState {
802 pitch_angle: 0.0,
803 yaw_angle: 0.0,
804 pitch_rate: 0.0,
805 yaw_rate: 0.0,
806 precession_angle: 0.0,
807 nutation_phase: 0.0,
808 };
809
810 let caliber_in = params.caliber_m / 0.0254;
811 let length_in = params.length_m / 0.0254;
812 let mass_gr = params.mass_kg / crate::constants::GRAINS_TO_KG;
813 let spin_rps = spin_rate_rad_s / (2.0 * std::f64::consts::PI);
814 let twist_in = velocity_mps * 3.28084 * 12.0 / spin_rps;
815 let bare_sg = crate::spin_drift::miller_stability(caliber_in, mass_gr, twist_in, length_in);
816 let velocity_correction = (velocity_mps * 3.28084 / 2800.0).powf(1.0 / 3.0);
817 let density_correction = 1.225 / params.air_density_kg_m3;
818 let corrected_sg = crate::spin_drift::calculate_dynamic_stability(
819 mass_gr,
820 velocity_mps,
821 spin_rate_rad_s,
822 caliber_in,
823 length_in,
824 params.air_density_kg_m3,
825 );
826 assert!(bare_sg < 1.0);
827 assert!(bare_sg * velocity_correction < 1.0);
828 assert!(bare_sg * density_correction < 1.0);
829 assert!(corrected_sg > 1.0);
830
831 let dt = 0.0001;
832 let actual = calculate_combined_angular_motion(¶ms, &initial_state, 0.0, dt, 0.001);
833 let expected_precession = calculate_precession_frequency(
834 spin_rate_rad_s,
835 params.spin_inertia,
836 params.transverse_inertia,
837 corrected_sg,
838 ) * dt;
839 let expected_nutation = calculate_nutation_frequency(
840 spin_rate_rad_s,
841 params.spin_inertia,
842 params.transverse_inertia,
843 corrected_sg,
844 ) * dt;
845
846 assert!(
847 (actual.precession_angle - expected_precession).abs() < 1e-12,
848 "corrected precession phase mismatch: actual={} expected={expected_precession}",
849 actual.precession_angle
850 );
851 assert!(
852 (actual.nutation_phase - expected_nutation).abs() < 1e-12,
853 "corrected nutation phase mismatch: actual={} expected={expected_nutation}",
854 actual.nutation_phase
855 );
856 }
857
858 #[test]
859 fn test_default_params() {
860 let params = PrecessionNutationParams::default();
861
862 assert!(params.mass_kg > 0.0);
864 assert!(params.caliber_m > 0.0);
865 assert!(params.length_m > 0.0);
866 assert!(params.spin_rate_rad_s > 0.0);
867 assert!(params.spin_inertia > 0.0);
868 assert!(params.transverse_inertia > 0.0);
869 assert!(params.velocity_mps > 0.0);
870 assert!(params.air_density_kg_m3 > 0.0);
871 assert!(params.mach > 0.0);
872 assert!(params.nutation_damping_factor > 0.0);
873 assert!(params.nutation_damping_factor < 1.0); }
875
876 #[test]
877 fn test_stability_effects() {
878 let freq_high_stability = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 5.0);
881 let freq_low_stability = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.5);
882 assert!(freq_high_stability > freq_low_stability);
883 }
884
885 #[test]
886 fn test_damping_time_evolution() {
887 let initial = 0.01;
888 let freq = 3000.0;
889 let spin = 17522.0;
890 let damping = 0.1;
891
892 let times = [0.0, 0.01, 0.02, 0.05, 0.1, 0.2];
894 let mut last_amp = initial;
895
896 for &t in ×[1..] {
897 let amp = calculate_nutation_amplitude(initial, t, freq, damping, spin);
898
899 assert!(amp < last_amp);
901 assert!(amp >= 0.0);
902 last_amp = amp;
903 }
904 }
905
906 #[test]
907 fn test_angular_state_evolution() {
908 let params = PrecessionNutationParams {
909 mass_kg: 0.01,
910 caliber_m: 0.008,
911 length_m: 0.03,
912 spin_rate_rad_s: 16000.0, spin_inertia: 5e-8,
914 transverse_inertia: 8e-7,
915 velocity_mps: 800.0,
916 air_density_kg_m3: 1.2,
917 mach: 2.3,
918 pitch_damping_coeff: -5.0,
919 nutation_damping_factor: 0.08,
920 };
921
922 let mut state = AngularState {
923 pitch_angle: 0.0,
924 yaw_angle: 0.005,
925 pitch_rate: 0.0,
926 yaw_rate: 0.0,
927 precession_angle: 0.0,
928 nutation_phase: 0.0,
929 };
930
931 let initial_phase = state.nutation_phase;
933 let initial_precession = state.precession_angle;
934
935 let dt = 0.0001;
937 for i in 0..100 {
938 let time = i as f64 * dt;
939 state = calculate_combined_angular_motion(¶ms, &state, time, dt, 0.002);
940 }
941
942 assert!(
944 state.precession_angle != initial_precession || state.nutation_phase != initial_phase
945 );
946
947 assert!(state.yaw_angle.abs() < 0.1);
949 assert!(state.pitch_angle.abs() < 0.1);
950 }
951}