use crate::database::PeptideDb;
use crate::input::FeatureMatrix;
use crate::mass::NEUTRON;
use rayon::prelude::*;
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default)]
pub struct MatchConfig {
pub ppm_tolerance: f64,
pub isotope_offsets: Vec<i8>,
pub min_charge: u8,
pub min_isotopes: u32,
pub min_combined_score: f64,
}
impl Default for MatchConfig {
fn default() -> Self {
MatchConfig {
ppm_tolerance: 10.0,
isotope_offsets: vec![-1, 0, 1],
min_charge: 1,
min_isotopes: 0,
min_combined_score: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct Pfm {
pub mass_error_ppm: f64,
pub feature_idx: u32,
pub peptide_idx: u32,
pub isotope_offset: i8,
pub decoy: bool,
}
pub(crate) fn passes(feature: &crate::input::Feature, cfg: &MatchConfig) -> bool {
feature.charge >= cfg.min_charge
&& feature.combined_score >= cfg.min_combined_score
&& (cfg.min_isotopes == 0
|| feature
.n_isotopes
.is_none_or(|n| n >= cfg.min_isotopes as f64))
}
fn joint_matches(matrix: &FeatureMatrix, fi: usize, db: &PeptideDb, cfg: &MatchConfig) -> Vec<Pfm> {
let group = &matrix.joint_evidence.as_ref().unwrap()[fi];
let feature = &matrix.features[fi];
let mut out = Vec::new();
for &offset in &cfg.isotope_offsets {
let mut windows: Vec<(f64, f64)> = group
.iter()
.filter(|o| passes(&o.feature, cfg))
.filter_map(|o| {
let c = o.feature.mass - offset as f64 * NEUTRON;
(c > 0.0).then_some({
(
c * (1.0 - cfg.ppm_tolerance * 1e-6),
c * (1.0 + cfg.ppm_tolerance * 1e-6),
)
})
})
.collect();
windows.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f64, f64)> = Vec::new();
for (lo, hi) in windows {
if let Some(last) = merged.last_mut().filter(|p| p.1 >= lo) {
last.1 = last.1.max(hi);
} else {
merged.push((lo, hi));
}
}
let center = feature.mass - offset as f64 * NEUTRON;
for (lo, hi) in merged {
let start = db.peptides.partition_point(|p| p.mass < lo);
for (j, pep) in db.peptides[start..].iter().enumerate() {
if pep.mass > hi {
break;
}
out.push(Pfm {
feature_idx: fi as u32,
peptide_idx: (start + j) as u32,
isotope_offset: offset,
mass_error_ppm: (pep.mass - center) / center * 1e6,
decoy: pep.decoy,
});
}
}
}
out
}
pub fn match_features(matrix: &FeatureMatrix, db: &PeptideDb, cfg: &MatchConfig) -> Vec<Pfm> {
matrix
.features
.par_iter()
.enumerate()
.flat_map_iter(|(fi, feature)| {
if matrix.joint_evidence.is_some() {
return joint_matches(matrix, fi, db, cfg).into_iter();
}
let mut out = Vec::new();
if !passes(feature, cfg) {
return out.into_iter();
}
for &offset in &cfg.isotope_offsets {
let center = feature.mass - (offset as f64) * NEUTRON;
if center <= 0.0 {
continue;
}
let tol = center * cfg.ppm_tolerance * 1e-6;
let lo = center - tol;
let hi = center + tol;
let start = db.peptides.partition_point(|p| p.mass < lo);
for (j, pep) in db.peptides[start..].iter().enumerate() {
if pep.mass > hi {
break;
}
out.push(Pfm {
feature_idx: fi as u32,
peptide_idx: (start + j) as u32,
isotope_offset: offset,
mass_error_ppm: (pep.mass - center) / center * 1e6,
decoy: pep.decoy,
});
}
}
out.into_iter()
})
.collect()
}
pub fn match_features_topk(
matrix: &FeatureMatrix,
db: &PeptideDb,
cfg: &MatchConfig,
k: usize,
) -> Vec<Pfm> {
matrix
.features
.par_iter()
.enumerate()
.flat_map_iter(|(fi, feature)| {
let mut targets: Vec<Pfm> = Vec::new();
let mut decoys: Vec<Pfm> = Vec::new();
if matrix.joint_evidence.is_some() {
for pfm in joint_matches(matrix, fi, db, cfg) {
if pfm.decoy {
decoys.push(pfm);
} else {
targets.push(pfm);
}
}
} else if passes(feature, cfg) {
for &offset in &cfg.isotope_offsets {
let center = feature.mass - (offset as f64) * NEUTRON;
if center <= 0.0 {
continue;
}
let tol = center * cfg.ppm_tolerance * 1e-6;
let lo = center - tol;
let hi = center + tol;
let start = db.peptides.partition_point(|p| p.mass < lo);
for (j, pep) in db.peptides[start..].iter().enumerate() {
if pep.mass > hi {
break;
}
let pfm = Pfm {
feature_idx: fi as u32,
peptide_idx: (start + j) as u32,
isotope_offset: offset,
mass_error_ppm: (pep.mass - center) / center * 1e6,
decoy: pep.decoy,
};
if pep.decoy {
decoys.push(pfm);
} else {
targets.push(pfm);
}
}
}
}
for bucket in [&mut targets, &mut decoys] {
bucket.sort_by(|a, b| {
a.mass_error_ppm
.abs()
.partial_cmp(&b.mass_error_ppm.abs())
.unwrap()
});
bucket.truncate(k);
}
targets.into_iter().chain(decoys)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::DigestConfig;
#[test]
fn matches_real_data() {
let (Some(features), Some(fasta)) = (
crate::test_input("tests/data/09744.features.tsv"),
crate::test_input("tests/data/entrap_human_yeast.fasta"),
) else {
return;
};
let matrix = FeatureMatrix::load(&features).unwrap();
let db = PeptideDb::build(&fasta, &DigestConfig::default()).unwrap();
let cfg = MatchConfig::default();
let pfms = match_features(&matrix, &db, &cfg);
let n_target = pfms.iter().filter(|p| !p.decoy).count();
let n_decoy = pfms.iter().filter(|p| p.decoy).count();
let n_feat_matched = {
let mut v: Vec<u32> = pfms.iter().map(|p| p.feature_idx).collect();
v.sort_unstable();
v.dedup();
v.len()
};
eprintln!(
"PFMs: {} ({} target, {} decoy); {}/{} features matched at least one peptide",
pfms.len(),
n_target,
n_decoy,
n_feat_matched,
matrix.len()
);
assert!(n_target > 0 && n_decoy > 0);
assert!(pfms
.iter()
.all(|p| p.mass_error_ppm.abs() <= cfg.ppm_tolerance + 1e-6));
let ratio = n_decoy as f64 / n_target as f64;
assert!(
(0.5..=2.0).contains(&ratio),
"target/decoy PFM ratio is lopsided: {n_target} target vs {n_decoy} decoy"
);
assert!(pfms.iter().all(|p| (p.feature_idx as usize) < matrix.len()));
assert!(pfms
.iter()
.all(|p| (p.peptide_idx as usize) < db.peptides.len()));
assert!(pfms
.iter()
.all(|p| cfg.isotope_offsets.contains(&p.isotope_offset)));
assert!(pfms
.iter()
.all(|p| passes(&matrix.features[p.feature_idx as usize], &cfg)));
assert!(
n_feat_matched * 4 > matrix.len(),
"only {n_feat_matched}/{} features matched a peptide",
matrix.len()
);
}
}