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 get_drag_coefficient_with_transonic(
677 mach: f64,
678 drag_model: &DragModel,
679 apply_transonic_correction: bool,
680 projectile_shape: Option<ProjectileShape>,
681 caliber: Option<f64>,
682 weight_grains: Option<f64>,
683) -> f64 {
684 let base_cd = get_drag_coefficient(mach, drag_model);
686
687 if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
689 let shape = match projectile_shape {
691 Some(s) => s,
692 None => {
693 if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
694 get_projectile_shape(
695 cal,
696 weight,
697 match drag_model {
698 DragModel::G1 => "G1",
699 DragModel::G6 => "G6",
700 DragModel::G7 => "G7",
701 DragModel::G8 => "G8",
702 _ => "G1", },
704 )
705 } else {
706 ProjectileShape::Spitzer }
708 }
709 };
710
711 transonic_correction(mach, base_cd, shape, false)
716 } else {
717 base_cd
718 }
719}
720
721#[allow(clippy::too_many_arguments)] pub fn get_drag_coefficient_full(
730 mach: f64,
731 drag_model: &DragModel,
732 apply_transonic_correction: bool,
733 apply_reynolds_correction: bool,
734 projectile_shape: Option<ProjectileShape>,
735 caliber: Option<f64>,
736 weight_grains: Option<f64>,
737 velocity_mps: Option<f64>,
738 air_density_kg_m3: Option<f64>,
739 temperature_c: Option<f64>,
740) -> f64 {
741 let mut cd = get_drag_coefficient_with_transonic(
743 mach,
744 drag_model,
745 apply_transonic_correction,
746 projectile_shape,
747 caliber,
748 weight_grains,
749 );
750
751 if apply_reynolds_correction && mach < 1.0 {
754 if let (Some(v), Some(cal), Some(rho), Some(temp)) =
755 (velocity_mps, caliber, air_density_kg_m3, temperature_c)
756 {
757 use crate::reynolds::apply_reynolds_correction;
758 cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
759 }
760 }
761
762 cd
763}
764
765#[cfg(test)]
766#[allow(clippy::items_after_test_module)] mod tests {
768 use super::*;
769
770 #[test]
771 fn test_g1_drag_coefficient_interpolation() {
772 let cd = get_drag_coefficient(1.0, &DragModel::G1);
773 assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
775 }
776
777 #[test]
778 fn test_g7_drag_coefficient_interpolation() {
779 let cd = get_drag_coefficient(1.0, &DragModel::G7);
780 assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
782 }
783
784 #[test]
785 fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
786 let models = [
787 DragModel::G1,
788 DragModel::G2,
789 DragModel::G5,
790 DragModel::G6,
791 DragModel::G7,
792 DragModel::G8,
793 DragModel::GI,
794 DragModel::GS,
795 ];
796 for drag_model in models {
797 for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
798 let base_cd = get_drag_coefficient(mach, &drag_model);
799 let corrected_cd = get_drag_coefficient_with_transonic(
800 mach,
801 &drag_model,
802 true,
803 Some(ProjectileShape::BoatTail),
804 Some(0.308),
805 Some(175.0),
806 );
807 assert_eq!(
808 corrected_cd.to_bits(),
809 base_cd.to_bits(),
810 "standard {drag_model:?} table already includes transonic drag at Mach \
811 {mach}: base={base_cd}, corrected={corrected_cd}"
812 );
813
814 let full_cd = get_drag_coefficient_full(
815 mach,
816 &drag_model,
817 true,
818 false,
819 Some(ProjectileShape::BoatTail),
820 Some(0.308),
821 Some(175.0),
822 None,
823 None,
824 None,
825 );
826 assert_eq!(full_cd.to_bits(), base_cd.to_bits());
827 }
828 }
829 }
830
831 #[test]
832 fn test_drag_coefficient_continuity() {
833 for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
835 let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
836 let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
837 let difference = (cd_after - cd_before).abs();
838 assert!(
839 difference < 0.05,
840 "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
841 );
842 }
843 }
844
845 #[test]
846 fn test_endpoint_bounds() {
847 let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
849 assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
850
851 let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
853 assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
854
855 let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
857 assert!(
858 cd_low_g7 > 0.01,
859 "Low Mach G7 should be positive: {cd_low_g7}"
860 );
861
862 let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
863 assert!(
864 cd_high_g7 >= 0.01,
865 "High Mach G7 should be positive: {cd_high_g7}"
866 );
867 }
868
869 #[test]
870 fn test_drag_table_creation() {
871 let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
872 let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
873 let table = DragTable::new(mach_vals, cd_vals);
874
875 assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
877
878 let cd_interp = table.interpolate(1.25);
880 assert!(cd_interp > 0.4 && cd_interp < 0.5);
881 }
882
883 #[test]
884 fn test_drag_table_empty() {
885 let table = DragTable::new(vec![], vec![]);
886 let result = table.interpolate(1.0);
887 assert_eq!(result, 0.5); }
889
890 #[test]
891 fn test_drag_table_single_point() {
892 let table = DragTable::new(vec![1.0], vec![0.4]);
893
894 assert_eq!(table.interpolate(0.5), 0.4);
896 assert_eq!(table.interpolate(1.0), 0.4);
897 assert_eq!(table.interpolate(2.0), 0.4);
898 }
899
900 #[test]
901 fn test_drag_table_two_points() {
902 let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
903
904 assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
906 assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
907
908 let mid = table.interpolate(1.5);
910 assert!((mid - 0.5).abs() < 1e-10);
911
912 let below = table.interpolate(0.5);
914 assert_eq!(below.to_bits(), 0.4_f64.to_bits());
915
916 let above = table.interpolate(3.0);
917 assert_eq!(above.to_bits(), 0.6_f64.to_bits());
918 }
919
920 #[test]
921 fn out_of_range_mach_holds_boundary_cd() {
922 let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
923
924 for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
925 assert_eq!(
926 table.interpolate(mach).to_bits(),
927 0.2_f64.to_bits(),
928 "Mach {mach} must hold the first tabulated Cd"
929 );
930 }
931 for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
932 assert_eq!(
933 table.interpolate(mach).to_bits(),
934 0.3_f64.to_bits(),
935 "Mach {mach} must hold the last tabulated Cd"
936 );
937 }
938 }
939
940 #[test]
941 fn test_linear_interpolation() {
942 let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
943
944 let result = table.linear_interpolate(0.5, 0);
946 assert!((result - 0.35).abs() < 1e-10);
947
948 let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
950 let result_same = table_same.linear_interpolate(1.0, 0);
951 assert_eq!(result_same, 0.4); }
953
954 #[test]
955 fn test_cubic_interpolation() {
956 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]);
958
959 let result = table.cubic_interpolate(1.25, 1);
961
962 assert!(result > 0.3 && result < 0.7);
964
965 let linear_result = table.linear_interpolate(1.25, 1);
967 assert!((result - linear_result).abs() < 0.2);
969 }
970
971 #[test]
972 fn nonuniform_cubic_reproduces_affine_data() {
973 let table = DragTable::new(
974 vec![0.0, 1.0, 3.0, 4.0],
975 vec![0.25, 0.3125, 0.4375, 0.5],
976 );
977
978 for mach in [1.5, 2.5] {
979 let expected = 0.25 + mach / 16.0;
980 let actual = table.interpolate(mach);
981 assert_eq!(
982 actual.to_bits(),
983 expected.to_bits(),
984 "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
985 );
986 }
987 }
988
989 #[test]
990 fn nonuniform_cubic_is_c1_at_spacing_transition() {
991 let table = DragTable::new(
992 vec![0.0, 1.0, 3.0, 4.0, 7.0],
993 vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
994 );
995 let knot = 3.0;
996 let expected_at_knot = 0.390625_f64;
997 let epsilon = 1e-6;
998 let at_knot = table.interpolate(knot);
999 let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
1000 let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
1001
1002 assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
1003 assert!(
1004 (left_slope - right_slope).abs() < 1e-5,
1005 "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_find_drag_tables_dir() {
1011 let _dir = find_drag_tables_dir();
1014 }
1016
1017 #[test]
1018 fn test_load_drag_table_fallback() {
1019 use std::path::Path;
1020
1021 let fake_dir = Path::new("/non/existent/directory");
1023 let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
1024
1025 let table = load_drag_table(fake_dir, "test", &fallback_data);
1026
1027 assert_eq!(table.mach_values.len(), 3);
1029 assert_eq!(table.cd_values.len(), 3);
1030 assert_eq!(table.mach_values[0], 0.5);
1031 assert_eq!(table.cd_values[0], 0.2);
1032 }
1033
1034 #[test]
1035 fn test_known_drag_values() {
1036 let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
1040 assert!(
1041 (g1_mach1 - 0.4805).abs() < 0.01,
1042 "G1 at Mach 1.0: {g1_mach1}"
1043 );
1044
1045 let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
1047 assert!(
1048 (g7_mach1 - 0.3803).abs() < 0.01,
1049 "G7 at Mach 1.0: {g7_mach1}"
1050 );
1051
1052 assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
1054 }
1055
1056 #[test]
1057 fn test_monotonicity_properties() {
1058 let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); let g1_values: Vec<f64> = mach_values
1063 .iter()
1064 .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1065 .collect();
1066
1067 let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1069 let max_index = g1_values
1070 .iter()
1071 .position(|&x| x == max_value)
1072 .expect("Should find maximum in non-empty vector");
1073 let peak_mach = mach_values
1074 .get(max_index)
1075 .copied()
1076 .expect("Index should be valid");
1077
1078 assert!(
1080 peak_mach > 1.0 && peak_mach < 1.6,
1081 "G1 peak at Mach {peak_mach}"
1082 );
1083 assert!(
1084 max_value > 0.5 && max_value < 1.0,
1085 "G1 peak value: {max_value}"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_physical_constraints() {
1091 let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1092
1093 for &mach in &test_machs {
1094 let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1095 let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1096
1097 assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1099 assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1100
1101 assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1103 assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1104 }
1105 }
1106
1107 #[test]
1108 fn test_performance_characteristics() {
1109 use std::time::Instant;
1111
1112 let start = Instant::now();
1113
1114 for i in 0..1000 {
1116 let mach = 0.5 + (i as f64) * 0.004; let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1118 let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1119 }
1120
1121 let elapsed = start.elapsed();
1122
1123 assert!(
1125 elapsed.as_millis() < 100,
1126 "Performance test took {}ms",
1127 elapsed.as_millis()
1128 );
1129 }
1130
1131 #[test]
1132 fn try_new_accepts_valid_table() {
1133 let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1134 assert_eq!(t.mach_values.len(), 3);
1135 }
1136
1137 #[test]
1138 fn try_new_rejects_mismatched_lengths() {
1139 let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1140 assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1141 }
1142
1143 #[test]
1144 fn try_new_rejects_too_few_points() {
1145 assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1146 }
1147
1148 #[test]
1149 fn try_new_rejects_non_ascending_mach() {
1150 assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1151 assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1152 }
1153
1154 #[test]
1155 fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1156 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1157 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1158 }
1159
1160 #[test]
1161 fn interpolate_does_not_panic_on_mismatched_table() {
1162 let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1164 let _ = bad.interpolate(0.1);
1165 let _ = bad.interpolate(5.0);
1166 let _ = bad.interpolate(1.0);
1167 }
1168
1169 #[test]
1170 fn from_csv_str_parses_with_header_and_comments() {
1171 let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1172 let t = DragTable::from_csv_str(csv).unwrap();
1173 assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1174 assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1175 }
1176
1177 #[test]
1178 fn from_csv_str_rejects_malformed_data_row() {
1179 let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1181 assert!(e.contains("line 2"), "got: {e}");
1182 }
1183
1184 #[test]
1185 fn from_csv_str_rejects_empty() {
1186 assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1187 }
1188
1189 #[test]
1190 fn from_csv_str_rejects_malformed_first_data_row() {
1191 assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1193 assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1194 }
1195
1196 #[test]
1197 fn from_csv_str_still_skips_textual_header() {
1198 let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1200 assert_eq!(t.mach_values, vec![0.5, 1.0]);
1201 }
1202
1203 #[test]
1204 fn from_csv_str_roundtrips_shipped_g7() {
1205 let g7 = include_str!("../data/g7.csv");
1207 let t = DragTable::from_csv_str(g7).unwrap();
1208 assert!(t.mach_values.len() > 20);
1209 }
1210
1211 #[test]
1214 fn test_g2_drag_coefficient_spot_values() {
1215 for (mach, expected) in [(0.0, 0.2303), (0.70, 0.1702), (1.20, 0.4021), (5.0, 0.1648)] {
1217 let cd = get_drag_coefficient(mach, &DragModel::G2);
1218 assert!(
1219 (cd - expected).abs() < 1e-6,
1220 "G2 CD at Mach {mach}: expected {expected}, got {cd}"
1221 );
1222 }
1223 }
1224
1225 #[test]
1226 fn test_g5_drag_coefficient_spot_values() {
1227 for (mach, expected) in [(0.0, 0.1710), (0.75, 0.1463), (1.0, 0.3379), (5.0, 0.2280)] {
1229 let cd = get_drag_coefficient(mach, &DragModel::G5);
1230 assert!(
1231 (cd - expected).abs() < 1e-6,
1232 "G5 CD at Mach {mach}: expected {expected}, got {cd}"
1233 );
1234 }
1235 }
1236
1237 #[test]
1238 fn test_gi_drag_coefficient_spot_values() {
1239 for (mach, expected) in [(0.0, 0.2282), (0.90, 0.3170), (1.20, 0.6279), (5.0, 0.4082)] {
1241 let cd = get_drag_coefficient(mach, &DragModel::GI);
1242 assert!(
1243 (cd - expected).abs() < 1e-6,
1244 "GI CD at Mach {mach}: expected {expected}, got {cd}"
1245 );
1246 }
1247 }
1248
1249 #[test]
1250 fn test_gs_drag_coefficient_spot_values() {
1251 for (mach, expected) in [(0.0, 0.4662), (0.60, 0.5260), (1.60, 1.0090), (4.0, 0.9280)] {
1253 let cd = get_drag_coefficient(mach, &DragModel::GS);
1254 assert!(
1255 (cd - expected).abs() < 1e-6,
1256 "GS CD at Mach {mach}: expected {expected}, got {cd}"
1257 );
1258 }
1259 }
1260
1261 #[test]
1262 fn test_ra4_drag_coefficient_spot_values() {
1263 for (mach, expected) in [(0.0, 0.2283), (0.70, 0.2288), (1.15, 0.5943), (4.0, 0.4969)] {
1265 let cd = get_drag_coefficient(mach, &DragModel::RA4);
1266 assert!(
1267 (cd - expected).abs() < 1e-6,
1268 "RA4 CD at Mach {mach}: expected {expected}, got {cd}"
1269 );
1270 }
1271 }
1272
1273 #[test]
1274 fn embedded_family_tables_have_ascending_mach_and_positive_cd() {
1275 let decks: [(&str, &str); 7] = [
1279 ("G1", include_str!("../data/g1.csv")),
1280 ("G2", include_str!("../data/g2.csv")),
1281 ("G5", include_str!("../data/g5.csv")),
1282 ("G7", include_str!("../data/g7.csv")),
1283 ("GI", include_str!("../data/gi.csv")),
1284 ("GS", include_str!("../data/gs.csv")),
1285 ("RA4", include_str!("../data/ra4.csv")),
1286 ];
1287 for (name, csv) in decks {
1288 let table =
1289 DragTable::from_csv_str(csv).unwrap_or_else(|e| panic!("{name} failed to parse: {e}"));
1290 assert!(
1291 table.mach_values.len() > 20,
1292 "{name}: expected a high-resolution table, got {} points",
1293 table.mach_values.len()
1294 );
1295 assert_eq!(table.mach_values[0], 0.0, "{name}: expected Mach axis to start at 0.0");
1296 }
1297
1298 let models = [
1301 DragModel::G1,
1302 DragModel::G2,
1303 DragModel::G5,
1304 DragModel::G6,
1305 DragModel::G7,
1306 DragModel::G8,
1307 DragModel::GI,
1308 DragModel::GS,
1309 DragModel::RA4,
1310 ];
1311 for model in models {
1312 for i in 0..=50 {
1313 let mach = i as f64 * 0.1; let cd = get_drag_coefficient(mach, &model);
1315 assert!(
1316 cd.is_finite() && cd > 0.0 && cd < 2.0,
1317 "{model:?} CD out of range at Mach {mach}: {cd}"
1318 );
1319 }
1320 }
1321 }
1322
1323 fn interpolated_drop_at(points: &[crate::TrajectoryPoint], target_x_m: f64) -> f64 {
1327 for (i, point) in points.iter().enumerate() {
1328 if point.position.x >= target_x_m {
1329 if i == 0 {
1330 return point.position.y;
1331 }
1332 let previous = &points[i - 1];
1333 let span = point.position.x - previous.position.x;
1334 if span.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1335 return point.position.y;
1336 }
1337 let fraction = (target_x_m - previous.position.x) / span;
1338 return previous.position.y + fraction * (point.position.y - previous.position.y);
1339 }
1340 }
1341 points.last().map(|p| p.position.y).unwrap_or(f64::NAN)
1342 }
1343
1344 fn drop_at_500yd_m(model: DragModel) -> f64 {
1345 let inputs = crate::BallisticInputs {
1346 bc_type: model,
1347 bc_value: 0.5,
1348 ground_threshold: -1000.0,
1349 ..Default::default()
1350 };
1351 let solver = crate::TrajectorySolver::new(
1352 inputs,
1353 crate::WindConditions::default(),
1354 crate::AtmosphericConditions::default(),
1355 );
1356 let result = solver.solve().expect("solve should succeed");
1357 assert!(result.max_range.is_finite());
1358 assert!(result.impact_velocity.is_finite() && result.impact_velocity > 0.0);
1359 assert!(!result.points.is_empty(), "{model:?}: solver produced no points");
1360 let drop = interpolated_drop_at(&result.points, 457.2); assert!(drop.is_finite(), "{model:?}: non-finite drop at 500yd");
1362 drop
1363 }
1364
1365 #[test]
1366 fn mba1386_g2_g5_gi_gs_drop_differs_from_g1_now_that_fallback_is_gone() {
1367 let g1_drop = drop_at_500yd_m(DragModel::G1);
1368 for model in [DragModel::G2, DragModel::G5, DragModel::GI, DragModel::GS] {
1369 let drop = drop_at_500yd_m(model);
1370 assert!(
1371 (drop - g1_drop).abs() > 0.01,
1372 "{model:?} 500yd drop ({drop}) must differ from the G1 result ({g1_drop}) by \
1373 more than solver noise now that the real table is wired in"
1374 );
1375 }
1376 }
1377
1378 #[test]
1379 fn mba1386_ra4_solver_smoke() {
1380 let drop = drop_at_500yd_m(DragModel::RA4);
1382 assert!(drop < 0.0, "500yd drop should be a fall below the muzzle line: {drop}");
1383 }
1384}
1385
1386pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1388 if segments.is_empty() {
1389 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1391
1392 let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1394
1395 if mach_values.is_empty() || segments.is_empty() {
1397 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1399
1400 if let Some(first_mach) = mach_values.first() {
1402 if mach <= *first_mach {
1403 return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1404 }
1405 }
1406
1407 if let Some(last_mach) = mach_values.last() {
1408 if mach >= *last_mach {
1409 return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1410 }
1411 }
1412
1413 let idx = match mach_values
1415 .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1416 {
1417 Ok(idx) => {
1418 return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1420 }
1421 Err(idx) => idx, };
1423
1424 if idx == 0 || idx >= segments.len() {
1426 let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1429 return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1430 }
1431
1432 match (segments.get(idx - 1), segments.get(idx)) {
1434 (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1435 let denominator = hi_mach - lo_mach;
1437 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1438 return *lo_bc; }
1440 let frac = (mach - lo_mach) / denominator;
1441 lo_bc + frac * (hi_bc - lo_bc)
1442 }
1443 _ => 0.5, }
1445}
1446
1447