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    /// Validated constructor for user-supplied drag decks. Enforces: equal-length axes,
25    /// at least 2 points, strictly-ascending finite non-negative Mach, and finite positive Cd.
26    /// Returns a descriptive, 1-based-row error string on failure (never panics).
27    pub fn try_new(mach_values: Vec<f64>, cd_values: Vec<f64>) -> Result<Self, String> {
28        if mach_values.len() != cd_values.len() {
29            return Err(format!(
30                "drag table has {} Mach values but {} Cd values; the columns must be equal length",
31                mach_values.len(),
32                cd_values.len()
33            ));
34        }
35        if mach_values.len() < 2 {
36            return Err(format!(
37                "drag table needs at least 2 points, got {}",
38                mach_values.len()
39            ));
40        }
41        for (i, &m) in mach_values.iter().enumerate() {
42            if !m.is_finite() || m < 0.0 {
43                return Err(format!(
44                    "drag table Mach at row {} must be finite and >= 0, got {m}",
45                    i + 1
46                ));
47            }
48            if i > 0 && m <= mach_values[i - 1] {
49                return Err(format!(
50                    "drag table Mach must strictly ascend; row {} ({m}) <= row {} ({})",
51                    i + 1,
52                    i,
53                    mach_values[i - 1]
54                ));
55            }
56        }
57        for (i, &cd) in cd_values.iter().enumerate() {
58            if !cd.is_finite() || cd <= 0.0 {
59                return Err(format!(
60                    "drag table Cd at row {} must be finite and > 0, got {cd}",
61                    i + 1
62                ));
63            }
64        }
65        Ok(Self { mach_values, cd_values })
66    }
67
68    /// Parse a user drag deck from CSV text: two columns `mach,cd` per line. Blank lines and
69    /// lines starting with `#` are ignored; a single leading header row is skipped once, but only
70    /// when its first column is not itself a valid number (e.g. `mach,cd`) — a first row whose
71    /// first column does parse as a float (e.g. `0.5` or `0.5,O.2`) is data, not a header, so a
72    /// missing/invalid second column there is a hard error, not a silent skip. Any unparseable
73    /// row is a hard error citing its 1-based line number. Values are validated via `try_new`.
74    pub fn from_csv_str(csv: &str) -> Result<Self, String> {
75        let mut mach_values = Vec::new();
76        let mut cd_values = Vec::new();
77        let mut header_skipped = false;
78        for (lineno, raw) in csv.lines().enumerate() {
79            let line = raw.trim();
80            if line.is_empty() || line.starts_with('#') {
81                continue;
82            }
83            let mut cols = line.split(',');
84            let m = cols.next().map(str::trim);
85            let cd = cols.next().map(str::trim);
86            let m_parsed = m.and_then(|s| s.parse::<f64>().ok());
87            match (m_parsed, cd.and_then(|s| s.parse::<f64>().ok())) {
88                (Some(m), Some(cd)) => {
89                    mach_values.push(m);
90                    cd_values.push(cd);
91                }
92                _ => {
93                    if !header_skipped && mach_values.is_empty() && m_parsed.is_none() {
94                        // Tolerate one leading header row (e.g. "mach,cd") — but only when its
95                        // first column gives no numeric evidence of being a data row. A row whose
96                        // first column *does* parse (e.g. "0.5" or "0.5,O.2") is malformed data,
97                        // not a header, and must error rather than be silently discarded.
98                        header_skipped = true;
99                        continue;
100                    }
101                    return Err(format!(
102                        "drag table CSV: could not parse two numbers from line {}: {:?}",
103                        lineno + 1,
104                        raw
105                    ));
106                }
107            }
108        }
109        if mach_values.is_empty() {
110            return Err("drag table CSV contained no data rows".to_string());
111        }
112        Self::try_new(mach_values, cd_values)
113    }
114
115    /// Load and validate a user drag deck from a CSV file path.
116    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
117        let path = path.as_ref();
118        let text = std::fs::read_to_string(path)
119            .map_err(|e| format!("could not read drag table {}: {e}", path.display()))?;
120        Self::from_csv_str(&text)
121    }
122
123    /// Interpolate drag coefficient for a Mach number, holding the nearest tabulated endpoint
124    /// outside the table's measured domain.
125    pub fn interpolate(&self, mach: f64) -> f64 {
126        let n = self.mach_values.len();
127
128        if n == 0 {
129            return 0.5; // Fallback
130        }
131
132        if n == 1 {
133            return self.cd_values.first().copied().unwrap_or(0.5);
134        }
135
136        // A table has no information beyond its measured Mach domain. Hold the nearest endpoint
137        // rather than extending the local edge slope indefinitely (which can drive Cd to 0.01).
138        if mach <= self.mach_values[0] {
139            return self.cd_values.first().copied().unwrap_or(0.5);
140        }
141
142        if mach >= self.mach_values[n - 1] {
143            // Guard against a caller-built mismatched table (`new` is infallible): index the Cd
144            // axis defensively rather than trusting the Mach-derived length.
145            return self.cd_values.get(n - 1).copied()
146                .or_else(|| self.cd_values.last().copied())
147                .unwrap_or(0.5);
148        }
149
150        // Find the segment containing the mach value. Binary search over the
151        // strictly-ascending mach axis; bit-identical to the previous linear scan
152        // (first segment [i, i+1] with m[i] <= mach <= m[i+1]) but O(log n).
153        let idx = self
154            .mach_values
155            .partition_point(|&m| m < mach)
156            .saturating_sub(1)
157            .min(n - 2);
158
159        // Use cubic interpolation if we have enough points, otherwise linear
160        if idx > 0 && idx < n - 2 {
161            // Cubic interpolation using 4 points
162            self.cubic_interpolate(mach, idx)
163        } else {
164            // Linear interpolation for edge cases
165            self.linear_interpolate(mach, idx)
166        }
167    }
168
169    /// Linear interpolation between two points
170    pub fn linear_interpolate(&self, mach: f64, idx: usize) -> f64 {
171        // Bounds check
172        if idx + 1 >= self.mach_values.len() || idx + 1 >= self.cd_values.len() {
173            return self.cd_values.get(idx).copied().unwrap_or(0.5);
174        }
175
176        let x0 = self.mach_values[idx];
177        let x1 = self.mach_values[idx + 1];
178        let y0 = self.cd_values[idx];
179        let y1 = self.cd_values[idx + 1];
180
181        if (x1 - x0).abs() < crate::constants::MIN_DIVISION_THRESHOLD {
182            return y0;
183        }
184
185        let t = (mach - x0) / (x1 - x0);
186        y0 + t * (y1 - y0)
187    }
188
189    /// Cubic Hermite interpolation using four points and centered chord-slope tangents.
190    pub fn cubic_interpolate(&self, mach: f64, idx: usize) -> f64 {
191        // Ensure we have enough points for cubic interpolation
192        if idx == 0 || idx + 1 >= self.mach_values.len() || idx + 1 >= self.cd_values.len() {
193            // Fall back to linear interpolation if not enough points
194            return self.linear_interpolate(mach, idx);
195        }
196
197        // Use points at idx-1, idx, idx+1, idx+2
198        let x = [
199            self.mach_values[idx - 1],
200            self.mach_values[idx],
201            self.mach_values[idx + 1],
202            if idx + 2 < self.mach_values.len() {
203                self.mach_values[idx + 2]
204            } else {
205                self.mach_values[idx + 1]
206            },
207        ];
208        let y = [
209            self.cd_values[idx - 1],
210            self.cd_values[idx],
211            self.cd_values[idx + 1],
212            if idx + 2 < self.cd_values.len() {
213                self.cd_values[idx + 2]
214            } else {
215                self.cd_values[idx + 1]
216            },
217        ];
218
219        // Scale centered chord-slope tangents by this segment's actual width. This Hermite
220        // construction remains C1 across non-uniform knots; using the fixed uniform Catmull-Rom
221        // coefficient matrix here bends even affine data when adjacent Mach intervals differ.
222        let segment_width = x[2] - x[1];
223        let left_chord_width = x[2] - x[0];
224        let right_chord_width = x[3] - x[1];
225        if segment_width.abs() < crate::constants::MIN_DIVISION_THRESHOLD
226            || left_chord_width.abs() < crate::constants::MIN_DIVISION_THRESHOLD
227            || right_chord_width.abs() < crate::constants::MIN_DIVISION_THRESHOLD
228        {
229            return self.linear_interpolate(mach, idx);
230        }
231        let t = (mach - x[1]) / segment_width;
232        let t2 = t * t;
233        let t3 = t2 * t;
234
235        let tangent1 = segment_width * (y[2] - y[0]) / left_chord_width;
236        let tangent2 = segment_width * (y[3] - y[1]) / right_chord_width;
237        let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
238        let h10 = t3 - 2.0 * t2 + t;
239        let h01 = -2.0 * t3 + 3.0 * t2;
240        let h11 = t3 - t2;
241
242        h00 * y[1] + h10 * tangent1 + h01 * y[2] + h11 * tangent2
243    }
244}
245
246/// Load drag table from NumPy binary file or CSV fallback
247pub fn load_drag_table(
248    drag_tables_dir: &Path,
249    filename: &str,
250    fallback_data: &[(f64, f64)],
251) -> DragTable {
252    // Try to load NumPy binary file first
253    let npy_path = drag_tables_dir.join(format!("{filename}.npy"));
254    if let Ok(array) = ndarray_npy::read_npy::<_, ArrayD<f64>>(&npy_path) {
255        if let Ok(array_2d) = array.into_dimensionality::<ndarray::Ix2>() {
256            let mach_values: Vec<f64> = array_2d.column(0).to_vec();
257            let cd_values: Vec<f64> = array_2d.column(1).to_vec();
258            return DragTable::new(mach_values, cd_values);
259        }
260    }
261
262    // Fallback to CSV file. Hand-parsed (MBA-1331: the `csv` crate is now a
263    // cli-feature dependency, and this was its only lib call site): any line whose
264    // first two comma-separated fields parse as f64 is a data row, everything else
265    // (headers, blanks, comments) is skipped. NOTE the old csv::Reader consumed the
266    // FIRST row as headers unconditionally, so a headerless file silently lost its
267    // first data point — parse-based skipping keeps that point.
268    let csv_path = drag_tables_dir.join(format!("{filename}.csv"));
269    if let Ok(bytes) = std::fs::read(&csv_path) {
270        // Lossy UTF-8 (a stray Latin-1 byte in a header comment must not reject the
271        // whole file) and bare-CR tolerance — the old csv::Reader accepted both.
272        let text = String::from_utf8_lossy(&bytes).replace('\r', "\n");
273        let mut mach_values = Vec::new();
274        let mut cd_values = Vec::new();
275
276        for line in text.lines() {
277            let mut fields = line.split(',');
278            if let (Some(m_str), Some(cd_str)) = (fields.next(), fields.next()) {
279                // trim_matches('"'): the old csv::Reader unquoted "1.05","0.42"-style
280                // rows (Excel exports); keep accepting them.
281                if let (Ok(mach), Ok(cd)) = (
282                    m_str.trim().trim_matches('"').trim().parse::<f64>(),
283                    cd_str.trim().trim_matches('"').trim().parse::<f64>(),
284                ) {
285                    mach_values.push(mach);
286                    cd_values.push(cd);
287                }
288            }
289        }
290
291        if !mach_values.is_empty() {
292            return DragTable::new(mach_values, cd_values);
293        }
294    }
295
296    // Use fallback data if both file loading methods fail
297    let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
298    let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
299    DragTable::new(mach_values, cd_values)
300}
301
302/// Find the drag tables directory relative to the current location
303fn find_drag_tables_dir() -> Option<std::path::PathBuf> {
304    // Try common relative paths from the Rust crate location
305    let candidates = [
306        "../drag_tables",
307        "../../drag_tables",
308        "../../../drag_tables",
309        "drag_tables",
310    ];
311
312    for candidate in &candidates {
313        let path = Path::new(candidate);
314        if path.exists() && path.is_dir() {
315            return Some(path.to_path_buf());
316        }
317    }
318
319    None
320}
321
322/// Parse an embedded CSV drag table (`mach,cd` per line, header tolerated). Used to bake the
323/// high-resolution G1/G7 tables (data/*.csv) into the binary so the engine never depends on a
324/// runtime `drag_tables/` directory existing. Falls back to the supplied coarse table only if
325/// parsing yields no points (the shipped data files always parse).
326fn parse_embedded_drag_table(csv: &str, fallback: &[(f64, f64)]) -> DragTable {
327    let mut mach_values = Vec::new();
328    let mut cd_values = Vec::new();
329    for line in csv.lines() {
330        let line = line.trim();
331        if line.is_empty() {
332            continue;
333        }
334        let mut cols = line.split(',');
335        if let (Some(m), Some(cd)) = (cols.next(), cols.next()) {
336            if let (Ok(m), Ok(cd)) = (m.trim().parse::<f64>(), cd.trim().parse::<f64>()) {
337                mach_values.push(m);
338                cd_values.push(cd);
339            }
340        }
341    }
342    if mach_values.is_empty() {
343        mach_values = fallback.iter().map(|(m, _)| *m).collect();
344        cd_values = fallback.iter().map(|(_, cd)| *cd).collect();
345    }
346    DragTable::new(mach_values, cd_values)
347}
348
349/// G1 drag table — high-resolution data baked in from data/g1.csv at compile time (MBA-939).
350/// The previous runtime loader searched for a `drag_tables/` directory that does not exist when
351/// the binary runs (the tables ship under data/), so the engine silently used the coarse 21-point
352/// fallback below, flattening the transonic drag rise. include_str! guarantees the full table.
353static G1_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
354    // Coarse 21-point fallback, retained only for the impossible parse-failure path.
355    let fallback_data = [
356        (0.0, 0.2629),
357        (0.5, 0.2695),
358        (0.6, 0.2752),
359        (0.7, 0.2817),
360        (0.8, 0.2902),
361        (0.9, 0.3012),
362        (1.0, 0.4805),
363        (1.1, 0.5933),
364        (1.2, 0.6318),
365        (1.3, 0.6440),
366        (1.4, 0.6444),
367        (1.5, 0.6372),
368        (1.6, 0.6252),
369        (1.7, 0.6105),
370        (1.8, 0.5956),
371        (1.9, 0.5815),
372        (2.0, 0.5934),
373        (2.5, 0.5598),
374        (3.0, 0.5133),
375        (4.0, 0.4811),
376        (5.0, 0.4988),
377    ];
378
379    parse_embedded_drag_table(include_str!("../data/g1.csv"), &fallback_data)
380});
381
382/// G7 drag table — high-resolution data baked in from data/g7.csv at compile time (MBA-939).
383/// Same root cause as G1: the runtime `drag_tables/` loader never resolved, so the coarse
384/// 21-point fallback was used, missing the Mach 0.9->1.0 transonic knee (the embedded 0.9 point
385/// was even wrong: 0.1294 vs the true 0.1464). include_str! bakes in the full 84-point table.
386static G7_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
387    // Coarse 21-point fallback, retained only for the impossible parse-failure path.
388    let fallback_data = [
389        (0.0, 0.1198),
390        (0.5, 0.1197),
391        (0.6, 0.1202),
392        (0.7, 0.1213),
393        (0.8, 0.1240),
394        (0.9, 0.1294),
395        (1.0, 0.3803),
396        (1.1, 0.4015),
397        (1.2, 0.4043),
398        (1.3, 0.3956),
399        (1.4, 0.3814),
400        (1.5, 0.3663),
401        (1.6, 0.3520),
402        (1.7, 0.3398),
403        (1.8, 0.3297),
404        (1.9, 0.3221),
405        (2.0, 0.2980),
406        (2.5, 0.2731),
407        (3.0, 0.2424),
408        (4.0, 0.2196),
409        (5.0, 0.1618),
410    ];
411
412    parse_embedded_drag_table(include_str!("../data/g7.csv"), &fallback_data)
413});
414
415/// G6 drag table - flat-base with 6 caliber secant ogive (military FMJ bullets)
416/// MBA-156: Added for completeness with ballistics_rust
417static G6_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
418    let fallback_data = [
419        (0.0, 0.2617),
420        (0.05, 0.2553),
421        (0.10, 0.2491),
422        (0.15, 0.2432),
423        (0.20, 0.2376),
424        (0.25, 0.2324),
425        (0.30, 0.2278),
426        (0.35, 0.2238),
427        (0.40, 0.2205),
428        (0.45, 0.2177),
429        (0.50, 0.2155),
430        (0.55, 0.2138),
431        (0.60, 0.2126),
432        (0.65, 0.2121),
433        (0.70, 0.2122),
434        (0.75, 0.2132),
435        (0.80, 0.2154),
436        (0.85, 0.2194),
437        (0.875, 0.2229),
438        (0.90, 0.2297),
439        (0.925, 0.2449),
440        (0.95, 0.2732),
441        (0.975, 0.3141),
442        (1.0, 0.3597),
443        (1.025, 0.3994),
444        (1.05, 0.4261),
445        (1.075, 0.4402),
446        (1.10, 0.4465),
447        (1.125, 0.4490),
448        (1.15, 0.4497),
449        (1.175, 0.4494),
450        (1.20, 0.4482),
451        (1.225, 0.4464),
452        (1.25, 0.4441),
453        (1.30, 0.4390),
454        (1.35, 0.4336),
455        (1.40, 0.4279),
456        (1.45, 0.4221),
457        (1.50, 0.4162),
458        (1.55, 0.4102),
459        (1.60, 0.4042),
460        (1.65, 0.3981),
461        (1.70, 0.3919),
462        (1.75, 0.3855),
463        (1.80, 0.3788),
464        (1.85, 0.3721),
465        (1.90, 0.3652),
466        (1.95, 0.3583),
467        (2.0, 0.3515),
468        (2.05, 0.3447),
469        (2.10, 0.3381),
470        (2.15, 0.3314),
471        (2.20, 0.3249),
472        (2.25, 0.3185),
473        (2.30, 0.3122),
474        (2.35, 0.3060),
475        (2.40, 0.3000),
476        (2.45, 0.2941),
477        (2.50, 0.2883),
478        (2.60, 0.2772),
479        (2.70, 0.2668),
480        (2.80, 0.2574),
481        (2.90, 0.2487),
482        (3.0, 0.2407),
483        (3.10, 0.2333),
484        (3.20, 0.2265),
485        (3.30, 0.2202),
486        (3.40, 0.2144),
487        (3.50, 0.2089),
488        (3.60, 0.2039),
489        (3.70, 0.1991),
490        (3.80, 0.1947),
491        (3.90, 0.1905),
492        (4.0, 0.1866),
493        (4.20, 0.1794),
494        (4.40, 0.1730),
495        (4.60, 0.1673),
496        (4.80, 0.1621),
497        (5.0, 0.1574),
498    ];
499
500    if let Some(drag_dir) = find_drag_tables_dir() {
501        load_drag_table(&drag_dir, "g6", &fallback_data)
502    } else {
503        // Use fallback data if directory not found
504        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
505        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
506        DragTable::new(mach_values, cd_values)
507    }
508});
509
510/// G8 drag table - flat-base with 10 caliber secant ogive
511/// MBA-156: Added for completeness with ballistics_rust
512static G8_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
513    let fallback_data = [
514        (0.0, 0.2105),
515        (0.05, 0.2105),
516        (0.10, 0.2104),
517        (0.15, 0.2104),
518        (0.20, 0.2103),
519        (0.25, 0.2103),
520        (0.30, 0.2103),
521        (0.35, 0.2103),
522        (0.40, 0.2103),
523        (0.45, 0.2102),
524        (0.50, 0.2102),
525        (0.55, 0.2102),
526        (0.60, 0.2102),
527        (0.65, 0.2102),
528        (0.70, 0.2103),
529        (0.75, 0.2103),
530        (0.80, 0.2104),
531        (0.825, 0.2104),
532        (0.85, 0.2105),
533        (0.875, 0.2106),
534        (0.90, 0.2109),
535        (0.925, 0.2183),
536        (0.95, 0.2571),
537        (0.975, 0.3358),
538        (1.0, 0.4068),
539        (1.025, 0.4378),
540        (1.05, 0.4476),
541        (1.075, 0.4493),
542        (1.10, 0.4477),
543        (1.125, 0.4450),
544        (1.15, 0.4419),
545        (1.20, 0.4353),
546        (1.25, 0.4283),
547        (1.30, 0.4208),
548        (1.35, 0.4133),
549        (1.40, 0.4059),
550        (1.45, 0.3986),
551        (1.50, 0.3915),
552        (1.55, 0.3845),
553        (1.60, 0.3777),
554        (1.65, 0.3710),
555        (1.70, 0.3645),
556        (1.75, 0.3581),
557        (1.80, 0.3519),
558        (1.85, 0.3458),
559        (1.90, 0.3400),
560        (1.95, 0.3343),
561        (2.0, 0.3288),
562        (2.05, 0.3234),
563        (2.10, 0.3182),
564        (2.15, 0.3131),
565        (2.20, 0.3081),
566        (2.25, 0.3032),
567        (2.30, 0.2983),
568        (2.35, 0.2937),
569        (2.40, 0.2891),
570        (2.45, 0.2845),
571        (2.50, 0.2802),
572        (2.60, 0.2720),
573        (2.70, 0.2642),
574        (2.80, 0.2569),
575        (2.90, 0.2499),
576        (3.0, 0.2432),
577        (3.10, 0.2368),
578        (3.20, 0.2308),
579        (3.30, 0.2251),
580        (3.40, 0.2197),
581        (3.50, 0.2147),
582        (3.60, 0.2101),
583        (3.70, 0.2058),
584        (3.80, 0.2019),
585        (3.90, 0.1983),
586        (4.0, 0.1950),
587        (4.20, 0.1890),
588        (4.40, 0.1837),
589        (4.60, 0.1791),
590        (4.80, 0.1750),
591        (5.0, 0.1713),
592    ];
593
594    if let Some(drag_dir) = find_drag_tables_dir() {
595        load_drag_table(&drag_dir, "g8", &fallback_data)
596    } else {
597        // Use fallback data if directory not found
598        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
599        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
600        DragTable::new(mach_values, cd_values)
601    }
602});
603
604/// G2 drag table — banded-based projectile (Aberdeen/BRL standard, as tabulated in McCoy,
605/// Modern Exterior Ballistics). High-resolution data baked in from data/g2.csv (MBA-1386);
606/// see that file's header for provenance.
607static G2_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
608    // 3-point fallback for the impossible parse-failure path only.
609    let fallback_data = [(0.0, 0.2303), (1.0, 0.3983), (5.0, 0.1648)];
610    parse_embedded_drag_table(include_str!("../data/g2.csv"), &fallback_data)
611});
612
613/// G5 drag table — short 7.5 caliber tangent ogive boat-tail (Aberdeen/BRL standard, as
614/// tabulated in McCoy, Modern Exterior Ballistics). High-resolution data baked in from
615/// data/g5.csv (MBA-1386); see that file's header for provenance.
616static G5_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
617    // 3-point fallback for the impossible parse-failure path only.
618    let fallback_data = [(0.0, 0.1710), (1.0, 0.3379), (5.0, 0.2280)];
619    parse_embedded_drag_table(include_str!("../data/g5.csv"), &fallback_data)
620});
621
622/// GI drag table — flat-based, 5.5 caliber tangent ogive (Aberdeen/BRL standard, as
623/// tabulated in McCoy, Modern Exterior Ballistics). High-resolution data baked in from
624/// data/gi.csv (MBA-1386); see that file's header for provenance.
625static GI_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
626    // 3-point fallback for the impossible parse-failure path only.
627    let fallback_data = [(0.0, 0.2282), (1.0, 0.4349), (5.0, 0.4082)];
628    parse_embedded_drag_table(include_str!("../data/gi.csv"), &fallback_data)
629});
630
631/// GS drag table — spherical (round-ball) projectile (Aberdeen/BRL standard, as tabulated in
632/// McCoy, Modern Exterior Ballistics). High-resolution data baked in from data/gs.csv
633/// (MBA-1386); see that file's header for provenance. Note the source table only extends to
634/// Mach 4.0 (not 5.0 like the other families).
635static GS_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
636    // 3-point fallback for the impossible parse-failure path only.
637    let fallback_data = [(0.0, 0.4662), (1.0, 0.8140), (4.0, 0.9280)];
638    parse_embedded_drag_table(include_str!("../data/gs.csv"), &fallback_data)
639});
640
641/// RA4 drag table — British RA 1929 reference function (as tabulated in McCoy, Modern
642/// Exterior Ballistics). High-resolution data baked in from data/ra4.csv (MBA-1386); see that
643/// file's header for provenance. Note the source table only extends to Mach 4.0 (not 5.0 like
644/// most of the G-family tables).
645static RA4_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
646    // 3-point fallback for the impossible parse-failure path only.
647    let fallback_data = [(0.0, 0.2283), (1.0, 0.3975), (4.0, 0.4969)];
648    parse_embedded_drag_table(include_str!("../data/ra4.csv"), &fallback_data)
649});
650
651/// Get drag coefficient for given Mach number and drag model.
652///
653/// Every `DragModel` variant now has a dedicated high-resolution reference table (MBA-1386):
654/// G1/G6/G7/G8 (Aberdeen/BRL, MBA-939/156), G2/G5/GI/GS (Aberdeen/BRL, as tabulated in McCoy),
655/// and RA4 (British RA 1929 reference function).
656pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
657    match drag_model {
658        DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
659        DragModel::G2 => G2_DRAG_TABLE.interpolate(mach),
660        DragModel::G5 => G5_DRAG_TABLE.interpolate(mach),
661        DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
662        DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
663        DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
664        DragModel::GI => GI_DRAG_TABLE.interpolate(mach),
665        DragModel::GS => GS_DRAG_TABLE.interpolate(mach),
666        DragModel::RA4 => RA4_DRAG_TABLE.interpolate(mach),
667    }
668}
669
670/// Get a standard G-table drag coefficient without double-counting transonic drag.
671///
672/// Standard G tables are total-drag curves that already contain the transonic
673/// rise and wave drag. `apply_transonic_correction` and the shape inputs remain
674/// in this public API for compatibility, but enabling the option does not stack
675/// the separate empirical rise/wave model on top of a G-table coefficient.
676pub fn get_drag_coefficient_with_transonic(
677    mach: f64,
678    drag_model: &DragModel,
679    apply_transonic_correction: bool,
680    projectile_shape: Option<ProjectileShape>,
681    caliber: Option<f64>,
682    weight_grains: Option<f64>,
683) -> f64 {
684    // Get base drag coefficient
685    let base_cd = get_drag_coefficient(mach, drag_model);
686
687    // Apply transonic corrections if requested and in transonic regime
688    if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
689        // Determine projectile shape if not provided
690        let shape = match projectile_shape {
691            Some(s) => s,
692            None => {
693                if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
694                    get_projectile_shape(
695                        cal,
696                        weight,
697                        match drag_model {
698                            DragModel::G1 => "G1",
699                            DragModel::G6 => "G6",
700                            DragModel::G7 => "G7",
701                            DragModel::G8 => "G8",
702                            _ => "G1", // Default to G1
703                        },
704                    )
705                } else {
706                    ProjectileShape::Spitzer // Default
707                }
708            }
709        };
710
711        // Standard G-model tables are total-drag reference curves and already
712        // contain their transonic rise and wave drag. Retain the public option
713        // for API compatibility, but do not stack the empirical rise/wave model
714        // on top of table Cd (MBA-1155).
715        transonic_correction(mach, base_cd, shape, false)
716    } else {
717        base_cd
718    }
719}
720
721/// Get drag coefficient with optional Reynolds correction.
722///
723/// The transonic option is retained for compatibility but, as documented by
724/// [`get_drag_coefficient_with_transonic`], standard G tables are not corrected
725/// a second time. Likewise, the Reynolds option only affects genuinely low-Re
726/// (`Re < 10,000`) inputs; ordinary ballistic Reynolds numbers use the standard
727/// table coefficient unchanged.
728#[allow(clippy::too_many_arguments)] // Public compatibility API; grouping would be breaking.
729pub fn get_drag_coefficient_full(
730    mach: f64,
731    drag_model: &DragModel,
732    apply_transonic_correction: bool,
733    apply_reynolds_correction: bool,
734    projectile_shape: Option<ProjectileShape>,
735    caliber: Option<f64>,
736    weight_grains: Option<f64>,
737    velocity_mps: Option<f64>,
738    air_density_kg_m3: Option<f64>,
739    temperature_c: Option<f64>,
740) -> f64 {
741    // Get base drag coefficient with transonic corrections if applicable
742    let mut cd = get_drag_coefficient_with_transonic(
743        mach,
744        drag_model,
745        apply_transonic_correction,
746        projectile_shape,
747        caliber,
748        weight_grains,
749    );
750
751    // Route the opt-in low-Re helper for subsonic inputs. It leaves the ordinary
752    // standard-table Reynolds-number range unchanged.
753    if apply_reynolds_correction && mach < 1.0 {
754        if let (Some(v), Some(cal), Some(rho), Some(temp)) =
755            (velocity_mps, caliber, air_density_kg_m3, temperature_c)
756        {
757            use crate::reynolds::apply_reynolds_correction;
758            cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
759        }
760    }
761
762    cd
763}
764
765#[cfg(test)]
766#[allow(clippy::items_after_test_module)] // Keep the legacy public helper below in place.
767mod tests {
768    use super::*;
769
770    #[test]
771    fn test_g1_drag_coefficient_interpolation() {
772        let cd = get_drag_coefficient(1.0, &DragModel::G1);
773        // Should be close to the G1 standard value at Mach 1.0
774        assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
775    }
776
777    #[test]
778    fn test_g7_drag_coefficient_interpolation() {
779        let cd = get_drag_coefficient(1.0, &DragModel::G7);
780        // Should be close to the G7 standard value at Mach 1.0
781        assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
782    }
783
784    #[test]
785    fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
786        let models = [
787            DragModel::G1,
788            DragModel::G2,
789            DragModel::G5,
790            DragModel::G6,
791            DragModel::G7,
792            DragModel::G8,
793            DragModel::GI,
794            DragModel::GS,
795        ];
796        for drag_model in models {
797            for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
798                let base_cd = get_drag_coefficient(mach, &drag_model);
799                let corrected_cd = get_drag_coefficient_with_transonic(
800                    mach,
801                    &drag_model,
802                    true,
803                    Some(ProjectileShape::BoatTail),
804                    Some(0.308),
805                    Some(175.0),
806                );
807                assert_eq!(
808                    corrected_cd.to_bits(),
809                    base_cd.to_bits(),
810                    "standard {drag_model:?} table already includes transonic drag at Mach \
811                     {mach}: base={base_cd}, corrected={corrected_cd}"
812                );
813
814                let full_cd = get_drag_coefficient_full(
815                    mach,
816                    &drag_model,
817                    true,
818                    false,
819                    Some(ProjectileShape::BoatTail),
820                    Some(0.308),
821                    Some(175.0),
822                    None,
823                    None,
824                    None,
825                );
826                assert_eq!(full_cd.to_bits(), base_cd.to_bits());
827            }
828        }
829    }
830
831    #[test]
832    fn test_drag_coefficient_continuity() {
833        // Test that drag coefficient function is smooth
834        for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
835            let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
836            let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
837            let difference = (cd_after - cd_before).abs();
838            assert!(
839                difference < 0.05,
840                "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
841            );
842        }
843    }
844
845    #[test]
846    fn test_endpoint_bounds() {
847        // Test endpoint hold below range
848        let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
849        assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
850
851        // Test endpoint hold above range
852        let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
853        assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
854
855        // Same for G7
856        let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
857        assert!(
858            cd_low_g7 > 0.01,
859            "Low Mach G7 should be positive: {cd_low_g7}"
860        );
861
862        let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
863        assert!(
864            cd_high_g7 >= 0.01,
865            "High Mach G7 should be positive: {cd_high_g7}"
866        );
867    }
868
869    #[test]
870    fn test_drag_table_creation() {
871        let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
872        let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
873        let table = DragTable::new(mach_vals, cd_vals);
874
875        // Test exact interpolation
876        assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
877
878        // Test interpolation between points
879        let cd_interp = table.interpolate(1.25);
880        assert!(cd_interp > 0.4 && cd_interp < 0.5);
881    }
882
883    #[test]
884    fn test_drag_table_empty() {
885        let table = DragTable::new(vec![], vec![]);
886        let result = table.interpolate(1.0);
887        assert_eq!(result, 0.5); // Should return fallback value
888    }
889
890    #[test]
891    fn test_drag_table_single_point() {
892        let table = DragTable::new(vec![1.0], vec![0.4]);
893
894        // Should return the single value for any Mach
895        assert_eq!(table.interpolate(0.5), 0.4);
896        assert_eq!(table.interpolate(1.0), 0.4);
897        assert_eq!(table.interpolate(2.0), 0.4);
898    }
899
900    #[test]
901    fn test_drag_table_two_points() {
902        let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
903
904        // Exact matches
905        assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
906        assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
907
908        // Linear interpolation
909        let mid = table.interpolate(1.5);
910        assert!((mid - 0.5).abs() < 1e-10);
911
912        // Out-of-range values hold the nearest endpoint.
913        let below = table.interpolate(0.5);
914        assert_eq!(below.to_bits(), 0.4_f64.to_bits());
915
916        let above = table.interpolate(3.0);
917        assert_eq!(above.to_bits(), 0.6_f64.to_bits());
918    }
919
920    #[test]
921    fn out_of_range_mach_holds_boundary_cd() {
922        let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
923
924        for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
925            assert_eq!(
926                table.interpolate(mach).to_bits(),
927                0.2_f64.to_bits(),
928                "Mach {mach} must hold the first tabulated Cd"
929            );
930        }
931        for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
932            assert_eq!(
933                table.interpolate(mach).to_bits(),
934                0.3_f64.to_bits(),
935                "Mach {mach} must hold the last tabulated Cd"
936            );
937        }
938    }
939
940    #[test]
941    fn test_linear_interpolation() {
942        let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
943
944        // Test linear interpolation between first two points
945        let result = table.linear_interpolate(0.5, 0);
946        assert!((result - 0.35).abs() < 1e-10);
947
948        // Test edge case with zero denominator
949        let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
950        let result_same = table_same.linear_interpolate(1.0, 0);
951        assert_eq!(result_same, 0.4); // Should return first value
952    }
953
954    #[test]
955    fn test_cubic_interpolation() {
956        // Create a table with enough points for cubic interpolation
957        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]);
958
959        // Test cubic interpolation in the middle
960        let result = table.cubic_interpolate(1.25, 1);
961
962        // Should be between the neighboring values
963        assert!(result > 0.3 && result < 0.7);
964
965        // Should be smooth (not exactly linear)
966        let linear_result = table.linear_interpolate(1.25, 1);
967        // Cubic and linear should be close but not identical for smooth curves
968        assert!((result - linear_result).abs() < 0.2);
969    }
970
971    #[test]
972    fn nonuniform_cubic_reproduces_affine_data() {
973        let table = DragTable::new(
974            vec![0.0, 1.0, 3.0, 4.0],
975            vec![0.25, 0.3125, 0.4375, 0.5],
976        );
977
978        for mach in [1.5, 2.5] {
979            let expected = 0.25 + mach / 16.0;
980            let actual = table.interpolate(mach);
981            assert_eq!(
982                actual.to_bits(),
983                expected.to_bits(),
984                "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
985            );
986        }
987    }
988
989    #[test]
990    fn nonuniform_cubic_is_c1_at_spacing_transition() {
991        let table = DragTable::new(
992            vec![0.0, 1.0, 3.0, 4.0, 7.0],
993            vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
994        );
995        let knot = 3.0;
996        let expected_at_knot = 0.390625_f64;
997        let epsilon = 1e-6;
998        let at_knot = table.interpolate(knot);
999        let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
1000        let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
1001
1002        assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
1003        assert!(
1004            (left_slope - right_slope).abs() < 1e-5,
1005            "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_find_drag_tables_dir() {
1011        // This test may pass or fail depending on the environment
1012        // but should not panic
1013        let _dir = find_drag_tables_dir();
1014        // Just ensure the function doesn't panic
1015    }
1016
1017    #[test]
1018    fn test_load_drag_table_fallback() {
1019        use std::path::Path;
1020
1021        // Test with non-existent directory - should use fallback data
1022        let fake_dir = Path::new("/non/existent/directory");
1023        let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
1024
1025        let table = load_drag_table(fake_dir, "test", &fallback_data);
1026
1027        // Should have fallback data
1028        assert_eq!(table.mach_values.len(), 3);
1029        assert_eq!(table.cd_values.len(), 3);
1030        assert_eq!(table.mach_values[0], 0.5);
1031        assert_eq!(table.cd_values[0], 0.2);
1032    }
1033
1034    #[test]
1035    fn test_known_drag_values() {
1036        // Test against known ballistic standard values
1037
1038        // G1 at Mach 1.0 should be around 0.4805
1039        let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
1040        assert!(
1041            (g1_mach1 - 0.4805).abs() < 0.01,
1042            "G1 at Mach 1.0: {g1_mach1}"
1043        );
1044
1045        // G7 at Mach 1.0 should be around 0.3803
1046        let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
1047        assert!(
1048            (g7_mach1 - 0.3803).abs() < 0.01,
1049            "G7 at Mach 1.0: {g7_mach1}"
1050        );
1051
1052        // G1 should generally be higher than G7 in transonic region
1053        assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
1054    }
1055
1056    #[test]
1057    fn test_monotonicity_properties() {
1058        // Test general drag curve properties
1059
1060        // G1 should peak somewhere in transonic region
1061        let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); // 0.8 to 1.9
1062        let g1_values: Vec<f64> = mach_values
1063            .iter()
1064            .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1065            .collect();
1066
1067        // Find maximum
1068        let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1069        let max_index = g1_values
1070            .iter()
1071            .position(|&x| x == max_value)
1072            .expect("Should find maximum in non-empty vector");
1073        let peak_mach = mach_values
1074            .get(max_index)
1075            .copied()
1076            .expect("Index should be valid");
1077
1078        // Peak should be in reasonable range
1079        assert!(
1080            peak_mach > 1.0 && peak_mach < 1.6,
1081            "G1 peak at Mach {peak_mach}"
1082        );
1083        assert!(
1084            max_value > 0.5 && max_value < 1.0,
1085            "G1 peak value: {max_value}"
1086        );
1087    }
1088
1089    #[test]
1090    fn test_physical_constraints() {
1091        let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1092
1093        for &mach in &test_machs {
1094            let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1095            let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1096
1097            // All drag coefficients should be positive
1098            assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1099            assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1100
1101            // Should be in reasonable physical ranges
1102            assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1103            assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1104        }
1105    }
1106
1107    #[test]
1108    fn test_performance_characteristics() {
1109        // This test ensures the implementation is efficient
1110        use std::time::Instant;
1111
1112        let start = Instant::now();
1113
1114        // Perform many calculations
1115        for i in 0..1000 {
1116            let mach = 0.5 + (i as f64) * 0.004; // 0.5 to 4.5
1117            let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1118            let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1119        }
1120
1121        let elapsed = start.elapsed();
1122
1123        // Should complete 2000 calculations quickly (within 100ms)
1124        assert!(
1125            elapsed.as_millis() < 100,
1126            "Performance test took {}ms",
1127            elapsed.as_millis()
1128        );
1129    }
1130
1131    #[test]
1132    fn try_new_accepts_valid_table() {
1133        let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1134        assert_eq!(t.mach_values.len(), 3);
1135    }
1136
1137    #[test]
1138    fn try_new_rejects_mismatched_lengths() {
1139        let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1140        assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1141    }
1142
1143    #[test]
1144    fn try_new_rejects_too_few_points() {
1145        assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1146    }
1147
1148    #[test]
1149    fn try_new_rejects_non_ascending_mach() {
1150        assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1151        assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1152    }
1153
1154    #[test]
1155    fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1156        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1157        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1158    }
1159
1160    #[test]
1161    fn interpolate_does_not_panic_on_mismatched_table() {
1162        // `new` is infallible; a caller could build a bad table. interpolate must not panic.
1163        let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1164        let _ = bad.interpolate(0.1);
1165        let _ = bad.interpolate(5.0);
1166        let _ = bad.interpolate(1.0);
1167    }
1168
1169    #[test]
1170    fn from_csv_str_parses_with_header_and_comments() {
1171        let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1172        let t = DragTable::from_csv_str(csv).unwrap();
1173        assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1174        assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1175    }
1176
1177    #[test]
1178    fn from_csv_str_rejects_malformed_data_row() {
1179        // header skip is allowed once; a bad DATA row must error with a line number.
1180        let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1181        assert!(e.contains("line 2"), "got: {e}");
1182    }
1183
1184    #[test]
1185    fn from_csv_str_rejects_empty() {
1186        assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1187    }
1188
1189    #[test]
1190    fn from_csv_str_rejects_malformed_first_data_row() {
1191        // first column is a valid number => it's data, not a header => must error, not vanish
1192        assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1193        assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1194    }
1195
1196    #[test]
1197    fn from_csv_str_still_skips_textual_header() {
1198        // genuine header (first column non-numeric) is still tolerated
1199        let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1200        assert_eq!(t.mach_values, vec![0.5, 1.0]);
1201    }
1202
1203    #[test]
1204    fn from_csv_str_roundtrips_shipped_g7() {
1205        // The embedded G7 deck must load and validate through the public loader.
1206        let g7 = include_str!("../data/g7.csv");
1207        let t = DragTable::from_csv_str(g7).unwrap();
1208        assert!(t.mach_values.len() > 20);
1209    }
1210
1211    // -- MBA-1386: real G2/G5/GI/GS reference tables + RA4 -----------------------------------
1212
1213    #[test]
1214    fn test_g2_drag_coefficient_spot_values() {
1215        // Exact values copied from data/g2.csv (JBM mcg2.txt).
1216        for (mach, expected) in [(0.0, 0.2303), (0.70, 0.1702), (1.20, 0.4021), (5.0, 0.1648)] {
1217            let cd = get_drag_coefficient(mach, &DragModel::G2);
1218            assert!(
1219                (cd - expected).abs() < 1e-6,
1220                "G2 CD at Mach {mach}: expected {expected}, got {cd}"
1221            );
1222        }
1223    }
1224
1225    #[test]
1226    fn test_g5_drag_coefficient_spot_values() {
1227        // Exact values copied from data/g5.csv (JBM mcg5.txt).
1228        for (mach, expected) in [(0.0, 0.1710), (0.75, 0.1463), (1.0, 0.3379), (5.0, 0.2280)] {
1229            let cd = get_drag_coefficient(mach, &DragModel::G5);
1230            assert!(
1231                (cd - expected).abs() < 1e-6,
1232                "G5 CD at Mach {mach}: expected {expected}, got {cd}"
1233            );
1234        }
1235    }
1236
1237    #[test]
1238    fn test_gi_drag_coefficient_spot_values() {
1239        // Exact values copied from data/gi.csv (JBM mcgi.txt).
1240        for (mach, expected) in [(0.0, 0.2282), (0.90, 0.3170), (1.20, 0.6279), (5.0, 0.4082)] {
1241            let cd = get_drag_coefficient(mach, &DragModel::GI);
1242            assert!(
1243                (cd - expected).abs() < 1e-6,
1244                "GI CD at Mach {mach}: expected {expected}, got {cd}"
1245            );
1246        }
1247    }
1248
1249    #[test]
1250    fn test_gs_drag_coefficient_spot_values() {
1251        // Exact values copied from data/gs.csv (JBM mcgs.txt). Table tops out at Mach 4.0.
1252        for (mach, expected) in [(0.0, 0.4662), (0.60, 0.5260), (1.60, 1.0090), (4.0, 0.9280)] {
1253            let cd = get_drag_coefficient(mach, &DragModel::GS);
1254            assert!(
1255                (cd - expected).abs() < 1e-6,
1256                "GS CD at Mach {mach}: expected {expected}, got {cd}"
1257            );
1258        }
1259    }
1260
1261    #[test]
1262    fn test_ra4_drag_coefficient_spot_values() {
1263        // Exact values copied from data/ra4.csv (JBM ra4.txt). Table tops out at Mach 4.0.
1264        for (mach, expected) in [(0.0, 0.2283), (0.70, 0.2288), (1.15, 0.5943), (4.0, 0.4969)] {
1265            let cd = get_drag_coefficient(mach, &DragModel::RA4);
1266            assert!(
1267                (cd - expected).abs() < 1e-6,
1268                "RA4 CD at Mach {mach}: expected {expected}, got {cd}"
1269            );
1270        }
1271    }
1272
1273    #[test]
1274    fn embedded_family_tables_have_ascending_mach_and_positive_cd() {
1275        // Structural check, via the public DragTable::from_csv_str loader (which enforces
1276        // strictly-ascending Mach and positive Cd through try_new): every embedded reference
1277        // table parses cleanly and has a sane point count.
1278        let decks: [(&str, &str); 7] = [
1279            ("G1", include_str!("../data/g1.csv")),
1280            ("G2", include_str!("../data/g2.csv")),
1281            ("G5", include_str!("../data/g5.csv")),
1282            ("G7", include_str!("../data/g7.csv")),
1283            ("GI", include_str!("../data/gi.csv")),
1284            ("GS", include_str!("../data/gs.csv")),
1285            ("RA4", include_str!("../data/ra4.csv")),
1286        ];
1287        for (name, csv) in decks {
1288            let table =
1289                DragTable::from_csv_str(csv).unwrap_or_else(|e| panic!("{name} failed to parse: {e}"));
1290            assert!(
1291                table.mach_values.len() > 20,
1292                "{name}: expected a high-resolution table, got {} points",
1293                table.mach_values.len()
1294            );
1295            assert_eq!(table.mach_values[0], 0.0, "{name}: expected Mach axis to start at 0.0");
1296        }
1297
1298        // Also sweep the full public get_drag_coefficient API across all 9 families (including
1299        // the runtime-loaded G6/G8) and confirm Cd stays finite and positive everywhere.
1300        let models = [
1301            DragModel::G1,
1302            DragModel::G2,
1303            DragModel::G5,
1304            DragModel::G6,
1305            DragModel::G7,
1306            DragModel::G8,
1307            DragModel::GI,
1308            DragModel::GS,
1309            DragModel::RA4,
1310        ];
1311        for model in models {
1312            for i in 0..=50 {
1313                let mach = i as f64 * 0.1; // 0.0 ..= 5.0
1314                let cd = get_drag_coefficient(mach, &model);
1315                assert!(
1316                    cd.is_finite() && cd > 0.0 && cd < 2.0,
1317                    "{model:?} CD out of range at Mach {mach}: {cd}"
1318                );
1319            }
1320        }
1321    }
1322
1323    /// Interpolate the trajectory height (position.y) at a given downrange distance,
1324    /// holding the nearest endpoint if the trajectory never reaches it. Mirrors the
1325    /// interpolation TrajectorySolver uses internally for zero-angle trials.
1326    fn interpolated_drop_at(points: &[crate::TrajectoryPoint], target_x_m: f64) -> f64 {
1327        for (i, point) in points.iter().enumerate() {
1328            if point.position.x >= target_x_m {
1329                if i == 0 {
1330                    return point.position.y;
1331                }
1332                let previous = &points[i - 1];
1333                let span = point.position.x - previous.position.x;
1334                if span.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1335                    return point.position.y;
1336                }
1337                let fraction = (target_x_m - previous.position.x) / span;
1338                return previous.position.y + fraction * (point.position.y - previous.position.y);
1339            }
1340        }
1341        points.last().map(|p| p.position.y).unwrap_or(f64::NAN)
1342    }
1343
1344    fn drop_at_500yd_m(model: DragModel) -> f64 {
1345        let inputs = crate::BallisticInputs {
1346            bc_type: model,
1347            bc_value: 0.5,
1348            ground_threshold: -1000.0,
1349            ..Default::default()
1350        };
1351        let solver = crate::TrajectorySolver::new(
1352            inputs,
1353            crate::WindConditions::default(),
1354            crate::AtmosphericConditions::default(),
1355        );
1356        let result = solver.solve().expect("solve should succeed");
1357        assert!(result.max_range.is_finite());
1358        assert!(result.impact_velocity.is_finite() && result.impact_velocity > 0.0);
1359        assert!(!result.points.is_empty(), "{model:?}: solver produced no points");
1360        let drop = interpolated_drop_at(&result.points, 457.2); // 500 yards
1361        assert!(drop.is_finite(), "{model:?}: non-finite drop at 500yd");
1362        drop
1363    }
1364
1365    #[test]
1366    fn mba1386_g2_g5_gi_gs_drop_differs_from_g1_now_that_fallback_is_gone() {
1367        let g1_drop = drop_at_500yd_m(DragModel::G1);
1368        for model in [DragModel::G2, DragModel::G5, DragModel::GI, DragModel::GS] {
1369            let drop = drop_at_500yd_m(model);
1370            assert!(
1371                (drop - g1_drop).abs() > 0.01,
1372                "{model:?} 500yd drop ({drop}) must differ from the G1 result ({g1_drop}) by \
1373                 more than solver noise now that the real table is wired in"
1374            );
1375        }
1376    }
1377
1378    #[test]
1379    fn mba1386_ra4_solver_smoke() {
1380        // RA4 never fell back to G1 (it's a new variant), so just prove it solves sanely.
1381        let drop = drop_at_500yd_m(DragModel::RA4);
1382        assert!(drop < 0.0, "500yd drop should be a fall below the muzzle line: {drop}");
1383    }
1384}
1385
1386/// Interpolate BC value for given Mach number from segments
1387pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1388    if segments.is_empty() {
1389        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1390    }
1391
1392    // Get just the mach values
1393    let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1394
1395    // Double-check we have values after collection
1396    if mach_values.is_empty() || segments.is_empty() {
1397        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1398    }
1399
1400    // Handle edge cases with safe indexing
1401    if let Some(first_mach) = mach_values.first() {
1402        if mach <= *first_mach {
1403            return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1404        }
1405    }
1406
1407    if let Some(last_mach) = mach_values.last() {
1408        if mach >= *last_mach {
1409            return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1410        }
1411    }
1412
1413    // Binary search to find the right segment with safe comparison
1414    let idx = match mach_values
1415        .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1416    {
1417        Ok(idx) => {
1418            // Exact match - safely get the BC value
1419            return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1420        }
1421        Err(idx) => idx, // Insert position
1422    };
1423
1424    // Ensure idx is valid for interpolation
1425    if idx == 0 || idx >= segments.len() {
1426        // Shouldn't happen given the edge case checks above, but be defensive
1427        // Use safe indexing
1428        let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1429        return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1430    }
1431
1432    // Linear interpolation between the two closest points with safe indexing
1433    match (segments.get(idx - 1), segments.get(idx)) {
1434        (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1435            // Ensure denominator is not zero for safe interpolation
1436            let denominator = hi_mach - lo_mach;
1437            if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1438                return *lo_bc; // Return lower BC if Mach values are too close
1439            }
1440            let frac = (mach - lo_mach) / denominator;
1441            lo_bc + frac * (hi_bc - lo_bc)
1442        }
1443        _ => 0.5, // Fallback if indices are somehow invalid
1444    }
1445}
1446
1447// Removed Python-specific function