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)
class SampleOperation(abc.ABC):
 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)
name
params
description
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
@abstractmethod
def needs_raw_ms_data(self):
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
@abstractmethod
def can_execute(self, sample, collection):
 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
@abstractmethod
def execute(self, sample_id, collection, **runtime_params):
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)
@abstractmethod
def collect_results(self, sample_id, result, collection):
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
class GapFillOperation(SampleOperation):
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.

description
209    @property
210    def description(self):
211        """Human-readable description for progress messages."""
212        return "gap-filling"

Human-readable description for progress messages.

def needs_raw_ms_data(self):
214    def needs_raw_ms_data(self):
215        """This operation needs raw MS1 data."""
216        return True, 1

This operation needs raw MS1 data.

def can_execute(self, sample, collection):
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.

def execute(self, sample_id, collection, **runtime_params):
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)
def collect_results(self, sample_id, result, collection):
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.

class ReloadFeaturesOperation(SampleOperation):
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.

description
449    @property
450    def description(self):
451        """Human-readable description for progress messages."""
452        return "reloading features"

Human-readable description for progress messages.

def needs_raw_ms_data(self):
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.

def can_execute(self, sample, collection):
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.

def execute(self, sample_id, collection, mf_ids_to_load=None, **runtime_params):
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
def collect_results(self, sample_id, result, collection):
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
class MolecularFormulaSearchOperation(SampleOperation):
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.

MolecularFormulaSearchOperation(name='molecular_formula_search', **kwargs)
645    def __init__(self, name="molecular_formula_search", **kwargs):
646        super().__init__(name, **kwargs)
description
640    @property
641    def description(self):
642        """Human-readable description for progress messages."""
643        return "molecular formula search"

Human-readable description for progress messages.

def needs_raw_ms_data(self):
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
def can_execute(self, sample, collection, **runtime_params):
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
def execute(self, sample_id, collection, **runtime_params):
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
def collect_results(self, sample_id, result, collection):
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
SampleOperation
name
params
class MS2SpectralSearchOperation(SampleOperation):
 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.

MS2SpectralSearchOperation(name='ms2_spectral_search', ms2_scan_filter=None, **kwargs)
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
description
878    @property
879    def description(self):
880        """Human-readable description for progress messages."""
881        return "MS2 spectral search"

Human-readable description for progress messages.

def needs_raw_ms_data(self):
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
def can_execute(self, sample, collection, **runtime_params):
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
def execute( self, sample_id, collection, fe_lib=None, molecular_metadata=None, **runtime_params):
 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
def collect_results(self, sample_id, result, collection):
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
SampleOperation
name
params
class LoadEICsOperation(SampleOperation):
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.

description
1114    @property
1115    def description(self):
1116        """Human-readable description for progress messages."""
1117        return "loading EICs"

Human-readable description for progress messages.

def needs_raw_ms_data(self):
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.

def can_execute(self, sample, collection):
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)
def execute(self, sample_id, collection, cluster_mz_dict=None, **runtime_params):
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
def collect_results(self, sample_id, result, collection):
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