ballistics_engine/
drag.rs

1/// Drag coefficient calculations for ballistics using actual drag table data
2use std::path::Path;
3use ndarray::ArrayD;
4use once_cell::sync::Lazy;
5use crate::DragModel;
6use crate::transonic_drag::{transonic_correction, get_projectile_shape, ProjectileShape};
7
8/// Drag table data structure
9#[derive(Debug, Clone)]
10pub struct DragTable {
11    pub mach_values: Vec<f64>,
12    pub cd_values: Vec<f64>,
13}
14
15impl DragTable {
16    /// Create a new drag table from mach and cd arrays
17    pub fn new(mach_values: Vec<f64>, cd_values: Vec<f64>) -> Self {
18        Self { mach_values, cd_values }
19    }
20
21    /// Interpolate drag coefficient for given Mach number using cubic spline-like interpolation
22    pub fn interpolate(&self, mach: f64) -> f64 {
23        let n = self.mach_values.len();
24        
25        if n == 0 {
26            return 0.5; // Fallback
27        }
28        
29        if n == 1 {
30            return self.cd_values[0];
31        }
32
33        // Handle out-of-bounds cases with extrapolation
34        if mach <= self.mach_values[0] {
35            // Linear extrapolation below range, but prevent negative values
36            if n >= 2 {
37                let slope = (self.cd_values[1] - self.cd_values[0]) / 
38                           (self.mach_values[1] - self.mach_values[0]);
39                let extrapolated = self.cd_values[0] + slope * (mach - self.mach_values[0]);
40                // Clamp to minimum reasonable value to prevent negative drag coefficients
41                return extrapolated.max(0.01);
42            }
43            return self.cd_values[0];
44        }
45        
46        if mach >= self.mach_values[n - 1] {
47            // Linear extrapolation above range, but prevent negative values
48            if n >= 2 {
49                let slope = (self.cd_values[n - 1] - self.cd_values[n - 2]) / 
50                           (self.mach_values[n - 1] - self.mach_values[n - 2]);
51                let extrapolated = self.cd_values[n - 1] + slope * (mach - self.mach_values[n - 1]);
52                // Clamp to minimum reasonable value to prevent negative drag coefficients
53                return extrapolated.max(0.01);
54            }
55            return self.cd_values[n - 1];
56        }
57
58        // Find the segment containing the mach value
59        let mut idx = 0;
60        for i in 0..n - 1 {
61            if mach >= self.mach_values[i] && mach <= self.mach_values[i + 1] {
62                idx = i;
63                break;
64            }
65        }
66
67        // Use cubic interpolation if we have enough points, otherwise linear
68        if idx > 0 && idx < n - 2 {
69            // Cubic interpolation using 4 points
70            self.cubic_interpolate(mach, idx)
71        } else {
72            // Linear interpolation for edge cases
73            self.linear_interpolate(mach, idx)
74        }
75    }
76
77    /// Linear interpolation between two points
78    pub fn linear_interpolate(&self, mach: f64, idx: usize) -> f64 {
79        // Bounds check
80        if idx + 1 >= self.mach_values.len() || idx + 1 >= self.cd_values.len() {
81            return self.cd_values.get(idx).copied().unwrap_or(0.5);
82        }
83        
84        let x0 = self.mach_values[idx];
85        let x1 = self.mach_values[idx + 1];
86        let y0 = self.cd_values[idx];
87        let y1 = self.cd_values[idx + 1];
88        
89        if (x1 - x0).abs() < crate::constants::MIN_DIVISION_THRESHOLD {
90            return y0;
91        }
92        
93        let t = (mach - x0) / (x1 - x0);
94        y0 + t * (y1 - y0)
95    }
96
97    /// Cubic interpolation using 4 points (similar to cubic spline)
98    pub fn cubic_interpolate(&self, mach: f64, idx: usize) -> f64 {
99        // Ensure we have enough points for cubic interpolation
100        if idx == 0 || idx + 1 >= self.mach_values.len() || 
101           idx + 1 >= self.cd_values.len() {
102            // Fall back to linear interpolation if not enough points
103            return self.linear_interpolate(mach, idx);
104        }
105        
106        // Use points at idx-1, idx, idx+1, idx+2
107        let x = [
108            self.mach_values[idx - 1],
109            self.mach_values[idx],
110            self.mach_values[idx + 1],
111            if idx + 2 < self.mach_values.len() { self.mach_values[idx + 2] } else { self.mach_values[idx + 1] }
112        ];
113        let y = [
114            self.cd_values[idx - 1],
115            self.cd_values[idx],
116            self.cd_values[idx + 1],
117            if idx + 2 < self.cd_values.len() { self.cd_values[idx + 2] } else { self.cd_values[idx + 1] }
118        ];
119
120        // Catmull-Rom spline interpolation
121        // Ensure denominator is not zero
122        let denominator = x[2] - x[1];
123        if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
124            return y[1]; // Return the value at the current point if points are too close
125        }
126        let t = (mach - x[1]) / denominator;
127        let t2 = t * t;
128        let t3 = t2 * t;
129
130        let a0 = -0.5 * y[0] + 1.5 * y[1] - 1.5 * y[2] + 0.5 * y[3];
131        let a1 = y[0] - 2.5 * y[1] + 2.0 * y[2] - 0.5 * y[3];
132        let a2 = -0.5 * y[0] + 0.5 * y[2];
133        let a3 = y[1];
134
135        a0 * t3 + a1 * t2 + a2 * t + a3
136    }
137}
138
139/// Load drag table from NumPy binary file or CSV fallback
140pub fn load_drag_table(drag_tables_dir: &Path, filename: &str, fallback_data: &[(f64, f64)]) -> DragTable {
141    // Try to load NumPy binary file first
142    let npy_path = drag_tables_dir.join(format!("{filename}.npy"));
143    if let Ok(array) = ndarray_npy::read_npy::<_, ArrayD<f64>>(&npy_path) {
144        if let Ok(array_2d) = array.into_dimensionality::<ndarray::Ix2>() {
145            let mach_values: Vec<f64> = array_2d.column(0).to_vec();
146            let cd_values: Vec<f64> = array_2d.column(1).to_vec();
147            return DragTable::new(mach_values, cd_values);
148        }
149    }
150
151    // Fallback to CSV file
152    let csv_path = drag_tables_dir.join(format!("{filename}.csv"));
153    if let Ok(mut reader) = csv::Reader::from_path(&csv_path) {
154        let mut mach_values = Vec::new();
155        let mut cd_values = Vec::new();
156        
157        for result in reader.records() {
158            if let Ok(record) = result {
159                if record.len() >= 2 {
160                    if let (Ok(mach), Ok(cd)) = (record[0].parse::<f64>(), record[1].parse::<f64>()) {
161                        mach_values.push(mach);
162                        cd_values.push(cd);
163                    }
164                }
165            }
166        }
167        
168        if !mach_values.is_empty() {
169            return DragTable::new(mach_values, cd_values);
170        }
171    }
172
173    // Use fallback data if both file loading methods fail
174    let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
175    let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
176    DragTable::new(mach_values, cd_values)
177}
178
179/// Find the drag tables directory relative to the current location
180fn find_drag_tables_dir() -> Option<std::path::PathBuf> {
181    // Try common relative paths from the Rust crate location
182    let candidates = [
183        "../drag_tables",
184        "../../drag_tables", 
185        "../../../drag_tables",
186        "drag_tables",
187    ];
188    
189    for candidate in &candidates {
190        let path = Path::new(candidate);
191        if path.exists() && path.is_dir() {
192            return Some(path.to_path_buf());
193        }
194    }
195    
196    None
197}
198
199/// G1 drag table with lazy loading
200static G1_DRAG_TABLE: Lazy<DragTable> = Lazy::new(|| {
201    let fallback_data = [
202        (0.0, 0.2629),
203        (0.5, 0.2695),
204        (0.6, 0.2752),
205        (0.7, 0.2817),
206        (0.8, 0.2902),
207        (0.9, 0.3012),
208        (1.0, 0.4805),
209        (1.1, 0.5933),
210        (1.2, 0.6318),
211        (1.3, 0.6440),
212        (1.4, 0.6444),
213        (1.5, 0.6372),
214        (1.6, 0.6252),
215        (1.7, 0.6105),
216        (1.8, 0.5956),
217        (1.9, 0.5815),
218        (2.0, 0.5934),
219        (2.5, 0.5598),
220        (3.0, 0.5133),
221        (4.0, 0.4811),
222        (5.0, 0.4988),
223    ];
224
225    if let Some(drag_dir) = find_drag_tables_dir() {
226        load_drag_table(&drag_dir, "g1", &fallback_data)
227    } else {
228        // Use fallback data if directory not found
229        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
230        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
231        DragTable::new(mach_values, cd_values)
232    }
233});
234
235/// G7 drag table with lazy loading
236static G7_DRAG_TABLE: Lazy<DragTable> = Lazy::new(|| {
237    let fallback_data = [
238        (0.0, 0.1198),
239        (0.5, 0.1197),
240        (0.6, 0.1202),
241        (0.7, 0.1213),
242        (0.8, 0.1240),
243        (0.9, 0.1294),
244        (1.0, 0.3803),
245        (1.1, 0.4015),
246        (1.2, 0.4043),
247        (1.3, 0.3956),
248        (1.4, 0.3814),
249        (1.5, 0.3663),
250        (1.6, 0.3520),
251        (1.7, 0.3398),
252        (1.8, 0.3297),
253        (1.9, 0.3221),
254        (2.0, 0.2980),
255        (2.5, 0.2731),
256        (3.0, 0.2424),
257        (4.0, 0.2196),
258        (5.0, 0.1618),
259    ];
260
261    if let Some(drag_dir) = find_drag_tables_dir() {
262        load_drag_table(&drag_dir, "g7", &fallback_data)
263    } else {
264        // Use fallback data if directory not found
265        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
266        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
267        DragTable::new(mach_values, cd_values)
268    }
269});
270
271/// G6 drag table - flat-base with 6 caliber secant ogive (military FMJ bullets)
272/// MBA-156: Added for completeness with ballistics_rust
273static G6_DRAG_TABLE: Lazy<DragTable> = Lazy::new(|| {
274    let fallback_data = [
275        (0.0, 0.2617), (0.05, 0.2553), (0.10, 0.2491), (0.15, 0.2432), (0.20, 0.2376),
276        (0.25, 0.2324), (0.30, 0.2278), (0.35, 0.2238), (0.40, 0.2205), (0.45, 0.2177),
277        (0.50, 0.2155), (0.55, 0.2138), (0.60, 0.2126), (0.65, 0.2121), (0.70, 0.2122),
278        (0.75, 0.2132), (0.80, 0.2154), (0.85, 0.2194), (0.875, 0.2229), (0.90, 0.2297),
279        (0.925, 0.2449), (0.95, 0.2732), (0.975, 0.3141), (1.0, 0.3597), (1.025, 0.3994),
280        (1.05, 0.4261), (1.075, 0.4402), (1.10, 0.4465), (1.125, 0.4490), (1.15, 0.4497),
281        (1.175, 0.4494), (1.20, 0.4482), (1.225, 0.4464), (1.25, 0.4441), (1.30, 0.4390),
282        (1.35, 0.4336), (1.40, 0.4279), (1.45, 0.4221), (1.50, 0.4162), (1.55, 0.4102),
283        (1.60, 0.4042), (1.65, 0.3981), (1.70, 0.3919), (1.75, 0.3855), (1.80, 0.3788),
284        (1.85, 0.3721), (1.90, 0.3652), (1.95, 0.3583), (2.0, 0.3515), (2.05, 0.3447),
285        (2.10, 0.3381), (2.15, 0.3314), (2.20, 0.3249), (2.25, 0.3185), (2.30, 0.3122),
286        (2.35, 0.3060), (2.40, 0.3000), (2.45, 0.2941), (2.50, 0.2883), (2.60, 0.2772),
287        (2.70, 0.2668), (2.80, 0.2574), (2.90, 0.2487), (3.0, 0.2407), (3.10, 0.2333),
288        (3.20, 0.2265), (3.30, 0.2202), (3.40, 0.2144), (3.50, 0.2089), (3.60, 0.2039),
289        (3.70, 0.1991), (3.80, 0.1947), (3.90, 0.1905), (4.0, 0.1866), (4.20, 0.1794),
290        (4.40, 0.1730), (4.60, 0.1673), (4.80, 0.1621), (5.0, 0.1574),
291    ];
292
293    if let Some(drag_dir) = find_drag_tables_dir() {
294        load_drag_table(&drag_dir, "g6", &fallback_data)
295    } else {
296        // Use fallback data if directory not found
297        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
298        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
299        DragTable::new(mach_values, cd_values)
300    }
301});
302
303/// G8 drag table - flat-base with 10 caliber secant ogive
304/// MBA-156: Added for completeness with ballistics_rust
305static G8_DRAG_TABLE: Lazy<DragTable> = Lazy::new(|| {
306    let fallback_data = [
307        (0.0, 0.2105), (0.05, 0.2105), (0.10, 0.2104), (0.15, 0.2104), (0.20, 0.2103),
308        (0.25, 0.2103), (0.30, 0.2103), (0.35, 0.2103), (0.40, 0.2103), (0.45, 0.2102),
309        (0.50, 0.2102), (0.55, 0.2102), (0.60, 0.2102), (0.65, 0.2102), (0.70, 0.2103),
310        (0.75, 0.2103), (0.80, 0.2104), (0.825, 0.2104), (0.85, 0.2105), (0.875, 0.2106),
311        (0.90, 0.2109), (0.925, 0.2183), (0.95, 0.2571), (0.975, 0.3358), (1.0, 0.4068),
312        (1.025, 0.4378), (1.05, 0.4476), (1.075, 0.4493), (1.10, 0.4477), (1.125, 0.4450),
313        (1.15, 0.4419), (1.20, 0.4353), (1.25, 0.4283), (1.30, 0.4208), (1.35, 0.4133),
314        (1.40, 0.4059), (1.45, 0.3986), (1.50, 0.3915), (1.55, 0.3845), (1.60, 0.3777),
315        (1.65, 0.3710), (1.70, 0.3645), (1.75, 0.3581), (1.80, 0.3519), (1.85, 0.3458),
316        (1.90, 0.3400), (1.95, 0.3343), (2.0, 0.3288), (2.05, 0.3234), (2.10, 0.3182),
317        (2.15, 0.3131), (2.20, 0.3081), (2.25, 0.3032), (2.30, 0.2983), (2.35, 0.2937),
318        (2.40, 0.2891), (2.45, 0.2845), (2.50, 0.2802), (2.60, 0.2720), (2.70, 0.2642),
319        (2.80, 0.2569), (2.90, 0.2499), (3.0, 0.2432), (3.10, 0.2368), (3.20, 0.2308),
320        (3.30, 0.2251), (3.40, 0.2197), (3.50, 0.2147), (3.60, 0.2101), (3.70, 0.2058),
321        (3.80, 0.2019), (3.90, 0.1983), (4.0, 0.1950), (4.20, 0.1890), (4.40, 0.1837),
322        (4.60, 0.1791), (4.80, 0.1750), (5.0, 0.1713),
323    ];
324
325    if let Some(drag_dir) = find_drag_tables_dir() {
326        load_drag_table(&drag_dir, "g8", &fallback_data)
327    } else {
328        // Use fallback data if directory not found
329        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
330        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
331        DragTable::new(mach_values, cd_values)
332    }
333});
334
335/// Get drag coefficient for given Mach number and drag model
336pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
337    match drag_model {
338        DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
339        DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
340        DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
341        DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
342        _ => G1_DRAG_TABLE.interpolate(mach), // Default to G1 for other models
343    }
344}
345
346/// Get drag coefficient with optional transonic corrections
347pub fn get_drag_coefficient_with_transonic(
348    mach: f64, 
349    drag_model: &DragModel,
350    apply_transonic_correction: bool,
351    projectile_shape: Option<ProjectileShape>,
352    caliber: Option<f64>,
353    weight_grains: Option<f64>,
354) -> f64 {
355    // Get base drag coefficient
356    let base_cd = get_drag_coefficient(mach, drag_model);
357    
358    // Apply transonic corrections if requested and in transonic regime
359    if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
360        // Determine projectile shape if not provided
361        let shape = match projectile_shape {
362            Some(s) => s,
363            None => {
364                if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
365                    get_projectile_shape(cal, weight, match drag_model {
366                        DragModel::G1 => "G1",
367                        DragModel::G6 => "G6",
368                        DragModel::G7 => "G7",
369                        DragModel::G8 => "G8",
370                        _ => "G1", // Default to G1
371                    })
372                } else {
373                    ProjectileShape::Spitzer // Default
374                }
375            }
376        };
377        
378        // Apply correction
379        transonic_correction(mach, base_cd, shape, true)
380    } else {
381        base_cd
382    }
383}
384
385/// Get drag coefficient with optional transonic and Reynolds corrections
386pub fn get_drag_coefficient_full(
387    mach: f64,
388    drag_model: &DragModel,
389    apply_transonic_correction: bool,
390    apply_reynolds_correction: bool,
391    projectile_shape: Option<ProjectileShape>,
392    caliber: Option<f64>,
393    weight_grains: Option<f64>,
394    velocity_mps: Option<f64>,
395    air_density_kg_m3: Option<f64>,
396    temperature_c: Option<f64>,
397) -> f64 {
398    // Get base drag coefficient with transonic corrections if applicable
399    let mut cd = get_drag_coefficient_with_transonic(
400        mach,
401        drag_model,
402        apply_transonic_correction,
403        projectile_shape,
404        caliber,
405        weight_grains,
406    );
407    
408    // Apply Reynolds corrections for low velocities (subsonic only)
409    if apply_reynolds_correction && mach < 1.0 {
410        if let (Some(v), Some(cal), Some(rho), Some(temp)) = 
411            (velocity_mps, caliber, air_density_kg_m3, temperature_c) {
412            use crate::reynolds::apply_reynolds_correction;
413            cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
414        }
415    }
416    
417    cd
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_g1_drag_coefficient_interpolation() {
426        let cd = get_drag_coefficient(1.0, &DragModel::G1);
427        // Should be close to the G1 standard value at Mach 1.0
428        assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
429    }
430
431    #[test]
432    fn test_g7_drag_coefficient_interpolation() {
433        let cd = get_drag_coefficient(1.0, &DragModel::G7);
434        // Should be close to the G7 standard value at Mach 1.0
435        assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
436    }
437
438    #[test]
439    fn test_drag_coefficient_continuity() {
440        // Test that drag coefficient function is smooth
441        for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
442            let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
443            let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
444            let difference = (cd_after - cd_before).abs();
445            assert!(difference < 0.05, "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}");
446        }
447    }
448
449    #[test]
450    fn test_extrapolation_bounds() {
451        // Test extrapolation below range
452        let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
453        assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
454        
455        // Test extrapolation above range - should be clamped to positive values
456        let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
457        assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
458        
459        // Same for G7
460        let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
461        assert!(cd_low_g7 > 0.01, "Low Mach G7 should be positive: {cd_low_g7}");
462        
463        let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
464        assert!(cd_high_g7 >= 0.01, "High Mach G7 should be positive: {cd_high_g7}");
465    }
466
467    #[test]
468    fn test_drag_table_creation() {
469        let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
470        let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
471        let table = DragTable::new(mach_vals, cd_vals);
472        
473        // Test exact interpolation
474        assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
475        
476        // Test interpolation between points
477        let cd_interp = table.interpolate(1.25);
478        assert!(cd_interp > 0.4 && cd_interp < 0.5);
479    }
480
481    #[test]
482    fn test_drag_table_empty() {
483        let table = DragTable::new(vec![], vec![]);
484        let result = table.interpolate(1.0);
485        assert_eq!(result, 0.5); // Should return fallback value
486    }
487
488    #[test]
489    fn test_drag_table_single_point() {
490        let table = DragTable::new(vec![1.0], vec![0.4]);
491        
492        // Should return the single value for any Mach
493        assert_eq!(table.interpolate(0.5), 0.4);
494        assert_eq!(table.interpolate(1.0), 0.4);
495        assert_eq!(table.interpolate(2.0), 0.4);
496    }
497
498    #[test]
499    fn test_drag_table_two_points() {
500        let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
501        
502        // Exact matches
503        assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
504        assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
505        
506        // Linear interpolation
507        let mid = table.interpolate(1.5);
508        assert!((mid - 0.5).abs() < 1e-10);
509        
510        // Extrapolation
511        let below = table.interpolate(0.5);
512        assert!(below.abs() > 1e-10); // Should have some value
513        
514        let above = table.interpolate(3.0);
515        assert!(above.abs() > 1e-10); // Should have some value
516    }
517
518    #[test]
519    fn test_linear_interpolation() {
520        let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
521        
522        // Test linear interpolation between first two points
523        let result = table.linear_interpolate(0.5, 0);
524        assert!((result - 0.35).abs() < 1e-10);
525        
526        // Test edge case with zero denominator
527        let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
528        let result_same = table_same.linear_interpolate(1.0, 0);
529        assert_eq!(result_same, 0.4); // Should return first value
530    }
531
532    #[test]
533    fn test_cubic_interpolation() {
534        // Create a table with enough points for cubic interpolation
535        let table = DragTable::new(
536            vec![0.5, 1.0, 1.5, 2.0, 2.5],
537            vec![0.2, 0.4, 0.6, 0.5, 0.3]
538        );
539        
540        // Test cubic interpolation in the middle
541        let result = table.cubic_interpolate(1.25, 1);
542        
543        // Should be between the neighboring values
544        assert!(result > 0.3 && result < 0.7);
545        
546        // Should be smooth (not exactly linear)
547        let linear_result = table.linear_interpolate(1.25, 1);
548        // Cubic and linear should be close but not identical for smooth curves
549        assert!((result - linear_result).abs() < 0.2);
550    }
551
552    #[test]
553    fn test_find_drag_tables_dir() {
554        // This test may pass or fail depending on the environment
555        // but should not panic
556        let _dir = find_drag_tables_dir();
557        // Just ensure the function doesn't panic
558    }
559
560    #[test]
561    fn test_load_drag_table_fallback() {
562        use std::path::Path;
563        
564        // Test with non-existent directory - should use fallback data
565        let fake_dir = Path::new("/non/existent/directory");
566        let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
567        
568        let table = load_drag_table(fake_dir, "test", &fallback_data);
569        
570        // Should have fallback data
571        assert_eq!(table.mach_values.len(), 3);
572        assert_eq!(table.cd_values.len(), 3);
573        assert_eq!(table.mach_values[0], 0.5);
574        assert_eq!(table.cd_values[0], 0.2);
575    }
576
577    #[test]
578    fn test_known_drag_values() {
579        // Test against known ballistic standard values
580        
581        // G1 at Mach 1.0 should be around 0.4805
582        let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
583        assert!((g1_mach1 - 0.4805).abs() < 0.01, "G1 at Mach 1.0: {g1_mach1}");
584        
585        // G7 at Mach 1.0 should be around 0.3803
586        let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
587        assert!((g7_mach1 - 0.3803).abs() < 0.01, "G7 at Mach 1.0: {g7_mach1}");
588        
589        // G1 should generally be higher than G7 in transonic region
590        assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
591    }
592
593    #[test]
594    fn test_monotonicity_properties() {
595        // Test general drag curve properties
596        
597        // G1 should peak somewhere in transonic region
598        let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); // 0.8 to 1.9
599        let g1_values: Vec<f64> = mach_values.iter()
600            .map(|&m| get_drag_coefficient(m, &DragModel::G1))
601            .collect();
602        
603        // Find maximum
604        let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
605        let max_index = g1_values.iter().position(|&x| x == max_value)
606            .expect("Should find maximum in non-empty vector");
607        let peak_mach = mach_values.get(max_index).copied()
608            .expect("Index should be valid");
609        
610        // Peak should be in reasonable range
611        assert!(peak_mach > 1.0 && peak_mach < 1.6, "G1 peak at Mach {peak_mach}");
612        assert!(max_value > 0.5 && max_value < 1.0, "G1 peak value: {max_value}");
613    }
614
615    #[test] 
616    fn test_physical_constraints() {
617        let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
618        
619        for &mach in &test_machs {
620            let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
621            let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
622            
623            // All drag coefficients should be positive
624            assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
625            assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
626            
627            // Should be in reasonable physical ranges
628            assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
629            assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
630        }
631    }
632
633    #[test]
634    fn test_performance_characteristics() {
635        // This test ensures the implementation is efficient
636        use std::time::Instant;
637        
638        let start = Instant::now();
639        
640        // Perform many calculations
641        for i in 0..1000 {
642            let mach = 0.5 + (i as f64) * 0.004; // 0.5 to 4.5
643            let _g1 = get_drag_coefficient(mach, &DragModel::G1);
644            let _g7 = get_drag_coefficient(mach, &DragModel::G7);
645        }
646        
647        let elapsed = start.elapsed();
648        
649        // Should complete 2000 calculations quickly (within 100ms)
650        assert!(elapsed.as_millis() < 100, "Performance test took {}ms", elapsed.as_millis());
651    }
652}
653
654/// Interpolate BC value for given Mach number from segments
655pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
656    if segments.is_empty() {
657        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
658    }
659    
660    // Get just the mach values
661    let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
662    
663    // Double-check we have values after collection
664    if mach_values.is_empty() || segments.is_empty() {
665        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
666    }
667    
668    // Handle edge cases with safe indexing
669    if let Some(first_mach) = mach_values.first() {
670        if mach <= *first_mach {
671            return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
672        }
673    }
674    
675    if let Some(last_mach) = mach_values.last() {
676        if mach >= *last_mach {
677            return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
678        }
679    }
680    
681    // Binary search to find the right segment with safe comparison
682    let idx = match mach_values.binary_search_by(|&m| {
683        m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal)
684    }) {
685        Ok(idx) => {
686            // Exact match - safely get the BC value
687            return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
688        }
689        Err(idx) => idx, // Insert position
690    };
691    
692    // Ensure idx is valid for interpolation
693    if idx == 0 || idx >= segments.len() {
694        // Shouldn't happen given the edge case checks above, but be defensive
695        // Use safe indexing
696        let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
697        return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
698    }
699    
700    // Linear interpolation between the two closest points with safe indexing
701    match (segments.get(idx - 1), segments.get(idx)) {
702        (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
703            // Ensure denominator is not zero for safe interpolation
704            let denominator = hi_mach - lo_mach;
705            if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
706                return *lo_bc; // Return lower BC if Mach values are too close
707            }
708            let frac = (mach - lo_mach) / denominator;
709            lo_bc + frac * (hi_bc - lo_bc)
710        }
711        _ => 0.5, // Fallback if indices are somehow invalid
712    }
713}
714
715// Removed Python-specific function