1use crate::input::Feature;
4
5#[derive(Debug, Clone)]
6pub struct Observation {
7 pub bootstrap_eligible: bool,
10 pub run_id: usize,
12 pub observation_id: usize,
13 pub feature: Feature,
15}
16
17#[derive(Debug, Clone)]
18pub struct Evidence {
19 pub best: usize,
20 pub mass_error: f64,
21 pub rt_error: f64,
22 pub deviation: f64,
23 pub values: [f32; 14],
25}
26
27pub struct Match {
28 pub best: usize,
29 pub mass_error: f64,
30 pub rt_error: f64,
31 pub deviation: f64,
32}
33
34pub const NAMES: [&str; 14] = [
35 "direct_runs",
36 "compatible_runs",
37 "compatible_fraction",
38 "second_deviation",
39 "median_deviation",
40 "median_mass_error",
41 "mass_mad",
42 "median_rt_error",
43 "rt_mad",
44 "median_quality",
45 "median_isotopes",
46 "median_scans",
47 "best_quality",
48 "has_second_run",
49];
50
51pub fn restrict_observations(matrix: &mut crate::FeatureMatrix, cfg: &crate::MatchConfig) {
55 let Some(groups) = matrix.joint_evidence.take() else {
56 return;
57 };
58 let cells = matrix.joint_cell_evidence.take();
59 let mut retained_cells = Vec::new();
60 let mut features = Vec::new();
61 let mut retained = Vec::new();
62 for (i, (mut f, mut group)) in std::mem::take(&mut matrix.features)
63 .into_iter()
64 .zip(groups)
65 .enumerate()
66 {
67 let before = group.len();
68 group.retain(|o| crate::matching::passes(&o.feature, cfg));
69 if group.is_empty() {
70 continue;
71 }
72 if group.len() != before {
73 let representative = group
74 .iter()
75 .max_by(|a, b| {
76 a.feature
77 .n_isotopes
78 .is_some_and(|n| n >= 3.0)
79 .cmp(&b.feature.n_isotopes.is_some_and(|n| n >= 3.0))
80 .then(
81 a.feature
82 .combined_score
83 .total_cmp(&b.feature.combined_score),
84 )
85 .then_with(|| {
86 (b.run_id, b.observation_id).cmp(&(a.run_id, a.observation_id))
87 })
88 })
89 .unwrap();
90 let intensities = f.intensities;
91 f = representative.feature.clone();
92 f.intensities = intensities;
93 f.n_contributing_runs = group
94 .iter()
95 .map(|o| o.run_id)
96 .collect::<std::collections::HashSet<_>>()
97 .len() as u32;
98 }
99 if let Some(cells) = &cells {
100 retained_cells.push(cells[i]);
101 }
102 features.push(f);
103 retained.push(group);
104 }
105 matrix.features = features;
106 matrix.joint_evidence = Some(retained);
107 matrix.joint_cell_evidence = cells.map(|_| retained_cells);
108}
109
110fn med(v: &mut [f64]) -> f64 {
111 v.sort_unstable_by(f64::total_cmp);
112 let n = v.len();
113 if n.is_multiple_of(2) {
114 (v[n / 2 - 1] + v[n / 2]) / 2.0
115 } else {
116 v[n / 2]
117 }
118}
119
120pub fn evidence(
124 observations: &[Observation],
125 peptide_mass: f64,
126 predicted_rt: f64,
127 mass_shift: f64,
128 mass_sigma: f64,
129 rt_sigma: f64,
130 mass_gate: f64,
131) -> Option<Evidence> {
132 let mut by_run: Vec<(usize, usize, f64, f64, f64)> = Vec::new();
133 for (i, o) in observations.iter().enumerate() {
134 let mass = (peptide_mass - o.feature.mass) / o.feature.mass * 1e6;
135 let rt = o.feature.rt - predicted_rt;
136 let mz = (mass - mass_shift) / mass_sigma;
137 let dev = mz * mz + (rt / rt_sigma).powi(2);
138 if let Some(prior) = by_run.iter_mut().find(|p| p.0 == o.run_id) {
141 let inside = (mass - mass_shift).abs() <= mass_gate;
142 let prior_inside = (prior.3 - mass_shift).abs() <= mass_gate;
143 let better_dev = dev < prior.2
144 || (dev == prior.2
145 && o.feature.combined_score > observations[prior.1].feature.combined_score);
146 if (inside && !prior_inside) || (inside == prior_inside && better_dev) {
147 *prior = (o.run_id, i, dev, mass, rt);
148 }
149 } else {
150 by_run.push((o.run_id, i, dev, mass, rt));
151 }
152 }
153 by_run.sort_unstable_by(|a, b| {
154 ((b.3 - mass_shift).abs() <= mass_gate)
155 .cmp(&((a.3 - mass_shift).abs() <= mass_gate))
156 .then(a.2.total_cmp(&b.2))
157 .then_with(|| {
158 observations[b.1]
159 .feature
160 .combined_score
161 .total_cmp(&observations[a.1].feature.combined_score)
162 })
163 .then(a.0.cmp(&b.0))
164 });
165 let best = by_run
166 .iter()
167 .find(|p| (p.3 - mass_shift).abs() <= mass_gate)?;
168 let n = by_run.len();
169 let compatible = by_run
170 .iter()
171 .filter(|p| p.2 <= 9.0 && (p.3 - mass_shift).abs() <= mass_gate)
172 .count();
173 let (mut mass, mut rt, mut devs, mut quality, mut isotopes, mut scans) = (
174 Vec::new(),
175 Vec::new(),
176 Vec::new(),
177 Vec::new(),
178 Vec::new(),
179 Vec::new(),
180 );
181 for p in &by_run {
182 let f = &observations[p.1].feature;
183 mass.push(p.3 - mass_shift);
184 rt.push(p.4);
185 devs.push(p.2.min(100.0));
186 quality.push(f.combined_score);
187 isotopes.push(f.n_isotopes.unwrap_or(0.0));
188 scans.push(f.n_scans.unwrap_or(0.0));
189 }
190 let mm = med(&mut mass);
191 let rm = med(&mut rt);
192 for x in &mut mass {
193 *x = (*x - mm).abs();
194 }
195 for x in &mut rt {
196 *x = (*x - rm).abs();
197 }
198 Some(Evidence {
199 best: best.1,
200 mass_error: best.3,
201 rt_error: best.4,
202 deviation: best.2,
203 values: [
204 (n as f64).ln_1p() as f32,
205 (compatible as f64).ln_1p() as f32,
206 compatible as f32 / n as f32,
207 by_run.get(1).map_or(100.0, |p| p.2.min(100.0)) as f32,
208 med(&mut devs) as f32,
209 mm as f32,
210 med(&mut mass) as f32,
211 rm as f32,
212 med(&mut rt) as f32,
213 med(&mut quality) as f32,
214 med(&mut isotopes).ln_1p() as f32,
215 med(&mut scans).ln_1p() as f32,
216 observations[best.1].feature.combined_score as f32,
217 (n > 1) as u8 as f32,
218 ],
219 })
220}
221
222pub fn best_deviation(
225 observations: &[Observation],
226 peptide_mass: f64,
227 predicted_rt: f64,
228 mass_shift: f64,
229 mass_sigma: f64,
230 rt_sigma: f64,
231 mass_gate: f64,
232) -> Option<f64> {
233 best_match(
234 observations,
235 peptide_mass,
236 predicted_rt,
237 mass_shift,
238 mass_sigma,
239 rt_sigma,
240 mass_gate,
241 )
242 .map(|m| m.deviation)
243}
244
245pub fn best_match(
246 observations: &[Observation],
247 peptide_mass: f64,
248 predicted_rt: f64,
249 mass_shift: f64,
250 mass_sigma: f64,
251 rt_sigma: f64,
252 mass_gate: f64,
253) -> Option<Match> {
254 observations
255 .iter()
256 .enumerate()
257 .filter_map(|(i, o)| {
258 let err = (peptide_mass - o.feature.mass) / o.feature.mass * 1e6;
259 let rt = o.feature.rt - predicted_rt;
260 ((err - mass_shift).abs() <= mass_gate).then(|| Match {
261 best: i,
262 mass_error: err,
263 rt_error: rt,
264 deviation: ((err - mass_shift) / mass_sigma).powi(2) + (rt / rt_sigma).powi(2),
265 })
266 })
267 .min_by(|a, b| {
268 a.deviation
269 .total_cmp(&b.deviation)
270 .then_with(|| {
271 observations[b.best]
272 .feature
273 .combined_score
274 .total_cmp(&observations[a.best].feature.combined_score)
275 })
276 .then_with(|| {
277 (
278 observations[a.best].run_id,
279 observations[a.best].observation_id,
280 )
281 .cmp(&(
282 observations[b.best].run_id,
283 observations[b.best].observation_id,
284 ))
285 })
286 })
287}
288
289pub fn calibration_rt(
292 observations: &[Observation],
293 peptide_mass: f64,
294 predicted_rt: f64,
295 mass_shift: f64,
296 mass_gate: f64,
297) -> Option<f64> {
298 observations
299 .iter()
300 .filter(|o| {
301 ((peptide_mass - o.feature.mass) / o.feature.mass * 1e6 - mass_shift).abs() <= mass_gate
302 })
303 .min_by(|a, b| {
304 (a.feature.rt - predicted_rt)
305 .abs()
306 .total_cmp(&(b.feature.rt - predicted_rt).abs())
307 .then(a.feature.rt.total_cmp(&b.feature.rt))
308 })
309 .map(|o| o.feature.rt)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 fn obs(run: usize, mass: f64, rt: f64) -> Observation {
316 Observation {
317 bootstrap_eligible: true,
318 run_id: run,
319 observation_id: 0,
320 feature: Feature {
321 mass,
322 mz: mass / 2.0 + 1.007276466621,
323 charge: 2,
324 rt,
325 im: None,
326 combined_score: 0.9,
327 n_contributing_runs: 1,
328 n_isotopes: Some(3.0),
329 n_scans: Some(10.0),
330 isoerror: None,
331 isoerror2: None,
332 rt_width: None,
333 cosine_score: Some(0.9),
334 isotope_score: Some(0.9),
335 ppm_error: None,
336 neutron_offset: None,
337 intensities: vec![100.0],
338 },
339 }
340 }
341 fn ev(o: &[Observation]) -> Evidence {
342 evidence(o, 1000.0, 10.0, 0.0, 1.0, 1.0, 3.0).unwrap()
343 }
344 #[test]
345 fn alternatives_and_duplicate_sources_do_not_inflate_support() {
346 let a = obs(0, 1000.0, 10.0);
347 let b = obs(1, 1000.0005, 10.1);
348 let base = ev(&[a.clone(), b.clone()]);
349 let copies = ev(&[a.clone(), obs(0, 1000.0, 12.0), b, a]);
350 assert_eq!(base.values, copies.values);
351 assert_eq!(base.deviation, copies.deviation);
352 }
353 #[test]
354 fn candidate_evidence_changes_when_an_additional_run_disagrees() {
355 let good = ev(&[obs(0, 1000.0, 10.0), obs(1, 1000.0005, 10.1)]);
356 let bad = ev(&[obs(0, 1000.0, 10.0), obs(1, 1000.006, 13.0)]);
357 assert_eq!(good.deviation, bad.deviation); assert!(good.values[2] > bad.values[2]);
359 assert!(good.values[4] < bad.values[4]);
360 }
361 #[test]
362 fn mass_and_rt_must_come_from_the_same_observation() {
363 let o = [obs(0, 1000.0, 20.0), obs(0, 1000.01, 10.0)];
364 assert!(best_deviation(&o, 1000.0, 10.0, 0.0, 1.0, 1.0, 3.0).unwrap() > 90.0);
365 assert!(ev(&o).deviation > 90.0);
366 }
367 #[test]
368 fn singleton_is_valid_and_order_does_not_change_summary() {
369 assert_eq!(ev(&[obs(0, 1000.0, 10.0)]).values[13], 0.0);
370 let a = obs(0, 1000.0, 10.0);
371 let b = obs(1, 1000.0005, 10.1);
372 assert_eq!(ev(&[a.clone(), b.clone()]).values, ev(&[b, a]).values);
373 }
374 #[test]
375 fn calibration_uses_the_mass_compatible_original_observation() {
376 let o = [obs(0, 1000.01, 10.0), obs(1, 1000.0, 10.2)];
377 assert_eq!(calibration_rt(&o, 1000.0, 10.0, 0.0, 3.0), Some(10.2));
378 assert_eq!(calibration_rt(&o[..1], 1000.0, 10.0, 0.0, 3.0), None);
379 }
380}