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(spin_inertia, transverse_inertia, spin_rate_rad_s, stability_factor).1
135}
136
137/// Fast-mode (nutation) angular frequency in rad/s:
138/// phi_fast = (Ix p / 2 Iy)(1 + sqrt(1 - 1/Sg)).
139pub fn calculate_nutation_frequency(
140    spin_rate_rad_s: f64,
141    spin_inertia: f64,
142    transverse_inertia: f64,
143    stability_factor: f64,
144) -> f64 {
145    epicyclic_frequencies(spin_inertia, transverse_inertia, spin_rate_rad_s, stability_factor).0
146}
147
148/// Calculate nutation amplitude with exponential damping
149pub fn calculate_nutation_amplitude(
150    initial_disturbance_rad: f64,
151    time_s: f64,
152    nutation_frequency: f64,
153    damping_factor: f64,
154    spin_rate_rad_s: f64,
155) -> f64 {
156    if nutation_frequency == 0.0 || spin_rate_rad_s == 0.0 {
157        return 0.0;
158    }
159
160    // Damping rate
161    let damping_rate = damping_factor * nutation_frequency;
162
163    // Exponential decay
164    let amplitude = initial_disturbance_rad * (-damping_rate * time_s).exp();
165
166    // Clamp to reasonable bounds
167    amplitude.min(0.1) // Max 0.1 rad (~5.7 degrees)
168}
169
170/// Calculate the combined precession and nutation motion
171pub fn calculate_combined_angular_motion(
172    params: &PrecessionNutationParams,
173    angular_state: &AngularState,
174    time_s: f64,
175    dt: f64,
176    initial_disturbance: f64,
177) -> AngularState {
178    // MBA-198: Guard against division by zero in stability calculation
179    if params.transverse_inertia == 0.0
180        || params.velocity_mps == 0.0
181        || params.length_m == 0.0
182        || !params.air_density_kg_m3.is_finite()
183        || params.air_density_kg_m3 <= 0.0
184    {
185        // Return unchanged state if invalid parameters
186        return *angular_state;
187    }
188
189    // Dimensionless gyroscopic stability factor via the engine's canonical dynamic Miller
190    // calculation, including the (V/2800 fps)^(1/3) and rho0/rho corrections. Use spin magnitude
191    // for stability (which depends on p^2); the signed rate below still controls phase direction.
192    let caliber_in = params.caliber_m / 0.0254;
193    let length_in = params.length_m / 0.0254;
194    let mass_gr = params.mass_kg / 0.00006479891;
195    let stability = crate::spin_drift::calculate_dynamic_stability(
196        mass_gr,
197        params.velocity_mps,
198        params.spin_rate_rad_s.abs(),
199        caliber_in,
200        length_in,
201        params.air_density_kg_m3,
202    );
203
204    // Precession (slow) and nutation (fast) angular frequencies, both rad/s.
205    let omega_p = calculate_precession_frequency(
206        params.spin_rate_rad_s,
207        params.spin_inertia,
208        params.transverse_inertia,
209        stability,
210    );
211    let omega_n = calculate_nutation_frequency(
212        params.spin_rate_rad_s,
213        params.spin_inertia,
214        params.transverse_inertia,
215        stability,
216    );
217
218    // Nutation amplitude (decaying)
219    let nutation_amp = calculate_nutation_amplitude(
220        initial_disturbance,
221        time_s,
222        omega_n,
223        params.nutation_damping_factor,
224        params.spin_rate_rad_s,
225    );
226
227    // Update precession angle
228    let new_precession_angle = angular_state.precession_angle + omega_p * dt;
229
230    // Update nutation phase
231    let new_nutation_phase = angular_state.nutation_phase + omega_n * dt;
232
233    // Calculate pitch damping moment
234    let pitch_moment = calculate_pitch_damping_moment(
235        angular_state.pitch_rate,
236        params.velocity_mps,
237        params.air_density_kg_m3,
238        params.caliber_m,
239        params.length_m,
240        params.mach,
241        &PitchDampingCoefficients {
242            subsonic: params.pitch_damping_coeff,
243            ..Default::default()
244        },
245    );
246
247    // Angular acceleration from damping
248    let pitch_accel = pitch_moment / params.transverse_inertia;
249
250    // Update angular rates
251    let new_pitch_rate = angular_state.pitch_rate + pitch_accel * dt;
252
253    // MBA-941: bounded epicyclic yaw. Previously `total_yaw = yaw_angle + nutation_amp*sin(phase)`
254    // re-added the nutation to the carried-forward yaw every step, so the yaw random-walked and the
255    // precession rate (yaw_rate) was never actually integrated. The yaw is now a bounded function
256    // of the cumulative precession/nutation PHASES — a slow precession arm plus the damped fast
257    // nutation arm — and yaw_rate is its true time derivative.
258    let coning_amp = initial_disturbance;
259    let total_yaw =
260        coning_amp * new_precession_angle.cos() + nutation_amp * new_nutation_phase.sin();
261    let damping_rate = params.nutation_damping_factor * omega_n;
262    let new_yaw_rate = -coning_amp * omega_p * new_precession_angle.sin()
263        + nutation_amp
264            * (omega_n * new_nutation_phase.cos() - damping_rate * new_nutation_phase.sin());
265
266    // Pitch angle evolves more slowly
267    let new_pitch = angular_state.pitch_angle + new_pitch_rate * dt;
268
269    AngularState {
270        pitch_angle: new_pitch,
271        yaw_angle: total_yaw,
272        pitch_rate: new_pitch_rate,
273        yaw_rate: new_yaw_rate,
274        precession_angle: new_precession_angle,
275        nutation_phase: new_nutation_phase,
276    }
277}
278
279/// Calculate the epicyclic (combined precession + nutation) motion
280pub fn calculate_epicyclic_motion(
281    spin_inertia: f64,
282    transverse_inertia: f64,
283    spin_rate_rad_s: f64,
284    stability_factor: f64,
285    time_s: f64,
286    initial_yaw_rad: f64,
287) -> (f64, f64) {
288    // MBA-198: Guard against division by zero
289    if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
290        // Unstable or no spin - no regular motion
291        return (initial_yaw_rad, initial_yaw_rad);
292    }
293
294    // Fast (nutation) and slow (precession) angular frequencies, both rad/s (MBA-941).
295    let (omega_fast, omega_slow) = epicyclic_frequencies(
296        spin_inertia,
297        transverse_inertia,
298        spin_rate_rad_s,
299        stability_factor,
300    );
301
302    // Choose the two modal amplitudes so yaw(0) reproduces the supplied disturbance and the
303    // undamped initial transverse rate is zero:
304    //   Kslow + Kfast = yaw0,  Kslow*wslow + Kfast*wfast = 0.
305    let frequency_split = omega_fast - omega_slow;
306    if frequency_split == 0.0 {
307        return (0.0, initial_yaw_rad);
308    }
309    let slow_amplitude = initial_yaw_rad * omega_fast / frequency_split;
310    let initial_fast_amplitude = initial_yaw_rad - slow_amplitude;
311
312    // Damping (exponential decay of fast mode)
313    let damping_factor = 0.1; // Typical value
314    let fast_amplitude = initial_fast_amplitude * (-damping_factor * omega_fast * time_s).exp();
315
316    // Combined motion
317    let slow_phase = omega_slow * time_s;
318    let fast_phase = omega_fast * time_s;
319
320    // Epicyclic coordinates
321    let yaw = slow_amplitude * slow_phase.cos() + fast_amplitude * fast_phase.cos();
322    let pitch = slow_amplitude * slow_phase.sin() + fast_amplitude * fast_phase.sin();
323
324    (pitch, yaw)
325}
326
327/// Calculate the first-order flat-fire yaw-of-repose magnitude from the projectile inertias.
328///
329/// This is the classical gravity/gyroscopic reduction
330/// `yaw = 4 * Iy * Sg * g / (Ix * |p| * V)`. `Sg = 1` returns its finite stable-side
331/// limit; `Sg < 1` returns `0.0` because no stable equilibrium exists. Crosswind is absent
332/// because it produces a damped transient, not persistent equilibrium yaw.
333pub fn calculate_limit_cycle_yaw_with_inertias(
334    velocity_mps: f64,
335    spin_rate_rad_s: f64,
336    stability_factor: f64,
337    spin_inertia: f64,
338    transverse_inertia: f64,
339) -> f64 {
340    if !velocity_mps.is_finite()
341        || velocity_mps <= 0.0
342        || !spin_rate_rad_s.is_finite()
343        || spin_rate_rad_s == 0.0
344        || !stability_factor.is_finite()
345        || stability_factor < 1.0
346        || !spin_inertia.is_finite()
347        || spin_inertia <= 0.0
348        || !transverse_inertia.is_finite()
349        || transverse_inertia <= 0.0
350    {
351        return 0.0;
352    }
353
354    4.0 * transverse_inertia * stability_factor * G_ACCEL_MPS2
355        / (spin_inertia * spin_rate_rad_s.abs() * velocity_mps)
356}
357
358/// Estimate flat-fire yaw of repose for the representative projectile in
359/// [`PrecessionNutationParams::default`].
360///
361/// Retained for source compatibility. The original signature has no projectile inertia, so it
362/// cannot produce a general yaw-of-repose value. `crosswind_mps` is ignored because crosswind yaw
363/// is transient; use [`calculate_limit_cycle_yaw_with_inertias`] for a projectile-specific result.
364#[deprecated(
365    since = "0.22.18",
366    note = "use calculate_limit_cycle_yaw_with_inertias for projectile-specific yaw of repose"
367)]
368pub fn calculate_limit_cycle_yaw(
369    velocity_mps: f64,
370    spin_rate_rad_s: f64,
371    stability_factor: f64,
372    _crosswind_mps: f64,
373) -> f64 {
374    let reference = PrecessionNutationParams::default();
375    calculate_limit_cycle_yaw_with_inertias(
376        velocity_mps,
377        spin_rate_rad_s,
378        stability_factor,
379        reference.spin_inertia,
380        reference.transverse_inertia,
381    )
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn projectile_inertias_match_reference_and_geometry_scaling() {
390        let mass = 0.01134;
391        let caliber = 0.00782;
392        let length = 0.033;
393        let (spin, transverse) = projectile_moments_of_inertia(mass, caliber, length);
394
395        assert!((spin / 6.94e-8 - 1.0).abs() < 0.01);
396        assert!((transverse / 9.13e-7 - 1.0).abs() < 0.01);
397
398        let (double_mass_spin, double_mass_transverse) =
399            projectile_moments_of_inertia(2.0 * mass, caliber, length);
400        assert!((double_mass_spin / spin - 2.0).abs() < 1e-12);
401        assert!((double_mass_transverse / transverse - 2.0).abs() < 1e-12);
402
403        let (double_caliber_spin, double_caliber_transverse) =
404            projectile_moments_of_inertia(mass, 2.0 * caliber, length);
405        assert!((double_caliber_spin / spin - 4.0).abs() < 1e-12);
406        assert!(double_caliber_transverse > transverse);
407
408        let (double_length_spin, double_length_transverse) =
409            projectile_moments_of_inertia(mass, caliber, 2.0 * length);
410        assert!((double_length_spin / spin - 1.0).abs() < 1e-12);
411        assert!(double_length_transverse / transverse > 3.5);
412    }
413
414    #[test]
415    fn projectile_inertias_reject_invalid_geometry() {
416        let invalid = [0.0, -1.0, f64::NAN, f64::INFINITY];
417
418        for value in invalid {
419            assert_eq!(
420                projectile_moments_of_inertia(value, 0.00782, 0.033),
421                (0.0, 0.0)
422            );
423            assert_eq!(
424                projectile_moments_of_inertia(0.01134, value, 0.033),
425                (0.0, 0.0)
426            );
427            assert_eq!(
428                projectile_moments_of_inertia(0.01134, 0.00782, value),
429                (0.0, 0.0)
430            );
431        }
432
433        assert_eq!(
434            projectile_moments_of_inertia(f64::MAX, f64::MAX, f64::MAX),
435            (0.0, 0.0)
436        );
437    }
438
439    #[test]
440    fn test_mba941_epicyclic_relations_and_limits() {
441        // Validate the corrected frequencies against the EXACT algebraic relations of the standard
442        // epicyclic decomposition (no external reference needed): with arm = Ix p / 2 Iy,
443        //   fast + slow = Ix p / Iy = 2*arm     (sum of the arm rates)
444        //   fast * slow = arm^2 / Sg            (product)
445        let (ix, iy, p) = (6.94e-8_f64, 9.13e-7_f64, 17522.0_f64);
446        let arm = ix * p / (2.0 * iy);
447        for &sg in &[1.5_f64, 2.5, 5.0, 50.0] {
448            let (fast, slow) = epicyclic_frequencies(ix, iy, p, sg);
449            assert!(fast > slow && slow > 0.0, "expect fast>slow>0 at Sg={sg}");
450            assert!(
451                ((fast + slow) - 2.0 * arm).abs() < 1e-6 * arm,
452                "sum != Ix p / Iy at Sg={sg}"
453            );
454            assert!(
455                (fast * slow - arm * arm / sg).abs() < 1e-6 * arm * arm,
456                "product != arm^2 / Sg at Sg={sg}"
457            );
458        }
459        // Marginal stability (Sg -> 1+): the two modes coalesce at Ix p / 2 Iy.
460        let (f1, s1) = epicyclic_frequencies(ix, iy, p, 1.0 + 1e-9);
461        assert!((f1 - arm).abs() < 1e-3 * arm && (s1 - arm).abs() < 1e-3 * arm);
462        // High stability: slow precession -> 0, fast nutation -> Ix p / Iy = 2*arm.
463        let (f2, s2) = epicyclic_frequencies(ix, iy, p, 1.0e6);
464        assert!(s2 < 1e-3 * arm, "slow precession should vanish at high Sg");
465        assert!((f2 - 2.0 * arm).abs() < 1e-3 * arm, "fast -> Ix p / Iy at high Sg");
466        // Not gyroscopically stable -> no epicyclic motion.
467        assert_eq!(epicyclic_frequencies(ix, iy, p, 0.9), (0.0, 0.0));
468    }
469
470    #[test]
471    fn test_precession_frequency() {
472        // Slow (precession) mode, rad/s: (Ix p / 2 Iy)(1 - sqrt(1 - 1/Sg)).
473        let freq = calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 2.5);
474        let nut = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 2.5);
475        // Positive, and slower than the nutation (fast) mode.
476        assert!(
477            freq > 0.0 && freq < nut,
478            "precession {freq} should satisfy 0 < freq < nutation {nut}"
479        );
480        // Unstable -> no precession.
481        assert_eq!(
482            calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9),
483            0.0
484        );
485    }
486
487    #[test]
488    fn test_nutation_frequency() {
489        // Fast (nutation) mode, rad/s: (Ix p / 2 Iy)(1 + sqrt(1 - 1/Sg)).
490        // arm = Ix p / 2 Iy ~= 666 rad/s; fast = arm*(1 + sqrt(1/3)) ~= 1050 rad/s.
491        let freq = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.5);
492        assert!(
493            (900.0..1200.0).contains(&freq),
494            "nutation freq {freq} rad/s out of expected band"
495        );
496    }
497
498    #[test]
499    fn test_nutation_damping() {
500        let initial = 0.01;
501        let freq = 3000.0;
502
503        // Check exponential decay
504        let amp_0 = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, 17522.0);
505        let amp_1 = calculate_nutation_amplitude(initial, 0.1, freq, 0.05, 17522.0);
506
507        assert_eq!(amp_0, initial);
508        assert!(amp_1 < amp_0);
509        assert!(amp_1 > 0.0);
510    }
511
512    #[test]
513    fn test_precession_edge_cases() {
514        // Unstable / marginally stable -> no regular precession.
515        assert_eq!(
516            calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9),
517            0.0
518        );
519        assert_eq!(
520            calculate_precession_frequency(17522.0, 6.94e-8, 9.13e-7, 1.0),
521            0.0
522        );
523        // Zero transverse inertia -> guarded to 0.
524        assert_eq!(
525            calculate_precession_frequency(17522.0, 6.94e-8, 0.0, 2.0),
526            0.0
527        );
528    }
529
530    #[test]
531    fn test_nutation_edge_cases() {
532        // Test unstable projectile (Sg <= 1)
533        let freq_unstable = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9);
534        assert_eq!(freq_unstable, 0.0);
535
536        // Test marginally stable (Sg = 1)
537        let freq_marginal = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.0);
538        assert_eq!(freq_marginal, 0.0);
539
540        // Test zero transverse inertia
541        let freq_zero_inertia = calculate_nutation_frequency(17522.0, 6.94e-8, 0.0, 2.0);
542        assert_eq!(freq_zero_inertia, 0.0);
543    }
544
545    #[test]
546    fn test_nutation_amplitude_bounds() {
547        let initial = 0.5; // Large initial disturbance
548        let freq = 3000.0;
549        let spin = 17522.0;
550
551        // Even with large initial disturbance, should be clamped
552        let amp = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, spin);
553        assert!(amp <= 0.1); // Max 0.1 rad
554
555        // Test zero frequency
556        let amp_zero_freq = calculate_nutation_amplitude(initial, 1.0, 0.0, 0.05, spin);
557        assert_eq!(amp_zero_freq, 0.0);
558
559        // Test zero spin
560        let amp_zero_spin = calculate_nutation_amplitude(initial, 1.0, freq, 0.05, 0.0);
561        assert_eq!(amp_zero_spin, 0.0);
562    }
563
564    #[test]
565    fn test_epicyclic_motion() {
566        let (pitch, yaw) = calculate_epicyclic_motion(
567            6.94e-8, // spin inertia
568            9.13e-7, // transverse inertia
569            17522.0, // spin rate
570            2.5,     // stability factor
571            0.1,     // time
572            0.01,    // initial yaw
573        );
574
575        // Bounded by the sum of the initial modal arm magnitudes (the fast arm only decays).
576        let (omega_fast, omega_slow) = epicyclic_frequencies(6.94e-8, 9.13e-7, 17522.0, 2.5);
577        let frequency_split = omega_fast - omega_slow;
578        let slow_amplitude = 0.01 * omega_fast / frequency_split;
579        let fast_amplitude = 0.01 - slow_amplitude;
580        let bound = slow_amplitude.abs() + fast_amplitude.abs() + 1e-9;
581        assert!(pitch.abs() <= bound, "pitch {pitch} exceeds bound {bound}");
582        assert!(yaw.abs() <= bound, "yaw {yaw} exceeds bound {bound}");
583
584        // Unstable case -> returns the initial yaw unchanged.
585        let (pitch_unstable, yaw_unstable) =
586            calculate_epicyclic_motion(6.94e-8, 9.13e-7, 17522.0, 0.9, 0.1, 0.01);
587        assert_eq!(pitch_unstable, 0.01);
588        assert_eq!(yaw_unstable, 0.01);
589    }
590
591    #[test]
592    fn epicyclic_motion_satisfies_supplied_initial_conditions() {
593        let calculate = |time_s| {
594            calculate_epicyclic_motion(
595                6.94e-8, // spin inertia
596                9.13e-7, // transverse inertia
597                17522.0, // spin rate
598                2.5,     // stability factor
599                time_s, 0.01, // initial yaw
600            )
601        };
602
603        let (initial_pitch, initial_yaw) = calculate(0.0);
604        assert!(initial_pitch.abs() < 1e-15);
605        assert!((initial_yaw - 0.01).abs() < 1e-14);
606
607        // Second-order forward difference for the initial transverse/pitch rate. The modal
608        // coefficients must cancel this rate, not merely be normalized to the initial yaw.
609        let h = 1e-7;
610        let pitch_h = calculate(h).0;
611        let pitch_2h = calculate(2.0 * h).0;
612        let initial_pitch_rate = (4.0 * pitch_h - pitch_2h) / (2.0 * h);
613        assert!(initial_pitch_rate.abs() < 1e-6);
614
615        assert_eq!(
616            calculate_epicyclic_motion(0.0, 9.13e-7, 17522.0, 2.5, 0.1, 0.01),
617            (0.0, 0.01)
618        );
619        assert_eq!(
620            calculate_epicyclic_motion(6.94e-8, 0.0, 17522.0, 2.5, 0.1, 0.01),
621            (0.0, 0.01)
622        );
623    }
624
625    #[test]
626    #[allow(deprecated)]
627    fn limit_cycle_yaw_excludes_crosswind_and_fabricated_instability_step() {
628        let calm = calculate_limit_cycle_yaw(850.0, 17522.0, 2.5, 0.0);
629        let crosswind = calculate_limit_cycle_yaw(850.0, 17522.0, 2.5, 10.0);
630        assert_eq!(crosswind.to_bits(), calm.to_bits());
631        assert!(calm > 0.0);
632
633        // Preserve the finite stable-side limit at Sg=1; below it no equilibrium exists.
634        let at_boundary = calculate_limit_cycle_yaw(850.0, 17522.0, 1.0, 0.0);
635        let above_boundary = calculate_limit_cycle_yaw(850.0, 17522.0, 1.0_f64.next_up(), 0.0);
636        assert!(at_boundary > 0.0);
637        assert!(((above_boundary - at_boundary) / at_boundary).abs() < 1e-12);
638        assert_eq!(
639            calculate_limit_cycle_yaw(850.0, 17522.0, 1.0_f64.next_down(), 0.0),
640            0.0
641        );
642
643        // For physically coupled states Sg scales with spin squared, so the gravity/gyroscopic
644        // balance makes yaw grow with spin rather than shrink with Sg.
645        let low_sg: f64 = 1.1;
646        let high_sg: f64 = 4.0;
647        let low_spin = 10_000.0;
648        let high_spin = low_spin * (high_sg / low_sg).sqrt();
649        let low = calculate_limit_cycle_yaw(850.0, low_spin, low_sg, 0.0);
650        let high = calculate_limit_cycle_yaw(850.0, high_spin, high_sg, 0.0);
651        assert!(high > low);
652        let expected_ratio = (high_sg / low_sg).sqrt();
653        assert!((high / low - expected_ratio).abs() < expected_ratio * 1e-12);
654    }
655
656    #[test]
657    fn inertia_aware_limit_cycle_yaw_matches_gravity_gyroscopic_balance() {
658        let velocity_mps = 850.0;
659        let spin_rate_rad_s = 17522.0;
660        let stability_factor = 2.5;
661        let spin_inertia = 6.94e-8;
662        let transverse_inertia = 9.13e-7;
663        let actual = calculate_limit_cycle_yaw_with_inertias(
664            velocity_mps,
665            spin_rate_rad_s,
666            stability_factor,
667            spin_inertia,
668            transverse_inertia,
669        );
670        let expected = 4.0 * transverse_inertia * stability_factor * 9.80665
671            / (spin_inertia * spin_rate_rad_s * velocity_mps);
672
673        assert!((actual - expected).abs() < 1e-15);
674        assert_eq!(
675            calculate_limit_cycle_yaw_with_inertias(
676                velocity_mps,
677                spin_rate_rad_s,
678                0.9,
679                spin_inertia,
680                transverse_inertia,
681            ),
682            0.0
683        );
684        assert_eq!(
685            calculate_limit_cycle_yaw_with_inertias(
686                velocity_mps,
687                spin_rate_rad_s,
688                stability_factor,
689                0.0,
690                transverse_inertia,
691            ),
692            0.0
693        );
694    }
695
696    #[test]
697    fn test_combined_angular_motion() {
698        let params = PrecessionNutationParams::default();
699        let initial_state = AngularState {
700            pitch_angle: 0.001,
701            yaw_angle: 0.002,
702            pitch_rate: 0.01,
703            yaw_rate: 0.01,
704            precession_angle: 0.0,
705            nutation_phase: 0.0,
706        };
707
708        let new_state = calculate_combined_angular_motion(
709            &params,
710            &initial_state,
711            0.1,   // time
712            0.001, // dt
713            0.001, // initial disturbance
714        );
715
716        // Check that nutation phase evolved (it always should with non-zero frequency)
717        // Precession might be very small with small yaw angles
718        assert!(
719            new_state.nutation_phase != initial_state.nutation_phase
720                || new_state.precession_angle != initial_state.precession_angle
721        );
722
723        // Check reasonable bounds
724        assert!(new_state.pitch_angle.abs() < 1.0);
725        assert!(new_state.yaw_angle.abs() < 1.0);
726    }
727
728    #[test]
729    fn combined_motion_yaw_rate_is_derivative_of_yaw() {
730        let params = PrecessionNutationParams::default();
731        let disturbance = 0.001;
732        let time = 0.02;
733        let h = 1e-7;
734        let state = |precession_angle, nutation_phase| AngularState {
735            pitch_angle: 0.0,
736            yaw_angle: 0.0,
737            pitch_rate: 0.0,
738            yaw_rate: 0.0,
739            precession_angle,
740            nutation_phase,
741        };
742
743        // A one-second phase step exposes the internally selected angular frequencies.
744        let phase_step =
745            calculate_combined_angular_motion(&params, &state(0.0, 0.0), time, 1.0, disturbance);
746        let omega_p = phase_step.precession_angle;
747        let omega_n = phase_step.nutation_phase;
748
749        // Center the nutation at a quarter-cycle, where its phase derivative vanishes and
750        // the damping-envelope derivative is isolated.
751        let center_state = state(0.0, std::f64::consts::FRAC_PI_2);
752        let center =
753            calculate_combined_angular_motion(&params, &center_state, time, 0.0, disturbance);
754        let before_state = state(-omega_p * h, std::f64::consts::FRAC_PI_2 - omega_n * h);
755        let before =
756            calculate_combined_angular_motion(&params, &before_state, time - h, 0.0, disturbance);
757        let after_state = state(omega_p * h, std::f64::consts::FRAC_PI_2 + omega_n * h);
758        let after =
759            calculate_combined_angular_motion(&params, &after_state, time + h, 0.0, disturbance);
760        let finite_difference = (after.yaw_angle - before.yaw_angle) / (2.0 * h);
761
762        assert!(
763            (center.yaw_rate - finite_difference).abs() < 1e-8,
764            "yaw_rate={} finite_difference={finite_difference}",
765            center.yaw_rate
766        );
767    }
768
769    #[test]
770    fn combined_motion_applies_velocity_and_density_to_stability() {
771        let velocity_mps = 1_000.0;
772        let spin_rate_rad_s = 15_095.0;
773        let params = PrecessionNutationParams {
774            mass_kg: 0.01134,
775            caliber_m: 0.00782,
776            length_m: 0.033,
777            spin_rate_rad_s,
778            spin_inertia: 6.94e-8,
779            transverse_inertia: 9.13e-7,
780            velocity_mps,
781            air_density_kg_m3: 1.0,
782            mach: velocity_mps / 343.0,
783            pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
784            nutation_damping_factor: 0.05,
785        };
786        let initial_state = AngularState {
787            pitch_angle: 0.0,
788            yaw_angle: 0.0,
789            pitch_rate: 0.0,
790            yaw_rate: 0.0,
791            precession_angle: 0.0,
792            nutation_phase: 0.0,
793        };
794
795        let caliber_in = params.caliber_m / 0.0254;
796        let length_in = params.length_m / 0.0254;
797        let mass_gr = params.mass_kg / 0.00006479891;
798        let spin_rps = spin_rate_rad_s / (2.0 * std::f64::consts::PI);
799        let twist_in = velocity_mps * 3.28084 * 12.0 / spin_rps;
800        let bare_sg = crate::spin_drift::miller_stability(
801            caliber_in,
802            mass_gr,
803            twist_in,
804            length_in,
805        );
806        let velocity_correction = (velocity_mps * 3.28084 / 2800.0).powf(1.0 / 3.0);
807        let density_correction = 1.225 / params.air_density_kg_m3;
808        let corrected_sg = crate::spin_drift::calculate_dynamic_stability(
809            mass_gr,
810            velocity_mps,
811            spin_rate_rad_s,
812            caliber_in,
813            length_in,
814            params.air_density_kg_m3,
815        );
816        assert!(bare_sg < 1.0);
817        assert!(bare_sg * velocity_correction < 1.0);
818        assert!(bare_sg * density_correction < 1.0);
819        assert!(corrected_sg > 1.0);
820
821        let dt = 0.0001;
822        let actual = calculate_combined_angular_motion(&params, &initial_state, 0.0, dt, 0.001);
823        let expected_precession = calculate_precession_frequency(
824            spin_rate_rad_s,
825            params.spin_inertia,
826            params.transverse_inertia,
827            corrected_sg,
828        ) * dt;
829        let expected_nutation = calculate_nutation_frequency(
830            spin_rate_rad_s,
831            params.spin_inertia,
832            params.transverse_inertia,
833            corrected_sg,
834        ) * dt;
835
836        assert!(
837            (actual.precession_angle - expected_precession).abs() < 1e-12,
838            "corrected precession phase mismatch: actual={} expected={expected_precession}",
839            actual.precession_angle
840        );
841        assert!(
842            (actual.nutation_phase - expected_nutation).abs() < 1e-12,
843            "corrected nutation phase mismatch: actual={} expected={expected_nutation}",
844            actual.nutation_phase
845        );
846    }
847
848    #[test]
849    fn test_default_params() {
850        let params = PrecessionNutationParams::default();
851
852        // Check reasonable default values
853        assert!(params.mass_kg > 0.0);
854        assert!(params.caliber_m > 0.0);
855        assert!(params.length_m > 0.0);
856        assert!(params.spin_rate_rad_s > 0.0);
857        assert!(params.spin_inertia > 0.0);
858        assert!(params.transverse_inertia > 0.0);
859        assert!(params.velocity_mps > 0.0);
860        assert!(params.air_density_kg_m3 > 0.0);
861        assert!(params.mach > 0.0);
862        assert!(params.nutation_damping_factor > 0.0);
863        assert!(params.nutation_damping_factor < 1.0); // Should be fraction
864    }
865
866    #[test]
867    fn test_stability_effects() {
868        // Higher stability gives a higher nutation (fast-mode) frequency:
869        // phi_fast = (Ix p / 2 Iy)(1 + sqrt(1 - 1/Sg)) increases monotonically with Sg.
870        let freq_high_stability = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 5.0);
871        let freq_low_stability = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.5);
872        assert!(freq_high_stability > freq_low_stability);
873    }
874
875    #[test]
876    fn test_damping_time_evolution() {
877        let initial = 0.01;
878        let freq = 3000.0;
879        let spin = 17522.0;
880        let damping = 0.1;
881
882        // Sample at different times
883        let times = [0.0, 0.01, 0.02, 0.05, 0.1, 0.2];
884        let mut last_amp = initial;
885
886        for &t in &times[1..] {
887            let amp = calculate_nutation_amplitude(initial, t, freq, damping, spin);
888
889            // Should monotonically decrease
890            assert!(amp < last_amp);
891            assert!(amp >= 0.0);
892            last_amp = amp;
893        }
894    }
895
896    #[test]
897    fn test_angular_state_evolution() {
898        let params = PrecessionNutationParams {
899            mass_kg: 0.01,
900            caliber_m: 0.008,
901            length_m: 0.03,
902            spin_rate_rad_s: 16000.0, // fast enough that the Miller Sg > 1 (gyroscopically stable)
903            spin_inertia: 5e-8,
904            transverse_inertia: 8e-7,
905            velocity_mps: 800.0,
906            air_density_kg_m3: 1.2,
907            mach: 2.3,
908            pitch_damping_coeff: -5.0,
909            nutation_damping_factor: 0.08,
910        };
911
912        let mut state = AngularState {
913            pitch_angle: 0.0,
914            yaw_angle: 0.005,
915            pitch_rate: 0.0,
916            yaw_rate: 0.0,
917            precession_angle: 0.0,
918            nutation_phase: 0.0,
919        };
920
921        // Store initial state for comparison
922        let initial_phase = state.nutation_phase;
923        let initial_precession = state.precession_angle;
924
925        // Evolve for several timesteps
926        let dt = 0.0001;
927        for i in 0..100 {
928            let time = i as f64 * dt;
929            state = calculate_combined_angular_motion(&params, &state, time, dt, 0.002);
930        }
931
932        // Should have evolved - at least one of these should change
933        assert!(
934            state.precession_angle != initial_precession || state.nutation_phase != initial_phase
935        );
936
937        // Should remain bounded
938        assert!(state.yaw_angle.abs() < 0.1);
939        assert!(state.pitch_angle.abs() < 0.1);
940    }
941}