Skip to main content

openmassspec_core/
centroid.rs

1//! Vendor-neutral profile-to-centroid peak picking.
2//!
3//! Centroiding is a transform over the arrays a [`SpectrumSource`] already
4//! yields (`mz`/`intensity`, and optionally `inv_mobility_per_peak`) - once a
5//! [`SpectrumRecord`] exists, the operation is identical regardless of which
6//! vendor parser produced it. [`Centroided`] wraps any `SpectrumSource` and
7//! applies the transform lazily, one spectrum at a time, so it composes with
8//! the streaming mzML writer and the Arrow bridge without buffering a whole
9//! run in memory.
10
11use crate::cv;
12use crate::enums::ScanMode;
13use crate::source::{ChromatogramIter, SpectrumIter, SpectrumSource};
14use crate::types::{CvTerm, RunMetadata, SpectrumRecord};
15
16/// Wraps a [`SpectrumSource`], centroiding every profile-mode spectrum it
17/// yields. Spectra already tagged [`ScanMode::Centroid`] pass through
18/// unchanged (idempotent).
19pub struct Centroided<S: SpectrumSource> {
20    inner: S,
21    min_intensity: f32,
22}
23
24impl<S: SpectrumSource> Centroided<S> {
25    /// Wrap `inner`, picking every local-maximum peak regardless of height.
26    pub fn new(inner: S) -> Self {
27        Self {
28            inner,
29            min_intensity: 0.0,
30        }
31    }
32
33    /// Discard picked peaks below `min_intensity` (a simple noise floor).
34    pub fn with_min_intensity(mut self, min_intensity: f32) -> Self {
35        self.min_intensity = min_intensity;
36        self
37    }
38}
39
40impl<S: SpectrumSource> SpectrumSource for Centroided<S> {
41    fn run_metadata(&self) -> RunMetadata {
42        self.inner.run_metadata()
43    }
44
45    /// Errors from the wrapped source pass through unchanged.
46    fn iter_spectra<'a>(&'a mut self) -> SpectrumIter<'a> {
47        let min_intensity = self.min_intensity;
48        Box::new(
49            self.inner
50                .iter_spectra()
51                .map(move |rec| rec.map(|rec| centroid_record(rec, min_intensity))),
52        )
53    }
54
55    fn iter_chromatograms<'a>(&'a mut self) -> ChromatogramIter<'a> {
56        self.inner.iter_chromatograms()
57    }
58
59    fn spectrum_count_hint(&self) -> Option<usize> {
60        self.inner.spectrum_count_hint()
61    }
62
63    fn additional_processing_steps(&self) -> Vec<CvTerm> {
64        let mut steps = self.inner.additional_processing_steps();
65        steps.push(cv::PEAK_PICKING.into());
66        steps
67    }
68}
69
70fn centroid_record(mut rec: SpectrumRecord, min_intensity: f32) -> SpectrumRecord {
71    if rec.scan_mode == Some(ScanMode::Centroid) {
72        return rec;
73    }
74
75    let (mz, intensity, inv_mobility_per_peak) = pick_peaks(
76        &rec.mz,
77        &rec.intensity,
78        rec.inv_mobility_per_peak.as_deref(),
79        min_intensity,
80    );
81
82    rec.mz = mz;
83    rec.intensity = intensity;
84    rec.inv_mobility_per_peak = inv_mobility_per_peak;
85    rec.scan_mode = Some(ScanMode::Centroid);
86    // These were derived from the profile arrays; let the mzML writer /
87    // Arrow bridge recompute them from the new centroided arrays instead of
88    // carrying stale values forward (see `SpectrumRecord`'s field docs).
89    rec.total_ion_current = None;
90    rec.base_peak_mz = None;
91    rec.base_peak_intensity = None;
92    rec.low_mz = None;
93    rec.high_mz = None;
94    rec
95}
96
97/// Local-maxima peak picking: a point is a picked peak if it is no smaller
98/// than both neighbors and strictly larger than at least one of them (this
99/// also correctly picks a single-point spectrum, `n == 1`, since the "beats
100/// a neighbor" check is vacuously satisfied). The picked m/z (and, when
101/// present, inverse mobility) is the intensity-weighted centroid over the
102/// apex and its immediate neighbors; the picked intensity is the apex
103/// height. This is intentionally simple - see `timsrust-centroid` /
104/// `pyteomics` for more sophisticated peer approaches if this ever needs to
105/// improve on plain local-maxima picking.
106fn pick_peaks(
107    mz: &[f64],
108    intensity: &[f32],
109    inv_mobility_per_peak: Option<&[f32]>,
110    min_intensity: f32,
111) -> (Vec<f64>, Vec<f32>, Option<Vec<f32>>) {
112    let n = mz.len();
113    let mut out_mz = Vec::new();
114    let mut out_intensity = Vec::new();
115    let mut out_im = inv_mobility_per_peak.map(|_| Vec::new());
116
117    for i in 0..n {
118        let no_smaller_than_left = i == 0 || intensity[i] >= intensity[i - 1];
119        let no_smaller_than_right = i == n - 1 || intensity[i] >= intensity[i + 1];
120        let beats_a_neighbor = n == 1
121            || (i > 0 && intensity[i] > intensity[i - 1])
122            || (i < n - 1 && intensity[i] > intensity[i + 1]);
123        if !(no_smaller_than_left && no_smaller_than_right && beats_a_neighbor) {
124            continue;
125        }
126        if intensity[i] < min_intensity {
127            continue;
128        }
129
130        let lo = i.saturating_sub(1);
131        let hi = (i + 1).min(n - 1);
132        let mut weighted_mz = 0.0f64;
133        let mut weighted_im = 0.0f64;
134        let mut weight_sum = 0.0f64;
135        for j in lo..=hi {
136            let w = f64::from(intensity[j]);
137            weighted_mz += mz[j] * w;
138            weight_sum += w;
139            if let Some(im) = inv_mobility_per_peak {
140                weighted_im += f64::from(im[j]) * w;
141            }
142        }
143        let centroid_mz = if weight_sum > 0.0 {
144            weighted_mz / weight_sum
145        } else {
146            mz[i]
147        };
148        out_mz.push(centroid_mz);
149        out_intensity.push(intensity[i]);
150        if let Some(out) = out_im.as_mut() {
151            #[allow(clippy::cast_possible_truncation)]
152            let centroid_im = if weight_sum > 0.0 {
153                (weighted_im / weight_sum) as f32
154            } else {
155                inv_mobility_per_peak.expect("out_im is Some only when inv_mobility_per_peak is")[i]
156            };
157            out.push(centroid_im);
158        }
159    }
160
161    (out_mz, out_intensity, out_im)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::conformance::assert_source_invariants;
168    use crate::{MsPower, Polarity};
169
170    struct OneSpectrumSource {
171        meta: RunMetadata,
172        rec: Option<SpectrumRecord>,
173        /// Yield an `Err` after the record.
174        then_fail: bool,
175    }
176
177    impl OneSpectrumSource {
178        fn new(rec: SpectrumRecord) -> Self {
179            Self {
180                meta: RunMetadata {
181                    source_file_name: "toy.raw".into(),
182                    source_file_format: CvTerm::new("MS:1000563", "Thermo RAW format"),
183                    native_id_format: CvTerm::new("MS:1000768", "Thermo nativeID format"),
184                    instrument: CvTerm::new("MS:1001911", "Q Exactive"),
185                    instrument_serial_number: None,
186                    software_name: "toy-writer".into(),
187                    software_version: "0.0.0".into(),
188                    acquisition_software_name: None,
189                    acquisition_software_version: None,
190                    start_timestamp: None,
191                    mobility_array_kind: None,
192                    analyzers: Vec::new(),
193                    extra: Default::default(),
194                },
195                rec: Some(rec),
196                then_fail: false,
197            }
198        }
199    }
200
201    impl SpectrumSource for OneSpectrumSource {
202        fn run_metadata(&self) -> RunMetadata {
203            self.meta.clone()
204        }
205
206        fn iter_spectra<'a>(&'a mut self) -> SpectrumIter<'a> {
207            let fail = self.then_fail.then(|| {
208                Err(crate::Error::vendor(std::io::Error::other(
209                    "truncated scan",
210                )))
211            });
212            Box::new(self.rec.take().map(Ok).into_iter().chain(fail))
213        }
214
215        fn spectrum_count_hint(&self) -> Option<usize> {
216            Some(usize::from(self.rec.is_some()))
217        }
218    }
219
220    fn profile_spectrum() -> SpectrumRecord {
221        // Two synthetic Gaussian-ish bumps centered at mz=100 and mz=200,
222        // known peak apexes so we can assert the picked centroids land near
223        // them. Compared only against this hand-built synthetic input, not
224        // any vendor tool or reference converter (clean-room rule).
225        let mz: Vec<f64> = vec![
226            99.0, 99.5, 100.0, 100.5, 101.0, // bump 1, apex at 100.0
227            199.0, 199.5, 200.0, 200.5, 201.0, // bump 2, apex at 200.0
228        ];
229        let intensity: Vec<f32> = vec![10.0, 50.0, 100.0, 50.0, 10.0, 5.0, 40.0, 80.0, 40.0, 5.0];
230        SpectrumRecord {
231            index: 0,
232            scan_number: 1,
233            native_id: "scan=1".into(),
234            ms_level: MsPower::Ms1.ms_level(),
235            polarity: Some(Polarity::Positive),
236            scan_mode: Some(ScanMode::Profile),
237            analyzer: None,
238            acquisition_event_id: None,
239            filter: None,
240            retention_time_sec: 1.0,
241            total_ion_current: Some(intensity.iter().map(|&v| v as f64).sum()),
242            base_peak_mz: Some(100.0),
243            base_peak_intensity: Some(100.0),
244            low_mz: Some(99.0),
245            high_mz: Some(201.0),
246            ion_injection_time_ms: None,
247            inv_mobility: None,
248            faims_cv: None,
249            precursor: None,
250            mz,
251            intensity,
252            inv_mobility_per_peak: None,
253            extra: Default::default(),
254        }
255    }
256
257    #[test]
258    fn centroids_profile_spectrum_at_known_apexes() {
259        let mut src = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
260        let recs: Vec<_> = src.iter_spectra().collect::<crate::Result<_>>().unwrap();
261        assert_eq!(recs.len(), 1);
262        let rec = &recs[0];
263
264        assert_eq!(rec.scan_mode, Some(ScanMode::Centroid));
265        assert!(
266            rec.mz.len() < 10,
267            "expected fewer peaks after centroiding, got {}",
268            rec.mz.len()
269        );
270        assert_eq!(rec.mz.len(), rec.intensity.len());
271
272        let near_100 = rec.mz.iter().any(|&m| (m - 100.0).abs() < 0.6);
273        let near_200 = rec.mz.iter().any(|&m| (m - 200.0).abs() < 0.6);
274        assert!(near_100, "no picked peak near mz=100: {:?}", rec.mz);
275        assert!(near_200, "no picked peak near mz=200: {:?}", rec.mz);
276
277        // Stale profile-derived summary fields must be cleared, not carried
278        // forward, so the writer recomputes them from the new arrays.
279        assert_eq!(rec.total_ion_current, None);
280        assert_eq!(rec.base_peak_mz, None);
281    }
282
283    #[test]
284    fn already_centroided_spectrum_passes_through_unchanged() {
285        let mut rec = profile_spectrum();
286        rec.scan_mode = Some(ScanMode::Centroid);
287        let original_mz = rec.mz.clone();
288
289        let mut src = Centroided::new(OneSpectrumSource::new(rec));
290        let recs: Vec<_> = src.iter_spectra().collect::<crate::Result<_>>().unwrap();
291        assert_eq!(recs[0].mz, original_mz);
292    }
293
294    #[test]
295    fn additional_processing_steps_reports_peak_picking() {
296        let src = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
297        assert_eq!(
298            src.additional_processing_steps(),
299            vec![CvTerm::from(cv::PEAK_PICKING)]
300        );
301    }
302
303    #[test]
304    fn additional_processing_steps_accumulate_through_nested_adapters() {
305        // Wrapping a Centroided source in another Centroided (a no-op in
306        // practice, since it is idempotent) must not drop the inner
307        // adapter's step - steps accumulate outer-to-inner delegation.
308        let inner = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
309        let outer = Centroided::new(inner);
310        assert_eq!(
311            outer.additional_processing_steps(),
312            vec![
313                CvTerm::from(cv::PEAK_PICKING),
314                CvTerm::from(cv::PEAK_PICKING),
315            ]
316        );
317    }
318
319    #[test]
320    fn wrapped_source_still_satisfies_conformance_invariants() {
321        let mut src = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
322        let n = assert_source_invariants(&mut src).expect("conformance");
323        assert_eq!(n, 1);
324    }
325
326    #[test]
327    fn composes_with_the_streaming_mzml_writer() {
328        // Centroided is just another SpectrumSource, so it drops straight
329        // into write_mzml with no special-casing on the writer's part.
330        let mut src = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
331        let mut buf = Vec::new();
332        crate::write_mzml(&mut src, &mut buf).expect("write_mzml");
333        let xml = String::from_utf8(buf).expect("utf8");
334
335        assert!(xml.contains(r#"<spectrumList count="1""#));
336        assert!(xml.contains(
337            r#"<cvParam cvRef="MS" accession="MS:1000127" name="centroid spectrum" value=""/>"#
338        ));
339        assert!(
340            !xml.contains(
341                r#"<cvParam cvRef="MS" accession="MS:1000128" name="profile spectrum" value=""/>"#
342            ),
343            "output should not still claim profile mode after centroiding"
344        );
345        // The dataProcessingList must record that peak picking happened,
346        // not just the blanket "Conversion to mzML" step every writer run
347        // gets.
348        assert!(xml.contains(
349            r#"<cvParam cvRef="MS" accession="MS:1000035" name="peak picking" value=""/>"#
350        ));
351    }
352
353    #[test]
354    fn inner_errors_pass_through() {
355        let mut inner = OneSpectrumSource::new(profile_spectrum());
356        inner.then_fail = true;
357        let mut src = Centroided::new(inner);
358        let results: Vec<_> = src.iter_spectra().collect();
359        assert_eq!(results.len(), 2);
360        assert_eq!(
361            results[0].as_ref().unwrap().scan_mode,
362            Some(ScanMode::Centroid)
363        );
364        let err = results[1].as_ref().unwrap_err();
365        assert!(err.to_string().contains("truncated scan"), "{err}");
366    }
367}