nx_supplement_batch.nx source
↩ module page · 126 lines · 5140 B
1// nx_supplement_batch.nx -- C5.4 + C4.4: batch CLI with user-extensible
2// compound DB + RT calibration + multi-file processing.
3//
4// USAGE (3 positional args, then peak files):
5// nx_supplement_batch <compounds-or-dash> <cal-or-dash> <peak1> [peak2] ...
6//
7// compounds-or-dash: path to user-defined compound TSV file, or "-"
8// to use only the 24-compound built-in seed DB.
9// Format (one per line, '#' comments):
10// id|name|SMILES|reg_code|citation
11// reg_code: 0=UNK 1=APPROVED 2=RESTRICTED 3=BANNED 4=RX
12//
13// cal-or-dash: path to RT calibration CSV, or "-" for mass-only.
14// Format (per C7.1):
15// # id ref_rt_seconds tolerance_seconds
16// 4 740.0 30.0
17//
18// peak1, peak2, ...: one or more peak text files to process.
19//
20// EXAMPLES:
21// # Built-in DB, no RT cal, 3 samples
22// $ nx_supplement_batch - - lot_A.txt lot_B.txt lot_C.txt
23//
24// # Built-in DB + RT cal, 3 samples
25// $ nx_supplement_batch - rt_cal.csv lot_A.txt lot_B.txt lot_C.txt
26//
27// # Lab's extended DB + RT cal, 50 samples
28// $ nx_supplement_batch lab_compounds.tsv rt_cal.csv samples/*.txt
29//
30// EXIT CODE:
31// Total banned detections summed across all samples.
32//
33// Pipe straight to LIMS:
34// $ nx_supplement_batch - - samples/*.txt | \
35// jq -r 'select(.verdict.recommendation=="REJECT") | .sample_id'
36
37import "nx_chem.nx"
38import "nx_chem_molecule.nx"
39import "nx_chem_smiles.nx"
40import "nx_chem_periodic.nx"
41import "nx_chem_valence.nx"
42import "nx_chem_mass.nx"
43import "nx_chem_isotope_pattern.nx"
44import "nx_chem_adulterant_db.nx"
45import "nx_chem_peak_list.nx"
46import "nx_chem_report_json.nx"
47
48// Process one sample: read peak file, emit JSON+newline to stdout,
49// return banned-count for the verdict aggregation.
50func process_one_sample(db: *AdulterantDB, peak_path: *u8) -> nx_int {
51 let pl: *PeakList = nx_chem_peak_list_read_file(peak_path)
52 if pl.n == 0 {
53 // Empty/missing file -- emit a minimal error JSON and skip
54 let err_buf: *u8 = sys_mmap(256)
55 var w: nx_int = 0
56 w = w + nx_chem_emit_str(err_buf, w, "{\"sample_id\":" as *u8)
57 let sid: *u8 = sys_mmap(256)
58 let _b1: nx_int = nx_chem_basename(peak_path, sid)
59 w = w + nx_chem_emit_str_escaped(err_buf, w, sid)
60 w = w + nx_chem_emit_str(err_buf, w, ",\"error\":\"empty or unreadable peak file\"}\n" as *u8)
61 let _w: i64 = sys_write(1, err_buf, w as i64)
62 return 0
63 }
64 let sample_id: *u8 = sys_mmap(256)
65 let _bn: nx_int = nx_chem_basename(peak_path, sample_id)
66 let buf: *u8 = sys_mmap(32768)
67 let n_json: nx_int = nx_chem_emit_report_json(buf, pl, db, sample_id, 5)
68 let _w1: i64 = sys_write(1, buf, n_json as i64)
69 let nl: *u8 = sys_mmap(8); nl[0] = 0x0A
70 let _w2: i64 = sys_write(1, nl, 1)
71 // Count banned via re-walk (same as nx_supplement_check)
72 var n_banned: nx_int = 0
73 var pi: nx_int = 0
74 while pi < pl.n {
75 let p: *PeakObservation = ((pl.peaks as nx_int) + (pi * NX_PEAK_OBS_BYTES)) as *PeakObservation
76 let outs: *MatchResult = (sys_mmap((4 * NX_MATCH_RESULT_BYTES) as i64)) as *MatchResult
77 let nm: nx_int = nx_chem_adulterant_db_lookup_with_rt(db, p.mz_q4, p.rt_q3, 5, outs, 4)
78 var mi: nx_int = 0
79 while mi < nm {
80 let rr: *MatchResult = ((outs as nx_int) + (mi * NX_MATCH_RESULT_BYTES)) as *MatchResult
81 let ee: *AdulterantEntry = nx_chem_adulterant_entry_by_id(db, rr.entry_id)
82 if ee.regulatory == NX_REG_BANNED { n_banned = n_banned + 1 }
83 mi = mi + 1
84 }
85 pi = pi + 1
86 }
87 return n_banned
88}
89
90// Treat argv string as "dash sentinel" if it's exactly "-" (single byte).
91// Returns 1 if argv is "-", 0 otherwise (real path).
92func is_dash_sentinel(p: *u8) -> nx_int {
93 if p[0] as nx_int != 45 { return 0 } // not '-'
94 if p[1] as nx_int != 0 { return 0 } // multi-char string starting with '-'
95 return 1
96}
97
98func main(argc: i64, argv: *i64) -> i64 {
99 if argc < 4 {
100 let msg: *u8 = "nx_supplement_batch: usage: <compounds-or-dash> <cal-or-dash> <peak1> [peak2] ...\n" as *u8
101 var n: i64 = 0
102 while msg[n] != 0 { n = n + 1 }
103 let _w: i64 = sys_write(2, msg, n)
104 return 1
105 }
106 let db: *AdulterantDB = nx_chem_adulterant_db_seed()
107 // argv[1]: compound DB extension (or "-" to skip)
108 let cmp_path: *u8 = argv[1] as *u8
109 if is_dash_sentinel(cmp_path) == 0 {
110 let _nc: nx_int = nx_chem_load_compounds_from_file(db, cmp_path)
111 }
112 // argv[2]: RT calibration file (or "-" to skip)
113 let cal_path: *u8 = argv[2] as *u8
114 if is_dash_sentinel(cal_path) == 0 {
115 let _nr: nx_int = nx_chem_load_rt_calibration(db, cal_path)
116 }
117 var total_banned: i64 = 0
118 var i: i64 = 3
119 while i < argc {
120 let peak_path: *u8 = argv[i] as *u8
121 let b: nx_int = process_one_sample(db, peak_path)
122 total_banned = total_banned + (b as i64)
123 i = i + 1
124 }
125 return total_banned
126}