Skip to main content

ballistics_engine/
drag.rs

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