1use std::collections::HashMap;
32
33use crate::source::SpectrumSource;
34use crate::types::SpectrumRecord;
35
36const FLOAT_REL_TOL: f64 = 1e-4;
38
39#[derive(Debug)]
46#[non_exhaustive]
47#[allow(missing_docs)]
48pub enum ConformanceError {
49 #[non_exhaustive]
52 Source {
53 position: usize,
54 error: crate::Error,
55 },
56 #[non_exhaustive]
58 PeakArrayLengthMismatch {
59 native_id: String,
60 mz_len: usize,
61 intensity_len: usize,
62 },
63 #[non_exhaustive]
65 MobilityArrayLengthMismatch {
66 native_id: String,
67 mz_len: usize,
68 mobility_len: usize,
69 },
70 #[non_exhaustive]
72 TicMismatch {
73 native_id: String,
74 declared: f64,
75 computed: f64,
76 },
77 #[non_exhaustive]
79 BasePeakIntensityMismatch {
80 native_id: String,
81 declared: f64,
82 computed: f64,
83 },
84 #[non_exhaustive]
86 MissingPrecursor { native_id: String, ms_level: u32 },
87 #[non_exhaustive]
89 RetentionTimeNonMonotonic {
90 prefix: String,
91 previous: f64,
92 current: f64,
93 native_id: String,
94 },
95 #[non_exhaustive]
97 IndexSequence {
98 native_id: String,
99 previous: Option<usize>,
100 current: usize,
101 },
102 #[non_exhaustive]
104 EmptySpectrum { native_id: String },
105}
106
107impl std::fmt::Display for ConformanceError {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 Self::Source { position, error } => {
111 write!(f, "source error at stream position {position}: {error}")
112 }
113 Self::PeakArrayLengthMismatch {
114 native_id,
115 mz_len,
116 intensity_len,
117 } => write!(
118 f,
119 "{native_id}: mz.len()={mz_len} != intensity.len()={intensity_len}"
120 ),
121 Self::MobilityArrayLengthMismatch {
122 native_id,
123 mz_len,
124 mobility_len,
125 } => write!(
126 f,
127 "{native_id}: inv_mobility_per_peak.len()={mobility_len} != mz.len()={mz_len}"
128 ),
129 Self::TicMismatch {
130 native_id,
131 declared,
132 computed,
133 } => {
134 write!(
135 f,
136 "{native_id}: declared TIC {declared} != computed {computed}"
137 )
138 }
139 Self::BasePeakIntensityMismatch {
140 native_id,
141 declared,
142 computed,
143 } => write!(
144 f,
145 "{native_id}: declared base-peak intensity {declared} != max(intensity) {computed}"
146 ),
147 Self::MissingPrecursor {
148 native_id,
149 ms_level,
150 } => write!(f, "{native_id}: ms_level={ms_level} but no precursor info"),
151 Self::RetentionTimeNonMonotonic {
152 prefix,
153 previous,
154 current,
155 native_id,
156 } => write!(
157 f,
158 "{native_id}: RT regressed in stream '{prefix}' ({previous} -> {current})"
159 ),
160 Self::IndexSequence {
161 native_id,
162 previous,
163 current,
164 } => write!(
165 f,
166 "{native_id}: index sequence broken (prev={previous:?}, current={current})"
167 ),
168 Self::EmptySpectrum { native_id } => write!(f, "{native_id}: empty spectrum"),
169 }
170 }
171}
172
173impl std::error::Error for ConformanceError {
174 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
175 match self {
176 Self::Source { error, .. } => Some(error),
177 _ => None,
178 }
179 }
180}
181
182fn rel_close(a: f64, b: f64, tol: f64) -> bool {
183 let scale = a.abs().max(b.abs()).max(1.0);
184 (a - b).abs() <= tol * scale
185}
186
187fn rt_stream_key(native_id: &str) -> String {
194 native_id
195 .split_whitespace()
196 .filter(|tok| !tok.starts_with("scan="))
197 .collect::<Vec<_>>()
198 .join(" ")
199}
200
201pub fn assert_source_invariants<S: SpectrumSource + ?Sized>(
208 src: &mut S,
209) -> Result<usize, ConformanceError> {
210 check_stream(
211 src.iter_spectra().enumerate().map(|(position, item)| {
212 item.map_err(|error| ConformanceError::Source { position, error })
213 }),
214 )
215}
216
217pub fn assert_iter_invariants<I: IntoIterator<Item = SpectrumRecord>>(
220 iter: I,
221) -> Result<usize, ConformanceError> {
222 check_stream(iter.into_iter().map(Ok))
223}
224
225fn check_stream(
226 iter: impl Iterator<Item = Result<SpectrumRecord, ConformanceError>>,
227) -> Result<usize, ConformanceError> {
228 let mut last_index: Option<usize> = None;
229 let mut last_rt: HashMap<String, f64> = HashMap::new();
230 let mut count = 0usize;
231 for spectrum in iter {
232 let spectrum = spectrum?;
233 check_one(&spectrum, last_index, &mut last_rt)?;
234 last_index = Some(spectrum.index);
235 count += 1;
236 }
237 Ok(count)
238}
239
240fn check_one(
241 s: &SpectrumRecord,
242 last_index: Option<usize>,
243 last_rt: &mut HashMap<String, f64>,
244) -> Result<(), ConformanceError> {
245 if s.mz.len() != s.intensity.len() {
246 return Err(ConformanceError::PeakArrayLengthMismatch {
247 native_id: s.native_id.clone(),
248 mz_len: s.mz.len(),
249 intensity_len: s.intensity.len(),
250 });
251 }
252 if let Some(mob) = &s.inv_mobility_per_peak {
253 if mob.len() != s.mz.len() {
254 return Err(ConformanceError::MobilityArrayLengthMismatch {
255 native_id: s.native_id.clone(),
256 mz_len: s.mz.len(),
257 mobility_len: mob.len(),
258 });
259 }
260 }
261 if let Some(tic) = s.total_ion_current {
262 let computed: f64 = s.intensity.iter().map(|&v| v as f64).sum();
263 if !rel_close(tic, computed, FLOAT_REL_TOL) {
264 return Err(ConformanceError::TicMismatch {
265 native_id: s.native_id.clone(),
266 declared: tic,
267 computed,
268 });
269 }
270 }
271 if let Some(bp) = s.base_peak_intensity {
272 if s.intensity.is_empty() {
273 let _ = bp;
275 } else {
276 let computed = s
277 .intensity
278 .iter()
279 .copied()
280 .fold(f32::NEG_INFINITY, f32::max) as f64;
281 if !rel_close(bp, computed, FLOAT_REL_TOL) {
282 return Err(ConformanceError::BasePeakIntensityMismatch {
283 native_id: s.native_id.clone(),
284 declared: bp,
285 computed,
286 });
287 }
288 }
289 }
290 if s.ms_level >= 2 {
291 let has_precursor = match &s.precursor {
292 Some(p) => {
293 p.target_mz.is_some() || p.selected_mz.is_some() || p.precursor_native_id.is_some()
294 }
295 None => false,
296 };
297 if !has_precursor {
298 return Err(ConformanceError::MissingPrecursor {
299 native_id: s.native_id.clone(),
300 ms_level: s.ms_level,
301 });
302 }
303 }
304 match last_index {
305 None => {
306 if s.index != 0 {
307 return Err(ConformanceError::IndexSequence {
308 native_id: s.native_id.clone(),
309 previous: None,
310 current: s.index,
311 });
312 }
313 }
314 Some(prev) => {
315 if s.index <= prev {
316 return Err(ConformanceError::IndexSequence {
317 native_id: s.native_id.clone(),
318 previous: Some(prev),
319 current: s.index,
320 });
321 }
322 }
323 }
324 let key = rt_stream_key(&s.native_id);
325 if let Some(prev) = last_rt.get(&key).copied() {
326 if s.retention_time_sec + 1e-6 < prev {
328 return Err(ConformanceError::RetentionTimeNonMonotonic {
329 prefix: key,
330 previous: prev,
331 current: s.retention_time_sec,
332 native_id: s.native_id.clone(),
333 });
334 }
335 }
336 last_rt.insert(key, s.retention_time_sec);
337 Ok(())
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use crate::types::RunMetadata;
344 use crate::{Polarity, PrecursorInfo, SpectrumRecord};
345
346 struct ToySource(Vec<SpectrumRecord>, Option<usize>);
348
349 impl SpectrumSource for ToySource {
350 fn run_metadata(&self) -> RunMetadata {
351 RunMetadata::new(
352 "toy",
353 crate::CvTerm::new("MS:1000563", "Thermo RAW format"),
354 crate::CvTerm::new("MS:1000768", "Thermo nativeID format"),
355 crate::CvTerm::new("MS:1000483", "Thermo Fisher Scientific instrument model"),
356 "test",
357 "0.0",
358 )
359 }
360 fn iter_spectra<'a>(&'a mut self) -> crate::SpectrumIter<'a> {
361 let fail_at = self.1;
362 Box::new(self.0.clone().into_iter().enumerate().map(move |(i, s)| {
363 if Some(i) == fail_at {
364 Err(crate::Error::Format("bad block".into()))
365 } else {
366 Ok(s)
367 }
368 }))
369 }
370 }
371
372 #[test]
373 fn source_error_fails_conformance() {
374 let mut src = ToySource(vec![ok_spec(0, 1, 1.0), ok_spec(1, 1, 2.0)], Some(1));
375 let err = assert_source_invariants(&mut src).unwrap_err();
376 assert!(
377 matches!(err, ConformanceError::Source { position: 1, .. }),
378 "{err:?}"
379 );
380 assert!(std::error::Error::source(&err).is_some());
381 }
382
383 #[test]
384 fn iter_invariants_accept_plain_records() {
385 assert_eq!(
386 assert_iter_invariants(vec![ok_spec(0, 1, 1.0), ok_spec(1, 2, 2.0)]).unwrap(),
387 2
388 );
389 }
390
391 fn ok_spec(index: usize, ms_level: u32, rt: f64) -> SpectrumRecord {
392 let mz = vec![100.0, 200.0];
393 let intensity = vec![10.0f32, 20.0];
394 SpectrumRecord {
395 index,
396 scan_number: (index + 1) as u32,
397 native_id: format!("controllerType=0 controllerNumber=1 scan={}", index + 1),
398 ms_level,
399 polarity: Some(Polarity::Positive),
400 scan_mode: None,
401 analyzer: None,
402 acquisition_event_id: None,
403 filter: None,
404 retention_time_sec: rt,
405 total_ion_current: Some(30.0),
406 base_peak_mz: Some(200.0),
407 base_peak_intensity: Some(20.0),
408 low_mz: Some(100.0),
409 high_mz: Some(200.0),
410 ion_injection_time_ms: None,
411 inv_mobility: None,
412 faims_cv: None,
413 precursor: if ms_level >= 2 {
414 Some(PrecursorInfo {
415 selected_mz: Some(500.0),
416 ..Default::default()
417 })
418 } else {
419 None
420 },
421 mz,
422 intensity,
423 inv_mobility_per_peak: None,
424 extra: Default::default(),
425 }
426 }
427
428 #[test]
429 fn happy_path_passes() {
430 let mut src = ToySource(
431 vec![ok_spec(0, 1, 1.0), ok_spec(1, 2, 2.0), ok_spec(2, 1, 3.0)],
432 None,
433 );
434 assert_eq!(assert_source_invariants(&mut src).unwrap(), 3);
435 }
436
437 #[test]
438 fn detects_tic_mismatch() {
439 let mut s = ok_spec(0, 1, 1.0);
440 s.total_ion_current = Some(999.0);
441 let mut src = ToySource(vec![s], None);
442 let err = assert_source_invariants(&mut src).unwrap_err();
443 assert!(matches!(err, ConformanceError::TicMismatch { .. }));
444 }
445
446 #[test]
447 fn detects_missing_precursor() {
448 let mut s = ok_spec(0, 2, 1.0);
449 s.precursor = None;
450 let mut src = ToySource(vec![s], None);
451 let err = assert_source_invariants(&mut src).unwrap_err();
452 assert!(matches!(err, ConformanceError::MissingPrecursor { .. }));
453 }
454
455 #[test]
456 fn detects_rt_regression_within_same_stream() {
457 let s0 = ok_spec(0, 1, 5.0);
458 let s1 = ok_spec(1, 1, 2.0);
459 let mut src = ToySource(vec![s0, s1], None);
460 let err = assert_source_invariants(&mut src).unwrap_err();
461 assert!(matches!(
462 err,
463 ConformanceError::RetentionTimeNonMonotonic { .. }
464 ));
465 }
466
467 #[test]
468 fn detects_mobility_length_mismatch() {
469 let mut s = ok_spec(0, 1, 1.0);
470 s.inv_mobility_per_peak = Some(vec![0.5]);
471 let mut src = ToySource(vec![s], None);
472 let err = assert_source_invariants(&mut src).unwrap_err();
473 assert!(matches!(
474 err,
475 ConformanceError::MobilityArrayLengthMismatch { .. }
476 ));
477 }
478}