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/// Get drag coefficient for given Mach number and drag model.
605///
606/// NOTE: only G1/G6/G7/G8 have dedicated tables. G2/G5/GI/GS currently fall back to the G1
607/// curve (no tables shipped yet), so callers requesting those models receive a G1
608/// approximation that is physically inaccurate (e.g. GS is the spherical/round-ball model).
609/// The fallback is made explicit below — rather than a silent `_` catch-all — so adding a new
610/// `DragModel` variant is a compile error until it is handled, and so the approximation is
611/// visible. Supplying real G2/G5/GI/GS tables is tracked separately.
612pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
613    match drag_model {
614        DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
615        DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
616        DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
617        DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
618        // No dedicated tables yet — approximate with the G1 curve (flagged, see note above).
619        DragModel::G2 | DragModel::G5 | DragModel::GI | DragModel::GS => {
620            G1_DRAG_TABLE.interpolate(mach)
621        }
622    }
623}
624
625/// Get a standard G-table drag coefficient without double-counting transonic drag.
626///
627/// Standard G tables are total-drag curves that already contain the transonic
628/// rise and wave drag. `apply_transonic_correction` and the shape inputs remain
629/// in this public API for compatibility, but enabling the option does not stack
630/// the separate empirical rise/wave model on top of a G-table coefficient.
631pub fn get_drag_coefficient_with_transonic(
632    mach: f64,
633    drag_model: &DragModel,
634    apply_transonic_correction: bool,
635    projectile_shape: Option<ProjectileShape>,
636    caliber: Option<f64>,
637    weight_grains: Option<f64>,
638) -> f64 {
639    // Get base drag coefficient
640    let base_cd = get_drag_coefficient(mach, drag_model);
641
642    // Apply transonic corrections if requested and in transonic regime
643    if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
644        // Determine projectile shape if not provided
645        let shape = match projectile_shape {
646            Some(s) => s,
647            None => {
648                if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
649                    get_projectile_shape(
650                        cal,
651                        weight,
652                        match drag_model {
653                            DragModel::G1 => "G1",
654                            DragModel::G6 => "G6",
655                            DragModel::G7 => "G7",
656                            DragModel::G8 => "G8",
657                            _ => "G1", // Default to G1
658                        },
659                    )
660                } else {
661                    ProjectileShape::Spitzer // Default
662                }
663            }
664        };
665
666        // Standard G-model tables are total-drag reference curves and already
667        // contain their transonic rise and wave drag. Retain the public option
668        // for API compatibility, but do not stack the empirical rise/wave model
669        // on top of table Cd (MBA-1155).
670        transonic_correction(mach, base_cd, shape, false)
671    } else {
672        base_cd
673    }
674}
675
676/// Get drag coefficient with optional Reynolds correction.
677///
678/// The transonic option is retained for compatibility but, as documented by
679/// [`get_drag_coefficient_with_transonic`], standard G tables are not corrected
680/// a second time. Likewise, the Reynolds option only affects genuinely low-Re
681/// (`Re < 10,000`) inputs; ordinary ballistic Reynolds numbers use the standard
682/// table coefficient unchanged.
683#[allow(clippy::too_many_arguments)] // Public compatibility API; grouping would be breaking.
684pub fn get_drag_coefficient_full(
685    mach: f64,
686    drag_model: &DragModel,
687    apply_transonic_correction: bool,
688    apply_reynolds_correction: bool,
689    projectile_shape: Option<ProjectileShape>,
690    caliber: Option<f64>,
691    weight_grains: Option<f64>,
692    velocity_mps: Option<f64>,
693    air_density_kg_m3: Option<f64>,
694    temperature_c: Option<f64>,
695) -> f64 {
696    // Get base drag coefficient with transonic corrections if applicable
697    let mut cd = get_drag_coefficient_with_transonic(
698        mach,
699        drag_model,
700        apply_transonic_correction,
701        projectile_shape,
702        caliber,
703        weight_grains,
704    );
705
706    // Route the opt-in low-Re helper for subsonic inputs. It leaves the ordinary
707    // standard-table Reynolds-number range unchanged.
708    if apply_reynolds_correction && mach < 1.0 {
709        if let (Some(v), Some(cal), Some(rho), Some(temp)) =
710            (velocity_mps, caliber, air_density_kg_m3, temperature_c)
711        {
712            use crate::reynolds::apply_reynolds_correction;
713            cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
714        }
715    }
716
717    cd
718}
719
720#[cfg(test)]
721#[allow(clippy::items_after_test_module)] // Keep the legacy public helper below in place.
722mod tests {
723    use super::*;
724
725    #[test]
726    fn test_g1_drag_coefficient_interpolation() {
727        let cd = get_drag_coefficient(1.0, &DragModel::G1);
728        // Should be close to the G1 standard value at Mach 1.0
729        assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
730    }
731
732    #[test]
733    fn test_g7_drag_coefficient_interpolation() {
734        let cd = get_drag_coefficient(1.0, &DragModel::G7);
735        // Should be close to the G7 standard value at Mach 1.0
736        assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
737    }
738
739    #[test]
740    fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
741        let models = [
742            DragModel::G1,
743            DragModel::G2,
744            DragModel::G5,
745            DragModel::G6,
746            DragModel::G7,
747            DragModel::G8,
748            DragModel::GI,
749            DragModel::GS,
750        ];
751        for drag_model in models {
752            for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
753                let base_cd = get_drag_coefficient(mach, &drag_model);
754                let corrected_cd = get_drag_coefficient_with_transonic(
755                    mach,
756                    &drag_model,
757                    true,
758                    Some(ProjectileShape::BoatTail),
759                    Some(0.308),
760                    Some(175.0),
761                );
762                assert_eq!(
763                    corrected_cd.to_bits(),
764                    base_cd.to_bits(),
765                    "standard {drag_model:?} table already includes transonic drag at Mach \
766                     {mach}: base={base_cd}, corrected={corrected_cd}"
767                );
768
769                let full_cd = get_drag_coefficient_full(
770                    mach,
771                    &drag_model,
772                    true,
773                    false,
774                    Some(ProjectileShape::BoatTail),
775                    Some(0.308),
776                    Some(175.0),
777                    None,
778                    None,
779                    None,
780                );
781                assert_eq!(full_cd.to_bits(), base_cd.to_bits());
782            }
783        }
784    }
785
786    #[test]
787    fn test_drag_coefficient_continuity() {
788        // Test that drag coefficient function is smooth
789        for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
790            let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
791            let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
792            let difference = (cd_after - cd_before).abs();
793            assert!(
794                difference < 0.05,
795                "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
796            );
797        }
798    }
799
800    #[test]
801    fn test_endpoint_bounds() {
802        // Test endpoint hold below range
803        let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
804        assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
805
806        // Test endpoint hold above range
807        let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
808        assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
809
810        // Same for G7
811        let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
812        assert!(
813            cd_low_g7 > 0.01,
814            "Low Mach G7 should be positive: {cd_low_g7}"
815        );
816
817        let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
818        assert!(
819            cd_high_g7 >= 0.01,
820            "High Mach G7 should be positive: {cd_high_g7}"
821        );
822    }
823
824    #[test]
825    fn test_drag_table_creation() {
826        let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
827        let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
828        let table = DragTable::new(mach_vals, cd_vals);
829
830        // Test exact interpolation
831        assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
832
833        // Test interpolation between points
834        let cd_interp = table.interpolate(1.25);
835        assert!(cd_interp > 0.4 && cd_interp < 0.5);
836    }
837
838    #[test]
839    fn test_drag_table_empty() {
840        let table = DragTable::new(vec![], vec![]);
841        let result = table.interpolate(1.0);
842        assert_eq!(result, 0.5); // Should return fallback value
843    }
844
845    #[test]
846    fn test_drag_table_single_point() {
847        let table = DragTable::new(vec![1.0], vec![0.4]);
848
849        // Should return the single value for any Mach
850        assert_eq!(table.interpolate(0.5), 0.4);
851        assert_eq!(table.interpolate(1.0), 0.4);
852        assert_eq!(table.interpolate(2.0), 0.4);
853    }
854
855    #[test]
856    fn test_drag_table_two_points() {
857        let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
858
859        // Exact matches
860        assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
861        assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
862
863        // Linear interpolation
864        let mid = table.interpolate(1.5);
865        assert!((mid - 0.5).abs() < 1e-10);
866
867        // Out-of-range values hold the nearest endpoint.
868        let below = table.interpolate(0.5);
869        assert_eq!(below.to_bits(), 0.4_f64.to_bits());
870
871        let above = table.interpolate(3.0);
872        assert_eq!(above.to_bits(), 0.6_f64.to_bits());
873    }
874
875    #[test]
876    fn out_of_range_mach_holds_boundary_cd() {
877        let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
878
879        for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
880            assert_eq!(
881                table.interpolate(mach).to_bits(),
882                0.2_f64.to_bits(),
883                "Mach {mach} must hold the first tabulated Cd"
884            );
885        }
886        for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
887            assert_eq!(
888                table.interpolate(mach).to_bits(),
889                0.3_f64.to_bits(),
890                "Mach {mach} must hold the last tabulated Cd"
891            );
892        }
893    }
894
895    #[test]
896    fn test_linear_interpolation() {
897        let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
898
899        // Test linear interpolation between first two points
900        let result = table.linear_interpolate(0.5, 0);
901        assert!((result - 0.35).abs() < 1e-10);
902
903        // Test edge case with zero denominator
904        let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
905        let result_same = table_same.linear_interpolate(1.0, 0);
906        assert_eq!(result_same, 0.4); // Should return first value
907    }
908
909    #[test]
910    fn test_cubic_interpolation() {
911        // Create a table with enough points for cubic interpolation
912        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]);
913
914        // Test cubic interpolation in the middle
915        let result = table.cubic_interpolate(1.25, 1);
916
917        // Should be between the neighboring values
918        assert!(result > 0.3 && result < 0.7);
919
920        // Should be smooth (not exactly linear)
921        let linear_result = table.linear_interpolate(1.25, 1);
922        // Cubic and linear should be close but not identical for smooth curves
923        assert!((result - linear_result).abs() < 0.2);
924    }
925
926    #[test]
927    fn nonuniform_cubic_reproduces_affine_data() {
928        let table = DragTable::new(
929            vec![0.0, 1.0, 3.0, 4.0],
930            vec![0.25, 0.3125, 0.4375, 0.5],
931        );
932
933        for mach in [1.5, 2.5] {
934            let expected = 0.25 + mach / 16.0;
935            let actual = table.interpolate(mach);
936            assert_eq!(
937                actual.to_bits(),
938                expected.to_bits(),
939                "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
940            );
941        }
942    }
943
944    #[test]
945    fn nonuniform_cubic_is_c1_at_spacing_transition() {
946        let table = DragTable::new(
947            vec![0.0, 1.0, 3.0, 4.0, 7.0],
948            vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
949        );
950        let knot = 3.0;
951        let expected_at_knot = 0.390625_f64;
952        let epsilon = 1e-6;
953        let at_knot = table.interpolate(knot);
954        let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
955        let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
956
957        assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
958        assert!(
959            (left_slope - right_slope).abs() < 1e-5,
960            "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
961        );
962    }
963
964    #[test]
965    fn test_find_drag_tables_dir() {
966        // This test may pass or fail depending on the environment
967        // but should not panic
968        let _dir = find_drag_tables_dir();
969        // Just ensure the function doesn't panic
970    }
971
972    #[test]
973    fn test_load_drag_table_fallback() {
974        use std::path::Path;
975
976        // Test with non-existent directory - should use fallback data
977        let fake_dir = Path::new("/non/existent/directory");
978        let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
979
980        let table = load_drag_table(fake_dir, "test", &fallback_data);
981
982        // Should have fallback data
983        assert_eq!(table.mach_values.len(), 3);
984        assert_eq!(table.cd_values.len(), 3);
985        assert_eq!(table.mach_values[0], 0.5);
986        assert_eq!(table.cd_values[0], 0.2);
987    }
988
989    #[test]
990    fn test_known_drag_values() {
991        // Test against known ballistic standard values
992
993        // G1 at Mach 1.0 should be around 0.4805
994        let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
995        assert!(
996            (g1_mach1 - 0.4805).abs() < 0.01,
997            "G1 at Mach 1.0: {g1_mach1}"
998        );
999
1000        // G7 at Mach 1.0 should be around 0.3803
1001        let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
1002        assert!(
1003            (g7_mach1 - 0.3803).abs() < 0.01,
1004            "G7 at Mach 1.0: {g7_mach1}"
1005        );
1006
1007        // G1 should generally be higher than G7 in transonic region
1008        assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
1009    }
1010
1011    #[test]
1012    fn test_monotonicity_properties() {
1013        // Test general drag curve properties
1014
1015        // G1 should peak somewhere in transonic region
1016        let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); // 0.8 to 1.9
1017        let g1_values: Vec<f64> = mach_values
1018            .iter()
1019            .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1020            .collect();
1021
1022        // Find maximum
1023        let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1024        let max_index = g1_values
1025            .iter()
1026            .position(|&x| x == max_value)
1027            .expect("Should find maximum in non-empty vector");
1028        let peak_mach = mach_values
1029            .get(max_index)
1030            .copied()
1031            .expect("Index should be valid");
1032
1033        // Peak should be in reasonable range
1034        assert!(
1035            peak_mach > 1.0 && peak_mach < 1.6,
1036            "G1 peak at Mach {peak_mach}"
1037        );
1038        assert!(
1039            max_value > 0.5 && max_value < 1.0,
1040            "G1 peak value: {max_value}"
1041        );
1042    }
1043
1044    #[test]
1045    fn test_physical_constraints() {
1046        let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1047
1048        for &mach in &test_machs {
1049            let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1050            let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1051
1052            // All drag coefficients should be positive
1053            assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1054            assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1055
1056            // Should be in reasonable physical ranges
1057            assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1058            assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1059        }
1060    }
1061
1062    #[test]
1063    fn test_performance_characteristics() {
1064        // This test ensures the implementation is efficient
1065        use std::time::Instant;
1066
1067        let start = Instant::now();
1068
1069        // Perform many calculations
1070        for i in 0..1000 {
1071            let mach = 0.5 + (i as f64) * 0.004; // 0.5 to 4.5
1072            let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1073            let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1074        }
1075
1076        let elapsed = start.elapsed();
1077
1078        // Should complete 2000 calculations quickly (within 100ms)
1079        assert!(
1080            elapsed.as_millis() < 100,
1081            "Performance test took {}ms",
1082            elapsed.as_millis()
1083        );
1084    }
1085
1086    #[test]
1087    fn try_new_accepts_valid_table() {
1088        let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1089        assert_eq!(t.mach_values.len(), 3);
1090    }
1091
1092    #[test]
1093    fn try_new_rejects_mismatched_lengths() {
1094        let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1095        assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1096    }
1097
1098    #[test]
1099    fn try_new_rejects_too_few_points() {
1100        assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1101    }
1102
1103    #[test]
1104    fn try_new_rejects_non_ascending_mach() {
1105        assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1106        assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1107    }
1108
1109    #[test]
1110    fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1111        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1112        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1113    }
1114
1115    #[test]
1116    fn interpolate_does_not_panic_on_mismatched_table() {
1117        // `new` is infallible; a caller could build a bad table. interpolate must not panic.
1118        let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1119        let _ = bad.interpolate(0.1);
1120        let _ = bad.interpolate(5.0);
1121        let _ = bad.interpolate(1.0);
1122    }
1123
1124    #[test]
1125    fn from_csv_str_parses_with_header_and_comments() {
1126        let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1127        let t = DragTable::from_csv_str(csv).unwrap();
1128        assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1129        assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1130    }
1131
1132    #[test]
1133    fn from_csv_str_rejects_malformed_data_row() {
1134        // header skip is allowed once; a bad DATA row must error with a line number.
1135        let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1136        assert!(e.contains("line 2"), "got: {e}");
1137    }
1138
1139    #[test]
1140    fn from_csv_str_rejects_empty() {
1141        assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1142    }
1143
1144    #[test]
1145    fn from_csv_str_rejects_malformed_first_data_row() {
1146        // first column is a valid number => it's data, not a header => must error, not vanish
1147        assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1148        assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1149    }
1150
1151    #[test]
1152    fn from_csv_str_still_skips_textual_header() {
1153        // genuine header (first column non-numeric) is still tolerated
1154        let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1155        assert_eq!(t.mach_values, vec![0.5, 1.0]);
1156    }
1157
1158    #[test]
1159    fn from_csv_str_roundtrips_shipped_g7() {
1160        // The embedded G7 deck must load and validate through the public loader.
1161        let g7 = include_str!("../data/g7.csv");
1162        let t = DragTable::from_csv_str(g7).unwrap();
1163        assert!(t.mach_values.len() > 20);
1164    }
1165}
1166
1167/// Interpolate BC value for given Mach number from segments
1168pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1169    if segments.is_empty() {
1170        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1171    }
1172
1173    // Get just the mach values
1174    let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1175
1176    // Double-check we have values after collection
1177    if mach_values.is_empty() || segments.is_empty() {
1178        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1179    }
1180
1181    // Handle edge cases with safe indexing
1182    if let Some(first_mach) = mach_values.first() {
1183        if mach <= *first_mach {
1184            return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1185        }
1186    }
1187
1188    if let Some(last_mach) = mach_values.last() {
1189        if mach >= *last_mach {
1190            return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1191        }
1192    }
1193
1194    // Binary search to find the right segment with safe comparison
1195    let idx = match mach_values
1196        .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1197    {
1198        Ok(idx) => {
1199            // Exact match - safely get the BC value
1200            return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1201        }
1202        Err(idx) => idx, // Insert position
1203    };
1204
1205    // Ensure idx is valid for interpolation
1206    if idx == 0 || idx >= segments.len() {
1207        // Shouldn't happen given the edge case checks above, but be defensive
1208        // Use safe indexing
1209        let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1210        return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1211    }
1212
1213    // Linear interpolation between the two closest points with safe indexing
1214    match (segments.get(idx - 1), segments.get(idx)) {
1215        (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1216            // Ensure denominator is not zero for safe interpolation
1217            let denominator = hi_mach - lo_mach;
1218            if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1219                return *lo_bc; // Return lower BC if Mach values are too close
1220            }
1221            let frac = (mach - lo_mach) / denominator;
1222            lo_bc + frac * (hi_bc - lo_bc)
1223        }
1224        _ => 0.5, // Fallback if indices are somehow invalid
1225    }
1226}
1227
1228// Removed Python-specific function