corems.molecular_id.search.database_interfaces
1import os 2import re 3import warnings 4from abc import ABC 5from io import StringIO 6from pathlib import Path 7import sqlite3 8 9import numpy as np 10import requests 11import pandas as pd 12from ms_entropy import FlashEntropySearch 13 14from corems.molecular_id.factory.EI_SQL import ( 15 EI_LowRes_SQLite, 16 Metadatar, 17 MetaboliteMetadata, 18) 19from corems.molecular_id.factory.lipid_molecular_metadata import LipidMetadata 20from corems.mass_spectra.calc.lc_calc import find_closest 21from corems.molecular_id.search import spectral_library_cache 22 23 24def _resolve_fe_kwargs(settings=None, fe_kwargs=None) -> dict: 25 """Merge SpectralSimilaritySearchSettings.as_fe_kwargs() with optional fe_kwargs overrides.""" 26 base = settings.as_fe_kwargs() if settings is not None else {} 27 if fe_kwargs: 28 base = {**base, **dict(fe_kwargs)} 29 return base 30 31 32class SpectralDatabaseInterface(ABC): 33 """ 34 Base class that facilitates connection to spectral reference databases, 35 such as EMSL's Metabolomics Reference Database (MetabRef). 36 37 """ 38 39 def __init__(self, key=None): 40 """ 41 Initialize instance. 42 43 Parameters 44 ---------- 45 key : str 46 Token key. 47 48 """ 49 50 self.key = key 51 52 def set_token(self, path): 53 """ 54 Set environment variable for MetabRef database token. 55 56 Parameters 57 ---------- 58 path : str 59 Path to token. 60 61 """ 62 63 # Read token from file 64 with open(path, "r", encoding="utf-8") as f: 65 token = f.readline().strip() 66 67 # Set environment variable 68 os.environ[self.key] = token 69 70 def get_token(self): 71 """ 72 Get environment variable for database token. 73 74 Returns 75 ------- 76 str 77 Token string. 78 79 """ 80 81 # Check for token 82 if self.key not in os.environ: 83 raise ValueError("Must set {} environment variable.".format(self.key)) 84 85 # Get token from environment variables 86 return os.environ.get(self.key) 87 88 def get_header(self): 89 """ 90 Access stored database token and prepare as header. 91 92 Returns 93 ------- 94 str 95 Header string. 96 97 """ 98 99 # Get token 100 token = self.get_token() 101 102 # Pad header information 103 header = {"Authorization": f"Bearer {token}", "Content-Type": "text/plain"} 104 105 return header 106 107 def get_query(self, url, use_header=True): 108 """ 109 Request payload from URL according to `get` protocol. 110 111 Parameters 112 ---------- 113 url : str 114 URL for request. 115 use_header: bool 116 Whether or not the query should include the header 117 118 Returns 119 ------- 120 dict 121 Response as JSON. 122 123 """ 124 125 # Query URL via `get` 126 if use_header: 127 response = requests.get(url, headers=self.get_header()) 128 else: 129 response = requests.get(url) 130 131 # Check response 132 response.raise_for_status() 133 134 # Return as JSON 135 return response.json() 136 137 def post_query(self, url, variable, values, tolerance): 138 """ 139 Request payload from URL according to `post` protocol. 140 141 Parameters 142 ---------- 143 url : str 144 URL for request. 145 variable : str 146 Variable to query. 147 values : str 148 Specific values of `variable` to query. 149 tolerance : str 150 Query tolerance relative to `values`. 151 152 Returns 153 ------- 154 dict 155 Response as JSON. 156 157 """ 158 159 # Coerce to string 160 if not isinstance(variable, str): 161 variable = str(variable).replace(" ", "") 162 163 if not isinstance(values, str): 164 values = str(values).replace(" ", "") 165 166 if not isinstance(tolerance, str): 167 tolerance = str(tolerance).replace(" ", "") 168 169 # Query URL via `post` 170 response = requests.post( 171 os.path.join(url, variable, tolerance), 172 data=values, 173 headers=self.get_header(), 174 ) 175 176 # Check response 177 response.raise_for_status() 178 179 # Return as JSON 180 return response.json() 181 182 def _check_flash_entropy_kwargs(self, fe_kwargs): 183 """ 184 Check FlashEntropy keyword arguments. 185 186 Parameters 187 ---------- 188 fe_kwargs : dict 189 Keyword arguments for FlashEntropy search. 190 191 192 Raises 193 ------ 194 ValueError 195 If "min_ms2_difference_in_da" or "max_ms2_tolerance_in_da" are present in `fe_kwargs` and they 196 are not equal. 197 198 """ 199 # If "min_ms2_difference_in_da" in fe_kwargs, check that "max_ms2_tolerance_in_da" is also present and that min_ms2_difference_in_da = 2xmax_ms2_tolerance_in_da 200 if ( 201 "min_ms2_difference_in_da" in fe_kwargs 202 or "max_ms2_tolerance_in_da" in fe_kwargs 203 ): 204 if ( 205 "min_ms2_difference_in_da" not in fe_kwargs 206 or "max_ms2_tolerance_in_da" not in fe_kwargs 207 ): 208 raise ValueError( 209 "Both 'min_ms2_difference_in_da' and 'max_ms2_tolerance_in_da' must be specified." 210 ) 211 if ( 212 fe_kwargs["min_ms2_difference_in_da"] 213 != 2 * fe_kwargs["max_ms2_tolerance_in_da"] 214 ): 215 raise ValueError( 216 "The values of 'min_ms2_difference_in_da' must be exactly 2x 'max_ms2_tolerance_in_da'." 217 ) 218 219 def _get_format_func(self, format): 220 """ 221 Obtain format function by key. 222 223 Returns 224 ------- 225 func 226 Formatting function. 227 """ 228 229 if format.lower() in self.format_map.keys(): 230 return self.format_map[format.lower()] 231 232 raise ValueError(("{} not a supported format.").format(format)) 233 234 def _dict_to_dataclass(self, source_dict, data_class): 235 """ 236 Convert dictionary to dataclass. 237 238 Notes 239 ----- 240 This function will pull the attributes a dataclass and its parent class 241 and convert the dictionary to a dataclass instance with the appropriate 242 attributes. 243 244 Parameters 245 ---------- 246 data_class : :obj:`~dataclasses.dataclass` 247 Dataclass to convert to. 248 source_dict : dict 249 Dictionary object to convert to dataclass. 250 251 Returns 252 ------- 253 :obj:`~dataclasses.dataclass` 254 Dataclass instance. 255 256 """ 257 258 # Get list of expected attributes of data_class 259 data_class_keys = list(data_class.__annotations__.keys()) 260 261 # Does the data_class inherit from another class, if so, get the attributes of the parent class as well 262 if len(data_class.__mro__) > 2: 263 parent_class_keys = list(data_class.__bases__[0].__annotations__.keys()) 264 data_class_keys = list(set(data_class_keys + parent_class_keys)) 265 266 # Remove keys that are not in the data_class from the input dictionary 267 input_dict = {k: v for k, v in source_dict.items() if k in data_class_keys} 268 269 # Add keys that are in the data class but not in the input dictionary as None 270 for key in data_class_keys: 271 if key not in input_dict.keys(): 272 input_dict[key] = None 273 return data_class(**input_dict) 274 275 def _spectrum_to_array(self, spectrum, normalize=True): 276 """ 277 Convert a parenthesis-delimited spectrum string to array. 278 279 Parameters 280 ---------- 281 spectrum : str 282 Spectrum string, i.e. list of (m/z,abundance) pairs. 283 normalize : bool 284 Normalize the spectrum by its magnitude. 285 286 Returns 287 ------- 288 :obj:`~numpy.array` 289 Array of shape (N, 2), with m/z in the first column and abundance in 290 the second. 291 """ 292 293 arr = np.array( 294 re.findall(r"\(([^,]+),([^)]+)\)", spectrum), dtype=float 295 ).reshape(-1, 2) 296 297 if normalize: 298 arr = self.normalize_peaks(arr) 299 300 return arr 301 302 @staticmethod 303 def normalize_peaks(arr): 304 """ 305 Normalize peaks in an array. 306 307 Parameters 308 ---------- 309 arr : :obj:`~numpy.array` 310 Array of shape (N, 2), with m/z in the first column and abundance in 311 the second. 312 313 Returns 314 ------- 315 :obj:`~numpy.array` 316 Normalized array of shape (N, 2), with m/z in the first column and 317 normalized abundance in the second. 318 """ 319 # Normalize the array 320 arr[:, -1] = arr[:, -1] / arr[:, -1].sum() 321 322 return arr 323 324 @staticmethod 325 def _build_flash_entropy_index(fe_lib, fe_kwargs={}, clean_spectra=True): 326 """ 327 Build FlashEntropy index. 328 329 Parameters 330 ---------- 331 fe_lib : list 332 List of spectra to build index from. Can be a list of dictionaries or 333 a FlashEntropy search instance. 334 fe_kwargs : dict, optional 335 Keyword arguments for FlashEntropy search. 336 clean_spectra : bool, optional 337 Clean spectra before building index. Default is True. 338 339 Returns 340 ------- 341 :obj:`~ms_entropy.FlashEntropySearch` 342 FlashEntropy search instance. 343 344 """ 345 # Initialize FlashEntropy 346 fe_init_kws = [ 347 "max_ms2_tolerance_in_da", 348 "mz_index_step", 349 "low_memory", 350 "path_data", 351 ] 352 fe_init_kws = {k: v for k, v in fe_kwargs.items() if k in fe_init_kws} 353 fes = FlashEntropySearch(**fe_init_kws) 354 355 # Build FlashEntropy index 356 fe_index_kws = [ 357 "max_indexed_mz", 358 "precursor_ions_removal_da", 359 "noise_threshold", 360 "min_ms2_difference_in_da", 361 "max_peak_num", 362 ] 363 fe_index_kws = {k: v for k, v in fe_kwargs.items() if k in fe_index_kws} 364 fes.build_index(fe_lib, **fe_index_kws, clean_spectra=clean_spectra) 365 366 # Store build_index parameters as custom attributes for later retrieval 367 # (FlashEntropy doesn't store these natively) 368 for key, value in fe_index_kws.items(): 369 setattr(fes, f"_build_{key}", value) 370 371 return fes 372 373 374class MetabRefInterface(SpectralDatabaseInterface): 375 """ 376 DEPRECATED interface retained for backward compatibility only. 377 """ 378 379 def __init__(self): 380 """ 381 Initialize instance with deprecation warning. 382 383 """ 384 385 super().__init__(key=None) 386 387 if self.__class__ is MetabRefInterface: 388 warnings.warn( 389 "MetabRefInterface is deprecated. Instantiate a concrete interface " 390 "such as GCMSLibraryInterface or LCLipidLibraryInterface instead.", 391 DeprecationWarning, 392 stacklevel=2, 393 ) 394 395 396class GCMSLibraryInterface(SpectralDatabaseInterface): 397 """ 398 Interface to bundled GCMS spectral libraries in MSP format. 399 400 Loads GCMS compound library and FAMES calibration library from local MSP files. 401 Default files are bundled with CoreMS, but can be overridden via environment variables. 402 """ 403 404 def __init__(self): 405 """ 406 Initialize instance. 407 """ 408 super().__init__(key=None) 409 410 # Local data file paths 411 from pathlib import Path 412 413 # Default to bundled data files 414 data_dir = Path(__file__).parent.parent / "data" 415 self.gcms_library_file = os.getenv( 416 "GCMS_LIBRARY_PATH", str(data_dir / "PNNLMetV20191015.msp") 417 ) 418 self.fames_library_file = os.getenv( 419 "FAMES_LIBRARY_PATH", str(data_dir / "FAMES_REF.msp") 420 ) 421 422 self.__init_format_map__() 423 424 def __init_format_map__(self): 425 """ 426 Initialize database format mapper, enabling multiple format requests. 427 428 """ 429 430 # Define format workflows 431 self.format_map = { 432 "json": lambda x, normalize, fe_kwargs: x, 433 "dict": lambda x, normalize, fe_kwargs: ( 434 self._to_LowResolutionEICompound_dict(x, normalize) 435 ), 436 "sql": lambda x, normalize, fe_kwargs: ( 437 self._LowResolutionEICompound_dict_to_sqlite( 438 self._to_LowResolutionEICompound_dict(x, normalize) 439 ) 440 ), 441 } 442 443 # Add aliases 444 self.format_map["metabref"] = self.format_map["json"] 445 self.format_map["datadict"] = self.format_map["dict"] 446 self.format_map["data-dict"] = self.format_map["dict"] 447 self.format_map["lowreseicompound"] = self.format_map["dict"] 448 self.format_map["lowres"] = self.format_map["dict"] 449 self.format_map["lowresgc"] = self.format_map["dict"] 450 self.format_map["sqlite"] = self.format_map["sql"] 451 452 def available_formats(self): 453 """ 454 View list of available formats. 455 456 Returns 457 ------- 458 list 459 Format map keys. 460 """ 461 462 return list(self.format_map.keys()) 463 464 def get_library(self, format="json", normalize=False): 465 """ 466 Load GC/MS library from local MSP file. 467 468 Parameters 469 ---------- 470 format : str 471 Format of requested library, i.e. "json", "sql", "dict". 472 See `available_formats` method for aliases. 473 normalize : bool 474 Normalize the spectrum by its magnitude. 475 476 Returns 477 ------- 478 Library in requested format. 479 480 """ 481 # Load from local MSP file 482 library_data = self._load_msp_file(self.gcms_library_file, normalize) 483 484 # Init format function 485 format_func = self._get_format_func(format) 486 487 # Apply format conversion 488 return format_func(library_data, normalize, {}) 489 490 def get_fames(self, format="json", normalize=False): 491 """ 492 Load GC/MS FAMEs library from local MSP file. 493 494 Parameters 495 ---------- 496 format : str 497 Format of requested library, i.e. "json", "sql", "dict". 498 See `available_formats` method for aliases. 499 normalize : bool 500 Normalize the spectrum by its magnitude. 501 502 Returns 503 ------- 504 Library in requested format. 505 506 """ 507 # Load from local MSP file 508 library_data = self._load_msp_file(self.fames_library_file, normalize) 509 510 # Init format function 511 format_func = self._get_format_func(format) 512 513 # Apply format conversion 514 return format_func(library_data, normalize, {}) 515 516 def _load_msp_file(self, file_path, normalize=False): 517 """ 518 Load and parse MSP file into format compatible with existing pipeline. 519 520 Parameters 521 ---------- 522 file_path : str 523 Path to MSP file 524 normalize : bool 525 Normalize spectra 526 527 Returns 528 ------- 529 list of dict 530 Library data in format compatible with _to_LowResolutionEICompound_dict 531 """ 532 from pathlib import Path 533 534 file_path = Path(file_path) 535 if not file_path.exists(): 536 raise FileNotFoundError( 537 f"Library file not found: {file_path}. " 538 f"Set GCMS_LIBRARY_PATH or FAMES_LIBRARY_PATH environment variable to specify location." 539 ) 540 541 # Parse MSP file 542 spectra = [] 543 spectrum = {} 544 peaks = [] 545 546 with open(file_path, "r") as f: 547 for line in f: 548 line = line.strip() 549 550 # Empty line marks end of spectrum 551 if not line: 552 if spectrum and peaks: 553 # Convert peaks to the format expected by downstream code 554 # Format: "(mz,abundance)(mz,abundance)..." 555 peak_str = "".join( 556 [f"({int(mz)},{int(abun)})" for mz, abun in peaks] 557 ) 558 spectrum["mz"] = peak_str 559 spectra.append(spectrum) 560 spectrum = {} 561 peaks = [] 562 continue 563 564 # Check if line contains peak data (starts with digit) 565 if line and line[0].isdigit(): 566 parts = line.split() 567 if len(parts) >= 2: 568 peaks.append((float(parts[0]), float(parts[1]))) 569 continue 570 571 # Handle metadata fields 572 if ":" in line: 573 key, value = line.split(":", 1) 574 key = key.strip().lower() 575 value = value.strip() 576 577 # Map MSP fields to expected format 578 field_mapping = { 579 "name": "molecule_name", 580 "formula": "formula", 581 "cas": "casno", 582 "retentiontime": "retention_time", 583 "ri": "ri", 584 "comment": "comments", 585 "num peaks": "peak_count", 586 "derivative": "derivative", 587 } 588 589 # Metadata fields that go into the metadata dict 590 metadata_fields = { 591 "inchikey": "inchikey", 592 "inchi": "inchi", 593 "smiles": "smiles", 594 "pubchem": "pubchem", 595 "chebi": "chebi", 596 "kegg": "kegg", 597 "refmet": "refmet", 598 "iupac_name": "iupac_name", 599 } 600 601 if key in field_mapping: 602 mapped_key = field_mapping[key] 603 # Convert numeric fields 604 if key in ["retentiontime", "ri"]: 605 try: 606 value = float(value) 607 except: 608 pass 609 elif key == "num peaks": 610 try: 611 value = int(value) 612 except: 613 pass 614 spectrum[mapped_key] = value 615 elif key in metadata_fields: 616 # Store in nested metadata dict 617 if "metadata" not in spectrum: 618 spectrum["metadata"] = {} 619 spectrum["metadata"][metadata_fields[key]] = value 620 else: 621 # Keep unmapped fields 622 spectrum[key] = value 623 624 # Add last spectrum if file doesn't end with blank line 625 if spectrum and peaks: 626 peak_str = "".join([f"({int(mz)},{int(abun)})" for mz, abun in peaks]) 627 spectrum["mz"] = peak_str 628 spectra.append(spectrum) 629 630 return spectra 631 632 def _to_LowResolutionEICompound_dict(self, metabref_lib, normalize=False): 633 """ 634 Convert MetabRef-formatted library to CoreMS LowResolutionEICompound-formatted 635 dictionary for local ingestion. 636 637 Parameters 638 ---------- 639 metabref_lib : dict 640 MetabRef GC-MS library in JSON format. 641 normalize : bool 642 Normalize each spectrum by its magnitude. 643 644 Returns 645 ------- 646 list of dict 647 List of each spectrum contained in dictionary. 648 649 """ 650 651 # All below key:value lookups are based on CoreMS class definitions 652 # NOT MetabRef content. For example, MetabRef has keys for PubChem, 653 # USI, etc. that are not considered below. 654 655 # Dictionary to map metabref keys to corems keys 656 metadatar_cols = { 657 "casno": "cas", 658 "inchikey": "inchikey", 659 "inchi": "inchi", 660 "chebi": "chebi", 661 "smiles": "smiles", 662 "kegg": "kegg", 663 "iupac_name": "iupac_name", 664 "traditional_name": "traditional_name", # Not present in metabref 665 "common_name": "common_name", # Not present in metabref 666 } 667 668 # Dictionary to map metabref keys to corems keys 669 lowres_ei_compound_cols = { 670 "id": "metabref_id", 671 "molecule_name": "name", # Is this correct? 672 "classify": "classify", # Not present in metabref 673 "formula": "formula", 674 "ri": "ri", 675 "rt": "retention_time", 676 "source": "source", # Not present in metabref 677 "casno": "casno", 678 "comments": "comment", 679 "source_temp_c": "source_temp_c", # Not present in metabref 680 "ev": "ev", # Not present in metabref 681 "peak_count": "peaks_count", 682 "mz": "mz", 683 "abundance": "abundance", 684 } 685 686 # Local result container 687 corems_lib = [] 688 689 # Enumerate spectra 690 for i, source_ in enumerate(metabref_lib): 691 # Copy source to prevent modification 692 source = source_.copy() 693 694 # Parse target data 695 target = { 696 lowres_ei_compound_cols[k]: v 697 for k, v in source.items() 698 if k in lowres_ei_compound_cols 699 } 700 701 # Explicitly add this to connect with LowResCompoundRef later 702 if "retention_time" in source: 703 target["rt"] = source["retention_time"] 704 elif "rt" in source: 705 target["rt"] = source["rt"] 706 707 # Parse (mz, abundance) 708 arr = self._spectrum_to_array(target["mz"], normalize=normalize) 709 target["mz"] = arr[:, 0] 710 target["abundance"] = arr[:, 1] 711 712 # Parse meta data 713 target["metadata"] = { 714 metadatar_cols[k]: v for k, v in source.items() if k in metadatar_cols 715 } 716 717 # Add anything else 718 for k in source: 719 if k not in lowres_ei_compound_cols: 720 target[k] = source[k] 721 722 # Add to CoreMS list 723 corems_lib.append(target) 724 725 return corems_lib 726 727 def _LowResolutionEICompound_dict_to_sqlite( 728 self, lowres_ei_compound_dict, url="sqlite://" 729 ): 730 """ 731 Convert CoreMS LowResolutionEICompound-formatted dictionary to SQLite 732 database for local ingestion. 733 734 Parameters 735 ---------- 736 lowres_ei_compound_dict : dict 737 CoreMS GC-MS library formatted for LowResolutionEICompound. 738 url : str 739 URL to SQLite prefix. 740 741 Returns 742 ------- 743 sqlite database 744 Spectra contained in SQLite database. 745 746 """ 747 748 # Dictionary to map corems keys to all-caps keys 749 capped_cols = { 750 "name": "NAME", 751 "formula": "FORM", 752 "ri": "RI", 753 "retention_time": "RT", 754 "source": "SOURCE", 755 "casno": "CASNO", 756 "comment": "COMMENT", 757 "peaks_count": "NUM PEAKS", 758 } 759 760 # Initialize SQLite object 761 sqlite_obj = EI_LowRes_SQLite(url=url) 762 763 # Iterate spectra 764 for _data_dict in lowres_ei_compound_dict: 765 # Copy source to prevent modification 766 data_dict = _data_dict.copy() 767 768 # Add missing capped values 769 for k, v in capped_cols.items(): 770 # Key exists 771 if k in data_dict: 772 # # This will replace the key 773 # data_dict[v] = data_dict.pop(k) 774 775 # This will keep both keys 776 data_dict[v] = data_dict[k] 777 778 # Parse number of peaks 779 if not data_dict.get("NUM PEAKS"): 780 data_dict["NUM PEAKS"] = len(data_dict.get("mz")) 781 782 # Parse CAS number 783 if not data_dict.get("CASNO"): 784 data_dict["CASNO"] = data_dict.get("CAS") 785 786 if not data_dict["CASNO"]: 787 data_dict["CASNO"] = 0 788 789 # Build linked metadata table 790 if "metadata" in data_dict: 791 metadata = data_dict.pop("metadata") 792 # Only create metadata entry if we have required fields and valid data 793 # Filter to only include fields that Metadatar model supports 794 supported_metadata_fields = [ 795 "cas", 796 "inchikey", 797 "inchi", 798 "chebi", 799 "smiles", 800 "kegg", 801 "iupac_name", 802 "traditional_name", 803 "common_name", 804 ] 805 filtered_metadata = { 806 k: v 807 for k, v in metadata.items() 808 if k in supported_metadata_fields and v 809 } 810 # Inchikey is required by the database model 811 if filtered_metadata and filtered_metadata.get("inchikey"): 812 data_dict["metadatar"] = Metadatar(**filtered_metadata) 813 814 # Attempt addition to sqlite 815 try: 816 sqlite_obj.add_compound(data_dict) 817 except: 818 print(data_dict["NAME"]) 819 820 return sqlite_obj 821 822 823class MetabRefGCInterface(GCMSLibraryInterface): 824 """ 825 DEPRECATED: Use GCMSLibraryInterface instead. 826 827 This interface is maintained for backward compatibility only. 828 MetabRef API has been discontinued as of 2026. 829 """ 830 831 def __init__(self): 832 """ 833 Initialize instance with deprecation warning. 834 """ 835 warnings.warn( 836 "MetabRefGCInterface is deprecated. Use GCMSLibraryInterface instead. " 837 "MetabRef API has been discontinued; all data now loads from bundled local MSP files.", 838 DeprecationWarning, 839 stacklevel=2, 840 ) 841 super().__init__() 842 843 844class LCLipidLibraryInterface(SpectralDatabaseInterface): 845 """ 846 Interface to a local sqlite lipid library for LC-MS spectral searches. 847 """ 848 849 DEFAULT_DOWNLOAD_URL = ( 850 "https://nmdcdemo.emsl.pnnl.gov/minio/lipidomics/parameter_files/" 851 "202412_lipid_ref.sqlite" 852 ) 853 854 def __init__(self, db_location=None): 855 """ 856 Initialize instance. 857 858 Parameters 859 ---------- 860 db_location : str | Path, optional 861 Local path to the sqlite lipid library. If omitted, the 862 COREMS_LIPIDOMICS_SQLITE_PATH environment variable is used. 863 """ 864 865 super().__init__(key=None) 866 self.db_location = db_location 867 self.__init_format_map__() 868 869 def _to_flashentropy(self, spectral_library, normalize=True, fe_kwargs={}): 870 """ 871 Convert a spectral library to FlashEntropy format. 872 873 Parameters 874 ---------- 875 spectral_library : dict 876 MS2 library in JSON format or FlashEntropy search instance 877 (for reformatting at different MS2 separation). 878 normalize : bool 879 Normalize each spectrum by its magnitude. 880 fe_kwargs : dict, optional 881 Keyword arguments for instantiation of FlashEntropy search and building index for FlashEntropy search; 882 any keys not recognized will be ignored. By default, all parameters set to defaults. 883 884 Returns 885 ------- 886 :obj:`~ms_entropy.FlashEntropySearch` 887 MS2 library as FlashEntropy search instance. 888 889 Raises 890 ------ 891 ValueError 892 If "min_ms2_difference_in_da" or "max_ms2_tolerance_in_da" are present in `fe_kwargs` and they are not equal. 893 894 """ 895 self._check_flash_entropy_kwargs(fe_kwargs) 896 897 # Initialize empty library 898 fe_lib = [] 899 900 # Enumerate spectra 901 for i, source in enumerate(spectral_library): 902 if "spectrum_data" in source.keys(): 903 spectrum = source["spectrum_data"] 904 else: 905 spectrum = source 906 907 if "precursor_mz" not in spectrum.keys(): 908 spectrum["precursor_mz"] = spectrum.pop("precursor_ion") 909 910 spectrum["peaks"] = self._spectrum_to_array( 911 spectrum["mz"], normalize=normalize 912 ) 913 fe_lib.append(spectrum) 914 915 fe_search = self._build_flash_entropy_index(fe_lib, fe_kwargs=fe_kwargs) 916 917 return fe_search 918 919 def __init_format_map__(self): 920 """ 921 Initialize database format mapper, enabling multiple format requests. 922 """ 923 924 self.format_map = { 925 "json": lambda x, normalize, fe_kwargs: x, 926 "flashentropy": lambda x, normalize, fe_kwargs: self._to_flashentropy( 927 x, normalize, fe_kwargs 928 ), 929 "dataframe": lambda x, normalize, fe_kwargs: pd.DataFrame(x), 930 } 931 932 self.format_map["fe"] = self.format_map["flashentropy"] 933 self.format_map["flash-entropy"] = self.format_map["flashentropy"] 934 self.format_map["df"] = self.format_map["dataframe"] 935 936 def available_formats(self): 937 """ 938 View list of available formats. 939 940 Returns 941 ------- 942 list 943 Format map keys. 944 """ 945 946 return list(self.format_map.keys()) 947 948 def _resolve_db_location(self): 949 """ 950 Resolve and validate sqlite database location. 951 952 Returns 953 ------- 954 Path 955 Existing sqlite database file path. 956 """ 957 958 db_location = self.db_location or os.getenv("COREMS_LIPIDOMICS_SQLITE_PATH") 959 if not db_location: 960 raise ValueError( 961 "A local lipid sqlite library path is required. " 962 "Set COREMS_LIPIDOMICS_SQLITE_PATH or pass db_location." 963 ) 964 965 db_path = Path(db_location).expanduser() 966 if not db_path.exists(): 967 raise FileNotFoundError( 968 f"Lipid sqlite library not found at {db_path}. " 969 f"Download it from {self.DEFAULT_DOWNLOAD_URL} " 970 "and set COREMS_LIPIDOMICS_SQLITE_PATH." 971 ) 972 973 return db_path 974 975 def _get_candidate_spectra(self, connection, mz_list, polarity, mz_tol_ppm): 976 """ 977 Fetch candidate spectra rows by precursor m/z and polarity. 978 979 Returns 980 ------- 981 pandas.DataFrame 982 Filtered rows from lipidMassSpectrumObject. 983 """ 984 985 mz_observed = np.sort(np.asarray(mz_list, dtype=float)) 986 if mz_observed.size == 0: 987 return pd.DataFrame() 988 989 mz_all = pd.read_sql_query( 990 "SELECT id, polarity, precursor_mz FROM lipidMassSpectrumObject", connection 991 ) 992 mz_all = mz_all[mz_all["polarity"] == polarity].copy() 993 if mz_all.empty: 994 return pd.DataFrame() 995 996 mz_all = mz_all.sort_values(by="precursor_mz").reset_index(drop=True) 997 998 if mz_observed.size == 1: 999 mz_all["closest_mz_obs"] = mz_observed[0] 1000 else: 1001 mz_all["closest_mz_obs"] = mz_observed[ 1002 find_closest(mz_observed, mz_all.precursor_mz.values) 1003 ] 1004 1005 mz_all["ppm_error"] = ( 1006 (mz_all["precursor_mz"] - mz_all["closest_mz_obs"]) 1007 / mz_all["precursor_mz"] 1008 * 1e6 1009 ) 1010 1011 mz_all = mz_all[np.abs(mz_all["ppm_error"]) <= mz_tol_ppm] 1012 if mz_all.empty: 1013 return pd.DataFrame() 1014 1015 mz_ids = tuple(mz_all["id"].tolist()) 1016 return pd.read_sql_query( 1017 f"SELECT * FROM lipidMassSpectrumObject WHERE id IN {mz_ids}", 1018 connection, 1019 ) 1020 1021 def get_lipid_library( 1022 self, 1023 mz_list, 1024 polarity, 1025 mz_tol_ppm, 1026 mz_tol_da_api=None, 1027 format="json", 1028 normalize=True, 1029 fe_kwargs=None, 1030 settings=None, 1031 api_delay=5, 1032 api_attempts=10, 1033 ): 1034 """ 1035 Retrieve lipid spectra and metadata from a local sqlite library. 1036 1037 FlashEntropy build knobs should come from 1038 :class:`~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings` 1039 so library generation matches later 1040 :meth:`~corems.molecular_id.search.lcms_spectral_search.LCMSSpectralSearch.fe_search` 1041 / molecular networking on the same LC-MS sample or collection. 1042 1043 Parameters 1044 ---------- 1045 mz_list : list 1046 List of precursor m/z values. 1047 polarity : str 1048 Ionization polarity, either "positive" or "negative". 1049 mz_tol_ppm : float 1050 Tolerance in ppm for precursor matching. 1051 mz_tol_da_api : float, optional 1052 Unused, kept for backward compatibility. 1053 format : str, optional 1054 Format of requested library, e.g. "json" or "flashentropy". 1055 normalize : bool, optional 1056 Normalize spectrum intensities (default True; always recommended). 1057 fe_kwargs : dict, optional 1058 Keyword arguments for FlashEntropy search. Override keys from 1059 *settings* when both are provided. Prefer *settings* for new code. 1060 settings : SpectralSimilaritySearchSettings, optional 1061 Encapsulated FE build settings. For an :class:`~corems.mass_spectra.factory.lc_class.LCMSBase` 1062 object, pass the profile bag used for MS2 association/search, typically:: 1063 1064 from corems.encapsulation.factory.parameters import settings_from_lcms 1065 settings=settings_from_lcms(lcms_obj) # mass_spectrum["ms2"] 1066 1067 or explicitly 1068 ``lcms_obj.parameters.mass_spectrum["ms2"].spectral_similarity_search``. 1069 For an :class:`~corems.mass_spectra.factory.lc_class.LCMSCollection`, 1070 configure settings once, broadcast to every sample, then pass 1071 collection settings into the library build:: 1072 1073 from corems.encapsulation.factory.parameters import ( 1074 apply_spectral_similarity_search_to_collection, 1075 settings_from_lcms_collection, 1076 ) 1077 from corems.encapsulation.factory.processingSetting import ( 1078 SpectralSimilaritySearchSettings, 1079 ) 1080 1081 ss = SpectralSimilaritySearchSettings() 1082 ss.max_ms2_tolerance_in_da = 0.01 1083 ss.ms2_min_fe_score = 0.3 1084 apply_spectral_similarity_search_to_collection( 1085 lcms_collection, ss, profile="ms2" 1086 ) 1087 settings=settings_from_lcms_collection(lcms_collection) 1088 1089 Mutating ``settings_from_lcms_collection(...)`` in place only changes 1090 the first sample. Multi-MS2 profiles use ``profile="ms2_cid"`` (etc.). 1091 api_delay : int, optional 1092 Unused, kept for backward compatibility. 1093 api_attempts : int, optional 1094 Unused, kept for backward compatibility. 1095 1096 Returns 1097 ------- 1098 tuple 1099 Library in requested format and lipid metadata dictionary. 1100 1101 Notes 1102 ----- 1103 *settings* only drives FlashEntropy index construction. Annotation 1104 thresholds such as ``ms2_min_fe_score`` are applied later by 1105 ``fe_search`` on the LCMS object (from the same 1106 ``spectral_similarity_search`` instance when configured consistently). 1107 1108 Examples 1109 -------- 1110 Build a FlashEntropy library from an LCMSBase object's MS2 profile, then search:: 1111 1112 from corems.encapsulation.factory.parameters import settings_from_lcms 1113 1114 settings = settings_from_lcms(lcms_obj, profile="ms2") 1115 fe_lib, lipid_meta = lipid_interface.get_lipid_library( 1116 mz_list=precursor_mzs, 1117 polarity=lcms_obj.polarity, 1118 mz_tol_ppm=5, 1119 format="flashentropy", 1120 settings=settings, 1121 ) 1122 lcms_obj.fe_search(scan_list=ms2_scans, fe_lib=fe_lib) 1123 1124 Collection — configure, broadcast to all samples, then build library:: 1125 1126 from corems.encapsulation.factory.parameters import ( 1127 apply_spectral_similarity_search_to_collection, 1128 settings_from_lcms_collection, 1129 ) 1130 from corems.encapsulation.factory.processingSetting import ( 1131 SpectralSimilaritySearchSettings, 1132 ) 1133 1134 ss = SpectralSimilaritySearchSettings() 1135 ss.max_ms2_tolerance_in_da = 0.01 1136 apply_spectral_similarity_search_to_collection(lcms_collection, ss) 1137 fe_lib, lipid_meta = lipid_interface.get_lipid_library( 1138 mz_list=precursor_mzs, 1139 polarity="positive", 1140 mz_tol_ppm=5, 1141 format="flashentropy", 1142 settings=settings_from_lcms_collection(lcms_collection), 1143 ) 1144 """ 1145 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 1146 1147 if not isinstance(mz_list, (list, np.ndarray)): 1148 raise ValueError("mz_list must be a list or numpy array") 1149 if not all(isinstance(mz, (float, int)) for mz in mz_list): 1150 raise ValueError("All elements in mz_list must be float or int") 1151 if polarity not in {"positive", "negative"}: 1152 raise ValueError("polarity must be either 'positive' or 'negative'") 1153 if not isinstance(mz_tol_ppm, (float, int)): 1154 raise ValueError("mz_tol_ppm must be a float or int") 1155 1156 db_path = self._resolve_db_location() 1157 connection = sqlite3.connect(str(db_path)) 1158 try: 1159 # Step 1: Get candidate spectra records based on m/z and polarity 1160 spectra_df = self._get_candidate_spectra( 1161 connection=connection, 1162 mz_list=mz_list, 1163 polarity=polarity, 1164 mz_tol_ppm=float(mz_tol_ppm), 1165 ) 1166 1167 if spectra_df.empty: 1168 format_func = self._get_format_func(format) 1169 return format_func([], normalize=normalize, fe_kwargs=fe_kwargs), {} 1170 1171 # Step 2: Get corresponding lipid metadata for candidate spectra from lipidTree view 1172 mol_ids = tuple(spectra_df["molecular_data_id"].tolist()) 1173 mol_df = pd.read_sql_query( 1174 f"SELECT * FROM lipidTree WHERE id IN {mol_ids}", 1175 connection, 1176 ) 1177 finally: 1178 connection.close() 1179 1180 mol_df["id_index"] = mol_df["id"] 1181 mol_df = mol_df.set_index("id_index") 1182 mol_records = mol_df.to_dict(orient="index") 1183 lipid_metadata = { 1184 int(k): self._dict_to_dataclass(v, LipidMetadata) 1185 for k, v in mol_records.items() 1186 } 1187 1188 spectra_records = spectra_df.to_dict(orient="records") 1189 format_func = self._get_format_func(format) 1190 library = format_func(spectra_records, normalize=normalize, fe_kwargs=fe_kwargs) 1191 return library, lipid_metadata 1192 1193 1194class MSPInterface(SpectralDatabaseInterface): 1195 """ 1196 Interface to parse NIST MSP files 1197 """ 1198 1199 def __init__(self, file_path, cache=True, cache_path=None, rebuild_cache=False): 1200 """ 1201 Initialize instance. 1202 1203 Parameters 1204 ---------- 1205 file_path : str 1206 Path to a local MSP file. 1207 cache : bool or str, optional 1208 If True, use a parquet sidecar when valid, otherwise parse and write 1209 it. If False, always parse. If ``"read"``, require a valid cache. 1210 Default is True. Sidecar path defaults to 1211 ``<file_path>.corems-lib.parquet``. 1212 cache_path : str or Path, optional 1213 Override the default cache path. 1214 rebuild_cache : bool, optional 1215 Force re-parse and rewrite the cache. Default is False. 1216 1217 Attributes 1218 ---------- 1219 file_path : str 1220 Path to the MSP file. 1221 _file_content : str 1222 Content of the MSP file (only set when parsing text). 1223 _data_frame : :obj:`~pandas.DataFrame` 1224 DataFrame of spectra from the MSP file with unaltered content. 1225 """ 1226 super().__init__(key=None) 1227 1228 self.file_path = file_path 1229 if not os.path.exists(self.file_path): 1230 raise FileNotFoundError( 1231 f"File {self.file_path} does not exist. Please check the file path." 1232 ) 1233 self._file_content = None 1234 self._cache_path = ( 1235 Path(cache_path) 1236 if cache_path is not None 1237 else spectral_library_cache.default_cache_path(self.file_path) 1238 ) 1239 self._data_frame = self._load_msp_dataframe( 1240 cache=cache, rebuild_cache=rebuild_cache 1241 ) 1242 self.__init_format_map__() 1243 1244 def _load_msp_dataframe(self, cache=True, rebuild_cache=False): 1245 """Load from parquet cache or parse the MSP file.""" 1246 use_cache = cache not in (False, None) 1247 require_cache = cache == "read" 1248 extra = {"source_kind": "msp"} 1249 1250 if ( 1251 use_cache 1252 and not rebuild_cache 1253 and spectral_library_cache.is_valid( 1254 self._cache_path, self.file_path, extra_meta=extra 1255 ) 1256 ): 1257 return spectral_library_cache.read(self._cache_path) 1258 1259 if require_cache: 1260 raise FileNotFoundError( 1261 f"Valid library cache required at {self._cache_path}" 1262 ) 1263 1264 with open(self.file_path, "r") as f: 1265 self._file_content = f.read() 1266 df = self._read_msp_file() 1267 if use_cache: 1268 spectral_library_cache.write( 1269 self._cache_path, df, self.file_path, extra_meta=extra 1270 ) 1271 return df 1272 1273 def __init_format_map__(self): 1274 """ 1275 Initialize database format mapper, enabling multiple format requests. 1276 1277 """ 1278 1279 # x is a pandas dataframe similar to self._data_frame format 1280 # Define format workflows 1281 self.format_map = { 1282 "msp": lambda x, normalize, fe_kwargs: self._to_msp(x, normalize), 1283 "flashentropy": lambda x, normalize, fe_kwargs: self._to_flashentropy( 1284 x, normalize, fe_kwargs 1285 ), 1286 "df": lambda x, normalize, fe_kwargs: self._to_df(x, normalize), 1287 } 1288 1289 # Add aliases 1290 self.format_map["fe"] = self.format_map["flashentropy"] 1291 self.format_map["flash-entropy"] = self.format_map["flashentropy"] 1292 self.format_map["dataframe"] = self.format_map["df"] 1293 self.format_map["data-frame"] = self.format_map["df"] 1294 1295 def _read_msp_file(self): 1296 """ 1297 Reads the MSP files into the pandas dataframe, and sort/remove zero intensity ions in MS/MS spectra. 1298 1299 Returns 1300 ------- 1301 :obj:`~pandas.DataFrame` 1302 DataFrame of spectra from the MSP file, exacly as it is in the file (no sorting, filtering etc) 1303 """ 1304 # If input_dataframe is provided, return it it 1305 spectra = [] 1306 spectrum = {} 1307 1308 f = StringIO(self._file_content) 1309 for line in f: 1310 line = line.strip() 1311 if not line: 1312 continue # Skip empty lines 1313 1314 # Handle metadata 1315 if ":" in line: 1316 key, value = line.split(":", 1) 1317 key = key.strip().lower() 1318 value = value.strip() 1319 1320 if key == "name": 1321 # Save current spectrum and start a new one 1322 if spectrum: 1323 spectra.append(spectrum) 1324 spectrum = {"name": value, "peaks": []} 1325 else: 1326 spectrum[key] = value 1327 1328 # Handle peak data (assumed to start with a number) 1329 elif line[0].isdigit(): 1330 peaks = line.split() 1331 m_z = float(peaks[0]) 1332 intensity = float(peaks[1]) 1333 spectrum["peaks"].append(([m_z, intensity])) 1334 # Save the last spectrum 1335 if spectrum: 1336 spectra.append(spectrum) 1337 1338 df = pd.DataFrame(spectra) 1339 for column in df.columns: 1340 if column != "peaks": # Skip 'peaks' column 1341 try: 1342 df[column] = pd.to_numeric(df[column], errors="raise") 1343 except: 1344 pass 1345 1346 # Standardize spectra ID column name 1347 # Check for common variations and create a standard 'spectra_id' column 1348 spectra_id_variants = ["spectrum_id", "gnps_spectra_id"] 1349 for variant in spectra_id_variants: 1350 if variant in df.columns and "spectra_id" not in df.columns: 1351 df["spectra_id"] = df[variant] 1352 break 1353 1354 # If no spectra_id column exists after checking variants, create one with sequential IDs 1355 if "spectra_id" not in df.columns: 1356 df["spectra_id"] = [f"spectrum_{i:06d}" for i in range(len(df))] 1357 1358 # Standardize compound name column 1359 # Ensure 'compound_name' column exists, using 'name' field 1360 if "name" in df.columns and "compound_name" not in df.columns: 1361 df["compound_name"] = df["name"] 1362 elif "compound_name" not in df.columns: 1363 warnings.warn( 1364 "MSP file does not contain 'name' or 'compound_name' field. " 1365 "Compound names will be set to 'Unknown'. This may affect plot labels and annotations.", 1366 UserWarning, 1367 ) 1368 df["compound_name"] = "Unknown" 1369 1370 return df 1371 1372 def _to_df(self, input_dataframe, normalize=True): 1373 """ 1374 Convert MSP-derived library to FlashEntropy library. 1375 1376 Parameters 1377 ---------- 1378 input_dataframe : :obj:`~pandas.DataFrame` 1379 Input DataFrame containing MSP-formatted spectra. 1380 normalize : bool, optional 1381 Normalize each spectrum by its magnitude. 1382 Default is True. 1383 1384 Returns 1385 ------- 1386 :obj:`~pandas.DataFrame` 1387 DataFrame of with desired normalization 1388 """ 1389 if not normalize: 1390 return input_dataframe 1391 else: 1392 # Convert to dictionary 1393 db_dict = input_dataframe.to_dict(orient="records") 1394 1395 # Initialize empty library 1396 lib = [] 1397 1398 # Enumerate spectra 1399 for i, source in enumerate(db_dict): 1400 spectrum = source 1401 # Check that spectrum["peaks"] exists 1402 if "peaks" not in spectrum.keys(): 1403 raise KeyError( 1404 "MSP not interpretted correctly, 'peaks' key not found in spectrum, check _dataframe attribute." 1405 ) 1406 1407 # Convert spectrum["peaks"] to numpy array 1408 if not isinstance(spectrum["peaks"], np.ndarray): 1409 spectrum["peaks"] = np.array(spectrum["peaks"]) 1410 1411 # Normalize peaks, if requested 1412 if normalize: 1413 spectrum["peaks"] = self.normalize_peaks(spectrum["peaks"]) 1414 spectrum["num peaks"] = len(spectrum["peaks"]) 1415 1416 # Add spectrum to library 1417 lib.append(spectrum) 1418 1419 # Convert to DataFrame 1420 df = pd.DataFrame(lib) 1421 return df 1422 1423 def _to_flashentropy(self, input_dataframe, normalize=True, fe_kwargs={}): 1424 """ 1425 Convert MSP-derived library to FlashEntropy library. 1426 1427 Parameters 1428 ---------- 1429 input_dataframe : :obj:`~pandas.DataFrame` 1430 Input DataFrame containing MSP-formatted spectra. 1431 normalize : bool 1432 Normalize each spectrum by its magnitude. 1433 fe_kwargs : dict, optional 1434 Keyword arguments for instantiation of FlashEntropy search and building index for FlashEntropy search; 1435 any keys not recognized will be ignored. By default, all parameters set to defaults. 1436 1437 Returns 1438 ------- 1439 :obj:`~ms_entropy.FlashEntropySearch` 1440 MS2 library as FlashEntropy search instance. 1441 1442 Raises 1443 ------ 1444 ValueError 1445 If "min_ms2_difference_in_da" or "max_ms2_tolerance_in_da" are present in `fe_kwargs` and they 1446 """ 1447 self._check_flash_entropy_kwargs(fe_kwargs) 1448 1449 db_df = input_dataframe 1450 1451 # Convert to dictionary 1452 db_dict = db_df.to_dict(orient="records") 1453 1454 # Initialize empty library 1455 fe_lib = [] 1456 1457 # Enumerate spectra 1458 for i, source in enumerate(db_dict): 1459 # Reorganize source dict, if necessary 1460 if "spectrum_data" in source.keys(): 1461 spectrum = source["spectrum_data"] 1462 else: 1463 spectrum = source 1464 1465 # Rename precursor_mz key for FlashEntropy 1466 if "precursor_mz" not in spectrum.keys(): 1467 if "precursormz" in spectrum: 1468 spectrum["precursor_mz"] = spectrum.pop("precursormz") 1469 elif "precursor_ion" in spectrum: 1470 spectrum["precursor_mz"] = spectrum.pop("precursor_ion") 1471 else: 1472 raise KeyError( 1473 "MSP must have either 'precursormz' or 'precursor_ion' key to be converted to FlashEntropy format." 1474 ) 1475 1476 # Check that spectrum["peaks"] exists 1477 if "peaks" not in spectrum.keys(): 1478 raise KeyError( 1479 "MSP not interpretted correctly, 'peaks' key not found in spectrum, check _dataframe attribute." 1480 ) 1481 1482 # Convert spectrum["peaks"] to numpy array 1483 if not isinstance(spectrum["peaks"], np.ndarray): 1484 spectrum["peaks"] = np.array(spectrum["peaks"]) 1485 1486 # Normalize peaks, if requested 1487 if normalize: 1488 spectrum["peaks"] = self.normalize_peaks(spectrum["peaks"]) 1489 1490 # Add spectrum to library 1491 fe_lib.append(spectrum) 1492 1493 # Build FlashEntropy index 1494 fe_search = self._build_flash_entropy_index(fe_lib, fe_kwargs=fe_kwargs) 1495 1496 return fe_search 1497 1498 def _check_msp_compatibility(self): 1499 """ 1500 Check if the MSP file is compatible with the get_metabolomics_spectra_library method and provide feedback if it is not. 1501 """ 1502 # Check polarity 1503 if ( 1504 "polarity" not in self._data_frame.columns 1505 and "ionmode" not in self._data_frame.columns 1506 ): 1507 raise ValueError( 1508 "Neither 'polarity' nor 'ionmode' columns found in the input MSP metadata. Please check the file." 1509 ) 1510 polarity_column = ( 1511 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 1512 ) 1513 1514 # Check if polarity_column contents is either "positive" or "negative" 1515 if not all(self._data_frame[polarity_column].isin(["positive", "negative"])): 1516 raise ValueError( 1517 f"Input field on MSP '{polarity_column}' must contain only 'positive' or 'negative' values." 1518 ) 1519 1520 # Check if the MSP file contains the required columns for metabolite metadata 1521 # either formula or molecular_formula, not null 1522 1523 if ( 1524 "formula" not in self._data_frame.columns 1525 and "molecular_formula" not in self._data_frame.columns 1526 ): 1527 raise ValueError( 1528 "Input field on MSP must contain either 'formula' or 'molecular_formula' columns." 1529 ) 1530 molecular_formula_column = ( 1531 "formula" if "formula" in self._data_frame.columns else "molecular_formula" 1532 ) 1533 if not all(self._data_frame[molecular_formula_column].notnull()): 1534 raise ValueError( 1535 f"Input field on MSP '{molecular_formula_column}' must contain only non-null values." 1536 ) 1537 1538 def get_metabolomics_spectra_library( 1539 self, 1540 polarity, 1541 metabolite_metadata_mapping={}, 1542 format="fe", 1543 normalize=True, 1544 fe_kwargs=None, 1545 settings=None, 1546 molecular_id_field="inchikey", 1547 ): 1548 """ 1549 Prepare metabolomics spectra library and associated metabolite metadata. 1550 1551 FlashEntropy build knobs should come from 1552 :class:`~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings` 1553 so library generation matches later 1554 :meth:`~corems.molecular_id.search.lcms_spectral_search.LCMSSpectralSearch.fe_search` 1555 / molecular networking on the same LC-MS sample or collection. 1556 1557 Parameters 1558 ---------- 1559 polarity : str 1560 Polarity of the spectra to extract. Must be 'positive' or 'negative'. 1561 metabolite_metadata_mapping : dict, optional 1562 Mapping of MSP field names to MetaboliteMetadata attribute names. 1563 Default uses common mappings (e.g., 'molecular_formula' -> 'formula'). 1564 format : str, optional 1565 Output format for the spectral library. Options: 'fe', 'flashentropy', 'msp', 'df'. 1566 Default is 'fe' (FlashEntropy). 1567 normalize : bool, optional 1568 Whether to normalize spectra. Default is True (always recommended). 1569 fe_kwargs : dict, optional 1570 Additional keyword arguments for FlashEntropy library creation. 1571 Override keys from *settings* when both are provided. Prefer *settings* 1572 for new code. 1573 settings : SpectralSimilaritySearchSettings, optional 1574 Encapsulated FE build settings. For an 1575 :class:`~corems.mass_spectra.factory.lc_class.LCMSBase` object, pass 1576 the MS2 parameter profile used for association/search, typically:: 1577 1578 from corems.encapsulation.factory.parameters import settings_from_lcms 1579 settings=settings_from_lcms(lcms_obj) # mass_spectrum["ms2"] 1580 1581 or explicitly 1582 ``lcms_obj.parameters.mass_spectrum["ms2"].spectral_similarity_search``. 1583 For an :class:`~corems.mass_spectra.factory.lc_class.LCMSCollection`, 1584 configure settings once, broadcast to every sample, then pass the 1585 collection's (first-sample) settings into the library build:: 1586 1587 from corems.encapsulation.factory.parameters import ( 1588 apply_spectral_similarity_search_to_collection, 1589 settings_from_lcms_collection, 1590 ) 1591 from corems.encapsulation.factory.processingSetting import ( 1592 SpectralSimilaritySearchSettings, 1593 ) 1594 1595 ss = SpectralSimilaritySearchSettings() 1596 ss.max_ms2_tolerance_in_da = 0.01 1597 apply_spectral_similarity_search_to_collection( 1598 lcms_collection, ss, profile="ms2" 1599 ) 1600 settings=settings_from_lcms_collection(lcms_collection) 1601 1602 Mutating ``settings_from_lcms_collection(...)`` in place only changes 1603 the first sample — use :func:`apply_spectral_similarity_search_to_collection` 1604 to keep all members equal. Multi-MS2 profiles use ``profile="ms2_cid"`` 1605 (or the matching ``mass_spectrum`` key). 1606 molecular_id_field : str, optional 1607 Field name to use as the unique molecular identifier for linking spectra to metadata. 1608 Default is 'inchikey'. The specified field must exist in the MSP file and contain 1609 non-null values for all entries. 1610 1611 Returns 1612 ------- 1613 tuple 1614 (spectral_library, metabolite_metadata_dict) where spectral_library is in the 1615 requested format and metabolite_metadata_dict maps molecular IDs to MetaboliteMetadata objects. 1616 1617 Notes 1618 ----- 1619 The molecular_id_field parameter allows flexibility for different MSP file formats: 1620 1621 - Use 'inchikey' for standard metabolite databases (default) 1622 - Use 'name' or 'spectra_id' for custom or isotope-labeled standards 1623 - The specified field must exist and contain non-null values for all entries 1624 1625 *settings* only drives FlashEntropy index construction. Annotation 1626 thresholds such as ``ms2_min_fe_score`` are applied later by 1627 ``fe_search`` on the LCMS object when it reads the same 1628 ``spectral_similarity_search`` instance. 1629 1630 Examples 1631 -------- 1632 Single LCMSBase file:: 1633 1634 from corems.encapsulation.factory.parameters import settings_from_lcms 1635 1636 settings = settings_from_lcms(lcms_obj, profile="ms2") 1637 fe_lib, mol_meta = msp_interface.get_metabolomics_spectra_library( 1638 polarity="positive", 1639 format="flashentropy", 1640 settings=settings, 1641 ) 1642 lcms_obj.fe_search(scan_list=ms2_scans, fe_lib=fe_lib) 1643 1644 Collection — configure, broadcast to all samples, then build library:: 1645 1646 from corems.encapsulation.factory.parameters import ( 1647 apply_spectral_similarity_search_to_collection, 1648 settings_from_lcms_collection, 1649 ) 1650 from corems.encapsulation.factory.processingSetting import ( 1651 SpectralSimilaritySearchSettings, 1652 ) 1653 1654 ss = SpectralSimilaritySearchSettings() 1655 ss.max_ms2_tolerance_in_da = 0.01 1656 apply_spectral_similarity_search_to_collection(lcms_collection, ss) 1657 fe_lib, mol_meta = msp_interface.get_metabolomics_spectra_library( 1658 polarity="negative", 1659 format="flashentropy", 1660 settings=settings_from_lcms_collection(lcms_collection), 1661 ) 1662 """ 1663 # Check if the MSP file is compatible with the get_metabolomics_spectra_library method 1664 self._check_msp_compatibility() 1665 1666 # Check if the polarity parameter is valid and if a polarity column exists in the dataframe 1667 if polarity not in ["positive", "negative"]: 1668 raise ValueError("Polarity must be 'positive' or 'negative'") 1669 polarity_column = ( 1670 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 1671 ) 1672 1673 # Get a subset of the initial dataframea by polarity 1674 db_df = self._data_frame[self._data_frame[polarity_column] == polarity].copy() 1675 1676 # Rename the columns of the db_df to match the MetaboliteMetadata dataclass using the metabolite_metadata_mapping 1677 # If the mapping is not provided, use the default mapping 1678 if not metabolite_metadata_mapping: 1679 metabolite_metadata_mapping = { 1680 "chebi_id": "chebi", 1681 "kegg_id": "kegg", 1682 "refmet_name": "common_name", 1683 "molecular_formula": "formula", 1684 "gnps_spectra_id": "id", 1685 "precursormz": "precursor_mz", 1686 "precursortype": "ion_type", 1687 } 1688 db_df.rename(columns=metabolite_metadata_mapping, inplace=True) 1689 1690 # Create molecular_data_id from the specified field 1691 if molecular_id_field not in db_df.columns: 1692 raise ValueError( 1693 f"Specified molecular_id_field '{molecular_id_field}' not found in MSP data. " 1694 f"Available columns: {', '.join(db_df.columns)}" 1695 ) 1696 1697 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 1698 1699 if not db_df[molecular_id_field].notnull().all(): 1700 raise ValueError( 1701 f"Specified molecular_id_field '{molecular_id_field}' contains null values. " 1702 f"All entries must have non-null values for the molecular ID field." 1703 ) 1704 1705 # Use the specified field as the molecular ID 1706 db_df["molecular_data_id"] = db_df[molecular_id_field].astype(str) 1707 1708 # Ensure 'id' field exists for spectra identification 1709 # If not present, create from spectra_id or use a sequential index 1710 if "id" not in db_df.columns: 1711 if "spectra_id" in db_df.columns: 1712 db_df["id"] = db_df["spectra_id"].astype(str) 1713 else: 1714 # Generate sequential IDs 1715 db_df["id"] = [f"spectrum_{i:06d}" for i in range(len(db_df))] 1716 1717 # Check if the resulting dataframe has the required columns for the flash entropy search 1718 required_columns = ["molecular_data_id", "precursor_mz", "ion_type", "id"] 1719 for col in required_columns: 1720 if col not in db_df.columns: 1721 raise ValueError( 1722 f"Input field on MSP must contain '{col}' column for FlashEntropy search." 1723 ) 1724 1725 # Pull out the metabolite metadata from the dataframe and put it into a different dataframe 1726 # First get a list of the possible attributes of the MetaboliteMetadata dataclass 1727 metabolite_metadata_keys = list(MetaboliteMetadata.__annotations__.keys()) 1728 # Replace id with molecular_data_id in metabolite_metadata_keys 1729 metabolite_metadata_keys = [ 1730 "molecular_data_id" if x == "id" else x for x in metabolite_metadata_keys 1731 ] 1732 metabolite_metadata_df = db_df[ 1733 db_df.columns[db_df.columns.isin(metabolite_metadata_keys)] 1734 ].copy() 1735 1736 # Make unique and recast the id column for metabolite metadata 1737 metabolite_metadata_df.drop_duplicates( 1738 subset=["molecular_data_id"], inplace=True 1739 ) 1740 metabolite_metadata_df["id"] = metabolite_metadata_df["molecular_data_id"] 1741 1742 # Convert to a dictionary using the molecular_data_id as the key 1743 metabolite_metadata_dict = metabolite_metadata_df.to_dict(orient="records") 1744 metabolite_metadata_dict = { 1745 v["id"]: self._dict_to_dataclass(v, MetaboliteMetadata) 1746 for v in metabolite_metadata_dict 1747 } 1748 1749 # Remove the metabolite metadata columns from the original dataframe 1750 for key in metabolite_metadata_keys: 1751 if key != "molecular_data_id": 1752 if key in db_df.columns: 1753 db_df.drop(columns=key, inplace=True) 1754 1755 # Format the spectral library 1756 format_func = self._get_format_func(format) 1757 lib = format_func(db_df, normalize=normalize, fe_kwargs=fe_kwargs) 1758 return (lib, metabolite_metadata_dict) 1759 1760 1761class SpectraverseMS2Interface(SpectralDatabaseInterface): 1762 """Interface to Spectraverse MS/MS (MS2) libraries (MGF). 1763 1764 Loads MS2 spectra only for metabolomics spectral search (Flash Entropy or 1765 DataFrame). Precursor m/z used for search is calculated from formula + adduct 1766 via CoreMS ion-type machinery, not the library experimental PRECURSOR_MZ. 1767 1768 Download the release MGF from Zenodo, e.g. 1769 https://zenodo.org/records/19927403 (``spectraverse-1.0.2.mgf``). 1770 1771 References 1772 ---------- 1773 Gupta, V., Qiang, H., Chung, H.-H., Herbst, E., & Skinnider, M. (2026). 1774 Comprehensive curation and harmonization of small molecule MS/MS libraries 1775 in Spectraverse [Data set]. Zenodo. 1776 https://doi.org/10.5281/zenodo.17252772 1777 1778 See also: https://github.com/skinniderlab/spectraverse-analysis 1779 """ 1780 1781 def __init__(self, file_path, cache=True, cache_path=None, rebuild_cache=False): 1782 """ 1783 Parameters 1784 ---------- 1785 file_path : str or Path 1786 Path to a Spectraverse MGF file. 1787 cache : bool or str, optional 1788 If True, use a parquet sidecar when valid, otherwise parse and write 1789 it. If False, always parse. If ``"read"``, require a valid cache. 1790 Default is True. 1791 cache_path : str or Path, optional 1792 Override default ``<file_path>.corems-lib.parquet``. 1793 rebuild_cache : bool, optional 1794 Force re-parse and rewrite the cache. Default is False. 1795 """ 1796 super().__init__(key=None) 1797 self.file_path = str(file_path) 1798 if not os.path.exists(self.file_path): 1799 raise FileNotFoundError( 1800 f"File {self.file_path} does not exist. Please check the file path." 1801 ) 1802 self._cache_path = ( 1803 Path(cache_path) 1804 if cache_path is not None 1805 else spectral_library_cache.default_cache_path(self.file_path) 1806 ) 1807 self._precursor_cache = {} 1808 self._data_frame = self._load_dataframe( 1809 cache=cache, rebuild_cache=rebuild_cache 1810 ) 1811 self.__init_format_map__() 1812 1813 def __init_format_map__(self): 1814 self.format_map = { 1815 "flashentropy": lambda x, normalize, fe_kwargs: self._to_flashentropy( 1816 x, normalize, fe_kwargs 1817 ), 1818 "df": lambda x, normalize, fe_kwargs: self._to_df(x, normalize), 1819 } 1820 self.format_map["fe"] = self.format_map["flashentropy"] 1821 self.format_map["flash-entropy"] = self.format_map["flashentropy"] 1822 self.format_map["dataframe"] = self.format_map["df"] 1823 self.format_map["data-frame"] = self.format_map["df"] 1824 1825 def _load_dataframe(self, cache=True, rebuild_cache=False): 1826 use_cache = cache not in (False, None) 1827 require_cache = cache == "read" 1828 extra = { 1829 "source_kind": "spectraverse_mgf", 1830 "precursor_policy": "formula+adduct", 1831 } 1832 1833 if ( 1834 use_cache 1835 and not rebuild_cache 1836 and spectral_library_cache.is_valid( 1837 self._cache_path, self.file_path, extra_meta=extra 1838 ) 1839 ): 1840 return spectral_library_cache.read(self._cache_path) 1841 1842 if require_cache: 1843 raise FileNotFoundError( 1844 f"Valid library cache required at {self._cache_path}" 1845 ) 1846 1847 df = self._read_mgf_file() 1848 if use_cache: 1849 spectral_library_cache.write( 1850 self._cache_path, df, self.file_path, extra_meta=extra 1851 ) 1852 return df 1853 1854 def _precursor_mz_from_formula(self, formula: str, adduct: str) -> float: 1855 """Calculate precursor m/z from neutral formula and adduct.""" 1856 from corems.molecular_formula.calc.MolecularFormulaCalc import ( 1857 MolecularFormulaCalc, 1858 ) 1859 1860 key = (formula, adduct) 1861 if key in self._precursor_cache: 1862 return self._precursor_cache[key] 1863 mz = MolecularFormulaCalc.precursor_mz_from_formula(formula, adduct) 1864 self._precursor_cache[key] = mz 1865 return mz 1866 1867 def _read_mgf_file(self) -> pd.DataFrame: 1868 """Stream-parse Spectraverse MGF; keep MS2 spectra only.""" 1869 spectra = [] 1870 headers = {} 1871 peaks = [] 1872 in_block = False 1873 n_skipped = 0 1874 1875 with open(self.file_path, "r", encoding="utf-8", errors="replace") as f: 1876 for line in f: 1877 line = line.strip() 1878 if not line: 1879 continue 1880 if line == "BEGIN IONS": 1881 in_block = True 1882 headers = {} 1883 peaks = [] 1884 continue 1885 if line == "END IONS": 1886 if in_block: 1887 row = self._finalize_mgf_block(headers, peaks) 1888 if row is None: 1889 n_skipped += 1 1890 else: 1891 spectra.append(row) 1892 in_block = False 1893 headers = {} 1894 peaks = [] 1895 continue 1896 if not in_block: 1897 continue 1898 if "=" in line and not line[0].isdigit(): 1899 key, value = line.split("=", 1) 1900 headers[key.strip().upper()] = value.strip() 1901 elif line[0].isdigit() or ( 1902 line[0] == "-" and len(line) > 1 and line[1].isdigit() 1903 ): 1904 parts = line.split() 1905 if len(parts) >= 2: 1906 peaks.append([float(parts[0]), float(parts[1])]) 1907 1908 if not spectra: 1909 raise ValueError( 1910 f"No MS2 spectra loaded from {self.file_path}" 1911 + (f" ({n_skipped} blocks skipped)" if n_skipped else "") 1912 ) 1913 if n_skipped: 1914 warnings.warn( 1915 f"Skipped {n_skipped} Spectraverse MGF blocks (non-MS2, empty, or " 1916 f"unsupported formula/adduct).", 1917 UserWarning, 1918 ) 1919 return pd.DataFrame(spectra) 1920 1921 def _finalize_mgf_block(self, headers: dict, peaks: list): 1922 ms_level = str(headers.get("MS_LEVEL", "")).strip().upper() 1923 if ms_level not in ("MS2", "2"): 1924 return None 1925 if not peaks: 1926 return None 1927 1928 formula = headers.get("FORMULA", "").strip() 1929 adduct = headers.get("ADDUCT", "").strip() 1930 if not formula or not adduct: 1931 return None 1932 1933 try: 1934 precursor_mz = self._precursor_mz_from_formula(formula, adduct) 1935 except Exception: 1936 return None 1937 1938 ionmode = headers.get("IONMODE", "").strip().lower() 1939 title = headers.get("TITLE", "").strip() 1940 compound_name = headers.get("COMPOUND_NAME", "").strip() or "Unknown" 1941 peaks_arr = np.asarray(peaks, dtype=np.float64) 1942 1943 lib_precursor = headers.get("PRECURSOR_MZ", "").strip() 1944 try: 1945 precursor_mz_library = float(lib_precursor) if lib_precursor else None 1946 except ValueError: 1947 precursor_mz_library = None 1948 1949 return { 1950 "id": title or None, 1951 "spectra_id": title or None, 1952 "name": compound_name, 1953 "compound_name": compound_name, 1954 "formula": formula, 1955 "inchikey": headers.get("INCHIKEY", "").strip() or None, 1956 "inchi": headers.get("INCHI", "").strip() or None, 1957 "smiles": headers.get("SMILES", "").strip() or None, 1958 "ionmode": ionmode, 1959 "polarity": ionmode, 1960 "ion_type": adduct, 1961 "adduct": adduct, 1962 "precursor_mz": precursor_mz, 1963 "precursor_mz_library": precursor_mz_library, 1964 "charge": headers.get("CHARGE", "").strip() or None, 1965 "source": headers.get("SOURCE", "").strip() or None, 1966 "instrument_type": headers.get("INSTRUMENT_TYPE", "").strip() or None, 1967 "ms_level": ms_level, 1968 "peaks": peaks_arr, 1969 "num peaks": len(peaks_arr), 1970 } 1971 1972 def _to_df(self, input_dataframe, normalize=True): 1973 if not normalize: 1974 return input_dataframe 1975 db_dict = input_dataframe.to_dict(orient="records") 1976 lib = [] 1977 for spectrum in db_dict: 1978 if "peaks" not in spectrum: 1979 raise KeyError("peaks key not found in spectrum") 1980 if not isinstance(spectrum["peaks"], np.ndarray): 1981 spectrum["peaks"] = np.array(spectrum["peaks"]) 1982 spectrum["peaks"] = self.normalize_peaks(spectrum["peaks"]) 1983 spectrum["num peaks"] = len(spectrum["peaks"]) 1984 lib.append(spectrum) 1985 return pd.DataFrame(lib) 1986 1987 def _to_flashentropy(self, input_dataframe, normalize=True, fe_kwargs={}): 1988 self._check_flash_entropy_kwargs(fe_kwargs) 1989 fe_lib = [] 1990 for source in input_dataframe.to_dict(orient="records"): 1991 spectrum = source 1992 if "precursor_mz" not in spectrum: 1993 raise KeyError("precursor_mz required for FlashEntropy format") 1994 if "peaks" not in spectrum: 1995 raise KeyError("peaks key not found in spectrum") 1996 if not isinstance(spectrum["peaks"], np.ndarray): 1997 spectrum["peaks"] = np.array(spectrum["peaks"]) 1998 if normalize: 1999 spectrum["peaks"] = self.normalize_peaks(spectrum["peaks"]) 2000 fe_lib.append(spectrum) 2001 return self._build_flash_entropy_index(fe_lib, fe_kwargs=fe_kwargs) 2002 2003 def _check_spectraverse_compatibility(self): 2004 if ( 2005 "polarity" not in self._data_frame.columns 2006 and "ionmode" not in self._data_frame.columns 2007 ): 2008 raise ValueError( 2009 "Neither 'polarity' nor 'ionmode' columns found in Spectraverse data." 2010 ) 2011 polarity_column = ( 2012 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 2013 ) 2014 if not all(self._data_frame[polarity_column].isin(["positive", "negative"])): 2015 raise ValueError( 2016 f"'{polarity_column}' must contain only 'positive' or 'negative' values." 2017 ) 2018 if "formula" not in self._data_frame.columns: 2019 raise ValueError("Spectraverse data must contain a 'formula' column.") 2020 if not all(self._data_frame["formula"].notnull()): 2021 raise ValueError("'formula' must contain only non-null values.") 2022 2023 def get_metabolomics_spectra_library( 2024 self, 2025 polarity, 2026 metabolite_metadata_mapping=None, 2027 format="fe", 2028 normalize=True, 2029 fe_kwargs=None, 2030 settings=None, 2031 molecular_id_field="inchikey", 2032 ): 2033 """ 2034 Prepare metabolomics MS2 spectra library and metabolite metadata. 2035 2036 Parameters 2037 ---------- 2038 polarity : str 2039 ``'positive'`` or ``'negative'``. 2040 metabolite_metadata_mapping : dict, optional 2041 Optional column renames into MetaboliteMetadata fields. 2042 format : str, optional 2043 ``'fe'`` / ``'flashentropy'`` or ``'df'`` / ``'dataframe'``. 2044 normalize : bool, optional 2045 Normalize peak intensities. Default True. 2046 fe_kwargs : dict, optional 2047 FlashEntropy build options. Override keys from *settings* when both 2048 are provided. Prefer *settings* for new code. 2049 settings : SpectralSimilaritySearchSettings, optional 2050 Encapsulated FE build settings (same object used by 2051 ``MSPInterface.get_metabolomics_spectra_library``). Constructor 2052 kwargs ``cache`` / ``cache_path`` / ``rebuild_cache`` stay on 2053 :meth:`__init__`; do not pass *settings* there. 2054 molecular_id_field : str, optional 2055 Field used as molecular metadata key. Default ``'inchikey'``. 2056 2057 Returns 2058 ------- 2059 tuple 2060 ``(spectral_library, metabolite_metadata_dict)``. 2061 """ 2062 if metabolite_metadata_mapping is None: 2063 metabolite_metadata_mapping = {} 2064 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 2065 2066 self._check_spectraverse_compatibility() 2067 2068 if polarity not in ["positive", "negative"]: 2069 raise ValueError("Polarity must be 'positive' or 'negative'") 2070 polarity_column = ( 2071 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 2072 ) 2073 db_df = self._data_frame[self._data_frame[polarity_column] == polarity].copy() 2074 if db_df.empty: 2075 raise ValueError(f"No Spectraverse spectra for polarity={polarity!r}") 2076 2077 if metabolite_metadata_mapping: 2078 db_df.rename(columns=metabolite_metadata_mapping, inplace=True) 2079 2080 if molecular_id_field not in db_df.columns: 2081 raise ValueError( 2082 f"Specified molecular_id_field '{molecular_id_field}' not found. " 2083 f"Available columns: {', '.join(map(str, db_df.columns))}" 2084 ) 2085 if not db_df[molecular_id_field].notnull().all(): 2086 raise ValueError( 2087 f"Specified molecular_id_field '{molecular_id_field}' contains null values." 2088 ) 2089 2090 db_df["molecular_data_id"] = db_df[molecular_id_field].astype(str) 2091 if "id" not in db_df.columns or db_df["id"].isnull().any(): 2092 if "spectra_id" in db_df.columns: 2093 db_df["id"] = db_df["spectra_id"].astype(str) 2094 else: 2095 db_df["id"] = [f"spectrum_{i:06d}" for i in range(len(db_df))] 2096 2097 required_columns = ["molecular_data_id", "precursor_mz", "ion_type", "id"] 2098 for col in required_columns: 2099 if col not in db_df.columns: 2100 raise ValueError( 2101 f"Spectraverse data must contain '{col}' for FlashEntropy search." 2102 ) 2103 2104 metabolite_metadata_keys = list(MetaboliteMetadata.__annotations__.keys()) 2105 metabolite_metadata_keys = [ 2106 "molecular_data_id" if x == "id" else x for x in metabolite_metadata_keys 2107 ] 2108 metabolite_metadata_df = db_df[ 2109 db_df.columns[db_df.columns.isin(metabolite_metadata_keys)] 2110 ].copy() 2111 metabolite_metadata_df.drop_duplicates( 2112 subset=["molecular_data_id"], inplace=True 2113 ) 2114 metabolite_metadata_df["id"] = metabolite_metadata_df["molecular_data_id"] 2115 metabolite_metadata_dict = { 2116 v["id"]: self._dict_to_dataclass(v, MetaboliteMetadata) 2117 for v in metabolite_metadata_df.to_dict(orient="records") 2118 } 2119 2120 for key in metabolite_metadata_keys: 2121 if key != "molecular_data_id" and key in db_df.columns: 2122 db_df.drop(columns=key, inplace=True) 2123 2124 format_func = self._get_format_func(format) 2125 lib = format_func(db_df, normalize=normalize, fe_kwargs=fe_kwargs) 2126 return (lib, metabolite_metadata_dict)
33class SpectralDatabaseInterface(ABC): 34 """ 35 Base class that facilitates connection to spectral reference databases, 36 such as EMSL's Metabolomics Reference Database (MetabRef). 37 38 """ 39 40 def __init__(self, key=None): 41 """ 42 Initialize instance. 43 44 Parameters 45 ---------- 46 key : str 47 Token key. 48 49 """ 50 51 self.key = key 52 53 def set_token(self, path): 54 """ 55 Set environment variable for MetabRef database token. 56 57 Parameters 58 ---------- 59 path : str 60 Path to token. 61 62 """ 63 64 # Read token from file 65 with open(path, "r", encoding="utf-8") as f: 66 token = f.readline().strip() 67 68 # Set environment variable 69 os.environ[self.key] = token 70 71 def get_token(self): 72 """ 73 Get environment variable for database token. 74 75 Returns 76 ------- 77 str 78 Token string. 79 80 """ 81 82 # Check for token 83 if self.key not in os.environ: 84 raise ValueError("Must set {} environment variable.".format(self.key)) 85 86 # Get token from environment variables 87 return os.environ.get(self.key) 88 89 def get_header(self): 90 """ 91 Access stored database token and prepare as header. 92 93 Returns 94 ------- 95 str 96 Header string. 97 98 """ 99 100 # Get token 101 token = self.get_token() 102 103 # Pad header information 104 header = {"Authorization": f"Bearer {token}", "Content-Type": "text/plain"} 105 106 return header 107 108 def get_query(self, url, use_header=True): 109 """ 110 Request payload from URL according to `get` protocol. 111 112 Parameters 113 ---------- 114 url : str 115 URL for request. 116 use_header: bool 117 Whether or not the query should include the header 118 119 Returns 120 ------- 121 dict 122 Response as JSON. 123 124 """ 125 126 # Query URL via `get` 127 if use_header: 128 response = requests.get(url, headers=self.get_header()) 129 else: 130 response = requests.get(url) 131 132 # Check response 133 response.raise_for_status() 134 135 # Return as JSON 136 return response.json() 137 138 def post_query(self, url, variable, values, tolerance): 139 """ 140 Request payload from URL according to `post` protocol. 141 142 Parameters 143 ---------- 144 url : str 145 URL for request. 146 variable : str 147 Variable to query. 148 values : str 149 Specific values of `variable` to query. 150 tolerance : str 151 Query tolerance relative to `values`. 152 153 Returns 154 ------- 155 dict 156 Response as JSON. 157 158 """ 159 160 # Coerce to string 161 if not isinstance(variable, str): 162 variable = str(variable).replace(" ", "") 163 164 if not isinstance(values, str): 165 values = str(values).replace(" ", "") 166 167 if not isinstance(tolerance, str): 168 tolerance = str(tolerance).replace(" ", "") 169 170 # Query URL via `post` 171 response = requests.post( 172 os.path.join(url, variable, tolerance), 173 data=values, 174 headers=self.get_header(), 175 ) 176 177 # Check response 178 response.raise_for_status() 179 180 # Return as JSON 181 return response.json() 182 183 def _check_flash_entropy_kwargs(self, fe_kwargs): 184 """ 185 Check FlashEntropy keyword arguments. 186 187 Parameters 188 ---------- 189 fe_kwargs : dict 190 Keyword arguments for FlashEntropy search. 191 192 193 Raises 194 ------ 195 ValueError 196 If "min_ms2_difference_in_da" or "max_ms2_tolerance_in_da" are present in `fe_kwargs` and they 197 are not equal. 198 199 """ 200 # If "min_ms2_difference_in_da" in fe_kwargs, check that "max_ms2_tolerance_in_da" is also present and that min_ms2_difference_in_da = 2xmax_ms2_tolerance_in_da 201 if ( 202 "min_ms2_difference_in_da" in fe_kwargs 203 or "max_ms2_tolerance_in_da" in fe_kwargs 204 ): 205 if ( 206 "min_ms2_difference_in_da" not in fe_kwargs 207 or "max_ms2_tolerance_in_da" not in fe_kwargs 208 ): 209 raise ValueError( 210 "Both 'min_ms2_difference_in_da' and 'max_ms2_tolerance_in_da' must be specified." 211 ) 212 if ( 213 fe_kwargs["min_ms2_difference_in_da"] 214 != 2 * fe_kwargs["max_ms2_tolerance_in_da"] 215 ): 216 raise ValueError( 217 "The values of 'min_ms2_difference_in_da' must be exactly 2x 'max_ms2_tolerance_in_da'." 218 ) 219 220 def _get_format_func(self, format): 221 """ 222 Obtain format function by key. 223 224 Returns 225 ------- 226 func 227 Formatting function. 228 """ 229 230 if format.lower() in self.format_map.keys(): 231 return self.format_map[format.lower()] 232 233 raise ValueError(("{} not a supported format.").format(format)) 234 235 def _dict_to_dataclass(self, source_dict, data_class): 236 """ 237 Convert dictionary to dataclass. 238 239 Notes 240 ----- 241 This function will pull the attributes a dataclass and its parent class 242 and convert the dictionary to a dataclass instance with the appropriate 243 attributes. 244 245 Parameters 246 ---------- 247 data_class : :obj:`~dataclasses.dataclass` 248 Dataclass to convert to. 249 source_dict : dict 250 Dictionary object to convert to dataclass. 251 252 Returns 253 ------- 254 :obj:`~dataclasses.dataclass` 255 Dataclass instance. 256 257 """ 258 259 # Get list of expected attributes of data_class 260 data_class_keys = list(data_class.__annotations__.keys()) 261 262 # Does the data_class inherit from another class, if so, get the attributes of the parent class as well 263 if len(data_class.__mro__) > 2: 264 parent_class_keys = list(data_class.__bases__[0].__annotations__.keys()) 265 data_class_keys = list(set(data_class_keys + parent_class_keys)) 266 267 # Remove keys that are not in the data_class from the input dictionary 268 input_dict = {k: v for k, v in source_dict.items() if k in data_class_keys} 269 270 # Add keys that are in the data class but not in the input dictionary as None 271 for key in data_class_keys: 272 if key not in input_dict.keys(): 273 input_dict[key] = None 274 return data_class(**input_dict) 275 276 def _spectrum_to_array(self, spectrum, normalize=True): 277 """ 278 Convert a parenthesis-delimited spectrum string to array. 279 280 Parameters 281 ---------- 282 spectrum : str 283 Spectrum string, i.e. list of (m/z,abundance) pairs. 284 normalize : bool 285 Normalize the spectrum by its magnitude. 286 287 Returns 288 ------- 289 :obj:`~numpy.array` 290 Array of shape (N, 2), with m/z in the first column and abundance in 291 the second. 292 """ 293 294 arr = np.array( 295 re.findall(r"\(([^,]+),([^)]+)\)", spectrum), dtype=float 296 ).reshape(-1, 2) 297 298 if normalize: 299 arr = self.normalize_peaks(arr) 300 301 return arr 302 303 @staticmethod 304 def normalize_peaks(arr): 305 """ 306 Normalize peaks in an array. 307 308 Parameters 309 ---------- 310 arr : :obj:`~numpy.array` 311 Array of shape (N, 2), with m/z in the first column and abundance in 312 the second. 313 314 Returns 315 ------- 316 :obj:`~numpy.array` 317 Normalized array of shape (N, 2), with m/z in the first column and 318 normalized abundance in the second. 319 """ 320 # Normalize the array 321 arr[:, -1] = arr[:, -1] / arr[:, -1].sum() 322 323 return arr 324 325 @staticmethod 326 def _build_flash_entropy_index(fe_lib, fe_kwargs={}, clean_spectra=True): 327 """ 328 Build FlashEntropy index. 329 330 Parameters 331 ---------- 332 fe_lib : list 333 List of spectra to build index from. Can be a list of dictionaries or 334 a FlashEntropy search instance. 335 fe_kwargs : dict, optional 336 Keyword arguments for FlashEntropy search. 337 clean_spectra : bool, optional 338 Clean spectra before building index. Default is True. 339 340 Returns 341 ------- 342 :obj:`~ms_entropy.FlashEntropySearch` 343 FlashEntropy search instance. 344 345 """ 346 # Initialize FlashEntropy 347 fe_init_kws = [ 348 "max_ms2_tolerance_in_da", 349 "mz_index_step", 350 "low_memory", 351 "path_data", 352 ] 353 fe_init_kws = {k: v for k, v in fe_kwargs.items() if k in fe_init_kws} 354 fes = FlashEntropySearch(**fe_init_kws) 355 356 # Build FlashEntropy index 357 fe_index_kws = [ 358 "max_indexed_mz", 359 "precursor_ions_removal_da", 360 "noise_threshold", 361 "min_ms2_difference_in_da", 362 "max_peak_num", 363 ] 364 fe_index_kws = {k: v for k, v in fe_kwargs.items() if k in fe_index_kws} 365 fes.build_index(fe_lib, **fe_index_kws, clean_spectra=clean_spectra) 366 367 # Store build_index parameters as custom attributes for later retrieval 368 # (FlashEntropy doesn't store these natively) 369 for key, value in fe_index_kws.items(): 370 setattr(fes, f"_build_{key}", value) 371 372 return fes
Base class that facilitates connection to spectral reference databases, such as EMSL's Metabolomics Reference Database (MetabRef).
40 def __init__(self, key=None): 41 """ 42 Initialize instance. 43 44 Parameters 45 ---------- 46 key : str 47 Token key. 48 49 """ 50 51 self.key = key
Initialize instance.
Parameters
- key (str): Token key.
53 def set_token(self, path): 54 """ 55 Set environment variable for MetabRef database token. 56 57 Parameters 58 ---------- 59 path : str 60 Path to token. 61 62 """ 63 64 # Read token from file 65 with open(path, "r", encoding="utf-8") as f: 66 token = f.readline().strip() 67 68 # Set environment variable 69 os.environ[self.key] = token
Set environment variable for MetabRef database token.
Parameters
- path (str): Path to token.
71 def get_token(self): 72 """ 73 Get environment variable for database token. 74 75 Returns 76 ------- 77 str 78 Token string. 79 80 """ 81 82 # Check for token 83 if self.key not in os.environ: 84 raise ValueError("Must set {} environment variable.".format(self.key)) 85 86 # Get token from environment variables 87 return os.environ.get(self.key)
Get environment variable for database token.
Returns
- str: Token string.
89 def get_header(self): 90 """ 91 Access stored database token and prepare as header. 92 93 Returns 94 ------- 95 str 96 Header string. 97 98 """ 99 100 # Get token 101 token = self.get_token() 102 103 # Pad header information 104 header = {"Authorization": f"Bearer {token}", "Content-Type": "text/plain"} 105 106 return header
Access stored database token and prepare as header.
Returns
- str: Header string.
108 def get_query(self, url, use_header=True): 109 """ 110 Request payload from URL according to `get` protocol. 111 112 Parameters 113 ---------- 114 url : str 115 URL for request. 116 use_header: bool 117 Whether or not the query should include the header 118 119 Returns 120 ------- 121 dict 122 Response as JSON. 123 124 """ 125 126 # Query URL via `get` 127 if use_header: 128 response = requests.get(url, headers=self.get_header()) 129 else: 130 response = requests.get(url) 131 132 # Check response 133 response.raise_for_status() 134 135 # Return as JSON 136 return response.json()
Request payload from URL according to get protocol.
Parameters
- url (str): URL for request.
- use_header (bool): Whether or not the query should include the header
Returns
- dict: Response as JSON.
138 def post_query(self, url, variable, values, tolerance): 139 """ 140 Request payload from URL according to `post` protocol. 141 142 Parameters 143 ---------- 144 url : str 145 URL for request. 146 variable : str 147 Variable to query. 148 values : str 149 Specific values of `variable` to query. 150 tolerance : str 151 Query tolerance relative to `values`. 152 153 Returns 154 ------- 155 dict 156 Response as JSON. 157 158 """ 159 160 # Coerce to string 161 if not isinstance(variable, str): 162 variable = str(variable).replace(" ", "") 163 164 if not isinstance(values, str): 165 values = str(values).replace(" ", "") 166 167 if not isinstance(tolerance, str): 168 tolerance = str(tolerance).replace(" ", "") 169 170 # Query URL via `post` 171 response = requests.post( 172 os.path.join(url, variable, tolerance), 173 data=values, 174 headers=self.get_header(), 175 ) 176 177 # Check response 178 response.raise_for_status() 179 180 # Return as JSON 181 return response.json()
Request payload from URL according to post protocol.
Parameters
- url (str): URL for request.
- variable (str): Variable to query.
- values (str):
Specific values of
variableto query. - tolerance (str):
Query tolerance relative to
values.
Returns
- dict: Response as JSON.
303 @staticmethod 304 def normalize_peaks(arr): 305 """ 306 Normalize peaks in an array. 307 308 Parameters 309 ---------- 310 arr : :obj:`~numpy.array` 311 Array of shape (N, 2), with m/z in the first column and abundance in 312 the second. 313 314 Returns 315 ------- 316 :obj:`~numpy.array` 317 Normalized array of shape (N, 2), with m/z in the first column and 318 normalized abundance in the second. 319 """ 320 # Normalize the array 321 arr[:, -1] = arr[:, -1] / arr[:, -1].sum() 322 323 return arr
Normalize peaks in an array.
Parameters
- arr (
~numpy.array): Array of shape (N, 2), with m/z in the first column and abundance in the second.
Returns
~numpy.array: Normalized array of shape (N, 2), with m/z in the first column and normalized abundance in the second.
375class MetabRefInterface(SpectralDatabaseInterface): 376 """ 377 DEPRECATED interface retained for backward compatibility only. 378 """ 379 380 def __init__(self): 381 """ 382 Initialize instance with deprecation warning. 383 384 """ 385 386 super().__init__(key=None) 387 388 if self.__class__ is MetabRefInterface: 389 warnings.warn( 390 "MetabRefInterface is deprecated. Instantiate a concrete interface " 391 "such as GCMSLibraryInterface or LCLipidLibraryInterface instead.", 392 DeprecationWarning, 393 stacklevel=2, 394 )
DEPRECATED interface retained for backward compatibility only.
380 def __init__(self): 381 """ 382 Initialize instance with deprecation warning. 383 384 """ 385 386 super().__init__(key=None) 387 388 if self.__class__ is MetabRefInterface: 389 warnings.warn( 390 "MetabRefInterface is deprecated. Instantiate a concrete interface " 391 "such as GCMSLibraryInterface or LCLipidLibraryInterface instead.", 392 DeprecationWarning, 393 stacklevel=2, 394 )
Initialize instance with deprecation warning.
Inherited Members
397class GCMSLibraryInterface(SpectralDatabaseInterface): 398 """ 399 Interface to bundled GCMS spectral libraries in MSP format. 400 401 Loads GCMS compound library and FAMES calibration library from local MSP files. 402 Default files are bundled with CoreMS, but can be overridden via environment variables. 403 """ 404 405 def __init__(self): 406 """ 407 Initialize instance. 408 """ 409 super().__init__(key=None) 410 411 # Local data file paths 412 from pathlib import Path 413 414 # Default to bundled data files 415 data_dir = Path(__file__).parent.parent / "data" 416 self.gcms_library_file = os.getenv( 417 "GCMS_LIBRARY_PATH", str(data_dir / "PNNLMetV20191015.msp") 418 ) 419 self.fames_library_file = os.getenv( 420 "FAMES_LIBRARY_PATH", str(data_dir / "FAMES_REF.msp") 421 ) 422 423 self.__init_format_map__() 424 425 def __init_format_map__(self): 426 """ 427 Initialize database format mapper, enabling multiple format requests. 428 429 """ 430 431 # Define format workflows 432 self.format_map = { 433 "json": lambda x, normalize, fe_kwargs: x, 434 "dict": lambda x, normalize, fe_kwargs: ( 435 self._to_LowResolutionEICompound_dict(x, normalize) 436 ), 437 "sql": lambda x, normalize, fe_kwargs: ( 438 self._LowResolutionEICompound_dict_to_sqlite( 439 self._to_LowResolutionEICompound_dict(x, normalize) 440 ) 441 ), 442 } 443 444 # Add aliases 445 self.format_map["metabref"] = self.format_map["json"] 446 self.format_map["datadict"] = self.format_map["dict"] 447 self.format_map["data-dict"] = self.format_map["dict"] 448 self.format_map["lowreseicompound"] = self.format_map["dict"] 449 self.format_map["lowres"] = self.format_map["dict"] 450 self.format_map["lowresgc"] = self.format_map["dict"] 451 self.format_map["sqlite"] = self.format_map["sql"] 452 453 def available_formats(self): 454 """ 455 View list of available formats. 456 457 Returns 458 ------- 459 list 460 Format map keys. 461 """ 462 463 return list(self.format_map.keys()) 464 465 def get_library(self, format="json", normalize=False): 466 """ 467 Load GC/MS library from local MSP file. 468 469 Parameters 470 ---------- 471 format : str 472 Format of requested library, i.e. "json", "sql", "dict". 473 See `available_formats` method for aliases. 474 normalize : bool 475 Normalize the spectrum by its magnitude. 476 477 Returns 478 ------- 479 Library in requested format. 480 481 """ 482 # Load from local MSP file 483 library_data = self._load_msp_file(self.gcms_library_file, normalize) 484 485 # Init format function 486 format_func = self._get_format_func(format) 487 488 # Apply format conversion 489 return format_func(library_data, normalize, {}) 490 491 def get_fames(self, format="json", normalize=False): 492 """ 493 Load GC/MS FAMEs library from local MSP file. 494 495 Parameters 496 ---------- 497 format : str 498 Format of requested library, i.e. "json", "sql", "dict". 499 See `available_formats` method for aliases. 500 normalize : bool 501 Normalize the spectrum by its magnitude. 502 503 Returns 504 ------- 505 Library in requested format. 506 507 """ 508 # Load from local MSP file 509 library_data = self._load_msp_file(self.fames_library_file, normalize) 510 511 # Init format function 512 format_func = self._get_format_func(format) 513 514 # Apply format conversion 515 return format_func(library_data, normalize, {}) 516 517 def _load_msp_file(self, file_path, normalize=False): 518 """ 519 Load and parse MSP file into format compatible with existing pipeline. 520 521 Parameters 522 ---------- 523 file_path : str 524 Path to MSP file 525 normalize : bool 526 Normalize spectra 527 528 Returns 529 ------- 530 list of dict 531 Library data in format compatible with _to_LowResolutionEICompound_dict 532 """ 533 from pathlib import Path 534 535 file_path = Path(file_path) 536 if not file_path.exists(): 537 raise FileNotFoundError( 538 f"Library file not found: {file_path}. " 539 f"Set GCMS_LIBRARY_PATH or FAMES_LIBRARY_PATH environment variable to specify location." 540 ) 541 542 # Parse MSP file 543 spectra = [] 544 spectrum = {} 545 peaks = [] 546 547 with open(file_path, "r") as f: 548 for line in f: 549 line = line.strip() 550 551 # Empty line marks end of spectrum 552 if not line: 553 if spectrum and peaks: 554 # Convert peaks to the format expected by downstream code 555 # Format: "(mz,abundance)(mz,abundance)..." 556 peak_str = "".join( 557 [f"({int(mz)},{int(abun)})" for mz, abun in peaks] 558 ) 559 spectrum["mz"] = peak_str 560 spectra.append(spectrum) 561 spectrum = {} 562 peaks = [] 563 continue 564 565 # Check if line contains peak data (starts with digit) 566 if line and line[0].isdigit(): 567 parts = line.split() 568 if len(parts) >= 2: 569 peaks.append((float(parts[0]), float(parts[1]))) 570 continue 571 572 # Handle metadata fields 573 if ":" in line: 574 key, value = line.split(":", 1) 575 key = key.strip().lower() 576 value = value.strip() 577 578 # Map MSP fields to expected format 579 field_mapping = { 580 "name": "molecule_name", 581 "formula": "formula", 582 "cas": "casno", 583 "retentiontime": "retention_time", 584 "ri": "ri", 585 "comment": "comments", 586 "num peaks": "peak_count", 587 "derivative": "derivative", 588 } 589 590 # Metadata fields that go into the metadata dict 591 metadata_fields = { 592 "inchikey": "inchikey", 593 "inchi": "inchi", 594 "smiles": "smiles", 595 "pubchem": "pubchem", 596 "chebi": "chebi", 597 "kegg": "kegg", 598 "refmet": "refmet", 599 "iupac_name": "iupac_name", 600 } 601 602 if key in field_mapping: 603 mapped_key = field_mapping[key] 604 # Convert numeric fields 605 if key in ["retentiontime", "ri"]: 606 try: 607 value = float(value) 608 except: 609 pass 610 elif key == "num peaks": 611 try: 612 value = int(value) 613 except: 614 pass 615 spectrum[mapped_key] = value 616 elif key in metadata_fields: 617 # Store in nested metadata dict 618 if "metadata" not in spectrum: 619 spectrum["metadata"] = {} 620 spectrum["metadata"][metadata_fields[key]] = value 621 else: 622 # Keep unmapped fields 623 spectrum[key] = value 624 625 # Add last spectrum if file doesn't end with blank line 626 if spectrum and peaks: 627 peak_str = "".join([f"({int(mz)},{int(abun)})" for mz, abun in peaks]) 628 spectrum["mz"] = peak_str 629 spectra.append(spectrum) 630 631 return spectra 632 633 def _to_LowResolutionEICompound_dict(self, metabref_lib, normalize=False): 634 """ 635 Convert MetabRef-formatted library to CoreMS LowResolutionEICompound-formatted 636 dictionary for local ingestion. 637 638 Parameters 639 ---------- 640 metabref_lib : dict 641 MetabRef GC-MS library in JSON format. 642 normalize : bool 643 Normalize each spectrum by its magnitude. 644 645 Returns 646 ------- 647 list of dict 648 List of each spectrum contained in dictionary. 649 650 """ 651 652 # All below key:value lookups are based on CoreMS class definitions 653 # NOT MetabRef content. For example, MetabRef has keys for PubChem, 654 # USI, etc. that are not considered below. 655 656 # Dictionary to map metabref keys to corems keys 657 metadatar_cols = { 658 "casno": "cas", 659 "inchikey": "inchikey", 660 "inchi": "inchi", 661 "chebi": "chebi", 662 "smiles": "smiles", 663 "kegg": "kegg", 664 "iupac_name": "iupac_name", 665 "traditional_name": "traditional_name", # Not present in metabref 666 "common_name": "common_name", # Not present in metabref 667 } 668 669 # Dictionary to map metabref keys to corems keys 670 lowres_ei_compound_cols = { 671 "id": "metabref_id", 672 "molecule_name": "name", # Is this correct? 673 "classify": "classify", # Not present in metabref 674 "formula": "formula", 675 "ri": "ri", 676 "rt": "retention_time", 677 "source": "source", # Not present in metabref 678 "casno": "casno", 679 "comments": "comment", 680 "source_temp_c": "source_temp_c", # Not present in metabref 681 "ev": "ev", # Not present in metabref 682 "peak_count": "peaks_count", 683 "mz": "mz", 684 "abundance": "abundance", 685 } 686 687 # Local result container 688 corems_lib = [] 689 690 # Enumerate spectra 691 for i, source_ in enumerate(metabref_lib): 692 # Copy source to prevent modification 693 source = source_.copy() 694 695 # Parse target data 696 target = { 697 lowres_ei_compound_cols[k]: v 698 for k, v in source.items() 699 if k in lowres_ei_compound_cols 700 } 701 702 # Explicitly add this to connect with LowResCompoundRef later 703 if "retention_time" in source: 704 target["rt"] = source["retention_time"] 705 elif "rt" in source: 706 target["rt"] = source["rt"] 707 708 # Parse (mz, abundance) 709 arr = self._spectrum_to_array(target["mz"], normalize=normalize) 710 target["mz"] = arr[:, 0] 711 target["abundance"] = arr[:, 1] 712 713 # Parse meta data 714 target["metadata"] = { 715 metadatar_cols[k]: v for k, v in source.items() if k in metadatar_cols 716 } 717 718 # Add anything else 719 for k in source: 720 if k not in lowres_ei_compound_cols: 721 target[k] = source[k] 722 723 # Add to CoreMS list 724 corems_lib.append(target) 725 726 return corems_lib 727 728 def _LowResolutionEICompound_dict_to_sqlite( 729 self, lowres_ei_compound_dict, url="sqlite://" 730 ): 731 """ 732 Convert CoreMS LowResolutionEICompound-formatted dictionary to SQLite 733 database for local ingestion. 734 735 Parameters 736 ---------- 737 lowres_ei_compound_dict : dict 738 CoreMS GC-MS library formatted for LowResolutionEICompound. 739 url : str 740 URL to SQLite prefix. 741 742 Returns 743 ------- 744 sqlite database 745 Spectra contained in SQLite database. 746 747 """ 748 749 # Dictionary to map corems keys to all-caps keys 750 capped_cols = { 751 "name": "NAME", 752 "formula": "FORM", 753 "ri": "RI", 754 "retention_time": "RT", 755 "source": "SOURCE", 756 "casno": "CASNO", 757 "comment": "COMMENT", 758 "peaks_count": "NUM PEAKS", 759 } 760 761 # Initialize SQLite object 762 sqlite_obj = EI_LowRes_SQLite(url=url) 763 764 # Iterate spectra 765 for _data_dict in lowres_ei_compound_dict: 766 # Copy source to prevent modification 767 data_dict = _data_dict.copy() 768 769 # Add missing capped values 770 for k, v in capped_cols.items(): 771 # Key exists 772 if k in data_dict: 773 # # This will replace the key 774 # data_dict[v] = data_dict.pop(k) 775 776 # This will keep both keys 777 data_dict[v] = data_dict[k] 778 779 # Parse number of peaks 780 if not data_dict.get("NUM PEAKS"): 781 data_dict["NUM PEAKS"] = len(data_dict.get("mz")) 782 783 # Parse CAS number 784 if not data_dict.get("CASNO"): 785 data_dict["CASNO"] = data_dict.get("CAS") 786 787 if not data_dict["CASNO"]: 788 data_dict["CASNO"] = 0 789 790 # Build linked metadata table 791 if "metadata" in data_dict: 792 metadata = data_dict.pop("metadata") 793 # Only create metadata entry if we have required fields and valid data 794 # Filter to only include fields that Metadatar model supports 795 supported_metadata_fields = [ 796 "cas", 797 "inchikey", 798 "inchi", 799 "chebi", 800 "smiles", 801 "kegg", 802 "iupac_name", 803 "traditional_name", 804 "common_name", 805 ] 806 filtered_metadata = { 807 k: v 808 for k, v in metadata.items() 809 if k in supported_metadata_fields and v 810 } 811 # Inchikey is required by the database model 812 if filtered_metadata and filtered_metadata.get("inchikey"): 813 data_dict["metadatar"] = Metadatar(**filtered_metadata) 814 815 # Attempt addition to sqlite 816 try: 817 sqlite_obj.add_compound(data_dict) 818 except: 819 print(data_dict["NAME"]) 820 821 return sqlite_obj
Interface to bundled GCMS spectral libraries in MSP format.
Loads GCMS compound library and FAMES calibration library from local MSP files. Default files are bundled with CoreMS, but can be overridden via environment variables.
405 def __init__(self): 406 """ 407 Initialize instance. 408 """ 409 super().__init__(key=None) 410 411 # Local data file paths 412 from pathlib import Path 413 414 # Default to bundled data files 415 data_dir = Path(__file__).parent.parent / "data" 416 self.gcms_library_file = os.getenv( 417 "GCMS_LIBRARY_PATH", str(data_dir / "PNNLMetV20191015.msp") 418 ) 419 self.fames_library_file = os.getenv( 420 "FAMES_LIBRARY_PATH", str(data_dir / "FAMES_REF.msp") 421 ) 422 423 self.__init_format_map__()
Initialize instance.
453 def available_formats(self): 454 """ 455 View list of available formats. 456 457 Returns 458 ------- 459 list 460 Format map keys. 461 """ 462 463 return list(self.format_map.keys())
View list of available formats.
Returns
- list: Format map keys.
465 def get_library(self, format="json", normalize=False): 466 """ 467 Load GC/MS library from local MSP file. 468 469 Parameters 470 ---------- 471 format : str 472 Format of requested library, i.e. "json", "sql", "dict". 473 See `available_formats` method for aliases. 474 normalize : bool 475 Normalize the spectrum by its magnitude. 476 477 Returns 478 ------- 479 Library in requested format. 480 481 """ 482 # Load from local MSP file 483 library_data = self._load_msp_file(self.gcms_library_file, normalize) 484 485 # Init format function 486 format_func = self._get_format_func(format) 487 488 # Apply format conversion 489 return format_func(library_data, normalize, {})
Load GC/MS library from local MSP file.
Parameters
- format (str):
Format of requested library, i.e. "json", "sql", "dict".
See
available_formatsmethod for aliases. - normalize (bool): Normalize the spectrum by its magnitude.
Returns
- Library in requested format.
491 def get_fames(self, format="json", normalize=False): 492 """ 493 Load GC/MS FAMEs library from local MSP file. 494 495 Parameters 496 ---------- 497 format : str 498 Format of requested library, i.e. "json", "sql", "dict". 499 See `available_formats` method for aliases. 500 normalize : bool 501 Normalize the spectrum by its magnitude. 502 503 Returns 504 ------- 505 Library in requested format. 506 507 """ 508 # Load from local MSP file 509 library_data = self._load_msp_file(self.fames_library_file, normalize) 510 511 # Init format function 512 format_func = self._get_format_func(format) 513 514 # Apply format conversion 515 return format_func(library_data, normalize, {})
Load GC/MS FAMEs library from local MSP file.
Parameters
- format (str):
Format of requested library, i.e. "json", "sql", "dict".
See
available_formatsmethod for aliases. - normalize (bool): Normalize the spectrum by its magnitude.
Returns
- Library in requested format.
Inherited Members
824class MetabRefGCInterface(GCMSLibraryInterface): 825 """ 826 DEPRECATED: Use GCMSLibraryInterface instead. 827 828 This interface is maintained for backward compatibility only. 829 MetabRef API has been discontinued as of 2026. 830 """ 831 832 def __init__(self): 833 """ 834 Initialize instance with deprecation warning. 835 """ 836 warnings.warn( 837 "MetabRefGCInterface is deprecated. Use GCMSLibraryInterface instead. " 838 "MetabRef API has been discontinued; all data now loads from bundled local MSP files.", 839 DeprecationWarning, 840 stacklevel=2, 841 ) 842 super().__init__()
DEPRECATED: Use GCMSLibraryInterface instead.
This interface is maintained for backward compatibility only. MetabRef API has been discontinued as of 2026.
832 def __init__(self): 833 """ 834 Initialize instance with deprecation warning. 835 """ 836 warnings.warn( 837 "MetabRefGCInterface is deprecated. Use GCMSLibraryInterface instead. " 838 "MetabRef API has been discontinued; all data now loads from bundled local MSP files.", 839 DeprecationWarning, 840 stacklevel=2, 841 ) 842 super().__init__()
Initialize instance with deprecation warning.
845class LCLipidLibraryInterface(SpectralDatabaseInterface): 846 """ 847 Interface to a local sqlite lipid library for LC-MS spectral searches. 848 """ 849 850 DEFAULT_DOWNLOAD_URL = ( 851 "https://nmdcdemo.emsl.pnnl.gov/minio/lipidomics/parameter_files/" 852 "202412_lipid_ref.sqlite" 853 ) 854 855 def __init__(self, db_location=None): 856 """ 857 Initialize instance. 858 859 Parameters 860 ---------- 861 db_location : str | Path, optional 862 Local path to the sqlite lipid library. If omitted, the 863 COREMS_LIPIDOMICS_SQLITE_PATH environment variable is used. 864 """ 865 866 super().__init__(key=None) 867 self.db_location = db_location 868 self.__init_format_map__() 869 870 def _to_flashentropy(self, spectral_library, normalize=True, fe_kwargs={}): 871 """ 872 Convert a spectral library to FlashEntropy format. 873 874 Parameters 875 ---------- 876 spectral_library : dict 877 MS2 library in JSON format or FlashEntropy search instance 878 (for reformatting at different MS2 separation). 879 normalize : bool 880 Normalize each spectrum by its magnitude. 881 fe_kwargs : dict, optional 882 Keyword arguments for instantiation of FlashEntropy search and building index for FlashEntropy search; 883 any keys not recognized will be ignored. By default, all parameters set to defaults. 884 885 Returns 886 ------- 887 :obj:`~ms_entropy.FlashEntropySearch` 888 MS2 library as FlashEntropy search instance. 889 890 Raises 891 ------ 892 ValueError 893 If "min_ms2_difference_in_da" or "max_ms2_tolerance_in_da" are present in `fe_kwargs` and they are not equal. 894 895 """ 896 self._check_flash_entropy_kwargs(fe_kwargs) 897 898 # Initialize empty library 899 fe_lib = [] 900 901 # Enumerate spectra 902 for i, source in enumerate(spectral_library): 903 if "spectrum_data" in source.keys(): 904 spectrum = source["spectrum_data"] 905 else: 906 spectrum = source 907 908 if "precursor_mz" not in spectrum.keys(): 909 spectrum["precursor_mz"] = spectrum.pop("precursor_ion") 910 911 spectrum["peaks"] = self._spectrum_to_array( 912 spectrum["mz"], normalize=normalize 913 ) 914 fe_lib.append(spectrum) 915 916 fe_search = self._build_flash_entropy_index(fe_lib, fe_kwargs=fe_kwargs) 917 918 return fe_search 919 920 def __init_format_map__(self): 921 """ 922 Initialize database format mapper, enabling multiple format requests. 923 """ 924 925 self.format_map = { 926 "json": lambda x, normalize, fe_kwargs: x, 927 "flashentropy": lambda x, normalize, fe_kwargs: self._to_flashentropy( 928 x, normalize, fe_kwargs 929 ), 930 "dataframe": lambda x, normalize, fe_kwargs: pd.DataFrame(x), 931 } 932 933 self.format_map["fe"] = self.format_map["flashentropy"] 934 self.format_map["flash-entropy"] = self.format_map["flashentropy"] 935 self.format_map["df"] = self.format_map["dataframe"] 936 937 def available_formats(self): 938 """ 939 View list of available formats. 940 941 Returns 942 ------- 943 list 944 Format map keys. 945 """ 946 947 return list(self.format_map.keys()) 948 949 def _resolve_db_location(self): 950 """ 951 Resolve and validate sqlite database location. 952 953 Returns 954 ------- 955 Path 956 Existing sqlite database file path. 957 """ 958 959 db_location = self.db_location or os.getenv("COREMS_LIPIDOMICS_SQLITE_PATH") 960 if not db_location: 961 raise ValueError( 962 "A local lipid sqlite library path is required. " 963 "Set COREMS_LIPIDOMICS_SQLITE_PATH or pass db_location." 964 ) 965 966 db_path = Path(db_location).expanduser() 967 if not db_path.exists(): 968 raise FileNotFoundError( 969 f"Lipid sqlite library not found at {db_path}. " 970 f"Download it from {self.DEFAULT_DOWNLOAD_URL} " 971 "and set COREMS_LIPIDOMICS_SQLITE_PATH." 972 ) 973 974 return db_path 975 976 def _get_candidate_spectra(self, connection, mz_list, polarity, mz_tol_ppm): 977 """ 978 Fetch candidate spectra rows by precursor m/z and polarity. 979 980 Returns 981 ------- 982 pandas.DataFrame 983 Filtered rows from lipidMassSpectrumObject. 984 """ 985 986 mz_observed = np.sort(np.asarray(mz_list, dtype=float)) 987 if mz_observed.size == 0: 988 return pd.DataFrame() 989 990 mz_all = pd.read_sql_query( 991 "SELECT id, polarity, precursor_mz FROM lipidMassSpectrumObject", connection 992 ) 993 mz_all = mz_all[mz_all["polarity"] == polarity].copy() 994 if mz_all.empty: 995 return pd.DataFrame() 996 997 mz_all = mz_all.sort_values(by="precursor_mz").reset_index(drop=True) 998 999 if mz_observed.size == 1: 1000 mz_all["closest_mz_obs"] = mz_observed[0] 1001 else: 1002 mz_all["closest_mz_obs"] = mz_observed[ 1003 find_closest(mz_observed, mz_all.precursor_mz.values) 1004 ] 1005 1006 mz_all["ppm_error"] = ( 1007 (mz_all["precursor_mz"] - mz_all["closest_mz_obs"]) 1008 / mz_all["precursor_mz"] 1009 * 1e6 1010 ) 1011 1012 mz_all = mz_all[np.abs(mz_all["ppm_error"]) <= mz_tol_ppm] 1013 if mz_all.empty: 1014 return pd.DataFrame() 1015 1016 mz_ids = tuple(mz_all["id"].tolist()) 1017 return pd.read_sql_query( 1018 f"SELECT * FROM lipidMassSpectrumObject WHERE id IN {mz_ids}", 1019 connection, 1020 ) 1021 1022 def get_lipid_library( 1023 self, 1024 mz_list, 1025 polarity, 1026 mz_tol_ppm, 1027 mz_tol_da_api=None, 1028 format="json", 1029 normalize=True, 1030 fe_kwargs=None, 1031 settings=None, 1032 api_delay=5, 1033 api_attempts=10, 1034 ): 1035 """ 1036 Retrieve lipid spectra and metadata from a local sqlite library. 1037 1038 FlashEntropy build knobs should come from 1039 :class:`~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings` 1040 so library generation matches later 1041 :meth:`~corems.molecular_id.search.lcms_spectral_search.LCMSSpectralSearch.fe_search` 1042 / molecular networking on the same LC-MS sample or collection. 1043 1044 Parameters 1045 ---------- 1046 mz_list : list 1047 List of precursor m/z values. 1048 polarity : str 1049 Ionization polarity, either "positive" or "negative". 1050 mz_tol_ppm : float 1051 Tolerance in ppm for precursor matching. 1052 mz_tol_da_api : float, optional 1053 Unused, kept for backward compatibility. 1054 format : str, optional 1055 Format of requested library, e.g. "json" or "flashentropy". 1056 normalize : bool, optional 1057 Normalize spectrum intensities (default True; always recommended). 1058 fe_kwargs : dict, optional 1059 Keyword arguments for FlashEntropy search. Override keys from 1060 *settings* when both are provided. Prefer *settings* for new code. 1061 settings : SpectralSimilaritySearchSettings, optional 1062 Encapsulated FE build settings. For an :class:`~corems.mass_spectra.factory.lc_class.LCMSBase` 1063 object, pass the profile bag used for MS2 association/search, typically:: 1064 1065 from corems.encapsulation.factory.parameters import settings_from_lcms 1066 settings=settings_from_lcms(lcms_obj) # mass_spectrum["ms2"] 1067 1068 or explicitly 1069 ``lcms_obj.parameters.mass_spectrum["ms2"].spectral_similarity_search``. 1070 For an :class:`~corems.mass_spectra.factory.lc_class.LCMSCollection`, 1071 configure settings once, broadcast to every sample, then pass 1072 collection settings into the library build:: 1073 1074 from corems.encapsulation.factory.parameters import ( 1075 apply_spectral_similarity_search_to_collection, 1076 settings_from_lcms_collection, 1077 ) 1078 from corems.encapsulation.factory.processingSetting import ( 1079 SpectralSimilaritySearchSettings, 1080 ) 1081 1082 ss = SpectralSimilaritySearchSettings() 1083 ss.max_ms2_tolerance_in_da = 0.01 1084 ss.ms2_min_fe_score = 0.3 1085 apply_spectral_similarity_search_to_collection( 1086 lcms_collection, ss, profile="ms2" 1087 ) 1088 settings=settings_from_lcms_collection(lcms_collection) 1089 1090 Mutating ``settings_from_lcms_collection(...)`` in place only changes 1091 the first sample. Multi-MS2 profiles use ``profile="ms2_cid"`` (etc.). 1092 api_delay : int, optional 1093 Unused, kept for backward compatibility. 1094 api_attempts : int, optional 1095 Unused, kept for backward compatibility. 1096 1097 Returns 1098 ------- 1099 tuple 1100 Library in requested format and lipid metadata dictionary. 1101 1102 Notes 1103 ----- 1104 *settings* only drives FlashEntropy index construction. Annotation 1105 thresholds such as ``ms2_min_fe_score`` are applied later by 1106 ``fe_search`` on the LCMS object (from the same 1107 ``spectral_similarity_search`` instance when configured consistently). 1108 1109 Examples 1110 -------- 1111 Build a FlashEntropy library from an LCMSBase object's MS2 profile, then search:: 1112 1113 from corems.encapsulation.factory.parameters import settings_from_lcms 1114 1115 settings = settings_from_lcms(lcms_obj, profile="ms2") 1116 fe_lib, lipid_meta = lipid_interface.get_lipid_library( 1117 mz_list=precursor_mzs, 1118 polarity=lcms_obj.polarity, 1119 mz_tol_ppm=5, 1120 format="flashentropy", 1121 settings=settings, 1122 ) 1123 lcms_obj.fe_search(scan_list=ms2_scans, fe_lib=fe_lib) 1124 1125 Collection — configure, broadcast to all samples, then build library:: 1126 1127 from corems.encapsulation.factory.parameters import ( 1128 apply_spectral_similarity_search_to_collection, 1129 settings_from_lcms_collection, 1130 ) 1131 from corems.encapsulation.factory.processingSetting import ( 1132 SpectralSimilaritySearchSettings, 1133 ) 1134 1135 ss = SpectralSimilaritySearchSettings() 1136 ss.max_ms2_tolerance_in_da = 0.01 1137 apply_spectral_similarity_search_to_collection(lcms_collection, ss) 1138 fe_lib, lipid_meta = lipid_interface.get_lipid_library( 1139 mz_list=precursor_mzs, 1140 polarity="positive", 1141 mz_tol_ppm=5, 1142 format="flashentropy", 1143 settings=settings_from_lcms_collection(lcms_collection), 1144 ) 1145 """ 1146 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 1147 1148 if not isinstance(mz_list, (list, np.ndarray)): 1149 raise ValueError("mz_list must be a list or numpy array") 1150 if not all(isinstance(mz, (float, int)) for mz in mz_list): 1151 raise ValueError("All elements in mz_list must be float or int") 1152 if polarity not in {"positive", "negative"}: 1153 raise ValueError("polarity must be either 'positive' or 'negative'") 1154 if not isinstance(mz_tol_ppm, (float, int)): 1155 raise ValueError("mz_tol_ppm must be a float or int") 1156 1157 db_path = self._resolve_db_location() 1158 connection = sqlite3.connect(str(db_path)) 1159 try: 1160 # Step 1: Get candidate spectra records based on m/z and polarity 1161 spectra_df = self._get_candidate_spectra( 1162 connection=connection, 1163 mz_list=mz_list, 1164 polarity=polarity, 1165 mz_tol_ppm=float(mz_tol_ppm), 1166 ) 1167 1168 if spectra_df.empty: 1169 format_func = self._get_format_func(format) 1170 return format_func([], normalize=normalize, fe_kwargs=fe_kwargs), {} 1171 1172 # Step 2: Get corresponding lipid metadata for candidate spectra from lipidTree view 1173 mol_ids = tuple(spectra_df["molecular_data_id"].tolist()) 1174 mol_df = pd.read_sql_query( 1175 f"SELECT * FROM lipidTree WHERE id IN {mol_ids}", 1176 connection, 1177 ) 1178 finally: 1179 connection.close() 1180 1181 mol_df["id_index"] = mol_df["id"] 1182 mol_df = mol_df.set_index("id_index") 1183 mol_records = mol_df.to_dict(orient="index") 1184 lipid_metadata = { 1185 int(k): self._dict_to_dataclass(v, LipidMetadata) 1186 for k, v in mol_records.items() 1187 } 1188 1189 spectra_records = spectra_df.to_dict(orient="records") 1190 format_func = self._get_format_func(format) 1191 library = format_func(spectra_records, normalize=normalize, fe_kwargs=fe_kwargs) 1192 return library, lipid_metadata
Interface to a local sqlite lipid library for LC-MS spectral searches.
855 def __init__(self, db_location=None): 856 """ 857 Initialize instance. 858 859 Parameters 860 ---------- 861 db_location : str | Path, optional 862 Local path to the sqlite lipid library. If omitted, the 863 COREMS_LIPIDOMICS_SQLITE_PATH environment variable is used. 864 """ 865 866 super().__init__(key=None) 867 self.db_location = db_location 868 self.__init_format_map__()
Initialize instance.
Parameters
- db_location (str | Path, optional): Local path to the sqlite lipid library. If omitted, the COREMS_LIPIDOMICS_SQLITE_PATH environment variable is used.
937 def available_formats(self): 938 """ 939 View list of available formats. 940 941 Returns 942 ------- 943 list 944 Format map keys. 945 """ 946 947 return list(self.format_map.keys())
View list of available formats.
Returns
- list: Format map keys.
1022 def get_lipid_library( 1023 self, 1024 mz_list, 1025 polarity, 1026 mz_tol_ppm, 1027 mz_tol_da_api=None, 1028 format="json", 1029 normalize=True, 1030 fe_kwargs=None, 1031 settings=None, 1032 api_delay=5, 1033 api_attempts=10, 1034 ): 1035 """ 1036 Retrieve lipid spectra and metadata from a local sqlite library. 1037 1038 FlashEntropy build knobs should come from 1039 :class:`~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings` 1040 so library generation matches later 1041 :meth:`~corems.molecular_id.search.lcms_spectral_search.LCMSSpectralSearch.fe_search` 1042 / molecular networking on the same LC-MS sample or collection. 1043 1044 Parameters 1045 ---------- 1046 mz_list : list 1047 List of precursor m/z values. 1048 polarity : str 1049 Ionization polarity, either "positive" or "negative". 1050 mz_tol_ppm : float 1051 Tolerance in ppm for precursor matching. 1052 mz_tol_da_api : float, optional 1053 Unused, kept for backward compatibility. 1054 format : str, optional 1055 Format of requested library, e.g. "json" or "flashentropy". 1056 normalize : bool, optional 1057 Normalize spectrum intensities (default True; always recommended). 1058 fe_kwargs : dict, optional 1059 Keyword arguments for FlashEntropy search. Override keys from 1060 *settings* when both are provided. Prefer *settings* for new code. 1061 settings : SpectralSimilaritySearchSettings, optional 1062 Encapsulated FE build settings. For an :class:`~corems.mass_spectra.factory.lc_class.LCMSBase` 1063 object, pass the profile bag used for MS2 association/search, typically:: 1064 1065 from corems.encapsulation.factory.parameters import settings_from_lcms 1066 settings=settings_from_lcms(lcms_obj) # mass_spectrum["ms2"] 1067 1068 or explicitly 1069 ``lcms_obj.parameters.mass_spectrum["ms2"].spectral_similarity_search``. 1070 For an :class:`~corems.mass_spectra.factory.lc_class.LCMSCollection`, 1071 configure settings once, broadcast to every sample, then pass 1072 collection settings into the library build:: 1073 1074 from corems.encapsulation.factory.parameters import ( 1075 apply_spectral_similarity_search_to_collection, 1076 settings_from_lcms_collection, 1077 ) 1078 from corems.encapsulation.factory.processingSetting import ( 1079 SpectralSimilaritySearchSettings, 1080 ) 1081 1082 ss = SpectralSimilaritySearchSettings() 1083 ss.max_ms2_tolerance_in_da = 0.01 1084 ss.ms2_min_fe_score = 0.3 1085 apply_spectral_similarity_search_to_collection( 1086 lcms_collection, ss, profile="ms2" 1087 ) 1088 settings=settings_from_lcms_collection(lcms_collection) 1089 1090 Mutating ``settings_from_lcms_collection(...)`` in place only changes 1091 the first sample. Multi-MS2 profiles use ``profile="ms2_cid"`` (etc.). 1092 api_delay : int, optional 1093 Unused, kept for backward compatibility. 1094 api_attempts : int, optional 1095 Unused, kept for backward compatibility. 1096 1097 Returns 1098 ------- 1099 tuple 1100 Library in requested format and lipid metadata dictionary. 1101 1102 Notes 1103 ----- 1104 *settings* only drives FlashEntropy index construction. Annotation 1105 thresholds such as ``ms2_min_fe_score`` are applied later by 1106 ``fe_search`` on the LCMS object (from the same 1107 ``spectral_similarity_search`` instance when configured consistently). 1108 1109 Examples 1110 -------- 1111 Build a FlashEntropy library from an LCMSBase object's MS2 profile, then search:: 1112 1113 from corems.encapsulation.factory.parameters import settings_from_lcms 1114 1115 settings = settings_from_lcms(lcms_obj, profile="ms2") 1116 fe_lib, lipid_meta = lipid_interface.get_lipid_library( 1117 mz_list=precursor_mzs, 1118 polarity=lcms_obj.polarity, 1119 mz_tol_ppm=5, 1120 format="flashentropy", 1121 settings=settings, 1122 ) 1123 lcms_obj.fe_search(scan_list=ms2_scans, fe_lib=fe_lib) 1124 1125 Collection — configure, broadcast to all samples, then build library:: 1126 1127 from corems.encapsulation.factory.parameters import ( 1128 apply_spectral_similarity_search_to_collection, 1129 settings_from_lcms_collection, 1130 ) 1131 from corems.encapsulation.factory.processingSetting import ( 1132 SpectralSimilaritySearchSettings, 1133 ) 1134 1135 ss = SpectralSimilaritySearchSettings() 1136 ss.max_ms2_tolerance_in_da = 0.01 1137 apply_spectral_similarity_search_to_collection(lcms_collection, ss) 1138 fe_lib, lipid_meta = lipid_interface.get_lipid_library( 1139 mz_list=precursor_mzs, 1140 polarity="positive", 1141 mz_tol_ppm=5, 1142 format="flashentropy", 1143 settings=settings_from_lcms_collection(lcms_collection), 1144 ) 1145 """ 1146 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 1147 1148 if not isinstance(mz_list, (list, np.ndarray)): 1149 raise ValueError("mz_list must be a list or numpy array") 1150 if not all(isinstance(mz, (float, int)) for mz in mz_list): 1151 raise ValueError("All elements in mz_list must be float or int") 1152 if polarity not in {"positive", "negative"}: 1153 raise ValueError("polarity must be either 'positive' or 'negative'") 1154 if not isinstance(mz_tol_ppm, (float, int)): 1155 raise ValueError("mz_tol_ppm must be a float or int") 1156 1157 db_path = self._resolve_db_location() 1158 connection = sqlite3.connect(str(db_path)) 1159 try: 1160 # Step 1: Get candidate spectra records based on m/z and polarity 1161 spectra_df = self._get_candidate_spectra( 1162 connection=connection, 1163 mz_list=mz_list, 1164 polarity=polarity, 1165 mz_tol_ppm=float(mz_tol_ppm), 1166 ) 1167 1168 if spectra_df.empty: 1169 format_func = self._get_format_func(format) 1170 return format_func([], normalize=normalize, fe_kwargs=fe_kwargs), {} 1171 1172 # Step 2: Get corresponding lipid metadata for candidate spectra from lipidTree view 1173 mol_ids = tuple(spectra_df["molecular_data_id"].tolist()) 1174 mol_df = pd.read_sql_query( 1175 f"SELECT * FROM lipidTree WHERE id IN {mol_ids}", 1176 connection, 1177 ) 1178 finally: 1179 connection.close() 1180 1181 mol_df["id_index"] = mol_df["id"] 1182 mol_df = mol_df.set_index("id_index") 1183 mol_records = mol_df.to_dict(orient="index") 1184 lipid_metadata = { 1185 int(k): self._dict_to_dataclass(v, LipidMetadata) 1186 for k, v in mol_records.items() 1187 } 1188 1189 spectra_records = spectra_df.to_dict(orient="records") 1190 format_func = self._get_format_func(format) 1191 library = format_func(spectra_records, normalize=normalize, fe_kwargs=fe_kwargs) 1192 return library, lipid_metadata
Retrieve lipid spectra and metadata from a local sqlite library.
FlashEntropy build knobs should come from
~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings
so library generation matches later
~corems.molecular_id.search.lcms_spectral_search.LCMSSpectralSearch.fe_search()
/ molecular networking on the same LC-MS sample or collection.
Parameters
- mz_list (list): List of precursor m/z values.
- polarity (str): Ionization polarity, either "positive" or "negative".
- mz_tol_ppm (float): Tolerance in ppm for precursor matching.
- mz_tol_da_api (float, optional): Unused, kept for backward compatibility.
- format (str, optional): Format of requested library, e.g. "json" or "flashentropy".
- normalize (bool, optional): Normalize spectrum intensities (default True; always recommended).
- fe_kwargs (dict, optional): Keyword arguments for FlashEntropy search. Override keys from settings when both are provided. Prefer settings for new code.
settings (SpectralSimilaritySearchSettings, optional): Encapsulated FE build settings. For an
~corems.mass_spectra.factory.lc_class.LCMSBaseobject, pass the profile bag used for MS2 association/search, typically::from corems.encapsulation.factory.parameters import settings_from_lcms settings=settings_from_lcms(lcms_obj) # mass_spectrum["ms2"]or explicitly
lcms_obj.parameters.mass_spectrum["ms2"].spectral_similarity_search. For an~corems.mass_spectra.factory.lc_class.LCMSCollection, configure settings once, broadcast to every sample, then pass collection settings into the library build::from corems.encapsulation.factory.parameters import ( apply_spectral_similarity_search_to_collection, settings_from_lcms_collection, ) from corems.encapsulation.factory.processingSetting import ( SpectralSimilaritySearchSettings, ) ss = SpectralSimilaritySearchSettings() ss.max_ms2_tolerance_in_da = 0.01 ss.ms2_min_fe_score = 0.3 apply_spectral_similarity_search_to_collection( lcms_collection, ss, profile="ms2" ) settings=settings_from_lcms_collection(lcms_collection)Mutating
settings_from_lcms_collection(...)in place only changes the first sample. Multi-MS2 profiles useprofile="ms2_cid"(etc.).- api_delay (int, optional): Unused, kept for backward compatibility.
- api_attempts (int, optional): Unused, kept for backward compatibility.
Returns
- tuple: Library in requested format and lipid metadata dictionary.
Notes
settings only drives FlashEntropy index construction. Annotation
thresholds such as ms2_min_fe_score are applied later by
fe_search on the LCMS object (from the same
spectral_similarity_search instance when configured consistently).
Examples
Build a FlashEntropy library from an LCMSBase object's MS2 profile, then search::
from corems.encapsulation.factory.parameters import settings_from_lcms
settings = settings_from_lcms(lcms_obj, profile="ms2")
fe_lib, lipid_meta = lipid_interface.get_lipid_library(
mz_list=precursor_mzs,
polarity=lcms_obj.polarity,
mz_tol_ppm=5,
format="flashentropy",
settings=settings,
)
lcms_obj.fe_search(scan_list=ms2_scans, fe_lib=fe_lib)
Collection — configure, broadcast to all samples, then build library::
from corems.encapsulation.factory.parameters import (
apply_spectral_similarity_search_to_collection,
settings_from_lcms_collection,
)
from corems.encapsulation.factory.processingSetting import (
SpectralSimilaritySearchSettings,
)
ss = SpectralSimilaritySearchSettings()
ss.max_ms2_tolerance_in_da = 0.01
apply_spectral_similarity_search_to_collection(lcms_collection, ss)
fe_lib, lipid_meta = lipid_interface.get_lipid_library(
mz_list=precursor_mzs,
polarity="positive",
mz_tol_ppm=5,
format="flashentropy",
settings=settings_from_lcms_collection(lcms_collection),
)
Inherited Members
1195class MSPInterface(SpectralDatabaseInterface): 1196 """ 1197 Interface to parse NIST MSP files 1198 """ 1199 1200 def __init__(self, file_path, cache=True, cache_path=None, rebuild_cache=False): 1201 """ 1202 Initialize instance. 1203 1204 Parameters 1205 ---------- 1206 file_path : str 1207 Path to a local MSP file. 1208 cache : bool or str, optional 1209 If True, use a parquet sidecar when valid, otherwise parse and write 1210 it. If False, always parse. If ``"read"``, require a valid cache. 1211 Default is True. Sidecar path defaults to 1212 ``<file_path>.corems-lib.parquet``. 1213 cache_path : str or Path, optional 1214 Override the default cache path. 1215 rebuild_cache : bool, optional 1216 Force re-parse and rewrite the cache. Default is False. 1217 1218 Attributes 1219 ---------- 1220 file_path : str 1221 Path to the MSP file. 1222 _file_content : str 1223 Content of the MSP file (only set when parsing text). 1224 _data_frame : :obj:`~pandas.DataFrame` 1225 DataFrame of spectra from the MSP file with unaltered content. 1226 """ 1227 super().__init__(key=None) 1228 1229 self.file_path = file_path 1230 if not os.path.exists(self.file_path): 1231 raise FileNotFoundError( 1232 f"File {self.file_path} does not exist. Please check the file path." 1233 ) 1234 self._file_content = None 1235 self._cache_path = ( 1236 Path(cache_path) 1237 if cache_path is not None 1238 else spectral_library_cache.default_cache_path(self.file_path) 1239 ) 1240 self._data_frame = self._load_msp_dataframe( 1241 cache=cache, rebuild_cache=rebuild_cache 1242 ) 1243 self.__init_format_map__() 1244 1245 def _load_msp_dataframe(self, cache=True, rebuild_cache=False): 1246 """Load from parquet cache or parse the MSP file.""" 1247 use_cache = cache not in (False, None) 1248 require_cache = cache == "read" 1249 extra = {"source_kind": "msp"} 1250 1251 if ( 1252 use_cache 1253 and not rebuild_cache 1254 and spectral_library_cache.is_valid( 1255 self._cache_path, self.file_path, extra_meta=extra 1256 ) 1257 ): 1258 return spectral_library_cache.read(self._cache_path) 1259 1260 if require_cache: 1261 raise FileNotFoundError( 1262 f"Valid library cache required at {self._cache_path}" 1263 ) 1264 1265 with open(self.file_path, "r") as f: 1266 self._file_content = f.read() 1267 df = self._read_msp_file() 1268 if use_cache: 1269 spectral_library_cache.write( 1270 self._cache_path, df, self.file_path, extra_meta=extra 1271 ) 1272 return df 1273 1274 def __init_format_map__(self): 1275 """ 1276 Initialize database format mapper, enabling multiple format requests. 1277 1278 """ 1279 1280 # x is a pandas dataframe similar to self._data_frame format 1281 # Define format workflows 1282 self.format_map = { 1283 "msp": lambda x, normalize, fe_kwargs: self._to_msp(x, normalize), 1284 "flashentropy": lambda x, normalize, fe_kwargs: self._to_flashentropy( 1285 x, normalize, fe_kwargs 1286 ), 1287 "df": lambda x, normalize, fe_kwargs: self._to_df(x, normalize), 1288 } 1289 1290 # Add aliases 1291 self.format_map["fe"] = self.format_map["flashentropy"] 1292 self.format_map["flash-entropy"] = self.format_map["flashentropy"] 1293 self.format_map["dataframe"] = self.format_map["df"] 1294 self.format_map["data-frame"] = self.format_map["df"] 1295 1296 def _read_msp_file(self): 1297 """ 1298 Reads the MSP files into the pandas dataframe, and sort/remove zero intensity ions in MS/MS spectra. 1299 1300 Returns 1301 ------- 1302 :obj:`~pandas.DataFrame` 1303 DataFrame of spectra from the MSP file, exacly as it is in the file (no sorting, filtering etc) 1304 """ 1305 # If input_dataframe is provided, return it it 1306 spectra = [] 1307 spectrum = {} 1308 1309 f = StringIO(self._file_content) 1310 for line in f: 1311 line = line.strip() 1312 if not line: 1313 continue # Skip empty lines 1314 1315 # Handle metadata 1316 if ":" in line: 1317 key, value = line.split(":", 1) 1318 key = key.strip().lower() 1319 value = value.strip() 1320 1321 if key == "name": 1322 # Save current spectrum and start a new one 1323 if spectrum: 1324 spectra.append(spectrum) 1325 spectrum = {"name": value, "peaks": []} 1326 else: 1327 spectrum[key] = value 1328 1329 # Handle peak data (assumed to start with a number) 1330 elif line[0].isdigit(): 1331 peaks = line.split() 1332 m_z = float(peaks[0]) 1333 intensity = float(peaks[1]) 1334 spectrum["peaks"].append(([m_z, intensity])) 1335 # Save the last spectrum 1336 if spectrum: 1337 spectra.append(spectrum) 1338 1339 df = pd.DataFrame(spectra) 1340 for column in df.columns: 1341 if column != "peaks": # Skip 'peaks' column 1342 try: 1343 df[column] = pd.to_numeric(df[column], errors="raise") 1344 except: 1345 pass 1346 1347 # Standardize spectra ID column name 1348 # Check for common variations and create a standard 'spectra_id' column 1349 spectra_id_variants = ["spectrum_id", "gnps_spectra_id"] 1350 for variant in spectra_id_variants: 1351 if variant in df.columns and "spectra_id" not in df.columns: 1352 df["spectra_id"] = df[variant] 1353 break 1354 1355 # If no spectra_id column exists after checking variants, create one with sequential IDs 1356 if "spectra_id" not in df.columns: 1357 df["spectra_id"] = [f"spectrum_{i:06d}" for i in range(len(df))] 1358 1359 # Standardize compound name column 1360 # Ensure 'compound_name' column exists, using 'name' field 1361 if "name" in df.columns and "compound_name" not in df.columns: 1362 df["compound_name"] = df["name"] 1363 elif "compound_name" not in df.columns: 1364 warnings.warn( 1365 "MSP file does not contain 'name' or 'compound_name' field. " 1366 "Compound names will be set to 'Unknown'. This may affect plot labels and annotations.", 1367 UserWarning, 1368 ) 1369 df["compound_name"] = "Unknown" 1370 1371 return df 1372 1373 def _to_df(self, input_dataframe, normalize=True): 1374 """ 1375 Convert MSP-derived library to FlashEntropy library. 1376 1377 Parameters 1378 ---------- 1379 input_dataframe : :obj:`~pandas.DataFrame` 1380 Input DataFrame containing MSP-formatted spectra. 1381 normalize : bool, optional 1382 Normalize each spectrum by its magnitude. 1383 Default is True. 1384 1385 Returns 1386 ------- 1387 :obj:`~pandas.DataFrame` 1388 DataFrame of with desired normalization 1389 """ 1390 if not normalize: 1391 return input_dataframe 1392 else: 1393 # Convert to dictionary 1394 db_dict = input_dataframe.to_dict(orient="records") 1395 1396 # Initialize empty library 1397 lib = [] 1398 1399 # Enumerate spectra 1400 for i, source in enumerate(db_dict): 1401 spectrum = source 1402 # Check that spectrum["peaks"] exists 1403 if "peaks" not in spectrum.keys(): 1404 raise KeyError( 1405 "MSP not interpretted correctly, 'peaks' key not found in spectrum, check _dataframe attribute." 1406 ) 1407 1408 # Convert spectrum["peaks"] to numpy array 1409 if not isinstance(spectrum["peaks"], np.ndarray): 1410 spectrum["peaks"] = np.array(spectrum["peaks"]) 1411 1412 # Normalize peaks, if requested 1413 if normalize: 1414 spectrum["peaks"] = self.normalize_peaks(spectrum["peaks"]) 1415 spectrum["num peaks"] = len(spectrum["peaks"]) 1416 1417 # Add spectrum to library 1418 lib.append(spectrum) 1419 1420 # Convert to DataFrame 1421 df = pd.DataFrame(lib) 1422 return df 1423 1424 def _to_flashentropy(self, input_dataframe, normalize=True, fe_kwargs={}): 1425 """ 1426 Convert MSP-derived library to FlashEntropy library. 1427 1428 Parameters 1429 ---------- 1430 input_dataframe : :obj:`~pandas.DataFrame` 1431 Input DataFrame containing MSP-formatted spectra. 1432 normalize : bool 1433 Normalize each spectrum by its magnitude. 1434 fe_kwargs : dict, optional 1435 Keyword arguments for instantiation of FlashEntropy search and building index for FlashEntropy search; 1436 any keys not recognized will be ignored. By default, all parameters set to defaults. 1437 1438 Returns 1439 ------- 1440 :obj:`~ms_entropy.FlashEntropySearch` 1441 MS2 library as FlashEntropy search instance. 1442 1443 Raises 1444 ------ 1445 ValueError 1446 If "min_ms2_difference_in_da" or "max_ms2_tolerance_in_da" are present in `fe_kwargs` and they 1447 """ 1448 self._check_flash_entropy_kwargs(fe_kwargs) 1449 1450 db_df = input_dataframe 1451 1452 # Convert to dictionary 1453 db_dict = db_df.to_dict(orient="records") 1454 1455 # Initialize empty library 1456 fe_lib = [] 1457 1458 # Enumerate spectra 1459 for i, source in enumerate(db_dict): 1460 # Reorganize source dict, if necessary 1461 if "spectrum_data" in source.keys(): 1462 spectrum = source["spectrum_data"] 1463 else: 1464 spectrum = source 1465 1466 # Rename precursor_mz key for FlashEntropy 1467 if "precursor_mz" not in spectrum.keys(): 1468 if "precursormz" in spectrum: 1469 spectrum["precursor_mz"] = spectrum.pop("precursormz") 1470 elif "precursor_ion" in spectrum: 1471 spectrum["precursor_mz"] = spectrum.pop("precursor_ion") 1472 else: 1473 raise KeyError( 1474 "MSP must have either 'precursormz' or 'precursor_ion' key to be converted to FlashEntropy format." 1475 ) 1476 1477 # Check that spectrum["peaks"] exists 1478 if "peaks" not in spectrum.keys(): 1479 raise KeyError( 1480 "MSP not interpretted correctly, 'peaks' key not found in spectrum, check _dataframe attribute." 1481 ) 1482 1483 # Convert spectrum["peaks"] to numpy array 1484 if not isinstance(spectrum["peaks"], np.ndarray): 1485 spectrum["peaks"] = np.array(spectrum["peaks"]) 1486 1487 # Normalize peaks, if requested 1488 if normalize: 1489 spectrum["peaks"] = self.normalize_peaks(spectrum["peaks"]) 1490 1491 # Add spectrum to library 1492 fe_lib.append(spectrum) 1493 1494 # Build FlashEntropy index 1495 fe_search = self._build_flash_entropy_index(fe_lib, fe_kwargs=fe_kwargs) 1496 1497 return fe_search 1498 1499 def _check_msp_compatibility(self): 1500 """ 1501 Check if the MSP file is compatible with the get_metabolomics_spectra_library method and provide feedback if it is not. 1502 """ 1503 # Check polarity 1504 if ( 1505 "polarity" not in self._data_frame.columns 1506 and "ionmode" not in self._data_frame.columns 1507 ): 1508 raise ValueError( 1509 "Neither 'polarity' nor 'ionmode' columns found in the input MSP metadata. Please check the file." 1510 ) 1511 polarity_column = ( 1512 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 1513 ) 1514 1515 # Check if polarity_column contents is either "positive" or "negative" 1516 if not all(self._data_frame[polarity_column].isin(["positive", "negative"])): 1517 raise ValueError( 1518 f"Input field on MSP '{polarity_column}' must contain only 'positive' or 'negative' values." 1519 ) 1520 1521 # Check if the MSP file contains the required columns for metabolite metadata 1522 # either formula or molecular_formula, not null 1523 1524 if ( 1525 "formula" not in self._data_frame.columns 1526 and "molecular_formula" not in self._data_frame.columns 1527 ): 1528 raise ValueError( 1529 "Input field on MSP must contain either 'formula' or 'molecular_formula' columns." 1530 ) 1531 molecular_formula_column = ( 1532 "formula" if "formula" in self._data_frame.columns else "molecular_formula" 1533 ) 1534 if not all(self._data_frame[molecular_formula_column].notnull()): 1535 raise ValueError( 1536 f"Input field on MSP '{molecular_formula_column}' must contain only non-null values." 1537 ) 1538 1539 def get_metabolomics_spectra_library( 1540 self, 1541 polarity, 1542 metabolite_metadata_mapping={}, 1543 format="fe", 1544 normalize=True, 1545 fe_kwargs=None, 1546 settings=None, 1547 molecular_id_field="inchikey", 1548 ): 1549 """ 1550 Prepare metabolomics spectra library and associated metabolite metadata. 1551 1552 FlashEntropy build knobs should come from 1553 :class:`~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings` 1554 so library generation matches later 1555 :meth:`~corems.molecular_id.search.lcms_spectral_search.LCMSSpectralSearch.fe_search` 1556 / molecular networking on the same LC-MS sample or collection. 1557 1558 Parameters 1559 ---------- 1560 polarity : str 1561 Polarity of the spectra to extract. Must be 'positive' or 'negative'. 1562 metabolite_metadata_mapping : dict, optional 1563 Mapping of MSP field names to MetaboliteMetadata attribute names. 1564 Default uses common mappings (e.g., 'molecular_formula' -> 'formula'). 1565 format : str, optional 1566 Output format for the spectral library. Options: 'fe', 'flashentropy', 'msp', 'df'. 1567 Default is 'fe' (FlashEntropy). 1568 normalize : bool, optional 1569 Whether to normalize spectra. Default is True (always recommended). 1570 fe_kwargs : dict, optional 1571 Additional keyword arguments for FlashEntropy library creation. 1572 Override keys from *settings* when both are provided. Prefer *settings* 1573 for new code. 1574 settings : SpectralSimilaritySearchSettings, optional 1575 Encapsulated FE build settings. For an 1576 :class:`~corems.mass_spectra.factory.lc_class.LCMSBase` object, pass 1577 the MS2 parameter profile used for association/search, typically:: 1578 1579 from corems.encapsulation.factory.parameters import settings_from_lcms 1580 settings=settings_from_lcms(lcms_obj) # mass_spectrum["ms2"] 1581 1582 or explicitly 1583 ``lcms_obj.parameters.mass_spectrum["ms2"].spectral_similarity_search``. 1584 For an :class:`~corems.mass_spectra.factory.lc_class.LCMSCollection`, 1585 configure settings once, broadcast to every sample, then pass the 1586 collection's (first-sample) settings into the library build:: 1587 1588 from corems.encapsulation.factory.parameters import ( 1589 apply_spectral_similarity_search_to_collection, 1590 settings_from_lcms_collection, 1591 ) 1592 from corems.encapsulation.factory.processingSetting import ( 1593 SpectralSimilaritySearchSettings, 1594 ) 1595 1596 ss = SpectralSimilaritySearchSettings() 1597 ss.max_ms2_tolerance_in_da = 0.01 1598 apply_spectral_similarity_search_to_collection( 1599 lcms_collection, ss, profile="ms2" 1600 ) 1601 settings=settings_from_lcms_collection(lcms_collection) 1602 1603 Mutating ``settings_from_lcms_collection(...)`` in place only changes 1604 the first sample — use :func:`apply_spectral_similarity_search_to_collection` 1605 to keep all members equal. Multi-MS2 profiles use ``profile="ms2_cid"`` 1606 (or the matching ``mass_spectrum`` key). 1607 molecular_id_field : str, optional 1608 Field name to use as the unique molecular identifier for linking spectra to metadata. 1609 Default is 'inchikey'. The specified field must exist in the MSP file and contain 1610 non-null values for all entries. 1611 1612 Returns 1613 ------- 1614 tuple 1615 (spectral_library, metabolite_metadata_dict) where spectral_library is in the 1616 requested format and metabolite_metadata_dict maps molecular IDs to MetaboliteMetadata objects. 1617 1618 Notes 1619 ----- 1620 The molecular_id_field parameter allows flexibility for different MSP file formats: 1621 1622 - Use 'inchikey' for standard metabolite databases (default) 1623 - Use 'name' or 'spectra_id' for custom or isotope-labeled standards 1624 - The specified field must exist and contain non-null values for all entries 1625 1626 *settings* only drives FlashEntropy index construction. Annotation 1627 thresholds such as ``ms2_min_fe_score`` are applied later by 1628 ``fe_search`` on the LCMS object when it reads the same 1629 ``spectral_similarity_search`` instance. 1630 1631 Examples 1632 -------- 1633 Single LCMSBase file:: 1634 1635 from corems.encapsulation.factory.parameters import settings_from_lcms 1636 1637 settings = settings_from_lcms(lcms_obj, profile="ms2") 1638 fe_lib, mol_meta = msp_interface.get_metabolomics_spectra_library( 1639 polarity="positive", 1640 format="flashentropy", 1641 settings=settings, 1642 ) 1643 lcms_obj.fe_search(scan_list=ms2_scans, fe_lib=fe_lib) 1644 1645 Collection — configure, broadcast to all samples, then build library:: 1646 1647 from corems.encapsulation.factory.parameters import ( 1648 apply_spectral_similarity_search_to_collection, 1649 settings_from_lcms_collection, 1650 ) 1651 from corems.encapsulation.factory.processingSetting import ( 1652 SpectralSimilaritySearchSettings, 1653 ) 1654 1655 ss = SpectralSimilaritySearchSettings() 1656 ss.max_ms2_tolerance_in_da = 0.01 1657 apply_spectral_similarity_search_to_collection(lcms_collection, ss) 1658 fe_lib, mol_meta = msp_interface.get_metabolomics_spectra_library( 1659 polarity="negative", 1660 format="flashentropy", 1661 settings=settings_from_lcms_collection(lcms_collection), 1662 ) 1663 """ 1664 # Check if the MSP file is compatible with the get_metabolomics_spectra_library method 1665 self._check_msp_compatibility() 1666 1667 # Check if the polarity parameter is valid and if a polarity column exists in the dataframe 1668 if polarity not in ["positive", "negative"]: 1669 raise ValueError("Polarity must be 'positive' or 'negative'") 1670 polarity_column = ( 1671 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 1672 ) 1673 1674 # Get a subset of the initial dataframea by polarity 1675 db_df = self._data_frame[self._data_frame[polarity_column] == polarity].copy() 1676 1677 # Rename the columns of the db_df to match the MetaboliteMetadata dataclass using the metabolite_metadata_mapping 1678 # If the mapping is not provided, use the default mapping 1679 if not metabolite_metadata_mapping: 1680 metabolite_metadata_mapping = { 1681 "chebi_id": "chebi", 1682 "kegg_id": "kegg", 1683 "refmet_name": "common_name", 1684 "molecular_formula": "formula", 1685 "gnps_spectra_id": "id", 1686 "precursormz": "precursor_mz", 1687 "precursortype": "ion_type", 1688 } 1689 db_df.rename(columns=metabolite_metadata_mapping, inplace=True) 1690 1691 # Create molecular_data_id from the specified field 1692 if molecular_id_field not in db_df.columns: 1693 raise ValueError( 1694 f"Specified molecular_id_field '{molecular_id_field}' not found in MSP data. " 1695 f"Available columns: {', '.join(db_df.columns)}" 1696 ) 1697 1698 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 1699 1700 if not db_df[molecular_id_field].notnull().all(): 1701 raise ValueError( 1702 f"Specified molecular_id_field '{molecular_id_field}' contains null values. " 1703 f"All entries must have non-null values for the molecular ID field." 1704 ) 1705 1706 # Use the specified field as the molecular ID 1707 db_df["molecular_data_id"] = db_df[molecular_id_field].astype(str) 1708 1709 # Ensure 'id' field exists for spectra identification 1710 # If not present, create from spectra_id or use a sequential index 1711 if "id" not in db_df.columns: 1712 if "spectra_id" in db_df.columns: 1713 db_df["id"] = db_df["spectra_id"].astype(str) 1714 else: 1715 # Generate sequential IDs 1716 db_df["id"] = [f"spectrum_{i:06d}" for i in range(len(db_df))] 1717 1718 # Check if the resulting dataframe has the required columns for the flash entropy search 1719 required_columns = ["molecular_data_id", "precursor_mz", "ion_type", "id"] 1720 for col in required_columns: 1721 if col not in db_df.columns: 1722 raise ValueError( 1723 f"Input field on MSP must contain '{col}' column for FlashEntropy search." 1724 ) 1725 1726 # Pull out the metabolite metadata from the dataframe and put it into a different dataframe 1727 # First get a list of the possible attributes of the MetaboliteMetadata dataclass 1728 metabolite_metadata_keys = list(MetaboliteMetadata.__annotations__.keys()) 1729 # Replace id with molecular_data_id in metabolite_metadata_keys 1730 metabolite_metadata_keys = [ 1731 "molecular_data_id" if x == "id" else x for x in metabolite_metadata_keys 1732 ] 1733 metabolite_metadata_df = db_df[ 1734 db_df.columns[db_df.columns.isin(metabolite_metadata_keys)] 1735 ].copy() 1736 1737 # Make unique and recast the id column for metabolite metadata 1738 metabolite_metadata_df.drop_duplicates( 1739 subset=["molecular_data_id"], inplace=True 1740 ) 1741 metabolite_metadata_df["id"] = metabolite_metadata_df["molecular_data_id"] 1742 1743 # Convert to a dictionary using the molecular_data_id as the key 1744 metabolite_metadata_dict = metabolite_metadata_df.to_dict(orient="records") 1745 metabolite_metadata_dict = { 1746 v["id"]: self._dict_to_dataclass(v, MetaboliteMetadata) 1747 for v in metabolite_metadata_dict 1748 } 1749 1750 # Remove the metabolite metadata columns from the original dataframe 1751 for key in metabolite_metadata_keys: 1752 if key != "molecular_data_id": 1753 if key in db_df.columns: 1754 db_df.drop(columns=key, inplace=True) 1755 1756 # Format the spectral library 1757 format_func = self._get_format_func(format) 1758 lib = format_func(db_df, normalize=normalize, fe_kwargs=fe_kwargs) 1759 return (lib, metabolite_metadata_dict)
Interface to parse NIST MSP files
1200 def __init__(self, file_path, cache=True, cache_path=None, rebuild_cache=False): 1201 """ 1202 Initialize instance. 1203 1204 Parameters 1205 ---------- 1206 file_path : str 1207 Path to a local MSP file. 1208 cache : bool or str, optional 1209 If True, use a parquet sidecar when valid, otherwise parse and write 1210 it. If False, always parse. If ``"read"``, require a valid cache. 1211 Default is True. Sidecar path defaults to 1212 ``<file_path>.corems-lib.parquet``. 1213 cache_path : str or Path, optional 1214 Override the default cache path. 1215 rebuild_cache : bool, optional 1216 Force re-parse and rewrite the cache. Default is False. 1217 1218 Attributes 1219 ---------- 1220 file_path : str 1221 Path to the MSP file. 1222 _file_content : str 1223 Content of the MSP file (only set when parsing text). 1224 _data_frame : :obj:`~pandas.DataFrame` 1225 DataFrame of spectra from the MSP file with unaltered content. 1226 """ 1227 super().__init__(key=None) 1228 1229 self.file_path = file_path 1230 if not os.path.exists(self.file_path): 1231 raise FileNotFoundError( 1232 f"File {self.file_path} does not exist. Please check the file path." 1233 ) 1234 self._file_content = None 1235 self._cache_path = ( 1236 Path(cache_path) 1237 if cache_path is not None 1238 else spectral_library_cache.default_cache_path(self.file_path) 1239 ) 1240 self._data_frame = self._load_msp_dataframe( 1241 cache=cache, rebuild_cache=rebuild_cache 1242 ) 1243 self.__init_format_map__()
Initialize instance.
Parameters
- file_path (str): Path to a local MSP file.
- cache (bool or str, optional):
If True, use a parquet sidecar when valid, otherwise parse and write
it. If False, always parse. If
"read", require a valid cache. Default is True. Sidecar path defaults to<file_path>.corems-lib.parquet. - cache_path (str or Path, optional): Override the default cache path.
- rebuild_cache (bool, optional): Force re-parse and rewrite the cache. Default is False.
Attributes
- file_path (str): Path to the MSP file.
- _file_content (str): Content of the MSP file (only set when parsing text).
- _data_frame (
~pandas.DataFrame): DataFrame of spectra from the MSP file with unaltered content.
1539 def get_metabolomics_spectra_library( 1540 self, 1541 polarity, 1542 metabolite_metadata_mapping={}, 1543 format="fe", 1544 normalize=True, 1545 fe_kwargs=None, 1546 settings=None, 1547 molecular_id_field="inchikey", 1548 ): 1549 """ 1550 Prepare metabolomics spectra library and associated metabolite metadata. 1551 1552 FlashEntropy build knobs should come from 1553 :class:`~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings` 1554 so library generation matches later 1555 :meth:`~corems.molecular_id.search.lcms_spectral_search.LCMSSpectralSearch.fe_search` 1556 / molecular networking on the same LC-MS sample or collection. 1557 1558 Parameters 1559 ---------- 1560 polarity : str 1561 Polarity of the spectra to extract. Must be 'positive' or 'negative'. 1562 metabolite_metadata_mapping : dict, optional 1563 Mapping of MSP field names to MetaboliteMetadata attribute names. 1564 Default uses common mappings (e.g., 'molecular_formula' -> 'formula'). 1565 format : str, optional 1566 Output format for the spectral library. Options: 'fe', 'flashentropy', 'msp', 'df'. 1567 Default is 'fe' (FlashEntropy). 1568 normalize : bool, optional 1569 Whether to normalize spectra. Default is True (always recommended). 1570 fe_kwargs : dict, optional 1571 Additional keyword arguments for FlashEntropy library creation. 1572 Override keys from *settings* when both are provided. Prefer *settings* 1573 for new code. 1574 settings : SpectralSimilaritySearchSettings, optional 1575 Encapsulated FE build settings. For an 1576 :class:`~corems.mass_spectra.factory.lc_class.LCMSBase` object, pass 1577 the MS2 parameter profile used for association/search, typically:: 1578 1579 from corems.encapsulation.factory.parameters import settings_from_lcms 1580 settings=settings_from_lcms(lcms_obj) # mass_spectrum["ms2"] 1581 1582 or explicitly 1583 ``lcms_obj.parameters.mass_spectrum["ms2"].spectral_similarity_search``. 1584 For an :class:`~corems.mass_spectra.factory.lc_class.LCMSCollection`, 1585 configure settings once, broadcast to every sample, then pass the 1586 collection's (first-sample) settings into the library build:: 1587 1588 from corems.encapsulation.factory.parameters import ( 1589 apply_spectral_similarity_search_to_collection, 1590 settings_from_lcms_collection, 1591 ) 1592 from corems.encapsulation.factory.processingSetting import ( 1593 SpectralSimilaritySearchSettings, 1594 ) 1595 1596 ss = SpectralSimilaritySearchSettings() 1597 ss.max_ms2_tolerance_in_da = 0.01 1598 apply_spectral_similarity_search_to_collection( 1599 lcms_collection, ss, profile="ms2" 1600 ) 1601 settings=settings_from_lcms_collection(lcms_collection) 1602 1603 Mutating ``settings_from_lcms_collection(...)`` in place only changes 1604 the first sample — use :func:`apply_spectral_similarity_search_to_collection` 1605 to keep all members equal. Multi-MS2 profiles use ``profile="ms2_cid"`` 1606 (or the matching ``mass_spectrum`` key). 1607 molecular_id_field : str, optional 1608 Field name to use as the unique molecular identifier for linking spectra to metadata. 1609 Default is 'inchikey'. The specified field must exist in the MSP file and contain 1610 non-null values for all entries. 1611 1612 Returns 1613 ------- 1614 tuple 1615 (spectral_library, metabolite_metadata_dict) where spectral_library is in the 1616 requested format and metabolite_metadata_dict maps molecular IDs to MetaboliteMetadata objects. 1617 1618 Notes 1619 ----- 1620 The molecular_id_field parameter allows flexibility for different MSP file formats: 1621 1622 - Use 'inchikey' for standard metabolite databases (default) 1623 - Use 'name' or 'spectra_id' for custom or isotope-labeled standards 1624 - The specified field must exist and contain non-null values for all entries 1625 1626 *settings* only drives FlashEntropy index construction. Annotation 1627 thresholds such as ``ms2_min_fe_score`` are applied later by 1628 ``fe_search`` on the LCMS object when it reads the same 1629 ``spectral_similarity_search`` instance. 1630 1631 Examples 1632 -------- 1633 Single LCMSBase file:: 1634 1635 from corems.encapsulation.factory.parameters import settings_from_lcms 1636 1637 settings = settings_from_lcms(lcms_obj, profile="ms2") 1638 fe_lib, mol_meta = msp_interface.get_metabolomics_spectra_library( 1639 polarity="positive", 1640 format="flashentropy", 1641 settings=settings, 1642 ) 1643 lcms_obj.fe_search(scan_list=ms2_scans, fe_lib=fe_lib) 1644 1645 Collection — configure, broadcast to all samples, then build library:: 1646 1647 from corems.encapsulation.factory.parameters import ( 1648 apply_spectral_similarity_search_to_collection, 1649 settings_from_lcms_collection, 1650 ) 1651 from corems.encapsulation.factory.processingSetting import ( 1652 SpectralSimilaritySearchSettings, 1653 ) 1654 1655 ss = SpectralSimilaritySearchSettings() 1656 ss.max_ms2_tolerance_in_da = 0.01 1657 apply_spectral_similarity_search_to_collection(lcms_collection, ss) 1658 fe_lib, mol_meta = msp_interface.get_metabolomics_spectra_library( 1659 polarity="negative", 1660 format="flashentropy", 1661 settings=settings_from_lcms_collection(lcms_collection), 1662 ) 1663 """ 1664 # Check if the MSP file is compatible with the get_metabolomics_spectra_library method 1665 self._check_msp_compatibility() 1666 1667 # Check if the polarity parameter is valid and if a polarity column exists in the dataframe 1668 if polarity not in ["positive", "negative"]: 1669 raise ValueError("Polarity must be 'positive' or 'negative'") 1670 polarity_column = ( 1671 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 1672 ) 1673 1674 # Get a subset of the initial dataframea by polarity 1675 db_df = self._data_frame[self._data_frame[polarity_column] == polarity].copy() 1676 1677 # Rename the columns of the db_df to match the MetaboliteMetadata dataclass using the metabolite_metadata_mapping 1678 # If the mapping is not provided, use the default mapping 1679 if not metabolite_metadata_mapping: 1680 metabolite_metadata_mapping = { 1681 "chebi_id": "chebi", 1682 "kegg_id": "kegg", 1683 "refmet_name": "common_name", 1684 "molecular_formula": "formula", 1685 "gnps_spectra_id": "id", 1686 "precursormz": "precursor_mz", 1687 "precursortype": "ion_type", 1688 } 1689 db_df.rename(columns=metabolite_metadata_mapping, inplace=True) 1690 1691 # Create molecular_data_id from the specified field 1692 if molecular_id_field not in db_df.columns: 1693 raise ValueError( 1694 f"Specified molecular_id_field '{molecular_id_field}' not found in MSP data. " 1695 f"Available columns: {', '.join(db_df.columns)}" 1696 ) 1697 1698 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 1699 1700 if not db_df[molecular_id_field].notnull().all(): 1701 raise ValueError( 1702 f"Specified molecular_id_field '{molecular_id_field}' contains null values. " 1703 f"All entries must have non-null values for the molecular ID field." 1704 ) 1705 1706 # Use the specified field as the molecular ID 1707 db_df["molecular_data_id"] = db_df[molecular_id_field].astype(str) 1708 1709 # Ensure 'id' field exists for spectra identification 1710 # If not present, create from spectra_id or use a sequential index 1711 if "id" not in db_df.columns: 1712 if "spectra_id" in db_df.columns: 1713 db_df["id"] = db_df["spectra_id"].astype(str) 1714 else: 1715 # Generate sequential IDs 1716 db_df["id"] = [f"spectrum_{i:06d}" for i in range(len(db_df))] 1717 1718 # Check if the resulting dataframe has the required columns for the flash entropy search 1719 required_columns = ["molecular_data_id", "precursor_mz", "ion_type", "id"] 1720 for col in required_columns: 1721 if col not in db_df.columns: 1722 raise ValueError( 1723 f"Input field on MSP must contain '{col}' column for FlashEntropy search." 1724 ) 1725 1726 # Pull out the metabolite metadata from the dataframe and put it into a different dataframe 1727 # First get a list of the possible attributes of the MetaboliteMetadata dataclass 1728 metabolite_metadata_keys = list(MetaboliteMetadata.__annotations__.keys()) 1729 # Replace id with molecular_data_id in metabolite_metadata_keys 1730 metabolite_metadata_keys = [ 1731 "molecular_data_id" if x == "id" else x for x in metabolite_metadata_keys 1732 ] 1733 metabolite_metadata_df = db_df[ 1734 db_df.columns[db_df.columns.isin(metabolite_metadata_keys)] 1735 ].copy() 1736 1737 # Make unique and recast the id column for metabolite metadata 1738 metabolite_metadata_df.drop_duplicates( 1739 subset=["molecular_data_id"], inplace=True 1740 ) 1741 metabolite_metadata_df["id"] = metabolite_metadata_df["molecular_data_id"] 1742 1743 # Convert to a dictionary using the molecular_data_id as the key 1744 metabolite_metadata_dict = metabolite_metadata_df.to_dict(orient="records") 1745 metabolite_metadata_dict = { 1746 v["id"]: self._dict_to_dataclass(v, MetaboliteMetadata) 1747 for v in metabolite_metadata_dict 1748 } 1749 1750 # Remove the metabolite metadata columns from the original dataframe 1751 for key in metabolite_metadata_keys: 1752 if key != "molecular_data_id": 1753 if key in db_df.columns: 1754 db_df.drop(columns=key, inplace=True) 1755 1756 # Format the spectral library 1757 format_func = self._get_format_func(format) 1758 lib = format_func(db_df, normalize=normalize, fe_kwargs=fe_kwargs) 1759 return (lib, metabolite_metadata_dict)
Prepare metabolomics spectra library and associated metabolite metadata.
FlashEntropy build knobs should come from
~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings
so library generation matches later
~corems.molecular_id.search.lcms_spectral_search.LCMSSpectralSearch.fe_search()
/ molecular networking on the same LC-MS sample or collection.
Parameters
- polarity (str): Polarity of the spectra to extract. Must be 'positive' or 'negative'.
- metabolite_metadata_mapping (dict, optional): Mapping of MSP field names to MetaboliteMetadata attribute names. Default uses common mappings (e.g., 'molecular_formula' -> 'formula').
- format (str, optional): Output format for the spectral library. Options: 'fe', 'flashentropy', 'msp', 'df'. Default is 'fe' (FlashEntropy).
- normalize (bool, optional): Whether to normalize spectra. Default is True (always recommended).
- fe_kwargs (dict, optional): Additional keyword arguments for FlashEntropy library creation. Override keys from settings when both are provided. Prefer settings for new code.
settings (SpectralSimilaritySearchSettings, optional): Encapsulated FE build settings. For an
~corems.mass_spectra.factory.lc_class.LCMSBaseobject, pass the MS2 parameter profile used for association/search, typically::from corems.encapsulation.factory.parameters import settings_from_lcms settings=settings_from_lcms(lcms_obj) # mass_spectrum["ms2"]or explicitly
lcms_obj.parameters.mass_spectrum["ms2"].spectral_similarity_search. For an~corems.mass_spectra.factory.lc_class.LCMSCollection, configure settings once, broadcast to every sample, then pass the collection's (first-sample) settings into the library build::from corems.encapsulation.factory.parameters import ( apply_spectral_similarity_search_to_collection, settings_from_lcms_collection, ) from corems.encapsulation.factory.processingSetting import ( SpectralSimilaritySearchSettings, ) ss = SpectralSimilaritySearchSettings() ss.max_ms2_tolerance_in_da = 0.01 apply_spectral_similarity_search_to_collection( lcms_collection, ss, profile="ms2" ) settings=settings_from_lcms_collection(lcms_collection)Mutating
settings_from_lcms_collection(...)in place only changes the first sample — useapply_spectral_similarity_search_to_collection()to keep all members equal. Multi-MS2 profiles useprofile="ms2_cid"(or the matchingmass_spectrumkey).- molecular_id_field (str, optional): Field name to use as the unique molecular identifier for linking spectra to metadata. Default is 'inchikey'. The specified field must exist in the MSP file and contain non-null values for all entries.
Returns
- tuple: (spectral_library, metabolite_metadata_dict) where spectral_library is in the requested format and metabolite_metadata_dict maps molecular IDs to MetaboliteMetadata objects.
Notes
The molecular_id_field parameter allows flexibility for different MSP file formats:
- Use 'inchikey' for standard metabolite databases (default)
- Use 'name' or 'spectra_id' for custom or isotope-labeled standards
- The specified field must exist and contain non-null values for all entries
settings only drives FlashEntropy index construction. Annotation
thresholds such as ms2_min_fe_score are applied later by
fe_search on the LCMS object when it reads the same
spectral_similarity_search instance.
Examples
Single LCMSBase file::
from corems.encapsulation.factory.parameters import settings_from_lcms
settings = settings_from_lcms(lcms_obj, profile="ms2")
fe_lib, mol_meta = msp_interface.get_metabolomics_spectra_library(
polarity="positive",
format="flashentropy",
settings=settings,
)
lcms_obj.fe_search(scan_list=ms2_scans, fe_lib=fe_lib)
Collection — configure, broadcast to all samples, then build library::
from corems.encapsulation.factory.parameters import (
apply_spectral_similarity_search_to_collection,
settings_from_lcms_collection,
)
from corems.encapsulation.factory.processingSetting import (
SpectralSimilaritySearchSettings,
)
ss = SpectralSimilaritySearchSettings()
ss.max_ms2_tolerance_in_da = 0.01
apply_spectral_similarity_search_to_collection(lcms_collection, ss)
fe_lib, mol_meta = msp_interface.get_metabolomics_spectra_library(
polarity="negative",
format="flashentropy",
settings=settings_from_lcms_collection(lcms_collection),
)
Inherited Members
1762class SpectraverseMS2Interface(SpectralDatabaseInterface): 1763 """Interface to Spectraverse MS/MS (MS2) libraries (MGF). 1764 1765 Loads MS2 spectra only for metabolomics spectral search (Flash Entropy or 1766 DataFrame). Precursor m/z used for search is calculated from formula + adduct 1767 via CoreMS ion-type machinery, not the library experimental PRECURSOR_MZ. 1768 1769 Download the release MGF from Zenodo, e.g. 1770 https://zenodo.org/records/19927403 (``spectraverse-1.0.2.mgf``). 1771 1772 References 1773 ---------- 1774 Gupta, V., Qiang, H., Chung, H.-H., Herbst, E., & Skinnider, M. (2026). 1775 Comprehensive curation and harmonization of small molecule MS/MS libraries 1776 in Spectraverse [Data set]. Zenodo. 1777 https://doi.org/10.5281/zenodo.17252772 1778 1779 See also: https://github.com/skinniderlab/spectraverse-analysis 1780 """ 1781 1782 def __init__(self, file_path, cache=True, cache_path=None, rebuild_cache=False): 1783 """ 1784 Parameters 1785 ---------- 1786 file_path : str or Path 1787 Path to a Spectraverse MGF file. 1788 cache : bool or str, optional 1789 If True, use a parquet sidecar when valid, otherwise parse and write 1790 it. If False, always parse. If ``"read"``, require a valid cache. 1791 Default is True. 1792 cache_path : str or Path, optional 1793 Override default ``<file_path>.corems-lib.parquet``. 1794 rebuild_cache : bool, optional 1795 Force re-parse and rewrite the cache. Default is False. 1796 """ 1797 super().__init__(key=None) 1798 self.file_path = str(file_path) 1799 if not os.path.exists(self.file_path): 1800 raise FileNotFoundError( 1801 f"File {self.file_path} does not exist. Please check the file path." 1802 ) 1803 self._cache_path = ( 1804 Path(cache_path) 1805 if cache_path is not None 1806 else spectral_library_cache.default_cache_path(self.file_path) 1807 ) 1808 self._precursor_cache = {} 1809 self._data_frame = self._load_dataframe( 1810 cache=cache, rebuild_cache=rebuild_cache 1811 ) 1812 self.__init_format_map__() 1813 1814 def __init_format_map__(self): 1815 self.format_map = { 1816 "flashentropy": lambda x, normalize, fe_kwargs: self._to_flashentropy( 1817 x, normalize, fe_kwargs 1818 ), 1819 "df": lambda x, normalize, fe_kwargs: self._to_df(x, normalize), 1820 } 1821 self.format_map["fe"] = self.format_map["flashentropy"] 1822 self.format_map["flash-entropy"] = self.format_map["flashentropy"] 1823 self.format_map["dataframe"] = self.format_map["df"] 1824 self.format_map["data-frame"] = self.format_map["df"] 1825 1826 def _load_dataframe(self, cache=True, rebuild_cache=False): 1827 use_cache = cache not in (False, None) 1828 require_cache = cache == "read" 1829 extra = { 1830 "source_kind": "spectraverse_mgf", 1831 "precursor_policy": "formula+adduct", 1832 } 1833 1834 if ( 1835 use_cache 1836 and not rebuild_cache 1837 and spectral_library_cache.is_valid( 1838 self._cache_path, self.file_path, extra_meta=extra 1839 ) 1840 ): 1841 return spectral_library_cache.read(self._cache_path) 1842 1843 if require_cache: 1844 raise FileNotFoundError( 1845 f"Valid library cache required at {self._cache_path}" 1846 ) 1847 1848 df = self._read_mgf_file() 1849 if use_cache: 1850 spectral_library_cache.write( 1851 self._cache_path, df, self.file_path, extra_meta=extra 1852 ) 1853 return df 1854 1855 def _precursor_mz_from_formula(self, formula: str, adduct: str) -> float: 1856 """Calculate precursor m/z from neutral formula and adduct.""" 1857 from corems.molecular_formula.calc.MolecularFormulaCalc import ( 1858 MolecularFormulaCalc, 1859 ) 1860 1861 key = (formula, adduct) 1862 if key in self._precursor_cache: 1863 return self._precursor_cache[key] 1864 mz = MolecularFormulaCalc.precursor_mz_from_formula(formula, adduct) 1865 self._precursor_cache[key] = mz 1866 return mz 1867 1868 def _read_mgf_file(self) -> pd.DataFrame: 1869 """Stream-parse Spectraverse MGF; keep MS2 spectra only.""" 1870 spectra = [] 1871 headers = {} 1872 peaks = [] 1873 in_block = False 1874 n_skipped = 0 1875 1876 with open(self.file_path, "r", encoding="utf-8", errors="replace") as f: 1877 for line in f: 1878 line = line.strip() 1879 if not line: 1880 continue 1881 if line == "BEGIN IONS": 1882 in_block = True 1883 headers = {} 1884 peaks = [] 1885 continue 1886 if line == "END IONS": 1887 if in_block: 1888 row = self._finalize_mgf_block(headers, peaks) 1889 if row is None: 1890 n_skipped += 1 1891 else: 1892 spectra.append(row) 1893 in_block = False 1894 headers = {} 1895 peaks = [] 1896 continue 1897 if not in_block: 1898 continue 1899 if "=" in line and not line[0].isdigit(): 1900 key, value = line.split("=", 1) 1901 headers[key.strip().upper()] = value.strip() 1902 elif line[0].isdigit() or ( 1903 line[0] == "-" and len(line) > 1 and line[1].isdigit() 1904 ): 1905 parts = line.split() 1906 if len(parts) >= 2: 1907 peaks.append([float(parts[0]), float(parts[1])]) 1908 1909 if not spectra: 1910 raise ValueError( 1911 f"No MS2 spectra loaded from {self.file_path}" 1912 + (f" ({n_skipped} blocks skipped)" if n_skipped else "") 1913 ) 1914 if n_skipped: 1915 warnings.warn( 1916 f"Skipped {n_skipped} Spectraverse MGF blocks (non-MS2, empty, or " 1917 f"unsupported formula/adduct).", 1918 UserWarning, 1919 ) 1920 return pd.DataFrame(spectra) 1921 1922 def _finalize_mgf_block(self, headers: dict, peaks: list): 1923 ms_level = str(headers.get("MS_LEVEL", "")).strip().upper() 1924 if ms_level not in ("MS2", "2"): 1925 return None 1926 if not peaks: 1927 return None 1928 1929 formula = headers.get("FORMULA", "").strip() 1930 adduct = headers.get("ADDUCT", "").strip() 1931 if not formula or not adduct: 1932 return None 1933 1934 try: 1935 precursor_mz = self._precursor_mz_from_formula(formula, adduct) 1936 except Exception: 1937 return None 1938 1939 ionmode = headers.get("IONMODE", "").strip().lower() 1940 title = headers.get("TITLE", "").strip() 1941 compound_name = headers.get("COMPOUND_NAME", "").strip() or "Unknown" 1942 peaks_arr = np.asarray(peaks, dtype=np.float64) 1943 1944 lib_precursor = headers.get("PRECURSOR_MZ", "").strip() 1945 try: 1946 precursor_mz_library = float(lib_precursor) if lib_precursor else None 1947 except ValueError: 1948 precursor_mz_library = None 1949 1950 return { 1951 "id": title or None, 1952 "spectra_id": title or None, 1953 "name": compound_name, 1954 "compound_name": compound_name, 1955 "formula": formula, 1956 "inchikey": headers.get("INCHIKEY", "").strip() or None, 1957 "inchi": headers.get("INCHI", "").strip() or None, 1958 "smiles": headers.get("SMILES", "").strip() or None, 1959 "ionmode": ionmode, 1960 "polarity": ionmode, 1961 "ion_type": adduct, 1962 "adduct": adduct, 1963 "precursor_mz": precursor_mz, 1964 "precursor_mz_library": precursor_mz_library, 1965 "charge": headers.get("CHARGE", "").strip() or None, 1966 "source": headers.get("SOURCE", "").strip() or None, 1967 "instrument_type": headers.get("INSTRUMENT_TYPE", "").strip() or None, 1968 "ms_level": ms_level, 1969 "peaks": peaks_arr, 1970 "num peaks": len(peaks_arr), 1971 } 1972 1973 def _to_df(self, input_dataframe, normalize=True): 1974 if not normalize: 1975 return input_dataframe 1976 db_dict = input_dataframe.to_dict(orient="records") 1977 lib = [] 1978 for spectrum in db_dict: 1979 if "peaks" not in spectrum: 1980 raise KeyError("peaks key not found in spectrum") 1981 if not isinstance(spectrum["peaks"], np.ndarray): 1982 spectrum["peaks"] = np.array(spectrum["peaks"]) 1983 spectrum["peaks"] = self.normalize_peaks(spectrum["peaks"]) 1984 spectrum["num peaks"] = len(spectrum["peaks"]) 1985 lib.append(spectrum) 1986 return pd.DataFrame(lib) 1987 1988 def _to_flashentropy(self, input_dataframe, normalize=True, fe_kwargs={}): 1989 self._check_flash_entropy_kwargs(fe_kwargs) 1990 fe_lib = [] 1991 for source in input_dataframe.to_dict(orient="records"): 1992 spectrum = source 1993 if "precursor_mz" not in spectrum: 1994 raise KeyError("precursor_mz required for FlashEntropy format") 1995 if "peaks" not in spectrum: 1996 raise KeyError("peaks key not found in spectrum") 1997 if not isinstance(spectrum["peaks"], np.ndarray): 1998 spectrum["peaks"] = np.array(spectrum["peaks"]) 1999 if normalize: 2000 spectrum["peaks"] = self.normalize_peaks(spectrum["peaks"]) 2001 fe_lib.append(spectrum) 2002 return self._build_flash_entropy_index(fe_lib, fe_kwargs=fe_kwargs) 2003 2004 def _check_spectraverse_compatibility(self): 2005 if ( 2006 "polarity" not in self._data_frame.columns 2007 and "ionmode" not in self._data_frame.columns 2008 ): 2009 raise ValueError( 2010 "Neither 'polarity' nor 'ionmode' columns found in Spectraverse data." 2011 ) 2012 polarity_column = ( 2013 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 2014 ) 2015 if not all(self._data_frame[polarity_column].isin(["positive", "negative"])): 2016 raise ValueError( 2017 f"'{polarity_column}' must contain only 'positive' or 'negative' values." 2018 ) 2019 if "formula" not in self._data_frame.columns: 2020 raise ValueError("Spectraverse data must contain a 'formula' column.") 2021 if not all(self._data_frame["formula"].notnull()): 2022 raise ValueError("'formula' must contain only non-null values.") 2023 2024 def get_metabolomics_spectra_library( 2025 self, 2026 polarity, 2027 metabolite_metadata_mapping=None, 2028 format="fe", 2029 normalize=True, 2030 fe_kwargs=None, 2031 settings=None, 2032 molecular_id_field="inchikey", 2033 ): 2034 """ 2035 Prepare metabolomics MS2 spectra library and metabolite metadata. 2036 2037 Parameters 2038 ---------- 2039 polarity : str 2040 ``'positive'`` or ``'negative'``. 2041 metabolite_metadata_mapping : dict, optional 2042 Optional column renames into MetaboliteMetadata fields. 2043 format : str, optional 2044 ``'fe'`` / ``'flashentropy'`` or ``'df'`` / ``'dataframe'``. 2045 normalize : bool, optional 2046 Normalize peak intensities. Default True. 2047 fe_kwargs : dict, optional 2048 FlashEntropy build options. Override keys from *settings* when both 2049 are provided. Prefer *settings* for new code. 2050 settings : SpectralSimilaritySearchSettings, optional 2051 Encapsulated FE build settings (same object used by 2052 ``MSPInterface.get_metabolomics_spectra_library``). Constructor 2053 kwargs ``cache`` / ``cache_path`` / ``rebuild_cache`` stay on 2054 :meth:`__init__`; do not pass *settings* there. 2055 molecular_id_field : str, optional 2056 Field used as molecular metadata key. Default ``'inchikey'``. 2057 2058 Returns 2059 ------- 2060 tuple 2061 ``(spectral_library, metabolite_metadata_dict)``. 2062 """ 2063 if metabolite_metadata_mapping is None: 2064 metabolite_metadata_mapping = {} 2065 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 2066 2067 self._check_spectraverse_compatibility() 2068 2069 if polarity not in ["positive", "negative"]: 2070 raise ValueError("Polarity must be 'positive' or 'negative'") 2071 polarity_column = ( 2072 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 2073 ) 2074 db_df = self._data_frame[self._data_frame[polarity_column] == polarity].copy() 2075 if db_df.empty: 2076 raise ValueError(f"No Spectraverse spectra for polarity={polarity!r}") 2077 2078 if metabolite_metadata_mapping: 2079 db_df.rename(columns=metabolite_metadata_mapping, inplace=True) 2080 2081 if molecular_id_field not in db_df.columns: 2082 raise ValueError( 2083 f"Specified molecular_id_field '{molecular_id_field}' not found. " 2084 f"Available columns: {', '.join(map(str, db_df.columns))}" 2085 ) 2086 if not db_df[molecular_id_field].notnull().all(): 2087 raise ValueError( 2088 f"Specified molecular_id_field '{molecular_id_field}' contains null values." 2089 ) 2090 2091 db_df["molecular_data_id"] = db_df[molecular_id_field].astype(str) 2092 if "id" not in db_df.columns or db_df["id"].isnull().any(): 2093 if "spectra_id" in db_df.columns: 2094 db_df["id"] = db_df["spectra_id"].astype(str) 2095 else: 2096 db_df["id"] = [f"spectrum_{i:06d}" for i in range(len(db_df))] 2097 2098 required_columns = ["molecular_data_id", "precursor_mz", "ion_type", "id"] 2099 for col in required_columns: 2100 if col not in db_df.columns: 2101 raise ValueError( 2102 f"Spectraverse data must contain '{col}' for FlashEntropy search." 2103 ) 2104 2105 metabolite_metadata_keys = list(MetaboliteMetadata.__annotations__.keys()) 2106 metabolite_metadata_keys = [ 2107 "molecular_data_id" if x == "id" else x for x in metabolite_metadata_keys 2108 ] 2109 metabolite_metadata_df = db_df[ 2110 db_df.columns[db_df.columns.isin(metabolite_metadata_keys)] 2111 ].copy() 2112 metabolite_metadata_df.drop_duplicates( 2113 subset=["molecular_data_id"], inplace=True 2114 ) 2115 metabolite_metadata_df["id"] = metabolite_metadata_df["molecular_data_id"] 2116 metabolite_metadata_dict = { 2117 v["id"]: self._dict_to_dataclass(v, MetaboliteMetadata) 2118 for v in metabolite_metadata_df.to_dict(orient="records") 2119 } 2120 2121 for key in metabolite_metadata_keys: 2122 if key != "molecular_data_id" and key in db_df.columns: 2123 db_df.drop(columns=key, inplace=True) 2124 2125 format_func = self._get_format_func(format) 2126 lib = format_func(db_df, normalize=normalize, fe_kwargs=fe_kwargs) 2127 return (lib, metabolite_metadata_dict)
Interface to Spectraverse MS/MS (MS2) libraries (MGF).
Loads MS2 spectra only for metabolomics spectral search (Flash Entropy or DataFrame). Precursor m/z used for search is calculated from formula + adduct via CoreMS ion-type machinery, not the library experimental PRECURSOR_MZ.
Download the release MGF from Zenodo, e.g.
https://zenodo.org/records/19927403 (spectraverse-1.0.2.mgf).
References
Gupta, V., Qiang, H., Chung, H.-H., Herbst, E., & Skinnider, M. (2026). Comprehensive curation and harmonization of small molecule MS/MS libraries in Spectraverse [Data set]. Zenodo. https://doi.org/10.5281/zenodo.17252772
See also: https://github.com/skinniderlab/spectraverse-analysis
1782 def __init__(self, file_path, cache=True, cache_path=None, rebuild_cache=False): 1783 """ 1784 Parameters 1785 ---------- 1786 file_path : str or Path 1787 Path to a Spectraverse MGF file. 1788 cache : bool or str, optional 1789 If True, use a parquet sidecar when valid, otherwise parse and write 1790 it. If False, always parse. If ``"read"``, require a valid cache. 1791 Default is True. 1792 cache_path : str or Path, optional 1793 Override default ``<file_path>.corems-lib.parquet``. 1794 rebuild_cache : bool, optional 1795 Force re-parse and rewrite the cache. Default is False. 1796 """ 1797 super().__init__(key=None) 1798 self.file_path = str(file_path) 1799 if not os.path.exists(self.file_path): 1800 raise FileNotFoundError( 1801 f"File {self.file_path} does not exist. Please check the file path." 1802 ) 1803 self._cache_path = ( 1804 Path(cache_path) 1805 if cache_path is not None 1806 else spectral_library_cache.default_cache_path(self.file_path) 1807 ) 1808 self._precursor_cache = {} 1809 self._data_frame = self._load_dataframe( 1810 cache=cache, rebuild_cache=rebuild_cache 1811 ) 1812 self.__init_format_map__()
Parameters
- file_path (str or Path): Path to a Spectraverse MGF file.
- cache (bool or str, optional):
If True, use a parquet sidecar when valid, otherwise parse and write
it. If False, always parse. If
"read", require a valid cache. Default is True. - cache_path (str or Path, optional):
Override default
<file_path>.corems-lib.parquet. - rebuild_cache (bool, optional): Force re-parse and rewrite the cache. Default is False.
2024 def get_metabolomics_spectra_library( 2025 self, 2026 polarity, 2027 metabolite_metadata_mapping=None, 2028 format="fe", 2029 normalize=True, 2030 fe_kwargs=None, 2031 settings=None, 2032 molecular_id_field="inchikey", 2033 ): 2034 """ 2035 Prepare metabolomics MS2 spectra library and metabolite metadata. 2036 2037 Parameters 2038 ---------- 2039 polarity : str 2040 ``'positive'`` or ``'negative'``. 2041 metabolite_metadata_mapping : dict, optional 2042 Optional column renames into MetaboliteMetadata fields. 2043 format : str, optional 2044 ``'fe'`` / ``'flashentropy'`` or ``'df'`` / ``'dataframe'``. 2045 normalize : bool, optional 2046 Normalize peak intensities. Default True. 2047 fe_kwargs : dict, optional 2048 FlashEntropy build options. Override keys from *settings* when both 2049 are provided. Prefer *settings* for new code. 2050 settings : SpectralSimilaritySearchSettings, optional 2051 Encapsulated FE build settings (same object used by 2052 ``MSPInterface.get_metabolomics_spectra_library``). Constructor 2053 kwargs ``cache`` / ``cache_path`` / ``rebuild_cache`` stay on 2054 :meth:`__init__`; do not pass *settings* there. 2055 molecular_id_field : str, optional 2056 Field used as molecular metadata key. Default ``'inchikey'``. 2057 2058 Returns 2059 ------- 2060 tuple 2061 ``(spectral_library, metabolite_metadata_dict)``. 2062 """ 2063 if metabolite_metadata_mapping is None: 2064 metabolite_metadata_mapping = {} 2065 fe_kwargs = _resolve_fe_kwargs(settings=settings, fe_kwargs=fe_kwargs) 2066 2067 self._check_spectraverse_compatibility() 2068 2069 if polarity not in ["positive", "negative"]: 2070 raise ValueError("Polarity must be 'positive' or 'negative'") 2071 polarity_column = ( 2072 "polarity" if "polarity" in self._data_frame.columns else "ionmode" 2073 ) 2074 db_df = self._data_frame[self._data_frame[polarity_column] == polarity].copy() 2075 if db_df.empty: 2076 raise ValueError(f"No Spectraverse spectra for polarity={polarity!r}") 2077 2078 if metabolite_metadata_mapping: 2079 db_df.rename(columns=metabolite_metadata_mapping, inplace=True) 2080 2081 if molecular_id_field not in db_df.columns: 2082 raise ValueError( 2083 f"Specified molecular_id_field '{molecular_id_field}' not found. " 2084 f"Available columns: {', '.join(map(str, db_df.columns))}" 2085 ) 2086 if not db_df[molecular_id_field].notnull().all(): 2087 raise ValueError( 2088 f"Specified molecular_id_field '{molecular_id_field}' contains null values." 2089 ) 2090 2091 db_df["molecular_data_id"] = db_df[molecular_id_field].astype(str) 2092 if "id" not in db_df.columns or db_df["id"].isnull().any(): 2093 if "spectra_id" in db_df.columns: 2094 db_df["id"] = db_df["spectra_id"].astype(str) 2095 else: 2096 db_df["id"] = [f"spectrum_{i:06d}" for i in range(len(db_df))] 2097 2098 required_columns = ["molecular_data_id", "precursor_mz", "ion_type", "id"] 2099 for col in required_columns: 2100 if col not in db_df.columns: 2101 raise ValueError( 2102 f"Spectraverse data must contain '{col}' for FlashEntropy search." 2103 ) 2104 2105 metabolite_metadata_keys = list(MetaboliteMetadata.__annotations__.keys()) 2106 metabolite_metadata_keys = [ 2107 "molecular_data_id" if x == "id" else x for x in metabolite_metadata_keys 2108 ] 2109 metabolite_metadata_df = db_df[ 2110 db_df.columns[db_df.columns.isin(metabolite_metadata_keys)] 2111 ].copy() 2112 metabolite_metadata_df.drop_duplicates( 2113 subset=["molecular_data_id"], inplace=True 2114 ) 2115 metabolite_metadata_df["id"] = metabolite_metadata_df["molecular_data_id"] 2116 metabolite_metadata_dict = { 2117 v["id"]: self._dict_to_dataclass(v, MetaboliteMetadata) 2118 for v in metabolite_metadata_df.to_dict(orient="records") 2119 } 2120 2121 for key in metabolite_metadata_keys: 2122 if key != "molecular_data_id" and key in db_df.columns: 2123 db_df.drop(columns=key, inplace=True) 2124 2125 format_func = self._get_format_func(format) 2126 lib = format_func(db_df, normalize=normalize, fe_kwargs=fe_kwargs) 2127 return (lib, metabolite_metadata_dict)
Prepare metabolomics MS2 spectra library and metabolite metadata.
Parameters
- polarity (str):
'positive'or'negative'. - metabolite_metadata_mapping (dict, optional): Optional column renames into MetaboliteMetadata fields.
- format (str, optional):
'fe'/'flashentropy'or'df'/'dataframe'. - normalize (bool, optional): Normalize peak intensities. Default True.
- fe_kwargs (dict, optional): FlashEntropy build options. Override keys from settings when both are provided. Prefer settings for new code.
- settings (SpectralSimilaritySearchSettings, optional):
Encapsulated FE build settings (same object used by
MSPInterface.get_metabolomics_spectra_library). Constructor kwargscache/cache_path/rebuild_cachestay on__init__(); do not pass settings there. - molecular_id_field (str, optional):
Field used as molecular metadata key. Default
'inchikey'.
Returns
- tuple:
(spectral_library, metabolite_metadata_dict).