Skip to main content

ballistics_engine/
drag.rs

1use crate::transonic_drag::{get_projectile_shape, transonic_correction, ProjectileShape};
2use crate::DragModel;
3use ndarray::ArrayD;
4use std::sync::LazyLock;
5/// Drag coefficient calculations for ballistics using actual drag table data
6use std::path::Path;
7
8/// Drag table data structure
9#[derive(Debug, Clone)]
10pub struct DragTable {
11    pub mach_values: Vec<f64>,
12    pub cd_values: Vec<f64>,
13}
14
15impl DragTable {
16    /// Create a new drag table from mach and cd arrays
17    pub fn new(mach_values: Vec<f64>, cd_values: Vec<f64>) -> Self {
18        Self {
19            mach_values,
20            cd_values,
21        }
22    }
23
24    /// 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/// The built-in reference drag table for one model, as tabulated data (MBA-1424).
671///
672/// Exposes the same `(Mach, Cd)` pairs [`get_drag_coefficient`] interpolates, so a consumer can
673/// plot or audit the standard curves without re-vendoring the numbers into their own code — and
674/// without their copy drifting from the engine's as tables are refined.
675///
676/// These are the public-domain Aberdeen/BRL reference functions as tabulated in McCoy, *Modern
677/// Exterior Ballistics*, plus the British RA 1929 function; each `data/*.csv` carries its own
678/// provenance header. Nothing here is vendor drag data.
679///
680/// The tables do not share a Mach domain: most run to Mach 5, while GS and RA4 stop at Mach 4.
681/// Read the returned table's `mach_values` rather than assuming a range.
682///
683/// This returns the *reference* curve for a standard projectile. For the effective Cd a specific
684/// bullet actually flew — form-factor scaled, with segmented-BC band steps — see
685/// [`crate::TrajectorySolver::effective_drag_coefficient`].
686pub fn reference_drag_table(drag_model: &DragModel) -> &'static DragTable {
687    match drag_model {
688        DragModel::G1 => &G1_DRAG_TABLE,
689        DragModel::G2 => &G2_DRAG_TABLE,
690        DragModel::G5 => &G5_DRAG_TABLE,
691        DragModel::G6 => &G6_DRAG_TABLE,
692        DragModel::G7 => &G7_DRAG_TABLE,
693        DragModel::G8 => &G8_DRAG_TABLE,
694        DragModel::GI => &GI_DRAG_TABLE,
695        DragModel::GS => &GS_DRAG_TABLE,
696        DragModel::RA4 => &RA4_DRAG_TABLE,
697    }
698}
699
700/// Output form for [`format_reference_drag_curve`].
701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum ReferenceDragCurveFormat {
703    Table,
704    Csv,
705    Json,
706}
707
708/// Render one built-in reference drag curve as text, identically on every surface (MBA-1424 /
709/// MBA-1426).
710///
711/// This is THE formatter — the native CLI's `drag-curve` and the browser terminal's both call
712/// it, so their table, CSV and JSON output cannot drift apart. That is a lesson, not a flourish:
713/// the `recoil` CSV header diverged between the two surfaces (`velocity_m/s` vs `velocity_mps`,
714/// MBA-1418) precisely because each carried its own copy of the format strings, and the
715/// divergence survived because the imperial spelling happened to agree.
716///
717/// Returned strings are newline-terminated; callers print or splice them verbatim. PDF is not a
718/// form of this data — each surface rejects it with its own error convention before calling in.
719pub fn format_reference_drag_curve(
720    drag_model: &DragModel,
721    format: ReferenceDragCurveFormat,
722) -> String {
723    let table = reference_drag_table(drag_model);
724    let points: Vec<(f64, f64)> = table
725        .mach_values
726        .iter()
727        .copied()
728        .zip(table.cd_values.iter().copied())
729        .collect();
730
731    match format {
732        ReferenceDragCurveFormat::Json => {
733            let document = serde_json::json!({
734                "drag_model": drag_model.to_string(),
735                "point_count": points.len(),
736                // The domain is per-table, not a constant: GS and RA4 stop at Mach 4 while the
737                // others run to Mach 5. Stated explicitly so a consumer does not assume one.
738                "mach_min": points.first().map(|p| p.0),
739                "mach_max": points.last().map(|p| p.0),
740                "source": "Aberdeen/BRL reference functions as tabulated in McCoy, Modern \
741                           Exterior Ballistics (RA4: British RA 1929). Public domain.",
742                "points": points
743                    .iter()
744                    .map(|(mach, cd)| serde_json::json!({"mach": mach, "cd": cd}))
745                    .collect::<Vec<_>>(),
746            });
747            let mut out = serde_json::to_string_pretty(&document)
748                // Infallible for this value: string keys, finite floats, no custom Serialize.
749                .expect("reference drag curve document serializes");
750            out.push('\n');
751            out
752        }
753        ReferenceDragCurveFormat::Csv => {
754            let mut out = String::from("mach,cd\n");
755            for (mach, cd) in &points {
756                out.push_str(&format!("{mach},{cd}\n"));
757            }
758            out
759        }
760        ReferenceDragCurveFormat::Table => {
761            let mut out = format!(
762                "{} reference drag curve\n{} points, Mach {:.2} to {:.2}\n\n",
763                drag_model.to_string().to_uppercase(),
764                points.len(),
765                points.first().map(|p| p.0).unwrap_or(0.0),
766                points.last().map(|p| p.0).unwrap_or(0.0)
767            );
768            out.push_str(&format!("{:>8}  {:>8}\n", "Mach", "Cd"));
769            out.push_str(&format!("{:->8}  {:->8}\n", "", ""));
770            for (mach, cd) in &points {
771                out.push_str(&format!("{mach:>8.3}  {cd:>8.4}\n"));
772            }
773            out
774        }
775    }
776}
777
778/// Get a standard G-table drag coefficient without double-counting transonic drag.
779///
780/// Standard G tables are total-drag curves that already contain the transonic
781/// rise and wave drag. `apply_transonic_correction` and the shape inputs remain
782/// in this public API for compatibility, but enabling the option does not stack
783/// the separate empirical rise/wave model on top of a G-table coefficient.
784pub fn get_drag_coefficient_with_transonic(
785    mach: f64,
786    drag_model: &DragModel,
787    apply_transonic_correction: bool,
788    projectile_shape: Option<ProjectileShape>,
789    caliber: Option<f64>,
790    weight_grains: Option<f64>,
791) -> f64 {
792    // Get base drag coefficient
793    let base_cd = get_drag_coefficient(mach, drag_model);
794
795    // Apply transonic corrections if requested and in transonic regime
796    if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
797        // Determine projectile shape if not provided
798        let shape = match projectile_shape {
799            Some(s) => s,
800            None => {
801                if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
802                    get_projectile_shape(
803                        cal,
804                        weight,
805                        match drag_model {
806                            DragModel::G1 => "G1",
807                            DragModel::G6 => "G6",
808                            DragModel::G7 => "G7",
809                            DragModel::G8 => "G8",
810                            _ => "G1", // Default to G1
811                        },
812                    )
813                } else {
814                    ProjectileShape::Spitzer // Default
815                }
816            }
817        };
818
819        // Standard G-model tables are total-drag reference curves and already
820        // contain their transonic rise and wave drag. Retain the public option
821        // for API compatibility, but do not stack the empirical rise/wave model
822        // on top of table Cd (MBA-1155).
823        transonic_correction(mach, base_cd, shape, false)
824    } else {
825        base_cd
826    }
827}
828
829/// Get drag coefficient with optional Reynolds correction.
830///
831/// The transonic option is retained for compatibility but, as documented by
832/// [`get_drag_coefficient_with_transonic`], standard G tables are not corrected
833/// a second time. Likewise, the Reynolds option only affects genuinely low-Re
834/// (`Re < 10,000`) inputs; ordinary ballistic Reynolds numbers use the standard
835/// table coefficient unchanged.
836#[allow(clippy::too_many_arguments)] // Public compatibility API; grouping would be breaking.
837pub fn get_drag_coefficient_full(
838    mach: f64,
839    drag_model: &DragModel,
840    apply_transonic_correction: bool,
841    apply_reynolds_correction: bool,
842    projectile_shape: Option<ProjectileShape>,
843    caliber: Option<f64>,
844    weight_grains: Option<f64>,
845    velocity_mps: Option<f64>,
846    air_density_kg_m3: Option<f64>,
847    temperature_c: Option<f64>,
848) -> f64 {
849    // Get base drag coefficient with transonic corrections if applicable
850    let mut cd = get_drag_coefficient_with_transonic(
851        mach,
852        drag_model,
853        apply_transonic_correction,
854        projectile_shape,
855        caliber,
856        weight_grains,
857    );
858
859    // Route the opt-in low-Re helper for subsonic inputs. It leaves the ordinary
860    // standard-table Reynolds-number range unchanged.
861    if apply_reynolds_correction && mach < 1.0 {
862        if let (Some(v), Some(cal), Some(rho), Some(temp)) =
863            (velocity_mps, caliber, air_density_kg_m3, temperature_c)
864        {
865            use crate::reynolds::apply_reynolds_correction;
866            cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
867        }
868    }
869
870    cd
871}
872
873#[cfg(test)]
874#[allow(clippy::items_after_test_module)] // Keep the legacy public helper below in place.
875mod tests {
876    use super::*;
877
878    #[test]
879    fn test_g1_drag_coefficient_interpolation() {
880        let cd = get_drag_coefficient(1.0, &DragModel::G1);
881        // Should be close to the G1 standard value at Mach 1.0
882        assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
883    }
884
885    #[test]
886    fn test_g7_drag_coefficient_interpolation() {
887        let cd = get_drag_coefficient(1.0, &DragModel::G7);
888        // Should be close to the G7 standard value at Mach 1.0
889        assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
890    }
891
892    #[test]
893    fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
894        let models = [
895            DragModel::G1,
896            DragModel::G2,
897            DragModel::G5,
898            DragModel::G6,
899            DragModel::G7,
900            DragModel::G8,
901            DragModel::GI,
902            DragModel::GS,
903        ];
904        for drag_model in models {
905            for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
906                let base_cd = get_drag_coefficient(mach, &drag_model);
907                let corrected_cd = get_drag_coefficient_with_transonic(
908                    mach,
909                    &drag_model,
910                    true,
911                    Some(ProjectileShape::BoatTail),
912                    Some(0.308),
913                    Some(175.0),
914                );
915                assert_eq!(
916                    corrected_cd.to_bits(),
917                    base_cd.to_bits(),
918                    "standard {drag_model:?} table already includes transonic drag at Mach \
919                     {mach}: base={base_cd}, corrected={corrected_cd}"
920                );
921
922                let full_cd = get_drag_coefficient_full(
923                    mach,
924                    &drag_model,
925                    true,
926                    false,
927                    Some(ProjectileShape::BoatTail),
928                    Some(0.308),
929                    Some(175.0),
930                    None,
931                    None,
932                    None,
933                );
934                assert_eq!(full_cd.to_bits(), base_cd.to_bits());
935            }
936        }
937    }
938
939    #[test]
940    fn test_drag_coefficient_continuity() {
941        // Test that drag coefficient function is smooth
942        for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
943            let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
944            let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
945            let difference = (cd_after - cd_before).abs();
946            assert!(
947                difference < 0.05,
948                "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
949            );
950        }
951    }
952
953    #[test]
954    fn test_endpoint_bounds() {
955        // Test endpoint hold below range
956        let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
957        assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
958
959        // Test endpoint hold above range
960        let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
961        assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
962
963        // Same for G7
964        let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
965        assert!(
966            cd_low_g7 > 0.01,
967            "Low Mach G7 should be positive: {cd_low_g7}"
968        );
969
970        let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
971        assert!(
972            cd_high_g7 >= 0.01,
973            "High Mach G7 should be positive: {cd_high_g7}"
974        );
975    }
976
977    #[test]
978    fn test_drag_table_creation() {
979        let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
980        let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
981        let table = DragTable::new(mach_vals, cd_vals);
982
983        // Test exact interpolation
984        assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
985
986        // Test interpolation between points
987        let cd_interp = table.interpolate(1.25);
988        assert!(cd_interp > 0.4 && cd_interp < 0.5);
989    }
990
991    #[test]
992    fn test_drag_table_empty() {
993        let table = DragTable::new(vec![], vec![]);
994        let result = table.interpolate(1.0);
995        assert_eq!(result, 0.5); // Should return fallback value
996    }
997
998    #[test]
999    fn test_drag_table_single_point() {
1000        let table = DragTable::new(vec![1.0], vec![0.4]);
1001
1002        // Should return the single value for any Mach
1003        assert_eq!(table.interpolate(0.5), 0.4);
1004        assert_eq!(table.interpolate(1.0), 0.4);
1005        assert_eq!(table.interpolate(2.0), 0.4);
1006    }
1007
1008    #[test]
1009    fn test_drag_table_two_points() {
1010        let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
1011
1012        // Exact matches
1013        assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
1014        assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
1015
1016        // Linear interpolation
1017        let mid = table.interpolate(1.5);
1018        assert!((mid - 0.5).abs() < 1e-10);
1019
1020        // Out-of-range values hold the nearest endpoint.
1021        let below = table.interpolate(0.5);
1022        assert_eq!(below.to_bits(), 0.4_f64.to_bits());
1023
1024        let above = table.interpolate(3.0);
1025        assert_eq!(above.to_bits(), 0.6_f64.to_bits());
1026    }
1027
1028    #[test]
1029    fn out_of_range_mach_holds_boundary_cd() {
1030        let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
1031
1032        for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
1033            assert_eq!(
1034                table.interpolate(mach).to_bits(),
1035                0.2_f64.to_bits(),
1036                "Mach {mach} must hold the first tabulated Cd"
1037            );
1038        }
1039        for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
1040            assert_eq!(
1041                table.interpolate(mach).to_bits(),
1042                0.3_f64.to_bits(),
1043                "Mach {mach} must hold the last tabulated Cd"
1044            );
1045        }
1046    }
1047
1048    #[test]
1049    fn test_linear_interpolation() {
1050        let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
1051
1052        // Test linear interpolation between first two points
1053        let result = table.linear_interpolate(0.5, 0);
1054        assert!((result - 0.35).abs() < 1e-10);
1055
1056        // Test edge case with zero denominator
1057        let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
1058        let result_same = table_same.linear_interpolate(1.0, 0);
1059        assert_eq!(result_same, 0.4); // Should return first value
1060    }
1061
1062    #[test]
1063    fn test_cubic_interpolation() {
1064        // Create a table with enough points for cubic interpolation
1065        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]);
1066
1067        // Test cubic interpolation in the middle
1068        let result = table.cubic_interpolate(1.25, 1);
1069
1070        // Should be between the neighboring values
1071        assert!(result > 0.3 && result < 0.7);
1072
1073        // Should be smooth (not exactly linear)
1074        let linear_result = table.linear_interpolate(1.25, 1);
1075        // Cubic and linear should be close but not identical for smooth curves
1076        assert!((result - linear_result).abs() < 0.2);
1077    }
1078
1079    #[test]
1080    fn nonuniform_cubic_reproduces_affine_data() {
1081        let table = DragTable::new(
1082            vec![0.0, 1.0, 3.0, 4.0],
1083            vec![0.25, 0.3125, 0.4375, 0.5],
1084        );
1085
1086        for mach in [1.5, 2.5] {
1087            let expected = 0.25 + mach / 16.0;
1088            let actual = table.interpolate(mach);
1089            assert_eq!(
1090                actual.to_bits(),
1091                expected.to_bits(),
1092                "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
1093            );
1094        }
1095    }
1096
1097    #[test]
1098    fn nonuniform_cubic_is_c1_at_spacing_transition() {
1099        let table = DragTable::new(
1100            vec![0.0, 1.0, 3.0, 4.0, 7.0],
1101            vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
1102        );
1103        let knot = 3.0;
1104        let expected_at_knot = 0.390625_f64;
1105        let epsilon = 1e-6;
1106        let at_knot = table.interpolate(knot);
1107        let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
1108        let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
1109
1110        assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
1111        assert!(
1112            (left_slope - right_slope).abs() < 1e-5,
1113            "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
1114        );
1115    }
1116
1117    #[test]
1118    fn test_find_drag_tables_dir() {
1119        // This test may pass or fail depending on the environment
1120        // but should not panic
1121        let _dir = find_drag_tables_dir();
1122        // Just ensure the function doesn't panic
1123    }
1124
1125    #[test]
1126    fn test_load_drag_table_fallback() {
1127        use std::path::Path;
1128
1129        // Test with non-existent directory - should use fallback data
1130        let fake_dir = Path::new("/non/existent/directory");
1131        let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
1132
1133        let table = load_drag_table(fake_dir, "test", &fallback_data);
1134
1135        // Should have fallback data
1136        assert_eq!(table.mach_values.len(), 3);
1137        assert_eq!(table.cd_values.len(), 3);
1138        assert_eq!(table.mach_values[0], 0.5);
1139        assert_eq!(table.cd_values[0], 0.2);
1140    }
1141
1142    #[test]
1143    fn test_known_drag_values() {
1144        // Test against known ballistic standard values
1145
1146        // G1 at Mach 1.0 should be around 0.4805
1147        let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
1148        assert!(
1149            (g1_mach1 - 0.4805).abs() < 0.01,
1150            "G1 at Mach 1.0: {g1_mach1}"
1151        );
1152
1153        // G7 at Mach 1.0 should be around 0.3803
1154        let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
1155        assert!(
1156            (g7_mach1 - 0.3803).abs() < 0.01,
1157            "G7 at Mach 1.0: {g7_mach1}"
1158        );
1159
1160        // G1 should generally be higher than G7 in transonic region
1161        assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
1162    }
1163
1164    #[test]
1165    fn test_monotonicity_properties() {
1166        // Test general drag curve properties
1167
1168        // G1 should peak somewhere in transonic region
1169        let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); // 0.8 to 1.9
1170        let g1_values: Vec<f64> = mach_values
1171            .iter()
1172            .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1173            .collect();
1174
1175        // Find maximum
1176        let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1177        let max_index = g1_values
1178            .iter()
1179            .position(|&x| x == max_value)
1180            .expect("Should find maximum in non-empty vector");
1181        let peak_mach = mach_values
1182            .get(max_index)
1183            .copied()
1184            .expect("Index should be valid");
1185
1186        // Peak should be in reasonable range
1187        assert!(
1188            peak_mach > 1.0 && peak_mach < 1.6,
1189            "G1 peak at Mach {peak_mach}"
1190        );
1191        assert!(
1192            max_value > 0.5 && max_value < 1.0,
1193            "G1 peak value: {max_value}"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_physical_constraints() {
1199        let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1200
1201        for &mach in &test_machs {
1202            let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1203            let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1204
1205            // All drag coefficients should be positive
1206            assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1207            assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1208
1209            // Should be in reasonable physical ranges
1210            assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1211            assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1212        }
1213    }
1214
1215    /// Catches a CATASTROPHIC regression in the drag lookup -- per-call file IO, re-parsing
1216    /// the table, an allocation storm -- and nothing finer.
1217    ///
1218    /// The bound is deliberately enormous relative to the real cost (2000 interpolations
1219    /// take well under a millisecond in release, and single-digit milliseconds in debug).
1220    /// It used to be 100 ms, which measured the HOST rather than the code: this suite runs
1221    /// in debug on virtualised BSD guests that share a physical machine, and a NetBSD guest
1222    /// co-scheduled with another VM blew the 100 ms bound while every solo run passed. That
1223    /// is a release-blocking flake, because the K3S BSD builders run `cargo test --release
1224    /// --lib` and a failure there costs a fleet re-run.
1225    ///
1226    /// A wall-clock assertion cannot distinguish slow code from a busy machine, so the only
1227    /// threshold worth asserting is one no amount of contention can reach but a genuine
1228    /// order-of-magnitude regression will. Real performance tracking belongs in the criterion
1229    /// benches, which measure against a baseline instead of a constant.
1230    #[test]
1231    fn test_performance_characteristics() {
1232        use std::time::Instant;
1233
1234        let start = Instant::now();
1235
1236        for i in 0..1000 {
1237            let mach = 0.5 + (i as f64) * 0.004; // 0.5 to 4.5
1238            let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1239            let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1240        }
1241
1242        let elapsed = start.elapsed();
1243
1244        assert!(
1245            elapsed.as_secs() < 2,
1246            "2000 drag lookups took {}ms — that is ~{}us per lookup, which means the lookup \
1247             is doing real work (IO, parsing, or allocation) rather than interpolating an \
1248             in-memory table",
1249            elapsed.as_millis(),
1250            elapsed.as_micros() / 2000
1251        );
1252    }
1253
1254    #[test]
1255    fn try_new_accepts_valid_table() {
1256        let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1257        assert_eq!(t.mach_values.len(), 3);
1258    }
1259
1260    #[test]
1261    fn try_new_rejects_mismatched_lengths() {
1262        let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1263        assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1264    }
1265
1266    #[test]
1267    fn try_new_rejects_too_few_points() {
1268        assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1269    }
1270
1271    #[test]
1272    fn try_new_rejects_non_ascending_mach() {
1273        assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1274        assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1275    }
1276
1277    #[test]
1278    fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1279        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1280        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1281    }
1282
1283    #[test]
1284    fn interpolate_does_not_panic_on_mismatched_table() {
1285        // `new` is infallible; a caller could build a bad table. interpolate must not panic.
1286        let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1287        let _ = bad.interpolate(0.1);
1288        let _ = bad.interpolate(5.0);
1289        let _ = bad.interpolate(1.0);
1290    }
1291
1292    #[test]
1293    fn from_csv_str_parses_with_header_and_comments() {
1294        let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1295        let t = DragTable::from_csv_str(csv).unwrap();
1296        assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1297        assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1298    }
1299
1300    #[test]
1301    fn from_csv_str_rejects_malformed_data_row() {
1302        // header skip is allowed once; a bad DATA row must error with a line number.
1303        let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1304        assert!(e.contains("line 2"), "got: {e}");
1305    }
1306
1307    #[test]
1308    fn from_csv_str_rejects_empty() {
1309        assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1310    }
1311
1312    #[test]
1313    fn from_csv_str_rejects_malformed_first_data_row() {
1314        // first column is a valid number => it's data, not a header => must error, not vanish
1315        assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1316        assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1317    }
1318
1319    #[test]
1320    fn from_csv_str_still_skips_textual_header() {
1321        // genuine header (first column non-numeric) is still tolerated
1322        let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1323        assert_eq!(t.mach_values, vec![0.5, 1.0]);
1324    }
1325
1326    #[test]
1327    fn from_csv_str_roundtrips_shipped_g7() {
1328        // The embedded G7 deck must load and validate through the public loader.
1329        let g7 = include_str!("../data/g7.csv");
1330        let t = DragTable::from_csv_str(g7).unwrap();
1331        assert!(t.mach_values.len() > 20);
1332    }
1333
1334    // -- MBA-1386: real G2/G5/GI/GS reference tables + RA4 -----------------------------------
1335
1336    #[test]
1337    fn test_g2_drag_coefficient_spot_values() {
1338        // Exact values copied from data/g2.csv (JBM mcg2.txt).
1339        for (mach, expected) in [(0.0, 0.2303), (0.70, 0.1702), (1.20, 0.4021), (5.0, 0.1648)] {
1340            let cd = get_drag_coefficient(mach, &DragModel::G2);
1341            assert!(
1342                (cd - expected).abs() < 1e-6,
1343                "G2 CD at Mach {mach}: expected {expected}, got {cd}"
1344            );
1345        }
1346    }
1347
1348    #[test]
1349    fn test_g5_drag_coefficient_spot_values() {
1350        // Exact values copied from data/g5.csv (JBM mcg5.txt).
1351        for (mach, expected) in [(0.0, 0.1710), (0.75, 0.1463), (1.0, 0.3379), (5.0, 0.2280)] {
1352            let cd = get_drag_coefficient(mach, &DragModel::G5);
1353            assert!(
1354                (cd - expected).abs() < 1e-6,
1355                "G5 CD at Mach {mach}: expected {expected}, got {cd}"
1356            );
1357        }
1358    }
1359
1360    #[test]
1361    fn test_gi_drag_coefficient_spot_values() {
1362        // Exact values copied from data/gi.csv (JBM mcgi.txt).
1363        for (mach, expected) in [(0.0, 0.2282), (0.90, 0.3170), (1.20, 0.6279), (5.0, 0.4082)] {
1364            let cd = get_drag_coefficient(mach, &DragModel::GI);
1365            assert!(
1366                (cd - expected).abs() < 1e-6,
1367                "GI CD at Mach {mach}: expected {expected}, got {cd}"
1368            );
1369        }
1370    }
1371
1372    #[test]
1373    fn test_gs_drag_coefficient_spot_values() {
1374        // Exact values copied from data/gs.csv (JBM mcgs.txt). Table tops out at Mach 4.0.
1375        for (mach, expected) in [(0.0, 0.4662), (0.60, 0.5260), (1.60, 1.0090), (4.0, 0.9280)] {
1376            let cd = get_drag_coefficient(mach, &DragModel::GS);
1377            assert!(
1378                (cd - expected).abs() < 1e-6,
1379                "GS CD at Mach {mach}: expected {expected}, got {cd}"
1380            );
1381        }
1382    }
1383
1384    #[test]
1385    fn test_ra4_drag_coefficient_spot_values() {
1386        // Exact values copied from data/ra4.csv (JBM ra4.txt). Table tops out at Mach 4.0.
1387        for (mach, expected) in [(0.0, 0.2283), (0.70, 0.2288), (1.15, 0.5943), (4.0, 0.4969)] {
1388            let cd = get_drag_coefficient(mach, &DragModel::RA4);
1389            assert!(
1390                (cd - expected).abs() < 1e-6,
1391                "RA4 CD at Mach {mach}: expected {expected}, got {cd}"
1392            );
1393        }
1394    }
1395
1396    #[test]
1397    fn embedded_family_tables_have_ascending_mach_and_positive_cd() {
1398        // Structural check, via the public DragTable::from_csv_str loader (which enforces
1399        // strictly-ascending Mach and positive Cd through try_new): every embedded reference
1400        // table parses cleanly and has a sane point count.
1401        let decks: [(&str, &str); 7] = [
1402            ("G1", include_str!("../data/g1.csv")),
1403            ("G2", include_str!("../data/g2.csv")),
1404            ("G5", include_str!("../data/g5.csv")),
1405            ("G7", include_str!("../data/g7.csv")),
1406            ("GI", include_str!("../data/gi.csv")),
1407            ("GS", include_str!("../data/gs.csv")),
1408            ("RA4", include_str!("../data/ra4.csv")),
1409        ];
1410        for (name, csv) in decks {
1411            let table =
1412                DragTable::from_csv_str(csv).unwrap_or_else(|e| panic!("{name} failed to parse: {e}"));
1413            assert!(
1414                table.mach_values.len() > 20,
1415                "{name}: expected a high-resolution table, got {} points",
1416                table.mach_values.len()
1417            );
1418            assert_eq!(table.mach_values[0], 0.0, "{name}: expected Mach axis to start at 0.0");
1419        }
1420
1421        // Also sweep the full public get_drag_coefficient API across all 9 families (including
1422        // the runtime-loaded G6/G8) and confirm Cd stays finite and positive everywhere.
1423        let models = [
1424            DragModel::G1,
1425            DragModel::G2,
1426            DragModel::G5,
1427            DragModel::G6,
1428            DragModel::G7,
1429            DragModel::G8,
1430            DragModel::GI,
1431            DragModel::GS,
1432            DragModel::RA4,
1433        ];
1434        for model in models {
1435            for i in 0..=50 {
1436                let mach = i as f64 * 0.1; // 0.0 ..= 5.0
1437                let cd = get_drag_coefficient(mach, &model);
1438                assert!(
1439                    cd.is_finite() && cd > 0.0 && cd < 2.0,
1440                    "{model:?} CD out of range at Mach {mach}: {cd}"
1441                );
1442            }
1443        }
1444    }
1445
1446    /// Interpolate the trajectory height (position.y) at a given downrange distance,
1447    /// holding the nearest endpoint if the trajectory never reaches it. Mirrors the
1448    /// interpolation TrajectorySolver uses internally for zero-angle trials.
1449    fn interpolated_drop_at(points: &[crate::TrajectoryPoint], target_x_m: f64) -> f64 {
1450        for (i, point) in points.iter().enumerate() {
1451            if point.position.x >= target_x_m {
1452                if i == 0 {
1453                    return point.position.y;
1454                }
1455                let previous = &points[i - 1];
1456                let span = point.position.x - previous.position.x;
1457                if span.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1458                    return point.position.y;
1459                }
1460                let fraction = (target_x_m - previous.position.x) / span;
1461                return previous.position.y + fraction * (point.position.y - previous.position.y);
1462            }
1463        }
1464        points.last().map(|p| p.position.y).unwrap_or(f64::NAN)
1465    }
1466
1467    fn drop_at_500yd_m(model: DragModel) -> f64 {
1468        let inputs = crate::BallisticInputs {
1469            bc_type: model,
1470            bc_value: 0.5,
1471            ground_threshold: -1000.0,
1472            ..Default::default()
1473        };
1474        let solver = crate::TrajectorySolver::new(
1475            inputs,
1476            crate::WindConditions::default(),
1477            crate::AtmosphericConditions::default(),
1478        );
1479        let result = solver.solve().expect("solve should succeed");
1480        assert!(result.max_range.is_finite());
1481        assert!(result.impact_velocity.is_finite() && result.impact_velocity > 0.0);
1482        assert!(!result.points.is_empty(), "{model:?}: solver produced no points");
1483        let drop = interpolated_drop_at(&result.points, 457.2); // 500 yards
1484        assert!(drop.is_finite(), "{model:?}: non-finite drop at 500yd");
1485        drop
1486    }
1487
1488    #[test]
1489    fn mba1386_g2_g5_gi_gs_drop_differs_from_g1_now_that_fallback_is_gone() {
1490        let g1_drop = drop_at_500yd_m(DragModel::G1);
1491        for model in [DragModel::G2, DragModel::G5, DragModel::GI, DragModel::GS] {
1492            let drop = drop_at_500yd_m(model);
1493            assert!(
1494                (drop - g1_drop).abs() > 0.01,
1495                "{model:?} 500yd drop ({drop}) must differ from the G1 result ({g1_drop}) by \
1496                 more than solver noise now that the real table is wired in"
1497            );
1498        }
1499    }
1500
1501    #[test]
1502    fn mba1386_ra4_solver_smoke() {
1503        // RA4 never fell back to G1 (it's a new variant), so just prove it solves sanely.
1504        let drop = drop_at_500yd_m(DragModel::RA4);
1505        assert!(drop < 0.0, "500yd drop should be a fall below the muzzle line: {drop}");
1506    }
1507}
1508
1509/// Interpolate BC value for given Mach number from segments
1510pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1511    if segments.is_empty() {
1512        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1513    }
1514
1515    // Get just the mach values
1516    let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1517
1518    // Double-check we have values after collection
1519    if mach_values.is_empty() || segments.is_empty() {
1520        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1521    }
1522
1523    // Handle edge cases with safe indexing
1524    if let Some(first_mach) = mach_values.first() {
1525        if mach <= *first_mach {
1526            return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1527        }
1528    }
1529
1530    if let Some(last_mach) = mach_values.last() {
1531        if mach >= *last_mach {
1532            return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1533        }
1534    }
1535
1536    // Binary search to find the right segment with safe comparison
1537    let idx = match mach_values
1538        .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1539    {
1540        Ok(idx) => {
1541            // Exact match - safely get the BC value
1542            return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1543        }
1544        Err(idx) => idx, // Insert position
1545    };
1546
1547    // Ensure idx is valid for interpolation
1548    if idx == 0 || idx >= segments.len() {
1549        // Shouldn't happen given the edge case checks above, but be defensive
1550        // Use safe indexing
1551        let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1552        return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1553    }
1554
1555    // Linear interpolation between the two closest points with safe indexing
1556    match (segments.get(idx - 1), segments.get(idx)) {
1557        (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1558            // Ensure denominator is not zero for safe interpolation
1559            let denominator = hi_mach - lo_mach;
1560            if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1561                return *lo_bc; // Return lower BC if Mach values are too close
1562            }
1563            let frac = (mach - lo_mach) / denominator;
1564            lo_bc + frac * (hi_bc - lo_bc)
1565        }
1566        _ => 0.5, // Fallback if indices are somehow invalid
1567    }
1568}
1569
1570// Removed Python-specific function
1571
1572/// MBA-1424: the built-in reference tables exposed as data.
1573#[cfg(test)]
1574mod reference_drag_table_tests {
1575    use super::*;
1576
1577    const ALL_MODELS: [DragModel; 9] = [
1578        DragModel::G1,
1579        DragModel::G2,
1580        DragModel::G5,
1581        DragModel::G6,
1582        DragModel::G7,
1583        DragModel::G8,
1584        DragModel::GI,
1585        DragModel::GS,
1586        DragModel::RA4,
1587    ];
1588
1589    /// The reason this accessor exists is so a consumer's chart cannot drift from the engine.
1590    /// That only holds if the data handed out is the data interpolated, so assert it directly at
1591    /// every tabulated point rather than trusting that both read the same static.
1592    #[test]
1593    fn the_exposed_table_is_the_one_the_solver_interpolates() {
1594        for model in ALL_MODELS {
1595            let table = reference_drag_table(&model);
1596            for (&mach, &cd) in table.mach_values.iter().zip(table.cd_values.iter()) {
1597                let interpolated = get_drag_coefficient(mach, &model);
1598                assert!(
1599                    (interpolated - cd).abs() < 1e-12,
1600                    "{model:?} disagrees at Mach {mach}: table {cd} vs solver {interpolated}"
1601                );
1602            }
1603        }
1604    }
1605
1606    #[test]
1607    fn every_model_has_a_usable_table() {
1608        for model in ALL_MODELS {
1609            let table = reference_drag_table(&model);
1610            assert_eq!(
1611                table.mach_values.len(),
1612                table.cd_values.len(),
1613                "{model:?} axes differ in length"
1614            );
1615            assert!(
1616                table.mach_values.len() > 2,
1617                "{model:?} fell back to a stub table"
1618            );
1619            assert!(
1620                table.mach_values.windows(2).all(|w| w[1] > w[0]),
1621                "{model:?} Mach axis is not strictly ascending"
1622            );
1623            assert!(
1624                table.cd_values.iter().all(|cd| cd.is_finite() && *cd > 0.0),
1625                "{model:?} has a non-physical Cd"
1626            );
1627        }
1628    }
1629
1630    /// The tables genuinely do not share a domain, which is why the accessor's contract is "read
1631    /// mach_values" rather than "assume 0 to 5". If this ever stops being true the docs and the
1632    /// CLI help that both say so should change with it.
1633    #[test]
1634    fn the_mach_domain_is_per_table_not_universal() {
1635        let g7_max = *reference_drag_table(&DragModel::G7).mach_values.last().unwrap();
1636        let gs_max = *reference_drag_table(&DragModel::GS).mach_values.last().unwrap();
1637        let ra4_max = *reference_drag_table(&DragModel::RA4).mach_values.last().unwrap();
1638
1639        assert!(g7_max > gs_max, "G7 should extend past GS ({g7_max} vs {gs_max})");
1640        assert!(g7_max > ra4_max, "G7 should extend past RA4 ({g7_max} vs {ra4_max})");
1641    }
1642}