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
604pub fn get_drag_coefficient(mach: f64, drag_model: &DragModel) -> f64 {
613 match drag_model {
614 DragModel::G1 => G1_DRAG_TABLE.interpolate(mach),
615 DragModel::G6 => G6_DRAG_TABLE.interpolate(mach),
616 DragModel::G7 => G7_DRAG_TABLE.interpolate(mach),
617 DragModel::G8 => G8_DRAG_TABLE.interpolate(mach),
618 DragModel::G2 | DragModel::G5 | DragModel::GI | DragModel::GS => {
620 G1_DRAG_TABLE.interpolate(mach)
621 }
622 }
623}
624
625pub fn get_drag_coefficient_with_transonic(
632 mach: f64,
633 drag_model: &DragModel,
634 apply_transonic_correction: bool,
635 projectile_shape: Option<ProjectileShape>,
636 caliber: Option<f64>,
637 weight_grains: Option<f64>,
638) -> f64 {
639 let base_cd = get_drag_coefficient(mach, drag_model);
641
642 if apply_transonic_correction && (0.8..=1.3).contains(&mach) {
644 let shape = match projectile_shape {
646 Some(s) => s,
647 None => {
648 if let (Some(cal), Some(weight)) = (caliber, weight_grains) {
649 get_projectile_shape(
650 cal,
651 weight,
652 match drag_model {
653 DragModel::G1 => "G1",
654 DragModel::G6 => "G6",
655 DragModel::G7 => "G7",
656 DragModel::G8 => "G8",
657 _ => "G1", },
659 )
660 } else {
661 ProjectileShape::Spitzer }
663 }
664 };
665
666 transonic_correction(mach, base_cd, shape, false)
671 } else {
672 base_cd
673 }
674}
675
676#[allow(clippy::too_many_arguments)] pub fn get_drag_coefficient_full(
685 mach: f64,
686 drag_model: &DragModel,
687 apply_transonic_correction: bool,
688 apply_reynolds_correction: bool,
689 projectile_shape: Option<ProjectileShape>,
690 caliber: Option<f64>,
691 weight_grains: Option<f64>,
692 velocity_mps: Option<f64>,
693 air_density_kg_m3: Option<f64>,
694 temperature_c: Option<f64>,
695) -> f64 {
696 let mut cd = get_drag_coefficient_with_transonic(
698 mach,
699 drag_model,
700 apply_transonic_correction,
701 projectile_shape,
702 caliber,
703 weight_grains,
704 );
705
706 if apply_reynolds_correction && mach < 1.0 {
709 if let (Some(v), Some(cal), Some(rho), Some(temp)) =
710 (velocity_mps, caliber, air_density_kg_m3, temperature_c)
711 {
712 use crate::reynolds::apply_reynolds_correction;
713 cd = apply_reynolds_correction(cd, v, cal, rho, temp, mach);
714 }
715 }
716
717 cd
718}
719
720#[cfg(test)]
721#[allow(clippy::items_after_test_module)] mod tests {
723 use super::*;
724
725 #[test]
726 fn test_g1_drag_coefficient_interpolation() {
727 let cd = get_drag_coefficient(1.0, &DragModel::G1);
728 assert!(cd > 0.4 && cd < 0.6, "G1 CD at Mach 1.0: {cd}");
730 }
731
732 #[test]
733 fn test_g7_drag_coefficient_interpolation() {
734 let cd = get_drag_coefficient(1.0, &DragModel::G7);
735 assert!(cd > 0.3 && cd < 0.5, "G7 CD at Mach 1.0: {cd}");
737 }
738
739 #[test]
740 fn standard_g_table_transonic_option_does_not_double_count_drag_rise() {
741 let models = [
742 DragModel::G1,
743 DragModel::G2,
744 DragModel::G5,
745 DragModel::G6,
746 DragModel::G7,
747 DragModel::G8,
748 DragModel::GI,
749 DragModel::GS,
750 ];
751 for drag_model in models {
752 for mach in [0.8, 0.95, 1.0, 1.1, 1.3] {
753 let base_cd = get_drag_coefficient(mach, &drag_model);
754 let corrected_cd = get_drag_coefficient_with_transonic(
755 mach,
756 &drag_model,
757 true,
758 Some(ProjectileShape::BoatTail),
759 Some(0.308),
760 Some(175.0),
761 );
762 assert_eq!(
763 corrected_cd.to_bits(),
764 base_cd.to_bits(),
765 "standard {drag_model:?} table already includes transonic drag at Mach \
766 {mach}: base={base_cd}, corrected={corrected_cd}"
767 );
768
769 let full_cd = get_drag_coefficient_full(
770 mach,
771 &drag_model,
772 true,
773 false,
774 Some(ProjectileShape::BoatTail),
775 Some(0.308),
776 Some(175.0),
777 None,
778 None,
779 None,
780 );
781 assert_eq!(full_cd.to_bits(), base_cd.to_bits());
782 }
783 }
784 }
785
786 #[test]
787 fn test_drag_coefficient_continuity() {
788 for mach in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0] {
790 let cd_before = get_drag_coefficient(mach - 0.01, &DragModel::G1);
791 let cd_after = get_drag_coefficient(mach + 0.01, &DragModel::G1);
792 let difference = (cd_after - cd_before).abs();
793 assert!(
794 difference < 0.05,
795 "Large discontinuity at Mach {mach}: {cd_before} vs {cd_after}"
796 );
797 }
798 }
799
800 #[test]
801 fn test_endpoint_bounds() {
802 let cd_low = get_drag_coefficient(0.0, &DragModel::G1);
804 assert!(cd_low > 0.01 && cd_low < 0.5, "Low Mach G1: {cd_low}");
805
806 let cd_high = get_drag_coefficient(10.0, &DragModel::G1);
808 assert!(cd_high > 0.01, "High Mach G1 should be positive: {cd_high}");
809
810 let cd_low_g7 = get_drag_coefficient(0.0, &DragModel::G7);
812 assert!(
813 cd_low_g7 > 0.01,
814 "Low Mach G7 should be positive: {cd_low_g7}"
815 );
816
817 let cd_high_g7 = get_drag_coefficient(20.0, &DragModel::G7);
818 assert!(
819 cd_high_g7 >= 0.01,
820 "High Mach G7 should be positive: {cd_high_g7}"
821 );
822 }
823
824 #[test]
825 fn test_drag_table_creation() {
826 let mach_vals = vec![0.5, 1.0, 1.5, 2.0];
827 let cd_vals = vec![0.2, 0.5, 0.4, 0.3];
828 let table = DragTable::new(mach_vals, cd_vals);
829
830 assert!((table.interpolate(1.0) - 0.5).abs() < 1e-10);
832
833 let cd_interp = table.interpolate(1.25);
835 assert!(cd_interp > 0.4 && cd_interp < 0.5);
836 }
837
838 #[test]
839 fn test_drag_table_empty() {
840 let table = DragTable::new(vec![], vec![]);
841 let result = table.interpolate(1.0);
842 assert_eq!(result, 0.5); }
844
845 #[test]
846 fn test_drag_table_single_point() {
847 let table = DragTable::new(vec![1.0], vec![0.4]);
848
849 assert_eq!(table.interpolate(0.5), 0.4);
851 assert_eq!(table.interpolate(1.0), 0.4);
852 assert_eq!(table.interpolate(2.0), 0.4);
853 }
854
855 #[test]
856 fn test_drag_table_two_points() {
857 let table = DragTable::new(vec![1.0, 2.0], vec![0.4, 0.6]);
858
859 assert!((table.interpolate(1.0) - 0.4).abs() < 1e-10);
861 assert!((table.interpolate(2.0) - 0.6).abs() < 1e-10);
862
863 let mid = table.interpolate(1.5);
865 assert!((mid - 0.5).abs() < 1e-10);
866
867 let below = table.interpolate(0.5);
869 assert_eq!(below.to_bits(), 0.4_f64.to_bits());
870
871 let above = table.interpolate(3.0);
872 assert_eq!(above.to_bits(), 0.6_f64.to_bits());
873 }
874
875 #[test]
876 fn out_of_range_mach_holds_boundary_cd() {
877 let table = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
878
879 for mach in [f64::NEG_INFINITY, -10.0, 0.49, 0.5] {
880 assert_eq!(
881 table.interpolate(mach).to_bits(),
882 0.2_f64.to_bits(),
883 "Mach {mach} must hold the first tabulated Cd"
884 );
885 }
886 for mach in [2.0, 2.01, 100.0, f64::INFINITY] {
887 assert_eq!(
888 table.interpolate(mach).to_bits(),
889 0.3_f64.to_bits(),
890 "Mach {mach} must hold the last tabulated Cd"
891 );
892 }
893 }
894
895 #[test]
896 fn test_linear_interpolation() {
897 let table = DragTable::new(vec![0.0, 1.0, 2.0], vec![0.2, 0.5, 0.3]);
898
899 let result = table.linear_interpolate(0.5, 0);
901 assert!((result - 0.35).abs() < 1e-10);
902
903 let table_same = DragTable::new(vec![1.0, 1.0], vec![0.4, 0.6]);
905 let result_same = table_same.linear_interpolate(1.0, 0);
906 assert_eq!(result_same, 0.4); }
908
909 #[test]
910 fn test_cubic_interpolation() {
911 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]);
913
914 let result = table.cubic_interpolate(1.25, 1);
916
917 assert!(result > 0.3 && result < 0.7);
919
920 let linear_result = table.linear_interpolate(1.25, 1);
922 assert!((result - linear_result).abs() < 0.2);
924 }
925
926 #[test]
927 fn nonuniform_cubic_reproduces_affine_data() {
928 let table = DragTable::new(
929 vec![0.0, 1.0, 3.0, 4.0],
930 vec![0.25, 0.3125, 0.4375, 0.5],
931 );
932
933 for mach in [1.5, 2.5] {
934 let expected = 0.25 + mach / 16.0;
935 let actual = table.interpolate(mach);
936 assert_eq!(
937 actual.to_bits(),
938 expected.to_bits(),
939 "non-uniform cubic bent affine data at Mach {mach}: {actual} vs {expected}"
940 );
941 }
942 }
943
944 #[test]
945 fn nonuniform_cubic_is_c1_at_spacing_transition() {
946 let table = DragTable::new(
947 vec![0.0, 1.0, 3.0, 4.0, 7.0],
948 vec![0.25, 0.265625, 0.390625, 0.5, 1.015625],
949 );
950 let knot = 3.0;
951 let expected_at_knot = 0.390625_f64;
952 let epsilon = 1e-6;
953 let at_knot = table.interpolate(knot);
954 let left_slope = (at_knot - table.interpolate(knot - epsilon)) / epsilon;
955 let right_slope = (table.interpolate(knot + epsilon) - at_knot) / epsilon;
956
957 assert_eq!(at_knot.to_bits(), expected_at_knot.to_bits());
958 assert!(
959 (left_slope - right_slope).abs() < 1e-5,
960 "non-uniform cubic has a derivative kink: left={left_slope}, right={right_slope}"
961 );
962 }
963
964 #[test]
965 fn test_find_drag_tables_dir() {
966 let _dir = find_drag_tables_dir();
969 }
971
972 #[test]
973 fn test_load_drag_table_fallback() {
974 use std::path::Path;
975
976 let fake_dir = Path::new("/non/existent/directory");
978 let fallback_data = [(0.5, 0.2), (1.0, 0.4), (1.5, 0.3)];
979
980 let table = load_drag_table(fake_dir, "test", &fallback_data);
981
982 assert_eq!(table.mach_values.len(), 3);
984 assert_eq!(table.cd_values.len(), 3);
985 assert_eq!(table.mach_values[0], 0.5);
986 assert_eq!(table.cd_values[0], 0.2);
987 }
988
989 #[test]
990 fn test_known_drag_values() {
991 let g1_mach1 = get_drag_coefficient(1.0, &DragModel::G1);
995 assert!(
996 (g1_mach1 - 0.4805).abs() < 0.01,
997 "G1 at Mach 1.0: {g1_mach1}"
998 );
999
1000 let g7_mach1 = get_drag_coefficient(1.0, &DragModel::G7);
1002 assert!(
1003 (g7_mach1 - 0.3803).abs() < 0.01,
1004 "G7 at Mach 1.0: {g7_mach1}"
1005 );
1006
1007 assert!(g1_mach1 > g7_mach1, "G1 should be > G7 at Mach 1.0");
1009 }
1010
1011 #[test]
1012 fn test_monotonicity_properties() {
1013 let mach_values: Vec<f64> = (8..20).map(|i| i as f64 * 0.1).collect(); let g1_values: Vec<f64> = mach_values
1018 .iter()
1019 .map(|&m| get_drag_coefficient(m, &DragModel::G1))
1020 .collect();
1021
1022 let max_value = g1_values.iter().copied().fold(0.0_f64, f64::max);
1024 let max_index = g1_values
1025 .iter()
1026 .position(|&x| x == max_value)
1027 .expect("Should find maximum in non-empty vector");
1028 let peak_mach = mach_values
1029 .get(max_index)
1030 .copied()
1031 .expect("Index should be valid");
1032
1033 assert!(
1035 peak_mach > 1.0 && peak_mach < 1.6,
1036 "G1 peak at Mach {peak_mach}"
1037 );
1038 assert!(
1039 max_value > 0.5 && max_value < 1.0,
1040 "G1 peak value: {max_value}"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_physical_constraints() {
1046 let test_machs = [0.1, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0];
1047
1048 for &mach in &test_machs {
1049 let g1_cd = get_drag_coefficient(mach, &DragModel::G1);
1050 let g7_cd = get_drag_coefficient(mach, &DragModel::G7);
1051
1052 assert!(g1_cd > 0.0, "G1 CD negative at Mach {mach}: {g1_cd}");
1054 assert!(g7_cd > 0.0, "G7 CD negative at Mach {mach}: {g7_cd}");
1055
1056 assert!(g1_cd < 2.0, "G1 CD too high at Mach {mach}: {g1_cd}");
1058 assert!(g7_cd < 1.5, "G7 CD too high at Mach {mach}: {g7_cd}");
1059 }
1060 }
1061
1062 #[test]
1063 fn test_performance_characteristics() {
1064 use std::time::Instant;
1066
1067 let start = Instant::now();
1068
1069 for i in 0..1000 {
1071 let mach = 0.5 + (i as f64) * 0.004; let _g1 = get_drag_coefficient(mach, &DragModel::G1);
1073 let _g7 = get_drag_coefficient(mach, &DragModel::G7);
1074 }
1075
1076 let elapsed = start.elapsed();
1077
1078 assert!(
1080 elapsed.as_millis() < 100,
1081 "Performance test took {}ms",
1082 elapsed.as_millis()
1083 );
1084 }
1085
1086 #[test]
1087 fn try_new_accepts_valid_table() {
1088 let t = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40, 0.30]).unwrap();
1089 assert_eq!(t.mach_values.len(), 3);
1090 }
1091
1092 #[test]
1093 fn try_new_rejects_mismatched_lengths() {
1094 let e = DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.20, 0.40]).unwrap_err();
1095 assert!(e.contains("Mach") && e.contains("Cd"), "got: {e}");
1096 }
1097
1098 #[test]
1099 fn try_new_rejects_too_few_points() {
1100 assert!(DragTable::try_new(vec![1.0], vec![0.3]).is_err());
1101 }
1102
1103 #[test]
1104 fn try_new_rejects_non_ascending_mach() {
1105 assert!(DragTable::try_new(vec![1.0, 1.0, 2.0], vec![0.3, 0.3, 0.3]).is_err());
1106 assert!(DragTable::try_new(vec![2.0, 1.0], vec![0.3, 0.3]).is_err());
1107 }
1108
1109 #[test]
1110 fn try_new_rejects_nonpositive_or_nonfinite_cd() {
1111 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, 0.0]).is_err());
1112 assert!(DragTable::try_new(vec![1.0, 2.0], vec![0.3, f64::NAN]).is_err());
1113 }
1114
1115 #[test]
1116 fn interpolate_does_not_panic_on_mismatched_table() {
1117 let bad = DragTable::new(vec![0.5, 1.0, 2.0], vec![0.2]);
1119 let _ = bad.interpolate(0.1);
1120 let _ = bad.interpolate(5.0);
1121 let _ = bad.interpolate(1.0);
1122 }
1123
1124 #[test]
1125 fn from_csv_str_parses_with_header_and_comments() {
1126 let csv = "# my deck\nmach,cd\n0.5, 0.230\n1.0,0.400\n2.0 , 0.300\n";
1127 let t = DragTable::from_csv_str(csv).unwrap();
1128 assert_eq!(t.mach_values, vec![0.5, 1.0, 2.0]);
1129 assert_eq!(t.cd_values, vec![0.230, 0.400, 0.300]);
1130 }
1131
1132 #[test]
1133 fn from_csv_str_rejects_malformed_data_row() {
1134 let e = DragTable::from_csv_str("0.5,0.23\n1.0,notanumber\n").unwrap_err();
1136 assert!(e.contains("line 2"), "got: {e}");
1137 }
1138
1139 #[test]
1140 fn from_csv_str_rejects_empty() {
1141 assert!(DragTable::from_csv_str("# only comments\n\n").is_err());
1142 }
1143
1144 #[test]
1145 fn from_csv_str_rejects_malformed_first_data_row() {
1146 assert!(DragTable::from_csv_str("0.5\n1.0,0.4\n2.0,0.3\n").is_err());
1148 assert!(DragTable::from_csv_str("0.5,O.2\n1.0,0.4\n2.0,0.3\n").is_err());
1149 }
1150
1151 #[test]
1152 fn from_csv_str_still_skips_textual_header() {
1153 let t = DragTable::from_csv_str("mach,cd\n0.5,0.2\n1.0,0.4\n").unwrap();
1155 assert_eq!(t.mach_values, vec![0.5, 1.0]);
1156 }
1157
1158 #[test]
1159 fn from_csv_str_roundtrips_shipped_g7() {
1160 let g7 = include_str!("../data/g7.csv");
1162 let t = DragTable::from_csv_str(g7).unwrap();
1163 assert!(t.mach_values.len() > 20);
1164 }
1165}
1166
1167pub fn interpolated_bc(mach: f64, segments: &[(f64, f64)]) -> f64 {
1169 if segments.is_empty() {
1170 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1172
1173 let mach_values: Vec<f64> = segments.iter().map(|(m, _)| *m).collect();
1175
1176 if mach_values.is_empty() || segments.is_empty() {
1178 return crate::constants::BC_FALLBACK_CONSERVATIVE; }
1180
1181 if let Some(first_mach) = mach_values.first() {
1183 if mach <= *first_mach {
1184 return segments.first().map(|(_, bc)| *bc).unwrap_or(0.5);
1185 }
1186 }
1187
1188 if let Some(last_mach) = mach_values.last() {
1189 if mach >= *last_mach {
1190 return segments.last().map(|(_, bc)| *bc).unwrap_or(0.5);
1191 }
1192 }
1193
1194 let idx = match mach_values
1196 .binary_search_by(|&m| m.partial_cmp(&mach).unwrap_or(std::cmp::Ordering::Equal))
1197 {
1198 Ok(idx) => {
1199 return segments.get(idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1201 }
1202 Err(idx) => idx, };
1204
1205 if idx == 0 || idx >= segments.len() {
1207 let safe_idx = idx.saturating_sub(1).min(segments.len().saturating_sub(1));
1210 return segments.get(safe_idx).map(|(_, bc)| *bc).unwrap_or(0.5);
1211 }
1212
1213 match (segments.get(idx - 1), segments.get(idx)) {
1215 (Some((lo_mach, lo_bc)), Some((hi_mach, hi_bc))) => {
1216 let denominator = hi_mach - lo_mach;
1218 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
1219 return *lo_bc; }
1221 let frac = (mach - lo_mach) / denominator;
1222 lo_bc + frac * (hi_bc - lo_bc)
1223 }
1224 _ => 0.5, }
1226}
1227
1228