1use crate::transonic_drag::{get_projectile_shape, transonic_correction, ProjectileShape};
2use crate::DragModel;
3use ndarray::ArrayD;
4use std::sync::LazyLock;
5use std::path::Path;
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 { mach_values, cd_values })
66 }
67
68 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 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 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 pub fn interpolate(&self, mach: f64) -> f64 {
126 let n = self.mach_values.len();
127
128 if n == 0 {
129 return 0.5; }
131
132 if n == 1 {
133 return self.cd_values.first().copied().unwrap_or(0.5);
134 }
135
136 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 return self.cd_values.get(n - 1).copied()
146 .or_else(|| self.cd_values.last().copied())
147 .unwrap_or(0.5);
148 }
149
150 let idx = self
154 .mach_values
155 .partition_point(|&m| m < mach)
156 .saturating_sub(1)
157 .min(n - 2);
158
159 if idx > 0 && idx < n - 2 {
161 self.cubic_interpolate(mach, idx)
163 } else {
164 self.linear_interpolate(mach, idx)
166 }
167 }
168
169 pub fn linear_interpolate(&self, mach: f64, idx: usize) -> f64 {
171 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 pub fn cubic_interpolate(&self, mach: f64, idx: usize) -> f64 {
191 if idx == 0 || idx + 1 >= self.mach_values.len() || idx + 1 >= self.cd_values.len() {
193 return self.linear_interpolate(mach, idx);
195 }
196
197 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 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
246pub fn load_drag_table(
248 drag_tables_dir: &Path,
249 filename: &str,
250 fallback_data: &[(f64, f64)],
251) -> DragTable {
252 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 let csv_path = drag_tables_dir.join(format!("{filename}.csv"));
269 if let Ok(bytes) = std::fs::read(&csv_path) {
270 let text = String::from_utf8_lossy(&bytes).replace('\r', "\n");
273 let mut mach_values = Vec::new();
274 let mut cd_values = Vec::new();
275
276 for line in text.lines() {
277 let mut fields = line.split(',');
278 if let (Some(m_str), Some(cd_str)) = (fields.next(), fields.next()) {
279 if let (Ok(mach), Ok(cd)) = (
282 m_str.trim().trim_matches('"').trim().parse::<f64>(),
283 cd_str.trim().trim_matches('"').trim().parse::<f64>(),
284 ) {
285 mach_values.push(mach);
286 cd_values.push(cd);
287 }
288 }
289 }
290
291 if !mach_values.is_empty() {
292 return DragTable::new(mach_values, cd_values);
293 }
294 }
295
296 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
298 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
299 DragTable::new(mach_values, cd_values)
300}
301
302fn find_drag_tables_dir() -> Option<std::path::PathBuf> {
304 let candidates = [
306 "../drag_tables",
307 "../../drag_tables",
308 "../../../drag_tables",
309 "drag_tables",
310 ];
311
312 for candidate in &candidates {
313 let path = Path::new(candidate);
314 if path.exists() && path.is_dir() {
315 return Some(path.to_path_buf());
316 }
317 }
318
319 None
320}
321
322fn parse_embedded_drag_table(csv: &str, fallback: &[(f64, f64)]) -> DragTable {
327 let mut mach_values = Vec::new();
328 let mut cd_values = Vec::new();
329 for line in csv.lines() {
330 let line = line.trim();
331 if line.is_empty() {
332 continue;
333 }
334 let mut cols = line.split(',');
335 if let (Some(m), Some(cd)) = (cols.next(), cols.next()) {
336 if let (Ok(m), Ok(cd)) = (m.trim().parse::<f64>(), cd.trim().parse::<f64>()) {
337 mach_values.push(m);
338 cd_values.push(cd);
339 }
340 }
341 }
342 if mach_values.is_empty() {
343 mach_values = fallback.iter().map(|(m, _)| *m).collect();
344 cd_values = fallback.iter().map(|(_, cd)| *cd).collect();
345 }
346 DragTable::new(mach_values, cd_values)
347}
348
349static G1_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
354 let fallback_data = [
356 (0.0, 0.2629),
357 (0.5, 0.2695),
358 (0.6, 0.2752),
359 (0.7, 0.2817),
360 (0.8, 0.2902),
361 (0.9, 0.3012),
362 (1.0, 0.4805),
363 (1.1, 0.5933),
364 (1.2, 0.6318),
365 (1.3, 0.6440),
366 (1.4, 0.6444),
367 (1.5, 0.6372),
368 (1.6, 0.6252),
369 (1.7, 0.6105),
370 (1.8, 0.5956),
371 (1.9, 0.5815),
372 (2.0, 0.5934),
373 (2.5, 0.5598),
374 (3.0, 0.5133),
375 (4.0, 0.4811),
376 (5.0, 0.4988),
377 ];
378
379 parse_embedded_drag_table(include_str!("../data/g1.csv"), &fallback_data)
380});
381
382static G7_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
387 let fallback_data = [
389 (0.0, 0.1198),
390 (0.5, 0.1197),
391 (0.6, 0.1202),
392 (0.7, 0.1213),
393 (0.8, 0.1240),
394 (0.9, 0.1294),
395 (1.0, 0.3803),
396 (1.1, 0.4015),
397 (1.2, 0.4043),
398 (1.3, 0.3956),
399 (1.4, 0.3814),
400 (1.5, 0.3663),
401 (1.6, 0.3520),
402 (1.7, 0.3398),
403 (1.8, 0.3297),
404 (1.9, 0.3221),
405 (2.0, 0.2980),
406 (2.5, 0.2731),
407 (3.0, 0.2424),
408 (4.0, 0.2196),
409 (5.0, 0.1618),
410 ];
411
412 parse_embedded_drag_table(include_str!("../data/g7.csv"), &fallback_data)
413});
414
415static G6_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
418 let fallback_data = [
419 (0.0, 0.2617),
420 (0.05, 0.2553),
421 (0.10, 0.2491),
422 (0.15, 0.2432),
423 (0.20, 0.2376),
424 (0.25, 0.2324),
425 (0.30, 0.2278),
426 (0.35, 0.2238),
427 (0.40, 0.2205),
428 (0.45, 0.2177),
429 (0.50, 0.2155),
430 (0.55, 0.2138),
431 (0.60, 0.2126),
432 (0.65, 0.2121),
433 (0.70, 0.2122),
434 (0.75, 0.2132),
435 (0.80, 0.2154),
436 (0.85, 0.2194),
437 (0.875, 0.2229),
438 (0.90, 0.2297),
439 (0.925, 0.2449),
440 (0.95, 0.2732),
441 (0.975, 0.3141),
442 (1.0, 0.3597),
443 (1.025, 0.3994),
444 (1.05, 0.4261),
445 (1.075, 0.4402),
446 (1.10, 0.4465),
447 (1.125, 0.4490),
448 (1.15, 0.4497),
449 (1.175, 0.4494),
450 (1.20, 0.4482),
451 (1.225, 0.4464),
452 (1.25, 0.4441),
453 (1.30, 0.4390),
454 (1.35, 0.4336),
455 (1.40, 0.4279),
456 (1.45, 0.4221),
457 (1.50, 0.4162),
458 (1.55, 0.4102),
459 (1.60, 0.4042),
460 (1.65, 0.3981),
461 (1.70, 0.3919),
462 (1.75, 0.3855),
463 (1.80, 0.3788),
464 (1.85, 0.3721),
465 (1.90, 0.3652),
466 (1.95, 0.3583),
467 (2.0, 0.3515),
468 (2.05, 0.3447),
469 (2.10, 0.3381),
470 (2.15, 0.3314),
471 (2.20, 0.3249),
472 (2.25, 0.3185),
473 (2.30, 0.3122),
474 (2.35, 0.3060),
475 (2.40, 0.3000),
476 (2.45, 0.2941),
477 (2.50, 0.2883),
478 (2.60, 0.2772),
479 (2.70, 0.2668),
480 (2.80, 0.2574),
481 (2.90, 0.2487),
482 (3.0, 0.2407),
483 (3.10, 0.2333),
484 (3.20, 0.2265),
485 (3.30, 0.2202),
486 (3.40, 0.2144),
487 (3.50, 0.2089),
488 (3.60, 0.2039),
489 (3.70, 0.1991),
490 (3.80, 0.1947),
491 (3.90, 0.1905),
492 (4.0, 0.1866),
493 (4.20, 0.1794),
494 (4.40, 0.1730),
495 (4.60, 0.1673),
496 (4.80, 0.1621),
497 (5.0, 0.1574),
498 ];
499
500 if let Some(drag_dir) = find_drag_tables_dir() {
501 load_drag_table(&drag_dir, "g6", &fallback_data)
502 } else {
503 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
505 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
506 DragTable::new(mach_values, cd_values)
507 }
508});
509
510static G8_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
513 let fallback_data = [
514 (0.0, 0.2105),
515 (0.05, 0.2105),
516 (0.10, 0.2104),
517 (0.15, 0.2104),
518 (0.20, 0.2103),
519 (0.25, 0.2103),
520 (0.30, 0.2103),
521 (0.35, 0.2103),
522 (0.40, 0.2103),
523 (0.45, 0.2102),
524 (0.50, 0.2102),
525 (0.55, 0.2102),
526 (0.60, 0.2102),
527 (0.65, 0.2102),
528 (0.70, 0.2103),
529 (0.75, 0.2103),
530 (0.80, 0.2104),
531 (0.825, 0.2104),
532 (0.85, 0.2105),
533 (0.875, 0.2106),
534 (0.90, 0.2109),
535 (0.925, 0.2183),
536 (0.95, 0.2571),
537 (0.975, 0.3358),
538 (1.0, 0.4068),
539 (1.025, 0.4378),
540 (1.05, 0.4476),
541 (1.075, 0.4493),
542 (1.10, 0.4477),
543 (1.125, 0.4450),
544 (1.15, 0.4419),
545 (1.20, 0.4353),
546 (1.25, 0.4283),
547 (1.30, 0.4208),
548 (1.35, 0.4133),
549 (1.40, 0.4059),
550 (1.45, 0.3986),
551 (1.50, 0.3915),
552 (1.55, 0.3845),
553 (1.60, 0.3777),
554 (1.65, 0.3710),
555 (1.70, 0.3645),
556 (1.75, 0.3581),
557 (1.80, 0.3519),
558 (1.85, 0.3458),
559 (1.90, 0.3400),
560 (1.95, 0.3343),
561 (2.0, 0.3288),
562 (2.05, 0.3234),
563 (2.10, 0.3182),
564 (2.15, 0.3131),
565 (2.20, 0.3081),
566 (2.25, 0.3032),
567 (2.30, 0.2983),
568 (2.35, 0.2937),
569 (2.40, 0.2891),
570 (2.45, 0.2845),
571 (2.50, 0.2802),
572 (2.60, 0.2720),
573 (2.70, 0.2642),
574 (2.80, 0.2569),
575 (2.90, 0.2499),
576 (3.0, 0.2432),
577 (3.10, 0.2368),
578 (3.20, 0.2308),
579 (3.30, 0.2251),
580 (3.40, 0.2197),
581 (3.50, 0.2147),
582 (3.60, 0.2101),
583 (3.70, 0.2058),
584 (3.80, 0.2019),
585 (3.90, 0.1983),
586 (4.0, 0.1950),
587 (4.20, 0.1890),
588 (4.40, 0.1837),
589 (4.60, 0.1791),
590 (4.80, 0.1750),
591 (5.0, 0.1713),
592 ];
593
594 if let Some(drag_dir) = find_drag_tables_dir() {
595 load_drag_table(&drag_dir, "g8", &fallback_data)
596 } else {
597 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
599 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
600 DragTable::new(mach_values, cd_values)
601 }
602});
603
604static G2_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
608 let fallback_data = [(0.0, 0.2303), (1.0, 0.3983), (5.0, 0.1648)];
610 parse_embedded_drag_table(include_str!("../data/g2.csv"), &fallback_data)
611});
612
613static G5_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
617 let fallback_data = [(0.0, 0.1710), (1.0, 0.3379), (5.0, 0.2280)];
619 parse_embedded_drag_table(include_str!("../data/g5.csv"), &fallback_data)
620});
621
622static GI_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
626 let fallback_data = [(0.0, 0.2282), (1.0, 0.4349), (5.0, 0.4082)];
628 parse_embedded_drag_table(include_str!("../data/gi.csv"), &fallback_data)
629});
630
631static GS_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
636 let fallback_data = [(0.0, 0.4662), (1.0, 0.8140), (4.0, 0.9280)];
638 parse_embedded_drag_table(include_str!("../data/gs.csv"), &fallback_data)
639});
640
641static RA4_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
646 let fallback_data = [(0.0, 0.2283), (1.0, 0.3975), (4.0, 0.4969)];
648 parse_embedded_drag_table(include_str!("../data/ra4.csv"), &fallback_data)
649});
650
651pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
657 match drag_model {
658 DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
659 DragModel::G2 => G2_DRAG_TABLE.interpolate(mach),
660 DragModel::G5 => G5_DRAG_TABLE.interpolate(mach),
661 DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
662 DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
663 DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
664 DragModel::GI => GI_DRAG_TABLE.interpolate(mach),
665 DragModel::GS => GS_DRAG_TABLE.interpolate(mach),
666 DragModel::RA4 => RA4_DRAG_TABLE.interpolate(mach),
667 }
668}
669
670pub fn reference_drag_table(drag_model: &DragModel) -> &'static DragTable {
687 match drag_model {
688 DragModel::G1 => &G1_DRAG_TABLE,
689 DragModel::G2 => &G2_DRAG_TABLE,
690 DragModel::G5 => &G5_DRAG_TABLE,
691 DragModel::G6 => &G6_DRAG_TABLE,
692 DragModel::G7 => &G7_DRAG_TABLE,
693 DragModel::G8 => &G8_DRAG_TABLE,
694 DragModel::GI => &GI_DRAG_TABLE,
695 DragModel::GS => &GS_DRAG_TABLE,
696 DragModel::RA4 => &RA4_DRAG_TABLE,
697 }
698}
699
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum ReferenceDragCurveFormat {
703 Table,
704 Csv,
705 Json,
706}
707
708pub fn format_reference_drag_curve(
720 drag_model: &DragModel,
721 format: ReferenceDragCurveFormat,
722) -> String {
723 let table = reference_drag_table(drag_model);
724 let points: Vec<(f64, f64)> = table
725 .mach_values
726 .iter()
727 .copied()
728 .zip(table.cd_values.iter().copied())
729 .collect();
730
731 match format {
732 ReferenceDragCurveFormat::Json => {
733 let document = serde_json::json!({
734 "drag_model": drag_model.to_string(),
735 "point_count": points.len(),
736 "mach_min": points.first().map(|p| p.0),
739 "mach_max": points.last().map(|p| p.0),
740 "source": "Aberdeen/BRL reference functions as tabulated in McCoy, Modern \
741 Exterior Ballistics (RA4: British RA 1929). Public domain.",
742 "points": points
743 .iter()
744 .map(|(mach, cd)| serde_json::json!({"mach": mach, "cd": cd}))
745 .collect::<Vec<_>>(),
746 });
747 let mut out = serde_json::to_string_pretty(&document)
748 .expect("reference drag curve document serializes");
750 out.push('\n');
751 out
752 }
753 ReferenceDragCurveFormat::Csv => {
754 let mut out = String::from("mach,cd\n");
755 for (mach, cd) in &points {
756 out.push_str(&format!("{mach},{cd}\n"));
757 }
758 out
759 }
760 ReferenceDragCurveFormat::Table => {
761 let mut out = format!(
762 "{} reference drag curve\n{} points, Mach {:.2} to {:.2}\n\n",
763 drag_model.to_string().to_uppercase(),
764 points.len(),
765 points.first().map(|p| p.0).unwrap_or(0.0),
766 points.last().map(|p| p.0).unwrap_or(0.0)
767 );
768 out.push_str(&format!("{:>8} {:>8}\n", "Mach", "Cd"));
769 out.push_str(&format!("{:->8} {:->8}\n", "", ""));
770 for (mach, cd) in &points {
771 out.push_str(&format!("{mach:>8.3} {cd:>8.4}\n"));
772 }
773 out
774 }
775 }
776}
777
778pub fn get_drag_coefficient_with_transonic(
785 mach: f64,
786 drag_model: &DragModel,
787 apply_transonic_correction: bool,
788 projectile_shape: Option<ProjectileShape>,
789 caliber: Option<f64>,
790 weight_grains: Option<f64>,
791) -> f64 {
792 let base_cd = get_drag_coefficient(mach, drag_model);
794
795 if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
797 let shape = match projectile_shape {
799 Some(s) => s,
800 None => {
801 if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
802 get_projectile_shape(
803 cal,
804 weight,
805 match drag_model {
806 DragModel::G1 => "G1",
807 DragModel::G6 => "G6",
808 DragModel::G7 => "G7",
809 DragModel::G8 => "G8",
810 _ => "G1", },
812 )
813 } else {
814 ProjectileShape::Spitzer }
816 }
817 };
818
819 transonic_correction(mach, base_cd, shape, false)
824 } else {
825 base_cd
826 }
827}
828
829#[allow(clippy::too_many_arguments)] pub fn get_drag_coefficient_full(
838 mach: f64,
839 drag_model: &DragModel,
840 apply_transonic_correction: bool,
841 apply_reynolds_correction: bool,
842 projectile_shape: Option<ProjectileShape>,
843 caliber: Option<f64>,
844 weight_grains: Option<f64>,
845 velocity_mps: Option<f64>,
846 air_density_kg_m3: Option<f64>,
847 temperature_c: Option<f64>,
848) -> f64 {
849 let mut cd = get_drag_coefficient_with_transonic(
851 mach,
852 drag_model,
853 apply_transonic_correction,
854 projectile_shape,
855 caliber,
856 weight_grains,
857 );
858
859 if apply_reynolds_correction && mach < 1.0 {
862 if let (Some(v), Some(cal), Some(rho), Some(temp)) =
863 (velocity_mps, caliber, air_density_kg_m3, temperature_c)
864 {
865 use crate::reynolds::apply_reynolds_correction;
866 cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
867 }
868 }
869
870 cd
871}
872
873#[cfg(test)]
874#[allow(clippy::items_after_test_module)] mod tests {
876 use super::*;
877
878 #[test]
879 fn test_g1_drag_coefficient_interpolation() {
880 let cd = get_drag_coefficient(1.0, &DragModel::G1);
881 assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
883 }
884
885 #[test]
886 fn test_g7_drag_coefficient_interpolation() {
887 let cd = get_drag_coefficient(1.0, &DragModel::G7);
888 assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
890 }
891
892 #[test]
893 fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
894 let models = [
895 DragModel::G1,
896 DragModel::G2,
897 DragModel::G5,
898 DragModel::G6,
899 DragModel::G7,
900 DragModel::G8,
901 DragModel::GI,
902 DragModel::GS,
903 ];
904 for drag_model in models {
905 for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
906 let base_cd = get_drag_coefficient(mach, &drag_model);
907 let corrected_cd = get_drag_coefficient_with_transonic(
908 mach,
909 &drag_model,
910 true,
911 Some(ProjectileShape::BoatTail),
912 Some(0.308),
913 Some(175.0),
914 );
915 assert_eq!(
916 corrected_cd.to_bits(),
917 base_cd.to_bits(),
918 "standard {drag_model:?} table already includes transonic drag at Mach \
919 {mach}: base={base_cd}, corrected={corrected_cd}"
920 );
921
922 let full_cd = get_drag_coefficient_full(
923 mach,
924 &drag_model,
925 true,
926 false,
927 Some(ProjectileShape::BoatTail),
928 Some(0.308),
929 Some(175.0),
930 None,
931 None,
932 None,
933 );
934 assert_eq!(full_cd.to_bits(), base_cd.to_bits());
935 }
936 }
937 }
938
939 #[test]
940 fn test_drag_coefficient_continuity() {
941 for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
943 let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
944 let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
945 let difference = (cd_after - cd_before).abs();
946 assert!(
947 difference < 0.05,
948 "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
949 );
950 }
951 }
952
953 #[test]
954 fn test_endpoint_bounds() {
955 let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
957 assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
958
959 let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
961 assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
962
963 let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
965 assert!(
966 cd_low_g7 > 0.01,
967 "Low Mach G7 should be positive: {cd_low_g7}"
968 );
969
970 let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
971 assert!(
972 cd_high_g7 >= 0.01,
973 "High Mach G7 should be positive: {cd_high_g7}"
974 );
975 }
976
977 #[test]
978 fn test_drag_table_creation() {
979 let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
980 let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
981 let table = DragTable::new(mach_vals, cd_vals);
982
983 assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
985
986 let cd_interp = table.interpolate(1.25);
988 assert!(cd_interp > 0.4 && cd_interp < 0.5);
989 }
990
991 #[test]
992 fn test_drag_table_empty() {
993 let table = DragTable::new(vec![], vec![]);
994 let result = table.interpolate(1.0);
995 assert_eq!(result, 0.5); }
997
998 #[test]
999 fn test_drag_table_single_point() {
1000 let table = DragTable::new(vec![1.0], vec![0.4]);
1001
1002 assert_eq!(table.interpolate(0.5), 0.4);
1004 assert_eq!(table.interpolate(1.0), 0.4);
1005 assert_eq!(table.interpolate(2.0), 0.4);
1006 }
1007
1008 #[test]
1009 fn test_drag_table_two_points() {
1010 let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
1011
1012 assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
1014 assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
1015
1016 let mid = table.interpolate(1.5);
1018 assert!((mid - 0.5).abs() < 1e-10);
1019
1020 let below = table.interpolate(0.5);
1022 assert_eq!(below.to_bits(), 0.4_f64.to_bits());
1023
1024 let above = table.interpolate(3.0);
1025 assert_eq!(above.to_bits(), 0.6_f64.to_bits());
1026 }
1027
1028 #[test]
1029 fn out_of_range_mach_holds_boundary_cd() {
1030 let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
1031
1032 for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
1033 assert_eq!(
1034 table.interpolate(mach).to_bits(),
1035 0.2_f64.to_bits(),
1036 "Mach {mach} must hold the first tabulated Cd"
1037 );
1038 }
1039 for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
1040 assert_eq!(
1041 table.interpolate(mach).to_bits(),
1042 0.3_f64.to_bits(),
1043 "Mach {mach} must hold the last tabulated Cd"
1044 );
1045 }
1046 }
1047
1048 #[test]
1049 fn test_linear_interpolation() {
1050 let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
1051
1052 let result = table.linear_interpolate(0.5, 0);
1054 assert!((result - 0.35).abs() < 1e-10);
1055
1056 let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
1058 let result_same = table_same.linear_interpolate(1.0, 0);
1059 assert_eq!(result_same, 0.4); }
1061
1062 #[test]
1063 fn test_cubic_interpolation() {
1064 let table = DragTable::new(vec![0.5, 1.0, 1.5, 2.0, 2.5], vec![0.2, 0.4, 0.6, 0.5, 0.3]);
1066
1067 let result = table.cubic_interpolate(1.25, 1);
1069
1070 assert!(result > 0.3 && result < 0.7);
1072
1073 let linear_result = table.linear_interpolate(1.25, 1);
1075 assert!((result - linear_result).abs() < 0.2);
1077 }
1078
1079 #[test]
1080 fn nonuniform_cubic_reproduces_affine_data() {
1081 let table = DragTable::new(
1082 vec![0.0, 1.0, 3.0, 4.0],
1083 vec![0.25, 0.3125, 0.4375, 0.5],
1084 );
1085
1086 for mach in [1.5, 2.5] {
1087 let expected = 0.25 + mach / 16.0;
1088 let actual = table.interpolate(mach);
1089 assert_eq!(
1090 actual.to_bits(),
1091 expected.to_bits(),
1092 "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
1093 );
1094 }
1095 }
1096
1097 #[test]
1098 fn nonuniform_cubic_is_c1_at_spacing_transition() {
1099 let table = DragTable::new(
1100 vec![0.0, 1.0, 3.0, 4.0, 7.0],
1101 vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
1102 );
1103 let knot = 3.0;
1104 let expected_at_knot = 0.390625_f64;
1105 let epsilon = 1e-6;
1106 let at_knot = table.interpolate(knot);
1107 let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
1108 let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
1109
1110 assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
1111 assert!(
1112 (left_slope - right_slope).abs() < 1e-5,
1113 "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
1114 );
1115 }
1116
1117 #[test]
1118 fn test_find_drag_tables_dir() {
1119 let _dir = find_drag_tables_dir();
1122 }
1124
1125 #[test]
1126 fn test_load_drag_table_fallback() {
1127 use std::path::Path;
1128
1129 let fake_dir = Path::new("/non/existent/directory");
1131 let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
1132
1133 let table = load_drag_table(fake_dir, "test", &fallback_data);
1134
1135 assert_eq!(table.mach_values.len(), 3);
1137 assert_eq!(table.cd_values.len(), 3);
1138 assert_eq!(table.mach_values[0], 0.5);
1139 assert_eq!(table.cd_values[0], 0.2);
1140 }
1141
1142 #[test]
1143 fn test_known_drag_values() {
1144 let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
1148 assert!(
1149 (g1_mach1 - 0.4805).abs() < 0.01,
1150 "G1 at Mach 1.0: {g1_mach1}"
1151 );
1152
1153 let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
1155 assert!(
1156 (g7_mach1 - 0.3803).abs() < 0.01,
1157 "G7 at Mach 1.0: {g7_mach1}"
1158 );
1159
1160 assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
1162 }
1163
1164 #[test]
1165 fn test_monotonicity_properties() {
1166 let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); let g1_values: Vec<f64> = mach_values
1171 .iter()
1172 .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1173 .collect();
1174
1175 let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1177 let max_index = g1_values
1178 .iter()
1179 .position(|&x| x == max_value)
1180 .expect("Should find maximum in non-empty vector");
1181 let peak_mach = mach_values
1182 .get(max_index)
1183 .copied()
1184 .expect("Index should be valid");
1185
1186 assert!(
1188 peak_mach > 1.0 && peak_mach < 1.6,
1189 "G1 peak at Mach {peak_mach}"
1190 );
1191 assert!(
1192 max_value > 0.5 && max_value < 1.0,
1193 "G1 peak value: {max_value}"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_physical_constraints() {
1199 let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1200
1201 for &mach in &test_machs {
1202 let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1203 let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1204
1205 assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1207 assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1208
1209 assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1211 assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1212 }
1213 }
1214
1215 #[test]
1231 fn test_performance_characteristics() {
1232 use std::time::Instant;
1233
1234 let start = Instant::now();
1235
1236 for i in 0..1000 {
1237 let mach = 0.5 + (i as f64) * 0.004; let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1239 let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1240 }
1241
1242 let elapsed = start.elapsed();
1243
1244 assert!(
1245 elapsed.as_secs() < 2,
1246 "2000 drag lookups took {}ms — that is ~{}us per lookup, which means the lookup \
1247 is doing real work (IO, parsing, or allocation) rather than interpolating an \
1248 in-memory table",
1249 elapsed.as_millis(),
1250 elapsed.as_micros() / 2000
1251 );
1252 }
1253
1254 #[test]
1255 fn try_new_accepts_valid_table() {
1256 let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1257 assert_eq!(t.mach_values.len(), 3);
1258 }
1259
1260 #[test]
1261 fn try_new_rejects_mismatched_lengths() {
1262 let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1263 assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1264 }
1265
1266 #[test]
1267 fn try_new_rejects_too_few_points() {
1268 assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1269 }
1270
1271 #[test]
1272 fn try_new_rejects_non_ascending_mach() {
1273 assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1274 assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1275 }
1276
1277 #[test]
1278 fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1279 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1280 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1281 }
1282
1283 #[test]
1284 fn interpolate_does_not_panic_on_mismatched_table() {
1285 let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1287 let _ = bad.interpolate(0.1);
1288 let _ = bad.interpolate(5.0);
1289 let _ = bad.interpolate(1.0);
1290 }
1291
1292 #[test]
1293 fn from_csv_str_parses_with_header_and_comments() {
1294 let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1295 let t = DragTable::from_csv_str(csv).unwrap();
1296 assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1297 assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1298 }
1299
1300 #[test]
1301 fn from_csv_str_rejects_malformed_data_row() {
1302 let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1304 assert!(e.contains("line 2"), "got: {e}");
1305 }
1306
1307 #[test]
1308 fn from_csv_str_rejects_empty() {
1309 assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1310 }
1311
1312 #[test]
1313 fn from_csv_str_rejects_malformed_first_data_row() {
1314 assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1316 assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1317 }
1318
1319 #[test]
1320 fn from_csv_str_still_skips_textual_header() {
1321 let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1323 assert_eq!(t.mach_values, vec![0.5, 1.0]);
1324 }
1325
1326 #[test]
1327 fn from_csv_str_roundtrips_shipped_g7() {
1328 let g7 = include_str!("../data/g7.csv");
1330 let t = DragTable::from_csv_str(g7).unwrap();
1331 assert!(t.mach_values.len() > 20);
1332 }
1333
1334 #[test]
1337 fn test_g2_drag_coefficient_spot_values() {
1338 for (mach, expected) in [(0.0, 0.2303), (0.70, 0.1702), (1.20, 0.4021), (5.0, 0.1648)] {
1340 let cd = get_drag_coefficient(mach, &DragModel::G2);
1341 assert!(
1342 (cd - expected).abs() < 1e-6,
1343 "G2 CD at Mach {mach}: expected {expected}, got {cd}"
1344 );
1345 }
1346 }
1347
1348 #[test]
1349 fn test_g5_drag_coefficient_spot_values() {
1350 for (mach, expected) in [(0.0, 0.1710), (0.75, 0.1463), (1.0, 0.3379), (5.0, 0.2280)] {
1352 let cd = get_drag_coefficient(mach, &DragModel::G5);
1353 assert!(
1354 (cd - expected).abs() < 1e-6,
1355 "G5 CD at Mach {mach}: expected {expected}, got {cd}"
1356 );
1357 }
1358 }
1359
1360 #[test]
1361 fn test_gi_drag_coefficient_spot_values() {
1362 for (mach, expected) in [(0.0, 0.2282), (0.90, 0.3170), (1.20, 0.6279), (5.0, 0.4082)] {
1364 let cd = get_drag_coefficient(mach, &DragModel::GI);
1365 assert!(
1366 (cd - expected).abs() < 1e-6,
1367 "GI CD at Mach {mach}: expected {expected}, got {cd}"
1368 );
1369 }
1370 }
1371
1372 #[test]
1373 fn test_gs_drag_coefficient_spot_values() {
1374 for (mach, expected) in [(0.0, 0.4662), (0.60, 0.5260), (1.60, 1.0090), (4.0, 0.9280)] {
1376 let cd = get_drag_coefficient(mach, &DragModel::GS);
1377 assert!(
1378 (cd - expected).abs() < 1e-6,
1379 "GS CD at Mach {mach}: expected {expected}, got {cd}"
1380 );
1381 }
1382 }
1383
1384 #[test]
1385 fn test_ra4_drag_coefficient_spot_values() {
1386 for (mach, expected) in [(0.0, 0.2283), (0.70, 0.2288), (1.15, 0.5943), (4.0, 0.4969)] {
1388 let cd = get_drag_coefficient(mach, &DragModel::RA4);
1389 assert!(
1390 (cd - expected).abs() < 1e-6,
1391 "RA4 CD at Mach {mach}: expected {expected}, got {cd}"
1392 );
1393 }
1394 }
1395
1396 #[test]
1397 fn embedded_family_tables_have_ascending_mach_and_positive_cd() {
1398 let decks: [(&str, &str); 7] = [
1402 ("G1", include_str!("../data/g1.csv")),
1403 ("G2", include_str!("../data/g2.csv")),
1404 ("G5", include_str!("../data/g5.csv")),
1405 ("G7", include_str!("../data/g7.csv")),
1406 ("GI", include_str!("../data/gi.csv")),
1407 ("GS", include_str!("../data/gs.csv")),
1408 ("RA4", include_str!("../data/ra4.csv")),
1409 ];
1410 for (name, csv) in decks {
1411 let table =
1412 DragTable::from_csv_str(csv).unwrap_or_else(|e| panic!("{name} failed to parse: {e}"));
1413 assert!(
1414 table.mach_values.len() > 20,
1415 "{name}: expected a high-resolution table, got {} points",
1416 table.mach_values.len()
1417 );
1418 assert_eq!(table.mach_values[0], 0.0, "{name}: expected Mach axis to start at 0.0");
1419 }
1420
1421 let models = [
1424 DragModel::G1,
1425 DragModel::G2,
1426 DragModel::G5,
1427 DragModel::G6,
1428 DragModel::G7,
1429 DragModel::G8,
1430 DragModel::GI,
1431 DragModel::GS,
1432 DragModel::RA4,
1433 ];
1434 for model in models {
1435 for i in 0..=50 {
1436 let mach = i as f64 * 0.1; let cd = get_drag_coefficient(mach, &model);
1438 assert!(
1439 cd.is_finite() && cd > 0.0 && cd < 2.0,
1440 "{model:?} CD out of range at Mach {mach}: {cd}"
1441 );
1442 }
1443 }
1444 }
1445
1446 fn interpolated_drop_at(points: &[crate::TrajectoryPoint], target_x_m: f64) -> f64 {
1450 for (i, point) in points.iter().enumerate() {
1451 if point.position.x >= target_x_m {
1452 if i == 0 {
1453 return point.position.y;
1454 }
1455 let previous = &points[i - 1];
1456 let span = point.position.x - previous.position.x;
1457 if span.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1458 return point.position.y;
1459 }
1460 let fraction = (target_x_m - previous.position.x) / span;
1461 return previous.position.y + fraction * (point.position.y - previous.position.y);
1462 }
1463 }
1464 points.last().map(|p| p.position.y).unwrap_or(f64::NAN)
1465 }
1466
1467 fn drop_at_500yd_m(model: DragModel) -> f64 {
1468 let inputs = crate::BallisticInputs {
1469 bc_type: model,
1470 bc_value: 0.5,
1471 ground_threshold: -1000.0,
1472 ..Default::default()
1473 };
1474 let solver = crate::TrajectorySolver::new(
1475 inputs,
1476 crate::WindConditions::default(),
1477 crate::AtmosphericConditions::default(),
1478 );
1479 let result = solver.solve().expect("solve should succeed");
1480 assert!(result.max_range.is_finite());
1481 assert!(result.impact_velocity.is_finite() && result.impact_velocity > 0.0);
1482 assert!(!result.points.is_empty(), "{model:?}: solver produced no points");
1483 let drop = interpolated_drop_at(&result.points, 457.2); assert!(drop.is_finite(), "{model:?}: non-finite drop at 500yd");
1485 drop
1486 }
1487
1488 #[test]
1489 fn mba1386_g2_g5_gi_gs_drop_differs_from_g1_now_that_fallback_is_gone() {
1490 let g1_drop = drop_at_500yd_m(DragModel::G1);
1491 for model in [DragModel::G2, DragModel::G5, DragModel::GI, DragModel::GS] {
1492 let drop = drop_at_500yd_m(model);
1493 assert!(
1494 (drop - g1_drop).abs() > 0.01,
1495 "{model:?} 500yd drop ({drop}) must differ from the G1 result ({g1_drop}) by \
1496 more than solver noise now that the real table is wired in"
1497 );
1498 }
1499 }
1500
1501 #[test]
1502 fn mba1386_ra4_solver_smoke() {
1503 let drop = drop_at_500yd_m(DragModel::RA4);
1505 assert!(drop < 0.0, "500yd drop should be a fall below the muzzle line: {drop}");
1506 }
1507}
1508
1509pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1511 if segments.is_empty() {
1512 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1514
1515 let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1517
1518 if mach_values.is_empty() || segments.is_empty() {
1520 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1522
1523 if let Some(first_mach) = mach_values.first() {
1525 if mach <= *first_mach {
1526 return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1527 }
1528 }
1529
1530 if let Some(last_mach) = mach_values.last() {
1531 if mach >= *last_mach {
1532 return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1533 }
1534 }
1535
1536 let idx = match mach_values
1538 .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1539 {
1540 Ok(idx) => {
1541 return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1543 }
1544 Err(idx) => idx, };
1546
1547 if idx == 0 || idx >= segments.len() {
1549 let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1552 return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1553 }
1554
1555 match (segments.get(idx - 1), segments.get(idx)) {
1557 (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1558 let denominator = hi_mach - lo_mach;
1560 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1561 return *lo_bc; }
1563 let frac = (mach - lo_mach) / denominator;
1564 lo_bc + frac * (hi_bc - lo_bc)
1565 }
1566 _ => 0.5, }
1568}
1569
1570#[cfg(test)]
1574mod reference_drag_table_tests {
1575 use super::*;
1576
1577 const ALL_MODELS: [DragModel; 9] = [
1578 DragModel::G1,
1579 DragModel::G2,
1580 DragModel::G5,
1581 DragModel::G6,
1582 DragModel::G7,
1583 DragModel::G8,
1584 DragModel::GI,
1585 DragModel::GS,
1586 DragModel::RA4,
1587 ];
1588
1589 #[test]
1593 fn the_exposed_table_is_the_one_the_solver_interpolates() {
1594 for model in ALL_MODELS {
1595 let table = reference_drag_table(&model);
1596 for (&mach, &cd) in table.mach_values.iter().zip(table.cd_values.iter()) {
1597 let interpolated = get_drag_coefficient(mach, &model);
1598 assert!(
1599 (interpolated - cd).abs() < 1e-12,
1600 "{model:?} disagrees at Mach {mach}: table {cd} vs solver {interpolated}"
1601 );
1602 }
1603 }
1604 }
1605
1606 #[test]
1607 fn every_model_has_a_usable_table() {
1608 for model in ALL_MODELS {
1609 let table = reference_drag_table(&model);
1610 assert_eq!(
1611 table.mach_values.len(),
1612 table.cd_values.len(),
1613 "{model:?} axes differ in length"
1614 );
1615 assert!(
1616 table.mach_values.len() > 2,
1617 "{model:?} fell back to a stub table"
1618 );
1619 assert!(
1620 table.mach_values.windows(2).all(|w| w[1] > w[0]),
1621 "{model:?} Mach axis is not strictly ascending"
1622 );
1623 assert!(
1624 table.cd_values.iter().all(|cd| cd.is_finite() && *cd > 0.0),
1625 "{model:?} has a non-physical Cd"
1626 );
1627 }
1628 }
1629
1630 #[test]
1634 fn the_mach_domain_is_per_table_not_universal() {
1635 let g7_max = *reference_drag_table(&DragModel::G7).mach_values.last().unwrap();
1636 let gs_max = *reference_drag_table(&DragModel::GS).mach_values.last().unwrap();
1637 let ra4_max = *reference_drag_table(&DragModel::RA4).mach_values.last().unwrap();
1638
1639 assert!(g7_max > gs_max, "G7 should extend past GS ({g7_max} vs {gs_max})");
1640 assert!(g7_max > ra4_max, "G7 should extend past RA4 ({g7_max} vs {ra4_max})");
1641 }
1642}