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