Skip to main content

ballistics_engine/
precession_nutation.rs

1//! Precession and Nutation Physics for Ballistic Projectiles
2//!
3//! This module implements the complex angular motion of spinning projectiles:
4//! - Precession: Slow coning motion of the projectile axis
5//! - Nutation: Fast oscillatory motion superimposed on precession
6//! - Angular momentum conservation
7//! - Gyroscopic effects
8
9// Precession and nutation modeling - now integrated!
10
11use crate::constants::G_ACCEL_MPS2;
12use crate::pitch_damping::{calculate_pitch_damping_moment, PitchDampingCoefficients};
13
14/// Estimate the projectile's longitudinal and transverse moments of inertia from its geometry.
15///
16/// The engine models a typical pointed projectile as an ogive for both axes. Invalid geometry
17/// returns zero inertias so callers naturally take the existing no-motion guard instead of
18/// propagating non-finite angular state.
19pub(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/// Complete angular state of the projectile
52#[derive(Debug, Clone, Copy)]
53pub struct AngularState {
54    pub pitch_angle: f64,      // Angle between axis and velocity (rad)
55    pub yaw_angle: f64,        // Angle in plane perpendicular to velocity (rad)
56    pub pitch_rate: f64,       // Rate of pitch angle change (rad/s)
57    pub yaw_rate: f64,         // Rate of yaw angle change (rad/s)
58    pub precession_angle: f64, // Cumulative precession angle (rad)
59    pub nutation_phase: f64,   // Phase of nutation oscillation (rad)
60}
61
62/// Parameters for precession and nutation calculations
63#[derive(Debug, Clone)]
64pub struct PrecessionNutationParams {
65    // Projectile properties
66    pub mass_kg: f64,
67    pub caliber_m: f64,
68    pub length_m: f64,
69    pub spin_rate_rad_s: f64,
70
71    // Moments of inertia
72    pub spin_inertia: f64,       // About longitudinal axis
73    pub transverse_inertia: f64, // About transverse axis
74
75    // Flight conditions
76    pub velocity_mps: f64,
77    pub air_density_kg_m3: f64,
78    pub mach: f64,
79
80    // Damping coefficients
81    pub pitch_damping_coeff: f64,
82    pub nutation_damping_factor: f64, // Fraction of critical damping
83}
84
85impl Default for PrecessionNutationParams {
86    fn default() -> Self {
87        Self {
88            mass_kg: 0.01134, // 175 grains
89            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
103/// The two epicyclic yaw-arm angular frequencies (rad/s) for a gyroscopically stable projectile:
104/// the FAST mode (nutation) and the SLOW mode (precession). Standard linearized aeroballistic
105/// result from the spinning-projectile yaw equation:
106///   phi_{fast,slow} = (Ix * p / 2 Iy) * [1 ± sqrt(1 - 1/Sg)]
107/// where Ix/Iy are the spin/transverse moments of inertia, p the spin rate, Sg the (dimensionless)
108/// gyroscopic stability factor. Returns (0, 0) when Sg <= 1 (no real epicyclic motion — the
109/// projectile is not gyroscopically stable) or the transverse inertia is zero. (MBA-941: the
110/// previous per-frequency formulas were dimensionally inconsistent — rad/m and length — and ad hoc.)
111pub 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    // Ix * p / 2 Iy  [rad/s] — the mean of the two arm rates.
121    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)) // (fast = nutation, slow = precession)
124}
125
126/// Slow-mode (precession) angular frequency in rad/s — the slow coning of the spin axis:
127/// phi_slow = (Ix p / 2 Iy)(1 - sqrt(1 - 1/Sg)).
128pub 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
143/// Fast-mode (nutation) angular frequency in rad/s:
144/// phi_fast = (Ix p / 2 Iy)(1 + sqrt(1 - 1/Sg)).
145pub 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
160/// Calculate nutation amplitude with exponential damping
161pub 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    // Damping rate
173    let damping_rate = damping_factor * nutation_frequency;
174
175    // Exponential decay
176    let amplitude = initial_disturbance_rad * (-damping_rate * time_s).exp();
177
178    // Clamp to reasonable bounds
179    amplitude.min(0.1) // Max 0.1 rad (~5.7 degrees)
180}
181
182/// Calculate the combined precession and nutation motion
183pub 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    // MBA-198: Guard against division by zero in stability calculation
191    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 unchanged state if invalid parameters
198        return *angular_state;
199    }
200
201    // Dimensionless gyroscopic stability factor via the engine's canonical dynamic Miller
202    // calculation, including the (V/2800 fps)^(1/3) and rho0/rho corrections. Use spin magnitude
203    // for stability (which depends on p^2); the signed rate below still controls phase direction.
204    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    // Precession (slow) and nutation (fast) angular frequencies, both rad/s.
217    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    // Nutation amplitude (decaying)
231    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    // Update precession angle
240    let new_precession_angle = angular_state.precession_angle + omega_p * dt;
241
242    // Update nutation phase
243    let new_nutation_phase = angular_state.nutation_phase + omega_n * dt;
244
245    // Calculate pitch damping moment
246    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    // Angular acceleration from damping
260    let pitch_accel = pitch_moment / params.transverse_inertia;
261
262    // Update angular rates
263    let new_pitch_rate = angular_state.pitch_rate + pitch_accel * dt;
264
265    // MBA-941: bounded epicyclic yaw. Previously `total_yaw = yaw_angle + nutation_amp*sin(phase)`
266    // re-added the nutation to the carried-forward yaw every step, so the yaw random-walked and the
267    // precession rate (yaw_rate) was never actually integrated. The yaw is now a bounded function
268    // of the cumulative precession/nutation PHASES — a slow precession arm plus the damped fast
269    // nutation arm — and yaw_rate is its true time derivative.
270    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    // Pitch angle evolves more slowly
279    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
291/// Calculate the epicyclic (combined precession + nutation) motion
292pub 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    // MBA-198: Guard against division by zero
301    if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
302        // Unstable or no spin - no regular motion
303        return (initial_yaw_rad, initial_yaw_rad);
304    }
305
306    // Fast (nutation) and slow (precession) angular frequencies, both rad/s (MBA-941).
307    let (omega_fast, omega_slow) = epicyclic_frequencies(
308        spin_inertia,
309        transverse_inertia,
310        spin_rate_rad_s,
311        stability_factor,
312    );
313
314    // Choose the two modal amplitudes so yaw(0) reproduces the supplied disturbance and the
315    // undamped initial transverse rate is zero:
316    //   Kslow + Kfast = yaw0,  Kslow*wslow + Kfast*wfast = 0.
317    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    // Damping (exponential decay of fast mode)
325    let damping_factor = 0.1; // Typical value
326    let fast_amplitude = initial_fast_amplitude * (-damping_factor * omega_fast * time_s).exp();
327
328    // Combined motion
329    let slow_phase = omega_slow * time_s;
330    let fast_phase = omega_fast * time_s;
331
332    // Epicyclic coordinates
333    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
339/// Calculate the first-order flat-fire yaw-of-repose magnitude from the projectile inertias.
340///
341/// This is the classical gravity/gyroscopic reduction
342/// `yaw = 4 * Iy * Sg * g / (Ix * |p| * V)`. `Sg = 1` returns its finite stable-side
343/// limit; `Sg < 1` returns `0.0` because no stable equilibrium exists. Crosswind is absent
344/// because it produces a damped transient, not persistent equilibrium yaw.
345pub 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/// Estimate flat-fire yaw of repose for the representative projectile in
371/// [`PrecessionNutationParams::default`].
372///
373/// Retained for source compatibility. The original signature has no projectile inertia, so it
374/// cannot produce a general yaw-of-repose value. `crosswind_mps` is ignored because crosswind yaw
375/// is transient; use [`calculate_limit_cycle_yaw_with_inertias`] for a projectile-specific result.
376#[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        // Validate the corrected frequencies against the EXACT algebraic relations of the standard
454        // epicyclic decomposition (no external reference needed): with arm = Ix p / 2 Iy,
455        //   fast + slow = Ix p / Iy = 2*arm     (sum of the arm rates)
456        //   fast * slow = arm^2 / Sg            (product)
457        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        // Marginal stability (Sg -> 1+): the two modes coalesce at Ix p / 2 Iy.
472        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        // High stability: slow precession -> 0, fast nutation -> Ix p / Iy = 2*arm.
475        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        // Not gyroscopically stable -> no epicyclic motion.
482        assert_eq!(epicyclic_frequencies(ix, iy, p, 0.9), (0.0, 0.0));
483    }
484
485    #[test]
486    fn test_precession_frequency() {
487        // Slow (precession) mode, rad/s: (Ix p / 2 Iy)(1 - sqrt(1 - 1/Sg)).
488        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        // Positive, and slower than the nutation (fast) mode.
491        assert!(
492            freq > 0.0 && freq < nut,
493            "precession {freq} should satisfy 0 < freq < nutation {nut}"
494        );
495        // Unstable -> no precession.
496        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        // Fast (nutation) mode, rad/s: (Ix p / 2 Iy)(1 + sqrt(1 - 1/Sg)).
505        // arm = Ix p / 2 Iy ~= 666 rad/s; fast = arm*(1 + sqrt(1/3)) ~= 1050 rad/s.
506        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        // Check exponential decay
519        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        // Unstable / marginally stable -> no regular precession.
530        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        // Zero transverse inertia -> guarded to 0.
539        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        // Test unstable projectile (Sg <= 1)
548        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        // Test marginally stable (Sg = 1)
552        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        // Test zero transverse inertia
556        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; // Large initial disturbance
563        let freq = 3000.0;
564        let spin = 17522.0;
565
566        // Even with large initial disturbance, should be clamped
567        let amp = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, spin);
568        assert!(amp <= 0.1); // Max 0.1 rad
569
570        // Test zero frequency
571        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        // Test zero spin
575        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, // spin inertia
583            9.13e-7, // transverse inertia
584            17522.0, // spin rate
585            2.5,     // stability factor
586            0.1,     // time
587            0.01,    // initial yaw
588        );
589
590        // Bounded by the sum of the initial modal arm magnitudes (the fast arm only decays).
591        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        // Unstable case -> returns the initial yaw unchanged.
600        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, // spin inertia
611                9.13e-7, // transverse inertia
612                17522.0, // spin rate
613                2.5,     // stability factor
614                time_s, 0.01, // initial yaw
615            )
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        // Second-order forward difference for the initial transverse/pitch rate. The modal
623        // coefficients must cancel this rate, not merely be normalized to the initial yaw.
624        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        // Preserve the finite stable-side limit at Sg=1; below it no equilibrium exists.
649        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        // For physically coupled states Sg scales with spin squared, so the gravity/gyroscopic
659        // balance makes yaw grow with spin rather than shrink with Sg.
660        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            &params,
725            &initial_state,
726            0.1,   // time
727            0.001, // dt
728            0.001, // initial disturbance
729        );
730
731        // Check that nutation phase evolved (it always should with non-zero frequency)
732        // Precession might be very small with small yaw angles
733        assert!(
734            new_state.nutation_phase != initial_state.nutation_phase
735                || new_state.precession_angle != initial_state.precession_angle
736        );
737
738        // Check reasonable bounds
739        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        // A one-second phase step exposes the internally selected angular frequencies.
759        let phase_step =
760            calculate_combined_angular_motion(&params, &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        // Center the nutation at a quarter-cycle, where its phase derivative vanishes and
765        // the damping-envelope derivative is isolated.
766        let center_state = state(0.0, std::f64::consts::FRAC_PI_2);
767        let center =
768            calculate_combined_angular_motion(&params, &center_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(&params, &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(&params, &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(&params, &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        // Check reasonable default values
863        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); // Should be fraction
874    }
875
876    #[test]
877    fn test_stability_effects() {
878        // Higher stability gives a higher nutation (fast-mode) frequency:
879        // phi_fast = (Ix p / 2 Iy)(1 + sqrt(1 - 1/Sg)) increases monotonically with Sg.
880        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        // Sample at different times
893        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 &times[1..] {
897            let amp = calculate_nutation_amplitude(initial, t, freq, damping, spin);
898
899            // Should monotonically decrease
900            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, // fast enough that the Miller Sg > 1 (gyroscopically stable)
913            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        // Store initial state for comparison
932        let initial_phase = state.nutation_phase;
933        let initial_precession = state.precession_angle;
934
935        // Evolve for several timesteps
936        let dt = 0.0001;
937        for i in 0..100 {
938            let time = i as f64 * dt;
939            state = calculate_combined_angular_motion(&params, &state, time, dt, 0.002);
940        }
941
942        // Should have evolved - at least one of these should change
943        assert!(
944            state.precession_angle != initial_precession || state.nutation_phase != initial_phase
945        );
946
947        // Should remain bounded
948        assert!(state.yaw_angle.abs() < 0.1);
949        assert!(state.pitch_angle.abs() < 0.1);
950    }
951}