corems.mass_spectra.factory.lc_class

   1from pathlib import Path
   2
   3import numpy as np
   4import pandas as pd
   5import warnings
   6import multiprocessing
   7
   8import matplotlib.pyplot as plt
   9
  10from corems.encapsulation.factory.parameters import LCMSParameters, LCMSCollectionParameters
  11from corems.mass_spectra.calc.lc_calc import LCCalculations, PHCalculations, LCMSCollectionCalculations
  12from corems.molecular_id.search.lcms_spectral_search import LCMSSpectralSearch
  13from corems.mass_spectrum.input.numpyArray import ms_from_array_profile, ms_from_array_centroid
  14from corems.mass_spectra.calc.lc_calc import find_closest
  15from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature
  16
  17
  18class MassSpectraBase:
  19    """Base class for mass spectra objects.
  20
  21    Parameters
  22    -----------
  23    file_location : str or Path
  24        The location of the file containing the mass spectra data.
  25    analyzer : str, optional
  26        The type of analyzer used to generate the mass spectra data. Defaults to 'Unknown'.
  27    instrument_label : str, optional
  28        The type of instrument used to generate the mass spectra data. Defaults to 'Unknown'.
  29    sample_name : str, optional
  30        The name of the sample; defaults to the file name if not provided to the parser. Defaults to None.
  31    spectra_parser : object, optional
  32        The spectra parser object used to create the mass spectra object. Defaults to None.
  33
  34    Attributes
  35    -----------
  36    spectra_parser_class : class
  37        The class of the spectra parser used to create the mass spectra object.
  38    file_location : str or Path
  39        The location of the file containing the mass spectra data.
  40    sample_name : str
  41        The name of the sample; defaults to the file name if not provided to the parser.
  42    analyzer : str
  43        The type of analyzer used to generate the mass spectra data. Derived from the spectra parser.
  44    instrument_label : str
  45        The type of instrument used to generate the mass spectra data. Derived from the spectra parser.
  46    _scan_info : dict
  47        A dictionary containing the scan data with columns for scan number, scan time, ms level, precursor m/z,
  48        scan text, and scan window (lower and upper).
  49        Associated with the property scan_df, which returns a pandas DataFrame or can set this attribute from a pandas DataFrame.
  50    _ms : dict
  51        A dictionary containing mass spectra for the dataset, keys of dictionary are scan numbers. Initialized as an empty dictionary.
  52    _ms_unprocessed: dictionary of pandas.DataFrames or None
  53        A dictionary of unprocssed mass spectra data, as an (optional) intermediate data product for peak picking.
  54        Key is ms_level, and value is dataframe with columns for scan number, m/z, and intensity. Default is None.
  55
  56    Methods
  57    --------
  58    * add_mass_spectra(scan_list, spectrum_mode: str = 'profile', use_parser = True, auto_process=True).
  59        Add mass spectra (or singlel mass spectrum) to _ms slot, from a list of scans
  60    * get_time_of_scan_id(scan).
  61        Returns the scan time for the specified scan number.
  62    """
  63
  64    def __init__(
  65        self,
  66        file_location,
  67        analyzer="Unknown",
  68        instrument_label="Unknown",
  69        sample_name=None,
  70        spectra_parser=None,
  71    ):
  72        if isinstance(file_location, str):
  73            file_location = Path(file_location)
  74        else:
  75            file_location = file_location
  76        if not file_location.exists():
  77            raise FileExistsError("File does not exist: " + str(file_location))
  78
  79        if sample_name:
  80            self.sample_name = sample_name
  81        else:
  82            self.sample_name = file_location.stem
  83
  84        self.file_location = file_location
  85        self.analyzer = analyzer
  86        self.instrument_label = instrument_label
  87        self._raw_file_location = None
  88
  89        # Add the spectra parser class to the object if it is not None
  90        if spectra_parser is not None:
  91            self.spectra_parser_class = spectra_parser.__class__
  92            if self.spectra_parser_class.__name__ == "ReadCoreMSHDFMassSpectra":
  93                self.raw_file_location = spectra_parser.get_raw_file_location()
  94
  95            # Check that spectra_parser.sample_name is same as sample_name etc, raise warning if not
  96            if (
  97                self.sample_name is not None
  98                and self.sample_name != self.spectra_parser.sample_name
  99                and self.spectra_parser_class.__name__ != "ReadCoreMSHDFMassSpectra"
 100            ):
 101                warnings.warn(
 102                    "sample_name provided to MassSpectraBase object does not match sample_name provided to spectra parser object",
 103                    UserWarning,
 104                )
 105            if self.analyzer != self.spectra_parser.analyzer:
 106                warnings.warn(
 107                    "analyzer provided to MassSpectraBase object does not match analyzer provided to spectra parser object",
 108                    UserWarning,
 109                )
 110            if self.instrument_label != self.spectra_parser.instrument_label:
 111                warnings.warn(
 112                    "instrument provided to MassSpectraBase object does not match instrument provided to spectra parser object",
 113                    UserWarning,
 114                )
 115            if self.file_location != self.spectra_parser.file_location:
 116                warnings.warn(
 117                    "file_location provided to MassSpectraBase object does not match file_location provided to spectra parser object",
 118                    UserWarning,
 119                )
 120
 121        # Instantiate empty dictionaries for scan information and mass spectra
 122        self._scan_info = {}
 123        self._ms = {}
 124        self._ms_unprocessed = {}
 125
 126    @property
 127    def spectra_parser(self):
 128        """Returns an instance of the spectra parser class."""
 129        # Check if a file exists at the raw_file_location
 130        if not Path(self.raw_file_location).exists():
 131            raise FileNotFoundError(
 132                f"Raw file not found at location: {self.raw_file_location}, update raw_file_location property to point to correct location."
 133            )        
 134        return self.spectra_parser_class(self.raw_file_location)
 135
 136    @property
 137    def raw_file_location(self):
 138        """Returns the file_location unless the _raw_file_location is not None."""
 139        return self._raw_file_location if self._raw_file_location is not None else self.file_location   
 140    
 141    @raw_file_location.setter
 142    def raw_file_location(self, value):
 143        self._raw_file_location = value
 144
 145    def add_mass_spectrum(self, mass_spec):
 146        """Adds a mass spectrum to the dataset.
 147
 148        Parameters
 149        -----------
 150        mass_spec : MassSpectrum
 151            The corems MassSpectrum object to be added to the dataset.
 152
 153        Notes
 154        -----
 155        This is a helper function for the add_mass_spectra() method, and is not intended to be called directly.
 156        """
 157        # check if mass_spec has a scan_number attribute
 158        if not hasattr(mass_spec, "scan_number"):
 159            raise ValueError(
 160                "Mass spectrum must have a scan_number attribute to be added to the dataset correctly"
 161            )
 162        self._ms[mass_spec.scan_number] = mass_spec
 163
 164    def add_mass_spectra(
 165        self,
 166        scan_list,
 167        spectrum_mode=None,
 168        ms_level=1,
 169        use_parser=True,
 170        auto_process=True,
 171        ms_params=None,
 172    ):
 173        """Add mass spectra to _ms dictionary, from a list of scans or single scan
 174
 175        Notes
 176        -----
 177        The mass spectra will inherit the mass_spectrum, ms_peak, and molecular_search parameters from the LCMSBase object.
 178
 179
 180        Parameters
 181        -----------
 182        scan_list : list of ints
 183            List of scans to use to populate _ms slot
 184        spectrum_mode : str or None
 185            The spectrum mode to use for the mass spectra.
 186            If None, method will use the spectrum mode from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
 187            Defaults to None.
 188        ms_level : int, optional
 189            The MS level to use for the mass spectra.
 190            This is used to pass the molecular_search parameters from the LCMS object to the individual MassSpectrum objects.
 191            Defaults to 1.
 192        using_parser : bool
 193            Whether to use the mass spectra parser to get the mass spectra.  Defaults to True.
 194        auto_process : bool
 195            Whether to auto-process the mass spectra.  Defaults to True.
 196        ms_params : MSParameters or None
 197            The mass spectrum parameters to use for the mass spectra.  If None, uses the globally set MSParameters.
 198
 199        Raises
 200        ------
 201        TypeError
 202            If scan_list is not a list of ints
 203        ValueError
 204            If polarity is not 'positive' or 'negative'
 205            If ms_level is not 1 or 2
 206        """
 207
 208        # check if scan_list is a list or a single int; if single int, convert to list
 209        if isinstance(scan_list, int):
 210            scan_list = [scan_list]
 211        if not isinstance(scan_list, list):
 212            raise TypeError("scan_list must be a list of integers")
 213        for scan in scan_list:
 214            if not isinstance(scan, int):
 215                raise TypeError("scan_list must be a list of integers")
 216
 217        # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
 218        if self.polarity == "negative":
 219            polarity = -1
 220        elif self.polarity == "positive":
 221            polarity = 1
 222        else:
 223            raise ValueError(
 224                "Polarity not set for dataset, must be a either 'positive' or 'negative'"
 225            )
 226
 227        # is not using_parser, check that ms1 and ms2 are not None
 228        if not use_parser:
 229            if ms_level not in self._ms_unprocessed.keys():
 230                raise ValueError(
 231                    "ms_level {} not found in _ms_unprocessed dictionary".format(
 232                        ms_level
 233                    )
 234                )
 235
 236        scan_list = list(set(scan_list))
 237        scan_list.sort()
 238
 239        # Skip scans that have already been added to _ms to avoid redundant reprocessing
 240        already_added = [s for s in scan_list if s in self._ms]
 241        if already_added:
 242            warnings.warn(
 243                "Skipping {} scan(s) already present in _ms: {}".format(
 244                    len(already_added), already_added
 245                ),
 246                UserWarning,
 247            )
 248            scan_list = [s for s in scan_list if s not in self._ms]
 249        if not scan_list:
 250            return
 251
 252        if not use_parser:
 253            if self._ms_unprocessed[ms_level] is None:
 254                raise ValueError(
 255                    "No unprocessed data found for ms_level {}".format(ms_level)
 256                )
 257            if (
 258                len(
 259                    np.setdiff1d(
 260                        scan_list, self._ms_unprocessed[ms_level].scan.tolist()
 261                    )
 262                )
 263                > 0
 264            ):
 265                raise ValueError(
 266                    "Not all scans in scan_list are present in the unprocessed data"
 267                )
 268            # Prepare the ms_df for parsing
 269            ms_df = self._ms_unprocessed[ms_level].copy().set_index("scan", drop=False)
 270
 271        if use_parser:
 272            # Use batch function to get all mass spectra at once
 273            if spectrum_mode is None:
 274                # get spectrum mode from _scan_info for each scan
 275                spectrum_modes = [self.scan_df.loc[scan, "ms_format"] for scan in scan_list]
 276                spectrum_mode_batch = spectrum_modes[0] if len(set(spectrum_modes)) == 1 else None
 277            else:
 278                spectrum_mode_batch = spectrum_mode
 279            
 280            ms_list = self.spectra_parser.get_mass_spectra_from_scan_list(
 281                scan_list=scan_list,
 282                spectrum_mode=spectrum_mode_batch,
 283                auto_process=False,
 284            )
 285            
 286            # Process each mass spectrum
 287            for i, scan in enumerate(scan_list):
 288                ms = ms_list[i] if i < len(ms_list) else None
 289                if ms is not None:
 290                    if ms_params is not None:
 291                        ms.parameters = ms_params
 292                    ms.scan_number = scan
 293                    if auto_process:
 294                        ms.process_mass_spec()
 295                    self.add_mass_spectrum(ms)
 296        else:
 297            # Original non-parser logic remains unchanged
 298            for scan in scan_list:
 299                ms = None
 300                if spectrum_mode is None:
 301                    # get spectrum mode from _scan_info
 302                    spectrum_mode_scan = self.scan_df.loc[scan, "ms_format"]
 303                else:
 304                    spectrum_mode_scan = spectrum_mode
 305                
 306                my_ms_df = ms_df.loc[scan]
 307                if spectrum_mode_scan == "profile":
 308                    # Check this - it might be better to use the MassSpectrumProfile class to instantiate the mass spectrum
 309                    ms = ms_from_array_profile(
 310                        my_ms_df.mz,
 311                        my_ms_df.intensity,
 312                        self.file_location,
 313                        polarity=polarity,
 314                        auto_process=False,
 315                    )
 316                else:
 317                   ms = ms_from_array_centroid(
 318                        mz = my_ms_df.mz,
 319                        abundance = my_ms_df.intensity,
 320                        rp = [np.nan] * len(my_ms_df.mz),
 321                        s2n = [np.nan] * len(my_ms_df.mz),
 322                        dataname = self.file_location,
 323                        polarity=polarity,
 324                        auto_process=False,
 325                    )
 326
 327                # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset
 328                if ms is not None:
 329                    if ms_params is not None:
 330                        ms.parameters = ms_params
 331                    ms.scan_number = scan
 332                    if auto_process:
 333                        ms.process_mass_spec()
 334                    self.add_mass_spectrum(ms)
 335
 336    def get_time_of_scan_id(self, scan):
 337        """Returns the scan time for the specified scan number.
 338
 339        Parameters
 340        -----------
 341        scan : int
 342            The scan number of the desired scan time.
 343
 344        Returns
 345        --------
 346        float
 347            The scan time for the specified scan number (in minutes).
 348
 349        Raises
 350        ------
 351        ValueError
 352            If no scan time is found for the specified scan number.
 353        """
 354        # Check if _retenion_time_list is empty and raise error if so
 355        if len(self._retention_time_list) == 0:
 356            raise ValueError("No retention times found in dataset")
 357        rt = self._retention_time_list[self._scans_number_list.index(scan)]
 358        return rt
 359
 360    @property
 361    def scan_df(self):
 362        """
 363        pandas.DataFrame : A pandas DataFrame containing the scan info data with columns for scan number, scan time, ms level, precursor m/z, scan text, and scan window (lower and upper).
 364        """
 365        scan_df = pd.DataFrame.from_dict(self._scan_info)
 366        return scan_df
 367        
 368    @property
 369    def ms(self):
 370        """
 371        dictionary : contains the key associated with mass spectra and values are the associated MassSpecProfiles
 372        """
 373        return self._ms
 374
 375    
 376    @scan_df.setter
 377    def scan_df(self, df):
 378        """
 379        Sets the scan data for the dataset.
 380
 381        Parameters
 382        -----------
 383        df : pandas.DataFrame
 384            A pandas DataFrame containing the scan data with columns for scan number, scan time, ms level,
 385            precursor m/z, scan text, and scan window (lower and upper).
 386        """
 387        self._scan_info = df.to_dict()
 388
 389    def __getitem__(self, scan_number):
 390        return self._ms.get(scan_number)
 391
 392
 393class LCMSBase(MassSpectraBase, LCCalculations, PHCalculations, LCMSSpectralSearch):
 394    """A class representing a liquid chromatography-mass spectrometry (LC-MS) data object.
 395
 396    This class is not intended to be instantiated directly, but rather to be instantiated by an appropriate mass spectra parser using the get_lcms_obj() method.
 397
 398    Parameters
 399    -----------
 400    file_location : str or Path
 401        The location of the file containing the mass spectra data.
 402    analyzer : str, optional
 403        The type of analyzer used to generate the mass spectra data. Defaults to 'Unknown'.
 404    instrument_label : str, optional
 405        The type of instrument used to generate the mass spectra data. Defaults to 'Unknown'.
 406    sample_name : str, optional
 407        The name of the sample; defaults to the file name if not provided to the parser. Defaults to None.
 408    spectra_parser : object, optional
 409        The spectra parser object used to create the mass spectra object. Defaults to None.
 410
 411    Attributes
 412    -----------
 413    polarity : str
 414        The polarity of the ionization mode used for the dataset.
 415    _parameters : LCMSParameters
 416        The parameters used for all methods called on the LCMSBase object. Set upon instantiation from LCMSParameters.
 417    _retention_time_list : numpy.ndarray
 418        An array of retention times for the dataset.
 419    _scans_number_list : list
 420        A list of scan numbers for the dataset.
 421    _tic_list : numpy.ndarray
 422        An array of total ion current (TIC) values for the dataset.
 423    eics : dict
 424        A dictionary containing extracted ion chromatograms (EICs) for the dataset.
 425        Key is the mz of the EIC. Initialized as an empty dictionary.
 426    mass_features : dictionary of LCMSMassFeature objects
 427        A dictionary containing mass features for the dataset.
 428        Key is mass feature ID. Initialized as an empty dictionary.
 429    induced_mass_features: dictionary of LCMSMassFeature objects
 430        A dictionary containing mass features from a collection that don't
 431        satisfy criteria for initial mass features. Key is mass feature ID.
 432        Initialized as an empty dictionary.
 433    missing_mass_features: pandas.DataFrame
 434        A table of clusters in a given sample for which a mass feature was
 435        sought and not found
 436    spectral_search_results : dictionary of MS2SearchResults objects
 437        A dictionary containing spectral search results for the dataset.
 438        Key is scan number : precursor mz. Initialized as an empty dictionary.
 439
 440    Methods
 441    --------
 442    * get_parameters_json().
 443        Returns the parameters used for the LC-MS analysis in JSON format.
 444    * add_associated_ms2_dda(add_to_lcmsobj=True, auto_process=True, use_parser=True)
 445        Adds which MS2 scans are associated with each mass feature to the
 446        mass_features dictionary and optionally adds the MS2 spectra to the _ms dictionary.
 447    * add_associated_ms1(add_to_lcmsobj=True, auto_process=True, use_parser=True)
 448        Adds the MS1 spectra associated with each mass feature to the
 449        mass_features dictionary and adds the MS1 spectra to the _ms dictionary.
 450    * mass_features_to_df()
 451        Returns a pandas dataframe summarizing the mass features in the dataset.
 452    * set_tic_list_from_data(overwrite=False)
 453        Sets the TIC list from the mass spectrum objects within the _ms dictionary.
 454    * set_retention_time_from_data(overwrite=False)
 455        Sets the retention time list from the data in the _ms dictionary.
 456    * set_scans_number_from_data(overwrite=False)
 457        Sets the scan number list from the data in the _ms dictionary.
 458    * plot_composite_mz_features(binsize = 1e-4, ph_int_min_thresh = 0.001, mf_plot = True, ms2_plot = True, return_fig = False)
 459        Generates plot of M/Z features comparing scan time vs M/Z value
 460    * search_for_targeted_mass_feature(ms1df: pd.DataFrame, sample: pd.Series, tol_flag = 0)
 461        Searches for mass features in specific M/Z and scan time windows that
 462        were missed by the persistent homology search
 463    """
 464
 465    def __init__(
 466        self,
 467        file_location,
 468        analyzer="Unknown",
 469        instrument_label="Unknown",
 470        sample_name=None,
 471        spectra_parser=None,
 472    ):
 473        super().__init__(
 474            file_location, analyzer, instrument_label, sample_name, spectra_parser
 475        )
 476        self.polarity = ""
 477        self._parameters = LCMSParameters()
 478        self._retention_time_list = []
 479        self._scans_number_list = []
 480        self._tic_list = []
 481        self.eics = {}
 482        self.mass_features = {}
 483        self.induced_mass_features = {}
 484        self.spectral_search_results = {}
 485
 486    def get_eic_mz_for_mass_feature(self, mf_mz, tolerance=0.0001):
 487        """Get the EIC dictionary key (m/z) that best matches a mass feature's m/z.
 488        
 489        Finds the closest EIC m/z key within the specified tolerance.
 490        
 491        Parameters
 492        ----------
 493        mf_mz : float
 494            The m/z value of the mass feature to match.
 495        tolerance : float, optional
 496            Maximum m/z difference for matching. Default is 0.0001 Da.
 497            
 498        Returns
 499        -------
 500        float or None
 501            The EIC dictionary key (m/z) of the closest matching EIC,
 502            or None if no EIC is within tolerance.
 503        """
 504        if not hasattr(self, 'eics') or not self.eics:
 505            return None
 506        
 507        best_eic_mz = None
 508        best_diff = tolerance
 509        for eic_mz in self.eics.keys():
 510            diff = abs(mf_mz - eic_mz)
 511            if diff < best_diff:
 512                best_diff = diff
 513                best_eic_mz = eic_mz
 514        return best_eic_mz
 515    
 516    def associate_eics_with_mass_features(self, tolerance=0.0001, induced=False):
 517        """Associate EICs with mass features using tolerance-based m/z matching.
 518        
 519        Associates EIC_Data objects from self.eics with mass features by finding
 520        the closest EIC within the specified m/z tolerance. This is more robust
 521        than exact matching which can fail due to floating point precision issues.
 522        
 523        Parameters
 524        ----------
 525        tolerance : float, optional
 526            Maximum m/z difference for matching EICs to mass features. Default is 0.0001 Da.
 527        induced : bool, optional
 528            If True, associates EICs with induced_mass_features instead of mass_features.
 529            Default is False.
 530            
 531        Notes
 532        -----
 533        For each mass feature, this method finds the EIC with the closest m/z value
 534        within the tolerance window and assigns it to the mass feature's _eic_data attribute.
 535        If multiple EICs are within tolerance, the one with the smallest m/z difference is chosen.
 536        """
 537        # Select which mass features dictionary to use
 538        mf_dict = self.induced_mass_features if induced else self.mass_features
 539        
 540        # Use the _eic_mz attribute on each mass_feature to find the closest matching EIC
 541        for idx in mf_dict.keys():
 542            mf_mz = mf_dict[idx]._eic_mz
 543            # Find closest EIC within tolerance
 544            best_match = None
 545            best_diff = tolerance
 546            for eic_mz, eic_data in self.eics.items():
 547                diff = abs(mf_mz - eic_mz)
 548                if diff < best_diff:
 549                    best_diff = diff
 550                    best_match = eic_data
 551            if best_match is not None:
 552                mf_dict[idx]._eic_data = best_match
 553
 554    def get_parameters_json(self):
 555        """Returns the parameters stored for the LC-MS object in JSON format.
 556
 557        Returns
 558        --------
 559        str
 560            The parameters used for the LC-MS analysis in JSON format.
 561        """
 562        return self.parameters.to_json()
 563
 564    def remove_unprocessed_data(self, ms_level=None):
 565        """Removes the unprocessed data from the LCMSBase object.
 566
 567        Parameters
 568        -----------
 569        ms_level : int, optional
 570            The MS level to remove the unprocessed data for. If None, removes unprocessed data for all MS levels.
 571
 572        Raises
 573        ------
 574        ValueError
 575            If ms_level is not 1 or 2.
 576
 577        Notes
 578        -----
 579        This method is useful for freeing up memory after the data has been processed.
 580        """
 581        if ms_level is None:
 582            for ms_level in self._ms_unprocessed.keys():
 583                self._ms_unprocessed[ms_level] = None
 584        if ms_level not in [1, 2]:
 585            raise ValueError("ms_level must be 1 or 2")
 586        self._ms_unprocessed[ms_level] = None
 587
 588    def _filter_ms2_scans_by_integration_bounds(self, mf_dict=None):
 589        """Filter MS2 scans to only include those within integration bounds.
 590        
 591        Removes MS2 scan numbers that fall outside the start_scan to final_scan range
 592        for each mass feature. This should be called after integration sets the bounds.
 593        
 594        Parameters
 595        ----------
 596        mf_dict : dict, optional
 597            Dictionary of mass features to filter. If None, uses self.mass_features.
 598            
 599        Returns
 600        -------
 601        int
 602            Number of MS2 scans removed across all mass features.
 603        """
 604        if mf_dict is None:
 605            mf_dict = self.mass_features
 606        
 607        total_removed = 0
 608        
 609        for mf_id, mf in mf_dict.items():
 610            # Only filter if integration bounds are set and MS2 scans exist
 611            if (hasattr(mf, 'start_scan') and hasattr(mf, 'final_scan') and 
 612                mf.start_scan is not None and mf.final_scan is not None and
 613                mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0):
 614                
 615                # Filter scan numbers to only those within bounds
 616                original_count = len(mf.ms2_scan_numbers)
 617                mf.ms2_scan_numbers = [
 618                    scan for scan in mf.ms2_scan_numbers 
 619                    if mf.start_scan <= scan <= mf.final_scan
 620                ]
 621                removed = original_count - len(mf.ms2_scan_numbers)
 622                total_removed += removed
 623        
 624        return total_removed
 625    
 626    def _find_ms2_scans_for_mass_features(self, mf_ids=None, scan_filter=None):
 627        """Find MS2 scans associated with mass features.
 628        
 629        This helper method finds MS2 scans that match mass features based on RT and m/z tolerances.
 630        It updates the ms2_scan_numbers attribute on each mass feature.
 631        
 632        Parameters
 633        ----------
 634        mf_ids : list of int, optional
 635            List of mass feature IDs to find MS2 for. If None, finds for all mass features.
 636        scan_filter : str, optional
 637            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
 638            
 639        Returns
 640        -------
 641        list
 642            List of unique MS2 scan numbers found across all mass features.
 643            
 644        Raises
 645        ------
 646        ValueError
 647            If no MS2 scans are found in the dataset.
 648        """
 649        # Get mass features to process
 650        if mf_ids is None:
 651            mf_ids = list(self.mass_features.keys())
 652        
 653        # Get mass features dataframe
 654        mf_df = self.mass_features_to_df()
 655        mf_df = mf_df.loc[mf_ids].copy()
 656        
 657        # Find ms2 scans that have a precursor m/z value
 658        ms2_scans = self.scan_df[self.scan_df.ms_level == 2]
 659        ms2_scans = ms2_scans[~ms2_scans.precursor_mz.isna()]
 660        ms2_scans = ms2_scans[ms2_scans.tic > 0]
 661        
 662        if len(ms2_scans) == 0:
 663            raise ValueError("No DDA scans found in dataset")
 664        
 665        if scan_filter is not None:
 666            ms2_scans = ms2_scans[ms2_scans.scan_text.str.contains(scan_filter)]
 667        
 668        # Get tolerances from parameters
 669        time_tol = self.parameters.lc_ms.ms2_dda_rt_tolerance
 670        mz_tol = self.parameters.lc_ms.ms2_dda_mz_tolerance
 671        
 672        # For each mass feature, find the ms2 scans that are within the roi scan time and mz range
 673        dda_scans = []
 674        for i, row in mf_df.iterrows():
 675            ms2_scans_filtered = ms2_scans[
 676                ms2_scans.scan_time.between(
 677                    row.scan_time - time_tol, row.scan_time + time_tol
 678                )
 679            ]
 680            ms2_scans_filtered = ms2_scans_filtered[
 681                ms2_scans_filtered.precursor_mz.between(
 682                    row.mz - mz_tol, row.mz + mz_tol
 683                )
 684            ]
 685            scan_list = ms2_scans_filtered.scan.tolist()
 686            if scan_list:
 687                # Filter scans by integration bounds if they exist
 688                mf = self.mass_features[i]
 689                if (hasattr(mf, 'start_scan') and hasattr(mf, 'final_scan') and 
 690                    mf.start_scan is not None and mf.final_scan is not None):
 691                    # Only keep scans within integration bounds
 692                    scan_list = [s for s in scan_list if mf.start_scan <= s <= mf.final_scan]
 693                
 694                if scan_list:  # Only add if there are still scans after filtering
 695                    self.mass_features[i].ms2_scan_numbers = (
 696                        scan_list + list(self.mass_features[i].ms2_scan_numbers)
 697                    )
 698                    dda_scans.extend(scan_list)
 699        
 700        return list(set(dda_scans))
 701    
 702    def add_associated_ms2_dda(
 703        self,
 704        auto_process=True,
 705        use_parser=True,
 706        spectrum_mode=None,
 707        ms_params_key="ms2",
 708        scan_filter=None,
 709    ):
 710        """Add MS2 spectra associated with mass features to the dataset.
 711
 712        Populates the mass_features ms2_scan_numbers attribute (on mass_features dictionary on LCMSObject)
 713
 714        Parameters
 715        -----------
 716        auto_process : bool, optional
 717            If True, auto-processes the MS2 spectra before adding it to the object's _ms dictionary. Default is True.
 718        use_parser : bool, optional
 719            If True, envoke the spectra parser to get the MS2 spectra. Default is True.
 720        spectrum_mode : str or None, optional
 721            The spectrum mode to use for the mass spectra.  If None, method will use the spectrum mode
 722            from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
 723            Defaults to None. (faster if defined, otherwise will check each scan)
 724        ms_params_key : string, optional
 725            The key of the mass spectrum parameters to use for the mass spectra, accessed from the LCMSObject.parameters.mass_spectrum attribute.
 726            Defaults to 'ms2'.
 727        scan_filter : str
 728            A string to filter the scans to add to the _ms dictionary.  If None, all scans are added.  Defaults to None.
 729            "hcd" will pull out only HCD scans.
 730
 731        Raises
 732        ------
 733        ValueError
 734            If mass_features is not set, must run find_mass_features() first.
 735            If no MS2 scans are found in the dataset.
 736            If no precursor m/z values are found in MS2 scans, not a DDA dataset.
 737        """
 738        # Check if mass_features is set, raise error if not
 739        if self.mass_features is None:
 740            raise ValueError(
 741                "mass_features not set, must run find_mass_features() first"
 742            )
 743
 744        # reconfigure ms_params to get the correct mass spectrum parameters from the key
 745        ms_params = self.parameters.mass_spectrum[ms_params_key]
 746
 747        # Find MS2 scans for all mass features
 748        dda_scans = self._find_ms2_scans_for_mass_features(scan_filter=scan_filter)
 749        
 750        # Load MS2 spectra
 751        self.add_mass_spectra(
 752            scan_list=dda_scans,
 753            auto_process=auto_process,
 754            spectrum_mode=spectrum_mode,
 755            use_parser=use_parser,
 756            ms_params=ms_params,
 757        )
 758        
 759        # Associate appropriate _ms attribute to appropriate mass feature's ms2_mass_spectra attribute
 760        for mf_id in self.mass_features:
 761            if self.mass_features[mf_id].ms2_scan_numbers is not None:
 762                for dda_scan in self.mass_features[mf_id].ms2_scan_numbers:
 763                    if dda_scan in self._ms.keys():
 764                        self.mass_features[mf_id].ms2_mass_spectra[dda_scan] = self._ms[
 765                            dda_scan
 766                        ]
 767
 768    def add_associated_ms1(
 769        self, auto_process=True, use_parser=True, spectrum_mode=None, induced_features=False
 770    ):
 771        """Add MS1 spectra associated with mass features to the dataset.
 772
 773        Parameters
 774        -----------
 775        auto_process : bool, optional
 776            If True, auto-processes the MS1 spectra before adding it to the object's _ms dictionary. Default is True.
 777        use_parser : bool, optional
 778            If True, envoke the spectra parser to get the MS1 spectra. Default is True.
 779        spectrum_mode : str or None, optional
 780            The spectrum mode to use for the mass spectra.  If None, method will use the spectrum mode
 781            from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
 782            Defaults to None. (faster if defined, otherwise will check each scan)
 783        induced_features : bool, optional
 784            If True, add associated MS1 of the induced mass features instead of the primary mass features
 785
 786        Raises
 787        ------
 788        ValueError
 789            If mass_features is not set, must run find_mass_features() first.
 790            If apex scans are not profile mode, all apex scans must be profile mode for averaging.
 791            If number of scans to average is not  1 or an integer with an integer median (i.e. 3, 5, 7, 9).
 792            If deconvolute is True and no EICs are found, did you run integrate_mass_features() first?
 793        """
 794        # Check if mass_features is set, raise error if not
 795        if self.mass_features is None:
 796            raise ValueError(
 797                "mass_features not set, must run find_mass_features() first"
 798            )
 799            
 800        if induced_features:
 801            mf_dict = self.induced_mass_features
 802        else:
 803            mf_dict = self.mass_features
 804
 805        scans_to_average = self.parameters.lc_ms.ms1_scans_to_average
 806        
 807        ## sketchy work around for induced mass features
 808        scan_list = [
 809            int(mf_dict[x].apex_scan) for x in mf_dict if int(mf_dict[x].apex_scan) != -99
 810        ]
 811
 812        if scans_to_average == 1:
 813            # Add to LCMSobj
 814            self.add_mass_spectra(
 815                scan_list = scan_list,
 816                auto_process=auto_process,
 817                use_parser=use_parser,
 818                spectrum_mode=spectrum_mode,
 819                ms_params=self.parameters.mass_spectrum["ms1"],
 820            )
 821
 822        elif (
 823            (scans_to_average - 1) % 2
 824        ) == 0:  # scans_to_average = 3, 5, 7 etc, mirror l/r around apex
 825            apex_scans = list(set(scan_list))
 826            # Check if all apex scans are profile mode, raise error if not
 827            if not all(self.scan_df.loc[apex_scans, "ms_format"] == "profile"):
 828                raise ValueError("All apex scans must be profile mode for averaging")
 829
 830            # First get sets of scans to average
 831            def get_scans_from_apex(ms1_scans, apex_scan, scans_to_average):
 832                ms1_idx_start = ms1_scans.index(apex_scan) - int(
 833                    (scans_to_average - 1) / 2
 834                )
 835                if ms1_idx_start < 0:
 836                    ms1_idx_start = 0
 837                ms1_idx_end = (
 838                    ms1_scans.index(apex_scan) + int((scans_to_average - 1) / 2) + 1
 839                )
 840                if ms1_idx_end > (len(ms1_scans) - 1):
 841                    ms1_idx_end = len(ms1_scans) - 1
 842                scan_list = ms1_scans[ms1_idx_start:ms1_idx_end]
 843                return scan_list
 844
 845            ms1_scans = self.ms1_scans
 846            scans_lists = [
 847                get_scans_from_apex(ms1_scans, apex_scan, scans_to_average)
 848                for apex_scan in apex_scans
 849            ]
 850
 851            # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
 852            if self.polarity == "negative":
 853                polarity = -1
 854            elif self.polarity == "positive":
 855                polarity = 1
 856
 857            if not use_parser:
 858                # Perform checks and prepare _ms_unprocessed dictionary if use_parser is False (saves time to do this once)
 859                ms1_unprocessed = self._ms_unprocessed[1].copy()
 860                # Set the index on _ms_unprocessed[1] to scan number
 861                ms1_unprocessed = ms1_unprocessed.set_index("scan", drop=False)
 862                self._ms_unprocessed[1] = ms1_unprocessed
 863
 864                # Check that all the scans in scan_lists are indexs in self._ms_unprocessed[1]
 865                scans_lists_flat = list(
 866                    set([scan for sublist in scans_lists for scan in sublist])
 867                )
 868                if (
 869                    len(
 870                        np.setdiff1d(
 871                            np.sort(scans_lists_flat),
 872                            np.sort(ms1_unprocessed.index.values),
 873                        )
 874                    )
 875                    > 0
 876                ):
 877                    raise ValueError(
 878                        "Not all scans to average are present in the unprocessed data"
 879                    )
 880
 881            for scan_list_average, apex_scan in zip(scans_lists, apex_scans):
 882                # Get unprocessed mass spectrum from scans
 883                ms = self.get_average_mass_spectrum(
 884                    scan_list=scan_list_average,
 885                    apex_scan=apex_scan,
 886                    spectrum_mode="profile",
 887                    ms_level=1,
 888                    auto_process=auto_process,
 889                    use_parser=use_parser,
 890                    perform_checks=False,
 891                    polarity=polarity,
 892                    ms_params=self.parameters.mass_spectrum["ms1"],
 893                )
 894                # Add mass spectrum to LCMS object and associated with mass feature
 895                self.add_mass_spectrum(ms)
 896
 897            if not use_parser:
 898                # Reset the index on _ms_unprocessed[1] to not be scan number
 899                ms1_unprocessed = ms1_unprocessed.reset_index(drop=True)
 900                self._ms_unprocessed[1] = ms1_unprocessed
 901        else:
 902            raise ValueError(
 903                "Number of scans to average must be 1 or an integer with an integer median (i.e. 3, 5, 7, 9)"
 904            )
 905
 906        # Associate the ms1 spectra with the mass features
 907        for k in mf_dict.keys():
 908            ## another induced feature work around
 909            if mf_dict[k].apex_scan != -99:
 910                mf_dict[k].mass_spectrum = self._ms[
 911                    mf_dict[k].apex_scan
 912                ]
 913                mf_dict[k].update_mz()
 914
 915    def mass_features_to_df(self, induced_features=False, drop_na_cols=False, include_cols=None):
 916        """Returns a pandas dataframe summarizing the mass features.
 917
 918        The dataframe contains the following columns: mf_id, mz, apex_scan, scan_time, intensity,
 919        persistence, area, monoisotopic_mf_id, and isotopologue_type.  The index is set to mf_id (mass feature ID).
 920        Parameters
 921        -----------
 922        induced_features : bool, optional
 923            If True, calls the induced_mass_features dictionary. Defaults to False.
 924        drop_na_cols : bool, optional
 925            If True, drops columns that are entirely NA. Defaults to False.
 926        include_cols : list of str, optional
 927            If provided, only includes the specified columns in the output (in addition to 'mf_id' which is always included as the index).
 928            If None, includes all available columns. Defaults to None.
 929
 930        Raises
 931        --------
 932        ValueError
 933            If the sample provided doesn't contain the mass feature data.        
 934
 935        Returns
 936        --------
 937        pandas.DataFrame
 938            A pandas dataframe of mass features with the following columns:
 939            mf_id, mz, apex_scan, scan_time, intensity, persistence, area.
 940        """
 941        import pandas as pd
 942
 943        def mass_spectrum_to_string(
 944            mass_spec, normalize=True, min_normalized_abun=0.01
 945        ):
 946            """Converts a mass spectrum to a string of m/z:abundance pairs.
 947
 948            Parameters
 949            -----------
 950            mass_spec : MassSpectrum
 951                A MassSpectrum object to be converted to a string.
 952            normalize : bool, optional
 953                If True, normalizes the abundance values to a maximum of 1. Defaults to True.
 954            min_normalized_abun : float, optional
 955                The minimum normalized abundance value to include in the string, only used if normalize is True. Defaults to 0.01.
 956
 957            Returns
 958            --------
 959            str
 960                A string of m/z:abundance pairs from the mass spectrum, separated by a semicolon.
 961            """
 962            mz_np = mass_spec.to_dataframe()["m/z"].values
 963            abun_np = mass_spec.to_dataframe()["Peak Height"].values
 964            if normalize:
 965                abun_np = abun_np / abun_np.max()
 966            mz_abun = np.column_stack((mz_np, abun_np))
 967            if normalize:
 968                mz_abun = mz_abun[mz_abun[:, 1] > min_normalized_abun]
 969            mz_abun_str = [
 970                str(round(mz, ndigits=4)) + ":" + str(round(abun, ndigits=2))
 971                for mz, abun in mz_abun
 972            ]
 973            return "; ".join(mz_abun_str)
 974
 975        if induced_features:
 976            mf_dict = self.induced_mass_features
 977        else:
 978            mf_dict = self.mass_features
 979        
 980        if len(mf_dict) == 0:
 981            # Return an empty dataframe with the expected structure
 982            # This allows collection processing to continue even if some samples have no features
 983            return pd.DataFrame()
 984            
 985        cols_in_df = [
 986            "id",
 987            "apex_scan",
 988            "start_scan",
 989            "final_scan",
 990            "retention_time",
 991            "intensity",
 992            "persistence",
 993            "area",
 994            "dispersity_index",
 995            "normalized_dispersity_index",
 996            "tailing_factor",
 997            "gaussian_similarity",
 998            "noise_score",
 999            "noise_score_min",
1000            "noise_score_max",
1001            "monoisotopic_mf_id",
1002            "isotopologue_type",
1003            "mass_spectrum_deconvoluted_parent",
1004            "ms2_scan_numbers",
1005            "type"
1006        ]
1007
1008        df_mf_list = []
1009        for mf_id in mf_dict.keys():
1010            # Find cols_in_df that are in single_mf
1011            df_keys = list(
1012                set(cols_in_df).intersection(mf_dict[mf_id].__dir__())
1013            )
1014            dict_mf = {}
1015            # Get the values for each key in df_keys from the mass feature object
1016            for key in df_keys:
1017                value = getattr(mf_dict[mf_id], key)
1018                # Wrap list/array values in a list so pandas treats them as single cell values
1019                if key == 'ms2_scan_numbers' and isinstance(value, (list, np.ndarray)):
1020                    dict_mf[key] = [value]
1021                else:
1022                    dict_mf[key] = value
1023            if len(mf_dict[mf_id].ms2_scan_numbers) > 0:
1024                # Add MS2 spectra info
1025                best_ms2_spectrum = mf_dict[mf_id].best_ms2
1026                if best_ms2_spectrum is not None:
1027                    dict_mf["ms2_spectrum"] = mass_spectrum_to_string(best_ms2_spectrum)
1028            if len(mf_dict[mf_id].associated_mass_features_deconvoluted) > 0:
1029                dict_mf["associated_mass_features"] = ", ".join(
1030                    map(
1031                        str,
1032                        mf_dict[mf_id].associated_mass_features_deconvoluted,
1033                    )
1034                )
1035            if mf_dict[mf_id]._half_height_width is not None:
1036                dict_mf["half_height_width"] = mf_dict[
1037                    mf_id
1038                ].half_height_width
1039            # Check if EIC for mass feature is set
1040            df_mf_single = pd.DataFrame(dict_mf, index=[mf_id])
1041            df_mf_single["mz"] = mf_dict[mf_id].mz
1042            df_mf_list.append(df_mf_single)
1043        df_mf = pd.concat(df_mf_list)
1044
1045        # rename _area to area and id to mf_id
1046        df_mf = df_mf.rename(
1047            columns={
1048                "id": "mf_id",
1049                "retention_time": "scan_time",            
1050            }
1051        )
1052
1053        # reorder columns
1054        col_order = [
1055            "mf_id",
1056            "type",
1057            "scan_time",
1058            "mz",
1059            "apex_scan",
1060            "start_scan",
1061            "final_scan",
1062            "intensity",
1063            "persistence",
1064            "area",
1065            "half_height_width",
1066            "tailing_factor",
1067            "dispersity_index",
1068            "normalized_dispersity_index",
1069            "gaussian_similarity",
1070            "noise_score",
1071            "noise_score_min",
1072            "noise_score_max",
1073            "monoisotopic_mf_id",
1074            "isotopologue_type",
1075            "mass_spectrum_deconvoluted_parent",
1076            "associated_mass_features",
1077            "ms2_scan_numbers",
1078            "ms2_spectrum",
1079        ]
1080        # drop columns that are not in col_order
1081        cols_to_order = [col for col in col_order if col in df_mf.columns]
1082        df_mf = df_mf[cols_to_order]
1083
1084        # reset index to mf_id
1085        df_mf = df_mf.set_index("mf_id")
1086        df_mf.index.name = "mf_id"
1087        
1088        if 'half_height_width' in df_mf.columns:
1089            df_mf["half_height_width"] = df_mf["half_height_width"].astype('float64')
1090        if 'tailing_factor' in df_mf.columns:
1091            df_mf["tailing_factor"] = df_mf["tailing_factor"].astype('float64')
1092        if 'dispersity_index' in df_mf.columns:
1093            df_mf["dispersity_index"] = df_mf["dispersity_index"].astype('float64')
1094        if 'normalized_dispersity_index' in df_mf.columns:
1095            df_mf["normalized_dispersity_index"] = df_mf["normalized_dispersity_index"].astype('float64')
1096        
1097        # Filter columns if include_cols is specified
1098        if include_cols is not None:
1099            # Ensure include_cols is a list
1100            if not isinstance(include_cols, list):
1101                raise ValueError("include_cols must be a list of column names")
1102            # Keep only requested columns that exist in the dataframe
1103            available_cols = [col for col in include_cols if col in df_mf.columns]
1104            df_mf = df_mf[available_cols]
1105        
1106        # Drop columns that are entirely NA if requested
1107        if drop_na_cols:
1108            df_mf = df_mf.dropna(axis=1, how='all')
1109        
1110        return df_mf
1111
1112    def mass_features_ms1_annot_to_df(self, suppress_warnings=False):
1113        """Returns a pandas dataframe summarizing the MS1 annotations for the mass features in the dataset.
1114
1115        Parameters
1116        -----------
1117        suppress_warnings : bool, optional
1118            If True, suppresses the warning when no MS1 annotations are found.
1119            Useful when calling from collection-level methods. Default is False.
1120
1121        Returns
1122        --------
1123        pandas.DataFrame
1124            A pandas dataframe of MS1 annotations for the mass features in the dataset.
1125            The index is set to mf_id (mass feature ID)
1126
1127        Raises
1128        ------
1129        Warning
1130            If no MS1 annotations were found for the mass features in the dataset
1131            (unless suppress_warnings=True).
1132        """
1133        annot_df_list_ms1 = []
1134        for mf_id in self.mass_features.keys():
1135            if self.mass_features[mf_id].mass_spectrum is None:
1136                pass
1137            else:
1138                # Add ms1 annotations to ms1 annotation list
1139                if (
1140                    np.abs(
1141                        (
1142                            self.mass_features[mf_id].ms1_peak.mz_exp
1143                            - self.mass_features[mf_id].mz
1144                        )
1145                    )
1146                    < 0.01
1147                ):
1148                    # Get the molecular formula from the mass spectrum
1149                    annot_df = self.mass_features[mf_id].mass_spectrum.to_dataframe()
1150                    # Subset to pull out only the peak associated with the mass feature
1151                    annot_df = annot_df[
1152                        annot_df["Index"] == self.mass_features[mf_id].ms1_peak.index
1153                    ].copy()
1154
1155                    # If there are more than 1 row, remove any rows without a molecular formula
1156                    if len(annot_df) > 1:
1157                        annot_df = annot_df[~annot_df["Molecular Formula"].isna()]
1158
1159                    # Remove the index column and add column for mf_id
1160                    annot_df = annot_df.drop(columns=["Index"])
1161                    annot_df["mf_id"] = mf_id
1162                    annot_df_list_ms1.append(annot_df)
1163
1164        if len(annot_df_list_ms1) > 0:
1165            annot_ms1_df_full = pd.concat(annot_df_list_ms1)
1166            annot_ms1_df_full = annot_ms1_df_full.set_index("mf_id")
1167            annot_ms1_df_full.index.name = "mf_id"
1168
1169        else:
1170            annot_ms1_df_full = None
1171            # Warn that no ms1 annotations were found (unless suppressed)
1172            if not suppress_warnings:
1173                warnings.warn(
1174                    "No MS1 annotations found for mass features in dataset, were MS1 spectra added and processed within the dataset?",
1175                    UserWarning,
1176                )
1177
1178        return annot_ms1_df_full
1179
1180    def mass_features_ms2_annot_to_df(self, molecular_metadata=None, suppress_warnings=False):
1181        """Returns a pandas dataframe summarizing the MS2 annotations for the mass features in the dataset.
1182
1183        Parameters
1184        -----------
1185        molecular_metadata :  dict of MolecularMetadata objects
1186            A dictionary of MolecularMetadata objects, keyed by ref_mol_id. Defaults to None.
1187        suppress_warnings : bool, optional
1188            If True, suppresses the warning when no MS2 annotations are found.
1189            Useful when calling from collection-level methods. Default is False.
1190
1191        Returns
1192        --------
1193        pandas.DataFrame
1194            A pandas dataframe of MS2 annotations for the mass features in the dataset,
1195            and optionally molecular metadata. The index is set to mf_id (mass feature ID)
1196
1197        Raises
1198        ------
1199        Warning
1200            If no MS2 annotations were found for the mass features in the dataset
1201            (unless suppress_warnings=True).
1202        """
1203        annot_df_list_ms2 = []
1204        for mf_id in self.mass_features.keys():
1205            if len(self.mass_features[mf_id].ms2_similarity_results) > 0:
1206                # Add ms2 annotations to ms2 annotation list
1207                for result in self.mass_features[mf_id].ms2_similarity_results:
1208                    annot_df_ms2 = result.to_dataframe()
1209                    annot_df_ms2["mf_id"] = mf_id
1210                    annot_df_list_ms2.append(annot_df_ms2)
1211
1212        if len(annot_df_list_ms2) > 0:
1213            annot_ms2_df_full = pd.concat(annot_df_list_ms2)
1214            if molecular_metadata is not None:
1215                molecular_metadata_df = pd.concat(
1216                    [
1217                        pd.DataFrame.from_dict(v.__dict__, orient="index").transpose()
1218                        for k, v in molecular_metadata.items()
1219                    ],
1220                    ignore_index=True,
1221                )
1222                molecular_metadata_df = molecular_metadata_df.rename(
1223                    columns={"id": "ref_mol_id"}
1224                )
1225                annot_ms2_df_full = annot_ms2_df_full.merge(
1226                    molecular_metadata_df, on="ref_mol_id", how="left"
1227                )
1228            annot_ms2_df_full = annot_ms2_df_full.drop_duplicates(
1229                subset=["mf_id", "query_spectrum_id", "ref_ms_id"]
1230            ).copy()
1231            annot_ms2_df_full = annot_ms2_df_full.set_index("mf_id")
1232            annot_ms2_df_full.index.name = "mf_id"
1233        else:
1234            annot_ms2_df_full = None
1235            # Warn that no ms2 annotations were found (unless suppressed)
1236            if not suppress_warnings:
1237                warnings.warn(
1238                    "No MS2 annotations found for mass features in dataset, were MS2 spectra added and searched against a database?",
1239                    UserWarning,
1240                )
1241
1242        return annot_ms2_df_full
1243
1244    def plot_composite_mz_features(self, binsize = 1e-4, ph_int_min_thresh = 0.001, mf_plot = True, ms2_plot = True, return_fig = False):
1245        """Returns a figure displaying 
1246            (1) thresholded, unprocessed data
1247            (2) the m/z features
1248            (3) which m/z features are associated with MS2 spectra
1249
1250        Parameters
1251        -----------
1252        binsize :  float
1253            Desired binsize for the m/z axis of the composite feature map.  Defaults to 1e-4.
1254        mf_plot : boolean
1255            Indicates whether to plot the m/z features. Defaults to True.
1256        ms2_plot : boolean
1257            Indicates whether to identify m/z features with associated MS2 spectra. Defaults to True.
1258        return_fig : boolean
1259            Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False.
1260
1261        Returns
1262        --------
1263        matplotlib.pyplot.Figure
1264            A figure with the thresholded, unprocessed data on an axis of m/z value with respect to 
1265            scan time. Unprocessed data is displayed in gray scale with darker colors indicating 
1266            higher intensities. If m/z features are plotted, they are displayed in cyan. If m/z
1267            features with associated with MS2 spectra are plotted, they are displayed in red.
1268
1269        Raises
1270        ------
1271        Warning
1272            If m/z features are set to be plot but aren't in the dataset.
1273            If m/z features with associated MS2 data are set to be plot but no MS2 annotations 
1274            were found for the m/z features in the dataset.
1275        """
1276        if mf_plot:
1277            # Check if mass_features is set, raise error if not
1278            if self.mass_features is None:
1279                raise ValueError(
1280                    "mass_features not set, must run find_mass_features() first"
1281                )
1282            ## call mass feature data
1283            mf_df = self.mass_features_to_df()
1284
1285        if ms2_plot:
1286            if not mf_plot:
1287                # Check if mass_features is set, raise error if not
1288                if self.mass_features is None:
1289                    raise ValueError(
1290                        "mass_features not set, must run find_mass_features() first"
1291                    )
1292
1293            ## call m/z feature data
1294            mf_df = self.mass_features_to_df()
1295
1296            # Check if ms2_spectrum is set, raise error if not
1297            if 'ms2_spectrum' not in mf_df.columns:
1298                raise ValueError(                
1299                    "ms2_spectrum not set, must run add_associated_ms2_dda() first"            
1300                )
1301    
1302        ## threshold and grid unprocessed data
1303        df = self._ms_unprocessed[1].copy()
1304        df = df.dropna(subset=['intensity']).reset_index(drop = True)
1305        threshold = ph_int_min_thresh * df.intensity.max()
1306        df_thres = df[df["intensity"] > threshold].reset_index(drop = True).copy()
1307        df = self.grid_data(df_thres)
1308    
1309        ## format unprocessed data for plotting
1310        df = df.merge(self.scan_df[['scan', 'scan_time']], on = 'scan')
1311        mz_grid = np.arange(0, np.max(df.mz), binsize)
1312        mz_data = np.array(df.mz)
1313        df['mz_bin'] = find_closest(mz_grid, mz_data)
1314        df['ab_bin'] = df.groupby(['mz_bin', 'scan_time']).intensity.transform(sum)
1315        unproc_df = df[['scan_time', 'mz_bin', 'ab_bin']].drop_duplicates(ignore_index = True)
1316
1317        ## generate figure
1318        fig = plt.figure()
1319        plt.scatter(
1320            unproc_df.scan_time,
1321            unproc_df.mz_bin*binsize,
1322            c = unproc_df.ab_bin/np.max(unproc_df.ab_bin),
1323            alpha = unproc_df.ab_bin/np.max(unproc_df.ab_bin), 
1324            cmap = 'Greys_r',
1325            s = 1
1326        )
1327
1328        if mf_plot:
1329            if ms2_plot:
1330                plt.scatter(
1331                    mf_df[mf_df.ms2_spectrum.isna()].scan_time,
1332                    mf_df[mf_df.ms2_spectrum.isna()].mz,
1333                    c = 'c',
1334                    s = 4,
1335                    label = 'M/Z features without MS2'
1336                )
1337            else:
1338                plt.scatter(
1339                    mf_df.scan_time,
1340                    mf_df.mz,
1341                    c = 'c',
1342                    s = 4,
1343                    label = 'M/Z features'
1344                )
1345
1346        if ms2_plot: 
1347            plt.scatter(
1348                mf_df[~mf_df.ms2_spectrum.isna()].scan_time,
1349                mf_df[~mf_df.ms2_spectrum.isna()].mz,
1350                c = 'r',
1351                s = 2,
1352                label = 'M/Z features with MS2'
1353            )
1354
1355        if mf_plot == True or ms2_plot == True:
1356            plt.legend(loc = 'lower center', bbox_to_anchor = (0.5, -0.25), ncol = 2)
1357        plt.xlabel('Scan time')
1358        plt.ylabel('m/z')
1359        plt.ylim(0, np.ceil(np.max(df.mz)))
1360        plt.xlim(0, np.ceil(np.max(df.scan_time)))
1361        plt.title('Composite Feature Map')
1362
1363        if return_fig:
1364            plt.close(fig)
1365            return fig
1366
1367        else:
1368            plt.show()
1369            
1370    def search_for_targeted_mass_features_batch(
1371            self,
1372            ms1df,
1373            mz_mins,
1374            mz_maxs,
1375            st_mins,
1376            st_maxs,
1377            set_ids,
1378            obj_idx=0,
1379            st_aligned=False
1380            ):
1381        """
1382        Returns multiple LCMSMassFeatures from a specific sample within specific mass and time ranges.
1383        Vectorized batch version of search_for_targeted_mass_feature for improved performance.
1384
1385        Parameters
1386        -----------
1387        ms1df : pd.DataFrame
1388            Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample.
1389        mz_mins : np.ndarray
1390            Array of lower bounds of m/z values to use to find peaks.
1391        mz_maxs : np.ndarray
1392            Array of upper bounds of m/z values to use to find peaks.
1393        st_mins : np.ndarray
1394            Array of lower bounds of scan times to use to find peaks.
1395        st_maxs : np.ndarray
1396            Array of upper bounds of scan times to use to find peaks.
1397        set_ids : np.ndarray or list
1398            Array of strings used as IDs in LCMSMassFeatures.
1399        obj_idx : int
1400            Identifies index of sample in a collection. Defaults to 0.
1401        st_aligned : bool
1402            Whether to use scan_time_aligned or scan_time. Defaults to False.
1403
1404        Returns
1405        --------
1406        dict
1407            Dictionary mapping set_id to LCMSMassFeature objects.
1408
1409        Raises
1410        ------
1411        ValueError
1412            If appropriate scan time data is not contained in ms1df or if array lengths don't match.
1413        """
1414        # Validate inputs
1415        n_features = len(mz_mins)
1416        if not all(len(arr) == n_features for arr in [mz_maxs, st_mins, st_maxs, set_ids]):
1417            raise ValueError("All input arrays must have the same length")
1418
1419        # Validate scan time column
1420        time_col = 'scan_time_aligned' if st_aligned else 'scan_time'
1421        if time_col not in ms1df.columns:
1422            raise ValueError(f"{time_col} not contained in ms1df")
1423
1424        # Pre-extract columns for faster access
1425        mz_vals = ms1df.mz.values
1426        st_vals = ms1df[time_col].values
1427        scan_vals = ms1df.scan.values
1428        intensity_vals = ms1df.intensity.values
1429
1430        # Process all features
1431        results = {}
1432        for i in range(n_features):
1433            # Vectorized filtering
1434            mask = (
1435                (mz_vals >= mz_mins[i]) & (mz_vals <= mz_maxs[i]) &
1436                (st_vals >= st_mins[i]) & (st_vals <= st_maxs[i])
1437            )
1438            
1439            if not mask.any():
1440                row_dict = {
1441                    'apex_scan': -99,
1442                    'mz': np.nan,
1443                    'intensity': np.nan,
1444                    'retention_time': np.nan,
1445                    'persistence': np.nan,
1446                    'id': set_ids[i]
1447                }
1448            else:
1449                # Find max intensity within filtered region
1450                filtered_intensities = intensity_vals[mask]
1451                max_idx = np.argmax(filtered_intensities)
1452                
1453                # Get indices of filtered data
1454                filtered_indices = np.where(mask)[0]
1455                peak_idx = filtered_indices[max_idx]
1456                
1457                row_dict = {
1458                    'apex_scan': scan_vals[peak_idx],
1459                    'mz': mz_vals[peak_idx],
1460                    'intensity': intensity_vals[peak_idx],
1461                    'retention_time': st_vals[peak_idx],
1462                    'persistence': np.nan,
1463                    'id': set_ids[i]
1464                }
1465
1466            results[set_ids[i]] = LCMSMassFeature(self, **row_dict)
1467
1468        return results
1469
1470    def search_for_targeted_mass_feature(
1471            self,
1472            ms1df, 
1473            mz_min,
1474            mz_max, 
1475            st_min, 
1476            st_max,
1477            set_id,
1478            obj_idx = 0,
1479            st_aligned = False
1480            ):
1481        """
1482        Returns an LCMSMassFeature from a specific sample within a specific mass and time range. Returns an empty
1483        LCMSMassFeature if no satisfactory peak is found in the given window.
1484
1485        Parameters
1486        -----------
1487        ms1df :  Pandas DataFrame
1488            Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample.
1489        mz_min : float
1490            Identifies lower bound of the weights to use to find a peak.
1491        mz_max : float
1492            Identifies upper bound of the weights to use to find a peak.
1493        st_min : float
1494            Identifies lower bound of the scan times to use to find a peak.
1495        st_max : float
1496            Identifies upper bound of the scan times to use to find a peak.
1497        set_id : str
1498            Indicates string used as ID in LCMSMassFeature.
1499        obj_idx : int
1500            Identifies index of sample in a collection that LCMSMassFeature should be assigned to. Defaults to 0 and is not used
1501            if data provided is an LCMSBase instead of an LCMSCollection.
1502        st_aligned : boolean
1503            Indicates whether to call scan time from scan_time or from scan_time_aligned if using a collection. Defaults to False.
1504
1505        Returns
1506        --------
1507        LCMSMassFeature
1508            Object from ChromaPeak that contains data on selected MS1 peak. If no peak is found, will contain missing 
1509            information and list the apex scan value as -99.
1510
1511        Raises
1512        ------
1513        Warning
1514            If appropriate scan time data is not contained in ms1df.
1515        """
1516        # Convert single feature to arrays and call batch method
1517        results = self.search_for_targeted_mass_features_batch(
1518            ms1df,
1519            np.array([mz_min]),
1520            np.array([mz_max]),
1521            np.array([st_min]),
1522            np.array([st_max]),
1523            [set_id],
1524            obj_idx=obj_idx,
1525            st_aligned=st_aligned
1526        )
1527        return results[set_id]
1528
1529
1530    def __len__(self):
1531        """
1532        Returns the number of mass spectra in the dataset.
1533
1534        Returns
1535        --------
1536        int
1537            The number of mass spectra in the dataset.
1538        """
1539        return len(self._ms)
1540
1541    def __getitem__(self, scan_number):
1542        """
1543        Returns the mass spectrum corresponding to the specified scan number.
1544
1545        Parameters
1546        -----------
1547        scan_number : int
1548            The scan number of the desired mass spectrum.
1549
1550        Returns
1551        --------
1552        MassSpectrum
1553            The mass spectrum corresponding to the specified scan number.
1554        """
1555        return self._ms.get(scan_number)
1556
1557    def __iter__(self):
1558        """Returns an iterator over the mass spectra in the dataset.
1559
1560        Returns
1561        --------
1562        iterator
1563            An iterator over the mass spectra in the dataset.
1564        """
1565        return iter(self._ms.values())
1566
1567    def set_tic_list_from_data(self, overwrite=False):
1568        """Sets the TIC list from the mass spectrum objects within the _ms dictionary.
1569
1570        Parameters
1571        -----------
1572        overwrite : bool, optional
1573            If True, overwrites the TIC list if it is already set. Defaults to False.
1574
1575        Notes
1576        -----
1577        If the _ms dictionary is incomplete, sets the TIC list to an empty list.
1578
1579        Raises
1580        ------
1581        ValueError
1582            If no mass spectra are found in the dataset.
1583            If the TIC list is already set and overwrite is False.
1584        """
1585        # Check if _ms is empty and raise error if so
1586        if len(self._ms) == 0:
1587            raise ValueError("No mass spectra found in dataset")
1588
1589        # Check if tic_list is already set and raise error if so
1590        if len(self.tic) > 0 and not overwrite:
1591            raise ValueError("TIC list already set, use overwrite=True to overwrite")
1592
1593        self.tic = [self._ms.get(i).tic for i in self.scans_number]
1594
1595    def set_retention_time_from_data(self, overwrite=False):
1596        """Sets the retention time list from the data in the _ms dictionary.
1597
1598        Parameters
1599        -----------
1600        overwrite : bool, optional
1601            If True, overwrites the retention time list if it is already set. Defaults to False.
1602
1603        Notes
1604        -----
1605        If the _ms dictionary is empty or incomplete, sets the retention time list to an empty list.
1606
1607        Raises
1608        ------
1609        ValueError
1610            If no mass spectra are found in the dataset.
1611            If the retention time list is already set and overwrite is False.
1612        """
1613        # Check if _ms is empty and raise error if so
1614        if len(self._ms) == 0:
1615            raise ValueError("No mass spectra found in dataset")
1616
1617        # Check if retention_time_list is already set and raise error if so
1618        if len(self.retention_time) > 0 and not overwrite:
1619            raise ValueError(
1620                "Retention time list already set, use overwrite=True to overwrite"
1621            )
1622
1623        retention_time_list = []
1624        for key_ms in sorted(self._ms.keys()):
1625            retention_time_list.append(self._ms.get(key_ms).retention_time)
1626        self.retention_time = retention_time_list
1627
1628    def set_scans_number_from_data(self, overwrite=False):
1629        """Sets the scan number list from the data in the _ms dictionary.
1630
1631        Notes
1632        -----
1633        If the _ms dictionary is empty or incomplete, sets the scan number list to an empty list.
1634
1635        Raises
1636        ------
1637        ValueError
1638            If no mass spectra are found in the dataset.
1639            If the scan number list is already set and overwrite is False.
1640        """
1641        # Check if _ms is empty and raise error if so
1642        if len(self._ms) == 0:
1643            raise ValueError("No mass spectra found in dataset")
1644
1645        # Check if scans_number_list is already set and raise error if so
1646        if len(self.scans_number) > 0 and not overwrite:
1647            raise ValueError(
1648                "Scan number list already set, use overwrite=True to overwrite"
1649            )
1650
1651        self.scans_number = sorted(self._ms.keys())
1652
1653    @property
1654    def ms1_scans(self):
1655        """
1656        list : A list of MS1 scan numbers for the dataset.
1657        """
1658        return self.scan_df[self.scan_df.ms_level == 1].index.tolist()
1659
1660    @property
1661    def parameters(self):
1662        """
1663        LCMSParameters : The parameters used for the LC-MS analysis.
1664        """
1665        return self._parameters
1666
1667    @parameters.setter
1668    def parameters(self, paramsinstance):
1669        """
1670        Sets the parameters used for the LC-MS analysis.
1671
1672        Parameters
1673        -----------
1674        paramsinstance : LCMSParameters
1675            The parameters used for the LC-MS analysis.
1676        """
1677        self._parameters = paramsinstance
1678
1679    @property
1680    def scans_number(self):
1681        """
1682        list : A list of scan numbers for the dataset.
1683        """
1684        return self._scans_number_list
1685
1686    @scans_number.setter
1687    def scans_number(self, scan_numbers_list):
1688        """
1689        Sets the scan numbers for the dataset.
1690
1691        Parameters
1692        -----------
1693        scan_numbers_list : list
1694            A list of scan numbers for the dataset.
1695        """
1696        self._scans_number_list = scan_numbers_list
1697
1698    @property
1699    def retention_time(self):
1700        """
1701        numpy.ndarray : An array of retention times for the dataset.
1702        """
1703        return self._retention_time_list
1704
1705    @retention_time.setter
1706    def retention_time(self, rt_list):
1707        """
1708        Sets the retention times for the dataset.
1709
1710        Parameters
1711        -----------
1712        rt_list : list
1713            A list of retention times for the dataset.
1714        """
1715        self._retention_time_list = np.array(rt_list)
1716
1717    @property
1718    def tic(self):
1719        """
1720        numpy.ndarray : An array of TIC values for the dataset.
1721        """
1722        return self._tic_list
1723
1724    @tic.setter
1725    def tic(self, tic_list):
1726        """
1727        Sets the TIC values for the dataset.
1728
1729        Parameters
1730        -----------
1731        tic_list : list
1732            A list of TIC values for the dataset.
1733        """
1734        self._tic_list = np.array(tic_list)
1735
1736class LCMSCollection(LCMSCollectionCalculations):
1737    """A class representing a collection of liquid chromatography-mass spectrometry (LC-MS) runs.
1738    These runs can be from the same or different samples, but must be from the same instrument and have the same parameters 
1739    for the initial processing steps.  The LCMS objects are stored in an ordered dictionary with the sample name as the key.
1740
1741    Parameters
1742    -----------
1743
1744    Attributes
1745    -----------
1746
1747    Methods
1748    --------
1749
1750    Notes
1751    ------
1752    This class is not intended to be instantiated directly, but rather instantiated using a parser object and then interacted with.
1753    #TODO KRH: add docstrings
1754    """
1755
1756    def __init__(
1757            self,
1758            collection_location,
1759            manifest,
1760            collection_parser=None
1761    ):
1762        self.collection_location = collection_location
1763        self._manifest_dict = manifest
1764        self.collection_parser = collection_parser
1765        self.raw_files_relocated = False
1766
1767        # These attributes are generally set by the parser during instantiation of this class
1768        self._lcms = {}
1769        self._combined_mass_features = None
1770        self._combined_induced_mass_features = None
1771        self.consensus_mass_features = {}
1772        self._parameters = LCMSCollectionParameters()
1773        self.isotopes_dropped = False
1774        self._mass_features_locked = False  # Prevents rebuilding mass_features_dataframe from samples
1775
1776        # These attributes are set during processing
1777        self.rt_aligned = False
1778        self.rt_alignment_attempted = False
1779        self.missing_mass_features_searched = False
1780
1781    def _reorder_lcms_objects(self):
1782        """
1783        Reorders the LCMS objects in the collection based on the order in the manifest.
1784        """
1785        ordered_samples = self.samples
1786        self._lcms = {k: self._lcms[k] for k in ordered_samples}
1787
1788    def __getitem__(self, index):
1789        if isinstance(index, (float, np.floating, np.ndarray)):
1790            index = int(index)
1791        elif isinstance(index, np.integer):
1792            index = int(index)
1793        samp_name = self.samples[index]
1794        self._lcms[samp_name]
1795        return self._lcms[samp_name]
1796    
1797    def __len__(self):
1798        return len(self.samples)
1799    
1800    def _prepare_lcms_mass_features_for_combination(self, lcms_obj, induced_features = False):
1801        """
1802        Prepares the mass features in the LCMS objects in the collection for combination.
1803        """        
1804        if induced_features:
1805            mf_df = lcms_obj.mass_features_to_df(induced_features = True)
1806        # Check if lcms_obj has attribute light_mf_df
1807        elif hasattr(lcms_obj, "light_mf_df"):
1808            mf_df = lcms_obj.light_mf_df
1809        else:
1810            mf_df = lcms_obj.mass_features_to_df()
1811        
1812        # If dataframe is empty, add minimal required columns and return
1813        if len(mf_df) == 0:
1814            import pandas as pd
1815            mf_df["sample_name"] = []
1816            mf_df["sample_id"] = []
1817            mf_df["coll_mf_id"] = []
1818            mf_df["mf_id"] = []
1819            mf_df["_eic_mz"] = []  # Include _eic_mz for consistency with non-empty dataframes
1820            if induced_features:
1821                mf_df["cluster"] = []
1822            return mf_df
1823        
1824        # Remove index
1825        mf_df = mf_df.reset_index(drop=False)
1826        # Add sample name and sample id to the dataframe
1827        mf_df["sample_name"] = lcms_obj.sample_name
1828        # Ensure sample_id is stored as an integer to avoid float indices later
1829        try:
1830            mf_df["sample_id"] = int(self.manifest[lcms_obj.sample_name]["collection_id"])
1831        except Exception:
1832            mf_df["sample_id"] = self.manifest[lcms_obj.sample_name]["collection_id"]
1833        mf_df["coll_mf_id"] = mf_df["sample_id"].astype(str) + "_" + mf_df["mf_id"].astype(str)
1834
1835        # For induced features, extract cluster from mf_id (format: c{cluster}_{index}_i)
1836        # and add as a column since cluster_index attribute may not be set on the object
1837        if induced_features:
1838            def extract_cluster(mf_id):
1839                # mf_id format: c{cluster}_{index}_i
1840                # Example: c123_5_i -> cluster 123
1841                if isinstance(mf_id, str) and mf_id.startswith('c') and '_i' in mf_id:
1842                    parts = mf_id.split('_')
1843                    if len(parts) >= 2:
1844                        cluster_str = parts[0][1:]  # Remove 'c' prefix
1845                        try:
1846                            return int(cluster_str)
1847                        except ValueError:
1848                            return None
1849                return None
1850            
1851            mf_df['cluster'] = mf_df['mf_id'].apply(extract_cluster)
1852
1853        # Check if scan_df has scan_time_aligned and add to mf_df if so
1854        if "scan_time_aligned" in lcms_obj.scan_df.columns:
1855            scan_df = lcms_obj.scan_df[["scan", "scan_time_aligned"]].copy()
1856            scan_df = scan_df.rename(columns={"scan": "apex_scan"})
1857            mf_df = mf_df.merge(scan_df, on="apex_scan")
1858        
1859        return mf_df
1860       
1861    def _combine_mass_features(self, induced_features = False):
1862        """
1863        Concatenates the mass features from all the LCMS objects in the collection.
1864
1865        Returns
1866        --------
1867        None, sets the _combined_mass_features or _combined_induced_mass_feature attribute.
1868        
1869        Notes
1870        -----
1871        If _mass_features_locked is True (e.g., when only representative features are loaded),
1872        this method will skip rebuilding the regular mass features dataframe to preserve
1873        the full collection-level dataframe. Induced features are always rebuilt since they
1874        are created during processing.
1875        """
1876        
1877        # Skip rebuilding regular mass features if locked (preserves full dataframe)
1878        if not induced_features and self._mass_features_locked:
1879            return
1880
1881        ## TODO: See why this function runs slower on multiprocessing,
1882        ## especially for induced features
1883        ## has only been considered so far on ~20 samples
1884#        if self.parameters.lcms_collection.cores == 1:
1885#            # Prepare mass features for combination sequentially
1886#            mf_df_list = []
1887#            for lcms_obj in self:
1888#                mf_df = self._prepare_lcms_mass_features_for_combination(lcms_obj, induced_features)
1889#                mf_df_list.append(mf_df)
1890
1891#        if self.parameters.lcms_collection.cores > 1:
1892#            # Parallelize the mass feature preparation
1893#            if self.parameters.lcms_collection.cores > len(self):
1894#                ncores = len(self)
1895#            else:
1896#                ncores = self.parameters.lcms_collection.cores
1897#            pool = multiprocessing.Pool(ncores)
1898#            mf_df_list = pool.starmap(
1899#                self._prepare_lcms_mass_features_for_combination, 
1900#                [(lcms_obj, induced_features) for lcms_obj in self]
1901#            )
1902
1903        # Prepare mass features for combination sequentially
1904        mf_df_list = []
1905        for lcms_obj in self:
1906            # Skip samples with no induced mass features if processing induced features
1907            if induced_features:
1908                if not hasattr(lcms_obj, 'induced_mass_features') or len(lcms_obj.induced_mass_features) == 0:
1909                    continue
1910            mf_df = self._prepare_lcms_mass_features_for_combination(lcms_obj, induced_features)
1911            mf_df_list.append(mf_df)
1912
1913        # If no mass features were collected (e.g., no induced features exist), return early
1914        if len(mf_df_list) == 0:
1915            # Add a warning here, not sure how one might reach this state, clearly saying if they are induced features or not
1916            warnings.warn("No mass features found to combine in the collection.", UserWarning)
1917            if induced_features:
1918                self._combined_induced_mass_features = None
1919            else:
1920                self._combined_mass_features = None
1921            return
1922
1923        combined_mass_features = pd.concat(mf_df_list)
1924        # Ensure sample_id and cluster columns have integer dtypes where possible
1925        if "sample_id" in combined_mass_features.columns:
1926            try:
1927                combined_mass_features["sample_id"] = combined_mass_features["sample_id"].astype(int)
1928            except Exception:
1929                combined_mass_features["sample_id"] = pd.to_numeric(
1930                    combined_mass_features["sample_id"], errors="coerce"
1931                ).astype("Int64")
1932        if "cluster" in combined_mass_features.columns:
1933            try:
1934                combined_mass_features["cluster"] = combined_mass_features["cluster"].astype(int)
1935            except Exception:
1936                combined_mass_features["cluster"] = pd.to_numeric(
1937                    combined_mass_features["cluster"], errors="coerce"
1938                ).astype("Int64")
1939        # Move coll_mf_id, sample_name, sample_id, and mf_id to front
1940        cols = combined_mass_features.columns.tolist()
1941        top_cols = ["coll_mf_id", "sample_name", "sample_id", "mf_id", "mz", "scan_time_aligned", "cluster"]
1942        cols = [x for x in top_cols + [col for col in cols if col not in top_cols] if x in cols]
1943        combined_mass_features = combined_mass_features[cols]
1944        # Make coll_mf_id the index
1945        combined_mass_features = combined_mass_features.set_index("coll_mf_id")
1946        if induced_features == True:
1947            self._combined_induced_mass_features = combined_mass_features
1948        else:
1949            self._combined_mass_features = combined_mass_features
1950
1951    def _check_mass_features_df(self, induced_features = False):
1952        """Checks if the mass features dataframe has expected columns.  If not, adds them.
1953        
1954        Returns
1955        --------
1956        pandas.DataFrame
1957            A pandas dataframe of mass features in the collection.
1958
1959        Notes
1960        ------
1961        If scan_time_aligned is not in the _combined_mass_features or 
1962        _combined_induced_mass_features, tries to add it.
1963
1964        """
1965        
1966        if induced_features:
1967            cmf_df = self._combined_induced_mass_features
1968        else:
1969            cmf_df = self._combined_mass_features
1970        # Check if parameters are set to drop isotopologues and drop if so
1971        if self.parameters.lcms_collection.drop_isotopologues:
1972            if not self.isotopes_dropped:
1973                self._drop_isotopologues()
1974        # Check if scan_time_aligned is in combined_mass_features, try to add if not
1975        if cmf_df is not None and "scan_time_aligned" not in cmf_df.columns:
1976            cmb_mf = cmf_df.copy()
1977            cmb_mf = cmb_mf.reset_index(drop=False)
1978            lcms_aligned = [True for x in self if "scan_time_aligned" in x.scan_df.columns]
1979            if len(lcms_aligned) == len(self):
1980                # Add scan_time_aligned to combined_mass_features dataframe
1981                scan_time_aligned_list = []
1982                for lcms_obj in self:
1983                    scan_time_df_i = lcms_obj.scan_df[["scan", "scan_time_aligned"]]
1984                    scan_time_df_i["sample_name"] = lcms_obj.sample_name
1985                    scan_time_aligned_list.append(scan_time_df_i)
1986                scan_time_aligned_df = pd.concat(scan_time_aligned_list)
1987                # Rename scan to apex_scan
1988                scan_time_aligned_df = scan_time_aligned_df.rename(columns={"scan": "apex_scan"})
1989                cmb_mf_merged = cmb_mf.merge(scan_time_aligned_df, on=["apex_scan", "sample_name"])
1990                cmb_mf_merged = cmb_mf_merged.set_index("coll_mf_id")
1991                # Merge scan_time_aligned_df with combined_mass_features on apex_scan and sample_name
1992                if induced_features:
1993                    self._combined_induced_mass_features = cmb_mf_merged
1994                else:
1995                    self._combined_mass_features = cmb_mf_merged
1996    
1997    def plot_tics(self, ms_level=1, type = "raw", plot_legend=False):
1998        """Plots the TICs for all the LCMS objects in the collection.
1999        
2000        Parameters
2001        -----------
2002        ms_level : int, optional
2003            The MS level to plot the TICs for. Defaults to 1.
2004        type : str, optional
2005            The type of TIC to plot, either "raw" or "corrected" or "both". Defaults to "raw".
2006        plot_legend : bool, optional
2007            If True, plots a legend on the TIC plot that labels each sample. Defaults to False.
2008        """
2009        to_plot = []
2010        if type == "both":
2011            to_plot = ["raw", "corrected"]
2012        else:
2013            to_plot = [type]
2014
2015        fig, axs = plt.subplots(
2016            len(to_plot), 1, figsize=(10, 5 * len(to_plot)), sharex=True, squeeze=False
2017        )
2018        
2019        for i, plot_type in enumerate(to_plot):
2020            ax = axs[i, 0]
2021            colors = iter(plt.cm.rainbow(np.linspace(0, 1, len(self))))
2022            for lcms_obj in self:
2023                c = next(colors)
2024                # check if lcms_obj is the center of the collection
2025                self.manifest_dataframe[self.manifest_dataframe['center']].collection_id.values
2026
2027                
2028                scan_df = lcms_obj.scan_df
2029                scan_df = scan_df[scan_df.ms_level == ms_level]
2030                if plot_type == "corrected":
2031                    # Check that scan_time_aligned is key in scan_df
2032                    if "scan_time_aligned" not in scan_df.columns:
2033                        raise ValueError(f"scan_time_aligned not found in scan_df for {lcms_obj.sample_name}")
2034                    else:
2035                        ax.plot(scan_df.scan_time_aligned, scan_df.tic, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2036                elif plot_type == "raw":
2037                    ax.plot(scan_df.scan_time, scan_df.tic, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2038            ax.set_xlabel("Retention Time (min," + f" {plot_type})" )
2039            ax.set_ylabel("TIC")
2040            if plot_legend:
2041                ax.legend()
2042        plt.show()
2043
2044    def plot_alignments(self, plot_legend=False):
2045        """Plots the alignment of the LCMS objects in the collection.
2046        
2047        Parameters
2048        -----------
2049        plot_legend : bool, optional
2050            If True, plots a legend on the alignment plot that labels each sample. Defaults to False.        
2051        """
2052        fig, ax = plt.subplots(figsize=(10, 5))
2053        colors = iter(plt.cm.rainbow(np.linspace(0, 1, len(self))))
2054
2055        for lcms_obj in self:
2056            c = next(colors)
2057            scan_df = lcms_obj.scan_df
2058            if "scan_time_aligned" not in scan_df.columns:
2059                raise ValueError(f"scan_time_aligned not found in scan_df for {lcms_obj.sample_name}")
2060            scan_df['time_diff'] = scan_df.scan_time - scan_df.scan_time_aligned
2061            ax.plot(scan_df.scan_time_aligned, scan_df.time_diff, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2062
2063        ax.set_xlabel("Aligned Retention Time (min)")
2064        ax.set_ylabel("Time Difference (min)")
2065        if plot_legend:
2066            ax.legend()
2067        plt.show()
2068
2069    def _drop_isotopologues(self):
2070        """Drops isotopologues from the mass features in combined_mass_features dataframe."""
2071        cmb_mf_df = self._combined_mass_features
2072
2073        # Keep monos or if no monos
2074        cmb_monos = cmb_mf_df[cmb_mf_df.monoisotopic_mf_id == cmb_mf_df.mf_id]
2075        cmb_nomonos = cmb_mf_df[cmb_mf_df.monoisotopic_mf_id.isnull()]
2076        # Keep deconvoluted parent or if no deconvoluted parent
2077        cmb_decon_parent = cmb_mf_df[cmb_mf_df.mass_spectrum_deconvoluted_parent | cmb_mf_df.monoisotopic_mf_id.isnull()]
2078
2079        cmb_mf_df2 = pd.concat([cmb_monos, cmb_nomonos, cmb_decon_parent])
2080        cmb_mf_df2 = cmb_mf_df2[~cmb_mf_df2.index.duplicated(keep='first')]
2081        self.isotopes_dropped = True
2082        self._combined_mass_features = cmb_mf_df2
2083    
2084
2085    def load_raw_data(self, sample_idx: int, ms_level = 1, time_range = None) -> None:
2086        """Load raw data for a specific sample index in the collection.
2087        
2088        Parameters
2089        -----------
2090        sample_idx : int
2091            The index of the sample in the collection.
2092        ms_level : int, optional
2093            The MS level to load raw data for. Defaults to 1.
2094        time_range : tuple or list of tuples, optional
2095            Retention time range(s) to load. Can be a single tuple (min, max) or
2096            a list of tuples for multiple ranges. If None, loads all data. Defaults to None.
2097            
2098        Raises
2099        -------
2100        IndexError
2101            If the sample index is out of range.
2102        ValueError
2103            If raw data for the specified MS level is already loaded for the sample index.
2104        ValueError
2105            If the spectra parser is not set for the LCMS object or if the parser type does not support loading raw data.
2106
2107        Returns
2108        --------
2109        None, but updates the LCMS object with the raw data for the specified MS level.
2110        """
2111        if sample_idx < 0 or sample_idx >= len(self.samples):
2112            raise IndexError("Sample index out of range.")
2113
2114        # Check that the sample does not already have raw data loaded
2115        if ms_level in self[sample_idx]._ms_unprocessed:
2116            raise ValueError(f"Raw data for MS{ms_level} already loaded for sample index {sample_idx}. Drop data first if you want to reload it.")
2117
2118        # Check the parser type of the LCMS object
2119        if self[sample_idx].spectra_parser is None:
2120            raise ValueError("Spectra parser is not set for this LCMS object.")
2121
2122        # Instantiate the parser and load the raw data using the correct method
2123        parser = self[sample_idx].spectra_parser
2124        parser_class_name = self[sample_idx].spectra_parser_class.__name__
2125        scan_df = self[sample_idx].scan_df
2126
2127        # Get raw data for the specified MS level using the appropriate method
2128        if parser_class_name == "ImportMassSpectraThermoMSFileReader":
2129            self[sample_idx]._ms_unprocessed[ms_level] = parser.get_ms_raw(
2130                spectra=f"ms{ms_level}",
2131                scan_df=scan_df,
2132                time_range=time_range
2133            )[f"ms{ms_level}"]
2134
2135        elif parser_class_name == "MZMLSpectraParser":
2136            data = parser.load()
2137            self[sample_idx]._ms_unprocessed[ms_level] = parser.get_ms_raw(
2138                spectra=f"ms{ms_level}",
2139                scan_df=scan_df,
2140                data=data,
2141                time_range=time_range
2142                )[f"ms{ms_level}"]
2143
2144        elif parser_class_name == "ReadCoreMSHDFMassSpectra":
2145            raise ValueError(
2146                "ReadCoreMSHDFMassSpectra does not have a method to load raw data. Need to instantiate the original parser to access the raw data."
2147            )
2148
2149    def drop_raw_data(self, sample_idx: int, ms_level = 1) -> None:
2150        """Drop raw data for a specific sample index in the collection.
2151
2152        Parameters
2153        -----------
2154        sample_idx : int
2155            The index of the sample in the collection.
2156        ms_level : int, optional
2157            The MS level to drop raw data for. Defaults to 1.
2158
2159        Raises
2160        -------
2161        IndexError
2162            If the sample index is out of range.
2163        ValueError
2164            If raw data for the specified MS level is not loaded for the sample index.
2165
2166        Returns
2167        --------
2168        None
2169        """
2170        if sample_idx < 0 or sample_idx >= len(self.samples):
2171            raise IndexError("Sample index out of range.")
2172
2173        # Check that the sample has raw data loaded
2174        if ms_level not in self[sample_idx]._ms_unprocessed:
2175            raise ValueError(f"No raw data for MS{ms_level} found for sample index {sample_idx}. Load data first if you want to drop it.")
2176
2177        # Drop the raw data
2178        del self[sample_idx]._ms_unprocessed[ms_level]
2179
2180    def update_raw_file_locations(self, new_raw_folder):
2181        """Update the raw file locations for all LCMS objects in the collection.
2182        
2183        This method updates the path to the original raw data files (.raw, .mzML, etc.)
2184        that were used to create the processed HDF5 files stored in .corems folders.
2185        
2186        Parameters
2187        -----------
2188        new_raw_folder : str or Path
2189            The new folder location containing the raw data files (.raw, .mzML, etc.).
2190            The method will look for raw files with the same base name as each sample.
2191            
2192        Raises
2193        -------
2194        FileNotFoundError
2195            If the new raw folder does not exist.
2196        FileNotFoundError
2197            If a raw file for a sample is not found in the new folder.
2198            
2199        Returns
2200        --------
2201        None, but updates the raw_file_location for each LCMS object in the collection.
2202        
2203        Examples
2204        --------
2205        If raw files were moved from /old/path/ to /new/path/:
2206        >>> lcms_collection.update_raw_file_locations("/new/path/")
2207        """
2208        from pathlib import Path
2209        
2210        if isinstance(new_raw_folder, str):
2211            new_raw_folder = Path(new_raw_folder)
2212        
2213        if not new_raw_folder.exists():
2214            raise FileNotFoundError(f"Raw data folder does not exist: {new_raw_folder}")
2215        
2216        # Common raw file extensions
2217        raw_extensions = ['.raw', '.mzML', '.mzml']
2218        
2219        for sample_name in self.samples:
2220            lcms_obj = self._lcms[sample_name]
2221            
2222            # Try to find the raw file with common extensions
2223            new_raw_file = None
2224            for ext in raw_extensions:
2225                candidate = new_raw_folder / f"{sample_name}{ext}"
2226                if candidate.exists():
2227                    new_raw_file = candidate
2228                    break
2229            
2230            if new_raw_file is None:
2231                raise FileNotFoundError(
2232                    f"Raw file for sample '{sample_name}' not found in {new_raw_folder}. "
2233                    f"Tried extensions: {', '.join(raw_extensions)}"
2234                )
2235            
2236            # Update the raw file location and set flag that raw files have been relocated
2237            lcms_obj.raw_file_location = new_raw_file
2238        self.raw_files_relocated = True
2239
2240    def collection_pivot_table(self, attribute = 'coll_mf_id', verbose = True):
2241        """Generate a pivot table of all regular and induced mass features in
2242        a collection. Default attribute presented is the mass feature ID, also
2243        prints a list of other available attributes.
2244
2245        Parameters
2246        -----------
2247        attribute : str
2248            The desired attribute to be presented in the pivot table. Defaults
2249            to mass feature ID
2250        verbose : boolean
2251            Print out all the possible values the fill the pivot table and list
2252            attributes that are not collected for induced mass features
2253
2254        Returns
2255        --------
2256        pd.DataFrame
2257            A DataFrame that displays one given attribute across all clusters
2258            and samples in a collection
2259        
2260        """
2261        
2262        mf_pivot = self.mass_features_dataframe.copy()
2263        mf_pivot.reset_index(inplace = True)
2264        
2265        # Only include induced mass features if gap-filling has been performed
2266        if self.induced_mass_features_dataframe is not None:
2267            imf_pivot = self.induced_mass_features_dataframe.copy()
2268            imf_pivot.reset_index(inplace = True)
2269            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
2270            mf_pivot = pd.concat([mf_pivot, imf_pivot], axis = 0)
2271            mf_pivot.reset_index(drop = True, inplace = True)
2272        else:
2273            imf_pivot = None
2274            
2275        mf_pivot['cluster'] = mf_pivot['cluster'].astype(int)
2276
2277        if verbose:
2278            print(
2279                'Attributes available for pivot table:\n',
2280                [x for x in mf_pivot.columns if x not in ['cluster', 'sample_name', 'mf_id', 'partition_idx', 'idx']]
2281            )
2282            if imf_pivot is not None:
2283                print(
2284                    '\nAttributes that have no value for induced mass features:\n',
2285                    imf_pivot.columns[imf_pivot.isna().all()].tolist()            
2286                )
2287        
2288        # Create pivot table and reindex to include all samples (even those with no features)
2289        pivot = mf_pivot.pivot(index = 'cluster', columns = 'sample_name', values = attribute)
2290        
2291        # Reindex columns to include all samples in the collection
2292        all_samples = self.samples
2293        pivot = pivot.reindex(columns=all_samples)
2294        
2295        return pivot
2296
2297    def cluster_representatives_table(self):
2298        """Generate a table of representative mass features from each consensus cluster.
2299        
2300        This method returns a DataFrame containing all attributes for the
2301        representative mass feature from each consensus cluster. The representative
2302        is selected using the same logic as process_consensus_features().
2303
2304        Returns
2305        --------
2306        pd.DataFrame
2307            A DataFrame with one row per cluster containing all attributes for 
2308            each cluster's representative mass feature. Includes:
2309            - cluster: cluster ID (as a column for easy joining)
2310            - polarity: ionization polarity from the collection
2311            - n_samples_detected: number of samples where the cluster was detected
2312            - All other mass feature attributes from the representative
2313            
2314        Notes
2315        -----
2316        The representative metric used is determined by
2317        self.parameters.lcms_collection.consensus_representative_metric and
2318        is the same metric used by process_consensus_features() for consistency.
2319        Common options include 'intensity' (highest intensity) or 
2320        'intensity_prefer_ms2' (highest intensity with preference for MS2 data).
2321        """
2322        
2323        mf_df = self.mass_features_dataframe.copy()
2324        mf_df.reset_index(inplace = True)
2325        
2326        # Include induced mass features if they exist (from gap-filling)
2327        if self.induced_mass_features_dataframe is not None:
2328            imf_df = self.induced_mass_features_dataframe.copy()
2329            imf_df.reset_index(inplace = True)
2330            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
2331            mf_df = pd.concat([mf_df, imf_df], axis = 0)
2332            mf_df.reset_index(drop = True, inplace = True)
2333        mf_df['cluster'] = mf_df['cluster'].astype(int)
2334        
2335        # Calculate number of samples per cluster
2336        cluster_sample_counts = mf_df.groupby('cluster')['sample_id'].nunique().to_dict()
2337        
2338        # Use the same representative selection logic as process_consensus_features
2339        # This uses the configured representative_metric from parameters
2340        representatives = self.get_representative_mass_features_for_all_clusters()
2341        
2342        # Get the coll_mf_ids of the representatives
2343        representative_ids = representatives['coll_mf_id'].tolist()
2344        
2345        # Filter mf_df to only include representative features
2346        consensus_report = mf_df[mf_df.coll_mf_id.isin(representative_ids)].copy()
2347        
2348        # Add polarity (get from first sample in collection)
2349        if len(self) > 0:
2350            polarity = self[0].polarity
2351        else:
2352            polarity = 'unknown'
2353        consensus_report['polarity'] = polarity
2354        
2355        # Add number of samples detected
2356        consensus_report['n_samples_detected'] = consensus_report['cluster'].map(cluster_sample_counts)
2357        
2358        # Reorder columns to put cluster at the front
2359        cols = consensus_report.columns.tolist()
2360        if 'cluster' in cols:
2361            cols.remove('cluster')
2362            cols = ['cluster'] + cols
2363            consensus_report = consensus_report[cols]
2364        
2365        # Sort by cluster and return with cluster as a regular column
2366        return consensus_report.sort_values(by='cluster')
2367
2368    def feature_annotations_table(
2369            self, 
2370            molecular_metadata=None, 
2371            drop_unannotated=False,
2372            report_best_only=False
2373            ):
2374        """Generate a comprehensive annotation table for all loaded mass features across samples.
2375        
2376        This method consolidates MS1 molecular formula assignments and MS2 spectral 
2377        search results for all mass features across all samples in the collection.
2378        Only includes representative mass features (one per cluster per sample).
2379        
2380        Parameters
2381        ----------
2382        molecular_metadata : dict, optional
2383            Dictionary of MolecularMetadata objects, keyed by metabref_mol_id.
2384            Required for including molecular metadata in MS2 annotations.
2385            Default is None.
2386        drop_unannotated : bool, optional
2387            If True, drops rows where all annotation columns (everything except 
2388            cluster, MS2 Spectrum, and representative_sample) are NaN.
2389            Default is False.
2390        report_best_only : bool, optional
2391            If True, only includes the best MS2 annotation per mass feature based on confidence score.
2392            Default is False, which includes all MS2 annotations for each mass feature.
2393        
2394        Returns
2395        -------
2396        pd.DataFrame
2397            Consolidated annotation report with columns including:
2398            - cluster: cluster ID
2399            - sample_name: sample name
2400            - sample_id: sample ID
2401            - Mass Feature ID: mass feature ID within the sample
2402            - Mass feature attributes (mz, scan_time, intensity, etc.)
2403            - MS1 annotations (if molecular_formula_search was run)
2404            - MS2 annotations (if ms2_spectral_search was run)
2405        
2406        Notes
2407        -----
2408        This method uses the standard LCMSMetabolomicsExport.to_report() workflow
2409        for each sample, then consolidates all results and adds cluster information.
2410        
2411        Only mass features that are loaded in each sample's mass_features dict
2412        are included (typically the representative features if load_representatives
2413        was used in process_consensus_features).
2414        
2415        Raises
2416        ------
2417        ValueError
2418            If no representative features have been loaded. Call process_consensus_features
2419            with load_representatives=True first.
2420        ValueError
2421            If no samples with loaded mass features are found in the collection.
2422        """
2423        from corems.mass_spectra.output.export import LCMSMetabolomicsExport
2424        import warnings
2425        
2426        # Check if representative features have been loaded
2427        # Count samples with mass features loaded
2428        samples_with_features = sum(
2429            1 for lcms_obj in self 
2430            if hasattr(lcms_obj, 'mass_features') and len(lcms_obj.mass_features) > 0
2431        )
2432        
2433        if samples_with_features == 0:
2434            raise ValueError(
2435                "No representative mass features have been loaded into individual samples. "
2436                "Call process_consensus_features() with load_representatives=True before "
2437                "calling feature_annotations_table()."
2438            )
2439        
2440        # Collect reports from all samples
2441        all_sample_reports = []
2442        has_any_ms2_annotations = False
2443        
2444        for sample_id, lcms_obj in enumerate(self):
2445            # Skip samples with no loaded mass features
2446            if not hasattr(lcms_obj, 'mass_features') or len(lcms_obj.mass_features) == 0:
2447                continue
2448            
2449            sample_name = self.samples[sample_id]
2450            
2451            # Create exporter and generate report using standard workflow
2452            # Suppress individual warnings - we'll warn at collection level if needed
2453            exporter = LCMSMetabolomicsExport("temp", lcms_obj)
2454            sample_report = exporter.to_report(molecular_metadata=molecular_metadata, suppress_warnings=True)
2455            
2456            # Check if this sample has any MS2 annotations
2457            ms2_cols = [col for col in sample_report.columns if 'Entropy Similarity' in col or 'spectral_similarity' in col.lower()]
2458            if ms2_cols and sample_report[ms2_cols].notna().any().any():
2459                has_any_ms2_annotations = True
2460            
2461            # Add sample information
2462            sample_report['representative_sample'] = sample_name
2463            sample_report['sample_id'] = sample_id
2464            
2465            # Get cluster information from the mass_features_dataframe
2466            # Build coll_mf_id for each row to look up cluster
2467            sample_report['coll_mf_id'] = sample_report['sample_id'].astype(str) + "_" + sample_report['Mass Feature ID'].astype(str)
2468            
2469            # Get cluster from mass_features_dataframe
2470            if self.mass_features_dataframe is not None and 'cluster' in self.mass_features_dataframe.columns:
2471                mf_df = self.mass_features_dataframe.reset_index()
2472                cluster_lookup = mf_df.set_index('coll_mf_id')['cluster'].to_dict()
2473                sample_report['cluster'] = sample_report['coll_mf_id'].map(cluster_lookup)
2474            else:
2475                sample_report['cluster'] = None
2476            
2477            # Drop temporary coll_mf_id column
2478            sample_report = sample_report.drop(columns=['coll_mf_id'])
2479            
2480            all_sample_reports.append(sample_report)
2481        
2482        # Combine all sample reports
2483        if len(all_sample_reports) == 0:
2484            raise ValueError("No samples with loaded mass features found in collection")
2485        
2486        collection_report = pd.concat(all_sample_reports, ignore_index=True)
2487        
2488        # Warn only if NO samples in the collection have MS2 annotations
2489        if not has_any_ms2_annotations:
2490            warnings.warn(
2491                "No MS2 annotations found across any samples in collection, were MS2 spectra added and searched against a database?",
2492                UserWarning,
2493            )
2494        
2495        # Reorder columns to match specified order
2496        desired_cols = [
2497            'cluster',
2498            'Isotopologue Type',
2499            'Is Largest Ion after Deconvolution',
2500            'MS2 Spectrum',
2501            'Calculated m/z',
2502            'm/z Error (ppm)',
2503            'm/z Error Score',
2504            'Isotopologue Similarity',
2505            'Confidence Score',
2506            'Ion Formula',
2507            'Ion Type',
2508            'Molecular Formula',
2509            'inchikey',
2510            'name',
2511            'ref_ms_id',
2512            'Entropy Similarity',
2513            'Library mzs in Query (fraction)',
2514            'Spectra with Annotation (n)',
2515            'representative_sample'
2516        ]
2517        
2518        # Include only desired columns that exist, maintaining order
2519        cols = [col for col in desired_cols if col in collection_report.columns]
2520        collection_report = collection_report[cols]
2521        
2522        # Optionally drop rows without any annotations
2523        if drop_unannotated:
2524            # Columns to exclude from the "all NA" check
2525            exclude_cols = ['cluster', 'MS2 Spectrum', 'representative_sample']
2526            # Get annotation columns (everything except the excluded ones)
2527            annot_cols = [col for col in collection_report.columns if col not in exclude_cols]
2528            # Keep rows where at least one annotation column is not NA
2529            if len(annot_cols) > 0:
2530                collection_report = collection_report[collection_report[annot_cols].notna().any(axis=1)]
2531        
2532        # Sort by cluster, then by annotation quality
2533        sort_cols = ['cluster']
2534        if 'Entropy Similarity' in collection_report.columns:
2535            sort_cols.extend(['Entropy Similarity', 'Confidence Score'])
2536            collection_report = collection_report.sort_values(
2537                by=sort_cols,
2538                ascending=[True, False, False]
2539            )
2540        elif 'Confidence Score' in collection_report.columns:
2541            sort_cols.append('Confidence Score')
2542            collection_report = collection_report.sort_values(
2543                by=sort_cols,
2544                ascending=[True, False]
2545            )
2546        else:
2547            collection_report = collection_report.sort_values(by=sort_cols)
2548        
2549        if report_best_only:
2550            # Keep only the best annotation per cluster based on the first annotation column available
2551            if 'Entropy Similarity' in collection_report.columns:
2552                best_annot_col = 'Entropy Similarity'
2553            elif 'Confidence Score' in collection_report.columns:
2554                best_annot_col = 'Confidence Score'
2555            else:
2556                best_annot_col = None
2557            
2558            if best_annot_col is not None:
2559                collection_report = collection_report.sort_values(by=['cluster', best_annot_col], ascending=[True, False])
2560                collection_report = collection_report.drop_duplicates(subset=['cluster'], keep='first')
2561
2562        return collection_report
2563
2564    @property
2565    def parameters(self):
2566        """
2567        LCMSCollectionParameters : The parameters used for the LCMS collection.
2568        """
2569        return self._parameters
2570    
2571    @parameters.setter
2572    def parameters(self, paramsinstance):
2573        """
2574        Sets the parameters used for the LCMS analysis collection.
2575
2576        Parameters
2577        -----------
2578        paramsinstance : LCMSCollectionParameters
2579            The parameters used for the LC-MS analysis.
2580        """
2581        self._parameters = paramsinstance
2582    
2583    @property
2584    def mass_features_dataframe(self):
2585        self._check_mass_features_df()
2586        return self._combined_mass_features
2587
2588    @mass_features_dataframe.setter
2589    def mass_features_dataframe(self, df):
2590        # Check that the dataframe has the expected columns
2591        expected_cols = ["sample_name", "sample_id", "mz", "scan_time"]
2592        if not all([col in df.columns for col in expected_cols]):
2593            raise ValueError(f"Expected columns not found in dataframe: {expected_cols}")
2594        
2595        # Check that coll_mf_id is the index and it is unique
2596        if df.index.name != "coll_mf_id":
2597            raise ValueError("coll_mf_id must be the index of the dataframe")
2598        if not df.index.is_unique:
2599            raise ValueError("coll_mf_id must be unique")
2600        self._combined_mass_features = df
2601
2602    @property
2603    def induced_mass_features_dataframe(self):
2604        self._check_mass_features_df(induced_features = True)
2605        if self._combined_induced_mass_features is not None and len(self._combined_induced_mass_features) > 0:
2606            # The cluster column is extracted from mf_id in _prepare_lcms_mass_features_for_combination
2607            # mf_id format for induced features: c{cluster}_{index}_i
2608            pass
2609        return self._combined_induced_mass_features
2610
2611    @induced_mass_features_dataframe.setter
2612    def induced_mass_features_dataframe(self, df):
2613        # Check that the dataframe has the expected columns
2614        expected_cols = ["sample_name", "sample_id", "mz", "scan_time"]
2615        if not all([col in df.columns for col in expected_cols]):
2616            raise ValueError(f"Expected columns not found in dataframe: {expected_cols}")
2617        
2618        # Check that coll_mf_id is the index and it is unique
2619        if df.index.name != "coll_mf_id":
2620            raise ValueError("coll_mf_id must be the index of the dataframe")
2621        if not df.index.is_unique:
2622            raise ValueError("coll_mf_id must be unique")
2623        self._combined_induced_mass_features = df    
2624    
2625    @property
2626    def cluster_summary_dataframe(self):
2627        return self.summarize_clusters()
2628    
2629    @property
2630    def samples(self):
2631        manifest_df = self.manifest_dataframe
2632        # order by batch, then by order
2633        manifest_df = manifest_df.sort_values(by=['batch', 'order'])
2634        return manifest_df.index.tolist()
2635    
2636    @property
2637    def manifest(self):
2638        return self._manifest_dict
2639    
2640    @property
2641    def manifest_dataframe(self):
2642        return pd.DataFrame(self._manifest_dict).T
2643
2644    @property
2645    def raw_files(self):
2646        """Returns a list of raw files in the collection."""
2647        return [x.raw_file_location for x in self]
2648    
2649    @property
2650    def rt_alignments(self):
2651        """Returns a dictionary of retention time alignments for the collection."""
2652        if self.rt_aligned:
2653            _rt_alignments = {}
2654            # Construct a dictionary of aligned retention times (stored on each LCMS object within the collection, not the collection itself)
2655            for i, lcms_obj in enumerate(self):
2656                aligned_times = [x for k, x in sorted(lcms_obj._scan_info["scan_time_aligned"].items())]
2657                _rt_alignments[i] = aligned_times
2658            return _rt_alignments
2659        else:
2660            return None
2661    
2662    @property
2663    def cluster_feature_dictionary(self):
2664        """Generates a dictionary with clusters for keys and mass feature IDs as entries"""
2665        df = self.mass_features_dataframe
2666        cluster_dict = df.groupby('cluster').apply(lambda x: x.index.tolist()).to_dict()
2667        return cluster_dict
2668    
2669    def get_eics_for_cluster(self, cluster_id):
2670        """
2671        Retrieve all EICs for mass features in a specific cluster across all samples.
2672        
2673        Returns a dictionary mapping sample names to EIC_Data objects for the given cluster.
2674        Useful for visualizing and comparing chromatographic peaks across samples.
2675        
2676        Parameters
2677        ----------
2678        cluster_id : int
2679            The cluster ID to retrieve EICs for
2680            
2681        Returns
2682        -------
2683        dict
2684            Dictionary with structure: {sample_name: EIC_Data object}
2685            Only includes samples where the EIC was loaded.
2686            
2687        Examples
2688        --------
2689        >>> # Load EICs first
2690        >>> collection.process_consensus_features(gather_eics=True, ...)
2691        >>> 
2692        >>> # Get all EICs for cluster 5
2693        >>> eics = collection.get_eics_for_cluster(5)
2694        >>> for sample_name, eic_data in eics.items():
2695        ...     print(f"{sample_name}: {len(eic_data.scans)} scans")
2696        
2697        Notes
2698        -----
2699        Requires that EICs have been loaded using gather_eics=True in
2700        process_consensus_features() or manually loaded via LoadEICsOperation.
2701        """
2702        eics_by_sample = {}
2703        
2704        # Iterate through all samples
2705        for sample_id, sample in enumerate(self):
2706            sample_name = self.samples[sample_id]
2707            
2708            # Check if sample has EICs loaded
2709            if not hasattr(sample, 'eics') or not sample.eics:
2710                continue
2711            
2712            # Find mass features in this cluster for this sample
2713            # Check both regular and induced mass features
2714            for mf in list(sample.mass_features.values()) + list(sample.induced_mass_features.values()):
2715                if hasattr(mf, 'cluster_index') and mf.cluster_index == cluster_id:
2716                    # Get the EIC for this mass feature's m/z
2717                    if mf.mz in sample.eics:
2718                        eics_by_sample[sample_name] = sample.eics[mf.mz]
2719                        break  # Found the EIC for this sample, move to next sample
2720        
2721        return eics_by_sample
class MassSpectraBase:
 19class MassSpectraBase:
 20    """Base class for mass spectra objects.
 21
 22    Parameters
 23    -----------
 24    file_location : str or Path
 25        The location of the file containing the mass spectra data.
 26    analyzer : str, optional
 27        The type of analyzer used to generate the mass spectra data. Defaults to 'Unknown'.
 28    instrument_label : str, optional
 29        The type of instrument used to generate the mass spectra data. Defaults to 'Unknown'.
 30    sample_name : str, optional
 31        The name of the sample; defaults to the file name if not provided to the parser. Defaults to None.
 32    spectra_parser : object, optional
 33        The spectra parser object used to create the mass spectra object. Defaults to None.
 34
 35    Attributes
 36    -----------
 37    spectra_parser_class : class
 38        The class of the spectra parser used to create the mass spectra object.
 39    file_location : str or Path
 40        The location of the file containing the mass spectra data.
 41    sample_name : str
 42        The name of the sample; defaults to the file name if not provided to the parser.
 43    analyzer : str
 44        The type of analyzer used to generate the mass spectra data. Derived from the spectra parser.
 45    instrument_label : str
 46        The type of instrument used to generate the mass spectra data. Derived from the spectra parser.
 47    _scan_info : dict
 48        A dictionary containing the scan data with columns for scan number, scan time, ms level, precursor m/z,
 49        scan text, and scan window (lower and upper).
 50        Associated with the property scan_df, which returns a pandas DataFrame or can set this attribute from a pandas DataFrame.
 51    _ms : dict
 52        A dictionary containing mass spectra for the dataset, keys of dictionary are scan numbers. Initialized as an empty dictionary.
 53    _ms_unprocessed: dictionary of pandas.DataFrames or None
 54        A dictionary of unprocssed mass spectra data, as an (optional) intermediate data product for peak picking.
 55        Key is ms_level, and value is dataframe with columns for scan number, m/z, and intensity. Default is None.
 56
 57    Methods
 58    --------
 59    * add_mass_spectra(scan_list, spectrum_mode: str = 'profile', use_parser = True, auto_process=True).
 60        Add mass spectra (or singlel mass spectrum) to _ms slot, from a list of scans
 61    * get_time_of_scan_id(scan).
 62        Returns the scan time for the specified scan number.
 63    """
 64
 65    def __init__(
 66        self,
 67        file_location,
 68        analyzer="Unknown",
 69        instrument_label="Unknown",
 70        sample_name=None,
 71        spectra_parser=None,
 72    ):
 73        if isinstance(file_location, str):
 74            file_location = Path(file_location)
 75        else:
 76            file_location = file_location
 77        if not file_location.exists():
 78            raise FileExistsError("File does not exist: " + str(file_location))
 79
 80        if sample_name:
 81            self.sample_name = sample_name
 82        else:
 83            self.sample_name = file_location.stem
 84
 85        self.file_location = file_location
 86        self.analyzer = analyzer
 87        self.instrument_label = instrument_label
 88        self._raw_file_location = None
 89
 90        # Add the spectra parser class to the object if it is not None
 91        if spectra_parser is not None:
 92            self.spectra_parser_class = spectra_parser.__class__
 93            if self.spectra_parser_class.__name__ == "ReadCoreMSHDFMassSpectra":
 94                self.raw_file_location = spectra_parser.get_raw_file_location()
 95
 96            # Check that spectra_parser.sample_name is same as sample_name etc, raise warning if not
 97            if (
 98                self.sample_name is not None
 99                and self.sample_name != self.spectra_parser.sample_name
100                and self.spectra_parser_class.__name__ != "ReadCoreMSHDFMassSpectra"
101            ):
102                warnings.warn(
103                    "sample_name provided to MassSpectraBase object does not match sample_name provided to spectra parser object",
104                    UserWarning,
105                )
106            if self.analyzer != self.spectra_parser.analyzer:
107                warnings.warn(
108                    "analyzer provided to MassSpectraBase object does not match analyzer provided to spectra parser object",
109                    UserWarning,
110                )
111            if self.instrument_label != self.spectra_parser.instrument_label:
112                warnings.warn(
113                    "instrument provided to MassSpectraBase object does not match instrument provided to spectra parser object",
114                    UserWarning,
115                )
116            if self.file_location != self.spectra_parser.file_location:
117                warnings.warn(
118                    "file_location provided to MassSpectraBase object does not match file_location provided to spectra parser object",
119                    UserWarning,
120                )
121
122        # Instantiate empty dictionaries for scan information and mass spectra
123        self._scan_info = {}
124        self._ms = {}
125        self._ms_unprocessed = {}
126
127    @property
128    def spectra_parser(self):
129        """Returns an instance of the spectra parser class."""
130        # Check if a file exists at the raw_file_location
131        if not Path(self.raw_file_location).exists():
132            raise FileNotFoundError(
133                f"Raw file not found at location: {self.raw_file_location}, update raw_file_location property to point to correct location."
134            )        
135        return self.spectra_parser_class(self.raw_file_location)
136
137    @property
138    def raw_file_location(self):
139        """Returns the file_location unless the _raw_file_location is not None."""
140        return self._raw_file_location if self._raw_file_location is not None else self.file_location   
141    
142    @raw_file_location.setter
143    def raw_file_location(self, value):
144        self._raw_file_location = value
145
146    def add_mass_spectrum(self, mass_spec):
147        """Adds a mass spectrum to the dataset.
148
149        Parameters
150        -----------
151        mass_spec : MassSpectrum
152            The corems MassSpectrum object to be added to the dataset.
153
154        Notes
155        -----
156        This is a helper function for the add_mass_spectra() method, and is not intended to be called directly.
157        """
158        # check if mass_spec has a scan_number attribute
159        if not hasattr(mass_spec, "scan_number"):
160            raise ValueError(
161                "Mass spectrum must have a scan_number attribute to be added to the dataset correctly"
162            )
163        self._ms[mass_spec.scan_number] = mass_spec
164
165    def add_mass_spectra(
166        self,
167        scan_list,
168        spectrum_mode=None,
169        ms_level=1,
170        use_parser=True,
171        auto_process=True,
172        ms_params=None,
173    ):
174        """Add mass spectra to _ms dictionary, from a list of scans or single scan
175
176        Notes
177        -----
178        The mass spectra will inherit the mass_spectrum, ms_peak, and molecular_search parameters from the LCMSBase object.
179
180
181        Parameters
182        -----------
183        scan_list : list of ints
184            List of scans to use to populate _ms slot
185        spectrum_mode : str or None
186            The spectrum mode to use for the mass spectra.
187            If None, method will use the spectrum mode from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
188            Defaults to None.
189        ms_level : int, optional
190            The MS level to use for the mass spectra.
191            This is used to pass the molecular_search parameters from the LCMS object to the individual MassSpectrum objects.
192            Defaults to 1.
193        using_parser : bool
194            Whether to use the mass spectra parser to get the mass spectra.  Defaults to True.
195        auto_process : bool
196            Whether to auto-process the mass spectra.  Defaults to True.
197        ms_params : MSParameters or None
198            The mass spectrum parameters to use for the mass spectra.  If None, uses the globally set MSParameters.
199
200        Raises
201        ------
202        TypeError
203            If scan_list is not a list of ints
204        ValueError
205            If polarity is not 'positive' or 'negative'
206            If ms_level is not 1 or 2
207        """
208
209        # check if scan_list is a list or a single int; if single int, convert to list
210        if isinstance(scan_list, int):
211            scan_list = [scan_list]
212        if not isinstance(scan_list, list):
213            raise TypeError("scan_list must be a list of integers")
214        for scan in scan_list:
215            if not isinstance(scan, int):
216                raise TypeError("scan_list must be a list of integers")
217
218        # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
219        if self.polarity == "negative":
220            polarity = -1
221        elif self.polarity == "positive":
222            polarity = 1
223        else:
224            raise ValueError(
225                "Polarity not set for dataset, must be a either 'positive' or 'negative'"
226            )
227
228        # is not using_parser, check that ms1 and ms2 are not None
229        if not use_parser:
230            if ms_level not in self._ms_unprocessed.keys():
231                raise ValueError(
232                    "ms_level {} not found in _ms_unprocessed dictionary".format(
233                        ms_level
234                    )
235                )
236
237        scan_list = list(set(scan_list))
238        scan_list.sort()
239
240        # Skip scans that have already been added to _ms to avoid redundant reprocessing
241        already_added = [s for s in scan_list if s in self._ms]
242        if already_added:
243            warnings.warn(
244                "Skipping {} scan(s) already present in _ms: {}".format(
245                    len(already_added), already_added
246                ),
247                UserWarning,
248            )
249            scan_list = [s for s in scan_list if s not in self._ms]
250        if not scan_list:
251            return
252
253        if not use_parser:
254            if self._ms_unprocessed[ms_level] is None:
255                raise ValueError(
256                    "No unprocessed data found for ms_level {}".format(ms_level)
257                )
258            if (
259                len(
260                    np.setdiff1d(
261                        scan_list, self._ms_unprocessed[ms_level].scan.tolist()
262                    )
263                )
264                > 0
265            ):
266                raise ValueError(
267                    "Not all scans in scan_list are present in the unprocessed data"
268                )
269            # Prepare the ms_df for parsing
270            ms_df = self._ms_unprocessed[ms_level].copy().set_index("scan", drop=False)
271
272        if use_parser:
273            # Use batch function to get all mass spectra at once
274            if spectrum_mode is None:
275                # get spectrum mode from _scan_info for each scan
276                spectrum_modes = [self.scan_df.loc[scan, "ms_format"] for scan in scan_list]
277                spectrum_mode_batch = spectrum_modes[0] if len(set(spectrum_modes)) == 1 else None
278            else:
279                spectrum_mode_batch = spectrum_mode
280            
281            ms_list = self.spectra_parser.get_mass_spectra_from_scan_list(
282                scan_list=scan_list,
283                spectrum_mode=spectrum_mode_batch,
284                auto_process=False,
285            )
286            
287            # Process each mass spectrum
288            for i, scan in enumerate(scan_list):
289                ms = ms_list[i] if i < len(ms_list) else None
290                if ms is not None:
291                    if ms_params is not None:
292                        ms.parameters = ms_params
293                    ms.scan_number = scan
294                    if auto_process:
295                        ms.process_mass_spec()
296                    self.add_mass_spectrum(ms)
297        else:
298            # Original non-parser logic remains unchanged
299            for scan in scan_list:
300                ms = None
301                if spectrum_mode is None:
302                    # get spectrum mode from _scan_info
303                    spectrum_mode_scan = self.scan_df.loc[scan, "ms_format"]
304                else:
305                    spectrum_mode_scan = spectrum_mode
306                
307                my_ms_df = ms_df.loc[scan]
308                if spectrum_mode_scan == "profile":
309                    # Check this - it might be better to use the MassSpectrumProfile class to instantiate the mass spectrum
310                    ms = ms_from_array_profile(
311                        my_ms_df.mz,
312                        my_ms_df.intensity,
313                        self.file_location,
314                        polarity=polarity,
315                        auto_process=False,
316                    )
317                else:
318                   ms = ms_from_array_centroid(
319                        mz = my_ms_df.mz,
320                        abundance = my_ms_df.intensity,
321                        rp = [np.nan] * len(my_ms_df.mz),
322                        s2n = [np.nan] * len(my_ms_df.mz),
323                        dataname = self.file_location,
324                        polarity=polarity,
325                        auto_process=False,
326                    )
327
328                # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset
329                if ms is not None:
330                    if ms_params is not None:
331                        ms.parameters = ms_params
332                    ms.scan_number = scan
333                    if auto_process:
334                        ms.process_mass_spec()
335                    self.add_mass_spectrum(ms)
336
337    def get_time_of_scan_id(self, scan):
338        """Returns the scan time for the specified scan number.
339
340        Parameters
341        -----------
342        scan : int
343            The scan number of the desired scan time.
344
345        Returns
346        --------
347        float
348            The scan time for the specified scan number (in minutes).
349
350        Raises
351        ------
352        ValueError
353            If no scan time is found for the specified scan number.
354        """
355        # Check if _retenion_time_list is empty and raise error if so
356        if len(self._retention_time_list) == 0:
357            raise ValueError("No retention times found in dataset")
358        rt = self._retention_time_list[self._scans_number_list.index(scan)]
359        return rt
360
361    @property
362    def scan_df(self):
363        """
364        pandas.DataFrame : A pandas DataFrame containing the scan info data with columns for scan number, scan time, ms level, precursor m/z, scan text, and scan window (lower and upper).
365        """
366        scan_df = pd.DataFrame.from_dict(self._scan_info)
367        return scan_df
368        
369    @property
370    def ms(self):
371        """
372        dictionary : contains the key associated with mass spectra and values are the associated MassSpecProfiles
373        """
374        return self._ms
375
376    
377    @scan_df.setter
378    def scan_df(self, df):
379        """
380        Sets the scan data for the dataset.
381
382        Parameters
383        -----------
384        df : pandas.DataFrame
385            A pandas DataFrame containing the scan data with columns for scan number, scan time, ms level,
386            precursor m/z, scan text, and scan window (lower and upper).
387        """
388        self._scan_info = df.to_dict()
389
390    def __getitem__(self, scan_number):
391        return self._ms.get(scan_number)

Base class for mass spectra objects.

Parameters
  • file_location (str or Path): The location of the file containing the mass spectra data.
  • analyzer (str, optional): The type of analyzer used to generate the mass spectra data. Defaults to 'Unknown'.
  • instrument_label (str, optional): The type of instrument used to generate the mass spectra data. Defaults to 'Unknown'.
  • sample_name (str, optional): The name of the sample; defaults to the file name if not provided to the parser. Defaults to None.
  • spectra_parser (object, optional): The spectra parser object used to create the mass spectra object. Defaults to None.
Attributes
  • spectra_parser_class (class): The class of the spectra parser used to create the mass spectra object.
  • file_location (str or Path): The location of the file containing the mass spectra data.
  • sample_name (str): The name of the sample; defaults to the file name if not provided to the parser.
  • analyzer (str): The type of analyzer used to generate the mass spectra data. Derived from the spectra parser.
  • instrument_label (str): The type of instrument used to generate the mass spectra data. Derived from the spectra parser.
  • _scan_info (dict): A dictionary containing the scan data with columns for scan number, scan time, ms level, precursor m/z, scan text, and scan window (lower and upper). Associated with the property scan_df, which returns a pandas DataFrame or can set this attribute from a pandas DataFrame.
  • _ms (dict): A dictionary containing mass spectra for the dataset, keys of dictionary are scan numbers. Initialized as an empty dictionary.
  • _ms_unprocessed (dictionary of pandas.DataFrames or None): A dictionary of unprocssed mass spectra data, as an (optional) intermediate data product for peak picking. Key is ms_level, and value is dataframe with columns for scan number, m/z, and intensity. Default is None.
Methods
  • add_mass_spectra(scan_list, spectrum_mode: str = 'profile', use_parser = True, auto_process=True). Add mass spectra (or singlel mass spectrum) to _ms slot, from a list of scans
  • get_time_of_scan_id(scan). Returns the scan time for the specified scan number.
MassSpectraBase( file_location, analyzer='Unknown', instrument_label='Unknown', sample_name=None, spectra_parser=None)
 65    def __init__(
 66        self,
 67        file_location,
 68        analyzer="Unknown",
 69        instrument_label="Unknown",
 70        sample_name=None,
 71        spectra_parser=None,
 72    ):
 73        if isinstance(file_location, str):
 74            file_location = Path(file_location)
 75        else:
 76            file_location = file_location
 77        if not file_location.exists():
 78            raise FileExistsError("File does not exist: " + str(file_location))
 79
 80        if sample_name:
 81            self.sample_name = sample_name
 82        else:
 83            self.sample_name = file_location.stem
 84
 85        self.file_location = file_location
 86        self.analyzer = analyzer
 87        self.instrument_label = instrument_label
 88        self._raw_file_location = None
 89
 90        # Add the spectra parser class to the object if it is not None
 91        if spectra_parser is not None:
 92            self.spectra_parser_class = spectra_parser.__class__
 93            if self.spectra_parser_class.__name__ == "ReadCoreMSHDFMassSpectra":
 94                self.raw_file_location = spectra_parser.get_raw_file_location()
 95
 96            # Check that spectra_parser.sample_name is same as sample_name etc, raise warning if not
 97            if (
 98                self.sample_name is not None
 99                and self.sample_name != self.spectra_parser.sample_name
100                and self.spectra_parser_class.__name__ != "ReadCoreMSHDFMassSpectra"
101            ):
102                warnings.warn(
103                    "sample_name provided to MassSpectraBase object does not match sample_name provided to spectra parser object",
104                    UserWarning,
105                )
106            if self.analyzer != self.spectra_parser.analyzer:
107                warnings.warn(
108                    "analyzer provided to MassSpectraBase object does not match analyzer provided to spectra parser object",
109                    UserWarning,
110                )
111            if self.instrument_label != self.spectra_parser.instrument_label:
112                warnings.warn(
113                    "instrument provided to MassSpectraBase object does not match instrument provided to spectra parser object",
114                    UserWarning,
115                )
116            if self.file_location != self.spectra_parser.file_location:
117                warnings.warn(
118                    "file_location provided to MassSpectraBase object does not match file_location provided to spectra parser object",
119                    UserWarning,
120                )
121
122        # Instantiate empty dictionaries for scan information and mass spectra
123        self._scan_info = {}
124        self._ms = {}
125        self._ms_unprocessed = {}
file_location
analyzer
instrument_label
spectra_parser
127    @property
128    def spectra_parser(self):
129        """Returns an instance of the spectra parser class."""
130        # Check if a file exists at the raw_file_location
131        if not Path(self.raw_file_location).exists():
132            raise FileNotFoundError(
133                f"Raw file not found at location: {self.raw_file_location}, update raw_file_location property to point to correct location."
134            )        
135        return self.spectra_parser_class(self.raw_file_location)

Returns an instance of the spectra parser class.

raw_file_location
137    @property
138    def raw_file_location(self):
139        """Returns the file_location unless the _raw_file_location is not None."""
140        return self._raw_file_location if self._raw_file_location is not None else self.file_location   

Returns the file_location unless the _raw_file_location is not None.

def add_mass_spectrum(self, mass_spec):
146    def add_mass_spectrum(self, mass_spec):
147        """Adds a mass spectrum to the dataset.
148
149        Parameters
150        -----------
151        mass_spec : MassSpectrum
152            The corems MassSpectrum object to be added to the dataset.
153
154        Notes
155        -----
156        This is a helper function for the add_mass_spectra() method, and is not intended to be called directly.
157        """
158        # check if mass_spec has a scan_number attribute
159        if not hasattr(mass_spec, "scan_number"):
160            raise ValueError(
161                "Mass spectrum must have a scan_number attribute to be added to the dataset correctly"
162            )
163        self._ms[mass_spec.scan_number] = mass_spec

Adds a mass spectrum to the dataset.

Parameters
  • mass_spec (MassSpectrum): The corems MassSpectrum object to be added to the dataset.
Notes

This is a helper function for the add_mass_spectra() method, and is not intended to be called directly.

def add_mass_spectra( self, scan_list, spectrum_mode=None, ms_level=1, use_parser=True, auto_process=True, ms_params=None):
165    def add_mass_spectra(
166        self,
167        scan_list,
168        spectrum_mode=None,
169        ms_level=1,
170        use_parser=True,
171        auto_process=True,
172        ms_params=None,
173    ):
174        """Add mass spectra to _ms dictionary, from a list of scans or single scan
175
176        Notes
177        -----
178        The mass spectra will inherit the mass_spectrum, ms_peak, and molecular_search parameters from the LCMSBase object.
179
180
181        Parameters
182        -----------
183        scan_list : list of ints
184            List of scans to use to populate _ms slot
185        spectrum_mode : str or None
186            The spectrum mode to use for the mass spectra.
187            If None, method will use the spectrum mode from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
188            Defaults to None.
189        ms_level : int, optional
190            The MS level to use for the mass spectra.
191            This is used to pass the molecular_search parameters from the LCMS object to the individual MassSpectrum objects.
192            Defaults to 1.
193        using_parser : bool
194            Whether to use the mass spectra parser to get the mass spectra.  Defaults to True.
195        auto_process : bool
196            Whether to auto-process the mass spectra.  Defaults to True.
197        ms_params : MSParameters or None
198            The mass spectrum parameters to use for the mass spectra.  If None, uses the globally set MSParameters.
199
200        Raises
201        ------
202        TypeError
203            If scan_list is not a list of ints
204        ValueError
205            If polarity is not 'positive' or 'negative'
206            If ms_level is not 1 or 2
207        """
208
209        # check if scan_list is a list or a single int; if single int, convert to list
210        if isinstance(scan_list, int):
211            scan_list = [scan_list]
212        if not isinstance(scan_list, list):
213            raise TypeError("scan_list must be a list of integers")
214        for scan in scan_list:
215            if not isinstance(scan, int):
216                raise TypeError("scan_list must be a list of integers")
217
218        # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
219        if self.polarity == "negative":
220            polarity = -1
221        elif self.polarity == "positive":
222            polarity = 1
223        else:
224            raise ValueError(
225                "Polarity not set for dataset, must be a either 'positive' or 'negative'"
226            )
227
228        # is not using_parser, check that ms1 and ms2 are not None
229        if not use_parser:
230            if ms_level not in self._ms_unprocessed.keys():
231                raise ValueError(
232                    "ms_level {} not found in _ms_unprocessed dictionary".format(
233                        ms_level
234                    )
235                )
236
237        scan_list = list(set(scan_list))
238        scan_list.sort()
239
240        # Skip scans that have already been added to _ms to avoid redundant reprocessing
241        already_added = [s for s in scan_list if s in self._ms]
242        if already_added:
243            warnings.warn(
244                "Skipping {} scan(s) already present in _ms: {}".format(
245                    len(already_added), already_added
246                ),
247                UserWarning,
248            )
249            scan_list = [s for s in scan_list if s not in self._ms]
250        if not scan_list:
251            return
252
253        if not use_parser:
254            if self._ms_unprocessed[ms_level] is None:
255                raise ValueError(
256                    "No unprocessed data found for ms_level {}".format(ms_level)
257                )
258            if (
259                len(
260                    np.setdiff1d(
261                        scan_list, self._ms_unprocessed[ms_level].scan.tolist()
262                    )
263                )
264                > 0
265            ):
266                raise ValueError(
267                    "Not all scans in scan_list are present in the unprocessed data"
268                )
269            # Prepare the ms_df for parsing
270            ms_df = self._ms_unprocessed[ms_level].copy().set_index("scan", drop=False)
271
272        if use_parser:
273            # Use batch function to get all mass spectra at once
274            if spectrum_mode is None:
275                # get spectrum mode from _scan_info for each scan
276                spectrum_modes = [self.scan_df.loc[scan, "ms_format"] for scan in scan_list]
277                spectrum_mode_batch = spectrum_modes[0] if len(set(spectrum_modes)) == 1 else None
278            else:
279                spectrum_mode_batch = spectrum_mode
280            
281            ms_list = self.spectra_parser.get_mass_spectra_from_scan_list(
282                scan_list=scan_list,
283                spectrum_mode=spectrum_mode_batch,
284                auto_process=False,
285            )
286            
287            # Process each mass spectrum
288            for i, scan in enumerate(scan_list):
289                ms = ms_list[i] if i < len(ms_list) else None
290                if ms is not None:
291                    if ms_params is not None:
292                        ms.parameters = ms_params
293                    ms.scan_number = scan
294                    if auto_process:
295                        ms.process_mass_spec()
296                    self.add_mass_spectrum(ms)
297        else:
298            # Original non-parser logic remains unchanged
299            for scan in scan_list:
300                ms = None
301                if spectrum_mode is None:
302                    # get spectrum mode from _scan_info
303                    spectrum_mode_scan = self.scan_df.loc[scan, "ms_format"]
304                else:
305                    spectrum_mode_scan = spectrum_mode
306                
307                my_ms_df = ms_df.loc[scan]
308                if spectrum_mode_scan == "profile":
309                    # Check this - it might be better to use the MassSpectrumProfile class to instantiate the mass spectrum
310                    ms = ms_from_array_profile(
311                        my_ms_df.mz,
312                        my_ms_df.intensity,
313                        self.file_location,
314                        polarity=polarity,
315                        auto_process=False,
316                    )
317                else:
318                   ms = ms_from_array_centroid(
319                        mz = my_ms_df.mz,
320                        abundance = my_ms_df.intensity,
321                        rp = [np.nan] * len(my_ms_df.mz),
322                        s2n = [np.nan] * len(my_ms_df.mz),
323                        dataname = self.file_location,
324                        polarity=polarity,
325                        auto_process=False,
326                    )
327
328                # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset
329                if ms is not None:
330                    if ms_params is not None:
331                        ms.parameters = ms_params
332                    ms.scan_number = scan
333                    if auto_process:
334                        ms.process_mass_spec()
335                    self.add_mass_spectrum(ms)

Add mass spectra to _ms dictionary, from a list of scans or single scan

Notes

The mass spectra will inherit the mass_spectrum, ms_peak, and molecular_search parameters from the LCMSBase object.

Parameters
  • scan_list (list of ints): List of scans to use to populate _ms slot
  • spectrum_mode (str or None): The spectrum mode to use for the mass spectra. If None, method will use the spectrum mode from the spectra parser to ascertain the spectrum mode (this allows for mixed types). Defaults to None.
  • ms_level (int, optional): The MS level to use for the mass spectra. This is used to pass the molecular_search parameters from the LCMS object to the individual MassSpectrum objects. Defaults to 1.
  • using_parser (bool): Whether to use the mass spectra parser to get the mass spectra. Defaults to True.
  • auto_process (bool): Whether to auto-process the mass spectra. Defaults to True.
  • ms_params (MSParameters or None): The mass spectrum parameters to use for the mass spectra. If None, uses the globally set MSParameters.
Raises
  • TypeError: If scan_list is not a list of ints
  • ValueError: If polarity is not 'positive' or 'negative' If ms_level is not 1 or 2
def get_time_of_scan_id(self, scan):
337    def get_time_of_scan_id(self, scan):
338        """Returns the scan time for the specified scan number.
339
340        Parameters
341        -----------
342        scan : int
343            The scan number of the desired scan time.
344
345        Returns
346        --------
347        float
348            The scan time for the specified scan number (in minutes).
349
350        Raises
351        ------
352        ValueError
353            If no scan time is found for the specified scan number.
354        """
355        # Check if _retenion_time_list is empty and raise error if so
356        if len(self._retention_time_list) == 0:
357            raise ValueError("No retention times found in dataset")
358        rt = self._retention_time_list[self._scans_number_list.index(scan)]
359        return rt

Returns the scan time for the specified scan number.

Parameters
  • scan (int): The scan number of the desired scan time.
Returns
  • float: The scan time for the specified scan number (in minutes).
Raises
  • ValueError: If no scan time is found for the specified scan number.
scan_df
361    @property
362    def scan_df(self):
363        """
364        pandas.DataFrame : A pandas DataFrame containing the scan info data with columns for scan number, scan time, ms level, precursor m/z, scan text, and scan window (lower and upper).
365        """
366        scan_df = pd.DataFrame.from_dict(self._scan_info)
367        return scan_df

pandas.DataFrame : A pandas DataFrame containing the scan info data with columns for scan number, scan time, ms level, precursor m/z, scan text, and scan window (lower and upper).

ms
369    @property
370    def ms(self):
371        """
372        dictionary : contains the key associated with mass spectra and values are the associated MassSpecProfiles
373        """
374        return self._ms

dictionary : contains the key associated with mass spectra and values are the associated MassSpecProfiles

 394class LCMSBase(MassSpectraBase, LCCalculations, PHCalculations, LCMSSpectralSearch):
 395    """A class representing a liquid chromatography-mass spectrometry (LC-MS) data object.
 396
 397    This class is not intended to be instantiated directly, but rather to be instantiated by an appropriate mass spectra parser using the get_lcms_obj() method.
 398
 399    Parameters
 400    -----------
 401    file_location : str or Path
 402        The location of the file containing the mass spectra data.
 403    analyzer : str, optional
 404        The type of analyzer used to generate the mass spectra data. Defaults to 'Unknown'.
 405    instrument_label : str, optional
 406        The type of instrument used to generate the mass spectra data. Defaults to 'Unknown'.
 407    sample_name : str, optional
 408        The name of the sample; defaults to the file name if not provided to the parser. Defaults to None.
 409    spectra_parser : object, optional
 410        The spectra parser object used to create the mass spectra object. Defaults to None.
 411
 412    Attributes
 413    -----------
 414    polarity : str
 415        The polarity of the ionization mode used for the dataset.
 416    _parameters : LCMSParameters
 417        The parameters used for all methods called on the LCMSBase object. Set upon instantiation from LCMSParameters.
 418    _retention_time_list : numpy.ndarray
 419        An array of retention times for the dataset.
 420    _scans_number_list : list
 421        A list of scan numbers for the dataset.
 422    _tic_list : numpy.ndarray
 423        An array of total ion current (TIC) values for the dataset.
 424    eics : dict
 425        A dictionary containing extracted ion chromatograms (EICs) for the dataset.
 426        Key is the mz of the EIC. Initialized as an empty dictionary.
 427    mass_features : dictionary of LCMSMassFeature objects
 428        A dictionary containing mass features for the dataset.
 429        Key is mass feature ID. Initialized as an empty dictionary.
 430    induced_mass_features: dictionary of LCMSMassFeature objects
 431        A dictionary containing mass features from a collection that don't
 432        satisfy criteria for initial mass features. Key is mass feature ID.
 433        Initialized as an empty dictionary.
 434    missing_mass_features: pandas.DataFrame
 435        A table of clusters in a given sample for which a mass feature was
 436        sought and not found
 437    spectral_search_results : dictionary of MS2SearchResults objects
 438        A dictionary containing spectral search results for the dataset.
 439        Key is scan number : precursor mz. Initialized as an empty dictionary.
 440
 441    Methods
 442    --------
 443    * get_parameters_json().
 444        Returns the parameters used for the LC-MS analysis in JSON format.
 445    * add_associated_ms2_dda(add_to_lcmsobj=True, auto_process=True, use_parser=True)
 446        Adds which MS2 scans are associated with each mass feature to the
 447        mass_features dictionary and optionally adds the MS2 spectra to the _ms dictionary.
 448    * add_associated_ms1(add_to_lcmsobj=True, auto_process=True, use_parser=True)
 449        Adds the MS1 spectra associated with each mass feature to the
 450        mass_features dictionary and adds the MS1 spectra to the _ms dictionary.
 451    * mass_features_to_df()
 452        Returns a pandas dataframe summarizing the mass features in the dataset.
 453    * set_tic_list_from_data(overwrite=False)
 454        Sets the TIC list from the mass spectrum objects within the _ms dictionary.
 455    * set_retention_time_from_data(overwrite=False)
 456        Sets the retention time list from the data in the _ms dictionary.
 457    * set_scans_number_from_data(overwrite=False)
 458        Sets the scan number list from the data in the _ms dictionary.
 459    * plot_composite_mz_features(binsize = 1e-4, ph_int_min_thresh = 0.001, mf_plot = True, ms2_plot = True, return_fig = False)
 460        Generates plot of M/Z features comparing scan time vs M/Z value
 461    * search_for_targeted_mass_feature(ms1df: pd.DataFrame, sample: pd.Series, tol_flag = 0)
 462        Searches for mass features in specific M/Z and scan time windows that
 463        were missed by the persistent homology search
 464    """
 465
 466    def __init__(
 467        self,
 468        file_location,
 469        analyzer="Unknown",
 470        instrument_label="Unknown",
 471        sample_name=None,
 472        spectra_parser=None,
 473    ):
 474        super().__init__(
 475            file_location, analyzer, instrument_label, sample_name, spectra_parser
 476        )
 477        self.polarity = ""
 478        self._parameters = LCMSParameters()
 479        self._retention_time_list = []
 480        self._scans_number_list = []
 481        self._tic_list = []
 482        self.eics = {}
 483        self.mass_features = {}
 484        self.induced_mass_features = {}
 485        self.spectral_search_results = {}
 486
 487    def get_eic_mz_for_mass_feature(self, mf_mz, tolerance=0.0001):
 488        """Get the EIC dictionary key (m/z) that best matches a mass feature's m/z.
 489        
 490        Finds the closest EIC m/z key within the specified tolerance.
 491        
 492        Parameters
 493        ----------
 494        mf_mz : float
 495            The m/z value of the mass feature to match.
 496        tolerance : float, optional
 497            Maximum m/z difference for matching. Default is 0.0001 Da.
 498            
 499        Returns
 500        -------
 501        float or None
 502            The EIC dictionary key (m/z) of the closest matching EIC,
 503            or None if no EIC is within tolerance.
 504        """
 505        if not hasattr(self, 'eics') or not self.eics:
 506            return None
 507        
 508        best_eic_mz = None
 509        best_diff = tolerance
 510        for eic_mz in self.eics.keys():
 511            diff = abs(mf_mz - eic_mz)
 512            if diff < best_diff:
 513                best_diff = diff
 514                best_eic_mz = eic_mz
 515        return best_eic_mz
 516    
 517    def associate_eics_with_mass_features(self, tolerance=0.0001, induced=False):
 518        """Associate EICs with mass features using tolerance-based m/z matching.
 519        
 520        Associates EIC_Data objects from self.eics with mass features by finding
 521        the closest EIC within the specified m/z tolerance. This is more robust
 522        than exact matching which can fail due to floating point precision issues.
 523        
 524        Parameters
 525        ----------
 526        tolerance : float, optional
 527            Maximum m/z difference for matching EICs to mass features. Default is 0.0001 Da.
 528        induced : bool, optional
 529            If True, associates EICs with induced_mass_features instead of mass_features.
 530            Default is False.
 531            
 532        Notes
 533        -----
 534        For each mass feature, this method finds the EIC with the closest m/z value
 535        within the tolerance window and assigns it to the mass feature's _eic_data attribute.
 536        If multiple EICs are within tolerance, the one with the smallest m/z difference is chosen.
 537        """
 538        # Select which mass features dictionary to use
 539        mf_dict = self.induced_mass_features if induced else self.mass_features
 540        
 541        # Use the _eic_mz attribute on each mass_feature to find the closest matching EIC
 542        for idx in mf_dict.keys():
 543            mf_mz = mf_dict[idx]._eic_mz
 544            # Find closest EIC within tolerance
 545            best_match = None
 546            best_diff = tolerance
 547            for eic_mz, eic_data in self.eics.items():
 548                diff = abs(mf_mz - eic_mz)
 549                if diff < best_diff:
 550                    best_diff = diff
 551                    best_match = eic_data
 552            if best_match is not None:
 553                mf_dict[idx]._eic_data = best_match
 554
 555    def get_parameters_json(self):
 556        """Returns the parameters stored for the LC-MS object in JSON format.
 557
 558        Returns
 559        --------
 560        str
 561            The parameters used for the LC-MS analysis in JSON format.
 562        """
 563        return self.parameters.to_json()
 564
 565    def remove_unprocessed_data(self, ms_level=None):
 566        """Removes the unprocessed data from the LCMSBase object.
 567
 568        Parameters
 569        -----------
 570        ms_level : int, optional
 571            The MS level to remove the unprocessed data for. If None, removes unprocessed data for all MS levels.
 572
 573        Raises
 574        ------
 575        ValueError
 576            If ms_level is not 1 or 2.
 577
 578        Notes
 579        -----
 580        This method is useful for freeing up memory after the data has been processed.
 581        """
 582        if ms_level is None:
 583            for ms_level in self._ms_unprocessed.keys():
 584                self._ms_unprocessed[ms_level] = None
 585        if ms_level not in [1, 2]:
 586            raise ValueError("ms_level must be 1 or 2")
 587        self._ms_unprocessed[ms_level] = None
 588
 589    def _filter_ms2_scans_by_integration_bounds(self, mf_dict=None):
 590        """Filter MS2 scans to only include those within integration bounds.
 591        
 592        Removes MS2 scan numbers that fall outside the start_scan to final_scan range
 593        for each mass feature. This should be called after integration sets the bounds.
 594        
 595        Parameters
 596        ----------
 597        mf_dict : dict, optional
 598            Dictionary of mass features to filter. If None, uses self.mass_features.
 599            
 600        Returns
 601        -------
 602        int
 603            Number of MS2 scans removed across all mass features.
 604        """
 605        if mf_dict is None:
 606            mf_dict = self.mass_features
 607        
 608        total_removed = 0
 609        
 610        for mf_id, mf in mf_dict.items():
 611            # Only filter if integration bounds are set and MS2 scans exist
 612            if (hasattr(mf, 'start_scan') and hasattr(mf, 'final_scan') and 
 613                mf.start_scan is not None and mf.final_scan is not None and
 614                mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0):
 615                
 616                # Filter scan numbers to only those within bounds
 617                original_count = len(mf.ms2_scan_numbers)
 618                mf.ms2_scan_numbers = [
 619                    scan for scan in mf.ms2_scan_numbers 
 620                    if mf.start_scan <= scan <= mf.final_scan
 621                ]
 622                removed = original_count - len(mf.ms2_scan_numbers)
 623                total_removed += removed
 624        
 625        return total_removed
 626    
 627    def _find_ms2_scans_for_mass_features(self, mf_ids=None, scan_filter=None):
 628        """Find MS2 scans associated with mass features.
 629        
 630        This helper method finds MS2 scans that match mass features based on RT and m/z tolerances.
 631        It updates the ms2_scan_numbers attribute on each mass feature.
 632        
 633        Parameters
 634        ----------
 635        mf_ids : list of int, optional
 636            List of mass feature IDs to find MS2 for. If None, finds for all mass features.
 637        scan_filter : str, optional
 638            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
 639            
 640        Returns
 641        -------
 642        list
 643            List of unique MS2 scan numbers found across all mass features.
 644            
 645        Raises
 646        ------
 647        ValueError
 648            If no MS2 scans are found in the dataset.
 649        """
 650        # Get mass features to process
 651        if mf_ids is None:
 652            mf_ids = list(self.mass_features.keys())
 653        
 654        # Get mass features dataframe
 655        mf_df = self.mass_features_to_df()
 656        mf_df = mf_df.loc[mf_ids].copy()
 657        
 658        # Find ms2 scans that have a precursor m/z value
 659        ms2_scans = self.scan_df[self.scan_df.ms_level == 2]
 660        ms2_scans = ms2_scans[~ms2_scans.precursor_mz.isna()]
 661        ms2_scans = ms2_scans[ms2_scans.tic > 0]
 662        
 663        if len(ms2_scans) == 0:
 664            raise ValueError("No DDA scans found in dataset")
 665        
 666        if scan_filter is not None:
 667            ms2_scans = ms2_scans[ms2_scans.scan_text.str.contains(scan_filter)]
 668        
 669        # Get tolerances from parameters
 670        time_tol = self.parameters.lc_ms.ms2_dda_rt_tolerance
 671        mz_tol = self.parameters.lc_ms.ms2_dda_mz_tolerance
 672        
 673        # For each mass feature, find the ms2 scans that are within the roi scan time and mz range
 674        dda_scans = []
 675        for i, row in mf_df.iterrows():
 676            ms2_scans_filtered = ms2_scans[
 677                ms2_scans.scan_time.between(
 678                    row.scan_time - time_tol, row.scan_time + time_tol
 679                )
 680            ]
 681            ms2_scans_filtered = ms2_scans_filtered[
 682                ms2_scans_filtered.precursor_mz.between(
 683                    row.mz - mz_tol, row.mz + mz_tol
 684                )
 685            ]
 686            scan_list = ms2_scans_filtered.scan.tolist()
 687            if scan_list:
 688                # Filter scans by integration bounds if they exist
 689                mf = self.mass_features[i]
 690                if (hasattr(mf, 'start_scan') and hasattr(mf, 'final_scan') and 
 691                    mf.start_scan is not None and mf.final_scan is not None):
 692                    # Only keep scans within integration bounds
 693                    scan_list = [s for s in scan_list if mf.start_scan <= s <= mf.final_scan]
 694                
 695                if scan_list:  # Only add if there are still scans after filtering
 696                    self.mass_features[i].ms2_scan_numbers = (
 697                        scan_list + list(self.mass_features[i].ms2_scan_numbers)
 698                    )
 699                    dda_scans.extend(scan_list)
 700        
 701        return list(set(dda_scans))
 702    
 703    def add_associated_ms2_dda(
 704        self,
 705        auto_process=True,
 706        use_parser=True,
 707        spectrum_mode=None,
 708        ms_params_key="ms2",
 709        scan_filter=None,
 710    ):
 711        """Add MS2 spectra associated with mass features to the dataset.
 712
 713        Populates the mass_features ms2_scan_numbers attribute (on mass_features dictionary on LCMSObject)
 714
 715        Parameters
 716        -----------
 717        auto_process : bool, optional
 718            If True, auto-processes the MS2 spectra before adding it to the object's _ms dictionary. Default is True.
 719        use_parser : bool, optional
 720            If True, envoke the spectra parser to get the MS2 spectra. Default is True.
 721        spectrum_mode : str or None, optional
 722            The spectrum mode to use for the mass spectra.  If None, method will use the spectrum mode
 723            from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
 724            Defaults to None. (faster if defined, otherwise will check each scan)
 725        ms_params_key : string, optional
 726            The key of the mass spectrum parameters to use for the mass spectra, accessed from the LCMSObject.parameters.mass_spectrum attribute.
 727            Defaults to 'ms2'.
 728        scan_filter : str
 729            A string to filter the scans to add to the _ms dictionary.  If None, all scans are added.  Defaults to None.
 730            "hcd" will pull out only HCD scans.
 731
 732        Raises
 733        ------
 734        ValueError
 735            If mass_features is not set, must run find_mass_features() first.
 736            If no MS2 scans are found in the dataset.
 737            If no precursor m/z values are found in MS2 scans, not a DDA dataset.
 738        """
 739        # Check if mass_features is set, raise error if not
 740        if self.mass_features is None:
 741            raise ValueError(
 742                "mass_features not set, must run find_mass_features() first"
 743            )
 744
 745        # reconfigure ms_params to get the correct mass spectrum parameters from the key
 746        ms_params = self.parameters.mass_spectrum[ms_params_key]
 747
 748        # Find MS2 scans for all mass features
 749        dda_scans = self._find_ms2_scans_for_mass_features(scan_filter=scan_filter)
 750        
 751        # Load MS2 spectra
 752        self.add_mass_spectra(
 753            scan_list=dda_scans,
 754            auto_process=auto_process,
 755            spectrum_mode=spectrum_mode,
 756            use_parser=use_parser,
 757            ms_params=ms_params,
 758        )
 759        
 760        # Associate appropriate _ms attribute to appropriate mass feature's ms2_mass_spectra attribute
 761        for mf_id in self.mass_features:
 762            if self.mass_features[mf_id].ms2_scan_numbers is not None:
 763                for dda_scan in self.mass_features[mf_id].ms2_scan_numbers:
 764                    if dda_scan in self._ms.keys():
 765                        self.mass_features[mf_id].ms2_mass_spectra[dda_scan] = self._ms[
 766                            dda_scan
 767                        ]
 768
 769    def add_associated_ms1(
 770        self, auto_process=True, use_parser=True, spectrum_mode=None, induced_features=False
 771    ):
 772        """Add MS1 spectra associated with mass features to the dataset.
 773
 774        Parameters
 775        -----------
 776        auto_process : bool, optional
 777            If True, auto-processes the MS1 spectra before adding it to the object's _ms dictionary. Default is True.
 778        use_parser : bool, optional
 779            If True, envoke the spectra parser to get the MS1 spectra. Default is True.
 780        spectrum_mode : str or None, optional
 781            The spectrum mode to use for the mass spectra.  If None, method will use the spectrum mode
 782            from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
 783            Defaults to None. (faster if defined, otherwise will check each scan)
 784        induced_features : bool, optional
 785            If True, add associated MS1 of the induced mass features instead of the primary mass features
 786
 787        Raises
 788        ------
 789        ValueError
 790            If mass_features is not set, must run find_mass_features() first.
 791            If apex scans are not profile mode, all apex scans must be profile mode for averaging.
 792            If number of scans to average is not  1 or an integer with an integer median (i.e. 3, 5, 7, 9).
 793            If deconvolute is True and no EICs are found, did you run integrate_mass_features() first?
 794        """
 795        # Check if mass_features is set, raise error if not
 796        if self.mass_features is None:
 797            raise ValueError(
 798                "mass_features not set, must run find_mass_features() first"
 799            )
 800            
 801        if induced_features:
 802            mf_dict = self.induced_mass_features
 803        else:
 804            mf_dict = self.mass_features
 805
 806        scans_to_average = self.parameters.lc_ms.ms1_scans_to_average
 807        
 808        ## sketchy work around for induced mass features
 809        scan_list = [
 810            int(mf_dict[x].apex_scan) for x in mf_dict if int(mf_dict[x].apex_scan) != -99
 811        ]
 812
 813        if scans_to_average == 1:
 814            # Add to LCMSobj
 815            self.add_mass_spectra(
 816                scan_list = scan_list,
 817                auto_process=auto_process,
 818                use_parser=use_parser,
 819                spectrum_mode=spectrum_mode,
 820                ms_params=self.parameters.mass_spectrum["ms1"],
 821            )
 822
 823        elif (
 824            (scans_to_average - 1) % 2
 825        ) == 0:  # scans_to_average = 3, 5, 7 etc, mirror l/r around apex
 826            apex_scans = list(set(scan_list))
 827            # Check if all apex scans are profile mode, raise error if not
 828            if not all(self.scan_df.loc[apex_scans, "ms_format"] == "profile"):
 829                raise ValueError("All apex scans must be profile mode for averaging")
 830
 831            # First get sets of scans to average
 832            def get_scans_from_apex(ms1_scans, apex_scan, scans_to_average):
 833                ms1_idx_start = ms1_scans.index(apex_scan) - int(
 834                    (scans_to_average - 1) / 2
 835                )
 836                if ms1_idx_start < 0:
 837                    ms1_idx_start = 0
 838                ms1_idx_end = (
 839                    ms1_scans.index(apex_scan) + int((scans_to_average - 1) / 2) + 1
 840                )
 841                if ms1_idx_end > (len(ms1_scans) - 1):
 842                    ms1_idx_end = len(ms1_scans) - 1
 843                scan_list = ms1_scans[ms1_idx_start:ms1_idx_end]
 844                return scan_list
 845
 846            ms1_scans = self.ms1_scans
 847            scans_lists = [
 848                get_scans_from_apex(ms1_scans, apex_scan, scans_to_average)
 849                for apex_scan in apex_scans
 850            ]
 851
 852            # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
 853            if self.polarity == "negative":
 854                polarity = -1
 855            elif self.polarity == "positive":
 856                polarity = 1
 857
 858            if not use_parser:
 859                # Perform checks and prepare _ms_unprocessed dictionary if use_parser is False (saves time to do this once)
 860                ms1_unprocessed = self._ms_unprocessed[1].copy()
 861                # Set the index on _ms_unprocessed[1] to scan number
 862                ms1_unprocessed = ms1_unprocessed.set_index("scan", drop=False)
 863                self._ms_unprocessed[1] = ms1_unprocessed
 864
 865                # Check that all the scans in scan_lists are indexs in self._ms_unprocessed[1]
 866                scans_lists_flat = list(
 867                    set([scan for sublist in scans_lists for scan in sublist])
 868                )
 869                if (
 870                    len(
 871                        np.setdiff1d(
 872                            np.sort(scans_lists_flat),
 873                            np.sort(ms1_unprocessed.index.values),
 874                        )
 875                    )
 876                    > 0
 877                ):
 878                    raise ValueError(
 879                        "Not all scans to average are present in the unprocessed data"
 880                    )
 881
 882            for scan_list_average, apex_scan in zip(scans_lists, apex_scans):
 883                # Get unprocessed mass spectrum from scans
 884                ms = self.get_average_mass_spectrum(
 885                    scan_list=scan_list_average,
 886                    apex_scan=apex_scan,
 887                    spectrum_mode="profile",
 888                    ms_level=1,
 889                    auto_process=auto_process,
 890                    use_parser=use_parser,
 891                    perform_checks=False,
 892                    polarity=polarity,
 893                    ms_params=self.parameters.mass_spectrum["ms1"],
 894                )
 895                # Add mass spectrum to LCMS object and associated with mass feature
 896                self.add_mass_spectrum(ms)
 897
 898            if not use_parser:
 899                # Reset the index on _ms_unprocessed[1] to not be scan number
 900                ms1_unprocessed = ms1_unprocessed.reset_index(drop=True)
 901                self._ms_unprocessed[1] = ms1_unprocessed
 902        else:
 903            raise ValueError(
 904                "Number of scans to average must be 1 or an integer with an integer median (i.e. 3, 5, 7, 9)"
 905            )
 906
 907        # Associate the ms1 spectra with the mass features
 908        for k in mf_dict.keys():
 909            ## another induced feature work around
 910            if mf_dict[k].apex_scan != -99:
 911                mf_dict[k].mass_spectrum = self._ms[
 912                    mf_dict[k].apex_scan
 913                ]
 914                mf_dict[k].update_mz()
 915
 916    def mass_features_to_df(self, induced_features=False, drop_na_cols=False, include_cols=None):
 917        """Returns a pandas dataframe summarizing the mass features.
 918
 919        The dataframe contains the following columns: mf_id, mz, apex_scan, scan_time, intensity,
 920        persistence, area, monoisotopic_mf_id, and isotopologue_type.  The index is set to mf_id (mass feature ID).
 921        Parameters
 922        -----------
 923        induced_features : bool, optional
 924            If True, calls the induced_mass_features dictionary. Defaults to False.
 925        drop_na_cols : bool, optional
 926            If True, drops columns that are entirely NA. Defaults to False.
 927        include_cols : list of str, optional
 928            If provided, only includes the specified columns in the output (in addition to 'mf_id' which is always included as the index).
 929            If None, includes all available columns. Defaults to None.
 930
 931        Raises
 932        --------
 933        ValueError
 934            If the sample provided doesn't contain the mass feature data.        
 935
 936        Returns
 937        --------
 938        pandas.DataFrame
 939            A pandas dataframe of mass features with the following columns:
 940            mf_id, mz, apex_scan, scan_time, intensity, persistence, area.
 941        """
 942        import pandas as pd
 943
 944        def mass_spectrum_to_string(
 945            mass_spec, normalize=True, min_normalized_abun=0.01
 946        ):
 947            """Converts a mass spectrum to a string of m/z:abundance pairs.
 948
 949            Parameters
 950            -----------
 951            mass_spec : MassSpectrum
 952                A MassSpectrum object to be converted to a string.
 953            normalize : bool, optional
 954                If True, normalizes the abundance values to a maximum of 1. Defaults to True.
 955            min_normalized_abun : float, optional
 956                The minimum normalized abundance value to include in the string, only used if normalize is True. Defaults to 0.01.
 957
 958            Returns
 959            --------
 960            str
 961                A string of m/z:abundance pairs from the mass spectrum, separated by a semicolon.
 962            """
 963            mz_np = mass_spec.to_dataframe()["m/z"].values
 964            abun_np = mass_spec.to_dataframe()["Peak Height"].values
 965            if normalize:
 966                abun_np = abun_np / abun_np.max()
 967            mz_abun = np.column_stack((mz_np, abun_np))
 968            if normalize:
 969                mz_abun = mz_abun[mz_abun[:, 1] > min_normalized_abun]
 970            mz_abun_str = [
 971                str(round(mz, ndigits=4)) + ":" + str(round(abun, ndigits=2))
 972                for mz, abun in mz_abun
 973            ]
 974            return "; ".join(mz_abun_str)
 975
 976        if induced_features:
 977            mf_dict = self.induced_mass_features
 978        else:
 979            mf_dict = self.mass_features
 980        
 981        if len(mf_dict) == 0:
 982            # Return an empty dataframe with the expected structure
 983            # This allows collection processing to continue even if some samples have no features
 984            return pd.DataFrame()
 985            
 986        cols_in_df = [
 987            "id",
 988            "apex_scan",
 989            "start_scan",
 990            "final_scan",
 991            "retention_time",
 992            "intensity",
 993            "persistence",
 994            "area",
 995            "dispersity_index",
 996            "normalized_dispersity_index",
 997            "tailing_factor",
 998            "gaussian_similarity",
 999            "noise_score",
1000            "noise_score_min",
1001            "noise_score_max",
1002            "monoisotopic_mf_id",
1003            "isotopologue_type",
1004            "mass_spectrum_deconvoluted_parent",
1005            "ms2_scan_numbers",
1006            "type"
1007        ]
1008
1009        df_mf_list = []
1010        for mf_id in mf_dict.keys():
1011            # Find cols_in_df that are in single_mf
1012            df_keys = list(
1013                set(cols_in_df).intersection(mf_dict[mf_id].__dir__())
1014            )
1015            dict_mf = {}
1016            # Get the values for each key in df_keys from the mass feature object
1017            for key in df_keys:
1018                value = getattr(mf_dict[mf_id], key)
1019                # Wrap list/array values in a list so pandas treats them as single cell values
1020                if key == 'ms2_scan_numbers' and isinstance(value, (list, np.ndarray)):
1021                    dict_mf[key] = [value]
1022                else:
1023                    dict_mf[key] = value
1024            if len(mf_dict[mf_id].ms2_scan_numbers) > 0:
1025                # Add MS2 spectra info
1026                best_ms2_spectrum = mf_dict[mf_id].best_ms2
1027                if best_ms2_spectrum is not None:
1028                    dict_mf["ms2_spectrum"] = mass_spectrum_to_string(best_ms2_spectrum)
1029            if len(mf_dict[mf_id].associated_mass_features_deconvoluted) > 0:
1030                dict_mf["associated_mass_features"] = ", ".join(
1031                    map(
1032                        str,
1033                        mf_dict[mf_id].associated_mass_features_deconvoluted,
1034                    )
1035                )
1036            if mf_dict[mf_id]._half_height_width is not None:
1037                dict_mf["half_height_width"] = mf_dict[
1038                    mf_id
1039                ].half_height_width
1040            # Check if EIC for mass feature is set
1041            df_mf_single = pd.DataFrame(dict_mf, index=[mf_id])
1042            df_mf_single["mz"] = mf_dict[mf_id].mz
1043            df_mf_list.append(df_mf_single)
1044        df_mf = pd.concat(df_mf_list)
1045
1046        # rename _area to area and id to mf_id
1047        df_mf = df_mf.rename(
1048            columns={
1049                "id": "mf_id",
1050                "retention_time": "scan_time",            
1051            }
1052        )
1053
1054        # reorder columns
1055        col_order = [
1056            "mf_id",
1057            "type",
1058            "scan_time",
1059            "mz",
1060            "apex_scan",
1061            "start_scan",
1062            "final_scan",
1063            "intensity",
1064            "persistence",
1065            "area",
1066            "half_height_width",
1067            "tailing_factor",
1068            "dispersity_index",
1069            "normalized_dispersity_index",
1070            "gaussian_similarity",
1071            "noise_score",
1072            "noise_score_min",
1073            "noise_score_max",
1074            "monoisotopic_mf_id",
1075            "isotopologue_type",
1076            "mass_spectrum_deconvoluted_parent",
1077            "associated_mass_features",
1078            "ms2_scan_numbers",
1079            "ms2_spectrum",
1080        ]
1081        # drop columns that are not in col_order
1082        cols_to_order = [col for col in col_order if col in df_mf.columns]
1083        df_mf = df_mf[cols_to_order]
1084
1085        # reset index to mf_id
1086        df_mf = df_mf.set_index("mf_id")
1087        df_mf.index.name = "mf_id"
1088        
1089        if 'half_height_width' in df_mf.columns:
1090            df_mf["half_height_width"] = df_mf["half_height_width"].astype('float64')
1091        if 'tailing_factor' in df_mf.columns:
1092            df_mf["tailing_factor"] = df_mf["tailing_factor"].astype('float64')
1093        if 'dispersity_index' in df_mf.columns:
1094            df_mf["dispersity_index"] = df_mf["dispersity_index"].astype('float64')
1095        if 'normalized_dispersity_index' in df_mf.columns:
1096            df_mf["normalized_dispersity_index"] = df_mf["normalized_dispersity_index"].astype('float64')
1097        
1098        # Filter columns if include_cols is specified
1099        if include_cols is not None:
1100            # Ensure include_cols is a list
1101            if not isinstance(include_cols, list):
1102                raise ValueError("include_cols must be a list of column names")
1103            # Keep only requested columns that exist in the dataframe
1104            available_cols = [col for col in include_cols if col in df_mf.columns]
1105            df_mf = df_mf[available_cols]
1106        
1107        # Drop columns that are entirely NA if requested
1108        if drop_na_cols:
1109            df_mf = df_mf.dropna(axis=1, how='all')
1110        
1111        return df_mf
1112
1113    def mass_features_ms1_annot_to_df(self, suppress_warnings=False):
1114        """Returns a pandas dataframe summarizing the MS1 annotations for the mass features in the dataset.
1115
1116        Parameters
1117        -----------
1118        suppress_warnings : bool, optional
1119            If True, suppresses the warning when no MS1 annotations are found.
1120            Useful when calling from collection-level methods. Default is False.
1121
1122        Returns
1123        --------
1124        pandas.DataFrame
1125            A pandas dataframe of MS1 annotations for the mass features in the dataset.
1126            The index is set to mf_id (mass feature ID)
1127
1128        Raises
1129        ------
1130        Warning
1131            If no MS1 annotations were found for the mass features in the dataset
1132            (unless suppress_warnings=True).
1133        """
1134        annot_df_list_ms1 = []
1135        for mf_id in self.mass_features.keys():
1136            if self.mass_features[mf_id].mass_spectrum is None:
1137                pass
1138            else:
1139                # Add ms1 annotations to ms1 annotation list
1140                if (
1141                    np.abs(
1142                        (
1143                            self.mass_features[mf_id].ms1_peak.mz_exp
1144                            - self.mass_features[mf_id].mz
1145                        )
1146                    )
1147                    < 0.01
1148                ):
1149                    # Get the molecular formula from the mass spectrum
1150                    annot_df = self.mass_features[mf_id].mass_spectrum.to_dataframe()
1151                    # Subset to pull out only the peak associated with the mass feature
1152                    annot_df = annot_df[
1153                        annot_df["Index"] == self.mass_features[mf_id].ms1_peak.index
1154                    ].copy()
1155
1156                    # If there are more than 1 row, remove any rows without a molecular formula
1157                    if len(annot_df) > 1:
1158                        annot_df = annot_df[~annot_df["Molecular Formula"].isna()]
1159
1160                    # Remove the index column and add column for mf_id
1161                    annot_df = annot_df.drop(columns=["Index"])
1162                    annot_df["mf_id"] = mf_id
1163                    annot_df_list_ms1.append(annot_df)
1164
1165        if len(annot_df_list_ms1) > 0:
1166            annot_ms1_df_full = pd.concat(annot_df_list_ms1)
1167            annot_ms1_df_full = annot_ms1_df_full.set_index("mf_id")
1168            annot_ms1_df_full.index.name = "mf_id"
1169
1170        else:
1171            annot_ms1_df_full = None
1172            # Warn that no ms1 annotations were found (unless suppressed)
1173            if not suppress_warnings:
1174                warnings.warn(
1175                    "No MS1 annotations found for mass features in dataset, were MS1 spectra added and processed within the dataset?",
1176                    UserWarning,
1177                )
1178
1179        return annot_ms1_df_full
1180
1181    def mass_features_ms2_annot_to_df(self, molecular_metadata=None, suppress_warnings=False):
1182        """Returns a pandas dataframe summarizing the MS2 annotations for the mass features in the dataset.
1183
1184        Parameters
1185        -----------
1186        molecular_metadata :  dict of MolecularMetadata objects
1187            A dictionary of MolecularMetadata objects, keyed by ref_mol_id. Defaults to None.
1188        suppress_warnings : bool, optional
1189            If True, suppresses the warning when no MS2 annotations are found.
1190            Useful when calling from collection-level methods. Default is False.
1191
1192        Returns
1193        --------
1194        pandas.DataFrame
1195            A pandas dataframe of MS2 annotations for the mass features in the dataset,
1196            and optionally molecular metadata. The index is set to mf_id (mass feature ID)
1197
1198        Raises
1199        ------
1200        Warning
1201            If no MS2 annotations were found for the mass features in the dataset
1202            (unless suppress_warnings=True).
1203        """
1204        annot_df_list_ms2 = []
1205        for mf_id in self.mass_features.keys():
1206            if len(self.mass_features[mf_id].ms2_similarity_results) > 0:
1207                # Add ms2 annotations to ms2 annotation list
1208                for result in self.mass_features[mf_id].ms2_similarity_results:
1209                    annot_df_ms2 = result.to_dataframe()
1210                    annot_df_ms2["mf_id"] = mf_id
1211                    annot_df_list_ms2.append(annot_df_ms2)
1212
1213        if len(annot_df_list_ms2) > 0:
1214            annot_ms2_df_full = pd.concat(annot_df_list_ms2)
1215            if molecular_metadata is not None:
1216                molecular_metadata_df = pd.concat(
1217                    [
1218                        pd.DataFrame.from_dict(v.__dict__, orient="index").transpose()
1219                        for k, v in molecular_metadata.items()
1220                    ],
1221                    ignore_index=True,
1222                )
1223                molecular_metadata_df = molecular_metadata_df.rename(
1224                    columns={"id": "ref_mol_id"}
1225                )
1226                annot_ms2_df_full = annot_ms2_df_full.merge(
1227                    molecular_metadata_df, on="ref_mol_id", how="left"
1228                )
1229            annot_ms2_df_full = annot_ms2_df_full.drop_duplicates(
1230                subset=["mf_id", "query_spectrum_id", "ref_ms_id"]
1231            ).copy()
1232            annot_ms2_df_full = annot_ms2_df_full.set_index("mf_id")
1233            annot_ms2_df_full.index.name = "mf_id"
1234        else:
1235            annot_ms2_df_full = None
1236            # Warn that no ms2 annotations were found (unless suppressed)
1237            if not suppress_warnings:
1238                warnings.warn(
1239                    "No MS2 annotations found for mass features in dataset, were MS2 spectra added and searched against a database?",
1240                    UserWarning,
1241                )
1242
1243        return annot_ms2_df_full
1244
1245    def plot_composite_mz_features(self, binsize = 1e-4, ph_int_min_thresh = 0.001, mf_plot = True, ms2_plot = True, return_fig = False):
1246        """Returns a figure displaying 
1247            (1) thresholded, unprocessed data
1248            (2) the m/z features
1249            (3) which m/z features are associated with MS2 spectra
1250
1251        Parameters
1252        -----------
1253        binsize :  float
1254            Desired binsize for the m/z axis of the composite feature map.  Defaults to 1e-4.
1255        mf_plot : boolean
1256            Indicates whether to plot the m/z features. Defaults to True.
1257        ms2_plot : boolean
1258            Indicates whether to identify m/z features with associated MS2 spectra. Defaults to True.
1259        return_fig : boolean
1260            Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False.
1261
1262        Returns
1263        --------
1264        matplotlib.pyplot.Figure
1265            A figure with the thresholded, unprocessed data on an axis of m/z value with respect to 
1266            scan time. Unprocessed data is displayed in gray scale with darker colors indicating 
1267            higher intensities. If m/z features are plotted, they are displayed in cyan. If m/z
1268            features with associated with MS2 spectra are plotted, they are displayed in red.
1269
1270        Raises
1271        ------
1272        Warning
1273            If m/z features are set to be plot but aren't in the dataset.
1274            If m/z features with associated MS2 data are set to be plot but no MS2 annotations 
1275            were found for the m/z features in the dataset.
1276        """
1277        if mf_plot:
1278            # Check if mass_features is set, raise error if not
1279            if self.mass_features is None:
1280                raise ValueError(
1281                    "mass_features not set, must run find_mass_features() first"
1282                )
1283            ## call mass feature data
1284            mf_df = self.mass_features_to_df()
1285
1286        if ms2_plot:
1287            if not mf_plot:
1288                # Check if mass_features is set, raise error if not
1289                if self.mass_features is None:
1290                    raise ValueError(
1291                        "mass_features not set, must run find_mass_features() first"
1292                    )
1293
1294            ## call m/z feature data
1295            mf_df = self.mass_features_to_df()
1296
1297            # Check if ms2_spectrum is set, raise error if not
1298            if 'ms2_spectrum' not in mf_df.columns:
1299                raise ValueError(                
1300                    "ms2_spectrum not set, must run add_associated_ms2_dda() first"            
1301                )
1302    
1303        ## threshold and grid unprocessed data
1304        df = self._ms_unprocessed[1].copy()
1305        df = df.dropna(subset=['intensity']).reset_index(drop = True)
1306        threshold = ph_int_min_thresh * df.intensity.max()
1307        df_thres = df[df["intensity"] > threshold].reset_index(drop = True).copy()
1308        df = self.grid_data(df_thres)
1309    
1310        ## format unprocessed data for plotting
1311        df = df.merge(self.scan_df[['scan', 'scan_time']], on = 'scan')
1312        mz_grid = np.arange(0, np.max(df.mz), binsize)
1313        mz_data = np.array(df.mz)
1314        df['mz_bin'] = find_closest(mz_grid, mz_data)
1315        df['ab_bin'] = df.groupby(['mz_bin', 'scan_time']).intensity.transform(sum)
1316        unproc_df = df[['scan_time', 'mz_bin', 'ab_bin']].drop_duplicates(ignore_index = True)
1317
1318        ## generate figure
1319        fig = plt.figure()
1320        plt.scatter(
1321            unproc_df.scan_time,
1322            unproc_df.mz_bin*binsize,
1323            c = unproc_df.ab_bin/np.max(unproc_df.ab_bin),
1324            alpha = unproc_df.ab_bin/np.max(unproc_df.ab_bin), 
1325            cmap = 'Greys_r',
1326            s = 1
1327        )
1328
1329        if mf_plot:
1330            if ms2_plot:
1331                plt.scatter(
1332                    mf_df[mf_df.ms2_spectrum.isna()].scan_time,
1333                    mf_df[mf_df.ms2_spectrum.isna()].mz,
1334                    c = 'c',
1335                    s = 4,
1336                    label = 'M/Z features without MS2'
1337                )
1338            else:
1339                plt.scatter(
1340                    mf_df.scan_time,
1341                    mf_df.mz,
1342                    c = 'c',
1343                    s = 4,
1344                    label = 'M/Z features'
1345                )
1346
1347        if ms2_plot: 
1348            plt.scatter(
1349                mf_df[~mf_df.ms2_spectrum.isna()].scan_time,
1350                mf_df[~mf_df.ms2_spectrum.isna()].mz,
1351                c = 'r',
1352                s = 2,
1353                label = 'M/Z features with MS2'
1354            )
1355
1356        if mf_plot == True or ms2_plot == True:
1357            plt.legend(loc = 'lower center', bbox_to_anchor = (0.5, -0.25), ncol = 2)
1358        plt.xlabel('Scan time')
1359        plt.ylabel('m/z')
1360        plt.ylim(0, np.ceil(np.max(df.mz)))
1361        plt.xlim(0, np.ceil(np.max(df.scan_time)))
1362        plt.title('Composite Feature Map')
1363
1364        if return_fig:
1365            plt.close(fig)
1366            return fig
1367
1368        else:
1369            plt.show()
1370            
1371    def search_for_targeted_mass_features_batch(
1372            self,
1373            ms1df,
1374            mz_mins,
1375            mz_maxs,
1376            st_mins,
1377            st_maxs,
1378            set_ids,
1379            obj_idx=0,
1380            st_aligned=False
1381            ):
1382        """
1383        Returns multiple LCMSMassFeatures from a specific sample within specific mass and time ranges.
1384        Vectorized batch version of search_for_targeted_mass_feature for improved performance.
1385
1386        Parameters
1387        -----------
1388        ms1df : pd.DataFrame
1389            Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample.
1390        mz_mins : np.ndarray
1391            Array of lower bounds of m/z values to use to find peaks.
1392        mz_maxs : np.ndarray
1393            Array of upper bounds of m/z values to use to find peaks.
1394        st_mins : np.ndarray
1395            Array of lower bounds of scan times to use to find peaks.
1396        st_maxs : np.ndarray
1397            Array of upper bounds of scan times to use to find peaks.
1398        set_ids : np.ndarray or list
1399            Array of strings used as IDs in LCMSMassFeatures.
1400        obj_idx : int
1401            Identifies index of sample in a collection. Defaults to 0.
1402        st_aligned : bool
1403            Whether to use scan_time_aligned or scan_time. Defaults to False.
1404
1405        Returns
1406        --------
1407        dict
1408            Dictionary mapping set_id to LCMSMassFeature objects.
1409
1410        Raises
1411        ------
1412        ValueError
1413            If appropriate scan time data is not contained in ms1df or if array lengths don't match.
1414        """
1415        # Validate inputs
1416        n_features = len(mz_mins)
1417        if not all(len(arr) == n_features for arr in [mz_maxs, st_mins, st_maxs, set_ids]):
1418            raise ValueError("All input arrays must have the same length")
1419
1420        # Validate scan time column
1421        time_col = 'scan_time_aligned' if st_aligned else 'scan_time'
1422        if time_col not in ms1df.columns:
1423            raise ValueError(f"{time_col} not contained in ms1df")
1424
1425        # Pre-extract columns for faster access
1426        mz_vals = ms1df.mz.values
1427        st_vals = ms1df[time_col].values
1428        scan_vals = ms1df.scan.values
1429        intensity_vals = ms1df.intensity.values
1430
1431        # Process all features
1432        results = {}
1433        for i in range(n_features):
1434            # Vectorized filtering
1435            mask = (
1436                (mz_vals >= mz_mins[i]) & (mz_vals <= mz_maxs[i]) &
1437                (st_vals >= st_mins[i]) & (st_vals <= st_maxs[i])
1438            )
1439            
1440            if not mask.any():
1441                row_dict = {
1442                    'apex_scan': -99,
1443                    'mz': np.nan,
1444                    'intensity': np.nan,
1445                    'retention_time': np.nan,
1446                    'persistence': np.nan,
1447                    'id': set_ids[i]
1448                }
1449            else:
1450                # Find max intensity within filtered region
1451                filtered_intensities = intensity_vals[mask]
1452                max_idx = np.argmax(filtered_intensities)
1453                
1454                # Get indices of filtered data
1455                filtered_indices = np.where(mask)[0]
1456                peak_idx = filtered_indices[max_idx]
1457                
1458                row_dict = {
1459                    'apex_scan': scan_vals[peak_idx],
1460                    'mz': mz_vals[peak_idx],
1461                    'intensity': intensity_vals[peak_idx],
1462                    'retention_time': st_vals[peak_idx],
1463                    'persistence': np.nan,
1464                    'id': set_ids[i]
1465                }
1466
1467            results[set_ids[i]] = LCMSMassFeature(self, **row_dict)
1468
1469        return results
1470
1471    def search_for_targeted_mass_feature(
1472            self,
1473            ms1df, 
1474            mz_min,
1475            mz_max, 
1476            st_min, 
1477            st_max,
1478            set_id,
1479            obj_idx = 0,
1480            st_aligned = False
1481            ):
1482        """
1483        Returns an LCMSMassFeature from a specific sample within a specific mass and time range. Returns an empty
1484        LCMSMassFeature if no satisfactory peak is found in the given window.
1485
1486        Parameters
1487        -----------
1488        ms1df :  Pandas DataFrame
1489            Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample.
1490        mz_min : float
1491            Identifies lower bound of the weights to use to find a peak.
1492        mz_max : float
1493            Identifies upper bound of the weights to use to find a peak.
1494        st_min : float
1495            Identifies lower bound of the scan times to use to find a peak.
1496        st_max : float
1497            Identifies upper bound of the scan times to use to find a peak.
1498        set_id : str
1499            Indicates string used as ID in LCMSMassFeature.
1500        obj_idx : int
1501            Identifies index of sample in a collection that LCMSMassFeature should be assigned to. Defaults to 0 and is not used
1502            if data provided is an LCMSBase instead of an LCMSCollection.
1503        st_aligned : boolean
1504            Indicates whether to call scan time from scan_time or from scan_time_aligned if using a collection. Defaults to False.
1505
1506        Returns
1507        --------
1508        LCMSMassFeature
1509            Object from ChromaPeak that contains data on selected MS1 peak. If no peak is found, will contain missing 
1510            information and list the apex scan value as -99.
1511
1512        Raises
1513        ------
1514        Warning
1515            If appropriate scan time data is not contained in ms1df.
1516        """
1517        # Convert single feature to arrays and call batch method
1518        results = self.search_for_targeted_mass_features_batch(
1519            ms1df,
1520            np.array([mz_min]),
1521            np.array([mz_max]),
1522            np.array([st_min]),
1523            np.array([st_max]),
1524            [set_id],
1525            obj_idx=obj_idx,
1526            st_aligned=st_aligned
1527        )
1528        return results[set_id]
1529
1530
1531    def __len__(self):
1532        """
1533        Returns the number of mass spectra in the dataset.
1534
1535        Returns
1536        --------
1537        int
1538            The number of mass spectra in the dataset.
1539        """
1540        return len(self._ms)
1541
1542    def __getitem__(self, scan_number):
1543        """
1544        Returns the mass spectrum corresponding to the specified scan number.
1545
1546        Parameters
1547        -----------
1548        scan_number : int
1549            The scan number of the desired mass spectrum.
1550
1551        Returns
1552        --------
1553        MassSpectrum
1554            The mass spectrum corresponding to the specified scan number.
1555        """
1556        return self._ms.get(scan_number)
1557
1558    def __iter__(self):
1559        """Returns an iterator over the mass spectra in the dataset.
1560
1561        Returns
1562        --------
1563        iterator
1564            An iterator over the mass spectra in the dataset.
1565        """
1566        return iter(self._ms.values())
1567
1568    def set_tic_list_from_data(self, overwrite=False):
1569        """Sets the TIC list from the mass spectrum objects within the _ms dictionary.
1570
1571        Parameters
1572        -----------
1573        overwrite : bool, optional
1574            If True, overwrites the TIC list if it is already set. Defaults to False.
1575
1576        Notes
1577        -----
1578        If the _ms dictionary is incomplete, sets the TIC list to an empty list.
1579
1580        Raises
1581        ------
1582        ValueError
1583            If no mass spectra are found in the dataset.
1584            If the TIC list is already set and overwrite is False.
1585        """
1586        # Check if _ms is empty and raise error if so
1587        if len(self._ms) == 0:
1588            raise ValueError("No mass spectra found in dataset")
1589
1590        # Check if tic_list is already set and raise error if so
1591        if len(self.tic) > 0 and not overwrite:
1592            raise ValueError("TIC list already set, use overwrite=True to overwrite")
1593
1594        self.tic = [self._ms.get(i).tic for i in self.scans_number]
1595
1596    def set_retention_time_from_data(self, overwrite=False):
1597        """Sets the retention time list from the data in the _ms dictionary.
1598
1599        Parameters
1600        -----------
1601        overwrite : bool, optional
1602            If True, overwrites the retention time list if it is already set. Defaults to False.
1603
1604        Notes
1605        -----
1606        If the _ms dictionary is empty or incomplete, sets the retention time list to an empty list.
1607
1608        Raises
1609        ------
1610        ValueError
1611            If no mass spectra are found in the dataset.
1612            If the retention time list is already set and overwrite is False.
1613        """
1614        # Check if _ms is empty and raise error if so
1615        if len(self._ms) == 0:
1616            raise ValueError("No mass spectra found in dataset")
1617
1618        # Check if retention_time_list is already set and raise error if so
1619        if len(self.retention_time) > 0 and not overwrite:
1620            raise ValueError(
1621                "Retention time list already set, use overwrite=True to overwrite"
1622            )
1623
1624        retention_time_list = []
1625        for key_ms in sorted(self._ms.keys()):
1626            retention_time_list.append(self._ms.get(key_ms).retention_time)
1627        self.retention_time = retention_time_list
1628
1629    def set_scans_number_from_data(self, overwrite=False):
1630        """Sets the scan number list from the data in the _ms dictionary.
1631
1632        Notes
1633        -----
1634        If the _ms dictionary is empty or incomplete, sets the scan number list to an empty list.
1635
1636        Raises
1637        ------
1638        ValueError
1639            If no mass spectra are found in the dataset.
1640            If the scan number list is already set and overwrite is False.
1641        """
1642        # Check if _ms is empty and raise error if so
1643        if len(self._ms) == 0:
1644            raise ValueError("No mass spectra found in dataset")
1645
1646        # Check if scans_number_list is already set and raise error if so
1647        if len(self.scans_number) > 0 and not overwrite:
1648            raise ValueError(
1649                "Scan number list already set, use overwrite=True to overwrite"
1650            )
1651
1652        self.scans_number = sorted(self._ms.keys())
1653
1654    @property
1655    def ms1_scans(self):
1656        """
1657        list : A list of MS1 scan numbers for the dataset.
1658        """
1659        return self.scan_df[self.scan_df.ms_level == 1].index.tolist()
1660
1661    @property
1662    def parameters(self):
1663        """
1664        LCMSParameters : The parameters used for the LC-MS analysis.
1665        """
1666        return self._parameters
1667
1668    @parameters.setter
1669    def parameters(self, paramsinstance):
1670        """
1671        Sets the parameters used for the LC-MS analysis.
1672
1673        Parameters
1674        -----------
1675        paramsinstance : LCMSParameters
1676            The parameters used for the LC-MS analysis.
1677        """
1678        self._parameters = paramsinstance
1679
1680    @property
1681    def scans_number(self):
1682        """
1683        list : A list of scan numbers for the dataset.
1684        """
1685        return self._scans_number_list
1686
1687    @scans_number.setter
1688    def scans_number(self, scan_numbers_list):
1689        """
1690        Sets the scan numbers for the dataset.
1691
1692        Parameters
1693        -----------
1694        scan_numbers_list : list
1695            A list of scan numbers for the dataset.
1696        """
1697        self._scans_number_list = scan_numbers_list
1698
1699    @property
1700    def retention_time(self):
1701        """
1702        numpy.ndarray : An array of retention times for the dataset.
1703        """
1704        return self._retention_time_list
1705
1706    @retention_time.setter
1707    def retention_time(self, rt_list):
1708        """
1709        Sets the retention times for the dataset.
1710
1711        Parameters
1712        -----------
1713        rt_list : list
1714            A list of retention times for the dataset.
1715        """
1716        self._retention_time_list = np.array(rt_list)
1717
1718    @property
1719    def tic(self):
1720        """
1721        numpy.ndarray : An array of TIC values for the dataset.
1722        """
1723        return self._tic_list
1724
1725    @tic.setter
1726    def tic(self, tic_list):
1727        """
1728        Sets the TIC values for the dataset.
1729
1730        Parameters
1731        -----------
1732        tic_list : list
1733            A list of TIC values for the dataset.
1734        """
1735        self._tic_list = np.array(tic_list)

A class representing a liquid chromatography-mass spectrometry (LC-MS) data object.

This class is not intended to be instantiated directly, but rather to be instantiated by an appropriate mass spectra parser using the get_lcms_obj() method.

Parameters
  • file_location (str or Path): The location of the file containing the mass spectra data.
  • analyzer (str, optional): The type of analyzer used to generate the mass spectra data. Defaults to 'Unknown'.
  • instrument_label (str, optional): The type of instrument used to generate the mass spectra data. Defaults to 'Unknown'.
  • sample_name (str, optional): The name of the sample; defaults to the file name if not provided to the parser. Defaults to None.
  • spectra_parser (object, optional): The spectra parser object used to create the mass spectra object. Defaults to None.
Attributes
  • polarity (str): The polarity of the ionization mode used for the dataset.
  • _parameters (LCMSParameters): The parameters used for all methods called on the LCMSBase object. Set upon instantiation from LCMSParameters.
  • _retention_time_list (numpy.ndarray): An array of retention times for the dataset.
  • _scans_number_list (list): A list of scan numbers for the dataset.
  • _tic_list (numpy.ndarray): An array of total ion current (TIC) values for the dataset.
  • eics (dict): A dictionary containing extracted ion chromatograms (EICs) for the dataset. Key is the mz of the EIC. Initialized as an empty dictionary.
  • mass_features (dictionary of LCMSMassFeature objects): A dictionary containing mass features for the dataset. Key is mass feature ID. Initialized as an empty dictionary.
  • induced_mass_features (dictionary of LCMSMassFeature objects): A dictionary containing mass features from a collection that don't satisfy criteria for initial mass features. Key is mass feature ID. Initialized as an empty dictionary.
  • missing_mass_features (pandas.DataFrame): A table of clusters in a given sample for which a mass feature was sought and not found
  • spectral_search_results (dictionary of MS2SearchResults objects): A dictionary containing spectral search results for the dataset. Key is scan number : precursor mz. Initialized as an empty dictionary.
Methods
  • get_parameters_json(). Returns the parameters used for the LC-MS analysis in JSON format.
  • add_associated_ms2_dda(add_to_lcmsobj=True, auto_process=True, use_parser=True) Adds which MS2 scans are associated with each mass feature to the mass_features dictionary and optionally adds the MS2 spectra to the _ms dictionary.
  • add_associated_ms1(add_to_lcmsobj=True, auto_process=True, use_parser=True) Adds the MS1 spectra associated with each mass feature to the mass_features dictionary and adds the MS1 spectra to the _ms dictionary.
  • mass_features_to_df() Returns a pandas dataframe summarizing the mass features in the dataset.
  • set_tic_list_from_data(overwrite=False) Sets the TIC list from the mass spectrum objects within the _ms dictionary.
  • set_retention_time_from_data(overwrite=False) Sets the retention time list from the data in the _ms dictionary.
  • set_scans_number_from_data(overwrite=False) Sets the scan number list from the data in the _ms dictionary.
  • plot_composite_mz_features(binsize = 1e-4, ph_int_min_thresh = 0.001, mf_plot = True, ms2_plot = True, return_fig = False) Generates plot of M/Z features comparing scan time vs M/Z value
  • search_for_targeted_mass_feature(ms1df: pd.DataFrame, sample: pd.Series, tol_flag = 0) Searches for mass features in specific M/Z and scan time windows that were missed by the persistent homology search
LCMSBase( file_location, analyzer='Unknown', instrument_label='Unknown', sample_name=None, spectra_parser=None)
466    def __init__(
467        self,
468        file_location,
469        analyzer="Unknown",
470        instrument_label="Unknown",
471        sample_name=None,
472        spectra_parser=None,
473    ):
474        super().__init__(
475            file_location, analyzer, instrument_label, sample_name, spectra_parser
476        )
477        self.polarity = ""
478        self._parameters = LCMSParameters()
479        self._retention_time_list = []
480        self._scans_number_list = []
481        self._tic_list = []
482        self.eics = {}
483        self.mass_features = {}
484        self.induced_mass_features = {}
485        self.spectral_search_results = {}
polarity
eics
mass_features
induced_mass_features
spectral_search_results
def get_eic_mz_for_mass_feature(self, mf_mz, tolerance=0.0001):
487    def get_eic_mz_for_mass_feature(self, mf_mz, tolerance=0.0001):
488        """Get the EIC dictionary key (m/z) that best matches a mass feature's m/z.
489        
490        Finds the closest EIC m/z key within the specified tolerance.
491        
492        Parameters
493        ----------
494        mf_mz : float
495            The m/z value of the mass feature to match.
496        tolerance : float, optional
497            Maximum m/z difference for matching. Default is 0.0001 Da.
498            
499        Returns
500        -------
501        float or None
502            The EIC dictionary key (m/z) of the closest matching EIC,
503            or None if no EIC is within tolerance.
504        """
505        if not hasattr(self, 'eics') or not self.eics:
506            return None
507        
508        best_eic_mz = None
509        best_diff = tolerance
510        for eic_mz in self.eics.keys():
511            diff = abs(mf_mz - eic_mz)
512            if diff < best_diff:
513                best_diff = diff
514                best_eic_mz = eic_mz
515        return best_eic_mz

Get the EIC dictionary key (m/z) that best matches a mass feature's m/z.

Finds the closest EIC m/z key within the specified tolerance.

Parameters
  • mf_mz (float): The m/z value of the mass feature to match.
  • tolerance (float, optional): Maximum m/z difference for matching. Default is 0.0001 Da.
Returns
  • float or None: The EIC dictionary key (m/z) of the closest matching EIC, or None if no EIC is within tolerance.
def associate_eics_with_mass_features(self, tolerance=0.0001, induced=False):
517    def associate_eics_with_mass_features(self, tolerance=0.0001, induced=False):
518        """Associate EICs with mass features using tolerance-based m/z matching.
519        
520        Associates EIC_Data objects from self.eics with mass features by finding
521        the closest EIC within the specified m/z tolerance. This is more robust
522        than exact matching which can fail due to floating point precision issues.
523        
524        Parameters
525        ----------
526        tolerance : float, optional
527            Maximum m/z difference for matching EICs to mass features. Default is 0.0001 Da.
528        induced : bool, optional
529            If True, associates EICs with induced_mass_features instead of mass_features.
530            Default is False.
531            
532        Notes
533        -----
534        For each mass feature, this method finds the EIC with the closest m/z value
535        within the tolerance window and assigns it to the mass feature's _eic_data attribute.
536        If multiple EICs are within tolerance, the one with the smallest m/z difference is chosen.
537        """
538        # Select which mass features dictionary to use
539        mf_dict = self.induced_mass_features if induced else self.mass_features
540        
541        # Use the _eic_mz attribute on each mass_feature to find the closest matching EIC
542        for idx in mf_dict.keys():
543            mf_mz = mf_dict[idx]._eic_mz
544            # Find closest EIC within tolerance
545            best_match = None
546            best_diff = tolerance
547            for eic_mz, eic_data in self.eics.items():
548                diff = abs(mf_mz - eic_mz)
549                if diff < best_diff:
550                    best_diff = diff
551                    best_match = eic_data
552            if best_match is not None:
553                mf_dict[idx]._eic_data = best_match

Associate EICs with mass features using tolerance-based m/z matching.

Associates EIC_Data objects from self.eics with mass features by finding the closest EIC within the specified m/z tolerance. This is more robust than exact matching which can fail due to floating point precision issues.

Parameters
  • tolerance (float, optional): Maximum m/z difference for matching EICs to mass features. Default is 0.0001 Da.
  • induced (bool, optional): If True, associates EICs with induced_mass_features instead of mass_features. Default is False.
Notes

For each mass feature, this method finds the EIC with the closest m/z value within the tolerance window and assigns it to the mass feature's _eic_data attribute. If multiple EICs are within tolerance, the one with the smallest m/z difference is chosen.

def get_parameters_json(self):
555    def get_parameters_json(self):
556        """Returns the parameters stored for the LC-MS object in JSON format.
557
558        Returns
559        --------
560        str
561            The parameters used for the LC-MS analysis in JSON format.
562        """
563        return self.parameters.to_json()

Returns the parameters stored for the LC-MS object in JSON format.

Returns
  • str: The parameters used for the LC-MS analysis in JSON format.
def remove_unprocessed_data(self, ms_level=None):
565    def remove_unprocessed_data(self, ms_level=None):
566        """Removes the unprocessed data from the LCMSBase object.
567
568        Parameters
569        -----------
570        ms_level : int, optional
571            The MS level to remove the unprocessed data for. If None, removes unprocessed data for all MS levels.
572
573        Raises
574        ------
575        ValueError
576            If ms_level is not 1 or 2.
577
578        Notes
579        -----
580        This method is useful for freeing up memory after the data has been processed.
581        """
582        if ms_level is None:
583            for ms_level in self._ms_unprocessed.keys():
584                self._ms_unprocessed[ms_level] = None
585        if ms_level not in [1, 2]:
586            raise ValueError("ms_level must be 1 or 2")
587        self._ms_unprocessed[ms_level] = None

Removes the unprocessed data from the LCMSBase object.

Parameters
  • ms_level (int, optional): The MS level to remove the unprocessed data for. If None, removes unprocessed data for all MS levels.
Raises
  • ValueError: If ms_level is not 1 or 2.
Notes

This method is useful for freeing up memory after the data has been processed.

def add_associated_ms2_dda( self, auto_process=True, use_parser=True, spectrum_mode=None, ms_params_key='ms2', scan_filter=None):
703    def add_associated_ms2_dda(
704        self,
705        auto_process=True,
706        use_parser=True,
707        spectrum_mode=None,
708        ms_params_key="ms2",
709        scan_filter=None,
710    ):
711        """Add MS2 spectra associated with mass features to the dataset.
712
713        Populates the mass_features ms2_scan_numbers attribute (on mass_features dictionary on LCMSObject)
714
715        Parameters
716        -----------
717        auto_process : bool, optional
718            If True, auto-processes the MS2 spectra before adding it to the object's _ms dictionary. Default is True.
719        use_parser : bool, optional
720            If True, envoke the spectra parser to get the MS2 spectra. Default is True.
721        spectrum_mode : str or None, optional
722            The spectrum mode to use for the mass spectra.  If None, method will use the spectrum mode
723            from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
724            Defaults to None. (faster if defined, otherwise will check each scan)
725        ms_params_key : string, optional
726            The key of the mass spectrum parameters to use for the mass spectra, accessed from the LCMSObject.parameters.mass_spectrum attribute.
727            Defaults to 'ms2'.
728        scan_filter : str
729            A string to filter the scans to add to the _ms dictionary.  If None, all scans are added.  Defaults to None.
730            "hcd" will pull out only HCD scans.
731
732        Raises
733        ------
734        ValueError
735            If mass_features is not set, must run find_mass_features() first.
736            If no MS2 scans are found in the dataset.
737            If no precursor m/z values are found in MS2 scans, not a DDA dataset.
738        """
739        # Check if mass_features is set, raise error if not
740        if self.mass_features is None:
741            raise ValueError(
742                "mass_features not set, must run find_mass_features() first"
743            )
744
745        # reconfigure ms_params to get the correct mass spectrum parameters from the key
746        ms_params = self.parameters.mass_spectrum[ms_params_key]
747
748        # Find MS2 scans for all mass features
749        dda_scans = self._find_ms2_scans_for_mass_features(scan_filter=scan_filter)
750        
751        # Load MS2 spectra
752        self.add_mass_spectra(
753            scan_list=dda_scans,
754            auto_process=auto_process,
755            spectrum_mode=spectrum_mode,
756            use_parser=use_parser,
757            ms_params=ms_params,
758        )
759        
760        # Associate appropriate _ms attribute to appropriate mass feature's ms2_mass_spectra attribute
761        for mf_id in self.mass_features:
762            if self.mass_features[mf_id].ms2_scan_numbers is not None:
763                for dda_scan in self.mass_features[mf_id].ms2_scan_numbers:
764                    if dda_scan in self._ms.keys():
765                        self.mass_features[mf_id].ms2_mass_spectra[dda_scan] = self._ms[
766                            dda_scan
767                        ]

Add MS2 spectra associated with mass features to the dataset.

Populates the mass_features ms2_scan_numbers attribute (on mass_features dictionary on LCMSObject)

Parameters
  • auto_process (bool, optional): If True, auto-processes the MS2 spectra before adding it to the object's _ms dictionary. Default is True.
  • use_parser (bool, optional): If True, envoke the spectra parser to get the MS2 spectra. Default is True.
  • spectrum_mode (str or None, optional): The spectrum mode to use for the mass spectra. If None, method will use the spectrum mode from the spectra parser to ascertain the spectrum mode (this allows for mixed types). Defaults to None. (faster if defined, otherwise will check each scan)
  • ms_params_key (string, optional): The key of the mass spectrum parameters to use for the mass spectra, accessed from the LCMSObject.parameters.mass_spectrum attribute. Defaults to 'ms2'.
  • scan_filter (str): A string to filter the scans to add to the _ms dictionary. If None, all scans are added. Defaults to None. "hcd" will pull out only HCD scans.
Raises
  • ValueError: If mass_features is not set, must run find_mass_features() first. If no MS2 scans are found in the dataset. If no precursor m/z values are found in MS2 scans, not a DDA dataset.
def add_associated_ms1( self, auto_process=True, use_parser=True, spectrum_mode=None, induced_features=False):
769    def add_associated_ms1(
770        self, auto_process=True, use_parser=True, spectrum_mode=None, induced_features=False
771    ):
772        """Add MS1 spectra associated with mass features to the dataset.
773
774        Parameters
775        -----------
776        auto_process : bool, optional
777            If True, auto-processes the MS1 spectra before adding it to the object's _ms dictionary. Default is True.
778        use_parser : bool, optional
779            If True, envoke the spectra parser to get the MS1 spectra. Default is True.
780        spectrum_mode : str or None, optional
781            The spectrum mode to use for the mass spectra.  If None, method will use the spectrum mode
782            from the spectra parser to ascertain the spectrum mode (this allows for mixed types).
783            Defaults to None. (faster if defined, otherwise will check each scan)
784        induced_features : bool, optional
785            If True, add associated MS1 of the induced mass features instead of the primary mass features
786
787        Raises
788        ------
789        ValueError
790            If mass_features is not set, must run find_mass_features() first.
791            If apex scans are not profile mode, all apex scans must be profile mode for averaging.
792            If number of scans to average is not  1 or an integer with an integer median (i.e. 3, 5, 7, 9).
793            If deconvolute is True and no EICs are found, did you run integrate_mass_features() first?
794        """
795        # Check if mass_features is set, raise error if not
796        if self.mass_features is None:
797            raise ValueError(
798                "mass_features not set, must run find_mass_features() first"
799            )
800            
801        if induced_features:
802            mf_dict = self.induced_mass_features
803        else:
804            mf_dict = self.mass_features
805
806        scans_to_average = self.parameters.lc_ms.ms1_scans_to_average
807        
808        ## sketchy work around for induced mass features
809        scan_list = [
810            int(mf_dict[x].apex_scan) for x in mf_dict if int(mf_dict[x].apex_scan) != -99
811        ]
812
813        if scans_to_average == 1:
814            # Add to LCMSobj
815            self.add_mass_spectra(
816                scan_list = scan_list,
817                auto_process=auto_process,
818                use_parser=use_parser,
819                spectrum_mode=spectrum_mode,
820                ms_params=self.parameters.mass_spectrum["ms1"],
821            )
822
823        elif (
824            (scans_to_average - 1) % 2
825        ) == 0:  # scans_to_average = 3, 5, 7 etc, mirror l/r around apex
826            apex_scans = list(set(scan_list))
827            # Check if all apex scans are profile mode, raise error if not
828            if not all(self.scan_df.loc[apex_scans, "ms_format"] == "profile"):
829                raise ValueError("All apex scans must be profile mode for averaging")
830
831            # First get sets of scans to average
832            def get_scans_from_apex(ms1_scans, apex_scan, scans_to_average):
833                ms1_idx_start = ms1_scans.index(apex_scan) - int(
834                    (scans_to_average - 1) / 2
835                )
836                if ms1_idx_start < 0:
837                    ms1_idx_start = 0
838                ms1_idx_end = (
839                    ms1_scans.index(apex_scan) + int((scans_to_average - 1) / 2) + 1
840                )
841                if ms1_idx_end > (len(ms1_scans) - 1):
842                    ms1_idx_end = len(ms1_scans) - 1
843                scan_list = ms1_scans[ms1_idx_start:ms1_idx_end]
844                return scan_list
845
846            ms1_scans = self.ms1_scans
847            scans_lists = [
848                get_scans_from_apex(ms1_scans, apex_scan, scans_to_average)
849                for apex_scan in apex_scans
850            ]
851
852            # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
853            if self.polarity == "negative":
854                polarity = -1
855            elif self.polarity == "positive":
856                polarity = 1
857
858            if not use_parser:
859                # Perform checks and prepare _ms_unprocessed dictionary if use_parser is False (saves time to do this once)
860                ms1_unprocessed = self._ms_unprocessed[1].copy()
861                # Set the index on _ms_unprocessed[1] to scan number
862                ms1_unprocessed = ms1_unprocessed.set_index("scan", drop=False)
863                self._ms_unprocessed[1] = ms1_unprocessed
864
865                # Check that all the scans in scan_lists are indexs in self._ms_unprocessed[1]
866                scans_lists_flat = list(
867                    set([scan for sublist in scans_lists for scan in sublist])
868                )
869                if (
870                    len(
871                        np.setdiff1d(
872                            np.sort(scans_lists_flat),
873                            np.sort(ms1_unprocessed.index.values),
874                        )
875                    )
876                    > 0
877                ):
878                    raise ValueError(
879                        "Not all scans to average are present in the unprocessed data"
880                    )
881
882            for scan_list_average, apex_scan in zip(scans_lists, apex_scans):
883                # Get unprocessed mass spectrum from scans
884                ms = self.get_average_mass_spectrum(
885                    scan_list=scan_list_average,
886                    apex_scan=apex_scan,
887                    spectrum_mode="profile",
888                    ms_level=1,
889                    auto_process=auto_process,
890                    use_parser=use_parser,
891                    perform_checks=False,
892                    polarity=polarity,
893                    ms_params=self.parameters.mass_spectrum["ms1"],
894                )
895                # Add mass spectrum to LCMS object and associated with mass feature
896                self.add_mass_spectrum(ms)
897
898            if not use_parser:
899                # Reset the index on _ms_unprocessed[1] to not be scan number
900                ms1_unprocessed = ms1_unprocessed.reset_index(drop=True)
901                self._ms_unprocessed[1] = ms1_unprocessed
902        else:
903            raise ValueError(
904                "Number of scans to average must be 1 or an integer with an integer median (i.e. 3, 5, 7, 9)"
905            )
906
907        # Associate the ms1 spectra with the mass features
908        for k in mf_dict.keys():
909            ## another induced feature work around
910            if mf_dict[k].apex_scan != -99:
911                mf_dict[k].mass_spectrum = self._ms[
912                    mf_dict[k].apex_scan
913                ]
914                mf_dict[k].update_mz()

Add MS1 spectra associated with mass features to the dataset.

Parameters
  • auto_process (bool, optional): If True, auto-processes the MS1 spectra before adding it to the object's _ms dictionary. Default is True.
  • use_parser (bool, optional): If True, envoke the spectra parser to get the MS1 spectra. Default is True.
  • spectrum_mode (str or None, optional): The spectrum mode to use for the mass spectra. If None, method will use the spectrum mode from the spectra parser to ascertain the spectrum mode (this allows for mixed types). Defaults to None. (faster if defined, otherwise will check each scan)
  • induced_features (bool, optional): If True, add associated MS1 of the induced mass features instead of the primary mass features
Raises
  • ValueError: If mass_features is not set, must run find_mass_features() first. If apex scans are not profile mode, all apex scans must be profile mode for averaging. If number of scans to average is not 1 or an integer with an integer median (i.e. 3, 5, 7, 9). If deconvolute is True and no EICs are found, did you run integrate_mass_features() first?
def mass_features_to_df(self, induced_features=False, drop_na_cols=False, include_cols=None):
 916    def mass_features_to_df(self, induced_features=False, drop_na_cols=False, include_cols=None):
 917        """Returns a pandas dataframe summarizing the mass features.
 918
 919        The dataframe contains the following columns: mf_id, mz, apex_scan, scan_time, intensity,
 920        persistence, area, monoisotopic_mf_id, and isotopologue_type.  The index is set to mf_id (mass feature ID).
 921        Parameters
 922        -----------
 923        induced_features : bool, optional
 924            If True, calls the induced_mass_features dictionary. Defaults to False.
 925        drop_na_cols : bool, optional
 926            If True, drops columns that are entirely NA. Defaults to False.
 927        include_cols : list of str, optional
 928            If provided, only includes the specified columns in the output (in addition to 'mf_id' which is always included as the index).
 929            If None, includes all available columns. Defaults to None.
 930
 931        Raises
 932        --------
 933        ValueError
 934            If the sample provided doesn't contain the mass feature data.        
 935
 936        Returns
 937        --------
 938        pandas.DataFrame
 939            A pandas dataframe of mass features with the following columns:
 940            mf_id, mz, apex_scan, scan_time, intensity, persistence, area.
 941        """
 942        import pandas as pd
 943
 944        def mass_spectrum_to_string(
 945            mass_spec, normalize=True, min_normalized_abun=0.01
 946        ):
 947            """Converts a mass spectrum to a string of m/z:abundance pairs.
 948
 949            Parameters
 950            -----------
 951            mass_spec : MassSpectrum
 952                A MassSpectrum object to be converted to a string.
 953            normalize : bool, optional
 954                If True, normalizes the abundance values to a maximum of 1. Defaults to True.
 955            min_normalized_abun : float, optional
 956                The minimum normalized abundance value to include in the string, only used if normalize is True. Defaults to 0.01.
 957
 958            Returns
 959            --------
 960            str
 961                A string of m/z:abundance pairs from the mass spectrum, separated by a semicolon.
 962            """
 963            mz_np = mass_spec.to_dataframe()["m/z"].values
 964            abun_np = mass_spec.to_dataframe()["Peak Height"].values
 965            if normalize:
 966                abun_np = abun_np / abun_np.max()
 967            mz_abun = np.column_stack((mz_np, abun_np))
 968            if normalize:
 969                mz_abun = mz_abun[mz_abun[:, 1] > min_normalized_abun]
 970            mz_abun_str = [
 971                str(round(mz, ndigits=4)) + ":" + str(round(abun, ndigits=2))
 972                for mz, abun in mz_abun
 973            ]
 974            return "; ".join(mz_abun_str)
 975
 976        if induced_features:
 977            mf_dict = self.induced_mass_features
 978        else:
 979            mf_dict = self.mass_features
 980        
 981        if len(mf_dict) == 0:
 982            # Return an empty dataframe with the expected structure
 983            # This allows collection processing to continue even if some samples have no features
 984            return pd.DataFrame()
 985            
 986        cols_in_df = [
 987            "id",
 988            "apex_scan",
 989            "start_scan",
 990            "final_scan",
 991            "retention_time",
 992            "intensity",
 993            "persistence",
 994            "area",
 995            "dispersity_index",
 996            "normalized_dispersity_index",
 997            "tailing_factor",
 998            "gaussian_similarity",
 999            "noise_score",
1000            "noise_score_min",
1001            "noise_score_max",
1002            "monoisotopic_mf_id",
1003            "isotopologue_type",
1004            "mass_spectrum_deconvoluted_parent",
1005            "ms2_scan_numbers",
1006            "type"
1007        ]
1008
1009        df_mf_list = []
1010        for mf_id in mf_dict.keys():
1011            # Find cols_in_df that are in single_mf
1012            df_keys = list(
1013                set(cols_in_df).intersection(mf_dict[mf_id].__dir__())
1014            )
1015            dict_mf = {}
1016            # Get the values for each key in df_keys from the mass feature object
1017            for key in df_keys:
1018                value = getattr(mf_dict[mf_id], key)
1019                # Wrap list/array values in a list so pandas treats them as single cell values
1020                if key == 'ms2_scan_numbers' and isinstance(value, (list, np.ndarray)):
1021                    dict_mf[key] = [value]
1022                else:
1023                    dict_mf[key] = value
1024            if len(mf_dict[mf_id].ms2_scan_numbers) > 0:
1025                # Add MS2 spectra info
1026                best_ms2_spectrum = mf_dict[mf_id].best_ms2
1027                if best_ms2_spectrum is not None:
1028                    dict_mf["ms2_spectrum"] = mass_spectrum_to_string(best_ms2_spectrum)
1029            if len(mf_dict[mf_id].associated_mass_features_deconvoluted) > 0:
1030                dict_mf["associated_mass_features"] = ", ".join(
1031                    map(
1032                        str,
1033                        mf_dict[mf_id].associated_mass_features_deconvoluted,
1034                    )
1035                )
1036            if mf_dict[mf_id]._half_height_width is not None:
1037                dict_mf["half_height_width"] = mf_dict[
1038                    mf_id
1039                ].half_height_width
1040            # Check if EIC for mass feature is set
1041            df_mf_single = pd.DataFrame(dict_mf, index=[mf_id])
1042            df_mf_single["mz"] = mf_dict[mf_id].mz
1043            df_mf_list.append(df_mf_single)
1044        df_mf = pd.concat(df_mf_list)
1045
1046        # rename _area to area and id to mf_id
1047        df_mf = df_mf.rename(
1048            columns={
1049                "id": "mf_id",
1050                "retention_time": "scan_time",            
1051            }
1052        )
1053
1054        # reorder columns
1055        col_order = [
1056            "mf_id",
1057            "type",
1058            "scan_time",
1059            "mz",
1060            "apex_scan",
1061            "start_scan",
1062            "final_scan",
1063            "intensity",
1064            "persistence",
1065            "area",
1066            "half_height_width",
1067            "tailing_factor",
1068            "dispersity_index",
1069            "normalized_dispersity_index",
1070            "gaussian_similarity",
1071            "noise_score",
1072            "noise_score_min",
1073            "noise_score_max",
1074            "monoisotopic_mf_id",
1075            "isotopologue_type",
1076            "mass_spectrum_deconvoluted_parent",
1077            "associated_mass_features",
1078            "ms2_scan_numbers",
1079            "ms2_spectrum",
1080        ]
1081        # drop columns that are not in col_order
1082        cols_to_order = [col for col in col_order if col in df_mf.columns]
1083        df_mf = df_mf[cols_to_order]
1084
1085        # reset index to mf_id
1086        df_mf = df_mf.set_index("mf_id")
1087        df_mf.index.name = "mf_id"
1088        
1089        if 'half_height_width' in df_mf.columns:
1090            df_mf["half_height_width"] = df_mf["half_height_width"].astype('float64')
1091        if 'tailing_factor' in df_mf.columns:
1092            df_mf["tailing_factor"] = df_mf["tailing_factor"].astype('float64')
1093        if 'dispersity_index' in df_mf.columns:
1094            df_mf["dispersity_index"] = df_mf["dispersity_index"].astype('float64')
1095        if 'normalized_dispersity_index' in df_mf.columns:
1096            df_mf["normalized_dispersity_index"] = df_mf["normalized_dispersity_index"].astype('float64')
1097        
1098        # Filter columns if include_cols is specified
1099        if include_cols is not None:
1100            # Ensure include_cols is a list
1101            if not isinstance(include_cols, list):
1102                raise ValueError("include_cols must be a list of column names")
1103            # Keep only requested columns that exist in the dataframe
1104            available_cols = [col for col in include_cols if col in df_mf.columns]
1105            df_mf = df_mf[available_cols]
1106        
1107        # Drop columns that are entirely NA if requested
1108        if drop_na_cols:
1109            df_mf = df_mf.dropna(axis=1, how='all')
1110        
1111        return df_mf

Returns a pandas dataframe summarizing the mass features.

The dataframe contains the following columns: mf_id, mz, apex_scan, scan_time, intensity, persistence, area, monoisotopic_mf_id, and isotopologue_type. The index is set to mf_id (mass feature ID).

Parameters
  • induced_features (bool, optional): If True, calls the induced_mass_features dictionary. Defaults to False.
  • drop_na_cols (bool, optional): If True, drops columns that are entirely NA. Defaults to False.
  • include_cols (list of str, optional): If provided, only includes the specified columns in the output (in addition to 'mf_id' which is always included as the index). If None, includes all available columns. Defaults to None.
Raises
  • ValueError: If the sample provided doesn't contain the mass feature data.
Returns
  • pandas.DataFrame: A pandas dataframe of mass features with the following columns: mf_id, mz, apex_scan, scan_time, intensity, persistence, area.
def mass_features_ms1_annot_to_df(self, suppress_warnings=False):
1113    def mass_features_ms1_annot_to_df(self, suppress_warnings=False):
1114        """Returns a pandas dataframe summarizing the MS1 annotations for the mass features in the dataset.
1115
1116        Parameters
1117        -----------
1118        suppress_warnings : bool, optional
1119            If True, suppresses the warning when no MS1 annotations are found.
1120            Useful when calling from collection-level methods. Default is False.
1121
1122        Returns
1123        --------
1124        pandas.DataFrame
1125            A pandas dataframe of MS1 annotations for the mass features in the dataset.
1126            The index is set to mf_id (mass feature ID)
1127
1128        Raises
1129        ------
1130        Warning
1131            If no MS1 annotations were found for the mass features in the dataset
1132            (unless suppress_warnings=True).
1133        """
1134        annot_df_list_ms1 = []
1135        for mf_id in self.mass_features.keys():
1136            if self.mass_features[mf_id].mass_spectrum is None:
1137                pass
1138            else:
1139                # Add ms1 annotations to ms1 annotation list
1140                if (
1141                    np.abs(
1142                        (
1143                            self.mass_features[mf_id].ms1_peak.mz_exp
1144                            - self.mass_features[mf_id].mz
1145                        )
1146                    )
1147                    < 0.01
1148                ):
1149                    # Get the molecular formula from the mass spectrum
1150                    annot_df = self.mass_features[mf_id].mass_spectrum.to_dataframe()
1151                    # Subset to pull out only the peak associated with the mass feature
1152                    annot_df = annot_df[
1153                        annot_df["Index"] == self.mass_features[mf_id].ms1_peak.index
1154                    ].copy()
1155
1156                    # If there are more than 1 row, remove any rows without a molecular formula
1157                    if len(annot_df) > 1:
1158                        annot_df = annot_df[~annot_df["Molecular Formula"].isna()]
1159
1160                    # Remove the index column and add column for mf_id
1161                    annot_df = annot_df.drop(columns=["Index"])
1162                    annot_df["mf_id"] = mf_id
1163                    annot_df_list_ms1.append(annot_df)
1164
1165        if len(annot_df_list_ms1) > 0:
1166            annot_ms1_df_full = pd.concat(annot_df_list_ms1)
1167            annot_ms1_df_full = annot_ms1_df_full.set_index("mf_id")
1168            annot_ms1_df_full.index.name = "mf_id"
1169
1170        else:
1171            annot_ms1_df_full = None
1172            # Warn that no ms1 annotations were found (unless suppressed)
1173            if not suppress_warnings:
1174                warnings.warn(
1175                    "No MS1 annotations found for mass features in dataset, were MS1 spectra added and processed within the dataset?",
1176                    UserWarning,
1177                )
1178
1179        return annot_ms1_df_full

Returns a pandas dataframe summarizing the MS1 annotations for the mass features in the dataset.

Parameters
  • suppress_warnings (bool, optional): If True, suppresses the warning when no MS1 annotations are found. Useful when calling from collection-level methods. Default is False.
Returns
  • pandas.DataFrame: A pandas dataframe of MS1 annotations for the mass features in the dataset. The index is set to mf_id (mass feature ID)
Raises
  • Warning: If no MS1 annotations were found for the mass features in the dataset (unless suppress_warnings=True).
def mass_features_ms2_annot_to_df(self, molecular_metadata=None, suppress_warnings=False):
1181    def mass_features_ms2_annot_to_df(self, molecular_metadata=None, suppress_warnings=False):
1182        """Returns a pandas dataframe summarizing the MS2 annotations for the mass features in the dataset.
1183
1184        Parameters
1185        -----------
1186        molecular_metadata :  dict of MolecularMetadata objects
1187            A dictionary of MolecularMetadata objects, keyed by ref_mol_id. Defaults to None.
1188        suppress_warnings : bool, optional
1189            If True, suppresses the warning when no MS2 annotations are found.
1190            Useful when calling from collection-level methods. Default is False.
1191
1192        Returns
1193        --------
1194        pandas.DataFrame
1195            A pandas dataframe of MS2 annotations for the mass features in the dataset,
1196            and optionally molecular metadata. The index is set to mf_id (mass feature ID)
1197
1198        Raises
1199        ------
1200        Warning
1201            If no MS2 annotations were found for the mass features in the dataset
1202            (unless suppress_warnings=True).
1203        """
1204        annot_df_list_ms2 = []
1205        for mf_id in self.mass_features.keys():
1206            if len(self.mass_features[mf_id].ms2_similarity_results) > 0:
1207                # Add ms2 annotations to ms2 annotation list
1208                for result in self.mass_features[mf_id].ms2_similarity_results:
1209                    annot_df_ms2 = result.to_dataframe()
1210                    annot_df_ms2["mf_id"] = mf_id
1211                    annot_df_list_ms2.append(annot_df_ms2)
1212
1213        if len(annot_df_list_ms2) > 0:
1214            annot_ms2_df_full = pd.concat(annot_df_list_ms2)
1215            if molecular_metadata is not None:
1216                molecular_metadata_df = pd.concat(
1217                    [
1218                        pd.DataFrame.from_dict(v.__dict__, orient="index").transpose()
1219                        for k, v in molecular_metadata.items()
1220                    ],
1221                    ignore_index=True,
1222                )
1223                molecular_metadata_df = molecular_metadata_df.rename(
1224                    columns={"id": "ref_mol_id"}
1225                )
1226                annot_ms2_df_full = annot_ms2_df_full.merge(
1227                    molecular_metadata_df, on="ref_mol_id", how="left"
1228                )
1229            annot_ms2_df_full = annot_ms2_df_full.drop_duplicates(
1230                subset=["mf_id", "query_spectrum_id", "ref_ms_id"]
1231            ).copy()
1232            annot_ms2_df_full = annot_ms2_df_full.set_index("mf_id")
1233            annot_ms2_df_full.index.name = "mf_id"
1234        else:
1235            annot_ms2_df_full = None
1236            # Warn that no ms2 annotations were found (unless suppressed)
1237            if not suppress_warnings:
1238                warnings.warn(
1239                    "No MS2 annotations found for mass features in dataset, were MS2 spectra added and searched against a database?",
1240                    UserWarning,
1241                )
1242
1243        return annot_ms2_df_full

Returns a pandas dataframe summarizing the MS2 annotations for the mass features in the dataset.

Parameters
  • molecular_metadata (dict of MolecularMetadata objects): A dictionary of MolecularMetadata objects, keyed by ref_mol_id. Defaults to None.
  • suppress_warnings (bool, optional): If True, suppresses the warning when no MS2 annotations are found. Useful when calling from collection-level methods. Default is False.
Returns
  • pandas.DataFrame: A pandas dataframe of MS2 annotations for the mass features in the dataset, and optionally molecular metadata. The index is set to mf_id (mass feature ID)
Raises
  • Warning: If no MS2 annotations were found for the mass features in the dataset (unless suppress_warnings=True).
def plot_composite_mz_features( self, binsize=0.0001, ph_int_min_thresh=0.001, mf_plot=True, ms2_plot=True, return_fig=False):
1245    def plot_composite_mz_features(self, binsize = 1e-4, ph_int_min_thresh = 0.001, mf_plot = True, ms2_plot = True, return_fig = False):
1246        """Returns a figure displaying 
1247            (1) thresholded, unprocessed data
1248            (2) the m/z features
1249            (3) which m/z features are associated with MS2 spectra
1250
1251        Parameters
1252        -----------
1253        binsize :  float
1254            Desired binsize for the m/z axis of the composite feature map.  Defaults to 1e-4.
1255        mf_plot : boolean
1256            Indicates whether to plot the m/z features. Defaults to True.
1257        ms2_plot : boolean
1258            Indicates whether to identify m/z features with associated MS2 spectra. Defaults to True.
1259        return_fig : boolean
1260            Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False.
1261
1262        Returns
1263        --------
1264        matplotlib.pyplot.Figure
1265            A figure with the thresholded, unprocessed data on an axis of m/z value with respect to 
1266            scan time. Unprocessed data is displayed in gray scale with darker colors indicating 
1267            higher intensities. If m/z features are plotted, they are displayed in cyan. If m/z
1268            features with associated with MS2 spectra are plotted, they are displayed in red.
1269
1270        Raises
1271        ------
1272        Warning
1273            If m/z features are set to be plot but aren't in the dataset.
1274            If m/z features with associated MS2 data are set to be plot but no MS2 annotations 
1275            were found for the m/z features in the dataset.
1276        """
1277        if mf_plot:
1278            # Check if mass_features is set, raise error if not
1279            if self.mass_features is None:
1280                raise ValueError(
1281                    "mass_features not set, must run find_mass_features() first"
1282                )
1283            ## call mass feature data
1284            mf_df = self.mass_features_to_df()
1285
1286        if ms2_plot:
1287            if not mf_plot:
1288                # Check if mass_features is set, raise error if not
1289                if self.mass_features is None:
1290                    raise ValueError(
1291                        "mass_features not set, must run find_mass_features() first"
1292                    )
1293
1294            ## call m/z feature data
1295            mf_df = self.mass_features_to_df()
1296
1297            # Check if ms2_spectrum is set, raise error if not
1298            if 'ms2_spectrum' not in mf_df.columns:
1299                raise ValueError(                
1300                    "ms2_spectrum not set, must run add_associated_ms2_dda() first"            
1301                )
1302    
1303        ## threshold and grid unprocessed data
1304        df = self._ms_unprocessed[1].copy()
1305        df = df.dropna(subset=['intensity']).reset_index(drop = True)
1306        threshold = ph_int_min_thresh * df.intensity.max()
1307        df_thres = df[df["intensity"] > threshold].reset_index(drop = True).copy()
1308        df = self.grid_data(df_thres)
1309    
1310        ## format unprocessed data for plotting
1311        df = df.merge(self.scan_df[['scan', 'scan_time']], on = 'scan')
1312        mz_grid = np.arange(0, np.max(df.mz), binsize)
1313        mz_data = np.array(df.mz)
1314        df['mz_bin'] = find_closest(mz_grid, mz_data)
1315        df['ab_bin'] = df.groupby(['mz_bin', 'scan_time']).intensity.transform(sum)
1316        unproc_df = df[['scan_time', 'mz_bin', 'ab_bin']].drop_duplicates(ignore_index = True)
1317
1318        ## generate figure
1319        fig = plt.figure()
1320        plt.scatter(
1321            unproc_df.scan_time,
1322            unproc_df.mz_bin*binsize,
1323            c = unproc_df.ab_bin/np.max(unproc_df.ab_bin),
1324            alpha = unproc_df.ab_bin/np.max(unproc_df.ab_bin), 
1325            cmap = 'Greys_r',
1326            s = 1
1327        )
1328
1329        if mf_plot:
1330            if ms2_plot:
1331                plt.scatter(
1332                    mf_df[mf_df.ms2_spectrum.isna()].scan_time,
1333                    mf_df[mf_df.ms2_spectrum.isna()].mz,
1334                    c = 'c',
1335                    s = 4,
1336                    label = 'M/Z features without MS2'
1337                )
1338            else:
1339                plt.scatter(
1340                    mf_df.scan_time,
1341                    mf_df.mz,
1342                    c = 'c',
1343                    s = 4,
1344                    label = 'M/Z features'
1345                )
1346
1347        if ms2_plot: 
1348            plt.scatter(
1349                mf_df[~mf_df.ms2_spectrum.isna()].scan_time,
1350                mf_df[~mf_df.ms2_spectrum.isna()].mz,
1351                c = 'r',
1352                s = 2,
1353                label = 'M/Z features with MS2'
1354            )
1355
1356        if mf_plot == True or ms2_plot == True:
1357            plt.legend(loc = 'lower center', bbox_to_anchor = (0.5, -0.25), ncol = 2)
1358        plt.xlabel('Scan time')
1359        plt.ylabel('m/z')
1360        plt.ylim(0, np.ceil(np.max(df.mz)))
1361        plt.xlim(0, np.ceil(np.max(df.scan_time)))
1362        plt.title('Composite Feature Map')
1363
1364        if return_fig:
1365            plt.close(fig)
1366            return fig
1367
1368        else:
1369            plt.show()

Returns a figure displaying (1) thresholded, unprocessed data (2) the m/z features (3) which m/z features are associated with MS2 spectra

Parameters
  • binsize (float): Desired binsize for the m/z axis of the composite feature map. Defaults to 1e-4.
  • mf_plot (boolean): Indicates whether to plot the m/z features. Defaults to True.
  • ms2_plot (boolean): Indicates whether to identify m/z features with associated MS2 spectra. Defaults to True.
  • return_fig (boolean): Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False.
Returns
  • matplotlib.pyplot.Figure: A figure with the thresholded, unprocessed data on an axis of m/z value with respect to scan time. Unprocessed data is displayed in gray scale with darker colors indicating higher intensities. If m/z features are plotted, they are displayed in cyan. If m/z features with associated with MS2 spectra are plotted, they are displayed in red.
Raises
  • Warning: If m/z features are set to be plot but aren't in the dataset. If m/z features with associated MS2 data are set to be plot but no MS2 annotations were found for the m/z features in the dataset.
def search_for_targeted_mass_features_batch( self, ms1df, mz_mins, mz_maxs, st_mins, st_maxs, set_ids, obj_idx=0, st_aligned=False):
1371    def search_for_targeted_mass_features_batch(
1372            self,
1373            ms1df,
1374            mz_mins,
1375            mz_maxs,
1376            st_mins,
1377            st_maxs,
1378            set_ids,
1379            obj_idx=0,
1380            st_aligned=False
1381            ):
1382        """
1383        Returns multiple LCMSMassFeatures from a specific sample within specific mass and time ranges.
1384        Vectorized batch version of search_for_targeted_mass_feature for improved performance.
1385
1386        Parameters
1387        -----------
1388        ms1df : pd.DataFrame
1389            Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample.
1390        mz_mins : np.ndarray
1391            Array of lower bounds of m/z values to use to find peaks.
1392        mz_maxs : np.ndarray
1393            Array of upper bounds of m/z values to use to find peaks.
1394        st_mins : np.ndarray
1395            Array of lower bounds of scan times to use to find peaks.
1396        st_maxs : np.ndarray
1397            Array of upper bounds of scan times to use to find peaks.
1398        set_ids : np.ndarray or list
1399            Array of strings used as IDs in LCMSMassFeatures.
1400        obj_idx : int
1401            Identifies index of sample in a collection. Defaults to 0.
1402        st_aligned : bool
1403            Whether to use scan_time_aligned or scan_time. Defaults to False.
1404
1405        Returns
1406        --------
1407        dict
1408            Dictionary mapping set_id to LCMSMassFeature objects.
1409
1410        Raises
1411        ------
1412        ValueError
1413            If appropriate scan time data is not contained in ms1df or if array lengths don't match.
1414        """
1415        # Validate inputs
1416        n_features = len(mz_mins)
1417        if not all(len(arr) == n_features for arr in [mz_maxs, st_mins, st_maxs, set_ids]):
1418            raise ValueError("All input arrays must have the same length")
1419
1420        # Validate scan time column
1421        time_col = 'scan_time_aligned' if st_aligned else 'scan_time'
1422        if time_col not in ms1df.columns:
1423            raise ValueError(f"{time_col} not contained in ms1df")
1424
1425        # Pre-extract columns for faster access
1426        mz_vals = ms1df.mz.values
1427        st_vals = ms1df[time_col].values
1428        scan_vals = ms1df.scan.values
1429        intensity_vals = ms1df.intensity.values
1430
1431        # Process all features
1432        results = {}
1433        for i in range(n_features):
1434            # Vectorized filtering
1435            mask = (
1436                (mz_vals >= mz_mins[i]) & (mz_vals <= mz_maxs[i]) &
1437                (st_vals >= st_mins[i]) & (st_vals <= st_maxs[i])
1438            )
1439            
1440            if not mask.any():
1441                row_dict = {
1442                    'apex_scan': -99,
1443                    'mz': np.nan,
1444                    'intensity': np.nan,
1445                    'retention_time': np.nan,
1446                    'persistence': np.nan,
1447                    'id': set_ids[i]
1448                }
1449            else:
1450                # Find max intensity within filtered region
1451                filtered_intensities = intensity_vals[mask]
1452                max_idx = np.argmax(filtered_intensities)
1453                
1454                # Get indices of filtered data
1455                filtered_indices = np.where(mask)[0]
1456                peak_idx = filtered_indices[max_idx]
1457                
1458                row_dict = {
1459                    'apex_scan': scan_vals[peak_idx],
1460                    'mz': mz_vals[peak_idx],
1461                    'intensity': intensity_vals[peak_idx],
1462                    'retention_time': st_vals[peak_idx],
1463                    'persistence': np.nan,
1464                    'id': set_ids[i]
1465                }
1466
1467            results[set_ids[i]] = LCMSMassFeature(self, **row_dict)
1468
1469        return results

Returns multiple LCMSMassFeatures from a specific sample within specific mass and time ranges. Vectorized batch version of search_for_targeted_mass_feature for improved performance.

Parameters
  • ms1df (pd.DataFrame): Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample.
  • mz_mins (np.ndarray): Array of lower bounds of m/z values to use to find peaks.
  • mz_maxs (np.ndarray): Array of upper bounds of m/z values to use to find peaks.
  • st_mins (np.ndarray): Array of lower bounds of scan times to use to find peaks.
  • st_maxs (np.ndarray): Array of upper bounds of scan times to use to find peaks.
  • set_ids (np.ndarray or list): Array of strings used as IDs in LCMSMassFeatures.
  • obj_idx (int): Identifies index of sample in a collection. Defaults to 0.
  • st_aligned (bool): Whether to use scan_time_aligned or scan_time. Defaults to False.
Returns
  • dict: Dictionary mapping set_id to LCMSMassFeature objects.
Raises
  • ValueError: If appropriate scan time data is not contained in ms1df or if array lengths don't match.
def search_for_targeted_mass_feature( self, ms1df, mz_min, mz_max, st_min, st_max, set_id, obj_idx=0, st_aligned=False):
1471    def search_for_targeted_mass_feature(
1472            self,
1473            ms1df, 
1474            mz_min,
1475            mz_max, 
1476            st_min, 
1477            st_max,
1478            set_id,
1479            obj_idx = 0,
1480            st_aligned = False
1481            ):
1482        """
1483        Returns an LCMSMassFeature from a specific sample within a specific mass and time range. Returns an empty
1484        LCMSMassFeature if no satisfactory peak is found in the given window.
1485
1486        Parameters
1487        -----------
1488        ms1df :  Pandas DataFrame
1489            Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample.
1490        mz_min : float
1491            Identifies lower bound of the weights to use to find a peak.
1492        mz_max : float
1493            Identifies upper bound of the weights to use to find a peak.
1494        st_min : float
1495            Identifies lower bound of the scan times to use to find a peak.
1496        st_max : float
1497            Identifies upper bound of the scan times to use to find a peak.
1498        set_id : str
1499            Indicates string used as ID in LCMSMassFeature.
1500        obj_idx : int
1501            Identifies index of sample in a collection that LCMSMassFeature should be assigned to. Defaults to 0 and is not used
1502            if data provided is an LCMSBase instead of an LCMSCollection.
1503        st_aligned : boolean
1504            Indicates whether to call scan time from scan_time or from scan_time_aligned if using a collection. Defaults to False.
1505
1506        Returns
1507        --------
1508        LCMSMassFeature
1509            Object from ChromaPeak that contains data on selected MS1 peak. If no peak is found, will contain missing 
1510            information and list the apex scan value as -99.
1511
1512        Raises
1513        ------
1514        Warning
1515            If appropriate scan time data is not contained in ms1df.
1516        """
1517        # Convert single feature to arrays and call batch method
1518        results = self.search_for_targeted_mass_features_batch(
1519            ms1df,
1520            np.array([mz_min]),
1521            np.array([mz_max]),
1522            np.array([st_min]),
1523            np.array([st_max]),
1524            [set_id],
1525            obj_idx=obj_idx,
1526            st_aligned=st_aligned
1527        )
1528        return results[set_id]

Returns an LCMSMassFeature from a specific sample within a specific mass and time range. Returns an empty LCMSMassFeature if no satisfactory peak is found in the given window.

Parameters
  • ms1df (Pandas DataFrame): Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample.
  • mz_min (float): Identifies lower bound of the weights to use to find a peak.
  • mz_max (float): Identifies upper bound of the weights to use to find a peak.
  • st_min (float): Identifies lower bound of the scan times to use to find a peak.
  • st_max (float): Identifies upper bound of the scan times to use to find a peak.
  • set_id (str): Indicates string used as ID in LCMSMassFeature.
  • obj_idx (int): Identifies index of sample in a collection that LCMSMassFeature should be assigned to. Defaults to 0 and is not used if data provided is an LCMSBase instead of an LCMSCollection.
  • st_aligned (boolean): Indicates whether to call scan time from scan_time or from scan_time_aligned if using a collection. Defaults to False.
Returns
  • LCMSMassFeature: Object from ChromaPeak that contains data on selected MS1 peak. If no peak is found, will contain missing information and list the apex scan value as -99.
Raises
  • Warning: If appropriate scan time data is not contained in ms1df.
def set_tic_list_from_data(self, overwrite=False):
1568    def set_tic_list_from_data(self, overwrite=False):
1569        """Sets the TIC list from the mass spectrum objects within the _ms dictionary.
1570
1571        Parameters
1572        -----------
1573        overwrite : bool, optional
1574            If True, overwrites the TIC list if it is already set. Defaults to False.
1575
1576        Notes
1577        -----
1578        If the _ms dictionary is incomplete, sets the TIC list to an empty list.
1579
1580        Raises
1581        ------
1582        ValueError
1583            If no mass spectra are found in the dataset.
1584            If the TIC list is already set and overwrite is False.
1585        """
1586        # Check if _ms is empty and raise error if so
1587        if len(self._ms) == 0:
1588            raise ValueError("No mass spectra found in dataset")
1589
1590        # Check if tic_list is already set and raise error if so
1591        if len(self.tic) > 0 and not overwrite:
1592            raise ValueError("TIC list already set, use overwrite=True to overwrite")
1593
1594        self.tic = [self._ms.get(i).tic for i in self.scans_number]

Sets the TIC list from the mass spectrum objects within the _ms dictionary.

Parameters
  • overwrite (bool, optional): If True, overwrites the TIC list if it is already set. Defaults to False.
Notes

If the _ms dictionary is incomplete, sets the TIC list to an empty list.

Raises
  • ValueError: If no mass spectra are found in the dataset. If the TIC list is already set and overwrite is False.
def set_retention_time_from_data(self, overwrite=False):
1596    def set_retention_time_from_data(self, overwrite=False):
1597        """Sets the retention time list from the data in the _ms dictionary.
1598
1599        Parameters
1600        -----------
1601        overwrite : bool, optional
1602            If True, overwrites the retention time list if it is already set. Defaults to False.
1603
1604        Notes
1605        -----
1606        If the _ms dictionary is empty or incomplete, sets the retention time list to an empty list.
1607
1608        Raises
1609        ------
1610        ValueError
1611            If no mass spectra are found in the dataset.
1612            If the retention time list is already set and overwrite is False.
1613        """
1614        # Check if _ms is empty and raise error if so
1615        if len(self._ms) == 0:
1616            raise ValueError("No mass spectra found in dataset")
1617
1618        # Check if retention_time_list is already set and raise error if so
1619        if len(self.retention_time) > 0 and not overwrite:
1620            raise ValueError(
1621                "Retention time list already set, use overwrite=True to overwrite"
1622            )
1623
1624        retention_time_list = []
1625        for key_ms in sorted(self._ms.keys()):
1626            retention_time_list.append(self._ms.get(key_ms).retention_time)
1627        self.retention_time = retention_time_list

Sets the retention time list from the data in the _ms dictionary.

Parameters
  • overwrite (bool, optional): If True, overwrites the retention time list if it is already set. Defaults to False.
Notes

If the _ms dictionary is empty or incomplete, sets the retention time list to an empty list.

Raises
  • ValueError: If no mass spectra are found in the dataset. If the retention time list is already set and overwrite is False.
def set_scans_number_from_data(self, overwrite=False):
1629    def set_scans_number_from_data(self, overwrite=False):
1630        """Sets the scan number list from the data in the _ms dictionary.
1631
1632        Notes
1633        -----
1634        If the _ms dictionary is empty or incomplete, sets the scan number list to an empty list.
1635
1636        Raises
1637        ------
1638        ValueError
1639            If no mass spectra are found in the dataset.
1640            If the scan number list is already set and overwrite is False.
1641        """
1642        # Check if _ms is empty and raise error if so
1643        if len(self._ms) == 0:
1644            raise ValueError("No mass spectra found in dataset")
1645
1646        # Check if scans_number_list is already set and raise error if so
1647        if len(self.scans_number) > 0 and not overwrite:
1648            raise ValueError(
1649                "Scan number list already set, use overwrite=True to overwrite"
1650            )
1651
1652        self.scans_number = sorted(self._ms.keys())

Sets the scan number list from the data in the _ms dictionary.

Notes

If the _ms dictionary is empty or incomplete, sets the scan number list to an empty list.

Raises
  • ValueError: If no mass spectra are found in the dataset. If the scan number list is already set and overwrite is False.
ms1_scans
1654    @property
1655    def ms1_scans(self):
1656        """
1657        list : A list of MS1 scan numbers for the dataset.
1658        """
1659        return self.scan_df[self.scan_df.ms_level == 1].index.tolist()

list : A list of MS1 scan numbers for the dataset.

parameters
1661    @property
1662    def parameters(self):
1663        """
1664        LCMSParameters : The parameters used for the LC-MS analysis.
1665        """
1666        return self._parameters

LCMSParameters : The parameters used for the LC-MS analysis.

scans_number
1680    @property
1681    def scans_number(self):
1682        """
1683        list : A list of scan numbers for the dataset.
1684        """
1685        return self._scans_number_list

list : A list of scan numbers for the dataset.

retention_time
1699    @property
1700    def retention_time(self):
1701        """
1702        numpy.ndarray : An array of retention times for the dataset.
1703        """
1704        return self._retention_time_list

numpy.ndarray : An array of retention times for the dataset.

tic
1718    @property
1719    def tic(self):
1720        """
1721        numpy.ndarray : An array of TIC values for the dataset.
1722        """
1723        return self._tic_list

numpy.ndarray : An array of TIC values for the dataset.

1737class LCMSCollection(LCMSCollectionCalculations):
1738    """A class representing a collection of liquid chromatography-mass spectrometry (LC-MS) runs.
1739    These runs can be from the same or different samples, but must be from the same instrument and have the same parameters 
1740    for the initial processing steps.  The LCMS objects are stored in an ordered dictionary with the sample name as the key.
1741
1742    Parameters
1743    -----------
1744
1745    Attributes
1746    -----------
1747
1748    Methods
1749    --------
1750
1751    Notes
1752    ------
1753    This class is not intended to be instantiated directly, but rather instantiated using a parser object and then interacted with.
1754    #TODO KRH: add docstrings
1755    """
1756
1757    def __init__(
1758            self,
1759            collection_location,
1760            manifest,
1761            collection_parser=None
1762    ):
1763        self.collection_location = collection_location
1764        self._manifest_dict = manifest
1765        self.collection_parser = collection_parser
1766        self.raw_files_relocated = False
1767
1768        # These attributes are generally set by the parser during instantiation of this class
1769        self._lcms = {}
1770        self._combined_mass_features = None
1771        self._combined_induced_mass_features = None
1772        self.consensus_mass_features = {}
1773        self._parameters = LCMSCollectionParameters()
1774        self.isotopes_dropped = False
1775        self._mass_features_locked = False  # Prevents rebuilding mass_features_dataframe from samples
1776
1777        # These attributes are set during processing
1778        self.rt_aligned = False
1779        self.rt_alignment_attempted = False
1780        self.missing_mass_features_searched = False
1781
1782    def _reorder_lcms_objects(self):
1783        """
1784        Reorders the LCMS objects in the collection based on the order in the manifest.
1785        """
1786        ordered_samples = self.samples
1787        self._lcms = {k: self._lcms[k] for k in ordered_samples}
1788
1789    def __getitem__(self, index):
1790        if isinstance(index, (float, np.floating, np.ndarray)):
1791            index = int(index)
1792        elif isinstance(index, np.integer):
1793            index = int(index)
1794        samp_name = self.samples[index]
1795        self._lcms[samp_name]
1796        return self._lcms[samp_name]
1797    
1798    def __len__(self):
1799        return len(self.samples)
1800    
1801    def _prepare_lcms_mass_features_for_combination(self, lcms_obj, induced_features = False):
1802        """
1803        Prepares the mass features in the LCMS objects in the collection for combination.
1804        """        
1805        if induced_features:
1806            mf_df = lcms_obj.mass_features_to_df(induced_features = True)
1807        # Check if lcms_obj has attribute light_mf_df
1808        elif hasattr(lcms_obj, "light_mf_df"):
1809            mf_df = lcms_obj.light_mf_df
1810        else:
1811            mf_df = lcms_obj.mass_features_to_df()
1812        
1813        # If dataframe is empty, add minimal required columns and return
1814        if len(mf_df) == 0:
1815            import pandas as pd
1816            mf_df["sample_name"] = []
1817            mf_df["sample_id"] = []
1818            mf_df["coll_mf_id"] = []
1819            mf_df["mf_id"] = []
1820            mf_df["_eic_mz"] = []  # Include _eic_mz for consistency with non-empty dataframes
1821            if induced_features:
1822                mf_df["cluster"] = []
1823            return mf_df
1824        
1825        # Remove index
1826        mf_df = mf_df.reset_index(drop=False)
1827        # Add sample name and sample id to the dataframe
1828        mf_df["sample_name"] = lcms_obj.sample_name
1829        # Ensure sample_id is stored as an integer to avoid float indices later
1830        try:
1831            mf_df["sample_id"] = int(self.manifest[lcms_obj.sample_name]["collection_id"])
1832        except Exception:
1833            mf_df["sample_id"] = self.manifest[lcms_obj.sample_name]["collection_id"]
1834        mf_df["coll_mf_id"] = mf_df["sample_id"].astype(str) + "_" + mf_df["mf_id"].astype(str)
1835
1836        # For induced features, extract cluster from mf_id (format: c{cluster}_{index}_i)
1837        # and add as a column since cluster_index attribute may not be set on the object
1838        if induced_features:
1839            def extract_cluster(mf_id):
1840                # mf_id format: c{cluster}_{index}_i
1841                # Example: c123_5_i -> cluster 123
1842                if isinstance(mf_id, str) and mf_id.startswith('c') and '_i' in mf_id:
1843                    parts = mf_id.split('_')
1844                    if len(parts) >= 2:
1845                        cluster_str = parts[0][1:]  # Remove 'c' prefix
1846                        try:
1847                            return int(cluster_str)
1848                        except ValueError:
1849                            return None
1850                return None
1851            
1852            mf_df['cluster'] = mf_df['mf_id'].apply(extract_cluster)
1853
1854        # Check if scan_df has scan_time_aligned and add to mf_df if so
1855        if "scan_time_aligned" in lcms_obj.scan_df.columns:
1856            scan_df = lcms_obj.scan_df[["scan", "scan_time_aligned"]].copy()
1857            scan_df = scan_df.rename(columns={"scan": "apex_scan"})
1858            mf_df = mf_df.merge(scan_df, on="apex_scan")
1859        
1860        return mf_df
1861       
1862    def _combine_mass_features(self, induced_features = False):
1863        """
1864        Concatenates the mass features from all the LCMS objects in the collection.
1865
1866        Returns
1867        --------
1868        None, sets the _combined_mass_features or _combined_induced_mass_feature attribute.
1869        
1870        Notes
1871        -----
1872        If _mass_features_locked is True (e.g., when only representative features are loaded),
1873        this method will skip rebuilding the regular mass features dataframe to preserve
1874        the full collection-level dataframe. Induced features are always rebuilt since they
1875        are created during processing.
1876        """
1877        
1878        # Skip rebuilding regular mass features if locked (preserves full dataframe)
1879        if not induced_features and self._mass_features_locked:
1880            return
1881
1882        ## TODO: See why this function runs slower on multiprocessing,
1883        ## especially for induced features
1884        ## has only been considered so far on ~20 samples
1885#        if self.parameters.lcms_collection.cores == 1:
1886#            # Prepare mass features for combination sequentially
1887#            mf_df_list = []
1888#            for lcms_obj in self:
1889#                mf_df = self._prepare_lcms_mass_features_for_combination(lcms_obj, induced_features)
1890#                mf_df_list.append(mf_df)
1891
1892#        if self.parameters.lcms_collection.cores > 1:
1893#            # Parallelize the mass feature preparation
1894#            if self.parameters.lcms_collection.cores > len(self):
1895#                ncores = len(self)
1896#            else:
1897#                ncores = self.parameters.lcms_collection.cores
1898#            pool = multiprocessing.Pool(ncores)
1899#            mf_df_list = pool.starmap(
1900#                self._prepare_lcms_mass_features_for_combination, 
1901#                [(lcms_obj, induced_features) for lcms_obj in self]
1902#            )
1903
1904        # Prepare mass features for combination sequentially
1905        mf_df_list = []
1906        for lcms_obj in self:
1907            # Skip samples with no induced mass features if processing induced features
1908            if induced_features:
1909                if not hasattr(lcms_obj, 'induced_mass_features') or len(lcms_obj.induced_mass_features) == 0:
1910                    continue
1911            mf_df = self._prepare_lcms_mass_features_for_combination(lcms_obj, induced_features)
1912            mf_df_list.append(mf_df)
1913
1914        # If no mass features were collected (e.g., no induced features exist), return early
1915        if len(mf_df_list) == 0:
1916            # Add a warning here, not sure how one might reach this state, clearly saying if they are induced features or not
1917            warnings.warn("No mass features found to combine in the collection.", UserWarning)
1918            if induced_features:
1919                self._combined_induced_mass_features = None
1920            else:
1921                self._combined_mass_features = None
1922            return
1923
1924        combined_mass_features = pd.concat(mf_df_list)
1925        # Ensure sample_id and cluster columns have integer dtypes where possible
1926        if "sample_id" in combined_mass_features.columns:
1927            try:
1928                combined_mass_features["sample_id"] = combined_mass_features["sample_id"].astype(int)
1929            except Exception:
1930                combined_mass_features["sample_id"] = pd.to_numeric(
1931                    combined_mass_features["sample_id"], errors="coerce"
1932                ).astype("Int64")
1933        if "cluster" in combined_mass_features.columns:
1934            try:
1935                combined_mass_features["cluster"] = combined_mass_features["cluster"].astype(int)
1936            except Exception:
1937                combined_mass_features["cluster"] = pd.to_numeric(
1938                    combined_mass_features["cluster"], errors="coerce"
1939                ).astype("Int64")
1940        # Move coll_mf_id, sample_name, sample_id, and mf_id to front
1941        cols = combined_mass_features.columns.tolist()
1942        top_cols = ["coll_mf_id", "sample_name", "sample_id", "mf_id", "mz", "scan_time_aligned", "cluster"]
1943        cols = [x for x in top_cols + [col for col in cols if col not in top_cols] if x in cols]
1944        combined_mass_features = combined_mass_features[cols]
1945        # Make coll_mf_id the index
1946        combined_mass_features = combined_mass_features.set_index("coll_mf_id")
1947        if induced_features == True:
1948            self._combined_induced_mass_features = combined_mass_features
1949        else:
1950            self._combined_mass_features = combined_mass_features
1951
1952    def _check_mass_features_df(self, induced_features = False):
1953        """Checks if the mass features dataframe has expected columns.  If not, adds them.
1954        
1955        Returns
1956        --------
1957        pandas.DataFrame
1958            A pandas dataframe of mass features in the collection.
1959
1960        Notes
1961        ------
1962        If scan_time_aligned is not in the _combined_mass_features or 
1963        _combined_induced_mass_features, tries to add it.
1964
1965        """
1966        
1967        if induced_features:
1968            cmf_df = self._combined_induced_mass_features
1969        else:
1970            cmf_df = self._combined_mass_features
1971        # Check if parameters are set to drop isotopologues and drop if so
1972        if self.parameters.lcms_collection.drop_isotopologues:
1973            if not self.isotopes_dropped:
1974                self._drop_isotopologues()
1975        # Check if scan_time_aligned is in combined_mass_features, try to add if not
1976        if cmf_df is not None and "scan_time_aligned" not in cmf_df.columns:
1977            cmb_mf = cmf_df.copy()
1978            cmb_mf = cmb_mf.reset_index(drop=False)
1979            lcms_aligned = [True for x in self if "scan_time_aligned" in x.scan_df.columns]
1980            if len(lcms_aligned) == len(self):
1981                # Add scan_time_aligned to combined_mass_features dataframe
1982                scan_time_aligned_list = []
1983                for lcms_obj in self:
1984                    scan_time_df_i = lcms_obj.scan_df[["scan", "scan_time_aligned"]]
1985                    scan_time_df_i["sample_name"] = lcms_obj.sample_name
1986                    scan_time_aligned_list.append(scan_time_df_i)
1987                scan_time_aligned_df = pd.concat(scan_time_aligned_list)
1988                # Rename scan to apex_scan
1989                scan_time_aligned_df = scan_time_aligned_df.rename(columns={"scan": "apex_scan"})
1990                cmb_mf_merged = cmb_mf.merge(scan_time_aligned_df, on=["apex_scan", "sample_name"])
1991                cmb_mf_merged = cmb_mf_merged.set_index("coll_mf_id")
1992                # Merge scan_time_aligned_df with combined_mass_features on apex_scan and sample_name
1993                if induced_features:
1994                    self._combined_induced_mass_features = cmb_mf_merged
1995                else:
1996                    self._combined_mass_features = cmb_mf_merged
1997    
1998    def plot_tics(self, ms_level=1, type = "raw", plot_legend=False):
1999        """Plots the TICs for all the LCMS objects in the collection.
2000        
2001        Parameters
2002        -----------
2003        ms_level : int, optional
2004            The MS level to plot the TICs for. Defaults to 1.
2005        type : str, optional
2006            The type of TIC to plot, either "raw" or "corrected" or "both". Defaults to "raw".
2007        plot_legend : bool, optional
2008            If True, plots a legend on the TIC plot that labels each sample. Defaults to False.
2009        """
2010        to_plot = []
2011        if type == "both":
2012            to_plot = ["raw", "corrected"]
2013        else:
2014            to_plot = [type]
2015
2016        fig, axs = plt.subplots(
2017            len(to_plot), 1, figsize=(10, 5 * len(to_plot)), sharex=True, squeeze=False
2018        )
2019        
2020        for i, plot_type in enumerate(to_plot):
2021            ax = axs[i, 0]
2022            colors = iter(plt.cm.rainbow(np.linspace(0, 1, len(self))))
2023            for lcms_obj in self:
2024                c = next(colors)
2025                # check if lcms_obj is the center of the collection
2026                self.manifest_dataframe[self.manifest_dataframe['center']].collection_id.values
2027
2028                
2029                scan_df = lcms_obj.scan_df
2030                scan_df = scan_df[scan_df.ms_level == ms_level]
2031                if plot_type == "corrected":
2032                    # Check that scan_time_aligned is key in scan_df
2033                    if "scan_time_aligned" not in scan_df.columns:
2034                        raise ValueError(f"scan_time_aligned not found in scan_df for {lcms_obj.sample_name}")
2035                    else:
2036                        ax.plot(scan_df.scan_time_aligned, scan_df.tic, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2037                elif plot_type == "raw":
2038                    ax.plot(scan_df.scan_time, scan_df.tic, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2039            ax.set_xlabel("Retention Time (min," + f" {plot_type})" )
2040            ax.set_ylabel("TIC")
2041            if plot_legend:
2042                ax.legend()
2043        plt.show()
2044
2045    def plot_alignments(self, plot_legend=False):
2046        """Plots the alignment of the LCMS objects in the collection.
2047        
2048        Parameters
2049        -----------
2050        plot_legend : bool, optional
2051            If True, plots a legend on the alignment plot that labels each sample. Defaults to False.        
2052        """
2053        fig, ax = plt.subplots(figsize=(10, 5))
2054        colors = iter(plt.cm.rainbow(np.linspace(0, 1, len(self))))
2055
2056        for lcms_obj in self:
2057            c = next(colors)
2058            scan_df = lcms_obj.scan_df
2059            if "scan_time_aligned" not in scan_df.columns:
2060                raise ValueError(f"scan_time_aligned not found in scan_df for {lcms_obj.sample_name}")
2061            scan_df['time_diff'] = scan_df.scan_time - scan_df.scan_time_aligned
2062            ax.plot(scan_df.scan_time_aligned, scan_df.time_diff, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2063
2064        ax.set_xlabel("Aligned Retention Time (min)")
2065        ax.set_ylabel("Time Difference (min)")
2066        if plot_legend:
2067            ax.legend()
2068        plt.show()
2069
2070    def _drop_isotopologues(self):
2071        """Drops isotopologues from the mass features in combined_mass_features dataframe."""
2072        cmb_mf_df = self._combined_mass_features
2073
2074        # Keep monos or if no monos
2075        cmb_monos = cmb_mf_df[cmb_mf_df.monoisotopic_mf_id == cmb_mf_df.mf_id]
2076        cmb_nomonos = cmb_mf_df[cmb_mf_df.monoisotopic_mf_id.isnull()]
2077        # Keep deconvoluted parent or if no deconvoluted parent
2078        cmb_decon_parent = cmb_mf_df[cmb_mf_df.mass_spectrum_deconvoluted_parent | cmb_mf_df.monoisotopic_mf_id.isnull()]
2079
2080        cmb_mf_df2 = pd.concat([cmb_monos, cmb_nomonos, cmb_decon_parent])
2081        cmb_mf_df2 = cmb_mf_df2[~cmb_mf_df2.index.duplicated(keep='first')]
2082        self.isotopes_dropped = True
2083        self._combined_mass_features = cmb_mf_df2
2084    
2085
2086    def load_raw_data(self, sample_idx: int, ms_level = 1, time_range = None) -> None:
2087        """Load raw data for a specific sample index in the collection.
2088        
2089        Parameters
2090        -----------
2091        sample_idx : int
2092            The index of the sample in the collection.
2093        ms_level : int, optional
2094            The MS level to load raw data for. Defaults to 1.
2095        time_range : tuple or list of tuples, optional
2096            Retention time range(s) to load. Can be a single tuple (min, max) or
2097            a list of tuples for multiple ranges. If None, loads all data. Defaults to None.
2098            
2099        Raises
2100        -------
2101        IndexError
2102            If the sample index is out of range.
2103        ValueError
2104            If raw data for the specified MS level is already loaded for the sample index.
2105        ValueError
2106            If the spectra parser is not set for the LCMS object or if the parser type does not support loading raw data.
2107
2108        Returns
2109        --------
2110        None, but updates the LCMS object with the raw data for the specified MS level.
2111        """
2112        if sample_idx < 0 or sample_idx >= len(self.samples):
2113            raise IndexError("Sample index out of range.")
2114
2115        # Check that the sample does not already have raw data loaded
2116        if ms_level in self[sample_idx]._ms_unprocessed:
2117            raise ValueError(f"Raw data for MS{ms_level} already loaded for sample index {sample_idx}. Drop data first if you want to reload it.")
2118
2119        # Check the parser type of the LCMS object
2120        if self[sample_idx].spectra_parser is None:
2121            raise ValueError("Spectra parser is not set for this LCMS object.")
2122
2123        # Instantiate the parser and load the raw data using the correct method
2124        parser = self[sample_idx].spectra_parser
2125        parser_class_name = self[sample_idx].spectra_parser_class.__name__
2126        scan_df = self[sample_idx].scan_df
2127
2128        # Get raw data for the specified MS level using the appropriate method
2129        if parser_class_name == "ImportMassSpectraThermoMSFileReader":
2130            self[sample_idx]._ms_unprocessed[ms_level] = parser.get_ms_raw(
2131                spectra=f"ms{ms_level}",
2132                scan_df=scan_df,
2133                time_range=time_range
2134            )[f"ms{ms_level}"]
2135
2136        elif parser_class_name == "MZMLSpectraParser":
2137            data = parser.load()
2138            self[sample_idx]._ms_unprocessed[ms_level] = parser.get_ms_raw(
2139                spectra=f"ms{ms_level}",
2140                scan_df=scan_df,
2141                data=data,
2142                time_range=time_range
2143                )[f"ms{ms_level}"]
2144
2145        elif parser_class_name == "ReadCoreMSHDFMassSpectra":
2146            raise ValueError(
2147                "ReadCoreMSHDFMassSpectra does not have a method to load raw data. Need to instantiate the original parser to access the raw data."
2148            )
2149
2150    def drop_raw_data(self, sample_idx: int, ms_level = 1) -> None:
2151        """Drop raw data for a specific sample index in the collection.
2152
2153        Parameters
2154        -----------
2155        sample_idx : int
2156            The index of the sample in the collection.
2157        ms_level : int, optional
2158            The MS level to drop raw data for. Defaults to 1.
2159
2160        Raises
2161        -------
2162        IndexError
2163            If the sample index is out of range.
2164        ValueError
2165            If raw data for the specified MS level is not loaded for the sample index.
2166
2167        Returns
2168        --------
2169        None
2170        """
2171        if sample_idx < 0 or sample_idx >= len(self.samples):
2172            raise IndexError("Sample index out of range.")
2173
2174        # Check that the sample has raw data loaded
2175        if ms_level not in self[sample_idx]._ms_unprocessed:
2176            raise ValueError(f"No raw data for MS{ms_level} found for sample index {sample_idx}. Load data first if you want to drop it.")
2177
2178        # Drop the raw data
2179        del self[sample_idx]._ms_unprocessed[ms_level]
2180
2181    def update_raw_file_locations(self, new_raw_folder):
2182        """Update the raw file locations for all LCMS objects in the collection.
2183        
2184        This method updates the path to the original raw data files (.raw, .mzML, etc.)
2185        that were used to create the processed HDF5 files stored in .corems folders.
2186        
2187        Parameters
2188        -----------
2189        new_raw_folder : str or Path
2190            The new folder location containing the raw data files (.raw, .mzML, etc.).
2191            The method will look for raw files with the same base name as each sample.
2192            
2193        Raises
2194        -------
2195        FileNotFoundError
2196            If the new raw folder does not exist.
2197        FileNotFoundError
2198            If a raw file for a sample is not found in the new folder.
2199            
2200        Returns
2201        --------
2202        None, but updates the raw_file_location for each LCMS object in the collection.
2203        
2204        Examples
2205        --------
2206        If raw files were moved from /old/path/ to /new/path/:
2207        >>> lcms_collection.update_raw_file_locations("/new/path/")
2208        """
2209        from pathlib import Path
2210        
2211        if isinstance(new_raw_folder, str):
2212            new_raw_folder = Path(new_raw_folder)
2213        
2214        if not new_raw_folder.exists():
2215            raise FileNotFoundError(f"Raw data folder does not exist: {new_raw_folder}")
2216        
2217        # Common raw file extensions
2218        raw_extensions = ['.raw', '.mzML', '.mzml']
2219        
2220        for sample_name in self.samples:
2221            lcms_obj = self._lcms[sample_name]
2222            
2223            # Try to find the raw file with common extensions
2224            new_raw_file = None
2225            for ext in raw_extensions:
2226                candidate = new_raw_folder / f"{sample_name}{ext}"
2227                if candidate.exists():
2228                    new_raw_file = candidate
2229                    break
2230            
2231            if new_raw_file is None:
2232                raise FileNotFoundError(
2233                    f"Raw file for sample '{sample_name}' not found in {new_raw_folder}. "
2234                    f"Tried extensions: {', '.join(raw_extensions)}"
2235                )
2236            
2237            # Update the raw file location and set flag that raw files have been relocated
2238            lcms_obj.raw_file_location = new_raw_file
2239        self.raw_files_relocated = True
2240
2241    def collection_pivot_table(self, attribute = 'coll_mf_id', verbose = True):
2242        """Generate a pivot table of all regular and induced mass features in
2243        a collection. Default attribute presented is the mass feature ID, also
2244        prints a list of other available attributes.
2245
2246        Parameters
2247        -----------
2248        attribute : str
2249            The desired attribute to be presented in the pivot table. Defaults
2250            to mass feature ID
2251        verbose : boolean
2252            Print out all the possible values the fill the pivot table and list
2253            attributes that are not collected for induced mass features
2254
2255        Returns
2256        --------
2257        pd.DataFrame
2258            A DataFrame that displays one given attribute across all clusters
2259            and samples in a collection
2260        
2261        """
2262        
2263        mf_pivot = self.mass_features_dataframe.copy()
2264        mf_pivot.reset_index(inplace = True)
2265        
2266        # Only include induced mass features if gap-filling has been performed
2267        if self.induced_mass_features_dataframe is not None:
2268            imf_pivot = self.induced_mass_features_dataframe.copy()
2269            imf_pivot.reset_index(inplace = True)
2270            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
2271            mf_pivot = pd.concat([mf_pivot, imf_pivot], axis = 0)
2272            mf_pivot.reset_index(drop = True, inplace = True)
2273        else:
2274            imf_pivot = None
2275            
2276        mf_pivot['cluster'] = mf_pivot['cluster'].astype(int)
2277
2278        if verbose:
2279            print(
2280                'Attributes available for pivot table:\n',
2281                [x for x in mf_pivot.columns if x not in ['cluster', 'sample_name', 'mf_id', 'partition_idx', 'idx']]
2282            )
2283            if imf_pivot is not None:
2284                print(
2285                    '\nAttributes that have no value for induced mass features:\n',
2286                    imf_pivot.columns[imf_pivot.isna().all()].tolist()            
2287                )
2288        
2289        # Create pivot table and reindex to include all samples (even those with no features)
2290        pivot = mf_pivot.pivot(index = 'cluster', columns = 'sample_name', values = attribute)
2291        
2292        # Reindex columns to include all samples in the collection
2293        all_samples = self.samples
2294        pivot = pivot.reindex(columns=all_samples)
2295        
2296        return pivot
2297
2298    def cluster_representatives_table(self):
2299        """Generate a table of representative mass features from each consensus cluster.
2300        
2301        This method returns a DataFrame containing all attributes for the
2302        representative mass feature from each consensus cluster. The representative
2303        is selected using the same logic as process_consensus_features().
2304
2305        Returns
2306        --------
2307        pd.DataFrame
2308            A DataFrame with one row per cluster containing all attributes for 
2309            each cluster's representative mass feature. Includes:
2310            - cluster: cluster ID (as a column for easy joining)
2311            - polarity: ionization polarity from the collection
2312            - n_samples_detected: number of samples where the cluster was detected
2313            - All other mass feature attributes from the representative
2314            
2315        Notes
2316        -----
2317        The representative metric used is determined by
2318        self.parameters.lcms_collection.consensus_representative_metric and
2319        is the same metric used by process_consensus_features() for consistency.
2320        Common options include 'intensity' (highest intensity) or 
2321        'intensity_prefer_ms2' (highest intensity with preference for MS2 data).
2322        """
2323        
2324        mf_df = self.mass_features_dataframe.copy()
2325        mf_df.reset_index(inplace = True)
2326        
2327        # Include induced mass features if they exist (from gap-filling)
2328        if self.induced_mass_features_dataframe is not None:
2329            imf_df = self.induced_mass_features_dataframe.copy()
2330            imf_df.reset_index(inplace = True)
2331            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
2332            mf_df = pd.concat([mf_df, imf_df], axis = 0)
2333            mf_df.reset_index(drop = True, inplace = True)
2334        mf_df['cluster'] = mf_df['cluster'].astype(int)
2335        
2336        # Calculate number of samples per cluster
2337        cluster_sample_counts = mf_df.groupby('cluster')['sample_id'].nunique().to_dict()
2338        
2339        # Use the same representative selection logic as process_consensus_features
2340        # This uses the configured representative_metric from parameters
2341        representatives = self.get_representative_mass_features_for_all_clusters()
2342        
2343        # Get the coll_mf_ids of the representatives
2344        representative_ids = representatives['coll_mf_id'].tolist()
2345        
2346        # Filter mf_df to only include representative features
2347        consensus_report = mf_df[mf_df.coll_mf_id.isin(representative_ids)].copy()
2348        
2349        # Add polarity (get from first sample in collection)
2350        if len(self) > 0:
2351            polarity = self[0].polarity
2352        else:
2353            polarity = 'unknown'
2354        consensus_report['polarity'] = polarity
2355        
2356        # Add number of samples detected
2357        consensus_report['n_samples_detected'] = consensus_report['cluster'].map(cluster_sample_counts)
2358        
2359        # Reorder columns to put cluster at the front
2360        cols = consensus_report.columns.tolist()
2361        if 'cluster' in cols:
2362            cols.remove('cluster')
2363            cols = ['cluster'] + cols
2364            consensus_report = consensus_report[cols]
2365        
2366        # Sort by cluster and return with cluster as a regular column
2367        return consensus_report.sort_values(by='cluster')
2368
2369    def feature_annotations_table(
2370            self, 
2371            molecular_metadata=None, 
2372            drop_unannotated=False,
2373            report_best_only=False
2374            ):
2375        """Generate a comprehensive annotation table for all loaded mass features across samples.
2376        
2377        This method consolidates MS1 molecular formula assignments and MS2 spectral 
2378        search results for all mass features across all samples in the collection.
2379        Only includes representative mass features (one per cluster per sample).
2380        
2381        Parameters
2382        ----------
2383        molecular_metadata : dict, optional
2384            Dictionary of MolecularMetadata objects, keyed by metabref_mol_id.
2385            Required for including molecular metadata in MS2 annotations.
2386            Default is None.
2387        drop_unannotated : bool, optional
2388            If True, drops rows where all annotation columns (everything except 
2389            cluster, MS2 Spectrum, and representative_sample) are NaN.
2390            Default is False.
2391        report_best_only : bool, optional
2392            If True, only includes the best MS2 annotation per mass feature based on confidence score.
2393            Default is False, which includes all MS2 annotations for each mass feature.
2394        
2395        Returns
2396        -------
2397        pd.DataFrame
2398            Consolidated annotation report with columns including:
2399            - cluster: cluster ID
2400            - sample_name: sample name
2401            - sample_id: sample ID
2402            - Mass Feature ID: mass feature ID within the sample
2403            - Mass feature attributes (mz, scan_time, intensity, etc.)
2404            - MS1 annotations (if molecular_formula_search was run)
2405            - MS2 annotations (if ms2_spectral_search was run)
2406        
2407        Notes
2408        -----
2409        This method uses the standard LCMSMetabolomicsExport.to_report() workflow
2410        for each sample, then consolidates all results and adds cluster information.
2411        
2412        Only mass features that are loaded in each sample's mass_features dict
2413        are included (typically the representative features if load_representatives
2414        was used in process_consensus_features).
2415        
2416        Raises
2417        ------
2418        ValueError
2419            If no representative features have been loaded. Call process_consensus_features
2420            with load_representatives=True first.
2421        ValueError
2422            If no samples with loaded mass features are found in the collection.
2423        """
2424        from corems.mass_spectra.output.export import LCMSMetabolomicsExport
2425        import warnings
2426        
2427        # Check if representative features have been loaded
2428        # Count samples with mass features loaded
2429        samples_with_features = sum(
2430            1 for lcms_obj in self 
2431            if hasattr(lcms_obj, 'mass_features') and len(lcms_obj.mass_features) > 0
2432        )
2433        
2434        if samples_with_features == 0:
2435            raise ValueError(
2436                "No representative mass features have been loaded into individual samples. "
2437                "Call process_consensus_features() with load_representatives=True before "
2438                "calling feature_annotations_table()."
2439            )
2440        
2441        # Collect reports from all samples
2442        all_sample_reports = []
2443        has_any_ms2_annotations = False
2444        
2445        for sample_id, lcms_obj in enumerate(self):
2446            # Skip samples with no loaded mass features
2447            if not hasattr(lcms_obj, 'mass_features') or len(lcms_obj.mass_features) == 0:
2448                continue
2449            
2450            sample_name = self.samples[sample_id]
2451            
2452            # Create exporter and generate report using standard workflow
2453            # Suppress individual warnings - we'll warn at collection level if needed
2454            exporter = LCMSMetabolomicsExport("temp", lcms_obj)
2455            sample_report = exporter.to_report(molecular_metadata=molecular_metadata, suppress_warnings=True)
2456            
2457            # Check if this sample has any MS2 annotations
2458            ms2_cols = [col for col in sample_report.columns if 'Entropy Similarity' in col or 'spectral_similarity' in col.lower()]
2459            if ms2_cols and sample_report[ms2_cols].notna().any().any():
2460                has_any_ms2_annotations = True
2461            
2462            # Add sample information
2463            sample_report['representative_sample'] = sample_name
2464            sample_report['sample_id'] = sample_id
2465            
2466            # Get cluster information from the mass_features_dataframe
2467            # Build coll_mf_id for each row to look up cluster
2468            sample_report['coll_mf_id'] = sample_report['sample_id'].astype(str) + "_" + sample_report['Mass Feature ID'].astype(str)
2469            
2470            # Get cluster from mass_features_dataframe
2471            if self.mass_features_dataframe is not None and 'cluster' in self.mass_features_dataframe.columns:
2472                mf_df = self.mass_features_dataframe.reset_index()
2473                cluster_lookup = mf_df.set_index('coll_mf_id')['cluster'].to_dict()
2474                sample_report['cluster'] = sample_report['coll_mf_id'].map(cluster_lookup)
2475            else:
2476                sample_report['cluster'] = None
2477            
2478            # Drop temporary coll_mf_id column
2479            sample_report = sample_report.drop(columns=['coll_mf_id'])
2480            
2481            all_sample_reports.append(sample_report)
2482        
2483        # Combine all sample reports
2484        if len(all_sample_reports) == 0:
2485            raise ValueError("No samples with loaded mass features found in collection")
2486        
2487        collection_report = pd.concat(all_sample_reports, ignore_index=True)
2488        
2489        # Warn only if NO samples in the collection have MS2 annotations
2490        if not has_any_ms2_annotations:
2491            warnings.warn(
2492                "No MS2 annotations found across any samples in collection, were MS2 spectra added and searched against a database?",
2493                UserWarning,
2494            )
2495        
2496        # Reorder columns to match specified order
2497        desired_cols = [
2498            'cluster',
2499            'Isotopologue Type',
2500            'Is Largest Ion after Deconvolution',
2501            'MS2 Spectrum',
2502            'Calculated m/z',
2503            'm/z Error (ppm)',
2504            'm/z Error Score',
2505            'Isotopologue Similarity',
2506            'Confidence Score',
2507            'Ion Formula',
2508            'Ion Type',
2509            'Molecular Formula',
2510            'inchikey',
2511            'name',
2512            'ref_ms_id',
2513            'Entropy Similarity',
2514            'Library mzs in Query (fraction)',
2515            'Spectra with Annotation (n)',
2516            'representative_sample'
2517        ]
2518        
2519        # Include only desired columns that exist, maintaining order
2520        cols = [col for col in desired_cols if col in collection_report.columns]
2521        collection_report = collection_report[cols]
2522        
2523        # Optionally drop rows without any annotations
2524        if drop_unannotated:
2525            # Columns to exclude from the "all NA" check
2526            exclude_cols = ['cluster', 'MS2 Spectrum', 'representative_sample']
2527            # Get annotation columns (everything except the excluded ones)
2528            annot_cols = [col for col in collection_report.columns if col not in exclude_cols]
2529            # Keep rows where at least one annotation column is not NA
2530            if len(annot_cols) > 0:
2531                collection_report = collection_report[collection_report[annot_cols].notna().any(axis=1)]
2532        
2533        # Sort by cluster, then by annotation quality
2534        sort_cols = ['cluster']
2535        if 'Entropy Similarity' in collection_report.columns:
2536            sort_cols.extend(['Entropy Similarity', 'Confidence Score'])
2537            collection_report = collection_report.sort_values(
2538                by=sort_cols,
2539                ascending=[True, False, False]
2540            )
2541        elif 'Confidence Score' in collection_report.columns:
2542            sort_cols.append('Confidence Score')
2543            collection_report = collection_report.sort_values(
2544                by=sort_cols,
2545                ascending=[True, False]
2546            )
2547        else:
2548            collection_report = collection_report.sort_values(by=sort_cols)
2549        
2550        if report_best_only:
2551            # Keep only the best annotation per cluster based on the first annotation column available
2552            if 'Entropy Similarity' in collection_report.columns:
2553                best_annot_col = 'Entropy Similarity'
2554            elif 'Confidence Score' in collection_report.columns:
2555                best_annot_col = 'Confidence Score'
2556            else:
2557                best_annot_col = None
2558            
2559            if best_annot_col is not None:
2560                collection_report = collection_report.sort_values(by=['cluster', best_annot_col], ascending=[True, False])
2561                collection_report = collection_report.drop_duplicates(subset=['cluster'], keep='first')
2562
2563        return collection_report
2564
2565    @property
2566    def parameters(self):
2567        """
2568        LCMSCollectionParameters : The parameters used for the LCMS collection.
2569        """
2570        return self._parameters
2571    
2572    @parameters.setter
2573    def parameters(self, paramsinstance):
2574        """
2575        Sets the parameters used for the LCMS analysis collection.
2576
2577        Parameters
2578        -----------
2579        paramsinstance : LCMSCollectionParameters
2580            The parameters used for the LC-MS analysis.
2581        """
2582        self._parameters = paramsinstance
2583    
2584    @property
2585    def mass_features_dataframe(self):
2586        self._check_mass_features_df()
2587        return self._combined_mass_features
2588
2589    @mass_features_dataframe.setter
2590    def mass_features_dataframe(self, df):
2591        # Check that the dataframe has the expected columns
2592        expected_cols = ["sample_name", "sample_id", "mz", "scan_time"]
2593        if not all([col in df.columns for col in expected_cols]):
2594            raise ValueError(f"Expected columns not found in dataframe: {expected_cols}")
2595        
2596        # Check that coll_mf_id is the index and it is unique
2597        if df.index.name != "coll_mf_id":
2598            raise ValueError("coll_mf_id must be the index of the dataframe")
2599        if not df.index.is_unique:
2600            raise ValueError("coll_mf_id must be unique")
2601        self._combined_mass_features = df
2602
2603    @property
2604    def induced_mass_features_dataframe(self):
2605        self._check_mass_features_df(induced_features = True)
2606        if self._combined_induced_mass_features is not None and len(self._combined_induced_mass_features) > 0:
2607            # The cluster column is extracted from mf_id in _prepare_lcms_mass_features_for_combination
2608            # mf_id format for induced features: c{cluster}_{index}_i
2609            pass
2610        return self._combined_induced_mass_features
2611
2612    @induced_mass_features_dataframe.setter
2613    def induced_mass_features_dataframe(self, df):
2614        # Check that the dataframe has the expected columns
2615        expected_cols = ["sample_name", "sample_id", "mz", "scan_time"]
2616        if not all([col in df.columns for col in expected_cols]):
2617            raise ValueError(f"Expected columns not found in dataframe: {expected_cols}")
2618        
2619        # Check that coll_mf_id is the index and it is unique
2620        if df.index.name != "coll_mf_id":
2621            raise ValueError("coll_mf_id must be the index of the dataframe")
2622        if not df.index.is_unique:
2623            raise ValueError("coll_mf_id must be unique")
2624        self._combined_induced_mass_features = df    
2625    
2626    @property
2627    def cluster_summary_dataframe(self):
2628        return self.summarize_clusters()
2629    
2630    @property
2631    def samples(self):
2632        manifest_df = self.manifest_dataframe
2633        # order by batch, then by order
2634        manifest_df = manifest_df.sort_values(by=['batch', 'order'])
2635        return manifest_df.index.tolist()
2636    
2637    @property
2638    def manifest(self):
2639        return self._manifest_dict
2640    
2641    @property
2642    def manifest_dataframe(self):
2643        return pd.DataFrame(self._manifest_dict).T
2644
2645    @property
2646    def raw_files(self):
2647        """Returns a list of raw files in the collection."""
2648        return [x.raw_file_location for x in self]
2649    
2650    @property
2651    def rt_alignments(self):
2652        """Returns a dictionary of retention time alignments for the collection."""
2653        if self.rt_aligned:
2654            _rt_alignments = {}
2655            # Construct a dictionary of aligned retention times (stored on each LCMS object within the collection, not the collection itself)
2656            for i, lcms_obj in enumerate(self):
2657                aligned_times = [x for k, x in sorted(lcms_obj._scan_info["scan_time_aligned"].items())]
2658                _rt_alignments[i] = aligned_times
2659            return _rt_alignments
2660        else:
2661            return None
2662    
2663    @property
2664    def cluster_feature_dictionary(self):
2665        """Generates a dictionary with clusters for keys and mass feature IDs as entries"""
2666        df = self.mass_features_dataframe
2667        cluster_dict = df.groupby('cluster').apply(lambda x: x.index.tolist()).to_dict()
2668        return cluster_dict
2669    
2670    def get_eics_for_cluster(self, cluster_id):
2671        """
2672        Retrieve all EICs for mass features in a specific cluster across all samples.
2673        
2674        Returns a dictionary mapping sample names to EIC_Data objects for the given cluster.
2675        Useful for visualizing and comparing chromatographic peaks across samples.
2676        
2677        Parameters
2678        ----------
2679        cluster_id : int
2680            The cluster ID to retrieve EICs for
2681            
2682        Returns
2683        -------
2684        dict
2685            Dictionary with structure: {sample_name: EIC_Data object}
2686            Only includes samples where the EIC was loaded.
2687            
2688        Examples
2689        --------
2690        >>> # Load EICs first
2691        >>> collection.process_consensus_features(gather_eics=True, ...)
2692        >>> 
2693        >>> # Get all EICs for cluster 5
2694        >>> eics = collection.get_eics_for_cluster(5)
2695        >>> for sample_name, eic_data in eics.items():
2696        ...     print(f"{sample_name}: {len(eic_data.scans)} scans")
2697        
2698        Notes
2699        -----
2700        Requires that EICs have been loaded using gather_eics=True in
2701        process_consensus_features() or manually loaded via LoadEICsOperation.
2702        """
2703        eics_by_sample = {}
2704        
2705        # Iterate through all samples
2706        for sample_id, sample in enumerate(self):
2707            sample_name = self.samples[sample_id]
2708            
2709            # Check if sample has EICs loaded
2710            if not hasattr(sample, 'eics') or not sample.eics:
2711                continue
2712            
2713            # Find mass features in this cluster for this sample
2714            # Check both regular and induced mass features
2715            for mf in list(sample.mass_features.values()) + list(sample.induced_mass_features.values()):
2716                if hasattr(mf, 'cluster_index') and mf.cluster_index == cluster_id:
2717                    # Get the EIC for this mass feature's m/z
2718                    if mf.mz in sample.eics:
2719                        eics_by_sample[sample_name] = sample.eics[mf.mz]
2720                        break  # Found the EIC for this sample, move to next sample
2721        
2722        return eics_by_sample

A class representing a collection of liquid chromatography-mass spectrometry (LC-MS) runs. These runs can be from the same or different samples, but must be from the same instrument and have the same parameters for the initial processing steps. The LCMS objects are stored in an ordered dictionary with the sample name as the key.

Parameters
Attributes
Methods
Notes

This class is not intended to be instantiated directly, but rather instantiated using a parser object and then interacted with.

TODO KRH: add docstrings

LCMSCollection(collection_location, manifest, collection_parser=None)
1757    def __init__(
1758            self,
1759            collection_location,
1760            manifest,
1761            collection_parser=None
1762    ):
1763        self.collection_location = collection_location
1764        self._manifest_dict = manifest
1765        self.collection_parser = collection_parser
1766        self.raw_files_relocated = False
1767
1768        # These attributes are generally set by the parser during instantiation of this class
1769        self._lcms = {}
1770        self._combined_mass_features = None
1771        self._combined_induced_mass_features = None
1772        self.consensus_mass_features = {}
1773        self._parameters = LCMSCollectionParameters()
1774        self.isotopes_dropped = False
1775        self._mass_features_locked = False  # Prevents rebuilding mass_features_dataframe from samples
1776
1777        # These attributes are set during processing
1778        self.rt_aligned = False
1779        self.rt_alignment_attempted = False
1780        self.missing_mass_features_searched = False
collection_location
collection_parser
raw_files_relocated
consensus_mass_features
isotopes_dropped
rt_aligned
rt_alignment_attempted
missing_mass_features_searched
def plot_tics(self, ms_level=1, type='raw', plot_legend=False):
1998    def plot_tics(self, ms_level=1, type = "raw", plot_legend=False):
1999        """Plots the TICs for all the LCMS objects in the collection.
2000        
2001        Parameters
2002        -----------
2003        ms_level : int, optional
2004            The MS level to plot the TICs for. Defaults to 1.
2005        type : str, optional
2006            The type of TIC to plot, either "raw" or "corrected" or "both". Defaults to "raw".
2007        plot_legend : bool, optional
2008            If True, plots a legend on the TIC plot that labels each sample. Defaults to False.
2009        """
2010        to_plot = []
2011        if type == "both":
2012            to_plot = ["raw", "corrected"]
2013        else:
2014            to_plot = [type]
2015
2016        fig, axs = plt.subplots(
2017            len(to_plot), 1, figsize=(10, 5 * len(to_plot)), sharex=True, squeeze=False
2018        )
2019        
2020        for i, plot_type in enumerate(to_plot):
2021            ax = axs[i, 0]
2022            colors = iter(plt.cm.rainbow(np.linspace(0, 1, len(self))))
2023            for lcms_obj in self:
2024                c = next(colors)
2025                # check if lcms_obj is the center of the collection
2026                self.manifest_dataframe[self.manifest_dataframe['center']].collection_id.values
2027
2028                
2029                scan_df = lcms_obj.scan_df
2030                scan_df = scan_df[scan_df.ms_level == ms_level]
2031                if plot_type == "corrected":
2032                    # Check that scan_time_aligned is key in scan_df
2033                    if "scan_time_aligned" not in scan_df.columns:
2034                        raise ValueError(f"scan_time_aligned not found in scan_df for {lcms_obj.sample_name}")
2035                    else:
2036                        ax.plot(scan_df.scan_time_aligned, scan_df.tic, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2037                elif plot_type == "raw":
2038                    ax.plot(scan_df.scan_time, scan_df.tic, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2039            ax.set_xlabel("Retention Time (min," + f" {plot_type})" )
2040            ax.set_ylabel("TIC")
2041            if plot_legend:
2042                ax.legend()
2043        plt.show()

Plots the TICs for all the LCMS objects in the collection.

Parameters
  • ms_level (int, optional): The MS level to plot the TICs for. Defaults to 1.
  • type (str, optional): The type of TIC to plot, either "raw" or "corrected" or "both". Defaults to "raw".
  • plot_legend (bool, optional): If True, plots a legend on the TIC plot that labels each sample. Defaults to False.
def plot_alignments(self, plot_legend=False):
2045    def plot_alignments(self, plot_legend=False):
2046        """Plots the alignment of the LCMS objects in the collection.
2047        
2048        Parameters
2049        -----------
2050        plot_legend : bool, optional
2051            If True, plots a legend on the alignment plot that labels each sample. Defaults to False.        
2052        """
2053        fig, ax = plt.subplots(figsize=(10, 5))
2054        colors = iter(plt.cm.rainbow(np.linspace(0, 1, len(self))))
2055
2056        for lcms_obj in self:
2057            c = next(colors)
2058            scan_df = lcms_obj.scan_df
2059            if "scan_time_aligned" not in scan_df.columns:
2060                raise ValueError(f"scan_time_aligned not found in scan_df for {lcms_obj.sample_name}")
2061            scan_df['time_diff'] = scan_df.scan_time - scan_df.scan_time_aligned
2062            ax.plot(scan_df.scan_time_aligned, scan_df.time_diff, label=lcms_obj.sample_name, c=c, linewidth=0.3)
2063
2064        ax.set_xlabel("Aligned Retention Time (min)")
2065        ax.set_ylabel("Time Difference (min)")
2066        if plot_legend:
2067            ax.legend()
2068        plt.show()

Plots the alignment of the LCMS objects in the collection.

Parameters
  • plot_legend (bool, optional): If True, plots a legend on the alignment plot that labels each sample. Defaults to False.
def load_raw_data(self, sample_idx: int, ms_level=1, time_range=None) -> None:
2086    def load_raw_data(self, sample_idx: int, ms_level = 1, time_range = None) -> None:
2087        """Load raw data for a specific sample index in the collection.
2088        
2089        Parameters
2090        -----------
2091        sample_idx : int
2092            The index of the sample in the collection.
2093        ms_level : int, optional
2094            The MS level to load raw data for. Defaults to 1.
2095        time_range : tuple or list of tuples, optional
2096            Retention time range(s) to load. Can be a single tuple (min, max) or
2097            a list of tuples for multiple ranges. If None, loads all data. Defaults to None.
2098            
2099        Raises
2100        -------
2101        IndexError
2102            If the sample index is out of range.
2103        ValueError
2104            If raw data for the specified MS level is already loaded for the sample index.
2105        ValueError
2106            If the spectra parser is not set for the LCMS object or if the parser type does not support loading raw data.
2107
2108        Returns
2109        --------
2110        None, but updates the LCMS object with the raw data for the specified MS level.
2111        """
2112        if sample_idx < 0 or sample_idx >= len(self.samples):
2113            raise IndexError("Sample index out of range.")
2114
2115        # Check that the sample does not already have raw data loaded
2116        if ms_level in self[sample_idx]._ms_unprocessed:
2117            raise ValueError(f"Raw data for MS{ms_level} already loaded for sample index {sample_idx}. Drop data first if you want to reload it.")
2118
2119        # Check the parser type of the LCMS object
2120        if self[sample_idx].spectra_parser is None:
2121            raise ValueError("Spectra parser is not set for this LCMS object.")
2122
2123        # Instantiate the parser and load the raw data using the correct method
2124        parser = self[sample_idx].spectra_parser
2125        parser_class_name = self[sample_idx].spectra_parser_class.__name__
2126        scan_df = self[sample_idx].scan_df
2127
2128        # Get raw data for the specified MS level using the appropriate method
2129        if parser_class_name == "ImportMassSpectraThermoMSFileReader":
2130            self[sample_idx]._ms_unprocessed[ms_level] = parser.get_ms_raw(
2131                spectra=f"ms{ms_level}",
2132                scan_df=scan_df,
2133                time_range=time_range
2134            )[f"ms{ms_level}"]
2135
2136        elif parser_class_name == "MZMLSpectraParser":
2137            data = parser.load()
2138            self[sample_idx]._ms_unprocessed[ms_level] = parser.get_ms_raw(
2139                spectra=f"ms{ms_level}",
2140                scan_df=scan_df,
2141                data=data,
2142                time_range=time_range
2143                )[f"ms{ms_level}"]
2144
2145        elif parser_class_name == "ReadCoreMSHDFMassSpectra":
2146            raise ValueError(
2147                "ReadCoreMSHDFMassSpectra does not have a method to load raw data. Need to instantiate the original parser to access the raw data."
2148            )

Load raw data for a specific sample index in the collection.

Parameters
  • sample_idx (int): The index of the sample in the collection.
  • ms_level (int, optional): The MS level to load raw data for. Defaults to 1.
  • time_range (tuple or list of tuples, optional): Retention time range(s) to load. Can be a single tuple (min, max) or a list of tuples for multiple ranges. If None, loads all data. Defaults to None.
Raises
  • IndexError: If the sample index is out of range.
  • ValueError: If raw data for the specified MS level is already loaded for the sample index.
  • ValueError: If the spectra parser is not set for the LCMS object or if the parser type does not support loading raw data.
Returns
  • None, but updates the LCMS object with the raw data for the specified MS level.
def drop_raw_data(self, sample_idx: int, ms_level=1) -> None:
2150    def drop_raw_data(self, sample_idx: int, ms_level = 1) -> None:
2151        """Drop raw data for a specific sample index in the collection.
2152
2153        Parameters
2154        -----------
2155        sample_idx : int
2156            The index of the sample in the collection.
2157        ms_level : int, optional
2158            The MS level to drop raw data for. Defaults to 1.
2159
2160        Raises
2161        -------
2162        IndexError
2163            If the sample index is out of range.
2164        ValueError
2165            If raw data for the specified MS level is not loaded for the sample index.
2166
2167        Returns
2168        --------
2169        None
2170        """
2171        if sample_idx < 0 or sample_idx >= len(self.samples):
2172            raise IndexError("Sample index out of range.")
2173
2174        # Check that the sample has raw data loaded
2175        if ms_level not in self[sample_idx]._ms_unprocessed:
2176            raise ValueError(f"No raw data for MS{ms_level} found for sample index {sample_idx}. Load data first if you want to drop it.")
2177
2178        # Drop the raw data
2179        del self[sample_idx]._ms_unprocessed[ms_level]

Drop raw data for a specific sample index in the collection.

Parameters
  • sample_idx (int): The index of the sample in the collection.
  • ms_level (int, optional): The MS level to drop raw data for. Defaults to 1.
Raises
  • IndexError: If the sample index is out of range.
  • ValueError: If raw data for the specified MS level is not loaded for the sample index.
Returns
  • None
def update_raw_file_locations(self, new_raw_folder):
2181    def update_raw_file_locations(self, new_raw_folder):
2182        """Update the raw file locations for all LCMS objects in the collection.
2183        
2184        This method updates the path to the original raw data files (.raw, .mzML, etc.)
2185        that were used to create the processed HDF5 files stored in .corems folders.
2186        
2187        Parameters
2188        -----------
2189        new_raw_folder : str or Path
2190            The new folder location containing the raw data files (.raw, .mzML, etc.).
2191            The method will look for raw files with the same base name as each sample.
2192            
2193        Raises
2194        -------
2195        FileNotFoundError
2196            If the new raw folder does not exist.
2197        FileNotFoundError
2198            If a raw file for a sample is not found in the new folder.
2199            
2200        Returns
2201        --------
2202        None, but updates the raw_file_location for each LCMS object in the collection.
2203        
2204        Examples
2205        --------
2206        If raw files were moved from /old/path/ to /new/path/:
2207        >>> lcms_collection.update_raw_file_locations("/new/path/")
2208        """
2209        from pathlib import Path
2210        
2211        if isinstance(new_raw_folder, str):
2212            new_raw_folder = Path(new_raw_folder)
2213        
2214        if not new_raw_folder.exists():
2215            raise FileNotFoundError(f"Raw data folder does not exist: {new_raw_folder}")
2216        
2217        # Common raw file extensions
2218        raw_extensions = ['.raw', '.mzML', '.mzml']
2219        
2220        for sample_name in self.samples:
2221            lcms_obj = self._lcms[sample_name]
2222            
2223            # Try to find the raw file with common extensions
2224            new_raw_file = None
2225            for ext in raw_extensions:
2226                candidate = new_raw_folder / f"{sample_name}{ext}"
2227                if candidate.exists():
2228                    new_raw_file = candidate
2229                    break
2230            
2231            if new_raw_file is None:
2232                raise FileNotFoundError(
2233                    f"Raw file for sample '{sample_name}' not found in {new_raw_folder}. "
2234                    f"Tried extensions: {', '.join(raw_extensions)}"
2235                )
2236            
2237            # Update the raw file location and set flag that raw files have been relocated
2238            lcms_obj.raw_file_location = new_raw_file
2239        self.raw_files_relocated = True

Update the raw file locations for all LCMS objects in the collection.

This method updates the path to the original raw data files (.raw, .mzML, etc.) that were used to create the processed HDF5 files stored in .corems folders.

Parameters
  • new_raw_folder (str or Path): The new folder location containing the raw data files (.raw, .mzML, etc.). The method will look for raw files with the same base name as each sample.
Raises
  • FileNotFoundError: If the new raw folder does not exist.
  • FileNotFoundError: If a raw file for a sample is not found in the new folder.
Returns
  • None, but updates the raw_file_location for each LCMS object in the collection.
Examples

If raw files were moved from /old/path/ to /new/path/:

>>> lcms_collection.update_raw_file_locations("/new/path/")
def collection_pivot_table(self, attribute='coll_mf_id', verbose=True):
2241    def collection_pivot_table(self, attribute = 'coll_mf_id', verbose = True):
2242        """Generate a pivot table of all regular and induced mass features in
2243        a collection. Default attribute presented is the mass feature ID, also
2244        prints a list of other available attributes.
2245
2246        Parameters
2247        -----------
2248        attribute : str
2249            The desired attribute to be presented in the pivot table. Defaults
2250            to mass feature ID
2251        verbose : boolean
2252            Print out all the possible values the fill the pivot table and list
2253            attributes that are not collected for induced mass features
2254
2255        Returns
2256        --------
2257        pd.DataFrame
2258            A DataFrame that displays one given attribute across all clusters
2259            and samples in a collection
2260        
2261        """
2262        
2263        mf_pivot = self.mass_features_dataframe.copy()
2264        mf_pivot.reset_index(inplace = True)
2265        
2266        # Only include induced mass features if gap-filling has been performed
2267        if self.induced_mass_features_dataframe is not None:
2268            imf_pivot = self.induced_mass_features_dataframe.copy()
2269            imf_pivot.reset_index(inplace = True)
2270            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
2271            mf_pivot = pd.concat([mf_pivot, imf_pivot], axis = 0)
2272            mf_pivot.reset_index(drop = True, inplace = True)
2273        else:
2274            imf_pivot = None
2275            
2276        mf_pivot['cluster'] = mf_pivot['cluster'].astype(int)
2277
2278        if verbose:
2279            print(
2280                'Attributes available for pivot table:\n',
2281                [x for x in mf_pivot.columns if x not in ['cluster', 'sample_name', 'mf_id', 'partition_idx', 'idx']]
2282            )
2283            if imf_pivot is not None:
2284                print(
2285                    '\nAttributes that have no value for induced mass features:\n',
2286                    imf_pivot.columns[imf_pivot.isna().all()].tolist()            
2287                )
2288        
2289        # Create pivot table and reindex to include all samples (even those with no features)
2290        pivot = mf_pivot.pivot(index = 'cluster', columns = 'sample_name', values = attribute)
2291        
2292        # Reindex columns to include all samples in the collection
2293        all_samples = self.samples
2294        pivot = pivot.reindex(columns=all_samples)
2295        
2296        return pivot

Generate a pivot table of all regular and induced mass features in a collection. Default attribute presented is the mass feature ID, also prints a list of other available attributes.

Parameters
  • attribute (str): The desired attribute to be presented in the pivot table. Defaults to mass feature ID
  • verbose (boolean): Print out all the possible values the fill the pivot table and list attributes that are not collected for induced mass features
Returns
  • pd.DataFrame: A DataFrame that displays one given attribute across all clusters and samples in a collection
def cluster_representatives_table(self):
2298    def cluster_representatives_table(self):
2299        """Generate a table of representative mass features from each consensus cluster.
2300        
2301        This method returns a DataFrame containing all attributes for the
2302        representative mass feature from each consensus cluster. The representative
2303        is selected using the same logic as process_consensus_features().
2304
2305        Returns
2306        --------
2307        pd.DataFrame
2308            A DataFrame with one row per cluster containing all attributes for 
2309            each cluster's representative mass feature. Includes:
2310            - cluster: cluster ID (as a column for easy joining)
2311            - polarity: ionization polarity from the collection
2312            - n_samples_detected: number of samples where the cluster was detected
2313            - All other mass feature attributes from the representative
2314            
2315        Notes
2316        -----
2317        The representative metric used is determined by
2318        self.parameters.lcms_collection.consensus_representative_metric and
2319        is the same metric used by process_consensus_features() for consistency.
2320        Common options include 'intensity' (highest intensity) or 
2321        'intensity_prefer_ms2' (highest intensity with preference for MS2 data).
2322        """
2323        
2324        mf_df = self.mass_features_dataframe.copy()
2325        mf_df.reset_index(inplace = True)
2326        
2327        # Include induced mass features if they exist (from gap-filling)
2328        if self.induced_mass_features_dataframe is not None:
2329            imf_df = self.induced_mass_features_dataframe.copy()
2330            imf_df.reset_index(inplace = True)
2331            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
2332            mf_df = pd.concat([mf_df, imf_df], axis = 0)
2333            mf_df.reset_index(drop = True, inplace = True)
2334        mf_df['cluster'] = mf_df['cluster'].astype(int)
2335        
2336        # Calculate number of samples per cluster
2337        cluster_sample_counts = mf_df.groupby('cluster')['sample_id'].nunique().to_dict()
2338        
2339        # Use the same representative selection logic as process_consensus_features
2340        # This uses the configured representative_metric from parameters
2341        representatives = self.get_representative_mass_features_for_all_clusters()
2342        
2343        # Get the coll_mf_ids of the representatives
2344        representative_ids = representatives['coll_mf_id'].tolist()
2345        
2346        # Filter mf_df to only include representative features
2347        consensus_report = mf_df[mf_df.coll_mf_id.isin(representative_ids)].copy()
2348        
2349        # Add polarity (get from first sample in collection)
2350        if len(self) > 0:
2351            polarity = self[0].polarity
2352        else:
2353            polarity = 'unknown'
2354        consensus_report['polarity'] = polarity
2355        
2356        # Add number of samples detected
2357        consensus_report['n_samples_detected'] = consensus_report['cluster'].map(cluster_sample_counts)
2358        
2359        # Reorder columns to put cluster at the front
2360        cols = consensus_report.columns.tolist()
2361        if 'cluster' in cols:
2362            cols.remove('cluster')
2363            cols = ['cluster'] + cols
2364            consensus_report = consensus_report[cols]
2365        
2366        # Sort by cluster and return with cluster as a regular column
2367        return consensus_report.sort_values(by='cluster')

Generate a table of representative mass features from each consensus cluster.

This method returns a DataFrame containing all attributes for the representative mass feature from each consensus cluster. The representative is selected using the same logic as process_consensus_features().

Returns
  • pd.DataFrame: A DataFrame with one row per cluster containing all attributes for each cluster's representative mass feature. Includes:
    • cluster: cluster ID (as a column for easy joining)
    • polarity: ionization polarity from the collection
    • n_samples_detected: number of samples where the cluster was detected
    • All other mass feature attributes from the representative
Notes

The representative metric used is determined by self.parameters.lcms_collection.consensus_representative_metric and is the same metric used by process_consensus_features() for consistency. Common options include 'intensity' (highest intensity) or 'intensity_prefer_ms2' (highest intensity with preference for MS2 data).

def feature_annotations_table( self, molecular_metadata=None, drop_unannotated=False, report_best_only=False):
2369    def feature_annotations_table(
2370            self, 
2371            molecular_metadata=None, 
2372            drop_unannotated=False,
2373            report_best_only=False
2374            ):
2375        """Generate a comprehensive annotation table for all loaded mass features across samples.
2376        
2377        This method consolidates MS1 molecular formula assignments and MS2 spectral 
2378        search results for all mass features across all samples in the collection.
2379        Only includes representative mass features (one per cluster per sample).
2380        
2381        Parameters
2382        ----------
2383        molecular_metadata : dict, optional
2384            Dictionary of MolecularMetadata objects, keyed by metabref_mol_id.
2385            Required for including molecular metadata in MS2 annotations.
2386            Default is None.
2387        drop_unannotated : bool, optional
2388            If True, drops rows where all annotation columns (everything except 
2389            cluster, MS2 Spectrum, and representative_sample) are NaN.
2390            Default is False.
2391        report_best_only : bool, optional
2392            If True, only includes the best MS2 annotation per mass feature based on confidence score.
2393            Default is False, which includes all MS2 annotations for each mass feature.
2394        
2395        Returns
2396        -------
2397        pd.DataFrame
2398            Consolidated annotation report with columns including:
2399            - cluster: cluster ID
2400            - sample_name: sample name
2401            - sample_id: sample ID
2402            - Mass Feature ID: mass feature ID within the sample
2403            - Mass feature attributes (mz, scan_time, intensity, etc.)
2404            - MS1 annotations (if molecular_formula_search was run)
2405            - MS2 annotations (if ms2_spectral_search was run)
2406        
2407        Notes
2408        -----
2409        This method uses the standard LCMSMetabolomicsExport.to_report() workflow
2410        for each sample, then consolidates all results and adds cluster information.
2411        
2412        Only mass features that are loaded in each sample's mass_features dict
2413        are included (typically the representative features if load_representatives
2414        was used in process_consensus_features).
2415        
2416        Raises
2417        ------
2418        ValueError
2419            If no representative features have been loaded. Call process_consensus_features
2420            with load_representatives=True first.
2421        ValueError
2422            If no samples with loaded mass features are found in the collection.
2423        """
2424        from corems.mass_spectra.output.export import LCMSMetabolomicsExport
2425        import warnings
2426        
2427        # Check if representative features have been loaded
2428        # Count samples with mass features loaded
2429        samples_with_features = sum(
2430            1 for lcms_obj in self 
2431            if hasattr(lcms_obj, 'mass_features') and len(lcms_obj.mass_features) > 0
2432        )
2433        
2434        if samples_with_features == 0:
2435            raise ValueError(
2436                "No representative mass features have been loaded into individual samples. "
2437                "Call process_consensus_features() with load_representatives=True before "
2438                "calling feature_annotations_table()."
2439            )
2440        
2441        # Collect reports from all samples
2442        all_sample_reports = []
2443        has_any_ms2_annotations = False
2444        
2445        for sample_id, lcms_obj in enumerate(self):
2446            # Skip samples with no loaded mass features
2447            if not hasattr(lcms_obj, 'mass_features') or len(lcms_obj.mass_features) == 0:
2448                continue
2449            
2450            sample_name = self.samples[sample_id]
2451            
2452            # Create exporter and generate report using standard workflow
2453            # Suppress individual warnings - we'll warn at collection level if needed
2454            exporter = LCMSMetabolomicsExport("temp", lcms_obj)
2455            sample_report = exporter.to_report(molecular_metadata=molecular_metadata, suppress_warnings=True)
2456            
2457            # Check if this sample has any MS2 annotations
2458            ms2_cols = [col for col in sample_report.columns if 'Entropy Similarity' in col or 'spectral_similarity' in col.lower()]
2459            if ms2_cols and sample_report[ms2_cols].notna().any().any():
2460                has_any_ms2_annotations = True
2461            
2462            # Add sample information
2463            sample_report['representative_sample'] = sample_name
2464            sample_report['sample_id'] = sample_id
2465            
2466            # Get cluster information from the mass_features_dataframe
2467            # Build coll_mf_id for each row to look up cluster
2468            sample_report['coll_mf_id'] = sample_report['sample_id'].astype(str) + "_" + sample_report['Mass Feature ID'].astype(str)
2469            
2470            # Get cluster from mass_features_dataframe
2471            if self.mass_features_dataframe is not None and 'cluster' in self.mass_features_dataframe.columns:
2472                mf_df = self.mass_features_dataframe.reset_index()
2473                cluster_lookup = mf_df.set_index('coll_mf_id')['cluster'].to_dict()
2474                sample_report['cluster'] = sample_report['coll_mf_id'].map(cluster_lookup)
2475            else:
2476                sample_report['cluster'] = None
2477            
2478            # Drop temporary coll_mf_id column
2479            sample_report = sample_report.drop(columns=['coll_mf_id'])
2480            
2481            all_sample_reports.append(sample_report)
2482        
2483        # Combine all sample reports
2484        if len(all_sample_reports) == 0:
2485            raise ValueError("No samples with loaded mass features found in collection")
2486        
2487        collection_report = pd.concat(all_sample_reports, ignore_index=True)
2488        
2489        # Warn only if NO samples in the collection have MS2 annotations
2490        if not has_any_ms2_annotations:
2491            warnings.warn(
2492                "No MS2 annotations found across any samples in collection, were MS2 spectra added and searched against a database?",
2493                UserWarning,
2494            )
2495        
2496        # Reorder columns to match specified order
2497        desired_cols = [
2498            'cluster',
2499            'Isotopologue Type',
2500            'Is Largest Ion after Deconvolution',
2501            'MS2 Spectrum',
2502            'Calculated m/z',
2503            'm/z Error (ppm)',
2504            'm/z Error Score',
2505            'Isotopologue Similarity',
2506            'Confidence Score',
2507            'Ion Formula',
2508            'Ion Type',
2509            'Molecular Formula',
2510            'inchikey',
2511            'name',
2512            'ref_ms_id',
2513            'Entropy Similarity',
2514            'Library mzs in Query (fraction)',
2515            'Spectra with Annotation (n)',
2516            'representative_sample'
2517        ]
2518        
2519        # Include only desired columns that exist, maintaining order
2520        cols = [col for col in desired_cols if col in collection_report.columns]
2521        collection_report = collection_report[cols]
2522        
2523        # Optionally drop rows without any annotations
2524        if drop_unannotated:
2525            # Columns to exclude from the "all NA" check
2526            exclude_cols = ['cluster', 'MS2 Spectrum', 'representative_sample']
2527            # Get annotation columns (everything except the excluded ones)
2528            annot_cols = [col for col in collection_report.columns if col not in exclude_cols]
2529            # Keep rows where at least one annotation column is not NA
2530            if len(annot_cols) > 0:
2531                collection_report = collection_report[collection_report[annot_cols].notna().any(axis=1)]
2532        
2533        # Sort by cluster, then by annotation quality
2534        sort_cols = ['cluster']
2535        if 'Entropy Similarity' in collection_report.columns:
2536            sort_cols.extend(['Entropy Similarity', 'Confidence Score'])
2537            collection_report = collection_report.sort_values(
2538                by=sort_cols,
2539                ascending=[True, False, False]
2540            )
2541        elif 'Confidence Score' in collection_report.columns:
2542            sort_cols.append('Confidence Score')
2543            collection_report = collection_report.sort_values(
2544                by=sort_cols,
2545                ascending=[True, False]
2546            )
2547        else:
2548            collection_report = collection_report.sort_values(by=sort_cols)
2549        
2550        if report_best_only:
2551            # Keep only the best annotation per cluster based on the first annotation column available
2552            if 'Entropy Similarity' in collection_report.columns:
2553                best_annot_col = 'Entropy Similarity'
2554            elif 'Confidence Score' in collection_report.columns:
2555                best_annot_col = 'Confidence Score'
2556            else:
2557                best_annot_col = None
2558            
2559            if best_annot_col is not None:
2560                collection_report = collection_report.sort_values(by=['cluster', best_annot_col], ascending=[True, False])
2561                collection_report = collection_report.drop_duplicates(subset=['cluster'], keep='first')
2562
2563        return collection_report

Generate a comprehensive annotation table for all loaded mass features across samples.

This method consolidates MS1 molecular formula assignments and MS2 spectral search results for all mass features across all samples in the collection. Only includes representative mass features (one per cluster per sample).

Parameters
  • molecular_metadata (dict, optional): Dictionary of MolecularMetadata objects, keyed by metabref_mol_id. Required for including molecular metadata in MS2 annotations. Default is None.
  • drop_unannotated (bool, optional): If True, drops rows where all annotation columns (everything except cluster, MS2 Spectrum, and representative_sample) are NaN. Default is False.
  • report_best_only (bool, optional): If True, only includes the best MS2 annotation per mass feature based on confidence score. Default is False, which includes all MS2 annotations for each mass feature.
Returns
  • pd.DataFrame: Consolidated annotation report with columns including:
    • cluster: cluster ID
    • sample_name: sample name
    • sample_id: sample ID
    • Mass Feature ID: mass feature ID within the sample
    • Mass feature attributes (mz, scan_time, intensity, etc.)
    • MS1 annotations (if molecular_formula_search was run)
    • MS2 annotations (if ms2_spectral_search was run)
Notes

This method uses the standard LCMSMetabolomicsExport.to_report() workflow for each sample, then consolidates all results and adds cluster information.

Only mass features that are loaded in each sample's mass_features dict are included (typically the representative features if load_representatives was used in process_consensus_features).

Raises
  • ValueError: If no representative features have been loaded. Call process_consensus_features with load_representatives=True first.
  • ValueError: If no samples with loaded mass features are found in the collection.
parameters
2565    @property
2566    def parameters(self):
2567        """
2568        LCMSCollectionParameters : The parameters used for the LCMS collection.
2569        """
2570        return self._parameters

LCMSCollectionParameters : The parameters used for the LCMS collection.

mass_features_dataframe
2584    @property
2585    def mass_features_dataframe(self):
2586        self._check_mass_features_df()
2587        return self._combined_mass_features
induced_mass_features_dataframe
2603    @property
2604    def induced_mass_features_dataframe(self):
2605        self._check_mass_features_df(induced_features = True)
2606        if self._combined_induced_mass_features is not None and len(self._combined_induced_mass_features) > 0:
2607            # The cluster column is extracted from mf_id in _prepare_lcms_mass_features_for_combination
2608            # mf_id format for induced features: c{cluster}_{index}_i
2609            pass
2610        return self._combined_induced_mass_features
cluster_summary_dataframe
2626    @property
2627    def cluster_summary_dataframe(self):
2628        return self.summarize_clusters()
samples
2630    @property
2631    def samples(self):
2632        manifest_df = self.manifest_dataframe
2633        # order by batch, then by order
2634        manifest_df = manifest_df.sort_values(by=['batch', 'order'])
2635        return manifest_df.index.tolist()
manifest
2637    @property
2638    def manifest(self):
2639        return self._manifest_dict
manifest_dataframe
2641    @property
2642    def manifest_dataframe(self):
2643        return pd.DataFrame(self._manifest_dict).T
raw_files
2645    @property
2646    def raw_files(self):
2647        """Returns a list of raw files in the collection."""
2648        return [x.raw_file_location for x in self]

Returns a list of raw files in the collection.

rt_alignments
2650    @property
2651    def rt_alignments(self):
2652        """Returns a dictionary of retention time alignments for the collection."""
2653        if self.rt_aligned:
2654            _rt_alignments = {}
2655            # Construct a dictionary of aligned retention times (stored on each LCMS object within the collection, not the collection itself)
2656            for i, lcms_obj in enumerate(self):
2657                aligned_times = [x for k, x in sorted(lcms_obj._scan_info["scan_time_aligned"].items())]
2658                _rt_alignments[i] = aligned_times
2659            return _rt_alignments
2660        else:
2661            return None

Returns a dictionary of retention time alignments for the collection.

cluster_feature_dictionary
2663    @property
2664    def cluster_feature_dictionary(self):
2665        """Generates a dictionary with clusters for keys and mass feature IDs as entries"""
2666        df = self.mass_features_dataframe
2667        cluster_dict = df.groupby('cluster').apply(lambda x: x.index.tolist()).to_dict()
2668        return cluster_dict

Generates a dictionary with clusters for keys and mass feature IDs as entries

def get_eics_for_cluster(self, cluster_id):
2670    def get_eics_for_cluster(self, cluster_id):
2671        """
2672        Retrieve all EICs for mass features in a specific cluster across all samples.
2673        
2674        Returns a dictionary mapping sample names to EIC_Data objects for the given cluster.
2675        Useful for visualizing and comparing chromatographic peaks across samples.
2676        
2677        Parameters
2678        ----------
2679        cluster_id : int
2680            The cluster ID to retrieve EICs for
2681            
2682        Returns
2683        -------
2684        dict
2685            Dictionary with structure: {sample_name: EIC_Data object}
2686            Only includes samples where the EIC was loaded.
2687            
2688        Examples
2689        --------
2690        >>> # Load EICs first
2691        >>> collection.process_consensus_features(gather_eics=True, ...)
2692        >>> 
2693        >>> # Get all EICs for cluster 5
2694        >>> eics = collection.get_eics_for_cluster(5)
2695        >>> for sample_name, eic_data in eics.items():
2696        ...     print(f"{sample_name}: {len(eic_data.scans)} scans")
2697        
2698        Notes
2699        -----
2700        Requires that EICs have been loaded using gather_eics=True in
2701        process_consensus_features() or manually loaded via LoadEICsOperation.
2702        """
2703        eics_by_sample = {}
2704        
2705        # Iterate through all samples
2706        for sample_id, sample in enumerate(self):
2707            sample_name = self.samples[sample_id]
2708            
2709            # Check if sample has EICs loaded
2710            if not hasattr(sample, 'eics') or not sample.eics:
2711                continue
2712            
2713            # Find mass features in this cluster for this sample
2714            # Check both regular and induced mass features
2715            for mf in list(sample.mass_features.values()) + list(sample.induced_mass_features.values()):
2716                if hasattr(mf, 'cluster_index') and mf.cluster_index == cluster_id:
2717                    # Get the EIC for this mass feature's m/z
2718                    if mf.mz in sample.eics:
2719                        eics_by_sample[sample_name] = sample.eics[mf.mz]
2720                        break  # Found the EIC for this sample, move to next sample
2721        
2722        return eics_by_sample

Retrieve all EICs for mass features in a specific cluster across all samples.

Returns a dictionary mapping sample names to EIC_Data objects for the given cluster. Useful for visualizing and comparing chromatographic peaks across samples.

Parameters
  • cluster_id (int): The cluster ID to retrieve EICs for
Returns
  • dict: Dictionary with structure: {sample_name: EIC_Data object} Only includes samples where the EIC was loaded.
Examples
>>> # Load EICs first
>>> collection.process_consensus_features(gather_eics=True, ...)
>>> 
>>> # Get all EICs for cluster 5
>>> eics = collection.get_eics_for_cluster(5)
>>> for sample_name, eic_data in eics.items():
...     print(f"{sample_name}: {len(eic_data.scans)} scans")
Notes

Requires that EICs have been loaded using gather_eics=True in process_consensus_features() or manually loaded via LoadEICsOperation.