Skip to main content

koth_ms/alignment/
mod.rs

1pub mod anchors;
2pub mod drift;
3pub mod warp;
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9use crate::models::{Hill, ScoredFeature};
10
11use anchors::{find_anchors, AnchorPair};
12use drift::DriftFit;
13use warp::RtWarp;
14
15/// RT-warp model. `Ransac` is the only supported model: it tolerates the ~25%
16/// wrong-match contamination in coordinate-only anchors and degrades far less
17/// than a sliding-median or affine fit as contamination rises, with fewer
18/// tuning knobs.
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum WarpKind {
22    /// RANSAC inlier selection → median piecewise-linear fit on the inliers →
23    /// isotonic monotonicity. Robust to a contaminated anchor set (wrong
24    /// coordinate-matches): the diagonal is found from a global line consensus
25    /// rather than refitting on a contaminated seed.
26    Ransac,
27}
28
29fn default_warp_kind() -> WarpKind {
30    WarpKind::Ransac
31}
32
33fn default_ransac_thresh() -> f64 {
34    0.01
35}
36
37/// Configuration for multi-run alignment.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default, deny_unknown_fields)]
40pub struct AlignmentConfig {
41    /// Explicit reference run name; None selects the highest-quality run.
42    pub reference_run: Option<String>,
43    /// PPM tolerance for finding anchor feature pairs
44    pub anchor_mass_ppm: f64,
45    /// Normalised RT window [0, 1] for anchor matching (e.g. 0.05 = ±5% of gradient)
46    pub rt_anchor_window: f64,
47    /// Ion mobility tolerance for anchor matching (1/K0 units)
48    pub im_tolerance: f64,
49    /// Minimum `combined_score` (isotope × chromato cosine) for a feature to
50    /// be eligible as an alignment anchor.
51    pub min_anchor_combined_score: f64,
52    /// Minimum anchor count required to fit a warp; falls back to identity if below
53    pub min_anchor_count: usize,
54    /// Bandwidth for the sliding-window median (fraction of normalised RT range)
55    /// used by the RANSAC warp's inlier median piecewise-linear fit.
56    pub rt_warp_bandwidth: f64,
57    /// Which warp model to fit. Only `ransac` (RANSAC-inlier piecewise warp) is
58    /// supported.
59    #[serde(default = "default_warp_kind")]
60    pub rt_warp_kind: WarpKind,
61    /// Inlier half-band for the RANSAC warp, in normalised RT units
62    /// (0.01 ≈ 1.4 min on a 142-min gradient). An anchor is an inlier if its
63    /// reference RT is within this distance of the consensus diagonal.
64    #[serde(default = "default_ransac_thresh")]
65    pub rt_warp_ransac_thresh: f64,
66}
67
68impl Default for AlignmentConfig {
69    fn default() -> Self {
70        Self {
71            reference_run: None,
72            anchor_mass_ppm: 10.0,
73            rt_anchor_window: 0.05,
74            im_tolerance: 0.05,
75            min_anchor_combined_score: 0.5,
76            min_anchor_count: 10,
77            rt_warp_bandwidth: 0.1,
78            rt_warp_kind: WarpKind::Ransac,
79            rt_warp_ransac_thresh: default_ransac_thresh(),
80        }
81    }
82}
83
84/// All input data for a single LC-MS run.
85pub struct RunInput {
86    pub name: String,
87    /// Scored features — used for anchor finding and as consensus source for the reference run
88    pub features: Vec<ScoredFeature>,
89    /// All hills for this run — used for LFQ grid extraction
90    pub hills: Vec<Hill>,
91    /// Retention time (minutes) indexed by absolute scan index.
92    /// If empty, hill RT positions are interpolated from rt_start / rt_end.
93    pub scan_times: Vec<f64>,
94    /// Optional measured RT bounds when hills are streamed and features are absent.
95    /// This is metadata, not an indexed scan-time vector.
96    pub rt_bounds: Option<(f64, f64)>,
97}
98
99impl RunInput {
100    /// Overall RT span of this run, derived from scan_times if available.
101    pub fn rt_range(&self) -> (f64, f64) {
102        if !self.scan_times.is_empty() {
103            let min = self
104                .scan_times
105                .iter()
106                .cloned()
107                .fold(f64::INFINITY, f64::min);
108            let max = self
109                .scan_times
110                .iter()
111                .cloned()
112                .fold(f64::NEG_INFINITY, f64::max);
113            return (min, max);
114        }
115        if let Some((min, max)) = self.rt_bounds {
116            if min.is_finite() && max.is_finite() && max > min {
117                return (min, max);
118            }
119        }
120        // Derive from feature RT extents as fallback
121        let min = self
122            .features
123            .iter()
124            .map(|f| f.feature.rt_start())
125            .fold(f64::INFINITY, f64::min);
126        let max = self
127            .features
128            .iter()
129            .map(|f| f.feature.rt_end())
130            .fold(f64::NEG_INFINITY, f64::max);
131        if min.is_finite() && max.is_finite() && max > min {
132            (min, max)
133        } else {
134            let min = self
135                .hills
136                .iter()
137                .map(|h| h.rt_start)
138                .fold(f64::INFINITY, f64::min);
139            let max = self
140                .hills
141                .iter()
142                .map(|h| h.rt_end)
143                .fold(f64::NEG_INFINITY, f64::max);
144            if min.is_finite() && max.is_finite() && max > min {
145                (min, max)
146            } else {
147                (0.0, 1.0)
148            }
149        }
150    }
151}
152
153/// Per-region estimate of the post-warp RT-residual spread σ(run_rt_norm),
154/// in normalised RT units. Built from the RANSAC inlier anchors: for each
155/// inlier, the residual `ref_rt_norm − (run_rt_norm + warp_delta)` is the RT
156/// scatter the warp could not remove. Binned over run-normalised RT with
157/// empirical-Bayes shrinkage toward the global σ (so sparse bins don't produce
158/// a wild estimate) and a floor. `sigma_norm_at` returns `None` when the model
159/// is degenerate (no inliers / zero spread), signalling callers to fall back to
160/// the raw RT term.
161#[derive(Debug, Clone, Serialize)]
162pub struct RtSigmaModel {
163    /// Shrunk σ per RT bin, in normalised RT units. Empty ⇒ degenerate.
164    bins: Vec<f64>,
165    /// Global (all-inlier) σ in normalised RT units.
166    global: f64,
167}
168
169impl RtSigmaModel {
170    /// σ at a run-normalised RT position, or `None` if the model is degenerate.
171    pub fn sigma_norm_at(&self, run_norm: f64) -> Option<f64> {
172        if self.bins.is_empty() || self.global <= 0.0 {
173            return None;
174        }
175        let nb = self.bins.len();
176        let idx = ((run_norm.clamp(0.0, 1.0) * nb as f64) as usize).min(nb - 1);
177        Some(self.bins[idx])
178    }
179}
180
181/// Number of RT bins for the residual-spread model.
182const RT_SIGMA_BINS: usize = 12;
183/// Empirical-Bayes pseudo-count: a bin's σ is shrunk toward the global σ as if
184/// it had this many extra global-variance observations. Larger ⇒ more shrinkage.
185const RT_SIGMA_SHRINK_N0: f64 = 20.0;
186/// Floor on any bin's σ, as a fraction of the global σ, to avoid an
187/// over-peaked RT likelihood in a bin that happens to look near-zero.
188const RT_SIGMA_FLOOR_FRAC: f64 = 0.25;
189
190fn build_rt_sigma_model(anchors: &[AnchorPair], active: &[bool], warp: &RtWarp) -> RtSigmaModel {
191    // Post-warp residuals of inlier anchors, keyed by run-normalised position.
192    let mut xs: Vec<f64> = Vec::new();
193    let mut res: Vec<f64> = Vec::new();
194    for (a, &ok) in anchors.iter().zip(active.iter()) {
195        if !ok {
196            continue;
197        }
198        let pred = a.run_rt_norm + warp.delta_at_run_norm(a.run_rt_norm);
199        xs.push(a.run_rt_norm);
200        res.push(a.ref_rt_norm - pred);
201    }
202    if res.len() < 2 {
203        return RtSigmaModel {
204            bins: Vec::new(),
205            global: 0.0,
206        };
207    }
208
209    let var = |v: &[f64]| -> f64 {
210        let n = v.len();
211        if n < 2 {
212            return 0.0;
213        }
214        let mean = v.iter().sum::<f64>() / n as f64;
215        v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64
216    };
217    let global_var = var(&res);
218    let global = global_var.sqrt();
219    if global <= 0.0 {
220        return RtSigmaModel {
221            bins: Vec::new(),
222            global: 0.0,
223        };
224    }
225    let floor = RT_SIGMA_FLOOR_FRAC * global;
226
227    let nb = RT_SIGMA_BINS;
228    let mut bin_res: Vec<Vec<f64>> = vec![Vec::new(); nb];
229    for (&x, &r) in xs.iter().zip(res.iter()) {
230        let idx = ((x.clamp(0.0, 1.0) * nb as f64) as usize).min(nb - 1);
231        bin_res[idx].push(r);
232    }
233
234    let bins: Vec<f64> = bin_res
235        .iter()
236        .map(|b| {
237            let n = b.len() as f64;
238            // Empirical-Bayes shrink the bin variance toward the global variance.
239            let shrunk_var =
240                (n * var(b) + RT_SIGMA_SHRINK_N0 * global_var) / (n + RT_SIGMA_SHRINK_N0);
241            shrunk_var.sqrt().max(floor)
242        })
243        .collect();
244
245    RtSigmaModel { bins, global }
246}
247
248/// Alignment parameters for a single non-reference run.
249pub struct RunAlignment {
250    pub n_anchors: usize,
251    pub rt_warp: RtWarp,
252    pub mass_drift: DriftFit,
253    pub im_drift: DriftFit,
254    pub run_rt_range: (f64, f64),
255    pub ref_rt_range: (f64, f64),
256    /// Per-region post-warp RT-residual spread, in normalised RT units.
257    pub rt_sigma: RtSigmaModel,
258    /// All anchor pairs used for this run's alignment.
259    /// Retained for diagnostic reporting; aligned 1:1 with the masks below.
260    pub anchors: Vec<AnchorPair>,
261    /// Per-anchor "kept" mask from the RT-warp sigma-clip iterations.
262    pub rt_active: Vec<bool>,
263    /// Per-anchor "kept" mask from the mass-drift sigma-clip iterations.
264    pub mass_active: Vec<bool>,
265    /// Per-anchor "kept" mask from the IM-drift fit.
266    /// `false` for anchors that lacked IM on either side OR were sigma-clipped.
267    pub im_active: Vec<bool>,
268}
269
270impl RunAlignment {
271    /// Map an absolute run RT → absolute reference RT.
272    pub fn warp_rt(&self, run_rt: f64) -> f64 {
273        self.rt_warp
274            .apply(run_rt, self.run_rt_range, self.ref_rt_range)
275    }
276
277    /// Apply mass-PPM drift correction to a run mz value.
278    /// `run_rt` should be the uncorrected run retention time.
279    pub fn correct_mz(&self, mz: f64, run_rt: f64) -> f64 {
280        // `mass_drift` is fit with x = ref_rt_norm (see `drift::fit_mass_drift`),
281        // so it must be evaluated on the reference-frame normalised RT — the same
282        // axis `predict_run_mz` uses. Warp the uncorrected run RT into reference
283        // space before normalising, rather than normalising against the run's own
284        // range (which would evaluate the fit off its own axis).
285        let ref_rt = self.warp_rt(run_rt);
286        let rt_norm = anchors::normalize_rt(ref_rt, self.ref_rt_range);
287        let ppm = self.mass_drift.predict(rt_norm);
288        mz / (1.0 + ppm / 1e6)
289    }
290
291    /// Apply ion-mobility drift correction.
292    pub fn correct_im(&self, im: f64, run_rt: f64) -> f64 {
293        let rt_norm = anchors::normalize_rt(run_rt, self.run_rt_range);
294        im - self.im_drift.predict(rt_norm)
295    }
296
297    /// Predict the expected RT of a reference feature in this run's native RT space.
298    /// Uses an approximate inverse warp (valid for the small-to-moderate shifts typical in LC-MS).
299    pub fn predict_run_rt(&self, ref_rt: f64) -> f64 {
300        let ref_norm = anchors::normalize_rt(ref_rt, self.ref_rt_range);
301        let delta = self.rt_warp.delta_at_ref_norm(ref_norm);
302        let run_norm = (ref_norm - delta).clamp(0.0, 1.0);
303        run_norm * (self.run_rt_range.1 - self.run_rt_range.0) + self.run_rt_range.0
304    }
305
306    /// Predict the expected m/z of a reference feature in this run's native m/z space.
307    pub fn predict_run_mz(&self, ref_mz: f64, ref_rt: f64) -> f64 {
308        let rt_norm = anchors::normalize_rt(ref_rt, self.ref_rt_range);
309        let ppm = self.mass_drift.predict(rt_norm);
310        ref_mz * (1.0 + ppm / 1e6)
311    }
312
313    /// Predict the expected ion mobility of a reference feature in this run's native IM space.
314    pub fn predict_run_im(&self, ref_im: f64, ref_rt: f64) -> f64 {
315        let rt_norm = anchors::normalize_rt(ref_rt, self.ref_rt_range);
316        ref_im + self.im_drift.predict(rt_norm)
317    }
318}
319
320/// Full alignment result for a set of runs.
321pub struct AlignmentResult {
322    /// Index of the chosen reference run in the input slice
323    pub reference_idx: usize,
324    pub reference_name: String,
325    /// Alignments for every non-reference run, keyed by run name
326    pub alignments: HashMap<String, RunAlignment>,
327}
328
329/// Compute alignment parameters for all runs relative to an auto-selected reference.
330///
331/// The reference is the run with the most features whose `combined_score`
332/// \>= `config.min_anchor_combined_score`.
333pub fn align_runs(runs: &[RunInput], config: &AlignmentConfig) -> AlignmentResult {
334    let auto_ref_idx = runs
335        .iter()
336        .enumerate()
337        .map(|(i, r)| {
338            let n = r
339                .features
340                .iter()
341                .filter(|f| f.combined_score >= config.min_anchor_combined_score)
342                .count();
343            (i, n)
344        })
345        .max_by_key(|(_, n)| *n)
346        .map(|(i, _)| i)
347        .unwrap_or(0);
348
349    let ref_idx = config.reference_run.as_ref().map_or(auto_ref_idx, |name| {
350        runs.iter()
351            .position(|r| &r.name == name)
352            .expect("configured reference run must exist")
353    });
354    let reference = &runs[ref_idx];
355    let ref_rt_range = reference.rt_range();
356
357    let mut alignments = HashMap::new();
358
359    for (i, run) in runs.iter().enumerate() {
360        if i == ref_idx {
361            continue;
362        }
363
364        let run_rt_range = run.rt_range();
365        let anchors = find_anchors(run, reference, config);
366
367        log::info!(
368            "Alignment: run '{}' → '{}': {} anchors",
369            run.name,
370            reference.name,
371            anchors.len()
372        );
373
374        let (rt_warp, rt_active) = if anchors.len() >= config.min_anchor_count {
375            match config.rt_warp_kind {
376                WarpKind::Ransac => warp::fit_ransac_warp(&anchors, config),
377            }
378        } else {
379            log::warn!(
380                "Run '{}': only {} anchors (< {}), using identity RT warp",
381                run.name,
382                anchors.len(),
383                config.min_anchor_count
384            );
385            (warp::identity_warp(), vec![false; anchors.len()])
386        };
387
388        let (mass_drift, mass_active) = drift::fit_mass_drift(&anchors);
389        let (im_drift, im_active) = drift::fit_im_drift(&anchors);
390
391        let rt_sigma = build_rt_sigma_model(&anchors, &rt_active, &rt_warp);
392
393        let n_anchors = anchors.len();
394        alignments.insert(
395            run.name.clone(),
396            RunAlignment {
397                n_anchors,
398                rt_warp,
399                mass_drift,
400                im_drift,
401                run_rt_range,
402                ref_rt_range,
403                rt_sigma,
404                anchors,
405                rt_active,
406                mass_active,
407                im_active,
408            },
409        );
410    }
411
412    AlignmentResult {
413        reference_idx: ref_idx,
414        reference_name: reference.name.clone(),
415        alignments,
416    }
417}