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("neither column is strictly ascending; expected a mach column".to_string());
210 }
211 (true, true) => {
212 // Both columns ascend (e.g. a (cd, mach) file whose cd happens to be
213 // monotonic across a subsonic-only deck). Break the tie by magnitude: mach
214 // spans past 1.0 in real decks, cd stays well under ~1.5, so the column with
215 // the larger maximum is mach — unless the two maxima are too close to call.
216 let max0 = col0.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
217 let max1 = col1.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
218 let (larger, smaller) = if max0 >= max1 {
219 (max0, max1)
220 } else {
221 (max1, max0)
222 };
223 if larger > 0.0 && smaller / larger >= 0.8 {
224 return Err(
225 "ambiguous columns: both ascend with similar ranges; cannot determine \
226 which is mach - if you know the order, convert the file to a mach,cd \
227 CSV to bypass detection"
228 .to_string(),
229 );
230 }
231 max0 > max1
232 }
233 };
234
235 let mut points = Vec::with_capacity(raw.len());
236 for &(lineno, a, b) in &raw {
237 let (mach, cd) = if mach_is_col0 { (a, b) } else { (b, a) };
238 if !mach.is_finite() || mach < 0.0 {
239 return Err(format!(
240 "line {lineno}: mach must be finite and >= 0, got {mach}"
241 ));
242 }
243 if !cd.is_finite() || cd <= 0.0 {
244 return Err(format!(
245 "line {lineno}: cd must be finite and > 0, got {cd}"
246 ));
247 }
248 points.push((mach, cd));
249 }
250
251 Ok(ParsedDragCurve { name, points })
252}
253
254/// Cheap sniff: does `text` look like `.drg` (or headerless two-column CSV of the same
255/// shape)? Finds the first two data rows and checks that one of the two columns is
256/// ascending between them — a bounded scan that does **not** allocate the full points
257/// vector (unlike calling [`parse_drg`] and checking `is_ok()`), so it's cheap to use as
258/// a pre-check before committing to a full parse.
259pub fn looks_like_drg(text: &str) -> bool {
260 let mut first: Option<(f64, f64)> = None;
261 for line in text.lines() {
262 match classify_line(line) {
263 LineKind::Row(a, b) => match first {
264 None => first = Some((a, b)),
265 Some((pa, pb)) => return pa < a || pb < b,
266 },
267 LineKind::DecimalComma => return false,
268 LineKind::Blank | LineKind::Other => {}
269 }
270 }
271 false
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 const SYNTH: &str = "SYN123, Synthetic Test Bullet 7.62mm 10.00g\n\
279 Doppler radar test data (synthetic)\n\
280 0.50 0.230\n\
281 0.80 0.210\n\
282 0.95 0.280\n\
283 1.05 0.450\n\
284 1.50 0.420\n\
285 2.50 0.300\n";
286
287 #[test]
288 fn parses_synthetic_drg_with_header() {
289 let c = parse_drg(SYNTH).unwrap();
290 assert_eq!(c.points.len(), 6);
291 assert_eq!(c.points[0], (0.50, 0.230));
292 assert_eq!(c.points[5], (2.50, 0.300));
293 assert_eq!(
294 c.name.as_deref(),
295 Some("SYN123, Synthetic Test Bullet 7.62mm 10.00g")
296 );
297 }
298
299 #[test]
300 fn rejects_bad_decks() {
301 // descending mach
302 assert!(parse_drg("t\n1.0 0.3\n0.9 0.3\n").is_err());
303 // single row
304 assert!(parse_drg("t\n1.0 0.3\n").is_err());
305 // non-positive cd
306 assert!(parse_drg("t\n0.5 0.0\n1.0 0.3\n").is_err());
307 // garbage after data starts
308 assert!(parse_drg("t\n0.5 0.3\nnot numbers\n1.0 0.3\n").is_err());
309 // decimal commas -> clear error mentioning decimal
310 let e = parse_drg("t\n0,5 0,3\n1,0 0,31\n").unwrap_err();
311 assert!(
312 e.to_lowercase().contains("decimal") || e.to_lowercase().contains("comma"),
313 "{e}"
314 );
315 // empty / header-only
316 assert!(parse_drg("").is_err());
317 assert!(parse_drg("just a title\n").is_err());
318 }
319
320 #[test]
321 fn sniff_distinguishes_drg_from_csv_and_junk() {
322 assert!(looks_like_drg(SYNTH));
323 assert!(!looks_like_drg("mach,cd\n")); // header-only csv
324 assert!(!looks_like_drg("hello world\nthis is prose\n"));
325 // a plain two-column csv WITHOUT header is also a valid drg shape — sniffing
326 // may accept it; that is fine (same numbers either way). Just pin the behavior:
327 assert!(looks_like_drg("0.5,0.3\n1.0,0.31\n"));
328 }
329
330 #[test]
331 fn row_cap_enforced() {
332 let mut s = String::from("t\n");
333 for i in 0..4097 {
334 s.push_str(&format!("{} 0.3\n", 0.1 + i as f64 * 0.001));
335 }
336 assert!(parse_drg(&s).is_err());
337 }
338
339 // --- Additional coverage beyond the brief's baseline, informed by the real vendor
340 // sample inspected for this task (structure only; no vendor data reproduced here) ---
341
342 #[test]
343 fn accepts_cd_mach_column_order_like_the_real_vendor_format() {
344 // Exercises column-order detection: some files store (cd, mach) rather than
345 // (mach, cd). Everything here — header text, field values, separators — is
346 // invented for this test; it shares no structure with any vendor file.
347 let synth_cd_mach = "synthetic reversed-column test deck, invented values\r\n\
348 0.230\t0.000\r\n\
349 0.210\t0.400\r\n\
350 0.280\t0.900\r\n\
351 0.450\t1.050\r\n\
352 0.300\t2.500\r\n";
353 let c = parse_drg(synth_cd_mach).unwrap();
354 assert_eq!(c.points.len(), 5);
355 assert_eq!(c.points[0], (0.000, 0.230));
356 assert_eq!(c.points[4], (2.500, 0.300));
357 }
358
359 #[test]
360 fn semicolon_separated_rows_are_accepted() {
361 let c = parse_drg("t\n0.5;0.3\n1.0;0.31\n").unwrap();
362 assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
363 }
364
365 #[test]
366 fn headerless_csv_has_no_name() {
367 let c = parse_drg("0.5,0.3\n1.0,0.31\n").unwrap();
368 assert_eq!(c.name, None);
369 }
370
371 #[test]
372 fn mach_may_start_at_zero() {
373 // The real vendor sample's first row is mach = 0 exactly; confirm the >= 0
374 // (not > 0) tolerance is intentional and doesn't reject this.
375 let c = parse_drg("t\n0.0 0.3\n1.0 0.31\n").unwrap();
376 assert_eq!(c.points[0], (0.0, 0.3));
377 }
378
379 #[test]
380 fn looks_like_drg_is_false_for_empty_and_single_row() {
381 assert!(!looks_like_drg(""));
382 assert!(!looks_like_drg("t\n1.0 0.3\n"));
383 }
384
385 // --- Column tie-break when both columns strictly ascend (MBA-1409 review finding 2) ---
386
387 #[test]
388 fn both_ascend_larger_max_col1_rescues_subsonic_only_cd_mach_deck() {
389 // A (cd, mach) deck limited to the subsonic range can have a strictly ascending
390 // cd column too (col0 here: 0.20 -> 0.30). Column 1 still wins because its
391 // maximum (2.0) is far larger than column 0's (0.30) — mach spans past 1.0,
392 // cd doesn't.
393 let c = parse_drg("t\n0.20 0.5\n0.25 1.0\n0.30 2.0\n").unwrap();
394 assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
395 }
396
397 #[test]
398 fn both_ascend_larger_max_col0_is_mach() {
399 // Ordinary (mach, cd) order where cd also happens to ascend (0.20 -> 0.30).
400 // Column 0 wins because its maximum (2.0) is far larger than column 1's (0.30).
401 let c = parse_drg("t\n0.5 0.20\n1.0 0.25\n2.0 0.30\n").unwrap();
402 assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
403 }
404
405 #[test]
406 fn both_ascend_similar_maxima_is_ambiguous_error() {
407 // Both columns ascend and their maxima (1.0 vs 1.1) are within 20% of each
408 // other -- too close to call which one is mach.
409 let e = parse_drg("t\n0.5 0.6\n0.8 0.9\n1.0 1.1\n").unwrap_err();
410 assert!(e.contains("ambiguous"), "{e}");
411 }
412
413 // --- Header-phase decimal-comma false positive (MBA-1409 review finding 3) ---
414
415 #[test]
416 fn header_line_with_prose_and_comma_number_is_skipped_not_flagged_as_decimal_comma() {
417 // A header/metadata line mixing prose with a comma-formatted number (only one of
418 // its two tokens looks comma-decimal-shaped) must be treated as ordinary
419 // skippable header text, not misdiagnosed as an attempted decimal-comma data row.
420 let c = parse_drg("Density 1,225\n0.5 0.3\n1.0 0.31\n").unwrap();
421 assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
422 assert_eq!(c.name.as_deref(), Some("Density 1,225"));
423 }
424}