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