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