use crate::input::Feature;
#[derive(Debug, Clone)]
pub struct Observation {
pub bootstrap_eligible: bool,
pub run_id: usize,
pub observation_id: usize,
pub feature: Feature,
}
#[derive(Debug, Clone)]
pub struct Evidence {
pub best: usize,
pub mass_error: f64,
pub rt_error: f64,
pub deviation: f64,
pub values: [f32; 14],
}
pub struct Match {
pub best: usize,
pub mass_error: f64,
pub rt_error: f64,
pub deviation: f64,
}
pub const NAMES: [&str; 14] = [
"direct_runs",
"compatible_runs",
"compatible_fraction",
"second_deviation",
"median_deviation",
"median_mass_error",
"mass_mad",
"median_rt_error",
"rt_mad",
"median_quality",
"median_isotopes",
"median_scans",
"best_quality",
"has_second_run",
];
pub fn restrict_observations(matrix: &mut crate::FeatureMatrix, cfg: &crate::MatchConfig) {
let Some(groups) = matrix.joint_evidence.take() else {
return;
};
let cells = matrix.joint_cell_evidence.take();
let mut retained_cells = Vec::new();
let mut features = Vec::new();
let mut retained = Vec::new();
for (i, (mut f, mut group)) in std::mem::take(&mut matrix.features)
.into_iter()
.zip(groups)
.enumerate()
{
let before = group.len();
group.retain(|o| crate::matching::passes(&o.feature, cfg));
if group.is_empty() {
continue;
}
if group.len() != before {
let representative = group
.iter()
.max_by(|a, b| {
a.feature
.n_isotopes
.is_some_and(|n| n >= 3.0)
.cmp(&b.feature.n_isotopes.is_some_and(|n| n >= 3.0))
.then(
a.feature
.combined_score
.total_cmp(&b.feature.combined_score),
)
.then_with(|| {
(b.run_id, b.observation_id).cmp(&(a.run_id, a.observation_id))
})
})
.unwrap();
let intensities = f.intensities;
f = representative.feature.clone();
f.intensities = intensities;
f.n_contributing_runs = group
.iter()
.map(|o| o.run_id)
.collect::<std::collections::HashSet<_>>()
.len() as u32;
}
if let Some(cells) = &cells {
retained_cells.push(cells[i]);
}
features.push(f);
retained.push(group);
}
matrix.features = features;
matrix.joint_evidence = Some(retained);
matrix.joint_cell_evidence = cells.map(|_| retained_cells);
}
fn med(v: &mut [f64]) -> f64 {
v.sort_unstable_by(f64::total_cmp);
let n = v.len();
if n.is_multiple_of(2) {
(v[n / 2 - 1] + v[n / 2]) / 2.0
} else {
v[n / 2]
}
}
pub fn evidence(
observations: &[Observation],
peptide_mass: f64,
predicted_rt: f64,
mass_shift: f64,
mass_sigma: f64,
rt_sigma: f64,
mass_gate: f64,
) -> Option<Evidence> {
let mut by_run: Vec<(usize, usize, f64, f64, f64)> = Vec::new();
for (i, o) in observations.iter().enumerate() {
let mass = (peptide_mass - o.feature.mass) / o.feature.mass * 1e6;
let rt = o.feature.rt - predicted_rt;
let mz = (mass - mass_shift) / mass_sigma;
let dev = mz * mz + (rt / rt_sigma).powi(2);
if let Some(prior) = by_run.iter_mut().find(|p| p.0 == o.run_id) {
let inside = (mass - mass_shift).abs() <= mass_gate;
let prior_inside = (prior.3 - mass_shift).abs() <= mass_gate;
let better_dev = dev < prior.2
|| (dev == prior.2
&& o.feature.combined_score > observations[prior.1].feature.combined_score);
if (inside && !prior_inside) || (inside == prior_inside && better_dev) {
*prior = (o.run_id, i, dev, mass, rt);
}
} else {
by_run.push((o.run_id, i, dev, mass, rt));
}
}
by_run.sort_unstable_by(|a, b| {
((b.3 - mass_shift).abs() <= mass_gate)
.cmp(&((a.3 - mass_shift).abs() <= mass_gate))
.then(a.2.total_cmp(&b.2))
.then_with(|| {
observations[b.1]
.feature
.combined_score
.total_cmp(&observations[a.1].feature.combined_score)
})
.then(a.0.cmp(&b.0))
});
let best = by_run
.iter()
.find(|p| (p.3 - mass_shift).abs() <= mass_gate)?;
let n = by_run.len();
let compatible = by_run
.iter()
.filter(|p| p.2 <= 9.0 && (p.3 - mass_shift).abs() <= mass_gate)
.count();
let (mut mass, mut rt, mut devs, mut quality, mut isotopes, mut scans) = (
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
);
for p in &by_run {
let f = &observations[p.1].feature;
mass.push(p.3 - mass_shift);
rt.push(p.4);
devs.push(p.2.min(100.0));
quality.push(f.combined_score);
isotopes.push(f.n_isotopes.unwrap_or(0.0));
scans.push(f.n_scans.unwrap_or(0.0));
}
let mm = med(&mut mass);
let rm = med(&mut rt);
for x in &mut mass {
*x = (*x - mm).abs();
}
for x in &mut rt {
*x = (*x - rm).abs();
}
Some(Evidence {
best: best.1,
mass_error: best.3,
rt_error: best.4,
deviation: best.2,
values: [
(n as f64).ln_1p() as f32,
(compatible as f64).ln_1p() as f32,
compatible as f32 / n as f32,
by_run.get(1).map_or(100.0, |p| p.2.min(100.0)) as f32,
med(&mut devs) as f32,
mm as f32,
med(&mut mass) as f32,
rm as f32,
med(&mut rt) as f32,
med(&mut quality) as f32,
med(&mut isotopes).ln_1p() as f32,
med(&mut scans).ln_1p() as f32,
observations[best.1].feature.combined_score as f32,
(n > 1) as u8 as f32,
],
})
}
pub fn best_deviation(
observations: &[Observation],
peptide_mass: f64,
predicted_rt: f64,
mass_shift: f64,
mass_sigma: f64,
rt_sigma: f64,
mass_gate: f64,
) -> Option<f64> {
best_match(
observations,
peptide_mass,
predicted_rt,
mass_shift,
mass_sigma,
rt_sigma,
mass_gate,
)
.map(|m| m.deviation)
}
pub fn best_match(
observations: &[Observation],
peptide_mass: f64,
predicted_rt: f64,
mass_shift: f64,
mass_sigma: f64,
rt_sigma: f64,
mass_gate: f64,
) -> Option<Match> {
observations
.iter()
.enumerate()
.filter_map(|(i, o)| {
let err = (peptide_mass - o.feature.mass) / o.feature.mass * 1e6;
let rt = o.feature.rt - predicted_rt;
((err - mass_shift).abs() <= mass_gate).then(|| Match {
best: i,
mass_error: err,
rt_error: rt,
deviation: ((err - mass_shift) / mass_sigma).powi(2) + (rt / rt_sigma).powi(2),
})
})
.min_by(|a, b| {
a.deviation
.total_cmp(&b.deviation)
.then_with(|| {
observations[b.best]
.feature
.combined_score
.total_cmp(&observations[a.best].feature.combined_score)
})
.then_with(|| {
(
observations[a.best].run_id,
observations[a.best].observation_id,
)
.cmp(&(
observations[b.best].run_id,
observations[b.best].observation_id,
))
})
})
}
pub fn calibration_rt(
observations: &[Observation],
peptide_mass: f64,
predicted_rt: f64,
mass_shift: f64,
mass_gate: f64,
) -> Option<f64> {
observations
.iter()
.filter(|o| {
((peptide_mass - o.feature.mass) / o.feature.mass * 1e6 - mass_shift).abs() <= mass_gate
})
.min_by(|a, b| {
(a.feature.rt - predicted_rt)
.abs()
.total_cmp(&(b.feature.rt - predicted_rt).abs())
.then(a.feature.rt.total_cmp(&b.feature.rt))
})
.map(|o| o.feature.rt)
}
#[cfg(test)]
mod tests {
use super::*;
fn obs(run: usize, mass: f64, rt: f64) -> Observation {
Observation {
bootstrap_eligible: true,
run_id: run,
observation_id: 0,
feature: Feature {
mass,
mz: mass / 2.0 + 1.007276466621,
charge: 2,
rt,
im: None,
combined_score: 0.9,
n_contributing_runs: 1,
n_isotopes: Some(3.0),
n_scans: Some(10.0),
isoerror: None,
isoerror2: None,
rt_width: None,
cosine_score: Some(0.9),
isotope_score: Some(0.9),
ppm_error: None,
neutron_offset: None,
intensities: vec![100.0],
},
}
}
fn ev(o: &[Observation]) -> Evidence {
evidence(o, 1000.0, 10.0, 0.0, 1.0, 1.0, 3.0).unwrap()
}
#[test]
fn alternatives_and_duplicate_sources_do_not_inflate_support() {
let a = obs(0, 1000.0, 10.0);
let b = obs(1, 1000.0005, 10.1);
let base = ev(&[a.clone(), b.clone()]);
let copies = ev(&[a.clone(), obs(0, 1000.0, 12.0), b, a]);
assert_eq!(base.values, copies.values);
assert_eq!(base.deviation, copies.deviation);
}
#[test]
fn candidate_evidence_changes_when_an_additional_run_disagrees() {
let good = ev(&[obs(0, 1000.0, 10.0), obs(1, 1000.0005, 10.1)]);
let bad = ev(&[obs(0, 1000.0, 10.0), obs(1, 1000.006, 13.0)]);
assert_eq!(good.deviation, bad.deviation); assert!(good.values[2] > bad.values[2]);
assert!(good.values[4] < bad.values[4]);
}
#[test]
fn mass_and_rt_must_come_from_the_same_observation() {
let o = [obs(0, 1000.0, 20.0), obs(0, 1000.01, 10.0)];
assert!(best_deviation(&o, 1000.0, 10.0, 0.0, 1.0, 1.0, 3.0).unwrap() > 90.0);
assert!(ev(&o).deviation > 90.0);
}
#[test]
fn singleton_is_valid_and_order_does_not_change_summary() {
assert_eq!(ev(&[obs(0, 1000.0, 10.0)]).values[13], 0.0);
let a = obs(0, 1000.0, 10.0);
let b = obs(1, 1000.0005, 10.1);
assert_eq!(ev(&[a.clone(), b.clone()]).values, ev(&[b, a]).values);
}
#[test]
fn calibration_uses_the_mass_compatible_original_observation() {
let o = [obs(0, 1000.01, 10.0), obs(1, 1000.0, 10.2)];
assert_eq!(calibration_rt(&o, 1000.0, 10.0, 0.0, 3.0), Some(10.2));
assert_eq!(calibration_rt(&o[..1], 1000.0, 10.0, 0.0, 3.0), None);
}
}