pub mod anchors;
pub mod drift;
pub mod warp;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::models::{Hill, ScoredFeature};
use anchors::{find_anchors, AnchorPair};
use drift::DriftFit;
use warp::RtWarp;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WarpKind {
Ransac,
}
fn default_warp_kind() -> WarpKind {
WarpKind::Ransac
}
fn default_ransac_thresh() -> f64 {
0.01
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AlignmentConfig {
pub reference_run: Option<String>,
pub anchor_mass_ppm: f64,
pub rt_anchor_window: f64,
pub im_tolerance: f64,
pub min_anchor_combined_score: f64,
pub min_anchor_count: usize,
pub rt_warp_bandwidth: f64,
#[serde(default = "default_warp_kind")]
pub rt_warp_kind: WarpKind,
#[serde(default = "default_ransac_thresh")]
pub rt_warp_ransac_thresh: f64,
}
impl Default for AlignmentConfig {
fn default() -> Self {
Self {
reference_run: None,
anchor_mass_ppm: 10.0,
rt_anchor_window: 0.05,
im_tolerance: 0.05,
min_anchor_combined_score: 0.5,
min_anchor_count: 10,
rt_warp_bandwidth: 0.1,
rt_warp_kind: WarpKind::Ransac,
rt_warp_ransac_thresh: default_ransac_thresh(),
}
}
}
pub struct RunInput {
pub name: String,
pub features: Vec<ScoredFeature>,
pub hills: Vec<Hill>,
pub scan_times: Vec<f64>,
pub rt_bounds: Option<(f64, f64)>,
}
impl RunInput {
pub fn rt_range(&self) -> (f64, f64) {
if !self.scan_times.is_empty() {
let min = self
.scan_times
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
let max = self
.scan_times
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
return (min, max);
}
if let Some((min, max)) = self.rt_bounds {
if min.is_finite() && max.is_finite() && max > min {
return (min, max);
}
}
let min = self
.features
.iter()
.map(|f| f.feature.rt_start())
.fold(f64::INFINITY, f64::min);
let max = self
.features
.iter()
.map(|f| f.feature.rt_end())
.fold(f64::NEG_INFINITY, f64::max);
if min.is_finite() && max.is_finite() && max > min {
(min, max)
} else {
let min = self
.hills
.iter()
.map(|h| h.rt_start)
.fold(f64::INFINITY, f64::min);
let max = self
.hills
.iter()
.map(|h| h.rt_end)
.fold(f64::NEG_INFINITY, f64::max);
if min.is_finite() && max.is_finite() && max > min {
(min, max)
} else {
(0.0, 1.0)
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RtSigmaModel {
bins: Vec<f64>,
global: f64,
}
impl RtSigmaModel {
pub fn sigma_norm_at(&self, run_norm: f64) -> Option<f64> {
if self.bins.is_empty() || self.global <= 0.0 {
return None;
}
let nb = self.bins.len();
let idx = ((run_norm.clamp(0.0, 1.0) * nb as f64) as usize).min(nb - 1);
Some(self.bins[idx])
}
}
const RT_SIGMA_BINS: usize = 12;
const RT_SIGMA_SHRINK_N0: f64 = 20.0;
const RT_SIGMA_FLOOR_FRAC: f64 = 0.25;
fn build_rt_sigma_model(anchors: &[AnchorPair], active: &[bool], warp: &RtWarp) -> RtSigmaModel {
let mut xs: Vec<f64> = Vec::new();
let mut res: Vec<f64> = Vec::new();
for (a, &ok) in anchors.iter().zip(active.iter()) {
if !ok {
continue;
}
let pred = a.run_rt_norm + warp.delta_at_run_norm(a.run_rt_norm);
xs.push(a.run_rt_norm);
res.push(a.ref_rt_norm - pred);
}
if res.len() < 2 {
return RtSigmaModel {
bins: Vec::new(),
global: 0.0,
};
}
let var = |v: &[f64]| -> f64 {
let n = v.len();
if n < 2 {
return 0.0;
}
let mean = v.iter().sum::<f64>() / n as f64;
v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64
};
let global_var = var(&res);
let global = global_var.sqrt();
if global <= 0.0 {
return RtSigmaModel {
bins: Vec::new(),
global: 0.0,
};
}
let floor = RT_SIGMA_FLOOR_FRAC * global;
let nb = RT_SIGMA_BINS;
let mut bin_res: Vec<Vec<f64>> = vec![Vec::new(); nb];
for (&x, &r) in xs.iter().zip(res.iter()) {
let idx = ((x.clamp(0.0, 1.0) * nb as f64) as usize).min(nb - 1);
bin_res[idx].push(r);
}
let bins: Vec<f64> = bin_res
.iter()
.map(|b| {
let n = b.len() as f64;
let shrunk_var =
(n * var(b) + RT_SIGMA_SHRINK_N0 * global_var) / (n + RT_SIGMA_SHRINK_N0);
shrunk_var.sqrt().max(floor)
})
.collect();
RtSigmaModel { bins, global }
}
pub struct RunAlignment {
pub n_anchors: usize,
pub rt_warp: RtWarp,
pub mass_drift: DriftFit,
pub im_drift: DriftFit,
pub run_rt_range: (f64, f64),
pub ref_rt_range: (f64, f64),
pub rt_sigma: RtSigmaModel,
pub anchors: Vec<AnchorPair>,
pub rt_active: Vec<bool>,
pub mass_active: Vec<bool>,
pub im_active: Vec<bool>,
}
impl RunAlignment {
pub fn warp_rt(&self, run_rt: f64) -> f64 {
self.rt_warp
.apply(run_rt, self.run_rt_range, self.ref_rt_range)
}
pub fn correct_mz(&self, mz: f64, run_rt: f64) -> f64 {
let ref_rt = self.warp_rt(run_rt);
let rt_norm = anchors::normalize_rt(ref_rt, self.ref_rt_range);
let ppm = self.mass_drift.predict(rt_norm);
mz / (1.0 + ppm / 1e6)
}
pub fn correct_im(&self, im: f64, run_rt: f64) -> f64 {
let rt_norm = anchors::normalize_rt(run_rt, self.run_rt_range);
im - self.im_drift.predict(rt_norm)
}
pub fn predict_run_rt(&self, ref_rt: f64) -> f64 {
let ref_norm = anchors::normalize_rt(ref_rt, self.ref_rt_range);
let delta = self.rt_warp.delta_at_ref_norm(ref_norm);
let run_norm = (ref_norm - delta).clamp(0.0, 1.0);
run_norm * (self.run_rt_range.1 - self.run_rt_range.0) + self.run_rt_range.0
}
pub fn predict_run_mz(&self, ref_mz: f64, ref_rt: f64) -> f64 {
let rt_norm = anchors::normalize_rt(ref_rt, self.ref_rt_range);
let ppm = self.mass_drift.predict(rt_norm);
ref_mz * (1.0 + ppm / 1e6)
}
pub fn predict_run_im(&self, ref_im: f64, ref_rt: f64) -> f64 {
let rt_norm = anchors::normalize_rt(ref_rt, self.ref_rt_range);
ref_im + self.im_drift.predict(rt_norm)
}
}
pub struct AlignmentResult {
pub reference_idx: usize,
pub reference_name: String,
pub alignments: HashMap<String, RunAlignment>,
}
pub fn align_runs(runs: &[RunInput], config: &AlignmentConfig) -> AlignmentResult {
let auto_ref_idx = runs
.iter()
.enumerate()
.map(|(i, r)| {
let n = r
.features
.iter()
.filter(|f| f.combined_score >= config.min_anchor_combined_score)
.count();
(i, n)
})
.max_by_key(|(_, n)| *n)
.map(|(i, _)| i)
.unwrap_or(0);
let ref_idx = config.reference_run.as_ref().map_or(auto_ref_idx, |name| {
runs.iter()
.position(|r| &r.name == name)
.expect("configured reference run must exist")
});
let reference = &runs[ref_idx];
let ref_rt_range = reference.rt_range();
let mut alignments = HashMap::new();
for (i, run) in runs.iter().enumerate() {
if i == ref_idx {
continue;
}
let run_rt_range = run.rt_range();
let anchors = find_anchors(run, reference, config);
log::info!(
"Alignment: run '{}' → '{}': {} anchors",
run.name,
reference.name,
anchors.len()
);
let (rt_warp, rt_active) = if anchors.len() >= config.min_anchor_count {
match config.rt_warp_kind {
WarpKind::Ransac => warp::fit_ransac_warp(&anchors, config),
}
} else {
log::warn!(
"Run '{}': only {} anchors (< {}), using identity RT warp",
run.name,
anchors.len(),
config.min_anchor_count
);
(warp::identity_warp(), vec![false; anchors.len()])
};
let (mass_drift, mass_active) = drift::fit_mass_drift(&anchors);
let (im_drift, im_active) = drift::fit_im_drift(&anchors);
let rt_sigma = build_rt_sigma_model(&anchors, &rt_active, &rt_warp);
let n_anchors = anchors.len();
alignments.insert(
run.name.clone(),
RunAlignment {
n_anchors,
rt_warp,
mass_drift,
im_drift,
run_rt_range,
ref_rt_range,
rt_sigma,
anchors,
rt_active,
mass_active,
im_active,
},
);
}
AlignmentResult {
reference_idx: ref_idx,
reference_name: reference.name.clone(),
alignments,
}
}