Skip to main content

ballistics_engine/
drag.rs

1use crate::transonic_drag::{get_projectile_shape, transonic_correction, ProjectileShape};
2use crate::DragModel;
3use ndarray::ArrayD;
4use std::sync::LazyLock;
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. Binary search over the
62        // strictly-ascending mach axis; bit-identical to the previous linear scan
63        // (first segment [i, i+1] with m[i] <= mach <= m[i+1]) but O(log n).
64        let idx = self
65            .mach_values
66            .partition_point(|&m| m < mach)
67            .saturating_sub(1)
68            .min(n - 2);
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: LazyLock<DragTable> = LazyLock::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: LazyLock<DragTable> = LazyLock::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: LazyLock<DragTable> = LazyLock::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: LazyLock<DragTable> = LazyLock::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.
474///
475/// NOTE: only G1/G6/G7/G8 have dedicated tables. G2/G5/GI/GS currently fall back to the G1
476/// curve (no tables shipped yet), so callers requesting those models receive a G1
477/// approximation that is physically inaccurate (e.g. GS is the spherical/round-ball model).
478/// The fallback is made explicit below — rather than a silent `_` catch-all — so adding a new
479/// `DragModel` variant is a compile error until it is handled, and so the approximation is
480/// visible. Supplying real G2/G5/GI/GS tables is tracked separately.
481pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
482    match drag_model {
483        DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
484        DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
485        DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
486        DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
487        // No dedicated tables yet — approximate with the G1 curve (flagged, see note above).
488        DragModel::G2 | DragModel::G5 | DragModel::GI | DragModel::GS => {
489            G1_DRAG_TABLE.interpolate(mach)
490        }
491    }
492}
493
494/// Get drag coefficient with optional transonic corrections
495pub fn get_drag_coefficient_with_transonic(
496    mach: f64,
497    drag_model: &DragModel,
498    apply_transonic_correction: bool,
499    projectile_shape: Option<ProjectileShape>,
500    caliber: Option<f64>,
501    weight_grains: Option<f64>,
502) -> f64 {
503    // Get base drag coefficient
504    let base_cd = get_drag_coefficient(mach, drag_model);
505
506    // Apply transonic corrections if requested and in transonic regime
507    if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
508        // Determine projectile shape if not provided
509        let shape = match projectile_shape {
510            Some(s) => s,
511            None => {
512                if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
513                    get_projectile_shape(
514                        cal,
515                        weight,
516                        match drag_model {
517                            DragModel::G1 => "G1",
518                            DragModel::G6 => "G6",
519                            DragModel::G7 => "G7",
520                            DragModel::G8 => "G8",
521                            _ => "G1", // Default to G1
522                        },
523                    )
524                } else {
525                    ProjectileShape::Spitzer // Default
526                }
527            }
528        };
529
530        // Apply correction
531        transonic_correction(mach, base_cd, shape, true)
532    } else {
533        base_cd
534    }
535}
536
537/// Get drag coefficient with optional transonic and Reynolds corrections
538pub fn get_drag_coefficient_full(
539    mach: f64,
540    drag_model: &DragModel,
541    apply_transonic_correction: bool,
542    apply_reynolds_correction: bool,
543    projectile_shape: Option<ProjectileShape>,
544    caliber: Option<f64>,
545    weight_grains: Option<f64>,
546    velocity_mps: Option<f64>,
547    air_density_kg_m3: Option<f64>,
548    temperature_c: Option<f64>,
549) -> f64 {
550    // Get base drag coefficient with transonic corrections if applicable
551    let mut cd = get_drag_coefficient_with_transonic(
552        mach,
553        drag_model,
554        apply_transonic_correction,
555        projectile_shape,
556        caliber,
557        weight_grains,
558    );
559
560    // Apply Reynolds corrections for low velocities (subsonic only)
561    if apply_reynolds_correction && mach < 1.0 {
562        if let (Some(v), Some(cal), Some(rho), Some(temp)) =
563            (velocity_mps, caliber, air_density_kg_m3, temperature_c)
564        {
565            use crate::reynolds::apply_reynolds_correction;
566            cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
567        }
568    }
569
570    cd
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn test_g1_drag_coefficient_interpolation() {
579        let cd = get_drag_coefficient(1.0, &DragModel::G1);
580        // Should be close to the G1 standard value at Mach 1.0
581        assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
582    }
583
584    #[test]
585    fn test_g7_drag_coefficient_interpolation() {
586        let cd = get_drag_coefficient(1.0, &DragModel::G7);
587        // Should be close to the G7 standard value at Mach 1.0
588        assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
589    }
590
591    #[test]
592    fn test_drag_coefficient_continuity() {
593        // Test that drag coefficient function is smooth
594        for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
595            let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
596            let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
597            let difference = (cd_after - cd_before).abs();
598            assert!(
599                difference < 0.05,
600                "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
601            );
602        }
603    }
604
605    #[test]
606    fn test_extrapolation_bounds() {
607        // Test extrapolation below range
608        let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
609        assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
610
611        // Test extrapolation above range - should be clamped to positive values
612        let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
613        assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
614
615        // Same for G7
616        let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
617        assert!(
618            cd_low_g7 > 0.01,
619            "Low Mach G7 should be positive: {cd_low_g7}"
620        );
621
622        let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
623        assert!(
624            cd_high_g7 >= 0.01,
625            "High Mach G7 should be positive: {cd_high_g7}"
626        );
627    }
628
629    #[test]
630    fn test_drag_table_creation() {
631        let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
632        let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
633        let table = DragTable::new(mach_vals, cd_vals);
634
635        // Test exact interpolation
636        assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
637
638        // Test interpolation between points
639        let cd_interp = table.interpolate(1.25);
640        assert!(cd_interp > 0.4 && cd_interp < 0.5);
641    }
642
643    #[test]
644    fn test_drag_table_empty() {
645        let table = DragTable::new(vec![], vec![]);
646        let result = table.interpolate(1.0);
647        assert_eq!(result, 0.5); // Should return fallback value
648    }
649
650    #[test]
651    fn test_drag_table_single_point() {
652        let table = DragTable::new(vec![1.0], vec![0.4]);
653
654        // Should return the single value for any Mach
655        assert_eq!(table.interpolate(0.5), 0.4);
656        assert_eq!(table.interpolate(1.0), 0.4);
657        assert_eq!(table.interpolate(2.0), 0.4);
658    }
659
660    #[test]
661    fn test_drag_table_two_points() {
662        let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
663
664        // Exact matches
665        assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
666        assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
667
668        // Linear interpolation
669        let mid = table.interpolate(1.5);
670        assert!((mid - 0.5).abs() < 1e-10);
671
672        // Extrapolation
673        let below = table.interpolate(0.5);
674        assert!(below.abs() > 1e-10); // Should have some value
675
676        let above = table.interpolate(3.0);
677        assert!(above.abs() > 1e-10); // Should have some value
678    }
679
680    #[test]
681    fn test_linear_interpolation() {
682        let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
683
684        // Test linear interpolation between first two points
685        let result = table.linear_interpolate(0.5, 0);
686        assert!((result - 0.35).abs() < 1e-10);
687
688        // Test edge case with zero denominator
689        let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
690        let result_same = table_same.linear_interpolate(1.0, 0);
691        assert_eq!(result_same, 0.4); // Should return first value
692    }
693
694    #[test]
695    fn test_cubic_interpolation() {
696        // Create a table with enough points for cubic interpolation
697        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]);
698
699        // Test cubic interpolation in the middle
700        let result = table.cubic_interpolate(1.25, 1);
701
702        // Should be between the neighboring values
703        assert!(result > 0.3 && result < 0.7);
704
705        // Should be smooth (not exactly linear)
706        let linear_result = table.linear_interpolate(1.25, 1);
707        // Cubic and linear should be close but not identical for smooth curves
708        assert!((result - linear_result).abs() < 0.2);
709    }
710
711    #[test]
712    fn test_find_drag_tables_dir() {
713        // This test may pass or fail depending on the environment
714        // but should not panic
715        let _dir = find_drag_tables_dir();
716        // Just ensure the function doesn't panic
717    }
718
719    #[test]
720    fn test_load_drag_table_fallback() {
721        use std::path::Path;
722
723        // Test with non-existent directory - should use fallback data
724        let fake_dir = Path::new("/non/existent/directory");
725        let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
726
727        let table = load_drag_table(fake_dir, "test", &fallback_data);
728
729        // Should have fallback data
730        assert_eq!(table.mach_values.len(), 3);
731        assert_eq!(table.cd_values.len(), 3);
732        assert_eq!(table.mach_values[0], 0.5);
733        assert_eq!(table.cd_values[0], 0.2);
734    }
735
736    #[test]
737    fn test_known_drag_values() {
738        // Test against known ballistic standard values
739
740        // G1 at Mach 1.0 should be around 0.4805
741        let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
742        assert!(
743            (g1_mach1 - 0.4805).abs() < 0.01,
744            "G1 at Mach 1.0: {g1_mach1}"
745        );
746
747        // G7 at Mach 1.0 should be around 0.3803
748        let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
749        assert!(
750            (g7_mach1 - 0.3803).abs() < 0.01,
751            "G7 at Mach 1.0: {g7_mach1}"
752        );
753
754        // G1 should generally be higher than G7 in transonic region
755        assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
756    }
757
758    #[test]
759    fn test_monotonicity_properties() {
760        // Test general drag curve properties
761
762        // G1 should peak somewhere in transonic region
763        let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); // 0.8 to 1.9
764        let g1_values: Vec<f64> = mach_values
765            .iter()
766            .map(|&m| get_drag_coefficient(m, &DragModel::G1))
767            .collect();
768
769        // Find maximum
770        let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
771        let max_index = g1_values
772            .iter()
773            .position(|&x| x == max_value)
774            .expect("Should find maximum in non-empty vector");
775        let peak_mach = mach_values
776            .get(max_index)
777            .copied()
778            .expect("Index should be valid");
779
780        // Peak should be in reasonable range
781        assert!(
782            peak_mach > 1.0 && peak_mach < 1.6,
783            "G1 peak at Mach {peak_mach}"
784        );
785        assert!(
786            max_value > 0.5 && max_value < 1.0,
787            "G1 peak value: {max_value}"
788        );
789    }
790
791    #[test]
792    fn test_physical_constraints() {
793        let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
794
795        for &mach in &test_machs {
796            let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
797            let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
798
799            // All drag coefficients should be positive
800            assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
801            assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
802
803            // Should be in reasonable physical ranges
804            assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
805            assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
806        }
807    }
808
809    #[test]
810    fn test_performance_characteristics() {
811        // This test ensures the implementation is efficient
812        use std::time::Instant;
813
814        let start = Instant::now();
815
816        // Perform many calculations
817        for i in 0..1000 {
818            let mach = 0.5 + (i as f64) * 0.004; // 0.5 to 4.5
819            let _g1 = get_drag_coefficient(mach, &DragModel::G1);
820            let _g7 = get_drag_coefficient(mach, &DragModel::G7);
821        }
822
823        let elapsed = start.elapsed();
824
825        // Should complete 2000 calculations quickly (within 100ms)
826        assert!(
827            elapsed.as_millis() < 100,
828            "Performance test took {}ms",
829            elapsed.as_millis()
830        );
831    }
832}
833
834/// Interpolate BC value for given Mach number from segments
835pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
836    if segments.is_empty() {
837        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
838    }
839
840    // Get just the mach values
841    let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
842
843    // Double-check we have values after collection
844    if mach_values.is_empty() || segments.is_empty() {
845        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
846    }
847
848    // Handle edge cases with safe indexing
849    if let Some(first_mach) = mach_values.first() {
850        if mach <= *first_mach {
851            return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
852        }
853    }
854
855    if let Some(last_mach) = mach_values.last() {
856        if mach >= *last_mach {
857            return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
858        }
859    }
860
861    // Binary search to find the right segment with safe comparison
862    let idx = match mach_values
863        .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
864    {
865        Ok(idx) => {
866            // Exact match - safely get the BC value
867            return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
868        }
869        Err(idx) => idx, // Insert position
870    };
871
872    // Ensure idx is valid for interpolation
873    if idx == 0 || idx >= segments.len() {
874        // Shouldn't happen given the edge case checks above, but be defensive
875        // Use safe indexing
876        let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
877        return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
878    }
879
880    // Linear interpolation between the two closest points with safe indexing
881    match (segments.get(idx - 1), segments.get(idx)) {
882        (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
883            // Ensure denominator is not zero for safe interpolation
884            let denominator = hi_mach - lo_mach;
885            if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
886                return *lo_bc; // Return lower BC if Mach values are too close
887            }
888            let frac = (mach - lo_mach) / denominator;
889            lo_bc + frac * (hi_bc - lo_bc)
890        }
891        _ => 0.5, // Fallback if indices are somehow invalid
892    }
893}
894
895// Removed Python-specific function