1use std::f64::consts::{FRAC_PI_2, PI};
30
31use hpr_core::interp::{Extrapolation, Interpolation, Lookup, Side, Table1D};
32use serde::{Deserialize, Serialize};
33
34use crate::error::AeroError;
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39#[non_exhaustive]
40pub struct DragTable {
41 pub power_off: Table1D,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub power_on: Option<Table1D>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub reference_diameter_m: Option<f64>,
51}
52
53impl DragTable {
54 pub fn new(power_off: Table1D, power_on: Option<Table1D>) -> Self {
57 Self {
58 power_off,
59 power_on,
60 reference_diameter_m: None,
61 }
62 }
63
64 pub fn with_reference_diameter_m(mut self, diameter_m: f64) -> Result<Self, AeroError> {
73 crate::error::check_dimension("drag table reference diameter", diameter_m, false)?;
74 self.reference_diameter_m = Some(diameter_m);
75 Ok(self)
76 }
77
78 pub fn from_csv(power_off: &str, power_on: Option<&str>) -> Result<Self, AeroError> {
85 Ok(Self::new(
86 parse_mach_csv(power_off, None)?,
87 power_on
88 .map(|text| parse_mach_csv(text, None))
89 .transpose()?,
90 ))
91 }
92
93 pub fn lookup(&self, mach: f64, thrusting: bool) -> Result<Lookup, AeroError> {
100 crate::drag::check_mach_any(mach)?;
101 let table = match (&self.power_on, thrusting) {
102 (Some(on), true) => on,
103 _ => &self.power_off,
104 };
105 Ok(table.lookup(mach)?)
106 }
107}
108
109fn split_fields(line: &str) -> Vec<String> {
112 let mut fields = Vec::new();
113 let mut field = String::new();
114 let mut quoted = false;
115 let mut chars = line.chars().peekable();
116 while let Some(c) = chars.next() {
117 match c {
118 '"' if quoted && chars.peek() == Some(&'"') => {
119 field.push('"');
120 chars.next();
121 }
122 '"' => quoted = !quoted,
123 ',' if !quoted => fields.push(std::mem::take(&mut field).trim().to_owned()),
124 _ => field.push(c),
125 }
126 }
127 fields.push(field.trim().to_owned());
128 while fields.len() > 1 && fields.last().is_some_and(String::is_empty) {
129 fields.pop();
130 }
131 fields
132}
133
134pub fn parse_mach_csv(text: &str, column: Option<&str>) -> Result<Table1D, AeroError> {
154 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
155 let mut lines = text
156 .lines()
157 .enumerate()
158 .map(|(i, line)| (i + 1, line.trim()))
159 .filter(|(_, line)| !line.is_empty())
160 .peekable();
161 let csv = |line: usize, message: String| AeroError::Csv { line, message };
162 let number = |f: &String| f.parse::<f64>().ok();
163
164 let (first_line, first) = lines
165 .peek()
166 .map(|&(n, line)| (n, split_fields(line)))
167 .ok_or_else(|| csv(0, "no rows".to_owned()))?;
168 let header = first.iter().all(|f| number(f).is_none());
169 let (mach_col, value_col, alpha_col, expected) = match column {
170 None => {
171 if header {
172 lines.next();
173 }
174 (0, 1, None, Some(2))
175 }
176 Some(name) => {
177 if !header {
178 return Err(csv(
179 first_line,
180 format!("expected a header naming `{name}`"),
181 ));
182 }
183 lines.next();
184 let names: Vec<String> = first.iter().map(|f| f.to_lowercase()).collect();
185 let mach = names
186 .iter()
187 .position(|f| f.contains("mach"))
188 .ok_or_else(|| csv(first_line, "no Mach column".to_owned()))?;
189 let wanted = name.trim().to_lowercase();
190 let value = names
191 .iter()
192 .position(|f| *f == wanted)
193 .ok_or_else(|| csv(first_line, format!("no column named `{name}`")))?;
194 let alpha = names.iter().position(|f| f.starts_with("alpha"));
195 (mach, value, alpha, None)
196 }
197 };
198
199 let (mut xs, mut ys): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
200 for (n, line) in lines {
201 let values: Vec<f64> = split_fields(line)
202 .iter()
203 .map(number)
204 .collect::<Option<_>>()
205 .ok_or_else(|| csv(n, format!("not a row of numbers: `{line}`")))?;
206 if let Some(expected) = expected
207 && values.len() != expected
208 {
209 return Err(csv(
210 n,
211 format!("expected {expected} columns, found {}", values.len()),
212 ));
213 }
214 let get = |index: usize| {
215 values
216 .get(index)
217 .copied()
218 .ok_or_else(|| csv(n, format!("missing column {}", index + 1)))
219 };
220 if let Some(alpha) = alpha_col
221 && get(alpha)? != 0.0
222 {
223 continue;
224 }
225 let (mach, value) = (get(mach_col)?, get(value_col)?);
226 if !(mach.is_finite() && value.is_finite()) {
227 return Err(csv(n, format!("not finite: Mach {mach}, value {value}")));
228 }
229 if let (Some(&last), Some(&last_value)) = (xs.last(), ys.last()) {
230 if mach == last && value == last_value {
232 continue;
233 }
234 if mach <= last {
235 return Err(csv(
236 n,
237 format!("Mach {mach} doesn't increase from the previous row's {last}"),
238 ));
239 }
240 }
241 xs.push(mach);
242 ys.push(value);
243 }
244 if xs.is_empty() {
245 return Err(csv(0, "no rows".to_owned()));
246 }
247 Ok(Table1D::new(
248 xs,
249 ys,
250 Interpolation::Linear,
251 Extrapolation::Clamp,
252 )?)
253}
254
255const INCH_M: f64 = 0.0254;
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263#[non_exhaustive]
264pub struct NormalForceColumn {
265 pub alpha_rad: f64,
267 pub slope_per_rad: Table1D,
270 pub cp_station_m: Table1D,
272}
273
274impl NormalForceColumn {
275 pub fn new(alpha_rad: f64, slope_per_rad: Table1D, cp_station_m: Table1D) -> Self {
278 Self {
279 alpha_rad,
280 slope_per_rad,
281 cp_station_m,
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
322#[serde(try_from = "NormalForceTableData", into = "NormalForceTableData")]
323pub struct NormalForceTable {
324 columns: Vec<NormalForceColumn>,
325 reference: TableReference,
326}
327
328#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
331#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
332#[non_exhaustive]
333pub enum TableReference {
334 #[default]
336 Rocket,
337 LargestBody,
340 Diameter {
342 diameter_m: f64,
344 },
345}
346
347#[derive(Serialize, Deserialize)]
349#[serde(deny_unknown_fields)]
350struct NormalForceTableData {
351 columns: Vec<NormalForceColumn>,
352 #[serde(default)]
353 reference: TableReference,
354}
355
356impl TryFrom<NormalForceTableData> for NormalForceTable {
357 type Error = AeroError;
358
359 fn try_from(data: NormalForceTableData) -> Result<Self, AeroError> {
360 NormalForceTable::new(data.columns)?.with_reference(data.reference)
361 }
362}
363
364impl From<NormalForceTable> for NormalForceTableData {
365 fn from(table: NormalForceTable) -> Self {
366 NormalForceTableData {
367 columns: table.columns,
368 reference: table.reference,
369 }
370 }
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
376#[non_exhaustive]
377pub struct NormalForceLookup {
378 pub coefficient: f64,
380 pub slope_per_rad: f64,
382 pub cp_station_m: f64,
384 pub mach_extrapolated: Option<Side>,
386 pub beyond_alpha: bool,
389}
390
391impl NormalForceTable {
392 pub fn new(columns: Vec<NormalForceColumn>) -> Result<Self, AeroError> {
399 let domain = |value| AeroError::Domain {
400 what: "normal-force table angle of attack",
401 value,
402 };
403 if columns.is_empty() {
404 return Err(AeroError::Domain {
405 what: "normal-force table column count",
406 value: 0.0,
407 });
408 }
409 let mut previous: Option<f64> = None;
410 for column in &columns {
411 let alpha = column.alpha_rad;
412 if !(0.0..FRAC_PI_2).contains(&alpha) || previous.is_some_and(|p| alpha <= p) {
413 return Err(domain(alpha));
414 }
415 previous = Some(alpha);
416 }
417 Ok(Self {
418 columns,
419 reference: TableReference::Rocket,
420 })
421 }
422
423 pub fn with_reference(mut self, reference: TableReference) -> Result<Self, AeroError> {
430 if let TableReference::Diameter { diameter_m } = reference {
431 crate::error::check_dimension(
432 "normal-force table reference diameter",
433 diameter_m,
434 false,
435 )?;
436 }
437 self.reference = reference;
438 Ok(self)
439 }
440
441 pub fn with_reference_diameter_m(self, diameter_m: f64) -> Result<Self, AeroError> {
448 self.with_reference(TableReference::Diameter { diameter_m })
449 }
450
451 pub fn columns(&self) -> &[NormalForceColumn] {
453 &self.columns
454 }
455
456 pub fn reference(&self) -> TableReference {
458 self.reference
459 }
460
461 pub fn lookup(&self, mach: f64, alpha_rad: f64) -> Result<NormalForceLookup, AeroError> {
471 let largest = self
472 .columns
473 .iter()
474 .flat_map(|c| c.cp_station_m.ys())
475 .fold(0.0_f64, |largest, &cp| largest.max(cp));
476 self.lookup_within(mach, alpha_rad, (0.0, 2.0 * largest))
477 }
478
479 pub fn lookup_within(
489 &self,
490 mach: f64,
491 alpha_rad: f64,
492 stations_m: (f64, f64),
493 ) -> Result<NormalForceLookup, AeroError> {
494 crate::drag::check_mach_any(mach)?;
495 let (fore, aft) = stations_m;
496 if !(fore.is_finite() && aft.is_finite() && fore <= aft) {
497 return Err(AeroError::Domain {
498 what: "normal-force table's stations, fore end",
499 value: fore,
500 });
501 }
502 if !(0.0..=PI).contains(&alpha_rad) {
503 return Err(AeroError::Domain {
504 what: "angle of attack",
505 value: alpha_rad,
506 });
507 }
508 let mut mach_extrapolated = None;
509 let mut read = |column: &NormalForceColumn| -> Result<(f64, f64), AeroError> {
510 let slope = column.slope_per_rad.lookup(mach)?;
511 let cp = column.cp_station_m.lookup(mach)?;
512 mach_extrapolated = mach_extrapolated.or(slope.extrapolated).or(cp.extrapolated);
513 Ok((slope.value, cp.value))
514 };
515 let last = self.columns.len() - 1;
517 let alpha_n = self.columns[last].alpha_rad;
518 let held = alpha_rad.min(alpha_n);
519 let upper = self
521 .columns
522 .partition_point(|c| c.alpha_rad < held)
523 .min(last);
524 let above = &self.columns[upper];
525 let (slope, cp) = if above.alpha_rad == held || upper == 0 {
526 read(above)?
528 } else {
529 let below = &self.columns[upper - 1];
530 let t = (held - below.alpha_rad) / (above.alpha_rad - below.alpha_rad);
531 let (s0, x0) = read(below)?;
532 let (s1, x1) = read(above)?;
533 (s0 + t * (s1 - s0), x0 + t * (x1 - x0))
534 };
535 let beyond_alpha = alpha_rad > alpha_n;
536 let (coefficient, cp) = if !beyond_alpha {
537 (slope * alpha_rad, cp)
538 } else if alpha_n == 0.0 {
539 (slope * alpha_rad.sin(), cp)
541 } else {
542 let s1 = alpha_rad.sin() / alpha_n.sin();
548 let force_n = slope * alpha_n;
549 let rest = match self.columns.first() {
550 Some(first) if first.alpha_rad == 0.0 && force_n > 0.0 => {
551 let (linear_slope, linear_cp) = read(first)?;
552 let linear = (linear_slope * alpha_n).clamp(0.0, force_n);
553 let rest = force_n - linear;
554 (rest > 0.0).then(|| {
555 let station = (force_n * cp - linear * linear_cp) / rest;
556 (rest, station.clamp(stations_m.0, stations_m.1))
557 })
558 }
559 _ => None,
560 };
561 match rest {
562 Some((rest, station)) => {
563 let extra = rest * (s1 * s1 - s1);
564 let force = force_n * s1 + extra;
565 let moment = force_n * s1 * cp + extra * station;
566 (force, if force != 0.0 { moment / force } else { cp })
567 }
568 None => (force_n * s1, cp),
569 }
570 };
571 Ok(NormalForceLookup {
572 coefficient,
573 slope_per_rad: if alpha_rad > 0.0 {
574 coefficient / alpha_rad
575 } else {
576 slope
577 },
578 cp_station_m: cp,
579 mach_extrapolated,
580 beyond_alpha,
581 })
582 }
583
584 pub fn from_rasaero_csv(text: &str) -> Result<Self, AeroError> {
639 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
640 let mut lines = text
641 .lines()
642 .enumerate()
643 .map(|(i, line)| (i + 1, line.trim()))
644 .filter(|(_, line)| !line.is_empty());
645 let csv = |line: usize, message: String| AeroError::Csv { line, message };
646 let (header_line, header) = lines.next().ok_or_else(|| csv(0, "no rows".to_owned()))?;
647 let names: Vec<String> = split_fields(header)
648 .iter()
649 .map(|f| f.to_lowercase())
650 .collect();
651 let column = |name: &str| {
652 names
653 .iter()
654 .position(|f| f == name)
655 .ok_or_else(|| csv(header_line, format!("no column named `{name}`")))
656 };
657 let (mach_col, alpha_col, cn_col, potential_col, cp_col) = (
658 column("mach")?,
659 column("alpha")?,
660 column("cn")?,
661 column("cn potential")?,
662 column("cp")?,
663 );
664
665 let mut angles: Vec<(f64, Vec<RasaeroRow>)> = Vec::new();
667 for (n, line) in lines {
668 let fields = split_fields(line);
670 let get = |index: usize| {
671 let field = fields
672 .get(index)
673 .ok_or_else(|| csv(n, format!("missing column {}", index + 1)))?;
674 field.parse::<f64>().map_err(|_| {
675 csv(
676 n,
677 format!("column {} is not a number: `{field}`", index + 1),
678 )
679 })
680 };
681 let alpha_deg = get(alpha_col)?;
682 let row = RasaeroRow {
683 line: n,
684 mach: get(mach_col)?,
685 cn: get(cn_col)?,
686 potential: get(potential_col)?,
687 cp_in: get(cp_col)?,
688 };
689 if ![alpha_deg, row.mach, row.cn, row.potential, row.cp_in]
690 .iter()
691 .all(|v| v.is_finite())
692 {
693 return Err(csv(n, format!("not finite: `{line}`")));
694 }
695 if !(0.0..90.0).contains(&alpha_deg) {
696 return Err(csv(
697 n,
698 format!("angle of attack {alpha_deg}° is outside [0°, 90°)"),
699 ));
700 }
701 let index = match angles.iter().position(|(a, _)| *a == alpha_deg) {
702 Some(index) => index,
703 None => {
704 angles.push((alpha_deg, Vec::new()));
705 angles.len() - 1
706 }
707 };
708 let rows = &mut angles[index].1;
709 if let Some(last) = rows.last() {
710 if row.same_values(last) {
712 continue;
713 }
714 if row.mach <= last.mach {
715 return Err(csv(
716 n,
717 format!(
718 "Mach {} doesn't increase from line {}'s {} at {alpha_deg}°",
719 row.mach, last.line, last.mach
720 ),
721 ));
722 }
723 }
724 rows.push(row);
725 }
726 angles.sort_by(|a, b| a.0.total_cmp(&b.0));
727 let (first_positive_deg, first_positive) = angles
728 .iter()
729 .find(|(a, _)| *a > 0.0)
730 .ok_or_else(|| csv(0, "no rows at a positive angle of attack".to_owned()))?;
731 let first_positive_rad = first_positive_deg.to_radians();
732
733 let table = |xs: Vec<f64>, ys: Vec<f64>| {
734 Table1D::new(xs, ys, Interpolation::Linear, Extrapolation::Clamp)
735 };
736 let mut columns = Vec::with_capacity(angles.len());
737 for (alpha_deg, rows) in &angles {
738 let alpha_rad = alpha_deg.to_radians();
739 let mut slopes = Vec::with_capacity(rows.len());
740 for row in rows {
741 slopes.push(if *alpha_deg > 0.0 {
742 row.cn / alpha_rad
743 } else {
744 let at = first_positive.partition_point(|r| r.mach < row.mach);
746 match first_positive.get(at) {
747 Some(r) if r.mach == row.mach => r.potential / first_positive_rad,
748 _ => {
749 return Err(csv(
750 row.line,
751 format!(
752 "no row at Mach {} and {first_positive_deg}° to give the \
753 slope at 0°",
754 row.mach
755 ),
756 ));
757 }
758 }
759 });
760 }
761 if rows.len() < Table1D::MIN_KNOTS {
762 let line = rows.first().map_or(0, |r| r.line);
763 return Err(csv(
764 line,
765 format!("{alpha_deg}° has one Mach number; a column needs two"),
766 ));
767 }
768 let machs: Vec<f64> = rows.iter().map(|r| r.mach).collect();
769 let cps = rows.iter().map(|r| r.cp_in * INCH_M).collect();
770 columns.push(NormalForceColumn::new(
771 alpha_rad,
772 table(machs.clone(), slopes)?,
773 table(machs, cps)?,
774 ));
775 }
776 Self::new(columns)?.with_reference(TableReference::LargestBody)
777 }
778}
779
780struct RasaeroRow {
782 line: usize,
783 mach: f64,
784 cn: f64,
785 potential: f64,
786 cp_in: f64,
787}
788
789impl RasaeroRow {
790 fn same_values(&self, other: &RasaeroRow) -> bool {
791 (self.mach, self.cn, self.potential, self.cp_in)
792 == (other.mach, other.cn, other.potential, other.cp_in)
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
801 fn reads_two_column_curves_with_their_quirks() {
802 let text = "0.01,0.949\r\n0.02,01.05\r\n0.30,0.40\r\n\r\n";
804 let table = parse_mach_csv(text, None).unwrap();
805 assert_eq!(table.xs(), [0.01, 0.02, 0.30]);
806 assert_eq!(table.ys(), [0.949, 1.05, 0.40]);
807 let with_header = parse_mach_csv("Mach,Cd\n0.1,0.5\n0.2,0.6\n", None).unwrap();
808 assert_eq!(with_header.ys(), [0.5, 0.6]);
809 }
810
811 #[test]
814 fn named_columns_and_zero_alpha_rows() {
815 let header = "Mach,Alpha,CD,CD Power-Off,CD Power-On,CA Power-Off,CA Power-On,CL,CN,\
816 CN Potential,CN Viscous,CNalpha (0 to 4 deg) (per rad),CP,CP (0 to 4 deg),\
817 Reynolds Number";
818 let text = format!(
819 "{header}\n\
820 0.1,0,0.51,0.50,0.40,0.50,0.40,0,0,0,0,6.1,66.2,66.2,5931000\n\
821 0.1,2,0.56,0.55,0.45,0.55,0.45,0.1,0.2,0.1,0.1,6.1,66.0,66.2,5931000\n\
822 0.2,0,0.53,0.52,0.42,0.52,0.42,0,0,0,0,6.1,66.2,66.2,11862000\n\
823 0.2,4,0.58,0.57,0.47,0.57,0.47,0.4,0.4,0.2,0.2,6.1,65.8,66.2,11862000\n"
824 );
825 let text = text.as_str();
826 let off = parse_mach_csv(text, Some("cd power-off")).unwrap();
827 let on = parse_mach_csv(text, Some(" CD Power-On ")).unwrap();
828 assert_eq!(
829 (off.xs(), off.ys()),
830 ([0.1, 0.2].as_slice(), [0.50, 0.52].as_slice())
831 );
832 assert_eq!(on.ys(), [0.40, 0.42]);
833 let table = DragTable::new(off, Some(on));
834 let mid = table.lookup(0.15, true).unwrap();
835 assert!((mid.value - 0.41).abs() < 1e-15);
836 assert_eq!(mid.extrapolated, None);
837 assert_eq!(table.lookup(0.15, false).unwrap().value, 0.51);
838 let high = table.lookup(3.0, false).unwrap();
839 assert_eq!((high.value, high.extrapolated), (0.52, Some(Side::Above)));
840 }
841
842 #[test]
843 fn malformed_text_names_the_line() {
844 let err = |text: &str, column| parse_mach_csv(text, column).unwrap_err();
845 let line = |text: &str, column| match err(text, column) {
846 AeroError::Csv { line, .. } => line,
847 other => panic!("expected a CSV error, got {other:?}"),
848 };
849 assert_eq!(line("", None), 0);
850 assert_eq!(line("0.1,0.5\n0.2,x\n", None), 2);
851 assert_eq!(line("0.1,0.5,0.4\n", None), 1);
852 assert_eq!(line("0.1,0.5\n", Some("cd")), 1);
853 assert_eq!(line("Mach,CD\n0.1,0.5\n", Some("CA")), 1);
854 assert_eq!(line("0.1,0.5x\n0.2,0.6\n0.3,0.7\n", None), 1);
856 assert_eq!(line("0.2,0.5\n\n0.1,0.5\n", None), 3);
858 assert_eq!(line("0.1,0.5\n0.1,0.6\n", None), 2);
859 let repeated = parse_mach_csv("0.1,0.5\n0.1,0.5\n0.1,0.5\n0.2,0.6\n", None).unwrap();
861 assert_eq!(repeated.xs(), [0.1, 0.2]);
862 assert_eq!(line("0.1,0.5\n0.2,nan\n", None), 2);
863 assert_eq!(line("0.1,0.5\n0.2,inf\n", None), 2);
864 let alpha = "Mach,Alpha,CD\n0.1,0,0.5\n0.1,2,0.6\n0.1,0,0.7\n";
865 assert_eq!(line(alpha, Some("CD")), 4);
866 assert!(matches!(err("0.2,0.5\n", None), AeroError::Table(_)));
867 let table = DragTable::from_csv("0.1,0.5\n0.2,0.6\n", None).unwrap();
868 assert!(matches!(
869 table.lookup(-0.1, false),
870 Err(AeroError::Domain { .. })
871 ));
872 assert!(matches!(
873 table.lookup(f64::NAN, false),
874 Err(AeroError::Domain { .. })
875 ));
876 assert_eq!(table.lookup(3.0, false).unwrap().value, 0.6);
878 assert_eq!(table.lookup(0.1, true).unwrap().value, 0.5);
880 }
881
882 #[test]
884 fn byte_order_marks_quotes_and_trailing_commas() {
885 let table = parse_mach_csv("\u{feff}0.01,0.949\n0.02,1.05\n", None).unwrap();
886 assert_eq!(table.xs(), [0.01, 0.02]);
887 let text = "\"Mach\",\"CN (0,4)\",\"CD\",\n0.1,6.1,0.5,\n0.2,6.2,0.6,\n";
888 let table = parse_mach_csv(text, Some("cd")).unwrap();
889 assert_eq!(table.ys(), [0.5, 0.6]);
890 assert_eq!(split_fields("a,\"b,\"\"c\"\"\",d,,"), ["a", "b,\"c\"", "d"]);
891 }
892
893 const RASAERO_HEADER: &str = "Mach,Alpha,CD,CD Power-Off,CD Power-On,CA Power-Off,\
895 CA Power-On,CL,CN,CN Potential,CN Viscous,\
896 CNalpha (0 to 4 deg) (per rad),CP,CP (0 to 4 deg),\
897 Reynolds Number";
898
899 fn rasaero_row(mach: f64, alpha_deg: f64, potential: f64, viscous: f64, cp_in: f64) -> String {
902 format!(
903 "{mach},{alpha_deg},0.5,0.5,0.4,0.5,0.4,0,{},{potential},{viscous},9.9,{cp_in},55.5,1e6",
904 potential + viscous
905 )
906 }
907
908 fn small_export() -> String {
913 let r2 = 2.0_f64.to_radians();
914 let r4 = 4.0_f64.to_radians();
915 let mut rows = vec![RASAERO_HEADER.to_owned()];
916 for (mach, cp_in) in [(0.5, 44.0), (1.0, 50.0), (1.5, 47.0)] {
917 rows.push(rasaero_row(mach, 0.0, 0.0, 0.0, cp_in));
918 }
919 for (mach, slope, viscous, cp_in) in [
920 (0.5, 7.0, 0.0, 44.0),
921 (1.0, 10.0, 0.03, 49.0),
922 (1.5, 9.0, 0.04, 46.0),
923 ] {
924 rows.push(rasaero_row(mach, 2.0, slope * r2, viscous, cp_in));
925 }
926 for (mach, slope, viscous, cp_in) in [(0.5, 7.0, 0.0, 44.0), (1.0, 10.0, 0.12, 48.0)] {
927 rows.push(rasaero_row(mach, 4.0, slope * r4, viscous, cp_in));
928 }
929 rows.join("\r\n")
930 }
931
932 fn close(got: f64, want: f64, rel: f64, what: &str) {
933 assert!(
934 (got - want).abs() <= rel * want.abs().max(1e-300),
935 "{what}: {got} against {want}"
936 );
937 }
938
939 #[test]
940 fn reads_a_rasaero_export_by_angle_of_attack() {
941 let table = NormalForceTable::from_rasaero_csv(&small_export()).unwrap();
942 let (r2, r4) = (2.0_f64.to_radians(), 4.0_f64.to_radians());
943 let alphas: Vec<f64> = table.columns().iter().map(|c| c.alpha_rad).collect();
944 assert_eq!(alphas, [0.0, r2, r4]);
945 assert_eq!(table.columns()[2].slope_per_rad.xs(), [0.5, 1.0]);
946 assert_eq!(table.reference(), TableReference::LargestBody);
947 close(
949 table.columns()[0].slope_per_rad.ys()[1],
950 10.0,
951 1e-15,
952 "0°, Mach 1",
953 );
954 close(
955 table.columns()[1].slope_per_rad.ys()[1],
956 10.0 + 0.03 / r2,
957 1e-15,
958 "2°, Mach 1",
959 );
960 let inch = 0.0254;
962 assert_eq!(table.columns()[0].cp_station_m.ys()[1], 50.0 * inch);
963
964 let at = |mach, alpha: f64| table.lookup(mach, alpha).unwrap();
965 let knot = at(1.0, r4);
967 close(knot.coefficient, 10.0 * r4 + 0.12, 1e-15, "CN at 4°");
968 assert_eq!(knot.cp_station_m, 48.0 * inch);
969 assert_eq!((knot.mach_extrapolated, knot.beyond_alpha), (None, false));
970 let s = |alpha: f64| 10.0 + 0.03 * alpha / (r2 * r2);
973 let three = at(1.0, 3.0_f64.to_radians());
974 close(
975 three.slope_per_rad,
976 s(3.0_f64.to_radians()),
977 1e-14,
978 "C_N/α at 3°",
979 );
980 close(three.cp_station_m, 48.5 * inch, 1e-14, "CP at 3°");
981 let one = at(1.0, 1.0_f64.to_radians());
982 close(
983 one.slope_per_rad,
984 s(1.0_f64.to_radians()),
985 1e-14,
986 "C_N/α at 1°",
987 );
988 close(one.cp_station_m, 49.5 * inch, 1e-14, "CP at 1°");
989 let zero = at(1.0, 0.0);
991 assert_eq!((zero.coefficient, zero.slope_per_rad), (0.0, 10.0));
992 close(at(0.75, 0.0).slope_per_rad, 8.5, 1e-15, "Mach 0.75");
994
995 let ten_rad = 10.0_f64.to_radians();
998 let s1 = ten_rad.sin() / r4.sin();
999 let (linear, rest) = (10.0 * r4, 0.12);
1000 let rest_moment = (linear + rest) * 48.0 * inch - linear * 50.0 * inch;
1001 let ten = at(1.0, ten_rad);
1002 let force = linear * s1 + rest * s1 * s1;
1003 close(ten.coefficient, force, 1e-14, "C_N at 10°");
1004 close(
1005 ten.cp_station_m,
1006 (linear * 50.0 * inch * s1 + rest_moment * s1 * s1) / force,
1007 1e-14,
1008 "CP at 10°",
1009 );
1010 assert!(ten.beyond_alpha);
1011 assert!(ten.cp_station_m < 48.0 * inch);
1013 let just = at(1.0, r4 * (1.0 + 1e-9));
1015 close(just.coefficient, knot.coefficient, 1e-8, "C_N just past 4°");
1016 close(
1017 just.cp_station_m,
1018 knot.cp_station_m,
1019 1e-8,
1020 "CP just past 4°",
1021 );
1022 assert!(at(1.0, PI).coefficient.abs() < 1e-14);
1024 let fast = at(3.0, r4);
1026 close(fast.coefficient, 10.0 * r4 + 0.12, 1e-15, "Mach 3 at 4°");
1027 assert_eq!(fast.mach_extrapolated, Some(Side::Above));
1028 assert_eq!(at(3.0, r2).cp_station_m, 46.0 * inch);
1029 let sub = at(0.5, ten_rad);
1031 close(sub.coefficient, 7.0 * r4 * s1, 1e-14, "Mach 0.5 at 10°");
1032 close(sub.cp_station_m, 44.0 * inch, 1e-14, "Mach 0.5 CP at 10°");
1033 }
1034
1035 #[test]
1036 fn rasaero_errors_name_the_line() {
1037 let line = |text: &str| match NormalForceTable::from_rasaero_csv(text) {
1038 Err(AeroError::Csv { line, .. }) => line,
1039 other => panic!("expected a CSV error, got {other:?}"),
1040 };
1041 let with = |rows: &[String]| format!("{RASAERO_HEADER}\n{}", rows.join("\n"));
1042 let row = |mach, alpha| rasaero_row(mach, alpha, 0.1 * alpha, 0.0, 60.0);
1043 assert_eq!(line(""), 0);
1044 assert_eq!(line("Mach,Alpha,CN,CP\n0.1,2,0.2,60\n"), 1);
1045 assert_eq!(line(&with(&[row(0.1, 0.0), row(0.2, 0.0)])), 0);
1046 let missing = [row(0.1, 0.0), row(0.2, 0.0), row(0.1, 2.0), row(0.3, 2.0)];
1048 assert_eq!(line(&with(&missing)), 3);
1049 assert_eq!(line(&with(&[row(0.2, 2.0), row(0.1, 2.0)])), 3);
1050 assert_eq!(line(&with(&[row(0.1, 2.0), row(0.1, 90.0)])), 3);
1051 assert_eq!(line(&with(&[row(0.1, -2.0)])), 2);
1052 assert_eq!(line(&with(&[row(0.1, 2.0), "0.2,2,x".to_owned()])), 3);
1053 let text_in_unused = row(0.2, 2.0).replacen(",9.9,", ",n/a,", 1);
1055 let table =
1056 NormalForceTable::from_rasaero_csv(&with(&[row(0.1, 2.0), text_in_unused])).unwrap();
1057 assert_eq!(table.columns()[0].slope_per_rad.xs(), [0.1, 0.2]);
1058 let nan = rasaero_row(0.2, 2.0, f64::NAN, 0.0, 60.0);
1059 assert_eq!(line(&with(&[row(0.1, 2.0), nan])), 3);
1060 let table = NormalForceTable::from_rasaero_csv(&with(&[
1062 row(0.1, 2.0),
1063 row(0.1, 2.0),
1064 row(0.2, 2.0),
1065 ]))
1066 .unwrap();
1067 assert_eq!(table.columns()[0].slope_per_rad.xs(), [0.1, 0.2]);
1068 assert_eq!(
1069 line(&with(&[row(0.1, 2.0), row(0.2, 2.0), row(0.1, 4.0)])),
1070 4
1071 );
1072 }
1073
1074 #[test]
1075 fn normal_force_tables_check_their_columns_and_round_trip() {
1076 let flat = |value| {
1077 Table1D::new(
1078 vec![0.0, 2.0],
1079 vec![value, value],
1080 Interpolation::Linear,
1081 Extrapolation::Clamp,
1082 )
1083 .unwrap()
1084 };
1085 let column = |alpha| NormalForceColumn::new(alpha, flat(6.0), flat(1.5));
1086 let domain = |result: Result<NormalForceTable, AeroError>| {
1087 assert!(
1088 matches!(result, Err(AeroError::Domain { .. })),
1089 "{result:?}"
1090 );
1091 };
1092 domain(NormalForceTable::new(Vec::new()));
1093 domain(NormalForceTable::new(vec![column(0.1), column(0.1)]));
1094 domain(NormalForceTable::new(vec![column(0.1), column(0.05)]));
1095 domain(NormalForceTable::new(vec![column(-0.1)]));
1096 domain(NormalForceTable::new(vec![column(FRAC_PI_2)]));
1097 domain(NormalForceTable::new(vec![column(f64::NAN)]));
1098 let table = NormalForceTable::new(vec![column(0.0), column(0.1)]).unwrap();
1099 domain(table.clone().with_reference_diameter_m(0.0));
1100 domain(table.clone().with_reference_diameter_m(f64::INFINITY));
1101 let table = table.with_reference_diameter_m(0.1).unwrap();
1102 assert_eq!(
1103 table.reference(),
1104 TableReference::Diameter { diameter_m: 0.1 }
1105 );
1106 let json = serde_json::to_string(&table).unwrap();
1107 assert_eq!(
1108 serde_json::from_str::<NormalForceTable>(&json).unwrap(),
1109 table
1110 );
1111 let reversed = json.replacen("\"alpha_rad\":0.1", "\"alpha_rad\":-0.1", 1);
1113 assert!(serde_json::from_str::<NormalForceTable>(&reversed).is_err());
1114 for (mach, alpha) in [(-0.1, 0.0), (f64::NAN, 0.0), (0.5, -0.01), (0.5, 3.2)] {
1115 assert!(matches!(
1116 table.lookup(mach, alpha),
1117 Err(AeroError::Domain { .. })
1118 ));
1119 }
1120 let single = NormalForceTable::new(vec![column(0.0)]).unwrap();
1122 let lookup = single.lookup(1.0, 0.3).unwrap();
1123 assert_eq!(lookup.coefficient, 6.0 * 0.3_f64.sin());
1124 assert!(lookup.beyond_alpha);
1125 assert_eq!(single.lookup(7.0, 0.0).unwrap().slope_per_rad, 6.0);
1127 }
1128
1129 fn flat(value: f64) -> Table1D {
1130 Table1D::new(
1131 vec![0.0, 2.0],
1132 vec![value, value],
1133 Interpolation::Linear,
1134 Extrapolation::Clamp,
1135 )
1136 .unwrap()
1137 }
1138
1139 #[test]
1143 fn a_rasaero_shaped_table_continues_its_crossflow_exactly() {
1144 let (a, x_a, b, x_b) = (9.0, 1.6, 4.0, 1.1);
1145 let (r2, r4) = (2.0_f64.to_radians(), 4.0_f64.to_radians());
1146 let force = |alpha: f64| a * alpha + b * alpha.sin().powi(2);
1147 let cp = |alpha: f64| (a * alpha * x_a + b * alpha.sin().powi(2) * x_b) / force(alpha);
1148 let column =
1149 |alpha: f64, slope, center| NormalForceColumn::new(alpha, flat(slope), flat(center));
1150 let table = NormalForceTable::new(vec![
1151 column(0.0, a, x_a),
1152 column(r2, force(r2) / r2, cp(r2)),
1153 column(r4, force(r4) / r4, cp(r4)),
1154 ])
1155 .unwrap();
1156 for alpha_deg in [10.0_f64, 30.0, 60.0, 90.0, 150.0] {
1157 let alpha = alpha_deg.to_radians();
1158 let s1 = alpha.sin() / r4.sin();
1159 let (linear, viscous) = (a * r4 * s1, b * alpha.sin().powi(2));
1160 let lookup = table.lookup(1.0, alpha).unwrap();
1161 close(lookup.coefficient, linear + viscous, 1e-12, "C_N");
1162 close(
1163 lookup.cp_station_m,
1164 (linear * x_a + viscous * x_b) / (linear + viscous),
1165 1e-12,
1166 "CP",
1167 );
1168 }
1169 }
1170
1171 #[test]
1175 fn the_continuation_does_not_jump_where_the_rest_s_moment_changes_sign() {
1176 let r4 = 4.0_f64.to_radians();
1177 let line = |a: f64, b: f64| {
1178 Table1D::new(
1179 vec![0.0, 1.0],
1180 vec![a, b],
1181 Interpolation::Linear,
1182 Extrapolation::Clamp,
1183 )
1184 .unwrap()
1185 };
1186 let table = NormalForceTable::new(vec![
1187 NormalForceColumn::new(0.0, flat(10.0), flat(1.0)),
1188 NormalForceColumn::new(r4, flat(11.0), line(0.92, 0.90)),
1189 ])
1190 .unwrap();
1191 let alpha = 30.0_f64.to_radians();
1192 let at = |mach: f64| table.lookup_within(mach, alpha, (0.0, 1.3)).unwrap();
1193 let mut previous = at(0.4);
1194 for step in 1..=2000 {
1195 let next = at(0.4 + f64::from(step) * 1e-4);
1196 assert!(
1197 (next.coefficient - previous.coefficient).abs() < 1e-3,
1198 "{previous:?} {next:?}"
1199 );
1200 let moment = |l: &NormalForceLookup| l.coefficient * l.cp_station_m;
1201 assert!((moment(&next) - moment(&previous)).abs() < 1e-3);
1202 previous = next;
1203 }
1204 for stations in [(1.3, 0.0), (f64::NAN, 1.3), (0.0, f64::INFINITY)] {
1206 assert!(matches!(
1207 table.lookup_within(0.5, alpha, stations),
1208 Err(AeroError::Domain { .. })
1209 ));
1210 }
1211 }
1212
1213 proptest::proptest! {
1214 #[test]
1218 fn the_continuation_keeps_its_sign_and_center(
1219 slope_0 in 0.5..20.0_f64,
1220 slope_n in 0.5..20.0_f64,
1221 cp_0 in 0.1..3.0_f64,
1222 cp_n in 0.1..3.0_f64,
1223 alpha_n_deg in 1.0..40.0_f64,
1224 beyond in 0.0..1.0_f64,
1225 ) {
1226 let alpha_n = alpha_n_deg.to_radians();
1227 let table = NormalForceTable::new(vec![
1228 NormalForceColumn::new(0.0, flat(slope_0), flat(cp_0)),
1229 NormalForceColumn::new(alpha_n, flat(slope_n), flat(cp_n)),
1230 ])
1231 .unwrap();
1232 let alpha = alpha_n + beyond * (PI - alpha_n);
1233 let lookup = table.lookup_within(1.0, alpha, (0.0, 3.0)).unwrap();
1234 proptest::prop_assert!(lookup.coefficient >= -1e-13, "{lookup:?}");
1236 if alpha.sin() >= alpha_n.sin() {
1237 proptest::prop_assert!(
1238 (-1e-9..=3.0 + 1e-9).contains(&lookup.cp_station_m),
1239 "{lookup:?}"
1240 );
1241 }
1242 }
1243
1244 #[test]
1248 fn the_continuation_is_continuous_in_mach(
1249 slope_0 in 5.0..15.0_f64,
1250 cp_0 in 0.8..1.2_f64,
1251 slope_change in -0.2..0.2_f64,
1252 cp_change in -0.1..0.1_f64,
1253 cp_offset in -0.1..0.1_f64,
1254 alpha_deg in 5.0..170.0_f64,
1255 ) {
1256 let r4 = 4.0_f64.to_radians();
1257 let line = |a: f64, b: f64| {
1258 Table1D::new(vec![0.0, 1.0], vec![a, b], Interpolation::Linear, Extrapolation::Clamp)
1259 .unwrap()
1260 };
1261 let table = NormalForceTable::new(vec![
1262 NormalForceColumn::new(0.0, flat(slope_0), flat(cp_0)),
1263 NormalForceColumn::new(
1264 r4,
1265 line(slope_0 - slope_change, slope_0 + slope_change),
1266 line(cp_0 + cp_offset - cp_change, cp_0 + cp_offset + cp_change),
1267 ),
1268 ])
1269 .unwrap();
1270 let alpha = alpha_deg.to_radians();
1271 let at = |mach: f64| {
1272 let l = table.lookup_within(mach, alpha, (0.0, 1.5)).unwrap();
1273 (l.coefficient, l.coefficient * l.cp_station_m)
1274 };
1275 let mut previous = at(0.0);
1276 for step in 1..=1000 {
1277 let next = at(f64::from(step) * 1e-3);
1278 proptest::prop_assert!((next.0 - previous.0).abs() < 0.05, "{previous:?} {next:?}");
1281 proptest::prop_assert!((next.1 - previous.1).abs() < 0.1, "{previous:?} {next:?}");
1282 previous = next;
1283 }
1284 }
1285 }
1286}