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}