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::pitch_damping::{calculate_pitch_damping_moment, PitchDampingCoefficients};
12
13/// Complete angular state of the projectile
14#[derive(Debug, Clone, Copy)]
15pub struct AngularState {
16    pub pitch_angle: f64,      // Angle between axis and velocity (rad)
17    pub yaw_angle: f64,        // Angle in plane perpendicular to velocity (rad)
18    pub pitch_rate: f64,       // Rate of pitch angle change (rad/s)
19    pub yaw_rate: f64,         // Rate of yaw angle change (rad/s)
20    pub precession_angle: f64, // Cumulative precession angle (rad)
21    pub nutation_phase: f64,   // Phase of nutation oscillation (rad)
22}
23
24/// Parameters for precession and nutation calculations
25#[derive(Debug, Clone)]
26pub struct PrecessionNutationParams {
27    // Projectile properties
28    pub mass_kg: f64,
29    pub caliber_m: f64,
30    pub length_m: f64,
31    pub spin_rate_rad_s: f64,
32
33    // Moments of inertia
34    pub spin_inertia: f64,       // About longitudinal axis
35    pub transverse_inertia: f64, // About transverse axis
36
37    // Flight conditions
38    pub velocity_mps: f64,
39    pub air_density_kg_m3: f64,
40    pub mach: f64,
41
42    // Damping coefficients
43    pub pitch_damping_coeff: f64,
44    pub nutation_damping_factor: f64, // Fraction of critical damping
45}
46
47impl Default for PrecessionNutationParams {
48    fn default() -> Self {
49        Self {
50            mass_kg: 0.01134, // 175 grains
51            caliber_m: 0.00782,
52            length_m: 0.033,
53            spin_rate_rad_s: 17522.0,
54            spin_inertia: 6.94e-8,
55            transverse_inertia: 9.13e-7,
56            velocity_mps: 850.0,
57            air_density_kg_m3: 1.225,
58            mach: 2.48,
59            pitch_damping_coeff: -0.8,
60            nutation_damping_factor: 0.05,
61        }
62    }
63}
64
65/// Calculate the natural precession frequency
66pub fn calculate_precession_frequency(
67    spin_rate_rad_s: f64,
68    velocity_mps: f64,
69    spin_inertia: f64,
70    transverse_inertia: f64,
71    yaw_angle_rad: f64,
72) -> f64 {
73    if velocity_mps == 0.0 || transverse_inertia == 0.0 {
74        return 0.0;
75    }
76
77    // Basic gyroscopic precession
78    // ωp = (Is * ωs * sin(α)) / (It * V)
79    (spin_inertia * spin_rate_rad_s * yaw_angle_rad.sin()) / (transverse_inertia * velocity_mps)
80}
81
82/// Calculate the natural nutation frequency
83pub fn calculate_nutation_frequency(
84    spin_rate_rad_s: f64,
85    spin_inertia: f64,
86    transverse_inertia: f64,
87    stability_factor: f64,
88) -> f64 {
89    if stability_factor <= 1.0 || transverse_inertia == 0.0 {
90        return 0.0;
91    }
92
93    // Nutation frequency
94    // ωn = ωs * sqrt(Is / It) * sqrt(Sg - 1)
95    let inertia_ratio = spin_inertia / transverse_inertia;
96    spin_rate_rad_s * inertia_ratio.sqrt() * (stability_factor - 1.0).sqrt()
97}
98
99/// Calculate nutation amplitude with exponential damping
100pub fn calculate_nutation_amplitude(
101    initial_disturbance_rad: f64,
102    time_s: f64,
103    nutation_frequency: f64,
104    damping_factor: f64,
105    spin_rate_rad_s: f64,
106) -> f64 {
107    if nutation_frequency == 0.0 || spin_rate_rad_s == 0.0 {
108        return 0.0;
109    }
110
111    // Damping rate
112    let damping_rate = damping_factor * nutation_frequency;
113
114    // Exponential decay
115    let amplitude = initial_disturbance_rad * (-damping_rate * time_s).exp();
116
117    // Clamp to reasonable bounds
118    amplitude.min(0.1) // Max 0.1 rad (~5.7 degrees)
119}
120
121/// Calculate the combined precession and nutation motion
122pub fn calculate_combined_angular_motion(
123    params: &PrecessionNutationParams,
124    angular_state: &AngularState,
125    time_s: f64,
126    dt: f64,
127    initial_disturbance: f64,
128) -> AngularState {
129    // MBA-198: Guard against division by zero in stability calculation
130    if params.transverse_inertia == 0.0 || params.velocity_mps == 0.0 || params.length_m == 0.0 {
131        // Return unchanged state if invalid parameters
132        return *angular_state;
133    }
134
135    // Calculate stability factor (simplified)
136    let stability = (params.spin_inertia * params.spin_rate_rad_s.powi(2))
137        / (4.0 * params.transverse_inertia * params.velocity_mps.powi(2) / params.length_m);
138
139    // Precession frequency
140    let omega_p = calculate_precession_frequency(
141        params.spin_rate_rad_s,
142        params.velocity_mps,
143        params.spin_inertia,
144        params.transverse_inertia,
145        angular_state.yaw_angle,
146    );
147
148    // Nutation frequency
149    let omega_n = calculate_nutation_frequency(
150        params.spin_rate_rad_s,
151        params.spin_inertia,
152        params.transverse_inertia,
153        stability,
154    );
155
156    // Nutation amplitude (decaying)
157    let nutation_amp = calculate_nutation_amplitude(
158        initial_disturbance,
159        time_s,
160        omega_n,
161        params.nutation_damping_factor,
162        params.spin_rate_rad_s,
163    );
164
165    // Update precession angle
166    let new_precession_angle = angular_state.precession_angle + omega_p * dt;
167
168    // Update nutation phase
169    let new_nutation_phase = angular_state.nutation_phase + omega_n * dt;
170
171    // Calculate pitch damping moment
172    let pitch_moment = calculate_pitch_damping_moment(
173        angular_state.pitch_rate,
174        params.velocity_mps,
175        params.air_density_kg_m3,
176        params.caliber_m,
177        params.length_m,
178        params.mach,
179        &PitchDampingCoefficients {
180            subsonic: params.pitch_damping_coeff,
181            ..Default::default()
182        },
183    );
184
185    // Angular acceleration from damping
186    let pitch_accel = pitch_moment / params.transverse_inertia;
187
188    // Update angular rates
189    let new_pitch_rate = angular_state.pitch_rate + pitch_accel * dt;
190    let new_yaw_rate = omega_p; // Precession rate
191
192    // Combined angle with nutation
193    // The total yaw is precession + nutation oscillation
194    let total_yaw = angular_state.yaw_angle + nutation_amp * new_nutation_phase.sin();
195
196    // Pitch angle evolves more slowly
197    let new_pitch = angular_state.pitch_angle + new_pitch_rate * dt;
198
199    AngularState {
200        pitch_angle: new_pitch,
201        yaw_angle: total_yaw,
202        pitch_rate: new_pitch_rate,
203        yaw_rate: new_yaw_rate,
204        precession_angle: new_precession_angle,
205        nutation_phase: new_nutation_phase,
206    }
207}
208
209/// Calculate the epicyclic (combined precession + nutation) motion
210pub fn calculate_epicyclic_motion(
211    spin_rate_rad_s: f64,
212    velocity_mps: f64,
213    stability_factor: f64,
214    time_s: f64,
215    initial_yaw_rad: f64,
216) -> (f64, f64) {
217    // MBA-198: Guard against division by zero
218    if stability_factor <= 1.0 || spin_rate_rad_s == 0.0 {
219        // Unstable or no spin - no regular motion
220        return (initial_yaw_rad, initial_yaw_rad);
221    }
222
223    // Frequencies (simplified model)
224    // Slow mode (precession)
225    let omega_slow = 2.0 * velocity_mps / (stability_factor * spin_rate_rad_s);
226
227    // Fast mode (nutation)
228    let omega_fast = spin_rate_rad_s * ((stability_factor - 1.0).sqrt()) / stability_factor;
229
230    // Amplitude ratio (fast/slow)
231    let amplitude_ratio = 1.0 / stability_factor;
232
233    // Damping (exponential decay of fast mode)
234    let damping_factor = 0.1; // Typical value
235    let fast_amplitude = amplitude_ratio * (-damping_factor * omega_fast * time_s).exp();
236
237    // Combined motion
238    let slow_phase = omega_slow * time_s;
239    let fast_phase = omega_fast * time_s;
240
241    // Epicyclic coordinates
242    let yaw = initial_yaw_rad * (slow_phase.cos() + fast_amplitude * fast_phase.cos());
243    let pitch = initial_yaw_rad * (slow_phase.sin() + fast_amplitude * fast_phase.sin());
244
245    (pitch, yaw)
246}
247
248/// Calculate the limit cycle yaw angle
249pub fn calculate_limit_cycle_yaw(
250    velocity_mps: f64,
251    _spin_rate_rad_s: f64,
252    stability_factor: f64,
253    crosswind_mps: f64,
254) -> f64 {
255    // Base yaw from crosswind
256    let wind_yaw = if crosswind_mps != 0.0 && velocity_mps > 0.0 {
257        (crosswind_mps / velocity_mps).atan()
258    } else {
259        0.0
260    };
261
262    // Yaw of repose (equilibrium)
263    let yaw_of_repose = if stability_factor > 1.0 {
264        // Typical value for spin-stabilized projectiles
265        let repose_factor = 1.0 / (1.0 + 0.5 * (stability_factor - 1.0));
266        0.002 * repose_factor // ~0.1 degrees nominal
267    } else {
268        0.01 // Larger for marginally stable
269    };
270
271    wind_yaw + yaw_of_repose
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_precession_frequency() {
280        let freq = calculate_precession_frequency(
281            17522.0, // spin rate
282            850.0,   // velocity
283            6.94e-8, // spin inertia
284            9.13e-7, // transverse inertia
285            0.002,   // yaw angle
286        );
287
288        // Should be small for small yaw angles
289        assert!(freq.abs() < 1.0);
290    }
291
292    #[test]
293    fn test_nutation_frequency() {
294        let freq = calculate_nutation_frequency(
295            17522.0, // spin rate
296            6.94e-8, // spin inertia
297            9.13e-7, // transverse inertia
298            1.5,     // stability
299        );
300
301        // Should be in the kHz range
302        assert!(freq > 1000.0);
303        assert!(freq < 10000.0);
304    }
305
306    #[test]
307    fn test_nutation_damping() {
308        let initial = 0.01;
309        let freq = 3000.0;
310
311        // Check exponential decay
312        let amp_0 = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, 17522.0);
313        let amp_1 = calculate_nutation_amplitude(initial, 0.1, freq, 0.05, 17522.0);
314
315        assert_eq!(amp_0, initial);
316        assert!(amp_1 < amp_0);
317        assert!(amp_1 > 0.0);
318    }
319
320    #[test]
321    fn test_precession_edge_cases() {
322        // Test zero velocity
323        let freq_zero_vel = calculate_precession_frequency(17522.0, 0.0, 6.94e-8, 9.13e-7, 0.002);
324        assert_eq!(freq_zero_vel, 0.0);
325
326        // Test zero transverse inertia
327        let freq_zero_inertia = calculate_precession_frequency(17522.0, 850.0, 6.94e-8, 0.0, 0.002);
328        assert_eq!(freq_zero_inertia, 0.0);
329
330        // Test zero yaw angle (sin(0) = 0)
331        let freq_zero_yaw = calculate_precession_frequency(17522.0, 850.0, 6.94e-8, 9.13e-7, 0.0);
332        assert_eq!(freq_zero_yaw, 0.0);
333    }
334
335    #[test]
336    fn test_nutation_edge_cases() {
337        // Test unstable projectile (Sg <= 1)
338        let freq_unstable = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 0.9);
339        assert_eq!(freq_unstable, 0.0);
340
341        // Test marginally stable (Sg = 1)
342        let freq_marginal = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.0);
343        assert_eq!(freq_marginal, 0.0);
344
345        // Test zero transverse inertia
346        let freq_zero_inertia = calculate_nutation_frequency(17522.0, 6.94e-8, 0.0, 2.0);
347        assert_eq!(freq_zero_inertia, 0.0);
348    }
349
350    #[test]
351    fn test_nutation_amplitude_bounds() {
352        let initial = 0.5; // Large initial disturbance
353        let freq = 3000.0;
354        let spin = 17522.0;
355
356        // Even with large initial disturbance, should be clamped
357        let amp = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, spin);
358        assert!(amp <= 0.1); // Max 0.1 rad
359
360        // Test zero frequency
361        let amp_zero_freq = calculate_nutation_amplitude(initial, 1.0, 0.0, 0.05, spin);
362        assert_eq!(amp_zero_freq, 0.0);
363
364        // Test zero spin
365        let amp_zero_spin = calculate_nutation_amplitude(initial, 1.0, freq, 0.05, 0.0);
366        assert_eq!(amp_zero_spin, 0.0);
367    }
368
369    #[test]
370    fn test_epicyclic_motion() {
371        let (pitch, yaw) = calculate_epicyclic_motion(
372            17522.0, // spin rate
373            850.0,   // velocity
374            2.5,     // stability factor
375            0.1,     // time
376            0.01,    // initial yaw
377        );
378
379        // Should produce reasonable angles
380        assert!(pitch.abs() <= 0.01);
381        assert!(yaw.abs() <= 0.01);
382
383        // Test unstable case
384        let (pitch_unstable, yaw_unstable) =
385            calculate_epicyclic_motion(17522.0, 850.0, 0.9, 0.1, 0.01);
386        assert_eq!(pitch_unstable, 0.01);
387        assert_eq!(yaw_unstable, 0.01);
388    }
389
390    #[test]
391    fn test_limit_cycle_yaw() {
392        // Test with crosswind
393        let yaw_wind = calculate_limit_cycle_yaw(
394            850.0,   // velocity
395            17522.0, // spin rate
396            2.5,     // stability
397            10.0,    // crosswind
398        );
399
400        // Should be small but non-zero
401        assert!(yaw_wind > 0.0);
402        assert!(yaw_wind < 0.1);
403
404        // Test without crosswind
405        let yaw_no_wind = calculate_limit_cycle_yaw(850.0, 17522.0, 2.5, 0.0);
406        assert!(yaw_no_wind > 0.0);
407        assert!(yaw_no_wind < yaw_wind);
408
409        // Test unstable projectile
410        let yaw_unstable = calculate_limit_cycle_yaw(850.0, 17522.0, 0.9, 0.0);
411        assert_eq!(yaw_unstable, 0.01); // Fixed value for unstable
412    }
413
414    #[test]
415    fn test_combined_angular_motion() {
416        let params = PrecessionNutationParams::default();
417        let initial_state = AngularState {
418            pitch_angle: 0.001,
419            yaw_angle: 0.002,
420            pitch_rate: 0.01,
421            yaw_rate: 0.01,
422            precession_angle: 0.0,
423            nutation_phase: 0.0,
424        };
425
426        let new_state = calculate_combined_angular_motion(
427            &params,
428            &initial_state,
429            0.1,   // time
430            0.001, // dt
431            0.001, // initial disturbance
432        );
433
434        // Check that nutation phase evolved (it always should with non-zero frequency)
435        // Precession might be very small with small yaw angles
436        assert!(
437            new_state.nutation_phase != initial_state.nutation_phase
438                || new_state.precession_angle != initial_state.precession_angle
439        );
440
441        // Check reasonable bounds
442        assert!(new_state.pitch_angle.abs() < 1.0);
443        assert!(new_state.yaw_angle.abs() < 1.0);
444    }
445
446    #[test]
447    fn test_default_params() {
448        let params = PrecessionNutationParams::default();
449
450        // Check reasonable default values
451        assert!(params.mass_kg > 0.0);
452        assert!(params.caliber_m > 0.0);
453        assert!(params.length_m > 0.0);
454        assert!(params.spin_rate_rad_s > 0.0);
455        assert!(params.spin_inertia > 0.0);
456        assert!(params.transverse_inertia > 0.0);
457        assert!(params.velocity_mps > 0.0);
458        assert!(params.air_density_kg_m3 > 0.0);
459        assert!(params.mach > 0.0);
460        assert!(params.nutation_damping_factor > 0.0);
461        assert!(params.nutation_damping_factor < 1.0); // Should be fraction
462    }
463
464    #[test]
465    fn test_stability_effects() {
466        // High stability should give lower frequencies
467        let freq_high_stability = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 5.0);
468
469        let freq_low_stability = calculate_nutation_frequency(17522.0, 6.94e-8, 9.13e-7, 1.5);
470
471        // Higher stability gives higher nutation frequency
472        assert!(freq_high_stability > freq_low_stability);
473    }
474
475    #[test]
476    fn test_damping_time_evolution() {
477        let initial = 0.01;
478        let freq = 3000.0;
479        let spin = 17522.0;
480        let damping = 0.1;
481
482        // Sample at different times
483        let times = [0.0, 0.01, 0.02, 0.05, 0.1, 0.2];
484        let mut last_amp = initial;
485
486        for &t in &times[1..] {
487            let amp = calculate_nutation_amplitude(initial, t, freq, damping, spin);
488
489            // Should monotonically decrease
490            assert!(amp < last_amp);
491            assert!(amp >= 0.0);
492            last_amp = amp;
493        }
494    }
495
496    #[test]
497    fn test_angular_state_evolution() {
498        let params = PrecessionNutationParams {
499            mass_kg: 0.01,
500            caliber_m: 0.008,
501            length_m: 0.03,
502            spin_rate_rad_s: 10000.0,
503            spin_inertia: 5e-8,
504            transverse_inertia: 8e-7,
505            velocity_mps: 800.0,
506            air_density_kg_m3: 1.2,
507            mach: 2.3,
508            pitch_damping_coeff: -0.5,
509            nutation_damping_factor: 0.08,
510        };
511
512        let mut state = AngularState {
513            pitch_angle: 0.0,
514            yaw_angle: 0.005,
515            pitch_rate: 0.0,
516            yaw_rate: 0.0,
517            precession_angle: 0.0,
518            nutation_phase: 0.0,
519        };
520
521        // Store initial state for comparison
522        let initial_phase = state.nutation_phase;
523        let initial_precession = state.precession_angle;
524
525        // Evolve for several timesteps
526        let dt = 0.0001;
527        for i in 0..100 {
528            let time = i as f64 * dt;
529            state = calculate_combined_angular_motion(&params, &state, time, dt, 0.002);
530        }
531
532        // Should have evolved - at least one of these should change
533        assert!(
534            state.precession_angle != initial_precession || state.nutation_phase != initial_phase
535        );
536
537        // Should remain bounded
538        assert!(state.yaw_angle.abs() < 0.1);
539        assert!(state.pitch_angle.abs() < 0.1);
540    }
541}