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