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
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.
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 = {}
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.
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.
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.
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
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.
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).
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
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 = {}
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.
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.
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.
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.
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.
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?
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Inherited Members
- MassSpectraBase
- file_location
- analyzer
- instrument_label
- spectra_parser
- raw_file_location
- add_mass_spectrum
- add_mass_spectra
- get_time_of_scan_id
- scan_df
- ms
- corems.mass_spectra.calc.lc_calc.LCCalculations
- get_max_eic
- smooth_tic
- eic_centroid_detector
- find_nearest_scan
- add_peak_metrics
- get_average_mass_spectrum
- find_mass_features
- integrate_mass_features
- find_c13_mass_features
- deconvolute_ms1_mass_features
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
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
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.
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.
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.
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
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/")
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
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).
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.
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.
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
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.
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.
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
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.
Inherited Members
- corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations
- clean_sparse_matrix
- match_mfs
- fit_rts
- get_anchor_mass_features
- attempt_alignment
- align_lcms_objects
- add_consensus_mass_features
- summarize_clusters
- plot_mz_features_per_cluster
- plot_mz_features_across_samples
- plot_consensus_mz_features
- plot_cluster
- get_representative_mass_features_for_all_clusters
- get_sample_mf_map_for_representatives
- get_most_representative_sample_for_cluster
- reload_representative_mass_features
- add_sparse_distance_matrix
- evaluate_clusters_for_repeats
- check_merge
- cluster_mass_features_agg_cluster
- cluster_inspection_plot
- plot_cluster_outlier_frequency
- fill_missing_cluster_features
- process_samples_pipeline
- process_consensus_features