ballistics_engine/
transonic_drag.rs

1/// Transonic drag modeling with shock wave effects
2///
3/// This module implements physics-based corrections for drag in the transonic regime
4/// (Mach 0.8-1.2) where shock waves significantly affect the drag coefficient.
5
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum ProjectileShape {
8    Spitzer,   // Sharp pointed (most common)
9    RoundNose, // Blunt/round nose
10    FlatBase,  // Flat base (wadcutter)
11    BoatTail,  // Boat tail design
12}
13
14impl ProjectileShape {
15    /// Parse from string representation
16    pub fn from_str(s: &str) -> Self {
17        match s.to_lowercase().as_str() {
18            "spitzer" => Self::Spitzer,
19            "round_nose" => Self::RoundNose,
20            "flat_base" => Self::FlatBase,
21            "boat_tail" => Self::BoatTail,
22            _ => Self::Spitzer, // Default
23        }
24    }
25}
26
27/// Calculate Prandtl-Glauert correction factor for compressibility effects
28///
29/// This factor accounts for air compressibility effects in subsonic flow approaching Mach 1.
30/// Formula: 1/sqrt(1-M²) where M is Mach number
31///
32/// Physical basis: As flow approaches sonic speeds, local acceleration over the projectile
33/// surface causes compressibility effects that increase the effective angle of attack and drag.
34///
35/// Note: This correction is only valid for subsonic flow (Mach < 1.0)
36#[allow(dead_code)]
37fn prandtl_glauert_correction(mach: f64) -> f64 {
38    if mach >= 0.99 {
39        // Near Mach 1, the theoretical correction approaches infinity (1/sqrt(1-1²) = 1/0)
40        // Cap at 10.0 to prevent numerical divergence while maintaining physical meaning
41        // This represents a practical limit where the linear theory breaks down
42        return 10.0;
43    }
44
45    // Classic Prandtl-Glauert compressibility correction factor
46    // Accounts for increased pressure coefficients due to compressibility
47    let beta = (1.0 - mach * mach).sqrt();
48    1.0 / beta
49}
50
51/// Get the critical Mach number where transonic drag rise begins
52///
53/// The critical Mach number is where local flow acceleration first reaches Mach 1,
54/// causing shock wave formation even though freestream velocity is still subsonic.
55/// These values are based on wind tunnel data and computational fluid dynamics.
56fn critical_mach_number(shape: ProjectileShape) -> f64 {
57    match shape {
58        // Sharp, pointed nose delays shock formation due to gradual flow acceleration
59        // Range: 0.83-0.87 depending on nose sharpness angle
60        // Source: Aberdeen Proving Ground wind tunnel data
61        ProjectileShape::Spitzer => 0.85,
62
63        // Blunt nose causes earlier shock formation due to rapid flow deceleration
64        // Range: 0.72-0.78 depending on nose radius
65        // Source: Classical aerodynamics literature (Hoerner, 1965)
66        ProjectileShape::RoundNose => 0.75,
67
68        // Flat base creates additional pressure drag and early transonic effects
69        // Range: 0.68-0.72, includes base drag contributions
70        // Source: Experimental ballistics data
71        ProjectileShape::FlatBase => 0.70,
72
73        // Tapered base (boat tail) reduces pressure drag, delays transonic rise
74        // Range: 0.86-0.90 depending on boat tail angle (typically 7-9 degrees)
75        // Source: Modern CFD validation studies
76        ProjectileShape::BoatTail => 0.88,
77    }
78}
79
80/// Calculate the transonic drag rise factor
81///
82/// This models the sharp increase in drag as shock waves form and strengthen
83/// in the transonic regime. Based on empirical correlations and theoretical
84/// models from Anderson's "Modern Compressible Flow" and McCoy's work.
85pub fn transonic_drag_rise(mach: f64, shape: ProjectileShape) -> f64 {
86    let m_crit = critical_mach_number(shape);
87
88    if mach < m_crit {
89        // Below critical Mach, no significant drag rise
90        return 1.0;
91    }
92
93    if mach < 1.0 {
94        // Subsonic drag rise (shock waves forming locally)
95        // Use a more physically accurate model based on empirical data
96
97        // Progress through the drag rise region with safe division
98        let denominator = 1.0 - m_crit;
99        if denominator.abs() < f64::EPSILON {
100            return 1.0; // No drag rise if critical Mach is 1.0
101        }
102        let progress = (mach - m_crit) / denominator;
103
104        if progress < 0.0 {
105            return 1.0;
106        }
107
108        // Different rise rates for different shapes
109        let rise_factor = match shape {
110            ProjectileShape::BoatTail => {
111                // Boat tail has gentler drag rise
112                1.0 + 1.2 * progress.powi(2)
113            }
114            ProjectileShape::RoundNose => {
115                // Round nose has steeper drag rise
116                1.0 + 2.0 * progress.powf(1.5)
117            }
118            _ => {
119                // Spitzer is in between
120                1.0 + 1.5 * progress.powf(1.8)
121            }
122        };
123
124        // Add compressibility effects near Mach 1
125        let rise_factor = if mach > 0.92 {
126            // Smoother transition
127            let comp_progress = (mach - 0.92) / 0.08;
128            let compressibility = 1.0 + 0.5 * comp_progress.powi(3);
129            rise_factor * compressibility
130        } else {
131            rise_factor
132        };
133
134        rise_factor.min(2.5) // More realistic cap
135    } else if mach < 1.2 {
136        // Transonic/early supersonic (bow shock forming)
137        // Peak drag occurs around Mach 1.0-1.1
138
139        // Shape-dependent peak location
140        let peak_mach = match shape {
141            ProjectileShape::Spitzer => 1.05,
142            _ => 1.02,
143        };
144
145        if mach <= peak_mach {
146            // Rising to peak
147            let base_rise = 1.8; // More realistic peak around 1.8x
148            let shape_factor = match shape {
149                ProjectileShape::RoundNose => 1.2,
150                _ => 1.0,
151            };
152            base_rise * shape_factor * (1.0 + 0.3 * (mach - 1.0))
153        } else {
154            // Descending from peak
155            let peak_drag = match shape {
156                ProjectileShape::RoundNose => 2.2,
157                _ => 1.8,
158            };
159            let decline_rate = 3.0; // How fast drag drops after peak
160            peak_drag * (-(decline_rate * (mach - peak_mach))).exp()
161        }
162    } else {
163        // Supersonic (Mach > 1.2)
164        // Drag decreases with Mach number as shock angle decreases
165        // This is handled by the base drag tables
166        1.0
167    }
168}
169
170/// Calculate the wave drag coefficient component
171///
172/// Wave drag is the additional drag caused by shock waves in transonic/supersonic flow.
173/// This is additive to the base drag coefficient.
174fn wave_drag_coefficient(mach: f64, shape: ProjectileShape) -> f64 {
175    if mach < 0.8 {
176        return 0.0;
177    }
178
179    if mach < 1.0 {
180        // Subsonic wave drag (local shocks only)
181        let m_crit = critical_mach_number(shape);
182        if mach < m_crit {
183            return 0.0;
184        }
185
186        // Gradual onset of wave drag with safe division
187        let denominator = 1.0 - m_crit;
188        if denominator.abs() < f64::EPSILON {
189            return 0.0; // No wave drag if critical Mach is 1.0
190        }
191        let progress = (mach - m_crit) / denominator;
192        let max_subsonic_wave = 0.1;
193        max_subsonic_wave * progress.powi(2)
194    } else {
195        // Supersonic wave drag
196        // Based on modified Whitcomb area rule
197        let fineness_ratio = 3.5; // Typical for bullets (length/diameter)
198
199        // Wave drag coefficient for cone-cylinder
200        let cd_wave_base = 0.15 / fineness_ratio;
201
202        // Mach number correction (wave drag decreases with Mach)
203        // Avoid division by zero at Mach 1.0
204        let mach_factor = 1.0
205            / (mach * mach - 1.0)
206                .max(crate::constants::MIN_MACH_THRESHOLD)
207                .sqrt();
208
209        // Shape correction
210        let shape_factor = match shape {
211            ProjectileShape::Spitzer => 0.8,   // Good for wave drag
212            ProjectileShape::RoundNose => 1.2, // Poor for wave drag
213            ProjectileShape::FlatBase => 1.5,  // Very poor
214            ProjectileShape::BoatTail => 0.7,  // Best for wave drag
215        };
216
217        cd_wave_base * mach_factor * shape_factor
218    }
219}
220
221/// Apply transonic corrections to a base drag coefficient
222///
223/// This is the main function to use for correcting drag coefficients
224/// in the transonic regime.
225pub fn transonic_correction(
226    mach: f64,
227    base_cd: f64,
228    shape: ProjectileShape,
229    include_wave_drag: bool,
230) -> f64 {
231    // Get the drag rise factor
232    let rise_factor = transonic_drag_rise(mach, shape);
233
234    // Apply to base drag
235    let mut corrected_cd = base_cd * rise_factor;
236
237    // Add wave drag if requested
238    if include_wave_drag && mach > 0.8 {
239        let wave_cd = wave_drag_coefficient(mach, shape);
240        corrected_cd += wave_cd;
241    }
242
243    corrected_cd
244}
245
246/// Estimate projectile shape from physical parameters
247///
248/// This is a simple heuristic based on typical bullet designs.
249pub fn get_projectile_shape(caliber: f64, weight_grains: f64, g_model: &str) -> ProjectileShape {
250    // G7 is typically used for boat tail bullets
251    if g_model == "G7" {
252        return ProjectileShape::BoatTail;
253    }
254
255    // Heavy for caliber often means longer, boat tail design
256    let weight_per_caliber = weight_grains / caliber;
257    if weight_per_caliber > 500.0 {
258        // e.g., 175gr .308
259        return ProjectileShape::BoatTail;
260    }
261
262    // Default to spitzer for most rifle bullets
263    if caliber < 0.35 {
264        // Rifle calibers
265        ProjectileShape::Spitzer
266    } else {
267        // Larger calibers often have round nose
268        ProjectileShape::RoundNose
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_prandtl_glauert() {
278        // Test subsonic values
279        assert!((prandtl_glauert_correction(0.5) - 1.1547).abs() < 0.001);
280        assert!((prandtl_glauert_correction(0.8) - 1.6667).abs() < 0.001);
281        assert!((prandtl_glauert_correction(0.95) - 3.2026).abs() < 0.001);
282
283        // Test near Mach 1 capping
284        assert_eq!(prandtl_glauert_correction(0.99), 10.0);
285    }
286
287    #[test]
288    fn test_critical_mach() {
289        assert_eq!(critical_mach_number(ProjectileShape::Spitzer), 0.85);
290        assert_eq!(critical_mach_number(ProjectileShape::BoatTail), 0.88);
291        assert_eq!(critical_mach_number(ProjectileShape::FlatBase), 0.70);
292    }
293
294    #[test]
295    fn test_transonic_drag_rise() {
296        let shape = ProjectileShape::Spitzer;
297
298        // Below critical Mach
299        assert_eq!(transonic_drag_rise(0.8, shape), 1.0);
300
301        // In transonic rise
302        let rise_0_9 = transonic_drag_rise(0.9, shape);
303        assert!(rise_0_9 > 1.0 && rise_0_9 < 2.0);
304
305        // Near Mach 1
306        let rise_0_98 = transonic_drag_rise(0.98, shape);
307        assert!(rise_0_98 > 2.0);
308
309        // Past peak
310        let rise_1_1 = transonic_drag_rise(1.1, shape);
311        assert!(rise_1_1 > 1.5 && rise_1_1 < 2.5);
312    }
313
314    #[test]
315    fn test_projectile_shape_estimation() {
316        // G7 should always return boat tail
317        let shape = get_projectile_shape(0.308, 175.0, "G7");
318        assert!(matches!(shape, ProjectileShape::BoatTail));
319
320        // Test that we get valid shapes for various inputs
321        let shape1 = get_projectile_shape(0.308, 200.0, "G1");
322        assert!(matches!(
323            shape1,
324            ProjectileShape::Spitzer | ProjectileShape::BoatTail | ProjectileShape::RoundNose
325        ));
326
327        let shape2 = get_projectile_shape(0.224, 55.0, "G1");
328        assert!(matches!(
329            shape2,
330            ProjectileShape::Spitzer | ProjectileShape::BoatTail | ProjectileShape::RoundNose
331        ));
332
333        let shape3 = get_projectile_shape(0.50, 300.0, "G1");
334        assert!(matches!(
335            shape3,
336            ProjectileShape::Spitzer | ProjectileShape::BoatTail | ProjectileShape::RoundNose
337        ));
338    }
339}
340
341// Removed Python-specific function
342
343// Removed Python-specific function