Skip to main content

mzdata_spectrum/
scan_properties.rs

1use std::borrow::Cow;
2use std::collections::{HashMap, HashSet};
3use std::fmt::Display;
4
5use log::warn;
6use num_traits::Float;
7
8// use super::spectrum_types::{CentroidPeakAdapting, DeconvolutedPeakAdapting, SpectrumLike};
9// use crate::io::traits::SpectrumSource;
10use crate::params::{
11    AccessionIntCode, CURIE, ControlledVocabulary, Param, ParamDescribed, ParamLike, ParamValue,
12    Unit,
13};
14use mzdata_meta::DissociationMethodTerm;
15use mzdata_param::{
16    ParamList, curie, find_param_method, impl_param_described, impl_param_described_deferred,
17};
18
19/**
20Describe the initialization stage of an isolation window
21*/
22#[derive(Debug, Clone, Copy, Default)]
23#[repr(i8)]
24pub enum IsolationWindowState {
25    /// The isolation window hasn't been populated at all yet
26    #[default]
27    Unknown = 0,
28    /// The isolation window is defined in terms of offsets relative
29    /// to an isolation target
30    Offset,
31    /// The isolation window is defined in terms of absolute bounds. This
32    /// configuration uses deprecated controlled vocabulary terms.
33    Explicit,
34    /// The isolation window is completely specified, either in terms of the
35    /// isolation target with optional relative boundaries or explicit boundaries
36    Complete,
37    /// No isolation is explicitly specified, meaning all ions were fragmented,
38    /// intentionally selecting everything.
39    NoIsolation,
40}
41
42impl Display for IsolationWindowState {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "{:?}", self)
45    }
46}
47
48#[derive(Debug)]
49/// A helper type that wraps an existing mutable reference to [`IsolationWindow`]
50/// that runs through a state machine updating fields depending upon the accumulated
51/// state of the isolation window.
52///
53/// If relative offsets are used, they will materialize +/- relative to [`Self::target`].
54/// If the deprecated absolute limits are used, they are taken as-is. The relative and
55/// absolute APIs should not be mixed as the state machine does not track absolute/relative
56/// bounds independently.
57///
58/// ```
59/// use mzdata_spectrum::{IsolationWindow, IsolationWindowBuilder};
60///
61/// let mut state = IsolationWindow::default();
62/// IsolationWindowBuilder(&mut state)
63///     .lower_offset(2.0)
64///     .upper_offset(3.0)
65///     .target(500.0);
66/// assert_eq!(state.lower_bound, 498.0);
67/// assert_eq!(state.upper_bound, 503.0);
68/// assert_eq!(state.target, 500.0);
69/// ```
70pub struct IsolationWindowBuilder<'a>(pub &'a mut IsolationWindow);
71
72impl<'a> IsolationWindowBuilder<'a> {
73
74    /// Get the current state of the isolation window
75    pub const fn flags(&self) -> IsolationWindowState {
76        self.0.flags
77    }
78
79    /// Set the target value
80    pub const fn target(self, value: f32) -> Self {
81        self.0.target = value;
82        self.0.flags = match self.0.flags {
83            IsolationWindowState::Unknown => IsolationWindowState::Complete,
84            IsolationWindowState::Explicit => IsolationWindowState::Complete,
85            IsolationWindowState::Offset => {
86                if self.0.lower_bound != 0.0 {
87                    self.0.lower_bound = self.0.target - self.0.lower_bound;
88                }
89                if self.0.upper_bound != 0.0  {
90                    self.0.upper_bound += self.0.target;
91                }
92                IsolationWindowState::Complete
93            }
94            IsolationWindowState::Complete => IsolationWindowState::Complete,
95            IsolationWindowState::NoIsolation => IsolationWindowState::NoIsolation,
96        };
97        self
98    }
99
100    /// Set the lower offset relative to the [`Self::target`]
101    ///
102    /// # Panics
103    /// If one of the bounds is already explicit and the target is not specified
104    pub const fn lower_offset(self, lower_bound: f32) -> Self {
105        match self.0.flags {
106            IsolationWindowState::Unknown => {
107                self.0.flags = IsolationWindowState::Offset;
108                self.0.lower_bound = lower_bound;
109            }
110            // The other offset came first; keep waiting for the target to resolve both.
111            IsolationWindowState::Offset => {
112                self.0.lower_bound = lower_bound;
113            }
114            IsolationWindowState::Complete => {
115                self.0.lower_bound = self.0.target - lower_bound;
116            }
117            IsolationWindowState::Explicit => {
118                if self.0.target == 0.0 {
119                    panic!("Mixed relative and absolute isolation window bounds could not be resolvd. Explicit and relative bounds specified before target");
120                } else {
121                    self.0.lower_bound = self.0.target - lower_bound;
122                }
123            }
124            _ => {}
125        }
126        self
127    }
128
129    /// Set the upper offset relative to the [`Self::target`]
130    ///
131    /// # Panics
132    /// If one of the bounds is already explicit and the target is not specified
133    pub const fn upper_offset(self, upper_bound: f32) -> Self {
134        match self.0.flags {
135            IsolationWindowState::Unknown => {
136                self.0.flags = IsolationWindowState::Offset;
137                self.0.upper_bound = upper_bound;
138            }
139            IsolationWindowState::Offset => {
140                self.0.upper_bound = upper_bound;
141            }
142            IsolationWindowState::Complete => {
143                self.0.upper_bound = self.0.target + upper_bound;
144            }
145            IsolationWindowState::Explicit => {
146                if self.0.target == 0.0 {
147                    panic!("Mixed relative and absolute isolation window bounds could not be resolvd. Explicit and relative bounds specified before target");
148                } else {
149                    self.0.upper_bound = self.0.target + upper_bound;
150                }
151            }
152            _ => {}
153        }
154        self
155    }
156
157    /// Set the explicit lower limit
158    pub const fn lower_limit(self, lower_bound: f32) -> Self {
159        match self.0.flags {
160            IsolationWindowState::Unknown | IsolationWindowState::Explicit | IsolationWindowState::Complete | IsolationWindowState::Offset => {
161                self.0.flags = IsolationWindowState::Explicit;
162                self.0.lower_bound = lower_bound;
163                if self.0.upper_bound != 0.0 && self.0.target != 0.0 {
164                    self.0.flags = IsolationWindowState::Complete
165                }
166            },
167            IsolationWindowState::NoIsolation => {},
168        }
169        self
170    }
171
172    /// Set the explicit upper limit
173    pub const fn upper_limit(self, upper_bound: f32) -> Self {
174        if matches!(
175            self.0.flags,
176            IsolationWindowState::Unknown
177                | IsolationWindowState::Explicit
178                | IsolationWindowState::Offset
179                | IsolationWindowState::Complete
180        ) {
181            self.0.flags = IsolationWindowState::Explicit;
182            self.0.upper_bound = upper_bound;
183            if self.0.lower_bound != 0.0 && self.0.target != 0.0 {
184                self.0.flags = IsolationWindowState::Complete
185            }
186        }
187        self
188    }
189
190    /// Set no isolation, MSe/all ions fragmentation
191    pub const fn no_isolation(self) -> Self {
192        self.0.flags = IsolationWindowState::NoIsolation;
193        self
194    }
195
196    /// Check if the window is finished being configured
197    pub const fn is_complete(&self) -> bool {
198        matches!(
199            self.0.flags,
200            IsolationWindowState::Complete | IsolationWindowState::NoIsolation
201        )
202    }
203
204    /// Set the isolation window unit
205    pub const fn unit(self, unit: Unit) -> Self {
206        if unit.is_unknown() {
207            return self
208        }
209        *self.0.unit_mut() = unit;
210        self
211    }
212}
213
214#[derive(Default, Debug, Clone)]
215#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
216/// The interval around the precursor ion that was isolated in the precursor scan.
217/// Although an isolation window may be specified either with explicit bounds or
218/// offsets from the target, this data structure always uses explicit bounds once
219/// it is in a [`IsolationWindowState::Complete`]. If [`Self::flags`] is
220/// [`IsolationWindowState::NoIsolation`], then the assumption is that the bounds
221/// are undefined and that there is no target.
222pub struct IsolationWindow {
223    /// The recorded isolation window target m/z, which may actually be outside the window
224    pub target: f32,
225    /// The lower m/z boundary of the isolation window.
226    pub lower_bound: f32,
227    /// The upper m/z boundary of the isolation window.
228    pub upper_bound: f32,
229    /// Describes the decision making process used to establish the bounds of the
230    /// window from the source file.
231    #[cfg_attr(feature = "serde", serde(skip))]
232    pub flags: IsolationWindowState,
233    unit: Unit
234}
235
236impl IsolationWindow {
237    pub const fn new(
238        target: f32,
239        lower_bound: f32,
240        upper_bound: f32,
241        flags: IsolationWindowState,
242    ) -> Self {
243        Self {
244            target,
245            lower_bound,
246            upper_bound,
247            flags,
248            unit: Unit::Unknown
249        }
250    }
251
252    /// Get the unit of the isolation window
253    pub const fn unit(&self) -> Unit {
254        self.unit
255    }
256
257    /// Get mutable access to unit of the isolation window
258    pub const fn unit_mut(&mut self) -> &mut Unit {
259        &mut self.unit
260    }
261
262    /// Create an isolation window that is in the "no isolation" state.
263    pub const fn full() -> Self {
264        Self::new(0.0, 0.0, 0.0, IsolationWindowState::NoIsolation)
265    }
266
267    /// Check if there is a registered intent to isolate nothing
268    pub const fn has_isolation(&self) -> bool {
269        !matches!(self.flags, IsolationWindowState::NoIsolation)
270    }
271
272    /// Create a new isolation window around `target` with width `radius`
273    /// units above and below the `target`
274    pub const fn around(target: f32, radius: f32) -> Self {
275        let lower_bound = target - radius;
276        let upper_bound = target + radius;
277        Self::new(
278            target,
279            lower_bound,
280            upper_bound,
281            IsolationWindowState::Complete,
282        )
283    }
284
285    /// Test if `point` is within the isolation window
286    pub fn contains<F: Float>(&self, point: F) -> bool {
287        let point = point.to_f32().unwrap();
288        self.lower_bound <= point && point <= self.upper_bound
289    }
290
291    /// Check if the isolation window has any width, or is just a single point at the
292    /// target. This often means that the bounds are unknown or unspecified. This is
293    /// distinct from [`Self::has_isolation`]
294    pub const fn is_empty(&self) -> bool {
295        self.lower_bound == 0.0 && self.upper_bound == 0.0 && self.has_isolation()
296    }
297}
298
299impl PartialEq for IsolationWindow {
300    fn eq(&self, other: &Self) -> bool {
301        self.target == other.target
302            && self.lower_bound == other.lower_bound
303            && self.upper_bound == other.upper_bound
304    }
305}
306
307/// The m/z range which was scanned
308#[derive(Default, Debug, Clone, PartialEq)]
309#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
310pub struct ScanWindow {
311    /// The minimum m/z scanned
312    pub lower_bound: f32,
313    /// The maximum m/z scanned
314    pub upper_bound: f32,
315}
316
317impl ScanWindow {
318    pub const fn new(lower_bound: f32, upper_bound: f32) -> Self {
319        Self {
320            lower_bound,
321            upper_bound,
322        }
323    }
324
325    /// Test if `point` is between [`Self::lower_bound`] and [`Self::upper_bound`]
326    pub fn contains<F: Float>(&self, point: F) -> bool {
327        let point = point.to_f32().unwrap();
328        self.lower_bound <= point && point <= self.upper_bound
329    }
330
331    /// Test if [`Self::lower_bound`] and [`Self::upper_bound`] are both equal to 0.
332    pub fn is_empty(&self) -> bool {
333        self.lower_bound == 0.0 && self.upper_bound == 0.0
334    }
335}
336
337type ScanWindowList = Vec<ScanWindow>;
338
339#[derive(Default, Debug, Clone, PartialEq)]
340#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
341/// Describes a single scan event. Unless additional post-processing is done,
342/// there is usually only one event per spectrum.
343pub struct ScanEvent {
344    /// When did the scan start acquisition relative to the run start, in minutes
345    pub start_time: f64,
346    /// The time spent filling the ion trapping device, in milliseconds
347    pub injection_time: f32,
348    /// The windows of the primary axis covered by this event
349    pub scan_windows: ScanWindowList,
350    /// The identifier of the [`InstrumentConfiguration`](crate::meta::InstrumentConfiguration) associated
351    /// with the event
352    pub instrument_configuration_id: u32,
353    /// Lazily initialized [`ParamList`]
354    pub params: Option<Box<ParamList>>,
355    /// The identifier for another spectrum in the current run or another file.
356    pub spectrum_reference: Option<Box<str>>,
357}
358
359pub(crate) const ION_MOBILITY_SCAN_TERMS: [CURIE; 4] = [
360    // ion mobility drift time
361    curie!(MS:1002476),
362    // inverse reduced ion mobility drift time
363    curie!(MS:1002815),
364    // FAIMS compensation voltage
365    curie!(MS:1001581),
366    // SELEXION compensation voltage
367    curie!(MS:1003371),
368];
369
370/// Describes an entity that may have a point-level ion mobility coordinate.
371///
372/// The default implementation searches for the following ion mobility kinds in attached
373/// [`Param`]s:
374/// - MS:1002476 ion mobility drift time (milliseconds)
375/// - MS:1002815 inverse reduced ion mobility (Vs/cm^2)
376/// - MS:1001581 FAIMS compensation voltage (volts)
377/// - MS:1003371 SELEXION compensation voltage (volts)
378pub trait IonMobilityMeasure: ParamDescribed {
379
380    /// Get the ion mobility coordinate, if one is available.
381    fn ion_mobility(&'_ self) -> Option<f64> {
382        for u in ION_MOBILITY_SCAN_TERMS {
383            if let Some(v) = self.get_param_by_curie(&u).map(|p| p.value()) {
384                return v.to_f64().map(Some).unwrap_or_else(|e| {
385                    warn!("Failed to parse ion mobility {u} value {v}: {e}");
386                    None
387                });
388            }
389        }
390        None
391    }
392
393    /// Test if an ion mobility value is present
394    fn has_ion_mobility(&self) -> bool {
395        self.ion_mobility().is_some()
396    }
397
398    /// Get the raw [`Param`] representation of the ion mobility measure
399    /// with the unit
400    fn ion_mobility_type(&self) -> Option<&Param> {
401        for u in ION_MOBILITY_SCAN_TERMS {
402            if let Some(v) = self.get_param_by_curie(&u) {
403                return Some(v);
404            }
405        }
406        None
407    }
408}
409
410pub(crate) const PRESET_SCAN_CONFIGURATION: CURIE = curie!(MS:1000616);
411pub(crate) const MASS_RESOLUTION: CURIE = curie!(MS:1000011);
412pub(crate) const FILTER_STRING: CURIE = curie!(MS:1000512);
413pub(crate) const SCAN_TITLE: CURIE = curie!(MS:1000499);
414
415impl ScanEvent {
416    pub fn new(
417        start_time: f64,
418        injection_time: f32,
419        scan_windows: ScanWindowList,
420        instrument_configuration_id: u32,
421        params: Option<Box<ParamList>>,
422    ) -> Self {
423        Self {
424            start_time,
425            injection_time,
426            scan_windows,
427            instrument_configuration_id,
428            params,
429            spectrum_reference: None,
430        }
431    }
432
433    find_param_method!(
434        filter_string,
435        &FILTER_STRING,
436        |p| { p.as_str() },
437        Option<Cow<'_, str>>
438    );
439    find_param_method!(resolution, &MASS_RESOLUTION);
440    find_param_method!(scan_configuration, &PRESET_SCAN_CONFIGURATION);
441}
442
443impl IonMobilityMeasure for ScanEvent {}
444
445type ScanEventList = Vec<ScanEvent>;
446
447/// Represents means by which a spectrum is generated using
448/// one or more instrument analyzers
449#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)]
450#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
451pub enum ScanCombination {
452    // MS:1000795
453    #[default]
454    NoCombination,
455    // MS:1000571
456    Sum,
457    // MS:1000573
458    Median,
459}
460
461impl Display for ScanCombination {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        write!(f, "{:?}", self)
464    }
465}
466
467impl ScanCombination {
468    pub fn from_accession(
469        controlled_vocabulary: ControlledVocabulary,
470        accession: AccessionIntCode,
471    ) -> Option<ScanCombination> {
472        match controlled_vocabulary {
473            ControlledVocabulary::MS => match accession {
474                1000795 => Some(Self::NoCombination),
475                1000571 => Some(Self::Sum),
476                1000573 => Some(Self::Median),
477                _ => None,
478            },
479            _ => None,
480        }
481    }
482
483    pub const fn name(&self) -> &str {
484        match self {
485            ScanCombination::NoCombination => "no combination",
486            ScanCombination::Sum => "sum of spectra",
487            ScanCombination::Median => "median of spectra",
488        }
489    }
490
491    pub const fn accession(&self) -> AccessionIntCode {
492        match self {
493            ScanCombination::NoCombination => 1000795,
494            ScanCombination::Sum => 1000571,
495            ScanCombination::Median => 1000573,
496        }
497    }
498
499    pub fn to_param(&self) -> Param {
500        Param {
501            name: self.name().to_string(),
502            value: Default::default(),
503            accession: Some(self.accession()),
504            controlled_vocabulary: Some(ControlledVocabulary::MS),
505            unit: Unit::Unknown,
506        }
507    }
508}
509
510#[derive(Default, Debug, Clone, PartialEq)]
511#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
512/// Describe the series of acquisition events that constructed the spectrum
513/// being described.
514pub struct Acquisition {
515    /// The list of scans that were acquired and combined to make a spectrum
516    pub scans: ScanEventList,
517    /// How the scans were combined to make the single spectrum.
518    pub combination: ScanCombination,
519    /// Lazily initialized [`ParamList`]
520    pub params: Option<Box<ParamList>>,
521}
522
523impl Acquisition {
524    /// Get the [`ScanEvent::start_time`] of [`Self::first_scan`] or 0.0 if empty.
525    pub fn start_time(&self) -> f64 {
526        self.first_scan().map(|v| v.start_time).unwrap_or_default()
527    }
528
529    /// An alias of `self.scans.first()`
530    pub fn first_scan(&self) -> Option<&ScanEvent> {
531        self.scans.first()
532    }
533
534    /// If the `self.scans.is_empty()`, create a new default [`ScanEvent`], and then
535    /// `self.scans.first_mut()`
536    pub fn first_scan_mut(&mut self) -> Option<&mut ScanEvent> {
537        if self.scans.is_empty() {
538            self.scans.push(ScanEvent::default());
539        }
540        self.scans.first_mut()
541    }
542
543    /// As [`Self::first_scan`] but for `last`
544    pub fn last_scan(&self) -> Option<&ScanEvent> {
545        self.scans.last()
546    }
547
548    /// As [`Self::first_scan_mut`] but for `last`. Also creates a [`ScanEvent`]
549    /// if it is missing.
550    pub fn last_scan_mut(&mut self) -> Option<&mut ScanEvent> {
551        if self.scans.is_empty() {
552            self.scans.push(ScanEvent::default());
553        }
554        self.scans.last_mut()
555    }
556
557    /// Get the list of [`InstrumentConfiguration::id`] referenced by this set of scans
558    pub fn instrument_configuration_ids(&self) -> Vec<u32> {
559        self.scans
560            .iter()
561            .map(|s| s.instrument_configuration_id)
562            .collect()
563    }
564
565    /// The number of scans in the acquisition
566    pub const fn len(&self) -> usize {
567        self.scans.len()
568    }
569
570    /// An alias for `self.scans.is_empty()`
571    pub fn is_empty(&self) -> bool {
572        self.scans.is_empty()
573    }
574
575    /// Iterate over references in [`Self::scans`]
576    pub fn iter(&self) -> std::slice::Iter<'_, ScanEvent> {
577        self.scans.iter()
578    }
579
580    /// Iterate over mutable references in [`Self::scans`]
581    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, ScanEvent> {
582        self.scans.iter_mut()
583    }
584}
585
586/// Describe the precursor ion that this entity represents
587pub trait IonProperties {
588    /// The selected ion m/z
589    fn mz(&self) -> f64;
590    /// The selected ion's estimated neutral mass, given the m/z and charge
591    fn neutral_mass(&self) -> f64;
592    /// The expected charge state of the selected ion, if known
593    fn charge(&self) -> Option<i32>;
594    fn has_charge(&self) -> bool {
595        self.charge().is_some()
596    }
597}
598
599#[derive(Debug, Clone, Default, PartialEq)]
600#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
601/// Describes a single selected ion from a precursor isolation
602pub struct SelectedIon {
603    /// The selected ion's m/z as reported, may not be the monoisotopic peak.
604    pub mz: f64,
605    /// The reported intensity of the selected ion's peak
606    pub intensity: f32,
607    /// The reported precursor ion's charge state. May be absent in
608    /// some source files.
609    pub charge: Option<i32>,
610    /// Additional lazily initialized [`ParamList`]
611    pub params: Option<Box<ParamList>>,
612}
613
614impl IonProperties for SelectedIon {
615    #[inline]
616    fn mz(&self) -> f64 {
617        self.mz
618    }
619
620    #[inline]
621    fn neutral_mass(&self) -> f64 {
622        mzdata_bindata::utils::neutral_mass(self.mz, self.charge.unwrap_or(1))
623    }
624
625    #[inline]
626    fn charge(&self) -> Option<i32> {
627        self.charge
628    }
629}
630
631impl IonMobilityMeasure for SelectedIon {}
632
633#[derive(Debug, Default, Clone, PartialEq)]
634#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
635/// Describes the activation method used to dissociate the precursor ion
636pub struct Activation {
637    _methods: Vec<DissociationMethodTerm>,
638    /// The energy used to dissociate the precursor(s). This will eventually cease to be a raw `f32`.
639    pub energy: f32,
640    /// Any additional [`Param`] describing the activation process
641    pub params: ParamList,
642}
643
644impl Activation {
645    /// Get a reference to the first activation method, if it exists
646    pub fn method(&self) -> Option<&DissociationMethodTerm> {
647        self._methods.first()
648    }
649
650    /// Get a mutable reference to the first activation method, if it exists
651    pub fn method_mut(&mut self) -> Option<&mut DissociationMethodTerm> {
652        self._methods.first_mut()
653    }
654
655    /// Get a slice over all of the activation methods used.
656    pub fn methods(&self) -> &[DissociationMethodTerm] {
657        &self._methods
658    }
659
660    /// Get a mutable reference to a [`Vec`] of activation methods, which may be useful
661    /// for adding or removing methods.
662    pub fn methods_mut(&mut self) -> &mut Vec<DissociationMethodTerm> {
663        &mut self._methods
664    }
665
666    /// Check if multiple dissociation methods were used
667    pub fn is_combined(&self) -> bool {
668        self._methods.len() > 1
669    }
670
671    /// Check if a [`ParamLike`] type references an activation method
672    pub fn is_param_activation<P: ParamLike>(p: &P) -> bool {
673        if p.is_controlled() && p.controlled_vocabulary().unwrap() == ControlledVocabulary::MS {
674            Self::accession_to_activation(p.accession().unwrap())
675        } else {
676            false
677        }
678    }
679
680    /// Translate an integer accession ID into a [`DissociationMethodTerm`] if possible to detect
681    /// if it refers to a dissociation method.
682    pub fn accession_to_activation(accession: AccessionIntCode) -> bool {
683        DissociationMethodTerm::from_accession(accession).is_some()
684    }
685
686    pub fn _extract_methods_from_params(&mut self) {
687        let mut methods = Vec::with_capacity(1);
688        let mut rest = Vec::with_capacity(self.params.len());
689        for p in self.params.drain(..) {
690            if Self::is_param_activation(&p) {
691                methods.push(p.into())
692            } else {
693                rest.push(p)
694            }
695        }
696        self.params = rest;
697        self._methods = methods;
698    }
699
700    /// The energy used to dissociate the precursor(s)
701    pub fn energy(&self) -> f32 {
702        self.energy
703    }
704}
705
706#[derive(Debug, Default, Clone, PartialEq)]
707#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
708/// Describes the precursor ion of the owning spectrum.
709pub struct Precursor {
710    /// Describes the selected ion's properties
711    pub ions: Vec<SelectedIon>,
712    /// Describes the isolation window around the selected ion
713    pub isolation_window: IsolationWindow,
714    /// The precursor scan ID, if given
715    pub precursor_id: Option<String>,
716    /// The product scan ID, if given
717    pub product_id: Option<String>,
718    /// The activation process applied to the precursor ion
719    pub activation: Activation,
720}
721
722impl Precursor {
723    // /// Given a [`SpectrumSource`] object, look up the precursor scan in it.
724    // /// This is useful when examining the area *around* where the precursor
725    // /// ion was or to obtain a snapshot of the retention time when the spectrum
726    // /// was scheduled.
727    // pub fn precursor_spectrum<C, D, S, R>(&self, source: &mut R) -> Option<S>
728    // where
729    //     C: CentroidPeakAdapting,
730    //     D: DeconvolutedPeakAdapting,
731    //     S: SpectrumLike<C, D>,
732    //     R: SpectrumSource<C, D, S>,
733    // {
734    //     match self.precursor_id.as_ref() {
735    //         Some(id) => source.get_spectrum_by_id(id),
736    //         None => None,
737    //     }
738    // }
739
740    /// Check if this precursor selection selected any ions
741    pub fn has_ions(&self) -> bool {
742        self.ions.is_empty()
743    }
744
745    // /// Given a [`SpectrumSource`] object, look up the product scan in it.
746    // /// This is rarely needed unless you have manually separated [`Precursor`]
747    // /// objects from their spectra.
748    // pub fn product_spectrum<C, D, S, R>(&self, source: &mut R) -> Option<S>
749    // where
750    //     C: CentroidPeakAdapting,
751    //     D: DeconvolutedPeakAdapting,
752    //     S: SpectrumLike<C, D>,
753    //     R: SpectrumSource<C, D, S>,
754    // {
755    //     match self.product_id.as_ref() {
756    //         Some(id) => source.get_spectrum_by_id(id),
757    //         None => None,
758    //     }
759    // }
760}
761
762/**
763A trait for abstracting over how a precursor ion is described, immutably.
764*/
765pub trait PrecursorSelection {
766    /// Describes the selected ion's properties
767    fn ion(&self) -> Option<&SelectedIon>;
768    /// Describes the isolation window around the selected ion
769    fn isolation_window(&self) -> &IsolationWindow;
770    /// The precursor scan ID, if given
771    fn precursor_id(&self) -> Option<&String>;
772    /// The product scan ID, if given
773    fn product_id(&self) -> Option<&String>;
774    /// The activation process applied to the precursor ion
775    fn activation(&self) -> &Activation;
776
777    fn iter(&self) -> impl Iterator<Item = &SelectedIon>;
778
779    /// Check if this precursor selection selected any ions
780    fn has_ions(&self) -> bool {
781        self.iter().count() > 0
782    }
783
784    fn last_ion(&self) -> Option<&SelectedIon> {
785        self.iter().last()
786    }
787
788    fn iter_mut(&mut self) -> impl Iterator<Item = &mut SelectedIon>;
789    fn ion_mut(&mut self) -> Option<&mut SelectedIon>;
790    fn activation_mut(&mut self) -> &mut Activation;
791    fn isolation_window_mut(&mut self) -> &mut IsolationWindow;
792    fn add_ion(&mut self, ion: SelectedIon);
793    fn last_ion_mut(&mut self) -> Option<&mut SelectedIon> {
794        self.iter_mut().last()
795    }
796}
797
798impl PrecursorSelection for Precursor {
799    fn ion(&self) -> Option<&SelectedIon> {
800        self.ions.first()
801    }
802
803    fn isolation_window(&self) -> &IsolationWindow {
804        &self.isolation_window
805    }
806
807    fn precursor_id(&self) -> Option<&String> {
808        self.precursor_id.as_ref()
809    }
810
811    fn product_id(&self) -> Option<&String> {
812        self.product_id.as_ref()
813    }
814
815    fn activation(&self) -> &Activation {
816        &self.activation
817    }
818
819    fn ion_mut(&mut self) -> Option<&mut SelectedIon> {
820        if self.ions.is_empty() {
821            self.ions.push(SelectedIon::default())
822        }
823        self.ions.first_mut()
824    }
825
826    fn activation_mut(&mut self) -> &mut Activation {
827        &mut self.activation
828    }
829
830    fn isolation_window_mut(&mut self) -> &mut IsolationWindow {
831        &mut self.isolation_window
832    }
833
834    fn iter(&self) -> impl Iterator<Item = &SelectedIon> {
835        self.ions.iter()
836    }
837
838    fn iter_mut(&mut self) -> impl Iterator<Item = &mut SelectedIon> {
839        self.ions.iter_mut()
840    }
841
842    fn add_ion(&mut self, ion: SelectedIon) {
843        self.ions.push(ion);
844    }
845
846    fn last_ion_mut(&mut self) -> Option<&mut SelectedIon> {
847        if self.ions.is_empty() {
848            self.ions.push(SelectedIon::default())
849        }
850        self.iter_mut().last()
851    }
852}
853
854/**
855Describes the polarity of a mass spectrum. A spectrum is either `Positive` (1+), `Negative` (-1)
856or `Unknown` (0). The `Unknown` state is the default.
857*/
858#[repr(i8)]
859#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Default)]
860#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
861pub enum ScanPolarity {
862    #[default]
863    /// The polarity of the spectrum is unknown
864    Unknown = 0,
865    /// The polarity is positive, where charge states represent the addition of positive charge
866    Positive = 1,
867    /// The polarity is negative, where the charge states reprsent the addition of electrons or other
868    /// negatively charged adduction
869    Negative = -1,
870}
871
872impl ScanPolarity {
873    /// Get a signed integer representing the polarity of the spectrum. This
874    /// assumes that unknown spectra are more likely to be positive so it
875    /// is distinct from the raw integer value of the enum where [`Unknown`](Self::Unknown)
876    /// is 0.
877    pub fn sign(&self) -> i32 {
878        match self {
879            ScanPolarity::Unknown => 1,
880            ScanPolarity::Positive => 1,
881            ScanPolarity::Negative => -1,
882        }
883    }
884}
885
886impl Display for ScanPolarity {
887    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
888        write!(f, "{:?}", self)
889    }
890}
891
892/**
893Describes the initial representation of the signal of a spectrum.
894
895Though most formats explicitly have a method of either conveying a processing level
896or an assumed level, the `Unknown` option is retained for partial initialization.
897*/
898#[repr(u8)]
899#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Hash, Eq)]
900#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
901pub enum SignalContinuity {
902    #[default]
903    Unknown = 0,
904    /// The spectrum is centroided, indicating that its primary representation is that of a
905    /// discrete peak list. There may be multiple peak lists and a profile spectrum may still
906    /// be present on the same spectrum.
907    Centroid = 3,
908    /// The spectrum is profile, indicating that its primary representation is a continuous
909    /// profile.
910    Profile = 5,
911}
912
913impl SignalContinuity {
914    pub const fn is_profile(&self) -> bool {
915        matches!(self, Self::Profile)
916    }
917
918    pub const fn is_centroid(&self) -> bool {
919        matches!(self, Self::Centroid)
920    }
921}
922
923impl Display for SignalContinuity {
924    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
925        write!(f, "{:?}", self)
926    }
927}
928
929/// An adapter type to make it possible to pass an `Option<Precursor>`, `Vec<Precursor>`,
930/// or [`Precursor`] in an argument context.
931#[derive(Debug)]
932pub enum AsPrecursorCollection {
933    /// A single [`Precursor`]
934    Single(Option<Precursor>),
935    /// Multiple [`Precursor`], denoting higher order MSn
936    Multiple(Vec<Precursor>),
937}
938
939impl From<AsPrecursorCollection> for Vec<Precursor> {
940    fn from(value: AsPrecursorCollection) -> Self {
941        match value {
942            AsPrecursorCollection::Single(precursor) => {
943                precursor.map(|v| vec![v]).unwrap_or_default()
944            }
945            AsPrecursorCollection::Multiple(precursors) => precursors,
946        }
947    }
948}
949
950impl From<Precursor> for AsPrecursorCollection {
951    fn from(value: Precursor) -> Self {
952        Self::Single(Some(value))
953    }
954}
955
956impl From<Option<Precursor>> for AsPrecursorCollection {
957    fn from(value: Option<Precursor>) -> Self {
958        Self::Single(value)
959    }
960}
961
962impl From<Vec<Precursor>> for AsPrecursorCollection {
963    fn from(value: Vec<Precursor>) -> Self {
964        Self::Multiple(value)
965    }
966}
967
968/**
969The set of descriptive metadata that give context for how a mass spectrum was acquired
970within a particular run. This forms the basis for a large portion of the [`SpectrumLike`]
971trait.
972*/
973#[derive(Debug, Default, Clone, PartialEq)]
974#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
975pub struct SpectrumDescription {
976    /// The spectrum's native identifier
977    pub id: String,
978    /// The ordinal sequence number for the spectrum
979    pub index: usize,
980    /// The degree of exponentiation of the spectrum, e.g MS1, MS2, MS3, etc
981    pub ms_level: u8,
982
983    /// The spectrum is in positive or negative mode
984    pub polarity: ScanPolarity,
985
986    /// The spectrum's main representation is as a peak list or a continuous
987    /// profile
988    pub signal_continuity: SignalContinuity,
989
990    /// A set of controlled or uncontrolled descriptors of the spectrum not already
991    /// covered by fields
992    pub params: ParamList,
993
994    /// A description of how the spectrum was acquired including time, scan windows, and more
995    pub acquisition: Acquisition,
996
997    /// The parent ion or ions and their isolation and activation description
998    pub precursor: Vec<Precursor>,
999}
1000
1001impl SpectrumDescription {
1002    #[allow(clippy::too_many_arguments)]
1003    pub fn new(
1004        id: String,
1005        index: usize,
1006        ms_level: u8,
1007        polarity: ScanPolarity,
1008        signal_continuity: SignalContinuity,
1009        params: ParamList,
1010        acquisition: Acquisition,
1011        precursor: impl Into<AsPrecursorCollection>,
1012    ) -> Self {
1013        Self {
1014            id,
1015            index,
1016            ms_level,
1017            polarity,
1018            signal_continuity,
1019            params,
1020            acquisition,
1021            precursor: precursor.into().into(),
1022        }
1023    }
1024
1025    find_param_method!(title, &SCAN_TITLE, |p| p.as_str(), Option<Cow<'_, str>>);
1026
1027    /// Find the type of spectrum described.
1028    ///
1029    /// A spectrum in `mzdata` is *usually* a mass spectrum of some sort, but that's not guaranteed
1030    /// to be the case. `mzdata` can handle non-MS spectra, but little of the signal processing
1031    /// machinery it provides currently supports those other kinds of data.
1032    pub fn spectrum_type(&self) -> Option<mzdata_meta::SpectrumType> {
1033        const SPECTRUM_TYPES: &[(mzdata_meta::SpectrumType, crate::params::ParamCow<'static>)] =
1034            mzdata_meta::SpectrumType::all_types();
1035
1036        let conv_table: HashMap<CURIE, mzdata_meta::SpectrumType> = SPECTRUM_TYPES
1037            .iter()
1038            .map(|(t, v)| (v.curie().unwrap(), *t))
1039            .collect();
1040
1041        for param in self.params().iter() {
1042            if let Some(c) = param.curie() {
1043                if let Some(t) = conv_table.get(&c) {
1044                    return Some(*t);
1045                }
1046            }
1047        }
1048        None
1049    }
1050
1051    /// Set the kind of spectrum represented.
1052    pub fn set_spectrum_type(&mut self, spectrum_type: mzdata_meta::SpectrumType) {
1053        const SPECTRUM_TYPES: &[(mzdata_meta::SpectrumType, crate::params::ParamCow<'static>)] =
1054            mzdata_meta::SpectrumType::all_types();
1055
1056        let to_insert: crate::params::ParamCow<'_> = spectrum_type.to_param();
1057
1058        let conv_table: HashSet<CURIE> = SPECTRUM_TYPES
1059            .iter()
1060            .map(|(_, v)| v.curie().unwrap())
1061            .collect();
1062
1063        for param in self.params_mut().iter_mut() {
1064            if let Some(c) = param.curie() {
1065                if conv_table.contains(&c) {
1066                    *param = to_insert.into();
1067                    return;
1068                }
1069            }
1070        }
1071
1072        self.add_param(to_insert.into());
1073    }
1074}
1075
1076impl_param_described!(Activation, SpectrumDescription);
1077impl_param_described_deferred!(SelectedIon, Acquisition, ScanEvent);
1078
1079/// Types of chromatograms enumerated in the PSI-MS controlled vocabulary
1080#[derive(Debug, Clone, Copy, PartialEq, Default, Eq, PartialOrd, Ord, Hash)]
1081#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1082pub enum ChromatogramType {
1083    #[default]
1084    Unknown,
1085    /// The sum of all intensity at each time point
1086    TotalIonCurrentChromatogram,
1087    /// The most intense peak at each time point
1088    BasePeakChromatogram,
1089    /// The intensity of selected ion at each time point
1090    SelectedIonCurrentChromatogram,
1091    /// The intensity of selected ion at each time point
1092    SelectedIonMonitoringChromatogram,
1093    /// The intensity of selected ion and its products at each time point
1094    SelectedReactionMonitoringChromatogram,
1095    AbsorptionChromatogram,
1096    EmissionChromatogram,
1097    /// The flow rate at each time point
1098    FlowRateChromatogram,
1099    /// The pressure at each time point
1100    PressureChromatogram,
1101    /// The temperature of an element at each time point
1102    TemperatureChromatogram,
1103    /// The intensity of the signal for a specific wavelength of EMR at each time point
1104    ElectromagneticRadiationChromatogram,
1105}
1106
1107impl ChromatogramType {
1108    /// Construct a [`ChromatogramType`] from a PSI-MS controlled vocabulary ID
1109    pub fn from_accession(accession: AccessionIntCode) -> Option<Self> {
1110        let tp = match accession {
1111            1000235 => Self::TotalIonCurrentChromatogram,
1112            1000628 => Self::BasePeakChromatogram,
1113            1000627 => Self::SelectedIonCurrentChromatogram,
1114            1001472 => Self::SelectedIonMonitoringChromatogram,
1115            1001473 => Self::SelectedReactionMonitoringChromatogram,
1116            1000812 => Self::AbsorptionChromatogram,
1117            1000813 => Self::EmissionChromatogram,
1118            1003020 => Self::FlowRateChromatogram,
1119            1003019 => Self::PressureChromatogram,
1120            1000811 => Self::ElectromagneticRadiationChromatogram,
1121            1000626 => Self::Unknown,
1122            1002715 => Self::TemperatureChromatogram,
1123            _ => return None,
1124        };
1125        Some(tp)
1126    }
1127
1128    /// Construct a [`ChromatogramType`] from a PSI-MS controlled vocabulary [`CURIE`]
1129    pub fn from_curie(curie: CURIE) -> Option<Self> {
1130        match curie.controlled_vocabulary() {
1131            ControlledVocabulary::MS => Self::from_accession(curie.accession_int()),
1132            _ => None,
1133        }
1134    }
1135
1136    /// Test if the chromatogram is a measure of EMR
1137    pub fn is_electromagnetic_radiation(&self) -> bool {
1138        matches!(
1139            self,
1140            Self::AbsorptionChromatogram
1141                | Self::EmissionChromatogram
1142                | Self::ElectromagneticRadiationChromatogram
1143        )
1144    }
1145
1146    /// Test if the chromatogram is an aggregation across multiple elements, specifically
1147    /// [`Self::TotalIonCurrentChromatogram`] or [`Self::BasePeakChromatogram`]
1148    pub fn is_aggregate(&self) -> bool {
1149        matches!(
1150            self,
1151            Self::TotalIonCurrentChromatogram
1152                | Self::BasePeakChromatogram
1153        )
1154    }
1155
1156    /// Test if the chromatogram tracks an ionization process
1157    pub fn is_ion_current(&self) -> bool {
1158        matches!(
1159            self,
1160            Self::SelectedIonCurrentChromatogram
1161                | Self::SelectedIonMonitoringChromatogram
1162                | Self::SelectedReactionMonitoringChromatogram
1163                | Self::TotalIonCurrentChromatogram
1164                | Self::BasePeakChromatogram
1165        )
1166    }
1167
1168    /// Convert this [`ChromatogramType`] to a [`CURIE`]
1169    pub fn to_curie(&self) -> CURIE {
1170        match self {
1171            Self::TotalIonCurrentChromatogram => CURIE::new(ControlledVocabulary::MS, 1000235),
1172            Self::BasePeakChromatogram => CURIE::new(ControlledVocabulary::MS, 1000628),
1173            Self::SelectedIonCurrentChromatogram => CURIE::new(ControlledVocabulary::MS, 1000627),
1174            Self::SelectedIonMonitoringChromatogram => {
1175                CURIE::new(ControlledVocabulary::MS, 1000472)
1176            }
1177            Self::SelectedReactionMonitoringChromatogram => {
1178                CURIE::new(ControlledVocabulary::MS, 1000473)
1179            }
1180            Self::AbsorptionChromatogram => CURIE::new(ControlledVocabulary::MS, 1000812),
1181            Self::ElectromagneticRadiationChromatogram => {
1182                CURIE::new(ControlledVocabulary::MS, 1000811)
1183            }
1184            Self::EmissionChromatogram => CURIE::new(ControlledVocabulary::MS, 1000813),
1185            Self::FlowRateChromatogram => CURIE::new(ControlledVocabulary::MS, 1003020),
1186            Self::PressureChromatogram => CURIE::new(ControlledVocabulary::MS, 1003019),
1187            Self::TemperatureChromatogram => CURIE::new(ControlledVocabulary::MS, 1002715),
1188            Self::Unknown => CURIE::new(ControlledVocabulary::MS, 1000626),
1189        }
1190    }
1191}
1192
1193/// A product ion isolated in lock-step from the precursor, as in selected reaction monitoring (SRM)
1194/// or multiple reaction monitoring (MRM).
1195#[derive(Debug, Default, Clone, PartialEq)]
1196#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1197pub struct Product {
1198    /// The product ion's isolation window.
1199    pub isolation_window: IsolationWindow,
1200}
1201
1202impl From<IsolationWindow> for Product {
1203    fn from(isolation_window: IsolationWindow) -> Self {
1204        Self { isolation_window }
1205    }
1206}
1207
1208impl Product {
1209    pub fn new(isolation_window: IsolationWindow) -> Self {
1210        Self { isolation_window }
1211    }
1212}
1213
1214/// The set of descriptive metadata that give context for how a chromatogram was
1215/// recorded.
1216///
1217/// Chromatograms record measures over time, and may be derived from mass spectra or
1218/// other forms of detectors.
1219#[derive(Debug, Default, Clone, PartialEq)]
1220#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1221pub struct ChromatogramDescription {
1222    /// The locally unique identifier for this chromatogram
1223    pub id: String,
1224    /// The ordinal index for this chromatogram
1225    pub index: usize,
1226    /// The MS level, if any, for this chromatogram
1227    pub ms_level: Option<u8>,
1228    /// The MS polarity, if any, for this chromatogram
1229    pub polarity: ScanPolarity,
1230    /// The kind of chromatogram this is
1231    pub chromatogram_type: ChromatogramType,
1232
1233    /// Additional [`Param`] that describe this chromatogram
1234    pub params: ParamList,
1235    /// Any [`Precursor`] that describe the derivation of this chromatogram
1236    pub precursor: Vec<Precursor>,
1237    /// Any reaction monitoring linked to this chromatogram
1238    pub products: Vec<Product>,
1239}
1240
1241impl ChromatogramDescription {
1242    /// See [`ChromatogramType::is_aggregate`]
1243    pub fn is_aggregate(&self) -> bool {
1244        self.chromatogram_type.is_aggregate()
1245    }
1246
1247    /// See [`ChromatogramType::is_electromagnetic_radiation`]
1248    pub fn is_electromagnetic_radiation(&self) -> bool {
1249        self.chromatogram_type.is_electromagnetic_radiation()
1250    }
1251
1252    /// See [`ChromatogramType::is_ion_current`]
1253    pub fn is_ion_current(&self) -> bool {
1254        self.chromatogram_type.is_ion_current()
1255    }
1256}
1257
1258impl_param_described!(ChromatogramDescription);
1259
1260#[cfg(test)]
1261mod contains_tests {
1262    use num_traits::Zero;
1263
1264use super::*;
1265
1266    #[test]
1267    fn isolation_window_builder_target_only() {
1268        let mut this = IsolationWindow::default();
1269        let builder = IsolationWindowBuilder(&mut this);
1270        builder.target(500.0);
1271        assert_eq!(this.target, 500.0);
1272        assert!(this.upper_bound.is_zero());
1273        assert!(this.lower_bound.is_zero());
1274
1275        this = IsolationWindow::default();
1276        let builder = IsolationWindowBuilder(&mut this);
1277        builder.target(500.0).lower_offset(2.0);
1278        assert_eq!(this.target, 500.0);
1279        assert!(this.upper_bound.is_zero());
1280        assert_eq!(this.lower_bound, 498.0);
1281    }
1282
1283    // Regression tests for the isolation/scan-window `contains` membership check. A prior bug used
1284    // `lower <= point && upper <= point`, which wrongly excluded interior points and wrongly included
1285    // points above the window; `contains` must be an inclusive `[lower, upper]` test.
1286
1287    #[test]
1288    fn isolation_window_contains_is_inclusive_range() {
1289        let window = IsolationWindow::around(810.789, 1.0); // [809.789, 811.789]
1290        assert!(window.contains(window.target), "target must be inside");
1291        assert!(
1292            window.contains(window.lower_bound),
1293            "lower bound is inclusive"
1294        );
1295        assert!(
1296            window.contains(window.upper_bound),
1297            "upper bound is inclusive"
1298        );
1299        assert!(window.contains(810.0_f64), "interior point");
1300        assert!(!window.contains(809.0_f64), "below the lower bound");
1301        assert!(!window.contains(812.0_f64), "above the upper bound");
1302    }
1303
1304    #[test]
1305    fn scan_window_contains_is_inclusive_range() {
1306        let window = ScanWindow::new(200.0, 2000.0);
1307        assert!(window.contains(1100.0_f64), "interior point");
1308        assert!(window.contains(200.0_f64), "lower bound is inclusive");
1309        assert!(window.contains(2000.0_f64), "upper bound is inclusive");
1310        assert!(!window.contains(199.0_f64), "below the lower bound");
1311        assert!(
1312            !window.contains(2500.0_f64),
1313            "above the upper bound (the old bug returned true here)"
1314        );
1315    }
1316}