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}