const MAX_DRG_ROWS: usize = 4096;
const MIN_DRG_ROWS: usize = 2;
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedDragCurve {
pub name: Option<String>,
pub points: Vec<(f64, f64)>,
}
enum LineKind {
Blank,
Row(f64, f64),
DecimalComma,
Other,
}
fn try_two_tokens(tokens: &[&str]) -> Option<LineKind> {
if tokens.len() != 2 {
return None;
}
let t0 = tokens[0].trim();
let t1 = tokens[1].trim();
match (t0.parse::<f64>(), t1.parse::<f64>()) {
(Ok(a), Ok(b)) => Some(LineKind::Row(a, b)),
_ => {
if looks_like_comma_decimal(t0) && looks_like_comma_decimal(t1) {
Some(LineKind::DecimalComma)
} else {
None
}
}
}
}
fn looks_like_comma_decimal(tok: &str) -> bool {
if tok.is_empty() || tok.contains('.') {
return false;
}
if tok.matches(',').count() != 1 {
return false;
}
tok.replace(',', ".").parse::<f64>().is_ok()
}
fn classify_line(line: &str) -> LineKind {
let trimmed = line.trim();
if trimmed.is_empty() {
return LineKind::Blank;
}
let ws_tokens: Vec<&str> = trimmed.split_whitespace().collect();
if let Some(kind) = try_two_tokens(&ws_tokens) {
return kind;
}
let comma_tokens: Vec<&str> = trimmed.split(',').map(str::trim).collect();
if let Some(kind) = try_two_tokens(&comma_tokens) {
return kind;
}
let semi_tokens: Vec<&str> = trimmed.split(';').map(str::trim).collect();
if let Some(kind) = try_two_tokens(&semi_tokens) {
return kind;
}
LineKind::Other
}
fn is_strictly_ascending(vals: &[f64]) -> bool {
vals.windows(2).all(|w| w[0] < w[1])
}
pub fn parse_drg(text: &str) -> Result<ParsedDragCurve, String> {
let mut name: Option<String> = None;
let mut header_done = false;
let mut raw: Vec<(usize, f64, f64)> = Vec::new();
for (idx, line) in text.lines().enumerate() {
let lineno = idx + 1;
match classify_line(line) {
LineKind::DecimalComma => {
return Err(format!(
"line {lineno}: numbers appear to use a decimal comma (e.g. \"0,5\"); \
.drg files must use a decimal point"
));
}
LineKind::Row(a, b) => {
header_done = true;
if raw.len() >= MAX_DRG_ROWS {
return Err(format!(
"line {lineno}: too many data rows (more than {MAX_DRG_ROWS})"
));
}
raw.push((lineno, a, b));
}
LineKind::Blank => {}
LineKind::Other => {
if header_done {
return Err(format!(
"line {lineno}: expected two numbers, found {:?}",
line.trim()
));
}
let trimmed = line.trim();
if name.is_none() && !trimmed.is_empty() {
name = Some(trimmed.to_string());
}
}
}
}
if raw.len() < MIN_DRG_ROWS {
return Err(format!(
"found {} data row(s); need at least {MIN_DRG_ROWS}",
raw.len()
));
}
let col0: Vec<f64> = raw.iter().map(|&(_, a, _)| a).collect();
let col1: Vec<f64> = raw.iter().map(|&(_, _, b)| b).collect();
let col0_ascends = is_strictly_ascending(&col0);
let col1_ascends = is_strictly_ascending(&col1);
let mach_is_col0 = match (col0_ascends, col1_ascends) {
(true, false) => true,
(false, true) => false,
(false, false) => {
return Err(
"neither column is strictly ascending; expected a mach column".to_string(),
);
}
(true, true) => {
let max0 = col0.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let max1 = col1.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let (larger, smaller) = if max0 >= max1 { (max0, max1) } else { (max1, max0) };
if larger > 0.0 && smaller / larger >= 0.8 {
return Err(
"ambiguous columns: both ascend with similar ranges; cannot determine \
which is mach - if you know the order, convert the file to a mach,cd \
CSV to bypass detection"
.to_string(),
);
}
max0 > max1
}
};
let mut points = Vec::with_capacity(raw.len());
for &(lineno, a, b) in &raw {
let (mach, cd) = if mach_is_col0 { (a, b) } else { (b, a) };
if !mach.is_finite() || mach < 0.0 {
return Err(format!(
"line {lineno}: mach must be finite and >= 0, got {mach}"
));
}
if !cd.is_finite() || cd <= 0.0 {
return Err(format!("line {lineno}: cd must be finite and > 0, got {cd}"));
}
points.push((mach, cd));
}
Ok(ParsedDragCurve { name, points })
}
pub fn looks_like_drg(text: &str) -> bool {
let mut first: Option<(f64, f64)> = None;
for line in text.lines() {
match classify_line(line) {
LineKind::Row(a, b) => match first {
None => first = Some((a, b)),
Some((pa, pb)) => return pa < a || pb < b,
},
LineKind::DecimalComma => return false,
LineKind::Blank | LineKind::Other => {}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const SYNTH: &str = "SYN123, Synthetic Test Bullet 7.62mm 10.00g\n\
Doppler radar test data (synthetic)\n\
0.50 0.230\n\
0.80 0.210\n\
0.95 0.280\n\
1.05 0.450\n\
1.50 0.420\n\
2.50 0.300\n";
#[test]
fn parses_synthetic_drg_with_header() {
let c = parse_drg(SYNTH).unwrap();
assert_eq!(c.points.len(), 6);
assert_eq!(c.points[0], (0.50, 0.230));
assert_eq!(c.points[5], (2.50, 0.300));
assert_eq!(c.name.as_deref(), Some("SYN123, Synthetic Test Bullet 7.62mm 10.00g"));
}
#[test]
fn rejects_bad_decks() {
assert!(parse_drg("t\n1.0 0.3\n0.9 0.3\n").is_err());
assert!(parse_drg("t\n1.0 0.3\n").is_err());
assert!(parse_drg("t\n0.5 0.0\n1.0 0.3\n").is_err());
assert!(parse_drg("t\n0.5 0.3\nnot numbers\n1.0 0.3\n").is_err());
let e = parse_drg("t\n0,5 0,3\n1,0 0,31\n").unwrap_err();
assert!(e.to_lowercase().contains("decimal") || e.to_lowercase().contains("comma"), "{e}");
assert!(parse_drg("").is_err());
assert!(parse_drg("just a title\n").is_err());
}
#[test]
fn sniff_distinguishes_drg_from_csv_and_junk() {
assert!(looks_like_drg(SYNTH));
assert!(!looks_like_drg("mach,cd\n")); assert!(!looks_like_drg("hello world\nthis is prose\n"));
assert!(looks_like_drg("0.5,0.3\n1.0,0.31\n"));
}
#[test]
fn row_cap_enforced() {
let mut s = String::from("t\n");
for i in 0..4097 {
s.push_str(&format!("{} 0.3\n", 0.1 + i as f64 * 0.001));
}
assert!(parse_drg(&s).is_err());
}
#[test]
fn accepts_cd_mach_column_order_like_the_real_vendor_format() {
let synth_cd_mach =
"synthetic reversed-column test deck, invented values\r\n\
0.230\t0.000\r\n\
0.210\t0.400\r\n\
0.280\t0.900\r\n\
0.450\t1.050\r\n\
0.300\t2.500\r\n";
let c = parse_drg(synth_cd_mach).unwrap();
assert_eq!(c.points.len(), 5);
assert_eq!(c.points[0], (0.000, 0.230));
assert_eq!(c.points[4], (2.500, 0.300));
}
#[test]
fn semicolon_separated_rows_are_accepted() {
let c = parse_drg("t\n0.5;0.3\n1.0;0.31\n").unwrap();
assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
}
#[test]
fn headerless_csv_has_no_name() {
let c = parse_drg("0.5,0.3\n1.0,0.31\n").unwrap();
assert_eq!(c.name, None);
}
#[test]
fn mach_may_start_at_zero() {
let c = parse_drg("t\n0.0 0.3\n1.0 0.31\n").unwrap();
assert_eq!(c.points[0], (0.0, 0.3));
}
#[test]
fn looks_like_drg_is_false_for_empty_and_single_row() {
assert!(!looks_like_drg(""));
assert!(!looks_like_drg("t\n1.0 0.3\n"));
}
#[test]
fn both_ascend_larger_max_col1_rescues_subsonic_only_cd_mach_deck() {
let c = parse_drg("t\n0.20 0.5\n0.25 1.0\n0.30 2.0\n").unwrap();
assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
}
#[test]
fn both_ascend_larger_max_col0_is_mach() {
let c = parse_drg("t\n0.5 0.20\n1.0 0.25\n2.0 0.30\n").unwrap();
assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
}
#[test]
fn both_ascend_similar_maxima_is_ambiguous_error() {
let e = parse_drg("t\n0.5 0.6\n0.8 0.9\n1.0 1.1\n").unwrap_err();
assert!(e.contains("ambiguous"), "{e}");
}
#[test]
fn header_line_with_prose_and_comma_number_is_skipped_not_flagged_as_decimal_comma() {
let c = parse_drg("Density 1,225\n0.5 0.3\n1.0 0.31\n").unwrap();
assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
assert_eq!(c.name.as_deref(), Some("Density 1,225"));
}
}