1use crate::gravity::Gravity;
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 calculate_limit_cycle_yaw_with_inertias_with_gravity(
353 velocity_mps,
354 spin_rate_rad_s,
355 stability_factor,
356 spin_inertia,
357 transverse_inertia,
358 Gravity::EARTH,
359 )
360}
361
362pub(crate) fn calculate_limit_cycle_yaw_with_inertias_with_gravity(
363 velocity_mps: f64,
364 spin_rate_rad_s: f64,
365 stability_factor: f64,
366 spin_inertia: f64,
367 transverse_inertia: f64,
368 gravity: Gravity,
369) -> f64 {
370 if !velocity_mps.is_finite()
371 || velocity_mps <= 0.0
372 || !spin_rate_rad_s.is_finite()
373 || spin_rate_rad_s == 0.0
374 || !stability_factor.is_finite()
375 || stability_factor < 1.0
376 || !spin_inertia.is_finite()
377 || spin_inertia <= 0.0
378 || !transverse_inertia.is_finite()
379 || transverse_inertia <= 0.0
380 {
381 return 0.0;
382 }
383
384 4.0 * transverse_inertia * stability_factor * gravity.magnitude()
385 / (spin_inertia * spin_rate_rad_s.abs() * velocity_mps)
386}
387
388#[deprecated(
395 since = "0.22.18",
396 note = "use calculate_limit_cycle_yaw_with_inertias for projectile-specific yaw of repose"
397)]
398pub fn calculate_limit_cycle_yaw(
399 velocity_mps: f64,
400 spin_rate_rad_s: f64,
401 stability_factor: f64,
402 _crosswind_mps: f64,
403) -> f64 {
404 let reference = PrecessionNutationParams::default();
405 calculate_limit_cycle_yaw_with_inertias(
406 velocity_mps,
407 spin_rate_rad_s,
408 stability_factor,
409 reference.spin_inertia,
410 reference.transverse_inertia,
411 )
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn projectile_inertias_match_reference_and_geometry_scaling() {
420 let mass = 0.01134;
421 let caliber = 0.00782;
422 let length = 0.033;
423 let (spin, transverse) = projectile_moments_of_inertia(mass, caliber, length);
424
425 assert!((spin / 6.94e-8 - 1.0).abs() < 0.01);
426 assert!((transverse / 9.13e-7 - 1.0).abs() < 0.01);
427
428 let (double_mass_spin, double_mass_transverse) =
429 projectile_moments_of_inertia(2.0 * mass, caliber, length);
430 assert!((double_mass_spin / spin - 2.0).abs() < 1e-12);
431 assert!((double_mass_transverse / transverse - 2.0).abs() < 1e-12);
432
433 let (double_caliber_spin, double_caliber_transverse) =
434 projectile_moments_of_inertia(mass, 2.0 * caliber, length);
435 assert!((double_caliber_spin / spin - 4.0).abs() < 1e-12);
436 assert!(double_caliber_transverse > transverse);
437
438 let (double_length_spin, double_length_transverse) =
439 projectile_moments_of_inertia(mass, caliber, 2.0 * length);
440 assert!((double_length_spin / spin - 1.0).abs() < 1e-12);
441 assert!(double_length_transverse / transverse > 3.5);
442 }
443
444 #[test]
445 fn projectile_inertias_reject_invalid_geometry() {
446 let invalid = [0.0, -1.0, f64::NAN, f64::INFINITY];
447
448 for value in invalid {
449 assert_eq!(
450 projectile_moments_of_inertia(value, 0.00782, 0.033),
451 (0.0, 0.0)
452 );
453 assert_eq!(
454 projectile_moments_of_inertia(0.01134, value, 0.033),
455 (0.0, 0.0)
456 );
457 assert_eq!(
458 projectile_moments_of_inertia(0.01134, 0.00782, value),
459 (0.0, 0.0)
460 );
461 }
462
463 assert_eq!(
464 projectile_moments_of_inertia(f64::MAX, f64::MAX, f64::MAX),
465 (0.0, 0.0)
466 );
467 }
468
469 #[test]
470 fn test_mba941_epicyclic_relations_and_limits() {
471 let (ix, iy, p) = (6.94e-8_f64, 9.13e-7_f64, 17522.0_f64);
476 let arm = ix * p / (2.0 * iy);
477 for &sg in &[1.5_f64, 2.5, 5.0, 50.0] {
478 let (fast, slow) = epicyclic_frequencies(ix, iy, p, sg);
479 assert!(fast > slow && slow > 0.0, "expect fast>slow>0 at Sg={sg}");
480 assert!(
481 ((fast + slow) - 2.0 * arm).abs() < 1e-6 * arm,
482 "sum != Ix p / Iy at Sg={sg}"
483 );
484 assert!(
485 (fast * slow - arm * arm / sg).abs() < 1e-6 * arm * arm,
486 "product != arm^2 / Sg at Sg={sg}"
487 );
488 }
489 let (f1, s1) = epicyclic_frequencies(ix, iy, p, 1.0 + 1e-9);
491 assert!((f1 - arm).abs() < 1e-3 * arm && (s1 - arm).abs() < 1e-3 * arm);
492 let (f2, s2) = epicyclic_frequencies(ix, iy, p, 1.0e6);
494 assert!(s2 < 1e-3 * arm, "slow precession should vanish at high Sg");
495 assert!(
496 (f2 - 2.0 * arm).abs() < 1e-3 * arm,
497 "fast -> Ix p / Iy at high Sg"
498 );
499 assert_eq!(epicyclic_frequencies(ix, iy, p, 0.9), (0.0, 0.0));
501 }
502
503 #[test]
504 fn test_precession_frequency() {
505 let freq = calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 2.5);
507 let nut = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 2.5);
508 assert!(
510 freq > 0.0 && freq < nut,
511 "precession {freq} should satisfy 0 < freq < nutation {nut}"
512 );
513 assert_eq!(
515 calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9),
516 0.0
517 );
518 }
519
520 #[test]
521 fn test_nutation_frequency() {
522 let freq = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.5);
525 assert!(
526 (900.0..1200.0).contains(&freq),
527 "nutation freq {freq} rad/s out of expected band"
528 );
529 }
530
531 #[test]
532 fn test_nutation_damping() {
533 let initial = 0.01;
534 let freq = 3000.0;
535
536 let amp_0 = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, 17522.0);
538 let amp_1 = calculate_nutation_amplitude(initial, 0.1, freq, 0.05, 17522.0);
539
540 assert_eq!(amp_0, initial);
541 assert!(amp_1 < amp_0);
542 assert!(amp_1 > 0.0);
543 }
544
545 #[test]
546 fn test_precession_edge_cases() {
547 assert_eq!(
549 calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9),
550 0.0
551 );
552 assert_eq!(
553 calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 1.0),
554 0.0
555 );
556 assert_eq!(
558 calculate_precession_frequency(17522.0, 6.94e-8, 0.0, 2.0),
559 0.0
560 );
561 }
562
563 #[test]
564 fn test_nutation_edge_cases() {
565 let freq_unstable = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9);
567 assert_eq!(freq_unstable, 0.0);
568
569 let freq_marginal = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.0);
571 assert_eq!(freq_marginal, 0.0);
572
573 let freq_zero_inertia = calculate_nutation_frequency(17522.0, 6.94e-8, 0.0, 2.0);
575 assert_eq!(freq_zero_inertia, 0.0);
576 }
577
578 #[test]
579 fn test_nutation_amplitude_bounds() {
580 let initial = 0.5; let freq = 3000.0;
582 let spin = 17522.0;
583
584 let amp = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, spin);
586 assert!(amp <= 0.1); let amp_zero_freq = calculate_nutation_amplitude(initial, 1.0, 0.0, 0.05, spin);
590 assert_eq!(amp_zero_freq, 0.0);
591
592 let amp_zero_spin = calculate_nutation_amplitude(initial, 1.0, freq, 0.05, 0.0);
594 assert_eq!(amp_zero_spin, 0.0);
595 }
596
597 #[test]
598 fn test_epicyclic_motion() {
599 let (pitch, yaw) = calculate_epicyclic_motion(
600 6.94e-8, 9.13e-7, 17522.0, 2.5, 0.1, 0.01, );
607
608 let (omega_fast, omega_slow) = epicyclic_frequencies(6.94e-8, 9.13e-7, 17522.0, 2.5);
610 let frequency_split = omega_fast - omega_slow;
611 let slow_amplitude = 0.01 * omega_fast / frequency_split;
612 let fast_amplitude = 0.01 - slow_amplitude;
613 let bound = slow_amplitude.abs() + fast_amplitude.abs() + 1e-9;
614 assert!(pitch.abs() <= bound, "pitch {pitch} exceeds bound {bound}");
615 assert!(yaw.abs() <= bound, "yaw {yaw} exceeds bound {bound}");
616
617 let (pitch_unstable, yaw_unstable) =
619 calculate_epicyclic_motion(6.94e-8, 9.13e-7, 17522.0, 0.9, 0.1, 0.01);
620 assert_eq!(pitch_unstable, 0.01);
621 assert_eq!(yaw_unstable, 0.01);
622 }
623
624 #[test]
625 fn epicyclic_motion_satisfies_supplied_initial_conditions() {
626 let calculate = |time_s| {
627 calculate_epicyclic_motion(
628 6.94e-8, 9.13e-7, 17522.0, 2.5, time_s, 0.01, )
634 };
635
636 let (initial_pitch, initial_yaw) = calculate(0.0);
637 assert!(initial_pitch.abs() < 1e-15);
638 assert!((initial_yaw - 0.01).abs() < 1e-14);
639
640 let h = 1e-7;
643 let pitch_h = calculate(h).0;
644 let pitch_2h = calculate(2.0 * h).0;
645 let initial_pitch_rate = (4.0 * pitch_h - pitch_2h) / (2.0 * h);
646 assert!(initial_pitch_rate.abs() < 1e-6);
647
648 assert_eq!(
649 calculate_epicyclic_motion(0.0, 9.13e-7, 17522.0, 2.5, 0.1, 0.01),
650 (0.0, 0.01)
651 );
652 assert_eq!(
653 calculate_epicyclic_motion(6.94e-8, 0.0, 17522.0, 2.5, 0.1, 0.01),
654 (0.0, 0.01)
655 );
656 }
657
658 #[test]
659 #[allow(deprecated)]
660 fn limit_cycle_yaw_excludes_crosswind_and_fabricated_instability_step() {
661 let calm = calculate_limit_cycle_yaw(850.0, 17522.0, 2.5, 0.0);
662 let crosswind = calculate_limit_cycle_yaw(850.0, 17522.0, 2.5, 10.0);
663 assert_eq!(crosswind.to_bits(), calm.to_bits());
664 assert!(calm > 0.0);
665
666 let at_boundary = calculate_limit_cycle_yaw(850.0, 17522.0, 1.0, 0.0);
668 let above_boundary = calculate_limit_cycle_yaw(850.0, 17522.0, 1.0_f64.next_up(), 0.0);
669 assert!(at_boundary > 0.0);
670 assert!(((above_boundary - at_boundary) / at_boundary).abs() < 1e-12);
671 assert_eq!(
672 calculate_limit_cycle_yaw(850.0, 17522.0, 1.0_f64.next_down(), 0.0),
673 0.0
674 );
675
676 let low_sg: f64 = 1.1;
679 let high_sg: f64 = 4.0;
680 let low_spin = 10_000.0;
681 let high_spin = low_spin * (high_sg / low_sg).sqrt();
682 let low = calculate_limit_cycle_yaw(850.0, low_spin, low_sg, 0.0);
683 let high = calculate_limit_cycle_yaw(850.0, high_spin, high_sg, 0.0);
684 assert!(high > low);
685 let expected_ratio = (high_sg / low_sg).sqrt();
686 assert!((high / low - expected_ratio).abs() < expected_ratio * 1e-12);
687 }
688
689 #[test]
690 fn inertia_aware_limit_cycle_yaw_matches_gravity_gyroscopic_balance() {
691 let velocity_mps = 850.0;
692 let spin_rate_rad_s = 17522.0;
693 let stability_factor = 2.5;
694 let spin_inertia = 6.94e-8;
695 let transverse_inertia = 9.13e-7;
696 let actual = calculate_limit_cycle_yaw_with_inertias(
697 velocity_mps,
698 spin_rate_rad_s,
699 stability_factor,
700 spin_inertia,
701 transverse_inertia,
702 );
703 let expected = 4.0 * transverse_inertia * stability_factor * 9.80665
704 / (spin_inertia * spin_rate_rad_s * velocity_mps);
705
706 assert!((actual - expected).abs() < 1e-15);
707 assert_eq!(
708 calculate_limit_cycle_yaw_with_inertias(
709 velocity_mps,
710 spin_rate_rad_s,
711 0.9,
712 spin_inertia,
713 transverse_inertia,
714 ),
715 0.0
716 );
717 assert_eq!(
718 calculate_limit_cycle_yaw_with_inertias(
719 velocity_mps,
720 spin_rate_rad_s,
721 stability_factor,
722 0.0,
723 transverse_inertia,
724 ),
725 0.0
726 );
727 }
728
729 #[test]
730 fn test_combined_angular_motion() {
731 let params = PrecessionNutationParams::default();
732 let initial_state = AngularState {
733 pitch_angle: 0.001,
734 yaw_angle: 0.002,
735 pitch_rate: 0.01,
736 yaw_rate: 0.01,
737 precession_angle: 0.0,
738 nutation_phase: 0.0,
739 };
740
741 let new_state = calculate_combined_angular_motion(
742 ¶ms,
743 &initial_state,
744 0.1, 0.001, 0.001, );
748
749 assert!(
752 new_state.nutation_phase != initial_state.nutation_phase
753 || new_state.precession_angle != initial_state.precession_angle
754 );
755
756 assert!(new_state.pitch_angle.abs() < 1.0);
758 assert!(new_state.yaw_angle.abs() < 1.0);
759 }
760
761 #[test]
762 fn combined_motion_yaw_rate_is_derivative_of_yaw() {
763 let params = PrecessionNutationParams::default();
764 let disturbance = 0.001;
765 let time = 0.02;
766 let h = 1e-7;
767 let state = |precession_angle, nutation_phase| AngularState {
768 pitch_angle: 0.0,
769 yaw_angle: 0.0,
770 pitch_rate: 0.0,
771 yaw_rate: 0.0,
772 precession_angle,
773 nutation_phase,
774 };
775
776 let phase_step =
778 calculate_combined_angular_motion(¶ms, &state(0.0, 0.0), time, 1.0, disturbance);
779 let omega_p = phase_step.precession_angle;
780 let omega_n = phase_step.nutation_phase;
781
782 let center_state = state(0.0, std::f64::consts::FRAC_PI_2);
785 let center =
786 calculate_combined_angular_motion(¶ms, ¢er_state, time, 0.0, disturbance);
787 let before_state = state(-omega_p * h, std::f64::consts::FRAC_PI_2 - omega_n * h);
788 let before =
789 calculate_combined_angular_motion(¶ms, &before_state, time - h, 0.0, disturbance);
790 let after_state = state(omega_p * h, std::f64::consts::FRAC_PI_2 + omega_n * h);
791 let after =
792 calculate_combined_angular_motion(¶ms, &after_state, time + h, 0.0, disturbance);
793 let finite_difference = (after.yaw_angle - before.yaw_angle) / (2.0 * h);
794
795 assert!(
796 (center.yaw_rate - finite_difference).abs() < 1e-8,
797 "yaw_rate={} finite_difference={finite_difference}",
798 center.yaw_rate
799 );
800 }
801
802 #[test]
803 fn combined_motion_applies_velocity_and_density_to_stability() {
804 let velocity_mps = 1_000.0;
805 let spin_rate_rad_s = 15_095.0;
806 let params = PrecessionNutationParams {
807 mass_kg: 0.01134,
808 caliber_m: 0.00782,
809 length_m: 0.033,
810 spin_rate_rad_s,
811 spin_inertia: 6.94e-8,
812 transverse_inertia: 9.13e-7,
813 velocity_mps,
814 air_density_kg_m3: 1.0,
815 mach: velocity_mps / 343.0,
816 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
817 nutation_damping_factor: 0.05,
818 };
819 let initial_state = AngularState {
820 pitch_angle: 0.0,
821 yaw_angle: 0.0,
822 pitch_rate: 0.0,
823 yaw_rate: 0.0,
824 precession_angle: 0.0,
825 nutation_phase: 0.0,
826 };
827
828 let caliber_in = params.caliber_m / 0.0254;
829 let length_in = params.length_m / 0.0254;
830 let mass_gr = params.mass_kg / crate::constants::GRAINS_TO_KG;
831 let spin_rps = spin_rate_rad_s / (2.0 * std::f64::consts::PI);
832 let twist_in = velocity_mps * 3.28084 * 12.0 / spin_rps;
833 let bare_sg = crate::spin_drift::miller_stability(caliber_in, mass_gr, twist_in, length_in);
834 let velocity_correction = (velocity_mps * 3.28084 / 2800.0).powf(1.0 / 3.0);
835 let density_correction = 1.225 / params.air_density_kg_m3;
836 let corrected_sg = crate::spin_drift::calculate_dynamic_stability(
837 mass_gr,
838 velocity_mps,
839 spin_rate_rad_s,
840 caliber_in,
841 length_in,
842 params.air_density_kg_m3,
843 );
844 assert!(bare_sg < 1.0);
845 assert!(bare_sg * velocity_correction < 1.0);
846 assert!(bare_sg * density_correction < 1.0);
847 assert!(corrected_sg > 1.0);
848
849 let dt = 0.0001;
850 let actual = calculate_combined_angular_motion(¶ms, &initial_state, 0.0, dt, 0.001);
851 let expected_precession = calculate_precession_frequency(
852 spin_rate_rad_s,
853 params.spin_inertia,
854 params.transverse_inertia,
855 corrected_sg,
856 ) * dt;
857 let expected_nutation = calculate_nutation_frequency(
858 spin_rate_rad_s,
859 params.spin_inertia,
860 params.transverse_inertia,
861 corrected_sg,
862 ) * dt;
863
864 assert!(
865 (actual.precession_angle - expected_precession).abs() < 1e-12,
866 "corrected precession phase mismatch: actual={} expected={expected_precession}",
867 actual.precession_angle
868 );
869 assert!(
870 (actual.nutation_phase - expected_nutation).abs() < 1e-12,
871 "corrected nutation phase mismatch: actual={} expected={expected_nutation}",
872 actual.nutation_phase
873 );
874 }
875
876 #[test]
877 fn test_default_params() {
878 let params = PrecessionNutationParams::default();
879
880 assert!(params.mass_kg > 0.0);
882 assert!(params.caliber_m > 0.0);
883 assert!(params.length_m > 0.0);
884 assert!(params.spin_rate_rad_s > 0.0);
885 assert!(params.spin_inertia > 0.0);
886 assert!(params.transverse_inertia > 0.0);
887 assert!(params.velocity_mps > 0.0);
888 assert!(params.air_density_kg_m3 > 0.0);
889 assert!(params.mach > 0.0);
890 assert!(params.nutation_damping_factor > 0.0);
891 assert!(params.nutation_damping_factor < 1.0); }
893
894 #[test]
895 fn test_stability_effects() {
896 let freq_high_stability = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 5.0);
899 let freq_low_stability = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.5);
900 assert!(freq_high_stability > freq_low_stability);
901 }
902
903 #[test]
904 fn test_damping_time_evolution() {
905 let initial = 0.01;
906 let freq = 3000.0;
907 let spin = 17522.0;
908 let damping = 0.1;
909
910 let times = [0.0, 0.01, 0.02, 0.05, 0.1, 0.2];
912 let mut last_amp = initial;
913
914 for &t in ×[1..] {
915 let amp = calculate_nutation_amplitude(initial, t, freq, damping, spin);
916
917 assert!(amp < last_amp);
919 assert!(amp >= 0.0);
920 last_amp = amp;
921 }
922 }
923
924 #[test]
925 fn test_angular_state_evolution() {
926 let params = PrecessionNutationParams {
927 mass_kg: 0.01,
928 caliber_m: 0.008,
929 length_m: 0.03,
930 spin_rate_rad_s: 16000.0, spin_inertia: 5e-8,
932 transverse_inertia: 8e-7,
933 velocity_mps: 800.0,
934 air_density_kg_m3: 1.2,
935 mach: 2.3,
936 pitch_damping_coeff: -5.0,
937 nutation_damping_factor: 0.08,
938 };
939
940 let mut state = AngularState {
941 pitch_angle: 0.0,
942 yaw_angle: 0.005,
943 pitch_rate: 0.0,
944 yaw_rate: 0.0,
945 precession_angle: 0.0,
946 nutation_phase: 0.0,
947 };
948
949 let initial_phase = state.nutation_phase;
951 let initial_precession = state.precession_angle;
952
953 let dt = 0.0001;
955 for i in 0..100 {
956 let time = i as f64 * dt;
957 state = calculate_combined_angular_motion(¶ms, &state, time, dt, 0.002);
958 }
959
960 assert!(
962 state.precession_angle != initial_precession || state.nutation_phase != initial_phase
963 );
964
965 assert!(state.yaw_angle.abs() < 0.1);
967 assert!(state.pitch_angle.abs() < 0.1);
968 }
969}