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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum WarpKind {
22 Ransac,
27}
28
29fn default_warp_kind() -> WarpKind {
30 WarpKind::Ransac
31}
32
33fn default_ransac_thresh() -> f64 {
34 0.01
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default, deny_unknown_fields)]
40pub struct AlignmentConfig {
41 pub reference_run: Option<String>,
43 pub anchor_mass_ppm: f64,
45 pub rt_anchor_window: f64,
47 pub im_tolerance: f64,
49 pub min_anchor_combined_score: f64,
52 pub min_anchor_count: usize,
54 pub rt_warp_bandwidth: f64,
57 #[serde(default = "default_warp_kind")]
60 pub rt_warp_kind: WarpKind,
61 #[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
84pub struct RunInput {
86 pub name: String,
87 pub features: Vec<ScoredFeature>,
89 pub hills: Vec<Hill>,
91 pub scan_times: Vec<f64>,
94 pub rt_bounds: Option<(f64, f64)>,
97}
98
99impl RunInput {
100 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 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#[derive(Debug, Clone, Serialize)]
162pub struct RtSigmaModel {
163 bins: Vec<f64>,
165 global: f64,
167}
168
169impl RtSigmaModel {
170 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
181const RT_SIGMA_BINS: usize = 12;
183const RT_SIGMA_SHRINK_N0: f64 = 20.0;
186const RT_SIGMA_FLOOR_FRAC: f64 = 0.25;
189
190fn build_rt_sigma_model(anchors: &[AnchorPair], active: &[bool], warp: &RtWarp) -> RtSigmaModel {
191 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 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
248pub 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 pub rt_sigma: RtSigmaModel,
258 pub anchors: Vec<AnchorPair>,
261 pub rt_active: Vec<bool>,
263 pub mass_active: Vec<bool>,
265 pub im_active: Vec<bool>,
268}
269
270impl RunAlignment {
271 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 pub fn correct_mz(&self, mz: f64, run_rt: f64) -> f64 {
280 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 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 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 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 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
320pub struct AlignmentResult {
322 pub reference_idx: usize,
324 pub reference_name: String,
325 pub alignments: HashMap<String, RunAlignment>,
327}
328
329pub 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}