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 The operation merges each gap's retention-time bounds, including the 184 expanded bounds when expand_on_miss is set and eic_buffer_time, and 185 passes that range to load_raw_data. load_raw_data forwards the range 186 into get_ms_raw with the sample's full scan table. The Thermo and mzML 187 readers ignore the range and read every scan in the table. Scattered 188 gaps merge into one window that covers the run, so the range does not 189 shrink a typical read. After the load, the search keeps MS1 points 190 inside the gap m/z and retention-time bounds. 191 192 Parameters 193 ---------- 194 name : str 195 Operation name 196 expand_on_miss : bool, optional 197 If True, a miss is searched again with the m/z window widened by 198 consensus_mz_tol_ppm and the retention-time window widened by 199 consensus_rt_tol minutes. Taken from gap_fill_expand_on_miss, 200 which defaults to True. 201 202 Notes 203 ----- 204 Requires that add_consensus_mass_features() has been run on the collection. 205 This operation loads raw MS1 data which will be available for subsequent operations. 206 """ 207 208 @property 209 def description(self): 210 """Human-readable description for progress messages.""" 211 return "gap-filling" 212 213 def needs_raw_ms_data(self): 214 """This operation needs raw MS1 data.""" 215 return True, 1 216 217 def can_execute(self, sample, collection): 218 """Check if cluster summary exists.""" 219 return ( 220 hasattr(collection, "cluster_summary_dataframe") 221 and collection.cluster_summary_dataframe is not None 222 ) 223 224 def execute(self, sample_id, collection, **runtime_params): 225 """ 226 Execute gap-filling for a single sample. 227 228 Parameters 229 ---------- 230 sample_id : int 231 Sample index to process 232 collection : LCMSBaseCollection 233 The collection 234 **runtime_params 235 Runtime parameters including: 236 - missingdf : pd.DataFrame - Cluster information and missing samples (optional) 237 - cluster_dict : dict - Cluster feature dictionary (optional) 238 - expand_on_miss : bool - Whether to expand search window on miss (optional) 239 If these are not provided, returns empty dict (no gaps to fill). 240 241 Returns 242 ------- 243 dict 244 Dictionary of induced mass features (empty if no gaps to fill) 245 """ 246 # Extract gap-fill parameters from runtime_params 247 # If not present, there are no gaps to fill, so return early 248 if "missingdf" not in runtime_params: 249 return {} 250 251 missingdf = runtime_params["missingdf"] 252 cluster_dict = runtime_params["cluster_dict"] 253 expand_on_miss = runtime_params["expand_on_miss"] 254 255 # This is essentially the same logic as _search_for_targeted_mass_features_in_sample 256 # but extracted into an operation 257 258 # Get clusters missing data for this sample 259 sampledf = ( 260 missingdf[missingdf.missing_samples.apply(lambda x: sample_id in x)] 261 .reset_index(drop=True) 262 .copy() 263 ) 264 265 # Skip if no missing features for this sample 266 if len(sampledf) == 0: 267 return {} 268 269 # Get buffer time from LCMS parameters for EIC extraction 270 # This ensures we capture the full chromatographic peak beyond cluster bounds 271 buffer_rt = collection[sample_id].parameters.lc_ms.eic_buffer_time 272 273 # Windows passed to the loader. Thermo and mzML still read the full scan table. 274 time_ranges = [] 275 276 for _, row in sampledf.iterrows(): 277 rt_min = row["scan_time_aligned_min"] 278 rt_max = row["scan_time_aligned_max"] 279 280 # If expand_on_miss, also consider the allowed bounds 281 if expand_on_miss: 282 rt_min = min(rt_min, row["sta_min_allowed"]) 283 rt_max = max(rt_max, row["sta_max_allowed"]) 284 285 # Apply buffer AFTER considering expand_on_miss bounds 286 # This ensures buffer is added beyond even the expanded search window 287 time_ranges.append((max(0, rt_min - buffer_rt), rt_max + buffer_rt)) 288 289 # Merge overlapping time ranges to reduce number of separate loads 290 time_ranges = sorted(time_ranges) 291 merged_ranges = [] 292 if time_ranges: 293 current_min, current_max = time_ranges[0] 294 for rt_min, rt_max in time_ranges[1:]: 295 if rt_min <= current_max: # Overlapping or adjacent 296 current_max = max(current_max, rt_max) 297 else: 298 merged_ranges.append((current_min, current_max)) 299 current_min, current_max = rt_min, rt_max 300 merged_ranges.append((current_min, current_max)) 301 302 # time_range is forwarded to get_ms_raw; Thermo and mzML ignore it. 303 collection.load_raw_data(sample_id, 1, time_range=merged_ranges) 304 305 # Get MS1 data 306 ms1df = collection[sample_id]._ms_unprocessed[1].copy() 307 scan_df = collection[sample_id].scan_df[["scan", "scan_time_aligned"]] 308 ms1df = pd.merge(ms1df, scan_df, on="scan") 309 310 # Pre-extract all values from sampledf 311 clusters = sampledf.cluster.values 312 mz_mins = sampledf.mz_min.values 313 mz_maxs = sampledf.mz_max.values 314 st_mins = sampledf.scan_time_aligned_min.values 315 st_maxs = sampledf.scan_time_aligned_max.values 316 317 if expand_on_miss: 318 mz_mins_allowed = sampledf.mz_min_allowed.values 319 mz_maxs_allowed = sampledf.mz_max_allowed.values 320 st_mins_allowed = sampledf.sta_min_allowed.values 321 st_maxs_allowed = sampledf.sta_max_allowed.values 322 323 # Pre-filter ms1df to reduce search space 324 mz_global_min = mz_mins.min() 325 mz_global_max = mz_maxs.max() 326 st_global_min = st_mins.min() 327 st_global_max = st_maxs.max() 328 329 if expand_on_miss: 330 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 331 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 332 st_global_min = min(st_global_min, st_mins_allowed.min()) 333 st_global_max = max(st_global_max, st_maxs_allowed.max()) 334 335 ms1df_filtered = ms1df[ 336 (ms1df.mz >= mz_global_min) 337 & (ms1df.mz <= mz_global_max) 338 & (ms1df.scan_time_aligned >= st_global_min) 339 & (ms1df.scan_time_aligned <= st_global_max) 340 ].copy() 341 342 # Generate set_ids for all features 343 set_ids = [f"c{clusters[i]}_{i}_i" for i in range(len(sampledf))] 344 345 # Use batch method to process all features at once 346 if expand_on_miss: 347 # First try with normal bounds 348 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 349 ms1df_filtered, 350 mz_mins, 351 mz_maxs, 352 st_mins, 353 st_maxs, 354 set_ids, 355 obj_idx=sample_id, 356 st_aligned=True, 357 ) 358 359 # Retry failed features with expanded bounds 360 failed_indices = [ 361 i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99 362 ] 363 if failed_indices: 364 failed_ids = [set_ids[i] for i in failed_indices] 365 retry_peaks = collection[ 366 sample_id 367 ].search_for_targeted_mass_features_batch( 368 ms1df_filtered, 369 mz_mins_allowed[failed_indices], 370 mz_maxs_allowed[failed_indices], 371 st_mins_allowed[failed_indices], 372 st_maxs_allowed[failed_indices], 373 failed_ids, 374 obj_idx=sample_id, 375 st_aligned=True, 376 ) 377 peaks_dict.update(retry_peaks) 378 else: 379 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 380 ms1df_filtered, 381 mz_mins, 382 mz_maxs, 383 st_mins, 384 st_maxs, 385 set_ids, 386 obj_idx=sample_id, 387 st_aligned=True, 388 ) 389 390 # Build induced_mass_features dict and update cluster_dict 391 induced_mass_features = {} 392 for i in range(len(sampledf)): 393 peak = peaks_dict[set_ids[i]] 394 induced_mass_features[peak.id] = peak 395 cluster_dict[clusters[i]] += [set_ids[i]] 396 397 # Integrate mass features (don't fail on bad integration). 398 # m/z, intensity, and retention time are already on the feature from the 399 # targeted search. Representative selection reads mass_features_dataframe 400 # only, so a gap-filled feature is never the cluster representative and 401 # does not get a processed MS1 spectrum here. 402 collection[sample_id].induced_mass_features = induced_mass_features 403 # The duplicate scan only deletes keys from mass_features. Induced ids 404 # are strings such as c12_0_i, so that scan does not remove them. 405 collection[sample_id].integrate_mass_features( 406 drop_if_fail=False, induced_features=True, drop_duplicates=False 407 ) 408 409 return collection[sample_id].induced_mass_features 410 411 def collect_results(self, sample_id, result, collection): 412 """Collect induced mass features back into sample.""" 413 collection[sample_id].induced_mass_features = result 414 415 416class ReloadFeaturesOperation(SampleOperation): 417 """ 418 Reload mass features from HDF5 and optionally add MS1/MS2 spectra. 419 420 This is useful when the collection was loaded with load_light=True, 421 which stores mass features only in the collection dataframe and not 422 as LCMSMassFeature objects in individual samples. 423 424 Parameters 425 ---------- 426 name : str 427 Operation name 428 add_ms1 : bool, optional 429 If True, adds MS1 spectra to mass features. Automatically uses raw MS1 data 430 if available (e.g., from gap-filling), otherwise uses parser. Spectrum mode 431 is auto-detected. Default is False. 432 add_ms2 : bool, optional 433 If True, also loads and associates MS2 spectra. Spectrum mode is auto-detected. 434 Default is False. 435 auto_process_ms2 : bool, optional 436 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 437 ms2_scan_filter : str or None, optional 438 Filter string for MS2 scans. Default is None. 439 440 Notes 441 ----- 442 MS1 spectra association automatically uses raw MS1 data if loaded by a previous 443 operation (e.g., GapFillOperation). This is efficient when multiple operations 444 need MS1 data in the same pipeline. All spectrum modes are auto-detected from 445 the data. 446 """ 447 448 @property 449 def description(self): 450 """Human-readable description for progress messages.""" 451 return "reloading features" 452 453 def needs_raw_ms_data(self): 454 """This operation doesn't need raw data.""" 455 return False, None 456 457 def can_execute(self, sample, collection): 458 """Check if collection parser is available.""" 459 return ( 460 hasattr(collection, "collection_parser") 461 and collection.collection_parser is not None 462 ) 463 464 def execute(self, sample_id, collection, mf_ids_to_load=None, **runtime_params): 465 """ 466 Execute feature reloading for a single sample. 467 468 Parameters 469 ---------- 470 sample_id : int 471 Sample ID to reload features for 472 collection : LCMSBaseCollection 473 The collection 474 mf_ids_to_load : list of str, optional 475 List of collection-level mf_ids to load 476 **runtime_params 477 Additional runtime parameters (ignored) 478 479 Returns 480 ------- 481 dict 482 Dictionary of reloaded mass features 483 """ 484 # Get parameters 485 add_ms1 = self.params.get("add_ms1", False) 486 add_ms2 = self.params.get("add_ms2", False) 487 auto_process_ms2 = self.params.get("auto_process_ms2", True) 488 ms2_scan_filter = self.params.get("ms2_scan_filter", None) 489 490 sample = collection[sample_id] 491 sample_name = collection.samples[sample_id] 492 493 # Auto-determine if we should use parser for MS1 (check if raw data is available) 494 has_raw_ms1 = ( 495 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 496 ) 497 use_parser_for_ms1 = ( 498 not has_raw_ms1 499 ) # Use parser only if raw data not available 500 501 # Spectrum modes will be auto-detected (None = auto-detect) 502 spectrum_mode_ms1 = None 503 ms2_spectrum_mode = None 504 505 # Check if we have a collection parser 506 if ( 507 not hasattr(collection, "collection_parser") 508 or collection.collection_parser is None 509 ): 510 print( 511 f"Warning: Cannot reload mass features for {sample_name} - no collection_parser available" 512 ) 513 return {} 514 515 # Get the HDF5 file for this sample 516 hdf5_file = ( 517 collection.collection_parser.folder_location 518 / f"{sample_name}.corems/{sample_name}.hdf5" 519 ) 520 521 if not hdf5_file.exists(): 522 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 523 return {} 524 525 # Import here to avoid circular imports 526 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 527 528 # If specific mf_ids requested, extract the local mf_ids we need 529 local_mf_ids_to_load = None 530 if mf_ids_to_load is not None: 531 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 532 # No parsing needed - they come from the mf_id column in the dataframe 533 if len(mf_ids_to_load) == 0: 534 # No features to load for this sample - return empty dict 535 return {} 536 local_mf_ids_to_load = set(mf_ids_to_load) 537 538 # Reload mass features from HDF5 539 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 540 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 541 542 # If add_ms1, associate MS1 spectra with the loaded mass features 543 if add_ms1 and len(sample.mass_features) > 0: 544 # Check if raw MS1 data is already loaded (e.g., from gap-filling) 545 has_raw_ms1 = ( 546 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 547 ) 548 549 if has_raw_ms1 and not use_parser_for_ms1: 550 # Use already-loaded raw data (more efficient) 551 sample.add_associated_ms1( 552 auto_process=True, use_parser=False, spectrum_mode=spectrum_mode_ms1 553 ) 554 else: 555 # Use parser to get MS1 spectra 556 sample.add_associated_ms1( 557 auto_process=True, use_parser=True, spectrum_mode=spectrum_mode_ms1 558 ) 559 560 # If add_ms2, associate MS2 spectra with the loaded mass features 561 if add_ms2 and len(sample.mass_features) > 0: 562 # Get the IDs of loaded mass features (use what was actually loaded) 563 mf_ids_for_ms2 = list(sample.mass_features.keys()) 564 565 collection._associate_ms2_with_mass_features( 566 sample, 567 mf_ids_for_ms2, 568 auto_process=auto_process_ms2, 569 spectrum_mode=ms2_spectrum_mode, 570 scan_filter=ms2_scan_filter, 571 ) 572 573 # Return both mass_features and _ms so they can be collected in multiprocessing 574 return {"mass_features": sample.mass_features, "_ms": sample._ms} 575 576 def collect_results(self, sample_id, result, collection): 577 """ 578 Collect reloaded mass features back into sample. 579 580 This operation loads a subset of mass features (e.g., representatives) 581 into sample.mass_features for processing, while preserving the full 582 mass_features_dataframe at the collection level. Sets a lock flag to 583 prevent automatic rebuilding of the collection dataframe from individual 584 samples. Also collects loaded mass spectra. 585 586 Parameters 587 ---------- 588 sample_id : int 589 Sample ID that was processed 590 result : dict 591 Dictionary with 'mass_features' and '_ms' from execute() 592 collection : LCMSBaseCollection 593 The collection 594 """ 595 # Update sample.mass_features with loaded features 596 if isinstance(result, dict) and "mass_features" in result: 597 collection[sample_id].mass_features = result["mass_features"] 598 # Also collect the _ms dictionary (MS1 and MS2 spectra) 599 if "_ms" in result: 600 collection[sample_id]._ms.update(result["_ms"]) 601 else: 602 # Backward compatibility - if result is just mass_features dict 603 collection[sample_id].mass_features = result 604 605 # Lock the collection dataframe to prevent rebuilding from individual samples 606 # (since we've only loaded a subset, rebuilding would lose data) 607 collection._mass_features_locked = True 608 609 610class MolecularFormulaSearchOperation(SampleOperation): 611 """ 612 Perform molecular formula search on mass features using associated MS1 spectra. 613 614 This operation runs molecular formula search on all mass features in a sample 615 that have associated MS1 spectra. Requires MS1 spectra to be loaded and 616 processed before execution. 617 618 Parameters 619 ---------- 620 name : str 621 Operation name (for logging) 622 **kwargs 623 Additional parameters passed to parent class 624 625 Examples 626 -------- 627 >>> op = MolecularFormulaSearchOperation('mf_search') 628 >>> # Use in pipeline 629 >>> results = collection.process_samples_pipeline([op]) 630 631 Notes 632 ----- 633 This operation requires that MS1 spectra have been associated with mass 634 features (e.g., via ReloadFeaturesOperation with add_ms1=True). The 635 molecular formula search uses parameters from the collection's 636 parameters.mass_spectrum["ms1"].molecular_search settings. 637 """ 638 639 @property 640 def description(self): 641 """Human-readable description for progress messages.""" 642 return "molecular formula search" 643 644 def __init__(self, name="molecular_formula_search", **kwargs): 645 super().__init__(name, **kwargs) 646 647 def needs_raw_ms_data(self): 648 """ 649 This operation doesn't need raw data - it works on processed MS1 spectra 650 that are already associated with mass features. 651 652 Returns 653 ------- 654 tuple 655 (False, None) - no raw data needed 656 """ 657 return False, None 658 659 def can_execute(self, sample, collection, **runtime_params): 660 """ 661 Check if molecular formula search can be executed. 662 663 Requires that the sample has mass features with associated MS1 spectra. 664 665 Parameters 666 ---------- 667 sample : LCMSObject 668 The sample object 669 collection : LCMSCollection 670 The collection containing the sample 671 **runtime_params 672 Runtime parameters (not used) 673 674 Returns 675 ------- 676 bool 677 True if sample has mass features with MS1 spectra 678 """ 679 # Check if sample has mass features 680 if not hasattr(sample, "mass_features") or not sample.mass_features: 681 return False 682 683 # Check if at least some mass features have MS1 spectra 684 has_ms1 = any( 685 hasattr(mf, "mass_spectrum") and mf.mass_spectrum is not None 686 for mf in sample.mass_features.values() 687 ) 688 689 return has_ms1 690 691 def execute(self, sample_id, collection, **runtime_params): 692 """ 693 Execute molecular formula search on a sample. 694 695 Creates a SearchMolecularFormulasLC object and runs mass feature search, 696 which annotates mass features with molecular formula assignments. 697 698 Parameters 699 ---------- 700 sample_id : str 701 Sample identifier 702 collection : LCMSCollection 703 The collection containing the sample 704 **runtime_params 705 Runtime parameters (not used) 706 707 Returns 708 ------- 709 int 710 Number of mass features that were searched 711 """ 712 from corems.molecular_id.search.molecularFormulaSearch import ( 713 SearchMolecularFormulasLC, 714 ) 715 import time 716 import sqlalchemy.exc 717 import sqlite3 718 719 sample = collection[sample_id] 720 721 # Verify that mass features exist 722 if not hasattr(sample, "mass_features") or not sample.mass_features: 723 return 0 # No mass features to search 724 725 # Verify that mass features have MS1 spectra associated 726 if not hasattr(sample, "_ms") or not sample._ms: 727 raise RuntimeError( 728 f"Sample {sample_id} does not have MS1 spectra loaded in _ms dictionary. " 729 "Molecular formula search requires MS1 spectra to be associated with mass features. " 730 "Ensure add_ms1=True when reloading features." 731 ) 732 733 from corems.mass_spectra.calc.feature_grouping import ( 734 annotation_meta_for_sample_mf, 735 constrain_annotation_active, 736 should_skip_isotope_for_annotation, 737 ) 738 739 settings = collection.parameters.lcms_collection 740 labels = getattr(collection, "feature_group_dataframe", None) 741 constrain = constrain_annotation_active( 742 labels, bool(getattr(settings, "feature_group_constrain_annotation", True)) 743 ) 744 mf_df = getattr(collection, "mass_features_dataframe", None) 745 746 # Prepare data for bulk molecular formula search 747 # Group mass features by their apex scan 748 scan_to_mf = {} 749 for mf_id, mf in sample.mass_features.items(): 750 if constrain: 751 meta = annotation_meta_for_sample_mf(mf_df, labels, sample_id, mf_id) 752 if meta is not None and should_skip_isotope_for_annotation( 753 meta.get("ion_role"), True 754 ): 755 continue 756 apex_scan = mf.apex_scan 757 if apex_scan not in scan_to_mf: 758 scan_to_mf[apex_scan] = [] 759 scan_to_mf[apex_scan].append(mf) 760 761 # Build lists of mass spectra and corresponding peaks 762 mass_spectrum_list = [] 763 ms_peaks_list = [] 764 765 for scan_num, mf_list in scan_to_mf.items(): 766 # Get the mass spectrum for this scan 767 if scan_num not in sample._ms: 768 continue # Skip if spectrum not loaded 769 770 mass_spectrum = sample._ms[scan_num] 771 772 # Verify spectrum is processed (has peaks) 773 if not hasattr(mass_spectrum, "_mspeaks") or not mass_spectrum._mspeaks: 774 continue # Skip unprocessed spectra 775 776 # Get the MS1 peaks for each mass feature at this scan 777 peaks_for_scan = [] 778 for mf in mf_list: 779 try: 780 # Use the ms1_peak property which finds the closest peak 781 ms1_peak = mf.ms1_peak 782 peaks_for_scan.append(ms1_peak) 783 except (AttributeError, IndexError): 784 # Skip if ms1_peak can't be determined 785 continue 786 787 if peaks_for_scan: 788 mass_spectrum_list.append(mass_spectrum) 789 ms_peaks_list.append(peaks_for_scan) 790 791 # Run molecular formula search if we have data, with retry logic for database locks 792 if mass_spectrum_list and ms_peaks_list: 793 max_retries = 10 794 retry_delay = 2 # seconds 795 796 for attempt in range(max_retries): 797 try: 798 mol_search = SearchMolecularFormulasLC(sample) 799 mol_search.bulk_run_molecular_formula_search( 800 mass_spectrum_list, ms_peaks_list 801 ) 802 break # Success, exit retry loop 803 except (sqlalchemy.exc.OperationalError, sqlite3.OperationalError) as e: 804 if attempt < max_retries - 1: 805 # Database is locked, retry after delay 806 print( 807 f"Sample {sample_id}: Database locked during molecular formula search, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})..." 808 ) 809 time.sleep(retry_delay) 810 else: 811 # Max retries exceeded, re-raise the exception 812 raise RuntimeError( 813 f"Sample {sample_id}: Molecular formula search failed after {max_retries} attempts due to database lock. " 814 "Try reducing parallel cores or increasing database timeout." 815 ) from e 816 817 # Return count of features searched 818 return len(sample.mass_features) 819 820 def collect_results(self, sample_id, result, collection): 821 """ 822 Collect results (no-op as search modifies mass features in place). 823 824 The molecular formula search modifies mass features in place by adding 825 molecular formula assignments, so no explicit result collection is needed. 826 827 Parameters 828 ---------- 829 sample_id : str 830 Sample identifier 831 result : int 832 Number of features searched 833 collection : LCMSCollection 834 The collection containing the sample 835 """ 836 # Search modifies mass features in place, nothing to collect 837 pass 838 839 840class MS2SpectralSearchOperation(SampleOperation): 841 """ 842 Perform MS2 spectral search using entropy-based matching. 843 844 This operation performs spectral library search on MS2 spectra associated 845 with mass features using FlashEntropy for fast similarity scoring. Requires 846 MS2 spectra to be loaded and processed before execution. 847 848 Parameters 849 ---------- 850 name : str 851 Operation name (for logging) 852 ms2_scan_filter : str or None, optional 853 Filter string for MS2 scans (e.g., 'hcd'). If None, uses all MS2 scans. 854 Default is None. 855 peak_sep_da : float, optional 856 Peak separation in Daltons for spectral matching. Default is 0.01. 857 **kwargs 858 Additional parameters passed to parent class 859 860 Examples 861 -------- 862 >>> op = MS2SpectralSearchOperation('ms2_search', ms2_scan_filter='hcd') 863 >>> # Use in pipeline - requires fe_lib in runtime_params 864 >>> results = collection.process_samples_pipeline([op]) 865 866 Notes 867 ----- 868 This operation requires: 869 - MS2 spectra to be associated with mass features 870 - FlashEntropy library (fe_lib) to be provided in runtime_params 871 - MS2 spectra must be processed (centroided) 872 873 The spectral search modifies mass features in place by adding spectral 874 match scores and metadata. 875 """ 876 877 @property 878 def description(self): 879 """Human-readable description for progress messages.""" 880 return "MS2 spectral search" 881 882 def __init__(self, name="ms2_spectral_search", ms2_scan_filter=None, **kwargs): 883 super().__init__(name, **kwargs) 884 self.params["ms2_scan_filter"] = ms2_scan_filter 885 886 def needs_raw_ms_data(self): 887 """ 888 This operation doesn't need raw data - it works on processed MS2 spectra 889 that are already associated with mass features. 890 891 Returns 892 ------- 893 tuple 894 (False, None) - no raw data needed 895 """ 896 return False, None 897 898 def can_execute(self, sample, collection, **runtime_params): 899 """ 900 Check if MS2 spectral search can be executed. 901 902 Requires that the sample has mass features with MS2 spectra associated. 903 904 Parameters 905 ---------- 906 sample : LCMSObject 907 The sample object 908 collection : LCMSCollection 909 The collection containing the sample 910 **runtime_params 911 Runtime parameters (not used) 912 913 Returns 914 ------- 915 bool 916 True if sample has mass features with MS2 spectra 917 """ 918 # Check if sample has mass features 919 if not hasattr(sample, "mass_features") or not sample.mass_features: 920 return False 921 922 # Check if any mass features have MS2 spectra associated 923 has_ms2 = any( 924 hasattr(mf, "ms2_mass_spectra") and mf.ms2_mass_spectra 925 for mf in sample.mass_features.values() 926 ) 927 928 return has_ms2 929 930 def execute( 931 self, 932 sample_id, 933 collection, 934 fe_lib=None, 935 molecular_metadata=None, 936 **runtime_params, 937 ): 938 """ 939 Execute MS2 spectral search on a sample. 940 941 Performs entropy-based spectral library search on all MS2 spectra 942 in the sample that match the scan filter criteria. 943 944 Parameters 945 ---------- 946 sample_id : str 947 Sample identifier 948 collection : LCMSCollection 949 The collection containing the sample 950 fe_lib : FlashEntropy library 951 Pre-computed FlashEntropy library for spectral matching 952 molecular_metadata : pd.DataFrame, optional 953 Metadata for molecules in the spectral library 954 **runtime_params 955 Runtime parameters (not used) 956 957 Returns 958 ------- 959 int 960 Number of MS2 spectra searched 961 """ 962 sample = collection[sample_id] 963 964 # Get parameters 965 ms2_scan_filter = self.params.get("ms2_scan_filter") 966 967 # Verify that we have a spectral library 968 if fe_lib is None: 969 raise ValueError( 970 f"Sample {sample_id}: MS2 spectral search requires fe_lib (FlashEntropy library) " 971 "to be provided in runtime parameters. Create the library at the collection level " 972 "and pass it to the pipeline." 973 ) 974 975 # Extract peak_sep_da from FlashEntropy library configuration 976 # peak_sep_da should be 2 * max_ms2_tolerance_in_da to match the min_ms2_difference_in_da 977 # used when creating the library 978 tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da 979 if tolerance_da is None: 980 raise ValueError( 981 f"Sample {sample_id}: Could not extract max_ms2_tolerance_in_da from FlashEntropy library. " 982 "Ensure the library was created with this parameter specified." 983 ) 984 peak_sep_da = 2 * tolerance_da 985 986 # Verify that sample has _ms dictionary 987 if not hasattr(sample, "_ms") or not sample._ms: 988 return 0 # No MS2 spectra to search 989 990 # Get MS2 scan numbers based on filter 991 if ms2_scan_filter is not None: 992 # Filter by scan text 993 ms2_scan_df = sample.scan_df[ 994 sample.scan_df.scan_text.str.contains(ms2_scan_filter) 995 & (sample.scan_df.ms_level == 2) 996 ] 997 else: 998 # All MS2 scans 999 ms2_scan_df = sample.scan_df[sample.scan_df.ms_level == 2] 1000 1001 # Get scans that are actually loaded in _ms 1002 ms2_scans_to_search = [ 1003 scan for scan in ms2_scan_df.scan.tolist() if scan in sample._ms.keys() 1004 ] 1005 1006 if not ms2_scans_to_search: 1007 return 0 # No MS2 spectra to search 1008 1009 # Perform spectral search using the sample's fe_search method 1010 sample.fe_search( 1011 scan_list=ms2_scans_to_search, fe_lib=fe_lib, peak_sep_da=peak_sep_da 1012 ) 1013 1014 from corems.mass_spectra.calc.feature_grouping import ( 1015 annotation_meta_for_sample_mf, 1016 constrain_annotation_active, 1017 should_skip_isotope_for_annotation, 1018 allowed_ion_types_for_row, 1019 subset_hits_by_allowed_ion_types, 1020 ) 1021 1022 settings = collection.parameters.lcms_collection 1023 labels = getattr(collection, "feature_group_dataframe", None) 1024 constrain = constrain_annotation_active( 1025 labels, bool(getattr(settings, "feature_group_constrain_annotation", True)) 1026 ) 1027 if constrain: 1028 mf_df = getattr(collection, "mass_features_dataframe", None) 1029 for mf_id, mf in list(sample.mass_features.items()): 1030 meta = annotation_meta_for_sample_mf(mf_df, labels, sample_id, mf_id) 1031 if meta is None: 1032 continue 1033 if should_skip_isotope_for_annotation(meta.get("ion_role"), True): 1034 mf.ms2_similarity_results = [] 1035 continue 1036 allowed = allowed_ion_types_for_row( 1037 meta.get("ion_type"), meta.get("possible_ion_types") 1038 ) 1039 if allowed is None: 1040 continue 1041 results = getattr(mf, "ms2_similarity_results", None) or [] 1042 filtered = [] 1043 for res in results: 1044 kept = subset_hits_by_allowed_ion_types(res, allowed) 1045 if kept is not None: 1046 filtered.append(kept) 1047 mf.ms2_similarity_results = filtered 1048 1049 # Return the spectral search results for collection 1050 # (needed for multiprocessing - results populated in worker need to be returned) 1051 return sample.spectral_search_results 1052 1053 def collect_results(self, sample_id, result, collection): 1054 """ 1055 Collect spectral search results back into the sample. 1056 1057 In multiprocessing, the worker's modifications don't persist to the 1058 main process, so we need to explicitly collect and reassign the results. 1059 This also re-associates the results with mass features. 1060 1061 Parameters 1062 ---------- 1063 sample_id : str 1064 Sample identifier 1065 result : dict 1066 Dictionary of spectral search results from execute() 1067 collection : LCMSCollection 1068 The collection containing the sample 1069 """ 1070 # Assign the spectral search results back to the sample 1071 if result: 1072 collection[sample_id].spectral_search_results.update(result) 1073 1074 # Re-associate results with mass features (same logic as fe_search) 1075 sample = collection[sample_id] 1076 if len(sample.mass_features) > 0: 1077 for mass_feature_id, mass_feature in sample.mass_features.items(): 1078 scan_ids = mass_feature.ms2_scan_numbers 1079 for ms2_scan_id in scan_ids: 1080 precursor_mz = mass_feature.mz 1081 try: 1082 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1083 except KeyError: 1084 pass 1085 else: 1086 sample.mass_features[ 1087 mass_feature_id 1088 ].ms2_similarity_results.append( 1089 sample.spectral_search_results[ms2_scan_id][ 1090 precursor_mz 1091 ] 1092 ) 1093 1094 1095class LoadEICsOperation(SampleOperation): 1096 """ 1097 Load extracted ion chromatograms (EICs) from HDF5 for regular mass features. 1098 1099 Loads EICs for regular mass features that belong to consensus clusters from HDF5. 1100 Induced (gap-filled) features already have EICs from integrate_mass_features, 1101 so no additional loading is needed for them. 1102 1103 This operation enables downstream visualization and analysis of chromatographic 1104 peaks across all samples in a cluster. 1105 1106 Notes 1107 ----- 1108 Requires that mass features have been loaded and cluster_index assigned. 1109 Regular mass feature EICs must have been previously saved to HDF5 with export_eics=True. 1110 Induced mass features already have EICs populated during gap-filling. 1111 """ 1112 1113 @property 1114 def description(self): 1115 """Human-readable description for progress messages.""" 1116 return "loading EICs" 1117 1118 def needs_raw_ms_data(self): 1119 """This operation doesn't need raw data - induced features already have EICs.""" 1120 return False, None 1121 1122 def can_execute(self, sample, collection): 1123 """ 1124 Check if EIC loading can be executed. 1125 1126 This operation can always execute if the sample exists - the actual work 1127 is determined by cluster_mz_dict in runtime_params. If cluster_mz_dict is 1128 empty or None, execute() will simply return 0 (no EICs loaded). 1129 1130 Returns 1131 ------- 1132 bool 1133 True (always executable - runtime_params control actual work) 1134 """ 1135 return True 1136 1137 def execute(self, sample_id, collection, cluster_mz_dict=None, **runtime_params): 1138 """ 1139 Load EICs from HDF5 for a single sample. 1140 1141 Loads EICs for regular mass features that belong to consensus clusters. 1142 Induced (gap-filled) mass features already have EICs from integrate_mass_features, 1143 so no additional loading is needed for them. 1144 1145 The cluster_mz_dict parameter (passed from collection level) maps sample_id 1146 to a list of m/z values that belong to clusters for that sample. 1147 1148 Parameters 1149 ---------- 1150 sample_id : int 1151 Sample index to process 1152 collection : LCMSBaseCollection 1153 The collection 1154 cluster_mz_dict : dict, optional 1155 Dictionary mapping sample_id to list of m/z values in clusters for that sample. 1156 If None, will not load any EICs. Default is None. 1157 **runtime_params 1158 Additional runtime parameters (ignored) 1159 1160 Returns 1161 ------- 1162 dict 1163 Dictionary of loaded EIC_Data objects, keyed by m/z value 1164 """ 1165 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 1166 1167 sample = collection[sample_id] 1168 1169 # If no cluster info provided or no m/z values for this sample, return early 1170 if cluster_mz_dict is None or sample_id not in cluster_mz_dict: 1171 return {} 1172 1173 # Get m/z values for this sample that belong to clusters 1174 sample_cluster_mz = set(cluster_mz_dict[sample_id]) 1175 if len(sample_cluster_mz) == 0: 1176 return {} 1177 1178 # Load EICs for each of the sample_cluster_mz 1179 hdf5_path = sample.file_location 1180 if hdf5_path and hdf5_path.exists(): 1181 reader = ReadCoreMSHDFMassSpectra(str(hdf5_path)) 1182 reader.import_eics(sample, mz_list=list(sample_cluster_mz)) 1183 # Return the loaded EICs for multiprocessing collection 1184 # (modifications in worker process don't persist to main process) 1185 return sample.eics.copy() 1186 1187 return {} 1188 1189 def collect_results(self, sample_id, result, collection): 1190 """ 1191 Collect loaded EICs back into sample. 1192 1193 In multiprocessing, the worker's modifications don't persist to the 1194 main process, so we need to explicitly collect and reassign the EICs. 1195 This also re-associates EICs with mass features. 1196 1197 Parameters 1198 ---------- 1199 sample_id : int 1200 Sample ID that was processed 1201 result : dict 1202 Dictionary of EIC_Data objects keyed by m/z, returned from execute() 1203 collection : LCMSBaseCollection 1204 The collection 1205 """ 1206 if result: 1207 # Update sample.eics with loaded EICs 1208 collection[sample_id].eics.update(result) 1209 # Note: EIC association with mass features happens after pipeline completes 1210 # to avoid multiprocessing issues (modifications in worker processes don't 1211 # 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 The operation merges each gap's retention-time bounds, including the 185 expanded bounds when expand_on_miss is set and eic_buffer_time, and 186 passes that range to load_raw_data. load_raw_data forwards the range 187 into get_ms_raw with the sample's full scan table. The Thermo and mzML 188 readers ignore the range and read every scan in the table. Scattered 189 gaps merge into one window that covers the run, so the range does not 190 shrink a typical read. After the load, the search keeps MS1 points 191 inside the gap m/z and retention-time bounds. 192 193 Parameters 194 ---------- 195 name : str 196 Operation name 197 expand_on_miss : bool, optional 198 If True, a miss is searched again with the m/z window widened by 199 consensus_mz_tol_ppm and the retention-time window widened by 200 consensus_rt_tol minutes. Taken from gap_fill_expand_on_miss, 201 which defaults to True. 202 203 Notes 204 ----- 205 Requires that add_consensus_mass_features() has been run on the collection. 206 This operation loads raw MS1 data which will be available for subsequent operations. 207 """ 208 209 @property 210 def description(self): 211 """Human-readable description for progress messages.""" 212 return "gap-filling" 213 214 def needs_raw_ms_data(self): 215 """This operation needs raw MS1 data.""" 216 return True, 1 217 218 def can_execute(self, sample, collection): 219 """Check if cluster summary exists.""" 220 return ( 221 hasattr(collection, "cluster_summary_dataframe") 222 and collection.cluster_summary_dataframe is not None 223 ) 224 225 def execute(self, sample_id, collection, **runtime_params): 226 """ 227 Execute gap-filling for a single sample. 228 229 Parameters 230 ---------- 231 sample_id : int 232 Sample index to process 233 collection : LCMSBaseCollection 234 The collection 235 **runtime_params 236 Runtime parameters including: 237 - missingdf : pd.DataFrame - Cluster information and missing samples (optional) 238 - cluster_dict : dict - Cluster feature dictionary (optional) 239 - expand_on_miss : bool - Whether to expand search window on miss (optional) 240 If these are not provided, returns empty dict (no gaps to fill). 241 242 Returns 243 ------- 244 dict 245 Dictionary of induced mass features (empty if no gaps to fill) 246 """ 247 # Extract gap-fill parameters from runtime_params 248 # If not present, there are no gaps to fill, so return early 249 if "missingdf" not in runtime_params: 250 return {} 251 252 missingdf = runtime_params["missingdf"] 253 cluster_dict = runtime_params["cluster_dict"] 254 expand_on_miss = runtime_params["expand_on_miss"] 255 256 # This is essentially the same logic as _search_for_targeted_mass_features_in_sample 257 # but extracted into an operation 258 259 # Get clusters missing data for this sample 260 sampledf = ( 261 missingdf[missingdf.missing_samples.apply(lambda x: sample_id in x)] 262 .reset_index(drop=True) 263 .copy() 264 ) 265 266 # Skip if no missing features for this sample 267 if len(sampledf) == 0: 268 return {} 269 270 # Get buffer time from LCMS parameters for EIC extraction 271 # This ensures we capture the full chromatographic peak beyond cluster bounds 272 buffer_rt = collection[sample_id].parameters.lc_ms.eic_buffer_time 273 274 # Windows passed to the loader. Thermo and mzML still read the full scan table. 275 time_ranges = [] 276 277 for _, row in sampledf.iterrows(): 278 rt_min = row["scan_time_aligned_min"] 279 rt_max = row["scan_time_aligned_max"] 280 281 # If expand_on_miss, also consider the allowed bounds 282 if expand_on_miss: 283 rt_min = min(rt_min, row["sta_min_allowed"]) 284 rt_max = max(rt_max, row["sta_max_allowed"]) 285 286 # Apply buffer AFTER considering expand_on_miss bounds 287 # This ensures buffer is added beyond even the expanded search window 288 time_ranges.append((max(0, rt_min - buffer_rt), rt_max + buffer_rt)) 289 290 # Merge overlapping time ranges to reduce number of separate loads 291 time_ranges = sorted(time_ranges) 292 merged_ranges = [] 293 if time_ranges: 294 current_min, current_max = time_ranges[0] 295 for rt_min, rt_max in time_ranges[1:]: 296 if rt_min <= current_max: # Overlapping or adjacent 297 current_max = max(current_max, rt_max) 298 else: 299 merged_ranges.append((current_min, current_max)) 300 current_min, current_max = rt_min, rt_max 301 merged_ranges.append((current_min, current_max)) 302 303 # time_range is forwarded to get_ms_raw; Thermo and mzML ignore it. 304 collection.load_raw_data(sample_id, 1, time_range=merged_ranges) 305 306 # Get MS1 data 307 ms1df = collection[sample_id]._ms_unprocessed[1].copy() 308 scan_df = collection[sample_id].scan_df[["scan", "scan_time_aligned"]] 309 ms1df = pd.merge(ms1df, scan_df, on="scan") 310 311 # Pre-extract all values from sampledf 312 clusters = sampledf.cluster.values 313 mz_mins = sampledf.mz_min.values 314 mz_maxs = sampledf.mz_max.values 315 st_mins = sampledf.scan_time_aligned_min.values 316 st_maxs = sampledf.scan_time_aligned_max.values 317 318 if expand_on_miss: 319 mz_mins_allowed = sampledf.mz_min_allowed.values 320 mz_maxs_allowed = sampledf.mz_max_allowed.values 321 st_mins_allowed = sampledf.sta_min_allowed.values 322 st_maxs_allowed = sampledf.sta_max_allowed.values 323 324 # Pre-filter ms1df to reduce search space 325 mz_global_min = mz_mins.min() 326 mz_global_max = mz_maxs.max() 327 st_global_min = st_mins.min() 328 st_global_max = st_maxs.max() 329 330 if expand_on_miss: 331 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 332 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 333 st_global_min = min(st_global_min, st_mins_allowed.min()) 334 st_global_max = max(st_global_max, st_maxs_allowed.max()) 335 336 ms1df_filtered = ms1df[ 337 (ms1df.mz >= mz_global_min) 338 & (ms1df.mz <= mz_global_max) 339 & (ms1df.scan_time_aligned >= st_global_min) 340 & (ms1df.scan_time_aligned <= st_global_max) 341 ].copy() 342 343 # Generate set_ids for all features 344 set_ids = [f"c{clusters[i]}_{i}_i" for i in range(len(sampledf))] 345 346 # Use batch method to process all features at once 347 if expand_on_miss: 348 # First try with normal bounds 349 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 350 ms1df_filtered, 351 mz_mins, 352 mz_maxs, 353 st_mins, 354 st_maxs, 355 set_ids, 356 obj_idx=sample_id, 357 st_aligned=True, 358 ) 359 360 # Retry failed features with expanded bounds 361 failed_indices = [ 362 i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99 363 ] 364 if failed_indices: 365 failed_ids = [set_ids[i] for i in failed_indices] 366 retry_peaks = collection[ 367 sample_id 368 ].search_for_targeted_mass_features_batch( 369 ms1df_filtered, 370 mz_mins_allowed[failed_indices], 371 mz_maxs_allowed[failed_indices], 372 st_mins_allowed[failed_indices], 373 st_maxs_allowed[failed_indices], 374 failed_ids, 375 obj_idx=sample_id, 376 st_aligned=True, 377 ) 378 peaks_dict.update(retry_peaks) 379 else: 380 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 381 ms1df_filtered, 382 mz_mins, 383 mz_maxs, 384 st_mins, 385 st_maxs, 386 set_ids, 387 obj_idx=sample_id, 388 st_aligned=True, 389 ) 390 391 # Build induced_mass_features dict and update cluster_dict 392 induced_mass_features = {} 393 for i in range(len(sampledf)): 394 peak = peaks_dict[set_ids[i]] 395 induced_mass_features[peak.id] = peak 396 cluster_dict[clusters[i]] += [set_ids[i]] 397 398 # Integrate mass features (don't fail on bad integration). 399 # m/z, intensity, and retention time are already on the feature from the 400 # targeted search. Representative selection reads mass_features_dataframe 401 # only, so a gap-filled feature is never the cluster representative and 402 # does not get a processed MS1 spectrum here. 403 collection[sample_id].induced_mass_features = induced_mass_features 404 # The duplicate scan only deletes keys from mass_features. Induced ids 405 # are strings such as c12_0_i, so that scan does not remove them. 406 collection[sample_id].integrate_mass_features( 407 drop_if_fail=False, induced_features=True, drop_duplicates=False 408 ) 409 410 return collection[sample_id].induced_mass_features 411 412 def collect_results(self, sample_id, result, collection): 413 """Collect induced mass features back into sample.""" 414 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.
The operation merges each gap's retention-time bounds, including the expanded bounds when expand_on_miss is set and eic_buffer_time, and passes that range to load_raw_data. load_raw_data forwards the range into get_ms_raw with the sample's full scan table. The Thermo and mzML readers ignore the range and read every scan in the table. Scattered gaps merge into one window that covers the run, so the range does not shrink a typical read. After the load, the search keeps MS1 points inside the gap m/z and retention-time bounds.
Parameters
- name (str): Operation name
- expand_on_miss (bool, optional): If True, a miss is searched again with the m/z window widened by consensus_mz_tol_ppm and the retention-time window widened by consensus_rt_tol minutes. Taken from gap_fill_expand_on_miss, which defaults to True.
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.
209 @property 210 def description(self): 211 """Human-readable description for progress messages.""" 212 return "gap-filling"
Human-readable description for progress messages.
218 def can_execute(self, sample, collection): 219 """Check if cluster summary exists.""" 220 return ( 221 hasattr(collection, "cluster_summary_dataframe") 222 and collection.cluster_summary_dataframe is not None 223 )
Check if cluster summary exists.
225 def execute(self, sample_id, collection, **runtime_params): 226 """ 227 Execute gap-filling for a single sample. 228 229 Parameters 230 ---------- 231 sample_id : int 232 Sample index to process 233 collection : LCMSBaseCollection 234 The collection 235 **runtime_params 236 Runtime parameters including: 237 - missingdf : pd.DataFrame - Cluster information and missing samples (optional) 238 - cluster_dict : dict - Cluster feature dictionary (optional) 239 - expand_on_miss : bool - Whether to expand search window on miss (optional) 240 If these are not provided, returns empty dict (no gaps to fill). 241 242 Returns 243 ------- 244 dict 245 Dictionary of induced mass features (empty if no gaps to fill) 246 """ 247 # Extract gap-fill parameters from runtime_params 248 # If not present, there are no gaps to fill, so return early 249 if "missingdf" not in runtime_params: 250 return {} 251 252 missingdf = runtime_params["missingdf"] 253 cluster_dict = runtime_params["cluster_dict"] 254 expand_on_miss = runtime_params["expand_on_miss"] 255 256 # This is essentially the same logic as _search_for_targeted_mass_features_in_sample 257 # but extracted into an operation 258 259 # Get clusters missing data for this sample 260 sampledf = ( 261 missingdf[missingdf.missing_samples.apply(lambda x: sample_id in x)] 262 .reset_index(drop=True) 263 .copy() 264 ) 265 266 # Skip if no missing features for this sample 267 if len(sampledf) == 0: 268 return {} 269 270 # Get buffer time from LCMS parameters for EIC extraction 271 # This ensures we capture the full chromatographic peak beyond cluster bounds 272 buffer_rt = collection[sample_id].parameters.lc_ms.eic_buffer_time 273 274 # Windows passed to the loader. Thermo and mzML still read the full scan table. 275 time_ranges = [] 276 277 for _, row in sampledf.iterrows(): 278 rt_min = row["scan_time_aligned_min"] 279 rt_max = row["scan_time_aligned_max"] 280 281 # If expand_on_miss, also consider the allowed bounds 282 if expand_on_miss: 283 rt_min = min(rt_min, row["sta_min_allowed"]) 284 rt_max = max(rt_max, row["sta_max_allowed"]) 285 286 # Apply buffer AFTER considering expand_on_miss bounds 287 # This ensures buffer is added beyond even the expanded search window 288 time_ranges.append((max(0, rt_min - buffer_rt), rt_max + buffer_rt)) 289 290 # Merge overlapping time ranges to reduce number of separate loads 291 time_ranges = sorted(time_ranges) 292 merged_ranges = [] 293 if time_ranges: 294 current_min, current_max = time_ranges[0] 295 for rt_min, rt_max in time_ranges[1:]: 296 if rt_min <= current_max: # Overlapping or adjacent 297 current_max = max(current_max, rt_max) 298 else: 299 merged_ranges.append((current_min, current_max)) 300 current_min, current_max = rt_min, rt_max 301 merged_ranges.append((current_min, current_max)) 302 303 # time_range is forwarded to get_ms_raw; Thermo and mzML ignore it. 304 collection.load_raw_data(sample_id, 1, time_range=merged_ranges) 305 306 # Get MS1 data 307 ms1df = collection[sample_id]._ms_unprocessed[1].copy() 308 scan_df = collection[sample_id].scan_df[["scan", "scan_time_aligned"]] 309 ms1df = pd.merge(ms1df, scan_df, on="scan") 310 311 # Pre-extract all values from sampledf 312 clusters = sampledf.cluster.values 313 mz_mins = sampledf.mz_min.values 314 mz_maxs = sampledf.mz_max.values 315 st_mins = sampledf.scan_time_aligned_min.values 316 st_maxs = sampledf.scan_time_aligned_max.values 317 318 if expand_on_miss: 319 mz_mins_allowed = sampledf.mz_min_allowed.values 320 mz_maxs_allowed = sampledf.mz_max_allowed.values 321 st_mins_allowed = sampledf.sta_min_allowed.values 322 st_maxs_allowed = sampledf.sta_max_allowed.values 323 324 # Pre-filter ms1df to reduce search space 325 mz_global_min = mz_mins.min() 326 mz_global_max = mz_maxs.max() 327 st_global_min = st_mins.min() 328 st_global_max = st_maxs.max() 329 330 if expand_on_miss: 331 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 332 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 333 st_global_min = min(st_global_min, st_mins_allowed.min()) 334 st_global_max = max(st_global_max, st_maxs_allowed.max()) 335 336 ms1df_filtered = ms1df[ 337 (ms1df.mz >= mz_global_min) 338 & (ms1df.mz <= mz_global_max) 339 & (ms1df.scan_time_aligned >= st_global_min) 340 & (ms1df.scan_time_aligned <= st_global_max) 341 ].copy() 342 343 # Generate set_ids for all features 344 set_ids = [f"c{clusters[i]}_{i}_i" for i in range(len(sampledf))] 345 346 # Use batch method to process all features at once 347 if expand_on_miss: 348 # First try with normal bounds 349 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 350 ms1df_filtered, 351 mz_mins, 352 mz_maxs, 353 st_mins, 354 st_maxs, 355 set_ids, 356 obj_idx=sample_id, 357 st_aligned=True, 358 ) 359 360 # Retry failed features with expanded bounds 361 failed_indices = [ 362 i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99 363 ] 364 if failed_indices: 365 failed_ids = [set_ids[i] for i in failed_indices] 366 retry_peaks = collection[ 367 sample_id 368 ].search_for_targeted_mass_features_batch( 369 ms1df_filtered, 370 mz_mins_allowed[failed_indices], 371 mz_maxs_allowed[failed_indices], 372 st_mins_allowed[failed_indices], 373 st_maxs_allowed[failed_indices], 374 failed_ids, 375 obj_idx=sample_id, 376 st_aligned=True, 377 ) 378 peaks_dict.update(retry_peaks) 379 else: 380 peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( 381 ms1df_filtered, 382 mz_mins, 383 mz_maxs, 384 st_mins, 385 st_maxs, 386 set_ids, 387 obj_idx=sample_id, 388 st_aligned=True, 389 ) 390 391 # Build induced_mass_features dict and update cluster_dict 392 induced_mass_features = {} 393 for i in range(len(sampledf)): 394 peak = peaks_dict[set_ids[i]] 395 induced_mass_features[peak.id] = peak 396 cluster_dict[clusters[i]] += [set_ids[i]] 397 398 # Integrate mass features (don't fail on bad integration). 399 # m/z, intensity, and retention time are already on the feature from the 400 # targeted search. Representative selection reads mass_features_dataframe 401 # only, so a gap-filled feature is never the cluster representative and 402 # does not get a processed MS1 spectrum here. 403 collection[sample_id].induced_mass_features = induced_mass_features 404 # The duplicate scan only deletes keys from mass_features. Induced ids 405 # are strings such as c12_0_i, so that scan does not remove them. 406 collection[sample_id].integrate_mass_features( 407 drop_if_fail=False, induced_features=True, drop_duplicates=False 408 ) 409 410 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)
412 def collect_results(self, sample_id, result, collection): 413 """Collect induced mass features back into sample.""" 414 collection[sample_id].induced_mass_features = result
Collect induced mass features back into sample.
Inherited Members
417class ReloadFeaturesOperation(SampleOperation): 418 """ 419 Reload mass features from HDF5 and optionally add MS1/MS2 spectra. 420 421 This is useful when the collection was loaded with load_light=True, 422 which stores mass features only in the collection dataframe and not 423 as LCMSMassFeature objects in individual samples. 424 425 Parameters 426 ---------- 427 name : str 428 Operation name 429 add_ms1 : bool, optional 430 If True, adds MS1 spectra to mass features. Automatically uses raw MS1 data 431 if available (e.g., from gap-filling), otherwise uses parser. Spectrum mode 432 is auto-detected. Default is False. 433 add_ms2 : bool, optional 434 If True, also loads and associates MS2 spectra. Spectrum mode is auto-detected. 435 Default is False. 436 auto_process_ms2 : bool, optional 437 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 438 ms2_scan_filter : str or None, optional 439 Filter string for MS2 scans. Default is None. 440 441 Notes 442 ----- 443 MS1 spectra association automatically uses raw MS1 data if loaded by a previous 444 operation (e.g., GapFillOperation). This is efficient when multiple operations 445 need MS1 data in the same pipeline. All spectrum modes are auto-detected from 446 the data. 447 """ 448 449 @property 450 def description(self): 451 """Human-readable description for progress messages.""" 452 return "reloading features" 453 454 def needs_raw_ms_data(self): 455 """This operation doesn't need raw data.""" 456 return False, None 457 458 def can_execute(self, sample, collection): 459 """Check if collection parser is available.""" 460 return ( 461 hasattr(collection, "collection_parser") 462 and collection.collection_parser is not None 463 ) 464 465 def execute(self, sample_id, collection, mf_ids_to_load=None, **runtime_params): 466 """ 467 Execute feature reloading for a single sample. 468 469 Parameters 470 ---------- 471 sample_id : int 472 Sample ID to reload features for 473 collection : LCMSBaseCollection 474 The collection 475 mf_ids_to_load : list of str, optional 476 List of collection-level mf_ids to load 477 **runtime_params 478 Additional runtime parameters (ignored) 479 480 Returns 481 ------- 482 dict 483 Dictionary of reloaded mass features 484 """ 485 # Get parameters 486 add_ms1 = self.params.get("add_ms1", False) 487 add_ms2 = self.params.get("add_ms2", False) 488 auto_process_ms2 = self.params.get("auto_process_ms2", True) 489 ms2_scan_filter = self.params.get("ms2_scan_filter", None) 490 491 sample = collection[sample_id] 492 sample_name = collection.samples[sample_id] 493 494 # Auto-determine if we should use parser for MS1 (check if raw data is available) 495 has_raw_ms1 = ( 496 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 497 ) 498 use_parser_for_ms1 = ( 499 not has_raw_ms1 500 ) # Use parser only if raw data not available 501 502 # Spectrum modes will be auto-detected (None = auto-detect) 503 spectrum_mode_ms1 = None 504 ms2_spectrum_mode = None 505 506 # Check if we have a collection parser 507 if ( 508 not hasattr(collection, "collection_parser") 509 or collection.collection_parser is None 510 ): 511 print( 512 f"Warning: Cannot reload mass features for {sample_name} - no collection_parser available" 513 ) 514 return {} 515 516 # Get the HDF5 file for this sample 517 hdf5_file = ( 518 collection.collection_parser.folder_location 519 / f"{sample_name}.corems/{sample_name}.hdf5" 520 ) 521 522 if not hdf5_file.exists(): 523 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 524 return {} 525 526 # Import here to avoid circular imports 527 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 528 529 # If specific mf_ids requested, extract the local mf_ids we need 530 local_mf_ids_to_load = None 531 if mf_ids_to_load is not None: 532 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 533 # No parsing needed - they come from the mf_id column in the dataframe 534 if len(mf_ids_to_load) == 0: 535 # No features to load for this sample - return empty dict 536 return {} 537 local_mf_ids_to_load = set(mf_ids_to_load) 538 539 # Reload mass features from HDF5 540 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 541 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 542 543 # If add_ms1, associate MS1 spectra with the loaded mass features 544 if add_ms1 and len(sample.mass_features) > 0: 545 # Check if raw MS1 data is already loaded (e.g., from gap-filling) 546 has_raw_ms1 = ( 547 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 548 ) 549 550 if has_raw_ms1 and not use_parser_for_ms1: 551 # Use already-loaded raw data (more efficient) 552 sample.add_associated_ms1( 553 auto_process=True, use_parser=False, spectrum_mode=spectrum_mode_ms1 554 ) 555 else: 556 # Use parser to get MS1 spectra 557 sample.add_associated_ms1( 558 auto_process=True, use_parser=True, spectrum_mode=spectrum_mode_ms1 559 ) 560 561 # If add_ms2, associate MS2 spectra with the loaded mass features 562 if add_ms2 and len(sample.mass_features) > 0: 563 # Get the IDs of loaded mass features (use what was actually loaded) 564 mf_ids_for_ms2 = list(sample.mass_features.keys()) 565 566 collection._associate_ms2_with_mass_features( 567 sample, 568 mf_ids_for_ms2, 569 auto_process=auto_process_ms2, 570 spectrum_mode=ms2_spectrum_mode, 571 scan_filter=ms2_scan_filter, 572 ) 573 574 # Return both mass_features and _ms so they can be collected in multiprocessing 575 return {"mass_features": sample.mass_features, "_ms": sample._ms} 576 577 def collect_results(self, sample_id, result, collection): 578 """ 579 Collect reloaded mass features back into sample. 580 581 This operation loads a subset of mass features (e.g., representatives) 582 into sample.mass_features for processing, while preserving the full 583 mass_features_dataframe at the collection level. Sets a lock flag to 584 prevent automatic rebuilding of the collection dataframe from individual 585 samples. Also collects loaded mass spectra. 586 587 Parameters 588 ---------- 589 sample_id : int 590 Sample ID that was processed 591 result : dict 592 Dictionary with 'mass_features' and '_ms' from execute() 593 collection : LCMSBaseCollection 594 The collection 595 """ 596 # Update sample.mass_features with loaded features 597 if isinstance(result, dict) and "mass_features" in result: 598 collection[sample_id].mass_features = result["mass_features"] 599 # Also collect the _ms dictionary (MS1 and MS2 spectra) 600 if "_ms" in result: 601 collection[sample_id]._ms.update(result["_ms"]) 602 else: 603 # Backward compatibility - if result is just mass_features dict 604 collection[sample_id].mass_features = result 605 606 # Lock the collection dataframe to prevent rebuilding from individual samples 607 # (since we've only loaded a subset, rebuilding would lose data) 608 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.
449 @property 450 def description(self): 451 """Human-readable description for progress messages.""" 452 return "reloading features"
Human-readable description for progress messages.
454 def needs_raw_ms_data(self): 455 """This operation doesn't need raw data.""" 456 return False, None
This operation doesn't need raw data.
458 def can_execute(self, sample, collection): 459 """Check if collection parser is available.""" 460 return ( 461 hasattr(collection, "collection_parser") 462 and collection.collection_parser is not None 463 )
Check if collection parser is available.
465 def execute(self, sample_id, collection, mf_ids_to_load=None, **runtime_params): 466 """ 467 Execute feature reloading for a single sample. 468 469 Parameters 470 ---------- 471 sample_id : int 472 Sample ID to reload features for 473 collection : LCMSBaseCollection 474 The collection 475 mf_ids_to_load : list of str, optional 476 List of collection-level mf_ids to load 477 **runtime_params 478 Additional runtime parameters (ignored) 479 480 Returns 481 ------- 482 dict 483 Dictionary of reloaded mass features 484 """ 485 # Get parameters 486 add_ms1 = self.params.get("add_ms1", False) 487 add_ms2 = self.params.get("add_ms2", False) 488 auto_process_ms2 = self.params.get("auto_process_ms2", True) 489 ms2_scan_filter = self.params.get("ms2_scan_filter", None) 490 491 sample = collection[sample_id] 492 sample_name = collection.samples[sample_id] 493 494 # Auto-determine if we should use parser for MS1 (check if raw data is available) 495 has_raw_ms1 = ( 496 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 497 ) 498 use_parser_for_ms1 = ( 499 not has_raw_ms1 500 ) # Use parser only if raw data not available 501 502 # Spectrum modes will be auto-detected (None = auto-detect) 503 spectrum_mode_ms1 = None 504 ms2_spectrum_mode = None 505 506 # Check if we have a collection parser 507 if ( 508 not hasattr(collection, "collection_parser") 509 or collection.collection_parser is None 510 ): 511 print( 512 f"Warning: Cannot reload mass features for {sample_name} - no collection_parser available" 513 ) 514 return {} 515 516 # Get the HDF5 file for this sample 517 hdf5_file = ( 518 collection.collection_parser.folder_location 519 / f"{sample_name}.corems/{sample_name}.hdf5" 520 ) 521 522 if not hdf5_file.exists(): 523 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 524 return {} 525 526 # Import here to avoid circular imports 527 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 528 529 # If specific mf_ids requested, extract the local mf_ids we need 530 local_mf_ids_to_load = None 531 if mf_ids_to_load is not None: 532 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 533 # No parsing needed - they come from the mf_id column in the dataframe 534 if len(mf_ids_to_load) == 0: 535 # No features to load for this sample - return empty dict 536 return {} 537 local_mf_ids_to_load = set(mf_ids_to_load) 538 539 # Reload mass features from HDF5 540 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 541 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 542 543 # If add_ms1, associate MS1 spectra with the loaded mass features 544 if add_ms1 and len(sample.mass_features) > 0: 545 # Check if raw MS1 data is already loaded (e.g., from gap-filling) 546 has_raw_ms1 = ( 547 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty 548 ) 549 550 if has_raw_ms1 and not use_parser_for_ms1: 551 # Use already-loaded raw data (more efficient) 552 sample.add_associated_ms1( 553 auto_process=True, use_parser=False, spectrum_mode=spectrum_mode_ms1 554 ) 555 else: 556 # Use parser to get MS1 spectra 557 sample.add_associated_ms1( 558 auto_process=True, use_parser=True, spectrum_mode=spectrum_mode_ms1 559 ) 560 561 # If add_ms2, associate MS2 spectra with the loaded mass features 562 if add_ms2 and len(sample.mass_features) > 0: 563 # Get the IDs of loaded mass features (use what was actually loaded) 564 mf_ids_for_ms2 = list(sample.mass_features.keys()) 565 566 collection._associate_ms2_with_mass_features( 567 sample, 568 mf_ids_for_ms2, 569 auto_process=auto_process_ms2, 570 spectrum_mode=ms2_spectrum_mode, 571 scan_filter=ms2_scan_filter, 572 ) 573 574 # Return both mass_features and _ms so they can be collected in multiprocessing 575 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
577 def collect_results(self, sample_id, result, collection): 578 """ 579 Collect reloaded mass features back into sample. 580 581 This operation loads a subset of mass features (e.g., representatives) 582 into sample.mass_features for processing, while preserving the full 583 mass_features_dataframe at the collection level. Sets a lock flag to 584 prevent automatic rebuilding of the collection dataframe from individual 585 samples. Also collects loaded mass spectra. 586 587 Parameters 588 ---------- 589 sample_id : int 590 Sample ID that was processed 591 result : dict 592 Dictionary with 'mass_features' and '_ms' from execute() 593 collection : LCMSBaseCollection 594 The collection 595 """ 596 # Update sample.mass_features with loaded features 597 if isinstance(result, dict) and "mass_features" in result: 598 collection[sample_id].mass_features = result["mass_features"] 599 # Also collect the _ms dictionary (MS1 and MS2 spectra) 600 if "_ms" in result: 601 collection[sample_id]._ms.update(result["_ms"]) 602 else: 603 # Backward compatibility - if result is just mass_features dict 604 collection[sample_id].mass_features = result 605 606 # Lock the collection dataframe to prevent rebuilding from individual samples 607 # (since we've only loaded a subset, rebuilding would lose data) 608 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
611class MolecularFormulaSearchOperation(SampleOperation): 612 """ 613 Perform molecular formula search on mass features using associated MS1 spectra. 614 615 This operation runs molecular formula search on all mass features in a sample 616 that have associated MS1 spectra. Requires MS1 spectra to be loaded and 617 processed before execution. 618 619 Parameters 620 ---------- 621 name : str 622 Operation name (for logging) 623 **kwargs 624 Additional parameters passed to parent class 625 626 Examples 627 -------- 628 >>> op = MolecularFormulaSearchOperation('mf_search') 629 >>> # Use in pipeline 630 >>> results = collection.process_samples_pipeline([op]) 631 632 Notes 633 ----- 634 This operation requires that MS1 spectra have been associated with mass 635 features (e.g., via ReloadFeaturesOperation with add_ms1=True). The 636 molecular formula search uses parameters from the collection's 637 parameters.mass_spectrum["ms1"].molecular_search settings. 638 """ 639 640 @property 641 def description(self): 642 """Human-readable description for progress messages.""" 643 return "molecular formula search" 644 645 def __init__(self, name="molecular_formula_search", **kwargs): 646 super().__init__(name, **kwargs) 647 648 def needs_raw_ms_data(self): 649 """ 650 This operation doesn't need raw data - it works on processed MS1 spectra 651 that are already associated with mass features. 652 653 Returns 654 ------- 655 tuple 656 (False, None) - no raw data needed 657 """ 658 return False, None 659 660 def can_execute(self, sample, collection, **runtime_params): 661 """ 662 Check if molecular formula search can be executed. 663 664 Requires that the sample has mass features with associated MS1 spectra. 665 666 Parameters 667 ---------- 668 sample : LCMSObject 669 The sample object 670 collection : LCMSCollection 671 The collection containing the sample 672 **runtime_params 673 Runtime parameters (not used) 674 675 Returns 676 ------- 677 bool 678 True if sample has mass features with MS1 spectra 679 """ 680 # Check if sample has mass features 681 if not hasattr(sample, "mass_features") or not sample.mass_features: 682 return False 683 684 # Check if at least some mass features have MS1 spectra 685 has_ms1 = any( 686 hasattr(mf, "mass_spectrum") and mf.mass_spectrum is not None 687 for mf in sample.mass_features.values() 688 ) 689 690 return has_ms1 691 692 def execute(self, sample_id, collection, **runtime_params): 693 """ 694 Execute molecular formula search on a sample. 695 696 Creates a SearchMolecularFormulasLC object and runs mass feature search, 697 which annotates mass features with molecular formula assignments. 698 699 Parameters 700 ---------- 701 sample_id : str 702 Sample identifier 703 collection : LCMSCollection 704 The collection containing the sample 705 **runtime_params 706 Runtime parameters (not used) 707 708 Returns 709 ------- 710 int 711 Number of mass features that were searched 712 """ 713 from corems.molecular_id.search.molecularFormulaSearch import ( 714 SearchMolecularFormulasLC, 715 ) 716 import time 717 import sqlalchemy.exc 718 import sqlite3 719 720 sample = collection[sample_id] 721 722 # Verify that mass features exist 723 if not hasattr(sample, "mass_features") or not sample.mass_features: 724 return 0 # No mass features to search 725 726 # Verify that mass features have MS1 spectra associated 727 if not hasattr(sample, "_ms") or not sample._ms: 728 raise RuntimeError( 729 f"Sample {sample_id} does not have MS1 spectra loaded in _ms dictionary. " 730 "Molecular formula search requires MS1 spectra to be associated with mass features. " 731 "Ensure add_ms1=True when reloading features." 732 ) 733 734 from corems.mass_spectra.calc.feature_grouping import ( 735 annotation_meta_for_sample_mf, 736 constrain_annotation_active, 737 should_skip_isotope_for_annotation, 738 ) 739 740 settings = collection.parameters.lcms_collection 741 labels = getattr(collection, "feature_group_dataframe", None) 742 constrain = constrain_annotation_active( 743 labels, bool(getattr(settings, "feature_group_constrain_annotation", True)) 744 ) 745 mf_df = getattr(collection, "mass_features_dataframe", None) 746 747 # Prepare data for bulk molecular formula search 748 # Group mass features by their apex scan 749 scan_to_mf = {} 750 for mf_id, mf in sample.mass_features.items(): 751 if constrain: 752 meta = annotation_meta_for_sample_mf(mf_df, labels, sample_id, mf_id) 753 if meta is not None and should_skip_isotope_for_annotation( 754 meta.get("ion_role"), True 755 ): 756 continue 757 apex_scan = mf.apex_scan 758 if apex_scan not in scan_to_mf: 759 scan_to_mf[apex_scan] = [] 760 scan_to_mf[apex_scan].append(mf) 761 762 # Build lists of mass spectra and corresponding peaks 763 mass_spectrum_list = [] 764 ms_peaks_list = [] 765 766 for scan_num, mf_list in scan_to_mf.items(): 767 # Get the mass spectrum for this scan 768 if scan_num not in sample._ms: 769 continue # Skip if spectrum not loaded 770 771 mass_spectrum = sample._ms[scan_num] 772 773 # Verify spectrum is processed (has peaks) 774 if not hasattr(mass_spectrum, "_mspeaks") or not mass_spectrum._mspeaks: 775 continue # Skip unprocessed spectra 776 777 # Get the MS1 peaks for each mass feature at this scan 778 peaks_for_scan = [] 779 for mf in mf_list: 780 try: 781 # Use the ms1_peak property which finds the closest peak 782 ms1_peak = mf.ms1_peak 783 peaks_for_scan.append(ms1_peak) 784 except (AttributeError, IndexError): 785 # Skip if ms1_peak can't be determined 786 continue 787 788 if peaks_for_scan: 789 mass_spectrum_list.append(mass_spectrum) 790 ms_peaks_list.append(peaks_for_scan) 791 792 # Run molecular formula search if we have data, with retry logic for database locks 793 if mass_spectrum_list and ms_peaks_list: 794 max_retries = 10 795 retry_delay = 2 # seconds 796 797 for attempt in range(max_retries): 798 try: 799 mol_search = SearchMolecularFormulasLC(sample) 800 mol_search.bulk_run_molecular_formula_search( 801 mass_spectrum_list, ms_peaks_list 802 ) 803 break # Success, exit retry loop 804 except (sqlalchemy.exc.OperationalError, sqlite3.OperationalError) as e: 805 if attempt < max_retries - 1: 806 # Database is locked, retry after delay 807 print( 808 f"Sample {sample_id}: Database locked during molecular formula search, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})..." 809 ) 810 time.sleep(retry_delay) 811 else: 812 # Max retries exceeded, re-raise the exception 813 raise RuntimeError( 814 f"Sample {sample_id}: Molecular formula search failed after {max_retries} attempts due to database lock. " 815 "Try reducing parallel cores or increasing database timeout." 816 ) from e 817 818 # Return count of features searched 819 return len(sample.mass_features) 820 821 def collect_results(self, sample_id, result, collection): 822 """ 823 Collect results (no-op as search modifies mass features in place). 824 825 The molecular formula search modifies mass features in place by adding 826 molecular formula assignments, so no explicit result collection is needed. 827 828 Parameters 829 ---------- 830 sample_id : str 831 Sample identifier 832 result : int 833 Number of features searched 834 collection : LCMSCollection 835 The collection containing the sample 836 """ 837 # Search modifies mass features in place, nothing to collect 838 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.
640 @property 641 def description(self): 642 """Human-readable description for progress messages.""" 643 return "molecular formula search"
Human-readable description for progress messages.
648 def needs_raw_ms_data(self): 649 """ 650 This operation doesn't need raw data - it works on processed MS1 spectra 651 that are already associated with mass features. 652 653 Returns 654 ------- 655 tuple 656 (False, None) - no raw data needed 657 """ 658 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
660 def can_execute(self, sample, collection, **runtime_params): 661 """ 662 Check if molecular formula search can be executed. 663 664 Requires that the sample has mass features with associated MS1 spectra. 665 666 Parameters 667 ---------- 668 sample : LCMSObject 669 The sample object 670 collection : LCMSCollection 671 The collection containing the sample 672 **runtime_params 673 Runtime parameters (not used) 674 675 Returns 676 ------- 677 bool 678 True if sample has mass features with MS1 spectra 679 """ 680 # Check if sample has mass features 681 if not hasattr(sample, "mass_features") or not sample.mass_features: 682 return False 683 684 # Check if at least some mass features have MS1 spectra 685 has_ms1 = any( 686 hasattr(mf, "mass_spectrum") and mf.mass_spectrum is not None 687 for mf in sample.mass_features.values() 688 ) 689 690 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
692 def execute(self, sample_id, collection, **runtime_params): 693 """ 694 Execute molecular formula search on a sample. 695 696 Creates a SearchMolecularFormulasLC object and runs mass feature search, 697 which annotates mass features with molecular formula assignments. 698 699 Parameters 700 ---------- 701 sample_id : str 702 Sample identifier 703 collection : LCMSCollection 704 The collection containing the sample 705 **runtime_params 706 Runtime parameters (not used) 707 708 Returns 709 ------- 710 int 711 Number of mass features that were searched 712 """ 713 from corems.molecular_id.search.molecularFormulaSearch import ( 714 SearchMolecularFormulasLC, 715 ) 716 import time 717 import sqlalchemy.exc 718 import sqlite3 719 720 sample = collection[sample_id] 721 722 # Verify that mass features exist 723 if not hasattr(sample, "mass_features") or not sample.mass_features: 724 return 0 # No mass features to search 725 726 # Verify that mass features have MS1 spectra associated 727 if not hasattr(sample, "_ms") or not sample._ms: 728 raise RuntimeError( 729 f"Sample {sample_id} does not have MS1 spectra loaded in _ms dictionary. " 730 "Molecular formula search requires MS1 spectra to be associated with mass features. " 731 "Ensure add_ms1=True when reloading features." 732 ) 733 734 from corems.mass_spectra.calc.feature_grouping import ( 735 annotation_meta_for_sample_mf, 736 constrain_annotation_active, 737 should_skip_isotope_for_annotation, 738 ) 739 740 settings = collection.parameters.lcms_collection 741 labels = getattr(collection, "feature_group_dataframe", None) 742 constrain = constrain_annotation_active( 743 labels, bool(getattr(settings, "feature_group_constrain_annotation", True)) 744 ) 745 mf_df = getattr(collection, "mass_features_dataframe", None) 746 747 # Prepare data for bulk molecular formula search 748 # Group mass features by their apex scan 749 scan_to_mf = {} 750 for mf_id, mf in sample.mass_features.items(): 751 if constrain: 752 meta = annotation_meta_for_sample_mf(mf_df, labels, sample_id, mf_id) 753 if meta is not None and should_skip_isotope_for_annotation( 754 meta.get("ion_role"), True 755 ): 756 continue 757 apex_scan = mf.apex_scan 758 if apex_scan not in scan_to_mf: 759 scan_to_mf[apex_scan] = [] 760 scan_to_mf[apex_scan].append(mf) 761 762 # Build lists of mass spectra and corresponding peaks 763 mass_spectrum_list = [] 764 ms_peaks_list = [] 765 766 for scan_num, mf_list in scan_to_mf.items(): 767 # Get the mass spectrum for this scan 768 if scan_num not in sample._ms: 769 continue # Skip if spectrum not loaded 770 771 mass_spectrum = sample._ms[scan_num] 772 773 # Verify spectrum is processed (has peaks) 774 if not hasattr(mass_spectrum, "_mspeaks") or not mass_spectrum._mspeaks: 775 continue # Skip unprocessed spectra 776 777 # Get the MS1 peaks for each mass feature at this scan 778 peaks_for_scan = [] 779 for mf in mf_list: 780 try: 781 # Use the ms1_peak property which finds the closest peak 782 ms1_peak = mf.ms1_peak 783 peaks_for_scan.append(ms1_peak) 784 except (AttributeError, IndexError): 785 # Skip if ms1_peak can't be determined 786 continue 787 788 if peaks_for_scan: 789 mass_spectrum_list.append(mass_spectrum) 790 ms_peaks_list.append(peaks_for_scan) 791 792 # Run molecular formula search if we have data, with retry logic for database locks 793 if mass_spectrum_list and ms_peaks_list: 794 max_retries = 10 795 retry_delay = 2 # seconds 796 797 for attempt in range(max_retries): 798 try: 799 mol_search = SearchMolecularFormulasLC(sample) 800 mol_search.bulk_run_molecular_formula_search( 801 mass_spectrum_list, ms_peaks_list 802 ) 803 break # Success, exit retry loop 804 except (sqlalchemy.exc.OperationalError, sqlite3.OperationalError) as e: 805 if attempt < max_retries - 1: 806 # Database is locked, retry after delay 807 print( 808 f"Sample {sample_id}: Database locked during molecular formula search, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})..." 809 ) 810 time.sleep(retry_delay) 811 else: 812 # Max retries exceeded, re-raise the exception 813 raise RuntimeError( 814 f"Sample {sample_id}: Molecular formula search failed after {max_retries} attempts due to database lock. " 815 "Try reducing parallel cores or increasing database timeout." 816 ) from e 817 818 # Return count of features searched 819 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
821 def collect_results(self, sample_id, result, collection): 822 """ 823 Collect results (no-op as search modifies mass features in place). 824 825 The molecular formula search modifies mass features in place by adding 826 molecular formula assignments, so no explicit result collection is needed. 827 828 Parameters 829 ---------- 830 sample_id : str 831 Sample identifier 832 result : int 833 Number of features searched 834 collection : LCMSCollection 835 The collection containing the sample 836 """ 837 # Search modifies mass features in place, nothing to collect 838 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
841class MS2SpectralSearchOperation(SampleOperation): 842 """ 843 Perform MS2 spectral search using entropy-based matching. 844 845 This operation performs spectral library search on MS2 spectra associated 846 with mass features using FlashEntropy for fast similarity scoring. Requires 847 MS2 spectra to be loaded and processed before execution. 848 849 Parameters 850 ---------- 851 name : str 852 Operation name (for logging) 853 ms2_scan_filter : str or None, optional 854 Filter string for MS2 scans (e.g., 'hcd'). If None, uses all MS2 scans. 855 Default is None. 856 peak_sep_da : float, optional 857 Peak separation in Daltons for spectral matching. Default is 0.01. 858 **kwargs 859 Additional parameters passed to parent class 860 861 Examples 862 -------- 863 >>> op = MS2SpectralSearchOperation('ms2_search', ms2_scan_filter='hcd') 864 >>> # Use in pipeline - requires fe_lib in runtime_params 865 >>> results = collection.process_samples_pipeline([op]) 866 867 Notes 868 ----- 869 This operation requires: 870 - MS2 spectra to be associated with mass features 871 - FlashEntropy library (fe_lib) to be provided in runtime_params 872 - MS2 spectra must be processed (centroided) 873 874 The spectral search modifies mass features in place by adding spectral 875 match scores and metadata. 876 """ 877 878 @property 879 def description(self): 880 """Human-readable description for progress messages.""" 881 return "MS2 spectral search" 882 883 def __init__(self, name="ms2_spectral_search", ms2_scan_filter=None, **kwargs): 884 super().__init__(name, **kwargs) 885 self.params["ms2_scan_filter"] = ms2_scan_filter 886 887 def needs_raw_ms_data(self): 888 """ 889 This operation doesn't need raw data - it works on processed MS2 spectra 890 that are already associated with mass features. 891 892 Returns 893 ------- 894 tuple 895 (False, None) - no raw data needed 896 """ 897 return False, None 898 899 def can_execute(self, sample, collection, **runtime_params): 900 """ 901 Check if MS2 spectral search can be executed. 902 903 Requires that the sample has mass features with MS2 spectra associated. 904 905 Parameters 906 ---------- 907 sample : LCMSObject 908 The sample object 909 collection : LCMSCollection 910 The collection containing the sample 911 **runtime_params 912 Runtime parameters (not used) 913 914 Returns 915 ------- 916 bool 917 True if sample has mass features with MS2 spectra 918 """ 919 # Check if sample has mass features 920 if not hasattr(sample, "mass_features") or not sample.mass_features: 921 return False 922 923 # Check if any mass features have MS2 spectra associated 924 has_ms2 = any( 925 hasattr(mf, "ms2_mass_spectra") and mf.ms2_mass_spectra 926 for mf in sample.mass_features.values() 927 ) 928 929 return has_ms2 930 931 def execute( 932 self, 933 sample_id, 934 collection, 935 fe_lib=None, 936 molecular_metadata=None, 937 **runtime_params, 938 ): 939 """ 940 Execute MS2 spectral search on a sample. 941 942 Performs entropy-based spectral library search on all MS2 spectra 943 in the sample that match the scan filter criteria. 944 945 Parameters 946 ---------- 947 sample_id : str 948 Sample identifier 949 collection : LCMSCollection 950 The collection containing the sample 951 fe_lib : FlashEntropy library 952 Pre-computed FlashEntropy library for spectral matching 953 molecular_metadata : pd.DataFrame, optional 954 Metadata for molecules in the spectral library 955 **runtime_params 956 Runtime parameters (not used) 957 958 Returns 959 ------- 960 int 961 Number of MS2 spectra searched 962 """ 963 sample = collection[sample_id] 964 965 # Get parameters 966 ms2_scan_filter = self.params.get("ms2_scan_filter") 967 968 # Verify that we have a spectral library 969 if fe_lib is None: 970 raise ValueError( 971 f"Sample {sample_id}: MS2 spectral search requires fe_lib (FlashEntropy library) " 972 "to be provided in runtime parameters. Create the library at the collection level " 973 "and pass it to the pipeline." 974 ) 975 976 # Extract peak_sep_da from FlashEntropy library configuration 977 # peak_sep_da should be 2 * max_ms2_tolerance_in_da to match the min_ms2_difference_in_da 978 # used when creating the library 979 tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da 980 if tolerance_da is None: 981 raise ValueError( 982 f"Sample {sample_id}: Could not extract max_ms2_tolerance_in_da from FlashEntropy library. " 983 "Ensure the library was created with this parameter specified." 984 ) 985 peak_sep_da = 2 * tolerance_da 986 987 # Verify that sample has _ms dictionary 988 if not hasattr(sample, "_ms") or not sample._ms: 989 return 0 # No MS2 spectra to search 990 991 # Get MS2 scan numbers based on filter 992 if ms2_scan_filter is not None: 993 # Filter by scan text 994 ms2_scan_df = sample.scan_df[ 995 sample.scan_df.scan_text.str.contains(ms2_scan_filter) 996 & (sample.scan_df.ms_level == 2) 997 ] 998 else: 999 # All MS2 scans 1000 ms2_scan_df = sample.scan_df[sample.scan_df.ms_level == 2] 1001 1002 # Get scans that are actually loaded in _ms 1003 ms2_scans_to_search = [ 1004 scan for scan in ms2_scan_df.scan.tolist() if scan in sample._ms.keys() 1005 ] 1006 1007 if not ms2_scans_to_search: 1008 return 0 # No MS2 spectra to search 1009 1010 # Perform spectral search using the sample's fe_search method 1011 sample.fe_search( 1012 scan_list=ms2_scans_to_search, fe_lib=fe_lib, peak_sep_da=peak_sep_da 1013 ) 1014 1015 from corems.mass_spectra.calc.feature_grouping import ( 1016 annotation_meta_for_sample_mf, 1017 constrain_annotation_active, 1018 should_skip_isotope_for_annotation, 1019 allowed_ion_types_for_row, 1020 subset_hits_by_allowed_ion_types, 1021 ) 1022 1023 settings = collection.parameters.lcms_collection 1024 labels = getattr(collection, "feature_group_dataframe", None) 1025 constrain = constrain_annotation_active( 1026 labels, bool(getattr(settings, "feature_group_constrain_annotation", True)) 1027 ) 1028 if constrain: 1029 mf_df = getattr(collection, "mass_features_dataframe", None) 1030 for mf_id, mf in list(sample.mass_features.items()): 1031 meta = annotation_meta_for_sample_mf(mf_df, labels, sample_id, mf_id) 1032 if meta is None: 1033 continue 1034 if should_skip_isotope_for_annotation(meta.get("ion_role"), True): 1035 mf.ms2_similarity_results = [] 1036 continue 1037 allowed = allowed_ion_types_for_row( 1038 meta.get("ion_type"), meta.get("possible_ion_types") 1039 ) 1040 if allowed is None: 1041 continue 1042 results = getattr(mf, "ms2_similarity_results", None) or [] 1043 filtered = [] 1044 for res in results: 1045 kept = subset_hits_by_allowed_ion_types(res, allowed) 1046 if kept is not None: 1047 filtered.append(kept) 1048 mf.ms2_similarity_results = filtered 1049 1050 # Return the spectral search results for collection 1051 # (needed for multiprocessing - results populated in worker need to be returned) 1052 return sample.spectral_search_results 1053 1054 def collect_results(self, sample_id, result, collection): 1055 """ 1056 Collect spectral search results back into the sample. 1057 1058 In multiprocessing, the worker's modifications don't persist to the 1059 main process, so we need to explicitly collect and reassign the results. 1060 This also re-associates the results with mass features. 1061 1062 Parameters 1063 ---------- 1064 sample_id : str 1065 Sample identifier 1066 result : dict 1067 Dictionary of spectral search results from execute() 1068 collection : LCMSCollection 1069 The collection containing the sample 1070 """ 1071 # Assign the spectral search results back to the sample 1072 if result: 1073 collection[sample_id].spectral_search_results.update(result) 1074 1075 # Re-associate results with mass features (same logic as fe_search) 1076 sample = collection[sample_id] 1077 if len(sample.mass_features) > 0: 1078 for mass_feature_id, mass_feature in sample.mass_features.items(): 1079 scan_ids = mass_feature.ms2_scan_numbers 1080 for ms2_scan_id in scan_ids: 1081 precursor_mz = mass_feature.mz 1082 try: 1083 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1084 except KeyError: 1085 pass 1086 else: 1087 sample.mass_features[ 1088 mass_feature_id 1089 ].ms2_similarity_results.append( 1090 sample.spectral_search_results[ms2_scan_id][ 1091 precursor_mz 1092 ] 1093 )
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.
878 @property 879 def description(self): 880 """Human-readable description for progress messages.""" 881 return "MS2 spectral search"
Human-readable description for progress messages.
887 def needs_raw_ms_data(self): 888 """ 889 This operation doesn't need raw data - it works on processed MS2 spectra 890 that are already associated with mass features. 891 892 Returns 893 ------- 894 tuple 895 (False, None) - no raw data needed 896 """ 897 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
899 def can_execute(self, sample, collection, **runtime_params): 900 """ 901 Check if MS2 spectral search can be executed. 902 903 Requires that the sample has mass features with MS2 spectra associated. 904 905 Parameters 906 ---------- 907 sample : LCMSObject 908 The sample object 909 collection : LCMSCollection 910 The collection containing the sample 911 **runtime_params 912 Runtime parameters (not used) 913 914 Returns 915 ------- 916 bool 917 True if sample has mass features with MS2 spectra 918 """ 919 # Check if sample has mass features 920 if not hasattr(sample, "mass_features") or not sample.mass_features: 921 return False 922 923 # Check if any mass features have MS2 spectra associated 924 has_ms2 = any( 925 hasattr(mf, "ms2_mass_spectra") and mf.ms2_mass_spectra 926 for mf in sample.mass_features.values() 927 ) 928 929 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
931 def execute( 932 self, 933 sample_id, 934 collection, 935 fe_lib=None, 936 molecular_metadata=None, 937 **runtime_params, 938 ): 939 """ 940 Execute MS2 spectral search on a sample. 941 942 Performs entropy-based spectral library search on all MS2 spectra 943 in the sample that match the scan filter criteria. 944 945 Parameters 946 ---------- 947 sample_id : str 948 Sample identifier 949 collection : LCMSCollection 950 The collection containing the sample 951 fe_lib : FlashEntropy library 952 Pre-computed FlashEntropy library for spectral matching 953 molecular_metadata : pd.DataFrame, optional 954 Metadata for molecules in the spectral library 955 **runtime_params 956 Runtime parameters (not used) 957 958 Returns 959 ------- 960 int 961 Number of MS2 spectra searched 962 """ 963 sample = collection[sample_id] 964 965 # Get parameters 966 ms2_scan_filter = self.params.get("ms2_scan_filter") 967 968 # Verify that we have a spectral library 969 if fe_lib is None: 970 raise ValueError( 971 f"Sample {sample_id}: MS2 spectral search requires fe_lib (FlashEntropy library) " 972 "to be provided in runtime parameters. Create the library at the collection level " 973 "and pass it to the pipeline." 974 ) 975 976 # Extract peak_sep_da from FlashEntropy library configuration 977 # peak_sep_da should be 2 * max_ms2_tolerance_in_da to match the min_ms2_difference_in_da 978 # used when creating the library 979 tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da 980 if tolerance_da is None: 981 raise ValueError( 982 f"Sample {sample_id}: Could not extract max_ms2_tolerance_in_da from FlashEntropy library. " 983 "Ensure the library was created with this parameter specified." 984 ) 985 peak_sep_da = 2 * tolerance_da 986 987 # Verify that sample has _ms dictionary 988 if not hasattr(sample, "_ms") or not sample._ms: 989 return 0 # No MS2 spectra to search 990 991 # Get MS2 scan numbers based on filter 992 if ms2_scan_filter is not None: 993 # Filter by scan text 994 ms2_scan_df = sample.scan_df[ 995 sample.scan_df.scan_text.str.contains(ms2_scan_filter) 996 & (sample.scan_df.ms_level == 2) 997 ] 998 else: 999 # All MS2 scans 1000 ms2_scan_df = sample.scan_df[sample.scan_df.ms_level == 2] 1001 1002 # Get scans that are actually loaded in _ms 1003 ms2_scans_to_search = [ 1004 scan for scan in ms2_scan_df.scan.tolist() if scan in sample._ms.keys() 1005 ] 1006 1007 if not ms2_scans_to_search: 1008 return 0 # No MS2 spectra to search 1009 1010 # Perform spectral search using the sample's fe_search method 1011 sample.fe_search( 1012 scan_list=ms2_scans_to_search, fe_lib=fe_lib, peak_sep_da=peak_sep_da 1013 ) 1014 1015 from corems.mass_spectra.calc.feature_grouping import ( 1016 annotation_meta_for_sample_mf, 1017 constrain_annotation_active, 1018 should_skip_isotope_for_annotation, 1019 allowed_ion_types_for_row, 1020 subset_hits_by_allowed_ion_types, 1021 ) 1022 1023 settings = collection.parameters.lcms_collection 1024 labels = getattr(collection, "feature_group_dataframe", None) 1025 constrain = constrain_annotation_active( 1026 labels, bool(getattr(settings, "feature_group_constrain_annotation", True)) 1027 ) 1028 if constrain: 1029 mf_df = getattr(collection, "mass_features_dataframe", None) 1030 for mf_id, mf in list(sample.mass_features.items()): 1031 meta = annotation_meta_for_sample_mf(mf_df, labels, sample_id, mf_id) 1032 if meta is None: 1033 continue 1034 if should_skip_isotope_for_annotation(meta.get("ion_role"), True): 1035 mf.ms2_similarity_results = [] 1036 continue 1037 allowed = allowed_ion_types_for_row( 1038 meta.get("ion_type"), meta.get("possible_ion_types") 1039 ) 1040 if allowed is None: 1041 continue 1042 results = getattr(mf, "ms2_similarity_results", None) or [] 1043 filtered = [] 1044 for res in results: 1045 kept = subset_hits_by_allowed_ion_types(res, allowed) 1046 if kept is not None: 1047 filtered.append(kept) 1048 mf.ms2_similarity_results = filtered 1049 1050 # Return the spectral search results for collection 1051 # (needed for multiprocessing - results populated in worker need to be returned) 1052 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
1054 def collect_results(self, sample_id, result, collection): 1055 """ 1056 Collect spectral search results back into the sample. 1057 1058 In multiprocessing, the worker's modifications don't persist to the 1059 main process, so we need to explicitly collect and reassign the results. 1060 This also re-associates the results with mass features. 1061 1062 Parameters 1063 ---------- 1064 sample_id : str 1065 Sample identifier 1066 result : dict 1067 Dictionary of spectral search results from execute() 1068 collection : LCMSCollection 1069 The collection containing the sample 1070 """ 1071 # Assign the spectral search results back to the sample 1072 if result: 1073 collection[sample_id].spectral_search_results.update(result) 1074 1075 # Re-associate results with mass features (same logic as fe_search) 1076 sample = collection[sample_id] 1077 if len(sample.mass_features) > 0: 1078 for mass_feature_id, mass_feature in sample.mass_features.items(): 1079 scan_ids = mass_feature.ms2_scan_numbers 1080 for ms2_scan_id in scan_ids: 1081 precursor_mz = mass_feature.mz 1082 try: 1083 sample.spectral_search_results[ms2_scan_id][precursor_mz] 1084 except KeyError: 1085 pass 1086 else: 1087 sample.mass_features[ 1088 mass_feature_id 1089 ].ms2_similarity_results.append( 1090 sample.spectral_search_results[ms2_scan_id][ 1091 precursor_mz 1092 ] 1093 )
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
1096class LoadEICsOperation(SampleOperation): 1097 """ 1098 Load extracted ion chromatograms (EICs) from HDF5 for regular mass features. 1099 1100 Loads EICs for regular mass features that belong to consensus clusters from HDF5. 1101 Induced (gap-filled) features already have EICs from integrate_mass_features, 1102 so no additional loading is needed for them. 1103 1104 This operation enables downstream visualization and analysis of chromatographic 1105 peaks across all samples in a cluster. 1106 1107 Notes 1108 ----- 1109 Requires that mass features have been loaded and cluster_index assigned. 1110 Regular mass feature EICs must have been previously saved to HDF5 with export_eics=True. 1111 Induced mass features already have EICs populated during gap-filling. 1112 """ 1113 1114 @property 1115 def description(self): 1116 """Human-readable description for progress messages.""" 1117 return "loading EICs" 1118 1119 def needs_raw_ms_data(self): 1120 """This operation doesn't need raw data - induced features already have EICs.""" 1121 return False, None 1122 1123 def can_execute(self, sample, collection): 1124 """ 1125 Check if EIC loading can be executed. 1126 1127 This operation can always execute if the sample exists - the actual work 1128 is determined by cluster_mz_dict in runtime_params. If cluster_mz_dict is 1129 empty or None, execute() will simply return 0 (no EICs loaded). 1130 1131 Returns 1132 ------- 1133 bool 1134 True (always executable - runtime_params control actual work) 1135 """ 1136 return True 1137 1138 def execute(self, sample_id, collection, cluster_mz_dict=None, **runtime_params): 1139 """ 1140 Load EICs from HDF5 for a single sample. 1141 1142 Loads EICs for regular mass features that belong to consensus clusters. 1143 Induced (gap-filled) mass features already have EICs from integrate_mass_features, 1144 so no additional loading is needed for them. 1145 1146 The cluster_mz_dict parameter (passed from collection level) maps sample_id 1147 to a list of m/z values that belong to clusters for that sample. 1148 1149 Parameters 1150 ---------- 1151 sample_id : int 1152 Sample index to process 1153 collection : LCMSBaseCollection 1154 The collection 1155 cluster_mz_dict : dict, optional 1156 Dictionary mapping sample_id to list of m/z values in clusters for that sample. 1157 If None, will not load any EICs. Default is None. 1158 **runtime_params 1159 Additional runtime parameters (ignored) 1160 1161 Returns 1162 ------- 1163 dict 1164 Dictionary of loaded EIC_Data objects, keyed by m/z value 1165 """ 1166 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 1167 1168 sample = collection[sample_id] 1169 1170 # If no cluster info provided or no m/z values for this sample, return early 1171 if cluster_mz_dict is None or sample_id not in cluster_mz_dict: 1172 return {} 1173 1174 # Get m/z values for this sample that belong to clusters 1175 sample_cluster_mz = set(cluster_mz_dict[sample_id]) 1176 if len(sample_cluster_mz) == 0: 1177 return {} 1178 1179 # Load EICs for each of the sample_cluster_mz 1180 hdf5_path = sample.file_location 1181 if hdf5_path and hdf5_path.exists(): 1182 reader = ReadCoreMSHDFMassSpectra(str(hdf5_path)) 1183 reader.import_eics(sample, mz_list=list(sample_cluster_mz)) 1184 # Return the loaded EICs for multiprocessing collection 1185 # (modifications in worker process don't persist to main process) 1186 return sample.eics.copy() 1187 1188 return {} 1189 1190 def collect_results(self, sample_id, result, collection): 1191 """ 1192 Collect loaded EICs back into sample. 1193 1194 In multiprocessing, the worker's modifications don't persist to the 1195 main process, so we need to explicitly collect and reassign the EICs. 1196 This also re-associates EICs with mass features. 1197 1198 Parameters 1199 ---------- 1200 sample_id : int 1201 Sample ID that was processed 1202 result : dict 1203 Dictionary of EIC_Data objects keyed by m/z, returned from execute() 1204 collection : LCMSBaseCollection 1205 The collection 1206 """ 1207 if result: 1208 # Update sample.eics with loaded EICs 1209 collection[sample_id].eics.update(result) 1210 # Note: EIC association with mass features happens after pipeline completes 1211 # to avoid multiprocessing issues (modifications in worker processes don't 1212 # 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.
1114 @property 1115 def description(self): 1116 """Human-readable description for progress messages.""" 1117 return "loading EICs"
Human-readable description for progress messages.
1119 def needs_raw_ms_data(self): 1120 """This operation doesn't need raw data - induced features already have EICs.""" 1121 return False, None
This operation doesn't need raw data - induced features already have EICs.
1123 def can_execute(self, sample, collection): 1124 """ 1125 Check if EIC loading can be executed. 1126 1127 This operation can always execute if the sample exists - the actual work 1128 is determined by cluster_mz_dict in runtime_params. If cluster_mz_dict is 1129 empty or None, execute() will simply return 0 (no EICs loaded). 1130 1131 Returns 1132 ------- 1133 bool 1134 True (always executable - runtime_params control actual work) 1135 """ 1136 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)
1138 def execute(self, sample_id, collection, cluster_mz_dict=None, **runtime_params): 1139 """ 1140 Load EICs from HDF5 for a single sample. 1141 1142 Loads EICs for regular mass features that belong to consensus clusters. 1143 Induced (gap-filled) mass features already have EICs from integrate_mass_features, 1144 so no additional loading is needed for them. 1145 1146 The cluster_mz_dict parameter (passed from collection level) maps sample_id 1147 to a list of m/z values that belong to clusters for that sample. 1148 1149 Parameters 1150 ---------- 1151 sample_id : int 1152 Sample index to process 1153 collection : LCMSBaseCollection 1154 The collection 1155 cluster_mz_dict : dict, optional 1156 Dictionary mapping sample_id to list of m/z values in clusters for that sample. 1157 If None, will not load any EICs. Default is None. 1158 **runtime_params 1159 Additional runtime parameters (ignored) 1160 1161 Returns 1162 ------- 1163 dict 1164 Dictionary of loaded EIC_Data objects, keyed by m/z value 1165 """ 1166 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 1167 1168 sample = collection[sample_id] 1169 1170 # If no cluster info provided or no m/z values for this sample, return early 1171 if cluster_mz_dict is None or sample_id not in cluster_mz_dict: 1172 return {} 1173 1174 # Get m/z values for this sample that belong to clusters 1175 sample_cluster_mz = set(cluster_mz_dict[sample_id]) 1176 if len(sample_cluster_mz) == 0: 1177 return {} 1178 1179 # Load EICs for each of the sample_cluster_mz 1180 hdf5_path = sample.file_location 1181 if hdf5_path and hdf5_path.exists(): 1182 reader = ReadCoreMSHDFMassSpectra(str(hdf5_path)) 1183 reader.import_eics(sample, mz_list=list(sample_cluster_mz)) 1184 # Return the loaded EICs for multiprocessing collection 1185 # (modifications in worker process don't persist to main process) 1186 return sample.eics.copy() 1187 1188 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
1190 def collect_results(self, sample_id, result, collection): 1191 """ 1192 Collect loaded EICs back into sample. 1193 1194 In multiprocessing, the worker's modifications don't persist to the 1195 main process, so we need to explicitly collect and reassign the EICs. 1196 This also re-associates EICs with mass features. 1197 1198 Parameters 1199 ---------- 1200 sample_id : int 1201 Sample ID that was processed 1202 result : dict 1203 Dictionary of EIC_Data objects keyed by m/z, returned from execute() 1204 collection : LCMSBaseCollection 1205 The collection 1206 """ 1207 if result: 1208 # Update sample.eics with loaded EICs 1209 collection[sample_id].eics.update(result) 1210 # Note: EIC association with mass features happens after pipeline completes 1211 # to avoid multiprocessing issues (modifications in worker processes don't 1212 # 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