Skip to main content

koth_core/config/
hills.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4#[serde(default, deny_unknown_fields)]
5pub struct HillsConfig {
6    pub min_scans: usize,
7    pub max_gap: usize,
8    pub split_hills: bool,
9    /// Persistence: a split between two adjacent peaks is kept only if the
10    /// valley between them drops to <= this fraction of the SMALLER peak.
11    /// Scale-free and immune to spike inflation. Default 0.60.
12    #[serde(default = "default_split_valley_ratio")]
13    pub split_valley_ratio: f64,
14    /// Persistence: absolute notch-depth floor as a multiple of the estimated
15    /// noise sigma (MAD of the trace's first difference). Rejects shallow noise
16    /// notches that the relative test alone would admit on a low baseline.
17    /// Default 5.0.
18    #[serde(default = "default_split_sigma_mult")]
19    pub split_sigma_mult: f64,
20    /// Persistence: minimum peak height as a fraction of the robust (95th-pct)
21    /// max intensity. Lower catches faint minor co-eluting peaks (e.g. 10:1
22    /// duals); higher rejects baseline bumps. Default 0.10.
23    #[serde(default = "default_split_height_frac")]
24    pub split_height_frac: f64,
25    /// Weight for the intensity LFC term in the hill-candidate distance score.
26    /// 0.0 disables it. Default 0.3; ~0.5 gives intensity consistency roughly
27    /// half the influence of m/z proximity when selecting which peak extends a
28    /// hill.
29    pub lfc_weight: f64,
30    /// Linear-interpolate intensity through internal zero-gap scans during
31    /// hill finalization. Independent of `smoothing_enabled`. Default false.
32    /// NOTE: turning this on can create artificial local maxima that the
33    /// split-hills logic picks up as new peaks — it materially increases
34    /// final hill count on data with many short gaps.
35    #[serde(default)]
36    pub gap_fill_enabled: bool,
37    /// Apply a running-average filter to the intensity profile during hill
38    /// finalization. Independent of `gap_fill_enabled`. Default false.
39    #[serde(default)]
40    pub smoothing_enabled: bool,
41    /// Half-width of the running-average window. Total window = 2*smoothing_window+1 scans.
42    /// 0 = no averaging. Ignored when smoothing_enabled is false.
43    #[serde(default = "default_smoothing_window")]
44    pub smoothing_window: usize,
45    /// Drop "large baseline-like" hills before isotope chain assembly.
46    /// A hill is dropped when its scan length ≥ `large_hill_min_scans` AND
47    /// `max(unsmoothed) / smoothed_endpoint < large_hill_peak_factor` on
48    /// either end — i.e., the intensity profile lacks a clear apex.
49    /// Catches column-bleed contaminants, plasticizers, and baseline
50    /// drift centroids that masquerade as hills. Default `false` for
51    /// backward compatibility. Ported from AlphaPept's `filter_hills`.
52    #[serde(default)]
53    pub filter_large_baseline_hills: bool,
54    /// Minimum scan span for a hill to be considered for the
55    /// baseline-hill filter. Default 40.
56    #[serde(default = "default_large_hill_min_scans")]
57    pub large_hill_min_scans: usize,
58    /// Required ratio of `max(intensity_profile)` to the smoothed
59    /// endpoint intensity. A clear chromatographic peak easily exceeds
60    /// this; a flat baseline trace fails. Default 2.0.
61    #[serde(default = "default_large_hill_peak_factor")]
62    pub large_hill_peak_factor: f64,
63    /// Pre-detection anomaly normalisation. When > 0, peak intensities
64    /// in each MS1 scan are scaled by `clamp(local_median(reference) /
65    /// scan_reference, tic_norm_min_scale, tic_norm_max_scale)` where
66    /// the local median is taken over a centred window of
67    /// `tic_norm_window` scans. The reference quantity is selected by
68    /// `tic_norm_mode`. Defaults to 0 = off.
69    ///
70    /// Use case: brief ESI dropouts / sub-AGC-failure events that the
71    /// instrument's automatic gain control didn't fully compensate.
72    /// Choosing the window too small (≲ peak FWHM in scans) will
73    /// flatten real apices, especially in `tic` mode.
74    #[serde(default)]
75    pub tic_norm_window: usize,
76    #[serde(default = "default_tic_norm_min_scale")]
77    pub tic_norm_min_scale: f64,
78    #[serde(default = "default_tic_norm_max_scale")]
79    pub tic_norm_max_scale: f64,
80    /// Which per-scan quantity to use as the normalisation reference.
81    /// "tic"    = total ion current (sum of all peak intensities). Heavy-
82    ///            tailed; dominated by a few intense peaks. Real elution
83    ///            apices spike the TIC and get scaled DOWN by the
84    ///            normaliser — apex shapes can be flattened.
85    /// "median" = median peak intensity per scan. Robust to a few intense
86    ///            peaks: apex elution adds tall peaks but barely moves
87    ///            the median. Dropouts dim everything → median drops →
88    ///            scaling fires only when the whole scan is suppressed.
89    ///            Recommended.
90    #[serde(default = "default_tic_norm_mode")]
91    pub tic_norm_mode: String,
92}
93
94fn default_smoothing_window() -> usize {
95    1
96}
97
98fn default_large_hill_min_scans() -> usize {
99    40
100}
101
102fn default_large_hill_peak_factor() -> f64 {
103    2.0
104}
105
106fn default_tic_norm_min_scale() -> f64 {
107    0.5
108}
109
110fn default_tic_norm_max_scale() -> f64 {
111    3.0
112}
113
114fn default_tic_norm_mode() -> String {
115    "median".to_string()
116}
117
118fn default_split_valley_ratio() -> f64 {
119    0.60
120}
121
122fn default_split_sigma_mult() -> f64 {
123    5.0
124}
125
126fn default_split_height_frac() -> f64 {
127    0.10
128}
129
130/// Per-field overrides applied to MS2 (DIA fragment) hill detection.
131///
132/// **Override semantics.** MS2 hill detection *inherits* the user's entire
133/// `[hills]` config and then overwrites only the fields set here. A field left
134/// `None` (absent from `[hills_ms2]` in the TOML) keeps its `[hills]` value —
135/// it does **not** fall back to [`HillsConfig::default`]. When the whole
136/// `[hills_ms2]` table is absent, MS2 uses `[hills]` verbatim (see
137/// [`super::KothConfig::ms2_hills`]), which is byte-for-byte the pre-existing
138/// behavior.
139///
140/// Every field mirrors the same-named field on [`HillsConfig`]; see there for
141/// what each one does.
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143#[serde(default, deny_unknown_fields)]
144pub struct HillsMs2Overrides {
145    // keep in sync with HillsConfig — one Option<T> per overridable field, and a
146    // matching `if let Some(..)` in `apply_to`. The `every_override_field_is_applied`
147    // guard test fails if a field here is not wired into `apply_to`.
148    pub min_scans: Option<usize>,
149    pub max_gap: Option<usize>,
150    pub split_hills: Option<bool>,
151    pub split_valley_ratio: Option<f64>,
152    pub split_sigma_mult: Option<f64>,
153    pub split_height_frac: Option<f64>,
154    pub lfc_weight: Option<f64>,
155    pub gap_fill_enabled: Option<bool>,
156    pub smoothing_enabled: Option<bool>,
157    pub smoothing_window: Option<usize>,
158    pub filter_large_baseline_hills: Option<bool>,
159    pub large_hill_min_scans: Option<usize>,
160    pub large_hill_peak_factor: Option<f64>,
161    pub tic_norm_window: Option<usize>,
162    pub tic_norm_min_scale: Option<f64>,
163    pub tic_norm_max_scale: Option<f64>,
164    pub tic_norm_mode: Option<String>,
165}
166
167impl HillsMs2Overrides {
168    /// Return a copy of `base` (`[hills]`) with each `Some(..)` override applied.
169    /// Fields left `None` keep their `base` value.
170    pub fn apply_to(&self, base: &HillsConfig) -> HillsConfig {
171        let mut c = base.clone();
172        if let Some(v) = self.min_scans {
173            c.min_scans = v;
174        }
175        if let Some(v) = self.max_gap {
176            c.max_gap = v;
177        }
178        if let Some(v) = self.split_hills {
179            c.split_hills = v;
180        }
181        if let Some(v) = self.split_valley_ratio {
182            c.split_valley_ratio = v;
183        }
184        if let Some(v) = self.split_sigma_mult {
185            c.split_sigma_mult = v;
186        }
187        if let Some(v) = self.split_height_frac {
188            c.split_height_frac = v;
189        }
190        if let Some(v) = self.lfc_weight {
191            c.lfc_weight = v;
192        }
193        if let Some(v) = self.gap_fill_enabled {
194            c.gap_fill_enabled = v;
195        }
196        if let Some(v) = self.smoothing_enabled {
197            c.smoothing_enabled = v;
198        }
199        if let Some(v) = self.smoothing_window {
200            c.smoothing_window = v;
201        }
202        if let Some(v) = self.filter_large_baseline_hills {
203            c.filter_large_baseline_hills = v;
204        }
205        if let Some(v) = self.large_hill_min_scans {
206            c.large_hill_min_scans = v;
207        }
208        if let Some(v) = self.large_hill_peak_factor {
209            c.large_hill_peak_factor = v;
210        }
211        if let Some(v) = self.tic_norm_window {
212            c.tic_norm_window = v;
213        }
214        if let Some(v) = self.tic_norm_min_scale {
215            c.tic_norm_min_scale = v;
216        }
217        if let Some(v) = self.tic_norm_max_scale {
218            c.tic_norm_max_scale = v;
219        }
220        if let Some(ref v) = self.tic_norm_mode {
221            c.tic_norm_mode = v.clone();
222        }
223        c
224    }
225}
226
227impl Default for HillsConfig {
228    fn default() -> Self {
229        Self {
230            min_scans: 3,
231            max_gap: 1,
232            split_hills: true,
233            split_valley_ratio: default_split_valley_ratio(),
234            split_sigma_mult: default_split_sigma_mult(),
235            split_height_frac: default_split_height_frac(),
236            lfc_weight: 0.3,
237            gap_fill_enabled: false,
238            smoothing_enabled: false,
239            smoothing_window: 1,
240            filter_large_baseline_hills: false,
241            large_hill_min_scans: default_large_hill_min_scans(),
242            large_hill_peak_factor: default_large_hill_peak_factor(),
243            tic_norm_window: 0,
244            tic_norm_min_scale: default_tic_norm_min_scale(),
245            tic_norm_max_scale: default_tic_norm_max_scale(),
246            tic_norm_mode: default_tic_norm_mode(),
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// Guard against a `HillsMs2Overrides` field that exists but was never wired
256    /// into `apply_to`. Build a `[hills]` where every field is non-default, and a
257    /// `[hills_ms2]` overriding every field to a value distinct from that base;
258    /// after `apply_to`, every field must have changed. An un-wired field keeps
259    /// its base value and fails the matching `assert_ne!`.
260    #[test]
261    fn every_override_field_is_applied() {
262        let base = HillsConfig {
263            min_scans: 5,
264            max_gap: 2,
265            split_hills: false,
266            split_valley_ratio: 0.5,
267            split_sigma_mult: 3.0,
268            split_height_frac: 0.2,
269            lfc_weight: 0.7,
270            gap_fill_enabled: true,
271            smoothing_enabled: true,
272            smoothing_window: 3,
273            filter_large_baseline_hills: true,
274            large_hill_min_scans: 30,
275            large_hill_peak_factor: 1.5,
276            tic_norm_window: 7,
277            tic_norm_min_scale: 0.4,
278            tic_norm_max_scale: 2.5,
279            tic_norm_mode: "tic".to_string(),
280        };
281        let ov = HillsMs2Overrides {
282            min_scans: Some(9),
283            max_gap: Some(4),
284            split_hills: Some(true),
285            split_valley_ratio: Some(0.8),
286            split_sigma_mult: Some(6.0),
287            split_height_frac: Some(0.3),
288            lfc_weight: Some(0.9),
289            gap_fill_enabled: Some(false),
290            smoothing_enabled: Some(false),
291            smoothing_window: Some(6),
292            filter_large_baseline_hills: Some(false),
293            large_hill_min_scans: Some(50),
294            large_hill_peak_factor: Some(3.0),
295            tic_norm_window: Some(11),
296            tic_norm_min_scale: Some(0.6),
297            tic_norm_max_scale: Some(4.0),
298            tic_norm_mode: Some("median".to_string()),
299        };
300        let got = ov.apply_to(&base);
301
302        assert_ne!(got.min_scans, base.min_scans);
303        assert_ne!(got.max_gap, base.max_gap);
304        assert_ne!(got.split_hills, base.split_hills);
305        assert_ne!(got.split_valley_ratio, base.split_valley_ratio);
306        assert_ne!(got.split_sigma_mult, base.split_sigma_mult);
307        assert_ne!(got.split_height_frac, base.split_height_frac);
308        assert_ne!(got.lfc_weight, base.lfc_weight);
309        assert_ne!(got.gap_fill_enabled, base.gap_fill_enabled);
310        assert_ne!(got.smoothing_enabled, base.smoothing_enabled);
311        assert_ne!(got.smoothing_window, base.smoothing_window);
312        assert_ne!(
313            got.filter_large_baseline_hills,
314            base.filter_large_baseline_hills
315        );
316        assert_ne!(got.large_hill_min_scans, base.large_hill_min_scans);
317        assert_ne!(got.large_hill_peak_factor, base.large_hill_peak_factor);
318        assert_ne!(got.tic_norm_window, base.tic_norm_window);
319        assert_ne!(got.tic_norm_min_scale, base.tic_norm_min_scale);
320        assert_ne!(got.tic_norm_max_scale, base.tic_norm_max_scale);
321        assert_ne!(got.tic_norm_mode, base.tic_norm_mode);
322
323        // Sanity: applied config equals the override values exactly.
324        assert_eq!(got.min_scans, 9);
325        assert_eq!(got.tic_norm_mode, "median");
326    }
327
328    /// A `None` override leaves the base value untouched (the documented
329    /// inherit-not-reset semantics).
330    #[test]
331    fn none_override_keeps_base_value() {
332        let base = HillsConfig {
333            min_scans: 5,
334            ..HillsConfig::default()
335        };
336        let got = HillsMs2Overrides::default().apply_to(&base);
337        assert_eq!(got.min_scans, 5);
338    }
339}