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
263    let csv_path = drag_tables_dir.join(format!("{filename}.csv"));
264    if let Ok(mut reader) = csv::Reader::from_path(&csv_path) {
265        let mut mach_values = Vec::new();
266        let mut cd_values = Vec::new();
267
268        for record in reader.records().flatten() {
269            if record.len() >= 2 {
270                if let (Ok(mach), Ok(cd)) = (record[0].parse::<f64>(), record[1].parse::<f64>())
271                {
272                    mach_values.push(mach);
273                    cd_values.push(cd);
274                }
275            }
276        }
277
278        if !mach_values.is_empty() {
279            return DragTable::new(mach_values, cd_values);
280        }
281    }
282
283    // Use fallback data if both file loading methods fail
284    let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
285    let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
286    DragTable::new(mach_values, cd_values)
287}
288
289/// Find the drag tables directory relative to the current location
290fn find_drag_tables_dir() -> Option<std::path::PathBuf> {
291    // Try common relative paths from the Rust crate location
292    let candidates = [
293        "../drag_tables",
294        "../../drag_tables",
295        "../../../drag_tables",
296        "drag_tables",
297    ];
298
299    for candidate in &candidates {
300        let path = Path::new(candidate);
301        if path.exists() && path.is_dir() {
302            return Some(path.to_path_buf());
303        }
304    }
305
306    None
307}
308
309/// Parse an embedded CSV drag table (`mach,cd` per line, header tolerated). Used to bake the
310/// high-resolution G1/G7 tables (data/*.csv) into the binary so the engine never depends on a
311/// runtime `drag_tables/` directory existing. Falls back to the supplied coarse table only if
312/// parsing yields no points (the shipped data files always parse).
313fn parse_embedded_drag_table(csv: &str, fallback: &[(f64, f64)]) -> DragTable {
314    let mut mach_values = Vec::new();
315    let mut cd_values = Vec::new();
316    for line in csv.lines() {
317        let line = line.trim();
318        if line.is_empty() {
319            continue;
320        }
321        let mut cols = line.split(',');
322        if let (Some(m), Some(cd)) = (cols.next(), cols.next()) {
323            if let (Ok(m), Ok(cd)) = (m.trim().parse::<f64>(), cd.trim().parse::<f64>()) {
324                mach_values.push(m);
325                cd_values.push(cd);
326            }
327        }
328    }
329    if mach_values.is_empty() {
330        mach_values = fallback.iter().map(|(m, _)| *m).collect();
331        cd_values = fallback.iter().map(|(_, cd)| *cd).collect();
332    }
333    DragTable::new(mach_values, cd_values)
334}
335
336/// G1 drag table — high-resolution data baked in from data/g1.csv at compile time (MBA-939).
337/// The previous runtime loader searched for a `drag_tables/` directory that does not exist when
338/// the binary runs (the tables ship under data/), so the engine silently used the coarse 21-point
339/// fallback below, flattening the transonic drag rise. include_str! guarantees the full table.
340static G1_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
341    // Coarse 21-point fallback, retained only for the impossible parse-failure path.
342    let fallback_data = [
343        (0.0, 0.2629),
344        (0.5, 0.2695),
345        (0.6, 0.2752),
346        (0.7, 0.2817),
347        (0.8, 0.2902),
348        (0.9, 0.3012),
349        (1.0, 0.4805),
350        (1.1, 0.5933),
351        (1.2, 0.6318),
352        (1.3, 0.6440),
353        (1.4, 0.6444),
354        (1.5, 0.6372),
355        (1.6, 0.6252),
356        (1.7, 0.6105),
357        (1.8, 0.5956),
358        (1.9, 0.5815),
359        (2.0, 0.5934),
360        (2.5, 0.5598),
361        (3.0, 0.5133),
362        (4.0, 0.4811),
363        (5.0, 0.4988),
364    ];
365
366    parse_embedded_drag_table(include_str!("../data/g1.csv"), &fallback_data)
367});
368
369/// G7 drag table — high-resolution data baked in from data/g7.csv at compile time (MBA-939).
370/// Same root cause as G1: the runtime `drag_tables/` loader never resolved, so the coarse
371/// 21-point fallback was used, missing the Mach 0.9->1.0 transonic knee (the embedded 0.9 point
372/// was even wrong: 0.1294 vs the true 0.1464). include_str! bakes in the full 84-point table.
373static G7_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
374    // Coarse 21-point fallback, retained only for the impossible parse-failure path.
375    let fallback_data = [
376        (0.0, 0.1198),
377        (0.5, 0.1197),
378        (0.6, 0.1202),
379        (0.7, 0.1213),
380        (0.8, 0.1240),
381        (0.9, 0.1294),
382        (1.0, 0.3803),
383        (1.1, 0.4015),
384        (1.2, 0.4043),
385        (1.3, 0.3956),
386        (1.4, 0.3814),
387        (1.5, 0.3663),
388        (1.6, 0.3520),
389        (1.7, 0.3398),
390        (1.8, 0.3297),
391        (1.9, 0.3221),
392        (2.0, 0.2980),
393        (2.5, 0.2731),
394        (3.0, 0.2424),
395        (4.0, 0.2196),
396        (5.0, 0.1618),
397    ];
398
399    parse_embedded_drag_table(include_str!("../data/g7.csv"), &fallback_data)
400});
401
402/// G6 drag table - flat-base with 6 caliber secant ogive (military FMJ bullets)
403/// MBA-156: Added for completeness with ballistics_rust
404static G6_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
405    let fallback_data = [
406        (0.0, 0.2617),
407        (0.05, 0.2553),
408        (0.10, 0.2491),
409        (0.15, 0.2432),
410        (0.20, 0.2376),
411        (0.25, 0.2324),
412        (0.30, 0.2278),
413        (0.35, 0.2238),
414        (0.40, 0.2205),
415        (0.45, 0.2177),
416        (0.50, 0.2155),
417        (0.55, 0.2138),
418        (0.60, 0.2126),
419        (0.65, 0.2121),
420        (0.70, 0.2122),
421        (0.75, 0.2132),
422        (0.80, 0.2154),
423        (0.85, 0.2194),
424        (0.875, 0.2229),
425        (0.90, 0.2297),
426        (0.925, 0.2449),
427        (0.95, 0.2732),
428        (0.975, 0.3141),
429        (1.0, 0.3597),
430        (1.025, 0.3994),
431        (1.05, 0.4261),
432        (1.075, 0.4402),
433        (1.10, 0.4465),
434        (1.125, 0.4490),
435        (1.15, 0.4497),
436        (1.175, 0.4494),
437        (1.20, 0.4482),
438        (1.225, 0.4464),
439        (1.25, 0.4441),
440        (1.30, 0.4390),
441        (1.35, 0.4336),
442        (1.40, 0.4279),
443        (1.45, 0.4221),
444        (1.50, 0.4162),
445        (1.55, 0.4102),
446        (1.60, 0.4042),
447        (1.65, 0.3981),
448        (1.70, 0.3919),
449        (1.75, 0.3855),
450        (1.80, 0.3788),
451        (1.85, 0.3721),
452        (1.90, 0.3652),
453        (1.95, 0.3583),
454        (2.0, 0.3515),
455        (2.05, 0.3447),
456        (2.10, 0.3381),
457        (2.15, 0.3314),
458        (2.20, 0.3249),
459        (2.25, 0.3185),
460        (2.30, 0.3122),
461        (2.35, 0.3060),
462        (2.40, 0.3000),
463        (2.45, 0.2941),
464        (2.50, 0.2883),
465        (2.60, 0.2772),
466        (2.70, 0.2668),
467        (2.80, 0.2574),
468        (2.90, 0.2487),
469        (3.0, 0.2407),
470        (3.10, 0.2333),
471        (3.20, 0.2265),
472        (3.30, 0.2202),
473        (3.40, 0.2144),
474        (3.50, 0.2089),
475        (3.60, 0.2039),
476        (3.70, 0.1991),
477        (3.80, 0.1947),
478        (3.90, 0.1905),
479        (4.0, 0.1866),
480        (4.20, 0.1794),
481        (4.40, 0.1730),
482        (4.60, 0.1673),
483        (4.80, 0.1621),
484        (5.0, 0.1574),
485    ];
486
487    if let Some(drag_dir) = find_drag_tables_dir() {
488        load_drag_table(&drag_dir, "g6", &fallback_data)
489    } else {
490        // Use fallback data if directory not found
491        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
492        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
493        DragTable::new(mach_values, cd_values)
494    }
495});
496
497/// G8 drag table - flat-base with 10 caliber secant ogive
498/// MBA-156: Added for completeness with ballistics_rust
499static G8_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
500    let fallback_data = [
501        (0.0, 0.2105),
502        (0.05, 0.2105),
503        (0.10, 0.2104),
504        (0.15, 0.2104),
505        (0.20, 0.2103),
506        (0.25, 0.2103),
507        (0.30, 0.2103),
508        (0.35, 0.2103),
509        (0.40, 0.2103),
510        (0.45, 0.2102),
511        (0.50, 0.2102),
512        (0.55, 0.2102),
513        (0.60, 0.2102),
514        (0.65, 0.2102),
515        (0.70, 0.2103),
516        (0.75, 0.2103),
517        (0.80, 0.2104),
518        (0.825, 0.2104),
519        (0.85, 0.2105),
520        (0.875, 0.2106),
521        (0.90, 0.2109),
522        (0.925, 0.2183),
523        (0.95, 0.2571),
524        (0.975, 0.3358),
525        (1.0, 0.4068),
526        (1.025, 0.4378),
527        (1.05, 0.4476),
528        (1.075, 0.4493),
529        (1.10, 0.4477),
530        (1.125, 0.4450),
531        (1.15, 0.4419),
532        (1.20, 0.4353),
533        (1.25, 0.4283),
534        (1.30, 0.4208),
535        (1.35, 0.4133),
536        (1.40, 0.4059),
537        (1.45, 0.3986),
538        (1.50, 0.3915),
539        (1.55, 0.3845),
540        (1.60, 0.3777),
541        (1.65, 0.3710),
542        (1.70, 0.3645),
543        (1.75, 0.3581),
544        (1.80, 0.3519),
545        (1.85, 0.3458),
546        (1.90, 0.3400),
547        (1.95, 0.3343),
548        (2.0, 0.3288),
549        (2.05, 0.3234),
550        (2.10, 0.3182),
551        (2.15, 0.3131),
552        (2.20, 0.3081),
553        (2.25, 0.3032),
554        (2.30, 0.2983),
555        (2.35, 0.2937),
556        (2.40, 0.2891),
557        (2.45, 0.2845),
558        (2.50, 0.2802),
559        (2.60, 0.2720),
560        (2.70, 0.2642),
561        (2.80, 0.2569),
562        (2.90, 0.2499),
563        (3.0, 0.2432),
564        (3.10, 0.2368),
565        (3.20, 0.2308),
566        (3.30, 0.2251),
567        (3.40, 0.2197),
568        (3.50, 0.2147),
569        (3.60, 0.2101),
570        (3.70, 0.2058),
571        (3.80, 0.2019),
572        (3.90, 0.1983),
573        (4.0, 0.1950),
574        (4.20, 0.1890),
575        (4.40, 0.1837),
576        (4.60, 0.1791),
577        (4.80, 0.1750),
578        (5.0, 0.1713),
579    ];
580
581    if let Some(drag_dir) = find_drag_tables_dir() {
582        load_drag_table(&drag_dir, "g8", &fallback_data)
583    } else {
584        // Use fallback data if directory not found
585        let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
586        let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
587        DragTable::new(mach_values, cd_values)
588    }
589});
590
591/// Get drag coefficient for given Mach number and drag model.
592///
593/// NOTE: only G1/G6/G7/G8 have dedicated tables. G2/G5/GI/GS currently fall back to the G1
594/// curve (no tables shipped yet), so callers requesting those models receive a G1
595/// approximation that is physically inaccurate (e.g. GS is the spherical/round-ball model).
596/// The fallback is made explicit below — rather than a silent `_` catch-all — so adding a new
597/// `DragModel` variant is a compile error until it is handled, and so the approximation is
598/// visible. Supplying real G2/G5/GI/GS tables is tracked separately.
599pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
600    match drag_model {
601        DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
602        DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
603        DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
604        DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
605        // No dedicated tables yet — approximate with the G1 curve (flagged, see note above).
606        DragModel::G2 | DragModel::G5 | DragModel::GI | DragModel::GS => {
607            G1_DRAG_TABLE.interpolate(mach)
608        }
609    }
610}
611
612/// Get a standard G-table drag coefficient without double-counting transonic drag.
613///
614/// Standard G tables are total-drag curves that already contain the transonic
615/// rise and wave drag. `apply_transonic_correction` and the shape inputs remain
616/// in this public API for compatibility, but enabling the option does not stack
617/// the separate empirical rise/wave model on top of a G-table coefficient.
618pub fn get_drag_coefficient_with_transonic(
619    mach: f64,
620    drag_model: &DragModel,
621    apply_transonic_correction: bool,
622    projectile_shape: Option<ProjectileShape>,
623    caliber: Option<f64>,
624    weight_grains: Option<f64>,
625) -> f64 {
626    // Get base drag coefficient
627    let base_cd = get_drag_coefficient(mach, drag_model);
628
629    // Apply transonic corrections if requested and in transonic regime
630    if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
631        // Determine projectile shape if not provided
632        let shape = match projectile_shape {
633            Some(s) => s,
634            None => {
635                if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
636                    get_projectile_shape(
637                        cal,
638                        weight,
639                        match drag_model {
640                            DragModel::G1 => "G1",
641                            DragModel::G6 => "G6",
642                            DragModel::G7 => "G7",
643                            DragModel::G8 => "G8",
644                            _ => "G1", // Default to G1
645                        },
646                    )
647                } else {
648                    ProjectileShape::Spitzer // Default
649                }
650            }
651        };
652
653        // Standard G-model tables are total-drag reference curves and already
654        // contain their transonic rise and wave drag. Retain the public option
655        // for API compatibility, but do not stack the empirical rise/wave model
656        // on top of table Cd (MBA-1155).
657        transonic_correction(mach, base_cd, shape, false)
658    } else {
659        base_cd
660    }
661}
662
663/// Get drag coefficient with optional Reynolds correction.
664///
665/// The transonic option is retained for compatibility but, as documented by
666/// [`get_drag_coefficient_with_transonic`], standard G tables are not corrected
667/// a second time. Likewise, the Reynolds option only affects genuinely low-Re
668/// (`Re < 10,000`) inputs; ordinary ballistic Reynolds numbers use the standard
669/// table coefficient unchanged.
670pub fn get_drag_coefficient_full(
671    mach: f64,
672    drag_model: &DragModel,
673    apply_transonic_correction: bool,
674    apply_reynolds_correction: bool,
675    projectile_shape: Option<ProjectileShape>,
676    caliber: Option<f64>,
677    weight_grains: Option<f64>,
678    velocity_mps: Option<f64>,
679    air_density_kg_m3: Option<f64>,
680    temperature_c: Option<f64>,
681) -> f64 {
682    // Get base drag coefficient with transonic corrections if applicable
683    let mut cd = get_drag_coefficient_with_transonic(
684        mach,
685        drag_model,
686        apply_transonic_correction,
687        projectile_shape,
688        caliber,
689        weight_grains,
690    );
691
692    // Route the opt-in low-Re helper for subsonic inputs. It leaves the ordinary
693    // standard-table Reynolds-number range unchanged.
694    if apply_reynolds_correction && mach < 1.0 {
695        if let (Some(v), Some(cal), Some(rho), Some(temp)) =
696            (velocity_mps, caliber, air_density_kg_m3, temperature_c)
697        {
698            use crate::reynolds::apply_reynolds_correction;
699            cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
700        }
701    }
702
703    cd
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    #[test]
711    fn test_g1_drag_coefficient_interpolation() {
712        let cd = get_drag_coefficient(1.0, &DragModel::G1);
713        // Should be close to the G1 standard value at Mach 1.0
714        assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
715    }
716
717    #[test]
718    fn test_g7_drag_coefficient_interpolation() {
719        let cd = get_drag_coefficient(1.0, &DragModel::G7);
720        // Should be close to the G7 standard value at Mach 1.0
721        assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
722    }
723
724    #[test]
725    fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
726        let models = [
727            DragModel::G1,
728            DragModel::G2,
729            DragModel::G5,
730            DragModel::G6,
731            DragModel::G7,
732            DragModel::G8,
733            DragModel::GI,
734            DragModel::GS,
735        ];
736        for drag_model in models {
737            for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
738                let base_cd = get_drag_coefficient(mach, &drag_model);
739                let corrected_cd = get_drag_coefficient_with_transonic(
740                    mach,
741                    &drag_model,
742                    true,
743                    Some(ProjectileShape::BoatTail),
744                    Some(0.308),
745                    Some(175.0),
746                );
747                assert_eq!(
748                    corrected_cd.to_bits(),
749                    base_cd.to_bits(),
750                    "standard {drag_model:?} table already includes transonic drag at Mach \
751                     {mach}: base={base_cd}, corrected={corrected_cd}"
752                );
753
754                let full_cd = get_drag_coefficient_full(
755                    mach,
756                    &drag_model,
757                    true,
758                    false,
759                    Some(ProjectileShape::BoatTail),
760                    Some(0.308),
761                    Some(175.0),
762                    None,
763                    None,
764                    None,
765                );
766                assert_eq!(full_cd.to_bits(), base_cd.to_bits());
767            }
768        }
769    }
770
771    #[test]
772    fn test_drag_coefficient_continuity() {
773        // Test that drag coefficient function is smooth
774        for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
775            let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
776            let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
777            let difference = (cd_after - cd_before).abs();
778            assert!(
779                difference < 0.05,
780                "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
781            );
782        }
783    }
784
785    #[test]
786    fn test_endpoint_bounds() {
787        // Test endpoint hold below range
788        let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
789        assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
790
791        // Test endpoint hold above range
792        let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
793        assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
794
795        // Same for G7
796        let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
797        assert!(
798            cd_low_g7 > 0.01,
799            "Low Mach G7 should be positive: {cd_low_g7}"
800        );
801
802        let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
803        assert!(
804            cd_high_g7 >= 0.01,
805            "High Mach G7 should be positive: {cd_high_g7}"
806        );
807    }
808
809    #[test]
810    fn test_drag_table_creation() {
811        let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
812        let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
813        let table = DragTable::new(mach_vals, cd_vals);
814
815        // Test exact interpolation
816        assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
817
818        // Test interpolation between points
819        let cd_interp = table.interpolate(1.25);
820        assert!(cd_interp > 0.4 && cd_interp < 0.5);
821    }
822
823    #[test]
824    fn test_drag_table_empty() {
825        let table = DragTable::new(vec![], vec![]);
826        let result = table.interpolate(1.0);
827        assert_eq!(result, 0.5); // Should return fallback value
828    }
829
830    #[test]
831    fn test_drag_table_single_point() {
832        let table = DragTable::new(vec![1.0], vec![0.4]);
833
834        // Should return the single value for any Mach
835        assert_eq!(table.interpolate(0.5), 0.4);
836        assert_eq!(table.interpolate(1.0), 0.4);
837        assert_eq!(table.interpolate(2.0), 0.4);
838    }
839
840    #[test]
841    fn test_drag_table_two_points() {
842        let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
843
844        // Exact matches
845        assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
846        assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
847
848        // Linear interpolation
849        let mid = table.interpolate(1.5);
850        assert!((mid - 0.5).abs() < 1e-10);
851
852        // Out-of-range values hold the nearest endpoint.
853        let below = table.interpolate(0.5);
854        assert_eq!(below.to_bits(), 0.4_f64.to_bits());
855
856        let above = table.interpolate(3.0);
857        assert_eq!(above.to_bits(), 0.6_f64.to_bits());
858    }
859
860    #[test]
861    fn out_of_range_mach_holds_boundary_cd() {
862        let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
863
864        for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
865            assert_eq!(
866                table.interpolate(mach).to_bits(),
867                0.2_f64.to_bits(),
868                "Mach {mach} must hold the first tabulated Cd"
869            );
870        }
871        for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
872            assert_eq!(
873                table.interpolate(mach).to_bits(),
874                0.3_f64.to_bits(),
875                "Mach {mach} must hold the last tabulated Cd"
876            );
877        }
878    }
879
880    #[test]
881    fn test_linear_interpolation() {
882        let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
883
884        // Test linear interpolation between first two points
885        let result = table.linear_interpolate(0.5, 0);
886        assert!((result - 0.35).abs() < 1e-10);
887
888        // Test edge case with zero denominator
889        let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
890        let result_same = table_same.linear_interpolate(1.0, 0);
891        assert_eq!(result_same, 0.4); // Should return first value
892    }
893
894    #[test]
895    fn test_cubic_interpolation() {
896        // Create a table with enough points for cubic interpolation
897        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]);
898
899        // Test cubic interpolation in the middle
900        let result = table.cubic_interpolate(1.25, 1);
901
902        // Should be between the neighboring values
903        assert!(result > 0.3 && result < 0.7);
904
905        // Should be smooth (not exactly linear)
906        let linear_result = table.linear_interpolate(1.25, 1);
907        // Cubic and linear should be close but not identical for smooth curves
908        assert!((result - linear_result).abs() < 0.2);
909    }
910
911    #[test]
912    fn nonuniform_cubic_reproduces_affine_data() {
913        let table = DragTable::new(
914            vec![0.0, 1.0, 3.0, 4.0],
915            vec![0.25, 0.3125, 0.4375, 0.5],
916        );
917
918        for mach in [1.5, 2.5] {
919            let expected = 0.25 + mach / 16.0;
920            let actual = table.interpolate(mach);
921            assert_eq!(
922                actual.to_bits(),
923                expected.to_bits(),
924                "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
925            );
926        }
927    }
928
929    #[test]
930    fn nonuniform_cubic_is_c1_at_spacing_transition() {
931        let table = DragTable::new(
932            vec![0.0, 1.0, 3.0, 4.0, 7.0],
933            vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
934        );
935        let knot = 3.0;
936        let expected_at_knot = 0.390625_f64;
937        let epsilon = 1e-6;
938        let at_knot = table.interpolate(knot);
939        let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
940        let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
941
942        assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
943        assert!(
944            (left_slope - right_slope).abs() < 1e-5,
945            "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
946        );
947    }
948
949    #[test]
950    fn test_find_drag_tables_dir() {
951        // This test may pass or fail depending on the environment
952        // but should not panic
953        let _dir = find_drag_tables_dir();
954        // Just ensure the function doesn't panic
955    }
956
957    #[test]
958    fn test_load_drag_table_fallback() {
959        use std::path::Path;
960
961        // Test with non-existent directory - should use fallback data
962        let fake_dir = Path::new("/non/existent/directory");
963        let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
964
965        let table = load_drag_table(fake_dir, "test", &fallback_data);
966
967        // Should have fallback data
968        assert_eq!(table.mach_values.len(), 3);
969        assert_eq!(table.cd_values.len(), 3);
970        assert_eq!(table.mach_values[0], 0.5);
971        assert_eq!(table.cd_values[0], 0.2);
972    }
973
974    #[test]
975    fn test_known_drag_values() {
976        // Test against known ballistic standard values
977
978        // G1 at Mach 1.0 should be around 0.4805
979        let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
980        assert!(
981            (g1_mach1 - 0.4805).abs() < 0.01,
982            "G1 at Mach 1.0: {g1_mach1}"
983        );
984
985        // G7 at Mach 1.0 should be around 0.3803
986        let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
987        assert!(
988            (g7_mach1 - 0.3803).abs() < 0.01,
989            "G7 at Mach 1.0: {g7_mach1}"
990        );
991
992        // G1 should generally be higher than G7 in transonic region
993        assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
994    }
995
996    #[test]
997    fn test_monotonicity_properties() {
998        // Test general drag curve properties
999
1000        // G1 should peak somewhere in transonic region
1001        let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); // 0.8 to 1.9
1002        let g1_values: Vec<f64> = mach_values
1003            .iter()
1004            .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1005            .collect();
1006
1007        // Find maximum
1008        let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1009        let max_index = g1_values
1010            .iter()
1011            .position(|&x| x == max_value)
1012            .expect("Should find maximum in non-empty vector");
1013        let peak_mach = mach_values
1014            .get(max_index)
1015            .copied()
1016            .expect("Index should be valid");
1017
1018        // Peak should be in reasonable range
1019        assert!(
1020            peak_mach > 1.0 && peak_mach < 1.6,
1021            "G1 peak at Mach {peak_mach}"
1022        );
1023        assert!(
1024            max_value > 0.5 && max_value < 1.0,
1025            "G1 peak value: {max_value}"
1026        );
1027    }
1028
1029    #[test]
1030    fn test_physical_constraints() {
1031        let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1032
1033        for &mach in &test_machs {
1034            let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1035            let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1036
1037            // All drag coefficients should be positive
1038            assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1039            assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1040
1041            // Should be in reasonable physical ranges
1042            assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1043            assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1044        }
1045    }
1046
1047    #[test]
1048    fn test_performance_characteristics() {
1049        // This test ensures the implementation is efficient
1050        use std::time::Instant;
1051
1052        let start = Instant::now();
1053
1054        // Perform many calculations
1055        for i in 0..1000 {
1056            let mach = 0.5 + (i as f64) * 0.004; // 0.5 to 4.5
1057            let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1058            let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1059        }
1060
1061        let elapsed = start.elapsed();
1062
1063        // Should complete 2000 calculations quickly (within 100ms)
1064        assert!(
1065            elapsed.as_millis() < 100,
1066            "Performance test took {}ms",
1067            elapsed.as_millis()
1068        );
1069    }
1070
1071    #[test]
1072    fn try_new_accepts_valid_table() {
1073        let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1074        assert_eq!(t.mach_values.len(), 3);
1075    }
1076
1077    #[test]
1078    fn try_new_rejects_mismatched_lengths() {
1079        let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1080        assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1081    }
1082
1083    #[test]
1084    fn try_new_rejects_too_few_points() {
1085        assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1086    }
1087
1088    #[test]
1089    fn try_new_rejects_non_ascending_mach() {
1090        assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1091        assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1092    }
1093
1094    #[test]
1095    fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1096        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1097        assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1098    }
1099
1100    #[test]
1101    fn interpolate_does_not_panic_on_mismatched_table() {
1102        // `new` is infallible; a caller could build a bad table. interpolate must not panic.
1103        let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1104        let _ = bad.interpolate(0.1);
1105        let _ = bad.interpolate(5.0);
1106        let _ = bad.interpolate(1.0);
1107    }
1108
1109    #[test]
1110    fn from_csv_str_parses_with_header_and_comments() {
1111        let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1112        let t = DragTable::from_csv_str(csv).unwrap();
1113        assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1114        assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1115    }
1116
1117    #[test]
1118    fn from_csv_str_rejects_malformed_data_row() {
1119        // header skip is allowed once; a bad DATA row must error with a line number.
1120        let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1121        assert!(e.contains("line 2"), "got: {e}");
1122    }
1123
1124    #[test]
1125    fn from_csv_str_rejects_empty() {
1126        assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1127    }
1128
1129    #[test]
1130    fn from_csv_str_rejects_malformed_first_data_row() {
1131        // first column is a valid number => it's data, not a header => must error, not vanish
1132        assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1133        assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1134    }
1135
1136    #[test]
1137    fn from_csv_str_still_skips_textual_header() {
1138        // genuine header (first column non-numeric) is still tolerated
1139        let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1140        assert_eq!(t.mach_values, vec![0.5, 1.0]);
1141    }
1142
1143    #[test]
1144    fn from_csv_str_roundtrips_shipped_g7() {
1145        // The embedded G7 deck must load and validate through the public loader.
1146        let g7 = include_str!("../data/g7.csv");
1147        let t = DragTable::from_csv_str(g7).unwrap();
1148        assert!(t.mach_values.len() > 20);
1149    }
1150}
1151
1152/// Interpolate BC value for given Mach number from segments
1153pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1154    if segments.is_empty() {
1155        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1156    }
1157
1158    // Get just the mach values
1159    let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1160
1161    // Double-check we have values after collection
1162    if mach_values.is_empty() || segments.is_empty() {
1163        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
1164    }
1165
1166    // Handle edge cases with safe indexing
1167    if let Some(first_mach) = mach_values.first() {
1168        if mach <= *first_mach {
1169            return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1170        }
1171    }
1172
1173    if let Some(last_mach) = mach_values.last() {
1174        if mach >= *last_mach {
1175            return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1176        }
1177    }
1178
1179    // Binary search to find the right segment with safe comparison
1180    let idx = match mach_values
1181        .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1182    {
1183        Ok(idx) => {
1184            // Exact match - safely get the BC value
1185            return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1186        }
1187        Err(idx) => idx, // Insert position
1188    };
1189
1190    // Ensure idx is valid for interpolation
1191    if idx == 0 || idx >= segments.len() {
1192        // Shouldn't happen given the edge case checks above, but be defensive
1193        // Use safe indexing
1194        let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1195        return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1196    }
1197
1198    // Linear interpolation between the two closest points with safe indexing
1199    match (segments.get(idx - 1), segments.get(idx)) {
1200        (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1201            // Ensure denominator is not zero for safe interpolation
1202            let denominator = hi_mach - lo_mach;
1203            if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1204                return *lo_bc; // Return lower BC if Mach values are too close
1205            }
1206            let frac = (mach - lo_mach) / denominator;
1207            lo_bc + frac * (hi_bc - lo_bc)
1208        }
1209        _ => 0.5, // Fallback if indices are somehow invalid
1210    }
1211}
1212
1213// Removed Python-specific function