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