koth_ms/lib.rs
1//! koth_ff: A high-performance LC-MS feature finder for timsTOF and mzML data.
2//!
3//! The in-memory detection stages live in the [`koth_core`] crate, which has no
4//! file I/O or native dependencies. They are re-exported here at their original
5//! paths (`koth_ms::hills`, `koth_ms::models`, `koth_ms::config::KothConfig`,
6//! `koth_ms::run_features`, ...); this crate adds the mzML, Bruker `.d` and
7//! Thermo `.raw` readers, the output writers, cross-run alignment and LFQ, and
8//! the `koth_ff` / `koth_align` executables.
9//!
10//! ## Pipeline
11//!
12//! 1. **Hill detection** – groups MS1 peaks across scans into chromatographic hills
13//! 2. **Feature finding** – groups hills into isotope envelopes with charge state assignment
14//! 3. **Scoring** – scores isotope pattern quality using the averagine model
15//!
16//! ## Quick start
17//!
18//! ```no_run
19//! use std::path::Path;
20//! use koth_ms::{run_hills, run_features, run_scoring, config::KothConfig};
21//!
22//! let config = KothConfig::default();
23//! let input = Path::new("data.mzML");
24//!
25//! let spectra = koth_ms::read_spectra(input, &config.file).unwrap();
26//! let hills = run_hills(&spectra, &config.hills, &config.file);
27//! let features = run_features(&hills, &config.features, &config.file).unwrap();
28//! let scored = run_scoring(&features, &config.scoring, &config.features, config.file.polarity);
29//! ```
30
31/// Crate version with the git commit it was built from, e.g. `0.1.0 (a1b2c3d4e5f6)`,
32/// or `0.1.0 (a1b2c3d4e5f6-dirty)` when the working tree had uncommitted changes.
33///
34/// This is what `--version` reports on both binaries, and what the benchmark's
35/// pin check reads. `KOTH_GIT_SHA` is set by `build.rs`; it is `unknown` when the
36/// crate is built from a source archive with no git directory.
37pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("KOTH_GIT_SHA"), ")");
38
39pub mod alignment;
40pub mod config;
41pub mod error;
42pub mod input;
43pub mod io;
44pub mod lfq;
45pub mod output;
46pub mod pipeline;
47
48// The in-memory stages, re-exported from koth-core at their original paths.
49pub use koth_core::{features, hills, mem, models, scoring, stats};
50
51// Stage-by-stage entry points, re-exported from koth-core.
52pub use koth_core::{run_features, run_hills, run_scoring};
53
54// In-process / streaming API surface. Re-exported at the crate root so callers
55// write `koth_ms::run_pipeline` / `koth_ms::PipelineSink` without reaching into
56// the module path.
57pub use pipeline::{
58 group_ms2_hills_by_window, run_pipeline, run_pipeline_from_spectra, run_pipeline_streaming,
59 run_pipeline_streaming_from_spectra, run_pipeline_with_ms2, FeatureFindingOutput,
60 PipelineOptions, PipelineSink,
61};
62
63// Re-export the core in-memory result types at the crate root so a downstream
64// crate (koth_tracer, uno) can name them without depending on the module layout.
65pub use models::{Feature, Hill, IsolationWindow, Polarity, ScoredFeature, Spectrum};
66
67use std::path::Path;
68
69use config::{FileConfig, HillsConfig};
70use error::KothError;
71use koth_core::shuffle_if_decoy;
72
73/// Read MS1 spectra from an mzML file or Bruker .d directory.
74pub fn read_spectra(path: &Path, file: &FileConfig) -> Result<Vec<Spectrum>, KothError> {
75 io::read_spectra(path, file)
76}
77
78/// Stage 1 (streaming): Detect hills from mzML, Bruker `.d`, or Thermo `.raw`
79/// using bounded spectrum buffers. Completed hills are retained for assembly;
80/// native readers also retain scan metadata. Decoy shuffling and optional TIC
81/// normalization still collect spectra. Gzipped mzML is decompressed as it is
82/// parsed, never held whole in memory.
83pub fn run_hills_streaming(
84 path: &Path,
85 config: &HillsConfig,
86 file: &FileConfig,
87) -> Result<Vec<Hill>, KothError> {
88 hills_streaming_inner(path, config, file)
89}
90
91/// Stage 1 (MS2): Detect MS2 hills partitioned by the precursor isolation window
92/// of each MS2 spectrum (DIA channels).
93///
94/// Supported inputs: mzML (`.mzML`/`.mzML.gz`), Bruker **diaPASEF** `.d`
95/// (`--features tdf`), and Thermo **DIA** `.raw` (`--features thermo`). On a
96/// diaPASEF `.d` the fixed isolation windows are read straight from the raw
97/// frames' quadrupole settings and each window segment is IM-collapsed into an
98/// MS2 spectrum (see [`io::bruker::read_bruker_ms2`]). On a DIA `.raw` each MS2
99/// scan becomes one MS2 spectrum stamped with its precursor isolation window
100/// (see `io::thermo::read_thermo_ms2`). ddaPASEF `.d` and DDA `.raw` are
101/// unsupported for MS2 (precursor reconstruction is out of scope): they yield an
102/// empty set plus a warning, leaving MS1 unaffected.
103pub fn run_ms2_hills_streaming(
104 path: &Path,
105 config: &HillsConfig,
106 file: &FileConfig,
107) -> Result<Vec<Hill>, KothError> {
108 let fmt = io::detect_format(path);
109
110 #[cfg(feature = "tdf")]
111 if fmt == io::InputFormat::BrukerD {
112 // Bruker diaPASEF: stream one MS2 spectrum per isolation-window segment
113 // out of the raw `.d`, then group by window exactly as the mzML path
114 // does. ddaPASEF / unknown acquisitions return an empty Vec (with a
115 // warning) from `read_bruker_ms2`, so MS1 is left untouched.
116 let spectra = io::bruker::read_bruker_ms2(path, file)?;
117 if spectra.is_empty() {
118 return Ok(Vec::new());
119 }
120 log::info!(
121 "Streaming MS2 hill detection from {} ({} Bruker diaPASEF MS2 spectra)",
122 path.display(),
123 spectra.len()
124 );
125 return Ok(hills::detect_ms2_hills_from_iter(
126 spectra.into_iter(),
127 config,
128 file,
129 ));
130 }
131
132 #[cfg(feature = "thermo")]
133 if fmt == io::InputFormat::ThermoRaw {
134 // Thermo DIA: one MS2 spectrum per MS2 scan, stamped with its precursor
135 // isolation window, then grouped by window exactly as the mzML path does.
136 // A DDA `.raw` (or one that cannot be confidently classified as DIA)
137 // returns an empty Vec (with a warning) from `read_thermo_ms2`, so MS1 is
138 // left untouched.
139 let spectra = io::thermo::read_thermo_ms2(path)?;
140 if spectra.is_empty() {
141 return Ok(Vec::new());
142 }
143 log::info!(
144 "Streaming MS2 hill detection from {} ({} Thermo DIA MS2 spectra)",
145 path.display(),
146 spectra.len()
147 );
148 return Ok(hills::detect_ms2_hills_from_iter(
149 spectra.into_iter(),
150 config,
151 file,
152 ));
153 }
154
155 if !matches!(fmt, io::InputFormat::Mzml | io::InputFormat::MzmlGz) {
156 log::warn!(
157 "MS2 hill detection supports mzML, Bruker diaPASEF .d, and Thermo DIA .raw \
158 (.raw needs --features thermo) inputs only; skipping '{}'",
159 path.display()
160 );
161 return Ok(Vec::new());
162 }
163 let (iter, status) = io::mzml::stream_mzml_ms2_checked(path)?;
164 log::info!("Streaming MS2 hill detection from {}", path.display());
165 let hills = hills::detect_ms2_hills_from_iter(iter, config, file);
166 status.check()?;
167 Ok(hills)
168}
169
170fn hills_streaming_inner(
171 path: &Path,
172 config: &HillsConfig,
173 file: &FileConfig,
174) -> Result<Vec<Hill>, KothError> {
175 let iter = io::stream_spectra(path, file)?;
176 if file.decoy_mode {
177 // Full-run shuffling deliberately retains its collecting semantics.
178 let spectra = shuffle_if_decoy(iter.collect::<Result<Vec<_>, _>>()?, file);
179 return Ok(hills::detect_hills_from_iter(
180 spectra.into_iter(),
181 config,
182 file,
183 ));
184 }
185
186 log::info!("Streaming MS1 hill detection from {}", path.display());
187 // Bruker already owns a bounded producer. Thermo and mzML are decoded on
188 // this prefetch thread, overlapping I/O with hill detection.
189 let iter: io::SpectrumStream = if io::detect_format(path) == io::InputFormat::BrukerD {
190 iter
191 } else {
192 Box::new(io::prefetch::prefetch(iter, PREFETCH_CAPACITY))
193 };
194 detect_hills_from_results(iter, config, file)
195}
196
197/// Stop on the first reader error and discard partial hills. The detector's
198/// infallible iterator API must never turn an I/O failure into a successful run.
199fn detect_hills_from_results(
200 iter: io::SpectrumStream,
201 config: &HillsConfig,
202 file: &FileConfig,
203) -> Result<Vec<Hill>, KothError> {
204 let mut error = None;
205 let spectra = iter.map_while(|item| match item {
206 Ok(spectrum) => Some(spectrum),
207 Err(e) => {
208 error = Some(e);
209 None
210 }
211 });
212 let hills = hills::detect_hills_from_iter(spectra, config, file);
213 match error {
214 Some(error) => Err(error),
215 None => Ok(hills),
216 }
217}
218
219/// Bounded look-ahead in spectra for the mzML and Thermo reader thread.
220const PREFETCH_CAPACITY: usize = 64;
221
222#[cfg(test)]
223mod ms1_streaming_tests {
224 use super::*;
225
226 #[test]
227 fn reader_error_discards_partial_detection() {
228 let items = vec![
229 Ok(Spectrum {
230 scan_index: 0,
231 retention_time: 1.0,
232 peaks: vec![],
233 ms_level: 1,
234 isolation_window: None,
235 faims_cv: None,
236 }),
237 Err(KothError::ThermoError("decode failure".into())),
238 ];
239 let result = detect_hills_from_results(
240 Box::new(items.into_iter()),
241 &HillsConfig::default(),
242 &FileConfig::default(),
243 );
244 assert!(matches!(result, Err(KothError::ThermoError(_))));
245 }
246}