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"));
264 if let Ok(mut reader) = csv::Reader::from_path(&csv_path) {
265 let mut mach_values = Vec::new();
266 let mut cd_values = Vec::new();
267
268 for record in reader.records().flatten() {
269 if record.len() >= 2 {
270 if let (Ok(mach), Ok(cd)) = (record[0].parse::<f64>(), record[1].parse::<f64>())
271 {
272 mach_values.push(mach);
273 cd_values.push(cd);
274 }
275 }
276 }
277
278 if !mach_values.is_empty() {
279 return DragTable::new(mach_values, cd_values);
280 }
281 }
282
283 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
285 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
286 DragTable::new(mach_values, cd_values)
287}
288
289fn find_drag_tables_dir() -> Option<std::path::PathBuf> {
291 let candidates = [
293 "../drag_tables",
294 "../../drag_tables",
295 "../../../drag_tables",
296 "drag_tables",
297 ];
298
299 for candidate in &candidates {
300 let path = Path::new(candidate);
301 if path.exists() && path.is_dir() {
302 return Some(path.to_path_buf());
303 }
304 }
305
306 None
307}
308
309fn parse_embedded_drag_table(csv: &str, fallback: &[(f64, f64)]) -> DragTable {
314 let mut mach_values = Vec::new();
315 let mut cd_values = Vec::new();
316 for line in csv.lines() {
317 let line = line.trim();
318 if line.is_empty() {
319 continue;
320 }
321 let mut cols = line.split(',');
322 if let (Some(m), Some(cd)) = (cols.next(), cols.next()) {
323 if let (Ok(m), Ok(cd)) = (m.trim().parse::<f64>(), cd.trim().parse::<f64>()) {
324 mach_values.push(m);
325 cd_values.push(cd);
326 }
327 }
328 }
329 if mach_values.is_empty() {
330 mach_values = fallback.iter().map(|(m, _)| *m).collect();
331 cd_values = fallback.iter().map(|(_, cd)| *cd).collect();
332 }
333 DragTable::new(mach_values, cd_values)
334}
335
336static G1_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
341 let fallback_data = [
343 (0.0, 0.2629),
344 (0.5, 0.2695),
345 (0.6, 0.2752),
346 (0.7, 0.2817),
347 (0.8, 0.2902),
348 (0.9, 0.3012),
349 (1.0, 0.4805),
350 (1.1, 0.5933),
351 (1.2, 0.6318),
352 (1.3, 0.6440),
353 (1.4, 0.6444),
354 (1.5, 0.6372),
355 (1.6, 0.6252),
356 (1.7, 0.6105),
357 (1.8, 0.5956),
358 (1.9, 0.5815),
359 (2.0, 0.5934),
360 (2.5, 0.5598),
361 (3.0, 0.5133),
362 (4.0, 0.4811),
363 (5.0, 0.4988),
364 ];
365
366 parse_embedded_drag_table(include_str!("../data/g1.csv"), &fallback_data)
367});
368
369static G7_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
374 let fallback_data = [
376 (0.0, 0.1198),
377 (0.5, 0.1197),
378 (0.6, 0.1202),
379 (0.7, 0.1213),
380 (0.8, 0.1240),
381 (0.9, 0.1294),
382 (1.0, 0.3803),
383 (1.1, 0.4015),
384 (1.2, 0.4043),
385 (1.3, 0.3956),
386 (1.4, 0.3814),
387 (1.5, 0.3663),
388 (1.6, 0.3520),
389 (1.7, 0.3398),
390 (1.8, 0.3297),
391 (1.9, 0.3221),
392 (2.0, 0.2980),
393 (2.5, 0.2731),
394 (3.0, 0.2424),
395 (4.0, 0.2196),
396 (5.0, 0.1618),
397 ];
398
399 parse_embedded_drag_table(include_str!("../data/g7.csv"), &fallback_data)
400});
401
402static G6_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
405 let fallback_data = [
406 (0.0, 0.2617),
407 (0.05, 0.2553),
408 (0.10, 0.2491),
409 (0.15, 0.2432),
410 (0.20, 0.2376),
411 (0.25, 0.2324),
412 (0.30, 0.2278),
413 (0.35, 0.2238),
414 (0.40, 0.2205),
415 (0.45, 0.2177),
416 (0.50, 0.2155),
417 (0.55, 0.2138),
418 (0.60, 0.2126),
419 (0.65, 0.2121),
420 (0.70, 0.2122),
421 (0.75, 0.2132),
422 (0.80, 0.2154),
423 (0.85, 0.2194),
424 (0.875, 0.2229),
425 (0.90, 0.2297),
426 (0.925, 0.2449),
427 (0.95, 0.2732),
428 (0.975, 0.3141),
429 (1.0, 0.3597),
430 (1.025, 0.3994),
431 (1.05, 0.4261),
432 (1.075, 0.4402),
433 (1.10, 0.4465),
434 (1.125, 0.4490),
435 (1.15, 0.4497),
436 (1.175, 0.4494),
437 (1.20, 0.4482),
438 (1.225, 0.4464),
439 (1.25, 0.4441),
440 (1.30, 0.4390),
441 (1.35, 0.4336),
442 (1.40, 0.4279),
443 (1.45, 0.4221),
444 (1.50, 0.4162),
445 (1.55, 0.4102),
446 (1.60, 0.4042),
447 (1.65, 0.3981),
448 (1.70, 0.3919),
449 (1.75, 0.3855),
450 (1.80, 0.3788),
451 (1.85, 0.3721),
452 (1.90, 0.3652),
453 (1.95, 0.3583),
454 (2.0, 0.3515),
455 (2.05, 0.3447),
456 (2.10, 0.3381),
457 (2.15, 0.3314),
458 (2.20, 0.3249),
459 (2.25, 0.3185),
460 (2.30, 0.3122),
461 (2.35, 0.3060),
462 (2.40, 0.3000),
463 (2.45, 0.2941),
464 (2.50, 0.2883),
465 (2.60, 0.2772),
466 (2.70, 0.2668),
467 (2.80, 0.2574),
468 (2.90, 0.2487),
469 (3.0, 0.2407),
470 (3.10, 0.2333),
471 (3.20, 0.2265),
472 (3.30, 0.2202),
473 (3.40, 0.2144),
474 (3.50, 0.2089),
475 (3.60, 0.2039),
476 (3.70, 0.1991),
477 (3.80, 0.1947),
478 (3.90, 0.1905),
479 (4.0, 0.1866),
480 (4.20, 0.1794),
481 (4.40, 0.1730),
482 (4.60, 0.1673),
483 (4.80, 0.1621),
484 (5.0, 0.1574),
485 ];
486
487 if let Some(drag_dir) = find_drag_tables_dir() {
488 load_drag_table(&drag_dir, "g6", &fallback_data)
489 } else {
490 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
492 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
493 DragTable::new(mach_values, cd_values)
494 }
495});
496
497static G8_DRAG_TABLE: LazyLock<DragTable> = LazyLock::new(|| {
500 let fallback_data = [
501 (0.0, 0.2105),
502 (0.05, 0.2105),
503 (0.10, 0.2104),
504 (0.15, 0.2104),
505 (0.20, 0.2103),
506 (0.25, 0.2103),
507 (0.30, 0.2103),
508 (0.35, 0.2103),
509 (0.40, 0.2103),
510 (0.45, 0.2102),
511 (0.50, 0.2102),
512 (0.55, 0.2102),
513 (0.60, 0.2102),
514 (0.65, 0.2102),
515 (0.70, 0.2103),
516 (0.75, 0.2103),
517 (0.80, 0.2104),
518 (0.825, 0.2104),
519 (0.85, 0.2105),
520 (0.875, 0.2106),
521 (0.90, 0.2109),
522 (0.925, 0.2183),
523 (0.95, 0.2571),
524 (0.975, 0.3358),
525 (1.0, 0.4068),
526 (1.025, 0.4378),
527 (1.05, 0.4476),
528 (1.075, 0.4493),
529 (1.10, 0.4477),
530 (1.125, 0.4450),
531 (1.15, 0.4419),
532 (1.20, 0.4353),
533 (1.25, 0.4283),
534 (1.30, 0.4208),
535 (1.35, 0.4133),
536 (1.40, 0.4059),
537 (1.45, 0.3986),
538 (1.50, 0.3915),
539 (1.55, 0.3845),
540 (1.60, 0.3777),
541 (1.65, 0.3710),
542 (1.70, 0.3645),
543 (1.75, 0.3581),
544 (1.80, 0.3519),
545 (1.85, 0.3458),
546 (1.90, 0.3400),
547 (1.95, 0.3343),
548 (2.0, 0.3288),
549 (2.05, 0.3234),
550 (2.10, 0.3182),
551 (2.15, 0.3131),
552 (2.20, 0.3081),
553 (2.25, 0.3032),
554 (2.30, 0.2983),
555 (2.35, 0.2937),
556 (2.40, 0.2891),
557 (2.45, 0.2845),
558 (2.50, 0.2802),
559 (2.60, 0.2720),
560 (2.70, 0.2642),
561 (2.80, 0.2569),
562 (2.90, 0.2499),
563 (3.0, 0.2432),
564 (3.10, 0.2368),
565 (3.20, 0.2308),
566 (3.30, 0.2251),
567 (3.40, 0.2197),
568 (3.50, 0.2147),
569 (3.60, 0.2101),
570 (3.70, 0.2058),
571 (3.80, 0.2019),
572 (3.90, 0.1983),
573 (4.0, 0.1950),
574 (4.20, 0.1890),
575 (4.40, 0.1837),
576 (4.60, 0.1791),
577 (4.80, 0.1750),
578 (5.0, 0.1713),
579 ];
580
581 if let Some(drag_dir) = find_drag_tables_dir() {
582 load_drag_table(&drag_dir, "g8", &fallback_data)
583 } else {
584 let mach_values: Vec<f64> = fallback_data.iter().map(|(m, _)| *m).collect();
586 let cd_values: Vec<f64> = fallback_data.iter().map(|(_, cd)| *cd).collect();
587 DragTable::new(mach_values, cd_values)
588 }
589});
590
591pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
600 match drag_model {
601 DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
602 DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
603 DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
604 DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
605 DragModel::G2 | DragModel::G5 | DragModel::GI | DragModel::GS => {
607 G1_DRAG_TABLE.interpolate(mach)
608 }
609 }
610}
611
612pub fn get_drag_coefficient_with_transonic(
619 mach: f64,
620 drag_model: &DragModel,
621 apply_transonic_correction: bool,
622 projectile_shape: Option<ProjectileShape>,
623 caliber: Option<f64>,
624 weight_grains: Option<f64>,
625) -> f64 {
626 let base_cd = get_drag_coefficient(mach, drag_model);
628
629 if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
631 let shape = match projectile_shape {
633 Some(s) => s,
634 None => {
635 if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
636 get_projectile_shape(
637 cal,
638 weight,
639 match drag_model {
640 DragModel::G1 => "G1",
641 DragModel::G6 => "G6",
642 DragModel::G7 => "G7",
643 DragModel::G8 => "G8",
644 _ => "G1", },
646 )
647 } else {
648 ProjectileShape::Spitzer }
650 }
651 };
652
653 transonic_correction(mach, base_cd, shape, false)
658 } else {
659 base_cd
660 }
661}
662
663pub fn get_drag_coefficient_full(
671 mach: f64,
672 drag_model: &DragModel,
673 apply_transonic_correction: bool,
674 apply_reynolds_correction: bool,
675 projectile_shape: Option<ProjectileShape>,
676 caliber: Option<f64>,
677 weight_grains: Option<f64>,
678 velocity_mps: Option<f64>,
679 air_density_kg_m3: Option<f64>,
680 temperature_c: Option<f64>,
681) -> f64 {
682 let mut cd = get_drag_coefficient_with_transonic(
684 mach,
685 drag_model,
686 apply_transonic_correction,
687 projectile_shape,
688 caliber,
689 weight_grains,
690 );
691
692 if apply_reynolds_correction && mach < 1.0 {
695 if let (Some(v), Some(cal), Some(rho), Some(temp)) =
696 (velocity_mps, caliber, air_density_kg_m3, temperature_c)
697 {
698 use crate::reynolds::apply_reynolds_correction;
699 cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
700 }
701 }
702
703 cd
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn test_g1_drag_coefficient_interpolation() {
712 let cd = get_drag_coefficient(1.0, &DragModel::G1);
713 assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
715 }
716
717 #[test]
718 fn test_g7_drag_coefficient_interpolation() {
719 let cd = get_drag_coefficient(1.0, &DragModel::G7);
720 assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
722 }
723
724 #[test]
725 fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
726 let models = [
727 DragModel::G1,
728 DragModel::G2,
729 DragModel::G5,
730 DragModel::G6,
731 DragModel::G7,
732 DragModel::G8,
733 DragModel::GI,
734 DragModel::GS,
735 ];
736 for drag_model in models {
737 for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
738 let base_cd = get_drag_coefficient(mach, &drag_model);
739 let corrected_cd = get_drag_coefficient_with_transonic(
740 mach,
741 &drag_model,
742 true,
743 Some(ProjectileShape::BoatTail),
744 Some(0.308),
745 Some(175.0),
746 );
747 assert_eq!(
748 corrected_cd.to_bits(),
749 base_cd.to_bits(),
750 "standard {drag_model:?} table already includes transonic drag at Mach \
751 {mach}: base={base_cd}, corrected={corrected_cd}"
752 );
753
754 let full_cd = get_drag_coefficient_full(
755 mach,
756 &drag_model,
757 true,
758 false,
759 Some(ProjectileShape::BoatTail),
760 Some(0.308),
761 Some(175.0),
762 None,
763 None,
764 None,
765 );
766 assert_eq!(full_cd.to_bits(), base_cd.to_bits());
767 }
768 }
769 }
770
771 #[test]
772 fn test_drag_coefficient_continuity() {
773 for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
775 let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
776 let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
777 let difference = (cd_after - cd_before).abs();
778 assert!(
779 difference < 0.05,
780 "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
781 );
782 }
783 }
784
785 #[test]
786 fn test_endpoint_bounds() {
787 let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
789 assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
790
791 let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
793 assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
794
795 let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
797 assert!(
798 cd_low_g7 > 0.01,
799 "Low Mach G7 should be positive: {cd_low_g7}"
800 );
801
802 let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
803 assert!(
804 cd_high_g7 >= 0.01,
805 "High Mach G7 should be positive: {cd_high_g7}"
806 );
807 }
808
809 #[test]
810 fn test_drag_table_creation() {
811 let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
812 let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
813 let table = DragTable::new(mach_vals, cd_vals);
814
815 assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
817
818 let cd_interp = table.interpolate(1.25);
820 assert!(cd_interp > 0.4 && cd_interp < 0.5);
821 }
822
823 #[test]
824 fn test_drag_table_empty() {
825 let table = DragTable::new(vec![], vec![]);
826 let result = table.interpolate(1.0);
827 assert_eq!(result, 0.5); }
829
830 #[test]
831 fn test_drag_table_single_point() {
832 let table = DragTable::new(vec![1.0], vec![0.4]);
833
834 assert_eq!(table.interpolate(0.5), 0.4);
836 assert_eq!(table.interpolate(1.0), 0.4);
837 assert_eq!(table.interpolate(2.0), 0.4);
838 }
839
840 #[test]
841 fn test_drag_table_two_points() {
842 let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
843
844 assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
846 assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
847
848 let mid = table.interpolate(1.5);
850 assert!((mid - 0.5).abs() < 1e-10);
851
852 let below = table.interpolate(0.5);
854 assert_eq!(below.to_bits(), 0.4_f64.to_bits());
855
856 let above = table.interpolate(3.0);
857 assert_eq!(above.to_bits(), 0.6_f64.to_bits());
858 }
859
860 #[test]
861 fn out_of_range_mach_holds_boundary_cd() {
862 let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
863
864 for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
865 assert_eq!(
866 table.interpolate(mach).to_bits(),
867 0.2_f64.to_bits(),
868 "Mach {mach} must hold the first tabulated Cd"
869 );
870 }
871 for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
872 assert_eq!(
873 table.interpolate(mach).to_bits(),
874 0.3_f64.to_bits(),
875 "Mach {mach} must hold the last tabulated Cd"
876 );
877 }
878 }
879
880 #[test]
881 fn test_linear_interpolation() {
882 let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
883
884 let result = table.linear_interpolate(0.5, 0);
886 assert!((result - 0.35).abs() < 1e-10);
887
888 let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
890 let result_same = table_same.linear_interpolate(1.0, 0);
891 assert_eq!(result_same, 0.4); }
893
894 #[test]
895 fn test_cubic_interpolation() {
896 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]);
898
899 let result = table.cubic_interpolate(1.25, 1);
901
902 assert!(result > 0.3 && result < 0.7);
904
905 let linear_result = table.linear_interpolate(1.25, 1);
907 assert!((result - linear_result).abs() < 0.2);
909 }
910
911 #[test]
912 fn nonuniform_cubic_reproduces_affine_data() {
913 let table = DragTable::new(
914 vec![0.0, 1.0, 3.0, 4.0],
915 vec![0.25, 0.3125, 0.4375, 0.5],
916 );
917
918 for mach in [1.5, 2.5] {
919 let expected = 0.25 + mach / 16.0;
920 let actual = table.interpolate(mach);
921 assert_eq!(
922 actual.to_bits(),
923 expected.to_bits(),
924 "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
925 );
926 }
927 }
928
929 #[test]
930 fn nonuniform_cubic_is_c1_at_spacing_transition() {
931 let table = DragTable::new(
932 vec![0.0, 1.0, 3.0, 4.0, 7.0],
933 vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
934 );
935 let knot = 3.0;
936 let expected_at_knot = 0.390625_f64;
937 let epsilon = 1e-6;
938 let at_knot = table.interpolate(knot);
939 let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
940 let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
941
942 assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
943 assert!(
944 (left_slope - right_slope).abs() < 1e-5,
945 "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
946 );
947 }
948
949 #[test]
950 fn test_find_drag_tables_dir() {
951 let _dir = find_drag_tables_dir();
954 }
956
957 #[test]
958 fn test_load_drag_table_fallback() {
959 use std::path::Path;
960
961 let fake_dir = Path::new("/non/existent/directory");
963 let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
964
965 let table = load_drag_table(fake_dir, "test", &fallback_data);
966
967 assert_eq!(table.mach_values.len(), 3);
969 assert_eq!(table.cd_values.len(), 3);
970 assert_eq!(table.mach_values[0], 0.5);
971 assert_eq!(table.cd_values[0], 0.2);
972 }
973
974 #[test]
975 fn test_known_drag_values() {
976 let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
980 assert!(
981 (g1_mach1 - 0.4805).abs() < 0.01,
982 "G1 at Mach 1.0: {g1_mach1}"
983 );
984
985 let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
987 assert!(
988 (g7_mach1 - 0.3803).abs() < 0.01,
989 "G7 at Mach 1.0: {g7_mach1}"
990 );
991
992 assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
994 }
995
996 #[test]
997 fn test_monotonicity_properties() {
998 let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); let g1_values: Vec<f64> = mach_values
1003 .iter()
1004 .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1005 .collect();
1006
1007 let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1009 let max_index = g1_values
1010 .iter()
1011 .position(|&x| x == max_value)
1012 .expect("Should find maximum in non-empty vector");
1013 let peak_mach = mach_values
1014 .get(max_index)
1015 .copied()
1016 .expect("Index should be valid");
1017
1018 assert!(
1020 peak_mach > 1.0 && peak_mach < 1.6,
1021 "G1 peak at Mach {peak_mach}"
1022 );
1023 assert!(
1024 max_value > 0.5 && max_value < 1.0,
1025 "G1 peak value: {max_value}"
1026 );
1027 }
1028
1029 #[test]
1030 fn test_physical_constraints() {
1031 let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1032
1033 for &mach in &test_machs {
1034 let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1035 let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1036
1037 assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1039 assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1040
1041 assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1043 assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1044 }
1045 }
1046
1047 #[test]
1048 fn test_performance_characteristics() {
1049 use std::time::Instant;
1051
1052 let start = Instant::now();
1053
1054 for i in 0..1000 {
1056 let mach = 0.5 + (i as f64) * 0.004; let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1058 let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1059 }
1060
1061 let elapsed = start.elapsed();
1062
1063 assert!(
1065 elapsed.as_millis() < 100,
1066 "Performance test took {}ms",
1067 elapsed.as_millis()
1068 );
1069 }
1070
1071 #[test]
1072 fn try_new_accepts_valid_table() {
1073 let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1074 assert_eq!(t.mach_values.len(), 3);
1075 }
1076
1077 #[test]
1078 fn try_new_rejects_mismatched_lengths() {
1079 let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1080 assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1081 }
1082
1083 #[test]
1084 fn try_new_rejects_too_few_points() {
1085 assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1086 }
1087
1088 #[test]
1089 fn try_new_rejects_non_ascending_mach() {
1090 assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1091 assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1092 }
1093
1094 #[test]
1095 fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1096 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1097 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1098 }
1099
1100 #[test]
1101 fn interpolate_does_not_panic_on_mismatched_table() {
1102 let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1104 let _ = bad.interpolate(0.1);
1105 let _ = bad.interpolate(5.0);
1106 let _ = bad.interpolate(1.0);
1107 }
1108
1109 #[test]
1110 fn from_csv_str_parses_with_header_and_comments() {
1111 let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1112 let t = DragTable::from_csv_str(csv).unwrap();
1113 assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1114 assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1115 }
1116
1117 #[test]
1118 fn from_csv_str_rejects_malformed_data_row() {
1119 let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1121 assert!(e.contains("line 2"), "got: {e}");
1122 }
1123
1124 #[test]
1125 fn from_csv_str_rejects_empty() {
1126 assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1127 }
1128
1129 #[test]
1130 fn from_csv_str_rejects_malformed_first_data_row() {
1131 assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1133 assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1134 }
1135
1136 #[test]
1137 fn from_csv_str_still_skips_textual_header() {
1138 let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1140 assert_eq!(t.mach_values, vec![0.5, 1.0]);
1141 }
1142
1143 #[test]
1144 fn from_csv_str_roundtrips_shipped_g7() {
1145 let g7 = include_str!("../data/g7.csv");
1147 let t = DragTable::from_csv_str(g7).unwrap();
1148 assert!(t.mach_values.len() > 20);
1149 }
1150}
1151
1152pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1154 if segments.is_empty() {
1155 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1157
1158 let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1160
1161 if mach_values.is_empty() || segments.is_empty() {
1163 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1165
1166 if let Some(first_mach) = mach_values.first() {
1168 if mach <= *first_mach {
1169 return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1170 }
1171 }
1172
1173 if let Some(last_mach) = mach_values.last() {
1174 if mach >= *last_mach {
1175 return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1176 }
1177 }
1178
1179 let idx = match mach_values
1181 .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1182 {
1183 Ok(idx) => {
1184 return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1186 }
1187 Err(idx) => idx, };
1189
1190 if idx == 0 || idx >= segments.len() {
1192 let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1195 return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1196 }
1197
1198 match (segments.get(idx - 1), segments.get(idx)) {
1200 (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1201 let denominator = hi_mach - lo_mach;
1203 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1204 return *lo_bc; }
1206 let frac = (mach - lo_mach) / denominator;
1207 lo_bc + frac * (hi_bc - lo_bc)
1208 }
1209 _ => 0.5, }
1211}
1212
1213