Skip to main content

koth_ms/config/
file.rs

1use serde::{Deserialize, Serialize};
2
3use crate::models::Polarity;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum ToleranceType {
8    Ppm,
9    Da,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ImToleranceType {
15    Relative,
16    Absolute,
17}
18
19/// File-reading and shared tolerance settings.
20///
21/// Tolerances defined here are used by both hill detection and feature finding.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(default, deny_unknown_fields)]
24pub struct FileConfig {
25    /// m/z tolerance (shared by hills and features stages)
26    pub mz_tolerance: f64,
27    /// "ppm" or "da"
28    pub mz_tolerance_type: ToleranceType,
29    /// Ion mobility tolerance (shared by hills and features stages)
30    pub im_tolerance: f64,
31    /// "relative" (fraction of IM value) or "absolute" (1/K0 units)
32    pub im_tolerance_type: ImToleranceType,
33    /// Discard peaks below this m/z. Defaults to 0.0 (no lower bound).
34    #[serde(default = "default_global_min_mz")]
35    pub global_min_mz: f64,
36    /// Discard peaks above this m/z. Defaults to +inf (no upper bound).
37    #[serde(default = "default_global_max_mz")]
38    pub global_max_mz: f64,
39    /// Fraction of total hill intensity to retain (0.0–1.0). 1.0 = keep all.
40    pub intensity_coverage: f64,
41    /// Number of threads (None = use all available CPUs)
42    pub n_threads: Option<usize>,
43    /// Bruker vertical-IM filter: column half-width in TOF-index units.
44    /// The filter scans each TOF column over the IM (scan) axis looking for
45    /// long vertical streaks of signal; this width sets how many TOF indices
46    /// to the left/right of each column are summed into the column profile.
47    #[serde(default = "default_bruker_filter_mz_half_width")]
48    pub bruker_filter_mz_half_width: u32,
49    /// Bruker vertical-IM filter: maximum consecutive empty scans tolerated
50    /// inside a kept run (morphological-close radius). Default 1.
51    #[serde(default = "default_bruker_filter_max_internal_gap")]
52    pub bruker_filter_max_internal_gap: usize,
53    /// Ion polarity of the acquisition, which sets how a neutral mass is
54    /// recovered from an observed m/z: `"positive"` (the default, `M + zH`) or
55    /// `"negative"` (`M − zH`).
56    ///
57    /// Peptides are acquired in positive mode. Nucleic acids are acquired in
58    /// negative mode, and getting this wrong shifts every reported `massCalib`
59    /// by `2·z·1.00728` Da, which is 8 Da on a 4-charged oligonucleotide — large
60    /// enough to defeat any downstream identification. koth does not read the
61    /// polarity out of the file; set it alongside `[features] isotope_model`.
62    #[serde(default)]
63    pub polarity: Polarity,
64    /// Bruker vertical-IM filter: minimum run span (gap-inclusive) in scans
65    /// for a column feature to survive. Default 5.
66    #[serde(default = "default_bruker_filter_min_feature_length")]
67    pub bruker_filter_min_feature_length: usize,
68    /// Bruker vertical-IM filter: per-scan summed-intensity floor for a scan
69    /// to count as "occupied" in the column profile. 0 = keep every nonzero
70    /// scan. Default 0.
71    #[serde(default)]
72    pub bruker_filter_min_window_intensity: u64,
73    /// Bruker vertical-IM filter: total summed intensity over the kept span
74    /// (including sub-threshold cells inside gaps) required to keep a run.
75    /// 0 = no floor. Default 0.
76    #[serde(default)]
77    pub bruker_filter_min_feature_intensity: u64,
78    /// Bruker vertical-IM filter: how many times to re-apply the filter to
79    /// its own survivors. Each pass is strictly more aggressive. Default 1.
80    #[serde(default = "default_bruker_filter_num_iterations")]
81    pub bruker_filter_num_iterations: usize,
82    /// Bruker watershed centroider: nearest-neighbour reach on the scan axis
83    /// (scans). Two points farther apart than this on either axis cannot
84    /// join the same group. Default 10.
85    #[serde(default = "default_bruker_watershed_box_scan")]
86    pub bruker_watershed_box_scan: u32,
87    /// Bruker watershed centroider: nearest-neighbour reach on the TOF axis
88    /// (TOF indices). Default 3.
89    #[serde(default = "default_bruker_watershed_box_mz_idx")]
90    pub bruker_watershed_box_mz_idx: u32,
91    /// Bruker watershed centroider: intensity floor for promoting an orphan
92    /// (point with no in-box neighbour) to a new seed. Below this it is
93    /// dropped without claiming territory. Default 0.
94    #[serde(default)]
95    pub bruker_watershed_min_seed_intensity: u64,
96    /// Bruker watershed centroider: drop final centroids whose summed group
97    /// intensity is below this floor. Default 0.
98    #[serde(default)]
99    pub bruker_watershed_min_centroid_total: u64,
100    /// Bruker watershed centroider: hard cap on how far (in TOF-index units)
101    /// any member of a group can sit from that group's seed. Prevents a
102    /// long follower chain from creeping past the real peak edge. Default 10.
103    #[serde(default = "default_bruker_watershed_max_tof_offset")]
104    pub bruker_watershed_max_tof_offset: u32,
105    /// Per-frame iterative MAD noise filter applied to the centroided peaks
106    /// before the Bruker reader emits a `Spectrum`. Same algorithm as
107    /// `noise_filter_sigma` (median + sigma * 1.4826 * MAD of the noise
108    /// floor, found by iterative sigma-clipping). `None` (default) disables.
109    /// Independent of `noise_filter_sigma`, which runs later on every spectrum
110    /// (from any input format) during hill detection. Typical value: 3.0.
111    #[serde(default)]
112    pub bruker_noise_sigma: Option<f64>,
113    /// 1/K0 scale for Bruker `.d` input. `"calibrated"` (default since 0.10.0)
114    /// converts each scan number with the run's acquisition calibration
115    /// (`TimsCalibration` in `analysis.tdf`), matching Bruker's timsdata SDK,
116    /// DataAnalysis and SDK-based tools. `"linear"` restores the straight line
117    /// between the acquisition-range bounds that koth used up to 0.9.0 (it can
118    /// be off by ~0.03 1/K0, about 2%). The conversion happens per centroid,
119    /// before hills are built. No effect on mzML or Thermo input. The streaming
120    /// path's dnoise MS1 polygon gate (`bruker_ms1_polygon`) uses the same scale.
121    #[serde(default)]
122    pub bruker_mobility_scale: crate::io::tims_calibration::MobilityScale,
123    /// Use the in-process `dnoise` streaming API instead of the local two-stage
124    /// reader. When `true`, each raw frame is run through dnoise's configured
125    /// stages (vertical-IM filter -> optional horizontal halo -> optional MS1
126    /// polygon -> watershed) in a single pass with no denoised `.d` written to
127    /// disk. When `false` (default), the historical local path runs (vertical
128    /// filter + watershed only, no halo/polygon) — this is the paper's validated
129    /// pipeline. This flag selects preprocessing, not spectrum buffering:
130    /// both modes stream MS1 spectra into hill detection.
131    #[serde(default)]
132    pub bruker_streaming: bool,
133    /// Streaming path only: apply dnoise's horizontal-halo filter after the
134    /// vertical filter (removes the weak m/z halo flanking bright ions). Default
135    /// `true` (matches the standalone `dnoise` MS1 pipeline). No effect unless
136    /// `bruker_streaming` is set.
137    #[serde(default = "default_bruker_halo")]
138    pub bruker_halo: bool,
139    /// Streaming halo: drop a peak below this fraction of the off-column box-max
140    /// reference. Default 0.15.
141    #[serde(default = "default_bruker_halo_peak_fraction")]
142    pub bruker_halo_peak_fraction: f64,
143    /// Streaming halo: reference-box half-width along the TOF index. Default 80.
144    #[serde(default = "default_bruker_halo_mz_idx_half_width")]
145    pub bruker_halo_mz_idx_half_width: u32,
146    /// Streaming halo: reference-box half-width along the ion-mobility scan axis.
147    /// Default 2.
148    #[serde(default = "default_bruker_halo_scan_half_width")]
149    pub bruker_halo_scan_half_width: usize,
150    /// Streaming path only: apply dnoise's ddaPASEF MS1 selection-polygon gate,
151    /// dropping MS1 points outside the run's IMS PolygonFilter — the (m/z, 1/K0)
152    /// region the acquisition method restricts precursor selection to. Signal
153    /// outside it was never a fragmentation candidate (background, the
154    /// singly-charged hump, out-of-range ions).
155    ///
156    /// Auto-detected: a no-op on diaPASEF and on any run that stores no polygon,
157    /// so enabling it cannot fail on data that has none. Default `false`, which
158    /// matches dnoise's own default and leaves every existing config's output
159    /// byte-identical. No effect unless `bruker_streaming` is set.
160    #[serde(default)]
161    pub bruker_ms1_polygon: bool,
162    /// Polygon gate: m/z leniency added to each side of the polygon interior, in
163    /// Daltons. Isotopes run to higher m/z, so a pad keeps an edge precursor's
164    /// envelope intact. Default 0.0 (the literal polygon).
165    #[serde(default)]
166    pub bruker_ms1_polygon_mz_pad: f64,
167    /// Polygon gate: ion-mobility leniency added to each side, in 1/K0.
168    /// Default 0.0 (the literal polygon).
169    #[serde(default)]
170    pub bruker_ms1_polygon_im_pad: f64,
171    /// Polygon gate: keep a whole MS1 feature when any of its points lies
172    /// inside the (padded) polygon, instead of gating point by point. Maps onto
173    /// dnoise's `Ms1PolygonParams::overlap`. Default `false`: koth gates point
174    /// by point, because it builds features from these points itself, and
175    /// keeping whole overlapping features can pull in clouds the polygon was
176    /// meant to exclude (such as the singly-charged hump) when the polygon's
177    /// shape is not known. Opt in only after checking the run's polygon.
178    #[serde(default)]
179    pub bruker_ms1_polygon_overlap: bool,
180    /// Polygon gate, overlap mode only: how far, in 1/K0, a kept feature may
181    /// extend beyond the mobility range of its inside points (dnoise's
182    /// `Ms1PolygonParams::overlap_reach`; `0.0` = unlimited). Ignored unless
183    /// `bruker_ms1_polygon_overlap` is set. Default 0.1.
184    #[serde(default = "default_bruker_ms1_polygon_overlap_reach")]
185    pub bruker_ms1_polygon_overlap_reach: f64,
186    /// Per-scan iterative sigma-clipping noise filter. When set, peaks whose
187    /// intensity falls below `median + sigma * (1.4826 * MAD)` of the estimated
188    /// noise floor are discarded before hill detection.
189    /// `None` (default) disables the filter; a typical starting value is 3.0.
190    pub noise_filter_sigma: Option<f64>,
191    /// Decoy mode: shuffle MS1 spectra into a random order before hill and
192    /// feature finding. Destroys the chromatographic structure while preserving
193    /// the per-scan peak distributions, producing a null (decoy) feature set.
194    #[serde(default)]
195    pub decoy_mode: bool,
196    /// If true, additionally detect MS2 hills (one set of hills per precursor
197    /// isolation window) from DIA mzML or diaPASEF inputs. Written to
198    /// `hills_ms2.{ext}`. DDA inputs produce no MS2 hills.
199    #[serde(default)]
200    pub ms2_hills_enabled: bool,
201    /// Enable ID-free isotope-consistency m/z recalibration. A pass-1 feature
202    /// detection collects the signed ppm deviation of every adjacent
203    /// isotope-hill spacing from its theoretical `neutron_mass / z` step and
204    /// bins those residuals over (m/z, RT). Pass 2 shifts the *expected*
205    /// isotope position during chain extension by the learned per-region
206    /// median offset, so isotope hills are searched for at their recalibrated
207    /// location. Skipped in decoy mode (shuffled spectra carry no real signal)
208    /// and when too few isotope-spacing samples are collected. Default off.
209    ///
210    /// When enabled, the fixed isotope-match tolerance is additionally replaced
211    /// with a region-adaptive one derived from the recalibration surface's
212    /// per-region residual spread σ:
213    /// `tol_ppm = clamp(tol_sigma_mult × σ(m/z,RT), tol_floor_ppm, mz_tolerance)`.
214    /// Tightens the search window where the instrument is precise (rejecting
215    /// false isotope matches) and relaxes it — never beyond `mz_tolerance` —
216    /// where it is noisy. Only honoured for ppm tolerances. Default true.
217    #[serde(default = "default_mz_recalibration")]
218    pub mz_recalibration: bool,
219    /// Number of m/z bins in the recalibration surface. Bin extents are
220    /// derived from the observed sample range. Default 20.
221    #[serde(default = "default_mz_recalibration_mz_bins")]
222    pub mz_recalibration_mz_bins: usize,
223    /// Number of RT bins in the recalibration surface. Default 8.
224    #[serde(default = "default_mz_recalibration_rt_bins")]
225    pub mz_recalibration_rt_bins: usize,
226    /// Minimum residual samples a surface cell must hold before its own median
227    /// is trusted; below this the cell falls back to the m/z-marginal median,
228    /// then the global median. Default 50.
229    #[serde(default = "default_mz_recalibration_min_samples")]
230    pub mz_recalibration_min_samples: usize,
231    /// `N` in the region-adaptive tolerance `N × σ`. Default 4.0.
232    #[serde(default = "default_mz_recalibration_tol_sigma_mult")]
233    pub mz_recalibration_tol_sigma_mult: f64,
234    /// Lower bound (ppm) on the region-adaptive tolerance, so a spuriously
235    /// tiny σ in a sparse region can't collapse the window. Default 1.0.
236    #[serde(default = "default_mz_recalibration_tol_floor_ppm")]
237    pub mz_recalibration_tol_floor_ppm: f64,
238}
239
240impl Default for FileConfig {
241    fn default() -> Self {
242        Self {
243            mz_tolerance: 8.0,
244            mz_tolerance_type: ToleranceType::Ppm,
245            im_tolerance: 0.05,
246            im_tolerance_type: ImToleranceType::Relative,
247            global_min_mz: 0.0,
248            global_max_mz: f64::INFINITY,
249            intensity_coverage: 1.0,
250            n_threads: None,
251            bruker_filter_mz_half_width: default_bruker_filter_mz_half_width(),
252            bruker_filter_max_internal_gap: default_bruker_filter_max_internal_gap(),
253            polarity: Polarity::default(),
254            bruker_filter_min_feature_length: default_bruker_filter_min_feature_length(),
255            bruker_filter_min_window_intensity: 0,
256            bruker_filter_min_feature_intensity: 0,
257            bruker_filter_num_iterations: default_bruker_filter_num_iterations(),
258            bruker_watershed_box_scan: default_bruker_watershed_box_scan(),
259            bruker_watershed_box_mz_idx: default_bruker_watershed_box_mz_idx(),
260            bruker_watershed_min_seed_intensity: 0,
261            bruker_watershed_min_centroid_total: 0,
262            bruker_watershed_max_tof_offset: default_bruker_watershed_max_tof_offset(),
263            bruker_noise_sigma: None,
264            bruker_mobility_scale: Default::default(),
265            bruker_streaming: false,
266            bruker_halo: default_bruker_halo(),
267            bruker_halo_peak_fraction: default_bruker_halo_peak_fraction(),
268            bruker_halo_mz_idx_half_width: default_bruker_halo_mz_idx_half_width(),
269            bruker_halo_scan_half_width: default_bruker_halo_scan_half_width(),
270            bruker_ms1_polygon: false,
271            bruker_ms1_polygon_mz_pad: 0.0,
272            bruker_ms1_polygon_im_pad: 0.0,
273            bruker_ms1_polygon_overlap: false,
274            bruker_ms1_polygon_overlap_reach: default_bruker_ms1_polygon_overlap_reach(),
275            noise_filter_sigma: None,
276            decoy_mode: false,
277            ms2_hills_enabled: false,
278            mz_recalibration: default_mz_recalibration(),
279            mz_recalibration_mz_bins: default_mz_recalibration_mz_bins(),
280            mz_recalibration_rt_bins: default_mz_recalibration_rt_bins(),
281            mz_recalibration_min_samples: default_mz_recalibration_min_samples(),
282            mz_recalibration_tol_sigma_mult: default_mz_recalibration_tol_sigma_mult(),
283            mz_recalibration_tol_floor_ppm: default_mz_recalibration_tol_floor_ppm(),
284        }
285    }
286}
287
288fn default_mz_recalibration() -> bool {
289    true
290}
291
292fn default_mz_recalibration_mz_bins() -> usize {
293    20
294}
295
296fn default_mz_recalibration_rt_bins() -> usize {
297    8
298}
299
300fn default_mz_recalibration_min_samples() -> usize {
301    50
302}
303
304fn default_mz_recalibration_tol_sigma_mult() -> f64 {
305    // Validated by a joint sweep on both benchmark platforms (Orbitrap
306    // PXD003881, timsTOF PXD070049): recall peaks at 5.0 on Orbitrap and at
307    // 3.0 on Bruker, with CV flat across the whole range on both. 4.0 is the
308    // balanced single default — within ~0.2pp of recall of either
309    // platform's own optimum, at no precision cost.
310    4.0
311}
312
313fn default_mz_recalibration_tol_floor_ppm() -> f64 {
314    1.0
315}
316
317fn default_global_min_mz() -> f64 {
318    0.0
319}
320
321fn default_global_max_mz() -> f64 {
322    f64::INFINITY
323}
324
325fn default_bruker_filter_mz_half_width() -> u32 {
326    2
327}
328
329fn default_bruker_filter_max_internal_gap() -> usize {
330    1
331}
332
333fn default_bruker_filter_min_feature_length() -> usize {
334    5
335}
336
337fn default_bruker_filter_num_iterations() -> usize {
338    1
339}
340
341fn default_bruker_watershed_box_scan() -> u32 {
342    10
343}
344
345fn default_bruker_watershed_box_mz_idx() -> u32 {
346    3
347}
348
349fn default_bruker_watershed_max_tof_offset() -> u32 {
350    10
351}
352
353fn default_bruker_halo() -> bool {
354    true
355}
356
357fn default_bruker_halo_peak_fraction() -> f64 {
358    0.15
359}
360
361fn default_bruker_halo_mz_idx_half_width() -> u32 {
362    80
363}
364
365fn default_bruker_halo_scan_half_width() -> usize {
366    2
367}
368
369fn default_bruker_ms1_polygon_overlap_reach() -> f64 {
370    0.1
371}