Skip to main content

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    #[allow(clippy::should_implement_trait)] // Legacy parser defaults instead of returning Result.
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    const MAX_CORRECTION: f64 = 10.0;
40    const MIN_BETA_SQUARED: f64 = 1.0 / (MAX_CORRECTION * MAX_CORRECTION);
41
42    // Classic Prandtl-Glauert compressibility correction factor
43    // Accounts for increased pressure coefficients due to compressibility. Follow the
44    // theoretical curve until it reaches the practical cap instead of jumping to the cap at an
45    // arbitrary Mach threshold.
46    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
55/// Get the critical Mach number where transonic drag rise begins
56///
57/// The critical Mach number is where local flow acceleration first reaches Mach 1,
58/// causing shock wave formation even though freestream velocity is still subsonic.
59/// These values are based on wind tunnel data and computational fluid dynamics.
60fn critical_mach_number(shape: ProjectileShape) -> f64 {
61    match shape {
62        // Sharp, pointed nose delays shock formation due to gradual flow acceleration
63        // Range: 0.83-0.87 depending on nose sharpness angle
64        // Source: Aberdeen Proving Ground wind tunnel data
65        ProjectileShape::Spitzer => 0.85,
66
67        // Blunt nose causes earlier shock formation due to rapid flow deceleration
68        // Range: 0.72-0.78 depending on nose radius
69        // Source: Classical aerodynamics literature (Hoerner, 1965)
70        ProjectileShape::RoundNose => 0.75,
71
72        // Flat base creates additional pressure drag and early transonic effects
73        // Range: 0.68-0.72, includes base drag contributions
74        // Source: Experimental ballistics data
75        ProjectileShape::FlatBase => 0.70,
76
77        // Tapered base (boat tail) reduces pressure drag, delays transonic rise
78        // Range: 0.86-0.90 depending on boat tail angle (typically 7-9 degrees)
79        // Source: Modern CFD validation studies
80        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
99/// Calculate the transonic drag rise factor
100///
101/// This models the sharp increase in drag as shock waves form and strengthen
102/// in the transonic regime. Based on empirical correlations and theoretical
103/// models from Anderson's "Modern Compressible Flow" and McCoy's work.
104pub 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        // Below critical Mach, no significant drag rise
110        return 1.0;
111    }
112
113    if mach < 1.0 {
114        // Subsonic drag rise (shock waves forming locally)
115        // Use a more physically accurate model based on empirical data
116
117        // Progress through the drag rise region with safe division
118        let denominator = 1.0 - m_crit;
119        if denominator.abs() < f64::EPSILON {
120            return 1.0; // No drag rise if critical Mach is 1.0
121        }
122        let progress = (mach - m_crit) / denominator;
123
124        if progress < 0.0 {
125            return 1.0;
126        }
127
128        // Different rise rates for different shapes
129        let rise_factor = match shape {
130            ProjectileShape::BoatTail => {
131                // Boat tail has gentler drag rise
132                1.0 + 1.2 * progress.powi(2)
133            }
134            ProjectileShape::RoundNose => {
135                // Round nose has steeper drag rise
136                1.0 + 2.0 * progress.powf(1.5)
137            }
138            _ => {
139                // Spitzer is in between
140                1.0 + 1.5 * progress.powf(1.8)
141            }
142        };
143
144        // Add compressibility effects near Mach 1
145        let rise_factor = if mach > 0.92 {
146            // Smoother transition
147            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        // Join the transonic branch continuously at Mach 1. Shape-specific caps avoid the old
155        // inverted 2.5 -> 1.8/2.16 sonic step (MBA-1162).
156        rise_factor.min(sonic_rise)
157    } else if mach < 1.2 {
158        // Transonic/early supersonic (bow shock forming)
159        // Peak drag occurs around Mach 1.0-1.1
160
161        // Shape-dependent peak location
162        let peak_mach = peak_drag_mach(shape);
163
164        if mach <= peak_mach {
165            // Rising to peak
166            sonic_rise * (1.0 + 0.3 * (mach - 1.0))
167        } else {
168            // Descend from the actual rising-branch endpoint so the peak join is continuous.
169            let peak_drag = sonic_rise * (1.0 + 0.3 * (peak_mach - 1.0));
170            let decline_rate = 3.0; // How fast drag drops after peak
171            peak_drag * (-(decline_rate * (mach - peak_mach))).exp()
172        }
173    } else {
174        // Supersonic (Mach > 1.2)
175        // Drag decreases with Mach number as shock angle decreases
176        // This is handled by the base drag tables
177        1.0
178    }
179}
180
181/// Calculate the wave drag coefficient component
182///
183/// Wave drag is the additional drag caused by shock waves in transonic/supersonic flow.
184/// This is additive to the base drag coefficient.
185fn 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        // Subsonic wave drag (local shocks only)
194        let m_crit = critical_mach_number(shape);
195        if mach < m_crit {
196            return 0.0;
197        }
198
199        // Gradual onset of wave drag with safe division
200        let denominator = 1.0 - m_crit;
201        if denominator.abs() < f64::EPSILON {
202            return 0.0; // No wave drag if critical Mach is 1.0
203        }
204        let progress = (mach - m_crit) / denominator;
205        MAX_SUBSONIC_WAVE * progress.powi(2)
206    } else {
207        // Supersonic wave drag
208        // Based on modified Whitcomb area rule
209        let fineness_ratio = 3.5; // Typical for bullets (length/diameter)
210
211        // Wave drag coefficient for cone-cylinder
212        let cd_wave_base = 0.15 / fineness_ratio;
213
214        // Shape correction
215        let shape_factor = match shape {
216            ProjectileShape::Spitzer => 0.8,   // Good for wave drag
217            ProjectileShape::RoundNose => 1.2, // Poor for wave drag
218            ProjectileShape::FlatBase => 1.5,  // Very poor
219            ProjectileShape::BoatTail => 0.7,  // Best for wave drag
220        };
221
222        // Linearized supersonic theory is singular at Mach 1. Cap its factor at
223        // the subsonic endpoint so wave drag remains continuous and bounded,
224        // then follow the original decay once the approximation becomes valid.
225        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
236/// Apply transonic corrections to a base drag coefficient
237///
238/// This is the main function to use for correcting drag coefficients
239/// in the transonic regime.
240pub fn transonic_correction(
241    mach: f64,
242    base_cd: f64,
243    shape: ProjectileShape,
244    include_wave_drag: bool,
245) -> f64 {
246    // Standard G1/G7/Gx tables already include their transonic drag rise. Only apply this
247    // empirical rise when the caller is explicitly adding wave/transonic effects to a table that
248    // does not already contain them.
249    let mut corrected_cd = if include_wave_drag {
250        base_cd * transonic_drag_rise(mach, shape)
251    } else {
252        base_cd
253    };
254
255    // Add wave drag if requested
256    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
264/// Resolve the projectile shape for transonic corrections (MBA-949). A user-supplied bullet_model
265/// name (boat/bt, round/rn, flat/fb) takes precedence; otherwise fall back to the caliber/weight/
266/// drag-model heuristic below. This name-parsing previously lived in cli_api and fast_trajectory
267/// but NOT derivatives, so named shapes were silently ignored on the integrate_trajectory path.
268/// Sharing one helper makes all three solver families resolve the same shape for the same inputs.
269pub 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
288/// Estimate projectile shape from physical parameters
289///
290/// This is a simple heuristic based on typical bullet designs.
291pub fn get_projectile_shape(caliber: f64, weight_grains: f64, g_model: &str) -> ProjectileShape {
292    // G7 is typically used for boat tail bullets
293    if g_model == "G7" {
294        return ProjectileShape::BoatTail;
295    }
296
297    // Heavy for caliber often means longer, boat tail design
298    let weight_per_caliber = weight_grains / caliber;
299    if weight_per_caliber > 500.0 {
300        // e.g., 175gr .308
301        return ProjectileShape::BoatTail;
302    }
303
304    // Default to spitzer for most rifle bullets
305    if caliber < 0.35 {
306        // Rifle calibers
307        ProjectileShape::Spitzer
308    } else {
309        // Larger calibers often have round nose
310        ProjectileShape::RoundNose
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_mba949_resolve_projectile_shape() {
320        // Named bullet_model takes precedence (case-insensitive, substring match).
321        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        // No name -> heuristic. None and an unrecognized name both defer to get_projectile_shape,
336        // so they must agree with it for the same caliber/weight/drag model.
337        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        // Test subsonic values
348        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        // Below critical Mach
378        assert_eq!(transonic_drag_rise(0.8, shape), 1.0);
379
380        // In transonic rise
381        let rise_0_9 = transonic_drag_rise(0.9, shape);
382        assert!(rise_0_9 > 1.0 && rise_0_9 < 2.0);
383
384        // Near Mach 1
385        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        // Past peak
390        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        // G7 should always return boat tail
464        let shape = get_projectile_shape(0.308, 175.0, "G7");
465        assert!(matches!(shape, ProjectileShape::BoatTail));
466
467        // Test that we get valid shapes for various inputs
468        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// Removed Python-specific function
489
490// Removed Python-specific function