corems.mass_spectra.input.mzml

  1from collections import defaultdict
  2from pathlib import Path
  3from typing import Optional, Union, List, Tuple
  4
  5import numpy as np
  6import pandas as pd
  7import pymzml
  8import datetime
  9
 10from corems.encapsulation.constant import Labels
 11from corems.encapsulation.factory.parameters import default_parameters
 12from corems.mass_spectra.factory.lc_class import LCMSBase, MassSpectraBase
 13from corems.mass_spectra.input.parserbase import SpectraParserInterface
 14from corems.mass_spectrum.factory.MassSpectrumClasses import (
 15    MassSpecCentroid,
 16    MassSpecProfile,
 17)
 18
 19
 20class MZMLSpectraParser(SpectraParserInterface):
 21    """A class for parsing mzml spectrometry data files into MassSpectraBase or LCMSBase objects
 22
 23    Parameters
 24    ----------
 25    file_location : str or Path
 26        The path to the RAW file to be parsed.
 27    analyzer : str, optional
 28        The type of mass analyzer used in the instrument. Default is "Unknown".
 29    instrument_label : str, optional
 30        The name of the instrument used to acquire the data. Default is "Unknown".
 31    sample_name : str, optional
 32        The name of the sample being analyzed. If not provided, the stem of the file_location path will be used.
 33
 34    Attributes
 35    ----------
 36    file_location : Path
 37        The path to the RAW file being parsed.
 38    analyzer : str
 39        The type of mass analyzer used in the instrument.
 40    instrument_label : str
 41        The name of the instrument used to acquire the data.
 42    sample_name : str
 43        The name of the sample being analyzed.
 44
 45    Methods
 46    -------
 47    * load().
 48        Load mzML file using pymzml.run.Reader and return the data as a numpy array.
 49    * run(spectra=True).
 50        Parses the mzml file and returns a dictionary of mass spectra dataframes and a scan metadata dataframe.
 51    * get_mass_spectrum_from_scan(scan_number, polarity, auto_process=True)
 52        Parses the mzml file and returns a MassSpecBase object from a single scan.
 53    * get_mass_spectra_obj().
 54        Parses the mzml file and instantiates a MassSpectraBase object.
 55    * get_lcms_obj().
 56        Parses the mzml file and instantiates an LCMSBase object.
 57    * get_instrument_info().
 58        Return instrument information from the mzML file.
 59    * get_creation_time().
 60        Return the creation time of the mzML file as a datetime object.
 61
 62    Inherits from ThermoBaseClass and SpectraParserInterface
 63    """
 64
 65    def __init__(
 66        self,
 67        file_location,
 68        analyzer="Unknown",
 69        instrument_label="Unknown",
 70        sample_name=None,
 71    ):
 72        # implementation details
 73        if isinstance(file_location, str):
 74            # if obj is a string it defaults to create a Path obj, pass the S3Path if needed
 75            file_location = Path(file_location)
 76        if not file_location.exists():
 77            raise FileExistsError("File does not exist: " + str(file_location))
 78        self.file_location = file_location
 79        self.analyzer = analyzer
 80        self.instrument_label = instrument_label
 81
 82        if sample_name:
 83            self.sample_name = sample_name
 84        else:
 85            self.sample_name = file_location.stem
 86
 87    def load(self):
 88        """
 89        Load mzML file using pymzml.run.Reader and return the data as a numpy array.
 90
 91        Returns
 92        -------
 93        numpy.ndarray
 94            The mass spectra data as a numpy array.
 95        """
 96        data = pymzml.run.Reader(self.file_location)
 97        return data
 98
 99    def get_scan_df(self, data=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
100        """
101        Return scan data as a pandas DataFrame.
102
103        Parameters
104        ----------
105        data : pymzml.run.Reader, optional
106            The mass spectra data. If None, will load the data.
107        time_range : tuple or list of tuples, optional
108            Retention time range(s) to filter scans. Can be:
109            - Single range: (start_time, end_time) in minutes
110            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
111            If None, returns all scans.
112
113        Returns
114        -------
115        pandas.DataFrame
116            A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time.
117        """
118        if data is None:
119            data = self.load()
120        # Scan dict
121        # instatinate scan dict, with empty lists of size of scans
122        n_scans = data.get_spectrum_count()
123        scan_dict = {
124            "scan": np.empty(n_scans, dtype=np.int32),
125            "scan_time": np.empty(n_scans, dtype=np.float32),
126            "ms_level": [None] * n_scans,
127            "polarity": [None] * n_scans,
128            "precursor_mz": [None] * n_scans,
129            "scan_text": [None] * n_scans,
130            "scan_window_lower": np.empty(n_scans, dtype=np.float32),
131            "scan_window_upper": np.empty(n_scans, dtype=np.float32),
132            "scan_precision": [None] * n_scans,
133            "tic": np.empty(n_scans, dtype=np.float32),
134            "ms_format": [None] * n_scans,
135        }
136
137        # First pass: loop through scans to get scan info
138        for i, spec in enumerate(data):
139            scan_dict["scan"][i] = spec.ID
140            scan_dict["ms_level"][i] = spec.ms_level
141            scan_dict["scan_precision"][i] = spec._measured_precision
142            scan_dict["tic"][i] = spec.TIC
143            if spec.selected_precursors:
144                scan_dict["precursor_mz"][i] = spec.selected_precursors[0].get(
145                    "mz", None
146                )
147            if spec["negative scan"] is not None:
148                scan_dict["polarity"][i] = "negative"
149            if spec["positive scan"] is not None:
150                scan_dict["polarity"][i] = "positive"
151            if spec["negative scan"] is not None and spec["positive scan"] is not None:
152                raise ValueError(
153                    "Error: scan {0} has both negative and positive polarity".format(
154                        spec.ID
155                    )
156                )
157
158            scan_dict["scan_time"][i] = spec.get("MS:1000016")
159            scan_dict["scan_text"][i] = spec.get("MS:1000512")
160            scan_dict["scan_window_lower"][i] = spec.get("MS:1000501")
161            scan_dict["scan_window_upper"][i] = spec.get("MS:1000500")
162            if spec.get("MS:1000128"):
163                scan_dict["ms_format"][i] = "profile"
164            elif spec.get("MS:1000127"):
165                scan_dict["ms_format"][i] = "centroid"
166            else:
167                scan_dict["ms_format"][i] = None
168
169        scan_df = pd.DataFrame(scan_dict)
170
171        # Remove any non-mass spectra scans (e.g., MS level 0 or None)
172        scan_df = scan_df[scan_df.ms_level.notnull() & (scan_df.ms_level > 0)].reset_index(drop=True)
173        
174        # Apply time range filtering if specified
175        if time_range is not None:
176            time_ranges = self._normalize_time_range(time_range)
177            # Create a mask for scans within any of the time ranges
178            mask = np.zeros(len(scan_df), dtype=bool)
179            for start_time, end_time in time_ranges:
180                mask |= (scan_df["scan_time"] >= start_time) & (scan_df["scan_time"] <= end_time)
181            scan_df = scan_df[mask].reset_index(drop=True)
182
183        return scan_df
184
185    def get_ms_raw(self, spectra, scan_df, data=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
186        """Return a dictionary of mass spectra data as a pandas DataFrame.
187
188        Parameters
189        ----------
190        spectra : str
191            Which mass spectra data to include in the output.
192            Options: None, "ms1", "ms2", "all".
193        scan_df : pandas.DataFrame
194            Scan dataframe. Output from get_scan_df().
195        data : pymzml.run.Reader, optional
196            The mass spectra data. If None, will load the data.
197        time_range : tuple or list of tuples, optional
198            Retention time range(s) to filter scans. Can be:
199            - Single range: (start_time, end_time) in minutes
200            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
201            If None, returns all scans. Note: filtering is typically done at scan_df level.
202
203        Returns
204        -------
205        dict
206            A dictionary containing the mass spectra data as pandas DataFrames, with keys corresponding to the MS level.
207
208        """
209        if data is None:
210            data = self.load()
211        if spectra == "all":
212            scan_df_forspec = scan_df
213        elif spectra == "ms1":
214            scan_df_forspec = scan_df[scan_df.ms_level == 1]
215        elif spectra == "ms2":
216            scan_df_forspec = scan_df[scan_df.ms_level == 2]
217        else:
218            raise ValueError("spectra must be 'all', 'ms1', or 'ms2'")
219
220        # Result container
221        res = {}
222
223        # Row count container
224        counter = {}
225
226        # Column name container
227        cols = {}
228
229        # set at float32
230        dtype = np.float32
231
232        # First pass: get nrows
233        N = defaultdict(lambda: 0)
234        for i, spec in enumerate(data):
235            if spec.ID in scan_df_forspec.scan.values:
236                # Get ms level
237                level = "ms{}".format(spec.ms_level)
238
239                # Number of rows
240                N[level] += spec.mz.shape[0]
241
242        # Second pass: parse
243        for i, spec in enumerate(data):
244            if spec.ID in scan_df_forspec.scan.values:
245                # Number of rows
246                n = spec.mz.shape[0]
247
248                # No measurements
249                if n == 0:
250                    continue
251
252                # Dimension check
253                if len(spec.mz) != len(spec.i):
254                    # raise an error if the mz and intensity arrays are not the same length
255                    raise ValueError("m/z and intensity array dimension mismatch")
256
257                # Scan/frame info
258                id_dict = spec.id_dict
259
260                # Get ms level
261                level = "ms{}".format(spec.ms_level)
262
263                # Columns
264                cols[level] = list(id_dict.keys()) + ["mz", "intensity"]
265                m = len(cols[level])
266
267                # Subarray init
268                arr = np.empty((n, m), dtype=dtype)
269                inx = 0
270
271                # Populate scan/frame info
272                for k, v in id_dict.items():
273                    arr[:, inx] = v
274                    inx += 1
275
276                # Populate m/z
277                arr[:, inx] = spec.mz
278                inx += 1
279
280                # Populate intensity
281                arr[:, inx] = spec.i
282                inx += 1
283
284                # Initialize output container
285                if level not in res:
286                    res[level] = np.empty((N[level], m), dtype=dtype)
287                    counter[level] = 0
288
289                # Insert subarray
290                res[level][counter[level] : counter[level] + n, :] = arr
291                counter[level] += n
292
293        # Construct ms1 and ms2 mz dataframes
294        for level in res.keys():
295            res[level] = pd.DataFrame(res[level], columns=cols[level]).drop(
296                columns=["controllerType", "controllerNumber"],
297            )
298
299        return res
300
301    def run(self, spectra="all", scan_df=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
302        """Parse the mzML file and return a dictionary of spectra dataframes and a scan metadata dataframe.
303
304        Parameters
305        ----------
306        spectra : str, optional
307            Which mass spectra data to include in the output. Default is "all".
308            Other options: None, "ms1", "ms2".
309        scan_df : pandas.DataFrame, optional
310            Scan dataframe.  If not provided, the scan dataframe is created from the mzML file.
311        time_range : tuple or list of tuples, optional
312            Retention time range(s) to load. Can be:
313            - Single range: (start_time, end_time) in minutes
314            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
315            If None, loads all scans.
316
317        Returns
318        -------
319        tuple
320            A tuple containing two elements:
321            - A dictionary containing the mass spectra data as numpy arrays, with keys corresponding to the MS level.
322            - A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time.
323        """
324
325        # Open file
326        data = self.load()
327
328        if scan_df is None:
329            scan_df = self.get_scan_df(data, time_range=time_range)
330
331        if spectra != "none":
332            res = self.get_ms_raw(spectra, scan_df, data)
333
334        else:
335            res = None
336
337        return res, scan_df
338
339    def get_mass_spectrum_from_scan(
340        self, scan_number, spectrum_mode, auto_process=True
341    ):
342        """Instatiate a mass spectrum object from the mzML file.
343
344        Parameters
345        ----------
346        scan_number : int
347            The scan number to be parsed.
348        spectrum_mode : str
349            The type of spectrum to instantiate.  Must be'profile' or 'centroid'.
350        polarity : int
351            The polarity of the scan.  Must be -1 or 1.
352        auto_process : bool, optional
353            If True, process the mass spectrum. Default is True.
354
355        Returns
356        -------
357        MassSpecProfile | MassSpecCentroid
358            The MassSpecProfile or MassSpecCentroid object containing the parsed mass spectrum.
359        """
360        # Use the batch function and return the first result
361        result_list = self.get_mass_spectra_from_scan_list(
362            [scan_number], spectrum_mode, auto_process
363        )
364        return result_list[0] if result_list else None
365
366    def get_mass_spectra_from_scan_list(
367        self, scan_list, spectrum_mode, auto_process=True
368    ):
369        """Instatiate mass spectrum objects from the mzML file.
370
371        Parameters
372        ----------
373        scan_list : list of int
374            The scan numbers to be parsed.
375        spectrum_mode : str
376            The type of spectrum to instantiate.  Must be'profile' or 'centroid'.
377        auto_process : bool, optional
378            If True, process the mass spectrum. Default is True.
379
380        Returns
381        -------
382        list of MassSpecProfile | MassSpecCentroid
383            List of MassSpecProfile or MassSpecCentroid objects containing the parsed mass spectra.
384        """
385
386        def set_metadata(
387            scan_number: int,
388            polarity: int,
389            file_location: str,
390            label=Labels.thermo_profile,
391        ):
392            """
393            Set the output parameters for creating a MassSpecProfile or MassSpecCentroid object.
394
395            Parameters
396            ----------
397            scan_number : int
398                The scan number.
399            polarity : int
400                The polarity of the data.
401            file_location : str
402                The file location.
403            label : str, optional
404                The label for the mass spectrum. Default is Labels.thermo_profile.
405
406            Returns
407            -------
408            dict
409                The output parameters ready for creating a MassSpecProfile or MassSpecCentroid object.
410            """
411            d_params = default_parameters(file_location)
412            d_params["label"] = label
413            d_params["polarity"] = polarity
414            d_params["filename_path"] = file_location
415            d_params["scan_number"] = scan_number
416
417            return d_params
418
419        # Open file
420        data = self.load()
421
422        mass_spectrum_objects = []
423        scan_set = set(scan_list)
424        # Materialize peak data during iteration. Do not retain live pymzml
425        # spectrum objects, and do not overwrite the first valid MS spectrum for
426        # a scan ID.
427        #
428        # Thermo multi-controller mzML files often reuse scan numbers across
429        # controllers (e.g. controllerType=0 MS data and controllerType=3
430        # auxiliary traces). pymzml exposes only the integer scan number as
431        # spec.ID, so sequential iteration can yield the same ID twice. Keeping
432        # the last yield (or random-access data[scan]) can replace real MS
433        # spectra with non-MS controller spectra and then fail centroid/profile
434        # checks with "spectrum is not centroided".
435        #
436        # Direct random-access via data[scan_number] also uses pymzml's
437        # byte-offset index, which is unreliable on Windows (CRLF vs LF).
438        collected = {}
439
440        for spec in data:
441            scan_id = spec.ID
442            if scan_id not in scan_set or scan_id in collected:
443                continue
444
445            # Skip non-MS / auxiliary-controller spectra that share scan numbers
446            ms_level = spec.ms_level
447            if ms_level is None or ms_level <= 0:
448                continue
449
450            if spec["negative scan"] is not None:
451                polarity = -1
452            elif spec["positive scan"] is not None:
453                polarity = 1
454            else:
455                polarity = None
456
457            mz = np.asarray(spec.mz)
458            abundance = np.asarray(spec.i)
459            collected[scan_id] = {
460                "mz": mz,
461                "abundance": abundance,
462                "polarity": polarity,
463                "is_profile": bool(spec.get("MS:1000128")),
464                "is_centroid": bool(spec.get("MS:1000127")),
465            }
466
467        for scan_number in scan_list:
468            entry = collected.get(scan_number)
469            if entry is None:
470                raise ValueError(
471                    "Scan number %d not found in mzML file" % scan_number
472                )
473
474            polarity = entry["polarity"]
475            mz = entry["mz"]
476            abundance = entry["abundance"]
477
478            # Get mass spectrum
479            if spectrum_mode == "profile":
480                # Check if profile
481                if not entry["is_profile"]:
482                    raise ValueError("spectrum is not profile")
483                data_dict = {
484                    Labels.mz: mz,
485                    Labels.abundance: abundance,
486                }
487                d_params = set_metadata(
488                    scan_number,
489                    polarity,
490                    self.file_location,
491                    label=Labels.simulated_profile,
492                )
493                mass_spectrum_obj = MassSpecProfile(
494                    data_dict, d_params, auto_process=auto_process
495                )
496            elif spectrum_mode == "centroid":
497                # Check if centroided
498                if not entry["is_centroid"]:
499                    raise ValueError("spectrum is not centroided")
500                data_dict = {
501                    Labels.mz: mz,
502                    Labels.abundance: abundance,
503                    Labels.rp: [np.nan] * len(mz),
504                    Labels.s2n: [np.nan] * len(abundance),
505                }
506                d_params = set_metadata(
507                    scan_number, polarity, self.file_location, label=Labels.corems_centroid
508                )
509                mass_spectrum_obj = MassSpecCentroid(
510                    data_dict, d_params, auto_process=auto_process
511                )
512            else:
513                raise ValueError(
514                    "spectrum_mode must be 'profile' or 'centroid', got %r"
515                    % spectrum_mode
516                )
517
518            mass_spectrum_objects.append(mass_spectrum_obj)
519
520        return mass_spectrum_objects
521
522    def get_mass_spectra_obj(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
523        """Instatiate a MassSpectraBase object from the mzML file.
524
525        Parameters
526        ----------
527        time_range : tuple or list of tuples, optional
528            Retention time range(s) to load. Can be:
529            - Single range: (start_time, end_time) in minutes
530            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
531            If None, loads all scans.
532
533        Returns
534        -------
535        MassSpectraBase
536            The MassSpectra object containing the parsed mass spectra.
537            The object is instatiated with the mzML file, analyzer, instrument, sample name, and scan dataframe.
538        """
539        _, scan_df = self.run(spectra=False, time_range=time_range)
540        mass_spectra_obj = MassSpectraBase(
541            self.file_location,
542            self.analyzer,
543            self.instrument_label,
544            self.sample_name,
545            self,
546        )
547        scan_df = scan_df.set_index("scan", drop=False)
548        mass_spectra_obj.scan_df = scan_df
549
550        return mass_spectra_obj
551
552    def get_lcms_obj(self, spectra="all", time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
553        """Instatiates a LCMSBase object from the mzML file.
554
555        Parameters
556        ----------
557        spectra : str, optional
558            Which mass spectra data to include in the output. Default is all.  Other options: none, ms1, ms2.
559        time_range : tuple or list of tuples, optional
560            Retention time range(s) to load. Can be:
561            - Single range: (start_time, end_time) in minutes
562            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
563            If None, loads all scans. Useful for targeted workflows to improve performance.
564
565        Returns
566        -------
567        LCMSBase
568            LCMS object containing mass spectra data.
569            The object is instatiated with the mzML file, analyzer, instrument, sample name, scan dataframe,
570            and mz dataframe(s), as well as lists of scan numbers, retention times, and TICs.
571        """
572        _, scan_df = self.run(spectra="none", time_range=time_range)  # first run it to just get scan info
573        if spectra != "none":
574            res, scan_df = self.run(
575                scan_df=scan_df, spectra=spectra, time_range=time_range
576            )  # second run to parse data
577        lcms_obj = LCMSBase(
578            self.file_location,
579            self.analyzer,
580            self.instrument_label,
581            self.sample_name,
582            self,
583        )
584        if spectra != "none":
585            for key in res:
586                key_int = int(key.replace("ms", ""))
587                res[key] = res[key][res[key].intensity > 0]
588                res[key] = res[key].sort_values(by=["scan", "mz"]).reset_index(drop=True)
589                lcms_obj._ms_unprocessed[key_int] = res[key]
590        lcms_obj.scan_df = scan_df.set_index("scan", drop=False)
591        # Check if polarity is mixed
592        if len(set(scan_df.polarity)) > 1:
593            raise ValueError("Mixed polarities detected in scan data")
594        lcms_obj.polarity = scan_df.polarity[0]
595        lcms_obj._scans_number_list = list(scan_df.scan)
596        lcms_obj._retention_time_list = list(scan_df.scan_time)
597        lcms_obj._tic_list = list(scan_df.tic)
598
599        return lcms_obj
600
601    def get_scans_in_time_range(
602        self, 
603        time_range: Union[Tuple[float, float], List[Tuple[float, float]]],
604        ms_level: Optional[int] = None
605    ) -> List[int]:
606        """
607        Return scan numbers within specified retention time range(s).
608        
609        This method provides efficient filtering of scans by retention time,
610        which is particularly useful for targeted workflows where only specific
611        time windows are of interest.
612        
613        Parameters
614        ----------
615        time_range : tuple or list of tuples
616            Retention time range(s) in minutes. Can be:
617            - Single range: (start_time, end_time)
618            - Multiple ranges: [(start1, end1), (start2, end2), ...]
619        ms_level : int, optional
620            If specified, only return scans of this MS level (e.g., 1 for MS1, 2 for MS2).
621            If None, returns scans of all MS levels.
622        
623        Returns
624        -------
625        list of int
626            List of scan numbers within the specified time range(s) and MS level.
627        
628        Examples
629        --------
630        Get MS1 scans between 1.0 and 2.0 minutes:
631        
632        >>> scans = parser.get_scans_in_time_range((1.0, 2.0), ms_level=1)
633        
634        Get scans in multiple time windows:
635        
636        >>> scans = parser.get_scans_in_time_range([(0.5, 1.5), (3.0, 4.0)])
637        """
638        # Get scan dataframe filtered by time range
639        scan_df = self.get_scan_df(time_range=time_range)
640        
641        # Further filter by MS level if specified
642        if ms_level is not None:
643            scan_df = scan_df[scan_df.ms_level == ms_level]
644        
645        # Return list of scan numbers
646        return scan_df.scan.tolist()
647
648    def get_instrument_info(self):
649        """
650        Return instrument information.
651
652        Returns
653        -------
654        dict
655            A dictionary with the keys 'model' and 'serial_number'.
656        """
657        # Load the pymzml data
658        data = self.load()
659        instrument_info = data.info.get('referenceable_param_group_list_element')[0]
660        cv_params = instrument_info.findall('{http://psi.hupo.org/ms/mzml}cvParam')
661
662        # Extract details from each cvParam
663        params = []
664        for param in cv_params:
665            accession = param.get('accession')  # Get 'accession' attribute
666            name = param.get('name')           # Get 'name' attribute
667            value = param.get('value')         # Get 'value' attribute
668            params.append({
669                'accession': accession,
670                'name': name,
671                'value': value
672            })
673
674        # Loop through params and try to find the relevant information
675        instrument_dict = {
676            'model': 'Unknown',
677            'serial_number': 'Unknown'
678        }
679
680        # Assuming there are only two paramters here - one is for the serial number (agnostic to the model) and the other is for the model
681        # If there are more than two, we raise an error
682        if len(params) < 2:
683            raise ValueError("Not enough parameters found in the instrument info, cannot parse.")
684        if len(params) > 2:
685            raise ValueError("Too many parameters found in the instrument info, cannot parse.")
686        for param in params:
687            if param['accession'] == 'MS:1000529':
688                instrument_dict['serial_number'] = param['value']
689            else:
690                instrument_dict['model'] = data.OT[param['accession']]     
691
692        return instrument_dict
693
694    def get_creation_time(self) -> datetime.datetime:
695        """
696        Return the creation time of the mzML file.
697        """
698        data = self.load()
699        write_time = data.info.get('start_time')
700        if write_time:
701            # Convert the write time to a datetime object
702            return datetime.datetime.strptime(write_time, "%Y-%m-%dT%H:%M:%SZ")
703        else:
704            raise ValueError("Creation time is not available in the mzML file. "
705                           "Please ensure the file contains the 'start_time' information.")
class MZMLSpectraParser(corems.mass_spectra.input.parserbase.SpectraParserInterface):
 21class MZMLSpectraParser(SpectraParserInterface):
 22    """A class for parsing mzml spectrometry data files into MassSpectraBase or LCMSBase objects
 23
 24    Parameters
 25    ----------
 26    file_location : str or Path
 27        The path to the RAW file to be parsed.
 28    analyzer : str, optional
 29        The type of mass analyzer used in the instrument. Default is "Unknown".
 30    instrument_label : str, optional
 31        The name of the instrument used to acquire the data. Default is "Unknown".
 32    sample_name : str, optional
 33        The name of the sample being analyzed. If not provided, the stem of the file_location path will be used.
 34
 35    Attributes
 36    ----------
 37    file_location : Path
 38        The path to the RAW file being parsed.
 39    analyzer : str
 40        The type of mass analyzer used in the instrument.
 41    instrument_label : str
 42        The name of the instrument used to acquire the data.
 43    sample_name : str
 44        The name of the sample being analyzed.
 45
 46    Methods
 47    -------
 48    * load().
 49        Load mzML file using pymzml.run.Reader and return the data as a numpy array.
 50    * run(spectra=True).
 51        Parses the mzml file and returns a dictionary of mass spectra dataframes and a scan metadata dataframe.
 52    * get_mass_spectrum_from_scan(scan_number, polarity, auto_process=True)
 53        Parses the mzml file and returns a MassSpecBase object from a single scan.
 54    * get_mass_spectra_obj().
 55        Parses the mzml file and instantiates a MassSpectraBase object.
 56    * get_lcms_obj().
 57        Parses the mzml file and instantiates an LCMSBase object.
 58    * get_instrument_info().
 59        Return instrument information from the mzML file.
 60    * get_creation_time().
 61        Return the creation time of the mzML file as a datetime object.
 62
 63    Inherits from ThermoBaseClass and SpectraParserInterface
 64    """
 65
 66    def __init__(
 67        self,
 68        file_location,
 69        analyzer="Unknown",
 70        instrument_label="Unknown",
 71        sample_name=None,
 72    ):
 73        # implementation details
 74        if isinstance(file_location, str):
 75            # if obj is a string it defaults to create a Path obj, pass the S3Path if needed
 76            file_location = Path(file_location)
 77        if not file_location.exists():
 78            raise FileExistsError("File does not exist: " + str(file_location))
 79        self.file_location = file_location
 80        self.analyzer = analyzer
 81        self.instrument_label = instrument_label
 82
 83        if sample_name:
 84            self.sample_name = sample_name
 85        else:
 86            self.sample_name = file_location.stem
 87
 88    def load(self):
 89        """
 90        Load mzML file using pymzml.run.Reader and return the data as a numpy array.
 91
 92        Returns
 93        -------
 94        numpy.ndarray
 95            The mass spectra data as a numpy array.
 96        """
 97        data = pymzml.run.Reader(self.file_location)
 98        return data
 99
100    def get_scan_df(self, data=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
101        """
102        Return scan data as a pandas DataFrame.
103
104        Parameters
105        ----------
106        data : pymzml.run.Reader, optional
107            The mass spectra data. If None, will load the data.
108        time_range : tuple or list of tuples, optional
109            Retention time range(s) to filter scans. Can be:
110            - Single range: (start_time, end_time) in minutes
111            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
112            If None, returns all scans.
113
114        Returns
115        -------
116        pandas.DataFrame
117            A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time.
118        """
119        if data is None:
120            data = self.load()
121        # Scan dict
122        # instatinate scan dict, with empty lists of size of scans
123        n_scans = data.get_spectrum_count()
124        scan_dict = {
125            "scan": np.empty(n_scans, dtype=np.int32),
126            "scan_time": np.empty(n_scans, dtype=np.float32),
127            "ms_level": [None] * n_scans,
128            "polarity": [None] * n_scans,
129            "precursor_mz": [None] * n_scans,
130            "scan_text": [None] * n_scans,
131            "scan_window_lower": np.empty(n_scans, dtype=np.float32),
132            "scan_window_upper": np.empty(n_scans, dtype=np.float32),
133            "scan_precision": [None] * n_scans,
134            "tic": np.empty(n_scans, dtype=np.float32),
135            "ms_format": [None] * n_scans,
136        }
137
138        # First pass: loop through scans to get scan info
139        for i, spec in enumerate(data):
140            scan_dict["scan"][i] = spec.ID
141            scan_dict["ms_level"][i] = spec.ms_level
142            scan_dict["scan_precision"][i] = spec._measured_precision
143            scan_dict["tic"][i] = spec.TIC
144            if spec.selected_precursors:
145                scan_dict["precursor_mz"][i] = spec.selected_precursors[0].get(
146                    "mz", None
147                )
148            if spec["negative scan"] is not None:
149                scan_dict["polarity"][i] = "negative"
150            if spec["positive scan"] is not None:
151                scan_dict["polarity"][i] = "positive"
152            if spec["negative scan"] is not None and spec["positive scan"] is not None:
153                raise ValueError(
154                    "Error: scan {0} has both negative and positive polarity".format(
155                        spec.ID
156                    )
157                )
158
159            scan_dict["scan_time"][i] = spec.get("MS:1000016")
160            scan_dict["scan_text"][i] = spec.get("MS:1000512")
161            scan_dict["scan_window_lower"][i] = spec.get("MS:1000501")
162            scan_dict["scan_window_upper"][i] = spec.get("MS:1000500")
163            if spec.get("MS:1000128"):
164                scan_dict["ms_format"][i] = "profile"
165            elif spec.get("MS:1000127"):
166                scan_dict["ms_format"][i] = "centroid"
167            else:
168                scan_dict["ms_format"][i] = None
169
170        scan_df = pd.DataFrame(scan_dict)
171
172        # Remove any non-mass spectra scans (e.g., MS level 0 or None)
173        scan_df = scan_df[scan_df.ms_level.notnull() & (scan_df.ms_level > 0)].reset_index(drop=True)
174        
175        # Apply time range filtering if specified
176        if time_range is not None:
177            time_ranges = self._normalize_time_range(time_range)
178            # Create a mask for scans within any of the time ranges
179            mask = np.zeros(len(scan_df), dtype=bool)
180            for start_time, end_time in time_ranges:
181                mask |= (scan_df["scan_time"] >= start_time) & (scan_df["scan_time"] <= end_time)
182            scan_df = scan_df[mask].reset_index(drop=True)
183
184        return scan_df
185
186    def get_ms_raw(self, spectra, scan_df, data=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
187        """Return a dictionary of mass spectra data as a pandas DataFrame.
188
189        Parameters
190        ----------
191        spectra : str
192            Which mass spectra data to include in the output.
193            Options: None, "ms1", "ms2", "all".
194        scan_df : pandas.DataFrame
195            Scan dataframe. Output from get_scan_df().
196        data : pymzml.run.Reader, optional
197            The mass spectra data. If None, will load the data.
198        time_range : tuple or list of tuples, optional
199            Retention time range(s) to filter scans. Can be:
200            - Single range: (start_time, end_time) in minutes
201            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
202            If None, returns all scans. Note: filtering is typically done at scan_df level.
203
204        Returns
205        -------
206        dict
207            A dictionary containing the mass spectra data as pandas DataFrames, with keys corresponding to the MS level.
208
209        """
210        if data is None:
211            data = self.load()
212        if spectra == "all":
213            scan_df_forspec = scan_df
214        elif spectra == "ms1":
215            scan_df_forspec = scan_df[scan_df.ms_level == 1]
216        elif spectra == "ms2":
217            scan_df_forspec = scan_df[scan_df.ms_level == 2]
218        else:
219            raise ValueError("spectra must be 'all', 'ms1', or 'ms2'")
220
221        # Result container
222        res = {}
223
224        # Row count container
225        counter = {}
226
227        # Column name container
228        cols = {}
229
230        # set at float32
231        dtype = np.float32
232
233        # First pass: get nrows
234        N = defaultdict(lambda: 0)
235        for i, spec in enumerate(data):
236            if spec.ID in scan_df_forspec.scan.values:
237                # Get ms level
238                level = "ms{}".format(spec.ms_level)
239
240                # Number of rows
241                N[level] += spec.mz.shape[0]
242
243        # Second pass: parse
244        for i, spec in enumerate(data):
245            if spec.ID in scan_df_forspec.scan.values:
246                # Number of rows
247                n = spec.mz.shape[0]
248
249                # No measurements
250                if n == 0:
251                    continue
252
253                # Dimension check
254                if len(spec.mz) != len(spec.i):
255                    # raise an error if the mz and intensity arrays are not the same length
256                    raise ValueError("m/z and intensity array dimension mismatch")
257
258                # Scan/frame info
259                id_dict = spec.id_dict
260
261                # Get ms level
262                level = "ms{}".format(spec.ms_level)
263
264                # Columns
265                cols[level] = list(id_dict.keys()) + ["mz", "intensity"]
266                m = len(cols[level])
267
268                # Subarray init
269                arr = np.empty((n, m), dtype=dtype)
270                inx = 0
271
272                # Populate scan/frame info
273                for k, v in id_dict.items():
274                    arr[:, inx] = v
275                    inx += 1
276
277                # Populate m/z
278                arr[:, inx] = spec.mz
279                inx += 1
280
281                # Populate intensity
282                arr[:, inx] = spec.i
283                inx += 1
284
285                # Initialize output container
286                if level not in res:
287                    res[level] = np.empty((N[level], m), dtype=dtype)
288                    counter[level] = 0
289
290                # Insert subarray
291                res[level][counter[level] : counter[level] + n, :] = arr
292                counter[level] += n
293
294        # Construct ms1 and ms2 mz dataframes
295        for level in res.keys():
296            res[level] = pd.DataFrame(res[level], columns=cols[level]).drop(
297                columns=["controllerType", "controllerNumber"],
298            )
299
300        return res
301
302    def run(self, spectra="all", scan_df=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
303        """Parse the mzML file and return a dictionary of spectra dataframes and a scan metadata dataframe.
304
305        Parameters
306        ----------
307        spectra : str, optional
308            Which mass spectra data to include in the output. Default is "all".
309            Other options: None, "ms1", "ms2".
310        scan_df : pandas.DataFrame, optional
311            Scan dataframe.  If not provided, the scan dataframe is created from the mzML file.
312        time_range : tuple or list of tuples, optional
313            Retention time range(s) to load. Can be:
314            - Single range: (start_time, end_time) in minutes
315            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
316            If None, loads all scans.
317
318        Returns
319        -------
320        tuple
321            A tuple containing two elements:
322            - A dictionary containing the mass spectra data as numpy arrays, with keys corresponding to the MS level.
323            - A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time.
324        """
325
326        # Open file
327        data = self.load()
328
329        if scan_df is None:
330            scan_df = self.get_scan_df(data, time_range=time_range)
331
332        if spectra != "none":
333            res = self.get_ms_raw(spectra, scan_df, data)
334
335        else:
336            res = None
337
338        return res, scan_df
339
340    def get_mass_spectrum_from_scan(
341        self, scan_number, spectrum_mode, auto_process=True
342    ):
343        """Instatiate a mass spectrum object from the mzML file.
344
345        Parameters
346        ----------
347        scan_number : int
348            The scan number to be parsed.
349        spectrum_mode : str
350            The type of spectrum to instantiate.  Must be'profile' or 'centroid'.
351        polarity : int
352            The polarity of the scan.  Must be -1 or 1.
353        auto_process : bool, optional
354            If True, process the mass spectrum. Default is True.
355
356        Returns
357        -------
358        MassSpecProfile | MassSpecCentroid
359            The MassSpecProfile or MassSpecCentroid object containing the parsed mass spectrum.
360        """
361        # Use the batch function and return the first result
362        result_list = self.get_mass_spectra_from_scan_list(
363            [scan_number], spectrum_mode, auto_process
364        )
365        return result_list[0] if result_list else None
366
367    def get_mass_spectra_from_scan_list(
368        self, scan_list, spectrum_mode, auto_process=True
369    ):
370        """Instatiate mass spectrum objects from the mzML file.
371
372        Parameters
373        ----------
374        scan_list : list of int
375            The scan numbers to be parsed.
376        spectrum_mode : str
377            The type of spectrum to instantiate.  Must be'profile' or 'centroid'.
378        auto_process : bool, optional
379            If True, process the mass spectrum. Default is True.
380
381        Returns
382        -------
383        list of MassSpecProfile | MassSpecCentroid
384            List of MassSpecProfile or MassSpecCentroid objects containing the parsed mass spectra.
385        """
386
387        def set_metadata(
388            scan_number: int,
389            polarity: int,
390            file_location: str,
391            label=Labels.thermo_profile,
392        ):
393            """
394            Set the output parameters for creating a MassSpecProfile or MassSpecCentroid object.
395
396            Parameters
397            ----------
398            scan_number : int
399                The scan number.
400            polarity : int
401                The polarity of the data.
402            file_location : str
403                The file location.
404            label : str, optional
405                The label for the mass spectrum. Default is Labels.thermo_profile.
406
407            Returns
408            -------
409            dict
410                The output parameters ready for creating a MassSpecProfile or MassSpecCentroid object.
411            """
412            d_params = default_parameters(file_location)
413            d_params["label"] = label
414            d_params["polarity"] = polarity
415            d_params["filename_path"] = file_location
416            d_params["scan_number"] = scan_number
417
418            return d_params
419
420        # Open file
421        data = self.load()
422
423        mass_spectrum_objects = []
424        scan_set = set(scan_list)
425        # Materialize peak data during iteration. Do not retain live pymzml
426        # spectrum objects, and do not overwrite the first valid MS spectrum for
427        # a scan ID.
428        #
429        # Thermo multi-controller mzML files often reuse scan numbers across
430        # controllers (e.g. controllerType=0 MS data and controllerType=3
431        # auxiliary traces). pymzml exposes only the integer scan number as
432        # spec.ID, so sequential iteration can yield the same ID twice. Keeping
433        # the last yield (or random-access data[scan]) can replace real MS
434        # spectra with non-MS controller spectra and then fail centroid/profile
435        # checks with "spectrum is not centroided".
436        #
437        # Direct random-access via data[scan_number] also uses pymzml's
438        # byte-offset index, which is unreliable on Windows (CRLF vs LF).
439        collected = {}
440
441        for spec in data:
442            scan_id = spec.ID
443            if scan_id not in scan_set or scan_id in collected:
444                continue
445
446            # Skip non-MS / auxiliary-controller spectra that share scan numbers
447            ms_level = spec.ms_level
448            if ms_level is None or ms_level <= 0:
449                continue
450
451            if spec["negative scan"] is not None:
452                polarity = -1
453            elif spec["positive scan"] is not None:
454                polarity = 1
455            else:
456                polarity = None
457
458            mz = np.asarray(spec.mz)
459            abundance = np.asarray(spec.i)
460            collected[scan_id] = {
461                "mz": mz,
462                "abundance": abundance,
463                "polarity": polarity,
464                "is_profile": bool(spec.get("MS:1000128")),
465                "is_centroid": bool(spec.get("MS:1000127")),
466            }
467
468        for scan_number in scan_list:
469            entry = collected.get(scan_number)
470            if entry is None:
471                raise ValueError(
472                    "Scan number %d not found in mzML file" % scan_number
473                )
474
475            polarity = entry["polarity"]
476            mz = entry["mz"]
477            abundance = entry["abundance"]
478
479            # Get mass spectrum
480            if spectrum_mode == "profile":
481                # Check if profile
482                if not entry["is_profile"]:
483                    raise ValueError("spectrum is not profile")
484                data_dict = {
485                    Labels.mz: mz,
486                    Labels.abundance: abundance,
487                }
488                d_params = set_metadata(
489                    scan_number,
490                    polarity,
491                    self.file_location,
492                    label=Labels.simulated_profile,
493                )
494                mass_spectrum_obj = MassSpecProfile(
495                    data_dict, d_params, auto_process=auto_process
496                )
497            elif spectrum_mode == "centroid":
498                # Check if centroided
499                if not entry["is_centroid"]:
500                    raise ValueError("spectrum is not centroided")
501                data_dict = {
502                    Labels.mz: mz,
503                    Labels.abundance: abundance,
504                    Labels.rp: [np.nan] * len(mz),
505                    Labels.s2n: [np.nan] * len(abundance),
506                }
507                d_params = set_metadata(
508                    scan_number, polarity, self.file_location, label=Labels.corems_centroid
509                )
510                mass_spectrum_obj = MassSpecCentroid(
511                    data_dict, d_params, auto_process=auto_process
512                )
513            else:
514                raise ValueError(
515                    "spectrum_mode must be 'profile' or 'centroid', got %r"
516                    % spectrum_mode
517                )
518
519            mass_spectrum_objects.append(mass_spectrum_obj)
520
521        return mass_spectrum_objects
522
523    def get_mass_spectra_obj(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
524        """Instatiate a MassSpectraBase object from the mzML file.
525
526        Parameters
527        ----------
528        time_range : tuple or list of tuples, optional
529            Retention time range(s) to load. Can be:
530            - Single range: (start_time, end_time) in minutes
531            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
532            If None, loads all scans.
533
534        Returns
535        -------
536        MassSpectraBase
537            The MassSpectra object containing the parsed mass spectra.
538            The object is instatiated with the mzML file, analyzer, instrument, sample name, and scan dataframe.
539        """
540        _, scan_df = self.run(spectra=False, time_range=time_range)
541        mass_spectra_obj = MassSpectraBase(
542            self.file_location,
543            self.analyzer,
544            self.instrument_label,
545            self.sample_name,
546            self,
547        )
548        scan_df = scan_df.set_index("scan", drop=False)
549        mass_spectra_obj.scan_df = scan_df
550
551        return mass_spectra_obj
552
553    def get_lcms_obj(self, spectra="all", time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
554        """Instatiates a LCMSBase object from the mzML file.
555
556        Parameters
557        ----------
558        spectra : str, optional
559            Which mass spectra data to include in the output. Default is all.  Other options: none, ms1, ms2.
560        time_range : tuple or list of tuples, optional
561            Retention time range(s) to load. Can be:
562            - Single range: (start_time, end_time) in minutes
563            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
564            If None, loads all scans. Useful for targeted workflows to improve performance.
565
566        Returns
567        -------
568        LCMSBase
569            LCMS object containing mass spectra data.
570            The object is instatiated with the mzML file, analyzer, instrument, sample name, scan dataframe,
571            and mz dataframe(s), as well as lists of scan numbers, retention times, and TICs.
572        """
573        _, scan_df = self.run(spectra="none", time_range=time_range)  # first run it to just get scan info
574        if spectra != "none":
575            res, scan_df = self.run(
576                scan_df=scan_df, spectra=spectra, time_range=time_range
577            )  # second run to parse data
578        lcms_obj = LCMSBase(
579            self.file_location,
580            self.analyzer,
581            self.instrument_label,
582            self.sample_name,
583            self,
584        )
585        if spectra != "none":
586            for key in res:
587                key_int = int(key.replace("ms", ""))
588                res[key] = res[key][res[key].intensity > 0]
589                res[key] = res[key].sort_values(by=["scan", "mz"]).reset_index(drop=True)
590                lcms_obj._ms_unprocessed[key_int] = res[key]
591        lcms_obj.scan_df = scan_df.set_index("scan", drop=False)
592        # Check if polarity is mixed
593        if len(set(scan_df.polarity)) > 1:
594            raise ValueError("Mixed polarities detected in scan data")
595        lcms_obj.polarity = scan_df.polarity[0]
596        lcms_obj._scans_number_list = list(scan_df.scan)
597        lcms_obj._retention_time_list = list(scan_df.scan_time)
598        lcms_obj._tic_list = list(scan_df.tic)
599
600        return lcms_obj
601
602    def get_scans_in_time_range(
603        self, 
604        time_range: Union[Tuple[float, float], List[Tuple[float, float]]],
605        ms_level: Optional[int] = None
606    ) -> List[int]:
607        """
608        Return scan numbers within specified retention time range(s).
609        
610        This method provides efficient filtering of scans by retention time,
611        which is particularly useful for targeted workflows where only specific
612        time windows are of interest.
613        
614        Parameters
615        ----------
616        time_range : tuple or list of tuples
617            Retention time range(s) in minutes. Can be:
618            - Single range: (start_time, end_time)
619            - Multiple ranges: [(start1, end1), (start2, end2), ...]
620        ms_level : int, optional
621            If specified, only return scans of this MS level (e.g., 1 for MS1, 2 for MS2).
622            If None, returns scans of all MS levels.
623        
624        Returns
625        -------
626        list of int
627            List of scan numbers within the specified time range(s) and MS level.
628        
629        Examples
630        --------
631        Get MS1 scans between 1.0 and 2.0 minutes:
632        
633        >>> scans = parser.get_scans_in_time_range((1.0, 2.0), ms_level=1)
634        
635        Get scans in multiple time windows:
636        
637        >>> scans = parser.get_scans_in_time_range([(0.5, 1.5), (3.0, 4.0)])
638        """
639        # Get scan dataframe filtered by time range
640        scan_df = self.get_scan_df(time_range=time_range)
641        
642        # Further filter by MS level if specified
643        if ms_level is not None:
644            scan_df = scan_df[scan_df.ms_level == ms_level]
645        
646        # Return list of scan numbers
647        return scan_df.scan.tolist()
648
649    def get_instrument_info(self):
650        """
651        Return instrument information.
652
653        Returns
654        -------
655        dict
656            A dictionary with the keys 'model' and 'serial_number'.
657        """
658        # Load the pymzml data
659        data = self.load()
660        instrument_info = data.info.get('referenceable_param_group_list_element')[0]
661        cv_params = instrument_info.findall('{http://psi.hupo.org/ms/mzml}cvParam')
662
663        # Extract details from each cvParam
664        params = []
665        for param in cv_params:
666            accession = param.get('accession')  # Get 'accession' attribute
667            name = param.get('name')           # Get 'name' attribute
668            value = param.get('value')         # Get 'value' attribute
669            params.append({
670                'accession': accession,
671                'name': name,
672                'value': value
673            })
674
675        # Loop through params and try to find the relevant information
676        instrument_dict = {
677            'model': 'Unknown',
678            'serial_number': 'Unknown'
679        }
680
681        # Assuming there are only two paramters here - one is for the serial number (agnostic to the model) and the other is for the model
682        # If there are more than two, we raise an error
683        if len(params) < 2:
684            raise ValueError("Not enough parameters found in the instrument info, cannot parse.")
685        if len(params) > 2:
686            raise ValueError("Too many parameters found in the instrument info, cannot parse.")
687        for param in params:
688            if param['accession'] == 'MS:1000529':
689                instrument_dict['serial_number'] = param['value']
690            else:
691                instrument_dict['model'] = data.OT[param['accession']]     
692
693        return instrument_dict
694
695    def get_creation_time(self) -> datetime.datetime:
696        """
697        Return the creation time of the mzML file.
698        """
699        data = self.load()
700        write_time = data.info.get('start_time')
701        if write_time:
702            # Convert the write time to a datetime object
703            return datetime.datetime.strptime(write_time, "%Y-%m-%dT%H:%M:%SZ")
704        else:
705            raise ValueError("Creation time is not available in the mzML file. "
706                           "Please ensure the file contains the 'start_time' information.")

A class for parsing mzml spectrometry data files into MassSpectraBase or LCMSBase objects

Parameters
  • file_location (str or Path): The path to the RAW file to be parsed.
  • analyzer (str, optional): The type of mass analyzer used in the instrument. Default is "Unknown".
  • instrument_label (str, optional): The name of the instrument used to acquire the data. Default is "Unknown".
  • sample_name (str, optional): The name of the sample being analyzed. If not provided, the stem of the file_location path will be used.
Attributes
  • file_location (Path): The path to the RAW file being parsed.
  • analyzer (str): The type of mass analyzer used in the instrument.
  • instrument_label (str): The name of the instrument used to acquire the data.
  • sample_name (str): The name of the sample being analyzed.
Methods
  • load(). Load mzML file using pymzml.run.Reader and return the data as a numpy array.
  • run(spectra=True). Parses the mzml file and returns a dictionary of mass spectra dataframes and a scan metadata dataframe.
  • get_mass_spectrum_from_scan(scan_number, polarity, auto_process=True) Parses the mzml file and returns a MassSpecBase object from a single scan.
  • get_mass_spectra_obj(). Parses the mzml file and instantiates a MassSpectraBase object.
  • get_lcms_obj(). Parses the mzml file and instantiates an LCMSBase object.
  • get_instrument_info(). Return instrument information from the mzML file.
  • get_creation_time(). Return the creation time of the mzML file as a datetime object.

Inherits from ThermoBaseClass and SpectraParserInterface

MZMLSpectraParser( file_location, analyzer='Unknown', instrument_label='Unknown', sample_name=None)
66    def __init__(
67        self,
68        file_location,
69        analyzer="Unknown",
70        instrument_label="Unknown",
71        sample_name=None,
72    ):
73        # implementation details
74        if isinstance(file_location, str):
75            # if obj is a string it defaults to create a Path obj, pass the S3Path if needed
76            file_location = Path(file_location)
77        if not file_location.exists():
78            raise FileExistsError("File does not exist: " + str(file_location))
79        self.file_location = file_location
80        self.analyzer = analyzer
81        self.instrument_label = instrument_label
82
83        if sample_name:
84            self.sample_name = sample_name
85        else:
86            self.sample_name = file_location.stem
file_location
analyzer
instrument_label
def load(self):
88    def load(self):
89        """
90        Load mzML file using pymzml.run.Reader and return the data as a numpy array.
91
92        Returns
93        -------
94        numpy.ndarray
95            The mass spectra data as a numpy array.
96        """
97        data = pymzml.run.Reader(self.file_location)
98        return data

Load mzML file using pymzml.run.Reader and return the data as a numpy array.

Returns
  • numpy.ndarray: The mass spectra data as a numpy array.
def get_scan_df( self, data=None, time_range: Union[Tuple[float, float], List[Tuple[float, float]], NoneType] = None):
100    def get_scan_df(self, data=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
101        """
102        Return scan data as a pandas DataFrame.
103
104        Parameters
105        ----------
106        data : pymzml.run.Reader, optional
107            The mass spectra data. If None, will load the data.
108        time_range : tuple or list of tuples, optional
109            Retention time range(s) to filter scans. Can be:
110            - Single range: (start_time, end_time) in minutes
111            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
112            If None, returns all scans.
113
114        Returns
115        -------
116        pandas.DataFrame
117            A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time.
118        """
119        if data is None:
120            data = self.load()
121        # Scan dict
122        # instatinate scan dict, with empty lists of size of scans
123        n_scans = data.get_spectrum_count()
124        scan_dict = {
125            "scan": np.empty(n_scans, dtype=np.int32),
126            "scan_time": np.empty(n_scans, dtype=np.float32),
127            "ms_level": [None] * n_scans,
128            "polarity": [None] * n_scans,
129            "precursor_mz": [None] * n_scans,
130            "scan_text": [None] * n_scans,
131            "scan_window_lower": np.empty(n_scans, dtype=np.float32),
132            "scan_window_upper": np.empty(n_scans, dtype=np.float32),
133            "scan_precision": [None] * n_scans,
134            "tic": np.empty(n_scans, dtype=np.float32),
135            "ms_format": [None] * n_scans,
136        }
137
138        # First pass: loop through scans to get scan info
139        for i, spec in enumerate(data):
140            scan_dict["scan"][i] = spec.ID
141            scan_dict["ms_level"][i] = spec.ms_level
142            scan_dict["scan_precision"][i] = spec._measured_precision
143            scan_dict["tic"][i] = spec.TIC
144            if spec.selected_precursors:
145                scan_dict["precursor_mz"][i] = spec.selected_precursors[0].get(
146                    "mz", None
147                )
148            if spec["negative scan"] is not None:
149                scan_dict["polarity"][i] = "negative"
150            if spec["positive scan"] is not None:
151                scan_dict["polarity"][i] = "positive"
152            if spec["negative scan"] is not None and spec["positive scan"] is not None:
153                raise ValueError(
154                    "Error: scan {0} has both negative and positive polarity".format(
155                        spec.ID
156                    )
157                )
158
159            scan_dict["scan_time"][i] = spec.get("MS:1000016")
160            scan_dict["scan_text"][i] = spec.get("MS:1000512")
161            scan_dict["scan_window_lower"][i] = spec.get("MS:1000501")
162            scan_dict["scan_window_upper"][i] = spec.get("MS:1000500")
163            if spec.get("MS:1000128"):
164                scan_dict["ms_format"][i] = "profile"
165            elif spec.get("MS:1000127"):
166                scan_dict["ms_format"][i] = "centroid"
167            else:
168                scan_dict["ms_format"][i] = None
169
170        scan_df = pd.DataFrame(scan_dict)
171
172        # Remove any non-mass spectra scans (e.g., MS level 0 or None)
173        scan_df = scan_df[scan_df.ms_level.notnull() & (scan_df.ms_level > 0)].reset_index(drop=True)
174        
175        # Apply time range filtering if specified
176        if time_range is not None:
177            time_ranges = self._normalize_time_range(time_range)
178            # Create a mask for scans within any of the time ranges
179            mask = np.zeros(len(scan_df), dtype=bool)
180            for start_time, end_time in time_ranges:
181                mask |= (scan_df["scan_time"] >= start_time) & (scan_df["scan_time"] <= end_time)
182            scan_df = scan_df[mask].reset_index(drop=True)
183
184        return scan_df

Return scan data as a pandas DataFrame.

Parameters
  • data (pymzml.run.Reader, optional): The mass spectra data. If None, will load the data.
  • time_range (tuple or list of tuples, optional): Retention time range(s) to filter scans. Can be:
    • Single range: (start_time, end_time) in minutes
    • Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes If None, returns all scans.
Returns
  • pandas.DataFrame: A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time.
def get_ms_raw( self, spectra, scan_df, data=None, time_range: Union[Tuple[float, float], List[Tuple[float, float]], NoneType] = None):
186    def get_ms_raw(self, spectra, scan_df, data=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
187        """Return a dictionary of mass spectra data as a pandas DataFrame.
188
189        Parameters
190        ----------
191        spectra : str
192            Which mass spectra data to include in the output.
193            Options: None, "ms1", "ms2", "all".
194        scan_df : pandas.DataFrame
195            Scan dataframe. Output from get_scan_df().
196        data : pymzml.run.Reader, optional
197            The mass spectra data. If None, will load the data.
198        time_range : tuple or list of tuples, optional
199            Retention time range(s) to filter scans. Can be:
200            - Single range: (start_time, end_time) in minutes
201            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
202            If None, returns all scans. Note: filtering is typically done at scan_df level.
203
204        Returns
205        -------
206        dict
207            A dictionary containing the mass spectra data as pandas DataFrames, with keys corresponding to the MS level.
208
209        """
210        if data is None:
211            data = self.load()
212        if spectra == "all":
213            scan_df_forspec = scan_df
214        elif spectra == "ms1":
215            scan_df_forspec = scan_df[scan_df.ms_level == 1]
216        elif spectra == "ms2":
217            scan_df_forspec = scan_df[scan_df.ms_level == 2]
218        else:
219            raise ValueError("spectra must be 'all', 'ms1', or 'ms2'")
220
221        # Result container
222        res = {}
223
224        # Row count container
225        counter = {}
226
227        # Column name container
228        cols = {}
229
230        # set at float32
231        dtype = np.float32
232
233        # First pass: get nrows
234        N = defaultdict(lambda: 0)
235        for i, spec in enumerate(data):
236            if spec.ID in scan_df_forspec.scan.values:
237                # Get ms level
238                level = "ms{}".format(spec.ms_level)
239
240                # Number of rows
241                N[level] += spec.mz.shape[0]
242
243        # Second pass: parse
244        for i, spec in enumerate(data):
245            if spec.ID in scan_df_forspec.scan.values:
246                # Number of rows
247                n = spec.mz.shape[0]
248
249                # No measurements
250                if n == 0:
251                    continue
252
253                # Dimension check
254                if len(spec.mz) != len(spec.i):
255                    # raise an error if the mz and intensity arrays are not the same length
256                    raise ValueError("m/z and intensity array dimension mismatch")
257
258                # Scan/frame info
259                id_dict = spec.id_dict
260
261                # Get ms level
262                level = "ms{}".format(spec.ms_level)
263
264                # Columns
265                cols[level] = list(id_dict.keys()) + ["mz", "intensity"]
266                m = len(cols[level])
267
268                # Subarray init
269                arr = np.empty((n, m), dtype=dtype)
270                inx = 0
271
272                # Populate scan/frame info
273                for k, v in id_dict.items():
274                    arr[:, inx] = v
275                    inx += 1
276
277                # Populate m/z
278                arr[:, inx] = spec.mz
279                inx += 1
280
281                # Populate intensity
282                arr[:, inx] = spec.i
283                inx += 1
284
285                # Initialize output container
286                if level not in res:
287                    res[level] = np.empty((N[level], m), dtype=dtype)
288                    counter[level] = 0
289
290                # Insert subarray
291                res[level][counter[level] : counter[level] + n, :] = arr
292                counter[level] += n
293
294        # Construct ms1 and ms2 mz dataframes
295        for level in res.keys():
296            res[level] = pd.DataFrame(res[level], columns=cols[level]).drop(
297                columns=["controllerType", "controllerNumber"],
298            )
299
300        return res

Return a dictionary of mass spectra data as a pandas DataFrame.

Parameters
  • spectra (str): Which mass spectra data to include in the output. Options: None, "ms1", "ms2", "all".
  • scan_df (pandas.DataFrame): Scan dataframe. Output from get_scan_df().
  • data (pymzml.run.Reader, optional): The mass spectra data. If None, will load the data.
  • time_range (tuple or list of tuples, optional): Retention time range(s) to filter scans. Can be:
    • Single range: (start_time, end_time) in minutes
    • Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes If None, returns all scans. Note: filtering is typically done at scan_df level.
Returns
  • dict: A dictionary containing the mass spectra data as pandas DataFrames, with keys corresponding to the MS level.
def run( self, spectra='all', scan_df=None, time_range: Union[Tuple[float, float], List[Tuple[float, float]], NoneType] = None):
302    def run(self, spectra="all", scan_df=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
303        """Parse the mzML file and return a dictionary of spectra dataframes and a scan metadata dataframe.
304
305        Parameters
306        ----------
307        spectra : str, optional
308            Which mass spectra data to include in the output. Default is "all".
309            Other options: None, "ms1", "ms2".
310        scan_df : pandas.DataFrame, optional
311            Scan dataframe.  If not provided, the scan dataframe is created from the mzML file.
312        time_range : tuple or list of tuples, optional
313            Retention time range(s) to load. Can be:
314            - Single range: (start_time, end_time) in minutes
315            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
316            If None, loads all scans.
317
318        Returns
319        -------
320        tuple
321            A tuple containing two elements:
322            - A dictionary containing the mass spectra data as numpy arrays, with keys corresponding to the MS level.
323            - A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time.
324        """
325
326        # Open file
327        data = self.load()
328
329        if scan_df is None:
330            scan_df = self.get_scan_df(data, time_range=time_range)
331
332        if spectra != "none":
333            res = self.get_ms_raw(spectra, scan_df, data)
334
335        else:
336            res = None
337
338        return res, scan_df

Parse the mzML file and return a dictionary of spectra dataframes and a scan metadata dataframe.

Parameters
  • spectra (str, optional): Which mass spectra data to include in the output. Default is "all". Other options: None, "ms1", "ms2".
  • scan_df (pandas.DataFrame, optional): Scan dataframe. If not provided, the scan dataframe is created from the mzML file.
  • time_range (tuple or list of tuples, optional): Retention time range(s) to load. Can be:
    • Single range: (start_time, end_time) in minutes
    • Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes If None, loads all scans.
Returns
  • tuple: A tuple containing two elements:
    • A dictionary containing the mass spectra data as numpy arrays, with keys corresponding to the MS level.
    • A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time.
def get_mass_spectrum_from_scan(self, scan_number, spectrum_mode, auto_process=True):
340    def get_mass_spectrum_from_scan(
341        self, scan_number, spectrum_mode, auto_process=True
342    ):
343        """Instatiate a mass spectrum object from the mzML file.
344
345        Parameters
346        ----------
347        scan_number : int
348            The scan number to be parsed.
349        spectrum_mode : str
350            The type of spectrum to instantiate.  Must be'profile' or 'centroid'.
351        polarity : int
352            The polarity of the scan.  Must be -1 or 1.
353        auto_process : bool, optional
354            If True, process the mass spectrum. Default is True.
355
356        Returns
357        -------
358        MassSpecProfile | MassSpecCentroid
359            The MassSpecProfile or MassSpecCentroid object containing the parsed mass spectrum.
360        """
361        # Use the batch function and return the first result
362        result_list = self.get_mass_spectra_from_scan_list(
363            [scan_number], spectrum_mode, auto_process
364        )
365        return result_list[0] if result_list else None

Instatiate a mass spectrum object from the mzML file.

Parameters
  • scan_number (int): The scan number to be parsed.
  • spectrum_mode (str): The type of spectrum to instantiate. Must be'profile' or 'centroid'.
  • polarity (int): The polarity of the scan. Must be -1 or 1.
  • auto_process (bool, optional): If True, process the mass spectrum. Default is True.
Returns
  • MassSpecProfile | MassSpecCentroid: The MassSpecProfile or MassSpecCentroid object containing the parsed mass spectrum.
def get_mass_spectra_from_scan_list(self, scan_list, spectrum_mode, auto_process=True):
367    def get_mass_spectra_from_scan_list(
368        self, scan_list, spectrum_mode, auto_process=True
369    ):
370        """Instatiate mass spectrum objects from the mzML file.
371
372        Parameters
373        ----------
374        scan_list : list of int
375            The scan numbers to be parsed.
376        spectrum_mode : str
377            The type of spectrum to instantiate.  Must be'profile' or 'centroid'.
378        auto_process : bool, optional
379            If True, process the mass spectrum. Default is True.
380
381        Returns
382        -------
383        list of MassSpecProfile | MassSpecCentroid
384            List of MassSpecProfile or MassSpecCentroid objects containing the parsed mass spectra.
385        """
386
387        def set_metadata(
388            scan_number: int,
389            polarity: int,
390            file_location: str,
391            label=Labels.thermo_profile,
392        ):
393            """
394            Set the output parameters for creating a MassSpecProfile or MassSpecCentroid object.
395
396            Parameters
397            ----------
398            scan_number : int
399                The scan number.
400            polarity : int
401                The polarity of the data.
402            file_location : str
403                The file location.
404            label : str, optional
405                The label for the mass spectrum. Default is Labels.thermo_profile.
406
407            Returns
408            -------
409            dict
410                The output parameters ready for creating a MassSpecProfile or MassSpecCentroid object.
411            """
412            d_params = default_parameters(file_location)
413            d_params["label"] = label
414            d_params["polarity"] = polarity
415            d_params["filename_path"] = file_location
416            d_params["scan_number"] = scan_number
417
418            return d_params
419
420        # Open file
421        data = self.load()
422
423        mass_spectrum_objects = []
424        scan_set = set(scan_list)
425        # Materialize peak data during iteration. Do not retain live pymzml
426        # spectrum objects, and do not overwrite the first valid MS spectrum for
427        # a scan ID.
428        #
429        # Thermo multi-controller mzML files often reuse scan numbers across
430        # controllers (e.g. controllerType=0 MS data and controllerType=3
431        # auxiliary traces). pymzml exposes only the integer scan number as
432        # spec.ID, so sequential iteration can yield the same ID twice. Keeping
433        # the last yield (or random-access data[scan]) can replace real MS
434        # spectra with non-MS controller spectra and then fail centroid/profile
435        # checks with "spectrum is not centroided".
436        #
437        # Direct random-access via data[scan_number] also uses pymzml's
438        # byte-offset index, which is unreliable on Windows (CRLF vs LF).
439        collected = {}
440
441        for spec in data:
442            scan_id = spec.ID
443            if scan_id not in scan_set or scan_id in collected:
444                continue
445
446            # Skip non-MS / auxiliary-controller spectra that share scan numbers
447            ms_level = spec.ms_level
448            if ms_level is None or ms_level <= 0:
449                continue
450
451            if spec["negative scan"] is not None:
452                polarity = -1
453            elif spec["positive scan"] is not None:
454                polarity = 1
455            else:
456                polarity = None
457
458            mz = np.asarray(spec.mz)
459            abundance = np.asarray(spec.i)
460            collected[scan_id] = {
461                "mz": mz,
462                "abundance": abundance,
463                "polarity": polarity,
464                "is_profile": bool(spec.get("MS:1000128")),
465                "is_centroid": bool(spec.get("MS:1000127")),
466            }
467
468        for scan_number in scan_list:
469            entry = collected.get(scan_number)
470            if entry is None:
471                raise ValueError(
472                    "Scan number %d not found in mzML file" % scan_number
473                )
474
475            polarity = entry["polarity"]
476            mz = entry["mz"]
477            abundance = entry["abundance"]
478
479            # Get mass spectrum
480            if spectrum_mode == "profile":
481                # Check if profile
482                if not entry["is_profile"]:
483                    raise ValueError("spectrum is not profile")
484                data_dict = {
485                    Labels.mz: mz,
486                    Labels.abundance: abundance,
487                }
488                d_params = set_metadata(
489                    scan_number,
490                    polarity,
491                    self.file_location,
492                    label=Labels.simulated_profile,
493                )
494                mass_spectrum_obj = MassSpecProfile(
495                    data_dict, d_params, auto_process=auto_process
496                )
497            elif spectrum_mode == "centroid":
498                # Check if centroided
499                if not entry["is_centroid"]:
500                    raise ValueError("spectrum is not centroided")
501                data_dict = {
502                    Labels.mz: mz,
503                    Labels.abundance: abundance,
504                    Labels.rp: [np.nan] * len(mz),
505                    Labels.s2n: [np.nan] * len(abundance),
506                }
507                d_params = set_metadata(
508                    scan_number, polarity, self.file_location, label=Labels.corems_centroid
509                )
510                mass_spectrum_obj = MassSpecCentroid(
511                    data_dict, d_params, auto_process=auto_process
512                )
513            else:
514                raise ValueError(
515                    "spectrum_mode must be 'profile' or 'centroid', got %r"
516                    % spectrum_mode
517                )
518
519            mass_spectrum_objects.append(mass_spectrum_obj)
520
521        return mass_spectrum_objects

Instatiate mass spectrum objects from the mzML file.

Parameters
  • scan_list (list of int): The scan numbers to be parsed.
  • spectrum_mode (str): The type of spectrum to instantiate. Must be'profile' or 'centroid'.
  • auto_process (bool, optional): If True, process the mass spectrum. Default is True.
Returns
  • list of MassSpecProfile | MassSpecCentroid: List of MassSpecProfile or MassSpecCentroid objects containing the parsed mass spectra.
def get_mass_spectra_obj( self, time_range: Union[Tuple[float, float], List[Tuple[float, float]], NoneType] = None):
523    def get_mass_spectra_obj(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
524        """Instatiate a MassSpectraBase object from the mzML file.
525
526        Parameters
527        ----------
528        time_range : tuple or list of tuples, optional
529            Retention time range(s) to load. Can be:
530            - Single range: (start_time, end_time) in minutes
531            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
532            If None, loads all scans.
533
534        Returns
535        -------
536        MassSpectraBase
537            The MassSpectra object containing the parsed mass spectra.
538            The object is instatiated with the mzML file, analyzer, instrument, sample name, and scan dataframe.
539        """
540        _, scan_df = self.run(spectra=False, time_range=time_range)
541        mass_spectra_obj = MassSpectraBase(
542            self.file_location,
543            self.analyzer,
544            self.instrument_label,
545            self.sample_name,
546            self,
547        )
548        scan_df = scan_df.set_index("scan", drop=False)
549        mass_spectra_obj.scan_df = scan_df
550
551        return mass_spectra_obj

Instatiate a MassSpectraBase object from the mzML file.

Parameters
  • time_range (tuple or list of tuples, optional): Retention time range(s) to load. Can be:
    • Single range: (start_time, end_time) in minutes
    • Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes If None, loads all scans.
Returns
  • MassSpectraBase: The MassSpectra object containing the parsed mass spectra. The object is instatiated with the mzML file, analyzer, instrument, sample name, and scan dataframe.
def get_lcms_obj( self, spectra='all', time_range: Union[Tuple[float, float], List[Tuple[float, float]], NoneType] = None):
553    def get_lcms_obj(self, spectra="all", time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None):
554        """Instatiates a LCMSBase object from the mzML file.
555
556        Parameters
557        ----------
558        spectra : str, optional
559            Which mass spectra data to include in the output. Default is all.  Other options: none, ms1, ms2.
560        time_range : tuple or list of tuples, optional
561            Retention time range(s) to load. Can be:
562            - Single range: (start_time, end_time) in minutes
563            - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes
564            If None, loads all scans. Useful for targeted workflows to improve performance.
565
566        Returns
567        -------
568        LCMSBase
569            LCMS object containing mass spectra data.
570            The object is instatiated with the mzML file, analyzer, instrument, sample name, scan dataframe,
571            and mz dataframe(s), as well as lists of scan numbers, retention times, and TICs.
572        """
573        _, scan_df = self.run(spectra="none", time_range=time_range)  # first run it to just get scan info
574        if spectra != "none":
575            res, scan_df = self.run(
576                scan_df=scan_df, spectra=spectra, time_range=time_range
577            )  # second run to parse data
578        lcms_obj = LCMSBase(
579            self.file_location,
580            self.analyzer,
581            self.instrument_label,
582            self.sample_name,
583            self,
584        )
585        if spectra != "none":
586            for key in res:
587                key_int = int(key.replace("ms", ""))
588                res[key] = res[key][res[key].intensity > 0]
589                res[key] = res[key].sort_values(by=["scan", "mz"]).reset_index(drop=True)
590                lcms_obj._ms_unprocessed[key_int] = res[key]
591        lcms_obj.scan_df = scan_df.set_index("scan", drop=False)
592        # Check if polarity is mixed
593        if len(set(scan_df.polarity)) > 1:
594            raise ValueError("Mixed polarities detected in scan data")
595        lcms_obj.polarity = scan_df.polarity[0]
596        lcms_obj._scans_number_list = list(scan_df.scan)
597        lcms_obj._retention_time_list = list(scan_df.scan_time)
598        lcms_obj._tic_list = list(scan_df.tic)
599
600        return lcms_obj

Instatiates a LCMSBase object from the mzML file.

Parameters
  • spectra (str, optional): Which mass spectra data to include in the output. Default is all. Other options: none, ms1, ms2.
  • time_range (tuple or list of tuples, optional): Retention time range(s) to load. Can be:
    • Single range: (start_time, end_time) in minutes
    • Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes If None, loads all scans. Useful for targeted workflows to improve performance.
Returns
  • LCMSBase: LCMS object containing mass spectra data. The object is instatiated with the mzML file, analyzer, instrument, sample name, scan dataframe, and mz dataframe(s), as well as lists of scan numbers, retention times, and TICs.
def get_scans_in_time_range( self, time_range: Union[Tuple[float, float], List[Tuple[float, float]]], ms_level: Optional[int] = None) -> List[int]:
602    def get_scans_in_time_range(
603        self, 
604        time_range: Union[Tuple[float, float], List[Tuple[float, float]]],
605        ms_level: Optional[int] = None
606    ) -> List[int]:
607        """
608        Return scan numbers within specified retention time range(s).
609        
610        This method provides efficient filtering of scans by retention time,
611        which is particularly useful for targeted workflows where only specific
612        time windows are of interest.
613        
614        Parameters
615        ----------
616        time_range : tuple or list of tuples
617            Retention time range(s) in minutes. Can be:
618            - Single range: (start_time, end_time)
619            - Multiple ranges: [(start1, end1), (start2, end2), ...]
620        ms_level : int, optional
621            If specified, only return scans of this MS level (e.g., 1 for MS1, 2 for MS2).
622            If None, returns scans of all MS levels.
623        
624        Returns
625        -------
626        list of int
627            List of scan numbers within the specified time range(s) and MS level.
628        
629        Examples
630        --------
631        Get MS1 scans between 1.0 and 2.0 minutes:
632        
633        >>> scans = parser.get_scans_in_time_range((1.0, 2.0), ms_level=1)
634        
635        Get scans in multiple time windows:
636        
637        >>> scans = parser.get_scans_in_time_range([(0.5, 1.5), (3.0, 4.0)])
638        """
639        # Get scan dataframe filtered by time range
640        scan_df = self.get_scan_df(time_range=time_range)
641        
642        # Further filter by MS level if specified
643        if ms_level is not None:
644            scan_df = scan_df[scan_df.ms_level == ms_level]
645        
646        # Return list of scan numbers
647        return scan_df.scan.tolist()

Return scan numbers within specified retention time range(s).

This method provides efficient filtering of scans by retention time, which is particularly useful for targeted workflows where only specific time windows are of interest.

Parameters
  • time_range (tuple or list of tuples): Retention time range(s) in minutes. Can be:
    • Single range: (start_time, end_time)
    • Multiple ranges: [(start1, end1), (start2, end2), ...]
  • ms_level (int, optional): If specified, only return scans of this MS level (e.g., 1 for MS1, 2 for MS2). If None, returns scans of all MS levels.
Returns
  • list of int: List of scan numbers within the specified time range(s) and MS level.
Examples

Get MS1 scans between 1.0 and 2.0 minutes:

>>> scans = parser.get_scans_in_time_range((1.0, 2.0), ms_level=1)

Get scans in multiple time windows:

>>> scans = parser.get_scans_in_time_range([(0.5, 1.5), (3.0, 4.0)])
def get_instrument_info(self):
649    def get_instrument_info(self):
650        """
651        Return instrument information.
652
653        Returns
654        -------
655        dict
656            A dictionary with the keys 'model' and 'serial_number'.
657        """
658        # Load the pymzml data
659        data = self.load()
660        instrument_info = data.info.get('referenceable_param_group_list_element')[0]
661        cv_params = instrument_info.findall('{http://psi.hupo.org/ms/mzml}cvParam')
662
663        # Extract details from each cvParam
664        params = []
665        for param in cv_params:
666            accession = param.get('accession')  # Get 'accession' attribute
667            name = param.get('name')           # Get 'name' attribute
668            value = param.get('value')         # Get 'value' attribute
669            params.append({
670                'accession': accession,
671                'name': name,
672                'value': value
673            })
674
675        # Loop through params and try to find the relevant information
676        instrument_dict = {
677            'model': 'Unknown',
678            'serial_number': 'Unknown'
679        }
680
681        # Assuming there are only two paramters here - one is for the serial number (agnostic to the model) and the other is for the model
682        # If there are more than two, we raise an error
683        if len(params) < 2:
684            raise ValueError("Not enough parameters found in the instrument info, cannot parse.")
685        if len(params) > 2:
686            raise ValueError("Too many parameters found in the instrument info, cannot parse.")
687        for param in params:
688            if param['accession'] == 'MS:1000529':
689                instrument_dict['serial_number'] = param['value']
690            else:
691                instrument_dict['model'] = data.OT[param['accession']]     
692
693        return instrument_dict

Return instrument information.

Returns
  • dict: A dictionary with the keys 'model' and 'serial_number'.
def get_creation_time(self) -> datetime.datetime:
695    def get_creation_time(self) -> datetime.datetime:
696        """
697        Return the creation time of the mzML file.
698        """
699        data = self.load()
700        write_time = data.info.get('start_time')
701        if write_time:
702            # Convert the write time to a datetime object
703            return datetime.datetime.strptime(write_time, "%Y-%m-%dT%H:%M:%SZ")
704        else:
705            raise ValueError("Creation time is not available in the mzML file. "
706                           "Please ensure the file contains the 'start_time' information.")

Return the creation time of the mzML file.