1use anyhow::{anyhow, Context, Result};
14use std::path::Path;
15
16pub const MATRIX_EVIDENCE_COLUMNS: [&str; 9] = [
20 "matrix_confident_fraction",
21 "matrix_confident_direct_fraction",
22 "matrix_confident_mbr_fraction",
23 "matrix_median_log_confidence",
24 "matrix_median_isotope_pattern",
25 "matrix_median_coelution",
26 "matrix_median_isotopes",
27 "matrix_median_abs_ppm",
28 "matrix_median_abs_rt",
29];
30
31#[derive(Debug, Clone, PartialEq)]
34pub struct Feature {
35 pub mass: f64,
37 pub mz: f64,
39 pub charge: u8,
40 pub rt: f64,
42 pub im: Option<f64>,
44 pub combined_score: f64,
47 pub n_contributing_runs: u32,
50 pub n_isotopes: Option<f64>,
53 pub n_scans: Option<f64>,
54 pub isoerror: Option<f64>,
58 pub isoerror2: Option<f64>,
59 pub rt_width: Option<f64>,
60 pub cosine_score: Option<f64>,
64 pub isotope_score: Option<f64>,
65 pub ppm_error: Option<f64>,
66 pub neutron_offset: Option<f64>,
67 pub intensities: Vec<f64>,
70}
71
72#[derive(Debug, Clone)]
74pub struct FeatureMatrix {
75 pub run_names: Vec<String>,
77 pub features: Vec<Feature>,
78 pub matrix_evidence: Option<Vec<[f32; 9]>>,
82 pub joint_evidence: Option<Vec<Vec<crate::joint::Observation>>>,
85 pub joint_cell_evidence: Option<Vec<[f32; 12]>>,
87}
88
89impl FeatureMatrix {
90 pub fn len(&self) -> usize {
91 self.features.len()
92 }
93 pub fn is_empty(&self) -> bool {
94 self.features.is_empty()
95 }
96 pub fn n_samples(&self) -> usize {
98 self.run_names.len()
99 }
100
101 pub fn load(features_path: impl AsRef<Path>) -> Result<Self> {
103 let (intensity_name, rows) =
104 read_features(features_path.as_ref()).with_context(|| "reading feature file")?;
105 let features = rows
106 .into_iter()
107 .map(MatrixRow::into_feature)
108 .collect::<Vec<_>>();
109 Ok(FeatureMatrix {
110 run_names: vec![intensity_name],
111 features,
112 matrix_evidence: None,
113 joint_evidence: None,
114 joint_cell_evidence: None,
115 })
116 }
117
118 pub fn load_matrix(path: impl AsRef<Path>, min_runs: u32) -> Result<Self> {
138 let path = path.as_ref();
139 let mut rdr = csv::ReaderBuilder::new()
140 .delimiter(b'\t')
141 .flexible(false)
142 .from_path(path)
143 .with_context(|| format!("opening {}", path.display()))?;
144 let header = rdr.headers()?.clone();
145 let col = |name: &str| -> Result<usize> {
146 header
147 .iter()
148 .position(|h| h == name)
149 .ok_or_else(|| anyhow!("aligned matrix missing column `{name}`"))
150 };
151 let (c_mass, c_mz, c_charge, c_rt) = (
152 col("massCalib")?,
153 col("mz")?,
154 col("charge")?,
155 col("rtApex")?,
156 );
157 let c_im = header.iter().position(|h| h == "im");
158 let c_cs = header.iter().position(|h| h == "combined_score");
159 let c_ncr = col("n_contributing_runs")?;
160 let evidence_cols: Vec<Option<usize>> = MATRIX_EVIDENCE_COLUMNS
161 .iter()
162 .map(|name| header.iter().position(|h| h == *name))
163 .collect();
164 let has_evidence = evidence_cols.iter().any(Option::is_some);
165 if has_evidence && evidence_cols.iter().any(|c| c.is_none_or(|c| c >= c_ncr)) {
166 return Err(anyhow!(
167 "matrix evidence requires all nine named columns before n_contributing_runs"
168 ));
169 }
170 let mut matrix_evidence = has_evidence.then(Vec::new);
171 let first_run = c_ncr + 1;
173 if first_run >= header.len() {
174 return Err(anyhow!("aligned matrix has no run intensity columns"));
175 }
176 let run_names: Vec<String> = header
177 .iter()
178 .skip(first_run)
179 .map(|s| s.to_string())
180 .collect();
181
182 let parse = |rec: &csv::StringRecord, c: usize, what: &str, line: usize| -> Result<f64> {
183 rec.get(c)
184 .unwrap_or("")
185 .parse::<f64>()
186 .map_err(|_| anyhow!("line {line}: cannot parse {what}"))
187 };
188 let mut features = Vec::new();
189 for (n, rec) in rdr.records().enumerate() {
190 let line = n + 2;
191 let rec = rec.with_context(|| format!("reading record at line {line}"))?;
192 let mut evidence = [0.0f32; 9];
193 if has_evidence {
194 for (i, c) in evidence_cols.iter().enumerate() {
195 let value = parse(&rec, c.unwrap(), MATRIX_EVIDENCE_COLUMNS[i], line)?;
196 if !value.is_finite()
197 || value < 0.0
198 || value > f32::MAX as f64
199 || (matches!(i, 0 | 1 | 2 | 4 | 5) && value > 1.0)
200 {
201 return Err(anyhow!(
202 "line {line}: invalid matrix evidence {}",
203 MATRIX_EVIDENCE_COLUMNS[i]
204 ));
205 }
206 evidence[i] = value as f32;
207 }
208 }
209 let intensities: Vec<f64> = (first_run..header.len())
210 .map(|c| {
211 rec.get(c)
212 .unwrap_or("0")
213 .trim()
214 .parse::<f64>()
215 .unwrap_or(0.0)
216 })
217 .collect();
218 let n_detected = rec
225 .get(c_ncr)
226 .and_then(|s| s.trim().parse::<u32>().ok())
227 .unwrap_or_else(|| intensities.iter().filter(|&&x| x > 0.0).count() as u32);
228 if n_detected < min_runs {
229 continue;
230 }
231 if let Some(values) = &mut matrix_evidence {
232 values.push(evidence);
233 }
234 let im = c_im
235 .and_then(|c| rec.get(c))
236 .map(str::trim)
237 .filter(|s| !s.is_empty())
238 .and_then(|s| s.parse::<f64>().ok());
239 let combined_score = c_cs
240 .and_then(|c| rec.get(c))
241 .and_then(|s| s.trim().parse::<f64>().ok())
242 .unwrap_or(1.0);
243 features.push(Feature {
244 mass: parse(&rec, c_mass, "massCalib", line)?,
245 mz: parse(&rec, c_mz, "mz", line)?,
246 charge: parse(&rec, c_charge, "charge", line)? as u8,
247 rt: parse(&rec, c_rt, "rtApex", line)?,
248 im,
249 combined_score,
250 n_contributing_runs: n_detected.max(1),
251 n_isotopes: None,
252 n_scans: None,
253 isoerror: None,
254 isoerror2: None,
255 rt_width: None,
256 cosine_score: None,
257 isotope_score: None,
258 ppm_error: None,
259 neutron_offset: None,
260 intensities,
261 });
262 }
263 Ok(FeatureMatrix {
264 run_names,
265 features,
266 matrix_evidence,
267 joint_evidence: None,
268 joint_cell_evidence: None,
269 })
270 }
271}
272
273struct MatrixRow {
275 mass: f64,
276 mz: f64,
277 charge: u8,
278 rt: f64,
279 im: Option<f64>,
280 combined_score: f64,
281 n_isotopes: Option<f64>,
282 n_scans: Option<f64>,
283 isoerror: Option<f64>,
284 isoerror2: Option<f64>,
285 rt_width: Option<f64>,
286 cosine_score: Option<f64>,
287 isotope_score: Option<f64>,
288 ppm_error: Option<f64>,
289 neutron_offset: Option<f64>,
290 intensity: f64,
291}
292
293impl MatrixRow {
294 fn into_feature(self) -> Feature {
295 Feature {
296 mass: self.mass,
297 mz: self.mz,
298 charge: self.charge,
299 rt: self.rt,
300 im: self.im,
301 combined_score: self.combined_score,
302 n_contributing_runs: 1,
303 n_isotopes: self.n_isotopes,
304 n_scans: self.n_scans,
305 isoerror: self.isoerror,
306 isoerror2: self.isoerror2,
307 rt_width: self.rt_width,
308 cosine_score: self.cosine_score,
309 isotope_score: self.isotope_score,
310 ppm_error: self.ppm_error,
311 neutron_offset: self.neutron_offset,
312 intensities: vec![self.intensity],
313 }
314 }
315}
316
317struct Layout {
319 mass: usize,
320 mz: usize,
321 charge: usize,
322 rt: usize,
323 im: Option<usize>,
324 combined_score: Option<usize>,
325 n_isotopes: Option<usize>,
326 n_scans: Option<usize>,
327 isoerror: Option<usize>,
328 isoerror2: Option<usize>,
329 rt_width: Option<usize>,
330 cosine_score: Option<usize>,
331 isotope_score: Option<usize>,
332 ppm_error: Option<usize>,
333 neutron_offset: Option<usize>,
334 intensity: Option<usize>,
335 intensity_name: String,
336}
337
338impl Layout {
339 fn from_header(header: &csv::StringRecord) -> Result<Self> {
340 let find = |name: &str| -> Result<usize> {
341 header
342 .iter()
343 .position(|h| h == name)
344 .ok_or_else(|| anyhow!("missing required column `{name}` in feature-file header"))
345 };
346 let find_any = |names: &[&str]| -> Option<usize> {
348 names
349 .iter()
350 .find_map(|&n| header.iter().position(|h| h == n))
351 };
352
353 let (intensity, intensity_name) =
355 match find_any(&["intensityApex", "intensitySum", "intensity"]) {
356 Some(i) => (Some(i), header.get(i).unwrap_or("intensity").to_string()),
357 None => (None, "intensity".to_string()),
358 };
359
360 Ok(Layout {
361 mass: find("massCalib")?,
362 mz: find("mz")?,
363 charge: find("charge")?,
364 rt: find("rtApex")?,
365 im: find_any(&["im", "ion_mobility", "FAIMS"]),
366 combined_score: find_any(&["combined_score"]),
367 n_isotopes: find_any(&["n_isotopes", "nIsotopes"]),
368 n_scans: find_any(&["n_scans", "nScans"]),
369 isoerror: find_any(&["isoerror"]),
370 isoerror2: find_any(&["isoerror2"]),
371 rt_width: find_any(&["rt_width"]),
372 cosine_score: find_any(&["cosine_score"]),
373 isotope_score: find_any(&["isotope_score"]),
374 ppm_error: find_any(&["ppm_error"]),
375 neutron_offset: find_any(&["neutron_offset"]),
376 intensity,
377 intensity_name,
378 })
379 }
380}
381
382fn read_features(path: &Path) -> Result<(String, Vec<MatrixRow>)> {
384 let mut rdr = csv::ReaderBuilder::new()
385 .delimiter(b'\t')
386 .flexible(false)
387 .from_path(path)
388 .with_context(|| format!("opening {}", path.display()))?;
389
390 let header = rdr.headers()?.clone();
391 let layout = Layout::from_header(&header)?;
392
393 let parse_f64 = |rec: &csv::StringRecord, col: usize, what: &str, line: usize| -> Result<f64> {
394 let s = rec.get(col).unwrap_or("");
395 s.parse::<f64>()
396 .map_err(|_| anyhow!("line {line}: cannot parse {what} `{s}` as number"))
397 };
398
399 let mut rows = Vec::new();
400 for (n, rec) in rdr.records().enumerate() {
401 let line = n + 2; let rec = rec.with_context(|| format!("reading record at line {line}"))?;
403
404 let opt_f64 = |col: Option<usize>| -> Option<f64> {
405 col.and_then(|c| rec.get(c))
406 .map(str::trim)
407 .filter(|s| !s.is_empty())
408 .and_then(|s| s.parse::<f64>().ok())
409 };
410
411 let charge = rec
412 .get(layout.charge)
413 .unwrap_or("")
414 .parse::<u8>()
415 .map_err(|_| anyhow!("line {line}: cannot parse charge"))?;
416
417 rows.push(MatrixRow {
418 mass: parse_f64(&rec, layout.mass, "massCalib", line)?,
419 mz: parse_f64(&rec, layout.mz, "mz", line)?,
420 charge,
421 rt: parse_f64(&rec, layout.rt, "rtApex", line)?,
422 im: opt_f64(layout.im),
423 combined_score: opt_f64(layout.combined_score).unwrap_or(1.0),
425 n_isotopes: opt_f64(layout.n_isotopes),
426 n_scans: opt_f64(layout.n_scans),
427 isoerror: opt_f64(layout.isoerror),
428 isoerror2: opt_f64(layout.isoerror2),
429 rt_width: opt_f64(layout.rt_width),
430 cosine_score: opt_f64(layout.cosine_score),
431 isotope_score: opt_f64(layout.isotope_score),
432 ppm_error: opt_f64(layout.ppm_error),
433 neutron_offset: opt_f64(layout.neutron_offset),
434 intensity: opt_f64(layout.intensity).unwrap_or(0.0),
435 });
436 }
437
438 Ok((layout.intensity_name, rows))
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 fn matrix_fixture(evidence: bool, invalid: bool) -> std::path::PathBuf {
446 let stamp = std::time::SystemTime::now()
447 .duration_since(std::time::UNIX_EPOCH)
448 .unwrap()
449 .as_nanos();
450 let path =
451 std::env::temp_dir().join(format!("uno-matrix-{}-{stamp}.tsv", std::process::id()));
452 let extra = if evidence {
453 format!("{}\t", MATRIX_EVIDENCE_COLUMNS.join("\t"))
454 } else {
455 String::new()
456 };
457 let values = if evidence {
458 if invalid {
459 "NaN\t0\t0\t0\t0\t0\t0\t0\t0\t"
460 } else {
461 "0.5\t0.25\t0.25\t2\t0.8\t0.9\t3\t1.2\t0.1\t"
462 }
463 } else {
464 ""
465 };
466 std::fs::write(
467 &path,
468 format!(
469 "massCalib\tmz\tcharge\trtApex\t{extra}n_contributing_runs\tA\tB\n\
470 1000\t501\t2\t10\t{values}1\t100\t0\n\
471 1100\t551\t2\t12\t{values}2\t200\t300\n"
472 ),
473 )
474 .unwrap();
475 path
476 }
477
478 #[test]
479 fn ordinary_matrix_keeps_original_classifier_schema() {
480 let path = matrix_fixture(false, false);
481 let m = FeatureMatrix::load_matrix(&path, 2).unwrap();
482 std::fs::remove_file(path).unwrap();
483 assert_eq!(m.run_names, ["A", "B"]);
484 assert_eq!(m.len(), 1);
485 assert_eq!(m.features[0].intensities, [200.0, 300.0]);
486 assert!(m.matrix_evidence.is_none());
487 }
488
489 #[test]
490 fn matrix_evidence_tracks_filtered_feature_rows() {
491 let path = matrix_fixture(true, false);
492 let m = FeatureMatrix::load_matrix(&path, 2).unwrap();
493 std::fs::remove_file(path).unwrap();
494 assert_eq!(m.run_names, ["A", "B"]);
495 assert_eq!(m.features[0].mass, 1100.0);
496 let values = m.matrix_evidence.unwrap();
497 assert_eq!(values.len(), 1);
498 assert_eq!(values[0], [0.5, 0.25, 0.25, 2.0, 0.8, 0.9, 3.0, 1.2, 0.1]);
499 }
500
501 #[test]
502 fn matrix_evidence_rejects_nonfinite_values_even_in_filtered_rows() {
503 let path = matrix_fixture(true, true);
504 let error = FeatureMatrix::load_matrix(&path, 2).unwrap_err();
505 std::fs::remove_file(path).unwrap();
506 assert!(error.to_string().contains("invalid matrix evidence"));
507 }
508
509 #[test]
510 fn matrix_evidence_rejects_partial_schema() {
511 let path = matrix_fixture(true, false);
512 let text = std::fs::read_to_string(&path)
513 .unwrap()
514 .replace("matrix_median_abs_rt", "unexpected");
515 std::fs::write(&path, text).unwrap();
516 let error = FeatureMatrix::load_matrix(&path, 2).unwrap_err();
517 std::fs::remove_file(path).unwrap();
518 assert!(error.to_string().contains("all nine named columns"));
519 }
520
521 #[test]
522 fn loads_single_run_features() {
523 let Some(path) = crate::test_input("tests/data/09744.features.tsv") else {
524 return;
525 };
526 let m = FeatureMatrix::load(&path).expect("load features");
527 assert_eq!(m.n_samples(), 1, "single-run input");
528 assert!(m.len() > 1000, "expected many features, got {}", m.len());
529
530 let f = &m.features[0];
531 assert!(f.charge >= 1);
532 assert!(f.mass > 0.0);
533 assert_eq!(f.intensities.len(), 1);
534 assert!(
537 f.cosine_score.is_some() || f.isotope_score.is_some(),
538 "single-run features must carry PFM-quality columns"
539 );
540 }
541}