1use crate::enums::ScanMode;
12use crate::source::SpectrumSource;
13use crate::types::{ChromatogramRecord, RunMetadata, SpectrumRecord};
14
15pub struct Centroided<S: SpectrumSource> {
19 inner: S,
20 min_intensity: f32,
21}
22
23impl<S: SpectrumSource> Centroided<S> {
24 pub fn new(inner: S) -> Self {
26 Self {
27 inner,
28 min_intensity: 0.0,
29 }
30 }
31
32 pub fn with_min_intensity(mut self, min_intensity: f32) -> Self {
34 self.min_intensity = min_intensity;
35 self
36 }
37}
38
39impl<S: SpectrumSource> SpectrumSource for Centroided<S> {
40 fn run_metadata(&self) -> RunMetadata {
41 self.inner.run_metadata()
42 }
43
44 fn iter_spectra<'a>(&'a mut self) -> Box<dyn Iterator<Item = SpectrumRecord> + 'a> {
45 let min_intensity = self.min_intensity;
46 Box::new(
47 self.inner
48 .iter_spectra()
49 .map(move |rec| centroid_record(rec, min_intensity)),
50 )
51 }
52
53 fn iter_chromatograms<'a>(&'a mut self) -> Box<dyn Iterator<Item = ChromatogramRecord> + 'a> {
54 self.inner.iter_chromatograms()
55 }
56
57 fn spectrum_count_hint(&self) -> Option<usize> {
58 self.inner.spectrum_count_hint()
59 }
60
61 fn additional_processing_steps(&self) -> Vec<(&'static str, &'static str)> {
62 let mut steps = self.inner.additional_processing_steps();
63 steps.push(("MS:1000035", "peak picking"));
64 steps
65 }
66}
67
68fn centroid_record(mut rec: SpectrumRecord, min_intensity: f32) -> SpectrumRecord {
69 if rec.scan_mode == Some(ScanMode::Centroid) {
70 return rec;
71 }
72
73 let (mz, intensity, inv_mobility_per_peak) = pick_peaks(
74 &rec.mz,
75 &rec.intensity,
76 rec.inv_mobility_per_peak.as_deref(),
77 min_intensity,
78 );
79
80 rec.mz = mz;
81 rec.intensity = intensity;
82 rec.inv_mobility_per_peak = inv_mobility_per_peak;
83 rec.scan_mode = Some(ScanMode::Centroid);
84 rec.total_ion_current = None;
88 rec.base_peak_mz = None;
89 rec.base_peak_intensity = None;
90 rec.low_mz = None;
91 rec.high_mz = None;
92 rec
93}
94
95fn pick_peaks(
105 mz: &[f64],
106 intensity: &[f32],
107 inv_mobility_per_peak: Option<&[f32]>,
108 min_intensity: f32,
109) -> (Vec<f64>, Vec<f32>, Option<Vec<f32>>) {
110 let n = mz.len();
111 let mut out_mz = Vec::new();
112 let mut out_intensity = Vec::new();
113 let mut out_im = inv_mobility_per_peak.map(|_| Vec::new());
114
115 for i in 0..n {
116 let no_smaller_than_left = i == 0 || intensity[i] >= intensity[i - 1];
117 let no_smaller_than_right = i == n - 1 || intensity[i] >= intensity[i + 1];
118 let beats_a_neighbor = n == 1
119 || (i > 0 && intensity[i] > intensity[i - 1])
120 || (i < n - 1 && intensity[i] > intensity[i + 1]);
121 if !(no_smaller_than_left && no_smaller_than_right && beats_a_neighbor) {
122 continue;
123 }
124 if intensity[i] < min_intensity {
125 continue;
126 }
127
128 let lo = i.saturating_sub(1);
129 let hi = (i + 1).min(n - 1);
130 let mut weighted_mz = 0.0f64;
131 let mut weighted_im = 0.0f64;
132 let mut weight_sum = 0.0f64;
133 for j in lo..=hi {
134 let w = f64::from(intensity[j]);
135 weighted_mz += mz[j] * w;
136 weight_sum += w;
137 if let Some(im) = inv_mobility_per_peak {
138 weighted_im += f64::from(im[j]) * w;
139 }
140 }
141 let centroid_mz = if weight_sum > 0.0 {
142 weighted_mz / weight_sum
143 } else {
144 mz[i]
145 };
146 out_mz.push(centroid_mz);
147 out_intensity.push(intensity[i]);
148 if let Some(out) = out_im.as_mut() {
149 #[allow(clippy::cast_possible_truncation)]
150 let centroid_im = if weight_sum > 0.0 {
151 (weighted_im / weight_sum) as f32
152 } else {
153 inv_mobility_per_peak.expect("out_im is Some only when inv_mobility_per_peak is")[i]
154 };
155 out.push(centroid_im);
156 }
157 }
158
159 (out_mz, out_intensity, out_im)
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::conformance::assert_source_invariants;
166 use crate::{CvTerm, MsPower, Polarity};
167
168 struct OneSpectrumSource {
169 meta: RunMetadata,
170 rec: Option<SpectrumRecord>,
171 }
172
173 impl OneSpectrumSource {
174 fn new(rec: SpectrumRecord) -> Self {
175 Self {
176 meta: RunMetadata {
177 source_file_name: "toy.raw".into(),
178 source_file_format: CvTerm::new("MS:1000563", "Thermo RAW format"),
179 native_id_format: CvTerm::new("MS:1000768", "Thermo nativeID format"),
180 instrument: CvTerm::new("MS:1001911", "Q Exactive"),
181 instrument_serial_number: None,
182 software_name: "toy-writer".into(),
183 software_version: "0.0.0".into(),
184 acquisition_software_name: None,
185 acquisition_software_version: None,
186 start_timestamp: None,
187 mobility_array_kind: None,
188 analyzers: Vec::new(),
189 extra: Default::default(),
190 },
191 rec: Some(rec),
192 }
193 }
194 }
195
196 impl SpectrumSource for OneSpectrumSource {
197 fn run_metadata(&self) -> RunMetadata {
198 self.meta.clone()
199 }
200
201 fn iter_spectra<'a>(&'a mut self) -> Box<dyn Iterator<Item = SpectrumRecord> + 'a> {
202 Box::new(self.rec.take().into_iter())
203 }
204
205 fn spectrum_count_hint(&self) -> Option<usize> {
206 Some(usize::from(self.rec.is_some()))
207 }
208 }
209
210 fn profile_spectrum() -> SpectrumRecord {
211 let mz: Vec<f64> = vec![
216 99.0, 99.5, 100.0, 100.5, 101.0, 199.0, 199.5, 200.0, 200.5, 201.0, ];
219 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];
220 SpectrumRecord {
221 index: 0,
222 scan_number: 1,
223 native_id: "scan=1".into(),
224 ms_level: MsPower::Ms1.ms_level(),
225 polarity: Some(Polarity::Positive),
226 scan_mode: Some(ScanMode::Profile),
227 analyzer: None,
228 acquisition_event_id: None,
229 filter: None,
230 retention_time_sec: 1.0,
231 total_ion_current: Some(intensity.iter().map(|&v| v as f64).sum()),
232 base_peak_mz: Some(100.0),
233 base_peak_intensity: Some(100.0),
234 low_mz: Some(99.0),
235 high_mz: Some(201.0),
236 ion_injection_time_ms: None,
237 inv_mobility: None,
238 faims_cv: None,
239 precursor: None,
240 mz,
241 intensity,
242 inv_mobility_per_peak: None,
243 extra: Default::default(),
244 }
245 }
246
247 #[test]
248 fn centroids_profile_spectrum_at_known_apexes() {
249 let mut src = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
250 let recs: Vec<_> = src.iter_spectra().collect();
251 assert_eq!(recs.len(), 1);
252 let rec = &recs[0];
253
254 assert_eq!(rec.scan_mode, Some(ScanMode::Centroid));
255 assert!(
256 rec.mz.len() < 10,
257 "expected fewer peaks after centroiding, got {}",
258 rec.mz.len()
259 );
260 assert_eq!(rec.mz.len(), rec.intensity.len());
261
262 let near_100 = rec.mz.iter().any(|&m| (m - 100.0).abs() < 0.6);
263 let near_200 = rec.mz.iter().any(|&m| (m - 200.0).abs() < 0.6);
264 assert!(near_100, "no picked peak near mz=100: {:?}", rec.mz);
265 assert!(near_200, "no picked peak near mz=200: {:?}", rec.mz);
266
267 assert_eq!(rec.total_ion_current, None);
270 assert_eq!(rec.base_peak_mz, None);
271 }
272
273 #[test]
274 fn already_centroided_spectrum_passes_through_unchanged() {
275 let mut rec = profile_spectrum();
276 rec.scan_mode = Some(ScanMode::Centroid);
277 let original_mz = rec.mz.clone();
278
279 let mut src = Centroided::new(OneSpectrumSource::new(rec));
280 let recs: Vec<_> = src.iter_spectra().collect();
281 assert_eq!(recs[0].mz, original_mz);
282 }
283
284 #[test]
285 fn additional_processing_steps_reports_peak_picking() {
286 let src = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
287 assert_eq!(
288 src.additional_processing_steps(),
289 vec![("MS:1000035", "peak picking")]
290 );
291 }
292
293 #[test]
294 fn additional_processing_steps_accumulate_through_nested_adapters() {
295 let inner = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
299 let outer = Centroided::new(inner);
300 assert_eq!(
301 outer.additional_processing_steps(),
302 vec![
303 ("MS:1000035", "peak picking"),
304 ("MS:1000035", "peak picking")
305 ]
306 );
307 }
308
309 #[test]
310 fn wrapped_source_still_satisfies_conformance_invariants() {
311 let mut src = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
312 let n = assert_source_invariants(&mut src).expect("conformance");
313 assert_eq!(n, 1);
314 }
315
316 #[test]
317 fn composes_with_the_streaming_mzml_writer() {
318 let mut src = Centroided::new(OneSpectrumSource::new(profile_spectrum()));
321 let mut buf = Vec::new();
322 crate::write_mzml(&mut src, &mut buf).expect("write_mzml");
323 let xml = String::from_utf8(buf).expect("utf8");
324
325 assert!(xml.contains(r#"<spectrumList count="1""#));
326 assert!(xml.contains(
327 r#"<cvParam cvRef="MS" accession="MS:1000127" name="centroid spectrum" value=""/>"#
328 ));
329 assert!(
330 !xml.contains(
331 r#"<cvParam cvRef="MS" accession="MS:1000128" name="profile spectrum" value=""/>"#
332 ),
333 "output should not still claim profile mode after centroiding"
334 );
335 assert!(xml.contains(
339 r#"<cvParam cvRef="MS" accession="MS:1000035" name="peak picking" value=""/>"#
340 ));
341 }
342}