code wiki / (root) / nx_chem_peak_list.nx

nx_chem_peak_list.nx source

↩ module page · 404 lines · 15504 B

1// nx_chem_peak_list.nx -- C5.0 milestone: text-format peak list parser. 2// 3// A lab analyst's LC-MS output (after peak picking) is typically a 4// list of "m/z [intensity] [retention_time]" entries. This module 5// parses that format into a PeakList struct that composes directly 6// with C4.0 adulterant_db_lookup_mh_plus. 7// 8// Supported format (whitespace-separated, '#' comments): 9// 10// # comment line ignored 11// m/z [intensity] [rt_seconds] 12// 280.1826 10000 742.3 13// 102.1274 8500 45.2 14// 195.0875 15000 234.1 15// 166.1223 5000 89.5 16// 17// Decimals are parsed to Q4 (4 fractional digits) for m/z; rt stored 18// as Q3 milliseconds. Intensity stored as raw nx_int. 19// 20// Honest gaps (deferred): 21// - mzML/mzXML parsing (XML-based standard): too complex without 22// full XML parser in substrate; C5.1. 23// - JCAMP-DX (NMR/IR/spectra format): C5.2. 24// - Centroided vs profile-mode discrimination: caller responsibility. 25// - Adduct/charge state assignment heuristics (a la CAMERA): C5.3. 26 27import "nx_chem.nx" 28import "nx_syscalls.nx" 29import "nx_loop.nx" 30 31struct PeakObservation { 32 mz_q4: nx_int, 33 intensity_q4: nx_int, 34 rt_q3: nx_int, 35} 36const NX_PEAK_OBS_BYTES: nx_int = 24 37 38struct PeakList { 39 peaks: *PeakObservation, 40 n: nx_int, 41 cap: nx_int, 42} 43const NX_PEAK_LIST_BYTES: nx_int = 24 44 45// ================================================================= 46// Allocate empty peak list with given capacity. 47// ================================================================= 48func nx_chem_peak_list_new(cap: nx_int) -> *PeakList { 49 let pl: *PeakList = (sys_mmap(NX_PEAK_LIST_BYTES as i64)) as *PeakList 50 pl.peaks = (sys_mmap((cap * NX_PEAK_OBS_BYTES) as i64)) as *PeakObservation 51 pl.n = 0 52 pl.cap = cap 53 return pl 54} 55 56// ================================================================= 57// Append one peak to the list. Returns 0 on success, -1 if full. 58// ================================================================= 59func nx_chem_peak_list_add(pl: *PeakList, mz_q4: nx_int, intensity_q4: nx_int, rt_q3: nx_int) -> nx_int { 60 if pl.n >= pl.cap { return -1 } 61 let p: *PeakObservation = ((pl.peaks as nx_int) + (pl.n * NX_PEAK_OBS_BYTES)) as *PeakObservation 62 p.mz_q4 = mz_q4 63 p.intensity_q4 = intensity_q4 64 p.rt_q3 = rt_q3 65 pl.n = pl.n + 1 66 return 0 67} 68 69// ================================================================= 70// Parse a decimal number starting at buf[offset]. Stores Q4 value 71// in *out_value (4 fractional digits, zero-padded if fewer in input). 72// Returns bytes consumed, or 0 if no digit found. 73// 74// Examples (input -> Q4 output): 75// "280" -> 2800000 (no fractional part; padded) 76// "280.18" -> 2801800 (2 fractional digits; padded with 00) 77// "280.1826" -> 2801826 (exact Q4) 78// "280.18263" -> 2801826 (extra digits truncated) 79// ================================================================= 80func nx_chem_parse_decimal_q4(buf: *u8, offset: nx_int, len: nx_int, out_value: *nx_int) -> nx_int { 81 var i: nx_int = offset 82 var int_part: nx_int = 0 83 var frac_part: nx_int = 0 84 var frac_digits: nx_int = 0 85 var got_digit: nx_int = 0 86 // Integer part -- bound by buffer length. 87 let lp_int: *NxLoopFrame = nx_loop_begin(len + 1) 88 while nx_loop_step(lp_int) == 1 { 89 if i >= len { nx_loop_break(lp_int) } 90 else { 91 let c: nx_int = buf[i] as nx_int 92 if c < 48 { nx_loop_break(lp_int) } 93 else { 94 if c > 57 { nx_loop_break(lp_int) } 95 else { 96 int_part = (int_part * 10) + (c - 48) 97 got_digit = 1 98 i = i + 1 99 } 100 } 101 } 102 } 103 // Fractional part (optional) 104 if i < len { 105 if buf[i] as nx_int == 46 { // '.' 106 i = i + 1 107 // Fractional part -- bound by buffer length. 108 let lp_frac: *NxLoopFrame = nx_loop_begin(len + 1) 109 while nx_loop_step(lp_frac) == 1 { 110 if i >= len { nx_loop_break(lp_frac) } 111 else { 112 let c2: nx_int = buf[i] as nx_int 113 if c2 < 48 { nx_loop_break(lp_frac) } 114 else { 115 if c2 > 57 { nx_loop_break(lp_frac) } 116 else { 117 if frac_digits < 4 { 118 frac_part = (frac_part * 10) + (c2 - 48) 119 frac_digits = frac_digits + 1 120 got_digit = 1 121 } 122 i = i + 1 123 } 124 } 125 } 126 } 127 // Pad to 4 digits 128 while frac_digits < 4 { 129 frac_part = frac_part * 10 130 frac_digits = frac_digits + 1 131 } 132 } 133 } 134 if frac_digits == 0 { 135 frac_part = 0 136 // Multiply integer to Q4 (10000) for output 137 } 138 *out_value = (int_part * 10000) + frac_part 139 if got_digit == 0 { return 0 } 140 return i - offset 141} 142 143// ================================================================= 144// Predicate: is the byte a horizontal whitespace char (space, tab, 145// or comma)? C5.5: comma added so the same parser handles BOTH 146// TSV (whitespace-separated) and CSV (comma-separated) peak files 147// transparently. Real-world LC-MS exports use both formats. 148// ================================================================= 149func nx_chem_is_hspace(c: nx_int) -> nx_int { 150 if c == 32 { return 1 } // ' ' 151 if c == 9 { return 1 } // '\t' 152 if c == 44 { return 1 } // ',' (CSV separator) 153 return 0 154} 155 156// ================================================================= 157// Predicate: is the byte a line-terminator (CR or LF)? 158// ================================================================= 159func nx_chem_is_lineterm(c: nx_int) -> nx_int { 160 if c == 10 { return 1 } // '\n' 161 if c == 13 { return 1 } // '\r' 162 return 0 163} 164 165// ================================================================= 166// Parse complete peak-list text into a PeakList struct. 167// Format: 168// - One peak per line 169// - Whitespace-separated fields: m/z [intensity] [rt_seconds] 170// - '#' anywhere starts a comment to end-of-line 171// - Empty lines ignored 172// - rt_seconds is converted to Q3 milliseconds (rt_q3 = rt_q4 / 10) 173// 174// Returns PeakList with parsed entries. 175// ================================================================= 176func nx_chem_peak_list_parse(buf: *u8, len: nx_int) -> *PeakList { 177 let pl: *PeakList = nx_chem_peak_list_new(4096) 178 var i: nx_int = 0 179 while i < len { 180 // Skip leading whitespace + newlines -- bound by buffer length. 181 let lp_ws: *NxLoopFrame = nx_loop_begin(len + 1) 182 while nx_loop_step(lp_ws) == 1 { 183 if i >= len { nx_loop_break(lp_ws) } 184 else { 185 let c: nx_int = buf[i] as nx_int 186 if c == 32 { i = i + 1 } 187 else { if c == 9 { i = i + 1 } 188 else { if c == 10 { i = i + 1 } 189 else { if c == 13 { i = i + 1 } 190 else { nx_loop_break(lp_ws) } } } } 191 } 192 } 193 if i >= len { return pl } 194 // Comment line: skip to EOL 195 if (buf[i] as nx_int) == 35 { // '#' 196 // Skip comment to EOL -- bound by buffer length. 197 let lp_cmt: *NxLoopFrame = nx_loop_begin(len + 1) 198 while nx_loop_step(lp_cmt) == 1 { 199 if i >= len { nx_loop_break(lp_cmt) } 200 else { 201 if nx_chem_is_lineterm(buf[i] as nx_int) == 1 { nx_loop_break(lp_cmt) } 202 else { i = i + 1 } 203 } 204 } 205 } 206 else { 207 // Parse m/z (mandatory) 208 var mz_q4: nx_int = 0 209 let n1: nx_int = nx_chem_parse_decimal_q4(buf, i, len, &mz_q4) 210 if n1 == 0 { 211 // unparseable -- skip to EOL, bound by buffer length. 212 let lp_skip: *NxLoopFrame = nx_loop_begin(len + 1) 213 while nx_loop_step(lp_skip) == 1 { 214 if i >= len { nx_loop_break(lp_skip) } 215 else { 216 if nx_chem_is_lineterm(buf[i] as nx_int) == 1 { nx_loop_break(lp_skip) } 217 else { i = i + 1 } 218 } 219 } 220 } 221 else { 222 i = i + n1 223 // Skip horizontal whitespace 224 var done_hs1: nx_int = 0 225 while done_hs1 == 0 { 226 if i >= len { done_hs1 = 1 } 227 else { 228 if nx_chem_is_hspace(buf[i] as nx_int) == 1 { i = i + 1 } 229 else { done_hs1 = 1 } 230 } 231 } 232 // Parse intensity (optional) 233 var intensity_q4: nx_int = 0 234 if i < len { 235 if nx_chem_is_lineterm(buf[i] as nx_int) == 0 { 236 if (buf[i] as nx_int) != 35 { 237 let n2: nx_int = nx_chem_parse_decimal_q4(buf, i, len, &intensity_q4) 238 if n2 > 0 { i = i + n2 } 239 } 240 } 241 } 242 // Skip horizontal whitespace 243 var done_hs2: nx_int = 0 244 while done_hs2 == 0 { 245 if i >= len { done_hs2 = 1 } 246 else { 247 if nx_chem_is_hspace(buf[i] as nx_int) == 1 { i = i + 1 } 248 else { done_hs2 = 1 } 249 } 250 } 251 // Parse rt_seconds (optional) 252 var rt_q4: nx_int = 0 253 if i < len { 254 if nx_chem_is_lineterm(buf[i] as nx_int) == 0 { 255 if (buf[i] as nx_int) != 35 { 256 let n3: nx_int = nx_chem_parse_decimal_q4(buf, i, len, &rt_q4) 257 if n3 > 0 { i = i + n3 } 258 } 259 } 260 } 261 let rt_q3: nx_int = rt_q4 / 10 262 let _add: nx_int = nx_chem_peak_list_add(pl, mz_q4, intensity_q4, rt_q3) 263 // Skip rest of line -- bound by buffer length. 264 let lp_eol: *NxLoopFrame = nx_loop_begin(len + 1) 265 while nx_loop_step(lp_eol) == 1 { 266 if i >= len { nx_loop_break(lp_eol) } 267 else { 268 if nx_chem_is_lineterm(buf[i] as nx_int) == 1 { nx_loop_break(lp_eol) } 269 else { i = i + 1 } 270 } 271 } 272 } 273 } 274 } 275 return pl 276} 277 278// ================================================================= 279// Read peak list from a text file on disk. 280// Composes sys_read_file (runtime/nx_syscalls.nx) + peak_list_parse. 281// 282// Returns an empty PeakList (n=0) if file missing or empty. The 283// caller should check pl.n == 0 to distinguish "no peaks found" from 284// successful parse-zero-peaks. 285// 286// This is the entry point a real lab analyst uses: write peak text 287// to a known path, run the substrate binary, get a verdict report. 288// ================================================================= 289func nx_chem_peak_list_read_file(path: *u8) -> *PeakList { 290 var len_out: i64 = 0 291 let buf: *u8 = sys_read_file(path, &len_out) 292 if (buf as nx_int) == 0 { 293 // file missing -- return empty list 294 return nx_chem_peak_list_new(8) 295 } 296 if len_out <= 0 { 297 return nx_chem_peak_list_new(8) 298 } 299 return nx_chem_peak_list_parse(buf, len_out as nx_int) 300} 301 302// ================================================================= 303// Write a buffer to disk. Creates / truncates the file. 304// Returns total bytes written, or -1 on error. 305// ================================================================= 306func nx_chem_write_file(path: *u8, buf: *u8, len: nx_int) -> nx_int { 307 let fd: i64 = sys_openat_wr(path, 420) // 0644 = 0o644 = 420 decimal 308 if fd < 0 { return -1 } 309 let n: i64 = sys_write(fd, buf, len as i64) 310 let _c: i64 = sys_close(fd) 311 return n as nx_int 312} 313 314// ================================================================= 315// PREVENT helper for NishiLang's literal-path syscall quirk. 316// 317// QUIRK: Passing a string-literal *u8 directly to sys_openat (via 318// sys_read_file / sys_openat_rd) silently fails -- the kernel cannot 319// read the path bytes, returning ENOENT even when the file exists. 320// Confirmed via runtime/nx_litpath_quirk_test.nx (commit f3cac8be). 321// 322// FIX: Copy the literal into a mmap'd buffer first. Bytes are 323// identical; syscall semantics differ because the kernel can read 324// from mmap'd memory but not from wherever NishiLang stores string 325// literals. 326// 327// USAGE: 328// let path: *u8 = nx_chem_path_from_literal("examples/foo.csv" as *u8) 329// let buf: *u8 = sys_read_file(path, &len) 330// 331// The substrate enforces this pattern in any code path that hands a 332// LITERAL string to a SYSCALL. Paths coming from argv don't need it 333// (they're already in mmap'd kernel-supplied memory). 334// ================================================================= 335func nx_chem_path_from_literal(literal: *u8) -> *u8 { 336 let buf: *u8 = sys_mmap(512) 337 var i: nx_int = 0 338 // C-string copy -- bound by defensive 4096 max literal length. 339 let lp_lit: *NxLoopFrame = nx_loop_begin(4096) 340 while nx_loop_step(lp_lit) == 1 { 341 let c: nx_int = literal[i] as nx_int 342 if c == 0 { 343 nx_loop_break(lp_lit) 344 } else { 345 buf[i] = literal[i] 346 i = i + 1 347 } 348 } 349 buf[i] = 0 350 return buf 351} 352 353// ================================================================= 354// Extract basename from a path string. Strips: 355// - everything before the last '/' or '\' 356// - the trailing extension (last '.') 357// Writes result into caller-provided buf (null-terminated). 358// Returns length of basename (excluding null terminator). 359// 360// Examples: 361// /tmp/SUSPECT-2026-001.txt -> "SUSPECT-2026-001" 362// sample.csv -> "sample" 363// no_extension -> "no_extension" 364// /trailing/slash/ -> "" (caller should treat as empty) 365// ================================================================= 366func nx_chem_basename(path: *u8, out_buf: *u8) -> nx_int { 367 // Find length of input 368 var path_len: nx_int = 0 369 // Path length scan -- bound by defensive 4096 (PATH_MAX-class). 370 let lp_pl: *NxLoopFrame = nx_loop_begin(4096) 371 while nx_loop_step(lp_pl) == 1 { 372 if path[path_len] as nx_int == 0 { 373 nx_loop_break(lp_pl) 374 } else { 375 path_len = path_len + 1 376 } 377 } 378 // Find last '/' or '\' 379 var start: nx_int = 0 380 var i: nx_int = 0 381 while i < path_len { 382 let c: nx_int = path[i] as nx_int 383 if c == 47 { start = i + 1 } // '/' 384 if c == 92 { start = i + 1 } // '\\' 385 i = i + 1 386 } 387 // Find last '.' after start (extension separator) 388 var end: nx_int = path_len 389 var j: nx_int = start 390 while j < path_len { 391 if path[j] as nx_int == 46 { end = j } // '.' 392 j = j + 1 393 } 394 // Copy [start..end) into out_buf 395 var k: nx_int = 0 396 var src_idx: nx_int = start 397 while src_idx < end { 398 out_buf[k] = path[src_idx] 399 k = k + 1 400 src_idx = src_idx + 1 401 } 402 out_buf[k] = 0 403 return k 404}