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/// Parse an embedded CSV drag table (`mach,cd` per line, header tolerated). Used to bake the
213/// high-resolution G1/G7 tables (data/*.csv) into the binary so the engine never depends on a
214/// runtime `drag_tables/` directory existing. Falls back to the supplied coarse table only if
215/// parsing yields no points (the shipped data files always parse).
216fn parse_embedded_drag_table(csv: &str, fallback: &[(f64, f64)]) -> DragTable {
217    let mut mach_values = Vec::new();
218    let mut cd_values = Vec::new();
219    for line in csv.lines() {
220        let line = line.trim();
221        if line.is_empty() {
222            continue;
223        }
224        let mut cols = line.split(',');
225        if let (Some(m), Some(cd)) = (cols.next(), cols.next()) {
226            if let (Ok(m), Ok(cd)) = (m.trim().parse::<f64>(), cd.trim().parse::<f64>()) {
227                mach_values.push(m);
228                cd_values.push(cd);
229            }
230        }
231    }
232    if mach_values.is_empty() {
233        mach_values = fallback.iter().map(|(m, _)| *m).collect();
234        cd_values = fallback.iter().map(|(_, cd)| *cd).collect();
235    }
236    DragTable::new(mach_values, cd_values)
237}
238
239/// G1 drag table — high-resolution data baked in from data/g1.csv at compile time (MBA-939).
240/// The previous runtime loader searched for a `drag_tables/` directory that does not exist when
241/// the binary runs (the tables ship under data/), so the engine silently used the coarse 21-point
242/// fallback below, flattening the transonic drag rise. include_str! guarantees the full table.
243static G1_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
244    // Coarse 21-point fallback, retained only for the impossible parse-failure path.
245    let fallback_data = [
246        (0.0, 0.2629),
247        (0.5, 0.2695),
248        (0.6, 0.2752),
249        (0.7, 0.2817),
250        (0.8, 0.2902),
251        (0.9, 0.3012),
252        (1.0, 0.4805),
253        (1.1, 0.5933),
254        (1.2, 0.6318),
255        (1.3, 0.6440),
256        (1.4, 0.6444),
257        (1.5, 0.6372),
258        (1.6, 0.6252),
259        (1.7, 0.6105),
260        (1.8, 0.5956),
261        (1.9, 0.5815),
262        (2.0, 0.5934),
263        (2.5, 0.5598),
264        (3.0, 0.5133),
265        (4.0, 0.4811),
266        (5.0, 0.4988),
267    ];
268
269    parse_embedded_drag_table(include_str!("../data/g1.csv"), &fallback_data)
270});
271
272/// G7 drag table — high-resolution data baked in from data/g7.csv at compile time (MBA-939).
273/// Same root cause as G1: the runtime `drag_tables/` loader never resolved, so the coarse
274/// 21-point fallback was used, missing the Mach 0.9->1.0 transonic knee (the embedded 0.9 point
275/// was even wrong: 0.1294 vs the true 0.1464). include_str! bakes in the full 84-point table.
276static G7_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
277    // Coarse 21-point fallback, retained only for the impossible parse-failure path.
278    let fallback_data = [
279        (0.0, 0.1198),
280        (0.5, 0.1197),
281        (0.6, 0.1202),
282        (0.7, 0.1213),
283        (0.8, 0.1240),
284        (0.9, 0.1294),
285        (1.0, 0.3803),
286        (1.1, 0.4015),
287        (1.2, 0.4043),
288        (1.3, 0.3956),
289        (1.4, 0.3814),
290        (1.5, 0.3663),
291        (1.6, 0.3520),
292        (1.7, 0.3398),
293        (1.8, 0.3297),
294        (1.9, 0.3221),
295        (2.0, 0.2980),
296        (2.5, 0.2731),
297        (3.0, 0.2424),
298        (4.0, 0.2196),
299        (5.0, 0.1618),
300    ];
301
302    parse_embedded_drag_table(include_str!("../data/g7.csv"), &fallback_data)
303});
304
305/// G6 drag table - flat-base with 6 caliber secant ogive (military FMJ bullets)
306/// MBA-156: Added for completeness with ballistics_rust
307static G6_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
308    let fallback_data = [
309        (0.0, 0.2617),
310        (0.05, 0.2553),
311        (0.10, 0.2491),
312        (0.15, 0.2432),
313        (0.20, 0.2376),
314        (0.25, 0.2324),
315        (0.30, 0.2278),
316        (0.35, 0.2238),
317        (0.40, 0.2205),
318        (0.45, 0.2177),
319        (0.50, 0.2155),
320        (0.55, 0.2138),
321        (0.60, 0.2126),
322        (0.65, 0.2121),
323        (0.70, 0.2122),
324        (0.75, 0.2132),
325        (0.80, 0.2154),
326        (0.85, 0.2194),
327        (0.875, 0.2229),
328        (0.90, 0.2297),
329        (0.925, 0.2449),
330        (0.95, 0.2732),
331        (0.975, 0.3141),
332        (1.0, 0.3597),
333        (1.025, 0.3994),
334        (1.05, 0.4261),
335        (1.075, 0.4402),
336        (1.10, 0.4465),
337        (1.125, 0.4490),
338        (1.15, 0.4497),
339        (1.175, 0.4494),
340        (1.20, 0.4482),
341        (1.225, 0.4464),
342        (1.25, 0.4441),
343        (1.30, 0.4390),
344        (1.35, 0.4336),
345        (1.40, 0.4279),
346        (1.45, 0.4221),
347        (1.50, 0.4162),
348        (1.55, 0.4102),
349        (1.60, 0.4042),
350        (1.65, 0.3981),
351        (1.70, 0.3919),
352        (1.75, 0.3855),
353        (1.80, 0.3788),
354        (1.85, 0.3721),
355        (1.90, 0.3652),
356        (1.95, 0.3583),
357        (2.0, 0.3515),
358        (2.05, 0.3447),
359        (2.10, 0.3381),
360        (2.15, 0.3314),
361        (2.20, 0.3249),
362        (2.25, 0.3185),
363        (2.30, 0.3122),
364        (2.35, 0.3060),
365        (2.40, 0.3000),
366        (2.45, 0.2941),
367        (2.50, 0.2883),
368        (2.60, 0.2772),
369        (2.70, 0.2668),
370        (2.80, 0.2574),
371        (2.90, 0.2487),
372        (3.0, 0.2407),
373        (3.10, 0.2333),
374        (3.20, 0.2265),
375        (3.30, 0.2202),
376        (3.40, 0.2144),
377        (3.50, 0.2089),
378        (3.60, 0.2039),
379        (3.70, 0.1991),
380        (3.80, 0.1947),
381        (3.90, 0.1905),
382        (4.0, 0.1866),
383        (4.20, 0.1794),
384        (4.40, 0.1730),
385        (4.60, 0.1673),
386        (4.80, 0.1621),
387        (5.0, 0.1574),
388    ];
389
390    if let Some(drag_dir) = find_drag_tables_dir() {
391        load_drag_table(&drag_dir, "g6", &fallback_data)
392    } else {
393        // Use fallback data if directory not found
394        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
395        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
396        DragTable::new(mach_values, cd_values)
397    }
398});
399
400/// G8 drag table - flat-base with 10 caliber secant ogive
401/// MBA-156: Added for completeness with ballistics_rust
402static G8_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
403    let fallback_data = [
404        (0.0, 0.2105),
405        (0.05, 0.2105),
406        (0.10, 0.2104),
407        (0.15, 0.2104),
408        (0.20, 0.2103),
409        (0.25, 0.2103),
410        (0.30, 0.2103),
411        (0.35, 0.2103),
412        (0.40, 0.2103),
413        (0.45, 0.2102),
414        (0.50, 0.2102),
415        (0.55, 0.2102),
416        (0.60, 0.2102),
417        (0.65, 0.2102),
418        (0.70, 0.2103),
419        (0.75, 0.2103),
420        (0.80, 0.2104),
421        (0.825, 0.2104),
422        (0.85, 0.2105),
423        (0.875, 0.2106),
424        (0.90, 0.2109),
425        (0.925, 0.2183),
426        (0.95, 0.2571),
427        (0.975, 0.3358),
428        (1.0, 0.4068),
429        (1.025, 0.4378),
430        (1.05, 0.4476),
431        (1.075, 0.4493),
432        (1.10, 0.4477),
433        (1.125, 0.4450),
434        (1.15, 0.4419),
435        (1.20, 0.4353),
436        (1.25, 0.4283),
437        (1.30, 0.4208),
438        (1.35, 0.4133),
439        (1.40, 0.4059),
440        (1.45, 0.3986),
441        (1.50, 0.3915),
442        (1.55, 0.3845),
443        (1.60, 0.3777),
444        (1.65, 0.3710),
445        (1.70, 0.3645),
446        (1.75, 0.3581),
447        (1.80, 0.3519),
448        (1.85, 0.3458),
449        (1.90, 0.3400),
450        (1.95, 0.3343),
451        (2.0, 0.3288),
452        (2.05, 0.3234),
453        (2.10, 0.3182),
454        (2.15, 0.3131),
455        (2.20, 0.3081),
456        (2.25, 0.3032),
457        (2.30, 0.2983),
458        (2.35, 0.2937),
459        (2.40, 0.2891),
460        (2.45, 0.2845),
461        (2.50, 0.2802),
462        (2.60, 0.2720),
463        (2.70, 0.2642),
464        (2.80, 0.2569),
465        (2.90, 0.2499),
466        (3.0, 0.2432),
467        (3.10, 0.2368),
468        (3.20, 0.2308),
469        (3.30, 0.2251),
470        (3.40, 0.2197),
471        (3.50, 0.2147),
472        (3.60, 0.2101),
473        (3.70, 0.2058),
474        (3.80, 0.2019),
475        (3.90, 0.1983),
476        (4.0, 0.1950),
477        (4.20, 0.1890),
478        (4.40, 0.1837),
479        (4.60, 0.1791),
480        (4.80, 0.1750),
481        (5.0, 0.1713),
482    ];
483
484    if let Some(drag_dir) = find_drag_tables_dir() {
485        load_drag_table(&drag_dir, "g8", &fallback_data)
486    } else {
487        // Use fallback data if directory not found
488        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
489        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
490        DragTable::new(mach_values, cd_values)
491    }
492});
493
494/// Get drag coefficient for given Mach number and drag model.
495///
496/// NOTE: only G1/G6/G7/G8 have dedicated tables. G2/G5/GI/GS currently fall back to the G1
497/// curve (no tables shipped yet), so callers requesting those models receive a G1
498/// approximation that is physically inaccurate (e.g. GS is the spherical/round-ball model).
499/// The fallback is made explicit below — rather than a silent `_` catch-all — so adding a new
500/// `DragModel` variant is a compile error until it is handled, and so the approximation is
501/// visible. Supplying real G2/G5/GI/GS tables is tracked separately.
502pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
503    match drag_model {
504        DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
505        DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
506        DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
507        DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
508        // No dedicated tables yet — approximate with the G1 curve (flagged, see note above).
509        DragModel::G2 | DragModel::G5 | DragModel::GI | DragModel::GS => {
510            G1_DRAG_TABLE.interpolate(mach)
511        }
512    }
513}
514
515/// Get drag coefficient with optional transonic corrections
516pub fn get_drag_coefficient_with_transonic(
517    mach: f64,
518    drag_model: &DragModel,
519    apply_transonic_correction: bool,
520    projectile_shape: Option<ProjectileShape>,
521    caliber: Option<f64>,
522    weight_grains: Option<f64>,
523) -> f64 {
524    // Get base drag coefficient
525    let base_cd = get_drag_coefficient(mach, drag_model);
526
527    // Apply transonic corrections if requested and in transonic regime
528    if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
529        // Determine projectile shape if not provided
530        let shape = match projectile_shape {
531            Some(s) => s,
532            None => {
533                if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
534                    get_projectile_shape(
535                        cal,
536                        weight,
537                        match drag_model {
538                            DragModel::G1 => "G1",
539                            DragModel::G6 => "G6",
540                            DragModel::G7 => "G7",
541                            DragModel::G8 => "G8",
542                            _ => "G1", // Default to G1
543                        },
544                    )
545                } else {
546                    ProjectileShape::Spitzer // Default
547                }
548            }
549        };
550
551        // Apply correction
552        transonic_correction(mach, base_cd, shape, true)
553    } else {
554        base_cd
555    }
556}
557
558/// Get drag coefficient with optional transonic and Reynolds corrections
559pub fn get_drag_coefficient_full(
560    mach: f64,
561    drag_model: &DragModel,
562    apply_transonic_correction: bool,
563    apply_reynolds_correction: bool,
564    projectile_shape: Option<ProjectileShape>,
565    caliber: Option<f64>,
566    weight_grains: Option<f64>,
567    velocity_mps: Option<f64>,
568    air_density_kg_m3: Option<f64>,
569    temperature_c: Option<f64>,
570) -> f64 {
571    // Get base drag coefficient with transonic corrections if applicable
572    let mut cd = get_drag_coefficient_with_transonic(
573        mach,
574        drag_model,
575        apply_transonic_correction,
576        projectile_shape,
577        caliber,
578        weight_grains,
579    );
580
581    // Apply Reynolds corrections for low velocities (subsonic only)
582    if apply_reynolds_correction && mach < 1.0 {
583        if let (Some(v), Some(cal), Some(rho), Some(temp)) =
584            (velocity_mps, caliber, air_density_kg_m3, temperature_c)
585        {
586            use crate::reynolds::apply_reynolds_correction;
587            cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
588        }
589    }
590
591    cd
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_g1_drag_coefficient_interpolation() {
600        let cd = get_drag_coefficient(1.0, &DragModel::G1);
601        // Should be close to the G1 standard value at Mach 1.0
602        assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
603    }
604
605    #[test]
606    fn test_g7_drag_coefficient_interpolation() {
607        let cd = get_drag_coefficient(1.0, &DragModel::G7);
608        // Should be close to the G7 standard value at Mach 1.0
609        assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
610    }
611
612    #[test]
613    fn test_drag_coefficient_continuity() {
614        // Test that drag coefficient function is smooth
615        for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
616            let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
617            let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
618            let difference = (cd_after - cd_before).abs();
619            assert!(
620                difference < 0.05,
621                "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
622            );
623        }
624    }
625
626    #[test]
627    fn test_extrapolation_bounds() {
628        // Test extrapolation below range
629        let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
630        assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
631
632        // Test extrapolation above range - should be clamped to positive values
633        let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
634        assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
635
636        // Same for G7
637        let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
638        assert!(
639            cd_low_g7 > 0.01,
640            "Low Mach G7 should be positive: {cd_low_g7}"
641        );
642
643        let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
644        assert!(
645            cd_high_g7 >= 0.01,
646            "High Mach G7 should be positive: {cd_high_g7}"
647        );
648    }
649
650    #[test]
651    fn test_drag_table_creation() {
652        let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
653        let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
654        let table = DragTable::new(mach_vals, cd_vals);
655
656        // Test exact interpolation
657        assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
658
659        // Test interpolation between points
660        let cd_interp = table.interpolate(1.25);
661        assert!(cd_interp > 0.4 && cd_interp < 0.5);
662    }
663
664    #[test]
665    fn test_drag_table_empty() {
666        let table = DragTable::new(vec![], vec![]);
667        let result = table.interpolate(1.0);
668        assert_eq!(result, 0.5); // Should return fallback value
669    }
670
671    #[test]
672    fn test_drag_table_single_point() {
673        let table = DragTable::new(vec![1.0], vec![0.4]);
674
675        // Should return the single value for any Mach
676        assert_eq!(table.interpolate(0.5), 0.4);
677        assert_eq!(table.interpolate(1.0), 0.4);
678        assert_eq!(table.interpolate(2.0), 0.4);
679    }
680
681    #[test]
682    fn test_drag_table_two_points() {
683        let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
684
685        // Exact matches
686        assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
687        assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
688
689        // Linear interpolation
690        let mid = table.interpolate(1.5);
691        assert!((mid - 0.5).abs() < 1e-10);
692
693        // Extrapolation
694        let below = table.interpolate(0.5);
695        assert!(below.abs() > 1e-10); // Should have some value
696
697        let above = table.interpolate(3.0);
698        assert!(above.abs() > 1e-10); // Should have some value
699    }
700
701    #[test]
702    fn test_linear_interpolation() {
703        let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
704
705        // Test linear interpolation between first two points
706        let result = table.linear_interpolate(0.5, 0);
707        assert!((result - 0.35).abs() < 1e-10);
708
709        // Test edge case with zero denominator
710        let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
711        let result_same = table_same.linear_interpolate(1.0, 0);
712        assert_eq!(result_same, 0.4); // Should return first value
713    }
714
715    #[test]
716    fn test_cubic_interpolation() {
717        // Create a table with enough points for cubic interpolation
718        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]);
719
720        // Test cubic interpolation in the middle
721        let result = table.cubic_interpolate(1.25, 1);
722
723        // Should be between the neighboring values
724        assert!(result > 0.3 && result < 0.7);
725
726        // Should be smooth (not exactly linear)
727        let linear_result = table.linear_interpolate(1.25, 1);
728        // Cubic and linear should be close but not identical for smooth curves
729        assert!((result - linear_result).abs() < 0.2);
730    }
731
732    #[test]
733    fn test_find_drag_tables_dir() {
734        // This test may pass or fail depending on the environment
735        // but should not panic
736        let _dir = find_drag_tables_dir();
737        // Just ensure the function doesn't panic
738    }
739
740    #[test]
741    fn test_load_drag_table_fallback() {
742        use std::path::Path;
743
744        // Test with non-existent directory - should use fallback data
745        let fake_dir = Path::new("/non/existent/directory");
746        let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
747
748        let table = load_drag_table(fake_dir, "test", &fallback_data);
749
750        // Should have fallback data
751        assert_eq!(table.mach_values.len(), 3);
752        assert_eq!(table.cd_values.len(), 3);
753        assert_eq!(table.mach_values[0], 0.5);
754        assert_eq!(table.cd_values[0], 0.2);
755    }
756
757    #[test]
758    fn test_known_drag_values() {
759        // Test against known ballistic standard values
760
761        // G1 at Mach 1.0 should be around 0.4805
762        let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
763        assert!(
764            (g1_mach1 - 0.4805).abs() < 0.01,
765            "G1 at Mach 1.0: {g1_mach1}"
766        );
767
768        // G7 at Mach 1.0 should be around 0.3803
769        let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
770        assert!(
771            (g7_mach1 - 0.3803).abs() < 0.01,
772            "G7 at Mach 1.0: {g7_mach1}"
773        );
774
775        // G1 should generally be higher than G7 in transonic region
776        assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
777    }
778
779    #[test]
780    fn test_monotonicity_properties() {
781        // Test general drag curve properties
782
783        // G1 should peak somewhere in transonic region
784        let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); // 0.8 to 1.9
785        let g1_values: Vec<f64> = mach_values
786            .iter()
787            .map(|&m| get_drag_coefficient(m, &DragModel::G1))
788            .collect();
789
790        // Find maximum
791        let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
792        let max_index = g1_values
793            .iter()
794            .position(|&x| x == max_value)
795            .expect("Should find maximum in non-empty vector");
796        let peak_mach = mach_values
797            .get(max_index)
798            .copied()
799            .expect("Index should be valid");
800
801        // Peak should be in reasonable range
802        assert!(
803            peak_mach > 1.0 && peak_mach < 1.6,
804            "G1 peak at Mach {peak_mach}"
805        );
806        assert!(
807            max_value > 0.5 && max_value < 1.0,
808            "G1 peak value: {max_value}"
809        );
810    }
811
812    #[test]
813    fn test_physical_constraints() {
814        let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
815
816        for &mach in &test_machs {
817            let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
818            let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
819
820            // All drag coefficients should be positive
821            assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
822            assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
823
824            // Should be in reasonable physical ranges
825            assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
826            assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
827        }
828    }
829
830    #[test]
831    fn test_performance_characteristics() {
832        // This test ensures the implementation is efficient
833        use std::time::Instant;
834
835        let start = Instant::now();
836
837        // Perform many calculations
838        for i in 0..1000 {
839            let mach = 0.5 + (i as f64) * 0.004; // 0.5 to 4.5
840            let _g1 = get_drag_coefficient(mach, &DragModel::G1);
841            let _g7 = get_drag_coefficient(mach, &DragModel::G7);
842        }
843
844        let elapsed = start.elapsed();
845
846        // Should complete 2000 calculations quickly (within 100ms)
847        assert!(
848            elapsed.as_millis() < 100,
849            "Performance test took {}ms",
850            elapsed.as_millis()
851        );
852    }
853}
854
855/// Interpolate BC value for given Mach number from segments
856pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
857    if segments.is_empty() {
858        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
859    }
860
861    // Get just the mach values
862    let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
863
864    // Double-check we have values after collection
865    if mach_values.is_empty() || segments.is_empty() {
866        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
867    }
868
869    // Handle edge cases with safe indexing
870    if let Some(first_mach) = mach_values.first() {
871        if mach <= *first_mach {
872            return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
873        }
874    }
875
876    if let Some(last_mach) = mach_values.last() {
877        if mach >= *last_mach {
878            return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
879        }
880    }
881
882    // Binary search to find the right segment with safe comparison
883    let idx = match mach_values
884        .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
885    {
886        Ok(idx) => {
887            // Exact match - safely get the BC value
888            return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
889        }
890        Err(idx) => idx, // Insert position
891    };
892
893    // Ensure idx is valid for interpolation
894    if idx == 0 || idx >= segments.len() {
895        // Shouldn't happen given the edge case checks above, but be defensive
896        // Use safe indexing
897        let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
898        return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
899    }
900
901    // Linear interpolation between the two closest points with safe indexing
902    match (segments.get(idx - 1), segments.get(idx)) {
903        (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
904            // Ensure denominator is not zero for safe interpolation
905            let denominator = hi_mach - lo_mach;
906            if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
907                return *lo_bc; // Return lower BC if Mach values are too close
908            }
909            let frac = (mach - lo_mach) / denominator;
910            lo_bc + frac * (hi_bc - lo_bc)
911        }
912        _ => 0.5, // Fallback if indices are somehow invalid
913    }
914}
915
916// Removed Python-specific function