1use crate::transonic_drag::{get_projectile_shape, transonic_correction, ProjectileShape};
2use crate::DragModel;
3use ndarray::ArrayD;
4use std::path::Path;
6use std::sync::LazyLock;
7
8#[derive(Debug, Clone)]
10pub struct DragTable {
11 pub mach_values: Vec<f64>,
12 pub cd_values: Vec<f64>,
13}
14
15impl DragTable {
16 pub fn new(mach_values: Vec<f64>, cd_values: Vec<f64>) -> Self {
18 Self {
19 mach_values,
20 cd_values,
21 }
22 }
23
24 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 {
66 mach_values,
67 cd_values,
68 })
69 }
70
71 pub fn from_csv_str(csv: &str) -> Result<Self, String> {
78 let mut mach_values = Vec::new();
79 let mut cd_values = Vec::new();
80 let mut header_skipped = false;
81 for (lineno, raw) in csv.lines().enumerate() {
82 let line = raw.trim();
83 if line.is_empty() || line.starts_with('#') {
84 continue;
85 }
86 let mut cols = line.split(',');
87 let m = cols.next().map(str::trim);
88 let cd = cols.next().map(str::trim);
89 let m_parsed = m.and_then(|s| s.parse::<f64>().ok());
90 match (m_parsed, cd.and_then(|s| s.parse::<f64>().ok())) {
91 (Some(m), Some(cd)) => {
92 mach_values.push(m);
93 cd_values.push(cd);
94 }
95 _ => {
96 if !header_skipped && mach_values.is_empty() && m_parsed.is_none() {
97 header_skipped = true;
102 continue;
103 }
104 return Err(format!(
105 "drag table CSV: could not parse two numbers from line {}: {:?}",
106 lineno + 1,
107 raw
108 ));
109 }
110 }
111 }
112 if mach_values.is_empty() {
113 return Err("drag table CSV contained no data rows".to_string());
114 }
115 Self::try_new(mach_values, cd_values)
116 }
117
118 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
120 let path = path.as_ref();
121 let text = std::fs::read_to_string(path)
122 .map_err(|e| format!("could not read drag table {}: {e}", path.display()))?;
123 Self::from_csv_str(&text)
124 }
125
126 pub fn interpolate(&self, mach: f64) -> f64 {
129 let n = self.mach_values.len();
130
131 if n == 0 {
132 return 0.5; }
134
135 if n == 1 {
136 return self.cd_values.first().copied().unwrap_or(0.5);
137 }
138
139 if mach <= self.mach_values[0] {
142 return self.cd_values.first().copied().unwrap_or(0.5);
143 }
144
145 if mach >= self.mach_values[n - 1] {
146 return self
149 .cd_values
150 .get(n - 1)
151 .copied()
152 .or_else(|| self.cd_values.last().copied())
153 .unwrap_or(0.5);
154 }
155
156 let idx = self
160 .mach_values
161 .partition_point(|&m| m < mach)
162 .saturating_sub(1)
163 .min(n - 2);
164
165 if idx > 0 && idx < n - 2 {
167 self.cubic_interpolate(mach, idx)
169 } else {
170 self.linear_interpolate(mach, idx)
172 }
173 }
174
175 pub fn linear_interpolate(&self, mach: f64, idx: usize) -> f64 {
177 if idx + 1 >= self.mach_values.len() || idx + 1 >= self.cd_values.len() {
179 return self.cd_values.get(idx).copied().unwrap_or(0.5);
180 }
181
182 let x0 = self.mach_values[idx];
183 let x1 = self.mach_values[idx + 1];
184 let y0 = self.cd_values[idx];
185 let y1 = self.cd_values[idx + 1];
186
187 if (x1 - x0).abs() < crate::constants::MIN_DIVISION_THRESHOLD {
188 return y0;
189 }
190
191 let t = (mach - x0) / (x1 - x0);
192 y0 + t * (y1 - y0)
193 }
194
195 pub fn cubic_interpolate(&self, mach: f64, idx: usize) -> f64 {
197 if idx == 0 || idx + 1 >= self.mach_values.len() || idx + 1 >= self.cd_values.len() {
199 return self.linear_interpolate(mach, idx);
201 }
202
203 let x = [
205 self.mach_values[idx - 1],
206 self.mach_values[idx],
207 self.mach_values[idx + 1],
208 if idx + 2 < self.mach_values.len() {
209 self.mach_values[idx + 2]
210 } else {
211 self.mach_values[idx + 1]
212 },
213 ];
214 let y = [
215 self.cd_values[idx - 1],
216 self.cd_values[idx],
217 self.cd_values[idx + 1],
218 if idx + 2 < self.cd_values.len() {
219 self.cd_values[idx + 2]
220 } else {
221 self.cd_values[idx + 1]
222 },
223 ];
224
225 let segment_width = x[2] - x[1];
229 let left_chord_width = x[2] - x[0];
230 let right_chord_width = x[3] - x[1];
231 if segment_width.abs() < crate::constants::MIN_DIVISION_THRESHOLD
232 || left_chord_width.abs() < crate::constants::MIN_DIVISION_THRESHOLD
233 || right_chord_width.abs() < crate::constants::MIN_DIVISION_THRESHOLD
234 {
235 return self.linear_interpolate(mach, idx);
236 }
237 let t = (mach - x[1]) / segment_width;
238 let t2 = t * t;
239 let t3 = t2 * t;
240
241 let tangent1 = segment_width * (y[2] - y[0]) / left_chord_width;
242 let tangent2 = segment_width * (y[3] - y[1]) / right_chord_width;
243 let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
244 let h10 = t3 - 2.0 * t2 + t;
245 let h01 = -2.0 * t3 + 3.0 * t2;
246 let h11 = t3 - t2;
247
248 h00 * y[1] + h10 * tangent1 + h01 * y[2] + h11 * tangent2
249 }
250}
251
252pub fn load_drag_table(
254 drag_tables_dir: &Path,
255 filename: &str,
256 fallback_data: &[(f64, f64)],
257) -> DragTable {
258 let npy_path = drag_tables_dir.join(format!("{filename}.npy"));
260 if let Ok(array) = ndarray_npy::read_npy::<_, ArrayD<f64>>(&npy_path) {
261 if let Ok(array_2d) = array.into_dimensionality::<ndarray::Ix2>() {
262 let mach_values: Vec<f64> = array_2d.column(0).to_vec();
263 let cd_values: Vec<f64> = array_2d.column(1).to_vec();
264 return DragTable::new(mach_values, cd_values);
265 }
266 }
267
268 let csv_path = drag_tables_dir.join(format!("{filename}.csv"));
275 if let Ok(bytes) = std::fs::read(&csv_path) {
276 let text = String::from_utf8_lossy(&bytes).replace('\r', "\n");
279 let mut mach_values = Vec::new();
280 let mut cd_values = Vec::new();
281
282 for line in text.lines() {
283 let mut fields = line.split(',');
284 if let (Some(m_str), Some(cd_str)) = (fields.next(), fields.next()) {
285 if let (Ok(mach), Ok(cd)) = (
288 m_str.trim().trim_matches('"').trim().parse::<f64>(),
289 cd_str.trim().trim_matches('"').trim().parse::<f64>(),
290 ) {
291 mach_values.push(mach);
292 cd_values.push(cd);
293 }
294 }
295 }
296
297 if !mach_values.is_empty() {
298 return DragTable::new(mach_values, cd_values);
299 }
300 }
301
302 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
304 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
305 DragTable::new(mach_values, cd_values)
306}
307
308fn find_drag_tables_dir() -> Option<std::path::PathBuf> {
310 let candidates = [
312 "../drag_tables",
313 "../../drag_tables",
314 "../../../drag_tables",
315 "drag_tables",
316 ];
317
318 for candidate in &candidates {
319 let path = Path::new(candidate);
320 if path.exists() && path.is_dir() {
321 return Some(path.to_path_buf());
322 }
323 }
324
325 None
326}
327
328fn parse_embedded_drag_table(csv: &str, fallback: &[(f64, f64)]) -> DragTable {
333 let mut mach_values = Vec::new();
334 let mut cd_values = Vec::new();
335 for line in csv.lines() {
336 let line = line.trim();
337 if line.is_empty() {
338 continue;
339 }
340 let mut cols = line.split(',');
341 if let (Some(m), Some(cd)) = (cols.next(), cols.next()) {
342 if let (Ok(m), Ok(cd)) = (m.trim().parse::<f64>(), cd.trim().parse::<f64>()) {
343 mach_values.push(m);
344 cd_values.push(cd);
345 }
346 }
347 }
348 if mach_values.is_empty() {
349 mach_values = fallback.iter().map(|(m, _)| *m).collect();
350 cd_values = fallback.iter().map(|(_, cd)| *cd).collect();
351 }
352 DragTable::new(mach_values, cd_values)
353}
354
355static G1_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
360 let fallback_data = [
362 (0.0, 0.2629),
363 (0.5, 0.2695),
364 (0.6, 0.2752),
365 (0.7, 0.2817),
366 (0.8, 0.2902),
367 (0.9, 0.3012),
368 (1.0, 0.4805),
369 (1.1, 0.5933),
370 (1.2, 0.6318),
371 (1.3, 0.6440),
372 (1.4, 0.6444),
373 (1.5, 0.6372),
374 (1.6, 0.6252),
375 (1.7, 0.6105),
376 (1.8, 0.5956),
377 (1.9, 0.5815),
378 (2.0, 0.5934),
379 (2.5, 0.5598),
380 (3.0, 0.5133),
381 (4.0, 0.4811),
382 (5.0, 0.4988),
383 ];
384
385 parse_embedded_drag_table(include_str!("../data/g1.csv"), &fallback_data)
386});
387
388static G7_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
393 let fallback_data = [
395 (0.0, 0.1198),
396 (0.5, 0.1197),
397 (0.6, 0.1202),
398 (0.7, 0.1213),
399 (0.8, 0.1240),
400 (0.9, 0.1294),
401 (1.0, 0.3803),
402 (1.1, 0.4015),
403 (1.2, 0.4043),
404 (1.3, 0.3956),
405 (1.4, 0.3814),
406 (1.5, 0.3663),
407 (1.6, 0.3520),
408 (1.7, 0.3398),
409 (1.8, 0.3297),
410 (1.9, 0.3221),
411 (2.0, 0.2980),
412 (2.5, 0.2731),
413 (3.0, 0.2424),
414 (4.0, 0.2196),
415 (5.0, 0.1618),
416 ];
417
418 parse_embedded_drag_table(include_str!("../data/g7.csv"), &fallback_data)
419});
420
421static G6_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
424 let fallback_data = [
425 (0.0, 0.2617),
426 (0.05, 0.2553),
427 (0.10, 0.2491),
428 (0.15, 0.2432),
429 (0.20, 0.2376),
430 (0.25, 0.2324),
431 (0.30, 0.2278),
432 (0.35, 0.2238),
433 (0.40, 0.2205),
434 (0.45, 0.2177),
435 (0.50, 0.2155),
436 (0.55, 0.2138),
437 (0.60, 0.2126),
438 (0.65, 0.2121),
439 (0.70, 0.2122),
440 (0.75, 0.2132),
441 (0.80, 0.2154),
442 (0.85, 0.2194),
443 (0.875, 0.2229),
444 (0.90, 0.2297),
445 (0.925, 0.2449),
446 (0.95, 0.2732),
447 (0.975, 0.3141),
448 (1.0, 0.3597),
449 (1.025, 0.3994),
450 (1.05, 0.4261),
451 (1.075, 0.4402),
452 (1.10, 0.4465),
453 (1.125, 0.4490),
454 (1.15, 0.4497),
455 (1.175, 0.4494),
456 (1.20, 0.4482),
457 (1.225, 0.4464),
458 (1.25, 0.4441),
459 (1.30, 0.4390),
460 (1.35, 0.4336),
461 (1.40, 0.4279),
462 (1.45, 0.4221),
463 (1.50, 0.4162),
464 (1.55, 0.4102),
465 (1.60, 0.4042),
466 (1.65, 0.3981),
467 (1.70, 0.3919),
468 (1.75, 0.3855),
469 (1.80, 0.3788),
470 (1.85, 0.3721),
471 (1.90, 0.3652),
472 (1.95, 0.3583),
473 (2.0, 0.3515),
474 (2.05, 0.3447),
475 (2.10, 0.3381),
476 (2.15, 0.3314),
477 (2.20, 0.3249),
478 (2.25, 0.3185),
479 (2.30, 0.3122),
480 (2.35, 0.3060),
481 (2.40, 0.3000),
482 (2.45, 0.2941),
483 (2.50, 0.2883),
484 (2.60, 0.2772),
485 (2.70, 0.2668),
486 (2.80, 0.2574),
487 (2.90, 0.2487),
488 (3.0, 0.2407),
489 (3.10, 0.2333),
490 (3.20, 0.2265),
491 (3.30, 0.2202),
492 (3.40, 0.2144),
493 (3.50, 0.2089),
494 (3.60, 0.2039),
495 (3.70, 0.1991),
496 (3.80, 0.1947),
497 (3.90, 0.1905),
498 (4.0, 0.1866),
499 (4.20, 0.1794),
500 (4.40, 0.1730),
501 (4.60, 0.1673),
502 (4.80, 0.1621),
503 (5.0, 0.1574),
504 ];
505
506 if let Some(drag_dir) = find_drag_tables_dir() {
507 load_drag_table(&drag_dir, "g6", &fallback_data)
508 } else {
509 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
511 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
512 DragTable::new(mach_values, cd_values)
513 }
514});
515
516static G8_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
519 let fallback_data = [
520 (0.0, 0.2105),
521 (0.05, 0.2105),
522 (0.10, 0.2104),
523 (0.15, 0.2104),
524 (0.20, 0.2103),
525 (0.25, 0.2103),
526 (0.30, 0.2103),
527 (0.35, 0.2103),
528 (0.40, 0.2103),
529 (0.45, 0.2102),
530 (0.50, 0.2102),
531 (0.55, 0.2102),
532 (0.60, 0.2102),
533 (0.65, 0.2102),
534 (0.70, 0.2103),
535 (0.75, 0.2103),
536 (0.80, 0.2104),
537 (0.825, 0.2104),
538 (0.85, 0.2105),
539 (0.875, 0.2106),
540 (0.90, 0.2109),
541 (0.925, 0.2183),
542 (0.95, 0.2571),
543 (0.975, 0.3358),
544 (1.0, 0.4068),
545 (1.025, 0.4378),
546 (1.05, 0.4476),
547 (1.075, 0.4493),
548 (1.10, 0.4477),
549 (1.125, 0.4450),
550 (1.15, 0.4419),
551 (1.20, 0.4353),
552 (1.25, 0.4283),
553 (1.30, 0.4208),
554 (1.35, 0.4133),
555 (1.40, 0.4059),
556 (1.45, 0.3986),
557 (1.50, 0.3915),
558 (1.55, 0.3845),
559 (1.60, 0.3777),
560 (1.65, 0.3710),
561 (1.70, 0.3645),
562 (1.75, 0.3581),
563 (1.80, 0.3519),
564 (1.85, 0.3458),
565 (1.90, 0.3400),
566 (1.95, 0.3343),
567 (2.0, 0.3288),
568 (2.05, 0.3234),
569 (2.10, 0.3182),
570 (2.15, 0.3131),
571 (2.20, 0.3081),
572 (2.25, 0.3032),
573 (2.30, 0.2983),
574 (2.35, 0.2937),
575 (2.40, 0.2891),
576 (2.45, 0.2845),
577 (2.50, 0.2802),
578 (2.60, 0.2720),
579 (2.70, 0.2642),
580 (2.80, 0.2569),
581 (2.90, 0.2499),
582 (3.0, 0.2432),
583 (3.10, 0.2368),
584 (3.20, 0.2308),
585 (3.30, 0.2251),
586 (3.40, 0.2197),
587 (3.50, 0.2147),
588 (3.60, 0.2101),
589 (3.70, 0.2058),
590 (3.80, 0.2019),
591 (3.90, 0.1983),
592 (4.0, 0.1950),
593 (4.20, 0.1890),
594 (4.40, 0.1837),
595 (4.60, 0.1791),
596 (4.80, 0.1750),
597 (5.0, 0.1713),
598 ];
599
600 if let Some(drag_dir) = find_drag_tables_dir() {
601 load_drag_table(&drag_dir, "g8", &fallback_data)
602 } else {
603 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
605 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
606 DragTable::new(mach_values, cd_values)
607 }
608});
609
610static G2_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
614 let fallback_data = [(0.0, 0.2303), (1.0, 0.3983), (5.0, 0.1648)];
616 parse_embedded_drag_table(include_str!("../data/g2.csv"), &fallback_data)
617});
618
619static G5_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
623 let fallback_data = [(0.0, 0.1710), (1.0, 0.3379), (5.0, 0.2280)];
625 parse_embedded_drag_table(include_str!("../data/g5.csv"), &fallback_data)
626});
627
628static GI_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
632 let fallback_data = [(0.0, 0.2282), (1.0, 0.4349), (5.0, 0.4082)];
634 parse_embedded_drag_table(include_str!("../data/gi.csv"), &fallback_data)
635});
636
637static GS_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
642 let fallback_data = [(0.0, 0.4662), (1.0, 0.8140), (4.0, 0.9280)];
644 parse_embedded_drag_table(include_str!("../data/gs.csv"), &fallback_data)
645});
646
647static RA4_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
652 let fallback_data = [(0.0, 0.2283), (1.0, 0.3975), (4.0, 0.4969)];
654 parse_embedded_drag_table(include_str!("../data/ra4.csv"), &fallback_data)
655});
656
657pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
663 match drag_model {
664 DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
665 DragModel::G2 => G2_DRAG_TABLE.interpolate(mach),
666 DragModel::G5 => G5_DRAG_TABLE.interpolate(mach),
667 DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
668 DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
669 DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
670 DragModel::GI => GI_DRAG_TABLE.interpolate(mach),
671 DragModel::GS => GS_DRAG_TABLE.interpolate(mach),
672 DragModel::RA4 => RA4_DRAG_TABLE.interpolate(mach),
673 }
674}
675
676pub fn reference_drag_table(drag_model: &DragModel) -> &'static DragTable {
693 match drag_model {
694 DragModel::G1 => &G1_DRAG_TABLE,
695 DragModel::G2 => &G2_DRAG_TABLE,
696 DragModel::G5 => &G5_DRAG_TABLE,
697 DragModel::G6 => &G6_DRAG_TABLE,
698 DragModel::G7 => &G7_DRAG_TABLE,
699 DragModel::G8 => &G8_DRAG_TABLE,
700 DragModel::GI => &GI_DRAG_TABLE,
701 DragModel::GS => &GS_DRAG_TABLE,
702 DragModel::RA4 => &RA4_DRAG_TABLE,
703 }
704}
705
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
708pub enum ReferenceDragCurveFormat {
709 Table,
710 Csv,
711 Json,
712}
713
714pub fn format_reference_drag_curve(
726 drag_model: &DragModel,
727 format: ReferenceDragCurveFormat,
728) -> String {
729 let table = reference_drag_table(drag_model);
730 let points: Vec<(f64, f64)> = table
731 .mach_values
732 .iter()
733 .copied()
734 .zip(table.cd_values.iter().copied())
735 .collect();
736
737 match format {
738 ReferenceDragCurveFormat::Json => {
739 let document = serde_json::json!({
740 "drag_model": drag_model.to_string(),
741 "point_count": points.len(),
742 "mach_min": points.first().map(|p| p.0),
745 "mach_max": points.last().map(|p| p.0),
746 "source": "Aberdeen/BRL reference functions as tabulated in McCoy, Modern \
747 Exterior Ballistics (RA4: British RA 1929). Public domain.",
748 "points": points
749 .iter()
750 .map(|(mach, cd)| serde_json::json!({"mach": mach, "cd": cd}))
751 .collect::<Vec<_>>(),
752 });
753 let mut out = serde_json::to_string_pretty(&document)
754 .expect("reference drag curve document serializes");
756 out.push('\n');
757 out
758 }
759 ReferenceDragCurveFormat::Csv => {
760 let mut out = String::from("mach,cd\n");
761 for (mach, cd) in &points {
762 out.push_str(&format!("{mach},{cd}\n"));
763 }
764 out
765 }
766 ReferenceDragCurveFormat::Table => {
767 let mut out = format!(
768 "{} reference drag curve\n{} points, Mach {:.2} to {:.2}\n\n",
769 drag_model.to_string().to_uppercase(),
770 points.len(),
771 points.first().map(|p| p.0).unwrap_or(0.0),
772 points.last().map(|p| p.0).unwrap_or(0.0)
773 );
774 out.push_str(&format!("{:>8} {:>8}\n", "Mach", "Cd"));
775 out.push_str(&format!("{:->8} {:->8}\n", "", ""));
776 for (mach, cd) in &points {
777 out.push_str(&format!("{mach:>8.3} {cd:>8.4}\n"));
778 }
779 out
780 }
781 }
782}
783
784pub fn get_drag_coefficient_with_transonic(
791 mach: f64,
792 drag_model: &DragModel,
793 apply_transonic_correction: bool,
794 projectile_shape: Option<ProjectileShape>,
795 caliber: Option<f64>,
796 weight_grains: Option<f64>,
797) -> f64 {
798 let base_cd = get_drag_coefficient(mach, drag_model);
800
801 if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
803 let shape = match projectile_shape {
805 Some(s) => s,
806 None => {
807 if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
808 get_projectile_shape(
809 cal,
810 weight,
811 match drag_model {
812 DragModel::G1 => "G1",
813 DragModel::G6 => "G6",
814 DragModel::G7 => "G7",
815 DragModel::G8 => "G8",
816 _ => "G1", },
818 )
819 } else {
820 ProjectileShape::Spitzer }
822 }
823 };
824
825 transonic_correction(mach, base_cd, shape, false)
830 } else {
831 base_cd
832 }
833}
834
835#[allow(clippy::too_many_arguments)] pub fn get_drag_coefficient_full(
844 mach: f64,
845 drag_model: &DragModel,
846 apply_transonic_correction: bool,
847 apply_reynolds_correction: bool,
848 projectile_shape: Option<ProjectileShape>,
849 caliber: Option<f64>,
850 weight_grains: Option<f64>,
851 velocity_mps: Option<f64>,
852 air_density_kg_m3: Option<f64>,
853 temperature_c: Option<f64>,
854) -> f64 {
855 let mut cd = get_drag_coefficient_with_transonic(
857 mach,
858 drag_model,
859 apply_transonic_correction,
860 projectile_shape,
861 caliber,
862 weight_grains,
863 );
864
865 if apply_reynolds_correction && mach < 1.0 {
868 if let (Some(v), Some(cal), Some(rho), Some(temp)) =
869 (velocity_mps, caliber, air_density_kg_m3, temperature_c)
870 {
871 use crate::reynolds::apply_reynolds_correction;
872 cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
873 }
874 }
875
876 cd
877}
878
879#[cfg(test)]
880#[allow(clippy::items_after_test_module)] mod tests {
882 use super::*;
883
884 #[test]
885 fn test_g1_drag_coefficient_interpolation() {
886 let cd = get_drag_coefficient(1.0, &DragModel::G1);
887 assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
889 }
890
891 #[test]
892 fn test_g7_drag_coefficient_interpolation() {
893 let cd = get_drag_coefficient(1.0, &DragModel::G7);
894 assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
896 }
897
898 #[test]
899 fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
900 let models = [
901 DragModel::G1,
902 DragModel::G2,
903 DragModel::G5,
904 DragModel::G6,
905 DragModel::G7,
906 DragModel::G8,
907 DragModel::GI,
908 DragModel::GS,
909 ];
910 for drag_model in models {
911 for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
912 let base_cd = get_drag_coefficient(mach, &drag_model);
913 let corrected_cd = get_drag_coefficient_with_transonic(
914 mach,
915 &drag_model,
916 true,
917 Some(ProjectileShape::BoatTail),
918 Some(0.308),
919 Some(175.0),
920 );
921 assert_eq!(
922 corrected_cd.to_bits(),
923 base_cd.to_bits(),
924 "standard {drag_model:?} table already includes transonic drag at Mach \
925 {mach}: base={base_cd}, corrected={corrected_cd}"
926 );
927
928 let full_cd = get_drag_coefficient_full(
929 mach,
930 &drag_model,
931 true,
932 false,
933 Some(ProjectileShape::BoatTail),
934 Some(0.308),
935 Some(175.0),
936 None,
937 None,
938 None,
939 );
940 assert_eq!(full_cd.to_bits(), base_cd.to_bits());
941 }
942 }
943 }
944
945 #[test]
946 fn test_drag_coefficient_continuity() {
947 for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
949 let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
950 let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
951 let difference = (cd_after - cd_before).abs();
952 assert!(
953 difference < 0.05,
954 "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
955 );
956 }
957 }
958
959 #[test]
960 fn test_endpoint_bounds() {
961 let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
963 assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
964
965 let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
967 assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
968
969 let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
971 assert!(
972 cd_low_g7 > 0.01,
973 "Low Mach G7 should be positive: {cd_low_g7}"
974 );
975
976 let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
977 assert!(
978 cd_high_g7 >= 0.01,
979 "High Mach G7 should be positive: {cd_high_g7}"
980 );
981 }
982
983 #[test]
984 fn test_drag_table_creation() {
985 let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
986 let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
987 let table = DragTable::new(mach_vals, cd_vals);
988
989 assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
991
992 let cd_interp = table.interpolate(1.25);
994 assert!(cd_interp > 0.4 && cd_interp < 0.5);
995 }
996
997 #[test]
998 fn test_drag_table_empty() {
999 let table = DragTable::new(vec![], vec![]);
1000 let result = table.interpolate(1.0);
1001 assert_eq!(result, 0.5); }
1003
1004 #[test]
1005 fn test_drag_table_single_point() {
1006 let table = DragTable::new(vec![1.0], vec![0.4]);
1007
1008 assert_eq!(table.interpolate(0.5), 0.4);
1010 assert_eq!(table.interpolate(1.0), 0.4);
1011 assert_eq!(table.interpolate(2.0), 0.4);
1012 }
1013
1014 #[test]
1015 fn test_drag_table_two_points() {
1016 let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
1017
1018 assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
1020 assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
1021
1022 let mid = table.interpolate(1.5);
1024 assert!((mid - 0.5).abs() < 1e-10);
1025
1026 let below = table.interpolate(0.5);
1028 assert_eq!(below.to_bits(), 0.4_f64.to_bits());
1029
1030 let above = table.interpolate(3.0);
1031 assert_eq!(above.to_bits(), 0.6_f64.to_bits());
1032 }
1033
1034 #[test]
1035 fn out_of_range_mach_holds_boundary_cd() {
1036 let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
1037
1038 for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
1039 assert_eq!(
1040 table.interpolate(mach).to_bits(),
1041 0.2_f64.to_bits(),
1042 "Mach {mach} must hold the first tabulated Cd"
1043 );
1044 }
1045 for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
1046 assert_eq!(
1047 table.interpolate(mach).to_bits(),
1048 0.3_f64.to_bits(),
1049 "Mach {mach} must hold the last tabulated Cd"
1050 );
1051 }
1052 }
1053
1054 #[test]
1055 fn test_linear_interpolation() {
1056 let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
1057
1058 let result = table.linear_interpolate(0.5, 0);
1060 assert!((result - 0.35).abs() < 1e-10);
1061
1062 let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
1064 let result_same = table_same.linear_interpolate(1.0, 0);
1065 assert_eq!(result_same, 0.4); }
1067
1068 #[test]
1069 fn test_cubic_interpolation() {
1070 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]);
1072
1073 let result = table.cubic_interpolate(1.25, 1);
1075
1076 assert!(result > 0.3 && result < 0.7);
1078
1079 let linear_result = table.linear_interpolate(1.25, 1);
1081 assert!((result - linear_result).abs() < 0.2);
1083 }
1084
1085 #[test]
1086 fn nonuniform_cubic_reproduces_affine_data() {
1087 let table = DragTable::new(vec![0.0, 1.0, 3.0, 4.0], vec![0.25, 0.3125, 0.4375, 0.5]);
1088
1089 for mach in [1.5, 2.5] {
1090 let expected = 0.25 + mach / 16.0;
1091 let actual = table.interpolate(mach);
1092 assert_eq!(
1093 actual.to_bits(),
1094 expected.to_bits(),
1095 "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
1096 );
1097 }
1098 }
1099
1100 #[test]
1101 fn nonuniform_cubic_is_c1_at_spacing_transition() {
1102 let table = DragTable::new(
1103 vec![0.0, 1.0, 3.0, 4.0, 7.0],
1104 vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
1105 );
1106 let knot = 3.0;
1107 let expected_at_knot = 0.390625_f64;
1108 let epsilon = 1e-6;
1109 let at_knot = table.interpolate(knot);
1110 let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
1111 let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
1112
1113 assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
1114 assert!(
1115 (left_slope - right_slope).abs() < 1e-5,
1116 "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
1117 );
1118 }
1119
1120 #[test]
1121 fn test_find_drag_tables_dir() {
1122 let _dir = find_drag_tables_dir();
1125 }
1127
1128 #[test]
1129 fn test_load_drag_table_fallback() {
1130 use std::path::Path;
1131
1132 let fake_dir = Path::new("/non/existent/directory");
1134 let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
1135
1136 let table = load_drag_table(fake_dir, "test", &fallback_data);
1137
1138 assert_eq!(table.mach_values.len(), 3);
1140 assert_eq!(table.cd_values.len(), 3);
1141 assert_eq!(table.mach_values[0], 0.5);
1142 assert_eq!(table.cd_values[0], 0.2);
1143 }
1144
1145 #[test]
1146 fn test_known_drag_values() {
1147 let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
1151 assert!(
1152 (g1_mach1 - 0.4805).abs() < 0.01,
1153 "G1 at Mach 1.0: {g1_mach1}"
1154 );
1155
1156 let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
1158 assert!(
1159 (g7_mach1 - 0.3803).abs() < 0.01,
1160 "G7 at Mach 1.0: {g7_mach1}"
1161 );
1162
1163 assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
1165 }
1166
1167 #[test]
1168 fn test_monotonicity_properties() {
1169 let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); let g1_values: Vec<f64> = mach_values
1174 .iter()
1175 .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1176 .collect();
1177
1178 let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1180 let max_index = g1_values
1181 .iter()
1182 .position(|&x| x == max_value)
1183 .expect("Should find maximum in non-empty vector");
1184 let peak_mach = mach_values
1185 .get(max_index)
1186 .copied()
1187 .expect("Index should be valid");
1188
1189 assert!(
1191 peak_mach > 1.0 && peak_mach < 1.6,
1192 "G1 peak at Mach {peak_mach}"
1193 );
1194 assert!(
1195 max_value > 0.5 && max_value < 1.0,
1196 "G1 peak value: {max_value}"
1197 );
1198 }
1199
1200 #[test]
1201 fn test_physical_constraints() {
1202 let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1203
1204 for &mach in &test_machs {
1205 let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1206 let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1207
1208 assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1210 assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1211
1212 assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1214 assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1215 }
1216 }
1217
1218 #[test]
1234 fn test_performance_characteristics() {
1235 use std::time::Instant;
1236
1237 let start = Instant::now();
1238
1239 for i in 0..1000 {
1240 let mach = 0.5 + (i as f64) * 0.004; let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1242 let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1243 }
1244
1245 let elapsed = start.elapsed();
1246
1247 assert!(
1248 elapsed.as_secs() < 2,
1249 "2000 drag lookups took {}ms — that is ~{}us per lookup, which means the lookup \
1250 is doing real work (IO, parsing, or allocation) rather than interpolating an \
1251 in-memory table",
1252 elapsed.as_millis(),
1253 elapsed.as_micros() / 2000
1254 );
1255 }
1256
1257 #[test]
1258 fn try_new_accepts_valid_table() {
1259 let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1260 assert_eq!(t.mach_values.len(), 3);
1261 }
1262
1263 #[test]
1264 fn try_new_rejects_mismatched_lengths() {
1265 let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1266 assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1267 }
1268
1269 #[test]
1270 fn try_new_rejects_too_few_points() {
1271 assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1272 }
1273
1274 #[test]
1275 fn try_new_rejects_non_ascending_mach() {
1276 assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1277 assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1278 }
1279
1280 #[test]
1281 fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1282 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1283 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1284 }
1285
1286 #[test]
1287 fn interpolate_does_not_panic_on_mismatched_table() {
1288 let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1290 let _ = bad.interpolate(0.1);
1291 let _ = bad.interpolate(5.0);
1292 let _ = bad.interpolate(1.0);
1293 }
1294
1295 #[test]
1296 fn from_csv_str_parses_with_header_and_comments() {
1297 let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1298 let t = DragTable::from_csv_str(csv).unwrap();
1299 assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1300 assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1301 }
1302
1303 #[test]
1304 fn from_csv_str_rejects_malformed_data_row() {
1305 let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1307 assert!(e.contains("line 2"), "got: {e}");
1308 }
1309
1310 #[test]
1311 fn from_csv_str_rejects_empty() {
1312 assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1313 }
1314
1315 #[test]
1316 fn from_csv_str_rejects_malformed_first_data_row() {
1317 assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1319 assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1320 }
1321
1322 #[test]
1323 fn from_csv_str_still_skips_textual_header() {
1324 let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1326 assert_eq!(t.mach_values, vec![0.5, 1.0]);
1327 }
1328
1329 #[test]
1330 fn from_csv_str_roundtrips_shipped_g7() {
1331 let g7 = include_str!("../data/g7.csv");
1333 let t = DragTable::from_csv_str(g7).unwrap();
1334 assert!(t.mach_values.len() > 20);
1335 }
1336
1337 #[test]
1340 fn test_g2_drag_coefficient_spot_values() {
1341 for (mach, expected) in [(0.0, 0.2303), (0.70, 0.1702), (1.20, 0.4021), (5.0, 0.1648)] {
1343 let cd = get_drag_coefficient(mach, &DragModel::G2);
1344 assert!(
1345 (cd - expected).abs() < 1e-6,
1346 "G2 CD at Mach {mach}: expected {expected}, got {cd}"
1347 );
1348 }
1349 }
1350
1351 #[test]
1352 fn test_g5_drag_coefficient_spot_values() {
1353 for (mach, expected) in [(0.0, 0.1710), (0.75, 0.1463), (1.0, 0.3379), (5.0, 0.2280)] {
1355 let cd = get_drag_coefficient(mach, &DragModel::G5);
1356 assert!(
1357 (cd - expected).abs() < 1e-6,
1358 "G5 CD at Mach {mach}: expected {expected}, got {cd}"
1359 );
1360 }
1361 }
1362
1363 #[test]
1364 fn test_gi_drag_coefficient_spot_values() {
1365 for (mach, expected) in [(0.0, 0.2282), (0.90, 0.3170), (1.20, 0.6279), (5.0, 0.4082)] {
1367 let cd = get_drag_coefficient(mach, &DragModel::GI);
1368 assert!(
1369 (cd - expected).abs() < 1e-6,
1370 "GI CD at Mach {mach}: expected {expected}, got {cd}"
1371 );
1372 }
1373 }
1374
1375 #[test]
1376 fn test_gs_drag_coefficient_spot_values() {
1377 for (mach, expected) in [(0.0, 0.4662), (0.60, 0.5260), (1.60, 1.0090), (4.0, 0.9280)] {
1379 let cd = get_drag_coefficient(mach, &DragModel::GS);
1380 assert!(
1381 (cd - expected).abs() < 1e-6,
1382 "GS CD at Mach {mach}: expected {expected}, got {cd}"
1383 );
1384 }
1385 }
1386
1387 #[test]
1388 fn test_ra4_drag_coefficient_spot_values() {
1389 for (mach, expected) in [(0.0, 0.2283), (0.70, 0.2288), (1.15, 0.5943), (4.0, 0.4969)] {
1391 let cd = get_drag_coefficient(mach, &DragModel::RA4);
1392 assert!(
1393 (cd - expected).abs() < 1e-6,
1394 "RA4 CD at Mach {mach}: expected {expected}, got {cd}"
1395 );
1396 }
1397 }
1398
1399 #[test]
1400 fn embedded_family_tables_have_ascending_mach_and_positive_cd() {
1401 let decks: [(&str, &str); 7] = [
1405 ("G1", include_str!("../data/g1.csv")),
1406 ("G2", include_str!("../data/g2.csv")),
1407 ("G5", include_str!("../data/g5.csv")),
1408 ("G7", include_str!("../data/g7.csv")),
1409 ("GI", include_str!("../data/gi.csv")),
1410 ("GS", include_str!("../data/gs.csv")),
1411 ("RA4", include_str!("../data/ra4.csv")),
1412 ];
1413 for (name, csv) in decks {
1414 let table = DragTable::from_csv_str(csv)
1415 .unwrap_or_else(|e| panic!("{name} failed to parse: {e}"));
1416 assert!(
1417 table.mach_values.len() > 20,
1418 "{name}: expected a high-resolution table, got {} points",
1419 table.mach_values.len()
1420 );
1421 assert_eq!(
1422 table.mach_values[0], 0.0,
1423 "{name}: expected Mach axis to start at 0.0"
1424 );
1425 }
1426
1427 let models = [
1430 DragModel::G1,
1431 DragModel::G2,
1432 DragModel::G5,
1433 DragModel::G6,
1434 DragModel::G7,
1435 DragModel::G8,
1436 DragModel::GI,
1437 DragModel::GS,
1438 DragModel::RA4,
1439 ];
1440 for model in models {
1441 for i in 0..=50 {
1442 let mach = i as f64 * 0.1; let cd = get_drag_coefficient(mach, &model);
1444 assert!(
1445 cd.is_finite() && cd > 0.0 && cd < 2.0,
1446 "{model:?} CD out of range at Mach {mach}: {cd}"
1447 );
1448 }
1449 }
1450 }
1451
1452 fn interpolated_drop_at(points: &[crate::TrajectoryPoint], target_x_m: f64) -> f64 {
1456 for (i, point) in points.iter().enumerate() {
1457 if point.position.x >= target_x_m {
1458 if i == 0 {
1459 return point.position.y;
1460 }
1461 let previous = &points[i - 1];
1462 let span = point.position.x - previous.position.x;
1463 if span.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1464 return point.position.y;
1465 }
1466 let fraction = (target_x_m - previous.position.x) / span;
1467 return previous.position.y + fraction * (point.position.y - previous.position.y);
1468 }
1469 }
1470 points.last().map(|p| p.position.y).unwrap_or(f64::NAN)
1471 }
1472
1473 fn drop_at_500yd_m(model: DragModel) -> f64 {
1474 let inputs = crate::BallisticInputs {
1475 bc_type: model,
1476 bc_value: 0.5,
1477 ground_threshold: -1000.0,
1478 ..Default::default()
1479 };
1480 let solver = crate::TrajectorySolver::new(
1481 inputs,
1482 crate::WindConditions::default(),
1483 crate::AtmosphericConditions::default(),
1484 );
1485 let result = solver.solve().expect("solve should succeed");
1486 assert!(result.max_range.is_finite());
1487 assert!(result.impact_velocity.is_finite() && result.impact_velocity > 0.0);
1488 assert!(
1489 !result.points.is_empty(),
1490 "{model:?}: solver produced no points"
1491 );
1492 let drop = interpolated_drop_at(&result.points, 457.2); assert!(drop.is_finite(), "{model:?}: non-finite drop at 500yd");
1494 drop
1495 }
1496
1497 #[test]
1498 fn mba1386_g2_g5_gi_gs_drop_differs_from_g1_now_that_fallback_is_gone() {
1499 let g1_drop = drop_at_500yd_m(DragModel::G1);
1500 for model in [DragModel::G2, DragModel::G5, DragModel::GI, DragModel::GS] {
1501 let drop = drop_at_500yd_m(model);
1502 assert!(
1503 (drop - g1_drop).abs() > 0.01,
1504 "{model:?} 500yd drop ({drop}) must differ from the G1 result ({g1_drop}) by \
1505 more than solver noise now that the real table is wired in"
1506 );
1507 }
1508 }
1509
1510 #[test]
1511 fn mba1386_ra4_solver_smoke() {
1512 let drop = drop_at_500yd_m(DragModel::RA4);
1514 assert!(
1515 drop < 0.0,
1516 "500yd drop should be a fall below the muzzle line: {drop}"
1517 );
1518 }
1519}
1520
1521pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1523 if segments.is_empty() {
1524 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1526
1527 let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1529
1530 if mach_values.is_empty() || segments.is_empty() {
1532 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1534
1535 if let Some(first_mach) = mach_values.first() {
1537 if mach <= *first_mach {
1538 return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1539 }
1540 }
1541
1542 if let Some(last_mach) = mach_values.last() {
1543 if mach >= *last_mach {
1544 return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1545 }
1546 }
1547
1548 let idx = match mach_values
1550 .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1551 {
1552 Ok(idx) => {
1553 return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1555 }
1556 Err(idx) => idx, };
1558
1559 if idx == 0 || idx >= segments.len() {
1561 let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1564 return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1565 }
1566
1567 match (segments.get(idx - 1), segments.get(idx)) {
1569 (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1570 let denominator = hi_mach - lo_mach;
1572 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1573 return *lo_bc; }
1575 let frac = (mach - lo_mach) / denominator;
1576 lo_bc + frac * (hi_bc - lo_bc)
1577 }
1578 _ => 0.5, }
1580}
1581
1582#[cfg(test)]
1586mod reference_drag_table_tests {
1587 use super::*;
1588
1589 const ALL_MODELS: [DragModel; 9] = [
1590 DragModel::G1,
1591 DragModel::G2,
1592 DragModel::G5,
1593 DragModel::G6,
1594 DragModel::G7,
1595 DragModel::G8,
1596 DragModel::GI,
1597 DragModel::GS,
1598 DragModel::RA4,
1599 ];
1600
1601 #[test]
1605 fn the_exposed_table_is_the_one_the_solver_interpolates() {
1606 for model in ALL_MODELS {
1607 let table = reference_drag_table(&model);
1608 for (&mach, &cd) in table.mach_values.iter().zip(table.cd_values.iter()) {
1609 let interpolated = get_drag_coefficient(mach, &model);
1610 assert!(
1611 (interpolated - cd).abs() < 1e-12,
1612 "{model:?} disagrees at Mach {mach}: table {cd} vs solver {interpolated}"
1613 );
1614 }
1615 }
1616 }
1617
1618 #[test]
1619 fn every_model_has_a_usable_table() {
1620 for model in ALL_MODELS {
1621 let table = reference_drag_table(&model);
1622 assert_eq!(
1623 table.mach_values.len(),
1624 table.cd_values.len(),
1625 "{model:?} axes differ in length"
1626 );
1627 assert!(
1628 table.mach_values.len() > 2,
1629 "{model:?} fell back to a stub table"
1630 );
1631 assert!(
1632 table.mach_values.windows(2).all(|w| w[1] > w[0]),
1633 "{model:?} Mach axis is not strictly ascending"
1634 );
1635 assert!(
1636 table.cd_values.iter().all(|cd| cd.is_finite() && *cd > 0.0),
1637 "{model:?} has a non-physical Cd"
1638 );
1639 }
1640 }
1641
1642 #[test]
1646 fn the_mach_domain_is_per_table_not_universal() {
1647 let g7_max = *reference_drag_table(&DragModel::G7)
1648 .mach_values
1649 .last()
1650 .unwrap();
1651 let gs_max = *reference_drag_table(&DragModel::GS)
1652 .mach_values
1653 .last()
1654 .unwrap();
1655 let ra4_max = *reference_drag_table(&DragModel::RA4)
1656 .mach_values
1657 .last()
1658 .unwrap();
1659
1660 assert!(
1661 g7_max > gs_max,
1662 "G7 should extend past GS ({g7_max} vs {gs_max})"
1663 );
1664 assert!(
1665 g7_max > ra4_max,
1666 "G7 should extend past RA4 ({g7_max} vs {ra4_max})"
1667 );
1668 }
1669}