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