ballistics_engine/drag_file.rs
1//! Cleanroom parser for the `.drg` drag-curve text file format (MBA-1409).
2//!
3//! `.drg` is a small vendor text format used to distribute Doppler-radar-measured
4//! drag-coefficient-vs-Mach curves for individual bullets (e.g. Lapua's free downloads
5//! for QuickTARGET Unlimited). This module was written from **publicly observable file
6//! structure only** — line layout, field separators, and column semantics inferred by
7//! inspecting one real vendor sample locally for grammar-pinning purposes (see the task
8//! report for structural findings). **No vendor drag-coefficient data is embedded here**:
9//! every fixture in this module's tests is synthetic, invented content.
10//!
11//! Shared by the CLI (`main.rs`) and the WASM terminal (`wasm.rs`); lives in the library
12//! crate — not gated behind any feature — so it compiles for `wasm32-unknown-unknown`.
13//! This module is fs-free: it parses an in-memory `&str`, never touches `std::fs`. File
14//! I/O (reading the path the user gave `--drag-table`, or the WASM text-ingestion glue)
15//! stays in the caller.
16//!
17//! ## Grammar (tolerant superset covering the vendor layout and plain two-column CSV)
18//!
19//! - Zero or more leading non-data lines (title/header/blank/comment) are skipped until
20//! the first line that parses as exactly two finite numbers. The first non-empty
21//! skipped line (trimmed) is captured as [`ParsedDragCurve::name`]; if the very first
22//! line in the file is itself a data row (headerless CSV), `name` is `None`.
23//! - From that point on, every non-blank line must parse as exactly two finite numbers —
24//! garbage after data has started is a hard error, not silently skipped.
25//! - Fields are separated by a run of whitespace (space or tab — real vendor files use
26//! tab) or by a single `,`/`;` delimiter.
27//! - Columns may appear as `(mach, cd)` *or* `(cd, mach)`. Column detection: if exactly
28//! one column is strictly ascending across every row, that column is mach (this is not
29//! merely a defensive fallback — some real vendor files use `(cd, mach)` column order,
30//! so this detection is load-bearing). If *both* columns ascend — e.g. a `(cd, mach)`
31//! file whose cd happens to be monotonic across a subsonic-only deck — the column with
32//! the larger maximum value is mach: mach spans past `1.0` in real decks, while cd
33//! stays well under `1.5`. If the two maxima are within 20% of each other, that's too
34//! close to call, and parsing fails with a dedicated "ambiguous columns" error rather
35//! than silently guessing. If neither column ascends, parsing fails with a dedicated
36//! error naming the problem.
37//! - The decimal separator is `.` only. A line is only flagged as the decimal-comma
38//! error if it splits into exactly two tokens and *both* look like decimal-comma
39//! numbers (e.g. `"0,5 0,3"`); this produces a dedicated error naming the problem,
40//! rather than a generic parse failure or a comma-CSV misread. A line where only one
41//! token looks like a decimal-comma number — e.g. a header line mixing prose and a
42//! comma-formatted number such as `"Density 1,225"` — is not treated as this error; it
43//! is simply skipped like ordinary header text.
44//! - Row count must be in `2..=4096` (`4096` matches `ffi::MAX_FFI_DRAG_TABLE_LEN`, the
45//! cap already enforced on the array-based FFI drag-table entry point).
46//! - `mach` must be finite and `>= 0` (the real vendor sample's first row is exactly
47//! `mach = 0`); `cd` must be finite and `> 0`.
48
49/// The maximum number of `(mach, cd)` rows a `.drg` file may contain.
50/// Matches `ffi::MAX_FFI_DRAG_TABLE_LEN`, the cap already enforced on the array-based FFI
51/// custom-drag-table entry point, so a `.drg` load can never produce a table too large for
52/// that path to accept.
53const MAX_DRG_ROWS: usize = 4096;
54/// The minimum number of rows needed to define a drag curve.
55const MIN_DRG_ROWS: usize = 2;
56
57/// A drag curve parsed from `.drg` text: an optional vendor-supplied name/description and
58/// the `(mach, cd)` points in ascending-mach order.
59#[derive(Debug, Clone, PartialEq)]
60pub struct ParsedDragCurve {
61 pub name: Option<String>,
62 pub points: Vec<(f64, f64)>,
63}
64
65/// How a single line of `.drg` text classifies during parsing.
66enum LineKind {
67 /// Blank (whitespace-only) line: always skippable, before or after data starts.
68 Blank,
69 /// A data row: two finite-parseable tokens, in file column order (not yet assigned
70 /// to mach/cd — that happens once every row has been collected).
71 Row(f64, f64),
72 /// Looks like an attempted data row using a decimal comma (e.g. `"0,5 0,3"`) rather
73 /// than a decimal point: exactly two tokens, *both* comma-decimal-shaped. Always a
74 /// hard error, even during header-skip — unlike a merely-skippable header line that
75 /// happens to contain one comma-formatted number amid prose (only one token would be
76 /// comma-decimal-shaped there, which doesn't qualify).
77 DecimalComma,
78 /// Anything else: header/title/comment text, or (once data has started) garbage.
79 Other,
80}
81
82/// Tries to interpret `tokens` as exactly two numeric fields. Returns `None` if `tokens`
83/// doesn't have exactly two entries (the caller should try a different separator), or if
84/// there aren't clearly two intended numbers at all.
85fn try_two_tokens(tokens: &[&str]) -> Option<LineKind> {
86 if tokens.len() != 2 {
87 return None;
88 }
89 let t0 = tokens[0].trim();
90 let t1 = tokens[1].trim();
91 match (t0.parse::<f64>(), t1.parse::<f64>()) {
92 (Ok(a), Ok(b)) => Some(LineKind::Row(a, b)),
93 _ => {
94 // Both tokens must look like decimal-comma numbers before we call this an
95 // attempted (malformed) data row. If only one token looks comma-decimal-ish
96 // (e.g. a header line mixing prose with a comma-formatted number, such as
97 // "Density 1,225"), that's not a data row at all — leave it as `None` so the
98 // caller falls through to ordinary header/garbage handling instead of a hard
99 // decimal-comma error.
100 if looks_like_comma_decimal(t0) && looks_like_comma_decimal(t1) {
101 Some(LineKind::DecimalComma)
102 } else {
103 None
104 }
105 }
106 }
107}
108
109/// True if `tok` looks like a number written with a decimal comma instead of a decimal
110/// point (e.g. `"0,523"`): no `.` present, exactly one `,`, and swapping that `,` for `.`
111/// makes it parse as `f64`.
112fn looks_like_comma_decimal(tok: &str) -> bool {
113 if tok.is_empty() || tok.contains('.') {
114 return false;
115 }
116 if tok.matches(',').count() != 1 {
117 return false;
118 }
119 tok.replace(',', ".").parse::<f64>().is_ok()
120}
121
122/// Classifies one line of `.drg` text. Tries whitespace-separated fields first (the real
123/// vendor format uses tab), then comma, then semicolon.
124fn classify_line(line: &str) -> LineKind {
125 let trimmed = line.trim();
126 if trimmed.is_empty() {
127 return LineKind::Blank;
128 }
129 let ws_tokens: Vec<&str> = trimmed.split_whitespace().collect();
130 if let Some(kind) = try_two_tokens(&ws_tokens) {
131 return kind;
132 }
133 let comma_tokens: Vec<&str> = trimmed.split(',').map(str::trim).collect();
134 if let Some(kind) = try_two_tokens(&comma_tokens) {
135 return kind;
136 }
137 let semi_tokens: Vec<&str> = trimmed.split(';').map(str::trim).collect();
138 if let Some(kind) = try_two_tokens(&semi_tokens) {
139 return kind;
140 }
141 LineKind::Other
142}
143
144/// True if every consecutive pair in `vals` is strictly increasing.
145fn is_strictly_ascending(vals: &[f64]) -> bool {
146 vals.windows(2).all(|w| w[0] < w[1])
147}
148
149/// Parses `.drg` drag-curve text into a [`ParsedDragCurve`]. See the module docs for the
150/// full tolerance contract. On any violation, returns `Err(String)` naming the offending
151/// line number (1-indexed) and the problem.
152pub fn parse_drg(text: &str) -> Result<ParsedDragCurve, String> {
153 let mut name: Option<String> = None;
154 let mut header_done = false;
155 let mut raw: Vec<(usize, f64, f64)> = Vec::new();
156
157 for (idx, line) in text.lines().enumerate() {
158 let lineno = idx + 1;
159 match classify_line(line) {
160 LineKind::DecimalComma => {
161 return Err(format!(
162 "line {lineno}: numbers appear to use a decimal comma (e.g. \"0,5\"); \
163 .drg files must use a decimal point"
164 ));
165 }
166 LineKind::Row(a, b) => {
167 header_done = true;
168 if raw.len() >= MAX_DRG_ROWS {
169 return Err(format!(
170 "line {lineno}: too many data rows (more than {MAX_DRG_ROWS})"
171 ));
172 }
173 raw.push((lineno, a, b));
174 }
175 LineKind::Blank => {}
176 LineKind::Other => {
177 if header_done {
178 return Err(format!(
179 "line {lineno}: expected two numbers, found {:?}",
180 line.trim()
181 ));
182 }
183 let trimmed = line.trim();
184 if name.is_none() && !trimmed.is_empty() {
185 name = Some(trimmed.to_string());
186 }
187 }
188 }
189 }
190
191 if raw.len() < MIN_DRG_ROWS {
192 return Err(format!(
193 "found {} data row(s); need at least {MIN_DRG_ROWS}",
194 raw.len()
195 ));
196 }
197
198 let col0: Vec<f64> = raw.iter().map(|&(_, a, _)| a).collect();
199 let col1: Vec<f64> = raw.iter().map(|&(_, _, b)| b).collect();
200
201 // Column detection. Real vendor files store (cd, mach) — column 0 is *not* mach there
202 // — so this detection must run for every file, not just as a defensive fallback.
203 let col0_ascends = is_strictly_ascending(&col0);
204 let col1_ascends = is_strictly_ascending(&col1);
205 let mach_is_col0 = match (col0_ascends, col1_ascends) {
206 (true, false) => true,
207 (false, true) => false,
208 (false, false) => {
209 return Err(
210 "neither column is strictly ascending; expected a mach column".to_string(),
211 );
212 }
213 (true, true) => {
214 // Both columns ascend (e.g. a (cd, mach) file whose cd happens to be
215 // monotonic across a subsonic-only deck). Break the tie by magnitude: mach
216 // spans past 1.0 in real decks, cd stays well under ~1.5, so the column with
217 // the larger maximum is mach — unless the two maxima are too close to call.
218 let max0 = col0.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
219 let max1 = col1.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
220 let (larger, smaller) = if max0 >= max1 { (max0, max1) } else { (max1, max0) };
221 if larger > 0.0 && smaller / larger >= 0.8 {
222 return Err(
223 "ambiguous columns: both ascend with similar ranges; cannot determine \
224 which is mach - if you know the order, convert the file to a mach,cd \
225 CSV to bypass detection"
226 .to_string(),
227 );
228 }
229 max0 > max1
230 }
231 };
232
233 let mut points = Vec::with_capacity(raw.len());
234 for &(lineno, a, b) in &raw {
235 let (mach, cd) = if mach_is_col0 { (a, b) } else { (b, a) };
236 if !mach.is_finite() || mach < 0.0 {
237 return Err(format!(
238 "line {lineno}: mach must be finite and >= 0, got {mach}"
239 ));
240 }
241 if !cd.is_finite() || cd <= 0.0 {
242 return Err(format!("line {lineno}: cd must be finite and > 0, got {cd}"));
243 }
244 points.push((mach, cd));
245 }
246
247 Ok(ParsedDragCurve { name, points })
248}
249
250/// Cheap sniff: does `text` look like `.drg` (or headerless two-column CSV of the same
251/// shape)? Finds the first two data rows and checks that one of the two columns is
252/// ascending between them — a bounded scan that does **not** allocate the full points
253/// vector (unlike calling [`parse_drg`] and checking `is_ok()`), so it's cheap to use as
254/// a pre-check before committing to a full parse.
255pub fn looks_like_drg(text: &str) -> bool {
256 let mut first: Option<(f64, f64)> = None;
257 for line in text.lines() {
258 match classify_line(line) {
259 LineKind::Row(a, b) => match first {
260 None => first = Some((a, b)),
261 Some((pa, pb)) => return pa < a || pb < b,
262 },
263 LineKind::DecimalComma => return false,
264 LineKind::Blank | LineKind::Other => {}
265 }
266 }
267 false
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 const SYNTH: &str = "SYN123, Synthetic Test Bullet 7.62mm 10.00g\n\
275 Doppler radar test data (synthetic)\n\
276 0.50 0.230\n\
277 0.80 0.210\n\
278 0.95 0.280\n\
279 1.05 0.450\n\
280 1.50 0.420\n\
281 2.50 0.300\n";
282
283 #[test]
284 fn parses_synthetic_drg_with_header() {
285 let c = parse_drg(SYNTH).unwrap();
286 assert_eq!(c.points.len(), 6);
287 assert_eq!(c.points[0], (0.50, 0.230));
288 assert_eq!(c.points[5], (2.50, 0.300));
289 assert_eq!(c.name.as_deref(), Some("SYN123, Synthetic Test Bullet 7.62mm 10.00g"));
290 }
291
292 #[test]
293 fn rejects_bad_decks() {
294 // descending mach
295 assert!(parse_drg("t\n1.0 0.3\n0.9 0.3\n").is_err());
296 // single row
297 assert!(parse_drg("t\n1.0 0.3\n").is_err());
298 // non-positive cd
299 assert!(parse_drg("t\n0.5 0.0\n1.0 0.3\n").is_err());
300 // garbage after data starts
301 assert!(parse_drg("t\n0.5 0.3\nnot numbers\n1.0 0.3\n").is_err());
302 // decimal commas -> clear error mentioning decimal
303 let e = parse_drg("t\n0,5 0,3\n1,0 0,31\n").unwrap_err();
304 assert!(e.to_lowercase().contains("decimal") || e.to_lowercase().contains("comma"), "{e}");
305 // empty / header-only
306 assert!(parse_drg("").is_err());
307 assert!(parse_drg("just a title\n").is_err());
308 }
309
310 #[test]
311 fn sniff_distinguishes_drg_from_csv_and_junk() {
312 assert!(looks_like_drg(SYNTH));
313 assert!(!looks_like_drg("mach,cd\n")); // header-only csv
314 assert!(!looks_like_drg("hello world\nthis is prose\n"));
315 // a plain two-column csv WITHOUT header is also a valid drg shape — sniffing
316 // may accept it; that is fine (same numbers either way). Just pin the behavior:
317 assert!(looks_like_drg("0.5,0.3\n1.0,0.31\n"));
318 }
319
320 #[test]
321 fn row_cap_enforced() {
322 let mut s = String::from("t\n");
323 for i in 0..4097 {
324 s.push_str(&format!("{} 0.3\n", 0.1 + i as f64 * 0.001));
325 }
326 assert!(parse_drg(&s).is_err());
327 }
328
329 // --- Additional coverage beyond the brief's baseline, informed by the real vendor
330 // sample inspected for this task (structure only; no vendor data reproduced here) ---
331
332 #[test]
333 fn accepts_cd_mach_column_order_like_the_real_vendor_format() {
334 // Exercises column-order detection: some files store (cd, mach) rather than
335 // (mach, cd). Everything here — header text, field values, separators — is
336 // invented for this test; it shares no structure with any vendor file.
337 let synth_cd_mach =
338 "synthetic reversed-column test deck, invented values\r\n\
339 0.230\t0.000\r\n\
340 0.210\t0.400\r\n\
341 0.280\t0.900\r\n\
342 0.450\t1.050\r\n\
343 0.300\t2.500\r\n";
344 let c = parse_drg(synth_cd_mach).unwrap();
345 assert_eq!(c.points.len(), 5);
346 assert_eq!(c.points[0], (0.000, 0.230));
347 assert_eq!(c.points[4], (2.500, 0.300));
348 }
349
350 #[test]
351 fn semicolon_separated_rows_are_accepted() {
352 let c = parse_drg("t\n0.5;0.3\n1.0;0.31\n").unwrap();
353 assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
354 }
355
356 #[test]
357 fn headerless_csv_has_no_name() {
358 let c = parse_drg("0.5,0.3\n1.0,0.31\n").unwrap();
359 assert_eq!(c.name, None);
360 }
361
362 #[test]
363 fn mach_may_start_at_zero() {
364 // The real vendor sample's first row is mach = 0 exactly; confirm the >= 0
365 // (not > 0) tolerance is intentional and doesn't reject this.
366 let c = parse_drg("t\n0.0 0.3\n1.0 0.31\n").unwrap();
367 assert_eq!(c.points[0], (0.0, 0.3));
368 }
369
370 #[test]
371 fn looks_like_drg_is_false_for_empty_and_single_row() {
372 assert!(!looks_like_drg(""));
373 assert!(!looks_like_drg("t\n1.0 0.3\n"));
374 }
375
376 // --- Column tie-break when both columns strictly ascend (MBA-1409 review finding 2) ---
377
378 #[test]
379 fn both_ascend_larger_max_col1_rescues_subsonic_only_cd_mach_deck() {
380 // A (cd, mach) deck limited to the subsonic range can have a strictly ascending
381 // cd column too (col0 here: 0.20 -> 0.30). Column 1 still wins because its
382 // maximum (2.0) is far larger than column 0's (0.30) — mach spans past 1.0,
383 // cd doesn't.
384 let c = parse_drg("t\n0.20 0.5\n0.25 1.0\n0.30 2.0\n").unwrap();
385 assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
386 }
387
388 #[test]
389 fn both_ascend_larger_max_col0_is_mach() {
390 // Ordinary (mach, cd) order where cd also happens to ascend (0.20 -> 0.30).
391 // Column 0 wins because its maximum (2.0) is far larger than column 1's (0.30).
392 let c = parse_drg("t\n0.5 0.20\n1.0 0.25\n2.0 0.30\n").unwrap();
393 assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
394 }
395
396 #[test]
397 fn both_ascend_similar_maxima_is_ambiguous_error() {
398 // Both columns ascend and their maxima (1.0 vs 1.1) are within 20% of each
399 // other -- too close to call which one is mach.
400 let e = parse_drg("t\n0.5 0.6\n0.8 0.9\n1.0 1.1\n").unwrap_err();
401 assert!(e.contains("ambiguous"), "{e}");
402 }
403
404 // --- Header-phase decimal-comma false positive (MBA-1409 review finding 3) ---
405
406 #[test]
407 fn header_line_with_prose_and_comma_number_is_skipped_not_flagged_as_decimal_comma() {
408 // A header/metadata line mixing prose with a comma-formatted number (only one of
409 // its two tokens looks comma-decimal-shaped) must be treated as ordinary
410 // skippable header text, not misdiagnosed as an attempted decimal-comma data row.
411 let c = parse_drg("Density 1,225\n0.5 0.3\n1.0 0.31\n").unwrap();
412 assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
413 assert_eq!(c.name.as_deref(), Some("Density 1,225"));
414 }
415}