Skip to main content

koth_core/config/
file.rs

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