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::gravity::Gravity;
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    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/// Estimate flat-fire yaw of repose for the representative projectile in
389/// [`PrecessionNutationParams::default`].
390///
391/// Retained for source compatibility. The original signature has no projectile inertia, so it
392/// cannot produce a general yaw-of-repose value. `crosswind_mps` is ignored because crosswind yaw
393/// is transient; use [`calculate_limit_cycle_yaw_with_inertias`] for a projectile-specific result.
394#[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        // Validate the corrected frequencies against the EXACT algebraic relations of the standard
472        // epicyclic decomposition (no external reference needed): with arm = Ix p / 2 Iy,
473        //   fast + slow = Ix p / Iy = 2*arm     (sum of the arm rates)
474        //   fast * slow = arm^2 / Sg            (product)
475        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        // Marginal stability (Sg -> 1+): the two modes coalesce at Ix p / 2 Iy.
490        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        // High stability: slow precession -> 0, fast nutation -> Ix p / Iy = 2*arm.
493        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        // Not gyroscopically stable -> no epicyclic motion.
500        assert_eq!(epicyclic_frequencies(ix, iy, p, 0.9), (0.0, 0.0));
501    }
502
503    #[test]
504    fn test_precession_frequency() {
505        // Slow (precession) mode, rad/s: (Ix p / 2 Iy)(1 - sqrt(1 - 1/Sg)).
506        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        // Positive, and slower than the nutation (fast) mode.
509        assert!(
510            freq > 0.0 && freq < nut,
511            "precession {freq} should satisfy 0 < freq < nutation {nut}"
512        );
513        // Unstable -> no precession.
514        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        // Fast (nutation) mode, rad/s: (Ix p / 2 Iy)(1 + sqrt(1 - 1/Sg)).
523        // arm = Ix p / 2 Iy ~= 666 rad/s; fast = arm*(1 + sqrt(1/3)) ~= 1050 rad/s.
524        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        // Check exponential decay
537        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        // Unstable / marginally stable -> no regular precession.
548        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        // Zero transverse inertia -> guarded to 0.
557        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        // Test unstable projectile (Sg <= 1)
566        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        // Test marginally stable (Sg = 1)
570        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        // Test zero transverse inertia
574        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; // Large initial disturbance
581        let freq = 3000.0;
582        let spin = 17522.0;
583
584        // Even with large initial disturbance, should be clamped
585        let amp = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, spin);
586        assert!(amp <= 0.1); // Max 0.1 rad
587
588        // Test zero frequency
589        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        // Test zero spin
593        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, // spin inertia
601            9.13e-7, // transverse inertia
602            17522.0, // spin rate
603            2.5,     // stability factor
604            0.1,     // time
605            0.01,    // initial yaw
606        );
607
608        // Bounded by the sum of the initial modal arm magnitudes (the fast arm only decays).
609        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        // Unstable case -> returns the initial yaw unchanged.
618        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, // spin inertia
629                9.13e-7, // transverse inertia
630                17522.0, // spin rate
631                2.5,     // stability factor
632                time_s, 0.01, // initial yaw
633            )
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        // Second-order forward difference for the initial transverse/pitch rate. The modal
641        // coefficients must cancel this rate, not merely be normalized to the initial yaw.
642        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        // Preserve the finite stable-side limit at Sg=1; below it no equilibrium exists.
667        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        // For physically coupled states Sg scales with spin squared, so the gravity/gyroscopic
677        // balance makes yaw grow with spin rather than shrink with Sg.
678        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            &params,
743            &initial_state,
744            0.1,   // time
745            0.001, // dt
746            0.001, // initial disturbance
747        );
748
749        // Check that nutation phase evolved (it always should with non-zero frequency)
750        // Precession might be very small with small yaw angles
751        assert!(
752            new_state.nutation_phase != initial_state.nutation_phase
753                || new_state.precession_angle != initial_state.precession_angle
754        );
755
756        // Check reasonable bounds
757        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        // A one-second phase step exposes the internally selected angular frequencies.
777        let phase_step =
778            calculate_combined_angular_motion(&params, &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        // Center the nutation at a quarter-cycle, where its phase derivative vanishes and
783        // the damping-envelope derivative is isolated.
784        let center_state = state(0.0, std::f64::consts::FRAC_PI_2);
785        let center =
786            calculate_combined_angular_motion(&params, &center_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(&params, &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(&params, &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(&params, &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        // Check reasonable default values
881        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); // Should be fraction
892    }
893
894    #[test]
895    fn test_stability_effects() {
896        // Higher stability gives a higher nutation (fast-mode) frequency:
897        // phi_fast = (Ix p / 2 Iy)(1 + sqrt(1 - 1/Sg)) increases monotonically with Sg.
898        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        // Sample at different times
911        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 &times[1..] {
915            let amp = calculate_nutation_amplitude(initial, t, freq, damping, spin);
916
917            // Should monotonically decrease
918            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, // fast enough that the Miller Sg > 1 (gyroscopically stable)
931            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        // Store initial state for comparison
950        let initial_phase = state.nutation_phase;
951        let initial_precession = state.precession_angle;
952
953        // Evolve for several timesteps
954        let dt = 0.0001;
955        for i in 0..100 {
956            let time = i as f64 * dt;
957            state = calculate_combined_angular_motion(&params, &state, time, dt, 0.002);
958        }
959
960        // Should have evolved - at least one of these should change
961        assert!(
962            state.precession_angle != initial_precession || state.nutation_phase != initial_phase
963        );
964
965        // Should remain bounded
966        assert!(state.yaw_angle.abs() < 0.1);
967        assert!(state.pitch_angle.abs() < 0.1);
968    }
969}