ballistics_engine/
drag.rs

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