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]
1216 fn test_performance_characteristics() {
1217 use std::time::Instant;
1219
1220 let start = Instant::now();
1221
1222 for i in 0..1000 {
1224 let mach = 0.5 + (i as f64) * 0.004; let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1226 let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1227 }
1228
1229 let elapsed = start.elapsed();
1230
1231 assert!(
1233 elapsed.as_millis() < 100,
1234 "Performance test took {}ms",
1235 elapsed.as_millis()
1236 );
1237 }
1238
1239 #[test]
1240 fn try_new_accepts_valid_table() {
1241 let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1242 assert_eq!(t.mach_values.len(), 3);
1243 }
1244
1245 #[test]
1246 fn try_new_rejects_mismatched_lengths() {
1247 let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1248 assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1249 }
1250
1251 #[test]
1252 fn try_new_rejects_too_few_points() {
1253 assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1254 }
1255
1256 #[test]
1257 fn try_new_rejects_non_ascending_mach() {
1258 assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1259 assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1260 }
1261
1262 #[test]
1263 fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1264 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1265 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1266 }
1267
1268 #[test]
1269 fn interpolate_does_not_panic_on_mismatched_table() {
1270 let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1272 let _ = bad.interpolate(0.1);
1273 let _ = bad.interpolate(5.0);
1274 let _ = bad.interpolate(1.0);
1275 }
1276
1277 #[test]
1278 fn from_csv_str_parses_with_header_and_comments() {
1279 let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1280 let t = DragTable::from_csv_str(csv).unwrap();
1281 assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1282 assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1283 }
1284
1285 #[test]
1286 fn from_csv_str_rejects_malformed_data_row() {
1287 let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1289 assert!(e.contains("line 2"), "got: {e}");
1290 }
1291
1292 #[test]
1293 fn from_csv_str_rejects_empty() {
1294 assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1295 }
1296
1297 #[test]
1298 fn from_csv_str_rejects_malformed_first_data_row() {
1299 assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1301 assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1302 }
1303
1304 #[test]
1305 fn from_csv_str_still_skips_textual_header() {
1306 let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1308 assert_eq!(t.mach_values, vec![0.5, 1.0]);
1309 }
1310
1311 #[test]
1312 fn from_csv_str_roundtrips_shipped_g7() {
1313 let g7 = include_str!("../data/g7.csv");
1315 let t = DragTable::from_csv_str(g7).unwrap();
1316 assert!(t.mach_values.len() > 20);
1317 }
1318
1319 #[test]
1322 fn test_g2_drag_coefficient_spot_values() {
1323 for (mach, expected) in [(0.0, 0.2303), (0.70, 0.1702), (1.20, 0.4021), (5.0, 0.1648)] {
1325 let cd = get_drag_coefficient(mach, &DragModel::G2);
1326 assert!(
1327 (cd - expected).abs() < 1e-6,
1328 "G2 CD at Mach {mach}: expected {expected}, got {cd}"
1329 );
1330 }
1331 }
1332
1333 #[test]
1334 fn test_g5_drag_coefficient_spot_values() {
1335 for (mach, expected) in [(0.0, 0.1710), (0.75, 0.1463), (1.0, 0.3379), (5.0, 0.2280)] {
1337 let cd = get_drag_coefficient(mach, &DragModel::G5);
1338 assert!(
1339 (cd - expected).abs() < 1e-6,
1340 "G5 CD at Mach {mach}: expected {expected}, got {cd}"
1341 );
1342 }
1343 }
1344
1345 #[test]
1346 fn test_gi_drag_coefficient_spot_values() {
1347 for (mach, expected) in [(0.0, 0.2282), (0.90, 0.3170), (1.20, 0.6279), (5.0, 0.4082)] {
1349 let cd = get_drag_coefficient(mach, &DragModel::GI);
1350 assert!(
1351 (cd - expected).abs() < 1e-6,
1352 "GI CD at Mach {mach}: expected {expected}, got {cd}"
1353 );
1354 }
1355 }
1356
1357 #[test]
1358 fn test_gs_drag_coefficient_spot_values() {
1359 for (mach, expected) in [(0.0, 0.4662), (0.60, 0.5260), (1.60, 1.0090), (4.0, 0.9280)] {
1361 let cd = get_drag_coefficient(mach, &DragModel::GS);
1362 assert!(
1363 (cd - expected).abs() < 1e-6,
1364 "GS CD at Mach {mach}: expected {expected}, got {cd}"
1365 );
1366 }
1367 }
1368
1369 #[test]
1370 fn test_ra4_drag_coefficient_spot_values() {
1371 for (mach, expected) in [(0.0, 0.2283), (0.70, 0.2288), (1.15, 0.5943), (4.0, 0.4969)] {
1373 let cd = get_drag_coefficient(mach, &DragModel::RA4);
1374 assert!(
1375 (cd - expected).abs() < 1e-6,
1376 "RA4 CD at Mach {mach}: expected {expected}, got {cd}"
1377 );
1378 }
1379 }
1380
1381 #[test]
1382 fn embedded_family_tables_have_ascending_mach_and_positive_cd() {
1383 let decks: [(&str, &str); 7] = [
1387 ("G1", include_str!("../data/g1.csv")),
1388 ("G2", include_str!("../data/g2.csv")),
1389 ("G5", include_str!("../data/g5.csv")),
1390 ("G7", include_str!("../data/g7.csv")),
1391 ("GI", include_str!("../data/gi.csv")),
1392 ("GS", include_str!("../data/gs.csv")),
1393 ("RA4", include_str!("../data/ra4.csv")),
1394 ];
1395 for (name, csv) in decks {
1396 let table =
1397 DragTable::from_csv_str(csv).unwrap_or_else(|e| panic!("{name} failed to parse: {e}"));
1398 assert!(
1399 table.mach_values.len() > 20,
1400 "{name}: expected a high-resolution table, got {} points",
1401 table.mach_values.len()
1402 );
1403 assert_eq!(table.mach_values[0], 0.0, "{name}: expected Mach axis to start at 0.0");
1404 }
1405
1406 let models = [
1409 DragModel::G1,
1410 DragModel::G2,
1411 DragModel::G5,
1412 DragModel::G6,
1413 DragModel::G7,
1414 DragModel::G8,
1415 DragModel::GI,
1416 DragModel::GS,
1417 DragModel::RA4,
1418 ];
1419 for model in models {
1420 for i in 0..=50 {
1421 let mach = i as f64 * 0.1; let cd = get_drag_coefficient(mach, &model);
1423 assert!(
1424 cd.is_finite() && cd > 0.0 && cd < 2.0,
1425 "{model:?} CD out of range at Mach {mach}: {cd}"
1426 );
1427 }
1428 }
1429 }
1430
1431 fn interpolated_drop_at(points: &[crate::TrajectoryPoint], target_x_m: f64) -> f64 {
1435 for (i, point) in points.iter().enumerate() {
1436 if point.position.x >= target_x_m {
1437 if i == 0 {
1438 return point.position.y;
1439 }
1440 let previous = &points[i - 1];
1441 let span = point.position.x - previous.position.x;
1442 if span.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1443 return point.position.y;
1444 }
1445 let fraction = (target_x_m - previous.position.x) / span;
1446 return previous.position.y + fraction * (point.position.y - previous.position.y);
1447 }
1448 }
1449 points.last().map(|p| p.position.y).unwrap_or(f64::NAN)
1450 }
1451
1452 fn drop_at_500yd_m(model: DragModel) -> f64 {
1453 let inputs = crate::BallisticInputs {
1454 bc_type: model,
1455 bc_value: 0.5,
1456 ground_threshold: -1000.0,
1457 ..Default::default()
1458 };
1459 let solver = crate::TrajectorySolver::new(
1460 inputs,
1461 crate::WindConditions::default(),
1462 crate::AtmosphericConditions::default(),
1463 );
1464 let result = solver.solve().expect("solve should succeed");
1465 assert!(result.max_range.is_finite());
1466 assert!(result.impact_velocity.is_finite() && result.impact_velocity > 0.0);
1467 assert!(!result.points.is_empty(), "{model:?}: solver produced no points");
1468 let drop = interpolated_drop_at(&result.points, 457.2); assert!(drop.is_finite(), "{model:?}: non-finite drop at 500yd");
1470 drop
1471 }
1472
1473 #[test]
1474 fn mba1386_g2_g5_gi_gs_drop_differs_from_g1_now_that_fallback_is_gone() {
1475 let g1_drop = drop_at_500yd_m(DragModel::G1);
1476 for model in [DragModel::G2, DragModel::G5, DragModel::GI, DragModel::GS] {
1477 let drop = drop_at_500yd_m(model);
1478 assert!(
1479 (drop - g1_drop).abs() > 0.01,
1480 "{model:?} 500yd drop ({drop}) must differ from the G1 result ({g1_drop}) by \
1481 more than solver noise now that the real table is wired in"
1482 );
1483 }
1484 }
1485
1486 #[test]
1487 fn mba1386_ra4_solver_smoke() {
1488 let drop = drop_at_500yd_m(DragModel::RA4);
1490 assert!(drop < 0.0, "500yd drop should be a fall below the muzzle line: {drop}");
1491 }
1492}
1493
1494pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1496 if segments.is_empty() {
1497 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1499
1500 let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1502
1503 if mach_values.is_empty() || segments.is_empty() {
1505 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1507
1508 if let Some(first_mach) = mach_values.first() {
1510 if mach <= *first_mach {
1511 return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1512 }
1513 }
1514
1515 if let Some(last_mach) = mach_values.last() {
1516 if mach >= *last_mach {
1517 return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1518 }
1519 }
1520
1521 let idx = match mach_values
1523 .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1524 {
1525 Ok(idx) => {
1526 return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1528 }
1529 Err(idx) => idx, };
1531
1532 if idx == 0 || idx >= segments.len() {
1534 let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1537 return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1538 }
1539
1540 match (segments.get(idx - 1), segments.get(idx)) {
1542 (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1543 let denominator = hi_mach - lo_mach;
1545 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1546 return *lo_bc; }
1548 let frac = (mach - lo_mach) / denominator;
1549 lo_bc + frac * (hi_bc - lo_bc)
1550 }
1551 _ => 0.5, }
1553}
1554
1555#[cfg(test)]
1559mod reference_drag_table_tests {
1560 use super::*;
1561
1562 const ALL_MODELS: [DragModel; 9] = [
1563 DragModel::G1,
1564 DragModel::G2,
1565 DragModel::G5,
1566 DragModel::G6,
1567 DragModel::G7,
1568 DragModel::G8,
1569 DragModel::GI,
1570 DragModel::GS,
1571 DragModel::RA4,
1572 ];
1573
1574 #[test]
1578 fn the_exposed_table_is_the_one_the_solver_interpolates() {
1579 for model in ALL_MODELS {
1580 let table = reference_drag_table(&model);
1581 for (&mach, &cd) in table.mach_values.iter().zip(table.cd_values.iter()) {
1582 let interpolated = get_drag_coefficient(mach, &model);
1583 assert!(
1584 (interpolated - cd).abs() < 1e-12,
1585 "{model:?} disagrees at Mach {mach}: table {cd} vs solver {interpolated}"
1586 );
1587 }
1588 }
1589 }
1590
1591 #[test]
1592 fn every_model_has_a_usable_table() {
1593 for model in ALL_MODELS {
1594 let table = reference_drag_table(&model);
1595 assert_eq!(
1596 table.mach_values.len(),
1597 table.cd_values.len(),
1598 "{model:?} axes differ in length"
1599 );
1600 assert!(
1601 table.mach_values.len() > 2,
1602 "{model:?} fell back to a stub table"
1603 );
1604 assert!(
1605 table.mach_values.windows(2).all(|w| w[1] > w[0]),
1606 "{model:?} Mach axis is not strictly ascending"
1607 );
1608 assert!(
1609 table.cd_values.iter().all(|cd| cd.is_finite() && *cd > 0.0),
1610 "{model:?} has a non-physical Cd"
1611 );
1612 }
1613 }
1614
1615 #[test]
1619 fn the_mach_domain_is_per_table_not_universal() {
1620 let g7_max = *reference_drag_table(&DragModel::G7).mach_values.last().unwrap();
1621 let gs_max = *reference_drag_table(&DragModel::GS).mach_values.last().unwrap();
1622 let ra4_max = *reference_drag_table(&DragModel::RA4).mach_values.last().unwrap();
1623
1624 assert!(g7_max > gs_max, "G7 should extend past GS ({g7_max} vs {gs_max})");
1625 assert!(g7_max > ra4_max, "G7 should extend past RA4 ({g7_max} vs {ra4_max})");
1626 }
1627}