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