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    #[test]
1216    fn test_performance_characteristics() {
1217        // This test ensures the implementation is efficient
1218        use std::time::Instant;
1219
1220        let start = Instant::now();
1221
1222        // Perform many calculations
1223        for i in 0..1000 {
1224            let mach = 0.5 + (i as f64) * 0.004; // 0.5 to 4.5
1225            let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1226            let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1227        }
1228
1229        let elapsed = start.elapsed();
1230
1231        // Should complete 2000 calculations quickly (within 100ms)
1232        assert!(
1233            elapsed.as_millis() < 100,
1234            "Performance test took {}ms",
1235            elapsed.as_millis()
1236        );
1237    }
1238
1239    #[test]
1240    fn try_new_accepts_valid_table() {
1241        let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1242        assert_eq!(t.mach_values.len(), 3);
1243    }
1244
1245    #[test]
1246    fn try_new_rejects_mismatched_lengths() {
1247        let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1248        assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1249    }
1250
1251    #[test]
1252    fn try_new_rejects_too_few_points() {
1253        assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1254    }
1255
1256    #[test]
1257    fn try_new_rejects_non_ascending_mach() {
1258        assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1259        assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1260    }
1261
1262    #[test]
1263    fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1264        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1265        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1266    }
1267
1268    #[test]
1269    fn interpolate_does_not_panic_on_mismatched_table() {
1270        // `new` is infallible; a caller could build a bad table. interpolate must not panic.
1271        let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1272        let _ = bad.interpolate(0.1);
1273        let _ = bad.interpolate(5.0);
1274        let _ = bad.interpolate(1.0);
1275    }
1276
1277    #[test]
1278    fn from_csv_str_parses_with_header_and_comments() {
1279        let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1280        let t = DragTable::from_csv_str(csv).unwrap();
1281        assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1282        assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1283    }
1284
1285    #[test]
1286    fn from_csv_str_rejects_malformed_data_row() {
1287        // header skip is allowed once; a bad DATA row must error with a line number.
1288        let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1289        assert!(e.contains("line 2"), "got: {e}");
1290    }
1291
1292    #[test]
1293    fn from_csv_str_rejects_empty() {
1294        assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1295    }
1296
1297    #[test]
1298    fn from_csv_str_rejects_malformed_first_data_row() {
1299        // first column is a valid number => it's data, not a header => must error, not vanish
1300        assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1301        assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1302    }
1303
1304    #[test]
1305    fn from_csv_str_still_skips_textual_header() {
1306        // genuine header (first column non-numeric) is still tolerated
1307        let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1308        assert_eq!(t.mach_values, vec![0.5, 1.0]);
1309    }
1310
1311    #[test]
1312    fn from_csv_str_roundtrips_shipped_g7() {
1313        // The embedded G7 deck must load and validate through the public loader.
1314        let g7 = include_str!("../data/g7.csv");
1315        let t = DragTable::from_csv_str(g7).unwrap();
1316        assert!(t.mach_values.len() > 20);
1317    }
1318
1319    // -- MBA-1386: real G2/G5/GI/GS reference tables + RA4 -----------------------------------
1320
1321    #[test]
1322    fn test_g2_drag_coefficient_spot_values() {
1323        // Exact values copied from data/g2.csv (JBM mcg2.txt).
1324        for (mach, expected) in [(0.0, 0.2303), (0.70, 0.1702), (1.20, 0.4021), (5.0, 0.1648)] {
1325            let cd = get_drag_coefficient(mach, &DragModel::G2);
1326            assert!(
1327                (cd - expected).abs() < 1e-6,
1328                "G2 CD at Mach {mach}: expected {expected}, got {cd}"
1329            );
1330        }
1331    }
1332
1333    #[test]
1334    fn test_g5_drag_coefficient_spot_values() {
1335        // Exact values copied from data/g5.csv (JBM mcg5.txt).
1336        for (mach, expected) in [(0.0, 0.1710), (0.75, 0.1463), (1.0, 0.3379), (5.0, 0.2280)] {
1337            let cd = get_drag_coefficient(mach, &DragModel::G5);
1338            assert!(
1339                (cd - expected).abs() < 1e-6,
1340                "G5 CD at Mach {mach}: expected {expected}, got {cd}"
1341            );
1342        }
1343    }
1344
1345    #[test]
1346    fn test_gi_drag_coefficient_spot_values() {
1347        // Exact values copied from data/gi.csv (JBM mcgi.txt).
1348        for (mach, expected) in [(0.0, 0.2282), (0.90, 0.3170), (1.20, 0.6279), (5.0, 0.4082)] {
1349            let cd = get_drag_coefficient(mach, &DragModel::GI);
1350            assert!(
1351                (cd - expected).abs() < 1e-6,
1352                "GI CD at Mach {mach}: expected {expected}, got {cd}"
1353            );
1354        }
1355    }
1356
1357    #[test]
1358    fn test_gs_drag_coefficient_spot_values() {
1359        // Exact values copied from data/gs.csv (JBM mcgs.txt). Table tops out at Mach 4.0.
1360        for (mach, expected) in [(0.0, 0.4662), (0.60, 0.5260), (1.60, 1.0090), (4.0, 0.9280)] {
1361            let cd = get_drag_coefficient(mach, &DragModel::GS);
1362            assert!(
1363                (cd - expected).abs() < 1e-6,
1364                "GS CD at Mach {mach}: expected {expected}, got {cd}"
1365            );
1366        }
1367    }
1368
1369    #[test]
1370    fn test_ra4_drag_coefficient_spot_values() {
1371        // Exact values copied from data/ra4.csv (JBM ra4.txt). Table tops out at Mach 4.0.
1372        for (mach, expected) in [(0.0, 0.2283), (0.70, 0.2288), (1.15, 0.5943), (4.0, 0.4969)] {
1373            let cd = get_drag_coefficient(mach, &DragModel::RA4);
1374            assert!(
1375                (cd - expected).abs() < 1e-6,
1376                "RA4 CD at Mach {mach}: expected {expected}, got {cd}"
1377            );
1378        }
1379    }
1380
1381    #[test]
1382    fn embedded_family_tables_have_ascending_mach_and_positive_cd() {
1383        // Structural check, via the public DragTable::from_csv_str loader (which enforces
1384        // strictly-ascending Mach and positive Cd through try_new): every embedded reference
1385        // table parses cleanly and has a sane point count.
1386        let decks: [(&str, &str); 7] = [
1387            ("G1", include_str!("../data/g1.csv")),
1388            ("G2", include_str!("../data/g2.csv")),
1389            ("G5", include_str!("../data/g5.csv")),
1390            ("G7", include_str!("../data/g7.csv")),
1391            ("GI", include_str!("../data/gi.csv")),
1392            ("GS", include_str!("../data/gs.csv")),
1393            ("RA4", include_str!("../data/ra4.csv")),
1394        ];
1395        for (name, csv) in decks {
1396            let table =
1397                DragTable::from_csv_str(csv).unwrap_or_else(|e| panic!("{name} failed to parse: {e}"));
1398            assert!(
1399                table.mach_values.len() > 20,
1400                "{name}: expected a high-resolution table, got {} points",
1401                table.mach_values.len()
1402            );
1403            assert_eq!(table.mach_values[0], 0.0, "{name}: expected Mach axis to start at 0.0");
1404        }
1405
1406        // Also sweep the full public get_drag_coefficient API across all 9 families (including
1407        // the runtime-loaded G6/G8) and confirm Cd stays finite and positive everywhere.
1408        let models = [
1409            DragModel::G1,
1410            DragModel::G2,
1411            DragModel::G5,
1412            DragModel::G6,
1413            DragModel::G7,
1414            DragModel::G8,
1415            DragModel::GI,
1416            DragModel::GS,
1417            DragModel::RA4,
1418        ];
1419        for model in models {
1420            for i in 0..=50 {
1421                let mach = i as f64 * 0.1; // 0.0 ..= 5.0
1422                let cd = get_drag_coefficient(mach, &model);
1423                assert!(
1424                    cd.is_finite() && cd > 0.0 && cd < 2.0,
1425                    "{model:?} CD out of range at Mach {mach}: {cd}"
1426                );
1427            }
1428        }
1429    }
1430
1431    /// Interpolate the trajectory height (position.y) at a given downrange distance,
1432    /// holding the nearest endpoint if the trajectory never reaches it. Mirrors the
1433    /// interpolation TrajectorySolver uses internally for zero-angle trials.
1434    fn interpolated_drop_at(points: &[crate::TrajectoryPoint], target_x_m: f64) -> f64 {
1435        for (i, point) in points.iter().enumerate() {
1436            if point.position.x >= target_x_m {
1437                if i == 0 {
1438                    return point.position.y;
1439                }
1440                let previous = &points[i - 1];
1441                let span = point.position.x - previous.position.x;
1442                if span.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1443                    return point.position.y;
1444                }
1445                let fraction = (target_x_m - previous.position.x) / span;
1446                return previous.position.y + fraction * (point.position.y - previous.position.y);
1447            }
1448        }
1449        points.last().map(|p| p.position.y).unwrap_or(f64::NAN)
1450    }
1451
1452    fn drop_at_500yd_m(model: DragModel) -> f64 {
1453        let inputs = crate::BallisticInputs {
1454            bc_type: model,
1455            bc_value: 0.5,
1456            ground_threshold: -1000.0,
1457            ..Default::default()
1458        };
1459        let solver = crate::TrajectorySolver::new(
1460            inputs,
1461            crate::WindConditions::default(),
1462            crate::AtmosphericConditions::default(),
1463        );
1464        let result = solver.solve().expect("solve should succeed");
1465        assert!(result.max_range.is_finite());
1466        assert!(result.impact_velocity.is_finite() && result.impact_velocity > 0.0);
1467        assert!(!result.points.is_empty(), "{model:?}: solver produced no points");
1468        let drop = interpolated_drop_at(&result.points, 457.2); // 500 yards
1469        assert!(drop.is_finite(), "{model:?}: non-finite drop at 500yd");
1470        drop
1471    }
1472
1473    #[test]
1474    fn mba1386_g2_g5_gi_gs_drop_differs_from_g1_now_that_fallback_is_gone() {
1475        let g1_drop = drop_at_500yd_m(DragModel::G1);
1476        for model in [DragModel::G2, DragModel::G5, DragModel::GI, DragModel::GS] {
1477            let drop = drop_at_500yd_m(model);
1478            assert!(
1479                (drop - g1_drop).abs() > 0.01,
1480                "{model:?} 500yd drop ({drop}) must differ from the G1 result ({g1_drop}) by \
1481                 more than solver noise now that the real table is wired in"
1482            );
1483        }
1484    }
1485
1486    #[test]
1487    fn mba1386_ra4_solver_smoke() {
1488        // RA4 never fell back to G1 (it's a new variant), so just prove it solves sanely.
1489        let drop = drop_at_500yd_m(DragModel::RA4);
1490        assert!(drop < 0.0, "500yd drop should be a fall below the muzzle line: {drop}");
1491    }
1492}
1493
1494/// Interpolate BC value for given Mach number from segments
1495pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1496    if segments.is_empty() {
1497        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1498    }
1499
1500    // Get just the mach values
1501    let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1502
1503    // Double-check we have values after collection
1504    if mach_values.is_empty() || segments.is_empty() {
1505        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1506    }
1507
1508    // Handle edge cases with safe indexing
1509    if let Some(first_mach) = mach_values.first() {
1510        if mach <= *first_mach {
1511            return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1512        }
1513    }
1514
1515    if let Some(last_mach) = mach_values.last() {
1516        if mach >= *last_mach {
1517            return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1518        }
1519    }
1520
1521    // Binary search to find the right segment with safe comparison
1522    let idx = match mach_values
1523        .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1524    {
1525        Ok(idx) => {
1526            // Exact match - safely get the BC value
1527            return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1528        }
1529        Err(idx) => idx, // Insert position
1530    };
1531
1532    // Ensure idx is valid for interpolation
1533    if idx == 0 || idx >= segments.len() {
1534        // Shouldn't happen given the edge case checks above, but be defensive
1535        // Use safe indexing
1536        let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1537        return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1538    }
1539
1540    // Linear interpolation between the two closest points with safe indexing
1541    match (segments.get(idx - 1), segments.get(idx)) {
1542        (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1543            // Ensure denominator is not zero for safe interpolation
1544            let denominator = hi_mach - lo_mach;
1545            if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1546                return *lo_bc; // Return lower BC if Mach values are too close
1547            }
1548            let frac = (mach - lo_mach) / denominator;
1549            lo_bc + frac * (hi_bc - lo_bc)
1550        }
1551        _ => 0.5, // Fallback if indices are somehow invalid
1552    }
1553}
1554
1555// Removed Python-specific function
1556
1557/// MBA-1424: the built-in reference tables exposed as data.
1558#[cfg(test)]
1559mod reference_drag_table_tests {
1560    use super::*;
1561
1562    const ALL_MODELS: [DragModel; 9] = [
1563        DragModel::G1,
1564        DragModel::G2,
1565        DragModel::G5,
1566        DragModel::G6,
1567        DragModel::G7,
1568        DragModel::G8,
1569        DragModel::GI,
1570        DragModel::GS,
1571        DragModel::RA4,
1572    ];
1573
1574    /// The reason this accessor exists is so a consumer's chart cannot drift from the engine.
1575    /// That only holds if the data handed out is the data interpolated, so assert it directly at
1576    /// every tabulated point rather than trusting that both read the same static.
1577    #[test]
1578    fn the_exposed_table_is_the_one_the_solver_interpolates() {
1579        for model in ALL_MODELS {
1580            let table = reference_drag_table(&model);
1581            for (&mach, &cd) in table.mach_values.iter().zip(table.cd_values.iter()) {
1582                let interpolated = get_drag_coefficient(mach, &model);
1583                assert!(
1584                    (interpolated - cd).abs() < 1e-12,
1585                    "{model:?} disagrees at Mach {mach}: table {cd} vs solver {interpolated}"
1586                );
1587            }
1588        }
1589    }
1590
1591    #[test]
1592    fn every_model_has_a_usable_table() {
1593        for model in ALL_MODELS {
1594            let table = reference_drag_table(&model);
1595            assert_eq!(
1596                table.mach_values.len(),
1597                table.cd_values.len(),
1598                "{model:?} axes differ in length"
1599            );
1600            assert!(
1601                table.mach_values.len() > 2,
1602                "{model:?} fell back to a stub table"
1603            );
1604            assert!(
1605                table.mach_values.windows(2).all(|w| w[1] > w[0]),
1606                "{model:?} Mach axis is not strictly ascending"
1607            );
1608            assert!(
1609                table.cd_values.iter().all(|cd| cd.is_finite() && *cd > 0.0),
1610                "{model:?} has a non-physical Cd"
1611            );
1612        }
1613    }
1614
1615    /// The tables genuinely do not share a domain, which is why the accessor's contract is "read
1616    /// mach_values" rather than "assume 0 to 5". If this ever stops being true the docs and the
1617    /// CLI help that both say so should change with it.
1618    #[test]
1619    fn the_mach_domain_is_per_table_not_universal() {
1620        let g7_max = *reference_drag_table(&DragModel::G7).mach_values.last().unwrap();
1621        let gs_max = *reference_drag_table(&DragModel::GS).mach_values.last().unwrap();
1622        let ra4_max = *reference_drag_table(&DragModel::RA4).mach_values.last().unwrap();
1623
1624        assert!(g7_max > gs_max, "G7 should extend past GS ({g7_max} vs {gs_max})");
1625        assert!(g7_max > ra4_max, "G7 should extend past RA4 ({g7_max} vs {ra4_max})");
1626    }
1627}