1#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum ProjectileShape {
8 Spitzer, RoundNose, FlatBase, BoatTail, }
13
14impl ProjectileShape {
15 #[allow(clippy::should_implement_trait)] 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, }
25 }
26}
27
28#[allow(dead_code)]
38fn prandtl_glauert_correction(mach: f64) -> f64 {
39 const MAX_CORRECTION: f64 = 10.0;
40 const MIN_BETA_SQUARED: f64 = 1.0 / (MAX_CORRECTION * MAX_CORRECTION);
41
42 let beta_squared = 1.0 - mach * mach;
47 if beta_squared <= MIN_BETA_SQUARED {
48 return MAX_CORRECTION;
49 }
50
51 let beta = beta_squared.sqrt();
52 1.0 / beta
53}
54
55fn critical_mach_number(shape: ProjectileShape) -> f64 {
61 match shape {
62 ProjectileShape::Spitzer => 0.85,
66
67 ProjectileShape::RoundNose => 0.75,
71
72 ProjectileShape::FlatBase => 0.70,
76
77 ProjectileShape::BoatTail => 0.88,
81 }
82}
83
84fn sonic_drag_rise(shape: ProjectileShape) -> f64 {
85 let shape_factor = match shape {
86 ProjectileShape::RoundNose => 1.2,
87 _ => 1.0,
88 };
89 1.8 * shape_factor
90}
91
92fn peak_drag_mach(shape: ProjectileShape) -> f64 {
93 match shape {
94 ProjectileShape::Spitzer => 1.05,
95 _ => 1.02,
96 }
97}
98
99pub fn transonic_drag_rise(mach: f64, shape: ProjectileShape) -> f64 {
105 let m_crit = critical_mach_number(shape);
106 let sonic_rise = sonic_drag_rise(shape);
107
108 if mach < m_crit {
109 return 1.0;
111 }
112
113 if mach < 1.0 {
114 let denominator = 1.0 - m_crit;
119 if denominator.abs() < f64::EPSILON {
120 return 1.0; }
122 let progress = (mach - m_crit) / denominator;
123
124 if progress < 0.0 {
125 return 1.0;
126 }
127
128 let rise_factor = match shape {
130 ProjectileShape::BoatTail => {
131 1.0 + 1.2 * progress.powi(2)
133 }
134 ProjectileShape::RoundNose => {
135 1.0 + 2.0 * progress.powf(1.5)
137 }
138 _ => {
139 1.0 + 1.5 * progress.powf(1.8)
141 }
142 };
143
144 let rise_factor = if mach > 0.92 {
146 let comp_progress = (mach - 0.92) / 0.08;
148 let compressibility = 1.0 + 0.5 * comp_progress.powi(3);
149 rise_factor * compressibility
150 } else {
151 rise_factor
152 };
153
154 rise_factor.min(sonic_rise)
157 } else if mach < 1.2 {
158 let peak_mach = peak_drag_mach(shape);
163
164 if mach <= peak_mach {
165 sonic_rise * (1.0 + 0.3 * (mach - 1.0))
167 } else {
168 let peak_drag = sonic_rise * (1.0 + 0.3 * (peak_mach - 1.0));
170 let decline_rate = 3.0; peak_drag * (-(decline_rate * (mach - peak_mach))).exp()
172 }
173 } else {
174 1.0
178 }
179}
180
181fn wave_drag_coefficient(mach: f64, shape: ProjectileShape) -> f64 {
186 const MAX_SUBSONIC_WAVE: f64 = 0.1;
187
188 if mach < 0.8 {
189 return 0.0;
190 }
191
192 if mach < 1.0 {
193 let m_crit = critical_mach_number(shape);
195 if mach < m_crit {
196 return 0.0;
197 }
198
199 let denominator = 1.0 - m_crit;
201 if denominator.abs() < f64::EPSILON {
202 return 0.0; }
204 let progress = (mach - m_crit) / denominator;
205 MAX_SUBSONIC_WAVE * progress.powi(2)
206 } else {
207 let fineness_ratio = 3.5; let cd_wave_base = 0.15 / fineness_ratio;
213
214 let shape_factor = match shape {
216 ProjectileShape::Spitzer => 0.8, ProjectileShape::RoundNose => 1.2, ProjectileShape::FlatBase => 1.5, ProjectileShape::BoatTail => 0.7, };
221
222 let max_mach_factor = MAX_SUBSONIC_WAVE / (cd_wave_base * shape_factor);
226 let mach_factor = if mach == 1.0 {
227 max_mach_factor
228 } else {
229 (1.0 / (mach * mach - 1.0).sqrt()).min(max_mach_factor)
230 };
231
232 cd_wave_base * mach_factor * shape_factor
233 }
234}
235
236pub fn transonic_correction(
241 mach: f64,
242 base_cd: f64,
243 shape: ProjectileShape,
244 include_wave_drag: bool,
245) -> f64 {
246 let mut corrected_cd = if include_wave_drag {
250 base_cd * transonic_drag_rise(mach, shape)
251 } else {
252 base_cd
253 };
254
255 if include_wave_drag && mach > 0.8 {
257 let wave_cd = wave_drag_coefficient(mach, shape);
258 corrected_cd += wave_cd;
259 }
260
261 corrected_cd
262}
263
264pub fn resolve_projectile_shape(
270 bullet_model: Option<&str>,
271 caliber: f64,
272 weight_grains: f64,
273 g_model: &str,
274) -> ProjectileShape {
275 if let Some(model) = bullet_model {
276 let m = model.to_lowercase();
277 if m.contains("boat") || m.contains("bt") {
278 return ProjectileShape::BoatTail;
279 } else if m.contains("round") || m.contains("rn") {
280 return ProjectileShape::RoundNose;
281 } else if m.contains("flat") || m.contains("fb") {
282 return ProjectileShape::FlatBase;
283 }
284 }
285 get_projectile_shape(caliber, weight_grains, g_model)
286}
287
288pub fn get_projectile_shape(caliber: f64, weight_grains: f64, g_model: &str) -> ProjectileShape {
292 if g_model == "G7" {
294 return ProjectileShape::BoatTail;
295 }
296
297 let weight_per_caliber = weight_grains / caliber;
299 if weight_per_caliber > 500.0 {
300 return ProjectileShape::BoatTail;
302 }
303
304 if caliber < 0.35 {
306 ProjectileShape::Spitzer
308 } else {
309 ProjectileShape::RoundNose
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_mba949_resolve_projectile_shape() {
320 let c = 0.308;
322 let w = 168.0;
323 assert!(matches!(
324 resolve_projectile_shape(Some("Sierra MatchKing Boat Tail"), c, w, "G1"),
325 ProjectileShape::BoatTail
326 ));
327 assert!(matches!(
328 resolve_projectile_shape(Some("300gr RN"), c, w, "G1"),
329 ProjectileShape::RoundNose
330 ));
331 assert!(matches!(
332 resolve_projectile_shape(Some("flat base"), c, w, "G1"),
333 ProjectileShape::FlatBase
334 ));
335 let heuristic = get_projectile_shape(c, w, "G7");
338 assert_eq!(resolve_projectile_shape(None, c, w, "G7"), heuristic);
339 assert_eq!(
340 resolve_projectile_shape(Some("unknown"), c, w, "G7"),
341 heuristic
342 );
343 }
344
345 #[test]
346 fn test_prandtl_glauert() {
347 assert!((prandtl_glauert_correction(0.5) - 1.1547).abs() < 0.001);
349 assert!((prandtl_glauert_correction(0.8) - 1.6667).abs() < 0.001);
350 assert!((prandtl_glauert_correction(0.95) - 3.2026).abs() < 0.001);
351 }
352
353 #[test]
354 fn prandtl_glauert_follows_the_curve_until_the_cap() {
355 for mach in [0.99_f64, 0.994] {
356 let expected = 1.0 / (1.0 - mach * mach).sqrt();
357 let actual = prandtl_glauert_correction(mach);
358
359 assert!((actual - expected).abs() < 1e-12);
360 assert!(actual < 10.0);
361 }
362 assert_eq!(prandtl_glauert_correction(0.995), 10.0);
363 assert!(prandtl_glauert_correction(f64::NAN).is_nan());
364 }
365
366 #[test]
367 fn test_critical_mach() {
368 assert_eq!(critical_mach_number(ProjectileShape::Spitzer), 0.85);
369 assert_eq!(critical_mach_number(ProjectileShape::BoatTail), 0.88);
370 assert_eq!(critical_mach_number(ProjectileShape::FlatBase), 0.70);
371 }
372
373 #[test]
374 fn test_transonic_drag_rise() {
375 let shape = ProjectileShape::Spitzer;
376
377 assert_eq!(transonic_drag_rise(0.8, shape), 1.0);
379
380 let rise_0_9 = transonic_drag_rise(0.9, shape);
382 assert!(rise_0_9 > 1.0 && rise_0_9 < 2.0);
383
384 let rise_0_98 = transonic_drag_rise(0.98, shape);
386 let rise_1_0 = transonic_drag_rise(1.0, shape);
387 assert!(rise_0_98 > 1.0 && rise_0_98 <= rise_1_0);
388
389 let rise_1_1 = transonic_drag_rise(1.1, shape);
391 assert!(rise_1_1 > 1.5 && rise_1_1 < 2.5);
392 }
393
394 #[test]
395 fn transonic_drag_rise_is_continuous_at_sonic_and_peak() {
396 const EPSILON: f64 = 1e-9;
397
398 for (shape, peak_mach, expected_sonic, expected_peak) in [
399 (ProjectileShape::Spitzer, 1.05, 1.8, 1.827),
400 (ProjectileShape::RoundNose, 1.02, 2.16, 2.17296),
401 (ProjectileShape::FlatBase, 1.02, 1.8, 1.8108),
402 (ProjectileShape::BoatTail, 1.02, 1.8, 1.8108),
403 ] {
404 let sonic = transonic_drag_rise(1.0, shape);
405 let just_below_sonic = transonic_drag_rise(1.0 - EPSILON, shape);
406 assert!((sonic - expected_sonic).abs() < 1e-12);
407 assert!(
408 (just_below_sonic - sonic).abs() < 1e-6,
409 "{shape:?} discontinuity at Mach 1: below={just_below_sonic}, at={sonic}"
410 );
411
412 let peak = transonic_drag_rise(peak_mach, shape);
413 let just_above_peak = transonic_drag_rise(peak_mach + EPSILON, shape);
414 assert!((peak - expected_peak).abs() < 1e-12);
415 assert!(
416 (peak - just_above_peak).abs() < 1e-6,
417 "{shape:?} discontinuity at peak Mach {peak_mach}: at={peak}, above={just_above_peak}"
418 );
419 assert!(peak > sonic, "{shape:?} peak must occur above Mach 1");
420 assert!(
421 transonic_drag_rise(peak_mach + 0.01, shape) < peak,
422 "{shape:?} drag rise must descend after its peak"
423 );
424 }
425 }
426
427 #[test]
428 fn wave_drag_is_bounded_and_continuous_at_sonic() {
429 const EPSILON: f64 = 1e-9;
430
431 for shape in [
432 ProjectileShape::Spitzer,
433 ProjectileShape::RoundNose,
434 ProjectileShape::FlatBase,
435 ProjectileShape::BoatTail,
436 ] {
437 let just_below = wave_drag_coefficient(1.0 - EPSILON, shape);
438 let sonic = wave_drag_coefficient(1.0, shape);
439 let just_above = wave_drag_coefficient(1.0 + EPSILON, shape);
440
441 assert!((sonic - 0.1).abs() < 1e-12);
442 assert!(
443 (just_below - sonic).abs() < 1e-6,
444 "{shape:?} wave drag discontinuity below Mach 1: below={just_below}, at={sonic}"
445 );
446 assert!(
447 (just_above - sonic).abs() < 1e-6,
448 "{shape:?} wave drag discontinuity above Mach 1: at={sonic}, above={just_above}"
449 );
450
451 for mach in [1.001, 1.01, 1.05, 1.1, 1.2] {
452 let wave_drag = wave_drag_coefficient(mach, shape);
453 assert!(
454 wave_drag <= 0.1 + 1e-12,
455 "{shape:?} wave drag must stay bounded near sonic: M={mach}, Cd={wave_drag}"
456 );
457 }
458 }
459 }
460
461 #[test]
462 fn test_projectile_shape_estimation() {
463 let shape = get_projectile_shape(0.308, 175.0, "G7");
465 assert!(matches!(shape, ProjectileShape::BoatTail));
466
467 let shape1 = get_projectile_shape(0.308, 200.0, "G1");
469 assert!(matches!(
470 shape1,
471 ProjectileShape::Spitzer | ProjectileShape::BoatTail | ProjectileShape::RoundNose
472 ));
473
474 let shape2 = get_projectile_shape(0.224, 55.0, "G1");
475 assert!(matches!(
476 shape2,
477 ProjectileShape::Spitzer | ProjectileShape::BoatTail | ProjectileShape::RoundNose
478 ));
479
480 let shape3 = get_projectile_shape(0.50, 300.0, "G1");
481 assert!(matches!(
482 shape3,
483 ProjectileShape::Spitzer | ProjectileShape::BoatTail | ProjectileShape::RoundNose
484 ));
485 }
486}
487
488