1use crate::cv;
12use crate::enums::ScanMode;
13use crate::source::{ChromatogramIter, SpectrumIter, SpectrumSource};
14use crate::types::{CvTerm, RunMetadata, SpectrumRecord};
15
16pub struct Centroided<S: SpectrumSource> {
20 inner: S,
21 min_intensity: f32,
22}
23
24impl<S: SpectrumSource> Centroided<S> {
25 pub fn new(inner: S) -> Self {
27 Self {
28 inner,
29 min_intensity: 0.0,
30 }
31 }
32
33 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 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 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
97fn 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 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 let mz: Vec<f64> = vec![
226 99.0, 99.5, 100.0, 100.5, 101.0, 199.0, 199.5, 200.0, 200.5, 201.0, ];
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 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 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 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 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}