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    // Calculate stability factor (simplified)
134    let stability = (params.spin_inertia * params.spin_rate_rad_s.powi(2)) / 
135        (4.0 * params.transverse_inertia * params.velocity_mps.powi(2) / params.length_m);
136    
137    // Precession frequency
138    let omega_p = calculate_precession_frequency(
139        params.spin_rate_rad_s,
140        params.velocity_mps,
141        params.spin_inertia,
142        params.transverse_inertia,
143        angular_state.yaw_angle,
144    );
145    
146    // Nutation frequency
147    let omega_n = calculate_nutation_frequency(
148        params.spin_rate_rad_s,
149        params.spin_inertia,
150        params.transverse_inertia,
151        stability,
152    );
153    
154    // Nutation amplitude (decaying)
155    let nutation_amp = calculate_nutation_amplitude(
156        initial_disturbance,
157        time_s,
158        omega_n,
159        params.nutation_damping_factor,
160        params.spin_rate_rad_s,
161    );
162    
163    // Update precession angle
164    let new_precession_angle = angular_state.precession_angle + omega_p * dt;
165    
166    // Update nutation phase
167    let new_nutation_phase = angular_state.nutation_phase + omega_n * dt;
168    
169    // Calculate pitch damping moment
170    let pitch_moment = calculate_pitch_damping_moment(
171        angular_state.pitch_rate,
172        params.velocity_mps,
173        params.air_density_kg_m3,
174        params.caliber_m,
175        params.length_m,
176        params.mach,
177        &PitchDampingCoefficients {
178            subsonic: params.pitch_damping_coeff,
179            ..Default::default()
180        },
181    );
182    
183    // Angular acceleration from damping
184    let pitch_accel = pitch_moment / params.transverse_inertia;
185    
186    // Update angular rates
187    let new_pitch_rate = angular_state.pitch_rate + pitch_accel * dt;
188    let new_yaw_rate = omega_p; // Precession rate
189    
190    // Combined angle with nutation
191    // The total yaw is precession + nutation oscillation
192    let total_yaw = angular_state.yaw_angle + nutation_amp * new_nutation_phase.sin();
193    
194    // Pitch angle evolves more slowly
195    let new_pitch = angular_state.pitch_angle + new_pitch_rate * dt;
196    
197    AngularState {
198        pitch_angle: new_pitch,
199        yaw_angle: total_yaw,
200        pitch_rate: new_pitch_rate,
201        yaw_rate: new_yaw_rate,
202        precession_angle: new_precession_angle,
203        nutation_phase: new_nutation_phase,
204    }
205}
206
207/// Calculate the epicyclic (combined precession + nutation) motion
208pub fn calculate_epicyclic_motion(
209    spin_rate_rad_s: f64,
210    velocity_mps: f64,
211    stability_factor: f64,
212    time_s: f64,
213    initial_yaw_rad: f64,
214) -> (f64, f64) {
215    if stability_factor <= 1.0 {
216        // Unstable - no regular motion
217        return (initial_yaw_rad, initial_yaw_rad);
218    }
219    
220    // Frequencies (simplified model)
221    // Slow mode (precession)
222    let omega_slow = 2.0 * velocity_mps / (stability_factor * spin_rate_rad_s);
223    
224    // Fast mode (nutation)
225    let omega_fast = spin_rate_rad_s * ((stability_factor - 1.0).sqrt()) / stability_factor;
226    
227    // Amplitude ratio (fast/slow)
228    let amplitude_ratio = 1.0 / stability_factor;
229    
230    // Damping (exponential decay of fast mode)
231    let damping_factor = 0.1; // Typical value
232    let fast_amplitude = amplitude_ratio * (-damping_factor * omega_fast * time_s).exp();
233    
234    // Combined motion
235    let slow_phase = omega_slow * time_s;
236    let fast_phase = omega_fast * time_s;
237    
238    // Epicyclic coordinates
239    let yaw = initial_yaw_rad * (slow_phase.cos() + fast_amplitude * fast_phase.cos());
240    let pitch = initial_yaw_rad * (slow_phase.sin() + fast_amplitude * fast_phase.sin());
241    
242    (pitch, yaw)
243}
244
245/// Calculate the limit cycle yaw angle
246pub fn calculate_limit_cycle_yaw(
247    velocity_mps: f64,
248    _spin_rate_rad_s: f64,
249    stability_factor: f64,
250    crosswind_mps: f64,
251) -> f64 {
252    // Base yaw from crosswind
253    let wind_yaw = if crosswind_mps != 0.0 && velocity_mps > 0.0 {
254        (crosswind_mps / velocity_mps).atan()
255    } else {
256        0.0
257    };
258    
259    // Yaw of repose (equilibrium)
260    let yaw_of_repose = if stability_factor > 1.0 {
261        // Typical value for spin-stabilized projectiles
262        let repose_factor = 1.0 / (1.0 + 0.5 * (stability_factor - 1.0));
263        0.002 * repose_factor // ~0.1 degrees nominal
264    } else {
265        0.01 // Larger for marginally stable
266    };
267    
268    wind_yaw + yaw_of_repose
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    
275    #[test]
276    fn test_precession_frequency() {
277        let freq = calculate_precession_frequency(
278            17522.0,  // spin rate
279            850.0,    // velocity
280            6.94e-8,  // spin inertia
281            9.13e-7,  // transverse inertia
282            0.002,    // yaw angle
283        );
284        
285        // Should be small for small yaw angles
286        assert!(freq.abs() < 1.0);
287    }
288    
289    #[test]
290    fn test_nutation_frequency() {
291        let freq = calculate_nutation_frequency(
292            17522.0,  // spin rate
293            6.94e-8,  // spin inertia
294            9.13e-7,  // transverse inertia
295            1.5,      // stability
296        );
297        
298        // Should be in the kHz range
299        assert!(freq > 1000.0);
300        assert!(freq < 10000.0);
301    }
302    
303    #[test]
304    fn test_nutation_damping() {
305        let initial = 0.01;
306        let freq = 3000.0;
307        
308        // Check exponential decay
309        let amp_0 = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, 17522.0);
310        let amp_1 = calculate_nutation_amplitude(initial, 0.1, freq, 0.05, 17522.0);
311        
312        assert_eq!(amp_0, initial);
313        assert!(amp_1 < amp_0);
314        assert!(amp_1 > 0.0);
315    }
316    
317    #[test]
318    fn test_precession_edge_cases() {
319        // Test zero velocity
320        let freq_zero_vel = calculate_precession_frequency(
321            17522.0, 0.0, 6.94e-8, 9.13e-7, 0.002
322        );
323        assert_eq!(freq_zero_vel, 0.0);
324        
325        // Test zero transverse inertia
326        let freq_zero_inertia = calculate_precession_frequency(
327            17522.0, 850.0, 6.94e-8, 0.0, 0.002
328        );
329        assert_eq!(freq_zero_inertia, 0.0);
330        
331        // Test zero yaw angle (sin(0) = 0)
332        let freq_zero_yaw = calculate_precession_frequency(
333            17522.0, 850.0, 6.94e-8, 9.13e-7, 0.0
334        );
335        assert_eq!(freq_zero_yaw, 0.0);
336    }
337    
338    #[test]
339    fn test_nutation_edge_cases() {
340        // Test unstable projectile (Sg <= 1)
341        let freq_unstable = calculate_nutation_frequency(
342            17522.0, 6.94e-8, 9.13e-7, 0.9
343        );
344        assert_eq!(freq_unstable, 0.0);
345        
346        // Test marginally stable (Sg = 1)
347        let freq_marginal = calculate_nutation_frequency(
348            17522.0, 6.94e-8, 9.13e-7, 1.0
349        );
350        assert_eq!(freq_marginal, 0.0);
351        
352        // Test zero transverse inertia
353        let freq_zero_inertia = calculate_nutation_frequency(
354            17522.0, 6.94e-8, 0.0, 2.0
355        );
356        assert_eq!(freq_zero_inertia, 0.0);
357    }
358    
359    #[test]
360    fn test_nutation_amplitude_bounds() {
361        let initial = 0.5; // Large initial disturbance
362        let freq = 3000.0;
363        let spin = 17522.0;
364        
365        // Even with large initial disturbance, should be clamped
366        let amp = calculate_nutation_amplitude(initial, 0.0, freq, 0.05, spin);
367        assert!(amp <= 0.1); // Max 0.1 rad
368        
369        // Test zero frequency
370        let amp_zero_freq = calculate_nutation_amplitude(initial, 1.0, 0.0, 0.05, spin);
371        assert_eq!(amp_zero_freq, 0.0);
372        
373        // Test zero spin
374        let amp_zero_spin = calculate_nutation_amplitude(initial, 1.0, freq, 0.05, 0.0);
375        assert_eq!(amp_zero_spin, 0.0);
376    }
377    
378    #[test]
379    fn test_epicyclic_motion() {
380        let (pitch, yaw) = calculate_epicyclic_motion(
381            17522.0,  // spin rate
382            850.0,    // velocity
383            2.5,      // stability factor
384            0.1,      // time
385            0.01      // initial yaw
386        );
387        
388        // Should produce reasonable angles
389        assert!(pitch.abs() <= 0.01);
390        assert!(yaw.abs() <= 0.01);
391        
392        // Test unstable case
393        let (pitch_unstable, yaw_unstable) = calculate_epicyclic_motion(
394            17522.0, 850.0, 0.9, 0.1, 0.01
395        );
396        assert_eq!(pitch_unstable, 0.01);
397        assert_eq!(yaw_unstable, 0.01);
398    }
399    
400    #[test]
401    fn test_limit_cycle_yaw() {
402        // Test with crosswind
403        let yaw_wind = calculate_limit_cycle_yaw(
404            850.0,    // velocity
405            17522.0,  // spin rate
406            2.5,      // stability
407            10.0      // crosswind
408        );
409        
410        // Should be small but non-zero
411        assert!(yaw_wind > 0.0);
412        assert!(yaw_wind < 0.1);
413        
414        // Test without crosswind
415        let yaw_no_wind = calculate_limit_cycle_yaw(
416            850.0, 17522.0, 2.5, 0.0
417        );
418        assert!(yaw_no_wind > 0.0);
419        assert!(yaw_no_wind < yaw_wind);
420        
421        // Test unstable projectile
422        let yaw_unstable = calculate_limit_cycle_yaw(
423            850.0, 17522.0, 0.9, 0.0
424        );
425        assert_eq!(yaw_unstable, 0.01); // Fixed value for unstable
426    }
427    
428    #[test]
429    fn test_combined_angular_motion() {
430        let params = PrecessionNutationParams::default();
431        let initial_state = AngularState {
432            pitch_angle: 0.001,
433            yaw_angle: 0.002,
434            pitch_rate: 0.01,
435            yaw_rate: 0.01,
436            precession_angle: 0.0,
437            nutation_phase: 0.0,
438        };
439        
440        let new_state = calculate_combined_angular_motion(
441            &params,
442            &initial_state,
443            0.1,  // time
444            0.001, // dt
445            0.001  // initial disturbance
446        );
447        
448        // Check that nutation phase evolved (it always should with non-zero frequency)
449        // Precession might be very small with small yaw angles
450        assert!(new_state.nutation_phase != initial_state.nutation_phase || 
451                new_state.precession_angle != initial_state.precession_angle);
452        
453        // Check reasonable bounds
454        assert!(new_state.pitch_angle.abs() < 1.0);
455        assert!(new_state.yaw_angle.abs() < 1.0);
456    }
457    
458    #[test]
459    fn test_default_params() {
460        let params = PrecessionNutationParams::default();
461        
462        // Check reasonable default values
463        assert!(params.mass_kg > 0.0);
464        assert!(params.caliber_m > 0.0);
465        assert!(params.length_m > 0.0);
466        assert!(params.spin_rate_rad_s > 0.0);
467        assert!(params.spin_inertia > 0.0);
468        assert!(params.transverse_inertia > 0.0);
469        assert!(params.velocity_mps > 0.0);
470        assert!(params.air_density_kg_m3 > 0.0);
471        assert!(params.mach > 0.0);
472        assert!(params.nutation_damping_factor > 0.0);
473        assert!(params.nutation_damping_factor < 1.0); // Should be fraction
474    }
475    
476    #[test]
477    fn test_stability_effects() {
478        // High stability should give lower frequencies
479        let freq_high_stability = calculate_nutation_frequency(
480            17522.0, 6.94e-8, 9.13e-7, 5.0
481        );
482        
483        let freq_low_stability = calculate_nutation_frequency(
484            17522.0, 6.94e-8, 9.13e-7, 1.5
485        );
486        
487        // Higher stability gives higher nutation frequency
488        assert!(freq_high_stability > freq_low_stability);
489    }
490    
491    #[test]
492    fn test_damping_time_evolution() {
493        let initial = 0.01;
494        let freq = 3000.0;
495        let spin = 17522.0;
496        let damping = 0.1;
497        
498        // Sample at different times
499        let times = [0.0, 0.01, 0.02, 0.05, 0.1, 0.2];
500        let mut last_amp = initial;
501        
502        for &t in &times[1..] {
503            let amp = calculate_nutation_amplitude(initial, t, freq, damping, spin);
504            
505            // Should monotonically decrease
506            assert!(amp < last_amp);
507            assert!(amp >= 0.0);
508            last_amp = amp;
509        }
510    }
511    
512    #[test]
513    fn test_angular_state_evolution() {
514        let params = PrecessionNutationParams {
515            mass_kg: 0.01,
516            caliber_m: 0.008,
517            length_m: 0.03,
518            spin_rate_rad_s: 10000.0,
519            spin_inertia: 5e-8,
520            transverse_inertia: 8e-7,
521            velocity_mps: 800.0,
522            air_density_kg_m3: 1.2,
523            mach: 2.3,
524            pitch_damping_coeff: -0.5,
525            nutation_damping_factor: 0.08,
526        };
527        
528        let mut state = AngularState {
529            pitch_angle: 0.0,
530            yaw_angle: 0.005,
531            pitch_rate: 0.0,
532            yaw_rate: 0.0,
533            precession_angle: 0.0,
534            nutation_phase: 0.0,
535        };
536        
537        // Store initial state for comparison
538        let initial_phase = state.nutation_phase;
539        let initial_precession = state.precession_angle;
540        
541        // Evolve for several timesteps
542        let dt = 0.0001;
543        for i in 0..100 {
544            let time = i as f64 * dt;
545            state = calculate_combined_angular_motion(
546                &params,
547                &state,
548                time,
549                dt,
550                0.002
551            );
552        }
553        
554        // Should have evolved - at least one of these should change
555        assert!(state.precession_angle != initial_precession || 
556                state.nutation_phase != initial_phase);
557        
558        // Should remain bounded
559        assert!(state.yaw_angle.abs() < 0.1);
560        assert!(state.pitch_angle.abs() < 0.1);
561    }
562}