corems.mass_spectra.calc.lc_calc_operations
Sample-level operations for LCMS collection processing pipelines.
This module provides a framework for defining reusable, composable operations that can be executed on individual samples in a parallelized manner.
Classes
SampleOperation Base class for all sample-level operations GapFillOperation Gap-fill missing cluster features for a sample ReloadFeaturesOperation Reload mass features from HDF5 for a sample
1""" 2Sample-level operations for LCMS collection processing pipelines. 3 4This module provides a framework for defining reusable, composable operations 5that can be executed on individual samples in a parallelized manner. 6 7Classes 8------- 9SampleOperation 10 Base class for all sample-level operations 11GapFillOperation 12 Gap-fill missing cluster features for a sample 13ReloadFeaturesOperation 14 Reload mass features from HDF5 for a sample 15 16""" 17 18from abc import ABC, abstractmethod 19import pandas as pd 20 21 22class SampleOperation(ABC): 23 """ 24 Base class for operations that can be performed on a sample. 25 26 All sample operations must inherit from this class and implement all 27 abstract methods. This ensures proper integration with the pipeline framework. 28 29 Parameters 30 ---------- 31 name : str 32 Name of the operation (for logging and identification) 33 **kwargs 34 Additional keyword arguments stored as operation parameters 35 36 Attributes 37 ---------- 38 name : str 39 Operation name 40 params : dict 41 Dictionary of operation parameters 42 description : str 43 Human-readable description for progress messages (must override in subclasses) 44 """ 45 46 def __init__(self, name, **kwargs): 47 self.name = name 48 self.params = kwargs 49 50 @property 51 @abstractmethod 52 def description(self): 53 """ 54 Human-readable description for progress messages. 55 56 This property must be overridden in subclasses to provide a meaningful 57 description that will be shown in progress bars (e.g., "gap-filling", 58 "reloading features", etc.). 59 60 Returns 61 ------- 62 str 63 Brief description of what this operation does 64 """ 65 pass 66 67 @abstractmethod 68 def needs_raw_ms_data(self): 69 """ 70 Declare whether this operation needs raw MS data loaded. 71 72 Subclasses must implement this method to specify raw data requirements. 73 The pipeline executor will ensure raw data is loaded before executing 74 operations that need it, and can clean it up afterwards. 75 76 Returns 77 ------- 78 tuple of (bool, int or None) 79 (needs_raw_data, ms_level) 80 - needs_raw_data: True if operation needs raw MS data 81 - ms_level: MS level needed (1 for MS1, 2 for MS2, etc.) or None 82 83 Examples 84 -------- 85 >>> def needs_raw_ms_data(self): 86 ... return True, 1 # Needs MS1 data 87 >>> def needs_raw_ms_data(self): 88 ... return False, None # No raw data needed 89 """ 90 pass 91 92 @abstractmethod 93 def can_execute(self, sample, collection): 94 """ 95 Check if this operation can be executed on the sample. 96 97 Subclasses must implement this method to define prerequisites. 98 Return True if the operation can execute, False otherwise. 99 100 Parameters 101 ---------- 102 sample : LCMSBase 103 The sample to check 104 collection : LCMSBaseCollection 105 The collection containing the sample 106 107 Returns 108 ------- 109 bool 110 True if operation can execute, False otherwise 111 112 Examples 113 -------- 114 >>> def can_execute(self, sample, collection): 115 ... return True # Can always execute 116 >>> def can_execute(self, sample, collection): 117 ... return hasattr(sample, 'mass_features') and sample.mass_features 118 """ 119 pass 120 121 @abstractmethod 122 def execute(self, sample_id, collection, **runtime_params): 123 """ 124 Execute the operation on a sample. 125 126 This method must be implemented by subclasses. 127 128 Parameters 129 ---------- 130 sample_id : int 131 Sample ID to process 132 collection : LCMSBaseCollection 133 The collection containing the sample 134 **runtime_params 135 Runtime parameters passed from the pipeline 136 137 Returns 138 ------- 139 result 140 Operation result (can be None if operation modifies sample in place) 141 """ 142 pass 143 144 @abstractmethod 145 def collect_results(self, sample_id, result, collection): 146 """ 147 Collect results back into collection after parallel execution. 148 149 Subclasses must implement this method to handle result collection. 150 If the operation modifies samples in place and doesn't need to collect 151 results, simply implement as `pass`. 152 153 Parameters 154 ---------- 155 sample_id : int 156 Sample ID that was processed 157 result 158 Result returned from execute() 159 collection : LCMSBaseCollection 160 The collection to update 161 162 Examples 163 -------- 164 >>> def collect_results(self, sample_id, result, collection): 165 ... pass # Operation modifies sample in place 166 >>> def collect_results(self, sample_id, result, collection): 167 ... collection[sample_id].induced_mass_features = result 168 """ 169 pass 170 171 def __repr__(self): 172 return f"{self.__class__.__name__}(name='{self.name}')" 173 174 175class GapFillOperation(SampleOperation): 176 """ 177 Gap-fill missing cluster features for a sample. 178 179 Searches raw MS1 data to find peaks in expected m/z and retention time 180 windows for clusters that are present in other samples but missing from 181 this sample. 182 183 Uses time range filtering for efficient data loading - only loads the 184 retention time windows where gaps need to be filled, plus a buffer 185 (controlled by eic_buffer_time parameter) for complete EIC extraction. 186 Multiple time ranges are automatically merged if they overlap. 187 188 Parameters 189 ---------- 190 name : str 191 Operation name 192 expand_on_miss : bool, optional 193 If True, expands search window when no peak is found. Default is False. 194 195 Notes 196 ----- 197 Requires that add_consensus_mass_features() has been run on the collection. 198 This operation loads raw MS1 data which will be available for subsequent operations. 199 Time range filtering significantly reduces memory usage and loading time for 200 large datasets with sparse gaps. 201 """ 202 203 @property 204 def description(self): 205 """Human-readable description for progress messages.""" 206 return "gap-filling" 207 208 def needs_raw_ms_data(self): 209 """This operation needs raw MS1 data.""" 210 return True, 1 211 212 def can_execute(self, sample, collection): 213 """Check if cluster summary exists.""" 214 return hasattr(collection, 'cluster_summary_dataframe') and \ 215 collection.cluster_summary_dataframe is not None 216 217 def execute(self, sample_id, collection, **runtime_params): 218 """ 219 Execute gap-filling for a single sample. 220 221 Parameters 222 ---------- 223 sample_id : int 224 Sample index to process 225 collection : LCMSBaseCollection 226 The collection 227 **runtime_params 228 Runtime parameters including: 229 - missingdf : pd.DataFrame - Cluster information and missing samples (optional) 230 - cluster_dict : dict - Cluster feature dictionary (optional) 231 - expand_on_miss : bool - Whether to expand search window on miss (optional) 232 If these are not provided, returns empty dict (no gaps to fill). 233 234 Returns 235 ------- 236 dict 237 Dictionary of induced mass features (empty if no gaps to fill) 238 """ 239 # Extract gap-fill parameters from runtime_params 240 # If not present, there are no gaps to fill, so return early 241 if 'missingdf' not in runtime_params: 242 return {} 243 244 missingdf = runtime_params['missingdf'] 245 cluster_dict = runtime_params['cluster_dict'] 246 expand_on_miss = runtime_params['expand_on_miss'] 247 248 # This is essentially the same logic as _search_for_targeted_mass_features_in_sample 249 # but extracted into an operation 250 251 # Get clusters missing data for this sample 252 sampledf = missingdf[ 253 missingdf.missing_samples.apply(lambda x: sample_id in x) 254 ].reset_index(drop=True).copy() 255 256 # Skip if no missing features for this sample 257 if len(sampledf) == 0: 258 return {} 259 260 # Get buffer time from LCMS parameters for EIC extraction 261 # This ensures we capture the full chromatographic peak beyond cluster bounds 262 buffer_rt = collection[sample_id].parameters.lc_ms.eic_buffer_time 263 264 # Calculate time ranges for efficient loading with buffer for EIC extraction 265 time_ranges = [] 266 267 for _, row in sampledf.iterrows(): 268 rt_min = row['scan_time_aligned_min'] 269 rt_max = row['scan_time_aligned_max'] 270 271 # If expand_on_miss, also consider the allowed bounds 272 if expand_on_miss: 273 rt_min = min(rt_min, row['sta_min_allowed']) 274 rt_max = max(rt_max, row['sta_max_allowed']) 275 276 # Apply buffer AFTER considering expand_on_miss bounds 277 # This ensures buffer is added beyond even the expanded search window 278 time_ranges.append((max(0, rt_min - buffer_rt), rt_max + buffer_rt)) 279 280 # Merge overlapping time ranges to reduce number of separate loads 281 time_ranges = sorted(time_ranges) 282 merged_ranges = [] 283 if time_ranges: 284 current_min, current_max = time_ranges[0] 285 for rt_min, rt_max in time_ranges[1:]: 286 if rt_min <= current_max: # Overlapping or adjacent 287 current_max = max(current_max, rt_max) 288 else: 289 merged_ranges.append((current_min, current_max)) 290 current_min, current_max = rt_min, rt_max 291 merged_ranges.append((current_min, current_max)) 292 293 # Load raw data for this sample with time range filtering 294 collection.load_raw_data(sample_id, 1, time_range=merged_ranges) 295 296 # Get MS1 data 297 ms1df = collection[sample_id]._ms_unprocessed[1].copy() 298 scan_df = collection[sample_id].scan_df[['scan', 'scan_time_aligned']] 299 ms1df = pd.merge(ms1df, scan_df, on='scan') 300 301 # Pre-extract all values from sampledf 302 clusters = sampledf.cluster.values 303 mz_mins = sampledf.mz_min.values 304 mz_maxs = sampledf.mz_max.values 305 st_mins = sampledf.scan_time_aligned_min.values 306 st_maxs = sampledf.scan_time_aligned_max.values 307 308 if expand_on_miss: 309 mz_mins_allowed = sampledf.mz_min_allowed.values 310 mz_maxs_allowed = sampledf.mz_max_allowed.values 311 st_mins_allowed = sampledf.sta_min_allowed.values 312 st_maxs_allowed = sampledf.sta_max_allowed.values 313 314 # Pre-filter ms1df to reduce search space 315 mz_global_min = mz_mins.min() 316 mz_global_max = mz_maxs.max() 317 st_global_min = st_mins.min() 318 st_global_max = st_maxs.max() 319 320 if expand_on_miss: 321 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 322 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 323 st_global_min = min(st_global_min, st_mins_allowed.min()) 324 st_global_max = max(st_global_max, st_maxs_allowed.max()) 325 326 ms1df_filtered = ms1df[ 327 (ms1df.mz >= mz_global_min) & 328 (ms1df.mz <= mz_global_max) & 329 (ms1df.scan_time_aligned >= st_global_min) & 330 (ms1df.scan_time_aligned <= st_global_max) 331 ].copy() 332 333 # Generate set_ids for all features 334 set_ids = [f'c{clusters[i]}_{i}_i' for i in range(len(sampledf))] 335 336 # Use batch method to process all features at once 337 if expand_on_miss: 338 # First try with normal bounds 339 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 340 ms1df_filtered, 341 mz_mins, 342 mz_maxs, 343 st_mins, 344 st_maxs, 345 set_ids, 346 obj_idx=sample_id, 347 st_aligned=True 348 ) 349 350 # Retry failed features with expanded bounds 351 failed_indices = [i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99] 352 if failed_indices: 353 failed_ids = [set_ids[i] for i in failed_indices] 354 retry_peaks = collection[sample_id].search_for_targeted_mass_features_batch( 355 ms1df_filtered, 356 mz_mins_allowed[failed_indices], 357 mz_maxs_allowed[failed_indices], 358 st_mins_allowed[failed_indices], 359 st_maxs_allowed[failed_indices], 360 failed_ids, 361 obj_idx=sample_id, 362 st_aligned=True 363 ) 364 peaks_dict.update(retry_peaks) 365 else: 366 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 367 ms1df_filtered, 368 mz_mins, 369 mz_maxs, 370 st_mins, 371 st_maxs, 372 set_ids, 373 obj_idx=sample_id, 374 st_aligned=True 375 ) 376 377 # Build induced_mass_features dict and update cluster_dict 378 induced_mass_features = {} 379 for i in range(len(sampledf)): 380 peak = peaks_dict[set_ids[i]] 381 induced_mass_features[peak.id] = peak 382 cluster_dict[clusters[i]] += [set_ids[i]] 383 384 # Integrate mass features (don't fail on bad integration) 385 collection[sample_id].induced_mass_features = induced_mass_features 386 collection[sample_id].integrate_mass_features(drop_if_fail=False, induced_features=True) 387 388 # Add MS1 spectra and peak metrics to successfully detected induced features 389 # Only process features that were successfully detected (apex_scan != -99) 390 # This is critical for having m/z values in the pivot table for gap-filled features 391 successful_induced = {k: v for k, v in induced_mass_features.items() if v.apex_scan != -99} 392 393 if len(successful_induced) > 0: 394 # Use the already-loaded raw data (use_parser=False) for efficiency 395 collection[sample_id].add_associated_ms1( 396 auto_process=True, 397 use_parser=False, 398 spectrum_mode=None, 399 induced_features=True 400 ) 401 402 # Return the induced features (some may have been filtered out) 403 return collection[sample_id].induced_mass_features 404 405 def collect_results(self, sample_id, result, collection): 406 """Collect induced mass features back into sample.""" 407 collection[sample_id].induced_mass_features = result 408 409 410class ReloadFeaturesOperation(SampleOperation): 411 """ 412 Reload mass features from HDF5 and optionally add MS1/MS2 spectra. 413 414 This is useful when the collection was loaded with load_light=True, 415 which stores mass features only in the collection dataframe and not 416 as LCMSMassFeature objects in individual samples. 417 418 Parameters 419 ---------- 420 name : str 421 Operation name 422 add_ms1 : bool, optional 423 If True, adds MS1 spectra to mass features. Automatically uses raw MS1 data 424 if available (e.g., from gap-filling), otherwise uses parser. Spectrum mode 425 is auto-detected. Default is False. 426 add_ms2 : bool, optional 427 If True, also loads and associates MS2 spectra. Spectrum mode is auto-detected. 428 Default is False. 429 auto_process_ms2 : bool, optional 430 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 431 ms2_scan_filter : str or None, optional 432 Filter string for MS2 scans. Default is None. 433 434 Notes 435 ----- 436 MS1 spectra association automatically uses raw MS1 data if loaded by a previous 437 operation (e.g., GapFillOperation). This is efficient when multiple operations 438 need MS1 data in the same pipeline. All spectrum modes are auto-detected from 439 the data. 440 """ 441 442 @property 443 def description(self): 444 """Human-readable description for progress messages.""" 445 return "reloading features" 446 447 def needs_raw_ms_data(self): 448 """This operation doesn't need raw data.""" 449 return False, None 450 451 def can_execute(self, sample, collection): 452 """Check if collection parser is available.""" 453 return hasattr(collection, 'collection_parser') and \ 454 collection.collection_parser is not None 455 456 def execute(self, sample_id, collection, mf_ids_to_load=None, **runtime_params): 457 """ 458 Execute feature reloading for a single sample. 459 460 Parameters 461 ---------- 462 sample_id : int 463 Sample ID to reload features for 464 collection : LCMSBaseCollection 465 The collection 466 mf_ids_to_load : list of str, optional 467 List of collection-level mf_ids to load 468 **runtime_params 469 Additional runtime parameters (ignored) 470 471 Returns 472 ------- 473 dict 474 Dictionary of reloaded mass features 475 """ 476 # Get parameters 477 add_ms1 = self.params.get('add_ms1', False) 478 add_ms2 = self.params.get('add_ms2', False) 479 auto_process_ms2 = self.params.get('auto_process_ms2', True) 480 ms2_scan_filter = self.params.get('ms2_scan_filter', None) 481 482 sample = collection[sample_id] 483 sample_name = collection.samples[sample_id] 484 485 # Auto-determine if we should use parser for MS1 (check if raw data is available) 486 has_raw_ms1 = 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 487 use_parser_for_ms1 = not has_raw_ms1 # Use parser only if raw data not available 488 489 # Spectrum modes will be auto-detected (None = auto-detect) 490 spectrum_mode_ms1 = None 491 ms2_spectrum_mode = None 492 493 # Check if we have a collection parser 494 if not hasattr(collection, 'collection_parser') or collection.collection_parser is None: 495 print(f"Warning: Cannot reload mass features for {sample_name} - no collection_parser available") 496 return {} 497 498 # Get the HDF5 file for this sample 499 hdf5_file = collection.collection_parser.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" 500 501 if not hdf5_file.exists(): 502 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 503 return {} 504 505 # Import here to avoid circular imports 506 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 507 508 # If specific mf_ids requested, extract the local mf_ids we need 509 local_mf_ids_to_load = None 510 if mf_ids_to_load is not None: 511 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 512 # No parsing needed - they come from the mf_id column in the dataframe 513 if len(mf_ids_to_load) == 0: 514 # No features to load for this sample - return empty dict 515 return {} 516 local_mf_ids_to_load = set(mf_ids_to_load) 517 518 # Reload mass features from HDF5 519 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 520 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 521 522 # If add_ms1, associate MS1 spectra with the loaded mass features 523 if add_ms1 and len(sample.mass_features) > 0: 524 # Check if raw MS1 data is already loaded (e.g., from gap-filling) 525 has_raw_ms1 = 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 526 527 if has_raw_ms1 and not use_parser_for_ms1: 528 # Use already-loaded raw data (more efficient) 529 sample.add_associated_ms1( 530 auto_process=True, 531 use_parser=False, 532 spectrum_mode=spectrum_mode_ms1 533 ) 534 else: 535 # Use parser to get MS1 spectra 536 sample.add_associated_ms1( 537 auto_process=True, 538 use_parser=True, 539 spectrum_mode=spectrum_mode_ms1 540 ) 541 542 # If add_ms2, associate MS2 spectra with the loaded mass features 543 if add_ms2 and len(sample.mass_features) > 0: 544 # Get the IDs of loaded mass features (use what was actually loaded) 545 mf_ids_for_ms2 = list(sample.mass_features.keys()) 546 547 collection._associate_ms2_with_mass_features( 548 sample, 549 mf_ids_for_ms2, 550 auto_process=auto_process_ms2, 551 spectrum_mode=ms2_spectrum_mode, 552 scan_filter=ms2_scan_filter 553 ) 554 555 # Return both mass_features and _ms so they can be collected in multiprocessing 556 return {'mass_features': sample.mass_features, '_ms': sample._ms} 557 558 def collect_results(self, sample_id, result, collection): 559 """ 560 Collect reloaded mass features back into sample. 561 562 This operation loads a subset of mass features (e.g., representatives) 563 into sample.mass_features for processing, while preserving the full 564 mass_features_dataframe at the collection level. Sets a lock flag to 565 prevent automatic rebuilding of the collection dataframe from individual 566 samples. Also collects loaded mass spectra. 567 568 Parameters 569 ---------- 570 sample_id : int 571 Sample ID that was processed 572 result : dict 573 Dictionary with 'mass_features' and '_ms' from execute() 574 collection : LCMSBaseCollection 575 The collection 576 """ 577 # Update sample.mass_features with loaded features 578 if isinstance(result, dict) and 'mass_features' in result: 579 collection[sample_id].mass_features = result['mass_features'] 580 # Also collect the _ms dictionary (MS1 and MS2 spectra) 581 if '_ms' in result: 582 collection[sample_id]._ms.update(result['_ms']) 583 else: 584 # Backward compatibility - if result is just mass_features dict 585 collection[sample_id].mass_features = result 586 587 # Lock the collection dataframe to prevent rebuilding from individual samples 588 # (since we've only loaded a subset, rebuilding would lose data) 589 collection._mass_features_locked = True 590 591 592class MolecularFormulaSearchOperation(SampleOperation): 593 """ 594 Perform molecular formula search on mass features using associated MS1 spectra. 595 596 This operation runs molecular formula search on all mass features in a sample 597 that have associated MS1 spectra. Requires MS1 spectra to be loaded and 598 processed before execution. 599 600 Parameters 601 ---------- 602 name : str 603 Operation name (for logging) 604 **kwargs 605 Additional parameters passed to parent class 606 607 Examples 608 -------- 609 >>> op = MolecularFormulaSearchOperation('mf_search') 610 >>> # Use in pipeline 611 >>> results = collection.process_samples_pipeline([op]) 612 613 Notes 614 ----- 615 This operation requires that MS1 spectra have been associated with mass 616 features (e.g., via ReloadFeaturesOperation with add_ms1=True). The 617 molecular formula search uses parameters from the collection's 618 parameters.mass_spectrum["ms1"].molecular_search settings. 619 """ 620 621 @property 622 def description(self): 623 """Human-readable description for progress messages.""" 624 return "molecular formula search" 625 626 def __init__(self, name='molecular_formula_search', **kwargs): 627 super().__init__(name, **kwargs) 628 629 def needs_raw_ms_data(self): 630 """ 631 This operation doesn't need raw data - it works on processed MS1 spectra 632 that are already associated with mass features. 633 634 Returns 635 ------- 636 tuple 637 (False, None) - no raw data needed 638 """ 639 return False, None 640 641 def can_execute(self, sample, collection, **runtime_params): 642 """ 643 Check if molecular formula search can be executed. 644 645 Requires that the sample has mass features with associated MS1 spectra. 646 647 Parameters 648 ---------- 649 sample : LCMSObject 650 The sample object 651 collection : LCMSCollection 652 The collection containing the sample 653 **runtime_params 654 Runtime parameters (not used) 655 656 Returns 657 ------- 658 bool 659 True if sample has mass features with MS1 spectra 660 """ 661 # Check if sample has mass features 662 if not hasattr(sample, 'mass_features') or not sample.mass_features: 663 return False 664 665 # Check if at least some mass features have MS1 spectra 666 has_ms1 = any( 667 hasattr(mf, 'mass_spectrum') and mf.mass_spectrum is not None 668 for mf in sample.mass_features.values() 669 ) 670 671 return has_ms1 672 673 def execute(self, sample_id, collection, **runtime_params): 674 """ 675 Execute molecular formula search on a sample. 676 677 Creates a SearchMolecularFormulasLC object and runs mass feature search, 678 which annotates mass features with molecular formula assignments. 679 680 Parameters 681 ---------- 682 sample_id : str 683 Sample identifier 684 collection : LCMSCollection 685 The collection containing the sample 686 **runtime_params 687 Runtime parameters (not used) 688 689 Returns 690 ------- 691 int 692 Number of mass features that were searched 693 """ 694 from corems.molecular_id.search.molecularFormulaSearch import SearchMolecularFormulasLC 695 import time 696 import sqlalchemy.exc 697 import sqlite3 698 699 sample = collection[sample_id] 700 701 # Verify that mass features exist 702 if not hasattr(sample, 'mass_features') or not sample.mass_features: 703 return 0 # No mass features to search 704 705 # Verify that mass features have MS1 spectra associated 706 if not hasattr(sample, '_ms') or not sample._ms: 707 raise RuntimeError( 708 f"Sample {sample_id} does not have MS1 spectra loaded in _ms dictionary. " 709 "Molecular formula search requires MS1 spectra to be associated with mass features. " 710 "Ensure add_ms1=True when reloading features." 711 ) 712 713 # Prepare data for bulk molecular formula search 714 # Group mass features by their apex scan 715 scan_to_mf = {} 716 for mf_id, mf in sample.mass_features.items(): 717 apex_scan = mf.apex_scan 718 if apex_scan not in scan_to_mf: 719 scan_to_mf[apex_scan] = [] 720 scan_to_mf[apex_scan].append(mf) 721 722 # Build lists of mass spectra and corresponding peaks 723 mass_spectrum_list = [] 724 ms_peaks_list = [] 725 726 for scan_num, mf_list in scan_to_mf.items(): 727 # Get the mass spectrum for this scan 728 if scan_num not in sample._ms: 729 continue # Skip if spectrum not loaded 730 731 mass_spectrum = sample._ms[scan_num] 732 733 # Verify spectrum is processed (has peaks) 734 if not hasattr(mass_spectrum, '_mspeaks') or not mass_spectrum._mspeaks: 735 continue # Skip unprocessed spectra 736 737 # Get the MS1 peaks for each mass feature at this scan 738 peaks_for_scan = [] 739 for mf in mf_list: 740 try: 741 # Use the ms1_peak property which finds the closest peak 742 ms1_peak = mf.ms1_peak 743 peaks_for_scan.append(ms1_peak) 744 except (AttributeError, IndexError): 745 # Skip if ms1_peak can't be determined 746 continue 747 748 if peaks_for_scan: 749 mass_spectrum_list.append(mass_spectrum) 750 ms_peaks_list.append(peaks_for_scan) 751 752 # Run molecular formula search if we have data, with retry logic for database locks 753 if mass_spectrum_list and ms_peaks_list: 754 max_retries = 10 755 retry_delay = 2 # seconds 756 757 for attempt in range(max_retries): 758 try: 759 mol_search = SearchMolecularFormulasLC(sample) 760 mol_search.bulk_run_molecular_formula_search(mass_spectrum_list, ms_peaks_list) 761 break # Success, exit retry loop 762 except (sqlalchemy.exc.OperationalError, sqlite3.OperationalError) as e: 763 if attempt < max_retries - 1: 764 # Database is locked, retry after delay 765 print(f"Sample {sample_id}: Database locked during molecular formula search, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})...") 766 time.sleep(retry_delay) 767 else: 768 # Max retries exceeded, re-raise the exception 769 raise RuntimeError( 770 f"Sample {sample_id}: Molecular formula search failed after {max_retries} attempts due to database lock. " 771 "Try reducing parallel cores or increasing database timeout." 772 ) from e 773 774 # Return count of features searched 775 return len(sample.mass_features) 776 777 def collect_results(self, sample_id, result, collection): 778 """ 779 Collect results (no-op as search modifies mass features in place). 780 781 The molecular formula search modifies mass features in place by adding 782 molecular formula assignments, so no explicit result collection is needed. 783 784 Parameters 785 ---------- 786 sample_id : str 787 Sample identifier 788 result : int 789 Number of features searched 790 collection : LCMSCollection 791 The collection containing the sample 792 """ 793 # Search modifies mass features in place, nothing to collect 794 pass 795 796 797class MS2SpectralSearchOperation(SampleOperation): 798 """ 799 Perform MS2 spectral search using entropy-based matching. 800 801 This operation performs spectral library search on MS2 spectra associated 802 with mass features using FlashEntropy for fast similarity scoring. Requires 803 MS2 spectra to be loaded and processed before execution. 804 805 Parameters 806 ---------- 807 name : str 808 Operation name (for logging) 809 ms2_scan_filter : str or None, optional 810 Filter string for MS2 scans (e.g., 'hcd'). If None, uses all MS2 scans. 811 Default is None. 812 peak_sep_da : float, optional 813 Peak separation in Daltons for spectral matching. Default is 0.01. 814 **kwargs 815 Additional parameters passed to parent class 816 817 Examples 818 -------- 819 >>> op = MS2SpectralSearchOperation('ms2_search', ms2_scan_filter='hcd') 820 >>> # Use in pipeline - requires fe_lib in runtime_params 821 >>> results = collection.process_samples_pipeline([op]) 822 823 Notes 824 ----- 825 This operation requires: 826 - MS2 spectra to be associated with mass features 827 - FlashEntropy library (fe_lib) to be provided in runtime_params 828 - MS2 spectra must be processed (centroided) 829 830 The spectral search modifies mass features in place by adding spectral 831 match scores and metadata. 832 """ 833 834 @property 835 def description(self): 836 """Human-readable description for progress messages.""" 837 return "MS2 spectral search" 838 839 def __init__(self, name='ms2_spectral_search', ms2_scan_filter=None, **kwargs): 840 super().__init__(name, **kwargs) 841 self.params['ms2_scan_filter'] = ms2_scan_filter 842 843 def needs_raw_ms_data(self): 844 """ 845 This operation doesn't need raw data - it works on processed MS2 spectra 846 that are already associated with mass features. 847 848 Returns 849 ------- 850 tuple 851 (False, None) - no raw data needed 852 """ 853 return False, None 854 855 def can_execute(self, sample, collection, **runtime_params): 856 """ 857 Check if MS2 spectral search can be executed. 858 859 Requires that the sample has mass features with MS2 spectra associated. 860 861 Parameters 862 ---------- 863 sample : LCMSObject 864 The sample object 865 collection : LCMSCollection 866 The collection containing the sample 867 **runtime_params 868 Runtime parameters (not used) 869 870 Returns 871 ------- 872 bool 873 True if sample has mass features with MS2 spectra 874 """ 875 # Check if sample has mass features 876 if not hasattr(sample, 'mass_features') or not sample.mass_features: 877 return False 878 879 # Check if any mass features have MS2 spectra associated 880 has_ms2 = any( 881 hasattr(mf, 'ms2_mass_spectra') and mf.ms2_mass_spectra 882 for mf in sample.mass_features.values() 883 ) 884 885 return has_ms2 886 887 def execute(self, sample_id, collection, fe_lib=None, molecular_metadata=None, **runtime_params): 888 """ 889 Execute MS2 spectral search on a sample. 890 891 Performs entropy-based spectral library search on all MS2 spectra 892 in the sample that match the scan filter criteria. 893 894 Parameters 895 ---------- 896 sample_id : str 897 Sample identifier 898 collection : LCMSCollection 899 The collection containing the sample 900 fe_lib : FlashEntropy library 901 Pre-computed FlashEntropy library for spectral matching 902 molecular_metadata : pd.DataFrame, optional 903 Metadata for molecules in the spectral library 904 **runtime_params 905 Runtime parameters (not used) 906 907 Returns 908 ------- 909 int 910 Number of MS2 spectra searched 911 """ 912 sample = collection[sample_id] 913 914 # Get parameters 915 ms2_scan_filter = self.params.get('ms2_scan_filter') 916 917 # Verify that we have a spectral library 918 if fe_lib is None: 919 raise ValueError( 920 f"Sample {sample_id}: MS2 spectral search requires fe_lib (FlashEntropy library) " 921 "to be provided in runtime parameters. Create the library at the collection level " 922 "and pass it to the pipeline." 923 ) 924 925 # Extract peak_sep_da from FlashEntropy library configuration 926 # peak_sep_da should be 2 * max_ms2_tolerance_in_da to match the min_ms2_difference_in_da 927 # used when creating the library 928 tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da 929 if tolerance_da is None: 930 raise ValueError( 931 f"Sample {sample_id}: Could not extract max_ms2_tolerance_in_da from FlashEntropy library. " 932 "Ensure the library was created with this parameter specified." 933 ) 934 peak_sep_da = 2 * tolerance_da 935 936 # Verify that sample has _ms dictionary 937 if not hasattr(sample, '_ms') or not sample._ms: 938 return 0 # No MS2 spectra to search 939 940 # Get MS2 scan numbers based on filter 941 if ms2_scan_filter is not None: 942 # Filter by scan text 943 ms2_scan_df = sample.scan_df[ 944 sample.scan_df.scan_text.str.contains(ms2_scan_filter) & 945 (sample.scan_df.ms_level == 2) 946 ] 947 else: 948 # All MS2 scans 949 ms2_scan_df = sample.scan_df[sample.scan_df.ms_level == 2] 950 951 # Get scans that are actually loaded in _ms 952 ms2_scans_to_search = [ 953 scan for scan in ms2_scan_df.scan.tolist() 954 if scan in sample._ms.keys() 955 ] 956 957 if not ms2_scans_to_search: 958 return 0 # No MS2 spectra to search 959 960 # Perform spectral search using the sample's fe_search method 961 sample.fe_search( 962 scan_list=ms2_scans_to_search, 963 fe_lib=fe_lib, 964 peak_sep_da=peak_sep_da 965 ) 966 967 # Return the spectral search results for collection 968 # (needed for multiprocessing - results populated in worker need to be returned) 969 return sample.spectral_search_results 970 971 def collect_results(self, sample_id, result, collection): 972 """ 973 Collect spectral search results back into the sample. 974 975 In multiprocessing, the worker's modifications don't persist to the 976 main process, so we need to explicitly collect and reassign the results. 977 This also re-associates the results with mass features. 978 979 Parameters 980 ---------- 981 sample_id : str 982 Sample identifier 983 result : dict 984 Dictionary of spectral search results from execute() 985 collection : LCMSCollection 986 The collection containing the sample 987 """ 988 # Assign the spectral search results back to the sample 989 if result: 990 collection[sample_id].spectral_search_results.update(result) 991 992 # Re-associate results with mass features (same logic as fe_search) 993 sample = collection[sample_id] 994 if len(sample.mass_features) > 0: 995 for mass_feature_id, mass_feature in sample.mass_features.items(): 996 scan_ids = mass_feature.ms2_scan_numbers 997 for ms2_scan_id in scan_ids: 998 precursor_mz = mass_feature.mz 999 try: 1000 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1001 except KeyError: 1002 pass 1003 else: 1004 sample.mass_features[ 1005 mass_feature_id 1006 ].ms2_similarity_results.append( 1007 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1008 ) 1009 1010 1011class LoadEICsOperation(SampleOperation): 1012 """ 1013 Load extracted ion chromatograms (EICs) from HDF5 for regular mass features. 1014 1015 Loads EICs for regular mass features that belong to consensus clusters from HDF5. 1016 Induced (gap-filled) features already have EICs from integrate_mass_features, 1017 so no additional loading is needed for them. 1018 1019 This operation enables downstream visualization and analysis of chromatographic 1020 peaks across all samples in a cluster. 1021 1022 Notes 1023 ----- 1024 Requires that mass features have been loaded and cluster_index assigned. 1025 Regular mass feature EICs must have been previously saved to HDF5 with export_eics=True. 1026 Induced mass features already have EICs populated during gap-filling. 1027 """ 1028 1029 @property 1030 def description(self): 1031 """Human-readable description for progress messages.""" 1032 return "loading EICs" 1033 1034 def needs_raw_ms_data(self): 1035 """This operation doesn't need raw data - induced features already have EICs.""" 1036 return False, None 1037 1038 def can_execute(self, sample, collection): 1039 """ 1040 Check if EIC loading can be executed. 1041 1042 This operation can always execute if the sample exists - the actual work 1043 is determined by cluster_mz_dict in runtime_params. If cluster_mz_dict is 1044 empty or None, execute() will simply return 0 (no EICs loaded). 1045 1046 Returns 1047 ------- 1048 bool 1049 True (always executable - runtime_params control actual work) 1050 """ 1051 return True 1052 1053 def execute(self, sample_id, collection, cluster_mz_dict=None, **runtime_params): 1054 """ 1055 Load EICs from HDF5 for a single sample. 1056 1057 Loads EICs for regular mass features that belong to consensus clusters. 1058 Induced (gap-filled) mass features already have EICs from integrate_mass_features, 1059 so no additional loading is needed for them. 1060 1061 The cluster_mz_dict parameter (passed from collection level) maps sample_id 1062 to a list of m/z values that belong to clusters for that sample. 1063 1064 Parameters 1065 ---------- 1066 sample_id : int 1067 Sample index to process 1068 collection : LCMSBaseCollection 1069 The collection 1070 cluster_mz_dict : dict, optional 1071 Dictionary mapping sample_id to list of m/z values in clusters for that sample. 1072 If None, will not load any EICs. Default is None. 1073 **runtime_params 1074 Additional runtime parameters (ignored) 1075 1076 Returns 1077 ------- 1078 dict 1079 Dictionary of loaded EIC_Data objects, keyed by m/z value 1080 """ 1081 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 1082 1083 sample = collection[sample_id] 1084 1085 # If no cluster info provided or no m/z values for this sample, return early 1086 if cluster_mz_dict is None or sample_id not in cluster_mz_dict: 1087 return {} 1088 1089 # Get m/z values for this sample that belong to clusters 1090 sample_cluster_mz = set(cluster_mz_dict[sample_id]) 1091 if len(sample_cluster_mz) == 0: 1092 return {} 1093 1094 # Load EICs for each of the sample_cluster_mz 1095 hdf5_path = sample.file_location 1096 if hdf5_path and hdf5_path.exists(): 1097 reader = ReadCoreMSHDFMassSpectra(str(hdf5_path)) 1098 reader.import_eics(sample, mz_list=list(sample_cluster_mz)) 1099 # Return the loaded EICs for multiprocessing collection 1100 # (modifications in worker process don't persist to main process) 1101 return sample.eics.copy() 1102 1103 return {} 1104 1105 def collect_results(self, sample_id, result, collection): 1106 """ 1107 Collect loaded EICs back into sample. 1108 1109 In multiprocessing, the worker's modifications don't persist to the 1110 main process, so we need to explicitly collect and reassign the EICs. 1111 This also re-associates EICs with mass features. 1112 1113 Parameters 1114 ---------- 1115 sample_id : int 1116 Sample ID that was processed 1117 result : dict 1118 Dictionary of EIC_Data objects keyed by m/z, returned from execute() 1119 collection : LCMSBaseCollection 1120 The collection 1121 """ 1122 if result: 1123 # Update sample.eics with loaded EICs 1124 collection[sample_id].eics.update(result) 1125 # Note: EIC association with mass features happens after pipeline completes 1126 # to avoid multiprocessing issues (modifications in worker processes don't 1127 # persist to main process objects)
23class SampleOperation(ABC): 24 """ 25 Base class for operations that can be performed on a sample. 26 27 All sample operations must inherit from this class and implement all 28 abstract methods. This ensures proper integration with the pipeline framework. 29 30 Parameters 31 ---------- 32 name : str 33 Name of the operation (for logging and identification) 34 **kwargs 35 Additional keyword arguments stored as operation parameters 36 37 Attributes 38 ---------- 39 name : str 40 Operation name 41 params : dict 42 Dictionary of operation parameters 43 description : str 44 Human-readable description for progress messages (must override in subclasses) 45 """ 46 47 def __init__(self, name, **kwargs): 48 self.name = name 49 self.params = kwargs 50 51 @property 52 @abstractmethod 53 def description(self): 54 """ 55 Human-readable description for progress messages. 56 57 This property must be overridden in subclasses to provide a meaningful 58 description that will be shown in progress bars (e.g., "gap-filling", 59 "reloading features", etc.). 60 61 Returns 62 ------- 63 str 64 Brief description of what this operation does 65 """ 66 pass 67 68 @abstractmethod 69 def needs_raw_ms_data(self): 70 """ 71 Declare whether this operation needs raw MS data loaded. 72 73 Subclasses must implement this method to specify raw data requirements. 74 The pipeline executor will ensure raw data is loaded before executing 75 operations that need it, and can clean it up afterwards. 76 77 Returns 78 ------- 79 tuple of (bool, int or None) 80 (needs_raw_data, ms_level) 81 - needs_raw_data: True if operation needs raw MS data 82 - ms_level: MS level needed (1 for MS1, 2 for MS2, etc.) or None 83 84 Examples 85 -------- 86 >>> def needs_raw_ms_data(self): 87 ... return True, 1 # Needs MS1 data 88 >>> def needs_raw_ms_data(self): 89 ... return False, None # No raw data needed 90 """ 91 pass 92 93 @abstractmethod 94 def can_execute(self, sample, collection): 95 """ 96 Check if this operation can be executed on the sample. 97 98 Subclasses must implement this method to define prerequisites. 99 Return True if the operation can execute, False otherwise. 100 101 Parameters 102 ---------- 103 sample : LCMSBase 104 The sample to check 105 collection : LCMSBaseCollection 106 The collection containing the sample 107 108 Returns 109 ------- 110 bool 111 True if operation can execute, False otherwise 112 113 Examples 114 -------- 115 >>> def can_execute(self, sample, collection): 116 ... return True # Can always execute 117 >>> def can_execute(self, sample, collection): 118 ... return hasattr(sample, 'mass_features') and sample.mass_features 119 """ 120 pass 121 122 @abstractmethod 123 def execute(self, sample_id, collection, **runtime_params): 124 """ 125 Execute the operation on a sample. 126 127 This method must be implemented by subclasses. 128 129 Parameters 130 ---------- 131 sample_id : int 132 Sample ID to process 133 collection : LCMSBaseCollection 134 The collection containing the sample 135 **runtime_params 136 Runtime parameters passed from the pipeline 137 138 Returns 139 ------- 140 result 141 Operation result (can be None if operation modifies sample in place) 142 """ 143 pass 144 145 @abstractmethod 146 def collect_results(self, sample_id, result, collection): 147 """ 148 Collect results back into collection after parallel execution. 149 150 Subclasses must implement this method to handle result collection. 151 If the operation modifies samples in place and doesn't need to collect 152 results, simply implement as `pass`. 153 154 Parameters 155 ---------- 156 sample_id : int 157 Sample ID that was processed 158 result 159 Result returned from execute() 160 collection : LCMSBaseCollection 161 The collection to update 162 163 Examples 164 -------- 165 >>> def collect_results(self, sample_id, result, collection): 166 ... pass # Operation modifies sample in place 167 >>> def collect_results(self, sample_id, result, collection): 168 ... collection[sample_id].induced_mass_features = result 169 """ 170 pass 171 172 def __repr__(self): 173 return f"{self.__class__.__name__}(name='{self.name}')"
Base class for operations that can be performed on a sample.
All sample operations must inherit from this class and implement all abstract methods. This ensures proper integration with the pipeline framework.
Parameters
- name (str): Name of the operation (for logging and identification)
- **kwargs: Additional keyword arguments stored as operation parameters
Attributes
- name (str): Operation name
- params (dict): Dictionary of operation parameters
- description (str): Human-readable description for progress messages (must override in subclasses)
51 @property 52 @abstractmethod 53 def description(self): 54 """ 55 Human-readable description for progress messages. 56 57 This property must be overridden in subclasses to provide a meaningful 58 description that will be shown in progress bars (e.g., "gap-filling", 59 "reloading features", etc.). 60 61 Returns 62 ------- 63 str 64 Brief description of what this operation does 65 """ 66 pass
Human-readable description for progress messages.
This property must be overridden in subclasses to provide a meaningful description that will be shown in progress bars (e.g., "gap-filling", "reloading features", etc.).
Returns
- str: Brief description of what this operation does
68 @abstractmethod 69 def needs_raw_ms_data(self): 70 """ 71 Declare whether this operation needs raw MS data loaded. 72 73 Subclasses must implement this method to specify raw data requirements. 74 The pipeline executor will ensure raw data is loaded before executing 75 operations that need it, and can clean it up afterwards. 76 77 Returns 78 ------- 79 tuple of (bool, int or None) 80 (needs_raw_data, ms_level) 81 - needs_raw_data: True if operation needs raw MS data 82 - ms_level: MS level needed (1 for MS1, 2 for MS2, etc.) or None 83 84 Examples 85 -------- 86 >>> def needs_raw_ms_data(self): 87 ... return True, 1 # Needs MS1 data 88 >>> def needs_raw_ms_data(self): 89 ... return False, None # No raw data needed 90 """ 91 pass
Declare whether this operation needs raw MS data loaded.
Subclasses must implement this method to specify raw data requirements. The pipeline executor will ensure raw data is loaded before executing operations that need it, and can clean it up afterwards.
Returns
- tuple of (bool, int or None): (needs_raw_data, ms_level)
- needs_raw_data: True if operation needs raw MS data
- ms_level: MS level needed (1 for MS1, 2 for MS2, etc.) or None
Examples
>>> def needs_raw_ms_data(self):
... return True, 1 # Needs MS1 data
>>> def needs_raw_ms_data(self):
... return False, None # No raw data needed
93 @abstractmethod 94 def can_execute(self, sample, collection): 95 """ 96 Check if this operation can be executed on the sample. 97 98 Subclasses must implement this method to define prerequisites. 99 Return True if the operation can execute, False otherwise. 100 101 Parameters 102 ---------- 103 sample : LCMSBase 104 The sample to check 105 collection : LCMSBaseCollection 106 The collection containing the sample 107 108 Returns 109 ------- 110 bool 111 True if operation can execute, False otherwise 112 113 Examples 114 -------- 115 >>> def can_execute(self, sample, collection): 116 ... return True # Can always execute 117 >>> def can_execute(self, sample, collection): 118 ... return hasattr(sample, 'mass_features') and sample.mass_features 119 """ 120 pass
Check if this operation can be executed on the sample.
Subclasses must implement this method to define prerequisites. Return True if the operation can execute, False otherwise.
Parameters
- sample (LCMSBase): The sample to check
- collection (LCMSBaseCollection): The collection containing the sample
Returns
- bool: True if operation can execute, False otherwise
Examples
>>> def can_execute(self, sample, collection):
... return True # Can always execute
>>> def can_execute(self, sample, collection):
... return hasattr(sample, 'mass_features') and sample.mass_features
122 @abstractmethod 123 def execute(self, sample_id, collection, **runtime_params): 124 """ 125 Execute the operation on a sample. 126 127 This method must be implemented by subclasses. 128 129 Parameters 130 ---------- 131 sample_id : int 132 Sample ID to process 133 collection : LCMSBaseCollection 134 The collection containing the sample 135 **runtime_params 136 Runtime parameters passed from the pipeline 137 138 Returns 139 ------- 140 result 141 Operation result (can be None if operation modifies sample in place) 142 """ 143 pass
Execute the operation on a sample.
This method must be implemented by subclasses.
Parameters
- sample_id (int): Sample ID to process
- collection (LCMSBaseCollection): The collection containing the sample
- **runtime_params: Runtime parameters passed from the pipeline
Returns
- result: Operation result (can be None if operation modifies sample in place)
145 @abstractmethod 146 def collect_results(self, sample_id, result, collection): 147 """ 148 Collect results back into collection after parallel execution. 149 150 Subclasses must implement this method to handle result collection. 151 If the operation modifies samples in place and doesn't need to collect 152 results, simply implement as `pass`. 153 154 Parameters 155 ---------- 156 sample_id : int 157 Sample ID that was processed 158 result 159 Result returned from execute() 160 collection : LCMSBaseCollection 161 The collection to update 162 163 Examples 164 -------- 165 >>> def collect_results(self, sample_id, result, collection): 166 ... pass # Operation modifies sample in place 167 >>> def collect_results(self, sample_id, result, collection): 168 ... collection[sample_id].induced_mass_features = result 169 """ 170 pass
Collect results back into collection after parallel execution.
Subclasses must implement this method to handle result collection.
If the operation modifies samples in place and doesn't need to collect
results, simply implement as pass.
Parameters
- sample_id (int): Sample ID that was processed
- result: Result returned from execute()
- collection (LCMSBaseCollection): The collection to update
Examples
>>> def collect_results(self, sample_id, result, collection):
... pass # Operation modifies sample in place
>>> def collect_results(self, sample_id, result, collection):
... collection[sample_id].induced_mass_features = result
176class GapFillOperation(SampleOperation): 177 """ 178 Gap-fill missing cluster features for a sample. 179 180 Searches raw MS1 data to find peaks in expected m/z and retention time 181 windows for clusters that are present in other samples but missing from 182 this sample. 183 184 Uses time range filtering for efficient data loading - only loads the 185 retention time windows where gaps need to be filled, plus a buffer 186 (controlled by eic_buffer_time parameter) for complete EIC extraction. 187 Multiple time ranges are automatically merged if they overlap. 188 189 Parameters 190 ---------- 191 name : str 192 Operation name 193 expand_on_miss : bool, optional 194 If True, expands search window when no peak is found. Default is False. 195 196 Notes 197 ----- 198 Requires that add_consensus_mass_features() has been run on the collection. 199 This operation loads raw MS1 data which will be available for subsequent operations. 200 Time range filtering significantly reduces memory usage and loading time for 201 large datasets with sparse gaps. 202 """ 203 204 @property 205 def description(self): 206 """Human-readable description for progress messages.""" 207 return "gap-filling" 208 209 def needs_raw_ms_data(self): 210 """This operation needs raw MS1 data.""" 211 return True, 1 212 213 def can_execute(self, sample, collection): 214 """Check if cluster summary exists.""" 215 return hasattr(collection, 'cluster_summary_dataframe') and \ 216 collection.cluster_summary_dataframe is not None 217 218 def execute(self, sample_id, collection, **runtime_params): 219 """ 220 Execute gap-filling for a single sample. 221 222 Parameters 223 ---------- 224 sample_id : int 225 Sample index to process 226 collection : LCMSBaseCollection 227 The collection 228 **runtime_params 229 Runtime parameters including: 230 - missingdf : pd.DataFrame - Cluster information and missing samples (optional) 231 - cluster_dict : dict - Cluster feature dictionary (optional) 232 - expand_on_miss : bool - Whether to expand search window on miss (optional) 233 If these are not provided, returns empty dict (no gaps to fill). 234 235 Returns 236 ------- 237 dict 238 Dictionary of induced mass features (empty if no gaps to fill) 239 """ 240 # Extract gap-fill parameters from runtime_params 241 # If not present, there are no gaps to fill, so return early 242 if 'missingdf' not in runtime_params: 243 return {} 244 245 missingdf = runtime_params['missingdf'] 246 cluster_dict = runtime_params['cluster_dict'] 247 expand_on_miss = runtime_params['expand_on_miss'] 248 249 # This is essentially the same logic as _search_for_targeted_mass_features_in_sample 250 # but extracted into an operation 251 252 # Get clusters missing data for this sample 253 sampledf = missingdf[ 254 missingdf.missing_samples.apply(lambda x: sample_id in x) 255 ].reset_index(drop=True).copy() 256 257 # Skip if no missing features for this sample 258 if len(sampledf) == 0: 259 return {} 260 261 # Get buffer time from LCMS parameters for EIC extraction 262 # This ensures we capture the full chromatographic peak beyond cluster bounds 263 buffer_rt = collection[sample_id].parameters.lc_ms.eic_buffer_time 264 265 # Calculate time ranges for efficient loading with buffer for EIC extraction 266 time_ranges = [] 267 268 for _, row in sampledf.iterrows(): 269 rt_min = row['scan_time_aligned_min'] 270 rt_max = row['scan_time_aligned_max'] 271 272 # If expand_on_miss, also consider the allowed bounds 273 if expand_on_miss: 274 rt_min = min(rt_min, row['sta_min_allowed']) 275 rt_max = max(rt_max, row['sta_max_allowed']) 276 277 # Apply buffer AFTER considering expand_on_miss bounds 278 # This ensures buffer is added beyond even the expanded search window 279 time_ranges.append((max(0, rt_min - buffer_rt), rt_max + buffer_rt)) 280 281 # Merge overlapping time ranges to reduce number of separate loads 282 time_ranges = sorted(time_ranges) 283 merged_ranges = [] 284 if time_ranges: 285 current_min, current_max = time_ranges[0] 286 for rt_min, rt_max in time_ranges[1:]: 287 if rt_min <= current_max: # Overlapping or adjacent 288 current_max = max(current_max, rt_max) 289 else: 290 merged_ranges.append((current_min, current_max)) 291 current_min, current_max = rt_min, rt_max 292 merged_ranges.append((current_min, current_max)) 293 294 # Load raw data for this sample with time range filtering 295 collection.load_raw_data(sample_id, 1, time_range=merged_ranges) 296 297 # Get MS1 data 298 ms1df = collection[sample_id]._ms_unprocessed[1].copy() 299 scan_df = collection[sample_id].scan_df[['scan', 'scan_time_aligned']] 300 ms1df = pd.merge(ms1df, scan_df, on='scan') 301 302 # Pre-extract all values from sampledf 303 clusters = sampledf.cluster.values 304 mz_mins = sampledf.mz_min.values 305 mz_maxs = sampledf.mz_max.values 306 st_mins = sampledf.scan_time_aligned_min.values 307 st_maxs = sampledf.scan_time_aligned_max.values 308 309 if expand_on_miss: 310 mz_mins_allowed = sampledf.mz_min_allowed.values 311 mz_maxs_allowed = sampledf.mz_max_allowed.values 312 st_mins_allowed = sampledf.sta_min_allowed.values 313 st_maxs_allowed = sampledf.sta_max_allowed.values 314 315 # Pre-filter ms1df to reduce search space 316 mz_global_min = mz_mins.min() 317 mz_global_max = mz_maxs.max() 318 st_global_min = st_mins.min() 319 st_global_max = st_maxs.max() 320 321 if expand_on_miss: 322 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 323 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 324 st_global_min = min(st_global_min, st_mins_allowed.min()) 325 st_global_max = max(st_global_max, st_maxs_allowed.max()) 326 327 ms1df_filtered = ms1df[ 328 (ms1df.mz >= mz_global_min) & 329 (ms1df.mz <= mz_global_max) & 330 (ms1df.scan_time_aligned >= st_global_min) & 331 (ms1df.scan_time_aligned <= st_global_max) 332 ].copy() 333 334 # Generate set_ids for all features 335 set_ids = [f'c{clusters[i]}_{i}_i' for i in range(len(sampledf))] 336 337 # Use batch method to process all features at once 338 if expand_on_miss: 339 # First try with normal bounds 340 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 341 ms1df_filtered, 342 mz_mins, 343 mz_maxs, 344 st_mins, 345 st_maxs, 346 set_ids, 347 obj_idx=sample_id, 348 st_aligned=True 349 ) 350 351 # Retry failed features with expanded bounds 352 failed_indices = [i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99] 353 if failed_indices: 354 failed_ids = [set_ids[i] for i in failed_indices] 355 retry_peaks = collection[sample_id].search_for_targeted_mass_features_batch( 356 ms1df_filtered, 357 mz_mins_allowed[failed_indices], 358 mz_maxs_allowed[failed_indices], 359 st_mins_allowed[failed_indices], 360 st_maxs_allowed[failed_indices], 361 failed_ids, 362 obj_idx=sample_id, 363 st_aligned=True 364 ) 365 peaks_dict.update(retry_peaks) 366 else: 367 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 368 ms1df_filtered, 369 mz_mins, 370 mz_maxs, 371 st_mins, 372 st_maxs, 373 set_ids, 374 obj_idx=sample_id, 375 st_aligned=True 376 ) 377 378 # Build induced_mass_features dict and update cluster_dict 379 induced_mass_features = {} 380 for i in range(len(sampledf)): 381 peak = peaks_dict[set_ids[i]] 382 induced_mass_features[peak.id] = peak 383 cluster_dict[clusters[i]] += [set_ids[i]] 384 385 # Integrate mass features (don't fail on bad integration) 386 collection[sample_id].induced_mass_features = induced_mass_features 387 collection[sample_id].integrate_mass_features(drop_if_fail=False, induced_features=True) 388 389 # Add MS1 spectra and peak metrics to successfully detected induced features 390 # Only process features that were successfully detected (apex_scan != -99) 391 # This is critical for having m/z values in the pivot table for gap-filled features 392 successful_induced = {k: v for k, v in induced_mass_features.items() if v.apex_scan != -99} 393 394 if len(successful_induced) > 0: 395 # Use the already-loaded raw data (use_parser=False) for efficiency 396 collection[sample_id].add_associated_ms1( 397 auto_process=True, 398 use_parser=False, 399 spectrum_mode=None, 400 induced_features=True 401 ) 402 403 # Return the induced features (some may have been filtered out) 404 return collection[sample_id].induced_mass_features 405 406 def collect_results(self, sample_id, result, collection): 407 """Collect induced mass features back into sample.""" 408 collection[sample_id].induced_mass_features = result
Gap-fill missing cluster features for a sample.
Searches raw MS1 data to find peaks in expected m/z and retention time windows for clusters that are present in other samples but missing from this sample.
Uses time range filtering for efficient data loading - only loads the retention time windows where gaps need to be filled, plus a buffer (controlled by eic_buffer_time parameter) for complete EIC extraction. Multiple time ranges are automatically merged if they overlap.
Parameters
- name (str): Operation name
- expand_on_miss (bool, optional): If True, expands search window when no peak is found. Default is False.
Notes
Requires that add_consensus_mass_features() has been run on the collection. This operation loads raw MS1 data which will be available for subsequent operations. Time range filtering significantly reduces memory usage and loading time for large datasets with sparse gaps.
204 @property 205 def description(self): 206 """Human-readable description for progress messages.""" 207 return "gap-filling"
Human-readable description for progress messages.
213 def can_execute(self, sample, collection): 214 """Check if cluster summary exists.""" 215 return hasattr(collection, 'cluster_summary_dataframe') and \ 216 collection.cluster_summary_dataframe is not None
Check if cluster summary exists.
218 def execute(self, sample_id, collection, **runtime_params): 219 """ 220 Execute gap-filling for a single sample. 221 222 Parameters 223 ---------- 224 sample_id : int 225 Sample index to process 226 collection : LCMSBaseCollection 227 The collection 228 **runtime_params 229 Runtime parameters including: 230 - missingdf : pd.DataFrame - Cluster information and missing samples (optional) 231 - cluster_dict : dict - Cluster feature dictionary (optional) 232 - expand_on_miss : bool - Whether to expand search window on miss (optional) 233 If these are not provided, returns empty dict (no gaps to fill). 234 235 Returns 236 ------- 237 dict 238 Dictionary of induced mass features (empty if no gaps to fill) 239 """ 240 # Extract gap-fill parameters from runtime_params 241 # If not present, there are no gaps to fill, so return early 242 if 'missingdf' not in runtime_params: 243 return {} 244 245 missingdf = runtime_params['missingdf'] 246 cluster_dict = runtime_params['cluster_dict'] 247 expand_on_miss = runtime_params['expand_on_miss'] 248 249 # This is essentially the same logic as _search_for_targeted_mass_features_in_sample 250 # but extracted into an operation 251 252 # Get clusters missing data for this sample 253 sampledf = missingdf[ 254 missingdf.missing_samples.apply(lambda x: sample_id in x) 255 ].reset_index(drop=True).copy() 256 257 # Skip if no missing features for this sample 258 if len(sampledf) == 0: 259 return {} 260 261 # Get buffer time from LCMS parameters for EIC extraction 262 # This ensures we capture the full chromatographic peak beyond cluster bounds 263 buffer_rt = collection[sample_id].parameters.lc_ms.eic_buffer_time 264 265 # Calculate time ranges for efficient loading with buffer for EIC extraction 266 time_ranges = [] 267 268 for _, row in sampledf.iterrows(): 269 rt_min = row['scan_time_aligned_min'] 270 rt_max = row['scan_time_aligned_max'] 271 272 # If expand_on_miss, also consider the allowed bounds 273 if expand_on_miss: 274 rt_min = min(rt_min, row['sta_min_allowed']) 275 rt_max = max(rt_max, row['sta_max_allowed']) 276 277 # Apply buffer AFTER considering expand_on_miss bounds 278 # This ensures buffer is added beyond even the expanded search window 279 time_ranges.append((max(0, rt_min - buffer_rt), rt_max + buffer_rt)) 280 281 # Merge overlapping time ranges to reduce number of separate loads 282 time_ranges = sorted(time_ranges) 283 merged_ranges = [] 284 if time_ranges: 285 current_min, current_max = time_ranges[0] 286 for rt_min, rt_max in time_ranges[1:]: 287 if rt_min <= current_max: # Overlapping or adjacent 288 current_max = max(current_max, rt_max) 289 else: 290 merged_ranges.append((current_min, current_max)) 291 current_min, current_max = rt_min, rt_max 292 merged_ranges.append((current_min, current_max)) 293 294 # Load raw data for this sample with time range filtering 295 collection.load_raw_data(sample_id, 1, time_range=merged_ranges) 296 297 # Get MS1 data 298 ms1df = collection[sample_id]._ms_unprocessed[1].copy() 299 scan_df = collection[sample_id].scan_df[['scan', 'scan_time_aligned']] 300 ms1df = pd.merge(ms1df, scan_df, on='scan') 301 302 # Pre-extract all values from sampledf 303 clusters = sampledf.cluster.values 304 mz_mins = sampledf.mz_min.values 305 mz_maxs = sampledf.mz_max.values 306 st_mins = sampledf.scan_time_aligned_min.values 307 st_maxs = sampledf.scan_time_aligned_max.values 308 309 if expand_on_miss: 310 mz_mins_allowed = sampledf.mz_min_allowed.values 311 mz_maxs_allowed = sampledf.mz_max_allowed.values 312 st_mins_allowed = sampledf.sta_min_allowed.values 313 st_maxs_allowed = sampledf.sta_max_allowed.values 314 315 # Pre-filter ms1df to reduce search space 316 mz_global_min = mz_mins.min() 317 mz_global_max = mz_maxs.max() 318 st_global_min = st_mins.min() 319 st_global_max = st_maxs.max() 320 321 if expand_on_miss: 322 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 323 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 324 st_global_min = min(st_global_min, st_mins_allowed.min()) 325 st_global_max = max(st_global_max, st_maxs_allowed.max()) 326 327 ms1df_filtered = ms1df[ 328 (ms1df.mz >= mz_global_min) & 329 (ms1df.mz <= mz_global_max) & 330 (ms1df.scan_time_aligned >= st_global_min) & 331 (ms1df.scan_time_aligned <= st_global_max) 332 ].copy() 333 334 # Generate set_ids for all features 335 set_ids = [f'c{clusters[i]}_{i}_i' for i in range(len(sampledf))] 336 337 # Use batch method to process all features at once 338 if expand_on_miss: 339 # First try with normal bounds 340 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 341 ms1df_filtered, 342 mz_mins, 343 mz_maxs, 344 st_mins, 345 st_maxs, 346 set_ids, 347 obj_idx=sample_id, 348 st_aligned=True 349 ) 350 351 # Retry failed features with expanded bounds 352 failed_indices = [i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99] 353 if failed_indices: 354 failed_ids = [set_ids[i] for i in failed_indices] 355 retry_peaks = collection[sample_id].search_for_targeted_mass_features_batch( 356 ms1df_filtered, 357 mz_mins_allowed[failed_indices], 358 mz_maxs_allowed[failed_indices], 359 st_mins_allowed[failed_indices], 360 st_maxs_allowed[failed_indices], 361 failed_ids, 362 obj_idx=sample_id, 363 st_aligned=True 364 ) 365 peaks_dict.update(retry_peaks) 366 else: 367 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 368 ms1df_filtered, 369 mz_mins, 370 mz_maxs, 371 st_mins, 372 st_maxs, 373 set_ids, 374 obj_idx=sample_id, 375 st_aligned=True 376 ) 377 378 # Build induced_mass_features dict and update cluster_dict 379 induced_mass_features = {} 380 for i in range(len(sampledf)): 381 peak = peaks_dict[set_ids[i]] 382 induced_mass_features[peak.id] = peak 383 cluster_dict[clusters[i]] += [set_ids[i]] 384 385 # Integrate mass features (don't fail on bad integration) 386 collection[sample_id].induced_mass_features = induced_mass_features 387 collection[sample_id].integrate_mass_features(drop_if_fail=False, induced_features=True) 388 389 # Add MS1 spectra and peak metrics to successfully detected induced features 390 # Only process features that were successfully detected (apex_scan != -99) 391 # This is critical for having m/z values in the pivot table for gap-filled features 392 successful_induced = {k: v for k, v in induced_mass_features.items() if v.apex_scan != -99} 393 394 if len(successful_induced) > 0: 395 # Use the already-loaded raw data (use_parser=False) for efficiency 396 collection[sample_id].add_associated_ms1( 397 auto_process=True, 398 use_parser=False, 399 spectrum_mode=None, 400 induced_features=True 401 ) 402 403 # Return the induced features (some may have been filtered out) 404 return collection[sample_id].induced_mass_features
Execute gap-filling for a single sample.
Parameters
- sample_id (int): Sample index to process
- collection (LCMSBaseCollection): The collection
- **runtime_params: Runtime parameters including:
- missingdf : pd.DataFrame - Cluster information and missing samples (optional)
- cluster_dict : dict - Cluster feature dictionary (optional)
- expand_on_miss : bool - Whether to expand search window on miss (optional) If these are not provided, returns empty dict (no gaps to fill).
Returns
- dict: Dictionary of induced mass features (empty if no gaps to fill)
406 def collect_results(self, sample_id, result, collection): 407 """Collect induced mass features back into sample.""" 408 collection[sample_id].induced_mass_features = result
Collect induced mass features back into sample.
Inherited Members
411class ReloadFeaturesOperation(SampleOperation): 412 """ 413 Reload mass features from HDF5 and optionally add MS1/MS2 spectra. 414 415 This is useful when the collection was loaded with load_light=True, 416 which stores mass features only in the collection dataframe and not 417 as LCMSMassFeature objects in individual samples. 418 419 Parameters 420 ---------- 421 name : str 422 Operation name 423 add_ms1 : bool, optional 424 If True, adds MS1 spectra to mass features. Automatically uses raw MS1 data 425 if available (e.g., from gap-filling), otherwise uses parser. Spectrum mode 426 is auto-detected. Default is False. 427 add_ms2 : bool, optional 428 If True, also loads and associates MS2 spectra. Spectrum mode is auto-detected. 429 Default is False. 430 auto_process_ms2 : bool, optional 431 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 432 ms2_scan_filter : str or None, optional 433 Filter string for MS2 scans. Default is None. 434 435 Notes 436 ----- 437 MS1 spectra association automatically uses raw MS1 data if loaded by a previous 438 operation (e.g., GapFillOperation). This is efficient when multiple operations 439 need MS1 data in the same pipeline. All spectrum modes are auto-detected from 440 the data. 441 """ 442 443 @property 444 def description(self): 445 """Human-readable description for progress messages.""" 446 return "reloading features" 447 448 def needs_raw_ms_data(self): 449 """This operation doesn't need raw data.""" 450 return False, None 451 452 def can_execute(self, sample, collection): 453 """Check if collection parser is available.""" 454 return hasattr(collection, 'collection_parser') and \ 455 collection.collection_parser is not None 456 457 def execute(self, sample_id, collection, mf_ids_to_load=None, **runtime_params): 458 """ 459 Execute feature reloading for a single sample. 460 461 Parameters 462 ---------- 463 sample_id : int 464 Sample ID to reload features for 465 collection : LCMSBaseCollection 466 The collection 467 mf_ids_to_load : list of str, optional 468 List of collection-level mf_ids to load 469 **runtime_params 470 Additional runtime parameters (ignored) 471 472 Returns 473 ------- 474 dict 475 Dictionary of reloaded mass features 476 """ 477 # Get parameters 478 add_ms1 = self.params.get('add_ms1', False) 479 add_ms2 = self.params.get('add_ms2', False) 480 auto_process_ms2 = self.params.get('auto_process_ms2', True) 481 ms2_scan_filter = self.params.get('ms2_scan_filter', None) 482 483 sample = collection[sample_id] 484 sample_name = collection.samples[sample_id] 485 486 # Auto-determine if we should use parser for MS1 (check if raw data is available) 487 has_raw_ms1 = 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 488 use_parser_for_ms1 = not has_raw_ms1 # Use parser only if raw data not available 489 490 # Spectrum modes will be auto-detected (None = auto-detect) 491 spectrum_mode_ms1 = None 492 ms2_spectrum_mode = None 493 494 # Check if we have a collection parser 495 if not hasattr(collection, 'collection_parser') or collection.collection_parser is None: 496 print(f"Warning: Cannot reload mass features for {sample_name} - no collection_parser available") 497 return {} 498 499 # Get the HDF5 file for this sample 500 hdf5_file = collection.collection_parser.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" 501 502 if not hdf5_file.exists(): 503 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 504 return {} 505 506 # Import here to avoid circular imports 507 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 508 509 # If specific mf_ids requested, extract the local mf_ids we need 510 local_mf_ids_to_load = None 511 if mf_ids_to_load is not None: 512 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 513 # No parsing needed - they come from the mf_id column in the dataframe 514 if len(mf_ids_to_load) == 0: 515 # No features to load for this sample - return empty dict 516 return {} 517 local_mf_ids_to_load = set(mf_ids_to_load) 518 519 # Reload mass features from HDF5 520 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 521 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 522 523 # If add_ms1, associate MS1 spectra with the loaded mass features 524 if add_ms1 and len(sample.mass_features) > 0: 525 # Check if raw MS1 data is already loaded (e.g., from gap-filling) 526 has_raw_ms1 = 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 527 528 if has_raw_ms1 and not use_parser_for_ms1: 529 # Use already-loaded raw data (more efficient) 530 sample.add_associated_ms1( 531 auto_process=True, 532 use_parser=False, 533 spectrum_mode=spectrum_mode_ms1 534 ) 535 else: 536 # Use parser to get MS1 spectra 537 sample.add_associated_ms1( 538 auto_process=True, 539 use_parser=True, 540 spectrum_mode=spectrum_mode_ms1 541 ) 542 543 # If add_ms2, associate MS2 spectra with the loaded mass features 544 if add_ms2 and len(sample.mass_features) > 0: 545 # Get the IDs of loaded mass features (use what was actually loaded) 546 mf_ids_for_ms2 = list(sample.mass_features.keys()) 547 548 collection._associate_ms2_with_mass_features( 549 sample, 550 mf_ids_for_ms2, 551 auto_process=auto_process_ms2, 552 spectrum_mode=ms2_spectrum_mode, 553 scan_filter=ms2_scan_filter 554 ) 555 556 # Return both mass_features and _ms so they can be collected in multiprocessing 557 return {'mass_features': sample.mass_features, '_ms': sample._ms} 558 559 def collect_results(self, sample_id, result, collection): 560 """ 561 Collect reloaded mass features back into sample. 562 563 This operation loads a subset of mass features (e.g., representatives) 564 into sample.mass_features for processing, while preserving the full 565 mass_features_dataframe at the collection level. Sets a lock flag to 566 prevent automatic rebuilding of the collection dataframe from individual 567 samples. Also collects loaded mass spectra. 568 569 Parameters 570 ---------- 571 sample_id : int 572 Sample ID that was processed 573 result : dict 574 Dictionary with 'mass_features' and '_ms' from execute() 575 collection : LCMSBaseCollection 576 The collection 577 """ 578 # Update sample.mass_features with loaded features 579 if isinstance(result, dict) and 'mass_features' in result: 580 collection[sample_id].mass_features = result['mass_features'] 581 # Also collect the _ms dictionary (MS1 and MS2 spectra) 582 if '_ms' in result: 583 collection[sample_id]._ms.update(result['_ms']) 584 else: 585 # Backward compatibility - if result is just mass_features dict 586 collection[sample_id].mass_features = result 587 588 # Lock the collection dataframe to prevent rebuilding from individual samples 589 # (since we've only loaded a subset, rebuilding would lose data) 590 collection._mass_features_locked = True
Reload mass features from HDF5 and optionally add MS1/MS2 spectra.
This is useful when the collection was loaded with load_light=True, which stores mass features only in the collection dataframe and not as LCMSMassFeature objects in individual samples.
Parameters
- name (str): Operation name
- add_ms1 (bool, optional): If True, adds MS1 spectra to mass features. Automatically uses raw MS1 data if available (e.g., from gap-filling), otherwise uses parser. Spectrum mode is auto-detected. Default is False.
- add_ms2 (bool, optional): If True, also loads and associates MS2 spectra. Spectrum mode is auto-detected. Default is False.
- auto_process_ms2 (bool, optional): If True and add_ms2=True, auto-processes MS2 spectra. Default is True.
- ms2_scan_filter (str or None, optional): Filter string for MS2 scans. Default is None.
Notes
MS1 spectra association automatically uses raw MS1 data if loaded by a previous operation (e.g., GapFillOperation). This is efficient when multiple operations need MS1 data in the same pipeline. All spectrum modes are auto-detected from the data.
443 @property 444 def description(self): 445 """Human-readable description for progress messages.""" 446 return "reloading features"
Human-readable description for progress messages.
448 def needs_raw_ms_data(self): 449 """This operation doesn't need raw data.""" 450 return False, None
This operation doesn't need raw data.
452 def can_execute(self, sample, collection): 453 """Check if collection parser is available.""" 454 return hasattr(collection, 'collection_parser') and \ 455 collection.collection_parser is not None
Check if collection parser is available.
457 def execute(self, sample_id, collection, mf_ids_to_load=None, **runtime_params): 458 """ 459 Execute feature reloading for a single sample. 460 461 Parameters 462 ---------- 463 sample_id : int 464 Sample ID to reload features for 465 collection : LCMSBaseCollection 466 The collection 467 mf_ids_to_load : list of str, optional 468 List of collection-level mf_ids to load 469 **runtime_params 470 Additional runtime parameters (ignored) 471 472 Returns 473 ------- 474 dict 475 Dictionary of reloaded mass features 476 """ 477 # Get parameters 478 add_ms1 = self.params.get('add_ms1', False) 479 add_ms2 = self.params.get('add_ms2', False) 480 auto_process_ms2 = self.params.get('auto_process_ms2', True) 481 ms2_scan_filter = self.params.get('ms2_scan_filter', None) 482 483 sample = collection[sample_id] 484 sample_name = collection.samples[sample_id] 485 486 # Auto-determine if we should use parser for MS1 (check if raw data is available) 487 has_raw_ms1 = 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 488 use_parser_for_ms1 = not has_raw_ms1 # Use parser only if raw data not available 489 490 # Spectrum modes will be auto-detected (None = auto-detect) 491 spectrum_mode_ms1 = None 492 ms2_spectrum_mode = None 493 494 # Check if we have a collection parser 495 if not hasattr(collection, 'collection_parser') or collection.collection_parser is None: 496 print(f"Warning: Cannot reload mass features for {sample_name} - no collection_parser available") 497 return {} 498 499 # Get the HDF5 file for this sample 500 hdf5_file = collection.collection_parser.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" 501 502 if not hdf5_file.exists(): 503 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 504 return {} 505 506 # Import here to avoid circular imports 507 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 508 509 # If specific mf_ids requested, extract the local mf_ids we need 510 local_mf_ids_to_load = None 511 if mf_ids_to_load is not None: 512 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 513 # No parsing needed - they come from the mf_id column in the dataframe 514 if len(mf_ids_to_load) == 0: 515 # No features to load for this sample - return empty dict 516 return {} 517 local_mf_ids_to_load = set(mf_ids_to_load) 518 519 # Reload mass features from HDF5 520 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 521 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 522 523 # If add_ms1, associate MS1 spectra with the loaded mass features 524 if add_ms1 and len(sample.mass_features) > 0: 525 # Check if raw MS1 data is already loaded (e.g., from gap-filling) 526 has_raw_ms1 = 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 527 528 if has_raw_ms1 and not use_parser_for_ms1: 529 # Use already-loaded raw data (more efficient) 530 sample.add_associated_ms1( 531 auto_process=True, 532 use_parser=False, 533 spectrum_mode=spectrum_mode_ms1 534 ) 535 else: 536 # Use parser to get MS1 spectra 537 sample.add_associated_ms1( 538 auto_process=True, 539 use_parser=True, 540 spectrum_mode=spectrum_mode_ms1 541 ) 542 543 # If add_ms2, associate MS2 spectra with the loaded mass features 544 if add_ms2 and len(sample.mass_features) > 0: 545 # Get the IDs of loaded mass features (use what was actually loaded) 546 mf_ids_for_ms2 = list(sample.mass_features.keys()) 547 548 collection._associate_ms2_with_mass_features( 549 sample, 550 mf_ids_for_ms2, 551 auto_process=auto_process_ms2, 552 spectrum_mode=ms2_spectrum_mode, 553 scan_filter=ms2_scan_filter 554 ) 555 556 # Return both mass_features and _ms so they can be collected in multiprocessing 557 return {'mass_features': sample.mass_features, '_ms': sample._ms}
Execute feature reloading for a single sample.
Parameters
- sample_id (int): Sample ID to reload features for
- collection (LCMSBaseCollection): The collection
- mf_ids_to_load (list of str, optional): List of collection-level mf_ids to load
- **runtime_params: Additional runtime parameters (ignored)
Returns
- dict: Dictionary of reloaded mass features
559 def collect_results(self, sample_id, result, collection): 560 """ 561 Collect reloaded mass features back into sample. 562 563 This operation loads a subset of mass features (e.g., representatives) 564 into sample.mass_features for processing, while preserving the full 565 mass_features_dataframe at the collection level. Sets a lock flag to 566 prevent automatic rebuilding of the collection dataframe from individual 567 samples. Also collects loaded mass spectra. 568 569 Parameters 570 ---------- 571 sample_id : int 572 Sample ID that was processed 573 result : dict 574 Dictionary with 'mass_features' and '_ms' from execute() 575 collection : LCMSBaseCollection 576 The collection 577 """ 578 # Update sample.mass_features with loaded features 579 if isinstance(result, dict) and 'mass_features' in result: 580 collection[sample_id].mass_features = result['mass_features'] 581 # Also collect the _ms dictionary (MS1 and MS2 spectra) 582 if '_ms' in result: 583 collection[sample_id]._ms.update(result['_ms']) 584 else: 585 # Backward compatibility - if result is just mass_features dict 586 collection[sample_id].mass_features = result 587 588 # Lock the collection dataframe to prevent rebuilding from individual samples 589 # (since we've only loaded a subset, rebuilding would lose data) 590 collection._mass_features_locked = True
Collect reloaded mass features back into sample.
This operation loads a subset of mass features (e.g., representatives) into sample.mass_features for processing, while preserving the full mass_features_dataframe at the collection level. Sets a lock flag to prevent automatic rebuilding of the collection dataframe from individual samples. Also collects loaded mass spectra.
Parameters
- sample_id (int): Sample ID that was processed
- result (dict): Dictionary with 'mass_features' and '_ms' from execute()
- collection (LCMSBaseCollection): The collection
Inherited Members
593class MolecularFormulaSearchOperation(SampleOperation): 594 """ 595 Perform molecular formula search on mass features using associated MS1 spectra. 596 597 This operation runs molecular formula search on all mass features in a sample 598 that have associated MS1 spectra. Requires MS1 spectra to be loaded and 599 processed before execution. 600 601 Parameters 602 ---------- 603 name : str 604 Operation name (for logging) 605 **kwargs 606 Additional parameters passed to parent class 607 608 Examples 609 -------- 610 >>> op = MolecularFormulaSearchOperation('mf_search') 611 >>> # Use in pipeline 612 >>> results = collection.process_samples_pipeline([op]) 613 614 Notes 615 ----- 616 This operation requires that MS1 spectra have been associated with mass 617 features (e.g., via ReloadFeaturesOperation with add_ms1=True). The 618 molecular formula search uses parameters from the collection's 619 parameters.mass_spectrum["ms1"].molecular_search settings. 620 """ 621 622 @property 623 def description(self): 624 """Human-readable description for progress messages.""" 625 return "molecular formula search" 626 627 def __init__(self, name='molecular_formula_search', **kwargs): 628 super().__init__(name, **kwargs) 629 630 def needs_raw_ms_data(self): 631 """ 632 This operation doesn't need raw data - it works on processed MS1 spectra 633 that are already associated with mass features. 634 635 Returns 636 ------- 637 tuple 638 (False, None) - no raw data needed 639 """ 640 return False, None 641 642 def can_execute(self, sample, collection, **runtime_params): 643 """ 644 Check if molecular formula search can be executed. 645 646 Requires that the sample has mass features with associated MS1 spectra. 647 648 Parameters 649 ---------- 650 sample : LCMSObject 651 The sample object 652 collection : LCMSCollection 653 The collection containing the sample 654 **runtime_params 655 Runtime parameters (not used) 656 657 Returns 658 ------- 659 bool 660 True if sample has mass features with MS1 spectra 661 """ 662 # Check if sample has mass features 663 if not hasattr(sample, 'mass_features') or not sample.mass_features: 664 return False 665 666 # Check if at least some mass features have MS1 spectra 667 has_ms1 = any( 668 hasattr(mf, 'mass_spectrum') and mf.mass_spectrum is not None 669 for mf in sample.mass_features.values() 670 ) 671 672 return has_ms1 673 674 def execute(self, sample_id, collection, **runtime_params): 675 """ 676 Execute molecular formula search on a sample. 677 678 Creates a SearchMolecularFormulasLC object and runs mass feature search, 679 which annotates mass features with molecular formula assignments. 680 681 Parameters 682 ---------- 683 sample_id : str 684 Sample identifier 685 collection : LCMSCollection 686 The collection containing the sample 687 **runtime_params 688 Runtime parameters (not used) 689 690 Returns 691 ------- 692 int 693 Number of mass features that were searched 694 """ 695 from corems.molecular_id.search.molecularFormulaSearch import SearchMolecularFormulasLC 696 import time 697 import sqlalchemy.exc 698 import sqlite3 699 700 sample = collection[sample_id] 701 702 # Verify that mass features exist 703 if not hasattr(sample, 'mass_features') or not sample.mass_features: 704 return 0 # No mass features to search 705 706 # Verify that mass features have MS1 spectra associated 707 if not hasattr(sample, '_ms') or not sample._ms: 708 raise RuntimeError( 709 f"Sample {sample_id} does not have MS1 spectra loaded in _ms dictionary. " 710 "Molecular formula search requires MS1 spectra to be associated with mass features. " 711 "Ensure add_ms1=True when reloading features." 712 ) 713 714 # Prepare data for bulk molecular formula search 715 # Group mass features by their apex scan 716 scan_to_mf = {} 717 for mf_id, mf in sample.mass_features.items(): 718 apex_scan = mf.apex_scan 719 if apex_scan not in scan_to_mf: 720 scan_to_mf[apex_scan] = [] 721 scan_to_mf[apex_scan].append(mf) 722 723 # Build lists of mass spectra and corresponding peaks 724 mass_spectrum_list = [] 725 ms_peaks_list = [] 726 727 for scan_num, mf_list in scan_to_mf.items(): 728 # Get the mass spectrum for this scan 729 if scan_num not in sample._ms: 730 continue # Skip if spectrum not loaded 731 732 mass_spectrum = sample._ms[scan_num] 733 734 # Verify spectrum is processed (has peaks) 735 if not hasattr(mass_spectrum, '_mspeaks') or not mass_spectrum._mspeaks: 736 continue # Skip unprocessed spectra 737 738 # Get the MS1 peaks for each mass feature at this scan 739 peaks_for_scan = [] 740 for mf in mf_list: 741 try: 742 # Use the ms1_peak property which finds the closest peak 743 ms1_peak = mf.ms1_peak 744 peaks_for_scan.append(ms1_peak) 745 except (AttributeError, IndexError): 746 # Skip if ms1_peak can't be determined 747 continue 748 749 if peaks_for_scan: 750 mass_spectrum_list.append(mass_spectrum) 751 ms_peaks_list.append(peaks_for_scan) 752 753 # Run molecular formula search if we have data, with retry logic for database locks 754 if mass_spectrum_list and ms_peaks_list: 755 max_retries = 10 756 retry_delay = 2 # seconds 757 758 for attempt in range(max_retries): 759 try: 760 mol_search = SearchMolecularFormulasLC(sample) 761 mol_search.bulk_run_molecular_formula_search(mass_spectrum_list, ms_peaks_list) 762 break # Success, exit retry loop 763 except (sqlalchemy.exc.OperationalError, sqlite3.OperationalError) as e: 764 if attempt < max_retries - 1: 765 # Database is locked, retry after delay 766 print(f"Sample {sample_id}: Database locked during molecular formula search, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})...") 767 time.sleep(retry_delay) 768 else: 769 # Max retries exceeded, re-raise the exception 770 raise RuntimeError( 771 f"Sample {sample_id}: Molecular formula search failed after {max_retries} attempts due to database lock. " 772 "Try reducing parallel cores or increasing database timeout." 773 ) from e 774 775 # Return count of features searched 776 return len(sample.mass_features) 777 778 def collect_results(self, sample_id, result, collection): 779 """ 780 Collect results (no-op as search modifies mass features in place). 781 782 The molecular formula search modifies mass features in place by adding 783 molecular formula assignments, so no explicit result collection is needed. 784 785 Parameters 786 ---------- 787 sample_id : str 788 Sample identifier 789 result : int 790 Number of features searched 791 collection : LCMSCollection 792 The collection containing the sample 793 """ 794 # Search modifies mass features in place, nothing to collect 795 pass
Perform molecular formula search on mass features using associated MS1 spectra.
This operation runs molecular formula search on all mass features in a sample that have associated MS1 spectra. Requires MS1 spectra to be loaded and processed before execution.
Parameters
- name (str): Operation name (for logging)
- **kwargs: Additional parameters passed to parent class
Examples
>>> op = MolecularFormulaSearchOperation('mf_search')
>>> # Use in pipeline
>>> results = collection.process_samples_pipeline([op])
Notes
This operation requires that MS1 spectra have been associated with mass features (e.g., via ReloadFeaturesOperation with add_ms1=True). The molecular formula search uses parameters from the collection's parameters.mass_spectrum["ms1"].molecular_search settings.
622 @property 623 def description(self): 624 """Human-readable description for progress messages.""" 625 return "molecular formula search"
Human-readable description for progress messages.
630 def needs_raw_ms_data(self): 631 """ 632 This operation doesn't need raw data - it works on processed MS1 spectra 633 that are already associated with mass features. 634 635 Returns 636 ------- 637 tuple 638 (False, None) - no raw data needed 639 """ 640 return False, None
This operation doesn't need raw data - it works on processed MS1 spectra that are already associated with mass features.
Returns
- tuple: (False, None) - no raw data needed
642 def can_execute(self, sample, collection, **runtime_params): 643 """ 644 Check if molecular formula search can be executed. 645 646 Requires that the sample has mass features with associated MS1 spectra. 647 648 Parameters 649 ---------- 650 sample : LCMSObject 651 The sample object 652 collection : LCMSCollection 653 The collection containing the sample 654 **runtime_params 655 Runtime parameters (not used) 656 657 Returns 658 ------- 659 bool 660 True if sample has mass features with MS1 spectra 661 """ 662 # Check if sample has mass features 663 if not hasattr(sample, 'mass_features') or not sample.mass_features: 664 return False 665 666 # Check if at least some mass features have MS1 spectra 667 has_ms1 = any( 668 hasattr(mf, 'mass_spectrum') and mf.mass_spectrum is not None 669 for mf in sample.mass_features.values() 670 ) 671 672 return has_ms1
Check if molecular formula search can be executed.
Requires that the sample has mass features with associated MS1 spectra.
Parameters
- sample (LCMSObject): The sample object
- collection (LCMSCollection): The collection containing the sample
- **runtime_params: Runtime parameters (not used)
Returns
- bool: True if sample has mass features with MS1 spectra
674 def execute(self, sample_id, collection, **runtime_params): 675 """ 676 Execute molecular formula search on a sample. 677 678 Creates a SearchMolecularFormulasLC object and runs mass feature search, 679 which annotates mass features with molecular formula assignments. 680 681 Parameters 682 ---------- 683 sample_id : str 684 Sample identifier 685 collection : LCMSCollection 686 The collection containing the sample 687 **runtime_params 688 Runtime parameters (not used) 689 690 Returns 691 ------- 692 int 693 Number of mass features that were searched 694 """ 695 from corems.molecular_id.search.molecularFormulaSearch import SearchMolecularFormulasLC 696 import time 697 import sqlalchemy.exc 698 import sqlite3 699 700 sample = collection[sample_id] 701 702 # Verify that mass features exist 703 if not hasattr(sample, 'mass_features') or not sample.mass_features: 704 return 0 # No mass features to search 705 706 # Verify that mass features have MS1 spectra associated 707 if not hasattr(sample, '_ms') or not sample._ms: 708 raise RuntimeError( 709 f"Sample {sample_id} does not have MS1 spectra loaded in _ms dictionary. " 710 "Molecular formula search requires MS1 spectra to be associated with mass features. " 711 "Ensure add_ms1=True when reloading features." 712 ) 713 714 # Prepare data for bulk molecular formula search 715 # Group mass features by their apex scan 716 scan_to_mf = {} 717 for mf_id, mf in sample.mass_features.items(): 718 apex_scan = mf.apex_scan 719 if apex_scan not in scan_to_mf: 720 scan_to_mf[apex_scan] = [] 721 scan_to_mf[apex_scan].append(mf) 722 723 # Build lists of mass spectra and corresponding peaks 724 mass_spectrum_list = [] 725 ms_peaks_list = [] 726 727 for scan_num, mf_list in scan_to_mf.items(): 728 # Get the mass spectrum for this scan 729 if scan_num not in sample._ms: 730 continue # Skip if spectrum not loaded 731 732 mass_spectrum = sample._ms[scan_num] 733 734 # Verify spectrum is processed (has peaks) 735 if not hasattr(mass_spectrum, '_mspeaks') or not mass_spectrum._mspeaks: 736 continue # Skip unprocessed spectra 737 738 # Get the MS1 peaks for each mass feature at this scan 739 peaks_for_scan = [] 740 for mf in mf_list: 741 try: 742 # Use the ms1_peak property which finds the closest peak 743 ms1_peak = mf.ms1_peak 744 peaks_for_scan.append(ms1_peak) 745 except (AttributeError, IndexError): 746 # Skip if ms1_peak can't be determined 747 continue 748 749 if peaks_for_scan: 750 mass_spectrum_list.append(mass_spectrum) 751 ms_peaks_list.append(peaks_for_scan) 752 753 # Run molecular formula search if we have data, with retry logic for database locks 754 if mass_spectrum_list and ms_peaks_list: 755 max_retries = 10 756 retry_delay = 2 # seconds 757 758 for attempt in range(max_retries): 759 try: 760 mol_search = SearchMolecularFormulasLC(sample) 761 mol_search.bulk_run_molecular_formula_search(mass_spectrum_list, ms_peaks_list) 762 break # Success, exit retry loop 763 except (sqlalchemy.exc.OperationalError, sqlite3.OperationalError) as e: 764 if attempt < max_retries - 1: 765 # Database is locked, retry after delay 766 print(f"Sample {sample_id}: Database locked during molecular formula search, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})...") 767 time.sleep(retry_delay) 768 else: 769 # Max retries exceeded, re-raise the exception 770 raise RuntimeError( 771 f"Sample {sample_id}: Molecular formula search failed after {max_retries} attempts due to database lock. " 772 "Try reducing parallel cores or increasing database timeout." 773 ) from e 774 775 # Return count of features searched 776 return len(sample.mass_features)
Execute molecular formula search on a sample.
Creates a SearchMolecularFormulasLC object and runs mass feature search, which annotates mass features with molecular formula assignments.
Parameters
- sample_id (str): Sample identifier
- collection (LCMSCollection): The collection containing the sample
- **runtime_params: Runtime parameters (not used)
Returns
- int: Number of mass features that were searched
778 def collect_results(self, sample_id, result, collection): 779 """ 780 Collect results (no-op as search modifies mass features in place). 781 782 The molecular formula search modifies mass features in place by adding 783 molecular formula assignments, so no explicit result collection is needed. 784 785 Parameters 786 ---------- 787 sample_id : str 788 Sample identifier 789 result : int 790 Number of features searched 791 collection : LCMSCollection 792 The collection containing the sample 793 """ 794 # Search modifies mass features in place, nothing to collect 795 pass
Collect results (no-op as search modifies mass features in place).
The molecular formula search modifies mass features in place by adding molecular formula assignments, so no explicit result collection is needed.
Parameters
- sample_id (str): Sample identifier
- result (int): Number of features searched
- collection (LCMSCollection): The collection containing the sample
Inherited Members
798class MS2SpectralSearchOperation(SampleOperation): 799 """ 800 Perform MS2 spectral search using entropy-based matching. 801 802 This operation performs spectral library search on MS2 spectra associated 803 with mass features using FlashEntropy for fast similarity scoring. Requires 804 MS2 spectra to be loaded and processed before execution. 805 806 Parameters 807 ---------- 808 name : str 809 Operation name (for logging) 810 ms2_scan_filter : str or None, optional 811 Filter string for MS2 scans (e.g., 'hcd'). If None, uses all MS2 scans. 812 Default is None. 813 peak_sep_da : float, optional 814 Peak separation in Daltons for spectral matching. Default is 0.01. 815 **kwargs 816 Additional parameters passed to parent class 817 818 Examples 819 -------- 820 >>> op = MS2SpectralSearchOperation('ms2_search', ms2_scan_filter='hcd') 821 >>> # Use in pipeline - requires fe_lib in runtime_params 822 >>> results = collection.process_samples_pipeline([op]) 823 824 Notes 825 ----- 826 This operation requires: 827 - MS2 spectra to be associated with mass features 828 - FlashEntropy library (fe_lib) to be provided in runtime_params 829 - MS2 spectra must be processed (centroided) 830 831 The spectral search modifies mass features in place by adding spectral 832 match scores and metadata. 833 """ 834 835 @property 836 def description(self): 837 """Human-readable description for progress messages.""" 838 return "MS2 spectral search" 839 840 def __init__(self, name='ms2_spectral_search', ms2_scan_filter=None, **kwargs): 841 super().__init__(name, **kwargs) 842 self.params['ms2_scan_filter'] = ms2_scan_filter 843 844 def needs_raw_ms_data(self): 845 """ 846 This operation doesn't need raw data - it works on processed MS2 spectra 847 that are already associated with mass features. 848 849 Returns 850 ------- 851 tuple 852 (False, None) - no raw data needed 853 """ 854 return False, None 855 856 def can_execute(self, sample, collection, **runtime_params): 857 """ 858 Check if MS2 spectral search can be executed. 859 860 Requires that the sample has mass features with MS2 spectra associated. 861 862 Parameters 863 ---------- 864 sample : LCMSObject 865 The sample object 866 collection : LCMSCollection 867 The collection containing the sample 868 **runtime_params 869 Runtime parameters (not used) 870 871 Returns 872 ------- 873 bool 874 True if sample has mass features with MS2 spectra 875 """ 876 # Check if sample has mass features 877 if not hasattr(sample, 'mass_features') or not sample.mass_features: 878 return False 879 880 # Check if any mass features have MS2 spectra associated 881 has_ms2 = any( 882 hasattr(mf, 'ms2_mass_spectra') and mf.ms2_mass_spectra 883 for mf in sample.mass_features.values() 884 ) 885 886 return has_ms2 887 888 def execute(self, sample_id, collection, fe_lib=None, molecular_metadata=None, **runtime_params): 889 """ 890 Execute MS2 spectral search on a sample. 891 892 Performs entropy-based spectral library search on all MS2 spectra 893 in the sample that match the scan filter criteria. 894 895 Parameters 896 ---------- 897 sample_id : str 898 Sample identifier 899 collection : LCMSCollection 900 The collection containing the sample 901 fe_lib : FlashEntropy library 902 Pre-computed FlashEntropy library for spectral matching 903 molecular_metadata : pd.DataFrame, optional 904 Metadata for molecules in the spectral library 905 **runtime_params 906 Runtime parameters (not used) 907 908 Returns 909 ------- 910 int 911 Number of MS2 spectra searched 912 """ 913 sample = collection[sample_id] 914 915 # Get parameters 916 ms2_scan_filter = self.params.get('ms2_scan_filter') 917 918 # Verify that we have a spectral library 919 if fe_lib is None: 920 raise ValueError( 921 f"Sample {sample_id}: MS2 spectral search requires fe_lib (FlashEntropy library) " 922 "to be provided in runtime parameters. Create the library at the collection level " 923 "and pass it to the pipeline." 924 ) 925 926 # Extract peak_sep_da from FlashEntropy library configuration 927 # peak_sep_da should be 2 * max_ms2_tolerance_in_da to match the min_ms2_difference_in_da 928 # used when creating the library 929 tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da 930 if tolerance_da is None: 931 raise ValueError( 932 f"Sample {sample_id}: Could not extract max_ms2_tolerance_in_da from FlashEntropy library. " 933 "Ensure the library was created with this parameter specified." 934 ) 935 peak_sep_da = 2 * tolerance_da 936 937 # Verify that sample has _ms dictionary 938 if not hasattr(sample, '_ms') or not sample._ms: 939 return 0 # No MS2 spectra to search 940 941 # Get MS2 scan numbers based on filter 942 if ms2_scan_filter is not None: 943 # Filter by scan text 944 ms2_scan_df = sample.scan_df[ 945 sample.scan_df.scan_text.str.contains(ms2_scan_filter) & 946 (sample.scan_df.ms_level == 2) 947 ] 948 else: 949 # All MS2 scans 950 ms2_scan_df = sample.scan_df[sample.scan_df.ms_level == 2] 951 952 # Get scans that are actually loaded in _ms 953 ms2_scans_to_search = [ 954 scan for scan in ms2_scan_df.scan.tolist() 955 if scan in sample._ms.keys() 956 ] 957 958 if not ms2_scans_to_search: 959 return 0 # No MS2 spectra to search 960 961 # Perform spectral search using the sample's fe_search method 962 sample.fe_search( 963 scan_list=ms2_scans_to_search, 964 fe_lib=fe_lib, 965 peak_sep_da=peak_sep_da 966 ) 967 968 # Return the spectral search results for collection 969 # (needed for multiprocessing - results populated in worker need to be returned) 970 return sample.spectral_search_results 971 972 def collect_results(self, sample_id, result, collection): 973 """ 974 Collect spectral search results back into the sample. 975 976 In multiprocessing, the worker's modifications don't persist to the 977 main process, so we need to explicitly collect and reassign the results. 978 This also re-associates the results with mass features. 979 980 Parameters 981 ---------- 982 sample_id : str 983 Sample identifier 984 result : dict 985 Dictionary of spectral search results from execute() 986 collection : LCMSCollection 987 The collection containing the sample 988 """ 989 # Assign the spectral search results back to the sample 990 if result: 991 collection[sample_id].spectral_search_results.update(result) 992 993 # Re-associate results with mass features (same logic as fe_search) 994 sample = collection[sample_id] 995 if len(sample.mass_features) > 0: 996 for mass_feature_id, mass_feature in sample.mass_features.items(): 997 scan_ids = mass_feature.ms2_scan_numbers 998 for ms2_scan_id in scan_ids: 999 precursor_mz = mass_feature.mz 1000 try: 1001 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1002 except KeyError: 1003 pass 1004 else: 1005 sample.mass_features[ 1006 mass_feature_id 1007 ].ms2_similarity_results.append( 1008 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1009 )
Perform MS2 spectral search using entropy-based matching.
This operation performs spectral library search on MS2 spectra associated with mass features using FlashEntropy for fast similarity scoring. Requires MS2 spectra to be loaded and processed before execution.
Parameters
- name (str): Operation name (for logging)
- ms2_scan_filter (str or None, optional): Filter string for MS2 scans (e.g., 'hcd'). If None, uses all MS2 scans. Default is None.
- peak_sep_da (float, optional): Peak separation in Daltons for spectral matching. Default is 0.01.
- **kwargs: Additional parameters passed to parent class
Examples
>>> op = MS2SpectralSearchOperation('ms2_search', ms2_scan_filter='hcd')
>>> # Use in pipeline - requires fe_lib in runtime_params
>>> results = collection.process_samples_pipeline([op])
Notes
This operation requires:
- MS2 spectra to be associated with mass features
- FlashEntropy library (fe_lib) to be provided in runtime_params
- MS2 spectra must be processed (centroided)
The spectral search modifies mass features in place by adding spectral match scores and metadata.
835 @property 836 def description(self): 837 """Human-readable description for progress messages.""" 838 return "MS2 spectral search"
Human-readable description for progress messages.
844 def needs_raw_ms_data(self): 845 """ 846 This operation doesn't need raw data - it works on processed MS2 spectra 847 that are already associated with mass features. 848 849 Returns 850 ------- 851 tuple 852 (False, None) - no raw data needed 853 """ 854 return False, None
This operation doesn't need raw data - it works on processed MS2 spectra that are already associated with mass features.
Returns
- tuple: (False, None) - no raw data needed
856 def can_execute(self, sample, collection, **runtime_params): 857 """ 858 Check if MS2 spectral search can be executed. 859 860 Requires that the sample has mass features with MS2 spectra associated. 861 862 Parameters 863 ---------- 864 sample : LCMSObject 865 The sample object 866 collection : LCMSCollection 867 The collection containing the sample 868 **runtime_params 869 Runtime parameters (not used) 870 871 Returns 872 ------- 873 bool 874 True if sample has mass features with MS2 spectra 875 """ 876 # Check if sample has mass features 877 if not hasattr(sample, 'mass_features') or not sample.mass_features: 878 return False 879 880 # Check if any mass features have MS2 spectra associated 881 has_ms2 = any( 882 hasattr(mf, 'ms2_mass_spectra') and mf.ms2_mass_spectra 883 for mf in sample.mass_features.values() 884 ) 885 886 return has_ms2
Check if MS2 spectral search can be executed.
Requires that the sample has mass features with MS2 spectra associated.
Parameters
- sample (LCMSObject): The sample object
- collection (LCMSCollection): The collection containing the sample
- **runtime_params: Runtime parameters (not used)
Returns
- bool: True if sample has mass features with MS2 spectra
888 def execute(self, sample_id, collection, fe_lib=None, molecular_metadata=None, **runtime_params): 889 """ 890 Execute MS2 spectral search on a sample. 891 892 Performs entropy-based spectral library search on all MS2 spectra 893 in the sample that match the scan filter criteria. 894 895 Parameters 896 ---------- 897 sample_id : str 898 Sample identifier 899 collection : LCMSCollection 900 The collection containing the sample 901 fe_lib : FlashEntropy library 902 Pre-computed FlashEntropy library for spectral matching 903 molecular_metadata : pd.DataFrame, optional 904 Metadata for molecules in the spectral library 905 **runtime_params 906 Runtime parameters (not used) 907 908 Returns 909 ------- 910 int 911 Number of MS2 spectra searched 912 """ 913 sample = collection[sample_id] 914 915 # Get parameters 916 ms2_scan_filter = self.params.get('ms2_scan_filter') 917 918 # Verify that we have a spectral library 919 if fe_lib is None: 920 raise ValueError( 921 f"Sample {sample_id}: MS2 spectral search requires fe_lib (FlashEntropy library) " 922 "to be provided in runtime parameters. Create the library at the collection level " 923 "and pass it to the pipeline." 924 ) 925 926 # Extract peak_sep_da from FlashEntropy library configuration 927 # peak_sep_da should be 2 * max_ms2_tolerance_in_da to match the min_ms2_difference_in_da 928 # used when creating the library 929 tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da 930 if tolerance_da is None: 931 raise ValueError( 932 f"Sample {sample_id}: Could not extract max_ms2_tolerance_in_da from FlashEntropy library. " 933 "Ensure the library was created with this parameter specified." 934 ) 935 peak_sep_da = 2 * tolerance_da 936 937 # Verify that sample has _ms dictionary 938 if not hasattr(sample, '_ms') or not sample._ms: 939 return 0 # No MS2 spectra to search 940 941 # Get MS2 scan numbers based on filter 942 if ms2_scan_filter is not None: 943 # Filter by scan text 944 ms2_scan_df = sample.scan_df[ 945 sample.scan_df.scan_text.str.contains(ms2_scan_filter) & 946 (sample.scan_df.ms_level == 2) 947 ] 948 else: 949 # All MS2 scans 950 ms2_scan_df = sample.scan_df[sample.scan_df.ms_level == 2] 951 952 # Get scans that are actually loaded in _ms 953 ms2_scans_to_search = [ 954 scan for scan in ms2_scan_df.scan.tolist() 955 if scan in sample._ms.keys() 956 ] 957 958 if not ms2_scans_to_search: 959 return 0 # No MS2 spectra to search 960 961 # Perform spectral search using the sample's fe_search method 962 sample.fe_search( 963 scan_list=ms2_scans_to_search, 964 fe_lib=fe_lib, 965 peak_sep_da=peak_sep_da 966 ) 967 968 # Return the spectral search results for collection 969 # (needed for multiprocessing - results populated in worker need to be returned) 970 return sample.spectral_search_results
Execute MS2 spectral search on a sample.
Performs entropy-based spectral library search on all MS2 spectra in the sample that match the scan filter criteria.
Parameters
- sample_id (str): Sample identifier
- collection (LCMSCollection): The collection containing the sample
- fe_lib (FlashEntropy library): Pre-computed FlashEntropy library for spectral matching
- molecular_metadata (pd.DataFrame, optional): Metadata for molecules in the spectral library
- **runtime_params: Runtime parameters (not used)
Returns
- int: Number of MS2 spectra searched
972 def collect_results(self, sample_id, result, collection): 973 """ 974 Collect spectral search results back into the sample. 975 976 In multiprocessing, the worker's modifications don't persist to the 977 main process, so we need to explicitly collect and reassign the results. 978 This also re-associates the results with mass features. 979 980 Parameters 981 ---------- 982 sample_id : str 983 Sample identifier 984 result : dict 985 Dictionary of spectral search results from execute() 986 collection : LCMSCollection 987 The collection containing the sample 988 """ 989 # Assign the spectral search results back to the sample 990 if result: 991 collection[sample_id].spectral_search_results.update(result) 992 993 # Re-associate results with mass features (same logic as fe_search) 994 sample = collection[sample_id] 995 if len(sample.mass_features) > 0: 996 for mass_feature_id, mass_feature in sample.mass_features.items(): 997 scan_ids = mass_feature.ms2_scan_numbers 998 for ms2_scan_id in scan_ids: 999 precursor_mz = mass_feature.mz 1000 try: 1001 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1002 except KeyError: 1003 pass 1004 else: 1005 sample.mass_features[ 1006 mass_feature_id 1007 ].ms2_similarity_results.append( 1008 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1009 )
Collect spectral search results back into the sample.
In multiprocessing, the worker's modifications don't persist to the main process, so we need to explicitly collect and reassign the results. This also re-associates the results with mass features.
Parameters
- sample_id (str): Sample identifier
- result (dict): Dictionary of spectral search results from execute()
- collection (LCMSCollection): The collection containing the sample
Inherited Members
1012class LoadEICsOperation(SampleOperation): 1013 """ 1014 Load extracted ion chromatograms (EICs) from HDF5 for regular mass features. 1015 1016 Loads EICs for regular mass features that belong to consensus clusters from HDF5. 1017 Induced (gap-filled) features already have EICs from integrate_mass_features, 1018 so no additional loading is needed for them. 1019 1020 This operation enables downstream visualization and analysis of chromatographic 1021 peaks across all samples in a cluster. 1022 1023 Notes 1024 ----- 1025 Requires that mass features have been loaded and cluster_index assigned. 1026 Regular mass feature EICs must have been previously saved to HDF5 with export_eics=True. 1027 Induced mass features already have EICs populated during gap-filling. 1028 """ 1029 1030 @property 1031 def description(self): 1032 """Human-readable description for progress messages.""" 1033 return "loading EICs" 1034 1035 def needs_raw_ms_data(self): 1036 """This operation doesn't need raw data - induced features already have EICs.""" 1037 return False, None 1038 1039 def can_execute(self, sample, collection): 1040 """ 1041 Check if EIC loading can be executed. 1042 1043 This operation can always execute if the sample exists - the actual work 1044 is determined by cluster_mz_dict in runtime_params. If cluster_mz_dict is 1045 empty or None, execute() will simply return 0 (no EICs loaded). 1046 1047 Returns 1048 ------- 1049 bool 1050 True (always executable - runtime_params control actual work) 1051 """ 1052 return True 1053 1054 def execute(self, sample_id, collection, cluster_mz_dict=None, **runtime_params): 1055 """ 1056 Load EICs from HDF5 for a single sample. 1057 1058 Loads EICs for regular mass features that belong to consensus clusters. 1059 Induced (gap-filled) mass features already have EICs from integrate_mass_features, 1060 so no additional loading is needed for them. 1061 1062 The cluster_mz_dict parameter (passed from collection level) maps sample_id 1063 to a list of m/z values that belong to clusters for that sample. 1064 1065 Parameters 1066 ---------- 1067 sample_id : int 1068 Sample index to process 1069 collection : LCMSBaseCollection 1070 The collection 1071 cluster_mz_dict : dict, optional 1072 Dictionary mapping sample_id to list of m/z values in clusters for that sample. 1073 If None, will not load any EICs. Default is None. 1074 **runtime_params 1075 Additional runtime parameters (ignored) 1076 1077 Returns 1078 ------- 1079 dict 1080 Dictionary of loaded EIC_Data objects, keyed by m/z value 1081 """ 1082 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 1083 1084 sample = collection[sample_id] 1085 1086 # If no cluster info provided or no m/z values for this sample, return early 1087 if cluster_mz_dict is None or sample_id not in cluster_mz_dict: 1088 return {} 1089 1090 # Get m/z values for this sample that belong to clusters 1091 sample_cluster_mz = set(cluster_mz_dict[sample_id]) 1092 if len(sample_cluster_mz) == 0: 1093 return {} 1094 1095 # Load EICs for each of the sample_cluster_mz 1096 hdf5_path = sample.file_location 1097 if hdf5_path and hdf5_path.exists(): 1098 reader = ReadCoreMSHDFMassSpectra(str(hdf5_path)) 1099 reader.import_eics(sample, mz_list=list(sample_cluster_mz)) 1100 # Return the loaded EICs for multiprocessing collection 1101 # (modifications in worker process don't persist to main process) 1102 return sample.eics.copy() 1103 1104 return {} 1105 1106 def collect_results(self, sample_id, result, collection): 1107 """ 1108 Collect loaded EICs back into sample. 1109 1110 In multiprocessing, the worker's modifications don't persist to the 1111 main process, so we need to explicitly collect and reassign the EICs. 1112 This also re-associates EICs with mass features. 1113 1114 Parameters 1115 ---------- 1116 sample_id : int 1117 Sample ID that was processed 1118 result : dict 1119 Dictionary of EIC_Data objects keyed by m/z, returned from execute() 1120 collection : LCMSBaseCollection 1121 The collection 1122 """ 1123 if result: 1124 # Update sample.eics with loaded EICs 1125 collection[sample_id].eics.update(result) 1126 # Note: EIC association with mass features happens after pipeline completes 1127 # to avoid multiprocessing issues (modifications in worker processes don't 1128 # persist to main process objects)
Load extracted ion chromatograms (EICs) from HDF5 for regular mass features.
Loads EICs for regular mass features that belong to consensus clusters from HDF5. Induced (gap-filled) features already have EICs from integrate_mass_features, so no additional loading is needed for them.
This operation enables downstream visualization and analysis of chromatographic peaks across all samples in a cluster.
Notes
Requires that mass features have been loaded and cluster_index assigned. Regular mass feature EICs must have been previously saved to HDF5 with export_eics=True. Induced mass features already have EICs populated during gap-filling.
1030 @property 1031 def description(self): 1032 """Human-readable description for progress messages.""" 1033 return "loading EICs"
Human-readable description for progress messages.
1035 def needs_raw_ms_data(self): 1036 """This operation doesn't need raw data - induced features already have EICs.""" 1037 return False, None
This operation doesn't need raw data - induced features already have EICs.
1039 def can_execute(self, sample, collection): 1040 """ 1041 Check if EIC loading can be executed. 1042 1043 This operation can always execute if the sample exists - the actual work 1044 is determined by cluster_mz_dict in runtime_params. If cluster_mz_dict is 1045 empty or None, execute() will simply return 0 (no EICs loaded). 1046 1047 Returns 1048 ------- 1049 bool 1050 True (always executable - runtime_params control actual work) 1051 """ 1052 return True
Check if EIC loading can be executed.
This operation can always execute if the sample exists - the actual work is determined by cluster_mz_dict in runtime_params. If cluster_mz_dict is empty or None, execute() will simply return 0 (no EICs loaded).
Returns
- bool: True (always executable - runtime_params control actual work)
1054 def execute(self, sample_id, collection, cluster_mz_dict=None, **runtime_params): 1055 """ 1056 Load EICs from HDF5 for a single sample. 1057 1058 Loads EICs for regular mass features that belong to consensus clusters. 1059 Induced (gap-filled) mass features already have EICs from integrate_mass_features, 1060 so no additional loading is needed for them. 1061 1062 The cluster_mz_dict parameter (passed from collection level) maps sample_id 1063 to a list of m/z values that belong to clusters for that sample. 1064 1065 Parameters 1066 ---------- 1067 sample_id : int 1068 Sample index to process 1069 collection : LCMSBaseCollection 1070 The collection 1071 cluster_mz_dict : dict, optional 1072 Dictionary mapping sample_id to list of m/z values in clusters for that sample. 1073 If None, will not load any EICs. Default is None. 1074 **runtime_params 1075 Additional runtime parameters (ignored) 1076 1077 Returns 1078 ------- 1079 dict 1080 Dictionary of loaded EIC_Data objects, keyed by m/z value 1081 """ 1082 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 1083 1084 sample = collection[sample_id] 1085 1086 # If no cluster info provided or no m/z values for this sample, return early 1087 if cluster_mz_dict is None or sample_id not in cluster_mz_dict: 1088 return {} 1089 1090 # Get m/z values for this sample that belong to clusters 1091 sample_cluster_mz = set(cluster_mz_dict[sample_id]) 1092 if len(sample_cluster_mz) == 0: 1093 return {} 1094 1095 # Load EICs for each of the sample_cluster_mz 1096 hdf5_path = sample.file_location 1097 if hdf5_path and hdf5_path.exists(): 1098 reader = ReadCoreMSHDFMassSpectra(str(hdf5_path)) 1099 reader.import_eics(sample, mz_list=list(sample_cluster_mz)) 1100 # Return the loaded EICs for multiprocessing collection 1101 # (modifications in worker process don't persist to main process) 1102 return sample.eics.copy() 1103 1104 return {}
Load EICs from HDF5 for a single sample.
Loads EICs for regular mass features that belong to consensus clusters. Induced (gap-filled) mass features already have EICs from integrate_mass_features, so no additional loading is needed for them.
The cluster_mz_dict parameter (passed from collection level) maps sample_id to a list of m/z values that belong to clusters for that sample.
Parameters
- sample_id (int): Sample index to process
- collection (LCMSBaseCollection): The collection
- cluster_mz_dict (dict, optional): Dictionary mapping sample_id to list of m/z values in clusters for that sample. If None, will not load any EICs. Default is None.
- **runtime_params: Additional runtime parameters (ignored)
Returns
- dict: Dictionary of loaded EIC_Data objects, keyed by m/z value
1106 def collect_results(self, sample_id, result, collection): 1107 """ 1108 Collect loaded EICs back into sample. 1109 1110 In multiprocessing, the worker's modifications don't persist to the 1111 main process, so we need to explicitly collect and reassign the EICs. 1112 This also re-associates EICs with mass features. 1113 1114 Parameters 1115 ---------- 1116 sample_id : int 1117 Sample ID that was processed 1118 result : dict 1119 Dictionary of EIC_Data objects keyed by m/z, returned from execute() 1120 collection : LCMSBaseCollection 1121 The collection 1122 """ 1123 if result: 1124 # Update sample.eics with loaded EICs 1125 collection[sample_id].eics.update(result) 1126 # Note: EIC association with mass features happens after pipeline completes 1127 # to avoid multiprocessing issues (modifications in worker processes don't 1128 # persist to main process objects)
Collect loaded EICs back into sample.
In multiprocessing, the worker's modifications don't persist to the main process, so we need to explicitly collect and reassign the EICs. This also re-associates EICs with mass features.
Parameters
- sample_id (int): Sample ID that was processed
- result (dict): Dictionary of EIC_Data objects keyed by m/z, returned from execute()
- collection (LCMSBaseCollection): The collection