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