corems.mass_spectra.calc.lc_calc

   1import math
   2import time
   3from itertools import product
   4
   5import numpy as np
   6import pandas as pd
   7import warnings
   8import scipy
   9
  10if not hasattr(np, "trapezoid"):  # numpy < 2.0
  11    np.trapezoid = np.trapz
  12from ripser import ripser
  13from scipy import sparse
  14from scipy.spatial import KDTree
  15from sklearn.svm import SVR
  16from sklearn.cluster import AgglomerativeClustering
  17import matplotlib.pyplot as plt
  18from tqdm import tqdm
  19
  20from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature
  21from corems.encapsulation.plot_utils import _finalize_plot
  22from corems.encapsulation.spawn_pool import spawn_pool
  23from corems.mass_spectra.calc import SignalProcessing as sp
  24from corems.mass_spectra.factory.chromat_data import EIC_Data
  25from corems.mass_spectrum.input.numpyArray import ms_from_array_profile
  26
  27warnings.filterwarnings("ignore", category=RuntimeWarning)
  28
  29
  30def find_closest(A, target):
  31    """Find the index of closest value in A to each value in target.
  32
  33    Parameters
  34    ----------
  35    A : :obj:`~numpy.array`
  36        The array to search (blueprint). A must be sorted.
  37    target : :obj:`~numpy.array`
  38        The array of values to search for. target must be sorted.
  39
  40    Returns
  41    -------
  42    :obj:`~numpy.array`
  43        The indices of the closest values in A to each value in target.
  44    """
  45    idx = A.searchsorted(target)
  46    idx = np.clip(idx, 1, len(A) - 1)
  47    left = A[idx - 1]
  48    right = A[idx]
  49    idx -= target - left < right - target
  50    return idx
  51
  52
  53def _mz_grid_centers(mz_desc, radius):
  54    """m/z bin centers, highest intensity first.
  55
  56    ``mz_desc`` is unique and ordered from highest intensity to lowest. A value
  57    is kept when no kept higher-intensity value lies within ``radius``. That is
  58    the parent rule of the distance-pair grid, without building the pair matrix.
  59    """
  60    mz = np.asarray(mz_desc, dtype=np.float64)
  61    n = mz.size
  62    if n == 0:
  63        return mz
  64    rank_order = np.argsort(mz, kind="mergesort")
  65    mz_by_rank = mz[rank_order]
  66    rank_of = np.empty(n, dtype=np.intp)
  67    rank_of[rank_order] = np.arange(n)
  68    bit = np.zeros(n + 1, dtype=np.int32)
  69    keep = np.zeros(n, dtype=bool)
  70
  71    def prefix(end):
  72        total = 0
  73        while end:
  74            total += int(bit[end])
  75            end -= end & -end
  76        return total
  77
  78    def mark(rank):
  79        node = rank + 1
  80        while node <= n:
  81            bit[node] += 1
  82            node += node & -node
  83
  84    for i, value in enumerate(mz):
  85        lo = int(np.searchsorted(mz_by_rank, value - radius, side="left"))
  86        hi = int(np.searchsorted(mz_by_rank, value + radius, side="right"))
  87        if prefix(hi) - prefix(lo) > 0:
  88            continue
  89        mark(int(rank_of[i]))
  90        keep[i] = True
  91    return mz[keep]
  92
  93
  94class LCCalculations:
  95    """Methods for performing LC calculations on mass spectra data.
  96
  97    Notes
  98    -----
  99    This class is intended to be used as a mixin for the LCMSBase class.
 100
 101    Methods
 102    -------
 103    * get_max_eic(eic_data).
 104        Returns the maximum EIC value from the given EIC data. A static method.
 105    * smooth_tic(tic).
 106        Smooths the TIC data using the specified smoothing method and settings.
 107    * eic_centroid_detector(rt, eic, max_eic).
 108        Performs EIC centroid detection on the given EIC data.
 109    * find_nearest_scan(rt).
 110        Finds the nearest scan to the given retention time.
 111    * get_average_mass_spectrum(scan_list, apex_scan, spectrum_mode="profile", ms_level=1, auto_process=True, use_parser=False, perform_checks=True, polarity=None).
 112        Returns an averaged mass spectrum object.
 113    * find_mass_features(ms_level=1).
 114        Find regions of interest for a given MS level (default is MS1).
 115    * integrate_mass_features(drop_if_fail=False, ms_level=1).
 116        Integrate mass features of interest and extracts EICs.
 117    * find_c13_mass_features().
 118        Evaluate mass features and mark likely C13 isotopes.
 119    * deconvolute_ms1_mass_features().
 120        Deconvolute mass features' ms1 mass spectra.
 121    """
 122
 123    @staticmethod
 124    def get_max_eic(eic_data: dict):
 125        """Returns the maximum EIC value from the given EIC data.
 126
 127        Notes
 128        -----
 129        This is a static method.
 130
 131        Parameters
 132        ----------
 133        eic_data : dict
 134            A dictionary containing EIC data.
 135
 136        Returns
 137        -------
 138        float
 139            The maximum EIC value.
 140        """
 141        max_eic = 0
 142        for eic_data in eic_data.values():
 143            ind_max_eic = max(eic_data.get("EIC"))
 144            max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic
 145
 146        return max_eic
 147
 148    def smooth_tic(self, tic):
 149        """Smooths the TIC or EIC data using the specified smoothing method and settings.
 150
 151        Parameters
 152        ----------
 153        tic : numpy.ndarray
 154            The TIC (or EIC) data to be smoothed.
 155
 156        Returns
 157        -------
 158        numpy.ndarray
 159            The smoothed TIC data.
 160        """
 161        implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method
 162
 163        pol_order = self.parameters.lc_ms.savgol_pol_order
 164
 165        window_len = self.parameters.lc_ms.smooth_window
 166
 167        window = self.parameters.lc_ms.smooth_method
 168
 169        return sp.smooth_signal(
 170            tic, window_len, window, pol_order, implemented_smooth_method
 171        )
 172
 173    def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]):
 174        """Performs EIC centroid detection on the given EIC data.
 175
 176        Parameters
 177        ----------
 178        rt : numpy.ndarray
 179            The retention time data.
 180        eic : numpy.ndarray
 181            The EIC data.
 182        max_eic : float
 183            The maximum EIC value.
 184        apex_indexes : list, optional
 185            The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function.
 186
 187        Returns
 188        -------
 189        numpy.ndarray
 190            The indexes of left, apex, and right limits as a generator.
 191        """
 192
 193        max_prominence = self.parameters.lc_ms.peak_max_prominence_percent
 194
 195        max_height = self.parameters.lc_ms.peak_height_max_percent
 196
 197        signal_threshold = self.parameters.lc_ms.eic_signal_threshold
 198
 199        min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints
 200
 201        peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold
 202
 203        include_indexes = sp.peak_picking_first_derivative(
 204            domain=rt,
 205            signal=eic,
 206            max_height=max_height,
 207            max_prominence=max_prominence,
 208            max_signal=max_eic,
 209            min_peak_datapoints=min_peak_datapoints,
 210            peak_derivative_threshold=peak_derivative_threshold,
 211            signal_threshold=signal_threshold,
 212            correct_baseline=False,
 213            plot_res=False,
 214            apex_indexes=apex_indexes,
 215        )
 216        # include_indexes is a generator of tuples (left_index, apex_index, right_index)
 217        include_indexes = list(include_indexes)
 218        # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex
 219        indicies = [x for x in include_indexes]
 220        for idx in indicies:
 221            if (idx[1] - idx[0] < min_peak_datapoints / 2) or (
 222                idx[2] - idx[1] < min_peak_datapoints / 2
 223            ):
 224                include_indexes.remove(idx)
 225        return include_indexes
 226
 227    def find_nearest_scan(self, rt):
 228        """Finds the nearest scan to the given retention time.
 229
 230        Parameters
 231        ----------
 232        rt : float
 233            The retention time (in minutes) to find the nearest scan for.
 234
 235        Returns
 236        -------
 237        int
 238            The scan number of the nearest scan.
 239        """
 240        array_rt = np.array(self.retention_time)
 241
 242        scan_index = (np.abs(array_rt - rt)).argmin()
 243
 244        real_scan = self.scans_number[scan_index]
 245
 246        return real_scan
 247
 248    def add_peak_metrics(self, remove_by_metrics=True, induced_features=False):
 249        """Add peak metrics to the mass features.
 250
 251        This function calculates the peak metrics for each mass feature and adds them to the mass feature objects.
 252
 253        Parameters
 254        ----------
 255        remove_by_metrics : bool, optional
 256            If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity,
 257            dispersity index, and noise score. Default is True, which checks the setting in the processing parameters.
 258            If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters.
 259        induced_features : bool, optional
 260            Whether the mass features to be integrated were induced. Default is False.
 261        """
 262        # Check that at least some mass features have eic data
 263        if induced_features:
 264            mf_dict_values = self.induced_mass_features.values()
 265        else:
 266            mf_dict_values = self.mass_features.values()
 267
 268        if not any([mf._eic_data is not None for mf in mf_dict_values]):
 269            raise ValueError(
 270                "No mass features have EIC data. Run integrate_mass_features first."
 271            )
 272
 273        for mass_feature in mf_dict_values:
 274            # Check if the mass feature has been integrated
 275            if mass_feature._eic_data is not None and mass_feature.area is not None:
 276                # Calculate peak metrics
 277                mass_feature.calc_half_height_width()
 278                mass_feature.calc_tailing_factor()
 279                mass_feature.calc_dispersity_index()
 280                mass_feature.calc_gaussian_similarity()
 281                mass_feature.calc_noise_score()
 282
 283        # Remove mass features by peak metrics if designated in parameters
 284        if (
 285            self.parameters.lc_ms.remove_mass_features_by_peak_metrics
 286            and remove_by_metrics
 287        ):
 288            self._remove_mass_features_by_peak_metrics(
 289                induced_features=induced_features
 290            )
 291
 292    def get_average_mass_spectrum(
 293        self,
 294        scan_list,
 295        apex_scan,
 296        spectrum_mode="profile",
 297        ms_level=1,
 298        auto_process=True,
 299        use_parser=False,
 300        perform_checks=True,
 301        polarity=None,
 302        ms_params=None,
 303    ):
 304        """Returns an averaged mass spectrum object
 305
 306        Parameters
 307        ----------
 308        scan_list : list
 309            List of scan numbers to average.
 310        apex_scan : int
 311            Number of the apex scan
 312        spectrum_mode : str, optional
 313            The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging.
 314        ms_level : int, optional
 315            The MS level to use. Defaults to 1.
 316        auto_process : bool, optional
 317            If True, the averaged mass spectrum will be auto-processed. Defaults to True.
 318        use_parser : bool, optional
 319            If True, the mass spectra will be obtained from the parser. Defaults to False.
 320        perform_checks : bool, optional
 321            If True, the function will check if the data are within the ms_unprocessed dictionary and are the correct mode. Defaults to True. Only set to False if you are sure the data are profile, and (if not using the parser) are in the ms_unprocessed dictionary!  ms_unprocessed dictionary also must be indexed on scan
 322        polarity : int, optional
 323            The polarity of the mass spectra (1 or -1). If not set, the polarity will be determined from the dataset. Defaults to None. (fastest if set to -1 or 1)
 324        ms_params : MSParameters, optional
 325            The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None.
 326
 327        Returns
 328        -------
 329        MassSpectrumProfile
 330            The averaged mass spectrum object.
 331
 332        Raises
 333        ------
 334        ValueError
 335            If the spectrum mode is not "profile".
 336            If the MS level is not found in the unprocessed mass spectra dictionary.
 337            If not all scan numbers are found in the unprocessed mass spectra dictionary.
 338        """
 339        if perform_checks:
 340            if spectrum_mode != "profile":
 341                raise ValueError("Averaging only supported for profile mode")
 342
 343        if polarity is None:
 344            # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
 345            if self.polarity == "negative":
 346                polarity = -1
 347            elif self.polarity == "positive":
 348                polarity = 1
 349            else:
 350                raise ValueError(
 351                    "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'"
 352                )
 353
 354        # if not using_parser, check that scan numbers are in _ms_unprocessed
 355        if not use_parser:
 356            if perform_checks:
 357                # Set index to scan for faster lookup
 358                ms_df = (
 359                    self._ms_unprocessed[ms_level]
 360                    .copy()
 361                    .set_index("scan", drop=False)
 362                    .sort_index()
 363                )
 364                my_ms_df = ms_df.loc[scan_list]
 365                # Check that all scan numbers are in the ms_df
 366                if not all(np.isin(scan_list, ms_df.index)):
 367                    raise ValueError(
 368                        "Not all scan numbers found in the unprocessed mass spectra dictionary"
 369                    )
 370            else:
 371                my_ms_df = (
 372                    pd.DataFrame({"scan": scan_list})
 373                    .set_index("scan")
 374                    .join(self._ms_unprocessed[ms_level], how="left")
 375                )
 376
 377        if use_parser:
 378            parser = self.spectra_parser
 379            ms_list = [
 380                parser.get_mass_spectrum_from_scan(
 381                    x, spectrum_mode=spectrum_mode, auto_process=False
 382                )
 383                for x in scan_list
 384            ]
 385            ms_mz = [x._mz_exp for x in ms_list]
 386            ms_int = [x._abundance for x in ms_list]
 387            my_ms_df = []
 388            for i in np.arange(len(ms_mz)):
 389                my_ms_df.append(
 390                    pd.DataFrame(
 391                        {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]}
 392                    )
 393                )
 394            my_ms_df = pd.concat(my_ms_df)
 395
 396        if not self.check_if_grid(my_ms_df):
 397            my_ms_df = self.grid_data(my_ms_df)
 398
 399        my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index()
 400
 401        ms = ms_from_array_profile(
 402            my_ms_ave.mz,
 403            my_ms_ave.intensity,
 404            self.file_location,
 405            polarity=polarity,
 406            auto_process=False,
 407        )
 408
 409        # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset
 410        if ms is not None:
 411            if ms_params is not None:
 412                ms.parameters = ms_params
 413            ms.scan_number = apex_scan
 414            if auto_process:
 415                ms.process_mass_spec()
 416        return ms
 417
 418    def _resolve_lcms_peak_picking_method(self, ms_level=1):
 419        """Resolve and validate the 2D peak picking method against scan formats.
 420
 421        Parameters
 422        ----------
 423        ms_level : int, optional
 424            MS level whose ``scan_df`` formats are checked. Default is 1.
 425
 426        Returns
 427        -------
 428        str
 429            Concrete method: ``persistent homology`` or
 430            ``centroided_persistent_homology``. If the stored method is
 431            ``by_format``, it is chosen from scan formats (all profile or all
 432            centroid).
 433
 434        Raises
 435        ------
 436        ValueError
 437            If the method is not implemented, ``by_format`` cannot be resolved
 438            (empty, mixed, or unknown formats), or an explicit method does
 439            not match the scan formats at ``ms_level``.
 440        """
 441        method = self.parameters.lc_ms.peak_picking_method
 442        msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level]
 443        values = list(msx_scan_df["ms_format"])
 444
 445        if method == "by_format":
 446            if len(values) == 0:
 447                raise ValueError(
 448                    "Cannot resolve by_format peak picking: no scans found "
 449                    "for the requested MS level."
 450                )
 451            if all(v == "profile" for v in values):
 452                return "persistent homology"
 453            if all(v == "centroid" for v in values):
 454                return "centroided_persistent_homology"
 455            found = sorted({str(v) for v in values})
 456            raise ValueError(
 457                "Cannot resolve by_format peak picking: MS scans are mixed "
 458                f"or unknown format (found {found}). Set peak_picking_method "
 459                "explicitly to 'persistent homology' or "
 460                "'centroided_persistent_homology'."
 461            )
 462
 463        if method == "persistent homology":
 464            if len(values) == 0 or not all(v == "profile" for v in values):
 465                raise ValueError(
 466                    "MS{} scans are not profile mode, which is required for persistent homology peak picking.".format(
 467                        ms_level
 468                    )
 469                )
 470            return method
 471
 472        if method == "centroided_persistent_homology":
 473            if len(values) == 0 or not all(v == "centroid" for v in values):
 474                raise ValueError(
 475                    "MS{} scans are not centroid mode, which is required for persistent homology centroided peak picking.".format(
 476                        ms_level
 477                    )
 478                )
 479            return method
 480
 481        raise ValueError("Peak picking method not implemented")
 482
 483    def find_mass_features(
 484        self,
 485        ms_level=1,
 486        grid=True,
 487        assign_ms2_scans=False,
 488        ms2_scan_filter=None,
 489        targeted_search=False,
 490        target_search_dict=None,
 491        accumulate_features=False,
 492    ):
 493        """Find mass features within an LCMSBase object
 494
 495        Wrapper that dispatches to a 2D peak picking implementation.
 496
 497        If ``parameters.lc_ms.peak_picking_method`` is ``by_format``, the method is
 498        chosen from ``scan_df.ms_format`` at the requested MS level (all
 499        profile → persistent homology; all centroid → centroided persistent
 500        homology). A successful resolve overwrites
 501        ``peak_picking_method`` with the concrete method so saved parameters
 502        record what was used.
 503
 504        Parameters
 505        ----------
 506        ms_level : int, optional
 507            The MS level to use for peak picking Default is 1.
 508        grid : bool, optional
 509            If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded),
 510            used for persistent homology peak picking for profile data only. Default is True.
 511        assign_ms2_scans : bool, optional
 512            If True, assign MS2 scan numbers to mass features after peak picking.
 513            This populates the ms2_scan_numbers attribute on each mass feature, which enables
 514            choosing representative features based on MS2 availability. Default is False.
 515        ms2_scan_filter : str or None, optional
 516            Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd').
 517            If None, all MS2 scans are considered. Default is None.
 518        targeted_search : bool, optional
 519            If True, perform targeted mass feature search using the target_search_dict.
 520            This mode filters data to only m/z and RT windows of interest and bypasses
 521            intensity and persistence thresholds. Default is False.
 522        target_search_dict : dict or None, optional
 523            Dictionary containing target search parameters. Required if targeted_search is True.
 524            Must contain:
 525                - 'target_mz_list': list of target m/z values
 526                - 'target_rt_list': list of target retention times (in minutes)
 527                - 'mz_tolerance_ppm': m/z tolerance in ppm
 528                - 'rt_tolerance': retention time tolerance (in minutes)
 529            Optionally can contain:
 530                - 'type': type label for mass features (e.g., "internal standard")
 531                  If not provided, defaults to "targeted"
 532            Default is None.
 533        accumulate_features : bool, optional
 534            If True, new mass features will be added to existing features rather than replacing them.
 535            This allows multiple sequential calls to find_mass_features to build up a combined set.
 536            Default is False (replace existing features for backwards compatibility).
 537
 538        Raises
 539        ------
 540        ValueError
 541            If no MS level data is found on the object.
 542            If ``by_format`` cannot resolve a method (empty, mixed, or unknown
 543            ``ms_format`` values at the requested MS level).
 544            If persistent homology peak picking is attempted on non-profile mode data.
 545            If centroided persistent homology is attempted on non-centroid mode data.
 546            If data is not gridded and grid is False.
 547            If peak picking method is not implemented.
 548            If targeted_search is True but target_search_dict is None or invalid.
 549
 550        Returns
 551        -------
 552        None, but assigns the mass_features and eics attributes to the object.
 553
 554        """
 555        # Validate targeted search parameters
 556        if targeted_search:
 557            if target_search_dict is None:
 558                raise ValueError(
 559                    "target_search_dict must be provided when targeted_search is True"
 560                )
 561            required_keys = [
 562                "target_mz_list",
 563                "target_rt_list",
 564                "mz_tolerance_ppm",
 565                "rt_tolerance",
 566            ]
 567            for key in required_keys:
 568                if key not in target_search_dict:
 569                    raise ValueError(f"target_search_dict must contain '{key}'")
 570            if len(target_search_dict["target_mz_list"]) != len(
 571                target_search_dict["target_rt_list"]
 572            ):
 573                raise ValueError(
 574                    "target_mz_list and target_rt_list must have the same length"
 575                )
 576
 577        requested_method = self.parameters.lc_ms.peak_picking_method
 578        pp_method = self._resolve_lcms_peak_picking_method(ms_level=ms_level)
 579        if requested_method == "by_format":
 580            self.parameters.lc_ms.peak_picking_method = pp_method
 581
 582        if targeted_search:
 583            mf_type = target_search_dict.get("type", "targeted")
 584        else:
 585            mf_type = "untargeted"
 586
 587        if pp_method == "persistent homology":
 588            self.find_mass_features_ph(
 589                ms_level=ms_level,
 590                grid=grid,
 591                targeted_search=targeted_search,
 592                target_search_dict=target_search_dict,
 593                mf_type=mf_type,
 594                accumulate_features=accumulate_features,
 595            )
 596        elif pp_method == "centroided_persistent_homology":
 597            self.find_mass_features_ph_centroid(
 598                ms_level=ms_level,
 599                targeted_search=targeted_search,
 600                target_search_dict=target_search_dict,
 601                mf_type=mf_type,
 602                accumulate_features=accumulate_features,
 603            )
 604        else:
 605            raise ValueError("Peak picking method not implemented")
 606
 607        # Cluster mass features to remove redundant features
 608        self.cluster_mass_features(drop_children=True)
 609
 610        # Optionally assign MS2 scan numbers to mass features during peak picking
 611        # This helps with choosing representative features that have MS2 data
 612        if assign_ms2_scans:
 613            try:
 614                self._find_ms2_scans_for_mass_features(
 615                    mf_ids=None,  # Process all mass features
 616                    scan_filter=ms2_scan_filter,
 617                )
 618            except ValueError:
 619                # No MS2 scans found - this is okay, just skip
 620                pass
 621
 622        # Remove noisey mass features if designated in parameters
 623        if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search:
 624            self._remove_redundant_mass_features()
 625
 626    def integrate_mass_features(
 627        self,
 628        drop_if_fail=True,
 629        drop_duplicates=True,
 630        ms_level=1,
 631        induced_features=False,
 632    ):
 633        """Integrate mass features and extract EICs.
 634
 635        Populates the _eics attribute on the LCMSBase object for each unique mz in the mass_features dataframe and adds data (start_scan, final_scan, area) to the mass_features attribute.
 636
 637        Parameters
 638        ----------
 639        drop_if_fail : bool, optional
 640            Whether to drop mass features if the EIC limit calculations fail.
 641            Default is True.
 642        drop_duplicates : bool, optional
 643            Whether to mass features that appear to be duplicates
 644            (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating).
 645            Default is True.
 646        ms_level : int, optional
 647            The MS level to use. Default is 1.
 648        induced_features : bool, optional
 649            Whether the mass features to be intergrated were induced. Default is False.
 650
 651        Raises
 652        ------
 653        ValueError
 654            If no mass features are found.
 655            If no MS level data is found for the given MS level (either in data or in the scan data)
 656
 657        Returns
 658        -------
 659        None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute.
 660
 661        Notes
 662        -----
 663        drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection).
 664        """
 665
 666        # Check if there is data
 667        if ms_level in self._ms_unprocessed.keys():
 668            raw_data = self._ms_unprocessed[ms_level].copy()
 669        else:
 670            raise ValueError("No MS level " + str(ms_level) + " data found")
 671
 672        # Check if mass_spectrum exists on each mass feature
 673        if induced_features:
 674            mf_dict = self.induced_mass_features
 675            if len(mf_dict) == 0:
 676                raise ValueError(
 677                    "No induced mass features found, did you run fill_missing_cluster_features() first?"
 678                )
 679
 680            ## remove not found induced mass features by mz <= 0 (-99 indicator)
 681            # also remove any where mz is nan
 682            mf_dict = {
 683                k: v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz)
 684            }
 685
 686        else:
 687            mf_dict = self.mass_features
 688            if len(mf_dict) == 0:
 689                raise ValueError(
 690                    "No mass features found, did you run find_mass_features() first?"
 691                )
 692
 693        # Subset scan data to only include correct ms_level
 694        scan_df_sub = self.scan_df[
 695            self.scan_df["ms_level"] == int(ms_level)
 696        ].reset_index(drop=True)
 697        if scan_df_sub.empty:
 698            raise ValueError("No MS level " + ms_level + " data found in scan data")
 699        scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy()
 700
 701        mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()])
 702        mzs_to_extract.sort()
 703
 704        # Pre-sort raw_data by mz for faster filtering
 705        raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True)
 706        raw_data_mz = raw_data_sorted["mz"].to_numpy()
 707        raw_scan = raw_data_sorted["scan"].to_numpy()
 708        raw_intensity = raw_data_sorted["intensity"].to_numpy()
 709        scan_ids = scan_df_sub["scan"].to_numpy()
 710        scan_time = scan_df_sub["scan_time"].to_numpy()
 711        n_scans = scan_ids.size
 712        # Sum into the scan-table row with the same float32 Kahan sum the
 713        # previous groupby used, so the chromatogram values stay identical.
 714        scan_order = np.argsort(scan_ids, kind="mergesort")
 715        sorted_scan_ids = scan_ids[scan_order]
 716        found = np.searchsorted(sorted_scan_ids, raw_scan)
 717        in_range = found < n_scans
 718        scan_col = np.empty(raw_scan.shape, dtype=np.intp)
 719        scan_col[in_range] = scan_order[found[in_range]]
 720        matched = np.zeros(raw_scan.shape, dtype=bool)
 721        matched[in_range] = sorted_scan_ids[found[in_range]] == raw_scan[in_range]
 722
 723        # Get EICs for each unique mz in mass features list
 724        for mz in mzs_to_extract:
 725            mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6
 726            mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6
 727
 728            # Use binary search for faster mz range filtering
 729            left_idx = int(np.searchsorted(raw_data_mz, mz_min, side="left"))
 730            right_idx = int(np.searchsorted(raw_data_mz, mz_max, side="right"))
 731            eic = np.zeros(n_scans, dtype=np.float32)
 732            if left_idx < right_idx:
 733                comp = np.zeros(n_scans, dtype=np.float32)
 734                cols = scan_col[left_idx:right_idx]
 735                vals = raw_intensity[left_idx:right_idx]
 736                ok = matched[left_idx:right_idx]
 737                for i in range(cols.size):
 738                    if not ok[i]:
 739                        continue
 740                    col = cols[i]
 741                    val = vals[i]
 742                    y = val - comp[col]
 743                    t = eic[col] + y
 744                    c = (t - eic[col]) - y
 745                    comp[col] = c if np.isfinite(c) else np.float32(0)
 746                    eic[col] = t
 747            myEIC = EIC_Data(
 748                scans=scan_ids.copy(),
 749                time=scan_time.copy(),
 750                eic=eic,
 751            )
 752            # Smooth EIC
 753            smoothed_eic = self.smooth_tic(myEIC.eic)
 754            smoothed_eic[smoothed_eic < 0] = 0
 755            myEIC.eic_smoothed = smoothed_eic
 756            self.eics[mz] = myEIC
 757
 758        # Get limits of mass features using EIC centroid detector and integrate
 759        for idx, mass_feature in list(mf_dict.items()):
 760            mz = mass_feature.mz
 761            apex_scan = mass_feature.apex_scan
 762
 763            # Pull EIC data and find apex scan index
 764            myEIC = self.eics[mz]
 765            mf_dict[idx]._eic_data = myEIC
 766            mf_dict[idx]._eic_mz = mz
 767            apex_index = np.searchsorted(myEIC.scans, apex_scan)
 768
 769            # Find left and right limits of peak using EIC centroid detector, add to EICData
 770            centroid_eics = self.eic_centroid_detector(
 771                myEIC.time,
 772                myEIC.eic_smoothed,
 773                mass_feature.intensity * 1.1,
 774                apex_indexes=[int(apex_index)],
 775            )
 776            l_a_r_scan_idx = [i for i in centroid_eics]
 777            if len(l_a_r_scan_idx) > 0:
 778                # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans
 779                # Find the number of consecutive non-zero values in the EIC segment
 780                mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0
 781                # Find the longest run of consecutive True values
 782                if np.any(mask):
 783                    # Find indices where mask changes value
 784                    diff = np.diff(np.concatenate(([0], mask.astype(int), [0])))
 785                    starts = np.where(diff == 1)[0]
 786                    ends = np.where(diff == -1)[0]
 787                    consecutive_scans = (ends - starts).max()
 788                else:
 789                    consecutive_scans = 0
 790                if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min:
 791                    mf_dict.pop(idx)
 792                    continue
 793                # Add start and final scan to mass_features and EICData
 794                left_scan, right_scan = (
 795                    myEIC.scans[l_a_r_scan_idx[0][0]],
 796                    myEIC.scans[l_a_r_scan_idx[0][2]],
 797                )
 798                mf_scan_apex = [(left_scan, int(apex_scan), right_scan)]
 799                myEIC.apexes = myEIC.apexes + mf_scan_apex
 800                mf_dict[idx].start_scan = left_scan
 801                mf_dict[idx].final_scan = right_scan
 802
 803                # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData
 804                area = np.trapezoid(
 805                    myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1],
 806                    myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1],
 807                )
 808                myEIC.areas = myEIC.areas + [area]
 809                self.eics[mz] = myEIC
 810                mf_dict[idx]._area = area
 811            else:
 812                if drop_if_fail is True:
 813                    mf_dict.pop(idx)
 814
 815        if drop_duplicates:
 816            # Prepare mass feature dataframe
 817            # Skip features without scan bounds (failed EIC limits when
 818            # drop_if_fail=False); they cannot participate in containment de-dup
 819            if induced_features:
 820                mf_df = self.mass_features_to_df(induced_features=True).copy()
 821                mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()]
 822            else:
 823                mf_df = self.mass_features_to_df(induced_features=False).copy()
 824                mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()]
 825
 826            # For each mass feature, find all mass features within the clustering tolerance ppm and drop if their start and end times are within another mass feature
 827            # Keep the first mass feature (highest persistence)
 828            for idx, mass_feature in mf_df.iterrows():
 829                mz = mass_feature.mz
 830                apex_scan = mass_feature.apex_scan
 831
 832                mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6
 833                mf_df_sub = mf_df[
 834                    mf_df["mz_diff_ppm"]
 835                    < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel
 836                    * 10**6
 837                ].copy()
 838
 839                # For all mass features within the clustering tolerance, check if the start and end times are within the start and end times of the mass feature
 840                for idx2, mass_feature2 in mf_df_sub.iterrows():
 841                    if idx2 != idx:
 842                        s1, e1 = mass_feature.start_scan, mass_feature.final_scan
 843                        s2, e2 = mass_feature2.start_scan, mass_feature2.final_scan
 844                        # Guard incomplete bounds (None / NaN) so ordering
 845                        # comparisons never raise TypeError
 846                        if s1 is None or e1 is None or s2 is None or e2 is None:
 847                            continue
 848                        if pd.isna(s1) or pd.isna(e1) or pd.isna(s2) or pd.isna(e2):
 849                            continue
 850                        if s2 >= s1 and e2 <= e1:
 851                            if idx2 in self.mass_features.keys():
 852                                self.mass_features.pop(idx2)
 853
 854        # Filter MS2 scans to only include those within integration bounds
 855        # This ensures MS2 scans outside start_scan to final_scan are removed
 856        if induced_features:
 857            self._filter_ms2_scans_by_integration_bounds(
 858                mf_dict=self.induced_mass_features
 859            )
 860        else:
 861            self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features)
 862
 863    def find_c13_mass_features(self):
 864        """Mark likely C13 isotopes and connect to monoisoitopic mass features.
 865
 866        Returns
 867        -------
 868        None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object.
 869
 870        Raises
 871        ------
 872        ValueError
 873            If no mass features are found.
 874        """
 875        verbose = self.parameters.lc_ms.verbose_processing
 876        if verbose:
 877            print("evaluating mass features for C13 isotopes")
 878        if self.mass_features is None:
 879            raise ValueError("No mass features found, run find_mass_features() first")
 880
 881        # Data prep fo sparse distance matrix
 882        dims = ["mz", "scan_time"]
 883        mf_df = self.mass_features_to_df().copy()
 884        # Drop mass features that have no area (these are likely to be noise)
 885        mf_df = mf_df[mf_df["area"].notnull()]
 886        mf_df["mf_id"] = mf_df.index.values
 887        dims = ["mz", "scan_time"]
 888
 889        # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features
 890        mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy()
 891
 892        mz_diff = 1.003355  # C13-C12 mass difference
 893        tol = [
 894            mf_df["mz"].median()
 895            * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
 896            self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5,
 897        ]  # mz, in relative; scan_time in minutes
 898
 899        # Compute inter-feature distances
 900        distances = None
 901        for i in range(len(dims)):
 902            # Construct k-d tree
 903            values = mf_df[dims[i]].values
 904            tree = KDTree(values.reshape(-1, 1))
 905
 906            max_tol = tol[i]
 907            if dims[i] == "mz":
 908                # Maximum absolute tolerance
 909                max_tol = mz_diff + tol[i]
 910
 911            # Compute sparse distance matrix
 912            # the larger the max_tol, the slower this operation is
 913            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
 914
 915            # Only consider forward case, exclude diagonal
 916            sdm = sparse.triu(sdm, k=1)
 917
 918            if dims[i] == "mz":
 919                min_tol = mz_diff - tol[i]
 920                # Get only the ones that are above the min tol
 921                idx = sdm.data > min_tol
 922
 923                # Reconstruct sparse distance matrix
 924                sdm = sparse.coo_matrix(
 925                    (sdm.data[idx], (sdm.row[idx], sdm.col[idx])),
 926                    shape=(len(values), len(values)),
 927                )
 928
 929            # Cast as binary matrix
 930            sdm.data = np.ones_like(sdm.data)
 931
 932            # Stack distances
 933            if distances is None:
 934                distances = sdm
 935            else:
 936                distances = distances.multiply(sdm)
 937
 938        # Extract indices of within-tolerance points
 939        distances = distances.tocoo()
 940        pairs = np.stack((distances.row, distances.col), axis=1)  # C12 to C13 pairs
 941
 942        # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df
 943        pairs_mf = pairs.copy()
 944        pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values
 945        pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values
 946
 947        # Connect monoisotopic masses with isotopologes within mass_features
 948        monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1]))
 949        for mono in monos:
 950            self.mass_features[mono].monoisotopic_mf_id = mono
 951        pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"])
 952        while not pairs_iso_df.empty:
 953            pairs_iso_df = pairs_iso_df.set_index("parent", drop=False)
 954            m1_isos = pairs_iso_df.loc[monos, "child"].unique()
 955            for iso in m1_isos:
 956                # Set monoisotopic_mf_id and isotopologue_type for isotopologues
 957                parent = pairs_mf[pairs_mf[:, 1] == iso, 0]
 958                if len(parent) > 1:
 959                    # Choose the parent that is closest in time to the isotopologue
 960                    parent_time = [self.mass_features[p].retention_time for p in parent]
 961                    time_diff = [
 962                        np.abs(self.mass_features[iso].retention_time - x)
 963                        for x in parent_time
 964                    ]
 965                    parent = parent[np.argmin(time_diff)]
 966                else:
 967                    parent = parent[0]
 968                self.mass_features[iso].monoisotopic_mf_id = self.mass_features[
 969                    parent
 970                ].monoisotopic_mf_id
 971                if self.mass_features[iso].monoisotopic_mf_id is not None:
 972                    mass_diff = (
 973                        self.mass_features[iso].mz
 974                        - self.mass_features[
 975                            self.mass_features[iso].monoisotopic_mf_id
 976                        ].mz
 977                    )
 978                    self.mass_features[iso].isotopologue_type = "13C" + str(
 979                        int(round(mass_diff, 0))
 980                    )
 981
 982            # Drop the mono and iso from the pairs_iso_df
 983            pairs_iso_df = pairs_iso_df.drop(
 984                index=monos, errors="ignore"
 985            )  # Drop pairs where the parent is a child that is a child of a root
 986            pairs_iso_df = pairs_iso_df.set_index("child", drop=False)
 987            pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore")
 988
 989            if not pairs_iso_df.empty:
 990                # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process
 991                monos = np.setdiff1d(
 992                    np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child)
 993                )
 994        if verbose:
 995            # Report fraction of compounds annotated with isotopes
 996            mf_df["c13_flag"] = np.where(
 997                np.logical_or(
 998                    np.isin(mf_df["mf_id"], pairs_mf[:, 0]),
 999                    np.isin(mf_df["mf_id"], pairs_mf[:, 1]),
1000                ),
1001                1,
1002                0,
1003            )
1004            print(
1005                str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3))
1006                + " of mass features have or are C13 isotopes"
1007            )
1008
1009    def deconvolute_ms1_mass_features(self):
1010        """Deconvolute MS1 mass features
1011
1012        Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features
1013
1014        Parameters
1015        ----------
1016        None
1017
1018        Returns
1019        -------
1020        None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent,
1021        and associated_mass_features_deconvoluted attributes to the mass features in the
1022        mass_features attribute of the LCMSBase object.
1023
1024        Raises
1025        ------
1026        ValueError
1027            If no mass features are found, must run find_mass_features() first.
1028            If no EICs are found, did you run integrate_mass_features() first?
1029
1030        """
1031        # Checks for set mass_features and eics
1032        if self.mass_features is None:
1033            raise ValueError(
1034                "No mass features found, did you run find_mass_features() first?"
1035            )
1036
1037        if self.eics == {}:
1038            raise ValueError(
1039                "No EICs found, did you run integrate_mass_features() first?"
1040            )
1041
1042        if 1 not in self._ms_unprocessed.keys():
1043            raise ValueError("No unprocessed MS1 spectra found.")
1044
1045        # Prep ms1 data
1046        ms1_data = self._ms_unprocessed[1].copy()
1047        ms1_data = ms1_data.set_index("scan")
1048
1049        # Prep mass feature summary
1050        mass_feature_df = self.mass_features_to_df()
1051
1052        # Loop through each mass feature
1053        for mf_id, mass_feature in self.mass_features.items():
1054            # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df
1055            if mass_feature.mz != mass_feature.ms1_peak.mz_exp:
1056                continue
1057
1058            # Get the left and right limits of the EIC of the mass feature
1059            l_scan, _, r_scan = mass_feature._eic_data.apexes[0]
1060
1061            # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz
1062            ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy()
1063            ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False)
1064
1065            # Get the centroided masses of the mass feature
1066            mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp
1067
1068            # Find the closest mz in the ms1 data to the centroided masses of the mass feature
1069            ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[
1070                find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values)
1071            ]
1072
1073            # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz
1074            ms1_data_sub["mz_diff_rel"] = (
1075                np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"])
1076                / ms1_data_sub["mz"]
1077            )
1078            ms1_data_sub = ms1_data_sub[
1079                ms1_data_sub["mz_diff_rel"]
1080                < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel
1081            ].reset_index(drop=True)
1082
1083            # Group by mass_feature_mz and scan and sum intensity
1084            ms1_data_sub_group = (
1085                ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"]
1086                .sum()
1087                .reset_index()
1088            )
1089
1090            # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity)
1091            corr = (
1092                ms1_data_sub_group.pivot(
1093                    index="scan", columns="mass_feature_mz", values="intensity"
1094                )
1095                .fillna(0)
1096                .corr()
1097            )
1098
1099            # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8
1100            decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min
1101
1102            # Try catch for KeyError in case the mass feature mz is not in the correlation matrix
1103            try:
1104                corr_subset = corr.loc[mass_feature.mz]
1105            except KeyError:
1106                # If the mass feature mz is not in the correlation matrix, skip to the next mass feature
1107                continue
1108
1109            corr_subset = corr_subset[corr_subset > decon_corr_min]
1110
1111            # Get the masses from the mass spectrum that are the result of the deconvolution
1112            mzs_decon = corr_subset.index.values
1113
1114            # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature
1115            mzs_decon_idx = [
1116                id
1117                for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp)
1118                if mz in mzs_decon
1119            ]
1120            mass_feature._ms_deconvoluted_idx = mzs_decon_idx
1121
1122            # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum
1123            if (
1124                mass_feature.ms1_peak.abundance
1125                == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max()
1126            ):
1127                mass_feature.mass_spectrum_deconvoluted_parent = True
1128            else:
1129                mass_feature.mass_spectrum_deconvoluted_parent = False
1130
1131            # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature
1132            # Subset mass_feature_df to only include mass features that are within the clustering tolerance
1133            mass_feature_df_sub = mass_feature_df[
1134                abs(mass_feature.retention_time - mass_feature_df["scan_time"])
1135                < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance
1136            ].copy()
1137            # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum
1138            mass_feature_df_sub["mz_diff_ppm"] = [
1139                np.abs(mzs_decon - mz).min() / mz * 10**6
1140                for mz in mass_feature_df_sub["mz"]
1141            ]
1142            # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses
1143            mfs_associated_decon = mass_feature_df_sub[
1144                mass_feature_df_sub["mz_diff_ppm"]
1145                < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6
1146            ].index.values
1147
1148            mass_feature.associated_mass_features_deconvoluted = mfs_associated_decon
1149
1150    def _remove_redundant_mass_features(
1151        self,
1152    ) -> None:
1153        """
1154        Identify and remove redundant mass features that are likely contaminants based on their m/z values and scan frequency.
1155        Especially useful for HILIC data where signals do not return to baseline between peaks or for data with significant background noise.
1156
1157        Contaminants are characterized by:
1158        1. Similar m/z values (within ppm_tolerance)
1159        2. High frequency across scan numbers (ubiquitous presence)
1160
1161        Notes
1162        -----
1163        Depends on self.mass_features being populated, uses the parameters in self.parameters.lc_ms for tolerances (mass_feature_cluster_mz_tolerance_rel)
1164        """
1165        ppm_tolerance = (
1166            self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 1e6
1167        )
1168        min_scan_frequency = self.parameters.lc_ms.redundant_scan_frequency_min
1169        n_retain = self.parameters.lc_ms.redundant_feature_retain_n
1170
1171        df = self.mass_features_to_df()
1172
1173        if df.empty:
1174            return pd.DataFrame()
1175        # df index should be mf_id
1176        if "mf_id" not in df.columns:
1177            if "mf_id" in df.index.names:
1178                df = df.reset_index()
1179            else:
1180                raise ValueError("DataFrame must contain 'mf_id' column or index.")
1181
1182        # Sort by m/z for efficient grouping
1183        df_sorted = df.sort_values("mz").reset_index(drop=True)
1184
1185        # Calculate total number of unique scans for frequency calculation
1186        # Calculate total possible scans (check the cluster rt tolerance and the min rt and max rt of the data)
1187        total_time = self.scan_df["scan_time"].max() - self.scan_df["scan_time"].min()
1188        cluster_rt_tolerance = self.parameters.lc_ms.mass_feature_cluster_rt_tolerance
1189        # If the feature was detected in every possible scan (and then rolled up), it would be in this many scans
1190        total_scans = int(total_time / cluster_rt_tolerance) + 1
1191
1192        # Group similar m/z values using ppm tolerance
1193        mz_groups = []
1194        current_group = []
1195
1196        for i, row in df_sorted.iterrows():
1197            current_mz = row["mz"]
1198
1199            if not current_group:
1200                # Start first group
1201                current_group = [i]
1202            else:
1203                # Check if current m/z is within tolerance of group representative
1204                group_representative_mz = df_sorted.iloc[current_group[0]]["mz"]
1205                ppm_diff = (
1206                    abs(current_mz - group_representative_mz)
1207                    / group_representative_mz
1208                    * 1e6
1209                )
1210
1211                if ppm_diff <= ppm_tolerance:
1212                    # Add to current group
1213                    current_group.append(i)
1214                else:
1215                    # Start new group, but first process current group
1216                    if len(current_group) > 0:
1217                        mz_groups.append(current_group)
1218                    current_group = [i]
1219
1220        # Don't forget the last group
1221        if current_group:
1222            mz_groups.append(current_group)
1223
1224        # Analyze each m/z group for contaminant characteristics
1225
1226        for group_indices in mz_groups:
1227            group_data = df_sorted.iloc[group_indices]
1228
1229            # Calculate group statistics
1230            unique_scans = group_data["apex_scan"].nunique()
1231            scan_frequency = unique_scans / total_scans
1232
1233            # Check if this group meets contaminant criteria
1234            if scan_frequency >= min_scan_frequency:
1235                group_data = group_data.sort_values("intensity", ascending=False)
1236                non_representative_mf_id = group_data.iloc[n_retain:][
1237                    "mf_id"
1238                ].tolist()  # These will be removed
1239
1240                self.mass_features = {
1241                    k: v
1242                    for k, v in self.mass_features.items()
1243                    if k not in non_representative_mf_id
1244                }
1245
1246    def _remove_mass_features_by_peak_metrics(self, induced_features=False) -> None:
1247        """Remove mass features based on peak metrics defined in mass_feature_attribute_filter_dict.
1248
1249        This method filters mass features based on various peak shape metrics and quality indicators
1250        such as noise scores, Gaussian similarity, tailing factors, dispersity index, etc.
1251
1252        The filtering criteria are defined in the mass_feature_attribute_filter_dict parameter,
1253        which should contain attribute names as keys and filter specifications as values.
1254
1255        Filter specification format:
1256        {attribute_name: {'value': threshold, 'operator': comparison}}
1257
1258        Available operators:
1259        - '>' or 'greater': Keep features where attribute > threshold
1260        - '<' or 'less': Keep features where attribute < threshold
1261        - '>=' or 'greater_equal': Keep features where attribute >= threshold
1262        - '<=' or 'less_equal': Keep features where attribute <= threshold
1263
1264        Examples:
1265        - {'noise_score_max': {'value': 0.5, 'operator': '>='}} - Keep features with noise_score_max >= 0.5
1266        - {'dispersity_index': {'value': 0.1, 'operator': '<'}} - Keep features with dispersity_index < 0.1
1267        - {'gaussian_similarity': {'value': 0.7, 'operator': '>='}} - Keep features with gaussian_similarity >= 0.7
1268
1269        Parameters
1270        ----------
1271        induced_features : bool, optional
1272            If True, filter induced_mass_features instead of regular mass_features. Default is False.
1273
1274        Returns
1275        -------
1276        None
1277            Modifies self.mass_features or self.induced_mass_features in place by removing filtered features.
1278
1279        Raises
1280        ------
1281        ValueError
1282            If no mass features are found, if an invalid attribute is specified, or if filter specification is malformed.
1283        """
1284        # Select the appropriate mass features dictionary
1285        if induced_features:
1286            mf_dict = self.induced_mass_features
1287            mf_type = "induced mass features"
1288        else:
1289            mf_dict = self.mass_features
1290            mf_type = "mass features"
1291
1292        if mf_dict is None or len(mf_dict) == 0:
1293            raise ValueError(
1294                f"No {mf_type} found, run {'gap filling' if induced_features else 'find_mass_features()'} first"
1295            )
1296
1297        filter_dict = self.parameters.lc_ms.mass_feature_attribute_filter_dict
1298
1299        if not filter_dict:
1300            # No filtering criteria specified, return early
1301            return
1302
1303        verbose = self.parameters.lc_ms.verbose_processing
1304        initial_count = len(mf_dict)
1305
1306        if verbose:
1307            print(
1308                f"Filtering {mf_type} using peak metrics. Initial count: {initial_count}"
1309            )
1310
1311        # List to collect IDs of mass features to remove
1312        features_to_remove = []
1313
1314        for mf_id, mass_feature in mf_dict.items():
1315            should_remove = False
1316
1317            for attribute_name, filter_spec in filter_dict.items():
1318                # Validate filter specification structure
1319                if not isinstance(filter_spec, dict):
1320                    raise ValueError(
1321                        f"Filter specification for '{attribute_name}' must be a dictionary with 'value' and 'operator' keys"
1322                    )
1323
1324                if "value" not in filter_spec or "operator" not in filter_spec:
1325                    raise ValueError(
1326                        f"Filter specification for '{attribute_name}' must contain both 'value' and 'operator' keys"
1327                    )
1328
1329                threshold_value = filter_spec["value"]
1330                operator = filter_spec["operator"].lower().strip()
1331
1332                # Validate operator
1333                valid_operators = {
1334                    ">",
1335                    "<",
1336                    ">=",
1337                    "<=",
1338                    "greater",
1339                    "less",
1340                    "greater_equal",
1341                    "less_equal",
1342                }
1343                if operator not in valid_operators:
1344                    raise ValueError(
1345                        f"Invalid operator '{operator}' for attribute '{attribute_name}'. Valid operators: {valid_operators}"
1346                    )
1347
1348                # Normalize operator names
1349                operator_map = {
1350                    "greater": ">",
1351                    "less": "<",
1352                    "greater_equal": ">=",
1353                    "less_equal": "<=",
1354                }
1355                operator = operator_map.get(operator, operator)
1356
1357                # Get the attribute value from the mass feature
1358                try:
1359                    if hasattr(mass_feature, attribute_name):
1360                        attribute_value = getattr(mass_feature, attribute_name)
1361                    else:
1362                        raise ValueError(
1363                            f"Mass feature does not have attribute '{attribute_name}'"
1364                        )
1365
1366                    # Handle None values or attributes that haven't been calculated
1367                    if attribute_value is None:
1368                        if verbose:
1369                            print(
1370                                f"Warning: Mass feature {mf_id} has None value for '{attribute_name}'. Removing feature."
1371                            )
1372                        should_remove = True
1373                        break
1374
1375                    # Handle numpy arrays (like half_height_width which returns mean)
1376                    if hasattr(attribute_value, "__len__") and not isinstance(
1377                        attribute_value, str
1378                    ):
1379                        # For arrays, we use the mean or appropriate summary statistic
1380                        if attribute_name == "half_height_width":
1381                            # half_height_width property already returns the mean
1382                            pass
1383                        else:
1384                            attribute_value = float(np.mean(attribute_value))
1385
1386                    # Handle NaN values
1387                    if np.isnan(float(attribute_value)):
1388                        if verbose:
1389                            print(
1390                                f"Warning: Mass feature {mf_id} has NaN value for '{attribute_name}'. Removing feature."
1391                            )
1392                        should_remove = True
1393                        break
1394
1395                    # Apply the threshold comparison based on operator
1396                    attribute_value = float(attribute_value)
1397                    threshold_value = float(threshold_value)
1398
1399                    if operator == ">" and not (attribute_value > threshold_value):
1400                        should_remove = True
1401                        break
1402                    elif operator == "<" and not (attribute_value < threshold_value):
1403                        should_remove = True
1404                        break
1405                    elif operator == ">=" and not (attribute_value >= threshold_value):
1406                        should_remove = True
1407                        break
1408                    elif operator == "<=" and not (attribute_value <= threshold_value):
1409                        should_remove = True
1410                        break
1411
1412                except (AttributeError, ValueError, TypeError) as e:
1413                    if verbose:
1414                        print(
1415                            f"Error evaluating filter '{attribute_name}' for mass feature {mf_id}: {e}"
1416                        )
1417                    should_remove = True
1418                    break
1419
1420            if should_remove:
1421                features_to_remove.append(mf_id)
1422
1423        # Remove filtered mass features
1424        for mf_id in features_to_remove:
1425            del mf_dict[mf_id]
1426
1427        if verbose and len(features_to_remove) > 0:
1428            print(
1429                f"Removed {len(features_to_remove)} {mf_type} based on peak metrics. Remaining: {len(mf_dict)}"
1430            )
1431
1432        # Update the appropriate dictionary
1433        if induced_features:
1434            self.induced_mass_features = mf_dict
1435        else:
1436            self.mass_features = mf_dict
1437
1438        # Clean up unassociated EICs and ms1 data (only for regular features)
1439        self._remove_unassociated_eics()
1440        self._remove_unassociated_ms1_spectra()
1441
1442    def _remove_unassociated_eics(self) -> None:
1443        """Remove EICs that are not associated with any mass features.
1444
1445        This method cleans up the eics attribute by removing any EICs that do not correspond to
1446        any mass features currently stored in the mass_features attribute. This is useful for
1447        freeing up memory and ensuring that only relevant EICs are retained.
1448
1449        Returns
1450        -------
1451        None
1452            Modifies self.eics in place by removing unassociated EICs.
1453        """
1454        if self.mass_features is None or len(self.mass_features) == 0:
1455            self.eics = {}
1456            return
1457
1458        # Get the set of m/z values associated with current mass features
1459        associated_mzs = {mf.mz for mf in self.mass_features.values()}
1460
1461        # Remove EICs that are not associated with any mass features
1462        self.eics = {mz: eic for mz, eic in self.eics.items() if mz in associated_mzs}
1463
1464    def _remove_unassociated_ms1_spectra(self) -> None:
1465        """Remove MS1 spectra that are not associated with any mass features.
1466        This method cleans up the _ms_unprocessed attribute by removing any MS1 spectra that do not correspond to
1467        any mass features currently stored in the mass_features attribute. This is useful for freeing up memory
1468        and ensuring that only relevant MS1 spectra are retained.
1469
1470        Returns
1471        -------
1472        None
1473        """
1474        if self.mass_features is None or len(self.mass_features) == 0:
1475            self._ms_unprocessed = {}
1476            return
1477
1478        # Get the set of m/z values associated with current mass features
1479        associated_ms1_scans = {mf.apex_scan for mf in self.mass_features.values()}
1480        associated_ms1_scans = [int(scan) for scan in associated_ms1_scans]
1481
1482        # Get keys within the _ms attribute (these are individual MassSpectrum objects)
1483        current_stored_spectra = list(set(self._ms.keys()))
1484        if len(current_stored_spectra) == 0:
1485            return
1486        current_stored_spectra = [int(scan) for scan in current_stored_spectra]
1487
1488        # Filter the current_stored_spectra to only ms1 scans
1489        current_stored_spectra_ms1 = [
1490            scan for scan in current_stored_spectra if scan in self.ms1_scans
1491        ]
1492
1493        # Remove MS1 spectra that are not associated with any mass features
1494        scans_to_drop = [
1495            scan
1496            for scan in current_stored_spectra_ms1
1497            if scan not in associated_ms1_scans
1498        ]
1499        for scan in scans_to_drop:
1500            if scan in self._ms:
1501                del self._ms[scan]
1502
1503
1504class PHCalculations:
1505    """Methods for performing calculations related to 2D peak picking via persistent homology on LCMS data.
1506
1507    Notes
1508    -----
1509    This class is intended to be used as a mixin for the LCMSBase class.
1510
1511    Methods
1512    -------
1513    * sparse_mean_filter(idx, V, radius=[0, 1, 1]).
1514        Sparse implementation of a mean filter.
1515    * embed_unique_indices(a).
1516        Creates an array of indices, sorted by unique element.
1517    * sparse_upper_star(idx, V).
1518        Sparse implementation of an upper star filtration.
1519    * check_if_grid(data).
1520        Check if the data is gridded in mz space.
1521    * grid_data(data).
1522        Grid the data in the mz dimension.
1523    * find_mass_features_ph(ms_level=1, grid=True).
1524        Find mass features within an LCMSBase object using persistent homology.
1525    * cluster_mass_features(drop_children=True).
1526        Cluster regions of interest.
1527    """
1528
1529    @staticmethod
1530    def sparse_mean_filter(idx, V, radius=[0, 1, 1]):
1531        """Sparse implementation of a mean filter.
1532
1533        Parameters
1534        ----------
1535        idx : :obj:`~numpy.array`
1536            Edge indices for each dimension (MxN).
1537        V : :obj:`~numpy.array`
1538            Array of intensity data (Mx1).
1539        radius : float or list
1540            Radius of the sparse filter in each dimension. Values less than
1541            zero indicate no connectivity in that dimension.
1542
1543        Returns
1544        -------
1545        :obj:`~numpy.array`
1546            Filtered intensities (Mx1).
1547
1548        Notes
1549        -----
1550        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos.
1551        This is a static method.
1552        """
1553
1554        # Copy indices
1555        idx = idx.copy().astype(V.dtype)
1556
1557        # Scale
1558        for i, r in enumerate(radius):
1559            # Increase inter-index distance
1560            if r < 1:
1561                idx[:, i] *= 2
1562
1563            # Do nothing
1564            elif r == 1:
1565                pass
1566
1567            # Decrease inter-index distance
1568            else:
1569                idx[:, i] /= r
1570
1571        # Connectivity matrix
1572        cmat = KDTree(idx)
1573        cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix")
1574        cmat.setdiag(1)
1575
1576        # Pair indices
1577        I, J = cmat.nonzero()
1578
1579        # Delete cmat
1580        cmat_shape = cmat.shape
1581        del cmat
1582
1583        # Sum over columns
1584        V_sum = sparse.bsr_matrix(
1585            (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype
1586        ).diagonal(0)
1587
1588        # Count over columns
1589        V_count = sparse.bsr_matrix(
1590            (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype
1591        ).diagonal(0)
1592
1593        return V_sum / V_count
1594
1595    @staticmethod
1596    def embed_unique_indices(a):
1597        """Creates an array of indices, sorted by unique element.
1598
1599        Parameters
1600        ----------
1601        a : :obj:`~numpy.array`
1602            Array of unique elements (Mx1).
1603
1604        Returns
1605        -------
1606        :obj:`~numpy.array`
1607            Array of indices (Mx1).
1608
1609        Notes
1610        -----
1611        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
1612        This is a static method.
1613        """
1614
1615        def count_tens(n):
1616            # Count tens
1617            ntens = (n - 1) // 10
1618
1619            while True:
1620                ntens_test = (ntens + n - 1) // 10
1621
1622                if ntens_test == ntens:
1623                    return ntens
1624                else:
1625                    ntens = ntens_test
1626
1627        def arange_exclude_10s(n):
1628            # How many 10s will there be?
1629            ntens = count_tens(n)
1630
1631            # Base array
1632            arr = np.arange(0, n + ntens)
1633
1634            # Exclude 10s
1635            arr = arr[(arr == 0) | (arr % 10 != 0)][:n]
1636
1637            return arr
1638
1639        # Creates an array of indices, sorted by unique element
1640        idx_sort = np.argsort(a)
1641        idx_unsort = np.argsort(idx_sort)
1642
1643        # Sorts records array so all unique elements are together
1644        sorted_a = a[idx_sort]
1645
1646        # Returns the unique values, the index of the first occurrence,
1647        # and the count for each element
1648        vals, idx_start, count = np.unique(
1649            sorted_a, return_index=True, return_counts=True
1650        )
1651
1652        # Splits the indices into separate arrays
1653        splits = np.split(idx_sort, idx_start[1:])
1654
1655        # Creates unique indices for each split
1656        idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits])
1657
1658        # Reorders according to input array
1659        idx_unq = idx_unq[idx_unsort]
1660
1661        # Magnitude of each index
1662        exp = np.log10(
1663            idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64)
1664        )
1665        idx_unq_mag = np.power(10, np.floor(exp) + 1)
1666
1667        # Result
1668        return a + idx_unq / idx_unq_mag
1669
1670    @staticmethod
1671    def _greedy_keep_mask(all_values, tol, relative):
1672        """Rows to keep, highest sort_by first.
1673
1674        ``all_values`` is aligned to a frame already sorted by ``sort_by``
1675        descending. A later row is dropped when a kept row passes every
1676        dimension. Relative limits use the kept row. Values are compared
1677        as float32. No pair matrix is built.
1678        """
1679        values = [np.asarray(col, dtype=np.float32) for col in all_values]
1680        n = len(values[0]) if values else 0
1681        keep = np.ones(n, dtype=bool)
1682        if n == 0 or any(float(t) < 0 for t in tol):
1683            return keep
1684        n_dims = len(values)
1685        grid = {}
1686        loose = []
1687
1688        def bin_index(raw, tol_d, rel):
1689            fv = np.float32(raw)
1690            if float(tol_d) == 0.0:
1691                if fv == 0:
1692                    fv = np.float32(0.0)
1693                return int(np.asarray(fv).view(np.int32))
1694            if (not rel) and float(tol_d) > 0.0:
1695                return int(math.floor(float(fv) / float(tol_d)))
1696            if rel and 0.0 < float(tol_d) < 1.0 and float(fv) > 0.0:
1697                return int(
1698                    math.floor(math.log(float(fv)) / math.log(1.0 + float(tol_d)))
1699                )
1700            return None
1701
1702        def query_bins(raw, tol_d, rel):
1703            key = bin_index(raw, tol_d, rel)
1704            if key is None:
1705                return None
1706            if float(tol_d) == 0.0:
1707                return (key,)
1708            fv = float(np.float32(raw))
1709            if not rel:
1710                width = float(tol_d)
1711                lo = int(math.floor((fv - width) / width)) - 1
1712                hi = int(math.floor((fv + width) / width)) + 1
1713                return range(lo, hi + 1)
1714            log_width = math.log(1.0 + float(tol_d))
1715            lo_v = fv / (1.0 + float(tol_d))
1716            hi_v = fv / (1.0 - float(tol_d))
1717            if lo_v <= 0.0:
1718                return None
1719            lo = int(math.floor(math.log(lo_v) / log_width)) - 1
1720            hi = int(math.floor(math.log(hi_v) / log_width)) + 1
1721            return range(lo, hi + 1)
1722
1723        def claims(parent_i, child_i):
1724            for d in range(n_dims):
1725                pv = values[d][parent_i]
1726                cv = values[d][child_i]
1727                if not np.isfinite(pv) or not np.isfinite(cv):
1728                    return False
1729                diff = np.float32(np.abs(np.float32(cv) - np.float32(pv)))
1730                if relative[d]:
1731                    limit = np.float32(np.float32(tol[d]) * np.float32(pv))
1732                else:
1733                    limit = np.float32(tol[d])
1734                if diff > limit:
1735                    return False
1736            return True
1737
1738        for i in range(n):
1739            if not all(np.isfinite(values[d][i]) for d in range(n_dims)):
1740                continue
1741            bins = [
1742                query_bins(values[d][i], tol[d], relative[d]) for d in range(n_dims)
1743            ]
1744            if any(bucket is None for bucket in bins):
1745                search = list(loose)
1746                for bucket in grid.values():
1747                    search.extend(bucket)
1748            else:
1749                search = []
1750                for combo in product(*bins):
1751                    search.extend(grid.get(combo, ()))
1752                search.extend(loose)
1753            if any(claims(parent_i, i) for parent_i in search):
1754                keep[i] = False
1755                continue
1756            keys = [bin_index(values[d][i], tol[d], relative[d]) for d in range(n_dims)]
1757            if any(key is None for key in keys):
1758                loose.append(i)
1759            else:
1760                grid.setdefault(tuple(keys), []).append(i)
1761        return keep
1762
1763    @staticmethod
1764    def roll_up_dataframe(
1765        df: pd.DataFrame,
1766        sort_by: str,
1767        tol: list,
1768        relative: list,
1769        dims: list,
1770        memory_opt_threshold: int = 10000,
1771    ):
1772        """Subset data by rolling up into apex in appropriate dimensions.
1773
1774        Parameters
1775        ----------
1776        data : pd.DataFrame
1777            The input data containing "dims" columns and the "sort_by" column.
1778        sort_by : str
1779            The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature
1780            (i.e., the mass feature with the highest value in the sort_by column).
1781        dims : list
1782            A list of dimension names (column names in the data DataFrame) to roll up the mass features by.
1783        tol : list
1784            A list of tolerances for each dimension. The length of the list must match the number of dimensions.
1785            The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension).
1786            If relative is True, a kept row claims a later row when the absolute difference is at most tol times the kept row's value.
1787            If relative is False, the absolute difference must be at most tol.
1788        relative : list
1789            A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False).
1790        memory_opt_threshold : int, optional
1791            Accepted for compatibility. Ignored. Every frame uses the same greedy search. Default is 10000.
1792
1793        Returns
1794        -------
1795        pd.DataFrame
1796            A DataFrame with only the rolled up mass features, with the original index and columns.
1797
1798
1799        Raises
1800        ------
1801        ValueError
1802            If the input data is not a pandas DataFrame.
1803            If the input data does not have columns for each of the dimensions in "dims".
1804            If the length of "dims", "tol", and "relative" do not match.
1805        """
1806        og_columns = df.columns.copy()
1807
1808        # Unindex the data, but keep the original index
1809        if df.index.name is not None:
1810            og_index = df.index.name
1811        else:
1812            og_index = "index"
1813        df = df.reset_index(drop=False)
1814
1815        # Sort data by sort_by column, and reindex
1816        df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True)
1817
1818        # Check that data is a DataFrame and has columns for each of the dims
1819        if not isinstance(df, pd.DataFrame):
1820            raise ValueError("Data must be a pandas DataFrame")
1821        for dim in dims:
1822            if dim not in df.columns:
1823                raise ValueError(f"Data must have a column for {dim}")
1824        if len(dims) != len(tol) or len(dims) != len(relative):
1825            raise ValueError(
1826                "Dimensions, tolerances, and relative flags must be the same length"
1827            )
1828
1829        # Pre-compute all values arrays
1830        all_values = [df[dim].values for dim in dims]
1831
1832        keep = PHCalculations._greedy_keep_mask(all_values, tol, relative)
1833        df_sub = df.iloc[np.flatnonzero(keep)]
1834
1835        # Set index back to og_index and only keep original columns
1836        df_sub = df_sub.set_index(og_index).sort_index()[og_columns]
1837
1838        return df_sub
1839
1840    def sparse_upper_star(self, idx, V):
1841        """Sparse implementation of an upper star filtration.
1842
1843        Parameters
1844        ----------
1845        idx : :obj:`~numpy.array`
1846            Edge indices for each dimension (MxN).
1847        V : :obj:`~numpy.array`
1848            Array of intensity data (Mx1).
1849        Returns
1850        -------
1851        idx : :obj:`~numpy.array`
1852            Index of filtered points (Mx1).
1853        persistence : :obj:`~numpy.array`
1854            Persistence of each filtered point (Mx1).
1855
1856        Notes
1857        -----
1858        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
1859        """
1860
1861        # Invert
1862        V = -1 * V.copy().astype(int)
1863
1864        # Embed indices
1865        V = self.embed_unique_indices(V)
1866
1867        # Connectivity matrix
1868        cmat = KDTree(idx)
1869        cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix")
1870        cmat.setdiag(1)
1871        cmat = sparse.triu(cmat)
1872
1873        # Pairwise minimums
1874        I, J = cmat.nonzero()
1875        d = np.maximum(V[I], V[J])
1876
1877        # Delete connectiity matrix
1878        cmat_shape = cmat.shape
1879        del cmat
1880
1881        # Sparse distance matrix
1882        sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape)
1883
1884        # Delete pairwise mins
1885        del d, I, J
1886
1887        # Persistence homology
1888        ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0]
1889
1890        # Bound death values
1891        ph[ph[:, 1] == np.inf, 1] = np.max(V)
1892
1893        # Construct tree to query against
1894        tree = KDTree(V.reshape((-1, 1)))
1895
1896        # Get the indexes of the first nearest neighbor by birth
1897        _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1)
1898
1899        return nn, -(ph[:, 0] // 1 - ph[:, 1] // 1)
1900
1901    def check_if_grid(self, data):
1902        """Check if the data are gridded in mz space.
1903
1904        Parameters
1905        ----------
1906        data : DataFrame
1907            DataFrame containing the mass spectrometry data.  Needs to have mz and scan columns.
1908
1909        Returns
1910        -------
1911        bool
1912            True if the data is gridded in the mz direction, False otherwise.
1913
1914        Notes
1915        -----
1916        This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly.
1917        """
1918        # Calculate the difference between consecutive mz values in a single scan
1919        dat_check = data.copy().reset_index(drop=True)
1920        dat_check["mz_diff"] = np.abs(dat_check["mz"].diff())
1921        mz_diff_min = (
1922            dat_check.groupby("scan")["mz_diff"].min().min()
1923        )  # within each scan, what is the smallest mz difference between consecutive mz values
1924
1925        # Find the mininum mz difference between mz values in the data; regardless of scan
1926        dat_check_mz = dat_check[["mz"]].drop_duplicates().copy()
1927        dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True)
1928        dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff())
1929
1930        # Get minimum mz_diff between mz values in the data
1931        mz_diff_min_raw = dat_check_mz["mz_diff"].min()
1932
1933        # If the minimum mz difference between mz values in the data is less than the minimum mz difference between mz values within a single scan, then the data is not gridded
1934        if mz_diff_min_raw < mz_diff_min:
1935            return False
1936        else:
1937            return True
1938
1939    def grid_data(self, data, attempts=5):
1940        """Grid the data in the mz dimension.
1941
1942        Data must be gridded prior to persistent homology calculations and computing average mass spectrum
1943
1944        Parameters
1945        ----------
1946        data : DataFrame
1947            The input data containing mz, scan, scan_time, and intensity columns.
1948        attempts : int, optional
1949            The number of attempts to grid the data. Default is 5.
1950
1951        Returns
1952        -------
1953        DataFrame
1954            The gridded data with mz, scan, scan_time, and intensity columns.
1955
1956        Raises
1957        ------
1958        ValueError
1959            If gridding fails after the specified number of attempts.
1960        """
1961        attempt_i = 0
1962        while attempt_i < attempts:
1963            attempt_i += 1
1964            data = self._grid_data(data)
1965
1966            if self.check_if_grid(data):
1967                return data
1968
1969        if not self.check_if_grid(data):
1970            raise ValueError(
1971                "Gridding failed after "
1972                + str(attempt_i)
1973                + " attempts. Please check the data."
1974            )
1975        else:
1976            return data
1977
1978    def _grid_data(self, data):
1979        """Internal method to grid the data in the mz dimension.
1980
1981        Notes
1982        -----
1983        This method is called by the grid_data method and should not be called directly.
1984        It will attempt to grid the data in the mz dimension by creating a grid of mz values based on the minimum mz difference within each scan,
1985        but it does not check if the data is already gridded or if the gridding is successful.
1986
1987        Parameters
1988        ----------
1989        data : pd.DataFrame or pl.DataFrame
1990            The input data to grid.
1991
1992        Returns
1993        -------
1994        pd.DataFrame or pl.DataFrame
1995            The data after attempting to grid it in the mz dimension.
1996        """
1997        # Calculate the difference between consecutive mz values in a single scan for grid spacing
1998        data_w = data.copy().reset_index(drop=True)
1999        data_w["mz_diff"] = np.abs(data_w["mz"].diff())
2000        mz_diff_min = data_w.groupby("scan")["mz_diff"].min().min() * 0.99999
2001
2002        # Need high intensity mz values first so they are parents in the output pairs stack
2003        dat_mz = data_w[["mz", "intensity"]].sort_values(
2004            by=["intensity"], ascending=False
2005        )
2006        dat_mz = dat_mz[["mz"]].drop_duplicates().reset_index(drop=True).copy()
2007        # Same kept centers as the distance-pair grid. A 1D index replaces the
2008        # pair matrix, which grew with the square of the scan count.
2009        mz_dat_np = np.sort(
2010            _mz_grid_centers(dat_mz["mz"].to_numpy(dtype=np.float64), mz_diff_min)
2011        )
2012
2013        # Sort data by mz and recast mz to nearest value in mz_dat_np
2014        data_w = data_w.sort_values(by=["mz"]).reset_index(drop=True).copy()
2015        data_w["mz_new"] = mz_dat_np[find_closest(mz_dat_np, data_w["mz"].values)]
2016        data_w["mz_diff"] = np.abs(data_w["mz"] - data_w["mz_new"])
2017
2018        # Rename mz_new as mz; drop mz_diff; groupby scan and mz and sum intensity
2019        new_data_w = data_w.rename(columns={"mz": "mz_orig", "mz_new": "mz"}).copy()
2020        new_data_w = (
2021            new_data_w.drop(columns=["mz_diff", "mz_orig"])
2022            .groupby(["scan", "mz"])["intensity"]
2023            .sum()
2024            .reset_index()
2025        )
2026        new_data_w = (
2027            new_data_w.sort_values(by=["scan", "mz"], ascending=[True, True])
2028            .reset_index(drop=True)
2029            .copy()
2030        )
2031
2032        return new_data_w
2033
2034    def _filter_data_by_targets(self, data, target_search_dict):
2035        """Filter MS data to only include m/z and RT windows around target values.
2036
2037        Parameters
2038        ----------
2039        data : pd.DataFrame
2040            MS data with 'mz' and 'scan_time' columns
2041        target_search_dict : dict
2042            Dictionary with target_mz_list, target_rt_list, mz_tolerance_ppm, rt_tolerance
2043
2044        Returns
2045        -------
2046        pd.DataFrame
2047            Filtered data containing only points within target windows
2048        """
2049        target_mz_list = target_search_dict["target_mz_list"]
2050        target_rt_list = target_search_dict["target_rt_list"]
2051        mz_tolerance_ppm = target_search_dict["mz_tolerance_ppm"]
2052        rt_tolerance = target_search_dict["rt_tolerance"]
2053
2054        # Create a mask for data points that fall within any target window
2055        mask = np.zeros(len(data), dtype=bool)
2056
2057        for target_mz, target_rt in zip(target_mz_list, target_rt_list):
2058            # Calculate m/z window
2059            mz_tol = target_mz * mz_tolerance_ppm / 1e6
2060            mz_min = target_mz - mz_tol
2061            mz_max = target_mz + mz_tol
2062
2063            # Calculate RT window
2064            rt_min = target_rt - rt_tolerance
2065            rt_max = target_rt + rt_tolerance
2066
2067            # Create mask for this target
2068            target_mask = (
2069                (data["mz"] >= mz_min)
2070                & (data["mz"] <= mz_max)
2071                & (data["scan_time"] >= rt_min)
2072                & (data["scan_time"] <= rt_max)
2073            )
2074
2075            # Combine with overall mask
2076            mask |= target_mask
2077
2078        return data[mask].reset_index(drop=True)
2079
2080    def find_mass_features_ph(
2081        self,
2082        ms_level=1,
2083        grid=True,
2084        targeted_search=False,
2085        target_search_dict=None,
2086        mf_type="untargeted",
2087        accumulate_features=False,
2088    ):
2089        """Find mass features within an LCMSBase object using persistent homology.
2090
2091        Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id)
2092
2093        Parameters
2094        ----------
2095        ms_level : int, optional
2096            The MS level to use. Default is 1.
2097        grid : bool, optional
2098            If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True.
2099        targeted_search : bool, optional
2100            If True, perform targeted search mode. Default is False.
2101        target_search_dict : dict or None, optional
2102            Dictionary with target parameters for targeted search. Default is None.
2103        mf_type : str, optional
2104            Type label for the mass features. Default is "untargeted".
2105        accumulate_features : bool, optional
2106            If True, add to existing features rather than replacing them. Default is False.
2107
2108        Raises
2109        ------
2110        ValueError
2111            If no MS level data is found on the object.
2112            If data is not gridded and grid is False.
2113
2114        Returns
2115        -------
2116        None, but assigns the mass_features attribute to the object.
2117
2118        Notes
2119        -----
2120        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
2121        """
2122        # Check that ms_level is a key in self._ms_uprocessed
2123        if ms_level not in self._ms_unprocessed.keys():
2124            raise ValueError(
2125                "No MS level "
2126                + str(ms_level)
2127                + " data found, did you instantiate with parser specific to MS level?"
2128            )
2129
2130        # Get ms data
2131        data = self._ms_unprocessed[ms_level].copy()
2132
2133        # Drop rows with missing intensity values and reset index
2134        data = data.dropna(subset=["intensity"]).reset_index(drop=True)
2135
2136        # Add scan_time for filtering if in targeted mode
2137        if targeted_search:
2138            data = data.merge(
2139                self.scan_df[["scan", "scan_time"]], on="scan", how="left"
2140            )
2141
2142        # Threshold data (bypass thresholds in targeted mode)
2143        dims = ["mz", "scan_time"]
2144        if targeted_search:
2145            # In targeted mode, bypass intensity and persistence thresholds
2146            threshold = 0
2147            persistence_threshold = 0
2148            # Filter data to only target windows
2149            data_thres = self._filter_data_by_targets(data, target_search_dict)
2150            if len(data_thres) == 0:
2151                if self.parameters.lc_ms.verbose_processing:
2152                    print("No data found in target windows")
2153                self.mass_features = {}
2154                return
2155        else:
2156            threshold = max(
2157                self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max(),
2158                self.parameters.lc_ms.ph_inten_min_abs,
2159            )
2160            persistence_threshold = (
2161                self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max()
2162            )
2163            data_thres = (
2164                data[data["intensity"] > threshold].reset_index(drop=True).copy()
2165            )
2166
2167        # One m/z grid for the whole run. A separate grid in each scan window
2168        # moves the apex of the same ion.
2169        if not self.check_if_grid(data_thres):
2170            if grid is False:
2171                raise ValueError(
2172                    "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function"
2173                )
2174            data_thres = self.grid_data(data_thres)
2175        if "scan_time" not in data_thres.columns:
2176            data_thres = data_thres.merge(
2177                self.scan_df[["scan", "scan_time"]], on="scan"
2178            )
2179
2180        return self._find_mass_features_ph_partition(
2181            data_thres,
2182            dims,
2183            persistence_threshold,
2184            mf_type,
2185            accumulate_features,
2186        )
2187
2188    def _find_mass_features_ph_partition(
2189        self,
2190        data_thres,
2191        dims,
2192        persistence_threshold,
2193        mf_type="untargeted",
2194        accumulate_features=False,
2195    ):
2196        """Find mass features on overlapping scan partitions of one m/z grid.
2197
2198        Splits the already gridded input into overlapping scan partitions,
2199        detects mass features with persistent homology, rolls up duplicates
2200        within and across partitions, and populates mass_features.
2201
2202        Parameters
2203        ----------
2204        data_thres : pd.DataFrame
2205            The thresholded input data containing mass spectrometry information.
2206        dims : list
2207            List of dimension names (e.g., ["mz", "scan_time"]) used for feature detection.
2208        persistence_threshold : float
2209            Minimum persistence value required for a detected mass feature to be retained.
2210        mf_type : str, optional
2211            Type label for the mass features. Default is "untargeted".
2212        accumulate_features : bool, optional
2213            If True, add to existing features rather than replacing them. Default is False.
2214
2215        Returns
2216        -------
2217        None
2218            Populates the mass_features attribute of the object with detected mass features.
2219        """
2220        all_mass_features = []
2221
2222        # Split scans into partitions
2223        unique_scans = sorted(data_thres["scan"].unique())
2224        unique_scans_n = len(unique_scans)
2225        if unique_scans_n == 0:
2226            if not accumulate_features:
2227                self.mass_features = {}
2228            return
2229
2230        # Calculate partition size in scans based on goal
2231        partition_size_goal = 5000
2232        scans_per_partition = max(
2233            1, partition_size_goal // (len(data_thres) // unique_scans_n)
2234        )
2235
2236        # Make partitions based on scans, with overlapping in partitioned scans
2237        scan_overlap = 4
2238        partition_scans = []
2239        for i in range(0, unique_scans_n, scans_per_partition):
2240            start_idx = max(0, i - scan_overlap)
2241            end_idx = min(
2242                unique_scans_n - 1, i + scans_per_partition - 1 + scan_overlap
2243            )
2244            scans_group = [int(s) for s in unique_scans[start_idx : end_idx + 1]]
2245            partition_scans.append(scans_group)
2246
2247        # Set index to scan for faster filtering
2248        data_thres = data_thres.set_index("scan")
2249        for scans in partition_scans:
2250            # Determine start and end scan for partition, with 5 scans overlap
2251            partition_data = data_thres.loc[scans].reset_index(drop=False).copy()
2252
2253            if len(partition_data) == 0:
2254                continue
2255
2256            if "scan_time" not in partition_data.columns:
2257                partition_data = partition_data.merge(
2258                    self.scan_df[["scan", "scan_time"]], on="scan"
2259                )
2260
2261            # Build factors for this partition
2262            factors = {
2263                dim: pd.factorize(partition_data[dim], sort=True)[1].astype(np.float32)
2264                for dim in dims
2265            }
2266
2267            # Build indexes
2268            index = {
2269                dim: np.searchsorted(factors[dim], partition_data[dim]).astype(
2270                    np.float32
2271                )
2272                for dim in factors
2273            }
2274
2275            # Process partition
2276            partition_features = self._process_partition_ph(
2277                partition_data, index, dims, persistence_threshold
2278            )
2279
2280            if len(partition_features) == 0:
2281                continue
2282
2283            # Roll up within partition
2284            partition_features = self.roll_up_dataframe(
2285                df=partition_features,
2286                sort_by="persistence",
2287                dims=["mz", "scan_time"],
2288                tol=[
2289                    self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2290                    self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2291                ],
2292                relative=[True, False],
2293            )
2294            partition_features = partition_features.reset_index(drop=True)
2295
2296            if len(partition_features) > 0:
2297                all_mass_features.append(partition_features)
2298
2299        # Combine results from all partitions
2300        if all_mass_features:
2301            combined_features = pd.concat(all_mass_features, ignore_index=True)
2302
2303            # Sort by persistence
2304            combined_features = combined_features.sort_values(
2305                by="persistence", ascending=False
2306            ).reset_index(drop=True)
2307
2308            # Remove duplicates from overlapping regions
2309            combined_features = self.roll_up_dataframe(
2310                df=combined_features,
2311                sort_by="persistence",
2312                dims=["mz", "scan_time"],
2313                tol=[
2314                    self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2315                    self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2316                ],
2317                relative=[True, False],
2318            )
2319
2320            # resort by persistence and reset index
2321            combined_features = combined_features.reset_index(drop=True)
2322
2323            # Populate mass_features attribute
2324            self._populate_mass_features(
2325                combined_features, mf_type, accumulate_features
2326            )
2327        else:
2328            self.mass_features = {}
2329
2330    def _process_partition_ph(self, partition_data, index, dims, persistence_threshold):
2331        """Process a single partition with persistent homology."""
2332        # Smooth data
2333        iterations = self.parameters.lc_ms.ph_smooth_it
2334        smooth_radius = [
2335            self.parameters.lc_ms.ph_smooth_radius_mz,
2336            self.parameters.lc_ms.ph_smooth_radius_scan,
2337        ]
2338
2339        index_array = np.vstack([index[dim] for dim in dims]).T
2340        V = partition_data["intensity"].values
2341        resid = np.inf
2342
2343        for i in range(iterations):
2344            # Previous iteration
2345            V_prev = V.copy()
2346            resid_prev = resid
2347            V = self.sparse_mean_filter(index_array, V, radius=smooth_radius)
2348
2349            # Calculate residual with previous iteration
2350            resid = np.sqrt(np.mean(np.square(V - V_prev)))
2351
2352            # Evaluate convergence
2353            if i > 0:
2354                # Percent change in residual
2355                test = np.abs(resid - resid_prev) / resid_prev
2356
2357                # Exit criteria
2358                if test <= 0:
2359                    break
2360
2361        # Overwrite values
2362        partition_data = partition_data.copy()
2363        partition_data["intensity"] = V
2364
2365        # Use persistent homology to find regions of interest
2366        pidx, pers = self.sparse_upper_star(index_array, V)
2367        pidx = pidx[pers > 1]
2368        pers = pers[pers > 1]
2369
2370        if len(pidx) == 0:
2371            return pd.DataFrame()
2372
2373        # Get peaks
2374        peaks = partition_data.iloc[pidx, :].reset_index(drop=True)
2375
2376        # Add persistence column
2377        peaks["persistence"] = pers
2378        mass_features = peaks.sort_values(
2379            by="persistence", ascending=False
2380        ).reset_index(drop=True)
2381
2382        # Filter by persistence threshold
2383        mass_features = mass_features.loc[
2384            mass_features["persistence"] > persistence_threshold, :
2385        ].reset_index(drop=True)
2386
2387        return mass_features
2388
2389    def _populate_mass_features(
2390        self, mass_features_df, mf_type="untargeted", accumulate_features=False
2391    ):
2392        """Populate the mass_features attribute from a DataFrame.
2393
2394        Parameters
2395        ----------
2396        mass_features_df : pd.DataFrame
2397            DataFrame containing mass feature information.
2398            Note that the order of this DataFrame will determine the order of mass features in the mass_features attribute.
2399        mf_type : str, optional
2400            Type label for the mass features. Default is "untargeted".
2401        accumulate_features : bool, optional
2402            If True, new features will be added to existing features rather than replacing them.
2403            Mass feature IDs will be offset to avoid conflicts. Default is False.
2404
2405        Returns
2406        -------
2407        None, but assigns or updates the mass_features attribute to the object.
2408        """
2409        # Rename scan column to apex_scan
2410        mass_features_df = mass_features_df.rename(
2411            columns={"scan": "apex_scan", "scan_time": "retention_time"}
2412        )
2413
2414        # Initialize or preserve existing mass_features attribute
2415        if (
2416            accumulate_features
2417            and self.mass_features is not None
2418            and len(self.mass_features) > 0
2419        ):
2420            # Find the maximum existing ID to offset new IDs and avoid conflicts
2421            id_offset = max(self.mass_features.keys()) + 1
2422            initial_count = len(self.mass_features)
2423        else:
2424            # Replace mode (default/backwards compatible)
2425            self.mass_features = {}
2426            id_offset = 0
2427            initial_count = 0
2428
2429        # Add new mass features
2430        for idx, row in enumerate(mass_features_df.itertuples()):
2431            row_dict = mass_features_df.iloc[row.Index].to_dict()
2432            lcms_feature = LCMSMassFeature(self, **row_dict)
2433            lcms_feature.type = mf_type
2434            # Use sequential ID starting from id_offset to avoid conflicts with existing features
2435            new_id = idx + id_offset
2436            lcms_feature._id = new_id  # Update the internal ID
2437            self.mass_features[new_id] = lcms_feature
2438
2439        if self.parameters.lc_ms.verbose_processing:
2440            if accumulate_features and initial_count > 0:
2441                print(
2442                    f"Found {len(mass_features_df)} new mass features (total: {len(self.mass_features)})"
2443                )
2444            else:
2445                print("Found " + str(len(mass_features_df)) + " initial mass features")
2446
2447    def find_mass_features_ph_centroid(
2448        self,
2449        ms_level=1,
2450        targeted_search=False,
2451        target_search_dict=None,
2452        mf_type="untargeted",
2453        accumulate_features=False,
2454    ):
2455        """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data.
2456
2457        Parameters
2458        ----------
2459        ms_level : int, optional
2460            The MS level to use. Default is 1.
2461        targeted_search : bool, optional
2462            If True, perform targeted search mode. Default is False.
2463        target_search_dict : dict or None, optional
2464            Dictionary with target parameters for targeted search. Default is None.
2465        mf_type : str, optional
2466            Type label for the mass features. Default is "untargeted".
2467        accumulate_features : bool, optional
2468            If True, add to existing features rather than replacing them. Default is False.
2469
2470        Raises
2471        ------
2472        ValueError
2473            If no MS level data is found on the object.
2474
2475        Returns
2476        -------
2477        None, but assigns the mass_features attribute to the object.
2478        """
2479        # Check that ms_level is a key in self._ms_uprocessed
2480        if ms_level not in self._ms_unprocessed.keys():
2481            raise ValueError(
2482                "No MS level "
2483                + str(ms_level)
2484                + " data found, did you instantiate with parser specific to MS level?"
2485            )
2486
2487        # Work with reference instead of copy
2488        data = self._ms_unprocessed[ms_level]
2489
2490        # Merge with scan data first (needed for filtering in targeted mode)
2491        scan_subset = self.scan_df[["scan", "scan_time"]]
2492        data_with_time = data.merge(scan_subset, on="scan", how="inner")
2493
2494        # Calculate threshold and filter (bypass in targeted mode)
2495        if targeted_search:
2496            # In targeted mode, bypass intensity threshold
2497            threshold = 0
2498            valid_mask = data_with_time["intensity"].notna()
2499            required_cols = ["mz", "intensity", "scan", "scan_time"]
2500            data_thres = data_with_time.loc[valid_mask, required_cols].copy()
2501
2502            # Filter to target windows
2503            data_thres = self._filter_data_by_targets(data_thres, target_search_dict)
2504
2505            if len(data_thres) == 0:
2506                if self.parameters.lc_ms.verbose_processing:
2507                    print("No data found in target windows")
2508                self.mass_features = {}
2509                return
2510        else:
2511            # Normal mode with threshold
2512            max_intensity = data_with_time["intensity"].max()
2513            threshold = max(
2514                self.parameters.lc_ms.ph_inten_min_rel * max_intensity,
2515                self.parameters.lc_ms.ph_inten_min_abs,
2516            )
2517            valid_mask = data_with_time["intensity"].notna() & (
2518                data_with_time["intensity"] > threshold
2519            )
2520            required_cols = ["mz", "intensity", "scan", "scan_time"]
2521            data_thres = data_with_time.loc[valid_mask, required_cols].copy()
2522
2523        data_thres["persistence"] = data_thres["intensity"]
2524        mf_df = data_thres
2525        del data_thres, scan_subset, data_with_time
2526
2527        # Order by scan_time and then mz to ensure features near in rt are processed together
2528        # It's ok that different scans are in different partitions; we will roll up later
2529        mf_df = mf_df.sort_values(
2530            by=["scan_time", "mz"], ascending=[True, True]
2531        ).reset_index(drop=True)
2532        partition_size = 10000
2533        partitions = [
2534            mf_df.iloc[i : i + partition_size].reset_index(drop=True)
2535            for i in range(0, len(mf_df), partition_size)
2536        ]
2537        del mf_df
2538
2539        # Run roll_up_dataframe on each partition
2540        rolled_partitions = []
2541        for part in partitions:
2542            rolled = self.roll_up_dataframe(
2543                df=part,
2544                sort_by="persistence",
2545                dims=["mz", "scan_time"],
2546                tol=[
2547                    self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2548                    self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2549                ],
2550                relative=[True, False],
2551            )
2552            rolled_partitions.append(rolled)
2553        del partitions
2554
2555        # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries
2556
2557        # Combine results and run a final roll-up to merge features near partition boundaries
2558        mf_df_final = pd.concat(rolled_partitions, ignore_index=True)
2559        del rolled_partitions
2560
2561        # Reorder by persistence before final roll-up
2562        mf_df_final = mf_df_final.sort_values(
2563            by="persistence", ascending=False
2564        ).reset_index(drop=True)
2565
2566        mf_df_final = self.roll_up_dataframe(
2567            df=mf_df_final,
2568            sort_by="persistence",
2569            dims=["mz", "scan_time"],
2570            tol=[
2571                self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2572                self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2573            ],
2574            relative=[True, False],
2575        )
2576        # reset index
2577        mf_df_final = mf_df_final.reset_index(drop=True)
2578
2579        # Combine rename and sort operations
2580        mass_features = (
2581            mf_df_final.rename(
2582                columns={"scan": "apex_scan", "scan_time": "retention_time"}
2583            )
2584            .sort_values(by="persistence", ascending=False)
2585            .reset_index(drop=True)
2586        )
2587        del mf_df_final  # Free memory
2588
2589        # Order by persistence and reset index
2590        mass_features = mass_features.sort_values(
2591            by="persistence", ascending=False
2592        ).reset_index(drop=True)
2593
2594        self.mass_features = {}
2595        for idx, row in mass_features.iterrows():
2596            row_dict = row.to_dict()
2597            lcms_feature = LCMSMassFeature(self, **row_dict)
2598            lcms_feature.type = mf_type
2599            self.mass_features[lcms_feature.id] = lcms_feature
2600
2601        if self.parameters.lc_ms.verbose_processing:
2602            print("Found " + str(len(mass_features)) + " initial mass features")
2603
2604    def cluster_mass_features(self, drop_children=True, sort_by="persistence"):
2605        """Cluster mass features
2606
2607        Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence.
2608
2609        Parameters
2610        ----------
2611        drop_children : bool, optional
2612            Whether to drop the mass features that are not cluster parents. Default is True.
2613        sort_by : str, optional
2614            The column to sort the mass features by, this will determine which mass features get rolled up into a parent mass feature. Default is "persistence".
2615
2616        Raises
2617        ------
2618        ValueError
2619            If no mass features are found.
2620            If too many mass features are found.
2621
2622        Returns
2623        -------
2624        None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents.
2625        """
2626        if self.mass_features is None:
2627            raise ValueError("No mass features found, run find_mass_features() first")
2628        if len(self.mass_features) > 400000:
2629            raise ValueError(
2630                "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold"
2631            )
2632        dims = ["mz", "scan_time"]
2633        mf_df_og = self.mass_features_to_df()
2634        mf_df = mf_df_og.copy()
2635
2636        tol = [
2637            self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2638            self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2639        ]  # mz, in relative; scan_time in minutes
2640        relative = [True, False]
2641
2642        # Roll up mass features based on their proximity in the declared dimensions
2643        mf_df_new = self.roll_up_dataframe(
2644            df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative
2645        )
2646
2647        mf_df["cluster_parent"] = np.where(
2648            np.isin(mf_df.index, mf_df_new.index), True, False
2649        )
2650
2651        # get mass feature ids of features that are not cluster parents
2652        cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values
2653        if drop_children is True:
2654            # Drop mass features that are not cluster parents from self
2655            self.mass_features = {
2656                k: v
2657                for k, v in self.mass_features.items()
2658                if k not in cluster_daughters
2659            }
2660        else:
2661            return cluster_daughters
2662
2663
2664class LCMSCollectionCalculations:
2665    """Methods for performing calculations related to LCMSCollection objects.
2666
2667    Notes
2668    -----
2669    This class is intended as a mixin for the LCMSCollection class.
2670    """
2671
2672    @staticmethod
2673    def _resolve_eic_query_mz(row):
2674        """Pick m/z used to look up ``sample.eics`` for a mass-feature row.
2675
2676        Prefer ``_eic_mz`` when present (set after integrate / gap-fill).
2677        Regular features in ``mass_features_dataframe`` often have NaN
2678        ``_eic_mz`` (never back-filled from objects on light load / reload),
2679        while induced features get ``_eic_mz`` written after gap-fill. Fall
2680        back to the feature ``mz`` so regular and representative traces are
2681        not silently dropped from multi-sample EIC plots.
2682        """
2683        if row is None:
2684            return None
2685        eic_mz = row.get("_eic_mz") if hasattr(row, "get") else None
2686        if eic_mz is not None and not pd.isna(eic_mz):
2687            return eic_mz
2688        mz = row.get("mz") if hasattr(row, "get") else None
2689        if mz is not None and not pd.isna(mz):
2690            return mz
2691        return None
2692
2693    @staticmethod
2694    def _get_eic_data_for_mz(sample, eic_mz, tolerance=0.0001):
2695        """Resolve EIC data for an m/z using exact key, then tolerance match.
2696
2697        Plotting previously used ``sample.eics.get(eic_mz)`` only. HDF5 EIC
2698        keys and feature ``_eic_mz`` / ``mz`` often differ by float noise, which
2699        silently dropped traces (including the representative). Elsewhere
2700        CoreMS uses the same default tolerance via
2701        ``get_eic_mz_for_mass_feature`` / ``associate_eics_with_mass_features``.
2702
2703        Parameters
2704        ----------
2705        sample : LCMSBase
2706            Sample that holds ``eics`` (dict keyed by m/z).
2707        eic_mz : float or None
2708            Target m/z (typically from the mass-feature ``_eic_mz`` or ``mz``).
2709        tolerance : float, optional
2710            Maximum |Δm/z| for fallback matching. Default is 0.0001 Da.
2711
2712        Returns
2713        -------
2714        EIC_Data or None
2715            Matching EIC data, or None if no key is within tolerance.
2716        """
2717        if eic_mz is None or pd.isna(eic_mz):
2718            return None
2719        if not hasattr(sample, "eics") or not sample.eics:
2720            return None
2721
2722        eic_data = sample.eics.get(eic_mz)
2723        if eic_data is not None:
2724            return eic_data
2725
2726        # Exact key miss: tolerance match (same default as associate_eics_with_mass_features)
2727        if hasattr(sample, "get_eic_mz_for_mass_feature"):
2728            matched_mz = sample.get_eic_mz_for_mass_feature(
2729                float(eic_mz), tolerance=tolerance
2730            )
2731            if matched_mz is not None:
2732                return sample.eics.get(matched_mz)
2733            return None
2734
2735        # Fallback if sample is a simple mock without the helper
2736        best_key = min(sample.eics, key=lambda k: abs(float(k) - float(eic_mz)))
2737        if abs(float(best_key) - float(eic_mz)) < tolerance:
2738            return sample.eics[best_key]
2739        return None
2740
2741    @staticmethod
2742    def _plot_multiple_eics(
2743        ax,
2744        cluster_mfs,
2745        induced_cluster_mfs,
2746        rep_sample_id,
2747        rep_mf_id,
2748        median_rt,
2749        eic_buffer_time,
2750        plot_smoothed=False,
2751        plot_datapoints=False,
2752        label_samples=False,
2753        lcms_collection=None,
2754    ):
2755        """Internal method to plot multiple EICs from different samples on a given axis.
2756
2757        Parameters
2758        ----------
2759        ax : matplotlib.axes.Axes
2760            The axis to plot on.
2761        cluster_mfs : pd.DataFrame
2762            DataFrame containing cluster mass features (non-induced).
2763        induced_cluster_mfs : pd.DataFrame or None
2764            DataFrame containing induced (gap-filled) mass features.
2765        rep_sample_id : int
2766            Sample ID of the representative mass feature.
2767        rep_mf_id : int
2768            Mass feature ID of the representative mass feature.
2769        median_rt : float
2770            Median retention time for the cluster.
2771        eic_buffer_time : float
2772            Time buffer around the peak (minutes).
2773        plot_smoothed : bool, optional
2774            If True, plot smoothed EICs. Default is False.
2775        plot_datapoints : bool, optional
2776            If True, plot EIC datapoints. Default is False.
2777        label_samples : bool, optional
2778            If True, label each sample individually. Default is False.
2779        lcms_collection : LCMSCollection, optional
2780            The parent collection object for accessing samples. Required.
2781        """
2782        ax.set_title("EICs from all samples", loc="left")
2783
2784        # Track if we've added labels for legend (to avoid duplicates)
2785        rep_labeled = False
2786        regular_labeled = False
2787        induced_labeled = False
2788
2789        # Plot regular (non-induced) mass features
2790        for _, row in cluster_mfs.iterrows():
2791            sample_id = int(row["sample_id"])
2792            mf_id = row["mf_id"]
2793            sample = lcms_collection[sample_id]
2794            sample_name = row["sample_name"]
2795
2796            # Prefer _eic_mz, else feature mz; then exact/tolerance key match (#257)
2797            eic_data = LCMSCollectionCalculations._get_eic_data_for_mz(
2798                sample, LCMSCollectionCalculations._resolve_eic_query_mz(row)
2799            )
2800
2801            if eic_data:
2802                # Determine line style and width
2803                if sample_id == rep_sample_id and mf_id == rep_mf_id:
2804                    # Representative feature - bold line
2805                    linewidth = 2.5
2806                    alpha = 1.0
2807                    color = "tab:blue"
2808                    if label_samples:
2809                        label = f"{sample_name} (representative)"
2810                    else:
2811                        label = "Representative" if not rep_labeled else None
2812                        rep_labeled = True
2813                else:
2814                    # Other features - thinner line
2815                    linewidth = 1.0
2816                    alpha = 0.5
2817                    color = "tab:blue"
2818                    if label_samples:
2819                        label = sample_name
2820                    else:
2821                        label = "Regular features" if not regular_labeled else None
2822                        regular_labeled = True
2823
2824                ax.plot(
2825                    eic_data.time,
2826                    eic_data.eic,
2827                    c=color,
2828                    linewidth=linewidth,
2829                    alpha=alpha,
2830                    linestyle="-",
2831                    label=label,
2832                )
2833
2834                if plot_datapoints:
2835                    ax.scatter(eic_data.time, eic_data.eic, c=color, alpha=alpha, s=10)
2836
2837                if plot_smoothed and hasattr(eic_data, "eic_smoothed"):
2838                    ax.plot(
2839                        eic_data.time,
2840                        eic_data.eic_smoothed,
2841                        c=color,
2842                        linestyle="--",
2843                        alpha=alpha * 0.8,
2844                        linewidth=linewidth * 0.8,
2845                    )
2846
2847        # Plot induced (gap-filled) mass features if available
2848        if induced_cluster_mfs is not None and not induced_cluster_mfs.empty:
2849            for _, row in induced_cluster_mfs.iterrows():
2850                sample_id = int(row["sample_id"])
2851                mf_id = row["mf_id"]
2852                sample = lcms_collection[sample_id]
2853                sample_name = row["sample_name"]
2854
2855                # Prefer _eic_mz, else feature mz; then exact/tolerance key match (#257)
2856                eic_data = LCMSCollectionCalculations._get_eic_data_for_mz(
2857                    sample, LCMSCollectionCalculations._resolve_eic_query_mz(row)
2858                )
2859
2860                if eic_data:
2861                    # Induced features - even thinner line
2862                    linewidth = 0.5
2863                    alpha = 0.4
2864                    color = "tab:orange"
2865
2866                    if label_samples:
2867                        label = f"{sample_name} (induced)"
2868                    else:
2869                        label = "Gap-filled features" if not induced_labeled else None
2870                        induced_labeled = True
2871
2872                    ax.plot(
2873                        eic_data.time,
2874                        eic_data.eic,
2875                        c=color,
2876                        linewidth=linewidth,
2877                        alpha=alpha,
2878                        linestyle="-",
2879                        label=label,
2880                    )
2881
2882                    if plot_datapoints:
2883                        ax.scatter(
2884                            eic_data.time, eic_data.eic, c=color, alpha=alpha, s=5
2885                        )
2886
2887                    if plot_smoothed and hasattr(eic_data, "eic_smoothed"):
2888                        ax.plot(
2889                            eic_data.time,
2890                            eic_data.eic_smoothed,
2891                            c=color,
2892                            linestyle="--",
2893                            alpha=alpha * 0.8,
2894                            linewidth=linewidth * 0.8,
2895                        )
2896
2897        # Add vertical line at median RT
2898        ax.axvline(x=median_rt, color="k", linestyle="--", alpha=0.7, label="Median RT")
2899
2900        ax.set_ylabel("Intensity")
2901        ax.set_xlabel("Time (minutes)")
2902        ax.set_xlim(
2903            median_rt - eic_buffer_time,
2904            median_rt + eic_buffer_time,
2905        )
2906        ax.legend(loc="upper left", fontsize=8)
2907        ax.yaxis.get_major_formatter().set_useOffset(False)
2908
2909    def clean_sparse_matrix(self, sparse_matrix):
2910        """Clean a sparse matrix by removing duplicates and sorting.
2911
2912        Parameters
2913        ----------
2914        sparse_matrix : :obj:`~numpy.array`
2915            A sparse matrix to clean.
2916
2917        Returns
2918        -------
2919        :obj:`~numpy.array`
2920            A cleaned sparse matrix.
2921        """
2922        for match in sparse_matrix:
2923            match.sort()
2924        sparse_matrix.sort()
2925        dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0)
2926        return dereplicated_sparse_matrix
2927
2928    def match_mfs(self, mf_c, mf_i):
2929        """Match mass features between two LCMS objects.
2930
2931        Parameters
2932        ----------
2933        mf_c : :obj:`~pandas.DataFrame`
2934            The mass features to match against.
2935        mf_i : :obj:`~pandas.DataFrame`
2936            The mass features to match.
2937
2938        Returns
2939        -------
2940        :obj:`~pandas.DataFrame`
2941            The matched mass features from mf_c.
2942        :obj:`~pandas.DataFrame`
2943            The matched mass features from mf_i.
2944
2945        Notes
2946        -----
2947        This function has been adapted from the original implementation in the Deimos package:
2948        https://github.com/pnnl/deimos
2949        """
2950        if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1:
2951            return None, None
2952
2953        # Prepare dataframes
2954        mf_c = mf_c.copy()
2955        mf_c["id_i"] = 0
2956        mf_i = mf_i.copy()
2957        mf_i["id_i"] = 1
2958
2959        # Set dimensions for matching
2960        dims = ["mz", "scan_time"]
2961        relative = [True, False]
2962        mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6
2963        rt_tol = self.parameters.lcms_collection.alignment_rt_tol
2964        tol = [mz_tol, rt_tol]
2965
2966        # Compute inter-feature distances
2967        idx = []
2968        for i, f in enumerate(dims):
2969            # vectors
2970            v1 = mf_c[f].values.reshape(-1, 1)
2971            v2 = mf_i[f].values.reshape(-1, 1)
2972
2973            # Distances
2974            d = scipy.spatial.distance.cdist(v1, v2)
2975
2976            if relative[i] is True:
2977                # Divisor
2978                basis = np.repeat(v1, v2.shape[0], axis=1)
2979                fix = np.repeat(v2, v1.shape[0], axis=1).T
2980                basis = np.where(basis == 0, fix, basis)
2981
2982                # Divide
2983                d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0)
2984
2985            # Check tol
2986            idx.append(d <= tol[i])
2987
2988        # Stack truth arrays
2989        idx = np.prod(np.dstack(idx), axis=-1, dtype=bool)
2990
2991        # Compute normalized 3d distance
2992        v1 = mf_c[dims].values / tol
2993        v2 = mf_i[dims].values / tol
2994        dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock")
2995
2996        # Separate features within tolerance from those outside
2997        # Features outside tolerance should be inf, features within tolerance keep their distance
2998        # Use idx mask: True for within tolerance, False for outside
2999        dist3d_within_tol = np.where(idx, dist3d, np.inf)
3000
3001        # Normalize to 0-1 (only affects within-tolerance distances)
3002        mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0
3003        if mx > 0:
3004            # Lower distance is better - normalize only the within-tolerance values
3005            dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf)
3006        else:
3007            # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs
3008            dist3d_within_tol = np.where(idx, 1e-10, np.inf)
3009
3010        # Use the masked distance matrix
3011        dist3d = dist3d_within_tol
3012
3013        # Min over dims
3014        mincols = np.min(dist3d, axis=0, keepdims=True)
3015
3016        # Zero out mincols over dims
3017        dist3d[dist3d != mincols] = np.inf
3018
3019        # Min over clusters
3020        minrows = np.min(dist3d, axis=1, keepdims=True)
3021
3022        # Where max and nonzero
3023        ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf))
3024
3025        # Reorder
3026        mf_c = mf_c.iloc[ii]
3027        mf_i = mf_i.iloc[jj]
3028
3029        if len(mf_c.index) < 1 or len(mf_i.index) < 1:
3030            return None, None
3031
3032        return mf_c, mf_i
3033
3034    def fit_rts(self, a, b, align="scan_time", **kwargs):
3035        """
3036        Fit a support vector regressor to matched features.
3037
3038        Parameters
3039        ----------
3040        a : :obj:`~pandas.DataFrame`
3041            First set of input feature coordinates and intensities; the center object and the object to align to.
3042        b : :obj:`~pandas.DataFrame`
3043            Second set of input feature coordinates and intensities; the object to align to the center object.
3044        align : str
3045            Dimension to align.
3046        kwargs
3047            Keyword arguments for support vector regressor
3048            (:class:`sklearn.svm.SVR`).
3049
3050        Returns
3051        -------
3052        :obj:`~function`
3053            An interpolation function where one can input a retention time and get the predicted retention time.
3054
3055        Notes
3056        -----
3057        This function has been adapted from the original implementation in the Deimos package:
3058        https://github.com/pnnl/deimos
3059
3060        """
3061
3062        # Uniqueify
3063        x = a[align].values
3064        y = b[align].values
3065        arr = np.vstack((x, y)).T
3066        arr = np.unique(arr, axis=0)
3067
3068        # Safety check: ensure we have data to work with
3069        if len(arr) == 0:
3070            warnings.warn(
3071                "No data points available for retention time fitting. Returning identity function."
3072            )
3073            return lambda x: x
3074
3075        # Check kwargs
3076        if "kernel" in kwargs:
3077            kernel = kwargs.get("kernel")
3078        else:
3079            kernel = "linear"
3080
3081        # Construct interpolation axis
3082        newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000)
3083
3084        # Linear kernel
3085        if kernel == "linear":
3086            reg = scipy.stats.linregress(x, y)
3087            newy = reg.slope * newx + reg.intercept
3088
3089        # Other kernels
3090        else:
3091            # Fit
3092            svr = SVR(**kwargs)
3093            svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0])
3094
3095            # Predict
3096            newy = svr.predict(newx.reshape(-1, 1))
3097
3098        # Pad x and y_pred with zeros to force interpolation to start at 0
3099        newx = np.concatenate(([0], newx))
3100        newy = np.concatenate(([0], newy))
3101
3102        # Pad x and y_pred with max time to force interpolation to end at max time to force interpolation to match at end max time
3103        max_time = self[0].scan_df["scan_time"].max()
3104        newx = np.concatenate((newx, [max_time]))
3105        newy = np.concatenate((newy, [max_time]))
3106
3107        # Return an interpolation function for the x and y_pred
3108        def interp(x):
3109            pred_y = np.interp(x, newx, newy)
3110            return pred_y
3111
3112        return interp
3113
3114    def get_anchor_mass_features(self, mf_df):
3115        """
3116        Get the anchor mass features from a DataFrame of mass features.
3117
3118        Parameters
3119        ----------
3120        mf_df : :obj:`~pandas.DataFrame`
3121            The mass features to filter to just the anchor mass features.
3122
3123        Returns
3124        -------
3125        :obj:`~pandas.DataFrame`
3126            The anchor mass features dataframe.
3127        """
3128        mf_df = mf_df.copy()
3129
3130        if (
3131            "deconvoluted_mass_spectra"
3132            in self.parameters.lcms_collection.mass_feature_anchor_technique
3133        ):
3134            # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent
3135            mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"])
3136            mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]]
3137
3138        if (
3139            "absolute_intensity"
3140            in self.parameters.lcms_collection.mass_feature_anchor_technique
3141        ):
3142            # Drop features that have an intensity lower than the threshold
3143            threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold
3144            mf_df = mf_df[mf_df["intensity"] > threshold]
3145
3146        if (
3147            "relative_intensity"
3148            in self.parameters.lcms_collection.mass_feature_anchor_technique
3149        ):
3150            # Drop features in the lower fraction of intensities
3151            threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold
3152            intensity_threshold = mf_df["intensity"].quantile(threshold_quantile)
3153            mf_df = mf_df[mf_df["intensity"] >= intensity_threshold]
3154
3155        return mf_df
3156
3157    def attempt_alignment(self, matches_c, matches_i):
3158        """
3159        Check if alignment is needed for the LCMS objects in the collection.
3160        """
3161
3162        # Hold out a subset of matches_c and matches_i for spline fitting
3163        matches_c.reset_index(drop=False, inplace=True)
3164        matches_i.reset_index(drop=False, inplace=True)
3165
3166        # Check if there are enough matches to attempt alignment
3167        minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches
3168        if len(matches_c) < minimum_matches:
3169            # Return False (no alignment) and identity function (returns original time)
3170            # which isn't used but is a placeholder to avoid errors in downstream code since
3171            # the function expects a callable to be returned
3172            return False, lambda x: x
3173
3174        # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c
3175        matches_c = matches_c.sort_values(by="scan_time")
3176        matches_i = matches_i.iloc[matches_c.index.values]
3177
3178        hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction
3179        # starting with an array of length len(matches_c), select equally spaced indices to hold out
3180        idx_holdout = matches_c.index.values[
3181            np.arange(0, len(matches_c), int(1 / hold_out_fraction))
3182        ]
3183
3184        matches_c_holdout = matches_c.loc[idx_holdout].copy()
3185        matches_i_holdout = matches_i.loc[idx_holdout].copy()
3186
3187        # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index
3188        matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name")
3189        matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name")
3190
3191        # Reset the scan_time to the original scan_time
3192        matches_i = matches_i.copy()
3193        matches_i["scan_time"] = matches_i["scan_time_og"]
3194
3195        # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features
3196        spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000)
3197
3198        # Check if the spline fitting improved the alignment for the holdout matches
3199        matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"])
3200        og_diff = np.abs(
3201            matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"]
3202        )
3203        fit_diff = np.abs(
3204            matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"]
3205        )
3206
3207        if (
3208            "fraction_improved"
3209            in self.parameters.lcms_collection.alignment_acceptance_technique
3210        ):
3211            fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff)
3212            use_spline_alignment = (
3213                fraction_improved
3214                > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold
3215            )
3216        if (
3217            "mean_squared_error_improved"
3218            in self.parameters.lcms_collection.alignment_acceptance_technique
3219        ):
3220            mse_og = np.mean(og_diff**2)
3221            mse = np.mean(fit_diff**2)
3222            use_spline_alignment = mse < mse_og
3223            # Convert to boolean
3224            use_spline_alignment = bool(use_spline_alignment)
3225
3226        return use_spline_alignment, spl
3227
3228    def align_lcms_objects(self, overwrite=False):
3229        """
3230        Align LCMS objects in the collection.
3231
3232        Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects.
3233        First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects,
3234        starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects
3235        are then fit to the center LCMS object using the matched mass features.
3236
3237        Returns
3238        -------
3239        None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object.
3240
3241        Notes
3242        -----
3243        This function has been adapted from the original implementation in the Deimos package:
3244        https://github.com/pnnl/deimos
3245        """
3246
3247        # Prepare the center LCMS object
3248        center_obj_ids = self.manifest_dataframe[
3249            self.manifest_dataframe["center"]
3250        ].collection_id.values
3251
3252        full_mf_df = self.mass_features_dataframe
3253        # re-index to sample_name for faster lookups
3254        full_mf_df = full_mf_df.reset_index().set_index("sample_name")
3255        samples_with_features = set(full_mf_df.index.get_level_values("sample_name"))
3256
3257        if "scan_time_aligned" in full_mf_df.columns and not overwrite:
3258            raise ValueError("Mass features have already been aligned")
3259
3260        def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline):
3261            """Set scan_time_aligned for one sample using spline or identity mapping."""
3262            if use_alignment and spline is not None:
3263                self[sample_idx]._scan_info["scan_time_aligned"] = {
3264                    k: spline(v)
3265                    for k, v in self[sample_idx]._scan_info["scan_time"].items()
3266                }
3267                return True
3268
3269            self[sample_idx]._scan_info["scan_time_aligned"] = (
3270                self[sample_idx]._scan_info["scan_time"].copy()
3271            )
3272            return False
3273
3274        def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline):
3275            """Return next sample index/dataframe with features, aligning empty samples on the way."""
3276            i = start_idx
3277            while 0 <= i < len(self):
3278                sample_name = self.samples[i]
3279                if sample_name in samples_with_features:
3280                    mf_df_i = full_mf_df.loc[sample_name].copy()
3281                    mf_df_i["scan_time_og"] = mf_df_i["scan_time"]
3282                    mf_df_i = mf_df_i.reset_index(drop=False)
3283                    if use_alignment and spline is not None:
3284                        # Use previous step transform as a better matching starting point.
3285                        mf_df_i["scan_time"] = spline(mf_df_i["scan_time"])
3286                    return i, mf_df_i
3287
3288                _set_scan_time_alignment_for_sample(i, use_alignment, spline)
3289                self.rt_alignment_attempted = True
3290                i += index_step
3291
3292            return i, None
3293
3294        anchor_mf_dfs = []
3295        for center_obj_id in center_obj_ids:
3296            # Get the anchor mass features from the center LCMS object
3297            mf_df_c = full_mf_df.loc[self.samples[center_obj_id]]
3298            mf_df_c = self.get_anchor_mass_features(mf_df_c)
3299            anchor_mf_dfs.append(mf_df_c)
3300
3301            # Set scan_time_aligned to scan_time for the center LCMS object
3302            center_scan_df = self[center_obj_id].scan_df.copy()
3303            center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"]
3304            self[center_obj_id].scan_df = center_scan_df
3305
3306            # Store alignment data for center object (identity mapping)
3307            center_sample_name = self.samples[center_obj_id]
3308
3309            index_steps = (1, -1)
3310            # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing)
3311            for index_step in index_steps:
3312                # Initialize spline for propagation to samples without features
3313                spl = None
3314                use_spline_alignment = False
3315
3316                # Loop through the other LCMS objects in this direction.
3317                i, mf_df_i = _get_feature_df_at_or_after(
3318                    center_obj_id + index_step,
3319                    index_step,
3320                    use_spline_alignment,
3321                    spl,
3322                )
3323
3324                while mf_df_i is not None:
3325                    mf_df_i = self.get_anchor_mass_features(mf_df_i)
3326
3327                    # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object.
3328                    matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i)
3329
3330                    if matches_c is not None:
3331                        use_spline_alignment, spl = self.attempt_alignment(
3332                            matches_c, matches_i
3333                        )
3334
3335                        # Record if we used alignment for this sample
3336                        sample_name = self.samples[i]
3337                        self._manifest_dict[sample_name]["use_rt_alignment"] = (
3338                            use_spline_alignment
3339                        )
3340
3341                        if use_spline_alignment:
3342                            # Set new retention times on scan_df for lc_obj using the spline fitting
3343                            matches_i["scan_time_fit"] = spl(matches_i["scan_time"])
3344
3345                        self.rt_aligned = _set_scan_time_alignment_for_sample(
3346                            i, use_spline_alignment, spl
3347                        )
3348                        self.rt_alignment_attempted = True
3349
3350                        i, mf_df_i = _get_feature_df_at_or_after(
3351                            i + index_step,
3352                            index_step,
3353                            use_spline_alignment,
3354                            spl,
3355                        )
3356                    else:
3357                        # If no matches are found, propagate prior alignment from this index step.
3358                        sample_name = self.samples[i]
3359                        used_previous_alignment = (
3360                            use_spline_alignment and spl is not None
3361                        )
3362                        self._manifest_dict[sample_name]["use_rt_alignment"] = (
3363                            used_previous_alignment
3364                        )
3365
3366                        self.rt_aligned = _set_scan_time_alignment_for_sample(
3367                            i, used_previous_alignment, spl
3368                        )
3369                        self.rt_alignment_attempted = True
3370
3371                        i, mf_df_i = _get_feature_df_at_or_after(
3372                            i + index_step,
3373                            index_step,
3374                            used_previous_alignment,
3375                            spl,
3376                        )
3377
3378        # Now align each batch using the center objects as anchors with the other batches
3379        mf_df_c = anchor_mf_dfs[0]
3380        for i in center_obj_ids[1:]:
3381            mf_df_i = full_mf_df.loc[self.samples[i]].copy()
3382            mf_df_i["scan_time_og"] = mf_df_i["scan_time"]
3383            mf_df_i = self.get_anchor_mass_features(mf_df_i)
3384
3385            matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i)
3386            if matches_c is not None:
3387                use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i)
3388
3389                # Record if we used alignment for this sample
3390                sample_name = self.samples[i]
3391                self._manifest_dict[sample_name]["use_rt_alignment"] = (
3392                    use_spline_alignment
3393                )
3394
3395                if use_spline_alignment:
3396                    # Set new retention times on all this object's
3397                    new_times = spl(self[i].scan_df["scan_time"])
3398                    new_scan_info = self[i].scan_df.copy()
3399                    new_scan_info["scan_time_aligned"] = new_times
3400                    self[i].scan_df = new_scan_info
3401
3402                    # Get the batch that this object belongs to
3403                    batch = self.manifest[self.samples[i]]["batch"]
3404
3405                    for j in range(len(self)):
3406                        if self.manifest[self.samples[j]]["batch"] == batch:
3407                            if j != i:
3408                                sample_name_j = self.samples[j]
3409                                self._manifest_dict[sample_name_j][
3410                                    "use_rt_alignment"
3411                                ] = use_spline_alignment
3412                                new_scan_info = self[j].scan_df.copy()
3413                                aligned_times = spl(
3414                                    self[j].scan_df["scan_time_aligned"]
3415                                )
3416                                new_scan_info["scan_time_aligned"] = aligned_times
3417                                self[j].scan_df = new_scan_info
3418
3419        # Set final mass_features_dataframe with the aligned scan_time
3420        center_sample_name = self.samples[center_obj_ids[0]]
3421        self._manifest_dict[center_sample_name]["use_rt_alignment"] = False
3422        new_scan_info = self[center_obj_ids[0]].scan_df.copy()
3423        new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"]
3424
3425    def add_consensus_mass_features(self):
3426        """
3427        Create consensus mass features by clustering aligned features across samples.
3428
3429        This method clusters mass features from all samples in the collection based on
3430        their m/z and aligned retention time proximity. Features that cluster together
3431        across samples are assigned a common cluster ID, creating consensus features
3432        that represent the same compound detected across multiple samples.
3433
3434        The clustering process:
3435        1. Partitions features by m/z to avoid large sparse matrices and enable parallelization
3436        2. Clusters features within each partition using hierarchical clustering
3437        3. Merges partition-boundary clusters that represent the same feature
3438        4. Filters out clusters not present in minimum fraction of samples
3439
3440        Must be run after align_lcms_objects(). Results are stored in the
3441        mass_features_dataframe with a 'cluster' column added.
3442
3443        Parameters
3444        ----------
3445        None
3446            Uses parameters from self.parameters.lcms_collection:
3447            - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm)
3448            - consensus_rt_tol: retention time tolerance for clustering (minutes)
3449            - consensus_partition_size: target partition size for managing memory and parallelization
3450            - consensus_min_sample_fraction: minimum fraction of samples a cluster
3451              must appear in to be retained (0-1)
3452            - cores: number of CPU cores to use for parallel partition processing
3453
3454        Returns
3455        -------
3456        None
3457            Updates self.mass_features_dataframe in place by adding 'cluster' column
3458            and filtering to retain only clusters meeting minimum sample presence.
3459
3460        Raises
3461        ------
3462        ValueError
3463            If mass features have not been aligned (run align_lcms_objects() first).
3464
3465        Notes
3466        -----
3467        - Partitioning prevents memory issues with large sparse distance matrices
3468        - Each partition is processed in parallel (up to cores limit)
3469        - Clusters not meeting consensus_min_sample_fraction are automatically removed
3470        - Access cluster_summary_dataframe property for summary statistics
3471        - Use fill_missing_cluster_features() for gap-filling after clustering
3472
3473        See Also
3474        --------
3475        align_lcms_objects : Aligns retention times before consensus clustering
3476        cluster_summary_dataframe : Property that generates summary statistics for clusters
3477        fill_missing_cluster_features : Gap-fill missing features in clusters
3478        """
3479        # Get the combined mass features from all LCMS objects, keep the original index as a separate column
3480        combined_mfs = self.mass_features_dataframe.copy()
3481        combined_mfs["coll_mf_id"] = combined_mfs.index
3482
3483        # Check if the mass features have been aligned
3484        if "scan_time_aligned" not in combined_mfs.columns:
3485            raise ValueError(
3486                "Mass features have not been aligned, run align_lcms_objects() first"
3487            )
3488
3489        # Partition the mass features by mz so we can parallelize the matching before clustering
3490        from corems.chroma_peak.calc import subset as corems_subset
3491
3492        # get max mz from combined_mfs and calculate tolerance from ppm
3493        mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
3494        n_partition_size = self.parameters.lcms_collection.consensus_partition_size
3495        lazy_partitions = corems_subset.multi_sample_partition(
3496            combined_mfs,
3497            split_on="mz",
3498            size=n_partition_size,
3499            tol=mz_tol,
3500            relative=True,
3501        )
3502
3503        # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning
3504        if np.array(lazy_partitions._counts).max() > 2 * n_partition_size:
3505            warnings.warn(
3506                "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol."
3507            )
3508
3509        # Cluster the mass features within each partition
3510        if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions:
3511            cores_to_use = lazy_partitions.n_partitions
3512        else:
3513            cores_to_use = self.parameters.lcms_collection.cores
3514        # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use)
3515        mfs_with_clusters = lazy_partitions.map(
3516            self.cluster_mass_features_agg_cluster, processes=cores_to_use
3517        )
3518
3519        # Clean up cluster id names after partitioning
3520        new_cluster_ids = (
3521            mfs_with_clusters[["cluster", "partition_idx"]]
3522            .drop_duplicates()
3523            .reset_index(drop=True)
3524        )
3525        new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index
3526        mfs_with_clusters = mfs_with_clusters.merge(
3527            new_cluster_ids, on=["cluster", "partition_idx"]
3528        )
3529        mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"]
3530        mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"])
3531
3532        # Embed a new cluster id into the mass features dataframe and set as index
3533        mfs_with_clusters["idx"] = mfs_with_clusters.index
3534
3535        try:
3536            # Check if any clusters can be merged into a single cluster
3537            eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters)
3538
3539            # Merge clusters identified in eval_dict
3540            while len(eval_dict["merge_these_clusters"]) > 0:
3541                list_of_clusters_to_merge = [
3542                    [x[0], x[1]] for x in eval_dict["merge_these_clusters"]
3543                ]
3544                # Convert to a dataframe with columns "new_cluster" and "cluster"
3545                df = pd.DataFrame(
3546                    np.array(list_of_clusters_to_merge),
3547                    columns=["new_cluster", "cluster"],
3548                )
3549                # Drop duplicates of "child" clusters
3550                df = df.drop_duplicates("cluster", keep="first")
3551                df = df.drop_duplicates("new_cluster", keep="first")
3552                mfs_with_clusters = mfs_with_clusters.merge(
3553                    df, on="cluster", how="left"
3554                )
3555                mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna(
3556                    mfs_with_clusters["cluster"]
3557                )
3558                mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"])
3559
3560                # Re-evaluate clusters for repeats
3561                eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters)
3562                self.mass_features_dataframe = mfs_with_clusters
3563
3564        except:
3565            mfs_with_clusters.set_index("coll_mf_id", inplace=True)
3566            self.mass_features_dataframe = mfs_with_clusters
3567
3568        # Filter out clusters that don't meet minimum sample fraction
3569        self._filter_clusters_by_sample_presence()
3570
3571        # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template?
3572
3573    def _filter_clusters_by_sample_presence(self):
3574        """
3575        Filter out clusters that don't meet the minimum sample fraction threshold.
3576
3577        Removes clusters (and their associated mass features) from the mass_features_dataframe
3578        if they don't appear in at least consensus_min_sample_fraction of samples.
3579
3580        This is called automatically at the end of add_consensus_mass_features().
3581
3582        Returns
3583        -------
3584        None
3585            Updates self.mass_features_dataframe in place by removing clusters that don't
3586            meet the minimum sample presence threshold.
3587        """
3588        if (
3589            self.mass_features_dataframe is None
3590            or len(self.mass_features_dataframe) == 0
3591        ):
3592            return
3593
3594        min_sample_fraction = (
3595            self.parameters.lcms_collection.consensus_min_sample_fraction
3596        )
3597
3598        # Validate parameter
3599        if not 0 <= min_sample_fraction <= 1:
3600            raise ValueError("consensus_min_sample_fraction must be between 0 and 1")
3601
3602        # Calculate minimum number of samples required
3603        total_samples = len(self.samples)
3604        min_samples_required = min_sample_fraction * total_samples
3605
3606        # Count unique samples per cluster
3607        cluster_sample_counts = (
3608            self.mass_features_dataframe.groupby("cluster")["sample_id"]
3609            .nunique()
3610            .reset_index(name="sample_count")
3611        )
3612
3613        # Identify clusters to keep
3614        clusters_to_keep = cluster_sample_counts[
3615            cluster_sample_counts["sample_count"] > min_samples_required
3616        ]["cluster"].values
3617
3618        # Filter mass features dataframe
3619        self.mass_features_dataframe = self.mass_features_dataframe[
3620            self.mass_features_dataframe["cluster"].isin(clusters_to_keep)
3621        ]
3622
3623    def summarize_clusters(self):
3624        """
3625        Generate summary statistics for consensus mass feature clusters.
3626
3627        Computes aggregate statistics (median, mean, std, min, max) for each cluster
3628        across all samples. Combines both regular mass features and induced mass features
3629        (from gap-filling) when available to provide complete cluster statistics.
3630
3631        Must be run after add_consensus_mass_features() which creates the cluster assignments.
3632        Results are stored in cluster_summary_dataframe property and used by plotting methods.
3633
3634        Parameters
3635        ----------
3636        None
3637            Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe.
3638            Both must contain 'cluster' column.
3639
3640        Returns
3641        -------
3642        :obj:`~pandas.DataFrame` or None
3643            DataFrame with one row per cluster containing summary statistics:
3644            - cluster: cluster ID
3645            - mz_{median,mean,std,max,min}: m/z statistics
3646            - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics
3647            - half_height_width_{median,mean,std,max,min}: peak width statistics
3648            - tailing_factor_{median,mean,std,max,min}: peak shape statistics
3649            - dispersity_index_{median,mean,std,max,min}: peak quality statistics
3650            - sample_id_nunique: number of unique samples containing the cluster
3651            - intensity_{max,median,mean,std,min}: intensity statistics
3652            - persistence_{max,median,mean,std,min}: persistence statistics
3653
3654            Returns None if mass_features_dataframe is empty.
3655
3656        Notes
3657        -----
3658        - Summary DataFrame is automatically stored in cluster_summary_dataframe property
3659        - Includes both regular and induced (gap-filled) mass features when available.
3660          ``group_consensus_features`` does not use this m/z median for ion-role
3661          geometry; that median is detected features only.
3662        - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster
3663        - Sample count (sample_id_nunique) indicates cluster prevalence across samples
3664        - Filters applied by consensus_min_sample_fraction affect which clusters appear
3665
3666        See Also
3667        --------
3668        add_consensus_mass_features : Creates clusters before summarization
3669        fill_missing_cluster_features : Creates induced mass features via gap-filling
3670        plot_consensus_mz_features : Visualizes cluster summaries
3671        plot_mz_features_per_cluster : Shows cluster size distribution
3672        """
3673        # First check if there are minimum columns in the features dataframe
3674        if (
3675            self.mass_features_dataframe is None
3676            or len(self.mass_features_dataframe.columns) < 1
3677        ):
3678            return None
3679
3680        # Combine regular and induced mass features
3681        mf_df = self.mass_features_dataframe.copy()
3682        mf_df = mf_df.reset_index(drop=False)
3683
3684        # Check if induced mass features are available and combine them
3685        if (
3686            self.induced_mass_features_dataframe is not None
3687            and len(self.induced_mass_features_dataframe) > 0
3688        ):
3689            imf_df = self.induced_mass_features_dataframe.copy()
3690            imf_df = imf_df.reset_index(drop=False)
3691            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
3692            # Combine regular and induced features
3693            mf_df = pd.concat([mf_df, imf_df], axis=0)
3694            mf_df = mf_df.reset_index(drop=True)
3695
3696        # Filter out any rows with NaN cluster values before converting to int
3697        if "cluster" in mf_df.columns:
3698            mf_df = mf_df.dropna(subset=["cluster"])
3699            mf_df["cluster"] = mf_df["cluster"].astype(int)
3700
3701        # Build aggregation dictionary based on available columns
3702        agg_dict = {
3703            "mz": ["median", "mean", "std", "max", "min"],
3704            "scan_time_aligned": ["median", "mean", "std", "max", "min"],
3705            "sample_id": ["nunique"],
3706            "intensity": ["max", "median", "mean", "std", "min"],
3707        }
3708
3709        # Add optional columns if they exist
3710        optional_columns = {
3711            "half_height_width": ["median", "mean", "std", "max", "min"],
3712            "tailing_factor": ["median", "mean", "std", "max", "min"],
3713            "dispersity_index": ["median", "mean", "std", "max", "min"],
3714            "persistence": ["max", "median", "mean", "std", "min"],
3715        }
3716
3717        for col, funcs in optional_columns.items():
3718            if col in mf_df.columns:
3719                agg_dict[col] = funcs
3720
3721        summary_df = mf_df.groupby("cluster").agg(agg_dict).reset_index()
3722
3723        # Fix the column names
3724        summary_df.columns = [
3725            "_".join(col).strip()
3726            for col in summary_df.columns.values
3727            if col != "cluster"
3728        ]
3729        summary_df = summary_df.rename(columns={"cluster_": "cluster"})
3730        # Set cluster as the index for easy lookup
3731        summary_df = summary_df.set_index("cluster")
3732
3733        # Merge consensus feature-group labels when present (isotopes + adducts).
3734        # Cluster summary exposes possible_ion_types (all
3735        # residual-tied candidates) and drops ion_type so a single type column
3736        # is not mistaken for unique assignment.
3737        fg = getattr(self, "feature_group_dataframe", None)
3738        if fg is not None and len(fg) > 0:
3739            from corems.mass_spectra.calc.feature_grouping import (
3740                GROUP_COLUMNS,
3741                merge_possible_ion_types,
3742            )
3743
3744            cols = [c for c in GROUP_COLUMNS if c in fg.columns]
3745            if cols:
3746                drop_cols = [c for c in cols if c in summary_df.columns]
3747                if drop_cols:
3748                    summary_df = summary_df.drop(columns=drop_cols)
3749                # Also drop stale ion_type from a prior join shape
3750                if "ion_type" in summary_df.columns:
3751                    summary_df = summary_df.drop(columns=["ion_type"])
3752                summary_df = summary_df.join(fg[cols], how="left")
3753
3754            if (
3755                "possible_ion_types" in summary_df.columns
3756                and "ion_type" in summary_df.columns
3757            ):
3758                for cid in summary_df.index:
3759                    it = summary_df.loc[cid, "ion_type"]
3760                    poss = summary_df.loc[cid, "possible_ion_types"]
3761                    filled = merge_possible_ion_types(it, poss)
3762                    if filled is not None:
3763                        summary_df.loc[cid, "possible_ion_types"] = filled
3764            if "ion_type" in summary_df.columns:
3765                summary_df = summary_df.drop(columns=["ion_type"])
3766
3767        return summary_df
3768
3769    def group_consensus_features(self):
3770        """
3771        Group consensus features into natural-abundance isotope and adduct families.
3772
3773        After consensus clustering and gap-filling, labels clusters that share
3774        similar retention time, an interpretable **singly-charged** mass offset,
3775        and correlated cross-sample **apex peak heights**.
3776
3777        Two stages
3778        ----------
3779        1. **Isotopes.** Unit ``Atoms`` edges (e.g. ¹²C/¹³C) plus roll-up to
3780           ¹³C₂, ¹³C₃, … when intermediates exist. Spacing is the raw Atoms
3781           Δm (``|z| = 1``). Multi-charge envelopes are out of scope.
3782           Chemical mono is the geometry root, not the tallest peak.
3783        2. **Adducts.** Pairwise ``ion_type_dict`` forms in
3784           ``feature_group_ion_types`` (must be ``|z| = 1``). Empty or a
3785           single configured type skips adduct linking (isotopes only).
3786
3787        **Not** for tracer, enriched, or isotopically labeled experiments.
3788
3789        Quant gate is fixed (no settings switch):
3790
3791        - Correlation: **Pearson** only, pairwise-complete on samples with both
3792          heights > 0 (threshold ``feature_group_corr_threshold``, default 0.80)
3793        - Abundance: mass-feature apex ``intensity`` only (not integrated area)
3794
3795        Coelution and m/z delta windows reuse collection
3796        ``alignment_rt_tol`` and ``alignment_mz_tol_ppm``. Other knobs
3797        (``feature_group_isotope_atoms``, shared-sample fraction,
3798        ``feature_group_ion_types``, …) live on ``parameters.lcms_collection``.
3799
3800        ``feature_group_ion_types`` is filtered to the collection polarity
3801        (all samples share one polarity; mixed collections cannot be
3802        instantiated). Only keys ending in ``+`` are kept on positive data
3803        and only keys ending in ``-`` on negative data.
3804
3805        Preferred for collection-level families. Optional per-file
3806        ``LCMSBase.find_c13_mass_features`` remains available for single-file
3807        workflows; do not treat both as authoritative in the same pipeline.
3808
3809        Must run after ``add_consensus_mass_features()``. The m/z median used for
3810        isotope and adduct spacing is calculated from detected features only.
3811        Gap-filled m/z values stay on the induced rows and do not enter that
3812        median. The height matrix still includes gap-filled apex intensities
3813        when present; missing entries are 0.
3814
3815        Returns
3816        -------
3817        pandas.DataFrame
3818            Cluster-indexed labels with columns ``feature_group_id``,
3819            ``ion_role``, ``ion_type``, ``possible_ion_types``,
3820            ``isotope_state``, ``mono_cluster_id`` (set only when the form
3821            has at least one isotope). Also stored on
3822            ``self.feature_group_dataframe`` and merged into
3823            ``mass_features_dataframe`` / ``induced_mass_features_dataframe``.
3824
3825        Raises
3826        ------
3827        ValueError
3828            If consensus features are missing or settings are invalid.
3829        """
3830        from corems.mass_spectra.calc.feature_grouping import (
3831            GROUP_COLUMNS,
3832            HEIGHT_COL,
3833            FeatureGroupParams,
3834            build_height_matrix_from_features,
3835            detected_cluster_mz_median,
3836            group_features_arrays,
3837            normalize_ms_polarity,
3838        )
3839
3840        if (
3841            self.mass_features_dataframe is None
3842            or len(self.mass_features_dataframe) == 0
3843        ):
3844            raise ValueError(
3845                "No mass features dataframe. Run add_consensus_mass_features() first."
3846            )
3847        if "cluster" not in self.mass_features_dataframe.columns:
3848            raise ValueError(
3849                "mass_features_dataframe has no 'cluster' column. "
3850                "Run add_consensus_mass_features() first."
3851            )
3852
3853        # Clear prior labels (idempotent re-run)
3854        self.feature_group_dataframe = None
3855        for col in GROUP_COLUMNS:
3856            if col in self.mass_features_dataframe.columns:
3857                self.mass_features_dataframe = self.mass_features_dataframe.drop(
3858                    columns=[col]
3859                )
3860            if (
3861                self.induced_mass_features_dataframe is not None
3862                and col in self.induced_mass_features_dataframe.columns
3863            ):
3864                self.induced_mass_features_dataframe = (
3865                    self.induced_mass_features_dataframe.drop(columns=[col])
3866                )
3867
3868        summary = self.summarize_clusters()
3869        if summary is None or len(summary) == 0:
3870            raise ValueError(
3871                "cluster summary is empty. Run add_consensus_mass_features() first."
3872            )
3873
3874        # Collection polarity: all samples share one polarity (enforced when
3875        # LCMSCollection is loaded). Filter opposite-sign adducts.
3876        collection_polarity = None
3877        if len(self):
3878            collection_polarity = normalize_ms_polarity(
3879                getattr(self[0], "polarity", None)
3880            )
3881
3882        params = FeatureGroupParams.from_lcms_collection_settings(
3883            self.parameters.lcms_collection,
3884            polarity=collection_polarity,
3885        )
3886
3887        cluster_ids = summary.index.to_numpy()
3888        # Gap-filled apex m/z is not part of ion-role geometry.
3889        mz = detected_cluster_mz_median(self.mass_features_dataframe, cluster_ids)
3890        if "scan_time_aligned_median" not in summary.columns:
3891            raise ValueError(
3892                "cluster summary missing scan_time_aligned_median; "
3893                "ensure features are RT-aligned before consensus."
3894            )
3895        rt = summary["scan_time_aligned_median"].to_numpy(dtype=float)
3896
3897        # Sample ids as used in mass_features_dataframe
3898        sample_ids = sorted(self.mass_features_dataframe["sample_id"].dropna().unique())
3899        if self.induced_mass_features_dataframe is not None and len(
3900            self.induced_mass_features_dataframe
3901        ):
3902            sample_ids = sorted(
3903                set(sample_ids)
3904                | set(
3905                    self.induced_mass_features_dataframe["sample_id"]
3906                    .dropna()
3907                    .unique()
3908                    .tolist()
3909                )
3910            )
3911
3912        t_height = time.perf_counter()
3913        heights = build_height_matrix_from_features(
3914            self.mass_features_dataframe,
3915            cluster_ids=cluster_ids,
3916            sample_ids=sample_ids,
3917            induced_df=self.induced_mass_features_dataframe,
3918            intensity_col=HEIGHT_COL,  # apex intensity only; not area
3919        )
3920        stage_timings = {
3921            "height_matrix": time.perf_counter() - t_height,
3922            "n_ion_types_configured": float(len(params.ion_types)),
3923            "collection_polarity": (
3924                str(collection_polarity) if collection_polarity else "unknown"
3925            ),
3926        }
3927
3928        algo_timings: dict = {}
3929        labels = group_features_arrays(
3930            cluster_ids, mz, rt, heights, params, timings_out=algo_timings
3931        )
3932        stage_timings.update(algo_timings)
3933        self._last_feature_group_timings = stage_timings
3934        self.feature_group_dataframe = labels
3935
3936        from corems.mass_spectra.calc.feature_grouping import (
3937            merge_feature_group_labels_into_frames,
3938        )
3939
3940        self.mass_features_dataframe, induced = merge_feature_group_labels_into_frames(
3941            labels,
3942            self.mass_features_dataframe,
3943            self.induced_mass_features_dataframe,
3944        )
3945        if self.induced_mass_features_dataframe is not None:
3946            self.induced_mass_features_dataframe = induced
3947
3948        return labels
3949
3950    def plot_mz_features_per_cluster(self, return_fig=False, path=None):
3951        """
3952        Plot the number of mass features in a cluster against how many clusters
3953        contain that number of mass features
3954
3955        Parameters
3956        -----------
3957        return_fig : bool, optional
3958            If True, return the open figure (caller owns lifecycle).
3959            Default is False.
3960        path : str or path-like, optional
3961            If set, save the figure to this path. When ``return_fig`` is False,
3962            the figure is closed after saving and ``plt.show()`` is not called.
3963
3964        Returns
3965        --------
3966        matplotlib.figure.Figure or None
3967            The figure if ``return_fig`` is True; otherwise None.
3968
3969        Raises
3970        ------
3971        ValueError
3972            If consensus features haven't been added to the object yet
3973        """
3974
3975        if not hasattr(self, "cluster_summary_dataframe"):
3976            raise ValueError(
3977                "cluster_summary_dataframe is not set, must run add_consensus_mass_features() first"
3978            )
3979        sum_data = self.cluster_summary_dataframe
3980        fig, ax = plt.subplots()
3981        sum_data.sample_id_nunique.value_counts().sort_index().plot(ax=ax, kind="bar")
3982        plt.xlabel("Number of mass features in a cluster")
3983        plt.ylabel("Number of clusters with this many mass features")
3984        return _finalize_plot(fig, return_fig=return_fig, path=path)
3985
3986    def plot_mz_features_across_samples(
3987        self, alpha=0.75, s=0.005, return_fig=False, path=None
3988    ):
3989        """
3990        Generate Scan Time vs m/z plot of all the mass features across all
3991        samples in collection where intensity of color on the plot indicates
3992        density of mass features, NOT INTENSITY
3993
3994        Parameters
3995        -----------
3996        alpha :  float
3997            Desired transparency for plotted m/z features.  Defaults to 0.75.
3998        s : float
3999            Desired size of plotted m/z features. Defaults to 0.005.
4000        return_fig : bool, optional
4001            If True, return the open figure (caller owns lifecycle).
4002            Default is False.
4003        path : str or path-like, optional
4004            If set, save the figure to this path. When ``return_fig`` is False,
4005            the figure is closed after saving and ``plt.show()`` is not called.
4006
4007        Returns
4008        --------
4009        matplotlib.figure.Figure or None
4010            A figure displaying a scan time vs m/z scatterplot of all the m/z
4011            features identified in the collection if ``return_fig`` is True;
4012            otherwise None. Parameters alpha (transparency) and s (marker size)
4013            allow the user to emphasize the density of features. Intensity of
4014            features is not represented.
4015        """
4016        df = self.mass_features_dataframe.copy()
4017        fig = plt.figure()
4018        plt.scatter(df.scan_time_aligned, df.mz, c="tab:gray", alpha=alpha, s=s)
4019
4020        plt.xlabel("Scan time")
4021        plt.ylabel("m/z")
4022        plt.ylim(0, np.ceil(np.max(df.mz)))
4023        plt.xlim(0, np.ceil(np.max(df.scan_time)))
4024        plt.title("All mass features, all samples")
4025
4026        return _finalize_plot(fig, return_fig=return_fig, path=path)
4027
4028    def plot_consensus_mz_features(
4029        self,
4030        xb="xb",
4031        xt="xt",
4032        yb="yb",
4033        yt="yt",
4034        show_all=True,
4035        return_fig=False,
4036        path=None,
4037    ):
4038        """
4039        Generate Scan Time vs m/z plot of the consensus features scaled by size
4040        with option ('show_all') of leaving the individual m/z features in the figure.
4041
4042        Parameters
4043        -----------
4044        xb :  float
4045            Desired starting scan time value for the x-axis. Defaults to 0.
4046        xt : float
4047            Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data.
4048        yb :  float
4049            Desired starting m/z value for the y-axis. Defaults to 0.
4050        yt : float
4051            Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data.
4052        show_all : boolean
4053            Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True.
4054        return_fig : bool, optional
4055            If True, return the open figure (caller owns lifecycle).
4056            Default is False.
4057        path : str or path-like, optional
4058            If set, save the figure to this path. When ``return_fig`` is False,
4059            the figure is closed after saving and ``plt.show()`` is not called.
4060
4061        Returns
4062        --------
4063        matplotlib.figure.Figure or None
4064            A scalable figure that overlays the consensus features over all the
4065            m/z features identified in the collection if ``return_fig`` is True;
4066            otherwise None. Consensus features are scaled by how many m/z
4067            features are represented in the consensus. Figure can be scaled by
4068            inputting desired boundaries on the scan time (xb, xt) and m/z
4069            values (yb, yt).
4070        """
4071        df = self.cluster_summary_dataframe.copy()
4072        mfdf = self.mass_features_dataframe.copy()
4073
4074        fig = plt.figure()
4075        if show_all:
4076            plt.scatter(mfdf.scan_time_aligned, mfdf.mz, c="tab:gray", s=1)
4077
4078        m = plt.scatter(
4079            df.scan_time_aligned_median,
4080            df.mz_median,
4081            c="tab:orange",
4082            alpha=0.7,
4083            s=(df.sample_id_nunique**2) / 5,
4084        )
4085
4086        plt.xlabel("Scan time")
4087        plt.ylabel("m/z")
4088
4089        if xt == "xt":
4090            xt = np.ceil(np.max(mfdf.mz))
4091        if yt == "yt":
4092            yt = np.ceil(np.max(mfdf.scan_time))
4093        if xb == "xb":
4094            xb = 0
4095        if yb == "yb":
4096            yb = 0
4097        plt.ylim(xb, xt)
4098        plt.xlim(yb, yt)
4099
4100        kw = dict(
4101            prop="sizes",
4102            num=max(1, int(len(df.sample_id_nunique.unique()) / 3)),
4103            color="tab:orange",
4104            alpha=0.7,
4105            func=lambda s: np.sqrt(s * 5),
4106        )
4107
4108        plt.legend(
4109            *m.legend_elements(**kw),
4110            title="Features\nper cluster",
4111            bbox_to_anchor=(1.01, 0.4, 0.225, 0.5),
4112        )
4113        plt.tight_layout()
4114        plt.title("Consensus Features")
4115
4116        return _finalize_plot(fig, return_fig=return_fig, path=path)
4117
4118    def plot_cluster(
4119        self,
4120        cluster_id,
4121        to_plot=["EIC", "MS1", "MS2"],
4122        return_fig=False,
4123        path=None,
4124        plot_smoothed_eic=False,
4125        plot_eic_datapoints=False,
4126        eic_buffer_time=None,
4127        label_samples=False,
4128        molecular_metadata=None,
4129        spectral_library=None,
4130    ):
4131        """
4132        Plot a consensus mass feature cluster across all samples.
4133
4134        Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster,
4135        highlighting the representative mass feature.
4136
4137        Parameters
4138        ----------
4139        cluster_id : int
4140            The cluster ID to plot
4141        to_plot : list, optional
4142            List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror".
4143            Default is ["EIC", "MS1", "MS2"].
4144        return_fig : bool, optional
4145            If True, return the open figure (caller owns lifecycle).
4146            Default is False.
4147        path : str or path-like, optional
4148            If set, save the figure to this path. When ``return_fig`` is False,
4149            the figure is closed after saving and ``plt.show()`` is not called.
4150        plot_smoothed_eic : bool, optional
4151            If True, plots smoothed EICs. Default is False.
4152        plot_eic_datapoints : bool, optional
4153            If True, plots EIC data points. Default is False.
4154        eic_buffer_time : float, optional
4155            Time buffer around the peak for EIC plotting (minutes).
4156            If None, uses parameter setting. Default is None.
4157        label_samples : bool, optional
4158            If True, labels each sample in the legend. Default is False.
4159        molecular_metadata : dict, optional
4160            Dictionary mapping molecular IDs to MetaboliteMetadata objects.
4161            Required for MS2_mirror plots. Default is None.
4162        spectral_library : FlashEntropySearch, optional
4163            FlashEntropy spectral library containing MS2 spectra.
4164            Required for MS2_mirror plots to retrieve library spectra. Default is None.
4165
4166        Returns
4167        -------
4168        matplotlib.figure.Figure or None
4169            The figure object if return_fig=True, otherwise None
4170
4171        Raises
4172        ------
4173        ValueError
4174            If cluster_id is not found or if required data is not loaded
4175        """
4176        import matplotlib.pyplot as plt
4177
4178        # Get cluster summary for median values
4179        if cluster_id not in self.cluster_summary_dataframe.index:
4180            raise ValueError(
4181                f"Cluster {cluster_id} not found in cluster_summary_dataframe. "
4182                f"Run add_consensus_mass_features() first."
4183            )
4184
4185        cluster_summary = self.cluster_summary_dataframe.loc[cluster_id]
4186
4187        # Get representative mass feature info
4188        rep_info = self.get_most_representative_sample_for_cluster(cluster_id)
4189        rep_sample_id = rep_info["sample_id"]
4190        rep_mf_id = rep_info["mf_id"]
4191        rep_sample = self[rep_sample_id]
4192
4193        # Check if representative mass feature is loaded
4194        if rep_mf_id not in rep_sample.mass_features:
4195            raise ValueError(
4196                f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. "
4197                f"Run reload_representative_mass_features() or process_consensus_features() first."
4198            )
4199
4200        rep_mf = rep_sample.mass_features[rep_mf_id]
4201
4202        # Get eic buffer time
4203        if eic_buffer_time is None:
4204            eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time
4205
4206        # Adjust to_plot based on available data
4207        if rep_mf.mass_spectrum is None:
4208            to_plot = [x for x in to_plot if x != "MS1"]
4209        if len(rep_mf.ms2_mass_spectra) == 0:
4210            to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]]
4211
4212        # Check if EICs are available
4213        cluster_mfs = self.mass_features_dataframe[
4214            self.mass_features_dataframe["cluster"] == cluster_id
4215        ]
4216
4217        has_eics = False
4218        # Check regular features
4219        for _, row in cluster_mfs.iterrows():
4220            sample_id = int(row["sample_id"])
4221            sample = self[sample_id]
4222            if hasattr(sample, "eics") and sample.eics:
4223                if len(sample.eics) > 0:
4224                    has_eics = True
4225                    break
4226
4227        # Also check induced features if available
4228        induced_cluster_mfs = None
4229        if not has_eics and self.induced_mass_features_dataframe is not None:
4230            induced_cluster_mfs = self.induced_mass_features_dataframe[
4231                self.induced_mass_features_dataframe["cluster"] == cluster_id
4232            ]
4233            for _, row in induced_cluster_mfs.iterrows():
4234                sample_id = int(row["sample_id"])
4235                sample = self[sample_id]
4236                if hasattr(sample, "eics") and sample.eics:
4237                    if len(sample.eics) > 0:
4238                        has_eics = True
4239                        break
4240
4241        if not has_eics:
4242            to_plot = [x for x in to_plot if x != "EIC"]
4243            if len(to_plot) == 0:
4244                raise ValueError(
4245                    f"No plottable data available for cluster {cluster_id}. "
4246                    f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first."
4247                )
4248
4249        # Get induced features if not already retrieved
4250        if (
4251            induced_cluster_mfs is None
4252            and self.induced_mass_features_dataframe is not None
4253        ):
4254            induced_cluster_mfs = self.induced_mass_features_dataframe[
4255                self.induced_mass_features_dataframe["cluster"] == cluster_id
4256            ]
4257
4258        # Check if MS1 is deconvoluted
4259        deconvoluted = rep_mf._ms_deconvoluted_idx is not None
4260
4261        # Create figure
4262        fig, axs = plt.subplots(
4263            len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False
4264        )
4265
4266        fig.suptitle(
4267            f"Consensus Cluster {cluster_id}: "
4268            f"m/z = {cluster_summary['mz_median']:.4f} "
4269            f"(±{cluster_summary['mz_std']:.4f}); "
4270            f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min "
4271            f"(±{cluster_summary['scan_time_aligned_std']:.2f}); "
4272            f"{int(cluster_summary['sample_id_nunique'])} samples"
4273        )
4274
4275        i = 0
4276
4277        # EIC plot - show all samples using helper method
4278        if "EIC" in to_plot:
4279            self._plot_multiple_eics(
4280                axs[i][0],
4281                cluster_mfs,
4282                induced_cluster_mfs,
4283                rep_sample_id,
4284                rep_mf_id,
4285                cluster_summary["scan_time_aligned_median"],
4286                eic_buffer_time,
4287                plot_smoothed=plot_smoothed_eic,
4288                plot_datapoints=plot_eic_datapoints,
4289                label_samples=label_samples,
4290                lcms_collection=self,
4291            )
4292            i += 1
4293
4294        # MS1 plot - from representative using helper method
4295        if "MS1" in to_plot:
4296            rep_mf._plot_ms1_spectrum(
4297                axs[i][0], deconvoluted=deconvoluted, sample_name=rep_sample.sample_name
4298            )
4299            i += 1
4300
4301        # MS2 plot - from representative using helper method
4302        if "MS2" in to_plot:
4303            rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name)
4304            i += 1
4305
4306        # MS2 mirror plot - from representative using helper method
4307        if "MS2_mirror" in to_plot:
4308            rep_mf._plot_ms2_mirror(
4309                axs[i][0],
4310                molecular_metadata=molecular_metadata,
4311                spectral_library=spectral_library,
4312            )
4313            i += 1
4314
4315        plt.tight_layout()
4316
4317        return _finalize_plot(fig, return_fig=return_fig, path=path)
4318
4319    def get_representative_mass_features_for_all_clusters(
4320        self, representative_metric=None
4321    ):
4322        """
4323        Get the most representative mass feature for all clusters in bulk.
4324
4325        This is much more efficient than calling get_most_representative_sample_for_cluster
4326        in a loop, as it processes all clusters in a single pass over the dataframe.
4327
4328        Parameters
4329        ----------
4330        representative_metric : str, optional
4331            The metric to use to determine the most representative sample.
4332            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4333            Options:
4334            - 'intensity': Selects the mass feature with the highest intensity
4335            - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans,
4336              or the highest intensity overall if none have MS2
4337            Default is None (uses parameter setting).
4338
4339        Returns
4340        -------
4341        :obj:`~pandas.DataFrame`
4342            DataFrame with one row per cluster containing:
4343            - cluster: cluster ID
4344            - sample_id: The sample ID of the most representative sample
4345            - mf_id: The mass feature ID in the sample
4346            - coll_mf_id: The collection-level mass feature ID (index)
4347            - has_ms2: Whether this mass feature has MS2 scan numbers
4348            - intensity: The intensity value of the representative mass feature
4349        """
4350        # Use default from parameters if not specified
4351        if representative_metric is None:
4352            representative_metric = (
4353                self.parameters.lcms_collection.consensus_representative_metric
4354            )
4355
4356        mf_df = self.mass_features_dataframe.copy()
4357        # Reset index to make coll_mf_id a column we can work with
4358        mf_df = mf_df.reset_index(drop=False)
4359
4360        # Handle special metric 'intensity_prefer_ms2'
4361        if representative_metric == "intensity_prefer_ms2":
4362            if "intensity" not in mf_df.columns:
4363                raise ValueError(
4364                    f"'intensity' column not found in mass_features_dataframe. "
4365                    f"Available columns: {mf_df.columns.tolist()}"
4366                )
4367
4368            # Add has_ms2 flag if ms2_scan_numbers column exists
4369            if "ms2_scan_numbers" in mf_df.columns:
4370
4371                def has_ms2_scans(val):
4372                    if val is None:
4373                        return False
4374                    try:
4375                        return len(val) > 0
4376                    except (TypeError, ValueError):
4377                        return False
4378
4379                mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans)
4380
4381                # Sort by has_ms2 (descending) then intensity (descending)
4382                # This ensures features with MS2 are preferred when intensities are equal
4383                mf_df = mf_df.sort_values(
4384                    ["has_ms2", "intensity"], ascending=[False, False]
4385                )
4386            else:
4387                mf_df["has_ms2"] = False
4388                mf_df = mf_df.sort_values("intensity", ascending=False)
4389
4390            # Group by cluster and take the first (highest intensity, preferring MS2)
4391            representatives = mf_df.groupby("cluster").first().reset_index()
4392
4393        else:
4394            # Standard metric - check if it exists
4395            if representative_metric not in mf_df.columns:
4396                raise ValueError(
4397                    f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}"
4398                )
4399
4400            # Add has_ms2 flag for consistency
4401            if "ms2_scan_numbers" in mf_df.columns:
4402
4403                def has_ms2_scans(val):
4404                    if val is None:
4405                        return False
4406                    try:
4407                        return len(val) > 0
4408                    except (TypeError, ValueError):
4409                        return False
4410
4411                mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans)
4412            else:
4413                mf_df["has_ms2"] = False
4414
4415            # Get the index of max value for each cluster
4416            idx = mf_df.groupby("cluster")[representative_metric].idxmax()
4417            representatives = mf_df.loc[idx].copy()
4418
4419        # Select only the columns we need
4420        result_cols = [
4421            "cluster",
4422            "sample_id",
4423            "mf_id",
4424            "coll_mf_id",
4425            "has_ms2",
4426            "intensity",
4427        ]
4428        representatives = representatives[result_cols]
4429
4430        return representatives
4431
4432    def get_sample_mf_map_for_representatives(
4433        self, representative_metric=None, include_cluster_id=True
4434    ):
4435        """
4436        Build a mapping of sample_id -> list of representative mass feature IDs to load.
4437
4438        This is a DRY helper method used by both process_consensus_features() and
4439        ReadSavedLCMSCollection to determine which mass features should be loaded
4440        for each sample when loading representatives.
4441
4442        Parameters
4443        ----------
4444        representative_metric : str, optional
4445            The metric to use to determine the most representative sample.
4446            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4447            Default is None.
4448        include_cluster_id : bool, optional
4449            If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id.
4450            Default is True.
4451
4452        Returns
4453        -------
4454        dict
4455            Dictionary mapping sample_id (int) to list of mass feature identifiers.
4456            If include_cluster_id=True: list of tuples (mf_id, cluster_id)
4457            If include_cluster_id=False: list of mf_id integers
4458
4459        Examples
4460        --------
4461        >>> # Get map with cluster IDs for loading
4462        >>> sample_mf_map = collection.get_sample_mf_map_for_representatives()
4463        >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...}
4464        >>>
4465        >>> # Get map without cluster IDs for pipeline
4466        >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False)
4467        >>> # sample_mf_map = {0: [123, 456], 1: [789], ...}
4468        """
4469        # Get all representative mass features in bulk (much faster than looping)
4470        representatives = self.get_representative_mass_features_for_all_clusters(
4471            representative_metric=representative_metric
4472        )
4473
4474        # Build sample_mf_map
4475        sample_mf_map = {}
4476        for _, row in representatives.iterrows():
4477            sample_id = row["sample_id"]
4478            mf_id = row["mf_id"]
4479            cluster_id = row["cluster"]
4480
4481            if sample_id not in sample_mf_map:
4482                sample_mf_map[sample_id] = []
4483
4484            if include_cluster_id:
4485                sample_mf_map[sample_id].append((mf_id, cluster_id))
4486            else:
4487                sample_mf_map[sample_id].append(mf_id)
4488
4489        return sample_mf_map
4490
4491    def get_most_representative_sample_for_cluster(
4492        self, cluster_id, representative_metric=None
4493    ):
4494        """
4495        Get the most representative sample for a given cluster based on a metric.
4496
4497        Parameters
4498        ----------
4499        cluster_id : int
4500            The cluster ID to find the representative sample for.
4501        representative_metric : str, optional
4502            The metric to use to determine the most representative sample.
4503            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4504            Options:
4505            - 'intensity': Selects the mass feature with the highest intensity
4506            - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans,
4507              or the highest intensity overall if none have MS2
4508            Default is None (uses parameter setting).
4509
4510        Returns
4511        -------
4512        dict
4513            Dictionary containing:
4514            - 'sample_id': The sample ID of the most representative sample
4515            - 'sample_name': The sample name of the most representative sample
4516            - 'mf_id': The mass feature ID in the sample
4517            - 'coll_mf_id': The collection-level mass feature ID (index)
4518            - 'has_ms2': Whether this mass feature has MS2 scan numbers
4519            - 'intensity': The intensity value of the representative mass feature
4520
4521        Raises
4522        ------
4523        ValueError
4524            If cluster_id is not found or if representative_metric is not a valid column.
4525        """
4526        # Use the bulk method to get all representatives, then filter to this cluster
4527        # This follows DRY principle and ensures consistency
4528        all_representatives = self.get_representative_mass_features_for_all_clusters(
4529            representative_metric=representative_metric
4530        )
4531
4532        # Filter to the requested cluster
4533        cluster_rep = all_representatives[all_representatives["cluster"] == cluster_id]
4534
4535        if len(cluster_rep) == 0:
4536            # Try to provide helpful error message
4537            available_clusters = self.mass_features_dataframe["cluster"].unique()
4538            raise ValueError(
4539                f"Cluster {cluster_id} not found in mass_features_dataframe. "
4540                f"Available clusters: {sorted(available_clusters[:10].tolist())}... "
4541                f"(showing first 10 of {len(available_clusters)} total clusters)"
4542            )
4543
4544        # Get the representative row (should only be one)
4545        rep_row = cluster_rep.iloc[0]
4546
4547        # Get sample name from sample_id (convert to int for list indexing)
4548        sample_id = int(rep_row["sample_id"])
4549        sample_name = self.samples[sample_id]
4550
4551        return {
4552            "sample_id": sample_id,
4553            "sample_name": sample_name,
4554            "mf_id": rep_row["mf_id"],
4555            "coll_mf_id": rep_row["coll_mf_id"],
4556            "has_ms2": rep_row["has_ms2"],
4557            "intensity": rep_row["intensity"],
4558        }
4559
4560    def reload_representative_mass_features(
4561        self,
4562        add_ms2=False,
4563        auto_process_ms2=True,
4564        ms2_spectrum_mode=None,
4565        ms2_scan_filter=None,
4566    ):
4567        """
4568        Reload mass features for all representative samples in the cluster summary.
4569
4570        This method is useful when the collection was loaded with load_light=True,
4571        which stores mass features only in the collection dataframe. This reloads
4572        the specific mass features that are representatives for each cluster,
4573        allowing them to be accessed as LCMSMassFeature objects.
4574
4575        Parameters
4576        ----------
4577        add_ms2 : bool, optional
4578            If True, also loads and associates MS2 spectra with mass features. Default is False.
4579        auto_process_ms2 : bool, optional
4580            If True and add_ms2=True, auto-processes MS2 spectra. Default is True.
4581        ms2_spectrum_mode : str or None, optional
4582            Spectrum mode for MS2 spectra. If None, determines from parser. Default is None.
4583        ms2_scan_filter : str or None, optional
4584            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
4585
4586        Returns
4587        -------
4588        dict
4589            Dictionary mapping sample_id to list of reloaded mf_ids.
4590
4591        Raises
4592        ------
4593        ValueError
4594            If cluster_summary_dataframe is not set (run add_consensus_mass_features first).
4595
4596        Notes
4597        -----
4598        - Only reloads mass features that are cluster representatives
4599        - Uses get_most_representative_sample_for_cluster() to determine which to reload
4600        - More memory-efficient than reloading all mass features
4601        - Parallelized based on lcms_collection.cores parameter
4602        - MS2 association uses same logic as add_associated_ms2_dda()
4603
4604        See Also
4605        --------
4606        _reload_sample_mass_features : Low-level method to reload specific mass features
4607        get_most_representative_sample_for_cluster : Gets representative sample for cluster
4608        """
4609        # Validate prerequisites
4610        if (
4611            not hasattr(self, "cluster_summary_dataframe")
4612            or self.cluster_summary_dataframe is None
4613        ):
4614            raise ValueError(
4615                "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first."
4616            )
4617
4618        # Get all representative mass features in bulk (much faster than looping)
4619        representatives = self.get_representative_mass_features_for_all_clusters()
4620
4621        # Build a dictionary of sample_id -> list of mf_ids that are representatives
4622        sample_mf_map = {}
4623        for _, row in representatives.iterrows():
4624            sample_id = row["sample_id"]
4625            mf_id = row["mf_id"]
4626
4627            if sample_id not in sample_mf_map:
4628                sample_mf_map[sample_id] = []
4629            sample_mf_map[sample_id].append(mf_id)
4630
4631        # Reload mass features for each sample (parallelized)
4632        if self.parameters.lcms_collection.cores == 1:
4633            # Serial processing
4634            from tqdm import tqdm
4635
4636            for sample_id in tqdm(
4637                sample_mf_map.keys(),
4638                desc="Reloading representative mass features",
4639                unit="sample",
4640            ):
4641                mf_ids = sample_mf_map[sample_id]
4642                self._reload_sample_mass_features(
4643                    sample_id,
4644                    mf_ids_to_load=mf_ids,
4645                    add_ms2=add_ms2,
4646                    auto_process_ms2=auto_process_ms2,
4647                    ms2_spectrum_mode=ms2_spectrum_mode,
4648                    ms2_scan_filter=ms2_scan_filter,
4649                )
4650        else:
4651            # Parallel processing
4652            from tqdm import tqdm
4653
4654            if self.parameters.lcms_collection.cores > len(sample_mf_map):
4655                ncores = len(sample_mf_map)
4656            else:
4657                ncores = self.parameters.lcms_collection.cores
4658
4659            # Build arguments list for starmap
4660            args_list = [
4661                (
4662                    sample_id,
4663                    sample_mf_map[sample_id],
4664                    add_ms2,
4665                    auto_process_ms2,
4666                    ms2_spectrum_mode,
4667                    ms2_scan_filter,
4668                    False,
4669                )
4670                for sample_id in sample_mf_map.keys()
4671            ]
4672
4673            # Execute in parallel
4674            with spawn_pool(ncores) as pool:
4675                mp_result = pool.starmap(self._reload_sample_mass_features, args_list)
4676
4677            # Collect results back into samples
4678            for i, sample_id in enumerate(
4679                tqdm(
4680                    sample_mf_map.keys(),
4681                    desc="Collecting reloaded mass features",
4682                    unit="sample",
4683                )
4684            ):
4685                self[sample_id].mass_features = mp_result[i]
4686
4687        return sample_mf_map
4688
4689    def _associate_ms2_with_mass_features(
4690        self,
4691        sample,
4692        local_mf_ids,
4693        auto_process=True,
4694        spectrum_mode=None,
4695        scan_filter=None,
4696    ):
4697        """
4698        Associate MS2 spectra with specific mass features in a sample.
4699
4700        Uses the LCMSBase helper method to find and load MS2 scans for the specified mass features.
4701
4702        Parameters
4703        ----------
4704        sample : LCMSBase
4705            The sample object containing mass features and scan data.
4706        local_mf_ids : list of int
4707            List of local (sample-level) mass feature IDs to find MS2 for.
4708        auto_process : bool, optional
4709            If True, auto-processes the MS2 spectra. Default is True.
4710        spectrum_mode : str or None, optional
4711            Spectrum mode for MS2 spectra. If None, determines from parser. Default is None.
4712        scan_filter : str or None, optional
4713            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
4714
4715        Returns
4716        -------
4717        dict
4718            Dictionary of scan_number -> MassSpectrum objects for the loaded MS2 spectra.
4719        """
4720        # Check if we have scan data
4721        if not hasattr(sample, "scan_df") or sample.scan_df is None:
4722            return {}
4723
4724        # Separate mass features into those that need scan finding vs those that already have scans
4725        mfs_needing_scan_finding = []
4726        unique_dda_scans = set()
4727
4728        for mf_id in local_mf_ids:
4729            if mf_id not in sample.mass_features:
4730                continue
4731            mf = sample.mass_features[mf_id]
4732            # If this mass feature already has MS2 scans, add them to our set
4733            if mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0:
4734                # Convert to integers in case they come from HDF5 as numpy types
4735                unique_dda_scans.update([int(scan) for scan in mf.ms2_scan_numbers])
4736            else:
4737                # Otherwise, we need to find scans for this mass feature
4738                mfs_needing_scan_finding.append(mf_id)
4739
4740        # Only run the scan finding for mass features that need it
4741        if mfs_needing_scan_finding:
4742            found_scans = sample._find_ms2_scans_for_mass_features(
4743                mf_ids=mfs_needing_scan_finding, scan_filter=scan_filter
4744            )
4745            unique_dda_scans.update(found_scans)
4746
4747        if len(unique_dda_scans) == 0:
4748            return {}
4749
4750        # Get ms2 parameters from sample
4751        # TODO KRH: deal with different ms2 scan types here (CID vs HCD), may need to add scan translator to the initializeion
4752        ms_params = sample.parameters.mass_spectrum["ms2"]
4753
4754        # Load MS2 spectra (convert set to list)
4755        sample.add_mass_spectra(
4756            scan_list=list(unique_dda_scans),
4757            auto_process=auto_process,
4758            spectrum_mode=spectrum_mode,
4759            ms_level=2,
4760            use_parser=True,
4761            ms_params=ms_params,
4762        )
4763
4764        # Associate MS2 spectra with mass features
4765        for mf_id in local_mf_ids:
4766            if mf_id not in sample.mass_features:
4767                continue
4768            if (
4769                sample.mass_features[mf_id].ms2_scan_numbers is not None
4770                and len(sample.mass_features[mf_id].ms2_scan_numbers) > 0
4771            ):
4772                for dda_scan in sample.mass_features[mf_id].ms2_scan_numbers:
4773                    if dda_scan in sample._ms:
4774                        sample.mass_features[mf_id].ms2_mass_spectra[dda_scan] = (
4775                            sample._ms[dda_scan]
4776                        )
4777
4778        # Return only the MS2 spectra we loaded (for parallel processing)
4779        return {
4780            scan: sample._ms[scan] for scan in unique_dda_scans if scan in sample._ms
4781        }
4782
4783    def _reload_sample_mass_features(
4784        self,
4785        sample_id,
4786        mf_ids_to_load=None,
4787        add_ms2=False,
4788        auto_process_ms2=True,
4789        ms2_spectrum_mode=None,
4790        ms2_scan_filter=None,
4791        inplace=True,
4792    ):
4793        """
4794        Reload specific mass features for a sample from HDF5.
4795
4796        This is useful when the collection was loaded with load_light=True,
4797        which stores mass features only in the collection dataframe and not
4798        as LCMSMassFeature objects in individual samples.
4799
4800        Parameters
4801        ----------
4802        sample_id : int
4803            The sample ID to reload mass features for.
4804        mf_ids_to_load : list of str, optional
4805            List of collection-level mf_ids (format: '{sample_id}_{local_mf_id}') to load.
4806            If None, loads all mass features for the sample.
4807        add_ms2 : bool, optional
4808            If True, also loads and associates MS2 spectra. Default is False.
4809        auto_process_ms2 : bool, optional
4810            If True, auto-processes MS2 spectra. Default is True.
4811        ms2_spectrum_mode : str or None, optional
4812            Spectrum mode for MS2 spectra. Default is None.
4813        ms2_scan_filter : str or None, optional
4814            Filter string for MS2 scans. Default is None.
4815        inplace : bool, optional
4816            If True, updates the sample's mass_features in place. If False, returns the
4817            mass_features dictionary (for multiprocessing). Default is True.
4818
4819        Returns
4820        -------
4821        dict or None
4822            If inplace=False, returns dictionary of mass features.
4823            Otherwise returns None and updates object in place.
4824        """
4825        sample = self[sample_id]
4826        sample_name = self.samples[sample_id]
4827
4828        # Check if we have a collection parser that can reload
4829        if not hasattr(self, "collection_parser") or self.collection_parser is None:
4830            print(
4831                "Warning: Cannot reload mass features - no collection_parser available"
4832            )
4833            if not inplace:
4834                return {}
4835            return
4836
4837        # Get the HDF5 file for this sample
4838        hdf5_file = (
4839            self.collection_parser.folder_location
4840            / f"{sample_name}.corems/{sample_name}.hdf5"
4841        )
4842
4843        if not hdf5_file.exists():
4844            print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}")
4845            if not inplace:
4846                return {}
4847            return
4848
4849        # Import here to avoid circular imports
4850        from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra
4851
4852        # If specific mf_ids requested, use them directly
4853        local_mf_ids_to_load = None
4854        if mf_ids_to_load is not None:
4855            # mf_ids_to_load is already a list of sample-level mf_ids (integers)
4856            # No parsing needed - they come from the mf_id column in the dataframe
4857            local_mf_ids_to_load = set(mf_ids_to_load)
4858
4859        # Reload mass features from HDF5
4860        with ReadCoreMSHDFMassSpectra(hdf5_file) as parser:
4861            # Load mass features - if specific IDs requested, only load those
4862            parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load)
4863
4864        # If add_ms2, associate MS2 spectra with the loaded mass features
4865        if add_ms2 and local_mf_ids_to_load is not None:
4866            self._associate_ms2_with_mass_features(
4867                sample,
4868                list(local_mf_ids_to_load),
4869                auto_process=auto_process_ms2,
4870                spectrum_mode=ms2_spectrum_mode,
4871                scan_filter=ms2_scan_filter,
4872            )
4873
4874        # Return mass features if not inplace (for multiprocessing)
4875        if not inplace:
4876            return sample.mass_features
4877
4878    def add_sparse_distance_matrix(self, features):
4879        if features is None:
4880            return None
4881        else:
4882            features = features.copy()
4883
4884        # Parameters for calculating distance between features
4885        dims = ["mz", "scan_time_aligned"]
4886        relative = [True, False]
4887        mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
4888        tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol]
4889        dist_weight = [1, 1]
4890
4891        # Check that the dimensions and tolerances are the same length
4892        if (
4893            len(dims) != len(tol)
4894            or len(dims) != len(relative)
4895            or len(dims) != len(dist_weight)
4896        ):
4897            raise ValueError(
4898                "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length"
4899            )
4900
4901        # Make connectivity matrix for masking within sample mass features
4902        ## Masking matrix cmat will mark all features from the same sample as 0
4903        ## To mask, a matrix can be multiplied by cmat and features from same
4904        ## samples are multiplied by 0, while features from different samples
4905        ## are multiplied by 1
4906
4907        if "sample_id" not in features.columns:
4908            cmat = None
4909        else:
4910            vals = features["sample_id"].values.reshape(-1, 1)
4911            cmat = scipy.spatial.distance.cdist(vals, vals)
4912            # Convert to binary (0 if same sample, 1 if different)
4913            cmat = np.where(cmat == 0, 0, 1)
4914            # Convert to coordinate matrix for sparse operations later
4915            cmat = sparse.coo_matrix(cmat)
4916
4917        # Compute inter-feature distances using sparse matrix approach
4918        distances = None  # clear the distances object before starting
4919        for i in range(len(dims)):  # iterate through all dimensions to be considered
4920            # Construct k-d tree
4921            values = features[dims[i]].values
4922
4923            tree = KDTree(values.reshape(-1, 1))
4924
4925            max_tol = tol[i]
4926            if relative[i] is True:
4927                # Maximum absolute tolerance
4928                max_tol = tol[i] * values.max()
4929
4930            # Compute sparse distance matrix
4931            # the larger the max_tol, the slower this operation is
4932            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
4933
4934            # Only consider forward case, exclude diagonal
4935            sdm = sparse.triu(sdm, k=1)
4936
4937            # Filter relative distances
4938            if relative[i] is True:
4939                # Compute relative distances
4940                rel_dists = sdm.data / values[sdm.row]
4941
4942                # Indices of relative distances less than tolerance
4943                idx = rel_dists <= tol[i]
4944
4945                # Reconstruct sparse distance matrix
4946                sdm = sparse.coo_matrix(
4947                    (rel_dists[idx], (sdm.row[idx], sdm.col[idx])),
4948                    shape=(len(values), len(values)),
4949                )
4950
4951            # Scaled distances wrt the maximum tolerance for the dimension
4952            sdm.data = sdm.data / tol[i]
4953
4954            # Stack distances for dimensions where na_allow is False
4955            if distances is None:
4956                sdm.data = sdm.data * dist_weight[i]
4957                # Replace zeros with epsilon to handle perfect matches
4958                sdm.data[sdm.data == 0] = 1e-10
4959                distances = sdm
4960            else:
4961                # Prepare sdm to match shape of existing distances
4962                distances_truth = distances.copy()
4963                # make new sparse matrix with same positions as previous
4964                # distance matrix but all ones for values
4965                distances_truth.data = np.ones_like(distances_truth.data)
4966
4967                # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them
4968                sdm.data[sdm.data == 0] = 1e-10
4969
4970                # multiply the new sparse matrix (sdm) by this mask to remove
4971                # data that doesn't exist in original sparse matrix
4972                sdm = distances_truth.multiply(sdm)
4973
4974                sdm.data = sdm.data * dist_weight[i]
4975                # Replace zeros with epsilon to handle perfect matches
4976                sdm.data[sdm.data == 0] = 1e-10
4977
4978                # use same process as before to remove data from previous
4979                # distances matrix that isn't in new distances matrix
4980                sdm_truth = sdm.copy()
4981                sdm_truth.data = np.ones_like(sdm_truth.data)
4982
4983                # remove the distances that are not sdm
4984                distances = distances.multiply(sdm_truth)
4985
4986                # Sum the new distances
4987                distances = distances + sdm
4988
4989        # Multiply by connectivity matrix for more masking
4990        distances = distances.multiply(cmat)
4991
4992        # Set attribute holding distance matrix
4993        self._sparse_distance_matrix = distances
4994
4995    def evaluate_clusters_for_repeats(self, features):
4996        raise NotImplementedError("evaluate_clusters_for_repeats not implemented yet")
4997        summary_df = self.cluster_summary_dataframe.copy()
4998
4999        # Arrange by decreasing median intensity
5000        summary_df = summary_df.sort_values(
5001            by="intensity_median", ascending=False
5002        ).reset_index(drop=True)
5003
5004        # Find clusters that are within the mz_tol and rt_tol of each other (on the medians)
5005        # Create a distance matrix
5006        # Define how to calculate the distance between features
5007        dims = ["mz_median", "scan_time_aligned_median"]
5008        relative = [True, False]
5009        mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5010        tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol]
5011
5012        # Compute inter-feature distances
5013        distances = None
5014        for i in range(len(dims)):
5015            # Construct k-d tree
5016            values = summary_df[dims[i]].values
5017            tree = KDTree(values.reshape(-1, 1))
5018
5019            max_tol = tol[i]
5020            if relative[i] is True:
5021                # Maximum absolute tolerance
5022                max_tol = tol[i] * values.max()
5023
5024            # Compute sparse distance matrix
5025            # the larger the max_tol, the slower this operation is
5026            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
5027
5028            # Only consider forward case, exclude diagonal
5029            sdm = sparse.triu(sdm, k=1)
5030
5031            # Filter relative distances
5032            if relative[i] is True:
5033                # Compute relative distances
5034                rel_dists = sdm.data / values[sdm.row]  # or col?
5035
5036                # Indices of relative distances less than tolerance
5037                idx = rel_dists <= tol[i]
5038
5039                # Reconstruct sparse distance matrix
5040                sdm = sparse.coo_matrix(
5041                    (rel_dists[idx], (sdm.row[idx], sdm.col[idx])),
5042                    shape=(len(values), len(values)),
5043                )
5044
5045            # Cast as binary matrix
5046            sdm.data = np.ones_like(sdm.data)
5047
5048            # Stack distances
5049            if distances is None:
5050                distances = sdm
5051            else:
5052                distances = distances.multiply(sdm)
5053
5054        # Roll up features
5055        # Extract indices of within-tolerance points
5056        distances = distances.tocoo()
5057        pairs = np.stack(
5058            (distances.row, distances.col), axis=1
5059        )  # These are the index values of the clusters, not the cluster ids
5060        # Conver to cluster ids
5061        pairs_df = pd.DataFrame(pairs, columns=["parent", "child"])
5062        pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values
5063        pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values
5064        pairs_df = pairs_df.set_index("parent")
5065
5066        merge_these_clusters = []
5067        possible_overlaps = []
5068        root_parents = np.setdiff1d(
5069            np.unique(pairs_df.index.values), np.unique(pairs_df.child.values)
5070        )
5071        for parent in root_parents:
5072            parent_features = features[features["cluster"] == parent]
5073            children = pairs_df.loc[[parent], "child"].tolist()
5074            for child in children:
5075                overlap = self.check_merge(parent_features, child, features)
5076                if len(overlap) == 0:
5077                    merge_these_clusters.append((parent, child, len(overlap)))
5078                else:
5079                    possible_overlaps.append((parent, child, len(overlap)))
5080
5081        result_dict = {}
5082        result_dict["merge_these_clusters"] = merge_these_clusters
5083        result_dict["possible_overlaps"] = possible_overlaps
5084
5085        return result_dict
5086
5087    def check_merge(self, parent_features, child, features):
5088        # Grab the features of the parent and children
5089        child_features = features[features["cluster"] == child]
5090
5091        # Check if there is an overlap between mf_coll_id in the parent and child clusters
5092        overlap = np.intersect1d(
5093            parent_features["sample_id"].values, child_features["sample_id"].values
5094        )
5095
5096        return overlap
5097
5098    def cluster_mass_features_agg_cluster(self, features):
5099        if features is None:
5100            return None
5101
5102        features = features.copy()
5103
5104        self.add_sparse_distance_matrix(features)
5105
5106        distances = self._sparse_distance_matrix
5107
5108        # Convert to full matrix
5109        distances = distances.todense()
5110
5111        # Cast all 0s to 1s for a distance matrix
5112        distances[distances == 0] = 1
5113        distances = np.asarray(distances)
5114
5115        # Perform clustering
5116        try:
5117            clustering = AgglomerativeClustering(
5118                n_clusters=None,
5119                linkage="complete",
5120                # using complete linkage will prevent one sample from being assigned to multiple clusters
5121                metric="precomputed",
5122                distance_threshold=1,
5123            ).fit(distances)
5124            features["cluster"] = clustering.labels_
5125
5126        # All data points are singleton clusters
5127        except:
5128            features["cluster"] = np.arange(len(features.index))
5129
5130        return features
5131
5132    def cluster_inspection_plot(self, clu, return_fig=False, path=None):
5133        """
5134        Generate Scan Time vs m/z plot for a narrow range around a given
5135        cluster. This tool is meant to support the user in fine tuning the
5136        tolerances used for the clustering algorithm. The user-provided cluster
5137        ID is highlighted in larger, magenta marker and the ten largest of the
5138        remaining clusters are idenfitied with different colors while the
5139        smallest clusters are light gray.
5140
5141        Parameters
5142        -----------
5143        clu :  integer
5144            A cluster ID that exists in self.mass_features_dataframe
5145        return_fig : bool, optional
5146            If True, return the open figure (caller owns lifecycle).
5147            Default is False.
5148        path : str or path-like, optional
5149            If set, save the figure to this path. When ``return_fig`` is False,
5150            the figure is closed after saving and ``plt.show()`` is not called.
5151
5152        Returns
5153        --------
5154        matplotlib.figure.Figure or None
5155            A figure displaying a scan time vs m/z scatterplot of small region
5156            around a given cluster with the ten largest clusters in the region
5157            distinctly identified if ``return_fig`` is True; otherwise None.
5158
5159        Raises
5160        ------
5161        ValueError
5162            If cluster data haven't been added to the object yet
5163        """
5164
5165        if "cluster" not in self.mass_features_dataframe.columns:
5166            raise ValueError(
5167                "Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first"
5168            )
5169
5170        else:
5171            mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5172            rttol = self.parameters.lcms_collection.consensus_rt_tol
5173            clu_features = self.mass_features_dataframe.copy()
5174
5175            inclu = clu_features[clu_features.cluster == clu]
5176            exclu = clu_features[clu_features.cluster != clu]
5177
5178            dt_ymin = np.floor(min(inclu.mz)) - 1
5179            dt_ymax = np.ceil(max(inclu.mz)) + 1
5180            dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1
5181            dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1
5182
5183            exclu = exclu[
5184                (exclu.mz.between(dt_ymin, dt_ymax, inclusive="both"))
5185                & (exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive="both"))
5186            ]
5187
5188            bigclulist = list(exclu.cluster.value_counts()[:10].index)
5189            bigclu = exclu[exclu.cluster.isin(bigclulist)]
5190            smclu = exclu[~exclu.cluster.isin(bigclulist)]
5191
5192            colors = np.arange(0, 10)
5193            colordict = dict(zip(bigclulist, colors))
5194            bigclu["color"] = bigclu.cluster.apply(lambda x: colordict[x])
5195
5196            fig = plt.figure(figsize=(7.5, 5))
5197
5198            plt.scatter(
5199                inclu.scan_time_aligned,
5200                inclu.mz,
5201                c="m",
5202                s=3,
5203                label="Cluster " + str(clu),
5204            )
5205
5206            plt.scatter(
5207                bigclu.scan_time_aligned, bigclu.mz, c=bigclu.color, cmap="tab10", s=1.5
5208            )
5209
5210            plt.scatter(
5211                smclu.scan_time_aligned,
5212                smclu.mz,
5213                c="silver",
5214                s=2,
5215                label="Small clusters",
5216            )
5217
5218            plt.ylim(dt_ymin, dt_ymax)
5219            plt.xlim(dt_xmin, dt_xmax)
5220            plt.legend(ncol=2, bbox_to_anchor=(0.8, -0.1))
5221            plt.xlabel("Scan time")
5222            plt.ylabel("m/z")
5223            title_str = "Cluster " + str(clu)
5224            title_str += ": representing " + str(len(inclu.sample_id.unique()))
5225            title_str += " of " + str(len(clu_features.sample_id.unique()))
5226            title_str += " samples\n"
5227            title_str += "M/Z tolerance: " + str(mztol) + "\n"
5228            title_str += "Scan Time tolerance: " + str(rttol)
5229            plt.title(title_str, fontsize=10)
5230
5231            return _finalize_plot(fig, return_fig=return_fig, path=path)
5232
5233    def plot_cluster_outlier_frequency(
5234        self,
5235        dim_list=["mz", "scan_time_aligned"],
5236        clu_size_thresh=0.5,
5237        return_fig=False,
5238        path=None,
5239    ):
5240        """
5241        Generate histogram showing the frequency of outlier occurrences by
5242        clustering dimension across all clusters
5243
5244        Parameters
5245        -----------
5246        dim_list :  list
5247            List of strings describing dimensions that can be used in
5248            clustering. Available list items:
5249                - 'mz'
5250                - 'scan_time_aligned'
5251                - 'half_height_width'
5252                - 'tailing_factor'
5253                - 'dispersity_index'
5254                - 'intensity'
5255                - 'persistence'
5256        clu_size_thresh : float
5257            Value between 0 and 1 that indicates what percentage of samples
5258            need to be present in a cluster before it's evaluated for outliers.
5259            Defaults to 0.5.
5260        return_fig : bool, optional
5261            If True, return the open figure (caller owns lifecycle).
5262            Default is False.
5263        path : str or path-like, optional
5264            If set, save the figure to this path. When ``return_fig`` is False,
5265            the figure is closed after saving and ``plt.show()`` is not called.
5266
5267        Returns
5268        --------
5269        matplotlib.figure.Figure or None
5270            A figure displaying the frequency of outlier occurrences across all
5271            clusters in the provided measurement dimensions if ``return_fig`` is
5272            True; otherwise None.
5273
5274        Raises
5275        ------
5276        ValueError
5277            If cluster data haven't been added to the object yet
5278        """
5279
5280        if not hasattr(self, "cluster_summary_dataframe"):
5281            raise ValueError(
5282                "cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first"
5283            )
5284
5285        mfdf = self.mass_features_dataframe.copy()
5286        summarydf = self.cluster_summary_dataframe
5287
5288        numsamples = len(self)
5289        sumdf = (
5290            summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh]
5291            .reset_index(drop=True)
5292            .copy()
5293        )
5294
5295        ## find the ranges for non-outlier values and add them to sumdf
5296        mergelist = ["cluster"]
5297        for dim in dim_list:
5298            maxtag = dim + "_outmax"
5299            mintag = dim + "_outmin"
5300            mergelist.append(maxtag)
5301            mergelist.append(mintag)
5302            # Calculate outlier thresholds using vectorized operations
5303            sumdf[mintag] = sumdf[dim + "_mean"] - 3 * sumdf[dim + "_std"]
5304            sumdf[maxtag] = sumdf[dim + "_mean"] + 3 * sumdf[dim + "_std"]
5305            ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's
5306            ## not flagged. This happens when there's not enough values to compute median/std for that
5307            ## dimension therefore can't have outliers
5308
5309        ## add ranges to mfdf and identify mass features that fall outside the ranges
5310        # Merge without dropping NaN - we'll handle it per-dimension
5311        outdf = pd.merge(mfdf, sumdf[mergelist], on="cluster")
5312
5313        outtags = ["cluster"]
5314        for dim in dim_list:
5315            dimtag = dim + "_outlier"
5316            outtags.append(dimtag)
5317            maxtag = dim + "_outmax"
5318            mintag = dim + "_outmin"
5319            # Only flag as outlier if thresholds are valid (not NaN)
5320            outdf[dimtag] = np.where(
5321                (outdf[maxtag].notna() & outdf[mintag].notna())
5322                & ((outdf[dim] > outdf[maxtag]) | (outdf[dim] < outdf[mintag])),
5323                True,
5324                False,
5325            )
5326
5327        ## identify number of outliers in each cluster
5328        outliers = outdf[outtags]
5329        outliers = outliers.groupby(["cluster"]).sum()
5330
5331        ## plot number of clusters that contain any outliers
5332        fig = plt.figure()
5333        plt.bar(dim_list, outliers.sum().values, width=0.5)
5334        plt.xticks(rotation=90)
5335        plt.title("Frequency of outliers across all clusters by category")
5336
5337        return _finalize_plot(fig, return_fig=return_fig, path=path)
5338
5339    def _search_for_targeted_mass_features_in_sample(
5340        self, obj_idx, missingdf, cluster_dict, expand_on_miss=False, inplace=True
5341    ):
5342        """
5343        Helper method to search for missing mass features in a single sample.
5344
5345        Internal method called by fill_missing_cluster_features() to perform
5346        gap-filling for one sample in the collection.
5347
5348        Parameters
5349        ----------
5350        obj_idx : int
5351            Index of the sample being processed
5352        missingdf : pd.DataFrame
5353            DataFrame containing cluster information with columns:
5354            'cluster', 'sample_id_nunique', 'mz_min', 'mz_max',
5355            'scan_time_aligned_min', 'scan_time_aligned_max', 'mz_min_allowed',
5356            'mz_max_allowed', 'scan_time_aligned_min_allowed',
5357            'scan_time_aligned_max_allowed', 'missing_samples'
5358        cluster_dict : dict
5359            Pre-computed cluster feature dictionary to avoid recomputation
5360        expand_on_miss : bool
5361            If True, expands search window when no peak found initially
5362        inplace : bool
5363            If True, assigns induced_mass_features in place. If False, returns the
5364            induced features dictionary (for multiprocessing)
5365
5366        Returns
5367        -------
5368        dict or None
5369            If inplace=False, returns dictionary of induced mass features.
5370            Otherwise returns None and updates object in place.
5371        """
5372        ## Use the pre-computed cluster dictionary passed as parameter
5373
5374        ## to get clusters missing data based on sample index:
5375        sampledf = (
5376            missingdf[missingdf.missing_samples.apply(lambda x: obj_idx in x)]
5377            .reset_index(drop=True)
5378            .copy()
5379        )
5380
5381        # Skip if no missing features for this sample
5382        if len(sampledf) == 0:
5383            if not inplace:
5384                return {}
5385            return
5386
5387        self.load_raw_data(obj_idx, 1)
5388
5389        ## this is the line that bugs due to _ms_unprocessed not having key 1
5390        ms1df = self[obj_idx]._ms_unprocessed[1].copy()
5391        scan_df = self[obj_idx].scan_df[["scan", "scan_time_aligned"]]
5392        ms1df = pd.merge(ms1df, scan_df, on="scan")
5393
5394        # Pre-extract all values from sampledf to avoid repeated .iloc calls
5395        clusters = sampledf.cluster.values
5396        mz_mins = sampledf.mz_min.values
5397        mz_maxs = sampledf.mz_max.values
5398        st_mins = sampledf.scan_time_aligned_min.values
5399        st_maxs = sampledf.scan_time_aligned_max.values
5400
5401        if expand_on_miss:
5402            mz_mins_allowed = sampledf.mz_min_allowed.values
5403            mz_maxs_allowed = sampledf.mz_max_allowed.values
5404            st_mins_allowed = sampledf.sta_min_allowed.values
5405            st_maxs_allowed = sampledf.sta_max_allowed.values
5406
5407        # Pre-filter ms1df to reduce search space
5408        mz_global_min = mz_mins.min()
5409        mz_global_max = mz_maxs.max()
5410        st_global_min = st_mins.min()
5411        st_global_max = st_maxs.max()
5412
5413        if expand_on_miss:
5414            mz_global_min = min(mz_global_min, mz_mins_allowed.min())
5415            mz_global_max = max(mz_global_max, mz_maxs_allowed.max())
5416            st_global_min = min(st_global_min, st_mins_allowed.min())
5417            st_global_max = max(st_global_max, st_maxs_allowed.max())
5418
5419        ms1df_filtered = ms1df[
5420            (ms1df.mz >= mz_global_min)
5421            & (ms1df.mz <= mz_global_max)
5422            & (ms1df.scan_time_aligned >= st_global_min)
5423            & (ms1df.scan_time_aligned <= st_global_max)
5424        ].copy()
5425
5426        # Generate set_ids for all features
5427        set_ids = [f"c{clusters[i]}_{i}_i" for i in range(len(sampledf))]
5428
5429        # Use batch method to process all features at once
5430        if expand_on_miss:
5431            # First try with normal bounds
5432            peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch(
5433                ms1df_filtered,
5434                mz_mins,
5435                mz_maxs,
5436                st_mins,
5437                st_maxs,
5438                set_ids,
5439                obj_idx=obj_idx,
5440                st_aligned=True,
5441            )
5442
5443            # Retry failed features with expanded bounds
5444            failed_indices = [
5445                i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99
5446            ]
5447            if failed_indices:
5448                failed_ids = [set_ids[i] for i in failed_indices]
5449                retry_peaks = self[obj_idx].search_for_targeted_mass_features_batch(
5450                    ms1df_filtered,
5451                    mz_mins_allowed[failed_indices],
5452                    mz_maxs_allowed[failed_indices],
5453                    st_mins_allowed[failed_indices],
5454                    st_maxs_allowed[failed_indices],
5455                    failed_ids,
5456                    obj_idx=obj_idx,
5457                    st_aligned=True,
5458                )
5459                peaks_dict.update(retry_peaks)
5460        else:
5461            peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch(
5462                ms1df_filtered,
5463                mz_mins,
5464                mz_maxs,
5465                st_mins,
5466                st_maxs,
5467                set_ids,
5468                obj_idx=obj_idx,
5469                st_aligned=True,
5470            )
5471
5472        # Assign peaks to induced_mass_features and cluster_dict
5473        for i in range(len(sampledf)):
5474            peak = peaks_dict[set_ids[i]]
5475            self[obj_idx].induced_mass_features[peak.id] = peak
5476            cluster_dict[clusters[i]] += [set_ids[i]]
5477
5478        # TODO KRH: Let's try to avoid these steps unless asked for by parameters to pick up speed
5479        if False:
5480            self[obj_idx].add_associated_ms1(induced_features=True)
5481            # need to set drop_if_fail to false for induced features as they will fail
5482            self[obj_idx].add_peak_metrics(induced_features=True)
5483
5484        # The duplicate scan only deletes keys from mass_features. Induced ids
5485        # are strings such as c12_0_i, so that scan does not remove them.
5486        self[obj_idx].integrate_mass_features(
5487            drop_if_fail=False, induced_features=True, drop_duplicates=False
5488        )
5489
5490        if not inplace:
5491            return self[obj_idx].induced_mass_features
5492
5493    def _missing_sample_ids_by_cluster(self, mfdf, cluster_ids):
5494        """Sample ids with no feature row for each cluster, in sample-id order.
5495
5496        Includes samples that have no mass features. Lookup matches numpy
5497        integers and integral floats to the cluster key.
5498        """
5499        all_sample_ids = list(range(len(self.samples)))
5500        if mfdf is None or len(mfdf) == 0:
5501            present_by_cluster = pd.Series(dtype=object)
5502        else:
5503            present_by_cluster = mfdf.groupby("cluster", sort=False)["sample_id"].agg(
5504                lambda ids: set(ids.to_numpy())
5505            )
5506        missing = []
5507        for cluster_id in cluster_ids:
5508            present = present_by_cluster.get(cluster_id, ())
5509            missing.append(
5510                [sample_id for sample_id in all_sample_ids if sample_id not in present]
5511            )
5512        return missing
5513
5514    def fill_missing_cluster_features(self):
5515        """
5516        Gap-filling for consensus mass features across collection samples.
5517
5518        For clusters present in multiple samples but missing from others, searches
5519        raw MS1 data to find peaks in expected m/z and retention time windows. This
5520        creates "induced" mass features for peaks that exist in the data but weren't
5521        detected in the initial peak detection.
5522
5523        Must be run after add_consensus_mass_features(). Results are accessible via
5524        induced_mass_features_dataframe property and included in collection_pivot_table
5525        and collection_consensus_report outputs.
5526
5527        Parameters
5528        ----------
5529        None
5530            Uses parameters from self.parameters.lcms_collection:
5531            - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain
5532              a cluster before gap-filling is attempted
5533            - gap_fill_expand_on_miss: If True, expands search window when no peak is found
5534
5535        Returns
5536        -------
5537        None
5538            Updates induced_mass_features attribute for each LCMSBase object and
5539            combines them into induced_mass_features_dataframe.
5540
5541        Raises
5542        ------
5543        ValueError
5544            If cluster_summary_dataframe is not set (must run add_consensus_mass_features first).
5545
5546        Notes
5547        -----
5548        - Loads raw MS1 data for each sample, which may be memory intensive
5549        - Induced features are integrated and metrics calculated automatically
5550        - Processing can be parallelized using parameters.lcms_collection.cores
5551
5552        See Also
5553        --------
5554        add_consensus_mass_features : Creates consensus features before gap-filling
5555        collection_pivot_table : Includes both regular and induced features
5556        collection_consensus_report : Reports on complete feature matrix
5557        """
5558
5559        # Validate prerequisites
5560        if (
5561            not hasattr(self, "cluster_summary_dataframe")
5562            or self.cluster_summary_dataframe is None
5563        ):
5564            raise ValueError(
5565                "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first."
5566            )
5567
5568        # Get parameters from settings
5569        min_cluster_presence = (
5570            self.parameters.lcms_collection.consensus_min_sample_fraction
5571        )
5572        expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss
5573
5574        # Validate parameters
5575        if not 0 <= min_cluster_presence <= 1:
5576            raise ValueError("consensus_min_sample_fraction must be between 0 and 1")
5577
5578        summarydf = self.cluster_summary_dataframe
5579        mfdf = self.mass_features_dataframe
5580
5581        sample_ct = len(self.samples)
5582
5583        # Identify clusters present in sufficient samples but not all samples
5584        missingdf = summarydf[
5585            [
5586                "cluster",
5587                "sample_id_nunique",
5588                "mz_min",
5589                "mz_max",
5590                "scan_time_aligned_min",
5591                "scan_time_aligned_max",
5592            ]
5593        ]
5594        missingdf = missingdf[
5595            missingdf.sample_id_nunique > min_cluster_presence * sample_ct
5596        ]
5597        missingdf = missingdf[missingdf.sample_id_nunique != sample_ct]
5598
5599        # Check if there are any clusters to gap-fill
5600        if len(missingdf) == 0:
5601            return
5602
5603        # One groupby. Sample ids stay in collection order, including samples
5604        # that have no mass features.
5605        missingdf["missing_samples"] = self._missing_sample_ids_by_cluster(
5606            mfdf, missingdf.cluster.to_numpy()
5607        )
5608
5609        # Calculate expanded search windows for expand_on_miss option
5610        mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5611        rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol
5612        missingdf["mz_max_allowed"] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max
5613        missingdf["mz_min_allowed"] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min
5614        # consensus_rt_tol is an absolute tolerance in minutes, same as clustering.
5615        missingdf["sta_max_allowed"] = missingdf.scan_time_aligned_max + rt_clu_tol
5616        missingdf["sta_min_allowed"] = missingdf.scan_time_aligned_min - rt_clu_tol
5617
5618        # Compute cluster dictionary once to avoid recomputing for each sample
5619        cluster_dict = self.cluster_feature_dictionary
5620
5621        # Process each sample to search for missing features
5622        if self.parameters.lcms_collection.cores == 1:
5623            for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"):
5624                self._search_for_targeted_mass_features_in_sample(
5625                    i, missingdf, cluster_dict, expand_on_miss
5626                )
5627
5628        if self.parameters.lcms_collection.cores > 1:
5629            if self.parameters.lcms_collection.cores > len(self):
5630                ncores = len(self)
5631            else:
5632                ncores = self.parameters.lcms_collection.cores
5633            with spawn_pool(ncores) as pool:
5634                mp_result = pool.starmap(
5635                    self._search_for_targeted_mass_features_in_sample,
5636                    [
5637                        (x, missingdf, cluster_dict, expand_on_miss, False)
5638                        for x in range(sample_ct)
5639                    ],
5640                )
5641
5642            for i in tqdm(
5643                range(sample_ct), desc="Collecting gap-filled features", unit="sample"
5644            ):
5645                self[i].induced_mass_features = mp_result[i]
5646
5647        self._combine_mass_features(induced_features=True)
5648
5649        # Mark that gap-filling has been performed
5650        self.missing_mass_features_searched = True
5651
5652        for sample_name in self.samples:
5653            self._lcms[sample_name].mass_features = {}
5654
5655    def process_samples_pipeline(
5656        self, operations, description=None, keep_raw_data=False, show_progress=True
5657    ):
5658        """
5659        Execute a pipeline of operations on all samples in parallel.
5660
5661        This method provides a flexible framework for performing multiple
5662        sample-level operations in a single parallelized pass, which is more
5663        efficient than calling separate methods sequentially.
5664
5665        Parameters
5666        ----------
5667        operations : list of SampleOperation
5668            List of operations to perform on each sample, in order.
5669            Each operation should be an instance of a class derived from
5670            SampleOperation (see lc_calc_operations module).
5671        description : str or None, optional
5672            Progress bar description. If None, automatically generates description
5673            from operation descriptions (e.g., "gap-filling, reloading features").
5674            Default is None.
5675        keep_raw_data : bool, optional
5676            If True, keeps raw MS data loaded in memory after pipeline completes.
5677            If False, cleans up raw data to free memory. Default is False.
5678        show_progress : bool, optional
5679            If True, displays progress bars during processing. If False, runs silently.
5680            Default is True.
5681
5682        Returns
5683        -------
5684        dict
5685            Dictionary with results from pipeline execution, keyed by operation name.
5686            Structure: {operation_name: {sample_id: result, ...}, ...}
5687
5688        Raises
5689        ------
5690        ValueError
5691            If operations list is empty or contains invalid operations.
5692
5693        Notes
5694        -----
5695        - Operations are executed sequentially within each sample
5696        - Samples are processed in parallel based on parameters.lcms_collection.cores
5697        - Each operation can have conditional execution via can_execute()
5698        - Results are collected back via collect_results() method of each operation
5699        - Failed operations for a sample are logged but don't halt processing
5700        - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True
5701
5702        Examples
5703        --------
5704        >>> from corems.mass_spectra.calc.lc_calc_operations import (
5705        ...     GapFillOperation, ReloadFeaturesOperation
5706        ... )
5707        >>> ops = [
5708        ...     GapFillOperation('gap_fill', expand_on_miss=True),
5709        ...     ReloadFeaturesOperation('reload', add_ms2=True)
5710        ... ]
5711        >>> results = lcms_collection.process_samples_pipeline(ops)
5712
5713        See Also
5714        --------
5715        lc_calc_operations : Module containing built-in operation classes
5716        fill_and_process_features : Convenience method combining common operations
5717        """
5718        from corems.mass_spectra.calc.lc_calc_operations import SampleOperation
5719
5720        # Validate operations
5721        if not operations or len(operations) == 0:
5722            raise ValueError("operations list cannot be empty")
5723
5724        for op in operations:
5725            if not isinstance(op, SampleOperation):
5726                raise ValueError(
5727                    f"All operations must be SampleOperation instances, got {type(op)}"
5728                )
5729
5730        # Generate description from operations if not provided
5731        if description is None:
5732            operation_descriptions = [op.description for op in operations]
5733            description = ", ".join(operation_descriptions).capitalize()
5734
5735        # Prepare runtime parameters for each operation
5736        # This is where we gather collection-level data that operations need
5737        runtime_params = self._prepare_pipeline_runtime_params(operations)
5738        runtime_params["keep_raw_data"] = keep_raw_data
5739
5740        # Execute pipeline
5741        sample_ct = len(self.samples)
5742
5743        if self.parameters.lcms_collection.cores == 1:
5744            # Serial processing
5745            results_by_operation = {op.name: {} for op in operations}
5746
5747            if show_progress:
5748                from tqdm import tqdm
5749
5750                # Print description on its own line before progress bar
5751                print(f"\n{description.capitalize()}:")
5752                iterator = tqdm(range(sample_ct), unit="sample", ncols=80)
5753            else:
5754                iterator = range(sample_ct)
5755
5756            for sample_id in iterator:
5757                sample_results = self._execute_sample_pipeline(
5758                    sample_id, operations, runtime_params, inplace=True
5759                )
5760                # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True)
5761                # Skip 'sample_id' key which is added for tracking
5762                for op_name, result in sample_results.items():
5763                    if op_name != "sample_id":
5764                        results_by_operation[op_name][sample_id] = result
5765        else:
5766            # Parallel processing
5767            if self.parameters.lcms_collection.cores > sample_ct:
5768                ncores = sample_ct
5769            else:
5770                ncores = self.parameters.lcms_collection.cores
5771
5772            # Build arguments for each sample
5773            args_list = [
5774                (sample_id, operations, runtime_params, False)
5775                for sample_id in range(sample_ct)
5776            ]
5777
5778            # Execute in parallel with progress tracking
5779            results_by_operation = {op.name: {} for op in operations}
5780
5781            with spawn_pool(ncores) as pool:
5782                if show_progress:
5783                    from tqdm import tqdm
5784                    import time
5785
5786                    # Use starmap_async for parallel execution with progress tracking
5787                    async_result = pool.starmap_async(
5788                        self._execute_sample_pipeline, args_list
5789                    )
5790
5791                    # Poll for completion and update progress bar
5792                    print(description)
5793                    pbar = tqdm(
5794                        total=sample_ct,
5795                        desc="",
5796                        unit="sample",
5797                        position=0,
5798                        leave=True,
5799                        dynamic_ncols=True,
5800                    )
5801                    prev_completed = 0
5802                    while not async_result.ready():
5803                        # Get number of completed tasks by checking remaining
5804                        completed = sample_ct - async_result._number_left
5805                        if completed > prev_completed:
5806                            pbar.update(completed - prev_completed)
5807                            prev_completed = completed
5808                        time.sleep(0.5)  # Poll every 500ms to avoid spam
5809
5810                    # Final update to 100%
5811                    if prev_completed < sample_ct:
5812                        pbar.update(sample_ct - prev_completed)
5813                    pbar.close()
5814
5815                    # Get all results
5816                    mp_results = async_result.get()
5817                else:
5818                    # Execute without progress
5819                    mp_results = pool.starmap(self._execute_sample_pipeline, args_list)
5820
5821            # Collect results back into collection
5822            for result in mp_results:
5823                sample_id = result.get("sample_id")
5824                for op in operations:
5825                    op_result = result.get(op.name)
5826                    if op_result is not None:
5827                        op.collect_results(sample_id, op_result, self)
5828                        results_by_operation[op.name][sample_id] = op_result
5829
5830        return results_by_operation
5831
5832    def _prepare_pipeline_runtime_params(self, operations):
5833        """
5834        Prepare runtime parameters needed by operations in the pipeline.
5835
5836        This method gathers collection-level data that operations need,
5837        such as cluster information for gap-filling or mf_ids for reloading.
5838
5839        Parameters
5840        ----------
5841        operations : list of SampleOperation
5842            List of operations that will be executed
5843
5844        Returns
5845        -------
5846        dict
5847            Dictionary of runtime parameters for operations
5848        """
5849        from corems.mass_spectra.calc.lc_calc_operations import (
5850            GapFillOperation,
5851            ReloadFeaturesOperation,
5852            MS2SpectralSearchOperation,
5853            LoadEICsOperation,
5854        )
5855
5856        runtime_params = {}
5857
5858        # Check if any operation needs gap-fill parameters
5859        needs_gap_fill = any(isinstance(op, GapFillOperation) for op in operations)
5860        if needs_gap_fill:
5861            # Prepare gap-fill parameters (same as fill_missing_cluster_features)
5862            min_cluster_presence = (
5863                self.parameters.lcms_collection.consensus_min_sample_fraction
5864            )
5865            expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss
5866
5867            summarydf = self.cluster_summary_dataframe
5868            mfdf = self.mass_features_dataframe
5869            sample_ct = len(self.samples)
5870
5871            # Identify clusters needing gap-filling
5872            # Note: cluster_summary_dataframe has 'cluster' as index, need to reset it
5873            missingdf = summarydf.reset_index()[
5874                [
5875                    "cluster",
5876                    "sample_id_nunique",
5877                    "mz_min",
5878                    "mz_max",
5879                    "scan_time_aligned_min",
5880                    "scan_time_aligned_max",
5881                ]
5882            ].copy()
5883            missingdf = missingdf[
5884                missingdf.sample_id_nunique > min_cluster_presence * sample_ct
5885            ]
5886            missingdf = missingdf[missingdf.sample_id_nunique != sample_ct]
5887
5888            if len(missingdf) > 0:
5889                # One groupby. Sample ids stay in collection order, including samples
5890                # that have no mass features.
5891                missingdf["missing_samples"] = self._missing_sample_ids_by_cluster(
5892                    mfdf, missingdf.cluster.to_numpy()
5893                )
5894
5895                # Calculate expanded search windows
5896                mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5897                rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol
5898                missingdf["mz_max_allowed"] = (
5899                    missingdf.mz_max + mz_clu_tol * missingdf.mz_max
5900                )
5901                missingdf["mz_min_allowed"] = (
5902                    missingdf.mz_min - mz_clu_tol * missingdf.mz_min
5903                )
5904                # consensus_rt_tol is an absolute tolerance in minutes, same as clustering.
5905                missingdf["sta_max_allowed"] = (
5906                    missingdf.scan_time_aligned_max + rt_clu_tol
5907                )
5908                missingdf["sta_min_allowed"] = (
5909                    missingdf.scan_time_aligned_min - rt_clu_tol
5910                )
5911
5912                runtime_params["missingdf"] = missingdf
5913                runtime_params["cluster_dict"] = self.cluster_feature_dictionary
5914                runtime_params["expand_on_miss"] = expand_on_miss
5915
5916        # Check if any operation needs reload parameters
5917        needs_reload = any(isinstance(op, ReloadFeaturesOperation) for op in operations)
5918        if needs_reload:
5919            # Use DRY helper method to build sample_mf_map
5920            sample_mf_map = self.get_sample_mf_map_for_representatives(
5921                include_cluster_id=False
5922            )
5923            runtime_params["sample_mf_map"] = sample_mf_map
5924
5925        # Check if any operation needs MS2 spectral search parameters
5926        needs_ms2_search = any(
5927            isinstance(op, MS2SpectralSearchOperation) for op in operations
5928        )
5929        if needs_ms2_search:
5930            # Pass through pre-prepared spectral library
5931            if hasattr(self, "_spectral_lib") and self._spectral_lib is not None:
5932                runtime_params["fe_lib"] = self._spectral_lib
5933            if hasattr(self, "_spectral_search_molecular_metadata"):
5934                runtime_params["molecular_metadata"] = (
5935                    self._spectral_search_molecular_metadata
5936                )
5937
5938        # Check if any operation needs EIC loading parameters
5939        needs_eic_loading = any(isinstance(op, LoadEICsOperation) for op in operations)
5940        if needs_eic_loading:
5941            # Map sample_id -> m/z list for LoadEICsOperation.
5942            # Must use collection *dataframes*, not only in-memory mass_features:
5943            # after load_representatives=True each sample holds only a sparse set
5944            # of representative objects, so object-only walks miss most cluster
5945            # members and plot_cluster shows incomplete multi-sample EICs (#258).
5946            runtime_params["cluster_mz_dict"] = (
5947                self._build_cluster_mz_dict_for_eic_loading()
5948            )
5949
5950        return runtime_params
5951
5952    def _build_cluster_mz_dict_for_eic_loading(self):
5953        """Build sample_id -> list of EIC m/z targets for all clustered features.
5954
5955        Uses ``mass_features_dataframe`` (and induced dataframe if present).
5956        Prefers ``_eic_mz``, falls back to ``mz``. Does not require feature
5957        objects to be loaded in ``sample.mass_features``.
5958
5959        Returns
5960        -------
5961        dict
5962            Mapping of sample_id (int) to unique float m/z values to load.
5963        """
5964        cluster_mz_dict = {}
5965
5966        def _add_from_df(df):
5967            if df is None or len(df) == 0:
5968                return
5969            if "cluster" in df.columns:
5970                df = df[df["cluster"].notna()]
5971            if len(df) == 0 or "sample_id" not in df.columns:
5972                return
5973            for sample_id, sample_df in df.groupby("sample_id"):
5974                mzs = []
5975                if "_eic_mz" in sample_df.columns:
5976                    mzs.extend(sample_df["_eic_mz"].dropna().tolist())
5977                if "mz" in sample_df.columns:
5978                    # Include mz for rows with missing _eic_mz
5979                    if "_eic_mz" in sample_df.columns:
5980                        missing = sample_df["_eic_mz"].isna()
5981                        mzs.extend(sample_df.loc[missing, "mz"].dropna().tolist())
5982                    else:
5983                        mzs.extend(sample_df["mz"].dropna().tolist())
5984                sid = int(sample_id)
5985                existing = cluster_mz_dict.get(sid, [])
5986                cluster_mz_dict[sid] = list(set(existing + [float(m) for m in mzs]))
5987
5988        # Regular clustered features only: these are what were exported to per-sample
5989        # HDF5 EICs. Induced (gap-filled) features get EICs at gap-fill time on the
5990        # sample object; they are typically not present in the original HDF5 eics group.
5991        _add_from_df(self.mass_features_dataframe)
5992
5993        return cluster_mz_dict
5994
5995    def _execute_sample_pipeline(
5996        self, sample_id, operations, runtime_params, inplace=True
5997    ):
5998        """
5999        Execute a pipeline of operations on a single sample.
6000
6001        This is the worker function called (potentially in parallel) for each sample.
6002
6003        Parameters
6004        ----------
6005        sample_id : int
6006            Sample ID to process
6007        operations : list of SampleOperation
6008            Operations to execute in order
6009        runtime_params : dict
6010            Runtime parameters prepared by _prepare_pipeline_runtime_params
6011        inplace : bool, optional
6012            If True, updates sample in place. If False, returns results for
6013            multiprocessing. Default is True.
6014
6015        Returns
6016        -------
6017        dict
6018            Dictionary with results from each operation, keyed by operation name.
6019            If inplace=True, returns results that need to be collected.
6020            If inplace=False, returns all results for multiprocessing collection.
6021        """
6022        results = {}
6023
6024        # Check if any operations need raw MS data
6025        needs_raw_data = {}  # {ms_level: True/False}
6026        for op in operations:
6027            needs_raw, ms_level = op.needs_raw_ms_data()
6028            if needs_raw and ms_level:
6029                needs_raw_data[ms_level] = True
6030
6031        # Load raw data once if any operations need it
6032        # Note: For gap-filling, it loads data internally, so we just track it here
6033        for ms_level in needs_raw_data.keys():
6034            # Gap-filling loads its own data, but we want to keep track that it's loaded
6035            # Other operations can then use the loaded data
6036            pass
6037
6038        for op in operations:
6039            # Check if operation can execute on this sample
6040            sample = self[sample_id]
6041            if not op.can_execute(sample, self):
6042                # Skip this operation for this sample if prerequisites aren't met
6043                # This allows processing to continue for samples that don't have
6044                # all required data (e.g., MS2 spectra)
6045                results[op.name] = None
6046                continue
6047
6048            # Prepare operation-specific runtime params
6049            op_runtime_params = {}
6050
6051            # Add gap-fill params if this is a gap-fill operation
6052            from corems.mass_spectra.calc.lc_calc_operations import (
6053                GapFillOperation,
6054                ReloadFeaturesOperation,
6055                MS2SpectralSearchOperation,
6056                LoadEICsOperation,
6057            )
6058
6059            if isinstance(op, GapFillOperation):
6060                if "missingdf" in runtime_params:
6061                    op_runtime_params["missingdf"] = runtime_params["missingdf"]
6062                    op_runtime_params["cluster_dict"] = runtime_params["cluster_dict"]
6063                    op_runtime_params["expand_on_miss"] = runtime_params[
6064                        "expand_on_miss"
6065                    ]
6066
6067            elif isinstance(op, ReloadFeaturesOperation):
6068                if "sample_mf_map" in runtime_params:
6069                    sample_mf_map = runtime_params["sample_mf_map"]
6070                    # Always pass mf_ids_to_load to ensure we only load what's needed
6071                    # If sample not in map, it has no representatives - pass empty list
6072                    op_runtime_params["mf_ids_to_load"] = sample_mf_map.get(
6073                        sample_id, []
6074                    )
6075
6076            elif isinstance(op, MS2SpectralSearchOperation):
6077                # Add MS2 spectral search parameters
6078                if "fe_lib" in runtime_params:
6079                    op_runtime_params["fe_lib"] = runtime_params["fe_lib"]
6080                if "molecular_metadata" in runtime_params:
6081                    op_runtime_params["molecular_metadata"] = runtime_params[
6082                        "molecular_metadata"
6083                    ]
6084
6085            elif isinstance(op, LoadEICsOperation):
6086                # Add EIC loading parameters
6087                if "cluster_mz_dict" in runtime_params:
6088                    op_runtime_params["cluster_mz_dict"] = runtime_params[
6089                        "cluster_mz_dict"
6090                    ]
6091
6092            # Execute the operation
6093            result = op.execute(sample_id, self, **op_runtime_params)
6094            results[op.name] = result
6095
6096            # If inplace, collect immediately
6097            if inplace and result is not None:
6098                op.collect_results(sample_id, result, self)
6099
6100        # Clean up raw data if requested
6101        keep_raw_data = runtime_params.get("keep_raw_data", False)
6102        if not keep_raw_data:
6103            for ms_level in needs_raw_data.keys():
6104                if ms_level in self[sample_id]._ms_unprocessed:
6105                    del self[sample_id]._ms_unprocessed[ms_level]
6106
6107        # Include sample_id in results for tracking (especially important for imap_unordered)
6108        results["sample_id"] = sample_id
6109        return results
6110
6111    def process_consensus_features(
6112        self,
6113        load_representatives=True,
6114        perform_gap_filling=True,
6115        add_ms1=False,
6116        add_ms2=False,
6117        ms2_scan_filter=None,
6118        molecular_formula_search=False,
6119        ms2_spectral_search=False,
6120        spectral_lib=None,
6121        molecular_metadata=None,
6122        gather_eics=False,
6123        group_features=False,
6124        keep_raw_data=False,
6125        show_progress=True,
6126    ):
6127        """
6128        Process consensus mass features across the collection in a single parallelized pass.
6129
6130        This method provides a convenient interface to the sample processing pipeline,
6131        allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association,
6132        molecular formula search, and MS2 spectral search) to be performed efficiently in
6133        a single pass through all samples.
6134
6135        Parameters
6136        ----------
6137        load_representatives : bool, optional
6138            If True, loads representative mass features from HDF5. Default is True.
6139        perform_gap_filling : bool, optional
6140            If True, performs gap-filling for missing cluster features. Default is True.
6141            This operation loads raw MS1 data which can be reused by subsequent operations.
6142        group_features : bool, optional
6143            If True, run ``group_consensus_features()`` after gap-fill (and before
6144            molecular formula / MS2 search when those are also enabled).
6145            Collection-level natural-abundance **isotope and adduct** family
6146            labeling (singly charged only; not tracer/enriched). Default is False.
6147        add_ms1 : bool, optional
6148            If True and load_representatives=True, associates MS1 spectra with
6149            loaded features. Automatically uses raw data from gap-filling if available,
6150            otherwise uses parser. Spectrum mode is auto-detected. Default is False.
6151        add_ms2 : bool, optional
6152            If True and load_representatives=True, associates MS2 spectra with
6153            loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False.
6154        ms2_scan_filter : str or None, optional
6155            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
6156        molecular_formula_search : bool, optional
6157            If True, performs molecular formula search on mass features using
6158            associated MS1 spectra. Requires add_ms1=True or that MS1 spectra
6159            are already associated. Uses parameters from
6160            parameters.mass_spectrum["ms1"].molecular_search. Default is False.
6161        ms2_spectral_search : bool, optional
6162            If True, performs MS2 spectral library search using FlashEntropy.
6163            Requires add_ms2=True and spectral_lib to be provided. Default is False.
6164        spectral_lib : FlashEntropy library, optional
6165            Pre-prepared FlashEntropy spectral library for MS2 search.
6166            Create using MSPInterface.get_metabolomics_spectra_library().
6167            Required if ms2_spectral_search=True. Default is None.
6168        molecular_metadata : pd.DataFrame, optional
6169            Molecular metadata corresponding to spectral_lib.
6170            Returned from MSPInterface.get_metabolomics_spectra_library().
6171            Stored as self.spectral_search_molecular_metadata for later export.
6172            Default is None.
6173        gather_eics : bool, optional
6174            If True, loads extracted ion chromatograms (EICs) from HDF5 for all
6175            mass features with assigned cluster_index (including gap-filled features).
6176            Enables access to EICs via get_eics_for_cluster(cluster_id) method.
6177            Requires that EICs were previously exported with export_eics=True.
6178            Default is False.
6179        keep_raw_data : bool, optional
6180            If True, keeps raw MS data loaded in memory after pipeline completes.
6181            If False, cleans up raw data to free memory. Default is False.
6182        show_progress : bool, optional
6183            If True, displays progress bars during processing. If False, runs silently.
6184            Default is True.
6185
6186        Returns
6187        -------
6188        dict
6189            Dictionary with pipeline results. Keys include:
6190            - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling)
6191            - 'reload': dict mapping sample_id to reloaded mass features (if reloading)
6192            - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search)
6193            - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search)
6194
6195        Raises
6196        ------
6197        ValueError
6198            If neither operation is enabled, or if required parameters are missing.
6199
6200        Notes
6201        -----
6202        - Must run add_consensus_mass_features() before calling this method
6203        - Processes samples in parallel based on parameters.lcms_collection.cores
6204        - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association
6205        - MS2 spectral search requires add_ms2=True and msp_file_path
6206        - FlashEntropy library is created once and reused across all samples
6207        - More efficient than calling individual methods separately
6208        - After gap-filling, sets missing_mass_features_searched = True
6209        - Mass features remain loaded in memory for downstream processing
6210        - For more advanced workflows, use process_samples_pipeline() directly
6211
6212        Examples
6213        --------
6214        >>> # Prepare spectral library for MS2 search
6215        >>> from corems.molecular_id.search.database_interfaces import MSPInterface
6216        >>> my_msp = MSPInterface(file_path='path/to/library.msp')
6217        >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library(
6218        ...     polarity='negative',
6219        ...     format='flashentropy',
6220        ...     normalize=True,
6221        ...     fe_kwargs={
6222        ...         'normalize_intensity': True,
6223        ...         'min_ms2_difference_in_da': 0.02,
6224        ...         'max_ms2_tolerance_in_da': 0.01,
6225        ...         'max_indexed_mz': 3000,
6226        ...         'precursor_ions_removal_da': None,
6227        ...         'noise_threshold': 0,
6228        ...     }
6229        ... )
6230        >>>
6231        >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search
6232        >>> results = lcms_collection.process_consensus_features(
6233        ...     load_representatives=True,
6234        ...     perform_gap_filling=True,
6235        ...     add_ms1=True,
6236        ...     add_ms2=True,
6237        ...     molecular_formula_search=True,
6238        ...     ms2_spectral_search=True,
6239        ...     spectral_lib=spectral_lib,
6240        ...     molecular_metadata=molecular_metadata
6241        ... )
6242
6243        See Also
6244        --------
6245        process_samples_pipeline : Generic pipeline executor for custom workflows
6246        fill_missing_cluster_features : Original gap-filling method
6247        reload_representative_mass_features : Original reload method
6248        """
6249        from corems.mass_spectra.calc.lc_calc_operations import (
6250            GapFillOperation,
6251            ReloadFeaturesOperation,
6252            MolecularFormulaSearchOperation,
6253            MS2SpectralSearchOperation,
6254            LoadEICsOperation,
6255        )
6256
6257        # Validate that at least one meaningful operation is enabled
6258        has_operations = (
6259            perform_gap_filling
6260            or load_representatives
6261            or molecular_formula_search
6262            or ms2_spectral_search
6263            or gather_eics
6264            or add_ms1
6265            or add_ms2
6266            or group_features
6267        )
6268
6269        if not has_operations:
6270            raise ValueError(
6271                "At least one operation must be enabled: perform_gap_filling, load_representatives, "
6272                "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, add_ms2, "
6273                "or group_features"
6274            )
6275
6276        # Validate prerequisites for gap-filling
6277        if perform_gap_filling:
6278            if (
6279                not hasattr(self, "cluster_summary_dataframe")
6280                or self.cluster_summary_dataframe is None
6281            ):
6282                raise ValueError(
6283                    "Cannot perform gap-filling: cluster_summary_dataframe not set. "
6284                    "You must run add_consensus_mass_features() before calling process_consensus_features()."
6285                )
6286
6287        # Validate prerequisites for MS2 spectral search
6288        if ms2_spectral_search:
6289            if spectral_lib is None:
6290                raise ValueError(
6291                    "MS2 spectral search requires spectral_lib to be provided. "
6292                    "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method."
6293                )
6294            # Check if mass features will be loaded OR are already loaded
6295            # (The operation's can_execute will check if MS2 spectra are actually present)
6296            if not load_representatives and not perform_gap_filling:
6297                # Check if at least one sample has mass features loaded
6298                # This allows MS2 search on already-loaded features
6299                has_loaded_features = any(
6300                    len(self[i].mass_features) > 0
6301                    if hasattr(self[i], "mass_features")
6302                    and self[i].mass_features is not None
6303                    else False
6304                    for i in range(len(self.samples))
6305                )
6306                if not has_loaded_features:
6307                    raise ValueError(
6308                        "MS2 spectral search requires mass features to be loaded. "
6309                        "Either set load_representatives=True or perform_gap_filling=True to load them, "
6310                        "or load them in a previous call to process_consensus_features() before calling "
6311                        "with ms2_spectral_search=True."
6312                    )
6313
6314        # Build pipeline in two phases so group_features runs after gap-fill
6315        # and before formula / MS2 search when those are requested in the same call.
6316        pre_operations = []
6317        post_operations = []
6318
6319        if perform_gap_filling:
6320            expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss
6321            pre_operations.append(
6322                GapFillOperation("gap_fill", expand_on_miss=expand_on_miss)
6323            )
6324
6325        if load_representatives:
6326            # MS1/MS2 association for representatives can happen in pre-pass;
6327            # formula/MS2 library search stay in post-pass after grouping.
6328            pre_operations.append(
6329                ReloadFeaturesOperation(
6330                    "reload",
6331                    add_ms1=add_ms1,
6332                    add_ms2=add_ms2,
6333                    auto_process_ms2=add_ms2,
6334                    ms2_scan_filter=ms2_scan_filter,
6335                )
6336            )
6337
6338        if molecular_formula_search:
6339            post_operations.append(MolecularFormulaSearchOperation("mf_search"))
6340
6341        if ms2_spectral_search:
6342            post_operations.append(
6343                MS2SpectralSearchOperation(
6344                    "ms2_search", ms2_scan_filter=ms2_scan_filter
6345                )
6346            )
6347            self._spectral_lib = spectral_lib
6348            self._spectral_search_molecular_metadata = molecular_metadata
6349
6350        if gather_eics:
6351            # After grouping when grouping is requested; otherwise with pre-pass
6352            # (or post-pass if only annotation ops are enabled).
6353            if group_features or not pre_operations:
6354                post_operations.append(LoadEICsOperation("load_eics"))
6355            else:
6356                pre_operations.append(LoadEICsOperation("load_eics"))
6357
6358        results = {}
6359
6360        def _run_ops(operations):
6361            if not operations:
6362                return {}
6363            return self.process_samples_pipeline(
6364                operations, keep_raw_data=keep_raw_data, show_progress=show_progress
6365            )
6366
6367        # Phase 1: gap-fill / reload (and eics if not grouping)
6368        if pre_operations:
6369            results.update(_run_ops(pre_operations))
6370
6371        # Post-processing for gap-fill must happen before grouping so heights include induced
6372        if perform_gap_filling:
6373            self._combine_mass_features(induced_features=True)
6374            self.missing_mass_features_searched = True
6375
6376            if (
6377                self.induced_mass_features_dataframe is not None
6378                and len(self.induced_mass_features_dataframe) > 0
6379            ):
6380                eics_mz = []
6381                for i, row in self.induced_mass_features_dataframe.iterrows():
6382                    sample_id = row["sample_id"]
6383                    sample = self[sample_id]
6384                    if row["mf_id"] in sample.induced_mass_features.keys():
6385                        eic_mz = sample.induced_mass_features[row["mf_id"]]._eic_mz
6386                        eics_mz.append(eic_mz)
6387                    else:
6388                        eics_mz.append(None)
6389                self.induced_mass_features_dataframe["_eic_mz"] = eics_mz
6390
6391        # Collection-level isotope + adduct grouping
6392        if group_features:
6393            t_group = time.perf_counter()
6394            labels = self.group_consensus_features()
6395            group_wall = time.perf_counter() - t_group
6396            results["group_features"] = {
6397                "n_clusters": len(labels),
6398                "n_grouped": int(labels["feature_group_id"].notna().sum()),
6399                "n_groups": int(labels["feature_group_id"].nunique(dropna=True)),
6400                "wall_seconds": group_wall,
6401                "stage_timings": getattr(self, "_last_feature_group_timings", None),
6402            }
6403
6404        # Phase 2: annotation / optional EICs after grouping
6405        if post_operations:
6406            # If only post_operations and no pre, still need a pipeline run
6407            post_results = _run_ops(post_operations)
6408            for k, v in post_results.items():
6409                results[k] = v
6410
6411        # Store molecular metadata if spectral search was performed
6412        if ms2_spectral_search and hasattr(self, "_spectral_search_molecular_metadata"):
6413            self.spectral_search_molecular_metadata = (
6414                self._spectral_search_molecular_metadata
6415            )
6416
6417        # Associate EICs while induced feature objects still exist (before any clear).
6418        if gather_eics:
6419            print("\nAssociating EICs with mass features:")
6420            from tqdm import tqdm
6421
6422            for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80):
6423                sample = self[sample_id]
6424                if sample.eics:
6425                    sample.associate_eics_with_mass_features(induced=False)
6426                    sample.associate_eics_with_mass_features(induced=True)
6427
6428        # Drop induced feature objects to free memory after grouping + post-ops
6429        if perform_gap_filling:
6430            for sample_name in self.samples:
6431                self._lcms[sample_name].induced_mass_features = {}
6432
6433        return results
def find_closest(A, target):
31def find_closest(A, target):
32    """Find the index of closest value in A to each value in target.
33
34    Parameters
35    ----------
36    A : :obj:`~numpy.array`
37        The array to search (blueprint). A must be sorted.
38    target : :obj:`~numpy.array`
39        The array of values to search for. target must be sorted.
40
41    Returns
42    -------
43    :obj:`~numpy.array`
44        The indices of the closest values in A to each value in target.
45    """
46    idx = A.searchsorted(target)
47    idx = np.clip(idx, 1, len(A) - 1)
48    left = A[idx - 1]
49    right = A[idx]
50    idx -= target - left < right - target
51    return idx

Find the index of closest value in A to each value in target.

Parameters
  • A (~numpy.array): The array to search (blueprint). A must be sorted.
  • target (~numpy.array): The array of values to search for. target must be sorted.
Returns
  • ~numpy.array: The indices of the closest values in A to each value in target.
class LCCalculations:
  95class LCCalculations:
  96    """Methods for performing LC calculations on mass spectra data.
  97
  98    Notes
  99    -----
 100    This class is intended to be used as a mixin for the LCMSBase class.
 101
 102    Methods
 103    -------
 104    * get_max_eic(eic_data).
 105        Returns the maximum EIC value from the given EIC data. A static method.
 106    * smooth_tic(tic).
 107        Smooths the TIC data using the specified smoothing method and settings.
 108    * eic_centroid_detector(rt, eic, max_eic).
 109        Performs EIC centroid detection on the given EIC data.
 110    * find_nearest_scan(rt).
 111        Finds the nearest scan to the given retention time.
 112    * get_average_mass_spectrum(scan_list, apex_scan, spectrum_mode="profile", ms_level=1, auto_process=True, use_parser=False, perform_checks=True, polarity=None).
 113        Returns an averaged mass spectrum object.
 114    * find_mass_features(ms_level=1).
 115        Find regions of interest for a given MS level (default is MS1).
 116    * integrate_mass_features(drop_if_fail=False, ms_level=1).
 117        Integrate mass features of interest and extracts EICs.
 118    * find_c13_mass_features().
 119        Evaluate mass features and mark likely C13 isotopes.
 120    * deconvolute_ms1_mass_features().
 121        Deconvolute mass features' ms1 mass spectra.
 122    """
 123
 124    @staticmethod
 125    def get_max_eic(eic_data: dict):
 126        """Returns the maximum EIC value from the given EIC data.
 127
 128        Notes
 129        -----
 130        This is a static method.
 131
 132        Parameters
 133        ----------
 134        eic_data : dict
 135            A dictionary containing EIC data.
 136
 137        Returns
 138        -------
 139        float
 140            The maximum EIC value.
 141        """
 142        max_eic = 0
 143        for eic_data in eic_data.values():
 144            ind_max_eic = max(eic_data.get("EIC"))
 145            max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic
 146
 147        return max_eic
 148
 149    def smooth_tic(self, tic):
 150        """Smooths the TIC or EIC data using the specified smoothing method and settings.
 151
 152        Parameters
 153        ----------
 154        tic : numpy.ndarray
 155            The TIC (or EIC) data to be smoothed.
 156
 157        Returns
 158        -------
 159        numpy.ndarray
 160            The smoothed TIC data.
 161        """
 162        implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method
 163
 164        pol_order = self.parameters.lc_ms.savgol_pol_order
 165
 166        window_len = self.parameters.lc_ms.smooth_window
 167
 168        window = self.parameters.lc_ms.smooth_method
 169
 170        return sp.smooth_signal(
 171            tic, window_len, window, pol_order, implemented_smooth_method
 172        )
 173
 174    def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]):
 175        """Performs EIC centroid detection on the given EIC data.
 176
 177        Parameters
 178        ----------
 179        rt : numpy.ndarray
 180            The retention time data.
 181        eic : numpy.ndarray
 182            The EIC data.
 183        max_eic : float
 184            The maximum EIC value.
 185        apex_indexes : list, optional
 186            The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function.
 187
 188        Returns
 189        -------
 190        numpy.ndarray
 191            The indexes of left, apex, and right limits as a generator.
 192        """
 193
 194        max_prominence = self.parameters.lc_ms.peak_max_prominence_percent
 195
 196        max_height = self.parameters.lc_ms.peak_height_max_percent
 197
 198        signal_threshold = self.parameters.lc_ms.eic_signal_threshold
 199
 200        min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints
 201
 202        peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold
 203
 204        include_indexes = sp.peak_picking_first_derivative(
 205            domain=rt,
 206            signal=eic,
 207            max_height=max_height,
 208            max_prominence=max_prominence,
 209            max_signal=max_eic,
 210            min_peak_datapoints=min_peak_datapoints,
 211            peak_derivative_threshold=peak_derivative_threshold,
 212            signal_threshold=signal_threshold,
 213            correct_baseline=False,
 214            plot_res=False,
 215            apex_indexes=apex_indexes,
 216        )
 217        # include_indexes is a generator of tuples (left_index, apex_index, right_index)
 218        include_indexes = list(include_indexes)
 219        # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex
 220        indicies = [x for x in include_indexes]
 221        for idx in indicies:
 222            if (idx[1] - idx[0] < min_peak_datapoints / 2) or (
 223                idx[2] - idx[1] < min_peak_datapoints / 2
 224            ):
 225                include_indexes.remove(idx)
 226        return include_indexes
 227
 228    def find_nearest_scan(self, rt):
 229        """Finds the nearest scan to the given retention time.
 230
 231        Parameters
 232        ----------
 233        rt : float
 234            The retention time (in minutes) to find the nearest scan for.
 235
 236        Returns
 237        -------
 238        int
 239            The scan number of the nearest scan.
 240        """
 241        array_rt = np.array(self.retention_time)
 242
 243        scan_index = (np.abs(array_rt - rt)).argmin()
 244
 245        real_scan = self.scans_number[scan_index]
 246
 247        return real_scan
 248
 249    def add_peak_metrics(self, remove_by_metrics=True, induced_features=False):
 250        """Add peak metrics to the mass features.
 251
 252        This function calculates the peak metrics for each mass feature and adds them to the mass feature objects.
 253
 254        Parameters
 255        ----------
 256        remove_by_metrics : bool, optional
 257            If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity,
 258            dispersity index, and noise score. Default is True, which checks the setting in the processing parameters.
 259            If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters.
 260        induced_features : bool, optional
 261            Whether the mass features to be integrated were induced. Default is False.
 262        """
 263        # Check that at least some mass features have eic data
 264        if induced_features:
 265            mf_dict_values = self.induced_mass_features.values()
 266        else:
 267            mf_dict_values = self.mass_features.values()
 268
 269        if not any([mf._eic_data is not None for mf in mf_dict_values]):
 270            raise ValueError(
 271                "No mass features have EIC data. Run integrate_mass_features first."
 272            )
 273
 274        for mass_feature in mf_dict_values:
 275            # Check if the mass feature has been integrated
 276            if mass_feature._eic_data is not None and mass_feature.area is not None:
 277                # Calculate peak metrics
 278                mass_feature.calc_half_height_width()
 279                mass_feature.calc_tailing_factor()
 280                mass_feature.calc_dispersity_index()
 281                mass_feature.calc_gaussian_similarity()
 282                mass_feature.calc_noise_score()
 283
 284        # Remove mass features by peak metrics if designated in parameters
 285        if (
 286            self.parameters.lc_ms.remove_mass_features_by_peak_metrics
 287            and remove_by_metrics
 288        ):
 289            self._remove_mass_features_by_peak_metrics(
 290                induced_features=induced_features
 291            )
 292
 293    def get_average_mass_spectrum(
 294        self,
 295        scan_list,
 296        apex_scan,
 297        spectrum_mode="profile",
 298        ms_level=1,
 299        auto_process=True,
 300        use_parser=False,
 301        perform_checks=True,
 302        polarity=None,
 303        ms_params=None,
 304    ):
 305        """Returns an averaged mass spectrum object
 306
 307        Parameters
 308        ----------
 309        scan_list : list
 310            List of scan numbers to average.
 311        apex_scan : int
 312            Number of the apex scan
 313        spectrum_mode : str, optional
 314            The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging.
 315        ms_level : int, optional
 316            The MS level to use. Defaults to 1.
 317        auto_process : bool, optional
 318            If True, the averaged mass spectrum will be auto-processed. Defaults to True.
 319        use_parser : bool, optional
 320            If True, the mass spectra will be obtained from the parser. Defaults to False.
 321        perform_checks : bool, optional
 322            If True, the function will check if the data are within the ms_unprocessed dictionary and are the correct mode. Defaults to True. Only set to False if you are sure the data are profile, and (if not using the parser) are in the ms_unprocessed dictionary!  ms_unprocessed dictionary also must be indexed on scan
 323        polarity : int, optional
 324            The polarity of the mass spectra (1 or -1). If not set, the polarity will be determined from the dataset. Defaults to None. (fastest if set to -1 or 1)
 325        ms_params : MSParameters, optional
 326            The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None.
 327
 328        Returns
 329        -------
 330        MassSpectrumProfile
 331            The averaged mass spectrum object.
 332
 333        Raises
 334        ------
 335        ValueError
 336            If the spectrum mode is not "profile".
 337            If the MS level is not found in the unprocessed mass spectra dictionary.
 338            If not all scan numbers are found in the unprocessed mass spectra dictionary.
 339        """
 340        if perform_checks:
 341            if spectrum_mode != "profile":
 342                raise ValueError("Averaging only supported for profile mode")
 343
 344        if polarity is None:
 345            # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
 346            if self.polarity == "negative":
 347                polarity = -1
 348            elif self.polarity == "positive":
 349                polarity = 1
 350            else:
 351                raise ValueError(
 352                    "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'"
 353                )
 354
 355        # if not using_parser, check that scan numbers are in _ms_unprocessed
 356        if not use_parser:
 357            if perform_checks:
 358                # Set index to scan for faster lookup
 359                ms_df = (
 360                    self._ms_unprocessed[ms_level]
 361                    .copy()
 362                    .set_index("scan", drop=False)
 363                    .sort_index()
 364                )
 365                my_ms_df = ms_df.loc[scan_list]
 366                # Check that all scan numbers are in the ms_df
 367                if not all(np.isin(scan_list, ms_df.index)):
 368                    raise ValueError(
 369                        "Not all scan numbers found in the unprocessed mass spectra dictionary"
 370                    )
 371            else:
 372                my_ms_df = (
 373                    pd.DataFrame({"scan": scan_list})
 374                    .set_index("scan")
 375                    .join(self._ms_unprocessed[ms_level], how="left")
 376                )
 377
 378        if use_parser:
 379            parser = self.spectra_parser
 380            ms_list = [
 381                parser.get_mass_spectrum_from_scan(
 382                    x, spectrum_mode=spectrum_mode, auto_process=False
 383                )
 384                for x in scan_list
 385            ]
 386            ms_mz = [x._mz_exp for x in ms_list]
 387            ms_int = [x._abundance for x in ms_list]
 388            my_ms_df = []
 389            for i in np.arange(len(ms_mz)):
 390                my_ms_df.append(
 391                    pd.DataFrame(
 392                        {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]}
 393                    )
 394                )
 395            my_ms_df = pd.concat(my_ms_df)
 396
 397        if not self.check_if_grid(my_ms_df):
 398            my_ms_df = self.grid_data(my_ms_df)
 399
 400        my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index()
 401
 402        ms = ms_from_array_profile(
 403            my_ms_ave.mz,
 404            my_ms_ave.intensity,
 405            self.file_location,
 406            polarity=polarity,
 407            auto_process=False,
 408        )
 409
 410        # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset
 411        if ms is not None:
 412            if ms_params is not None:
 413                ms.parameters = ms_params
 414            ms.scan_number = apex_scan
 415            if auto_process:
 416                ms.process_mass_spec()
 417        return ms
 418
 419    def _resolve_lcms_peak_picking_method(self, ms_level=1):
 420        """Resolve and validate the 2D peak picking method against scan formats.
 421
 422        Parameters
 423        ----------
 424        ms_level : int, optional
 425            MS level whose ``scan_df`` formats are checked. Default is 1.
 426
 427        Returns
 428        -------
 429        str
 430            Concrete method: ``persistent homology`` or
 431            ``centroided_persistent_homology``. If the stored method is
 432            ``by_format``, it is chosen from scan formats (all profile or all
 433            centroid).
 434
 435        Raises
 436        ------
 437        ValueError
 438            If the method is not implemented, ``by_format`` cannot be resolved
 439            (empty, mixed, or unknown formats), or an explicit method does
 440            not match the scan formats at ``ms_level``.
 441        """
 442        method = self.parameters.lc_ms.peak_picking_method
 443        msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level]
 444        values = list(msx_scan_df["ms_format"])
 445
 446        if method == "by_format":
 447            if len(values) == 0:
 448                raise ValueError(
 449                    "Cannot resolve by_format peak picking: no scans found "
 450                    "for the requested MS level."
 451                )
 452            if all(v == "profile" for v in values):
 453                return "persistent homology"
 454            if all(v == "centroid" for v in values):
 455                return "centroided_persistent_homology"
 456            found = sorted({str(v) for v in values})
 457            raise ValueError(
 458                "Cannot resolve by_format peak picking: MS scans are mixed "
 459                f"or unknown format (found {found}). Set peak_picking_method "
 460                "explicitly to 'persistent homology' or "
 461                "'centroided_persistent_homology'."
 462            )
 463
 464        if method == "persistent homology":
 465            if len(values) == 0 or not all(v == "profile" for v in values):
 466                raise ValueError(
 467                    "MS{} scans are not profile mode, which is required for persistent homology peak picking.".format(
 468                        ms_level
 469                    )
 470                )
 471            return method
 472
 473        if method == "centroided_persistent_homology":
 474            if len(values) == 0 or not all(v == "centroid" for v in values):
 475                raise ValueError(
 476                    "MS{} scans are not centroid mode, which is required for persistent homology centroided peak picking.".format(
 477                        ms_level
 478                    )
 479                )
 480            return method
 481
 482        raise ValueError("Peak picking method not implemented")
 483
 484    def find_mass_features(
 485        self,
 486        ms_level=1,
 487        grid=True,
 488        assign_ms2_scans=False,
 489        ms2_scan_filter=None,
 490        targeted_search=False,
 491        target_search_dict=None,
 492        accumulate_features=False,
 493    ):
 494        """Find mass features within an LCMSBase object
 495
 496        Wrapper that dispatches to a 2D peak picking implementation.
 497
 498        If ``parameters.lc_ms.peak_picking_method`` is ``by_format``, the method is
 499        chosen from ``scan_df.ms_format`` at the requested MS level (all
 500        profile → persistent homology; all centroid → centroided persistent
 501        homology). A successful resolve overwrites
 502        ``peak_picking_method`` with the concrete method so saved parameters
 503        record what was used.
 504
 505        Parameters
 506        ----------
 507        ms_level : int, optional
 508            The MS level to use for peak picking Default is 1.
 509        grid : bool, optional
 510            If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded),
 511            used for persistent homology peak picking for profile data only. Default is True.
 512        assign_ms2_scans : bool, optional
 513            If True, assign MS2 scan numbers to mass features after peak picking.
 514            This populates the ms2_scan_numbers attribute on each mass feature, which enables
 515            choosing representative features based on MS2 availability. Default is False.
 516        ms2_scan_filter : str or None, optional
 517            Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd').
 518            If None, all MS2 scans are considered. Default is None.
 519        targeted_search : bool, optional
 520            If True, perform targeted mass feature search using the target_search_dict.
 521            This mode filters data to only m/z and RT windows of interest and bypasses
 522            intensity and persistence thresholds. Default is False.
 523        target_search_dict : dict or None, optional
 524            Dictionary containing target search parameters. Required if targeted_search is True.
 525            Must contain:
 526                - 'target_mz_list': list of target m/z values
 527                - 'target_rt_list': list of target retention times (in minutes)
 528                - 'mz_tolerance_ppm': m/z tolerance in ppm
 529                - 'rt_tolerance': retention time tolerance (in minutes)
 530            Optionally can contain:
 531                - 'type': type label for mass features (e.g., "internal standard")
 532                  If not provided, defaults to "targeted"
 533            Default is None.
 534        accumulate_features : bool, optional
 535            If True, new mass features will be added to existing features rather than replacing them.
 536            This allows multiple sequential calls to find_mass_features to build up a combined set.
 537            Default is False (replace existing features for backwards compatibility).
 538
 539        Raises
 540        ------
 541        ValueError
 542            If no MS level data is found on the object.
 543            If ``by_format`` cannot resolve a method (empty, mixed, or unknown
 544            ``ms_format`` values at the requested MS level).
 545            If persistent homology peak picking is attempted on non-profile mode data.
 546            If centroided persistent homology is attempted on non-centroid mode data.
 547            If data is not gridded and grid is False.
 548            If peak picking method is not implemented.
 549            If targeted_search is True but target_search_dict is None or invalid.
 550
 551        Returns
 552        -------
 553        None, but assigns the mass_features and eics attributes to the object.
 554
 555        """
 556        # Validate targeted search parameters
 557        if targeted_search:
 558            if target_search_dict is None:
 559                raise ValueError(
 560                    "target_search_dict must be provided when targeted_search is True"
 561                )
 562            required_keys = [
 563                "target_mz_list",
 564                "target_rt_list",
 565                "mz_tolerance_ppm",
 566                "rt_tolerance",
 567            ]
 568            for key in required_keys:
 569                if key not in target_search_dict:
 570                    raise ValueError(f"target_search_dict must contain '{key}'")
 571            if len(target_search_dict["target_mz_list"]) != len(
 572                target_search_dict["target_rt_list"]
 573            ):
 574                raise ValueError(
 575                    "target_mz_list and target_rt_list must have the same length"
 576                )
 577
 578        requested_method = self.parameters.lc_ms.peak_picking_method
 579        pp_method = self._resolve_lcms_peak_picking_method(ms_level=ms_level)
 580        if requested_method == "by_format":
 581            self.parameters.lc_ms.peak_picking_method = pp_method
 582
 583        if targeted_search:
 584            mf_type = target_search_dict.get("type", "targeted")
 585        else:
 586            mf_type = "untargeted"
 587
 588        if pp_method == "persistent homology":
 589            self.find_mass_features_ph(
 590                ms_level=ms_level,
 591                grid=grid,
 592                targeted_search=targeted_search,
 593                target_search_dict=target_search_dict,
 594                mf_type=mf_type,
 595                accumulate_features=accumulate_features,
 596            )
 597        elif pp_method == "centroided_persistent_homology":
 598            self.find_mass_features_ph_centroid(
 599                ms_level=ms_level,
 600                targeted_search=targeted_search,
 601                target_search_dict=target_search_dict,
 602                mf_type=mf_type,
 603                accumulate_features=accumulate_features,
 604            )
 605        else:
 606            raise ValueError("Peak picking method not implemented")
 607
 608        # Cluster mass features to remove redundant features
 609        self.cluster_mass_features(drop_children=True)
 610
 611        # Optionally assign MS2 scan numbers to mass features during peak picking
 612        # This helps with choosing representative features that have MS2 data
 613        if assign_ms2_scans:
 614            try:
 615                self._find_ms2_scans_for_mass_features(
 616                    mf_ids=None,  # Process all mass features
 617                    scan_filter=ms2_scan_filter,
 618                )
 619            except ValueError:
 620                # No MS2 scans found - this is okay, just skip
 621                pass
 622
 623        # Remove noisey mass features if designated in parameters
 624        if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search:
 625            self._remove_redundant_mass_features()
 626
 627    def integrate_mass_features(
 628        self,
 629        drop_if_fail=True,
 630        drop_duplicates=True,
 631        ms_level=1,
 632        induced_features=False,
 633    ):
 634        """Integrate mass features and extract EICs.
 635
 636        Populates the _eics attribute on the LCMSBase object for each unique mz in the mass_features dataframe and adds data (start_scan, final_scan, area) to the mass_features attribute.
 637
 638        Parameters
 639        ----------
 640        drop_if_fail : bool, optional
 641            Whether to drop mass features if the EIC limit calculations fail.
 642            Default is True.
 643        drop_duplicates : bool, optional
 644            Whether to mass features that appear to be duplicates
 645            (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating).
 646            Default is True.
 647        ms_level : int, optional
 648            The MS level to use. Default is 1.
 649        induced_features : bool, optional
 650            Whether the mass features to be intergrated were induced. Default is False.
 651
 652        Raises
 653        ------
 654        ValueError
 655            If no mass features are found.
 656            If no MS level data is found for the given MS level (either in data or in the scan data)
 657
 658        Returns
 659        -------
 660        None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute.
 661
 662        Notes
 663        -----
 664        drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection).
 665        """
 666
 667        # Check if there is data
 668        if ms_level in self._ms_unprocessed.keys():
 669            raw_data = self._ms_unprocessed[ms_level].copy()
 670        else:
 671            raise ValueError("No MS level " + str(ms_level) + " data found")
 672
 673        # Check if mass_spectrum exists on each mass feature
 674        if induced_features:
 675            mf_dict = self.induced_mass_features
 676            if len(mf_dict) == 0:
 677                raise ValueError(
 678                    "No induced mass features found, did you run fill_missing_cluster_features() first?"
 679                )
 680
 681            ## remove not found induced mass features by mz <= 0 (-99 indicator)
 682            # also remove any where mz is nan
 683            mf_dict = {
 684                k: v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz)
 685            }
 686
 687        else:
 688            mf_dict = self.mass_features
 689            if len(mf_dict) == 0:
 690                raise ValueError(
 691                    "No mass features found, did you run find_mass_features() first?"
 692                )
 693
 694        # Subset scan data to only include correct ms_level
 695        scan_df_sub = self.scan_df[
 696            self.scan_df["ms_level"] == int(ms_level)
 697        ].reset_index(drop=True)
 698        if scan_df_sub.empty:
 699            raise ValueError("No MS level " + ms_level + " data found in scan data")
 700        scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy()
 701
 702        mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()])
 703        mzs_to_extract.sort()
 704
 705        # Pre-sort raw_data by mz for faster filtering
 706        raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True)
 707        raw_data_mz = raw_data_sorted["mz"].to_numpy()
 708        raw_scan = raw_data_sorted["scan"].to_numpy()
 709        raw_intensity = raw_data_sorted["intensity"].to_numpy()
 710        scan_ids = scan_df_sub["scan"].to_numpy()
 711        scan_time = scan_df_sub["scan_time"].to_numpy()
 712        n_scans = scan_ids.size
 713        # Sum into the scan-table row with the same float32 Kahan sum the
 714        # previous groupby used, so the chromatogram values stay identical.
 715        scan_order = np.argsort(scan_ids, kind="mergesort")
 716        sorted_scan_ids = scan_ids[scan_order]
 717        found = np.searchsorted(sorted_scan_ids, raw_scan)
 718        in_range = found < n_scans
 719        scan_col = np.empty(raw_scan.shape, dtype=np.intp)
 720        scan_col[in_range] = scan_order[found[in_range]]
 721        matched = np.zeros(raw_scan.shape, dtype=bool)
 722        matched[in_range] = sorted_scan_ids[found[in_range]] == raw_scan[in_range]
 723
 724        # Get EICs for each unique mz in mass features list
 725        for mz in mzs_to_extract:
 726            mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6
 727            mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6
 728
 729            # Use binary search for faster mz range filtering
 730            left_idx = int(np.searchsorted(raw_data_mz, mz_min, side="left"))
 731            right_idx = int(np.searchsorted(raw_data_mz, mz_max, side="right"))
 732            eic = np.zeros(n_scans, dtype=np.float32)
 733            if left_idx < right_idx:
 734                comp = np.zeros(n_scans, dtype=np.float32)
 735                cols = scan_col[left_idx:right_idx]
 736                vals = raw_intensity[left_idx:right_idx]
 737                ok = matched[left_idx:right_idx]
 738                for i in range(cols.size):
 739                    if not ok[i]:
 740                        continue
 741                    col = cols[i]
 742                    val = vals[i]
 743                    y = val - comp[col]
 744                    t = eic[col] + y
 745                    c = (t - eic[col]) - y
 746                    comp[col] = c if np.isfinite(c) else np.float32(0)
 747                    eic[col] = t
 748            myEIC = EIC_Data(
 749                scans=scan_ids.copy(),
 750                time=scan_time.copy(),
 751                eic=eic,
 752            )
 753            # Smooth EIC
 754            smoothed_eic = self.smooth_tic(myEIC.eic)
 755            smoothed_eic[smoothed_eic < 0] = 0
 756            myEIC.eic_smoothed = smoothed_eic
 757            self.eics[mz] = myEIC
 758
 759        # Get limits of mass features using EIC centroid detector and integrate
 760        for idx, mass_feature in list(mf_dict.items()):
 761            mz = mass_feature.mz
 762            apex_scan = mass_feature.apex_scan
 763
 764            # Pull EIC data and find apex scan index
 765            myEIC = self.eics[mz]
 766            mf_dict[idx]._eic_data = myEIC
 767            mf_dict[idx]._eic_mz = mz
 768            apex_index = np.searchsorted(myEIC.scans, apex_scan)
 769
 770            # Find left and right limits of peak using EIC centroid detector, add to EICData
 771            centroid_eics = self.eic_centroid_detector(
 772                myEIC.time,
 773                myEIC.eic_smoothed,
 774                mass_feature.intensity * 1.1,
 775                apex_indexes=[int(apex_index)],
 776            )
 777            l_a_r_scan_idx = [i for i in centroid_eics]
 778            if len(l_a_r_scan_idx) > 0:
 779                # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans
 780                # Find the number of consecutive non-zero values in the EIC segment
 781                mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0
 782                # Find the longest run of consecutive True values
 783                if np.any(mask):
 784                    # Find indices where mask changes value
 785                    diff = np.diff(np.concatenate(([0], mask.astype(int), [0])))
 786                    starts = np.where(diff == 1)[0]
 787                    ends = np.where(diff == -1)[0]
 788                    consecutive_scans = (ends - starts).max()
 789                else:
 790                    consecutive_scans = 0
 791                if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min:
 792                    mf_dict.pop(idx)
 793                    continue
 794                # Add start and final scan to mass_features and EICData
 795                left_scan, right_scan = (
 796                    myEIC.scans[l_a_r_scan_idx[0][0]],
 797                    myEIC.scans[l_a_r_scan_idx[0][2]],
 798                )
 799                mf_scan_apex = [(left_scan, int(apex_scan), right_scan)]
 800                myEIC.apexes = myEIC.apexes + mf_scan_apex
 801                mf_dict[idx].start_scan = left_scan
 802                mf_dict[idx].final_scan = right_scan
 803
 804                # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData
 805                area = np.trapezoid(
 806                    myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1],
 807                    myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1],
 808                )
 809                myEIC.areas = myEIC.areas + [area]
 810                self.eics[mz] = myEIC
 811                mf_dict[idx]._area = area
 812            else:
 813                if drop_if_fail is True:
 814                    mf_dict.pop(idx)
 815
 816        if drop_duplicates:
 817            # Prepare mass feature dataframe
 818            # Skip features without scan bounds (failed EIC limits when
 819            # drop_if_fail=False); they cannot participate in containment de-dup
 820            if induced_features:
 821                mf_df = self.mass_features_to_df(induced_features=True).copy()
 822                mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()]
 823            else:
 824                mf_df = self.mass_features_to_df(induced_features=False).copy()
 825                mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()]
 826
 827            # For each mass feature, find all mass features within the clustering tolerance ppm and drop if their start and end times are within another mass feature
 828            # Keep the first mass feature (highest persistence)
 829            for idx, mass_feature in mf_df.iterrows():
 830                mz = mass_feature.mz
 831                apex_scan = mass_feature.apex_scan
 832
 833                mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6
 834                mf_df_sub = mf_df[
 835                    mf_df["mz_diff_ppm"]
 836                    < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel
 837                    * 10**6
 838                ].copy()
 839
 840                # For all mass features within the clustering tolerance, check if the start and end times are within the start and end times of the mass feature
 841                for idx2, mass_feature2 in mf_df_sub.iterrows():
 842                    if idx2 != idx:
 843                        s1, e1 = mass_feature.start_scan, mass_feature.final_scan
 844                        s2, e2 = mass_feature2.start_scan, mass_feature2.final_scan
 845                        # Guard incomplete bounds (None / NaN) so ordering
 846                        # comparisons never raise TypeError
 847                        if s1 is None or e1 is None or s2 is None or e2 is None:
 848                            continue
 849                        if pd.isna(s1) or pd.isna(e1) or pd.isna(s2) or pd.isna(e2):
 850                            continue
 851                        if s2 >= s1 and e2 <= e1:
 852                            if idx2 in self.mass_features.keys():
 853                                self.mass_features.pop(idx2)
 854
 855        # Filter MS2 scans to only include those within integration bounds
 856        # This ensures MS2 scans outside start_scan to final_scan are removed
 857        if induced_features:
 858            self._filter_ms2_scans_by_integration_bounds(
 859                mf_dict=self.induced_mass_features
 860            )
 861        else:
 862            self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features)
 863
 864    def find_c13_mass_features(self):
 865        """Mark likely C13 isotopes and connect to monoisoitopic mass features.
 866
 867        Returns
 868        -------
 869        None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object.
 870
 871        Raises
 872        ------
 873        ValueError
 874            If no mass features are found.
 875        """
 876        verbose = self.parameters.lc_ms.verbose_processing
 877        if verbose:
 878            print("evaluating mass features for C13 isotopes")
 879        if self.mass_features is None:
 880            raise ValueError("No mass features found, run find_mass_features() first")
 881
 882        # Data prep fo sparse distance matrix
 883        dims = ["mz", "scan_time"]
 884        mf_df = self.mass_features_to_df().copy()
 885        # Drop mass features that have no area (these are likely to be noise)
 886        mf_df = mf_df[mf_df["area"].notnull()]
 887        mf_df["mf_id"] = mf_df.index.values
 888        dims = ["mz", "scan_time"]
 889
 890        # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features
 891        mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy()
 892
 893        mz_diff = 1.003355  # C13-C12 mass difference
 894        tol = [
 895            mf_df["mz"].median()
 896            * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
 897            self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5,
 898        ]  # mz, in relative; scan_time in minutes
 899
 900        # Compute inter-feature distances
 901        distances = None
 902        for i in range(len(dims)):
 903            # Construct k-d tree
 904            values = mf_df[dims[i]].values
 905            tree = KDTree(values.reshape(-1, 1))
 906
 907            max_tol = tol[i]
 908            if dims[i] == "mz":
 909                # Maximum absolute tolerance
 910                max_tol = mz_diff + tol[i]
 911
 912            # Compute sparse distance matrix
 913            # the larger the max_tol, the slower this operation is
 914            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
 915
 916            # Only consider forward case, exclude diagonal
 917            sdm = sparse.triu(sdm, k=1)
 918
 919            if dims[i] == "mz":
 920                min_tol = mz_diff - tol[i]
 921                # Get only the ones that are above the min tol
 922                idx = sdm.data > min_tol
 923
 924                # Reconstruct sparse distance matrix
 925                sdm = sparse.coo_matrix(
 926                    (sdm.data[idx], (sdm.row[idx], sdm.col[idx])),
 927                    shape=(len(values), len(values)),
 928                )
 929
 930            # Cast as binary matrix
 931            sdm.data = np.ones_like(sdm.data)
 932
 933            # Stack distances
 934            if distances is None:
 935                distances = sdm
 936            else:
 937                distances = distances.multiply(sdm)
 938
 939        # Extract indices of within-tolerance points
 940        distances = distances.tocoo()
 941        pairs = np.stack((distances.row, distances.col), axis=1)  # C12 to C13 pairs
 942
 943        # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df
 944        pairs_mf = pairs.copy()
 945        pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values
 946        pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values
 947
 948        # Connect monoisotopic masses with isotopologes within mass_features
 949        monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1]))
 950        for mono in monos:
 951            self.mass_features[mono].monoisotopic_mf_id = mono
 952        pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"])
 953        while not pairs_iso_df.empty:
 954            pairs_iso_df = pairs_iso_df.set_index("parent", drop=False)
 955            m1_isos = pairs_iso_df.loc[monos, "child"].unique()
 956            for iso in m1_isos:
 957                # Set monoisotopic_mf_id and isotopologue_type for isotopologues
 958                parent = pairs_mf[pairs_mf[:, 1] == iso, 0]
 959                if len(parent) > 1:
 960                    # Choose the parent that is closest in time to the isotopologue
 961                    parent_time = [self.mass_features[p].retention_time for p in parent]
 962                    time_diff = [
 963                        np.abs(self.mass_features[iso].retention_time - x)
 964                        for x in parent_time
 965                    ]
 966                    parent = parent[np.argmin(time_diff)]
 967                else:
 968                    parent = parent[0]
 969                self.mass_features[iso].monoisotopic_mf_id = self.mass_features[
 970                    parent
 971                ].monoisotopic_mf_id
 972                if self.mass_features[iso].monoisotopic_mf_id is not None:
 973                    mass_diff = (
 974                        self.mass_features[iso].mz
 975                        - self.mass_features[
 976                            self.mass_features[iso].monoisotopic_mf_id
 977                        ].mz
 978                    )
 979                    self.mass_features[iso].isotopologue_type = "13C" + str(
 980                        int(round(mass_diff, 0))
 981                    )
 982
 983            # Drop the mono and iso from the pairs_iso_df
 984            pairs_iso_df = pairs_iso_df.drop(
 985                index=monos, errors="ignore"
 986            )  # Drop pairs where the parent is a child that is a child of a root
 987            pairs_iso_df = pairs_iso_df.set_index("child", drop=False)
 988            pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore")
 989
 990            if not pairs_iso_df.empty:
 991                # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process
 992                monos = np.setdiff1d(
 993                    np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child)
 994                )
 995        if verbose:
 996            # Report fraction of compounds annotated with isotopes
 997            mf_df["c13_flag"] = np.where(
 998                np.logical_or(
 999                    np.isin(mf_df["mf_id"], pairs_mf[:, 0]),
1000                    np.isin(mf_df["mf_id"], pairs_mf[:, 1]),
1001                ),
1002                1,
1003                0,
1004            )
1005            print(
1006                str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3))
1007                + " of mass features have or are C13 isotopes"
1008            )
1009
1010    def deconvolute_ms1_mass_features(self):
1011        """Deconvolute MS1 mass features
1012
1013        Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features
1014
1015        Parameters
1016        ----------
1017        None
1018
1019        Returns
1020        -------
1021        None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent,
1022        and associated_mass_features_deconvoluted attributes to the mass features in the
1023        mass_features attribute of the LCMSBase object.
1024
1025        Raises
1026        ------
1027        ValueError
1028            If no mass features are found, must run find_mass_features() first.
1029            If no EICs are found, did you run integrate_mass_features() first?
1030
1031        """
1032        # Checks for set mass_features and eics
1033        if self.mass_features is None:
1034            raise ValueError(
1035                "No mass features found, did you run find_mass_features() first?"
1036            )
1037
1038        if self.eics == {}:
1039            raise ValueError(
1040                "No EICs found, did you run integrate_mass_features() first?"
1041            )
1042
1043        if 1 not in self._ms_unprocessed.keys():
1044            raise ValueError("No unprocessed MS1 spectra found.")
1045
1046        # Prep ms1 data
1047        ms1_data = self._ms_unprocessed[1].copy()
1048        ms1_data = ms1_data.set_index("scan")
1049
1050        # Prep mass feature summary
1051        mass_feature_df = self.mass_features_to_df()
1052
1053        # Loop through each mass feature
1054        for mf_id, mass_feature in self.mass_features.items():
1055            # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df
1056            if mass_feature.mz != mass_feature.ms1_peak.mz_exp:
1057                continue
1058
1059            # Get the left and right limits of the EIC of the mass feature
1060            l_scan, _, r_scan = mass_feature._eic_data.apexes[0]
1061
1062            # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz
1063            ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy()
1064            ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False)
1065
1066            # Get the centroided masses of the mass feature
1067            mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp
1068
1069            # Find the closest mz in the ms1 data to the centroided masses of the mass feature
1070            ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[
1071                find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values)
1072            ]
1073
1074            # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz
1075            ms1_data_sub["mz_diff_rel"] = (
1076                np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"])
1077                / ms1_data_sub["mz"]
1078            )
1079            ms1_data_sub = ms1_data_sub[
1080                ms1_data_sub["mz_diff_rel"]
1081                < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel
1082            ].reset_index(drop=True)
1083
1084            # Group by mass_feature_mz and scan and sum intensity
1085            ms1_data_sub_group = (
1086                ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"]
1087                .sum()
1088                .reset_index()
1089            )
1090
1091            # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity)
1092            corr = (
1093                ms1_data_sub_group.pivot(
1094                    index="scan", columns="mass_feature_mz", values="intensity"
1095                )
1096                .fillna(0)
1097                .corr()
1098            )
1099
1100            # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8
1101            decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min
1102
1103            # Try catch for KeyError in case the mass feature mz is not in the correlation matrix
1104            try:
1105                corr_subset = corr.loc[mass_feature.mz]
1106            except KeyError:
1107                # If the mass feature mz is not in the correlation matrix, skip to the next mass feature
1108                continue
1109
1110            corr_subset = corr_subset[corr_subset > decon_corr_min]
1111
1112            # Get the masses from the mass spectrum that are the result of the deconvolution
1113            mzs_decon = corr_subset.index.values
1114
1115            # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature
1116            mzs_decon_idx = [
1117                id
1118                for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp)
1119                if mz in mzs_decon
1120            ]
1121            mass_feature._ms_deconvoluted_idx = mzs_decon_idx
1122
1123            # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum
1124            if (
1125                mass_feature.ms1_peak.abundance
1126                == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max()
1127            ):
1128                mass_feature.mass_spectrum_deconvoluted_parent = True
1129            else:
1130                mass_feature.mass_spectrum_deconvoluted_parent = False
1131
1132            # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature
1133            # Subset mass_feature_df to only include mass features that are within the clustering tolerance
1134            mass_feature_df_sub = mass_feature_df[
1135                abs(mass_feature.retention_time - mass_feature_df["scan_time"])
1136                < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance
1137            ].copy()
1138            # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum
1139            mass_feature_df_sub["mz_diff_ppm"] = [
1140                np.abs(mzs_decon - mz).min() / mz * 10**6
1141                for mz in mass_feature_df_sub["mz"]
1142            ]
1143            # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses
1144            mfs_associated_decon = mass_feature_df_sub[
1145                mass_feature_df_sub["mz_diff_ppm"]
1146                < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6
1147            ].index.values
1148
1149            mass_feature.associated_mass_features_deconvoluted = mfs_associated_decon
1150
1151    def _remove_redundant_mass_features(
1152        self,
1153    ) -> None:
1154        """
1155        Identify and remove redundant mass features that are likely contaminants based on their m/z values and scan frequency.
1156        Especially useful for HILIC data where signals do not return to baseline between peaks or for data with significant background noise.
1157
1158        Contaminants are characterized by:
1159        1. Similar m/z values (within ppm_tolerance)
1160        2. High frequency across scan numbers (ubiquitous presence)
1161
1162        Notes
1163        -----
1164        Depends on self.mass_features being populated, uses the parameters in self.parameters.lc_ms for tolerances (mass_feature_cluster_mz_tolerance_rel)
1165        """
1166        ppm_tolerance = (
1167            self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 1e6
1168        )
1169        min_scan_frequency = self.parameters.lc_ms.redundant_scan_frequency_min
1170        n_retain = self.parameters.lc_ms.redundant_feature_retain_n
1171
1172        df = self.mass_features_to_df()
1173
1174        if df.empty:
1175            return pd.DataFrame()
1176        # df index should be mf_id
1177        if "mf_id" not in df.columns:
1178            if "mf_id" in df.index.names:
1179                df = df.reset_index()
1180            else:
1181                raise ValueError("DataFrame must contain 'mf_id' column or index.")
1182
1183        # Sort by m/z for efficient grouping
1184        df_sorted = df.sort_values("mz").reset_index(drop=True)
1185
1186        # Calculate total number of unique scans for frequency calculation
1187        # Calculate total possible scans (check the cluster rt tolerance and the min rt and max rt of the data)
1188        total_time = self.scan_df["scan_time"].max() - self.scan_df["scan_time"].min()
1189        cluster_rt_tolerance = self.parameters.lc_ms.mass_feature_cluster_rt_tolerance
1190        # If the feature was detected in every possible scan (and then rolled up), it would be in this many scans
1191        total_scans = int(total_time / cluster_rt_tolerance) + 1
1192
1193        # Group similar m/z values using ppm tolerance
1194        mz_groups = []
1195        current_group = []
1196
1197        for i, row in df_sorted.iterrows():
1198            current_mz = row["mz"]
1199
1200            if not current_group:
1201                # Start first group
1202                current_group = [i]
1203            else:
1204                # Check if current m/z is within tolerance of group representative
1205                group_representative_mz = df_sorted.iloc[current_group[0]]["mz"]
1206                ppm_diff = (
1207                    abs(current_mz - group_representative_mz)
1208                    / group_representative_mz
1209                    * 1e6
1210                )
1211
1212                if ppm_diff <= ppm_tolerance:
1213                    # Add to current group
1214                    current_group.append(i)
1215                else:
1216                    # Start new group, but first process current group
1217                    if len(current_group) > 0:
1218                        mz_groups.append(current_group)
1219                    current_group = [i]
1220
1221        # Don't forget the last group
1222        if current_group:
1223            mz_groups.append(current_group)
1224
1225        # Analyze each m/z group for contaminant characteristics
1226
1227        for group_indices in mz_groups:
1228            group_data = df_sorted.iloc[group_indices]
1229
1230            # Calculate group statistics
1231            unique_scans = group_data["apex_scan"].nunique()
1232            scan_frequency = unique_scans / total_scans
1233
1234            # Check if this group meets contaminant criteria
1235            if scan_frequency >= min_scan_frequency:
1236                group_data = group_data.sort_values("intensity", ascending=False)
1237                non_representative_mf_id = group_data.iloc[n_retain:][
1238                    "mf_id"
1239                ].tolist()  # These will be removed
1240
1241                self.mass_features = {
1242                    k: v
1243                    for k, v in self.mass_features.items()
1244                    if k not in non_representative_mf_id
1245                }
1246
1247    def _remove_mass_features_by_peak_metrics(self, induced_features=False) -> None:
1248        """Remove mass features based on peak metrics defined in mass_feature_attribute_filter_dict.
1249
1250        This method filters mass features based on various peak shape metrics and quality indicators
1251        such as noise scores, Gaussian similarity, tailing factors, dispersity index, etc.
1252
1253        The filtering criteria are defined in the mass_feature_attribute_filter_dict parameter,
1254        which should contain attribute names as keys and filter specifications as values.
1255
1256        Filter specification format:
1257        {attribute_name: {'value': threshold, 'operator': comparison}}
1258
1259        Available operators:
1260        - '>' or 'greater': Keep features where attribute > threshold
1261        - '<' or 'less': Keep features where attribute < threshold
1262        - '>=' or 'greater_equal': Keep features where attribute >= threshold
1263        - '<=' or 'less_equal': Keep features where attribute <= threshold
1264
1265        Examples:
1266        - {'noise_score_max': {'value': 0.5, 'operator': '>='}} - Keep features with noise_score_max >= 0.5
1267        - {'dispersity_index': {'value': 0.1, 'operator': '<'}} - Keep features with dispersity_index < 0.1
1268        - {'gaussian_similarity': {'value': 0.7, 'operator': '>='}} - Keep features with gaussian_similarity >= 0.7
1269
1270        Parameters
1271        ----------
1272        induced_features : bool, optional
1273            If True, filter induced_mass_features instead of regular mass_features. Default is False.
1274
1275        Returns
1276        -------
1277        None
1278            Modifies self.mass_features or self.induced_mass_features in place by removing filtered features.
1279
1280        Raises
1281        ------
1282        ValueError
1283            If no mass features are found, if an invalid attribute is specified, or if filter specification is malformed.
1284        """
1285        # Select the appropriate mass features dictionary
1286        if induced_features:
1287            mf_dict = self.induced_mass_features
1288            mf_type = "induced mass features"
1289        else:
1290            mf_dict = self.mass_features
1291            mf_type = "mass features"
1292
1293        if mf_dict is None or len(mf_dict) == 0:
1294            raise ValueError(
1295                f"No {mf_type} found, run {'gap filling' if induced_features else 'find_mass_features()'} first"
1296            )
1297
1298        filter_dict = self.parameters.lc_ms.mass_feature_attribute_filter_dict
1299
1300        if not filter_dict:
1301            # No filtering criteria specified, return early
1302            return
1303
1304        verbose = self.parameters.lc_ms.verbose_processing
1305        initial_count = len(mf_dict)
1306
1307        if verbose:
1308            print(
1309                f"Filtering {mf_type} using peak metrics. Initial count: {initial_count}"
1310            )
1311
1312        # List to collect IDs of mass features to remove
1313        features_to_remove = []
1314
1315        for mf_id, mass_feature in mf_dict.items():
1316            should_remove = False
1317
1318            for attribute_name, filter_spec in filter_dict.items():
1319                # Validate filter specification structure
1320                if not isinstance(filter_spec, dict):
1321                    raise ValueError(
1322                        f"Filter specification for '{attribute_name}' must be a dictionary with 'value' and 'operator' keys"
1323                    )
1324
1325                if "value" not in filter_spec or "operator" not in filter_spec:
1326                    raise ValueError(
1327                        f"Filter specification for '{attribute_name}' must contain both 'value' and 'operator' keys"
1328                    )
1329
1330                threshold_value = filter_spec["value"]
1331                operator = filter_spec["operator"].lower().strip()
1332
1333                # Validate operator
1334                valid_operators = {
1335                    ">",
1336                    "<",
1337                    ">=",
1338                    "<=",
1339                    "greater",
1340                    "less",
1341                    "greater_equal",
1342                    "less_equal",
1343                }
1344                if operator not in valid_operators:
1345                    raise ValueError(
1346                        f"Invalid operator '{operator}' for attribute '{attribute_name}'. Valid operators: {valid_operators}"
1347                    )
1348
1349                # Normalize operator names
1350                operator_map = {
1351                    "greater": ">",
1352                    "less": "<",
1353                    "greater_equal": ">=",
1354                    "less_equal": "<=",
1355                }
1356                operator = operator_map.get(operator, operator)
1357
1358                # Get the attribute value from the mass feature
1359                try:
1360                    if hasattr(mass_feature, attribute_name):
1361                        attribute_value = getattr(mass_feature, attribute_name)
1362                    else:
1363                        raise ValueError(
1364                            f"Mass feature does not have attribute '{attribute_name}'"
1365                        )
1366
1367                    # Handle None values or attributes that haven't been calculated
1368                    if attribute_value is None:
1369                        if verbose:
1370                            print(
1371                                f"Warning: Mass feature {mf_id} has None value for '{attribute_name}'. Removing feature."
1372                            )
1373                        should_remove = True
1374                        break
1375
1376                    # Handle numpy arrays (like half_height_width which returns mean)
1377                    if hasattr(attribute_value, "__len__") and not isinstance(
1378                        attribute_value, str
1379                    ):
1380                        # For arrays, we use the mean or appropriate summary statistic
1381                        if attribute_name == "half_height_width":
1382                            # half_height_width property already returns the mean
1383                            pass
1384                        else:
1385                            attribute_value = float(np.mean(attribute_value))
1386
1387                    # Handle NaN values
1388                    if np.isnan(float(attribute_value)):
1389                        if verbose:
1390                            print(
1391                                f"Warning: Mass feature {mf_id} has NaN value for '{attribute_name}'. Removing feature."
1392                            )
1393                        should_remove = True
1394                        break
1395
1396                    # Apply the threshold comparison based on operator
1397                    attribute_value = float(attribute_value)
1398                    threshold_value = float(threshold_value)
1399
1400                    if operator == ">" and not (attribute_value > threshold_value):
1401                        should_remove = True
1402                        break
1403                    elif operator == "<" and not (attribute_value < threshold_value):
1404                        should_remove = True
1405                        break
1406                    elif operator == ">=" and not (attribute_value >= threshold_value):
1407                        should_remove = True
1408                        break
1409                    elif operator == "<=" and not (attribute_value <= threshold_value):
1410                        should_remove = True
1411                        break
1412
1413                except (AttributeError, ValueError, TypeError) as e:
1414                    if verbose:
1415                        print(
1416                            f"Error evaluating filter '{attribute_name}' for mass feature {mf_id}: {e}"
1417                        )
1418                    should_remove = True
1419                    break
1420
1421            if should_remove:
1422                features_to_remove.append(mf_id)
1423
1424        # Remove filtered mass features
1425        for mf_id in features_to_remove:
1426            del mf_dict[mf_id]
1427
1428        if verbose and len(features_to_remove) > 0:
1429            print(
1430                f"Removed {len(features_to_remove)} {mf_type} based on peak metrics. Remaining: {len(mf_dict)}"
1431            )
1432
1433        # Update the appropriate dictionary
1434        if induced_features:
1435            self.induced_mass_features = mf_dict
1436        else:
1437            self.mass_features = mf_dict
1438
1439        # Clean up unassociated EICs and ms1 data (only for regular features)
1440        self._remove_unassociated_eics()
1441        self._remove_unassociated_ms1_spectra()
1442
1443    def _remove_unassociated_eics(self) -> None:
1444        """Remove EICs that are not associated with any mass features.
1445
1446        This method cleans up the eics attribute by removing any EICs that do not correspond to
1447        any mass features currently stored in the mass_features attribute. This is useful for
1448        freeing up memory and ensuring that only relevant EICs are retained.
1449
1450        Returns
1451        -------
1452        None
1453            Modifies self.eics in place by removing unassociated EICs.
1454        """
1455        if self.mass_features is None or len(self.mass_features) == 0:
1456            self.eics = {}
1457            return
1458
1459        # Get the set of m/z values associated with current mass features
1460        associated_mzs = {mf.mz for mf in self.mass_features.values()}
1461
1462        # Remove EICs that are not associated with any mass features
1463        self.eics = {mz: eic for mz, eic in self.eics.items() if mz in associated_mzs}
1464
1465    def _remove_unassociated_ms1_spectra(self) -> None:
1466        """Remove MS1 spectra that are not associated with any mass features.
1467        This method cleans up the _ms_unprocessed attribute by removing any MS1 spectra that do not correspond to
1468        any mass features currently stored in the mass_features attribute. This is useful for freeing up memory
1469        and ensuring that only relevant MS1 spectra are retained.
1470
1471        Returns
1472        -------
1473        None
1474        """
1475        if self.mass_features is None or len(self.mass_features) == 0:
1476            self._ms_unprocessed = {}
1477            return
1478
1479        # Get the set of m/z values associated with current mass features
1480        associated_ms1_scans = {mf.apex_scan for mf in self.mass_features.values()}
1481        associated_ms1_scans = [int(scan) for scan in associated_ms1_scans]
1482
1483        # Get keys within the _ms attribute (these are individual MassSpectrum objects)
1484        current_stored_spectra = list(set(self._ms.keys()))
1485        if len(current_stored_spectra) == 0:
1486            return
1487        current_stored_spectra = [int(scan) for scan in current_stored_spectra]
1488
1489        # Filter the current_stored_spectra to only ms1 scans
1490        current_stored_spectra_ms1 = [
1491            scan for scan in current_stored_spectra if scan in self.ms1_scans
1492        ]
1493
1494        # Remove MS1 spectra that are not associated with any mass features
1495        scans_to_drop = [
1496            scan
1497            for scan in current_stored_spectra_ms1
1498            if scan not in associated_ms1_scans
1499        ]
1500        for scan in scans_to_drop:
1501            if scan in self._ms:
1502                del self._ms[scan]

Methods for performing LC calculations on mass spectra data.

Notes

This class is intended to be used as a mixin for the LCMSBase class.

Methods
  • get_max_eic(eic_data). Returns the maximum EIC value from the given EIC data. A static method.
  • smooth_tic(tic). Smooths the TIC data using the specified smoothing method and settings.
  • eic_centroid_detector(rt, eic, max_eic). Performs EIC centroid detection on the given EIC data.
  • find_nearest_scan(rt). Finds the nearest scan to the given retention time.
  • get_average_mass_spectrum(scan_list, apex_scan, spectrum_mode="profile", ms_level=1, auto_process=True, use_parser=False, perform_checks=True, polarity=None). Returns an averaged mass spectrum object.
  • find_mass_features(ms_level=1). Find regions of interest for a given MS level (default is MS1).
  • integrate_mass_features(drop_if_fail=False, ms_level=1). Integrate mass features of interest and extracts EICs.
  • find_c13_mass_features(). Evaluate mass features and mark likely C13 isotopes.
  • deconvolute_ms1_mass_features(). Deconvolute mass features' ms1 mass spectra.
@staticmethod
def get_max_eic(eic_data: dict):
124    @staticmethod
125    def get_max_eic(eic_data: dict):
126        """Returns the maximum EIC value from the given EIC data.
127
128        Notes
129        -----
130        This is a static method.
131
132        Parameters
133        ----------
134        eic_data : dict
135            A dictionary containing EIC data.
136
137        Returns
138        -------
139        float
140            The maximum EIC value.
141        """
142        max_eic = 0
143        for eic_data in eic_data.values():
144            ind_max_eic = max(eic_data.get("EIC"))
145            max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic
146
147        return max_eic

Returns the maximum EIC value from the given EIC data.

Notes

This is a static method.

Parameters
  • eic_data (dict): A dictionary containing EIC data.
Returns
  • float: The maximum EIC value.
def smooth_tic(self, tic):
149    def smooth_tic(self, tic):
150        """Smooths the TIC or EIC data using the specified smoothing method and settings.
151
152        Parameters
153        ----------
154        tic : numpy.ndarray
155            The TIC (or EIC) data to be smoothed.
156
157        Returns
158        -------
159        numpy.ndarray
160            The smoothed TIC data.
161        """
162        implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method
163
164        pol_order = self.parameters.lc_ms.savgol_pol_order
165
166        window_len = self.parameters.lc_ms.smooth_window
167
168        window = self.parameters.lc_ms.smooth_method
169
170        return sp.smooth_signal(
171            tic, window_len, window, pol_order, implemented_smooth_method
172        )

Smooths the TIC or EIC data using the specified smoothing method and settings.

Parameters
  • tic (numpy.ndarray): The TIC (or EIC) data to be smoothed.
Returns
  • numpy.ndarray: The smoothed TIC data.
def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]):
174    def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]):
175        """Performs EIC centroid detection on the given EIC data.
176
177        Parameters
178        ----------
179        rt : numpy.ndarray
180            The retention time data.
181        eic : numpy.ndarray
182            The EIC data.
183        max_eic : float
184            The maximum EIC value.
185        apex_indexes : list, optional
186            The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function.
187
188        Returns
189        -------
190        numpy.ndarray
191            The indexes of left, apex, and right limits as a generator.
192        """
193
194        max_prominence = self.parameters.lc_ms.peak_max_prominence_percent
195
196        max_height = self.parameters.lc_ms.peak_height_max_percent
197
198        signal_threshold = self.parameters.lc_ms.eic_signal_threshold
199
200        min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints
201
202        peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold
203
204        include_indexes = sp.peak_picking_first_derivative(
205            domain=rt,
206            signal=eic,
207            max_height=max_height,
208            max_prominence=max_prominence,
209            max_signal=max_eic,
210            min_peak_datapoints=min_peak_datapoints,
211            peak_derivative_threshold=peak_derivative_threshold,
212            signal_threshold=signal_threshold,
213            correct_baseline=False,
214            plot_res=False,
215            apex_indexes=apex_indexes,
216        )
217        # include_indexes is a generator of tuples (left_index, apex_index, right_index)
218        include_indexes = list(include_indexes)
219        # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex
220        indicies = [x for x in include_indexes]
221        for idx in indicies:
222            if (idx[1] - idx[0] < min_peak_datapoints / 2) or (
223                idx[2] - idx[1] < min_peak_datapoints / 2
224            ):
225                include_indexes.remove(idx)
226        return include_indexes

Performs EIC centroid detection on the given EIC data.

Parameters
  • rt (numpy.ndarray): The retention time data.
  • eic (numpy.ndarray): The EIC data.
  • max_eic (float): The maximum EIC value.
  • apex_indexes (list, optional): The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function.
Returns
  • numpy.ndarray: The indexes of left, apex, and right limits as a generator.
def find_nearest_scan(self, rt):
228    def find_nearest_scan(self, rt):
229        """Finds the nearest scan to the given retention time.
230
231        Parameters
232        ----------
233        rt : float
234            The retention time (in minutes) to find the nearest scan for.
235
236        Returns
237        -------
238        int
239            The scan number of the nearest scan.
240        """
241        array_rt = np.array(self.retention_time)
242
243        scan_index = (np.abs(array_rt - rt)).argmin()
244
245        real_scan = self.scans_number[scan_index]
246
247        return real_scan

Finds the nearest scan to the given retention time.

Parameters
  • rt (float): The retention time (in minutes) to find the nearest scan for.
Returns
  • int: The scan number of the nearest scan.
def add_peak_metrics(self, remove_by_metrics=True, induced_features=False):
249    def add_peak_metrics(self, remove_by_metrics=True, induced_features=False):
250        """Add peak metrics to the mass features.
251
252        This function calculates the peak metrics for each mass feature and adds them to the mass feature objects.
253
254        Parameters
255        ----------
256        remove_by_metrics : bool, optional
257            If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity,
258            dispersity index, and noise score. Default is True, which checks the setting in the processing parameters.
259            If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters.
260        induced_features : bool, optional
261            Whether the mass features to be integrated were induced. Default is False.
262        """
263        # Check that at least some mass features have eic data
264        if induced_features:
265            mf_dict_values = self.induced_mass_features.values()
266        else:
267            mf_dict_values = self.mass_features.values()
268
269        if not any([mf._eic_data is not None for mf in mf_dict_values]):
270            raise ValueError(
271                "No mass features have EIC data. Run integrate_mass_features first."
272            )
273
274        for mass_feature in mf_dict_values:
275            # Check if the mass feature has been integrated
276            if mass_feature._eic_data is not None and mass_feature.area is not None:
277                # Calculate peak metrics
278                mass_feature.calc_half_height_width()
279                mass_feature.calc_tailing_factor()
280                mass_feature.calc_dispersity_index()
281                mass_feature.calc_gaussian_similarity()
282                mass_feature.calc_noise_score()
283
284        # Remove mass features by peak metrics if designated in parameters
285        if (
286            self.parameters.lc_ms.remove_mass_features_by_peak_metrics
287            and remove_by_metrics
288        ):
289            self._remove_mass_features_by_peak_metrics(
290                induced_features=induced_features
291            )

Add peak metrics to the mass features.

This function calculates the peak metrics for each mass feature and adds them to the mass feature objects.

Parameters
  • remove_by_metrics (bool, optional): If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters.
  • induced_features (bool, optional): Whether the mass features to be integrated were induced. Default is False.
def get_average_mass_spectrum( self, scan_list, apex_scan, spectrum_mode='profile', ms_level=1, auto_process=True, use_parser=False, perform_checks=True, polarity=None, ms_params=None):
293    def get_average_mass_spectrum(
294        self,
295        scan_list,
296        apex_scan,
297        spectrum_mode="profile",
298        ms_level=1,
299        auto_process=True,
300        use_parser=False,
301        perform_checks=True,
302        polarity=None,
303        ms_params=None,
304    ):
305        """Returns an averaged mass spectrum object
306
307        Parameters
308        ----------
309        scan_list : list
310            List of scan numbers to average.
311        apex_scan : int
312            Number of the apex scan
313        spectrum_mode : str, optional
314            The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging.
315        ms_level : int, optional
316            The MS level to use. Defaults to 1.
317        auto_process : bool, optional
318            If True, the averaged mass spectrum will be auto-processed. Defaults to True.
319        use_parser : bool, optional
320            If True, the mass spectra will be obtained from the parser. Defaults to False.
321        perform_checks : bool, optional
322            If True, the function will check if the data are within the ms_unprocessed dictionary and are the correct mode. Defaults to True. Only set to False if you are sure the data are profile, and (if not using the parser) are in the ms_unprocessed dictionary!  ms_unprocessed dictionary also must be indexed on scan
323        polarity : int, optional
324            The polarity of the mass spectra (1 or -1). If not set, the polarity will be determined from the dataset. Defaults to None. (fastest if set to -1 or 1)
325        ms_params : MSParameters, optional
326            The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None.
327
328        Returns
329        -------
330        MassSpectrumProfile
331            The averaged mass spectrum object.
332
333        Raises
334        ------
335        ValueError
336            If the spectrum mode is not "profile".
337            If the MS level is not found in the unprocessed mass spectra dictionary.
338            If not all scan numbers are found in the unprocessed mass spectra dictionary.
339        """
340        if perform_checks:
341            if spectrum_mode != "profile":
342                raise ValueError("Averaging only supported for profile mode")
343
344        if polarity is None:
345            # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation)
346            if self.polarity == "negative":
347                polarity = -1
348            elif self.polarity == "positive":
349                polarity = 1
350            else:
351                raise ValueError(
352                    "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'"
353                )
354
355        # if not using_parser, check that scan numbers are in _ms_unprocessed
356        if not use_parser:
357            if perform_checks:
358                # Set index to scan for faster lookup
359                ms_df = (
360                    self._ms_unprocessed[ms_level]
361                    .copy()
362                    .set_index("scan", drop=False)
363                    .sort_index()
364                )
365                my_ms_df = ms_df.loc[scan_list]
366                # Check that all scan numbers are in the ms_df
367                if not all(np.isin(scan_list, ms_df.index)):
368                    raise ValueError(
369                        "Not all scan numbers found in the unprocessed mass spectra dictionary"
370                    )
371            else:
372                my_ms_df = (
373                    pd.DataFrame({"scan": scan_list})
374                    .set_index("scan")
375                    .join(self._ms_unprocessed[ms_level], how="left")
376                )
377
378        if use_parser:
379            parser = self.spectra_parser
380            ms_list = [
381                parser.get_mass_spectrum_from_scan(
382                    x, spectrum_mode=spectrum_mode, auto_process=False
383                )
384                for x in scan_list
385            ]
386            ms_mz = [x._mz_exp for x in ms_list]
387            ms_int = [x._abundance for x in ms_list]
388            my_ms_df = []
389            for i in np.arange(len(ms_mz)):
390                my_ms_df.append(
391                    pd.DataFrame(
392                        {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]}
393                    )
394                )
395            my_ms_df = pd.concat(my_ms_df)
396
397        if not self.check_if_grid(my_ms_df):
398            my_ms_df = self.grid_data(my_ms_df)
399
400        my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index()
401
402        ms = ms_from_array_profile(
403            my_ms_ave.mz,
404            my_ms_ave.intensity,
405            self.file_location,
406            polarity=polarity,
407            auto_process=False,
408        )
409
410        # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset
411        if ms is not None:
412            if ms_params is not None:
413                ms.parameters = ms_params
414            ms.scan_number = apex_scan
415            if auto_process:
416                ms.process_mass_spec()
417        return ms

Returns an averaged mass spectrum object

Parameters
  • scan_list (list): List of scan numbers to average.
  • apex_scan (int): Number of the apex scan
  • spectrum_mode (str, optional): The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging.
  • ms_level (int, optional): The MS level to use. Defaults to 1.
  • auto_process (bool, optional): If True, the averaged mass spectrum will be auto-processed. Defaults to True.
  • use_parser (bool, optional): If True, the mass spectra will be obtained from the parser. Defaults to False.
  • perform_checks (bool, optional): If True, the function will check if the data are within the ms_unprocessed dictionary and are the correct mode. Defaults to True. Only set to False if you are sure the data are profile, and (if not using the parser) are in the ms_unprocessed dictionary! ms_unprocessed dictionary also must be indexed on scan
  • polarity (int, optional): The polarity of the mass spectra (1 or -1). If not set, the polarity will be determined from the dataset. Defaults to None. (fastest if set to -1 or 1)
  • ms_params (MSParameters, optional): The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None.
Returns
  • MassSpectrumProfile: The averaged mass spectrum object.
Raises
  • ValueError: If the spectrum mode is not "profile". If the MS level is not found in the unprocessed mass spectra dictionary. If not all scan numbers are found in the unprocessed mass spectra dictionary.
def find_mass_features( self, ms_level=1, grid=True, assign_ms2_scans=False, ms2_scan_filter=None, targeted_search=False, target_search_dict=None, accumulate_features=False):
484    def find_mass_features(
485        self,
486        ms_level=1,
487        grid=True,
488        assign_ms2_scans=False,
489        ms2_scan_filter=None,
490        targeted_search=False,
491        target_search_dict=None,
492        accumulate_features=False,
493    ):
494        """Find mass features within an LCMSBase object
495
496        Wrapper that dispatches to a 2D peak picking implementation.
497
498        If ``parameters.lc_ms.peak_picking_method`` is ``by_format``, the method is
499        chosen from ``scan_df.ms_format`` at the requested MS level (all
500        profile → persistent homology; all centroid → centroided persistent
501        homology). A successful resolve overwrites
502        ``peak_picking_method`` with the concrete method so saved parameters
503        record what was used.
504
505        Parameters
506        ----------
507        ms_level : int, optional
508            The MS level to use for peak picking Default is 1.
509        grid : bool, optional
510            If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded),
511            used for persistent homology peak picking for profile data only. Default is True.
512        assign_ms2_scans : bool, optional
513            If True, assign MS2 scan numbers to mass features after peak picking.
514            This populates the ms2_scan_numbers attribute on each mass feature, which enables
515            choosing representative features based on MS2 availability. Default is False.
516        ms2_scan_filter : str or None, optional
517            Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd').
518            If None, all MS2 scans are considered. Default is None.
519        targeted_search : bool, optional
520            If True, perform targeted mass feature search using the target_search_dict.
521            This mode filters data to only m/z and RT windows of interest and bypasses
522            intensity and persistence thresholds. Default is False.
523        target_search_dict : dict or None, optional
524            Dictionary containing target search parameters. Required if targeted_search is True.
525            Must contain:
526                - 'target_mz_list': list of target m/z values
527                - 'target_rt_list': list of target retention times (in minutes)
528                - 'mz_tolerance_ppm': m/z tolerance in ppm
529                - 'rt_tolerance': retention time tolerance (in minutes)
530            Optionally can contain:
531                - 'type': type label for mass features (e.g., "internal standard")
532                  If not provided, defaults to "targeted"
533            Default is None.
534        accumulate_features : bool, optional
535            If True, new mass features will be added to existing features rather than replacing them.
536            This allows multiple sequential calls to find_mass_features to build up a combined set.
537            Default is False (replace existing features for backwards compatibility).
538
539        Raises
540        ------
541        ValueError
542            If no MS level data is found on the object.
543            If ``by_format`` cannot resolve a method (empty, mixed, or unknown
544            ``ms_format`` values at the requested MS level).
545            If persistent homology peak picking is attempted on non-profile mode data.
546            If centroided persistent homology is attempted on non-centroid mode data.
547            If data is not gridded and grid is False.
548            If peak picking method is not implemented.
549            If targeted_search is True but target_search_dict is None or invalid.
550
551        Returns
552        -------
553        None, but assigns the mass_features and eics attributes to the object.
554
555        """
556        # Validate targeted search parameters
557        if targeted_search:
558            if target_search_dict is None:
559                raise ValueError(
560                    "target_search_dict must be provided when targeted_search is True"
561                )
562            required_keys = [
563                "target_mz_list",
564                "target_rt_list",
565                "mz_tolerance_ppm",
566                "rt_tolerance",
567            ]
568            for key in required_keys:
569                if key not in target_search_dict:
570                    raise ValueError(f"target_search_dict must contain '{key}'")
571            if len(target_search_dict["target_mz_list"]) != len(
572                target_search_dict["target_rt_list"]
573            ):
574                raise ValueError(
575                    "target_mz_list and target_rt_list must have the same length"
576                )
577
578        requested_method = self.parameters.lc_ms.peak_picking_method
579        pp_method = self._resolve_lcms_peak_picking_method(ms_level=ms_level)
580        if requested_method == "by_format":
581            self.parameters.lc_ms.peak_picking_method = pp_method
582
583        if targeted_search:
584            mf_type = target_search_dict.get("type", "targeted")
585        else:
586            mf_type = "untargeted"
587
588        if pp_method == "persistent homology":
589            self.find_mass_features_ph(
590                ms_level=ms_level,
591                grid=grid,
592                targeted_search=targeted_search,
593                target_search_dict=target_search_dict,
594                mf_type=mf_type,
595                accumulate_features=accumulate_features,
596            )
597        elif pp_method == "centroided_persistent_homology":
598            self.find_mass_features_ph_centroid(
599                ms_level=ms_level,
600                targeted_search=targeted_search,
601                target_search_dict=target_search_dict,
602                mf_type=mf_type,
603                accumulate_features=accumulate_features,
604            )
605        else:
606            raise ValueError("Peak picking method not implemented")
607
608        # Cluster mass features to remove redundant features
609        self.cluster_mass_features(drop_children=True)
610
611        # Optionally assign MS2 scan numbers to mass features during peak picking
612        # This helps with choosing representative features that have MS2 data
613        if assign_ms2_scans:
614            try:
615                self._find_ms2_scans_for_mass_features(
616                    mf_ids=None,  # Process all mass features
617                    scan_filter=ms2_scan_filter,
618                )
619            except ValueError:
620                # No MS2 scans found - this is okay, just skip
621                pass
622
623        # Remove noisey mass features if designated in parameters
624        if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search:
625            self._remove_redundant_mass_features()

Find mass features within an LCMSBase object

Wrapper that dispatches to a 2D peak picking implementation.

If parameters.lc_ms.peak_picking_method is by_format, the method is chosen from scan_df.ms_format at the requested MS level (all profile → persistent homology; all centroid → centroided persistent homology). A successful resolve overwrites peak_picking_method with the concrete method so saved parameters record what was used.

Parameters
  • ms_level (int, optional): The MS level to use for peak picking Default is 1.
  • grid (bool, optional): If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), used for persistent homology peak picking for profile data only. Default is True.
  • assign_ms2_scans (bool, optional): If True, assign MS2 scan numbers to mass features after peak picking. This populates the ms2_scan_numbers attribute on each mass feature, which enables choosing representative features based on MS2 availability. Default is False.
  • ms2_scan_filter (str or None, optional): Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). If None, all MS2 scans are considered. Default is None.
  • targeted_search (bool, optional): If True, perform targeted mass feature search using the target_search_dict. This mode filters data to only m/z and RT windows of interest and bypasses intensity and persistence thresholds. Default is False.
  • target_search_dict (dict or None, optional): Dictionary containing target search parameters. Required if targeted_search is True. Must contain: - 'target_mz_list': list of target m/z values - 'target_rt_list': list of target retention times (in minutes) - 'mz_tolerance_ppm': m/z tolerance in ppm - 'rt_tolerance': retention time tolerance (in minutes) Optionally can contain: - 'type': type label for mass features (e.g., "internal standard") If not provided, defaults to "targeted" Default is None.
  • accumulate_features (bool, optional): If True, new mass features will be added to existing features rather than replacing them. This allows multiple sequential calls to find_mass_features to build up a combined set. Default is False (replace existing features for backwards compatibility).
Raises
  • ValueError: If no MS level data is found on the object. If by_format cannot resolve a method (empty, mixed, or unknown ms_format values at the requested MS level). If persistent homology peak picking is attempted on non-profile mode data. If centroided persistent homology is attempted on non-centroid mode data. If data is not gridded and grid is False. If peak picking method is not implemented. If targeted_search is True but target_search_dict is None or invalid.
Returns
  • None, but assigns the mass_features and eics attributes to the object.
def integrate_mass_features( self, drop_if_fail=True, drop_duplicates=True, ms_level=1, induced_features=False):
627    def integrate_mass_features(
628        self,
629        drop_if_fail=True,
630        drop_duplicates=True,
631        ms_level=1,
632        induced_features=False,
633    ):
634        """Integrate mass features and extract EICs.
635
636        Populates the _eics attribute on the LCMSBase object for each unique mz in the mass_features dataframe and adds data (start_scan, final_scan, area) to the mass_features attribute.
637
638        Parameters
639        ----------
640        drop_if_fail : bool, optional
641            Whether to drop mass features if the EIC limit calculations fail.
642            Default is True.
643        drop_duplicates : bool, optional
644            Whether to mass features that appear to be duplicates
645            (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating).
646            Default is True.
647        ms_level : int, optional
648            The MS level to use. Default is 1.
649        induced_features : bool, optional
650            Whether the mass features to be intergrated were induced. Default is False.
651
652        Raises
653        ------
654        ValueError
655            If no mass features are found.
656            If no MS level data is found for the given MS level (either in data or in the scan data)
657
658        Returns
659        -------
660        None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute.
661
662        Notes
663        -----
664        drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection).
665        """
666
667        # Check if there is data
668        if ms_level in self._ms_unprocessed.keys():
669            raw_data = self._ms_unprocessed[ms_level].copy()
670        else:
671            raise ValueError("No MS level " + str(ms_level) + " data found")
672
673        # Check if mass_spectrum exists on each mass feature
674        if induced_features:
675            mf_dict = self.induced_mass_features
676            if len(mf_dict) == 0:
677                raise ValueError(
678                    "No induced mass features found, did you run fill_missing_cluster_features() first?"
679                )
680
681            ## remove not found induced mass features by mz <= 0 (-99 indicator)
682            # also remove any where mz is nan
683            mf_dict = {
684                k: v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz)
685            }
686
687        else:
688            mf_dict = self.mass_features
689            if len(mf_dict) == 0:
690                raise ValueError(
691                    "No mass features found, did you run find_mass_features() first?"
692                )
693
694        # Subset scan data to only include correct ms_level
695        scan_df_sub = self.scan_df[
696            self.scan_df["ms_level"] == int(ms_level)
697        ].reset_index(drop=True)
698        if scan_df_sub.empty:
699            raise ValueError("No MS level " + ms_level + " data found in scan data")
700        scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy()
701
702        mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()])
703        mzs_to_extract.sort()
704
705        # Pre-sort raw_data by mz for faster filtering
706        raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True)
707        raw_data_mz = raw_data_sorted["mz"].to_numpy()
708        raw_scan = raw_data_sorted["scan"].to_numpy()
709        raw_intensity = raw_data_sorted["intensity"].to_numpy()
710        scan_ids = scan_df_sub["scan"].to_numpy()
711        scan_time = scan_df_sub["scan_time"].to_numpy()
712        n_scans = scan_ids.size
713        # Sum into the scan-table row with the same float32 Kahan sum the
714        # previous groupby used, so the chromatogram values stay identical.
715        scan_order = np.argsort(scan_ids, kind="mergesort")
716        sorted_scan_ids = scan_ids[scan_order]
717        found = np.searchsorted(sorted_scan_ids, raw_scan)
718        in_range = found < n_scans
719        scan_col = np.empty(raw_scan.shape, dtype=np.intp)
720        scan_col[in_range] = scan_order[found[in_range]]
721        matched = np.zeros(raw_scan.shape, dtype=bool)
722        matched[in_range] = sorted_scan_ids[found[in_range]] == raw_scan[in_range]
723
724        # Get EICs for each unique mz in mass features list
725        for mz in mzs_to_extract:
726            mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6
727            mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6
728
729            # Use binary search for faster mz range filtering
730            left_idx = int(np.searchsorted(raw_data_mz, mz_min, side="left"))
731            right_idx = int(np.searchsorted(raw_data_mz, mz_max, side="right"))
732            eic = np.zeros(n_scans, dtype=np.float32)
733            if left_idx < right_idx:
734                comp = np.zeros(n_scans, dtype=np.float32)
735                cols = scan_col[left_idx:right_idx]
736                vals = raw_intensity[left_idx:right_idx]
737                ok = matched[left_idx:right_idx]
738                for i in range(cols.size):
739                    if not ok[i]:
740                        continue
741                    col = cols[i]
742                    val = vals[i]
743                    y = val - comp[col]
744                    t = eic[col] + y
745                    c = (t - eic[col]) - y
746                    comp[col] = c if np.isfinite(c) else np.float32(0)
747                    eic[col] = t
748            myEIC = EIC_Data(
749                scans=scan_ids.copy(),
750                time=scan_time.copy(),
751                eic=eic,
752            )
753            # Smooth EIC
754            smoothed_eic = self.smooth_tic(myEIC.eic)
755            smoothed_eic[smoothed_eic < 0] = 0
756            myEIC.eic_smoothed = smoothed_eic
757            self.eics[mz] = myEIC
758
759        # Get limits of mass features using EIC centroid detector and integrate
760        for idx, mass_feature in list(mf_dict.items()):
761            mz = mass_feature.mz
762            apex_scan = mass_feature.apex_scan
763
764            # Pull EIC data and find apex scan index
765            myEIC = self.eics[mz]
766            mf_dict[idx]._eic_data = myEIC
767            mf_dict[idx]._eic_mz = mz
768            apex_index = np.searchsorted(myEIC.scans, apex_scan)
769
770            # Find left and right limits of peak using EIC centroid detector, add to EICData
771            centroid_eics = self.eic_centroid_detector(
772                myEIC.time,
773                myEIC.eic_smoothed,
774                mass_feature.intensity * 1.1,
775                apex_indexes=[int(apex_index)],
776            )
777            l_a_r_scan_idx = [i for i in centroid_eics]
778            if len(l_a_r_scan_idx) > 0:
779                # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans
780                # Find the number of consecutive non-zero values in the EIC segment
781                mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0
782                # Find the longest run of consecutive True values
783                if np.any(mask):
784                    # Find indices where mask changes value
785                    diff = np.diff(np.concatenate(([0], mask.astype(int), [0])))
786                    starts = np.where(diff == 1)[0]
787                    ends = np.where(diff == -1)[0]
788                    consecutive_scans = (ends - starts).max()
789                else:
790                    consecutive_scans = 0
791                if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min:
792                    mf_dict.pop(idx)
793                    continue
794                # Add start and final scan to mass_features and EICData
795                left_scan, right_scan = (
796                    myEIC.scans[l_a_r_scan_idx[0][0]],
797                    myEIC.scans[l_a_r_scan_idx[0][2]],
798                )
799                mf_scan_apex = [(left_scan, int(apex_scan), right_scan)]
800                myEIC.apexes = myEIC.apexes + mf_scan_apex
801                mf_dict[idx].start_scan = left_scan
802                mf_dict[idx].final_scan = right_scan
803
804                # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData
805                area = np.trapezoid(
806                    myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1],
807                    myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1],
808                )
809                myEIC.areas = myEIC.areas + [area]
810                self.eics[mz] = myEIC
811                mf_dict[idx]._area = area
812            else:
813                if drop_if_fail is True:
814                    mf_dict.pop(idx)
815
816        if drop_duplicates:
817            # Prepare mass feature dataframe
818            # Skip features without scan bounds (failed EIC limits when
819            # drop_if_fail=False); they cannot participate in containment de-dup
820            if induced_features:
821                mf_df = self.mass_features_to_df(induced_features=True).copy()
822                mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()]
823            else:
824                mf_df = self.mass_features_to_df(induced_features=False).copy()
825                mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()]
826
827            # For each mass feature, find all mass features within the clustering tolerance ppm and drop if their start and end times are within another mass feature
828            # Keep the first mass feature (highest persistence)
829            for idx, mass_feature in mf_df.iterrows():
830                mz = mass_feature.mz
831                apex_scan = mass_feature.apex_scan
832
833                mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6
834                mf_df_sub = mf_df[
835                    mf_df["mz_diff_ppm"]
836                    < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel
837                    * 10**6
838                ].copy()
839
840                # For all mass features within the clustering tolerance, check if the start and end times are within the start and end times of the mass feature
841                for idx2, mass_feature2 in mf_df_sub.iterrows():
842                    if idx2 != idx:
843                        s1, e1 = mass_feature.start_scan, mass_feature.final_scan
844                        s2, e2 = mass_feature2.start_scan, mass_feature2.final_scan
845                        # Guard incomplete bounds (None / NaN) so ordering
846                        # comparisons never raise TypeError
847                        if s1 is None or e1 is None or s2 is None or e2 is None:
848                            continue
849                        if pd.isna(s1) or pd.isna(e1) or pd.isna(s2) or pd.isna(e2):
850                            continue
851                        if s2 >= s1 and e2 <= e1:
852                            if idx2 in self.mass_features.keys():
853                                self.mass_features.pop(idx2)
854
855        # Filter MS2 scans to only include those within integration bounds
856        # This ensures MS2 scans outside start_scan to final_scan are removed
857        if induced_features:
858            self._filter_ms2_scans_by_integration_bounds(
859                mf_dict=self.induced_mass_features
860            )
861        else:
862            self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features)

Integrate mass features and extract EICs.

Populates the _eics attribute on the LCMSBase object for each unique mz in the mass_features dataframe and adds data (start_scan, final_scan, area) to the mass_features attribute.

Parameters
  • drop_if_fail (bool, optional): Whether to drop mass features if the EIC limit calculations fail. Default is True.
  • drop_duplicates (bool, optional): Whether to mass features that appear to be duplicates (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating). Default is True.
  • ms_level (int, optional): The MS level to use. Default is 1.
  • induced_features (bool, optional): Whether the mass features to be intergrated were induced. Default is False.
Raises
  • ValueError: If no mass features are found. If no MS level data is found for the given MS level (either in data or in the scan data)
Returns
  • None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute.
Notes

drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection).

def find_c13_mass_features(self):
 864    def find_c13_mass_features(self):
 865        """Mark likely C13 isotopes and connect to monoisoitopic mass features.
 866
 867        Returns
 868        -------
 869        None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object.
 870
 871        Raises
 872        ------
 873        ValueError
 874            If no mass features are found.
 875        """
 876        verbose = self.parameters.lc_ms.verbose_processing
 877        if verbose:
 878            print("evaluating mass features for C13 isotopes")
 879        if self.mass_features is None:
 880            raise ValueError("No mass features found, run find_mass_features() first")
 881
 882        # Data prep fo sparse distance matrix
 883        dims = ["mz", "scan_time"]
 884        mf_df = self.mass_features_to_df().copy()
 885        # Drop mass features that have no area (these are likely to be noise)
 886        mf_df = mf_df[mf_df["area"].notnull()]
 887        mf_df["mf_id"] = mf_df.index.values
 888        dims = ["mz", "scan_time"]
 889
 890        # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features
 891        mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy()
 892
 893        mz_diff = 1.003355  # C13-C12 mass difference
 894        tol = [
 895            mf_df["mz"].median()
 896            * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
 897            self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5,
 898        ]  # mz, in relative; scan_time in minutes
 899
 900        # Compute inter-feature distances
 901        distances = None
 902        for i in range(len(dims)):
 903            # Construct k-d tree
 904            values = mf_df[dims[i]].values
 905            tree = KDTree(values.reshape(-1, 1))
 906
 907            max_tol = tol[i]
 908            if dims[i] == "mz":
 909                # Maximum absolute tolerance
 910                max_tol = mz_diff + tol[i]
 911
 912            # Compute sparse distance matrix
 913            # the larger the max_tol, the slower this operation is
 914            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
 915
 916            # Only consider forward case, exclude diagonal
 917            sdm = sparse.triu(sdm, k=1)
 918
 919            if dims[i] == "mz":
 920                min_tol = mz_diff - tol[i]
 921                # Get only the ones that are above the min tol
 922                idx = sdm.data > min_tol
 923
 924                # Reconstruct sparse distance matrix
 925                sdm = sparse.coo_matrix(
 926                    (sdm.data[idx], (sdm.row[idx], sdm.col[idx])),
 927                    shape=(len(values), len(values)),
 928                )
 929
 930            # Cast as binary matrix
 931            sdm.data = np.ones_like(sdm.data)
 932
 933            # Stack distances
 934            if distances is None:
 935                distances = sdm
 936            else:
 937                distances = distances.multiply(sdm)
 938
 939        # Extract indices of within-tolerance points
 940        distances = distances.tocoo()
 941        pairs = np.stack((distances.row, distances.col), axis=1)  # C12 to C13 pairs
 942
 943        # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df
 944        pairs_mf = pairs.copy()
 945        pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values
 946        pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values
 947
 948        # Connect monoisotopic masses with isotopologes within mass_features
 949        monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1]))
 950        for mono in monos:
 951            self.mass_features[mono].monoisotopic_mf_id = mono
 952        pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"])
 953        while not pairs_iso_df.empty:
 954            pairs_iso_df = pairs_iso_df.set_index("parent", drop=False)
 955            m1_isos = pairs_iso_df.loc[monos, "child"].unique()
 956            for iso in m1_isos:
 957                # Set monoisotopic_mf_id and isotopologue_type for isotopologues
 958                parent = pairs_mf[pairs_mf[:, 1] == iso, 0]
 959                if len(parent) > 1:
 960                    # Choose the parent that is closest in time to the isotopologue
 961                    parent_time = [self.mass_features[p].retention_time for p in parent]
 962                    time_diff = [
 963                        np.abs(self.mass_features[iso].retention_time - x)
 964                        for x in parent_time
 965                    ]
 966                    parent = parent[np.argmin(time_diff)]
 967                else:
 968                    parent = parent[0]
 969                self.mass_features[iso].monoisotopic_mf_id = self.mass_features[
 970                    parent
 971                ].monoisotopic_mf_id
 972                if self.mass_features[iso].monoisotopic_mf_id is not None:
 973                    mass_diff = (
 974                        self.mass_features[iso].mz
 975                        - self.mass_features[
 976                            self.mass_features[iso].monoisotopic_mf_id
 977                        ].mz
 978                    )
 979                    self.mass_features[iso].isotopologue_type = "13C" + str(
 980                        int(round(mass_diff, 0))
 981                    )
 982
 983            # Drop the mono and iso from the pairs_iso_df
 984            pairs_iso_df = pairs_iso_df.drop(
 985                index=monos, errors="ignore"
 986            )  # Drop pairs where the parent is a child that is a child of a root
 987            pairs_iso_df = pairs_iso_df.set_index("child", drop=False)
 988            pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore")
 989
 990            if not pairs_iso_df.empty:
 991                # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process
 992                monos = np.setdiff1d(
 993                    np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child)
 994                )
 995        if verbose:
 996            # Report fraction of compounds annotated with isotopes
 997            mf_df["c13_flag"] = np.where(
 998                np.logical_or(
 999                    np.isin(mf_df["mf_id"], pairs_mf[:, 0]),
1000                    np.isin(mf_df["mf_id"], pairs_mf[:, 1]),
1001                ),
1002                1,
1003                0,
1004            )
1005            print(
1006                str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3))
1007                + " of mass features have or are C13 isotopes"
1008            )

Mark likely C13 isotopes and connect to monoisoitopic mass features.

Returns
  • None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object.
Raises
  • ValueError: If no mass features are found.
def deconvolute_ms1_mass_features(self):
1010    def deconvolute_ms1_mass_features(self):
1011        """Deconvolute MS1 mass features
1012
1013        Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features
1014
1015        Parameters
1016        ----------
1017        None
1018
1019        Returns
1020        -------
1021        None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent,
1022        and associated_mass_features_deconvoluted attributes to the mass features in the
1023        mass_features attribute of the LCMSBase object.
1024
1025        Raises
1026        ------
1027        ValueError
1028            If no mass features are found, must run find_mass_features() first.
1029            If no EICs are found, did you run integrate_mass_features() first?
1030
1031        """
1032        # Checks for set mass_features and eics
1033        if self.mass_features is None:
1034            raise ValueError(
1035                "No mass features found, did you run find_mass_features() first?"
1036            )
1037
1038        if self.eics == {}:
1039            raise ValueError(
1040                "No EICs found, did you run integrate_mass_features() first?"
1041            )
1042
1043        if 1 not in self._ms_unprocessed.keys():
1044            raise ValueError("No unprocessed MS1 spectra found.")
1045
1046        # Prep ms1 data
1047        ms1_data = self._ms_unprocessed[1].copy()
1048        ms1_data = ms1_data.set_index("scan")
1049
1050        # Prep mass feature summary
1051        mass_feature_df = self.mass_features_to_df()
1052
1053        # Loop through each mass feature
1054        for mf_id, mass_feature in self.mass_features.items():
1055            # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df
1056            if mass_feature.mz != mass_feature.ms1_peak.mz_exp:
1057                continue
1058
1059            # Get the left and right limits of the EIC of the mass feature
1060            l_scan, _, r_scan = mass_feature._eic_data.apexes[0]
1061
1062            # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz
1063            ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy()
1064            ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False)
1065
1066            # Get the centroided masses of the mass feature
1067            mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp
1068
1069            # Find the closest mz in the ms1 data to the centroided masses of the mass feature
1070            ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[
1071                find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values)
1072            ]
1073
1074            # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz
1075            ms1_data_sub["mz_diff_rel"] = (
1076                np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"])
1077                / ms1_data_sub["mz"]
1078            )
1079            ms1_data_sub = ms1_data_sub[
1080                ms1_data_sub["mz_diff_rel"]
1081                < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel
1082            ].reset_index(drop=True)
1083
1084            # Group by mass_feature_mz and scan and sum intensity
1085            ms1_data_sub_group = (
1086                ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"]
1087                .sum()
1088                .reset_index()
1089            )
1090
1091            # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity)
1092            corr = (
1093                ms1_data_sub_group.pivot(
1094                    index="scan", columns="mass_feature_mz", values="intensity"
1095                )
1096                .fillna(0)
1097                .corr()
1098            )
1099
1100            # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8
1101            decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min
1102
1103            # Try catch for KeyError in case the mass feature mz is not in the correlation matrix
1104            try:
1105                corr_subset = corr.loc[mass_feature.mz]
1106            except KeyError:
1107                # If the mass feature mz is not in the correlation matrix, skip to the next mass feature
1108                continue
1109
1110            corr_subset = corr_subset[corr_subset > decon_corr_min]
1111
1112            # Get the masses from the mass spectrum that are the result of the deconvolution
1113            mzs_decon = corr_subset.index.values
1114
1115            # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature
1116            mzs_decon_idx = [
1117                id
1118                for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp)
1119                if mz in mzs_decon
1120            ]
1121            mass_feature._ms_deconvoluted_idx = mzs_decon_idx
1122
1123            # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum
1124            if (
1125                mass_feature.ms1_peak.abundance
1126                == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max()
1127            ):
1128                mass_feature.mass_spectrum_deconvoluted_parent = True
1129            else:
1130                mass_feature.mass_spectrum_deconvoluted_parent = False
1131
1132            # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature
1133            # Subset mass_feature_df to only include mass features that are within the clustering tolerance
1134            mass_feature_df_sub = mass_feature_df[
1135                abs(mass_feature.retention_time - mass_feature_df["scan_time"])
1136                < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance
1137            ].copy()
1138            # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum
1139            mass_feature_df_sub["mz_diff_ppm"] = [
1140                np.abs(mzs_decon - mz).min() / mz * 10**6
1141                for mz in mass_feature_df_sub["mz"]
1142            ]
1143            # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses
1144            mfs_associated_decon = mass_feature_df_sub[
1145                mass_feature_df_sub["mz_diff_ppm"]
1146                < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6
1147            ].index.values
1148
1149            mass_feature.associated_mass_features_deconvoluted = mfs_associated_decon

Deconvolute MS1 mass features

Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features

Parameters
  • None
Returns
  • None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent,
  • and associated_mass_features_deconvoluted attributes to the mass features in the
  • mass_features attribute of the LCMSBase object.
Raises
  • ValueError: If no mass features are found, must run find_mass_features() first. If no EICs are found, did you run integrate_mass_features() first?
class PHCalculations:
1505class PHCalculations:
1506    """Methods for performing calculations related to 2D peak picking via persistent homology on LCMS data.
1507
1508    Notes
1509    -----
1510    This class is intended to be used as a mixin for the LCMSBase class.
1511
1512    Methods
1513    -------
1514    * sparse_mean_filter(idx, V, radius=[0, 1, 1]).
1515        Sparse implementation of a mean filter.
1516    * embed_unique_indices(a).
1517        Creates an array of indices, sorted by unique element.
1518    * sparse_upper_star(idx, V).
1519        Sparse implementation of an upper star filtration.
1520    * check_if_grid(data).
1521        Check if the data is gridded in mz space.
1522    * grid_data(data).
1523        Grid the data in the mz dimension.
1524    * find_mass_features_ph(ms_level=1, grid=True).
1525        Find mass features within an LCMSBase object using persistent homology.
1526    * cluster_mass_features(drop_children=True).
1527        Cluster regions of interest.
1528    """
1529
1530    @staticmethod
1531    def sparse_mean_filter(idx, V, radius=[0, 1, 1]):
1532        """Sparse implementation of a mean filter.
1533
1534        Parameters
1535        ----------
1536        idx : :obj:`~numpy.array`
1537            Edge indices for each dimension (MxN).
1538        V : :obj:`~numpy.array`
1539            Array of intensity data (Mx1).
1540        radius : float or list
1541            Radius of the sparse filter in each dimension. Values less than
1542            zero indicate no connectivity in that dimension.
1543
1544        Returns
1545        -------
1546        :obj:`~numpy.array`
1547            Filtered intensities (Mx1).
1548
1549        Notes
1550        -----
1551        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos.
1552        This is a static method.
1553        """
1554
1555        # Copy indices
1556        idx = idx.copy().astype(V.dtype)
1557
1558        # Scale
1559        for i, r in enumerate(radius):
1560            # Increase inter-index distance
1561            if r < 1:
1562                idx[:, i] *= 2
1563
1564            # Do nothing
1565            elif r == 1:
1566                pass
1567
1568            # Decrease inter-index distance
1569            else:
1570                idx[:, i] /= r
1571
1572        # Connectivity matrix
1573        cmat = KDTree(idx)
1574        cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix")
1575        cmat.setdiag(1)
1576
1577        # Pair indices
1578        I, J = cmat.nonzero()
1579
1580        # Delete cmat
1581        cmat_shape = cmat.shape
1582        del cmat
1583
1584        # Sum over columns
1585        V_sum = sparse.bsr_matrix(
1586            (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype
1587        ).diagonal(0)
1588
1589        # Count over columns
1590        V_count = sparse.bsr_matrix(
1591            (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype
1592        ).diagonal(0)
1593
1594        return V_sum / V_count
1595
1596    @staticmethod
1597    def embed_unique_indices(a):
1598        """Creates an array of indices, sorted by unique element.
1599
1600        Parameters
1601        ----------
1602        a : :obj:`~numpy.array`
1603            Array of unique elements (Mx1).
1604
1605        Returns
1606        -------
1607        :obj:`~numpy.array`
1608            Array of indices (Mx1).
1609
1610        Notes
1611        -----
1612        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
1613        This is a static method.
1614        """
1615
1616        def count_tens(n):
1617            # Count tens
1618            ntens = (n - 1) // 10
1619
1620            while True:
1621                ntens_test = (ntens + n - 1) // 10
1622
1623                if ntens_test == ntens:
1624                    return ntens
1625                else:
1626                    ntens = ntens_test
1627
1628        def arange_exclude_10s(n):
1629            # How many 10s will there be?
1630            ntens = count_tens(n)
1631
1632            # Base array
1633            arr = np.arange(0, n + ntens)
1634
1635            # Exclude 10s
1636            arr = arr[(arr == 0) | (arr % 10 != 0)][:n]
1637
1638            return arr
1639
1640        # Creates an array of indices, sorted by unique element
1641        idx_sort = np.argsort(a)
1642        idx_unsort = np.argsort(idx_sort)
1643
1644        # Sorts records array so all unique elements are together
1645        sorted_a = a[idx_sort]
1646
1647        # Returns the unique values, the index of the first occurrence,
1648        # and the count for each element
1649        vals, idx_start, count = np.unique(
1650            sorted_a, return_index=True, return_counts=True
1651        )
1652
1653        # Splits the indices into separate arrays
1654        splits = np.split(idx_sort, idx_start[1:])
1655
1656        # Creates unique indices for each split
1657        idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits])
1658
1659        # Reorders according to input array
1660        idx_unq = idx_unq[idx_unsort]
1661
1662        # Magnitude of each index
1663        exp = np.log10(
1664            idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64)
1665        )
1666        idx_unq_mag = np.power(10, np.floor(exp) + 1)
1667
1668        # Result
1669        return a + idx_unq / idx_unq_mag
1670
1671    @staticmethod
1672    def _greedy_keep_mask(all_values, tol, relative):
1673        """Rows to keep, highest sort_by first.
1674
1675        ``all_values`` is aligned to a frame already sorted by ``sort_by``
1676        descending. A later row is dropped when a kept row passes every
1677        dimension. Relative limits use the kept row. Values are compared
1678        as float32. No pair matrix is built.
1679        """
1680        values = [np.asarray(col, dtype=np.float32) for col in all_values]
1681        n = len(values[0]) if values else 0
1682        keep = np.ones(n, dtype=bool)
1683        if n == 0 or any(float(t) < 0 for t in tol):
1684            return keep
1685        n_dims = len(values)
1686        grid = {}
1687        loose = []
1688
1689        def bin_index(raw, tol_d, rel):
1690            fv = np.float32(raw)
1691            if float(tol_d) == 0.0:
1692                if fv == 0:
1693                    fv = np.float32(0.0)
1694                return int(np.asarray(fv).view(np.int32))
1695            if (not rel) and float(tol_d) > 0.0:
1696                return int(math.floor(float(fv) / float(tol_d)))
1697            if rel and 0.0 < float(tol_d) < 1.0 and float(fv) > 0.0:
1698                return int(
1699                    math.floor(math.log(float(fv)) / math.log(1.0 + float(tol_d)))
1700                )
1701            return None
1702
1703        def query_bins(raw, tol_d, rel):
1704            key = bin_index(raw, tol_d, rel)
1705            if key is None:
1706                return None
1707            if float(tol_d) == 0.0:
1708                return (key,)
1709            fv = float(np.float32(raw))
1710            if not rel:
1711                width = float(tol_d)
1712                lo = int(math.floor((fv - width) / width)) - 1
1713                hi = int(math.floor((fv + width) / width)) + 1
1714                return range(lo, hi + 1)
1715            log_width = math.log(1.0 + float(tol_d))
1716            lo_v = fv / (1.0 + float(tol_d))
1717            hi_v = fv / (1.0 - float(tol_d))
1718            if lo_v <= 0.0:
1719                return None
1720            lo = int(math.floor(math.log(lo_v) / log_width)) - 1
1721            hi = int(math.floor(math.log(hi_v) / log_width)) + 1
1722            return range(lo, hi + 1)
1723
1724        def claims(parent_i, child_i):
1725            for d in range(n_dims):
1726                pv = values[d][parent_i]
1727                cv = values[d][child_i]
1728                if not np.isfinite(pv) or not np.isfinite(cv):
1729                    return False
1730                diff = np.float32(np.abs(np.float32(cv) - np.float32(pv)))
1731                if relative[d]:
1732                    limit = np.float32(np.float32(tol[d]) * np.float32(pv))
1733                else:
1734                    limit = np.float32(tol[d])
1735                if diff > limit:
1736                    return False
1737            return True
1738
1739        for i in range(n):
1740            if not all(np.isfinite(values[d][i]) for d in range(n_dims)):
1741                continue
1742            bins = [
1743                query_bins(values[d][i], tol[d], relative[d]) for d in range(n_dims)
1744            ]
1745            if any(bucket is None for bucket in bins):
1746                search = list(loose)
1747                for bucket in grid.values():
1748                    search.extend(bucket)
1749            else:
1750                search = []
1751                for combo in product(*bins):
1752                    search.extend(grid.get(combo, ()))
1753                search.extend(loose)
1754            if any(claims(parent_i, i) for parent_i in search):
1755                keep[i] = False
1756                continue
1757            keys = [bin_index(values[d][i], tol[d], relative[d]) for d in range(n_dims)]
1758            if any(key is None for key in keys):
1759                loose.append(i)
1760            else:
1761                grid.setdefault(tuple(keys), []).append(i)
1762        return keep
1763
1764    @staticmethod
1765    def roll_up_dataframe(
1766        df: pd.DataFrame,
1767        sort_by: str,
1768        tol: list,
1769        relative: list,
1770        dims: list,
1771        memory_opt_threshold: int = 10000,
1772    ):
1773        """Subset data by rolling up into apex in appropriate dimensions.
1774
1775        Parameters
1776        ----------
1777        data : pd.DataFrame
1778            The input data containing "dims" columns and the "sort_by" column.
1779        sort_by : str
1780            The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature
1781            (i.e., the mass feature with the highest value in the sort_by column).
1782        dims : list
1783            A list of dimension names (column names in the data DataFrame) to roll up the mass features by.
1784        tol : list
1785            A list of tolerances for each dimension. The length of the list must match the number of dimensions.
1786            The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension).
1787            If relative is True, a kept row claims a later row when the absolute difference is at most tol times the kept row's value.
1788            If relative is False, the absolute difference must be at most tol.
1789        relative : list
1790            A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False).
1791        memory_opt_threshold : int, optional
1792            Accepted for compatibility. Ignored. Every frame uses the same greedy search. Default is 10000.
1793
1794        Returns
1795        -------
1796        pd.DataFrame
1797            A DataFrame with only the rolled up mass features, with the original index and columns.
1798
1799
1800        Raises
1801        ------
1802        ValueError
1803            If the input data is not a pandas DataFrame.
1804            If the input data does not have columns for each of the dimensions in "dims".
1805            If the length of "dims", "tol", and "relative" do not match.
1806        """
1807        og_columns = df.columns.copy()
1808
1809        # Unindex the data, but keep the original index
1810        if df.index.name is not None:
1811            og_index = df.index.name
1812        else:
1813            og_index = "index"
1814        df = df.reset_index(drop=False)
1815
1816        # Sort data by sort_by column, and reindex
1817        df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True)
1818
1819        # Check that data is a DataFrame and has columns for each of the dims
1820        if not isinstance(df, pd.DataFrame):
1821            raise ValueError("Data must be a pandas DataFrame")
1822        for dim in dims:
1823            if dim not in df.columns:
1824                raise ValueError(f"Data must have a column for {dim}")
1825        if len(dims) != len(tol) or len(dims) != len(relative):
1826            raise ValueError(
1827                "Dimensions, tolerances, and relative flags must be the same length"
1828            )
1829
1830        # Pre-compute all values arrays
1831        all_values = [df[dim].values for dim in dims]
1832
1833        keep = PHCalculations._greedy_keep_mask(all_values, tol, relative)
1834        df_sub = df.iloc[np.flatnonzero(keep)]
1835
1836        # Set index back to og_index and only keep original columns
1837        df_sub = df_sub.set_index(og_index).sort_index()[og_columns]
1838
1839        return df_sub
1840
1841    def sparse_upper_star(self, idx, V):
1842        """Sparse implementation of an upper star filtration.
1843
1844        Parameters
1845        ----------
1846        idx : :obj:`~numpy.array`
1847            Edge indices for each dimension (MxN).
1848        V : :obj:`~numpy.array`
1849            Array of intensity data (Mx1).
1850        Returns
1851        -------
1852        idx : :obj:`~numpy.array`
1853            Index of filtered points (Mx1).
1854        persistence : :obj:`~numpy.array`
1855            Persistence of each filtered point (Mx1).
1856
1857        Notes
1858        -----
1859        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
1860        """
1861
1862        # Invert
1863        V = -1 * V.copy().astype(int)
1864
1865        # Embed indices
1866        V = self.embed_unique_indices(V)
1867
1868        # Connectivity matrix
1869        cmat = KDTree(idx)
1870        cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix")
1871        cmat.setdiag(1)
1872        cmat = sparse.triu(cmat)
1873
1874        # Pairwise minimums
1875        I, J = cmat.nonzero()
1876        d = np.maximum(V[I], V[J])
1877
1878        # Delete connectiity matrix
1879        cmat_shape = cmat.shape
1880        del cmat
1881
1882        # Sparse distance matrix
1883        sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape)
1884
1885        # Delete pairwise mins
1886        del d, I, J
1887
1888        # Persistence homology
1889        ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0]
1890
1891        # Bound death values
1892        ph[ph[:, 1] == np.inf, 1] = np.max(V)
1893
1894        # Construct tree to query against
1895        tree = KDTree(V.reshape((-1, 1)))
1896
1897        # Get the indexes of the first nearest neighbor by birth
1898        _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1)
1899
1900        return nn, -(ph[:, 0] // 1 - ph[:, 1] // 1)
1901
1902    def check_if_grid(self, data):
1903        """Check if the data are gridded in mz space.
1904
1905        Parameters
1906        ----------
1907        data : DataFrame
1908            DataFrame containing the mass spectrometry data.  Needs to have mz and scan columns.
1909
1910        Returns
1911        -------
1912        bool
1913            True if the data is gridded in the mz direction, False otherwise.
1914
1915        Notes
1916        -----
1917        This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly.
1918        """
1919        # Calculate the difference between consecutive mz values in a single scan
1920        dat_check = data.copy().reset_index(drop=True)
1921        dat_check["mz_diff"] = np.abs(dat_check["mz"].diff())
1922        mz_diff_min = (
1923            dat_check.groupby("scan")["mz_diff"].min().min()
1924        )  # within each scan, what is the smallest mz difference between consecutive mz values
1925
1926        # Find the mininum mz difference between mz values in the data; regardless of scan
1927        dat_check_mz = dat_check[["mz"]].drop_duplicates().copy()
1928        dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True)
1929        dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff())
1930
1931        # Get minimum mz_diff between mz values in the data
1932        mz_diff_min_raw = dat_check_mz["mz_diff"].min()
1933
1934        # If the minimum mz difference between mz values in the data is less than the minimum mz difference between mz values within a single scan, then the data is not gridded
1935        if mz_diff_min_raw < mz_diff_min:
1936            return False
1937        else:
1938            return True
1939
1940    def grid_data(self, data, attempts=5):
1941        """Grid the data in the mz dimension.
1942
1943        Data must be gridded prior to persistent homology calculations and computing average mass spectrum
1944
1945        Parameters
1946        ----------
1947        data : DataFrame
1948            The input data containing mz, scan, scan_time, and intensity columns.
1949        attempts : int, optional
1950            The number of attempts to grid the data. Default is 5.
1951
1952        Returns
1953        -------
1954        DataFrame
1955            The gridded data with mz, scan, scan_time, and intensity columns.
1956
1957        Raises
1958        ------
1959        ValueError
1960            If gridding fails after the specified number of attempts.
1961        """
1962        attempt_i = 0
1963        while attempt_i < attempts:
1964            attempt_i += 1
1965            data = self._grid_data(data)
1966
1967            if self.check_if_grid(data):
1968                return data
1969
1970        if not self.check_if_grid(data):
1971            raise ValueError(
1972                "Gridding failed after "
1973                + str(attempt_i)
1974                + " attempts. Please check the data."
1975            )
1976        else:
1977            return data
1978
1979    def _grid_data(self, data):
1980        """Internal method to grid the data in the mz dimension.
1981
1982        Notes
1983        -----
1984        This method is called by the grid_data method and should not be called directly.
1985        It will attempt to grid the data in the mz dimension by creating a grid of mz values based on the minimum mz difference within each scan,
1986        but it does not check if the data is already gridded or if the gridding is successful.
1987
1988        Parameters
1989        ----------
1990        data : pd.DataFrame or pl.DataFrame
1991            The input data to grid.
1992
1993        Returns
1994        -------
1995        pd.DataFrame or pl.DataFrame
1996            The data after attempting to grid it in the mz dimension.
1997        """
1998        # Calculate the difference between consecutive mz values in a single scan for grid spacing
1999        data_w = data.copy().reset_index(drop=True)
2000        data_w["mz_diff"] = np.abs(data_w["mz"].diff())
2001        mz_diff_min = data_w.groupby("scan")["mz_diff"].min().min() * 0.99999
2002
2003        # Need high intensity mz values first so they are parents in the output pairs stack
2004        dat_mz = data_w[["mz", "intensity"]].sort_values(
2005            by=["intensity"], ascending=False
2006        )
2007        dat_mz = dat_mz[["mz"]].drop_duplicates().reset_index(drop=True).copy()
2008        # Same kept centers as the distance-pair grid. A 1D index replaces the
2009        # pair matrix, which grew with the square of the scan count.
2010        mz_dat_np = np.sort(
2011            _mz_grid_centers(dat_mz["mz"].to_numpy(dtype=np.float64), mz_diff_min)
2012        )
2013
2014        # Sort data by mz and recast mz to nearest value in mz_dat_np
2015        data_w = data_w.sort_values(by=["mz"]).reset_index(drop=True).copy()
2016        data_w["mz_new"] = mz_dat_np[find_closest(mz_dat_np, data_w["mz"].values)]
2017        data_w["mz_diff"] = np.abs(data_w["mz"] - data_w["mz_new"])
2018
2019        # Rename mz_new as mz; drop mz_diff; groupby scan and mz and sum intensity
2020        new_data_w = data_w.rename(columns={"mz": "mz_orig", "mz_new": "mz"}).copy()
2021        new_data_w = (
2022            new_data_w.drop(columns=["mz_diff", "mz_orig"])
2023            .groupby(["scan", "mz"])["intensity"]
2024            .sum()
2025            .reset_index()
2026        )
2027        new_data_w = (
2028            new_data_w.sort_values(by=["scan", "mz"], ascending=[True, True])
2029            .reset_index(drop=True)
2030            .copy()
2031        )
2032
2033        return new_data_w
2034
2035    def _filter_data_by_targets(self, data, target_search_dict):
2036        """Filter MS data to only include m/z and RT windows around target values.
2037
2038        Parameters
2039        ----------
2040        data : pd.DataFrame
2041            MS data with 'mz' and 'scan_time' columns
2042        target_search_dict : dict
2043            Dictionary with target_mz_list, target_rt_list, mz_tolerance_ppm, rt_tolerance
2044
2045        Returns
2046        -------
2047        pd.DataFrame
2048            Filtered data containing only points within target windows
2049        """
2050        target_mz_list = target_search_dict["target_mz_list"]
2051        target_rt_list = target_search_dict["target_rt_list"]
2052        mz_tolerance_ppm = target_search_dict["mz_tolerance_ppm"]
2053        rt_tolerance = target_search_dict["rt_tolerance"]
2054
2055        # Create a mask for data points that fall within any target window
2056        mask = np.zeros(len(data), dtype=bool)
2057
2058        for target_mz, target_rt in zip(target_mz_list, target_rt_list):
2059            # Calculate m/z window
2060            mz_tol = target_mz * mz_tolerance_ppm / 1e6
2061            mz_min = target_mz - mz_tol
2062            mz_max = target_mz + mz_tol
2063
2064            # Calculate RT window
2065            rt_min = target_rt - rt_tolerance
2066            rt_max = target_rt + rt_tolerance
2067
2068            # Create mask for this target
2069            target_mask = (
2070                (data["mz"] >= mz_min)
2071                & (data["mz"] <= mz_max)
2072                & (data["scan_time"] >= rt_min)
2073                & (data["scan_time"] <= rt_max)
2074            )
2075
2076            # Combine with overall mask
2077            mask |= target_mask
2078
2079        return data[mask].reset_index(drop=True)
2080
2081    def find_mass_features_ph(
2082        self,
2083        ms_level=1,
2084        grid=True,
2085        targeted_search=False,
2086        target_search_dict=None,
2087        mf_type="untargeted",
2088        accumulate_features=False,
2089    ):
2090        """Find mass features within an LCMSBase object using persistent homology.
2091
2092        Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id)
2093
2094        Parameters
2095        ----------
2096        ms_level : int, optional
2097            The MS level to use. Default is 1.
2098        grid : bool, optional
2099            If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True.
2100        targeted_search : bool, optional
2101            If True, perform targeted search mode. Default is False.
2102        target_search_dict : dict or None, optional
2103            Dictionary with target parameters for targeted search. Default is None.
2104        mf_type : str, optional
2105            Type label for the mass features. Default is "untargeted".
2106        accumulate_features : bool, optional
2107            If True, add to existing features rather than replacing them. Default is False.
2108
2109        Raises
2110        ------
2111        ValueError
2112            If no MS level data is found on the object.
2113            If data is not gridded and grid is False.
2114
2115        Returns
2116        -------
2117        None, but assigns the mass_features attribute to the object.
2118
2119        Notes
2120        -----
2121        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
2122        """
2123        # Check that ms_level is a key in self._ms_uprocessed
2124        if ms_level not in self._ms_unprocessed.keys():
2125            raise ValueError(
2126                "No MS level "
2127                + str(ms_level)
2128                + " data found, did you instantiate with parser specific to MS level?"
2129            )
2130
2131        # Get ms data
2132        data = self._ms_unprocessed[ms_level].copy()
2133
2134        # Drop rows with missing intensity values and reset index
2135        data = data.dropna(subset=["intensity"]).reset_index(drop=True)
2136
2137        # Add scan_time for filtering if in targeted mode
2138        if targeted_search:
2139            data = data.merge(
2140                self.scan_df[["scan", "scan_time"]], on="scan", how="left"
2141            )
2142
2143        # Threshold data (bypass thresholds in targeted mode)
2144        dims = ["mz", "scan_time"]
2145        if targeted_search:
2146            # In targeted mode, bypass intensity and persistence thresholds
2147            threshold = 0
2148            persistence_threshold = 0
2149            # Filter data to only target windows
2150            data_thres = self._filter_data_by_targets(data, target_search_dict)
2151            if len(data_thres) == 0:
2152                if self.parameters.lc_ms.verbose_processing:
2153                    print("No data found in target windows")
2154                self.mass_features = {}
2155                return
2156        else:
2157            threshold = max(
2158                self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max(),
2159                self.parameters.lc_ms.ph_inten_min_abs,
2160            )
2161            persistence_threshold = (
2162                self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max()
2163            )
2164            data_thres = (
2165                data[data["intensity"] > threshold].reset_index(drop=True).copy()
2166            )
2167
2168        # One m/z grid for the whole run. A separate grid in each scan window
2169        # moves the apex of the same ion.
2170        if not self.check_if_grid(data_thres):
2171            if grid is False:
2172                raise ValueError(
2173                    "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function"
2174                )
2175            data_thres = self.grid_data(data_thres)
2176        if "scan_time" not in data_thres.columns:
2177            data_thres = data_thres.merge(
2178                self.scan_df[["scan", "scan_time"]], on="scan"
2179            )
2180
2181        return self._find_mass_features_ph_partition(
2182            data_thres,
2183            dims,
2184            persistence_threshold,
2185            mf_type,
2186            accumulate_features,
2187        )
2188
2189    def _find_mass_features_ph_partition(
2190        self,
2191        data_thres,
2192        dims,
2193        persistence_threshold,
2194        mf_type="untargeted",
2195        accumulate_features=False,
2196    ):
2197        """Find mass features on overlapping scan partitions of one m/z grid.
2198
2199        Splits the already gridded input into overlapping scan partitions,
2200        detects mass features with persistent homology, rolls up duplicates
2201        within and across partitions, and populates mass_features.
2202
2203        Parameters
2204        ----------
2205        data_thres : pd.DataFrame
2206            The thresholded input data containing mass spectrometry information.
2207        dims : list
2208            List of dimension names (e.g., ["mz", "scan_time"]) used for feature detection.
2209        persistence_threshold : float
2210            Minimum persistence value required for a detected mass feature to be retained.
2211        mf_type : str, optional
2212            Type label for the mass features. Default is "untargeted".
2213        accumulate_features : bool, optional
2214            If True, add to existing features rather than replacing them. Default is False.
2215
2216        Returns
2217        -------
2218        None
2219            Populates the mass_features attribute of the object with detected mass features.
2220        """
2221        all_mass_features = []
2222
2223        # Split scans into partitions
2224        unique_scans = sorted(data_thres["scan"].unique())
2225        unique_scans_n = len(unique_scans)
2226        if unique_scans_n == 0:
2227            if not accumulate_features:
2228                self.mass_features = {}
2229            return
2230
2231        # Calculate partition size in scans based on goal
2232        partition_size_goal = 5000
2233        scans_per_partition = max(
2234            1, partition_size_goal // (len(data_thres) // unique_scans_n)
2235        )
2236
2237        # Make partitions based on scans, with overlapping in partitioned scans
2238        scan_overlap = 4
2239        partition_scans = []
2240        for i in range(0, unique_scans_n, scans_per_partition):
2241            start_idx = max(0, i - scan_overlap)
2242            end_idx = min(
2243                unique_scans_n - 1, i + scans_per_partition - 1 + scan_overlap
2244            )
2245            scans_group = [int(s) for s in unique_scans[start_idx : end_idx + 1]]
2246            partition_scans.append(scans_group)
2247
2248        # Set index to scan for faster filtering
2249        data_thres = data_thres.set_index("scan")
2250        for scans in partition_scans:
2251            # Determine start and end scan for partition, with 5 scans overlap
2252            partition_data = data_thres.loc[scans].reset_index(drop=False).copy()
2253
2254            if len(partition_data) == 0:
2255                continue
2256
2257            if "scan_time" not in partition_data.columns:
2258                partition_data = partition_data.merge(
2259                    self.scan_df[["scan", "scan_time"]], on="scan"
2260                )
2261
2262            # Build factors for this partition
2263            factors = {
2264                dim: pd.factorize(partition_data[dim], sort=True)[1].astype(np.float32)
2265                for dim in dims
2266            }
2267
2268            # Build indexes
2269            index = {
2270                dim: np.searchsorted(factors[dim], partition_data[dim]).astype(
2271                    np.float32
2272                )
2273                for dim in factors
2274            }
2275
2276            # Process partition
2277            partition_features = self._process_partition_ph(
2278                partition_data, index, dims, persistence_threshold
2279            )
2280
2281            if len(partition_features) == 0:
2282                continue
2283
2284            # Roll up within partition
2285            partition_features = self.roll_up_dataframe(
2286                df=partition_features,
2287                sort_by="persistence",
2288                dims=["mz", "scan_time"],
2289                tol=[
2290                    self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2291                    self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2292                ],
2293                relative=[True, False],
2294            )
2295            partition_features = partition_features.reset_index(drop=True)
2296
2297            if len(partition_features) > 0:
2298                all_mass_features.append(partition_features)
2299
2300        # Combine results from all partitions
2301        if all_mass_features:
2302            combined_features = pd.concat(all_mass_features, ignore_index=True)
2303
2304            # Sort by persistence
2305            combined_features = combined_features.sort_values(
2306                by="persistence", ascending=False
2307            ).reset_index(drop=True)
2308
2309            # Remove duplicates from overlapping regions
2310            combined_features = self.roll_up_dataframe(
2311                df=combined_features,
2312                sort_by="persistence",
2313                dims=["mz", "scan_time"],
2314                tol=[
2315                    self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2316                    self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2317                ],
2318                relative=[True, False],
2319            )
2320
2321            # resort by persistence and reset index
2322            combined_features = combined_features.reset_index(drop=True)
2323
2324            # Populate mass_features attribute
2325            self._populate_mass_features(
2326                combined_features, mf_type, accumulate_features
2327            )
2328        else:
2329            self.mass_features = {}
2330
2331    def _process_partition_ph(self, partition_data, index, dims, persistence_threshold):
2332        """Process a single partition with persistent homology."""
2333        # Smooth data
2334        iterations = self.parameters.lc_ms.ph_smooth_it
2335        smooth_radius = [
2336            self.parameters.lc_ms.ph_smooth_radius_mz,
2337            self.parameters.lc_ms.ph_smooth_radius_scan,
2338        ]
2339
2340        index_array = np.vstack([index[dim] for dim in dims]).T
2341        V = partition_data["intensity"].values
2342        resid = np.inf
2343
2344        for i in range(iterations):
2345            # Previous iteration
2346            V_prev = V.copy()
2347            resid_prev = resid
2348            V = self.sparse_mean_filter(index_array, V, radius=smooth_radius)
2349
2350            # Calculate residual with previous iteration
2351            resid = np.sqrt(np.mean(np.square(V - V_prev)))
2352
2353            # Evaluate convergence
2354            if i > 0:
2355                # Percent change in residual
2356                test = np.abs(resid - resid_prev) / resid_prev
2357
2358                # Exit criteria
2359                if test <= 0:
2360                    break
2361
2362        # Overwrite values
2363        partition_data = partition_data.copy()
2364        partition_data["intensity"] = V
2365
2366        # Use persistent homology to find regions of interest
2367        pidx, pers = self.sparse_upper_star(index_array, V)
2368        pidx = pidx[pers > 1]
2369        pers = pers[pers > 1]
2370
2371        if len(pidx) == 0:
2372            return pd.DataFrame()
2373
2374        # Get peaks
2375        peaks = partition_data.iloc[pidx, :].reset_index(drop=True)
2376
2377        # Add persistence column
2378        peaks["persistence"] = pers
2379        mass_features = peaks.sort_values(
2380            by="persistence", ascending=False
2381        ).reset_index(drop=True)
2382
2383        # Filter by persistence threshold
2384        mass_features = mass_features.loc[
2385            mass_features["persistence"] > persistence_threshold, :
2386        ].reset_index(drop=True)
2387
2388        return mass_features
2389
2390    def _populate_mass_features(
2391        self, mass_features_df, mf_type="untargeted", accumulate_features=False
2392    ):
2393        """Populate the mass_features attribute from a DataFrame.
2394
2395        Parameters
2396        ----------
2397        mass_features_df : pd.DataFrame
2398            DataFrame containing mass feature information.
2399            Note that the order of this DataFrame will determine the order of mass features in the mass_features attribute.
2400        mf_type : str, optional
2401            Type label for the mass features. Default is "untargeted".
2402        accumulate_features : bool, optional
2403            If True, new features will be added to existing features rather than replacing them.
2404            Mass feature IDs will be offset to avoid conflicts. Default is False.
2405
2406        Returns
2407        -------
2408        None, but assigns or updates the mass_features attribute to the object.
2409        """
2410        # Rename scan column to apex_scan
2411        mass_features_df = mass_features_df.rename(
2412            columns={"scan": "apex_scan", "scan_time": "retention_time"}
2413        )
2414
2415        # Initialize or preserve existing mass_features attribute
2416        if (
2417            accumulate_features
2418            and self.mass_features is not None
2419            and len(self.mass_features) > 0
2420        ):
2421            # Find the maximum existing ID to offset new IDs and avoid conflicts
2422            id_offset = max(self.mass_features.keys()) + 1
2423            initial_count = len(self.mass_features)
2424        else:
2425            # Replace mode (default/backwards compatible)
2426            self.mass_features = {}
2427            id_offset = 0
2428            initial_count = 0
2429
2430        # Add new mass features
2431        for idx, row in enumerate(mass_features_df.itertuples()):
2432            row_dict = mass_features_df.iloc[row.Index].to_dict()
2433            lcms_feature = LCMSMassFeature(self, **row_dict)
2434            lcms_feature.type = mf_type
2435            # Use sequential ID starting from id_offset to avoid conflicts with existing features
2436            new_id = idx + id_offset
2437            lcms_feature._id = new_id  # Update the internal ID
2438            self.mass_features[new_id] = lcms_feature
2439
2440        if self.parameters.lc_ms.verbose_processing:
2441            if accumulate_features and initial_count > 0:
2442                print(
2443                    f"Found {len(mass_features_df)} new mass features (total: {len(self.mass_features)})"
2444                )
2445            else:
2446                print("Found " + str(len(mass_features_df)) + " initial mass features")
2447
2448    def find_mass_features_ph_centroid(
2449        self,
2450        ms_level=1,
2451        targeted_search=False,
2452        target_search_dict=None,
2453        mf_type="untargeted",
2454        accumulate_features=False,
2455    ):
2456        """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data.
2457
2458        Parameters
2459        ----------
2460        ms_level : int, optional
2461            The MS level to use. Default is 1.
2462        targeted_search : bool, optional
2463            If True, perform targeted search mode. Default is False.
2464        target_search_dict : dict or None, optional
2465            Dictionary with target parameters for targeted search. Default is None.
2466        mf_type : str, optional
2467            Type label for the mass features. Default is "untargeted".
2468        accumulate_features : bool, optional
2469            If True, add to existing features rather than replacing them. Default is False.
2470
2471        Raises
2472        ------
2473        ValueError
2474            If no MS level data is found on the object.
2475
2476        Returns
2477        -------
2478        None, but assigns the mass_features attribute to the object.
2479        """
2480        # Check that ms_level is a key in self._ms_uprocessed
2481        if ms_level not in self._ms_unprocessed.keys():
2482            raise ValueError(
2483                "No MS level "
2484                + str(ms_level)
2485                + " data found, did you instantiate with parser specific to MS level?"
2486            )
2487
2488        # Work with reference instead of copy
2489        data = self._ms_unprocessed[ms_level]
2490
2491        # Merge with scan data first (needed for filtering in targeted mode)
2492        scan_subset = self.scan_df[["scan", "scan_time"]]
2493        data_with_time = data.merge(scan_subset, on="scan", how="inner")
2494
2495        # Calculate threshold and filter (bypass in targeted mode)
2496        if targeted_search:
2497            # In targeted mode, bypass intensity threshold
2498            threshold = 0
2499            valid_mask = data_with_time["intensity"].notna()
2500            required_cols = ["mz", "intensity", "scan", "scan_time"]
2501            data_thres = data_with_time.loc[valid_mask, required_cols].copy()
2502
2503            # Filter to target windows
2504            data_thres = self._filter_data_by_targets(data_thres, target_search_dict)
2505
2506            if len(data_thres) == 0:
2507                if self.parameters.lc_ms.verbose_processing:
2508                    print("No data found in target windows")
2509                self.mass_features = {}
2510                return
2511        else:
2512            # Normal mode with threshold
2513            max_intensity = data_with_time["intensity"].max()
2514            threshold = max(
2515                self.parameters.lc_ms.ph_inten_min_rel * max_intensity,
2516                self.parameters.lc_ms.ph_inten_min_abs,
2517            )
2518            valid_mask = data_with_time["intensity"].notna() & (
2519                data_with_time["intensity"] > threshold
2520            )
2521            required_cols = ["mz", "intensity", "scan", "scan_time"]
2522            data_thres = data_with_time.loc[valid_mask, required_cols].copy()
2523
2524        data_thres["persistence"] = data_thres["intensity"]
2525        mf_df = data_thres
2526        del data_thres, scan_subset, data_with_time
2527
2528        # Order by scan_time and then mz to ensure features near in rt are processed together
2529        # It's ok that different scans are in different partitions; we will roll up later
2530        mf_df = mf_df.sort_values(
2531            by=["scan_time", "mz"], ascending=[True, True]
2532        ).reset_index(drop=True)
2533        partition_size = 10000
2534        partitions = [
2535            mf_df.iloc[i : i + partition_size].reset_index(drop=True)
2536            for i in range(0, len(mf_df), partition_size)
2537        ]
2538        del mf_df
2539
2540        # Run roll_up_dataframe on each partition
2541        rolled_partitions = []
2542        for part in partitions:
2543            rolled = self.roll_up_dataframe(
2544                df=part,
2545                sort_by="persistence",
2546                dims=["mz", "scan_time"],
2547                tol=[
2548                    self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2549                    self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2550                ],
2551                relative=[True, False],
2552            )
2553            rolled_partitions.append(rolled)
2554        del partitions
2555
2556        # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries
2557
2558        # Combine results and run a final roll-up to merge features near partition boundaries
2559        mf_df_final = pd.concat(rolled_partitions, ignore_index=True)
2560        del rolled_partitions
2561
2562        # Reorder by persistence before final roll-up
2563        mf_df_final = mf_df_final.sort_values(
2564            by="persistence", ascending=False
2565        ).reset_index(drop=True)
2566
2567        mf_df_final = self.roll_up_dataframe(
2568            df=mf_df_final,
2569            sort_by="persistence",
2570            dims=["mz", "scan_time"],
2571            tol=[
2572                self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2573                self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2574            ],
2575            relative=[True, False],
2576        )
2577        # reset index
2578        mf_df_final = mf_df_final.reset_index(drop=True)
2579
2580        # Combine rename and sort operations
2581        mass_features = (
2582            mf_df_final.rename(
2583                columns={"scan": "apex_scan", "scan_time": "retention_time"}
2584            )
2585            .sort_values(by="persistence", ascending=False)
2586            .reset_index(drop=True)
2587        )
2588        del mf_df_final  # Free memory
2589
2590        # Order by persistence and reset index
2591        mass_features = mass_features.sort_values(
2592            by="persistence", ascending=False
2593        ).reset_index(drop=True)
2594
2595        self.mass_features = {}
2596        for idx, row in mass_features.iterrows():
2597            row_dict = row.to_dict()
2598            lcms_feature = LCMSMassFeature(self, **row_dict)
2599            lcms_feature.type = mf_type
2600            self.mass_features[lcms_feature.id] = lcms_feature
2601
2602        if self.parameters.lc_ms.verbose_processing:
2603            print("Found " + str(len(mass_features)) + " initial mass features")
2604
2605    def cluster_mass_features(self, drop_children=True, sort_by="persistence"):
2606        """Cluster mass features
2607
2608        Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence.
2609
2610        Parameters
2611        ----------
2612        drop_children : bool, optional
2613            Whether to drop the mass features that are not cluster parents. Default is True.
2614        sort_by : str, optional
2615            The column to sort the mass features by, this will determine which mass features get rolled up into a parent mass feature. Default is "persistence".
2616
2617        Raises
2618        ------
2619        ValueError
2620            If no mass features are found.
2621            If too many mass features are found.
2622
2623        Returns
2624        -------
2625        None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents.
2626        """
2627        if self.mass_features is None:
2628            raise ValueError("No mass features found, run find_mass_features() first")
2629        if len(self.mass_features) > 400000:
2630            raise ValueError(
2631                "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold"
2632            )
2633        dims = ["mz", "scan_time"]
2634        mf_df_og = self.mass_features_to_df()
2635        mf_df = mf_df_og.copy()
2636
2637        tol = [
2638            self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2639            self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2640        ]  # mz, in relative; scan_time in minutes
2641        relative = [True, False]
2642
2643        # Roll up mass features based on their proximity in the declared dimensions
2644        mf_df_new = self.roll_up_dataframe(
2645            df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative
2646        )
2647
2648        mf_df["cluster_parent"] = np.where(
2649            np.isin(mf_df.index, mf_df_new.index), True, False
2650        )
2651
2652        # get mass feature ids of features that are not cluster parents
2653        cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values
2654        if drop_children is True:
2655            # Drop mass features that are not cluster parents from self
2656            self.mass_features = {
2657                k: v
2658                for k, v in self.mass_features.items()
2659                if k not in cluster_daughters
2660            }
2661        else:
2662            return cluster_daughters

Methods for performing calculations related to 2D peak picking via persistent homology on LCMS data.

Notes

This class is intended to be used as a mixin for the LCMSBase class.

Methods
  • sparse_mean_filter(idx, V, radius=[0, 1, 1]). Sparse implementation of a mean filter.
  • embed_unique_indices(a). Creates an array of indices, sorted by unique element.
  • sparse_upper_star(idx, V). Sparse implementation of an upper star filtration.
  • check_if_grid(data). Check if the data is gridded in mz space.
  • grid_data(data). Grid the data in the mz dimension.
  • find_mass_features_ph(ms_level=1, grid=True). Find mass features within an LCMSBase object using persistent homology.
  • cluster_mass_features(drop_children=True). Cluster regions of interest.
@staticmethod
def sparse_mean_filter(idx, V, radius=[0, 1, 1]):
1530    @staticmethod
1531    def sparse_mean_filter(idx, V, radius=[0, 1, 1]):
1532        """Sparse implementation of a mean filter.
1533
1534        Parameters
1535        ----------
1536        idx : :obj:`~numpy.array`
1537            Edge indices for each dimension (MxN).
1538        V : :obj:`~numpy.array`
1539            Array of intensity data (Mx1).
1540        radius : float or list
1541            Radius of the sparse filter in each dimension. Values less than
1542            zero indicate no connectivity in that dimension.
1543
1544        Returns
1545        -------
1546        :obj:`~numpy.array`
1547            Filtered intensities (Mx1).
1548
1549        Notes
1550        -----
1551        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos.
1552        This is a static method.
1553        """
1554
1555        # Copy indices
1556        idx = idx.copy().astype(V.dtype)
1557
1558        # Scale
1559        for i, r in enumerate(radius):
1560            # Increase inter-index distance
1561            if r < 1:
1562                idx[:, i] *= 2
1563
1564            # Do nothing
1565            elif r == 1:
1566                pass
1567
1568            # Decrease inter-index distance
1569            else:
1570                idx[:, i] /= r
1571
1572        # Connectivity matrix
1573        cmat = KDTree(idx)
1574        cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix")
1575        cmat.setdiag(1)
1576
1577        # Pair indices
1578        I, J = cmat.nonzero()
1579
1580        # Delete cmat
1581        cmat_shape = cmat.shape
1582        del cmat
1583
1584        # Sum over columns
1585        V_sum = sparse.bsr_matrix(
1586            (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype
1587        ).diagonal(0)
1588
1589        # Count over columns
1590        V_count = sparse.bsr_matrix(
1591            (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype
1592        ).diagonal(0)
1593
1594        return V_sum / V_count

Sparse implementation of a mean filter.

Parameters
  • idx (~numpy.array): Edge indices for each dimension (MxN).
  • V (~numpy.array): Array of intensity data (Mx1).
  • radius (float or list): Radius of the sparse filter in each dimension. Values less than zero indicate no connectivity in that dimension.
Returns
  • ~numpy.array: Filtered intensities (Mx1).
Notes

This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos. This is a static method.

@staticmethod
def embed_unique_indices(a):
1596    @staticmethod
1597    def embed_unique_indices(a):
1598        """Creates an array of indices, sorted by unique element.
1599
1600        Parameters
1601        ----------
1602        a : :obj:`~numpy.array`
1603            Array of unique elements (Mx1).
1604
1605        Returns
1606        -------
1607        :obj:`~numpy.array`
1608            Array of indices (Mx1).
1609
1610        Notes
1611        -----
1612        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
1613        This is a static method.
1614        """
1615
1616        def count_tens(n):
1617            # Count tens
1618            ntens = (n - 1) // 10
1619
1620            while True:
1621                ntens_test = (ntens + n - 1) // 10
1622
1623                if ntens_test == ntens:
1624                    return ntens
1625                else:
1626                    ntens = ntens_test
1627
1628        def arange_exclude_10s(n):
1629            # How many 10s will there be?
1630            ntens = count_tens(n)
1631
1632            # Base array
1633            arr = np.arange(0, n + ntens)
1634
1635            # Exclude 10s
1636            arr = arr[(arr == 0) | (arr % 10 != 0)][:n]
1637
1638            return arr
1639
1640        # Creates an array of indices, sorted by unique element
1641        idx_sort = np.argsort(a)
1642        idx_unsort = np.argsort(idx_sort)
1643
1644        # Sorts records array so all unique elements are together
1645        sorted_a = a[idx_sort]
1646
1647        # Returns the unique values, the index of the first occurrence,
1648        # and the count for each element
1649        vals, idx_start, count = np.unique(
1650            sorted_a, return_index=True, return_counts=True
1651        )
1652
1653        # Splits the indices into separate arrays
1654        splits = np.split(idx_sort, idx_start[1:])
1655
1656        # Creates unique indices for each split
1657        idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits])
1658
1659        # Reorders according to input array
1660        idx_unq = idx_unq[idx_unsort]
1661
1662        # Magnitude of each index
1663        exp = np.log10(
1664            idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64)
1665        )
1666        idx_unq_mag = np.power(10, np.floor(exp) + 1)
1667
1668        # Result
1669        return a + idx_unq / idx_unq_mag

Creates an array of indices, sorted by unique element.

Parameters
  • a (~numpy.array): Array of unique elements (Mx1).
Returns
  • ~numpy.array: Array of indices (Mx1).
Notes

This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos This is a static method.

@staticmethod
def roll_up_dataframe( df: pandas.core.frame.DataFrame, sort_by: str, tol: list, relative: list, dims: list, memory_opt_threshold: int = 10000):
1764    @staticmethod
1765    def roll_up_dataframe(
1766        df: pd.DataFrame,
1767        sort_by: str,
1768        tol: list,
1769        relative: list,
1770        dims: list,
1771        memory_opt_threshold: int = 10000,
1772    ):
1773        """Subset data by rolling up into apex in appropriate dimensions.
1774
1775        Parameters
1776        ----------
1777        data : pd.DataFrame
1778            The input data containing "dims" columns and the "sort_by" column.
1779        sort_by : str
1780            The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature
1781            (i.e., the mass feature with the highest value in the sort_by column).
1782        dims : list
1783            A list of dimension names (column names in the data DataFrame) to roll up the mass features by.
1784        tol : list
1785            A list of tolerances for each dimension. The length of the list must match the number of dimensions.
1786            The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension).
1787            If relative is True, a kept row claims a later row when the absolute difference is at most tol times the kept row's value.
1788            If relative is False, the absolute difference must be at most tol.
1789        relative : list
1790            A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False).
1791        memory_opt_threshold : int, optional
1792            Accepted for compatibility. Ignored. Every frame uses the same greedy search. Default is 10000.
1793
1794        Returns
1795        -------
1796        pd.DataFrame
1797            A DataFrame with only the rolled up mass features, with the original index and columns.
1798
1799
1800        Raises
1801        ------
1802        ValueError
1803            If the input data is not a pandas DataFrame.
1804            If the input data does not have columns for each of the dimensions in "dims".
1805            If the length of "dims", "tol", and "relative" do not match.
1806        """
1807        og_columns = df.columns.copy()
1808
1809        # Unindex the data, but keep the original index
1810        if df.index.name is not None:
1811            og_index = df.index.name
1812        else:
1813            og_index = "index"
1814        df = df.reset_index(drop=False)
1815
1816        # Sort data by sort_by column, and reindex
1817        df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True)
1818
1819        # Check that data is a DataFrame and has columns for each of the dims
1820        if not isinstance(df, pd.DataFrame):
1821            raise ValueError("Data must be a pandas DataFrame")
1822        for dim in dims:
1823            if dim not in df.columns:
1824                raise ValueError(f"Data must have a column for {dim}")
1825        if len(dims) != len(tol) or len(dims) != len(relative):
1826            raise ValueError(
1827                "Dimensions, tolerances, and relative flags must be the same length"
1828            )
1829
1830        # Pre-compute all values arrays
1831        all_values = [df[dim].values for dim in dims]
1832
1833        keep = PHCalculations._greedy_keep_mask(all_values, tol, relative)
1834        df_sub = df.iloc[np.flatnonzero(keep)]
1835
1836        # Set index back to og_index and only keep original columns
1837        df_sub = df_sub.set_index(og_index).sort_index()[og_columns]
1838
1839        return df_sub

Subset data by rolling up into apex in appropriate dimensions.

Parameters
  • data (pd.DataFrame): The input data containing "dims" columns and the "sort_by" column.
  • sort_by (str): The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature (i.e., the mass feature with the highest value in the sort_by column).
  • dims (list): A list of dimension names (column names in the data DataFrame) to roll up the mass features by.
  • tol (list): A list of tolerances for each dimension. The length of the list must match the number of dimensions. The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension). If relative is True, a kept row claims a later row when the absolute difference is at most tol times the kept row's value. If relative is False, the absolute difference must be at most tol.
  • relative (list): A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False).
  • memory_opt_threshold (int, optional): Accepted for compatibility. Ignored. Every frame uses the same greedy search. Default is 10000.
Returns
  • pd.DataFrame: A DataFrame with only the rolled up mass features, with the original index and columns.
Raises
  • ValueError: If the input data is not a pandas DataFrame. If the input data does not have columns for each of the dimensions in "dims". If the length of "dims", "tol", and "relative" do not match.
def sparse_upper_star(self, idx, V):
1841    def sparse_upper_star(self, idx, V):
1842        """Sparse implementation of an upper star filtration.
1843
1844        Parameters
1845        ----------
1846        idx : :obj:`~numpy.array`
1847            Edge indices for each dimension (MxN).
1848        V : :obj:`~numpy.array`
1849            Array of intensity data (Mx1).
1850        Returns
1851        -------
1852        idx : :obj:`~numpy.array`
1853            Index of filtered points (Mx1).
1854        persistence : :obj:`~numpy.array`
1855            Persistence of each filtered point (Mx1).
1856
1857        Notes
1858        -----
1859        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
1860        """
1861
1862        # Invert
1863        V = -1 * V.copy().astype(int)
1864
1865        # Embed indices
1866        V = self.embed_unique_indices(V)
1867
1868        # Connectivity matrix
1869        cmat = KDTree(idx)
1870        cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix")
1871        cmat.setdiag(1)
1872        cmat = sparse.triu(cmat)
1873
1874        # Pairwise minimums
1875        I, J = cmat.nonzero()
1876        d = np.maximum(V[I], V[J])
1877
1878        # Delete connectiity matrix
1879        cmat_shape = cmat.shape
1880        del cmat
1881
1882        # Sparse distance matrix
1883        sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape)
1884
1885        # Delete pairwise mins
1886        del d, I, J
1887
1888        # Persistence homology
1889        ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0]
1890
1891        # Bound death values
1892        ph[ph[:, 1] == np.inf, 1] = np.max(V)
1893
1894        # Construct tree to query against
1895        tree = KDTree(V.reshape((-1, 1)))
1896
1897        # Get the indexes of the first nearest neighbor by birth
1898        _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1)
1899
1900        return nn, -(ph[:, 0] // 1 - ph[:, 1] // 1)

Sparse implementation of an upper star filtration.

Parameters
  • idx (~numpy.array): Edge indices for each dimension (MxN).
  • V (~numpy.array): Array of intensity data (Mx1).
Returns
  • idx (~numpy.array): Index of filtered points (Mx1).
  • persistence (~numpy.array): Persistence of each filtered point (Mx1).
Notes

This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos

def check_if_grid(self, data):
1902    def check_if_grid(self, data):
1903        """Check if the data are gridded in mz space.
1904
1905        Parameters
1906        ----------
1907        data : DataFrame
1908            DataFrame containing the mass spectrometry data.  Needs to have mz and scan columns.
1909
1910        Returns
1911        -------
1912        bool
1913            True if the data is gridded in the mz direction, False otherwise.
1914
1915        Notes
1916        -----
1917        This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly.
1918        """
1919        # Calculate the difference between consecutive mz values in a single scan
1920        dat_check = data.copy().reset_index(drop=True)
1921        dat_check["mz_diff"] = np.abs(dat_check["mz"].diff())
1922        mz_diff_min = (
1923            dat_check.groupby("scan")["mz_diff"].min().min()
1924        )  # within each scan, what is the smallest mz difference between consecutive mz values
1925
1926        # Find the mininum mz difference between mz values in the data; regardless of scan
1927        dat_check_mz = dat_check[["mz"]].drop_duplicates().copy()
1928        dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True)
1929        dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff())
1930
1931        # Get minimum mz_diff between mz values in the data
1932        mz_diff_min_raw = dat_check_mz["mz_diff"].min()
1933
1934        # If the minimum mz difference between mz values in the data is less than the minimum mz difference between mz values within a single scan, then the data is not gridded
1935        if mz_diff_min_raw < mz_diff_min:
1936            return False
1937        else:
1938            return True

Check if the data are gridded in mz space.

Parameters
  • data (DataFrame): DataFrame containing the mass spectrometry data. Needs to have mz and scan columns.
Returns
  • bool: True if the data is gridded in the mz direction, False otherwise.
Notes

This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly.

def grid_data(self, data, attempts=5):
1940    def grid_data(self, data, attempts=5):
1941        """Grid the data in the mz dimension.
1942
1943        Data must be gridded prior to persistent homology calculations and computing average mass spectrum
1944
1945        Parameters
1946        ----------
1947        data : DataFrame
1948            The input data containing mz, scan, scan_time, and intensity columns.
1949        attempts : int, optional
1950            The number of attempts to grid the data. Default is 5.
1951
1952        Returns
1953        -------
1954        DataFrame
1955            The gridded data with mz, scan, scan_time, and intensity columns.
1956
1957        Raises
1958        ------
1959        ValueError
1960            If gridding fails after the specified number of attempts.
1961        """
1962        attempt_i = 0
1963        while attempt_i < attempts:
1964            attempt_i += 1
1965            data = self._grid_data(data)
1966
1967            if self.check_if_grid(data):
1968                return data
1969
1970        if not self.check_if_grid(data):
1971            raise ValueError(
1972                "Gridding failed after "
1973                + str(attempt_i)
1974                + " attempts. Please check the data."
1975            )
1976        else:
1977            return data

Grid the data in the mz dimension.

Data must be gridded prior to persistent homology calculations and computing average mass spectrum

Parameters
  • data (DataFrame): The input data containing mz, scan, scan_time, and intensity columns.
  • attempts (int, optional): The number of attempts to grid the data. Default is 5.
Returns
  • DataFrame: The gridded data with mz, scan, scan_time, and intensity columns.
Raises
  • ValueError: If gridding fails after the specified number of attempts.
def find_mass_features_ph( self, ms_level=1, grid=True, targeted_search=False, target_search_dict=None, mf_type='untargeted', accumulate_features=False):
2081    def find_mass_features_ph(
2082        self,
2083        ms_level=1,
2084        grid=True,
2085        targeted_search=False,
2086        target_search_dict=None,
2087        mf_type="untargeted",
2088        accumulate_features=False,
2089    ):
2090        """Find mass features within an LCMSBase object using persistent homology.
2091
2092        Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id)
2093
2094        Parameters
2095        ----------
2096        ms_level : int, optional
2097            The MS level to use. Default is 1.
2098        grid : bool, optional
2099            If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True.
2100        targeted_search : bool, optional
2101            If True, perform targeted search mode. Default is False.
2102        target_search_dict : dict or None, optional
2103            Dictionary with target parameters for targeted search. Default is None.
2104        mf_type : str, optional
2105            Type label for the mass features. Default is "untargeted".
2106        accumulate_features : bool, optional
2107            If True, add to existing features rather than replacing them. Default is False.
2108
2109        Raises
2110        ------
2111        ValueError
2112            If no MS level data is found on the object.
2113            If data is not gridded and grid is False.
2114
2115        Returns
2116        -------
2117        None, but assigns the mass_features attribute to the object.
2118
2119        Notes
2120        -----
2121        This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
2122        """
2123        # Check that ms_level is a key in self._ms_uprocessed
2124        if ms_level not in self._ms_unprocessed.keys():
2125            raise ValueError(
2126                "No MS level "
2127                + str(ms_level)
2128                + " data found, did you instantiate with parser specific to MS level?"
2129            )
2130
2131        # Get ms data
2132        data = self._ms_unprocessed[ms_level].copy()
2133
2134        # Drop rows with missing intensity values and reset index
2135        data = data.dropna(subset=["intensity"]).reset_index(drop=True)
2136
2137        # Add scan_time for filtering if in targeted mode
2138        if targeted_search:
2139            data = data.merge(
2140                self.scan_df[["scan", "scan_time"]], on="scan", how="left"
2141            )
2142
2143        # Threshold data (bypass thresholds in targeted mode)
2144        dims = ["mz", "scan_time"]
2145        if targeted_search:
2146            # In targeted mode, bypass intensity and persistence thresholds
2147            threshold = 0
2148            persistence_threshold = 0
2149            # Filter data to only target windows
2150            data_thres = self._filter_data_by_targets(data, target_search_dict)
2151            if len(data_thres) == 0:
2152                if self.parameters.lc_ms.verbose_processing:
2153                    print("No data found in target windows")
2154                self.mass_features = {}
2155                return
2156        else:
2157            threshold = max(
2158                self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max(),
2159                self.parameters.lc_ms.ph_inten_min_abs,
2160            )
2161            persistence_threshold = (
2162                self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max()
2163            )
2164            data_thres = (
2165                data[data["intensity"] > threshold].reset_index(drop=True).copy()
2166            )
2167
2168        # One m/z grid for the whole run. A separate grid in each scan window
2169        # moves the apex of the same ion.
2170        if not self.check_if_grid(data_thres):
2171            if grid is False:
2172                raise ValueError(
2173                    "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function"
2174                )
2175            data_thres = self.grid_data(data_thres)
2176        if "scan_time" not in data_thres.columns:
2177            data_thres = data_thres.merge(
2178                self.scan_df[["scan", "scan_time"]], on="scan"
2179            )
2180
2181        return self._find_mass_features_ph_partition(
2182            data_thres,
2183            dims,
2184            persistence_threshold,
2185            mf_type,
2186            accumulate_features,
2187        )

Find mass features within an LCMSBase object using persistent homology.

Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id)

Parameters
  • ms_level (int, optional): The MS level to use. Default is 1.
  • grid (bool, optional): If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True.
  • targeted_search (bool, optional): If True, perform targeted search mode. Default is False.
  • target_search_dict (dict or None, optional): Dictionary with target parameters for targeted search. Default is None.
  • mf_type (str, optional): Type label for the mass features. Default is "untargeted".
  • accumulate_features (bool, optional): If True, add to existing features rather than replacing them. Default is False.
Raises
  • ValueError: If no MS level data is found on the object. If data is not gridded and grid is False.
Returns
  • None, but assigns the mass_features attribute to the object.
Notes

This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos

def find_mass_features_ph_centroid( self, ms_level=1, targeted_search=False, target_search_dict=None, mf_type='untargeted', accumulate_features=False):
2448    def find_mass_features_ph_centroid(
2449        self,
2450        ms_level=1,
2451        targeted_search=False,
2452        target_search_dict=None,
2453        mf_type="untargeted",
2454        accumulate_features=False,
2455    ):
2456        """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data.
2457
2458        Parameters
2459        ----------
2460        ms_level : int, optional
2461            The MS level to use. Default is 1.
2462        targeted_search : bool, optional
2463            If True, perform targeted search mode. Default is False.
2464        target_search_dict : dict or None, optional
2465            Dictionary with target parameters for targeted search. Default is None.
2466        mf_type : str, optional
2467            Type label for the mass features. Default is "untargeted".
2468        accumulate_features : bool, optional
2469            If True, add to existing features rather than replacing them. Default is False.
2470
2471        Raises
2472        ------
2473        ValueError
2474            If no MS level data is found on the object.
2475
2476        Returns
2477        -------
2478        None, but assigns the mass_features attribute to the object.
2479        """
2480        # Check that ms_level is a key in self._ms_uprocessed
2481        if ms_level not in self._ms_unprocessed.keys():
2482            raise ValueError(
2483                "No MS level "
2484                + str(ms_level)
2485                + " data found, did you instantiate with parser specific to MS level?"
2486            )
2487
2488        # Work with reference instead of copy
2489        data = self._ms_unprocessed[ms_level]
2490
2491        # Merge with scan data first (needed for filtering in targeted mode)
2492        scan_subset = self.scan_df[["scan", "scan_time"]]
2493        data_with_time = data.merge(scan_subset, on="scan", how="inner")
2494
2495        # Calculate threshold and filter (bypass in targeted mode)
2496        if targeted_search:
2497            # In targeted mode, bypass intensity threshold
2498            threshold = 0
2499            valid_mask = data_with_time["intensity"].notna()
2500            required_cols = ["mz", "intensity", "scan", "scan_time"]
2501            data_thres = data_with_time.loc[valid_mask, required_cols].copy()
2502
2503            # Filter to target windows
2504            data_thres = self._filter_data_by_targets(data_thres, target_search_dict)
2505
2506            if len(data_thres) == 0:
2507                if self.parameters.lc_ms.verbose_processing:
2508                    print("No data found in target windows")
2509                self.mass_features = {}
2510                return
2511        else:
2512            # Normal mode with threshold
2513            max_intensity = data_with_time["intensity"].max()
2514            threshold = max(
2515                self.parameters.lc_ms.ph_inten_min_rel * max_intensity,
2516                self.parameters.lc_ms.ph_inten_min_abs,
2517            )
2518            valid_mask = data_with_time["intensity"].notna() & (
2519                data_with_time["intensity"] > threshold
2520            )
2521            required_cols = ["mz", "intensity", "scan", "scan_time"]
2522            data_thres = data_with_time.loc[valid_mask, required_cols].copy()
2523
2524        data_thres["persistence"] = data_thres["intensity"]
2525        mf_df = data_thres
2526        del data_thres, scan_subset, data_with_time
2527
2528        # Order by scan_time and then mz to ensure features near in rt are processed together
2529        # It's ok that different scans are in different partitions; we will roll up later
2530        mf_df = mf_df.sort_values(
2531            by=["scan_time", "mz"], ascending=[True, True]
2532        ).reset_index(drop=True)
2533        partition_size = 10000
2534        partitions = [
2535            mf_df.iloc[i : i + partition_size].reset_index(drop=True)
2536            for i in range(0, len(mf_df), partition_size)
2537        ]
2538        del mf_df
2539
2540        # Run roll_up_dataframe on each partition
2541        rolled_partitions = []
2542        for part in partitions:
2543            rolled = self.roll_up_dataframe(
2544                df=part,
2545                sort_by="persistence",
2546                dims=["mz", "scan_time"],
2547                tol=[
2548                    self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2549                    self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2550                ],
2551                relative=[True, False],
2552            )
2553            rolled_partitions.append(rolled)
2554        del partitions
2555
2556        # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries
2557
2558        # Combine results and run a final roll-up to merge features near partition boundaries
2559        mf_df_final = pd.concat(rolled_partitions, ignore_index=True)
2560        del rolled_partitions
2561
2562        # Reorder by persistence before final roll-up
2563        mf_df_final = mf_df_final.sort_values(
2564            by="persistence", ascending=False
2565        ).reset_index(drop=True)
2566
2567        mf_df_final = self.roll_up_dataframe(
2568            df=mf_df_final,
2569            sort_by="persistence",
2570            dims=["mz", "scan_time"],
2571            tol=[
2572                self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2573                self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2574            ],
2575            relative=[True, False],
2576        )
2577        # reset index
2578        mf_df_final = mf_df_final.reset_index(drop=True)
2579
2580        # Combine rename and sort operations
2581        mass_features = (
2582            mf_df_final.rename(
2583                columns={"scan": "apex_scan", "scan_time": "retention_time"}
2584            )
2585            .sort_values(by="persistence", ascending=False)
2586            .reset_index(drop=True)
2587        )
2588        del mf_df_final  # Free memory
2589
2590        # Order by persistence and reset index
2591        mass_features = mass_features.sort_values(
2592            by="persistence", ascending=False
2593        ).reset_index(drop=True)
2594
2595        self.mass_features = {}
2596        for idx, row in mass_features.iterrows():
2597            row_dict = row.to_dict()
2598            lcms_feature = LCMSMassFeature(self, **row_dict)
2599            lcms_feature.type = mf_type
2600            self.mass_features[lcms_feature.id] = lcms_feature
2601
2602        if self.parameters.lc_ms.verbose_processing:
2603            print("Found " + str(len(mass_features)) + " initial mass features")

Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data.

Parameters
  • ms_level (int, optional): The MS level to use. Default is 1.
  • targeted_search (bool, optional): If True, perform targeted search mode. Default is False.
  • target_search_dict (dict or None, optional): Dictionary with target parameters for targeted search. Default is None.
  • mf_type (str, optional): Type label for the mass features. Default is "untargeted".
  • accumulate_features (bool, optional): If True, add to existing features rather than replacing them. Default is False.
Raises
  • ValueError: If no MS level data is found on the object.
Returns
  • None, but assigns the mass_features attribute to the object.
def cluster_mass_features(self, drop_children=True, sort_by='persistence'):
2605    def cluster_mass_features(self, drop_children=True, sort_by="persistence"):
2606        """Cluster mass features
2607
2608        Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence.
2609
2610        Parameters
2611        ----------
2612        drop_children : bool, optional
2613            Whether to drop the mass features that are not cluster parents. Default is True.
2614        sort_by : str, optional
2615            The column to sort the mass features by, this will determine which mass features get rolled up into a parent mass feature. Default is "persistence".
2616
2617        Raises
2618        ------
2619        ValueError
2620            If no mass features are found.
2621            If too many mass features are found.
2622
2623        Returns
2624        -------
2625        None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents.
2626        """
2627        if self.mass_features is None:
2628            raise ValueError("No mass features found, run find_mass_features() first")
2629        if len(self.mass_features) > 400000:
2630            raise ValueError(
2631                "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold"
2632            )
2633        dims = ["mz", "scan_time"]
2634        mf_df_og = self.mass_features_to_df()
2635        mf_df = mf_df_og.copy()
2636
2637        tol = [
2638            self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel,
2639            self.parameters.lc_ms.mass_feature_cluster_rt_tolerance,
2640        ]  # mz, in relative; scan_time in minutes
2641        relative = [True, False]
2642
2643        # Roll up mass features based on their proximity in the declared dimensions
2644        mf_df_new = self.roll_up_dataframe(
2645            df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative
2646        )
2647
2648        mf_df["cluster_parent"] = np.where(
2649            np.isin(mf_df.index, mf_df_new.index), True, False
2650        )
2651
2652        # get mass feature ids of features that are not cluster parents
2653        cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values
2654        if drop_children is True:
2655            # Drop mass features that are not cluster parents from self
2656            self.mass_features = {
2657                k: v
2658                for k, v in self.mass_features.items()
2659                if k not in cluster_daughters
2660            }
2661        else:
2662            return cluster_daughters

Cluster mass features

Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence.

Parameters
  • drop_children (bool, optional): Whether to drop the mass features that are not cluster parents. Default is True.
  • sort_by (str, optional): The column to sort the mass features by, this will determine which mass features get rolled up into a parent mass feature. Default is "persistence".
Raises
  • ValueError: If no mass features are found. If too many mass features are found.
Returns
  • None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents.
class LCMSCollectionCalculations:
2665class LCMSCollectionCalculations:
2666    """Methods for performing calculations related to LCMSCollection objects.
2667
2668    Notes
2669    -----
2670    This class is intended as a mixin for the LCMSCollection class.
2671    """
2672
2673    @staticmethod
2674    def _resolve_eic_query_mz(row):
2675        """Pick m/z used to look up ``sample.eics`` for a mass-feature row.
2676
2677        Prefer ``_eic_mz`` when present (set after integrate / gap-fill).
2678        Regular features in ``mass_features_dataframe`` often have NaN
2679        ``_eic_mz`` (never back-filled from objects on light load / reload),
2680        while induced features get ``_eic_mz`` written after gap-fill. Fall
2681        back to the feature ``mz`` so regular and representative traces are
2682        not silently dropped from multi-sample EIC plots.
2683        """
2684        if row is None:
2685            return None
2686        eic_mz = row.get("_eic_mz") if hasattr(row, "get") else None
2687        if eic_mz is not None and not pd.isna(eic_mz):
2688            return eic_mz
2689        mz = row.get("mz") if hasattr(row, "get") else None
2690        if mz is not None and not pd.isna(mz):
2691            return mz
2692        return None
2693
2694    @staticmethod
2695    def _get_eic_data_for_mz(sample, eic_mz, tolerance=0.0001):
2696        """Resolve EIC data for an m/z using exact key, then tolerance match.
2697
2698        Plotting previously used ``sample.eics.get(eic_mz)`` only. HDF5 EIC
2699        keys and feature ``_eic_mz`` / ``mz`` often differ by float noise, which
2700        silently dropped traces (including the representative). Elsewhere
2701        CoreMS uses the same default tolerance via
2702        ``get_eic_mz_for_mass_feature`` / ``associate_eics_with_mass_features``.
2703
2704        Parameters
2705        ----------
2706        sample : LCMSBase
2707            Sample that holds ``eics`` (dict keyed by m/z).
2708        eic_mz : float or None
2709            Target m/z (typically from the mass-feature ``_eic_mz`` or ``mz``).
2710        tolerance : float, optional
2711            Maximum |Δm/z| for fallback matching. Default is 0.0001 Da.
2712
2713        Returns
2714        -------
2715        EIC_Data or None
2716            Matching EIC data, or None if no key is within tolerance.
2717        """
2718        if eic_mz is None or pd.isna(eic_mz):
2719            return None
2720        if not hasattr(sample, "eics") or not sample.eics:
2721            return None
2722
2723        eic_data = sample.eics.get(eic_mz)
2724        if eic_data is not None:
2725            return eic_data
2726
2727        # Exact key miss: tolerance match (same default as associate_eics_with_mass_features)
2728        if hasattr(sample, "get_eic_mz_for_mass_feature"):
2729            matched_mz = sample.get_eic_mz_for_mass_feature(
2730                float(eic_mz), tolerance=tolerance
2731            )
2732            if matched_mz is not None:
2733                return sample.eics.get(matched_mz)
2734            return None
2735
2736        # Fallback if sample is a simple mock without the helper
2737        best_key = min(sample.eics, key=lambda k: abs(float(k) - float(eic_mz)))
2738        if abs(float(best_key) - float(eic_mz)) < tolerance:
2739            return sample.eics[best_key]
2740        return None
2741
2742    @staticmethod
2743    def _plot_multiple_eics(
2744        ax,
2745        cluster_mfs,
2746        induced_cluster_mfs,
2747        rep_sample_id,
2748        rep_mf_id,
2749        median_rt,
2750        eic_buffer_time,
2751        plot_smoothed=False,
2752        plot_datapoints=False,
2753        label_samples=False,
2754        lcms_collection=None,
2755    ):
2756        """Internal method to plot multiple EICs from different samples on a given axis.
2757
2758        Parameters
2759        ----------
2760        ax : matplotlib.axes.Axes
2761            The axis to plot on.
2762        cluster_mfs : pd.DataFrame
2763            DataFrame containing cluster mass features (non-induced).
2764        induced_cluster_mfs : pd.DataFrame or None
2765            DataFrame containing induced (gap-filled) mass features.
2766        rep_sample_id : int
2767            Sample ID of the representative mass feature.
2768        rep_mf_id : int
2769            Mass feature ID of the representative mass feature.
2770        median_rt : float
2771            Median retention time for the cluster.
2772        eic_buffer_time : float
2773            Time buffer around the peak (minutes).
2774        plot_smoothed : bool, optional
2775            If True, plot smoothed EICs. Default is False.
2776        plot_datapoints : bool, optional
2777            If True, plot EIC datapoints. Default is False.
2778        label_samples : bool, optional
2779            If True, label each sample individually. Default is False.
2780        lcms_collection : LCMSCollection, optional
2781            The parent collection object for accessing samples. Required.
2782        """
2783        ax.set_title("EICs from all samples", loc="left")
2784
2785        # Track if we've added labels for legend (to avoid duplicates)
2786        rep_labeled = False
2787        regular_labeled = False
2788        induced_labeled = False
2789
2790        # Plot regular (non-induced) mass features
2791        for _, row in cluster_mfs.iterrows():
2792            sample_id = int(row["sample_id"])
2793            mf_id = row["mf_id"]
2794            sample = lcms_collection[sample_id]
2795            sample_name = row["sample_name"]
2796
2797            # Prefer _eic_mz, else feature mz; then exact/tolerance key match (#257)
2798            eic_data = LCMSCollectionCalculations._get_eic_data_for_mz(
2799                sample, LCMSCollectionCalculations._resolve_eic_query_mz(row)
2800            )
2801
2802            if eic_data:
2803                # Determine line style and width
2804                if sample_id == rep_sample_id and mf_id == rep_mf_id:
2805                    # Representative feature - bold line
2806                    linewidth = 2.5
2807                    alpha = 1.0
2808                    color = "tab:blue"
2809                    if label_samples:
2810                        label = f"{sample_name} (representative)"
2811                    else:
2812                        label = "Representative" if not rep_labeled else None
2813                        rep_labeled = True
2814                else:
2815                    # Other features - thinner line
2816                    linewidth = 1.0
2817                    alpha = 0.5
2818                    color = "tab:blue"
2819                    if label_samples:
2820                        label = sample_name
2821                    else:
2822                        label = "Regular features" if not regular_labeled else None
2823                        regular_labeled = True
2824
2825                ax.plot(
2826                    eic_data.time,
2827                    eic_data.eic,
2828                    c=color,
2829                    linewidth=linewidth,
2830                    alpha=alpha,
2831                    linestyle="-",
2832                    label=label,
2833                )
2834
2835                if plot_datapoints:
2836                    ax.scatter(eic_data.time, eic_data.eic, c=color, alpha=alpha, s=10)
2837
2838                if plot_smoothed and hasattr(eic_data, "eic_smoothed"):
2839                    ax.plot(
2840                        eic_data.time,
2841                        eic_data.eic_smoothed,
2842                        c=color,
2843                        linestyle="--",
2844                        alpha=alpha * 0.8,
2845                        linewidth=linewidth * 0.8,
2846                    )
2847
2848        # Plot induced (gap-filled) mass features if available
2849        if induced_cluster_mfs is not None and not induced_cluster_mfs.empty:
2850            for _, row in induced_cluster_mfs.iterrows():
2851                sample_id = int(row["sample_id"])
2852                mf_id = row["mf_id"]
2853                sample = lcms_collection[sample_id]
2854                sample_name = row["sample_name"]
2855
2856                # Prefer _eic_mz, else feature mz; then exact/tolerance key match (#257)
2857                eic_data = LCMSCollectionCalculations._get_eic_data_for_mz(
2858                    sample, LCMSCollectionCalculations._resolve_eic_query_mz(row)
2859                )
2860
2861                if eic_data:
2862                    # Induced features - even thinner line
2863                    linewidth = 0.5
2864                    alpha = 0.4
2865                    color = "tab:orange"
2866
2867                    if label_samples:
2868                        label = f"{sample_name} (induced)"
2869                    else:
2870                        label = "Gap-filled features" if not induced_labeled else None
2871                        induced_labeled = True
2872
2873                    ax.plot(
2874                        eic_data.time,
2875                        eic_data.eic,
2876                        c=color,
2877                        linewidth=linewidth,
2878                        alpha=alpha,
2879                        linestyle="-",
2880                        label=label,
2881                    )
2882
2883                    if plot_datapoints:
2884                        ax.scatter(
2885                            eic_data.time, eic_data.eic, c=color, alpha=alpha, s=5
2886                        )
2887
2888                    if plot_smoothed and hasattr(eic_data, "eic_smoothed"):
2889                        ax.plot(
2890                            eic_data.time,
2891                            eic_data.eic_smoothed,
2892                            c=color,
2893                            linestyle="--",
2894                            alpha=alpha * 0.8,
2895                            linewidth=linewidth * 0.8,
2896                        )
2897
2898        # Add vertical line at median RT
2899        ax.axvline(x=median_rt, color="k", linestyle="--", alpha=0.7, label="Median RT")
2900
2901        ax.set_ylabel("Intensity")
2902        ax.set_xlabel("Time (minutes)")
2903        ax.set_xlim(
2904            median_rt - eic_buffer_time,
2905            median_rt + eic_buffer_time,
2906        )
2907        ax.legend(loc="upper left", fontsize=8)
2908        ax.yaxis.get_major_formatter().set_useOffset(False)
2909
2910    def clean_sparse_matrix(self, sparse_matrix):
2911        """Clean a sparse matrix by removing duplicates and sorting.
2912
2913        Parameters
2914        ----------
2915        sparse_matrix : :obj:`~numpy.array`
2916            A sparse matrix to clean.
2917
2918        Returns
2919        -------
2920        :obj:`~numpy.array`
2921            A cleaned sparse matrix.
2922        """
2923        for match in sparse_matrix:
2924            match.sort()
2925        sparse_matrix.sort()
2926        dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0)
2927        return dereplicated_sparse_matrix
2928
2929    def match_mfs(self, mf_c, mf_i):
2930        """Match mass features between two LCMS objects.
2931
2932        Parameters
2933        ----------
2934        mf_c : :obj:`~pandas.DataFrame`
2935            The mass features to match against.
2936        mf_i : :obj:`~pandas.DataFrame`
2937            The mass features to match.
2938
2939        Returns
2940        -------
2941        :obj:`~pandas.DataFrame`
2942            The matched mass features from mf_c.
2943        :obj:`~pandas.DataFrame`
2944            The matched mass features from mf_i.
2945
2946        Notes
2947        -----
2948        This function has been adapted from the original implementation in the Deimos package:
2949        https://github.com/pnnl/deimos
2950        """
2951        if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1:
2952            return None, None
2953
2954        # Prepare dataframes
2955        mf_c = mf_c.copy()
2956        mf_c["id_i"] = 0
2957        mf_i = mf_i.copy()
2958        mf_i["id_i"] = 1
2959
2960        # Set dimensions for matching
2961        dims = ["mz", "scan_time"]
2962        relative = [True, False]
2963        mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6
2964        rt_tol = self.parameters.lcms_collection.alignment_rt_tol
2965        tol = [mz_tol, rt_tol]
2966
2967        # Compute inter-feature distances
2968        idx = []
2969        for i, f in enumerate(dims):
2970            # vectors
2971            v1 = mf_c[f].values.reshape(-1, 1)
2972            v2 = mf_i[f].values.reshape(-1, 1)
2973
2974            # Distances
2975            d = scipy.spatial.distance.cdist(v1, v2)
2976
2977            if relative[i] is True:
2978                # Divisor
2979                basis = np.repeat(v1, v2.shape[0], axis=1)
2980                fix = np.repeat(v2, v1.shape[0], axis=1).T
2981                basis = np.where(basis == 0, fix, basis)
2982
2983                # Divide
2984                d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0)
2985
2986            # Check tol
2987            idx.append(d <= tol[i])
2988
2989        # Stack truth arrays
2990        idx = np.prod(np.dstack(idx), axis=-1, dtype=bool)
2991
2992        # Compute normalized 3d distance
2993        v1 = mf_c[dims].values / tol
2994        v2 = mf_i[dims].values / tol
2995        dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock")
2996
2997        # Separate features within tolerance from those outside
2998        # Features outside tolerance should be inf, features within tolerance keep their distance
2999        # Use idx mask: True for within tolerance, False for outside
3000        dist3d_within_tol = np.where(idx, dist3d, np.inf)
3001
3002        # Normalize to 0-1 (only affects within-tolerance distances)
3003        mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0
3004        if mx > 0:
3005            # Lower distance is better - normalize only the within-tolerance values
3006            dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf)
3007        else:
3008            # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs
3009            dist3d_within_tol = np.where(idx, 1e-10, np.inf)
3010
3011        # Use the masked distance matrix
3012        dist3d = dist3d_within_tol
3013
3014        # Min over dims
3015        mincols = np.min(dist3d, axis=0, keepdims=True)
3016
3017        # Zero out mincols over dims
3018        dist3d[dist3d != mincols] = np.inf
3019
3020        # Min over clusters
3021        minrows = np.min(dist3d, axis=1, keepdims=True)
3022
3023        # Where max and nonzero
3024        ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf))
3025
3026        # Reorder
3027        mf_c = mf_c.iloc[ii]
3028        mf_i = mf_i.iloc[jj]
3029
3030        if len(mf_c.index) < 1 or len(mf_i.index) < 1:
3031            return None, None
3032
3033        return mf_c, mf_i
3034
3035    def fit_rts(self, a, b, align="scan_time", **kwargs):
3036        """
3037        Fit a support vector regressor to matched features.
3038
3039        Parameters
3040        ----------
3041        a : :obj:`~pandas.DataFrame`
3042            First set of input feature coordinates and intensities; the center object and the object to align to.
3043        b : :obj:`~pandas.DataFrame`
3044            Second set of input feature coordinates and intensities; the object to align to the center object.
3045        align : str
3046            Dimension to align.
3047        kwargs
3048            Keyword arguments for support vector regressor
3049            (:class:`sklearn.svm.SVR`).
3050
3051        Returns
3052        -------
3053        :obj:`~function`
3054            An interpolation function where one can input a retention time and get the predicted retention time.
3055
3056        Notes
3057        -----
3058        This function has been adapted from the original implementation in the Deimos package:
3059        https://github.com/pnnl/deimos
3060
3061        """
3062
3063        # Uniqueify
3064        x = a[align].values
3065        y = b[align].values
3066        arr = np.vstack((x, y)).T
3067        arr = np.unique(arr, axis=0)
3068
3069        # Safety check: ensure we have data to work with
3070        if len(arr) == 0:
3071            warnings.warn(
3072                "No data points available for retention time fitting. Returning identity function."
3073            )
3074            return lambda x: x
3075
3076        # Check kwargs
3077        if "kernel" in kwargs:
3078            kernel = kwargs.get("kernel")
3079        else:
3080            kernel = "linear"
3081
3082        # Construct interpolation axis
3083        newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000)
3084
3085        # Linear kernel
3086        if kernel == "linear":
3087            reg = scipy.stats.linregress(x, y)
3088            newy = reg.slope * newx + reg.intercept
3089
3090        # Other kernels
3091        else:
3092            # Fit
3093            svr = SVR(**kwargs)
3094            svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0])
3095
3096            # Predict
3097            newy = svr.predict(newx.reshape(-1, 1))
3098
3099        # Pad x and y_pred with zeros to force interpolation to start at 0
3100        newx = np.concatenate(([0], newx))
3101        newy = np.concatenate(([0], newy))
3102
3103        # Pad x and y_pred with max time to force interpolation to end at max time to force interpolation to match at end max time
3104        max_time = self[0].scan_df["scan_time"].max()
3105        newx = np.concatenate((newx, [max_time]))
3106        newy = np.concatenate((newy, [max_time]))
3107
3108        # Return an interpolation function for the x and y_pred
3109        def interp(x):
3110            pred_y = np.interp(x, newx, newy)
3111            return pred_y
3112
3113        return interp
3114
3115    def get_anchor_mass_features(self, mf_df):
3116        """
3117        Get the anchor mass features from a DataFrame of mass features.
3118
3119        Parameters
3120        ----------
3121        mf_df : :obj:`~pandas.DataFrame`
3122            The mass features to filter to just the anchor mass features.
3123
3124        Returns
3125        -------
3126        :obj:`~pandas.DataFrame`
3127            The anchor mass features dataframe.
3128        """
3129        mf_df = mf_df.copy()
3130
3131        if (
3132            "deconvoluted_mass_spectra"
3133            in self.parameters.lcms_collection.mass_feature_anchor_technique
3134        ):
3135            # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent
3136            mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"])
3137            mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]]
3138
3139        if (
3140            "absolute_intensity"
3141            in self.parameters.lcms_collection.mass_feature_anchor_technique
3142        ):
3143            # Drop features that have an intensity lower than the threshold
3144            threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold
3145            mf_df = mf_df[mf_df["intensity"] > threshold]
3146
3147        if (
3148            "relative_intensity"
3149            in self.parameters.lcms_collection.mass_feature_anchor_technique
3150        ):
3151            # Drop features in the lower fraction of intensities
3152            threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold
3153            intensity_threshold = mf_df["intensity"].quantile(threshold_quantile)
3154            mf_df = mf_df[mf_df["intensity"] >= intensity_threshold]
3155
3156        return mf_df
3157
3158    def attempt_alignment(self, matches_c, matches_i):
3159        """
3160        Check if alignment is needed for the LCMS objects in the collection.
3161        """
3162
3163        # Hold out a subset of matches_c and matches_i for spline fitting
3164        matches_c.reset_index(drop=False, inplace=True)
3165        matches_i.reset_index(drop=False, inplace=True)
3166
3167        # Check if there are enough matches to attempt alignment
3168        minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches
3169        if len(matches_c) < minimum_matches:
3170            # Return False (no alignment) and identity function (returns original time)
3171            # which isn't used but is a placeholder to avoid errors in downstream code since
3172            # the function expects a callable to be returned
3173            return False, lambda x: x
3174
3175        # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c
3176        matches_c = matches_c.sort_values(by="scan_time")
3177        matches_i = matches_i.iloc[matches_c.index.values]
3178
3179        hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction
3180        # starting with an array of length len(matches_c), select equally spaced indices to hold out
3181        idx_holdout = matches_c.index.values[
3182            np.arange(0, len(matches_c), int(1 / hold_out_fraction))
3183        ]
3184
3185        matches_c_holdout = matches_c.loc[idx_holdout].copy()
3186        matches_i_holdout = matches_i.loc[idx_holdout].copy()
3187
3188        # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index
3189        matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name")
3190        matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name")
3191
3192        # Reset the scan_time to the original scan_time
3193        matches_i = matches_i.copy()
3194        matches_i["scan_time"] = matches_i["scan_time_og"]
3195
3196        # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features
3197        spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000)
3198
3199        # Check if the spline fitting improved the alignment for the holdout matches
3200        matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"])
3201        og_diff = np.abs(
3202            matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"]
3203        )
3204        fit_diff = np.abs(
3205            matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"]
3206        )
3207
3208        if (
3209            "fraction_improved"
3210            in self.parameters.lcms_collection.alignment_acceptance_technique
3211        ):
3212            fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff)
3213            use_spline_alignment = (
3214                fraction_improved
3215                > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold
3216            )
3217        if (
3218            "mean_squared_error_improved"
3219            in self.parameters.lcms_collection.alignment_acceptance_technique
3220        ):
3221            mse_og = np.mean(og_diff**2)
3222            mse = np.mean(fit_diff**2)
3223            use_spline_alignment = mse < mse_og
3224            # Convert to boolean
3225            use_spline_alignment = bool(use_spline_alignment)
3226
3227        return use_spline_alignment, spl
3228
3229    def align_lcms_objects(self, overwrite=False):
3230        """
3231        Align LCMS objects in the collection.
3232
3233        Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects.
3234        First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects,
3235        starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects
3236        are then fit to the center LCMS object using the matched mass features.
3237
3238        Returns
3239        -------
3240        None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object.
3241
3242        Notes
3243        -----
3244        This function has been adapted from the original implementation in the Deimos package:
3245        https://github.com/pnnl/deimos
3246        """
3247
3248        # Prepare the center LCMS object
3249        center_obj_ids = self.manifest_dataframe[
3250            self.manifest_dataframe["center"]
3251        ].collection_id.values
3252
3253        full_mf_df = self.mass_features_dataframe
3254        # re-index to sample_name for faster lookups
3255        full_mf_df = full_mf_df.reset_index().set_index("sample_name")
3256        samples_with_features = set(full_mf_df.index.get_level_values("sample_name"))
3257
3258        if "scan_time_aligned" in full_mf_df.columns and not overwrite:
3259            raise ValueError("Mass features have already been aligned")
3260
3261        def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline):
3262            """Set scan_time_aligned for one sample using spline or identity mapping."""
3263            if use_alignment and spline is not None:
3264                self[sample_idx]._scan_info["scan_time_aligned"] = {
3265                    k: spline(v)
3266                    for k, v in self[sample_idx]._scan_info["scan_time"].items()
3267                }
3268                return True
3269
3270            self[sample_idx]._scan_info["scan_time_aligned"] = (
3271                self[sample_idx]._scan_info["scan_time"].copy()
3272            )
3273            return False
3274
3275        def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline):
3276            """Return next sample index/dataframe with features, aligning empty samples on the way."""
3277            i = start_idx
3278            while 0 <= i < len(self):
3279                sample_name = self.samples[i]
3280                if sample_name in samples_with_features:
3281                    mf_df_i = full_mf_df.loc[sample_name].copy()
3282                    mf_df_i["scan_time_og"] = mf_df_i["scan_time"]
3283                    mf_df_i = mf_df_i.reset_index(drop=False)
3284                    if use_alignment and spline is not None:
3285                        # Use previous step transform as a better matching starting point.
3286                        mf_df_i["scan_time"] = spline(mf_df_i["scan_time"])
3287                    return i, mf_df_i
3288
3289                _set_scan_time_alignment_for_sample(i, use_alignment, spline)
3290                self.rt_alignment_attempted = True
3291                i += index_step
3292
3293            return i, None
3294
3295        anchor_mf_dfs = []
3296        for center_obj_id in center_obj_ids:
3297            # Get the anchor mass features from the center LCMS object
3298            mf_df_c = full_mf_df.loc[self.samples[center_obj_id]]
3299            mf_df_c = self.get_anchor_mass_features(mf_df_c)
3300            anchor_mf_dfs.append(mf_df_c)
3301
3302            # Set scan_time_aligned to scan_time for the center LCMS object
3303            center_scan_df = self[center_obj_id].scan_df.copy()
3304            center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"]
3305            self[center_obj_id].scan_df = center_scan_df
3306
3307            # Store alignment data for center object (identity mapping)
3308            center_sample_name = self.samples[center_obj_id]
3309
3310            index_steps = (1, -1)
3311            # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing)
3312            for index_step in index_steps:
3313                # Initialize spline for propagation to samples without features
3314                spl = None
3315                use_spline_alignment = False
3316
3317                # Loop through the other LCMS objects in this direction.
3318                i, mf_df_i = _get_feature_df_at_or_after(
3319                    center_obj_id + index_step,
3320                    index_step,
3321                    use_spline_alignment,
3322                    spl,
3323                )
3324
3325                while mf_df_i is not None:
3326                    mf_df_i = self.get_anchor_mass_features(mf_df_i)
3327
3328                    # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object.
3329                    matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i)
3330
3331                    if matches_c is not None:
3332                        use_spline_alignment, spl = self.attempt_alignment(
3333                            matches_c, matches_i
3334                        )
3335
3336                        # Record if we used alignment for this sample
3337                        sample_name = self.samples[i]
3338                        self._manifest_dict[sample_name]["use_rt_alignment"] = (
3339                            use_spline_alignment
3340                        )
3341
3342                        if use_spline_alignment:
3343                            # Set new retention times on scan_df for lc_obj using the spline fitting
3344                            matches_i["scan_time_fit"] = spl(matches_i["scan_time"])
3345
3346                        self.rt_aligned = _set_scan_time_alignment_for_sample(
3347                            i, use_spline_alignment, spl
3348                        )
3349                        self.rt_alignment_attempted = True
3350
3351                        i, mf_df_i = _get_feature_df_at_or_after(
3352                            i + index_step,
3353                            index_step,
3354                            use_spline_alignment,
3355                            spl,
3356                        )
3357                    else:
3358                        # If no matches are found, propagate prior alignment from this index step.
3359                        sample_name = self.samples[i]
3360                        used_previous_alignment = (
3361                            use_spline_alignment and spl is not None
3362                        )
3363                        self._manifest_dict[sample_name]["use_rt_alignment"] = (
3364                            used_previous_alignment
3365                        )
3366
3367                        self.rt_aligned = _set_scan_time_alignment_for_sample(
3368                            i, used_previous_alignment, spl
3369                        )
3370                        self.rt_alignment_attempted = True
3371
3372                        i, mf_df_i = _get_feature_df_at_or_after(
3373                            i + index_step,
3374                            index_step,
3375                            used_previous_alignment,
3376                            spl,
3377                        )
3378
3379        # Now align each batch using the center objects as anchors with the other batches
3380        mf_df_c = anchor_mf_dfs[0]
3381        for i in center_obj_ids[1:]:
3382            mf_df_i = full_mf_df.loc[self.samples[i]].copy()
3383            mf_df_i["scan_time_og"] = mf_df_i["scan_time"]
3384            mf_df_i = self.get_anchor_mass_features(mf_df_i)
3385
3386            matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i)
3387            if matches_c is not None:
3388                use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i)
3389
3390                # Record if we used alignment for this sample
3391                sample_name = self.samples[i]
3392                self._manifest_dict[sample_name]["use_rt_alignment"] = (
3393                    use_spline_alignment
3394                )
3395
3396                if use_spline_alignment:
3397                    # Set new retention times on all this object's
3398                    new_times = spl(self[i].scan_df["scan_time"])
3399                    new_scan_info = self[i].scan_df.copy()
3400                    new_scan_info["scan_time_aligned"] = new_times
3401                    self[i].scan_df = new_scan_info
3402
3403                    # Get the batch that this object belongs to
3404                    batch = self.manifest[self.samples[i]]["batch"]
3405
3406                    for j in range(len(self)):
3407                        if self.manifest[self.samples[j]]["batch"] == batch:
3408                            if j != i:
3409                                sample_name_j = self.samples[j]
3410                                self._manifest_dict[sample_name_j][
3411                                    "use_rt_alignment"
3412                                ] = use_spline_alignment
3413                                new_scan_info = self[j].scan_df.copy()
3414                                aligned_times = spl(
3415                                    self[j].scan_df["scan_time_aligned"]
3416                                )
3417                                new_scan_info["scan_time_aligned"] = aligned_times
3418                                self[j].scan_df = new_scan_info
3419
3420        # Set final mass_features_dataframe with the aligned scan_time
3421        center_sample_name = self.samples[center_obj_ids[0]]
3422        self._manifest_dict[center_sample_name]["use_rt_alignment"] = False
3423        new_scan_info = self[center_obj_ids[0]].scan_df.copy()
3424        new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"]
3425
3426    def add_consensus_mass_features(self):
3427        """
3428        Create consensus mass features by clustering aligned features across samples.
3429
3430        This method clusters mass features from all samples in the collection based on
3431        their m/z and aligned retention time proximity. Features that cluster together
3432        across samples are assigned a common cluster ID, creating consensus features
3433        that represent the same compound detected across multiple samples.
3434
3435        The clustering process:
3436        1. Partitions features by m/z to avoid large sparse matrices and enable parallelization
3437        2. Clusters features within each partition using hierarchical clustering
3438        3. Merges partition-boundary clusters that represent the same feature
3439        4. Filters out clusters not present in minimum fraction of samples
3440
3441        Must be run after align_lcms_objects(). Results are stored in the
3442        mass_features_dataframe with a 'cluster' column added.
3443
3444        Parameters
3445        ----------
3446        None
3447            Uses parameters from self.parameters.lcms_collection:
3448            - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm)
3449            - consensus_rt_tol: retention time tolerance for clustering (minutes)
3450            - consensus_partition_size: target partition size for managing memory and parallelization
3451            - consensus_min_sample_fraction: minimum fraction of samples a cluster
3452              must appear in to be retained (0-1)
3453            - cores: number of CPU cores to use for parallel partition processing
3454
3455        Returns
3456        -------
3457        None
3458            Updates self.mass_features_dataframe in place by adding 'cluster' column
3459            and filtering to retain only clusters meeting minimum sample presence.
3460
3461        Raises
3462        ------
3463        ValueError
3464            If mass features have not been aligned (run align_lcms_objects() first).
3465
3466        Notes
3467        -----
3468        - Partitioning prevents memory issues with large sparse distance matrices
3469        - Each partition is processed in parallel (up to cores limit)
3470        - Clusters not meeting consensus_min_sample_fraction are automatically removed
3471        - Access cluster_summary_dataframe property for summary statistics
3472        - Use fill_missing_cluster_features() for gap-filling after clustering
3473
3474        See Also
3475        --------
3476        align_lcms_objects : Aligns retention times before consensus clustering
3477        cluster_summary_dataframe : Property that generates summary statistics for clusters
3478        fill_missing_cluster_features : Gap-fill missing features in clusters
3479        """
3480        # Get the combined mass features from all LCMS objects, keep the original index as a separate column
3481        combined_mfs = self.mass_features_dataframe.copy()
3482        combined_mfs["coll_mf_id"] = combined_mfs.index
3483
3484        # Check if the mass features have been aligned
3485        if "scan_time_aligned" not in combined_mfs.columns:
3486            raise ValueError(
3487                "Mass features have not been aligned, run align_lcms_objects() first"
3488            )
3489
3490        # Partition the mass features by mz so we can parallelize the matching before clustering
3491        from corems.chroma_peak.calc import subset as corems_subset
3492
3493        # get max mz from combined_mfs and calculate tolerance from ppm
3494        mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
3495        n_partition_size = self.parameters.lcms_collection.consensus_partition_size
3496        lazy_partitions = corems_subset.multi_sample_partition(
3497            combined_mfs,
3498            split_on="mz",
3499            size=n_partition_size,
3500            tol=mz_tol,
3501            relative=True,
3502        )
3503
3504        # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning
3505        if np.array(lazy_partitions._counts).max() > 2 * n_partition_size:
3506            warnings.warn(
3507                "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol."
3508            )
3509
3510        # Cluster the mass features within each partition
3511        if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions:
3512            cores_to_use = lazy_partitions.n_partitions
3513        else:
3514            cores_to_use = self.parameters.lcms_collection.cores
3515        # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use)
3516        mfs_with_clusters = lazy_partitions.map(
3517            self.cluster_mass_features_agg_cluster, processes=cores_to_use
3518        )
3519
3520        # Clean up cluster id names after partitioning
3521        new_cluster_ids = (
3522            mfs_with_clusters[["cluster", "partition_idx"]]
3523            .drop_duplicates()
3524            .reset_index(drop=True)
3525        )
3526        new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index
3527        mfs_with_clusters = mfs_with_clusters.merge(
3528            new_cluster_ids, on=["cluster", "partition_idx"]
3529        )
3530        mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"]
3531        mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"])
3532
3533        # Embed a new cluster id into the mass features dataframe and set as index
3534        mfs_with_clusters["idx"] = mfs_with_clusters.index
3535
3536        try:
3537            # Check if any clusters can be merged into a single cluster
3538            eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters)
3539
3540            # Merge clusters identified in eval_dict
3541            while len(eval_dict["merge_these_clusters"]) > 0:
3542                list_of_clusters_to_merge = [
3543                    [x[0], x[1]] for x in eval_dict["merge_these_clusters"]
3544                ]
3545                # Convert to a dataframe with columns "new_cluster" and "cluster"
3546                df = pd.DataFrame(
3547                    np.array(list_of_clusters_to_merge),
3548                    columns=["new_cluster", "cluster"],
3549                )
3550                # Drop duplicates of "child" clusters
3551                df = df.drop_duplicates("cluster", keep="first")
3552                df = df.drop_duplicates("new_cluster", keep="first")
3553                mfs_with_clusters = mfs_with_clusters.merge(
3554                    df, on="cluster", how="left"
3555                )
3556                mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna(
3557                    mfs_with_clusters["cluster"]
3558                )
3559                mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"])
3560
3561                # Re-evaluate clusters for repeats
3562                eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters)
3563                self.mass_features_dataframe = mfs_with_clusters
3564
3565        except:
3566            mfs_with_clusters.set_index("coll_mf_id", inplace=True)
3567            self.mass_features_dataframe = mfs_with_clusters
3568
3569        # Filter out clusters that don't meet minimum sample fraction
3570        self._filter_clusters_by_sample_presence()
3571
3572        # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template?
3573
3574    def _filter_clusters_by_sample_presence(self):
3575        """
3576        Filter out clusters that don't meet the minimum sample fraction threshold.
3577
3578        Removes clusters (and their associated mass features) from the mass_features_dataframe
3579        if they don't appear in at least consensus_min_sample_fraction of samples.
3580
3581        This is called automatically at the end of add_consensus_mass_features().
3582
3583        Returns
3584        -------
3585        None
3586            Updates self.mass_features_dataframe in place by removing clusters that don't
3587            meet the minimum sample presence threshold.
3588        """
3589        if (
3590            self.mass_features_dataframe is None
3591            or len(self.mass_features_dataframe) == 0
3592        ):
3593            return
3594
3595        min_sample_fraction = (
3596            self.parameters.lcms_collection.consensus_min_sample_fraction
3597        )
3598
3599        # Validate parameter
3600        if not 0 <= min_sample_fraction <= 1:
3601            raise ValueError("consensus_min_sample_fraction must be between 0 and 1")
3602
3603        # Calculate minimum number of samples required
3604        total_samples = len(self.samples)
3605        min_samples_required = min_sample_fraction * total_samples
3606
3607        # Count unique samples per cluster
3608        cluster_sample_counts = (
3609            self.mass_features_dataframe.groupby("cluster")["sample_id"]
3610            .nunique()
3611            .reset_index(name="sample_count")
3612        )
3613
3614        # Identify clusters to keep
3615        clusters_to_keep = cluster_sample_counts[
3616            cluster_sample_counts["sample_count"] > min_samples_required
3617        ]["cluster"].values
3618
3619        # Filter mass features dataframe
3620        self.mass_features_dataframe = self.mass_features_dataframe[
3621            self.mass_features_dataframe["cluster"].isin(clusters_to_keep)
3622        ]
3623
3624    def summarize_clusters(self):
3625        """
3626        Generate summary statistics for consensus mass feature clusters.
3627
3628        Computes aggregate statistics (median, mean, std, min, max) for each cluster
3629        across all samples. Combines both regular mass features and induced mass features
3630        (from gap-filling) when available to provide complete cluster statistics.
3631
3632        Must be run after add_consensus_mass_features() which creates the cluster assignments.
3633        Results are stored in cluster_summary_dataframe property and used by plotting methods.
3634
3635        Parameters
3636        ----------
3637        None
3638            Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe.
3639            Both must contain 'cluster' column.
3640
3641        Returns
3642        -------
3643        :obj:`~pandas.DataFrame` or None
3644            DataFrame with one row per cluster containing summary statistics:
3645            - cluster: cluster ID
3646            - mz_{median,mean,std,max,min}: m/z statistics
3647            - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics
3648            - half_height_width_{median,mean,std,max,min}: peak width statistics
3649            - tailing_factor_{median,mean,std,max,min}: peak shape statistics
3650            - dispersity_index_{median,mean,std,max,min}: peak quality statistics
3651            - sample_id_nunique: number of unique samples containing the cluster
3652            - intensity_{max,median,mean,std,min}: intensity statistics
3653            - persistence_{max,median,mean,std,min}: persistence statistics
3654
3655            Returns None if mass_features_dataframe is empty.
3656
3657        Notes
3658        -----
3659        - Summary DataFrame is automatically stored in cluster_summary_dataframe property
3660        - Includes both regular and induced (gap-filled) mass features when available.
3661          ``group_consensus_features`` does not use this m/z median for ion-role
3662          geometry; that median is detected features only.
3663        - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster
3664        - Sample count (sample_id_nunique) indicates cluster prevalence across samples
3665        - Filters applied by consensus_min_sample_fraction affect which clusters appear
3666
3667        See Also
3668        --------
3669        add_consensus_mass_features : Creates clusters before summarization
3670        fill_missing_cluster_features : Creates induced mass features via gap-filling
3671        plot_consensus_mz_features : Visualizes cluster summaries
3672        plot_mz_features_per_cluster : Shows cluster size distribution
3673        """
3674        # First check if there are minimum columns in the features dataframe
3675        if (
3676            self.mass_features_dataframe is None
3677            or len(self.mass_features_dataframe.columns) < 1
3678        ):
3679            return None
3680
3681        # Combine regular and induced mass features
3682        mf_df = self.mass_features_dataframe.copy()
3683        mf_df = mf_df.reset_index(drop=False)
3684
3685        # Check if induced mass features are available and combine them
3686        if (
3687            self.induced_mass_features_dataframe is not None
3688            and len(self.induced_mass_features_dataframe) > 0
3689        ):
3690            imf_df = self.induced_mass_features_dataframe.copy()
3691            imf_df = imf_df.reset_index(drop=False)
3692            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
3693            # Combine regular and induced features
3694            mf_df = pd.concat([mf_df, imf_df], axis=0)
3695            mf_df = mf_df.reset_index(drop=True)
3696
3697        # Filter out any rows with NaN cluster values before converting to int
3698        if "cluster" in mf_df.columns:
3699            mf_df = mf_df.dropna(subset=["cluster"])
3700            mf_df["cluster"] = mf_df["cluster"].astype(int)
3701
3702        # Build aggregation dictionary based on available columns
3703        agg_dict = {
3704            "mz": ["median", "mean", "std", "max", "min"],
3705            "scan_time_aligned": ["median", "mean", "std", "max", "min"],
3706            "sample_id": ["nunique"],
3707            "intensity": ["max", "median", "mean", "std", "min"],
3708        }
3709
3710        # Add optional columns if they exist
3711        optional_columns = {
3712            "half_height_width": ["median", "mean", "std", "max", "min"],
3713            "tailing_factor": ["median", "mean", "std", "max", "min"],
3714            "dispersity_index": ["median", "mean", "std", "max", "min"],
3715            "persistence": ["max", "median", "mean", "std", "min"],
3716        }
3717
3718        for col, funcs in optional_columns.items():
3719            if col in mf_df.columns:
3720                agg_dict[col] = funcs
3721
3722        summary_df = mf_df.groupby("cluster").agg(agg_dict).reset_index()
3723
3724        # Fix the column names
3725        summary_df.columns = [
3726            "_".join(col).strip()
3727            for col in summary_df.columns.values
3728            if col != "cluster"
3729        ]
3730        summary_df = summary_df.rename(columns={"cluster_": "cluster"})
3731        # Set cluster as the index for easy lookup
3732        summary_df = summary_df.set_index("cluster")
3733
3734        # Merge consensus feature-group labels when present (isotopes + adducts).
3735        # Cluster summary exposes possible_ion_types (all
3736        # residual-tied candidates) and drops ion_type so a single type column
3737        # is not mistaken for unique assignment.
3738        fg = getattr(self, "feature_group_dataframe", None)
3739        if fg is not None and len(fg) > 0:
3740            from corems.mass_spectra.calc.feature_grouping import (
3741                GROUP_COLUMNS,
3742                merge_possible_ion_types,
3743            )
3744
3745            cols = [c for c in GROUP_COLUMNS if c in fg.columns]
3746            if cols:
3747                drop_cols = [c for c in cols if c in summary_df.columns]
3748                if drop_cols:
3749                    summary_df = summary_df.drop(columns=drop_cols)
3750                # Also drop stale ion_type from a prior join shape
3751                if "ion_type" in summary_df.columns:
3752                    summary_df = summary_df.drop(columns=["ion_type"])
3753                summary_df = summary_df.join(fg[cols], how="left")
3754
3755            if (
3756                "possible_ion_types" in summary_df.columns
3757                and "ion_type" in summary_df.columns
3758            ):
3759                for cid in summary_df.index:
3760                    it = summary_df.loc[cid, "ion_type"]
3761                    poss = summary_df.loc[cid, "possible_ion_types"]
3762                    filled = merge_possible_ion_types(it, poss)
3763                    if filled is not None:
3764                        summary_df.loc[cid, "possible_ion_types"] = filled
3765            if "ion_type" in summary_df.columns:
3766                summary_df = summary_df.drop(columns=["ion_type"])
3767
3768        return summary_df
3769
3770    def group_consensus_features(self):
3771        """
3772        Group consensus features into natural-abundance isotope and adduct families.
3773
3774        After consensus clustering and gap-filling, labels clusters that share
3775        similar retention time, an interpretable **singly-charged** mass offset,
3776        and correlated cross-sample **apex peak heights**.
3777
3778        Two stages
3779        ----------
3780        1. **Isotopes.** Unit ``Atoms`` edges (e.g. ¹²C/¹³C) plus roll-up to
3781           ¹³C₂, ¹³C₃, … when intermediates exist. Spacing is the raw Atoms
3782           Δm (``|z| = 1``). Multi-charge envelopes are out of scope.
3783           Chemical mono is the geometry root, not the tallest peak.
3784        2. **Adducts.** Pairwise ``ion_type_dict`` forms in
3785           ``feature_group_ion_types`` (must be ``|z| = 1``). Empty or a
3786           single configured type skips adduct linking (isotopes only).
3787
3788        **Not** for tracer, enriched, or isotopically labeled experiments.
3789
3790        Quant gate is fixed (no settings switch):
3791
3792        - Correlation: **Pearson** only, pairwise-complete on samples with both
3793          heights > 0 (threshold ``feature_group_corr_threshold``, default 0.80)
3794        - Abundance: mass-feature apex ``intensity`` only (not integrated area)
3795
3796        Coelution and m/z delta windows reuse collection
3797        ``alignment_rt_tol`` and ``alignment_mz_tol_ppm``. Other knobs
3798        (``feature_group_isotope_atoms``, shared-sample fraction,
3799        ``feature_group_ion_types``, …) live on ``parameters.lcms_collection``.
3800
3801        ``feature_group_ion_types`` is filtered to the collection polarity
3802        (all samples share one polarity; mixed collections cannot be
3803        instantiated). Only keys ending in ``+`` are kept on positive data
3804        and only keys ending in ``-`` on negative data.
3805
3806        Preferred for collection-level families. Optional per-file
3807        ``LCMSBase.find_c13_mass_features`` remains available for single-file
3808        workflows; do not treat both as authoritative in the same pipeline.
3809
3810        Must run after ``add_consensus_mass_features()``. The m/z median used for
3811        isotope and adduct spacing is calculated from detected features only.
3812        Gap-filled m/z values stay on the induced rows and do not enter that
3813        median. The height matrix still includes gap-filled apex intensities
3814        when present; missing entries are 0.
3815
3816        Returns
3817        -------
3818        pandas.DataFrame
3819            Cluster-indexed labels with columns ``feature_group_id``,
3820            ``ion_role``, ``ion_type``, ``possible_ion_types``,
3821            ``isotope_state``, ``mono_cluster_id`` (set only when the form
3822            has at least one isotope). Also stored on
3823            ``self.feature_group_dataframe`` and merged into
3824            ``mass_features_dataframe`` / ``induced_mass_features_dataframe``.
3825
3826        Raises
3827        ------
3828        ValueError
3829            If consensus features are missing or settings are invalid.
3830        """
3831        from corems.mass_spectra.calc.feature_grouping import (
3832            GROUP_COLUMNS,
3833            HEIGHT_COL,
3834            FeatureGroupParams,
3835            build_height_matrix_from_features,
3836            detected_cluster_mz_median,
3837            group_features_arrays,
3838            normalize_ms_polarity,
3839        )
3840
3841        if (
3842            self.mass_features_dataframe is None
3843            or len(self.mass_features_dataframe) == 0
3844        ):
3845            raise ValueError(
3846                "No mass features dataframe. Run add_consensus_mass_features() first."
3847            )
3848        if "cluster" not in self.mass_features_dataframe.columns:
3849            raise ValueError(
3850                "mass_features_dataframe has no 'cluster' column. "
3851                "Run add_consensus_mass_features() first."
3852            )
3853
3854        # Clear prior labels (idempotent re-run)
3855        self.feature_group_dataframe = None
3856        for col in GROUP_COLUMNS:
3857            if col in self.mass_features_dataframe.columns:
3858                self.mass_features_dataframe = self.mass_features_dataframe.drop(
3859                    columns=[col]
3860                )
3861            if (
3862                self.induced_mass_features_dataframe is not None
3863                and col in self.induced_mass_features_dataframe.columns
3864            ):
3865                self.induced_mass_features_dataframe = (
3866                    self.induced_mass_features_dataframe.drop(columns=[col])
3867                )
3868
3869        summary = self.summarize_clusters()
3870        if summary is None or len(summary) == 0:
3871            raise ValueError(
3872                "cluster summary is empty. Run add_consensus_mass_features() first."
3873            )
3874
3875        # Collection polarity: all samples share one polarity (enforced when
3876        # LCMSCollection is loaded). Filter opposite-sign adducts.
3877        collection_polarity = None
3878        if len(self):
3879            collection_polarity = normalize_ms_polarity(
3880                getattr(self[0], "polarity", None)
3881            )
3882
3883        params = FeatureGroupParams.from_lcms_collection_settings(
3884            self.parameters.lcms_collection,
3885            polarity=collection_polarity,
3886        )
3887
3888        cluster_ids = summary.index.to_numpy()
3889        # Gap-filled apex m/z is not part of ion-role geometry.
3890        mz = detected_cluster_mz_median(self.mass_features_dataframe, cluster_ids)
3891        if "scan_time_aligned_median" not in summary.columns:
3892            raise ValueError(
3893                "cluster summary missing scan_time_aligned_median; "
3894                "ensure features are RT-aligned before consensus."
3895            )
3896        rt = summary["scan_time_aligned_median"].to_numpy(dtype=float)
3897
3898        # Sample ids as used in mass_features_dataframe
3899        sample_ids = sorted(self.mass_features_dataframe["sample_id"].dropna().unique())
3900        if self.induced_mass_features_dataframe is not None and len(
3901            self.induced_mass_features_dataframe
3902        ):
3903            sample_ids = sorted(
3904                set(sample_ids)
3905                | set(
3906                    self.induced_mass_features_dataframe["sample_id"]
3907                    .dropna()
3908                    .unique()
3909                    .tolist()
3910                )
3911            )
3912
3913        t_height = time.perf_counter()
3914        heights = build_height_matrix_from_features(
3915            self.mass_features_dataframe,
3916            cluster_ids=cluster_ids,
3917            sample_ids=sample_ids,
3918            induced_df=self.induced_mass_features_dataframe,
3919            intensity_col=HEIGHT_COL,  # apex intensity only; not area
3920        )
3921        stage_timings = {
3922            "height_matrix": time.perf_counter() - t_height,
3923            "n_ion_types_configured": float(len(params.ion_types)),
3924            "collection_polarity": (
3925                str(collection_polarity) if collection_polarity else "unknown"
3926            ),
3927        }
3928
3929        algo_timings: dict = {}
3930        labels = group_features_arrays(
3931            cluster_ids, mz, rt, heights, params, timings_out=algo_timings
3932        )
3933        stage_timings.update(algo_timings)
3934        self._last_feature_group_timings = stage_timings
3935        self.feature_group_dataframe = labels
3936
3937        from corems.mass_spectra.calc.feature_grouping import (
3938            merge_feature_group_labels_into_frames,
3939        )
3940
3941        self.mass_features_dataframe, induced = merge_feature_group_labels_into_frames(
3942            labels,
3943            self.mass_features_dataframe,
3944            self.induced_mass_features_dataframe,
3945        )
3946        if self.induced_mass_features_dataframe is not None:
3947            self.induced_mass_features_dataframe = induced
3948
3949        return labels
3950
3951    def plot_mz_features_per_cluster(self, return_fig=False, path=None):
3952        """
3953        Plot the number of mass features in a cluster against how many clusters
3954        contain that number of mass features
3955
3956        Parameters
3957        -----------
3958        return_fig : bool, optional
3959            If True, return the open figure (caller owns lifecycle).
3960            Default is False.
3961        path : str or path-like, optional
3962            If set, save the figure to this path. When ``return_fig`` is False,
3963            the figure is closed after saving and ``plt.show()`` is not called.
3964
3965        Returns
3966        --------
3967        matplotlib.figure.Figure or None
3968            The figure if ``return_fig`` is True; otherwise None.
3969
3970        Raises
3971        ------
3972        ValueError
3973            If consensus features haven't been added to the object yet
3974        """
3975
3976        if not hasattr(self, "cluster_summary_dataframe"):
3977            raise ValueError(
3978                "cluster_summary_dataframe is not set, must run add_consensus_mass_features() first"
3979            )
3980        sum_data = self.cluster_summary_dataframe
3981        fig, ax = plt.subplots()
3982        sum_data.sample_id_nunique.value_counts().sort_index().plot(ax=ax, kind="bar")
3983        plt.xlabel("Number of mass features in a cluster")
3984        plt.ylabel("Number of clusters with this many mass features")
3985        return _finalize_plot(fig, return_fig=return_fig, path=path)
3986
3987    def plot_mz_features_across_samples(
3988        self, alpha=0.75, s=0.005, return_fig=False, path=None
3989    ):
3990        """
3991        Generate Scan Time vs m/z plot of all the mass features across all
3992        samples in collection where intensity of color on the plot indicates
3993        density of mass features, NOT INTENSITY
3994
3995        Parameters
3996        -----------
3997        alpha :  float
3998            Desired transparency for plotted m/z features.  Defaults to 0.75.
3999        s : float
4000            Desired size of plotted m/z features. Defaults to 0.005.
4001        return_fig : bool, optional
4002            If True, return the open figure (caller owns lifecycle).
4003            Default is False.
4004        path : str or path-like, optional
4005            If set, save the figure to this path. When ``return_fig`` is False,
4006            the figure is closed after saving and ``plt.show()`` is not called.
4007
4008        Returns
4009        --------
4010        matplotlib.figure.Figure or None
4011            A figure displaying a scan time vs m/z scatterplot of all the m/z
4012            features identified in the collection if ``return_fig`` is True;
4013            otherwise None. Parameters alpha (transparency) and s (marker size)
4014            allow the user to emphasize the density of features. Intensity of
4015            features is not represented.
4016        """
4017        df = self.mass_features_dataframe.copy()
4018        fig = plt.figure()
4019        plt.scatter(df.scan_time_aligned, df.mz, c="tab:gray", alpha=alpha, s=s)
4020
4021        plt.xlabel("Scan time")
4022        plt.ylabel("m/z")
4023        plt.ylim(0, np.ceil(np.max(df.mz)))
4024        plt.xlim(0, np.ceil(np.max(df.scan_time)))
4025        plt.title("All mass features, all samples")
4026
4027        return _finalize_plot(fig, return_fig=return_fig, path=path)
4028
4029    def plot_consensus_mz_features(
4030        self,
4031        xb="xb",
4032        xt="xt",
4033        yb="yb",
4034        yt="yt",
4035        show_all=True,
4036        return_fig=False,
4037        path=None,
4038    ):
4039        """
4040        Generate Scan Time vs m/z plot of the consensus features scaled by size
4041        with option ('show_all') of leaving the individual m/z features in the figure.
4042
4043        Parameters
4044        -----------
4045        xb :  float
4046            Desired starting scan time value for the x-axis. Defaults to 0.
4047        xt : float
4048            Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data.
4049        yb :  float
4050            Desired starting m/z value for the y-axis. Defaults to 0.
4051        yt : float
4052            Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data.
4053        show_all : boolean
4054            Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True.
4055        return_fig : bool, optional
4056            If True, return the open figure (caller owns lifecycle).
4057            Default is False.
4058        path : str or path-like, optional
4059            If set, save the figure to this path. When ``return_fig`` is False,
4060            the figure is closed after saving and ``plt.show()`` is not called.
4061
4062        Returns
4063        --------
4064        matplotlib.figure.Figure or None
4065            A scalable figure that overlays the consensus features over all the
4066            m/z features identified in the collection if ``return_fig`` is True;
4067            otherwise None. Consensus features are scaled by how many m/z
4068            features are represented in the consensus. Figure can be scaled by
4069            inputting desired boundaries on the scan time (xb, xt) and m/z
4070            values (yb, yt).
4071        """
4072        df = self.cluster_summary_dataframe.copy()
4073        mfdf = self.mass_features_dataframe.copy()
4074
4075        fig = plt.figure()
4076        if show_all:
4077            plt.scatter(mfdf.scan_time_aligned, mfdf.mz, c="tab:gray", s=1)
4078
4079        m = plt.scatter(
4080            df.scan_time_aligned_median,
4081            df.mz_median,
4082            c="tab:orange",
4083            alpha=0.7,
4084            s=(df.sample_id_nunique**2) / 5,
4085        )
4086
4087        plt.xlabel("Scan time")
4088        plt.ylabel("m/z")
4089
4090        if xt == "xt":
4091            xt = np.ceil(np.max(mfdf.mz))
4092        if yt == "yt":
4093            yt = np.ceil(np.max(mfdf.scan_time))
4094        if xb == "xb":
4095            xb = 0
4096        if yb == "yb":
4097            yb = 0
4098        plt.ylim(xb, xt)
4099        plt.xlim(yb, yt)
4100
4101        kw = dict(
4102            prop="sizes",
4103            num=max(1, int(len(df.sample_id_nunique.unique()) / 3)),
4104            color="tab:orange",
4105            alpha=0.7,
4106            func=lambda s: np.sqrt(s * 5),
4107        )
4108
4109        plt.legend(
4110            *m.legend_elements(**kw),
4111            title="Features\nper cluster",
4112            bbox_to_anchor=(1.01, 0.4, 0.225, 0.5),
4113        )
4114        plt.tight_layout()
4115        plt.title("Consensus Features")
4116
4117        return _finalize_plot(fig, return_fig=return_fig, path=path)
4118
4119    def plot_cluster(
4120        self,
4121        cluster_id,
4122        to_plot=["EIC", "MS1", "MS2"],
4123        return_fig=False,
4124        path=None,
4125        plot_smoothed_eic=False,
4126        plot_eic_datapoints=False,
4127        eic_buffer_time=None,
4128        label_samples=False,
4129        molecular_metadata=None,
4130        spectral_library=None,
4131    ):
4132        """
4133        Plot a consensus mass feature cluster across all samples.
4134
4135        Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster,
4136        highlighting the representative mass feature.
4137
4138        Parameters
4139        ----------
4140        cluster_id : int
4141            The cluster ID to plot
4142        to_plot : list, optional
4143            List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror".
4144            Default is ["EIC", "MS1", "MS2"].
4145        return_fig : bool, optional
4146            If True, return the open figure (caller owns lifecycle).
4147            Default is False.
4148        path : str or path-like, optional
4149            If set, save the figure to this path. When ``return_fig`` is False,
4150            the figure is closed after saving and ``plt.show()`` is not called.
4151        plot_smoothed_eic : bool, optional
4152            If True, plots smoothed EICs. Default is False.
4153        plot_eic_datapoints : bool, optional
4154            If True, plots EIC data points. Default is False.
4155        eic_buffer_time : float, optional
4156            Time buffer around the peak for EIC plotting (minutes).
4157            If None, uses parameter setting. Default is None.
4158        label_samples : bool, optional
4159            If True, labels each sample in the legend. Default is False.
4160        molecular_metadata : dict, optional
4161            Dictionary mapping molecular IDs to MetaboliteMetadata objects.
4162            Required for MS2_mirror plots. Default is None.
4163        spectral_library : FlashEntropySearch, optional
4164            FlashEntropy spectral library containing MS2 spectra.
4165            Required for MS2_mirror plots to retrieve library spectra. Default is None.
4166
4167        Returns
4168        -------
4169        matplotlib.figure.Figure or None
4170            The figure object if return_fig=True, otherwise None
4171
4172        Raises
4173        ------
4174        ValueError
4175            If cluster_id is not found or if required data is not loaded
4176        """
4177        import matplotlib.pyplot as plt
4178
4179        # Get cluster summary for median values
4180        if cluster_id not in self.cluster_summary_dataframe.index:
4181            raise ValueError(
4182                f"Cluster {cluster_id} not found in cluster_summary_dataframe. "
4183                f"Run add_consensus_mass_features() first."
4184            )
4185
4186        cluster_summary = self.cluster_summary_dataframe.loc[cluster_id]
4187
4188        # Get representative mass feature info
4189        rep_info = self.get_most_representative_sample_for_cluster(cluster_id)
4190        rep_sample_id = rep_info["sample_id"]
4191        rep_mf_id = rep_info["mf_id"]
4192        rep_sample = self[rep_sample_id]
4193
4194        # Check if representative mass feature is loaded
4195        if rep_mf_id not in rep_sample.mass_features:
4196            raise ValueError(
4197                f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. "
4198                f"Run reload_representative_mass_features() or process_consensus_features() first."
4199            )
4200
4201        rep_mf = rep_sample.mass_features[rep_mf_id]
4202
4203        # Get eic buffer time
4204        if eic_buffer_time is None:
4205            eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time
4206
4207        # Adjust to_plot based on available data
4208        if rep_mf.mass_spectrum is None:
4209            to_plot = [x for x in to_plot if x != "MS1"]
4210        if len(rep_mf.ms2_mass_spectra) == 0:
4211            to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]]
4212
4213        # Check if EICs are available
4214        cluster_mfs = self.mass_features_dataframe[
4215            self.mass_features_dataframe["cluster"] == cluster_id
4216        ]
4217
4218        has_eics = False
4219        # Check regular features
4220        for _, row in cluster_mfs.iterrows():
4221            sample_id = int(row["sample_id"])
4222            sample = self[sample_id]
4223            if hasattr(sample, "eics") and sample.eics:
4224                if len(sample.eics) > 0:
4225                    has_eics = True
4226                    break
4227
4228        # Also check induced features if available
4229        induced_cluster_mfs = None
4230        if not has_eics and self.induced_mass_features_dataframe is not None:
4231            induced_cluster_mfs = self.induced_mass_features_dataframe[
4232                self.induced_mass_features_dataframe["cluster"] == cluster_id
4233            ]
4234            for _, row in induced_cluster_mfs.iterrows():
4235                sample_id = int(row["sample_id"])
4236                sample = self[sample_id]
4237                if hasattr(sample, "eics") and sample.eics:
4238                    if len(sample.eics) > 0:
4239                        has_eics = True
4240                        break
4241
4242        if not has_eics:
4243            to_plot = [x for x in to_plot if x != "EIC"]
4244            if len(to_plot) == 0:
4245                raise ValueError(
4246                    f"No plottable data available for cluster {cluster_id}. "
4247                    f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first."
4248                )
4249
4250        # Get induced features if not already retrieved
4251        if (
4252            induced_cluster_mfs is None
4253            and self.induced_mass_features_dataframe is not None
4254        ):
4255            induced_cluster_mfs = self.induced_mass_features_dataframe[
4256                self.induced_mass_features_dataframe["cluster"] == cluster_id
4257            ]
4258
4259        # Check if MS1 is deconvoluted
4260        deconvoluted = rep_mf._ms_deconvoluted_idx is not None
4261
4262        # Create figure
4263        fig, axs = plt.subplots(
4264            len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False
4265        )
4266
4267        fig.suptitle(
4268            f"Consensus Cluster {cluster_id}: "
4269            f"m/z = {cluster_summary['mz_median']:.4f} "
4270            f"(±{cluster_summary['mz_std']:.4f}); "
4271            f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min "
4272            f"(±{cluster_summary['scan_time_aligned_std']:.2f}); "
4273            f"{int(cluster_summary['sample_id_nunique'])} samples"
4274        )
4275
4276        i = 0
4277
4278        # EIC plot - show all samples using helper method
4279        if "EIC" in to_plot:
4280            self._plot_multiple_eics(
4281                axs[i][0],
4282                cluster_mfs,
4283                induced_cluster_mfs,
4284                rep_sample_id,
4285                rep_mf_id,
4286                cluster_summary["scan_time_aligned_median"],
4287                eic_buffer_time,
4288                plot_smoothed=plot_smoothed_eic,
4289                plot_datapoints=plot_eic_datapoints,
4290                label_samples=label_samples,
4291                lcms_collection=self,
4292            )
4293            i += 1
4294
4295        # MS1 plot - from representative using helper method
4296        if "MS1" in to_plot:
4297            rep_mf._plot_ms1_spectrum(
4298                axs[i][0], deconvoluted=deconvoluted, sample_name=rep_sample.sample_name
4299            )
4300            i += 1
4301
4302        # MS2 plot - from representative using helper method
4303        if "MS2" in to_plot:
4304            rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name)
4305            i += 1
4306
4307        # MS2 mirror plot - from representative using helper method
4308        if "MS2_mirror" in to_plot:
4309            rep_mf._plot_ms2_mirror(
4310                axs[i][0],
4311                molecular_metadata=molecular_metadata,
4312                spectral_library=spectral_library,
4313            )
4314            i += 1
4315
4316        plt.tight_layout()
4317
4318        return _finalize_plot(fig, return_fig=return_fig, path=path)
4319
4320    def get_representative_mass_features_for_all_clusters(
4321        self, representative_metric=None
4322    ):
4323        """
4324        Get the most representative mass feature for all clusters in bulk.
4325
4326        This is much more efficient than calling get_most_representative_sample_for_cluster
4327        in a loop, as it processes all clusters in a single pass over the dataframe.
4328
4329        Parameters
4330        ----------
4331        representative_metric : str, optional
4332            The metric to use to determine the most representative sample.
4333            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4334            Options:
4335            - 'intensity': Selects the mass feature with the highest intensity
4336            - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans,
4337              or the highest intensity overall if none have MS2
4338            Default is None (uses parameter setting).
4339
4340        Returns
4341        -------
4342        :obj:`~pandas.DataFrame`
4343            DataFrame with one row per cluster containing:
4344            - cluster: cluster ID
4345            - sample_id: The sample ID of the most representative sample
4346            - mf_id: The mass feature ID in the sample
4347            - coll_mf_id: The collection-level mass feature ID (index)
4348            - has_ms2: Whether this mass feature has MS2 scan numbers
4349            - intensity: The intensity value of the representative mass feature
4350        """
4351        # Use default from parameters if not specified
4352        if representative_metric is None:
4353            representative_metric = (
4354                self.parameters.lcms_collection.consensus_representative_metric
4355            )
4356
4357        mf_df = self.mass_features_dataframe.copy()
4358        # Reset index to make coll_mf_id a column we can work with
4359        mf_df = mf_df.reset_index(drop=False)
4360
4361        # Handle special metric 'intensity_prefer_ms2'
4362        if representative_metric == "intensity_prefer_ms2":
4363            if "intensity" not in mf_df.columns:
4364                raise ValueError(
4365                    f"'intensity' column not found in mass_features_dataframe. "
4366                    f"Available columns: {mf_df.columns.tolist()}"
4367                )
4368
4369            # Add has_ms2 flag if ms2_scan_numbers column exists
4370            if "ms2_scan_numbers" in mf_df.columns:
4371
4372                def has_ms2_scans(val):
4373                    if val is None:
4374                        return False
4375                    try:
4376                        return len(val) > 0
4377                    except (TypeError, ValueError):
4378                        return False
4379
4380                mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans)
4381
4382                # Sort by has_ms2 (descending) then intensity (descending)
4383                # This ensures features with MS2 are preferred when intensities are equal
4384                mf_df = mf_df.sort_values(
4385                    ["has_ms2", "intensity"], ascending=[False, False]
4386                )
4387            else:
4388                mf_df["has_ms2"] = False
4389                mf_df = mf_df.sort_values("intensity", ascending=False)
4390
4391            # Group by cluster and take the first (highest intensity, preferring MS2)
4392            representatives = mf_df.groupby("cluster").first().reset_index()
4393
4394        else:
4395            # Standard metric - check if it exists
4396            if representative_metric not in mf_df.columns:
4397                raise ValueError(
4398                    f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}"
4399                )
4400
4401            # Add has_ms2 flag for consistency
4402            if "ms2_scan_numbers" in mf_df.columns:
4403
4404                def has_ms2_scans(val):
4405                    if val is None:
4406                        return False
4407                    try:
4408                        return len(val) > 0
4409                    except (TypeError, ValueError):
4410                        return False
4411
4412                mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans)
4413            else:
4414                mf_df["has_ms2"] = False
4415
4416            # Get the index of max value for each cluster
4417            idx = mf_df.groupby("cluster")[representative_metric].idxmax()
4418            representatives = mf_df.loc[idx].copy()
4419
4420        # Select only the columns we need
4421        result_cols = [
4422            "cluster",
4423            "sample_id",
4424            "mf_id",
4425            "coll_mf_id",
4426            "has_ms2",
4427            "intensity",
4428        ]
4429        representatives = representatives[result_cols]
4430
4431        return representatives
4432
4433    def get_sample_mf_map_for_representatives(
4434        self, representative_metric=None, include_cluster_id=True
4435    ):
4436        """
4437        Build a mapping of sample_id -> list of representative mass feature IDs to load.
4438
4439        This is a DRY helper method used by both process_consensus_features() and
4440        ReadSavedLCMSCollection to determine which mass features should be loaded
4441        for each sample when loading representatives.
4442
4443        Parameters
4444        ----------
4445        representative_metric : str, optional
4446            The metric to use to determine the most representative sample.
4447            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4448            Default is None.
4449        include_cluster_id : bool, optional
4450            If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id.
4451            Default is True.
4452
4453        Returns
4454        -------
4455        dict
4456            Dictionary mapping sample_id (int) to list of mass feature identifiers.
4457            If include_cluster_id=True: list of tuples (mf_id, cluster_id)
4458            If include_cluster_id=False: list of mf_id integers
4459
4460        Examples
4461        --------
4462        >>> # Get map with cluster IDs for loading
4463        >>> sample_mf_map = collection.get_sample_mf_map_for_representatives()
4464        >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...}
4465        >>>
4466        >>> # Get map without cluster IDs for pipeline
4467        >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False)
4468        >>> # sample_mf_map = {0: [123, 456], 1: [789], ...}
4469        """
4470        # Get all representative mass features in bulk (much faster than looping)
4471        representatives = self.get_representative_mass_features_for_all_clusters(
4472            representative_metric=representative_metric
4473        )
4474
4475        # Build sample_mf_map
4476        sample_mf_map = {}
4477        for _, row in representatives.iterrows():
4478            sample_id = row["sample_id"]
4479            mf_id = row["mf_id"]
4480            cluster_id = row["cluster"]
4481
4482            if sample_id not in sample_mf_map:
4483                sample_mf_map[sample_id] = []
4484
4485            if include_cluster_id:
4486                sample_mf_map[sample_id].append((mf_id, cluster_id))
4487            else:
4488                sample_mf_map[sample_id].append(mf_id)
4489
4490        return sample_mf_map
4491
4492    def get_most_representative_sample_for_cluster(
4493        self, cluster_id, representative_metric=None
4494    ):
4495        """
4496        Get the most representative sample for a given cluster based on a metric.
4497
4498        Parameters
4499        ----------
4500        cluster_id : int
4501            The cluster ID to find the representative sample for.
4502        representative_metric : str, optional
4503            The metric to use to determine the most representative sample.
4504            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4505            Options:
4506            - 'intensity': Selects the mass feature with the highest intensity
4507            - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans,
4508              or the highest intensity overall if none have MS2
4509            Default is None (uses parameter setting).
4510
4511        Returns
4512        -------
4513        dict
4514            Dictionary containing:
4515            - 'sample_id': The sample ID of the most representative sample
4516            - 'sample_name': The sample name of the most representative sample
4517            - 'mf_id': The mass feature ID in the sample
4518            - 'coll_mf_id': The collection-level mass feature ID (index)
4519            - 'has_ms2': Whether this mass feature has MS2 scan numbers
4520            - 'intensity': The intensity value of the representative mass feature
4521
4522        Raises
4523        ------
4524        ValueError
4525            If cluster_id is not found or if representative_metric is not a valid column.
4526        """
4527        # Use the bulk method to get all representatives, then filter to this cluster
4528        # This follows DRY principle and ensures consistency
4529        all_representatives = self.get_representative_mass_features_for_all_clusters(
4530            representative_metric=representative_metric
4531        )
4532
4533        # Filter to the requested cluster
4534        cluster_rep = all_representatives[all_representatives["cluster"] == cluster_id]
4535
4536        if len(cluster_rep) == 0:
4537            # Try to provide helpful error message
4538            available_clusters = self.mass_features_dataframe["cluster"].unique()
4539            raise ValueError(
4540                f"Cluster {cluster_id} not found in mass_features_dataframe. "
4541                f"Available clusters: {sorted(available_clusters[:10].tolist())}... "
4542                f"(showing first 10 of {len(available_clusters)} total clusters)"
4543            )
4544
4545        # Get the representative row (should only be one)
4546        rep_row = cluster_rep.iloc[0]
4547
4548        # Get sample name from sample_id (convert to int for list indexing)
4549        sample_id = int(rep_row["sample_id"])
4550        sample_name = self.samples[sample_id]
4551
4552        return {
4553            "sample_id": sample_id,
4554            "sample_name": sample_name,
4555            "mf_id": rep_row["mf_id"],
4556            "coll_mf_id": rep_row["coll_mf_id"],
4557            "has_ms2": rep_row["has_ms2"],
4558            "intensity": rep_row["intensity"],
4559        }
4560
4561    def reload_representative_mass_features(
4562        self,
4563        add_ms2=False,
4564        auto_process_ms2=True,
4565        ms2_spectrum_mode=None,
4566        ms2_scan_filter=None,
4567    ):
4568        """
4569        Reload mass features for all representative samples in the cluster summary.
4570
4571        This method is useful when the collection was loaded with load_light=True,
4572        which stores mass features only in the collection dataframe. This reloads
4573        the specific mass features that are representatives for each cluster,
4574        allowing them to be accessed as LCMSMassFeature objects.
4575
4576        Parameters
4577        ----------
4578        add_ms2 : bool, optional
4579            If True, also loads and associates MS2 spectra with mass features. Default is False.
4580        auto_process_ms2 : bool, optional
4581            If True and add_ms2=True, auto-processes MS2 spectra. Default is True.
4582        ms2_spectrum_mode : str or None, optional
4583            Spectrum mode for MS2 spectra. If None, determines from parser. Default is None.
4584        ms2_scan_filter : str or None, optional
4585            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
4586
4587        Returns
4588        -------
4589        dict
4590            Dictionary mapping sample_id to list of reloaded mf_ids.
4591
4592        Raises
4593        ------
4594        ValueError
4595            If cluster_summary_dataframe is not set (run add_consensus_mass_features first).
4596
4597        Notes
4598        -----
4599        - Only reloads mass features that are cluster representatives
4600        - Uses get_most_representative_sample_for_cluster() to determine which to reload
4601        - More memory-efficient than reloading all mass features
4602        - Parallelized based on lcms_collection.cores parameter
4603        - MS2 association uses same logic as add_associated_ms2_dda()
4604
4605        See Also
4606        --------
4607        _reload_sample_mass_features : Low-level method to reload specific mass features
4608        get_most_representative_sample_for_cluster : Gets representative sample for cluster
4609        """
4610        # Validate prerequisites
4611        if (
4612            not hasattr(self, "cluster_summary_dataframe")
4613            or self.cluster_summary_dataframe is None
4614        ):
4615            raise ValueError(
4616                "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first."
4617            )
4618
4619        # Get all representative mass features in bulk (much faster than looping)
4620        representatives = self.get_representative_mass_features_for_all_clusters()
4621
4622        # Build a dictionary of sample_id -> list of mf_ids that are representatives
4623        sample_mf_map = {}
4624        for _, row in representatives.iterrows():
4625            sample_id = row["sample_id"]
4626            mf_id = row["mf_id"]
4627
4628            if sample_id not in sample_mf_map:
4629                sample_mf_map[sample_id] = []
4630            sample_mf_map[sample_id].append(mf_id)
4631
4632        # Reload mass features for each sample (parallelized)
4633        if self.parameters.lcms_collection.cores == 1:
4634            # Serial processing
4635            from tqdm import tqdm
4636
4637            for sample_id in tqdm(
4638                sample_mf_map.keys(),
4639                desc="Reloading representative mass features",
4640                unit="sample",
4641            ):
4642                mf_ids = sample_mf_map[sample_id]
4643                self._reload_sample_mass_features(
4644                    sample_id,
4645                    mf_ids_to_load=mf_ids,
4646                    add_ms2=add_ms2,
4647                    auto_process_ms2=auto_process_ms2,
4648                    ms2_spectrum_mode=ms2_spectrum_mode,
4649                    ms2_scan_filter=ms2_scan_filter,
4650                )
4651        else:
4652            # Parallel processing
4653            from tqdm import tqdm
4654
4655            if self.parameters.lcms_collection.cores > len(sample_mf_map):
4656                ncores = len(sample_mf_map)
4657            else:
4658                ncores = self.parameters.lcms_collection.cores
4659
4660            # Build arguments list for starmap
4661            args_list = [
4662                (
4663                    sample_id,
4664                    sample_mf_map[sample_id],
4665                    add_ms2,
4666                    auto_process_ms2,
4667                    ms2_spectrum_mode,
4668                    ms2_scan_filter,
4669                    False,
4670                )
4671                for sample_id in sample_mf_map.keys()
4672            ]
4673
4674            # Execute in parallel
4675            with spawn_pool(ncores) as pool:
4676                mp_result = pool.starmap(self._reload_sample_mass_features, args_list)
4677
4678            # Collect results back into samples
4679            for i, sample_id in enumerate(
4680                tqdm(
4681                    sample_mf_map.keys(),
4682                    desc="Collecting reloaded mass features",
4683                    unit="sample",
4684                )
4685            ):
4686                self[sample_id].mass_features = mp_result[i]
4687
4688        return sample_mf_map
4689
4690    def _associate_ms2_with_mass_features(
4691        self,
4692        sample,
4693        local_mf_ids,
4694        auto_process=True,
4695        spectrum_mode=None,
4696        scan_filter=None,
4697    ):
4698        """
4699        Associate MS2 spectra with specific mass features in a sample.
4700
4701        Uses the LCMSBase helper method to find and load MS2 scans for the specified mass features.
4702
4703        Parameters
4704        ----------
4705        sample : LCMSBase
4706            The sample object containing mass features and scan data.
4707        local_mf_ids : list of int
4708            List of local (sample-level) mass feature IDs to find MS2 for.
4709        auto_process : bool, optional
4710            If True, auto-processes the MS2 spectra. Default is True.
4711        spectrum_mode : str or None, optional
4712            Spectrum mode for MS2 spectra. If None, determines from parser. Default is None.
4713        scan_filter : str or None, optional
4714            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
4715
4716        Returns
4717        -------
4718        dict
4719            Dictionary of scan_number -> MassSpectrum objects for the loaded MS2 spectra.
4720        """
4721        # Check if we have scan data
4722        if not hasattr(sample, "scan_df") or sample.scan_df is None:
4723            return {}
4724
4725        # Separate mass features into those that need scan finding vs those that already have scans
4726        mfs_needing_scan_finding = []
4727        unique_dda_scans = set()
4728
4729        for mf_id in local_mf_ids:
4730            if mf_id not in sample.mass_features:
4731                continue
4732            mf = sample.mass_features[mf_id]
4733            # If this mass feature already has MS2 scans, add them to our set
4734            if mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0:
4735                # Convert to integers in case they come from HDF5 as numpy types
4736                unique_dda_scans.update([int(scan) for scan in mf.ms2_scan_numbers])
4737            else:
4738                # Otherwise, we need to find scans for this mass feature
4739                mfs_needing_scan_finding.append(mf_id)
4740
4741        # Only run the scan finding for mass features that need it
4742        if mfs_needing_scan_finding:
4743            found_scans = sample._find_ms2_scans_for_mass_features(
4744                mf_ids=mfs_needing_scan_finding, scan_filter=scan_filter
4745            )
4746            unique_dda_scans.update(found_scans)
4747
4748        if len(unique_dda_scans) == 0:
4749            return {}
4750
4751        # Get ms2 parameters from sample
4752        # TODO KRH: deal with different ms2 scan types here (CID vs HCD), may need to add scan translator to the initializeion
4753        ms_params = sample.parameters.mass_spectrum["ms2"]
4754
4755        # Load MS2 spectra (convert set to list)
4756        sample.add_mass_spectra(
4757            scan_list=list(unique_dda_scans),
4758            auto_process=auto_process,
4759            spectrum_mode=spectrum_mode,
4760            ms_level=2,
4761            use_parser=True,
4762            ms_params=ms_params,
4763        )
4764
4765        # Associate MS2 spectra with mass features
4766        for mf_id in local_mf_ids:
4767            if mf_id not in sample.mass_features:
4768                continue
4769            if (
4770                sample.mass_features[mf_id].ms2_scan_numbers is not None
4771                and len(sample.mass_features[mf_id].ms2_scan_numbers) > 0
4772            ):
4773                for dda_scan in sample.mass_features[mf_id].ms2_scan_numbers:
4774                    if dda_scan in sample._ms:
4775                        sample.mass_features[mf_id].ms2_mass_spectra[dda_scan] = (
4776                            sample._ms[dda_scan]
4777                        )
4778
4779        # Return only the MS2 spectra we loaded (for parallel processing)
4780        return {
4781            scan: sample._ms[scan] for scan in unique_dda_scans if scan in sample._ms
4782        }
4783
4784    def _reload_sample_mass_features(
4785        self,
4786        sample_id,
4787        mf_ids_to_load=None,
4788        add_ms2=False,
4789        auto_process_ms2=True,
4790        ms2_spectrum_mode=None,
4791        ms2_scan_filter=None,
4792        inplace=True,
4793    ):
4794        """
4795        Reload specific mass features for a sample from HDF5.
4796
4797        This is useful when the collection was loaded with load_light=True,
4798        which stores mass features only in the collection dataframe and not
4799        as LCMSMassFeature objects in individual samples.
4800
4801        Parameters
4802        ----------
4803        sample_id : int
4804            The sample ID to reload mass features for.
4805        mf_ids_to_load : list of str, optional
4806            List of collection-level mf_ids (format: '{sample_id}_{local_mf_id}') to load.
4807            If None, loads all mass features for the sample.
4808        add_ms2 : bool, optional
4809            If True, also loads and associates MS2 spectra. Default is False.
4810        auto_process_ms2 : bool, optional
4811            If True, auto-processes MS2 spectra. Default is True.
4812        ms2_spectrum_mode : str or None, optional
4813            Spectrum mode for MS2 spectra. Default is None.
4814        ms2_scan_filter : str or None, optional
4815            Filter string for MS2 scans. Default is None.
4816        inplace : bool, optional
4817            If True, updates the sample's mass_features in place. If False, returns the
4818            mass_features dictionary (for multiprocessing). Default is True.
4819
4820        Returns
4821        -------
4822        dict or None
4823            If inplace=False, returns dictionary of mass features.
4824            Otherwise returns None and updates object in place.
4825        """
4826        sample = self[sample_id]
4827        sample_name = self.samples[sample_id]
4828
4829        # Check if we have a collection parser that can reload
4830        if not hasattr(self, "collection_parser") or self.collection_parser is None:
4831            print(
4832                "Warning: Cannot reload mass features - no collection_parser available"
4833            )
4834            if not inplace:
4835                return {}
4836            return
4837
4838        # Get the HDF5 file for this sample
4839        hdf5_file = (
4840            self.collection_parser.folder_location
4841            / f"{sample_name}.corems/{sample_name}.hdf5"
4842        )
4843
4844        if not hdf5_file.exists():
4845            print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}")
4846            if not inplace:
4847                return {}
4848            return
4849
4850        # Import here to avoid circular imports
4851        from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra
4852
4853        # If specific mf_ids requested, use them directly
4854        local_mf_ids_to_load = None
4855        if mf_ids_to_load is not None:
4856            # mf_ids_to_load is already a list of sample-level mf_ids (integers)
4857            # No parsing needed - they come from the mf_id column in the dataframe
4858            local_mf_ids_to_load = set(mf_ids_to_load)
4859
4860        # Reload mass features from HDF5
4861        with ReadCoreMSHDFMassSpectra(hdf5_file) as parser:
4862            # Load mass features - if specific IDs requested, only load those
4863            parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load)
4864
4865        # If add_ms2, associate MS2 spectra with the loaded mass features
4866        if add_ms2 and local_mf_ids_to_load is not None:
4867            self._associate_ms2_with_mass_features(
4868                sample,
4869                list(local_mf_ids_to_load),
4870                auto_process=auto_process_ms2,
4871                spectrum_mode=ms2_spectrum_mode,
4872                scan_filter=ms2_scan_filter,
4873            )
4874
4875        # Return mass features if not inplace (for multiprocessing)
4876        if not inplace:
4877            return sample.mass_features
4878
4879    def add_sparse_distance_matrix(self, features):
4880        if features is None:
4881            return None
4882        else:
4883            features = features.copy()
4884
4885        # Parameters for calculating distance between features
4886        dims = ["mz", "scan_time_aligned"]
4887        relative = [True, False]
4888        mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
4889        tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol]
4890        dist_weight = [1, 1]
4891
4892        # Check that the dimensions and tolerances are the same length
4893        if (
4894            len(dims) != len(tol)
4895            or len(dims) != len(relative)
4896            or len(dims) != len(dist_weight)
4897        ):
4898            raise ValueError(
4899                "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length"
4900            )
4901
4902        # Make connectivity matrix for masking within sample mass features
4903        ## Masking matrix cmat will mark all features from the same sample as 0
4904        ## To mask, a matrix can be multiplied by cmat and features from same
4905        ## samples are multiplied by 0, while features from different samples
4906        ## are multiplied by 1
4907
4908        if "sample_id" not in features.columns:
4909            cmat = None
4910        else:
4911            vals = features["sample_id"].values.reshape(-1, 1)
4912            cmat = scipy.spatial.distance.cdist(vals, vals)
4913            # Convert to binary (0 if same sample, 1 if different)
4914            cmat = np.where(cmat == 0, 0, 1)
4915            # Convert to coordinate matrix for sparse operations later
4916            cmat = sparse.coo_matrix(cmat)
4917
4918        # Compute inter-feature distances using sparse matrix approach
4919        distances = None  # clear the distances object before starting
4920        for i in range(len(dims)):  # iterate through all dimensions to be considered
4921            # Construct k-d tree
4922            values = features[dims[i]].values
4923
4924            tree = KDTree(values.reshape(-1, 1))
4925
4926            max_tol = tol[i]
4927            if relative[i] is True:
4928                # Maximum absolute tolerance
4929                max_tol = tol[i] * values.max()
4930
4931            # Compute sparse distance matrix
4932            # the larger the max_tol, the slower this operation is
4933            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
4934
4935            # Only consider forward case, exclude diagonal
4936            sdm = sparse.triu(sdm, k=1)
4937
4938            # Filter relative distances
4939            if relative[i] is True:
4940                # Compute relative distances
4941                rel_dists = sdm.data / values[sdm.row]
4942
4943                # Indices of relative distances less than tolerance
4944                idx = rel_dists <= tol[i]
4945
4946                # Reconstruct sparse distance matrix
4947                sdm = sparse.coo_matrix(
4948                    (rel_dists[idx], (sdm.row[idx], sdm.col[idx])),
4949                    shape=(len(values), len(values)),
4950                )
4951
4952            # Scaled distances wrt the maximum tolerance for the dimension
4953            sdm.data = sdm.data / tol[i]
4954
4955            # Stack distances for dimensions where na_allow is False
4956            if distances is None:
4957                sdm.data = sdm.data * dist_weight[i]
4958                # Replace zeros with epsilon to handle perfect matches
4959                sdm.data[sdm.data == 0] = 1e-10
4960                distances = sdm
4961            else:
4962                # Prepare sdm to match shape of existing distances
4963                distances_truth = distances.copy()
4964                # make new sparse matrix with same positions as previous
4965                # distance matrix but all ones for values
4966                distances_truth.data = np.ones_like(distances_truth.data)
4967
4968                # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them
4969                sdm.data[sdm.data == 0] = 1e-10
4970
4971                # multiply the new sparse matrix (sdm) by this mask to remove
4972                # data that doesn't exist in original sparse matrix
4973                sdm = distances_truth.multiply(sdm)
4974
4975                sdm.data = sdm.data * dist_weight[i]
4976                # Replace zeros with epsilon to handle perfect matches
4977                sdm.data[sdm.data == 0] = 1e-10
4978
4979                # use same process as before to remove data from previous
4980                # distances matrix that isn't in new distances matrix
4981                sdm_truth = sdm.copy()
4982                sdm_truth.data = np.ones_like(sdm_truth.data)
4983
4984                # remove the distances that are not sdm
4985                distances = distances.multiply(sdm_truth)
4986
4987                # Sum the new distances
4988                distances = distances + sdm
4989
4990        # Multiply by connectivity matrix for more masking
4991        distances = distances.multiply(cmat)
4992
4993        # Set attribute holding distance matrix
4994        self._sparse_distance_matrix = distances
4995
4996    def evaluate_clusters_for_repeats(self, features):
4997        raise NotImplementedError("evaluate_clusters_for_repeats not implemented yet")
4998        summary_df = self.cluster_summary_dataframe.copy()
4999
5000        # Arrange by decreasing median intensity
5001        summary_df = summary_df.sort_values(
5002            by="intensity_median", ascending=False
5003        ).reset_index(drop=True)
5004
5005        # Find clusters that are within the mz_tol and rt_tol of each other (on the medians)
5006        # Create a distance matrix
5007        # Define how to calculate the distance between features
5008        dims = ["mz_median", "scan_time_aligned_median"]
5009        relative = [True, False]
5010        mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5011        tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol]
5012
5013        # Compute inter-feature distances
5014        distances = None
5015        for i in range(len(dims)):
5016            # Construct k-d tree
5017            values = summary_df[dims[i]].values
5018            tree = KDTree(values.reshape(-1, 1))
5019
5020            max_tol = tol[i]
5021            if relative[i] is True:
5022                # Maximum absolute tolerance
5023                max_tol = tol[i] * values.max()
5024
5025            # Compute sparse distance matrix
5026            # the larger the max_tol, the slower this operation is
5027            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
5028
5029            # Only consider forward case, exclude diagonal
5030            sdm = sparse.triu(sdm, k=1)
5031
5032            # Filter relative distances
5033            if relative[i] is True:
5034                # Compute relative distances
5035                rel_dists = sdm.data / values[sdm.row]  # or col?
5036
5037                # Indices of relative distances less than tolerance
5038                idx = rel_dists <= tol[i]
5039
5040                # Reconstruct sparse distance matrix
5041                sdm = sparse.coo_matrix(
5042                    (rel_dists[idx], (sdm.row[idx], sdm.col[idx])),
5043                    shape=(len(values), len(values)),
5044                )
5045
5046            # Cast as binary matrix
5047            sdm.data = np.ones_like(sdm.data)
5048
5049            # Stack distances
5050            if distances is None:
5051                distances = sdm
5052            else:
5053                distances = distances.multiply(sdm)
5054
5055        # Roll up features
5056        # Extract indices of within-tolerance points
5057        distances = distances.tocoo()
5058        pairs = np.stack(
5059            (distances.row, distances.col), axis=1
5060        )  # These are the index values of the clusters, not the cluster ids
5061        # Conver to cluster ids
5062        pairs_df = pd.DataFrame(pairs, columns=["parent", "child"])
5063        pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values
5064        pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values
5065        pairs_df = pairs_df.set_index("parent")
5066
5067        merge_these_clusters = []
5068        possible_overlaps = []
5069        root_parents = np.setdiff1d(
5070            np.unique(pairs_df.index.values), np.unique(pairs_df.child.values)
5071        )
5072        for parent in root_parents:
5073            parent_features = features[features["cluster"] == parent]
5074            children = pairs_df.loc[[parent], "child"].tolist()
5075            for child in children:
5076                overlap = self.check_merge(parent_features, child, features)
5077                if len(overlap) == 0:
5078                    merge_these_clusters.append((parent, child, len(overlap)))
5079                else:
5080                    possible_overlaps.append((parent, child, len(overlap)))
5081
5082        result_dict = {}
5083        result_dict["merge_these_clusters"] = merge_these_clusters
5084        result_dict["possible_overlaps"] = possible_overlaps
5085
5086        return result_dict
5087
5088    def check_merge(self, parent_features, child, features):
5089        # Grab the features of the parent and children
5090        child_features = features[features["cluster"] == child]
5091
5092        # Check if there is an overlap between mf_coll_id in the parent and child clusters
5093        overlap = np.intersect1d(
5094            parent_features["sample_id"].values, child_features["sample_id"].values
5095        )
5096
5097        return overlap
5098
5099    def cluster_mass_features_agg_cluster(self, features):
5100        if features is None:
5101            return None
5102
5103        features = features.copy()
5104
5105        self.add_sparse_distance_matrix(features)
5106
5107        distances = self._sparse_distance_matrix
5108
5109        # Convert to full matrix
5110        distances = distances.todense()
5111
5112        # Cast all 0s to 1s for a distance matrix
5113        distances[distances == 0] = 1
5114        distances = np.asarray(distances)
5115
5116        # Perform clustering
5117        try:
5118            clustering = AgglomerativeClustering(
5119                n_clusters=None,
5120                linkage="complete",
5121                # using complete linkage will prevent one sample from being assigned to multiple clusters
5122                metric="precomputed",
5123                distance_threshold=1,
5124            ).fit(distances)
5125            features["cluster"] = clustering.labels_
5126
5127        # All data points are singleton clusters
5128        except:
5129            features["cluster"] = np.arange(len(features.index))
5130
5131        return features
5132
5133    def cluster_inspection_plot(self, clu, return_fig=False, path=None):
5134        """
5135        Generate Scan Time vs m/z plot for a narrow range around a given
5136        cluster. This tool is meant to support the user in fine tuning the
5137        tolerances used for the clustering algorithm. The user-provided cluster
5138        ID is highlighted in larger, magenta marker and the ten largest of the
5139        remaining clusters are idenfitied with different colors while the
5140        smallest clusters are light gray.
5141
5142        Parameters
5143        -----------
5144        clu :  integer
5145            A cluster ID that exists in self.mass_features_dataframe
5146        return_fig : bool, optional
5147            If True, return the open figure (caller owns lifecycle).
5148            Default is False.
5149        path : str or path-like, optional
5150            If set, save the figure to this path. When ``return_fig`` is False,
5151            the figure is closed after saving and ``plt.show()`` is not called.
5152
5153        Returns
5154        --------
5155        matplotlib.figure.Figure or None
5156            A figure displaying a scan time vs m/z scatterplot of small region
5157            around a given cluster with the ten largest clusters in the region
5158            distinctly identified if ``return_fig`` is True; otherwise None.
5159
5160        Raises
5161        ------
5162        ValueError
5163            If cluster data haven't been added to the object yet
5164        """
5165
5166        if "cluster" not in self.mass_features_dataframe.columns:
5167            raise ValueError(
5168                "Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first"
5169            )
5170
5171        else:
5172            mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5173            rttol = self.parameters.lcms_collection.consensus_rt_tol
5174            clu_features = self.mass_features_dataframe.copy()
5175
5176            inclu = clu_features[clu_features.cluster == clu]
5177            exclu = clu_features[clu_features.cluster != clu]
5178
5179            dt_ymin = np.floor(min(inclu.mz)) - 1
5180            dt_ymax = np.ceil(max(inclu.mz)) + 1
5181            dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1
5182            dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1
5183
5184            exclu = exclu[
5185                (exclu.mz.between(dt_ymin, dt_ymax, inclusive="both"))
5186                & (exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive="both"))
5187            ]
5188
5189            bigclulist = list(exclu.cluster.value_counts()[:10].index)
5190            bigclu = exclu[exclu.cluster.isin(bigclulist)]
5191            smclu = exclu[~exclu.cluster.isin(bigclulist)]
5192
5193            colors = np.arange(0, 10)
5194            colordict = dict(zip(bigclulist, colors))
5195            bigclu["color"] = bigclu.cluster.apply(lambda x: colordict[x])
5196
5197            fig = plt.figure(figsize=(7.5, 5))
5198
5199            plt.scatter(
5200                inclu.scan_time_aligned,
5201                inclu.mz,
5202                c="m",
5203                s=3,
5204                label="Cluster " + str(clu),
5205            )
5206
5207            plt.scatter(
5208                bigclu.scan_time_aligned, bigclu.mz, c=bigclu.color, cmap="tab10", s=1.5
5209            )
5210
5211            plt.scatter(
5212                smclu.scan_time_aligned,
5213                smclu.mz,
5214                c="silver",
5215                s=2,
5216                label="Small clusters",
5217            )
5218
5219            plt.ylim(dt_ymin, dt_ymax)
5220            plt.xlim(dt_xmin, dt_xmax)
5221            plt.legend(ncol=2, bbox_to_anchor=(0.8, -0.1))
5222            plt.xlabel("Scan time")
5223            plt.ylabel("m/z")
5224            title_str = "Cluster " + str(clu)
5225            title_str += ": representing " + str(len(inclu.sample_id.unique()))
5226            title_str += " of " + str(len(clu_features.sample_id.unique()))
5227            title_str += " samples\n"
5228            title_str += "M/Z tolerance: " + str(mztol) + "\n"
5229            title_str += "Scan Time tolerance: " + str(rttol)
5230            plt.title(title_str, fontsize=10)
5231
5232            return _finalize_plot(fig, return_fig=return_fig, path=path)
5233
5234    def plot_cluster_outlier_frequency(
5235        self,
5236        dim_list=["mz", "scan_time_aligned"],
5237        clu_size_thresh=0.5,
5238        return_fig=False,
5239        path=None,
5240    ):
5241        """
5242        Generate histogram showing the frequency of outlier occurrences by
5243        clustering dimension across all clusters
5244
5245        Parameters
5246        -----------
5247        dim_list :  list
5248            List of strings describing dimensions that can be used in
5249            clustering. Available list items:
5250                - 'mz'
5251                - 'scan_time_aligned'
5252                - 'half_height_width'
5253                - 'tailing_factor'
5254                - 'dispersity_index'
5255                - 'intensity'
5256                - 'persistence'
5257        clu_size_thresh : float
5258            Value between 0 and 1 that indicates what percentage of samples
5259            need to be present in a cluster before it's evaluated for outliers.
5260            Defaults to 0.5.
5261        return_fig : bool, optional
5262            If True, return the open figure (caller owns lifecycle).
5263            Default is False.
5264        path : str or path-like, optional
5265            If set, save the figure to this path. When ``return_fig`` is False,
5266            the figure is closed after saving and ``plt.show()`` is not called.
5267
5268        Returns
5269        --------
5270        matplotlib.figure.Figure or None
5271            A figure displaying the frequency of outlier occurrences across all
5272            clusters in the provided measurement dimensions if ``return_fig`` is
5273            True; otherwise None.
5274
5275        Raises
5276        ------
5277        ValueError
5278            If cluster data haven't been added to the object yet
5279        """
5280
5281        if not hasattr(self, "cluster_summary_dataframe"):
5282            raise ValueError(
5283                "cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first"
5284            )
5285
5286        mfdf = self.mass_features_dataframe.copy()
5287        summarydf = self.cluster_summary_dataframe
5288
5289        numsamples = len(self)
5290        sumdf = (
5291            summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh]
5292            .reset_index(drop=True)
5293            .copy()
5294        )
5295
5296        ## find the ranges for non-outlier values and add them to sumdf
5297        mergelist = ["cluster"]
5298        for dim in dim_list:
5299            maxtag = dim + "_outmax"
5300            mintag = dim + "_outmin"
5301            mergelist.append(maxtag)
5302            mergelist.append(mintag)
5303            # Calculate outlier thresholds using vectorized operations
5304            sumdf[mintag] = sumdf[dim + "_mean"] - 3 * sumdf[dim + "_std"]
5305            sumdf[maxtag] = sumdf[dim + "_mean"] + 3 * sumdf[dim + "_std"]
5306            ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's
5307            ## not flagged. This happens when there's not enough values to compute median/std for that
5308            ## dimension therefore can't have outliers
5309
5310        ## add ranges to mfdf and identify mass features that fall outside the ranges
5311        # Merge without dropping NaN - we'll handle it per-dimension
5312        outdf = pd.merge(mfdf, sumdf[mergelist], on="cluster")
5313
5314        outtags = ["cluster"]
5315        for dim in dim_list:
5316            dimtag = dim + "_outlier"
5317            outtags.append(dimtag)
5318            maxtag = dim + "_outmax"
5319            mintag = dim + "_outmin"
5320            # Only flag as outlier if thresholds are valid (not NaN)
5321            outdf[dimtag] = np.where(
5322                (outdf[maxtag].notna() & outdf[mintag].notna())
5323                & ((outdf[dim] > outdf[maxtag]) | (outdf[dim] < outdf[mintag])),
5324                True,
5325                False,
5326            )
5327
5328        ## identify number of outliers in each cluster
5329        outliers = outdf[outtags]
5330        outliers = outliers.groupby(["cluster"]).sum()
5331
5332        ## plot number of clusters that contain any outliers
5333        fig = plt.figure()
5334        plt.bar(dim_list, outliers.sum().values, width=0.5)
5335        plt.xticks(rotation=90)
5336        plt.title("Frequency of outliers across all clusters by category")
5337
5338        return _finalize_plot(fig, return_fig=return_fig, path=path)
5339
5340    def _search_for_targeted_mass_features_in_sample(
5341        self, obj_idx, missingdf, cluster_dict, expand_on_miss=False, inplace=True
5342    ):
5343        """
5344        Helper method to search for missing mass features in a single sample.
5345
5346        Internal method called by fill_missing_cluster_features() to perform
5347        gap-filling for one sample in the collection.
5348
5349        Parameters
5350        ----------
5351        obj_idx : int
5352            Index of the sample being processed
5353        missingdf : pd.DataFrame
5354            DataFrame containing cluster information with columns:
5355            'cluster', 'sample_id_nunique', 'mz_min', 'mz_max',
5356            'scan_time_aligned_min', 'scan_time_aligned_max', 'mz_min_allowed',
5357            'mz_max_allowed', 'scan_time_aligned_min_allowed',
5358            'scan_time_aligned_max_allowed', 'missing_samples'
5359        cluster_dict : dict
5360            Pre-computed cluster feature dictionary to avoid recomputation
5361        expand_on_miss : bool
5362            If True, expands search window when no peak found initially
5363        inplace : bool
5364            If True, assigns induced_mass_features in place. If False, returns the
5365            induced features dictionary (for multiprocessing)
5366
5367        Returns
5368        -------
5369        dict or None
5370            If inplace=False, returns dictionary of induced mass features.
5371            Otherwise returns None and updates object in place.
5372        """
5373        ## Use the pre-computed cluster dictionary passed as parameter
5374
5375        ## to get clusters missing data based on sample index:
5376        sampledf = (
5377            missingdf[missingdf.missing_samples.apply(lambda x: obj_idx in x)]
5378            .reset_index(drop=True)
5379            .copy()
5380        )
5381
5382        # Skip if no missing features for this sample
5383        if len(sampledf) == 0:
5384            if not inplace:
5385                return {}
5386            return
5387
5388        self.load_raw_data(obj_idx, 1)
5389
5390        ## this is the line that bugs due to _ms_unprocessed not having key 1
5391        ms1df = self[obj_idx]._ms_unprocessed[1].copy()
5392        scan_df = self[obj_idx].scan_df[["scan", "scan_time_aligned"]]
5393        ms1df = pd.merge(ms1df, scan_df, on="scan")
5394
5395        # Pre-extract all values from sampledf to avoid repeated .iloc calls
5396        clusters = sampledf.cluster.values
5397        mz_mins = sampledf.mz_min.values
5398        mz_maxs = sampledf.mz_max.values
5399        st_mins = sampledf.scan_time_aligned_min.values
5400        st_maxs = sampledf.scan_time_aligned_max.values
5401
5402        if expand_on_miss:
5403            mz_mins_allowed = sampledf.mz_min_allowed.values
5404            mz_maxs_allowed = sampledf.mz_max_allowed.values
5405            st_mins_allowed = sampledf.sta_min_allowed.values
5406            st_maxs_allowed = sampledf.sta_max_allowed.values
5407
5408        # Pre-filter ms1df to reduce search space
5409        mz_global_min = mz_mins.min()
5410        mz_global_max = mz_maxs.max()
5411        st_global_min = st_mins.min()
5412        st_global_max = st_maxs.max()
5413
5414        if expand_on_miss:
5415            mz_global_min = min(mz_global_min, mz_mins_allowed.min())
5416            mz_global_max = max(mz_global_max, mz_maxs_allowed.max())
5417            st_global_min = min(st_global_min, st_mins_allowed.min())
5418            st_global_max = max(st_global_max, st_maxs_allowed.max())
5419
5420        ms1df_filtered = ms1df[
5421            (ms1df.mz >= mz_global_min)
5422            & (ms1df.mz <= mz_global_max)
5423            & (ms1df.scan_time_aligned >= st_global_min)
5424            & (ms1df.scan_time_aligned <= st_global_max)
5425        ].copy()
5426
5427        # Generate set_ids for all features
5428        set_ids = [f"c{clusters[i]}_{i}_i" for i in range(len(sampledf))]
5429
5430        # Use batch method to process all features at once
5431        if expand_on_miss:
5432            # First try with normal bounds
5433            peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch(
5434                ms1df_filtered,
5435                mz_mins,
5436                mz_maxs,
5437                st_mins,
5438                st_maxs,
5439                set_ids,
5440                obj_idx=obj_idx,
5441                st_aligned=True,
5442            )
5443
5444            # Retry failed features with expanded bounds
5445            failed_indices = [
5446                i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99
5447            ]
5448            if failed_indices:
5449                failed_ids = [set_ids[i] for i in failed_indices]
5450                retry_peaks = self[obj_idx].search_for_targeted_mass_features_batch(
5451                    ms1df_filtered,
5452                    mz_mins_allowed[failed_indices],
5453                    mz_maxs_allowed[failed_indices],
5454                    st_mins_allowed[failed_indices],
5455                    st_maxs_allowed[failed_indices],
5456                    failed_ids,
5457                    obj_idx=obj_idx,
5458                    st_aligned=True,
5459                )
5460                peaks_dict.update(retry_peaks)
5461        else:
5462            peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch(
5463                ms1df_filtered,
5464                mz_mins,
5465                mz_maxs,
5466                st_mins,
5467                st_maxs,
5468                set_ids,
5469                obj_idx=obj_idx,
5470                st_aligned=True,
5471            )
5472
5473        # Assign peaks to induced_mass_features and cluster_dict
5474        for i in range(len(sampledf)):
5475            peak = peaks_dict[set_ids[i]]
5476            self[obj_idx].induced_mass_features[peak.id] = peak
5477            cluster_dict[clusters[i]] += [set_ids[i]]
5478
5479        # TODO KRH: Let's try to avoid these steps unless asked for by parameters to pick up speed
5480        if False:
5481            self[obj_idx].add_associated_ms1(induced_features=True)
5482            # need to set drop_if_fail to false for induced features as they will fail
5483            self[obj_idx].add_peak_metrics(induced_features=True)
5484
5485        # The duplicate scan only deletes keys from mass_features. Induced ids
5486        # are strings such as c12_0_i, so that scan does not remove them.
5487        self[obj_idx].integrate_mass_features(
5488            drop_if_fail=False, induced_features=True, drop_duplicates=False
5489        )
5490
5491        if not inplace:
5492            return self[obj_idx].induced_mass_features
5493
5494    def _missing_sample_ids_by_cluster(self, mfdf, cluster_ids):
5495        """Sample ids with no feature row for each cluster, in sample-id order.
5496
5497        Includes samples that have no mass features. Lookup matches numpy
5498        integers and integral floats to the cluster key.
5499        """
5500        all_sample_ids = list(range(len(self.samples)))
5501        if mfdf is None or len(mfdf) == 0:
5502            present_by_cluster = pd.Series(dtype=object)
5503        else:
5504            present_by_cluster = mfdf.groupby("cluster", sort=False)["sample_id"].agg(
5505                lambda ids: set(ids.to_numpy())
5506            )
5507        missing = []
5508        for cluster_id in cluster_ids:
5509            present = present_by_cluster.get(cluster_id, ())
5510            missing.append(
5511                [sample_id for sample_id in all_sample_ids if sample_id not in present]
5512            )
5513        return missing
5514
5515    def fill_missing_cluster_features(self):
5516        """
5517        Gap-filling for consensus mass features across collection samples.
5518
5519        For clusters present in multiple samples but missing from others, searches
5520        raw MS1 data to find peaks in expected m/z and retention time windows. This
5521        creates "induced" mass features for peaks that exist in the data but weren't
5522        detected in the initial peak detection.
5523
5524        Must be run after add_consensus_mass_features(). Results are accessible via
5525        induced_mass_features_dataframe property and included in collection_pivot_table
5526        and collection_consensus_report outputs.
5527
5528        Parameters
5529        ----------
5530        None
5531            Uses parameters from self.parameters.lcms_collection:
5532            - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain
5533              a cluster before gap-filling is attempted
5534            - gap_fill_expand_on_miss: If True, expands search window when no peak is found
5535
5536        Returns
5537        -------
5538        None
5539            Updates induced_mass_features attribute for each LCMSBase object and
5540            combines them into induced_mass_features_dataframe.
5541
5542        Raises
5543        ------
5544        ValueError
5545            If cluster_summary_dataframe is not set (must run add_consensus_mass_features first).
5546
5547        Notes
5548        -----
5549        - Loads raw MS1 data for each sample, which may be memory intensive
5550        - Induced features are integrated and metrics calculated automatically
5551        - Processing can be parallelized using parameters.lcms_collection.cores
5552
5553        See Also
5554        --------
5555        add_consensus_mass_features : Creates consensus features before gap-filling
5556        collection_pivot_table : Includes both regular and induced features
5557        collection_consensus_report : Reports on complete feature matrix
5558        """
5559
5560        # Validate prerequisites
5561        if (
5562            not hasattr(self, "cluster_summary_dataframe")
5563            or self.cluster_summary_dataframe is None
5564        ):
5565            raise ValueError(
5566                "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first."
5567            )
5568
5569        # Get parameters from settings
5570        min_cluster_presence = (
5571            self.parameters.lcms_collection.consensus_min_sample_fraction
5572        )
5573        expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss
5574
5575        # Validate parameters
5576        if not 0 <= min_cluster_presence <= 1:
5577            raise ValueError("consensus_min_sample_fraction must be between 0 and 1")
5578
5579        summarydf = self.cluster_summary_dataframe
5580        mfdf = self.mass_features_dataframe
5581
5582        sample_ct = len(self.samples)
5583
5584        # Identify clusters present in sufficient samples but not all samples
5585        missingdf = summarydf[
5586            [
5587                "cluster",
5588                "sample_id_nunique",
5589                "mz_min",
5590                "mz_max",
5591                "scan_time_aligned_min",
5592                "scan_time_aligned_max",
5593            ]
5594        ]
5595        missingdf = missingdf[
5596            missingdf.sample_id_nunique > min_cluster_presence * sample_ct
5597        ]
5598        missingdf = missingdf[missingdf.sample_id_nunique != sample_ct]
5599
5600        # Check if there are any clusters to gap-fill
5601        if len(missingdf) == 0:
5602            return
5603
5604        # One groupby. Sample ids stay in collection order, including samples
5605        # that have no mass features.
5606        missingdf["missing_samples"] = self._missing_sample_ids_by_cluster(
5607            mfdf, missingdf.cluster.to_numpy()
5608        )
5609
5610        # Calculate expanded search windows for expand_on_miss option
5611        mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5612        rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol
5613        missingdf["mz_max_allowed"] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max
5614        missingdf["mz_min_allowed"] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min
5615        # consensus_rt_tol is an absolute tolerance in minutes, same as clustering.
5616        missingdf["sta_max_allowed"] = missingdf.scan_time_aligned_max + rt_clu_tol
5617        missingdf["sta_min_allowed"] = missingdf.scan_time_aligned_min - rt_clu_tol
5618
5619        # Compute cluster dictionary once to avoid recomputing for each sample
5620        cluster_dict = self.cluster_feature_dictionary
5621
5622        # Process each sample to search for missing features
5623        if self.parameters.lcms_collection.cores == 1:
5624            for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"):
5625                self._search_for_targeted_mass_features_in_sample(
5626                    i, missingdf, cluster_dict, expand_on_miss
5627                )
5628
5629        if self.parameters.lcms_collection.cores > 1:
5630            if self.parameters.lcms_collection.cores > len(self):
5631                ncores = len(self)
5632            else:
5633                ncores = self.parameters.lcms_collection.cores
5634            with spawn_pool(ncores) as pool:
5635                mp_result = pool.starmap(
5636                    self._search_for_targeted_mass_features_in_sample,
5637                    [
5638                        (x, missingdf, cluster_dict, expand_on_miss, False)
5639                        for x in range(sample_ct)
5640                    ],
5641                )
5642
5643            for i in tqdm(
5644                range(sample_ct), desc="Collecting gap-filled features", unit="sample"
5645            ):
5646                self[i].induced_mass_features = mp_result[i]
5647
5648        self._combine_mass_features(induced_features=True)
5649
5650        # Mark that gap-filling has been performed
5651        self.missing_mass_features_searched = True
5652
5653        for sample_name in self.samples:
5654            self._lcms[sample_name].mass_features = {}
5655
5656    def process_samples_pipeline(
5657        self, operations, description=None, keep_raw_data=False, show_progress=True
5658    ):
5659        """
5660        Execute a pipeline of operations on all samples in parallel.
5661
5662        This method provides a flexible framework for performing multiple
5663        sample-level operations in a single parallelized pass, which is more
5664        efficient than calling separate methods sequentially.
5665
5666        Parameters
5667        ----------
5668        operations : list of SampleOperation
5669            List of operations to perform on each sample, in order.
5670            Each operation should be an instance of a class derived from
5671            SampleOperation (see lc_calc_operations module).
5672        description : str or None, optional
5673            Progress bar description. If None, automatically generates description
5674            from operation descriptions (e.g., "gap-filling, reloading features").
5675            Default is None.
5676        keep_raw_data : bool, optional
5677            If True, keeps raw MS data loaded in memory after pipeline completes.
5678            If False, cleans up raw data to free memory. Default is False.
5679        show_progress : bool, optional
5680            If True, displays progress bars during processing. If False, runs silently.
5681            Default is True.
5682
5683        Returns
5684        -------
5685        dict
5686            Dictionary with results from pipeline execution, keyed by operation name.
5687            Structure: {operation_name: {sample_id: result, ...}, ...}
5688
5689        Raises
5690        ------
5691        ValueError
5692            If operations list is empty or contains invalid operations.
5693
5694        Notes
5695        -----
5696        - Operations are executed sequentially within each sample
5697        - Samples are processed in parallel based on parameters.lcms_collection.cores
5698        - Each operation can have conditional execution via can_execute()
5699        - Results are collected back via collect_results() method of each operation
5700        - Failed operations for a sample are logged but don't halt processing
5701        - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True
5702
5703        Examples
5704        --------
5705        >>> from corems.mass_spectra.calc.lc_calc_operations import (
5706        ...     GapFillOperation, ReloadFeaturesOperation
5707        ... )
5708        >>> ops = [
5709        ...     GapFillOperation('gap_fill', expand_on_miss=True),
5710        ...     ReloadFeaturesOperation('reload', add_ms2=True)
5711        ... ]
5712        >>> results = lcms_collection.process_samples_pipeline(ops)
5713
5714        See Also
5715        --------
5716        lc_calc_operations : Module containing built-in operation classes
5717        fill_and_process_features : Convenience method combining common operations
5718        """
5719        from corems.mass_spectra.calc.lc_calc_operations import SampleOperation
5720
5721        # Validate operations
5722        if not operations or len(operations) == 0:
5723            raise ValueError("operations list cannot be empty")
5724
5725        for op in operations:
5726            if not isinstance(op, SampleOperation):
5727                raise ValueError(
5728                    f"All operations must be SampleOperation instances, got {type(op)}"
5729                )
5730
5731        # Generate description from operations if not provided
5732        if description is None:
5733            operation_descriptions = [op.description for op in operations]
5734            description = ", ".join(operation_descriptions).capitalize()
5735
5736        # Prepare runtime parameters for each operation
5737        # This is where we gather collection-level data that operations need
5738        runtime_params = self._prepare_pipeline_runtime_params(operations)
5739        runtime_params["keep_raw_data"] = keep_raw_data
5740
5741        # Execute pipeline
5742        sample_ct = len(self.samples)
5743
5744        if self.parameters.lcms_collection.cores == 1:
5745            # Serial processing
5746            results_by_operation = {op.name: {} for op in operations}
5747
5748            if show_progress:
5749                from tqdm import tqdm
5750
5751                # Print description on its own line before progress bar
5752                print(f"\n{description.capitalize()}:")
5753                iterator = tqdm(range(sample_ct), unit="sample", ncols=80)
5754            else:
5755                iterator = range(sample_ct)
5756
5757            for sample_id in iterator:
5758                sample_results = self._execute_sample_pipeline(
5759                    sample_id, operations, runtime_params, inplace=True
5760                )
5761                # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True)
5762                # Skip 'sample_id' key which is added for tracking
5763                for op_name, result in sample_results.items():
5764                    if op_name != "sample_id":
5765                        results_by_operation[op_name][sample_id] = result
5766        else:
5767            # Parallel processing
5768            if self.parameters.lcms_collection.cores > sample_ct:
5769                ncores = sample_ct
5770            else:
5771                ncores = self.parameters.lcms_collection.cores
5772
5773            # Build arguments for each sample
5774            args_list = [
5775                (sample_id, operations, runtime_params, False)
5776                for sample_id in range(sample_ct)
5777            ]
5778
5779            # Execute in parallel with progress tracking
5780            results_by_operation = {op.name: {} for op in operations}
5781
5782            with spawn_pool(ncores) as pool:
5783                if show_progress:
5784                    from tqdm import tqdm
5785                    import time
5786
5787                    # Use starmap_async for parallel execution with progress tracking
5788                    async_result = pool.starmap_async(
5789                        self._execute_sample_pipeline, args_list
5790                    )
5791
5792                    # Poll for completion and update progress bar
5793                    print(description)
5794                    pbar = tqdm(
5795                        total=sample_ct,
5796                        desc="",
5797                        unit="sample",
5798                        position=0,
5799                        leave=True,
5800                        dynamic_ncols=True,
5801                    )
5802                    prev_completed = 0
5803                    while not async_result.ready():
5804                        # Get number of completed tasks by checking remaining
5805                        completed = sample_ct - async_result._number_left
5806                        if completed > prev_completed:
5807                            pbar.update(completed - prev_completed)
5808                            prev_completed = completed
5809                        time.sleep(0.5)  # Poll every 500ms to avoid spam
5810
5811                    # Final update to 100%
5812                    if prev_completed < sample_ct:
5813                        pbar.update(sample_ct - prev_completed)
5814                    pbar.close()
5815
5816                    # Get all results
5817                    mp_results = async_result.get()
5818                else:
5819                    # Execute without progress
5820                    mp_results = pool.starmap(self._execute_sample_pipeline, args_list)
5821
5822            # Collect results back into collection
5823            for result in mp_results:
5824                sample_id = result.get("sample_id")
5825                for op in operations:
5826                    op_result = result.get(op.name)
5827                    if op_result is not None:
5828                        op.collect_results(sample_id, op_result, self)
5829                        results_by_operation[op.name][sample_id] = op_result
5830
5831        return results_by_operation
5832
5833    def _prepare_pipeline_runtime_params(self, operations):
5834        """
5835        Prepare runtime parameters needed by operations in the pipeline.
5836
5837        This method gathers collection-level data that operations need,
5838        such as cluster information for gap-filling or mf_ids for reloading.
5839
5840        Parameters
5841        ----------
5842        operations : list of SampleOperation
5843            List of operations that will be executed
5844
5845        Returns
5846        -------
5847        dict
5848            Dictionary of runtime parameters for operations
5849        """
5850        from corems.mass_spectra.calc.lc_calc_operations import (
5851            GapFillOperation,
5852            ReloadFeaturesOperation,
5853            MS2SpectralSearchOperation,
5854            LoadEICsOperation,
5855        )
5856
5857        runtime_params = {}
5858
5859        # Check if any operation needs gap-fill parameters
5860        needs_gap_fill = any(isinstance(op, GapFillOperation) for op in operations)
5861        if needs_gap_fill:
5862            # Prepare gap-fill parameters (same as fill_missing_cluster_features)
5863            min_cluster_presence = (
5864                self.parameters.lcms_collection.consensus_min_sample_fraction
5865            )
5866            expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss
5867
5868            summarydf = self.cluster_summary_dataframe
5869            mfdf = self.mass_features_dataframe
5870            sample_ct = len(self.samples)
5871
5872            # Identify clusters needing gap-filling
5873            # Note: cluster_summary_dataframe has 'cluster' as index, need to reset it
5874            missingdf = summarydf.reset_index()[
5875                [
5876                    "cluster",
5877                    "sample_id_nunique",
5878                    "mz_min",
5879                    "mz_max",
5880                    "scan_time_aligned_min",
5881                    "scan_time_aligned_max",
5882                ]
5883            ].copy()
5884            missingdf = missingdf[
5885                missingdf.sample_id_nunique > min_cluster_presence * sample_ct
5886            ]
5887            missingdf = missingdf[missingdf.sample_id_nunique != sample_ct]
5888
5889            if len(missingdf) > 0:
5890                # One groupby. Sample ids stay in collection order, including samples
5891                # that have no mass features.
5892                missingdf["missing_samples"] = self._missing_sample_ids_by_cluster(
5893                    mfdf, missingdf.cluster.to_numpy()
5894                )
5895
5896                # Calculate expanded search windows
5897                mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5898                rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol
5899                missingdf["mz_max_allowed"] = (
5900                    missingdf.mz_max + mz_clu_tol * missingdf.mz_max
5901                )
5902                missingdf["mz_min_allowed"] = (
5903                    missingdf.mz_min - mz_clu_tol * missingdf.mz_min
5904                )
5905                # consensus_rt_tol is an absolute tolerance in minutes, same as clustering.
5906                missingdf["sta_max_allowed"] = (
5907                    missingdf.scan_time_aligned_max + rt_clu_tol
5908                )
5909                missingdf["sta_min_allowed"] = (
5910                    missingdf.scan_time_aligned_min - rt_clu_tol
5911                )
5912
5913                runtime_params["missingdf"] = missingdf
5914                runtime_params["cluster_dict"] = self.cluster_feature_dictionary
5915                runtime_params["expand_on_miss"] = expand_on_miss
5916
5917        # Check if any operation needs reload parameters
5918        needs_reload = any(isinstance(op, ReloadFeaturesOperation) for op in operations)
5919        if needs_reload:
5920            # Use DRY helper method to build sample_mf_map
5921            sample_mf_map = self.get_sample_mf_map_for_representatives(
5922                include_cluster_id=False
5923            )
5924            runtime_params["sample_mf_map"] = sample_mf_map
5925
5926        # Check if any operation needs MS2 spectral search parameters
5927        needs_ms2_search = any(
5928            isinstance(op, MS2SpectralSearchOperation) for op in operations
5929        )
5930        if needs_ms2_search:
5931            # Pass through pre-prepared spectral library
5932            if hasattr(self, "_spectral_lib") and self._spectral_lib is not None:
5933                runtime_params["fe_lib"] = self._spectral_lib
5934            if hasattr(self, "_spectral_search_molecular_metadata"):
5935                runtime_params["molecular_metadata"] = (
5936                    self._spectral_search_molecular_metadata
5937                )
5938
5939        # Check if any operation needs EIC loading parameters
5940        needs_eic_loading = any(isinstance(op, LoadEICsOperation) for op in operations)
5941        if needs_eic_loading:
5942            # Map sample_id -> m/z list for LoadEICsOperation.
5943            # Must use collection *dataframes*, not only in-memory mass_features:
5944            # after load_representatives=True each sample holds only a sparse set
5945            # of representative objects, so object-only walks miss most cluster
5946            # members and plot_cluster shows incomplete multi-sample EICs (#258).
5947            runtime_params["cluster_mz_dict"] = (
5948                self._build_cluster_mz_dict_for_eic_loading()
5949            )
5950
5951        return runtime_params
5952
5953    def _build_cluster_mz_dict_for_eic_loading(self):
5954        """Build sample_id -> list of EIC m/z targets for all clustered features.
5955
5956        Uses ``mass_features_dataframe`` (and induced dataframe if present).
5957        Prefers ``_eic_mz``, falls back to ``mz``. Does not require feature
5958        objects to be loaded in ``sample.mass_features``.
5959
5960        Returns
5961        -------
5962        dict
5963            Mapping of sample_id (int) to unique float m/z values to load.
5964        """
5965        cluster_mz_dict = {}
5966
5967        def _add_from_df(df):
5968            if df is None or len(df) == 0:
5969                return
5970            if "cluster" in df.columns:
5971                df = df[df["cluster"].notna()]
5972            if len(df) == 0 or "sample_id" not in df.columns:
5973                return
5974            for sample_id, sample_df in df.groupby("sample_id"):
5975                mzs = []
5976                if "_eic_mz" in sample_df.columns:
5977                    mzs.extend(sample_df["_eic_mz"].dropna().tolist())
5978                if "mz" in sample_df.columns:
5979                    # Include mz for rows with missing _eic_mz
5980                    if "_eic_mz" in sample_df.columns:
5981                        missing = sample_df["_eic_mz"].isna()
5982                        mzs.extend(sample_df.loc[missing, "mz"].dropna().tolist())
5983                    else:
5984                        mzs.extend(sample_df["mz"].dropna().tolist())
5985                sid = int(sample_id)
5986                existing = cluster_mz_dict.get(sid, [])
5987                cluster_mz_dict[sid] = list(set(existing + [float(m) for m in mzs]))
5988
5989        # Regular clustered features only: these are what were exported to per-sample
5990        # HDF5 EICs. Induced (gap-filled) features get EICs at gap-fill time on the
5991        # sample object; they are typically not present in the original HDF5 eics group.
5992        _add_from_df(self.mass_features_dataframe)
5993
5994        return cluster_mz_dict
5995
5996    def _execute_sample_pipeline(
5997        self, sample_id, operations, runtime_params, inplace=True
5998    ):
5999        """
6000        Execute a pipeline of operations on a single sample.
6001
6002        This is the worker function called (potentially in parallel) for each sample.
6003
6004        Parameters
6005        ----------
6006        sample_id : int
6007            Sample ID to process
6008        operations : list of SampleOperation
6009            Operations to execute in order
6010        runtime_params : dict
6011            Runtime parameters prepared by _prepare_pipeline_runtime_params
6012        inplace : bool, optional
6013            If True, updates sample in place. If False, returns results for
6014            multiprocessing. Default is True.
6015
6016        Returns
6017        -------
6018        dict
6019            Dictionary with results from each operation, keyed by operation name.
6020            If inplace=True, returns results that need to be collected.
6021            If inplace=False, returns all results for multiprocessing collection.
6022        """
6023        results = {}
6024
6025        # Check if any operations need raw MS data
6026        needs_raw_data = {}  # {ms_level: True/False}
6027        for op in operations:
6028            needs_raw, ms_level = op.needs_raw_ms_data()
6029            if needs_raw and ms_level:
6030                needs_raw_data[ms_level] = True
6031
6032        # Load raw data once if any operations need it
6033        # Note: For gap-filling, it loads data internally, so we just track it here
6034        for ms_level in needs_raw_data.keys():
6035            # Gap-filling loads its own data, but we want to keep track that it's loaded
6036            # Other operations can then use the loaded data
6037            pass
6038
6039        for op in operations:
6040            # Check if operation can execute on this sample
6041            sample = self[sample_id]
6042            if not op.can_execute(sample, self):
6043                # Skip this operation for this sample if prerequisites aren't met
6044                # This allows processing to continue for samples that don't have
6045                # all required data (e.g., MS2 spectra)
6046                results[op.name] = None
6047                continue
6048
6049            # Prepare operation-specific runtime params
6050            op_runtime_params = {}
6051
6052            # Add gap-fill params if this is a gap-fill operation
6053            from corems.mass_spectra.calc.lc_calc_operations import (
6054                GapFillOperation,
6055                ReloadFeaturesOperation,
6056                MS2SpectralSearchOperation,
6057                LoadEICsOperation,
6058            )
6059
6060            if isinstance(op, GapFillOperation):
6061                if "missingdf" in runtime_params:
6062                    op_runtime_params["missingdf"] = runtime_params["missingdf"]
6063                    op_runtime_params["cluster_dict"] = runtime_params["cluster_dict"]
6064                    op_runtime_params["expand_on_miss"] = runtime_params[
6065                        "expand_on_miss"
6066                    ]
6067
6068            elif isinstance(op, ReloadFeaturesOperation):
6069                if "sample_mf_map" in runtime_params:
6070                    sample_mf_map = runtime_params["sample_mf_map"]
6071                    # Always pass mf_ids_to_load to ensure we only load what's needed
6072                    # If sample not in map, it has no representatives - pass empty list
6073                    op_runtime_params["mf_ids_to_load"] = sample_mf_map.get(
6074                        sample_id, []
6075                    )
6076
6077            elif isinstance(op, MS2SpectralSearchOperation):
6078                # Add MS2 spectral search parameters
6079                if "fe_lib" in runtime_params:
6080                    op_runtime_params["fe_lib"] = runtime_params["fe_lib"]
6081                if "molecular_metadata" in runtime_params:
6082                    op_runtime_params["molecular_metadata"] = runtime_params[
6083                        "molecular_metadata"
6084                    ]
6085
6086            elif isinstance(op, LoadEICsOperation):
6087                # Add EIC loading parameters
6088                if "cluster_mz_dict" in runtime_params:
6089                    op_runtime_params["cluster_mz_dict"] = runtime_params[
6090                        "cluster_mz_dict"
6091                    ]
6092
6093            # Execute the operation
6094            result = op.execute(sample_id, self, **op_runtime_params)
6095            results[op.name] = result
6096
6097            # If inplace, collect immediately
6098            if inplace and result is not None:
6099                op.collect_results(sample_id, result, self)
6100
6101        # Clean up raw data if requested
6102        keep_raw_data = runtime_params.get("keep_raw_data", False)
6103        if not keep_raw_data:
6104            for ms_level in needs_raw_data.keys():
6105                if ms_level in self[sample_id]._ms_unprocessed:
6106                    del self[sample_id]._ms_unprocessed[ms_level]
6107
6108        # Include sample_id in results for tracking (especially important for imap_unordered)
6109        results["sample_id"] = sample_id
6110        return results
6111
6112    def process_consensus_features(
6113        self,
6114        load_representatives=True,
6115        perform_gap_filling=True,
6116        add_ms1=False,
6117        add_ms2=False,
6118        ms2_scan_filter=None,
6119        molecular_formula_search=False,
6120        ms2_spectral_search=False,
6121        spectral_lib=None,
6122        molecular_metadata=None,
6123        gather_eics=False,
6124        group_features=False,
6125        keep_raw_data=False,
6126        show_progress=True,
6127    ):
6128        """
6129        Process consensus mass features across the collection in a single parallelized pass.
6130
6131        This method provides a convenient interface to the sample processing pipeline,
6132        allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association,
6133        molecular formula search, and MS2 spectral search) to be performed efficiently in
6134        a single pass through all samples.
6135
6136        Parameters
6137        ----------
6138        load_representatives : bool, optional
6139            If True, loads representative mass features from HDF5. Default is True.
6140        perform_gap_filling : bool, optional
6141            If True, performs gap-filling for missing cluster features. Default is True.
6142            This operation loads raw MS1 data which can be reused by subsequent operations.
6143        group_features : bool, optional
6144            If True, run ``group_consensus_features()`` after gap-fill (and before
6145            molecular formula / MS2 search when those are also enabled).
6146            Collection-level natural-abundance **isotope and adduct** family
6147            labeling (singly charged only; not tracer/enriched). Default is False.
6148        add_ms1 : bool, optional
6149            If True and load_representatives=True, associates MS1 spectra with
6150            loaded features. Automatically uses raw data from gap-filling if available,
6151            otherwise uses parser. Spectrum mode is auto-detected. Default is False.
6152        add_ms2 : bool, optional
6153            If True and load_representatives=True, associates MS2 spectra with
6154            loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False.
6155        ms2_scan_filter : str or None, optional
6156            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
6157        molecular_formula_search : bool, optional
6158            If True, performs molecular formula search on mass features using
6159            associated MS1 spectra. Requires add_ms1=True or that MS1 spectra
6160            are already associated. Uses parameters from
6161            parameters.mass_spectrum["ms1"].molecular_search. Default is False.
6162        ms2_spectral_search : bool, optional
6163            If True, performs MS2 spectral library search using FlashEntropy.
6164            Requires add_ms2=True and spectral_lib to be provided. Default is False.
6165        spectral_lib : FlashEntropy library, optional
6166            Pre-prepared FlashEntropy spectral library for MS2 search.
6167            Create using MSPInterface.get_metabolomics_spectra_library().
6168            Required if ms2_spectral_search=True. Default is None.
6169        molecular_metadata : pd.DataFrame, optional
6170            Molecular metadata corresponding to spectral_lib.
6171            Returned from MSPInterface.get_metabolomics_spectra_library().
6172            Stored as self.spectral_search_molecular_metadata for later export.
6173            Default is None.
6174        gather_eics : bool, optional
6175            If True, loads extracted ion chromatograms (EICs) from HDF5 for all
6176            mass features with assigned cluster_index (including gap-filled features).
6177            Enables access to EICs via get_eics_for_cluster(cluster_id) method.
6178            Requires that EICs were previously exported with export_eics=True.
6179            Default is False.
6180        keep_raw_data : bool, optional
6181            If True, keeps raw MS data loaded in memory after pipeline completes.
6182            If False, cleans up raw data to free memory. Default is False.
6183        show_progress : bool, optional
6184            If True, displays progress bars during processing. If False, runs silently.
6185            Default is True.
6186
6187        Returns
6188        -------
6189        dict
6190            Dictionary with pipeline results. Keys include:
6191            - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling)
6192            - 'reload': dict mapping sample_id to reloaded mass features (if reloading)
6193            - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search)
6194            - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search)
6195
6196        Raises
6197        ------
6198        ValueError
6199            If neither operation is enabled, or if required parameters are missing.
6200
6201        Notes
6202        -----
6203        - Must run add_consensus_mass_features() before calling this method
6204        - Processes samples in parallel based on parameters.lcms_collection.cores
6205        - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association
6206        - MS2 spectral search requires add_ms2=True and msp_file_path
6207        - FlashEntropy library is created once and reused across all samples
6208        - More efficient than calling individual methods separately
6209        - After gap-filling, sets missing_mass_features_searched = True
6210        - Mass features remain loaded in memory for downstream processing
6211        - For more advanced workflows, use process_samples_pipeline() directly
6212
6213        Examples
6214        --------
6215        >>> # Prepare spectral library for MS2 search
6216        >>> from corems.molecular_id.search.database_interfaces import MSPInterface
6217        >>> my_msp = MSPInterface(file_path='path/to/library.msp')
6218        >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library(
6219        ...     polarity='negative',
6220        ...     format='flashentropy',
6221        ...     normalize=True,
6222        ...     fe_kwargs={
6223        ...         'normalize_intensity': True,
6224        ...         'min_ms2_difference_in_da': 0.02,
6225        ...         'max_ms2_tolerance_in_da': 0.01,
6226        ...         'max_indexed_mz': 3000,
6227        ...         'precursor_ions_removal_da': None,
6228        ...         'noise_threshold': 0,
6229        ...     }
6230        ... )
6231        >>>
6232        >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search
6233        >>> results = lcms_collection.process_consensus_features(
6234        ...     load_representatives=True,
6235        ...     perform_gap_filling=True,
6236        ...     add_ms1=True,
6237        ...     add_ms2=True,
6238        ...     molecular_formula_search=True,
6239        ...     ms2_spectral_search=True,
6240        ...     spectral_lib=spectral_lib,
6241        ...     molecular_metadata=molecular_metadata
6242        ... )
6243
6244        See Also
6245        --------
6246        process_samples_pipeline : Generic pipeline executor for custom workflows
6247        fill_missing_cluster_features : Original gap-filling method
6248        reload_representative_mass_features : Original reload method
6249        """
6250        from corems.mass_spectra.calc.lc_calc_operations import (
6251            GapFillOperation,
6252            ReloadFeaturesOperation,
6253            MolecularFormulaSearchOperation,
6254            MS2SpectralSearchOperation,
6255            LoadEICsOperation,
6256        )
6257
6258        # Validate that at least one meaningful operation is enabled
6259        has_operations = (
6260            perform_gap_filling
6261            or load_representatives
6262            or molecular_formula_search
6263            or ms2_spectral_search
6264            or gather_eics
6265            or add_ms1
6266            or add_ms2
6267            or group_features
6268        )
6269
6270        if not has_operations:
6271            raise ValueError(
6272                "At least one operation must be enabled: perform_gap_filling, load_representatives, "
6273                "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, add_ms2, "
6274                "or group_features"
6275            )
6276
6277        # Validate prerequisites for gap-filling
6278        if perform_gap_filling:
6279            if (
6280                not hasattr(self, "cluster_summary_dataframe")
6281                or self.cluster_summary_dataframe is None
6282            ):
6283                raise ValueError(
6284                    "Cannot perform gap-filling: cluster_summary_dataframe not set. "
6285                    "You must run add_consensus_mass_features() before calling process_consensus_features()."
6286                )
6287
6288        # Validate prerequisites for MS2 spectral search
6289        if ms2_spectral_search:
6290            if spectral_lib is None:
6291                raise ValueError(
6292                    "MS2 spectral search requires spectral_lib to be provided. "
6293                    "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method."
6294                )
6295            # Check if mass features will be loaded OR are already loaded
6296            # (The operation's can_execute will check if MS2 spectra are actually present)
6297            if not load_representatives and not perform_gap_filling:
6298                # Check if at least one sample has mass features loaded
6299                # This allows MS2 search on already-loaded features
6300                has_loaded_features = any(
6301                    len(self[i].mass_features) > 0
6302                    if hasattr(self[i], "mass_features")
6303                    and self[i].mass_features is not None
6304                    else False
6305                    for i in range(len(self.samples))
6306                )
6307                if not has_loaded_features:
6308                    raise ValueError(
6309                        "MS2 spectral search requires mass features to be loaded. "
6310                        "Either set load_representatives=True or perform_gap_filling=True to load them, "
6311                        "or load them in a previous call to process_consensus_features() before calling "
6312                        "with ms2_spectral_search=True."
6313                    )
6314
6315        # Build pipeline in two phases so group_features runs after gap-fill
6316        # and before formula / MS2 search when those are requested in the same call.
6317        pre_operations = []
6318        post_operations = []
6319
6320        if perform_gap_filling:
6321            expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss
6322            pre_operations.append(
6323                GapFillOperation("gap_fill", expand_on_miss=expand_on_miss)
6324            )
6325
6326        if load_representatives:
6327            # MS1/MS2 association for representatives can happen in pre-pass;
6328            # formula/MS2 library search stay in post-pass after grouping.
6329            pre_operations.append(
6330                ReloadFeaturesOperation(
6331                    "reload",
6332                    add_ms1=add_ms1,
6333                    add_ms2=add_ms2,
6334                    auto_process_ms2=add_ms2,
6335                    ms2_scan_filter=ms2_scan_filter,
6336                )
6337            )
6338
6339        if molecular_formula_search:
6340            post_operations.append(MolecularFormulaSearchOperation("mf_search"))
6341
6342        if ms2_spectral_search:
6343            post_operations.append(
6344                MS2SpectralSearchOperation(
6345                    "ms2_search", ms2_scan_filter=ms2_scan_filter
6346                )
6347            )
6348            self._spectral_lib = spectral_lib
6349            self._spectral_search_molecular_metadata = molecular_metadata
6350
6351        if gather_eics:
6352            # After grouping when grouping is requested; otherwise with pre-pass
6353            # (or post-pass if only annotation ops are enabled).
6354            if group_features or not pre_operations:
6355                post_operations.append(LoadEICsOperation("load_eics"))
6356            else:
6357                pre_operations.append(LoadEICsOperation("load_eics"))
6358
6359        results = {}
6360
6361        def _run_ops(operations):
6362            if not operations:
6363                return {}
6364            return self.process_samples_pipeline(
6365                operations, keep_raw_data=keep_raw_data, show_progress=show_progress
6366            )
6367
6368        # Phase 1: gap-fill / reload (and eics if not grouping)
6369        if pre_operations:
6370            results.update(_run_ops(pre_operations))
6371
6372        # Post-processing for gap-fill must happen before grouping so heights include induced
6373        if perform_gap_filling:
6374            self._combine_mass_features(induced_features=True)
6375            self.missing_mass_features_searched = True
6376
6377            if (
6378                self.induced_mass_features_dataframe is not None
6379                and len(self.induced_mass_features_dataframe) > 0
6380            ):
6381                eics_mz = []
6382                for i, row in self.induced_mass_features_dataframe.iterrows():
6383                    sample_id = row["sample_id"]
6384                    sample = self[sample_id]
6385                    if row["mf_id"] in sample.induced_mass_features.keys():
6386                        eic_mz = sample.induced_mass_features[row["mf_id"]]._eic_mz
6387                        eics_mz.append(eic_mz)
6388                    else:
6389                        eics_mz.append(None)
6390                self.induced_mass_features_dataframe["_eic_mz"] = eics_mz
6391
6392        # Collection-level isotope + adduct grouping
6393        if group_features:
6394            t_group = time.perf_counter()
6395            labels = self.group_consensus_features()
6396            group_wall = time.perf_counter() - t_group
6397            results["group_features"] = {
6398                "n_clusters": len(labels),
6399                "n_grouped": int(labels["feature_group_id"].notna().sum()),
6400                "n_groups": int(labels["feature_group_id"].nunique(dropna=True)),
6401                "wall_seconds": group_wall,
6402                "stage_timings": getattr(self, "_last_feature_group_timings", None),
6403            }
6404
6405        # Phase 2: annotation / optional EICs after grouping
6406        if post_operations:
6407            # If only post_operations and no pre, still need a pipeline run
6408            post_results = _run_ops(post_operations)
6409            for k, v in post_results.items():
6410                results[k] = v
6411
6412        # Store molecular metadata if spectral search was performed
6413        if ms2_spectral_search and hasattr(self, "_spectral_search_molecular_metadata"):
6414            self.spectral_search_molecular_metadata = (
6415                self._spectral_search_molecular_metadata
6416            )
6417
6418        # Associate EICs while induced feature objects still exist (before any clear).
6419        if gather_eics:
6420            print("\nAssociating EICs with mass features:")
6421            from tqdm import tqdm
6422
6423            for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80):
6424                sample = self[sample_id]
6425                if sample.eics:
6426                    sample.associate_eics_with_mass_features(induced=False)
6427                    sample.associate_eics_with_mass_features(induced=True)
6428
6429        # Drop induced feature objects to free memory after grouping + post-ops
6430        if perform_gap_filling:
6431            for sample_name in self.samples:
6432                self._lcms[sample_name].induced_mass_features = {}
6433
6434        return results

Methods for performing calculations related to LCMSCollection objects.

Notes

This class is intended as a mixin for the LCMSCollection class.

def clean_sparse_matrix(self, sparse_matrix):
2910    def clean_sparse_matrix(self, sparse_matrix):
2911        """Clean a sparse matrix by removing duplicates and sorting.
2912
2913        Parameters
2914        ----------
2915        sparse_matrix : :obj:`~numpy.array`
2916            A sparse matrix to clean.
2917
2918        Returns
2919        -------
2920        :obj:`~numpy.array`
2921            A cleaned sparse matrix.
2922        """
2923        for match in sparse_matrix:
2924            match.sort()
2925        sparse_matrix.sort()
2926        dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0)
2927        return dereplicated_sparse_matrix

Clean a sparse matrix by removing duplicates and sorting.

Parameters
  • sparse_matrix (~numpy.array): A sparse matrix to clean.
Returns
  • ~numpy.array: A cleaned sparse matrix.
def match_mfs(self, mf_c, mf_i):
2929    def match_mfs(self, mf_c, mf_i):
2930        """Match mass features between two LCMS objects.
2931
2932        Parameters
2933        ----------
2934        mf_c : :obj:`~pandas.DataFrame`
2935            The mass features to match against.
2936        mf_i : :obj:`~pandas.DataFrame`
2937            The mass features to match.
2938
2939        Returns
2940        -------
2941        :obj:`~pandas.DataFrame`
2942            The matched mass features from mf_c.
2943        :obj:`~pandas.DataFrame`
2944            The matched mass features from mf_i.
2945
2946        Notes
2947        -----
2948        This function has been adapted from the original implementation in the Deimos package:
2949        https://github.com/pnnl/deimos
2950        """
2951        if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1:
2952            return None, None
2953
2954        # Prepare dataframes
2955        mf_c = mf_c.copy()
2956        mf_c["id_i"] = 0
2957        mf_i = mf_i.copy()
2958        mf_i["id_i"] = 1
2959
2960        # Set dimensions for matching
2961        dims = ["mz", "scan_time"]
2962        relative = [True, False]
2963        mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6
2964        rt_tol = self.parameters.lcms_collection.alignment_rt_tol
2965        tol = [mz_tol, rt_tol]
2966
2967        # Compute inter-feature distances
2968        idx = []
2969        for i, f in enumerate(dims):
2970            # vectors
2971            v1 = mf_c[f].values.reshape(-1, 1)
2972            v2 = mf_i[f].values.reshape(-1, 1)
2973
2974            # Distances
2975            d = scipy.spatial.distance.cdist(v1, v2)
2976
2977            if relative[i] is True:
2978                # Divisor
2979                basis = np.repeat(v1, v2.shape[0], axis=1)
2980                fix = np.repeat(v2, v1.shape[0], axis=1).T
2981                basis = np.where(basis == 0, fix, basis)
2982
2983                # Divide
2984                d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0)
2985
2986            # Check tol
2987            idx.append(d <= tol[i])
2988
2989        # Stack truth arrays
2990        idx = np.prod(np.dstack(idx), axis=-1, dtype=bool)
2991
2992        # Compute normalized 3d distance
2993        v1 = mf_c[dims].values / tol
2994        v2 = mf_i[dims].values / tol
2995        dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock")
2996
2997        # Separate features within tolerance from those outside
2998        # Features outside tolerance should be inf, features within tolerance keep their distance
2999        # Use idx mask: True for within tolerance, False for outside
3000        dist3d_within_tol = np.where(idx, dist3d, np.inf)
3001
3002        # Normalize to 0-1 (only affects within-tolerance distances)
3003        mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0
3004        if mx > 0:
3005            # Lower distance is better - normalize only the within-tolerance values
3006            dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf)
3007        else:
3008            # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs
3009            dist3d_within_tol = np.where(idx, 1e-10, np.inf)
3010
3011        # Use the masked distance matrix
3012        dist3d = dist3d_within_tol
3013
3014        # Min over dims
3015        mincols = np.min(dist3d, axis=0, keepdims=True)
3016
3017        # Zero out mincols over dims
3018        dist3d[dist3d != mincols] = np.inf
3019
3020        # Min over clusters
3021        minrows = np.min(dist3d, axis=1, keepdims=True)
3022
3023        # Where max and nonzero
3024        ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf))
3025
3026        # Reorder
3027        mf_c = mf_c.iloc[ii]
3028        mf_i = mf_i.iloc[jj]
3029
3030        if len(mf_c.index) < 1 or len(mf_i.index) < 1:
3031            return None, None
3032
3033        return mf_c, mf_i

Match mass features between two LCMS objects.

Parameters
  • mf_c (~pandas.DataFrame): The mass features to match against.
  • mf_i (~pandas.DataFrame): The mass features to match.
Returns
  • ~pandas.DataFrame: The matched mass features from mf_c.
  • ~pandas.DataFrame: The matched mass features from mf_i.
Notes

This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos

def fit_rts(self, a, b, align='scan_time', **kwargs):
3035    def fit_rts(self, a, b, align="scan_time", **kwargs):
3036        """
3037        Fit a support vector regressor to matched features.
3038
3039        Parameters
3040        ----------
3041        a : :obj:`~pandas.DataFrame`
3042            First set of input feature coordinates and intensities; the center object and the object to align to.
3043        b : :obj:`~pandas.DataFrame`
3044            Second set of input feature coordinates and intensities; the object to align to the center object.
3045        align : str
3046            Dimension to align.
3047        kwargs
3048            Keyword arguments for support vector regressor
3049            (:class:`sklearn.svm.SVR`).
3050
3051        Returns
3052        -------
3053        :obj:`~function`
3054            An interpolation function where one can input a retention time and get the predicted retention time.
3055
3056        Notes
3057        -----
3058        This function has been adapted from the original implementation in the Deimos package:
3059        https://github.com/pnnl/deimos
3060
3061        """
3062
3063        # Uniqueify
3064        x = a[align].values
3065        y = b[align].values
3066        arr = np.vstack((x, y)).T
3067        arr = np.unique(arr, axis=0)
3068
3069        # Safety check: ensure we have data to work with
3070        if len(arr) == 0:
3071            warnings.warn(
3072                "No data points available for retention time fitting. Returning identity function."
3073            )
3074            return lambda x: x
3075
3076        # Check kwargs
3077        if "kernel" in kwargs:
3078            kernel = kwargs.get("kernel")
3079        else:
3080            kernel = "linear"
3081
3082        # Construct interpolation axis
3083        newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000)
3084
3085        # Linear kernel
3086        if kernel == "linear":
3087            reg = scipy.stats.linregress(x, y)
3088            newy = reg.slope * newx + reg.intercept
3089
3090        # Other kernels
3091        else:
3092            # Fit
3093            svr = SVR(**kwargs)
3094            svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0])
3095
3096            # Predict
3097            newy = svr.predict(newx.reshape(-1, 1))
3098
3099        # Pad x and y_pred with zeros to force interpolation to start at 0
3100        newx = np.concatenate(([0], newx))
3101        newy = np.concatenate(([0], newy))
3102
3103        # Pad x and y_pred with max time to force interpolation to end at max time to force interpolation to match at end max time
3104        max_time = self[0].scan_df["scan_time"].max()
3105        newx = np.concatenate((newx, [max_time]))
3106        newy = np.concatenate((newy, [max_time]))
3107
3108        # Return an interpolation function for the x and y_pred
3109        def interp(x):
3110            pred_y = np.interp(x, newx, newy)
3111            return pred_y
3112
3113        return interp

Fit a support vector regressor to matched features.

Parameters
  • a (~pandas.DataFrame): First set of input feature coordinates and intensities; the center object and the object to align to.
  • b (~pandas.DataFrame): Second set of input feature coordinates and intensities; the object to align to the center object.
  • align (str): Dimension to align.
  • kwargs: Keyword arguments for support vector regressor (sklearn.svm.SVR).
Returns
  • ~function: An interpolation function where one can input a retention time and get the predicted retention time.
Notes

This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos

def get_anchor_mass_features(self, mf_df):
3115    def get_anchor_mass_features(self, mf_df):
3116        """
3117        Get the anchor mass features from a DataFrame of mass features.
3118
3119        Parameters
3120        ----------
3121        mf_df : :obj:`~pandas.DataFrame`
3122            The mass features to filter to just the anchor mass features.
3123
3124        Returns
3125        -------
3126        :obj:`~pandas.DataFrame`
3127            The anchor mass features dataframe.
3128        """
3129        mf_df = mf_df.copy()
3130
3131        if (
3132            "deconvoluted_mass_spectra"
3133            in self.parameters.lcms_collection.mass_feature_anchor_technique
3134        ):
3135            # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent
3136            mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"])
3137            mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]]
3138
3139        if (
3140            "absolute_intensity"
3141            in self.parameters.lcms_collection.mass_feature_anchor_technique
3142        ):
3143            # Drop features that have an intensity lower than the threshold
3144            threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold
3145            mf_df = mf_df[mf_df["intensity"] > threshold]
3146
3147        if (
3148            "relative_intensity"
3149            in self.parameters.lcms_collection.mass_feature_anchor_technique
3150        ):
3151            # Drop features in the lower fraction of intensities
3152            threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold
3153            intensity_threshold = mf_df["intensity"].quantile(threshold_quantile)
3154            mf_df = mf_df[mf_df["intensity"] >= intensity_threshold]
3155
3156        return mf_df

Get the anchor mass features from a DataFrame of mass features.

Parameters
  • mf_df (~pandas.DataFrame): The mass features to filter to just the anchor mass features.
Returns
  • ~pandas.DataFrame: The anchor mass features dataframe.
def attempt_alignment(self, matches_c, matches_i):
3158    def attempt_alignment(self, matches_c, matches_i):
3159        """
3160        Check if alignment is needed for the LCMS objects in the collection.
3161        """
3162
3163        # Hold out a subset of matches_c and matches_i for spline fitting
3164        matches_c.reset_index(drop=False, inplace=True)
3165        matches_i.reset_index(drop=False, inplace=True)
3166
3167        # Check if there are enough matches to attempt alignment
3168        minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches
3169        if len(matches_c) < minimum_matches:
3170            # Return False (no alignment) and identity function (returns original time)
3171            # which isn't used but is a placeholder to avoid errors in downstream code since
3172            # the function expects a callable to be returned
3173            return False, lambda x: x
3174
3175        # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c
3176        matches_c = matches_c.sort_values(by="scan_time")
3177        matches_i = matches_i.iloc[matches_c.index.values]
3178
3179        hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction
3180        # starting with an array of length len(matches_c), select equally spaced indices to hold out
3181        idx_holdout = matches_c.index.values[
3182            np.arange(0, len(matches_c), int(1 / hold_out_fraction))
3183        ]
3184
3185        matches_c_holdout = matches_c.loc[idx_holdout].copy()
3186        matches_i_holdout = matches_i.loc[idx_holdout].copy()
3187
3188        # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index
3189        matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name")
3190        matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name")
3191
3192        # Reset the scan_time to the original scan_time
3193        matches_i = matches_i.copy()
3194        matches_i["scan_time"] = matches_i["scan_time_og"]
3195
3196        # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features
3197        spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000)
3198
3199        # Check if the spline fitting improved the alignment for the holdout matches
3200        matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"])
3201        og_diff = np.abs(
3202            matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"]
3203        )
3204        fit_diff = np.abs(
3205            matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"]
3206        )
3207
3208        if (
3209            "fraction_improved"
3210            in self.parameters.lcms_collection.alignment_acceptance_technique
3211        ):
3212            fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff)
3213            use_spline_alignment = (
3214                fraction_improved
3215                > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold
3216            )
3217        if (
3218            "mean_squared_error_improved"
3219            in self.parameters.lcms_collection.alignment_acceptance_technique
3220        ):
3221            mse_og = np.mean(og_diff**2)
3222            mse = np.mean(fit_diff**2)
3223            use_spline_alignment = mse < mse_og
3224            # Convert to boolean
3225            use_spline_alignment = bool(use_spline_alignment)
3226
3227        return use_spline_alignment, spl

Check if alignment is needed for the LCMS objects in the collection.

def align_lcms_objects(self, overwrite=False):
3229    def align_lcms_objects(self, overwrite=False):
3230        """
3231        Align LCMS objects in the collection.
3232
3233        Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects.
3234        First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects,
3235        starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects
3236        are then fit to the center LCMS object using the matched mass features.
3237
3238        Returns
3239        -------
3240        None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object.
3241
3242        Notes
3243        -----
3244        This function has been adapted from the original implementation in the Deimos package:
3245        https://github.com/pnnl/deimos
3246        """
3247
3248        # Prepare the center LCMS object
3249        center_obj_ids = self.manifest_dataframe[
3250            self.manifest_dataframe["center"]
3251        ].collection_id.values
3252
3253        full_mf_df = self.mass_features_dataframe
3254        # re-index to sample_name for faster lookups
3255        full_mf_df = full_mf_df.reset_index().set_index("sample_name")
3256        samples_with_features = set(full_mf_df.index.get_level_values("sample_name"))
3257
3258        if "scan_time_aligned" in full_mf_df.columns and not overwrite:
3259            raise ValueError("Mass features have already been aligned")
3260
3261        def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline):
3262            """Set scan_time_aligned for one sample using spline or identity mapping."""
3263            if use_alignment and spline is not None:
3264                self[sample_idx]._scan_info["scan_time_aligned"] = {
3265                    k: spline(v)
3266                    for k, v in self[sample_idx]._scan_info["scan_time"].items()
3267                }
3268                return True
3269
3270            self[sample_idx]._scan_info["scan_time_aligned"] = (
3271                self[sample_idx]._scan_info["scan_time"].copy()
3272            )
3273            return False
3274
3275        def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline):
3276            """Return next sample index/dataframe with features, aligning empty samples on the way."""
3277            i = start_idx
3278            while 0 <= i < len(self):
3279                sample_name = self.samples[i]
3280                if sample_name in samples_with_features:
3281                    mf_df_i = full_mf_df.loc[sample_name].copy()
3282                    mf_df_i["scan_time_og"] = mf_df_i["scan_time"]
3283                    mf_df_i = mf_df_i.reset_index(drop=False)
3284                    if use_alignment and spline is not None:
3285                        # Use previous step transform as a better matching starting point.
3286                        mf_df_i["scan_time"] = spline(mf_df_i["scan_time"])
3287                    return i, mf_df_i
3288
3289                _set_scan_time_alignment_for_sample(i, use_alignment, spline)
3290                self.rt_alignment_attempted = True
3291                i += index_step
3292
3293            return i, None
3294
3295        anchor_mf_dfs = []
3296        for center_obj_id in center_obj_ids:
3297            # Get the anchor mass features from the center LCMS object
3298            mf_df_c = full_mf_df.loc[self.samples[center_obj_id]]
3299            mf_df_c = self.get_anchor_mass_features(mf_df_c)
3300            anchor_mf_dfs.append(mf_df_c)
3301
3302            # Set scan_time_aligned to scan_time for the center LCMS object
3303            center_scan_df = self[center_obj_id].scan_df.copy()
3304            center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"]
3305            self[center_obj_id].scan_df = center_scan_df
3306
3307            # Store alignment data for center object (identity mapping)
3308            center_sample_name = self.samples[center_obj_id]
3309
3310            index_steps = (1, -1)
3311            # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing)
3312            for index_step in index_steps:
3313                # Initialize spline for propagation to samples without features
3314                spl = None
3315                use_spline_alignment = False
3316
3317                # Loop through the other LCMS objects in this direction.
3318                i, mf_df_i = _get_feature_df_at_or_after(
3319                    center_obj_id + index_step,
3320                    index_step,
3321                    use_spline_alignment,
3322                    spl,
3323                )
3324
3325                while mf_df_i is not None:
3326                    mf_df_i = self.get_anchor_mass_features(mf_df_i)
3327
3328                    # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object.
3329                    matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i)
3330
3331                    if matches_c is not None:
3332                        use_spline_alignment, spl = self.attempt_alignment(
3333                            matches_c, matches_i
3334                        )
3335
3336                        # Record if we used alignment for this sample
3337                        sample_name = self.samples[i]
3338                        self._manifest_dict[sample_name]["use_rt_alignment"] = (
3339                            use_spline_alignment
3340                        )
3341
3342                        if use_spline_alignment:
3343                            # Set new retention times on scan_df for lc_obj using the spline fitting
3344                            matches_i["scan_time_fit"] = spl(matches_i["scan_time"])
3345
3346                        self.rt_aligned = _set_scan_time_alignment_for_sample(
3347                            i, use_spline_alignment, spl
3348                        )
3349                        self.rt_alignment_attempted = True
3350
3351                        i, mf_df_i = _get_feature_df_at_or_after(
3352                            i + index_step,
3353                            index_step,
3354                            use_spline_alignment,
3355                            spl,
3356                        )
3357                    else:
3358                        # If no matches are found, propagate prior alignment from this index step.
3359                        sample_name = self.samples[i]
3360                        used_previous_alignment = (
3361                            use_spline_alignment and spl is not None
3362                        )
3363                        self._manifest_dict[sample_name]["use_rt_alignment"] = (
3364                            used_previous_alignment
3365                        )
3366
3367                        self.rt_aligned = _set_scan_time_alignment_for_sample(
3368                            i, used_previous_alignment, spl
3369                        )
3370                        self.rt_alignment_attempted = True
3371
3372                        i, mf_df_i = _get_feature_df_at_or_after(
3373                            i + index_step,
3374                            index_step,
3375                            used_previous_alignment,
3376                            spl,
3377                        )
3378
3379        # Now align each batch using the center objects as anchors with the other batches
3380        mf_df_c = anchor_mf_dfs[0]
3381        for i in center_obj_ids[1:]:
3382            mf_df_i = full_mf_df.loc[self.samples[i]].copy()
3383            mf_df_i["scan_time_og"] = mf_df_i["scan_time"]
3384            mf_df_i = self.get_anchor_mass_features(mf_df_i)
3385
3386            matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i)
3387            if matches_c is not None:
3388                use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i)
3389
3390                # Record if we used alignment for this sample
3391                sample_name = self.samples[i]
3392                self._manifest_dict[sample_name]["use_rt_alignment"] = (
3393                    use_spline_alignment
3394                )
3395
3396                if use_spline_alignment:
3397                    # Set new retention times on all this object's
3398                    new_times = spl(self[i].scan_df["scan_time"])
3399                    new_scan_info = self[i].scan_df.copy()
3400                    new_scan_info["scan_time_aligned"] = new_times
3401                    self[i].scan_df = new_scan_info
3402
3403                    # Get the batch that this object belongs to
3404                    batch = self.manifest[self.samples[i]]["batch"]
3405
3406                    for j in range(len(self)):
3407                        if self.manifest[self.samples[j]]["batch"] == batch:
3408                            if j != i:
3409                                sample_name_j = self.samples[j]
3410                                self._manifest_dict[sample_name_j][
3411                                    "use_rt_alignment"
3412                                ] = use_spline_alignment
3413                                new_scan_info = self[j].scan_df.copy()
3414                                aligned_times = spl(
3415                                    self[j].scan_df["scan_time_aligned"]
3416                                )
3417                                new_scan_info["scan_time_aligned"] = aligned_times
3418                                self[j].scan_df = new_scan_info
3419
3420        # Set final mass_features_dataframe with the aligned scan_time
3421        center_sample_name = self.samples[center_obj_ids[0]]
3422        self._manifest_dict[center_sample_name]["use_rt_alignment"] = False
3423        new_scan_info = self[center_obj_ids[0]].scan_df.copy()
3424        new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"]

Align LCMS objects in the collection.

Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects are then fit to the center LCMS object using the matched mass features.

Returns
  • None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object.
Notes

This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos

def add_consensus_mass_features(self):
3426    def add_consensus_mass_features(self):
3427        """
3428        Create consensus mass features by clustering aligned features across samples.
3429
3430        This method clusters mass features from all samples in the collection based on
3431        their m/z and aligned retention time proximity. Features that cluster together
3432        across samples are assigned a common cluster ID, creating consensus features
3433        that represent the same compound detected across multiple samples.
3434
3435        The clustering process:
3436        1. Partitions features by m/z to avoid large sparse matrices and enable parallelization
3437        2. Clusters features within each partition using hierarchical clustering
3438        3. Merges partition-boundary clusters that represent the same feature
3439        4. Filters out clusters not present in minimum fraction of samples
3440
3441        Must be run after align_lcms_objects(). Results are stored in the
3442        mass_features_dataframe with a 'cluster' column added.
3443
3444        Parameters
3445        ----------
3446        None
3447            Uses parameters from self.parameters.lcms_collection:
3448            - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm)
3449            - consensus_rt_tol: retention time tolerance for clustering (minutes)
3450            - consensus_partition_size: target partition size for managing memory and parallelization
3451            - consensus_min_sample_fraction: minimum fraction of samples a cluster
3452              must appear in to be retained (0-1)
3453            - cores: number of CPU cores to use for parallel partition processing
3454
3455        Returns
3456        -------
3457        None
3458            Updates self.mass_features_dataframe in place by adding 'cluster' column
3459            and filtering to retain only clusters meeting minimum sample presence.
3460
3461        Raises
3462        ------
3463        ValueError
3464            If mass features have not been aligned (run align_lcms_objects() first).
3465
3466        Notes
3467        -----
3468        - Partitioning prevents memory issues with large sparse distance matrices
3469        - Each partition is processed in parallel (up to cores limit)
3470        - Clusters not meeting consensus_min_sample_fraction are automatically removed
3471        - Access cluster_summary_dataframe property for summary statistics
3472        - Use fill_missing_cluster_features() for gap-filling after clustering
3473
3474        See Also
3475        --------
3476        align_lcms_objects : Aligns retention times before consensus clustering
3477        cluster_summary_dataframe : Property that generates summary statistics for clusters
3478        fill_missing_cluster_features : Gap-fill missing features in clusters
3479        """
3480        # Get the combined mass features from all LCMS objects, keep the original index as a separate column
3481        combined_mfs = self.mass_features_dataframe.copy()
3482        combined_mfs["coll_mf_id"] = combined_mfs.index
3483
3484        # Check if the mass features have been aligned
3485        if "scan_time_aligned" not in combined_mfs.columns:
3486            raise ValueError(
3487                "Mass features have not been aligned, run align_lcms_objects() first"
3488            )
3489
3490        # Partition the mass features by mz so we can parallelize the matching before clustering
3491        from corems.chroma_peak.calc import subset as corems_subset
3492
3493        # get max mz from combined_mfs and calculate tolerance from ppm
3494        mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
3495        n_partition_size = self.parameters.lcms_collection.consensus_partition_size
3496        lazy_partitions = corems_subset.multi_sample_partition(
3497            combined_mfs,
3498            split_on="mz",
3499            size=n_partition_size,
3500            tol=mz_tol,
3501            relative=True,
3502        )
3503
3504        # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning
3505        if np.array(lazy_partitions._counts).max() > 2 * n_partition_size:
3506            warnings.warn(
3507                "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol."
3508            )
3509
3510        # Cluster the mass features within each partition
3511        if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions:
3512            cores_to_use = lazy_partitions.n_partitions
3513        else:
3514            cores_to_use = self.parameters.lcms_collection.cores
3515        # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use)
3516        mfs_with_clusters = lazy_partitions.map(
3517            self.cluster_mass_features_agg_cluster, processes=cores_to_use
3518        )
3519
3520        # Clean up cluster id names after partitioning
3521        new_cluster_ids = (
3522            mfs_with_clusters[["cluster", "partition_idx"]]
3523            .drop_duplicates()
3524            .reset_index(drop=True)
3525        )
3526        new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index
3527        mfs_with_clusters = mfs_with_clusters.merge(
3528            new_cluster_ids, on=["cluster", "partition_idx"]
3529        )
3530        mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"]
3531        mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"])
3532
3533        # Embed a new cluster id into the mass features dataframe and set as index
3534        mfs_with_clusters["idx"] = mfs_with_clusters.index
3535
3536        try:
3537            # Check if any clusters can be merged into a single cluster
3538            eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters)
3539
3540            # Merge clusters identified in eval_dict
3541            while len(eval_dict["merge_these_clusters"]) > 0:
3542                list_of_clusters_to_merge = [
3543                    [x[0], x[1]] for x in eval_dict["merge_these_clusters"]
3544                ]
3545                # Convert to a dataframe with columns "new_cluster" and "cluster"
3546                df = pd.DataFrame(
3547                    np.array(list_of_clusters_to_merge),
3548                    columns=["new_cluster", "cluster"],
3549                )
3550                # Drop duplicates of "child" clusters
3551                df = df.drop_duplicates("cluster", keep="first")
3552                df = df.drop_duplicates("new_cluster", keep="first")
3553                mfs_with_clusters = mfs_with_clusters.merge(
3554                    df, on="cluster", how="left"
3555                )
3556                mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna(
3557                    mfs_with_clusters["cluster"]
3558                )
3559                mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"])
3560
3561                # Re-evaluate clusters for repeats
3562                eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters)
3563                self.mass_features_dataframe = mfs_with_clusters
3564
3565        except:
3566            mfs_with_clusters.set_index("coll_mf_id", inplace=True)
3567            self.mass_features_dataframe = mfs_with_clusters
3568
3569        # Filter out clusters that don't meet minimum sample fraction
3570        self._filter_clusters_by_sample_presence()
3571
3572        # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template?

Create consensus mass features by clustering aligned features across samples.

This method clusters mass features from all samples in the collection based on their m/z and aligned retention time proximity. Features that cluster together across samples are assigned a common cluster ID, creating consensus features that represent the same compound detected across multiple samples.

The clustering process:

  1. Partitions features by m/z to avoid large sparse matrices and enable parallelization
  2. Clusters features within each partition using hierarchical clustering
  3. Merges partition-boundary clusters that represent the same feature
  4. Filters out clusters not present in minimum fraction of samples

Must be run after align_lcms_objects(). Results are stored in the mass_features_dataframe with a 'cluster' column added.

Parameters
  • None: Uses parameters from self.parameters.lcms_collection:
    • consensus_mz_tol_ppm: m/z tolerance for clustering (ppm)
    • consensus_rt_tol: retention time tolerance for clustering (minutes)
    • consensus_partition_size: target partition size for managing memory and parallelization
    • consensus_min_sample_fraction: minimum fraction of samples a cluster must appear in to be retained (0-1)
    • cores: number of CPU cores to use for parallel partition processing
Returns
  • None: Updates self.mass_features_dataframe in place by adding 'cluster' column and filtering to retain only clusters meeting minimum sample presence.
Raises
  • ValueError: If mass features have not been aligned (run align_lcms_objects() first).
Notes
  • Partitioning prevents memory issues with large sparse distance matrices
  • Each partition is processed in parallel (up to cores limit)
  • Clusters not meeting consensus_min_sample_fraction are automatically removed
  • Access cluster_summary_dataframe property for summary statistics
  • Use fill_missing_cluster_features() for gap-filling after clustering
See Also

align_lcms_objects: Aligns retention times before consensus clustering
cluster_summary_dataframe: Property that generates summary statistics for clusters
fill_missing_cluster_features: Gap-fill missing features in clusters

def summarize_clusters(self):
3624    def summarize_clusters(self):
3625        """
3626        Generate summary statistics for consensus mass feature clusters.
3627
3628        Computes aggregate statistics (median, mean, std, min, max) for each cluster
3629        across all samples. Combines both regular mass features and induced mass features
3630        (from gap-filling) when available to provide complete cluster statistics.
3631
3632        Must be run after add_consensus_mass_features() which creates the cluster assignments.
3633        Results are stored in cluster_summary_dataframe property and used by plotting methods.
3634
3635        Parameters
3636        ----------
3637        None
3638            Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe.
3639            Both must contain 'cluster' column.
3640
3641        Returns
3642        -------
3643        :obj:`~pandas.DataFrame` or None
3644            DataFrame with one row per cluster containing summary statistics:
3645            - cluster: cluster ID
3646            - mz_{median,mean,std,max,min}: m/z statistics
3647            - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics
3648            - half_height_width_{median,mean,std,max,min}: peak width statistics
3649            - tailing_factor_{median,mean,std,max,min}: peak shape statistics
3650            - dispersity_index_{median,mean,std,max,min}: peak quality statistics
3651            - sample_id_nunique: number of unique samples containing the cluster
3652            - intensity_{max,median,mean,std,min}: intensity statistics
3653            - persistence_{max,median,mean,std,min}: persistence statistics
3654
3655            Returns None if mass_features_dataframe is empty.
3656
3657        Notes
3658        -----
3659        - Summary DataFrame is automatically stored in cluster_summary_dataframe property
3660        - Includes both regular and induced (gap-filled) mass features when available.
3661          ``group_consensus_features`` does not use this m/z median for ion-role
3662          geometry; that median is detected features only.
3663        - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster
3664        - Sample count (sample_id_nunique) indicates cluster prevalence across samples
3665        - Filters applied by consensus_min_sample_fraction affect which clusters appear
3666
3667        See Also
3668        --------
3669        add_consensus_mass_features : Creates clusters before summarization
3670        fill_missing_cluster_features : Creates induced mass features via gap-filling
3671        plot_consensus_mz_features : Visualizes cluster summaries
3672        plot_mz_features_per_cluster : Shows cluster size distribution
3673        """
3674        # First check if there are minimum columns in the features dataframe
3675        if (
3676            self.mass_features_dataframe is None
3677            or len(self.mass_features_dataframe.columns) < 1
3678        ):
3679            return None
3680
3681        # Combine regular and induced mass features
3682        mf_df = self.mass_features_dataframe.copy()
3683        mf_df = mf_df.reset_index(drop=False)
3684
3685        # Check if induced mass features are available and combine them
3686        if (
3687            self.induced_mass_features_dataframe is not None
3688            and len(self.induced_mass_features_dataframe) > 0
3689        ):
3690            imf_df = self.induced_mass_features_dataframe.copy()
3691            imf_df = imf_df.reset_index(drop=False)
3692            # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination
3693            # Combine regular and induced features
3694            mf_df = pd.concat([mf_df, imf_df], axis=0)
3695            mf_df = mf_df.reset_index(drop=True)
3696
3697        # Filter out any rows with NaN cluster values before converting to int
3698        if "cluster" in mf_df.columns:
3699            mf_df = mf_df.dropna(subset=["cluster"])
3700            mf_df["cluster"] = mf_df["cluster"].astype(int)
3701
3702        # Build aggregation dictionary based on available columns
3703        agg_dict = {
3704            "mz": ["median", "mean", "std", "max", "min"],
3705            "scan_time_aligned": ["median", "mean", "std", "max", "min"],
3706            "sample_id": ["nunique"],
3707            "intensity": ["max", "median", "mean", "std", "min"],
3708        }
3709
3710        # Add optional columns if they exist
3711        optional_columns = {
3712            "half_height_width": ["median", "mean", "std", "max", "min"],
3713            "tailing_factor": ["median", "mean", "std", "max", "min"],
3714            "dispersity_index": ["median", "mean", "std", "max", "min"],
3715            "persistence": ["max", "median", "mean", "std", "min"],
3716        }
3717
3718        for col, funcs in optional_columns.items():
3719            if col in mf_df.columns:
3720                agg_dict[col] = funcs
3721
3722        summary_df = mf_df.groupby("cluster").agg(agg_dict).reset_index()
3723
3724        # Fix the column names
3725        summary_df.columns = [
3726            "_".join(col).strip()
3727            for col in summary_df.columns.values
3728            if col != "cluster"
3729        ]
3730        summary_df = summary_df.rename(columns={"cluster_": "cluster"})
3731        # Set cluster as the index for easy lookup
3732        summary_df = summary_df.set_index("cluster")
3733
3734        # Merge consensus feature-group labels when present (isotopes + adducts).
3735        # Cluster summary exposes possible_ion_types (all
3736        # residual-tied candidates) and drops ion_type so a single type column
3737        # is not mistaken for unique assignment.
3738        fg = getattr(self, "feature_group_dataframe", None)
3739        if fg is not None and len(fg) > 0:
3740            from corems.mass_spectra.calc.feature_grouping import (
3741                GROUP_COLUMNS,
3742                merge_possible_ion_types,
3743            )
3744
3745            cols = [c for c in GROUP_COLUMNS if c in fg.columns]
3746            if cols:
3747                drop_cols = [c for c in cols if c in summary_df.columns]
3748                if drop_cols:
3749                    summary_df = summary_df.drop(columns=drop_cols)
3750                # Also drop stale ion_type from a prior join shape
3751                if "ion_type" in summary_df.columns:
3752                    summary_df = summary_df.drop(columns=["ion_type"])
3753                summary_df = summary_df.join(fg[cols], how="left")
3754
3755            if (
3756                "possible_ion_types" in summary_df.columns
3757                and "ion_type" in summary_df.columns
3758            ):
3759                for cid in summary_df.index:
3760                    it = summary_df.loc[cid, "ion_type"]
3761                    poss = summary_df.loc[cid, "possible_ion_types"]
3762                    filled = merge_possible_ion_types(it, poss)
3763                    if filled is not None:
3764                        summary_df.loc[cid, "possible_ion_types"] = filled
3765            if "ion_type" in summary_df.columns:
3766                summary_df = summary_df.drop(columns=["ion_type"])
3767
3768        return summary_df

Generate summary statistics for consensus mass feature clusters.

Computes aggregate statistics (median, mean, std, min, max) for each cluster across all samples. Combines both regular mass features and induced mass features (from gap-filling) when available to provide complete cluster statistics.

Must be run after add_consensus_mass_features() which creates the cluster assignments. Results are stored in cluster_summary_dataframe property and used by plotting methods.

Parameters
  • None: Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. Both must contain 'cluster' column.
Returns
  • ~pandas.DataFrame or None: DataFrame with one row per cluster containing summary statistics:
    • cluster: cluster ID
    • mz_{median,mean,std,max,min}: m/z statistics
    • scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics
    • half_height_width_{median,mean,std,max,min}: peak width statistics
    • tailing_factor_{median,mean,std,max,min}: peak shape statistics
    • dispersity_index_{median,mean,std,max,min}: peak quality statistics
    • sample_id_nunique: number of unique samples containing the cluster
    • intensity_{max,median,mean,std,min}: intensity statistics
    • persistence_{max,median,mean,std,min}: persistence statistics

Returns None if mass_features_dataframe is empty.

Notes
  • Summary DataFrame is automatically stored in cluster_summary_dataframe property
  • Includes both regular and induced (gap-filled) mass features when available. group_consensus_features does not use this m/z median for ion-role geometry; that median is detected features only.
  • Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster
  • Sample count (sample_id_nunique) indicates cluster prevalence across samples
  • Filters applied by consensus_min_sample_fraction affect which clusters appear
See Also

add_consensus_mass_features: Creates clusters before summarization
fill_missing_cluster_features: Creates induced mass features via gap-filling
plot_consensus_mz_features: Visualizes cluster summaries
plot_mz_features_per_cluster: Shows cluster size distribution

def group_consensus_features(self):
3770    def group_consensus_features(self):
3771        """
3772        Group consensus features into natural-abundance isotope and adduct families.
3773
3774        After consensus clustering and gap-filling, labels clusters that share
3775        similar retention time, an interpretable **singly-charged** mass offset,
3776        and correlated cross-sample **apex peak heights**.
3777
3778        Two stages
3779        ----------
3780        1. **Isotopes.** Unit ``Atoms`` edges (e.g. ¹²C/¹³C) plus roll-up to
3781           ¹³C₂, ¹³C₃, … when intermediates exist. Spacing is the raw Atoms
3782           Δm (``|z| = 1``). Multi-charge envelopes are out of scope.
3783           Chemical mono is the geometry root, not the tallest peak.
3784        2. **Adducts.** Pairwise ``ion_type_dict`` forms in
3785           ``feature_group_ion_types`` (must be ``|z| = 1``). Empty or a
3786           single configured type skips adduct linking (isotopes only).
3787
3788        **Not** for tracer, enriched, or isotopically labeled experiments.
3789
3790        Quant gate is fixed (no settings switch):
3791
3792        - Correlation: **Pearson** only, pairwise-complete on samples with both
3793          heights > 0 (threshold ``feature_group_corr_threshold``, default 0.80)
3794        - Abundance: mass-feature apex ``intensity`` only (not integrated area)
3795
3796        Coelution and m/z delta windows reuse collection
3797        ``alignment_rt_tol`` and ``alignment_mz_tol_ppm``. Other knobs
3798        (``feature_group_isotope_atoms``, shared-sample fraction,
3799        ``feature_group_ion_types``, …) live on ``parameters.lcms_collection``.
3800
3801        ``feature_group_ion_types`` is filtered to the collection polarity
3802        (all samples share one polarity; mixed collections cannot be
3803        instantiated). Only keys ending in ``+`` are kept on positive data
3804        and only keys ending in ``-`` on negative data.
3805
3806        Preferred for collection-level families. Optional per-file
3807        ``LCMSBase.find_c13_mass_features`` remains available for single-file
3808        workflows; do not treat both as authoritative in the same pipeline.
3809
3810        Must run after ``add_consensus_mass_features()``. The m/z median used for
3811        isotope and adduct spacing is calculated from detected features only.
3812        Gap-filled m/z values stay on the induced rows and do not enter that
3813        median. The height matrix still includes gap-filled apex intensities
3814        when present; missing entries are 0.
3815
3816        Returns
3817        -------
3818        pandas.DataFrame
3819            Cluster-indexed labels with columns ``feature_group_id``,
3820            ``ion_role``, ``ion_type``, ``possible_ion_types``,
3821            ``isotope_state``, ``mono_cluster_id`` (set only when the form
3822            has at least one isotope). Also stored on
3823            ``self.feature_group_dataframe`` and merged into
3824            ``mass_features_dataframe`` / ``induced_mass_features_dataframe``.
3825
3826        Raises
3827        ------
3828        ValueError
3829            If consensus features are missing or settings are invalid.
3830        """
3831        from corems.mass_spectra.calc.feature_grouping import (
3832            GROUP_COLUMNS,
3833            HEIGHT_COL,
3834            FeatureGroupParams,
3835            build_height_matrix_from_features,
3836            detected_cluster_mz_median,
3837            group_features_arrays,
3838            normalize_ms_polarity,
3839        )
3840
3841        if (
3842            self.mass_features_dataframe is None
3843            or len(self.mass_features_dataframe) == 0
3844        ):
3845            raise ValueError(
3846                "No mass features dataframe. Run add_consensus_mass_features() first."
3847            )
3848        if "cluster" not in self.mass_features_dataframe.columns:
3849            raise ValueError(
3850                "mass_features_dataframe has no 'cluster' column. "
3851                "Run add_consensus_mass_features() first."
3852            )
3853
3854        # Clear prior labels (idempotent re-run)
3855        self.feature_group_dataframe = None
3856        for col in GROUP_COLUMNS:
3857            if col in self.mass_features_dataframe.columns:
3858                self.mass_features_dataframe = self.mass_features_dataframe.drop(
3859                    columns=[col]
3860                )
3861            if (
3862                self.induced_mass_features_dataframe is not None
3863                and col in self.induced_mass_features_dataframe.columns
3864            ):
3865                self.induced_mass_features_dataframe = (
3866                    self.induced_mass_features_dataframe.drop(columns=[col])
3867                )
3868
3869        summary = self.summarize_clusters()
3870        if summary is None or len(summary) == 0:
3871            raise ValueError(
3872                "cluster summary is empty. Run add_consensus_mass_features() first."
3873            )
3874
3875        # Collection polarity: all samples share one polarity (enforced when
3876        # LCMSCollection is loaded). Filter opposite-sign adducts.
3877        collection_polarity = None
3878        if len(self):
3879            collection_polarity = normalize_ms_polarity(
3880                getattr(self[0], "polarity", None)
3881            )
3882
3883        params = FeatureGroupParams.from_lcms_collection_settings(
3884            self.parameters.lcms_collection,
3885            polarity=collection_polarity,
3886        )
3887
3888        cluster_ids = summary.index.to_numpy()
3889        # Gap-filled apex m/z is not part of ion-role geometry.
3890        mz = detected_cluster_mz_median(self.mass_features_dataframe, cluster_ids)
3891        if "scan_time_aligned_median" not in summary.columns:
3892            raise ValueError(
3893                "cluster summary missing scan_time_aligned_median; "
3894                "ensure features are RT-aligned before consensus."
3895            )
3896        rt = summary["scan_time_aligned_median"].to_numpy(dtype=float)
3897
3898        # Sample ids as used in mass_features_dataframe
3899        sample_ids = sorted(self.mass_features_dataframe["sample_id"].dropna().unique())
3900        if self.induced_mass_features_dataframe is not None and len(
3901            self.induced_mass_features_dataframe
3902        ):
3903            sample_ids = sorted(
3904                set(sample_ids)
3905                | set(
3906                    self.induced_mass_features_dataframe["sample_id"]
3907                    .dropna()
3908                    .unique()
3909                    .tolist()
3910                )
3911            )
3912
3913        t_height = time.perf_counter()
3914        heights = build_height_matrix_from_features(
3915            self.mass_features_dataframe,
3916            cluster_ids=cluster_ids,
3917            sample_ids=sample_ids,
3918            induced_df=self.induced_mass_features_dataframe,
3919            intensity_col=HEIGHT_COL,  # apex intensity only; not area
3920        )
3921        stage_timings = {
3922            "height_matrix": time.perf_counter() - t_height,
3923            "n_ion_types_configured": float(len(params.ion_types)),
3924            "collection_polarity": (
3925                str(collection_polarity) if collection_polarity else "unknown"
3926            ),
3927        }
3928
3929        algo_timings: dict = {}
3930        labels = group_features_arrays(
3931            cluster_ids, mz, rt, heights, params, timings_out=algo_timings
3932        )
3933        stage_timings.update(algo_timings)
3934        self._last_feature_group_timings = stage_timings
3935        self.feature_group_dataframe = labels
3936
3937        from corems.mass_spectra.calc.feature_grouping import (
3938            merge_feature_group_labels_into_frames,
3939        )
3940
3941        self.mass_features_dataframe, induced = merge_feature_group_labels_into_frames(
3942            labels,
3943            self.mass_features_dataframe,
3944            self.induced_mass_features_dataframe,
3945        )
3946        if self.induced_mass_features_dataframe is not None:
3947            self.induced_mass_features_dataframe = induced
3948
3949        return labels

Group consensus features into natural-abundance isotope and adduct families.

After consensus clustering and gap-filling, labels clusters that share similar retention time, an interpretable singly-charged mass offset, and correlated cross-sample apex peak heights.

Two stages
  1. Isotopes. Unit Atoms edges (e.g. ¹²C/¹³C) plus roll-up to ¹³C₂, ¹³C₃, … when intermediates exist. Spacing is the raw Atoms Δm (|z| = 1). Multi-charge envelopes are out of scope. Chemical mono is the geometry root, not the tallest peak.
  2. Adducts. Pairwise ion_type_dict forms in feature_group_ion_types (must be |z| = 1). Empty or a single configured type skips adduct linking (isotopes only).

Not for tracer, enriched, or isotopically labeled experiments.

Quant gate is fixed (no settings switch):

  • Correlation: Pearson only, pairwise-complete on samples with both heights > 0 (threshold feature_group_corr_threshold, default 0.80)
  • Abundance: mass-feature apex intensity only (not integrated area)

Coelution and m/z delta windows reuse collection alignment_rt_tol and alignment_mz_tol_ppm. Other knobs (feature_group_isotope_atoms, shared-sample fraction, feature_group_ion_types, …) live on parameters.lcms_collection.

feature_group_ion_types is filtered to the collection polarity (all samples share one polarity; mixed collections cannot be instantiated). Only keys ending in + are kept on positive data and only keys ending in - on negative data.

Preferred for collection-level families. Optional per-file LCMSBase.find_c13_mass_features remains available for single-file workflows; do not treat both as authoritative in the same pipeline.

Must run after add_consensus_mass_features(). The m/z median used for isotope and adduct spacing is calculated from detected features only. Gap-filled m/z values stay on the induced rows and do not enter that median. The height matrix still includes gap-filled apex intensities when present; missing entries are 0.

Returns
  • pandas.DataFrame: Cluster-indexed labels with columns feature_group_id, ion_role, ion_type, possible_ion_types, isotope_state, mono_cluster_id (set only when the form has at least one isotope). Also stored on self.feature_group_dataframe and merged into mass_features_dataframe / induced_mass_features_dataframe.
Raises
  • ValueError: If consensus features are missing or settings are invalid.
def plot_mz_features_per_cluster(self, return_fig=False, path=None):
3951    def plot_mz_features_per_cluster(self, return_fig=False, path=None):
3952        """
3953        Plot the number of mass features in a cluster against how many clusters
3954        contain that number of mass features
3955
3956        Parameters
3957        -----------
3958        return_fig : bool, optional
3959            If True, return the open figure (caller owns lifecycle).
3960            Default is False.
3961        path : str or path-like, optional
3962            If set, save the figure to this path. When ``return_fig`` is False,
3963            the figure is closed after saving and ``plt.show()`` is not called.
3964
3965        Returns
3966        --------
3967        matplotlib.figure.Figure or None
3968            The figure if ``return_fig`` is True; otherwise None.
3969
3970        Raises
3971        ------
3972        ValueError
3973            If consensus features haven't been added to the object yet
3974        """
3975
3976        if not hasattr(self, "cluster_summary_dataframe"):
3977            raise ValueError(
3978                "cluster_summary_dataframe is not set, must run add_consensus_mass_features() first"
3979            )
3980        sum_data = self.cluster_summary_dataframe
3981        fig, ax = plt.subplots()
3982        sum_data.sample_id_nunique.value_counts().sort_index().plot(ax=ax, kind="bar")
3983        plt.xlabel("Number of mass features in a cluster")
3984        plt.ylabel("Number of clusters with this many mass features")
3985        return _finalize_plot(fig, return_fig=return_fig, path=path)

Plot the number of mass features in a cluster against how many clusters contain that number of mass features

Parameters
  • return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
  • path (str or path-like, optional): If set, save the figure to this path. When return_fig is False, the figure is closed after saving and plt.show() is not called.
Returns
  • matplotlib.figure.Figure or None: The figure if return_fig is True; otherwise None.
Raises
  • ValueError: If consensus features haven't been added to the object yet
def plot_mz_features_across_samples(self, alpha=0.75, s=0.005, return_fig=False, path=None):
3987    def plot_mz_features_across_samples(
3988        self, alpha=0.75, s=0.005, return_fig=False, path=None
3989    ):
3990        """
3991        Generate Scan Time vs m/z plot of all the mass features across all
3992        samples in collection where intensity of color on the plot indicates
3993        density of mass features, NOT INTENSITY
3994
3995        Parameters
3996        -----------
3997        alpha :  float
3998            Desired transparency for plotted m/z features.  Defaults to 0.75.
3999        s : float
4000            Desired size of plotted m/z features. Defaults to 0.005.
4001        return_fig : bool, optional
4002            If True, return the open figure (caller owns lifecycle).
4003            Default is False.
4004        path : str or path-like, optional
4005            If set, save the figure to this path. When ``return_fig`` is False,
4006            the figure is closed after saving and ``plt.show()`` is not called.
4007
4008        Returns
4009        --------
4010        matplotlib.figure.Figure or None
4011            A figure displaying a scan time vs m/z scatterplot of all the m/z
4012            features identified in the collection if ``return_fig`` is True;
4013            otherwise None. Parameters alpha (transparency) and s (marker size)
4014            allow the user to emphasize the density of features. Intensity of
4015            features is not represented.
4016        """
4017        df = self.mass_features_dataframe.copy()
4018        fig = plt.figure()
4019        plt.scatter(df.scan_time_aligned, df.mz, c="tab:gray", alpha=alpha, s=s)
4020
4021        plt.xlabel("Scan time")
4022        plt.ylabel("m/z")
4023        plt.ylim(0, np.ceil(np.max(df.mz)))
4024        plt.xlim(0, np.ceil(np.max(df.scan_time)))
4025        plt.title("All mass features, all samples")
4026
4027        return _finalize_plot(fig, return_fig=return_fig, path=path)

Generate Scan Time vs m/z plot of all the mass features across all samples in collection where intensity of color on the plot indicates density of mass features, NOT INTENSITY

Parameters
  • alpha (float): Desired transparency for plotted m/z features. Defaults to 0.75.
  • s (float): Desired size of plotted m/z features. Defaults to 0.005.
  • return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
  • path (str or path-like, optional): If set, save the figure to this path. When return_fig is False, the figure is closed after saving and plt.show() is not called.
Returns
  • matplotlib.figure.Figure or None: A figure displaying a scan time vs m/z scatterplot of all the m/z features identified in the collection if return_fig is True; otherwise None. Parameters alpha (transparency) and s (marker size) allow the user to emphasize the density of features. Intensity of features is not represented.
def plot_consensus_mz_features( self, xb='xb', xt='xt', yb='yb', yt='yt', show_all=True, return_fig=False, path=None):
4029    def plot_consensus_mz_features(
4030        self,
4031        xb="xb",
4032        xt="xt",
4033        yb="yb",
4034        yt="yt",
4035        show_all=True,
4036        return_fig=False,
4037        path=None,
4038    ):
4039        """
4040        Generate Scan Time vs m/z plot of the consensus features scaled by size
4041        with option ('show_all') of leaving the individual m/z features in the figure.
4042
4043        Parameters
4044        -----------
4045        xb :  float
4046            Desired starting scan time value for the x-axis. Defaults to 0.
4047        xt : float
4048            Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data.
4049        yb :  float
4050            Desired starting m/z value for the y-axis. Defaults to 0.
4051        yt : float
4052            Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data.
4053        show_all : boolean
4054            Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True.
4055        return_fig : bool, optional
4056            If True, return the open figure (caller owns lifecycle).
4057            Default is False.
4058        path : str or path-like, optional
4059            If set, save the figure to this path. When ``return_fig`` is False,
4060            the figure is closed after saving and ``plt.show()`` is not called.
4061
4062        Returns
4063        --------
4064        matplotlib.figure.Figure or None
4065            A scalable figure that overlays the consensus features over all the
4066            m/z features identified in the collection if ``return_fig`` is True;
4067            otherwise None. Consensus features are scaled by how many m/z
4068            features are represented in the consensus. Figure can be scaled by
4069            inputting desired boundaries on the scan time (xb, xt) and m/z
4070            values (yb, yt).
4071        """
4072        df = self.cluster_summary_dataframe.copy()
4073        mfdf = self.mass_features_dataframe.copy()
4074
4075        fig = plt.figure()
4076        if show_all:
4077            plt.scatter(mfdf.scan_time_aligned, mfdf.mz, c="tab:gray", s=1)
4078
4079        m = plt.scatter(
4080            df.scan_time_aligned_median,
4081            df.mz_median,
4082            c="tab:orange",
4083            alpha=0.7,
4084            s=(df.sample_id_nunique**2) / 5,
4085        )
4086
4087        plt.xlabel("Scan time")
4088        plt.ylabel("m/z")
4089
4090        if xt == "xt":
4091            xt = np.ceil(np.max(mfdf.mz))
4092        if yt == "yt":
4093            yt = np.ceil(np.max(mfdf.scan_time))
4094        if xb == "xb":
4095            xb = 0
4096        if yb == "yb":
4097            yb = 0
4098        plt.ylim(xb, xt)
4099        plt.xlim(yb, yt)
4100
4101        kw = dict(
4102            prop="sizes",
4103            num=max(1, int(len(df.sample_id_nunique.unique()) / 3)),
4104            color="tab:orange",
4105            alpha=0.7,
4106            func=lambda s: np.sqrt(s * 5),
4107        )
4108
4109        plt.legend(
4110            *m.legend_elements(**kw),
4111            title="Features\nper cluster",
4112            bbox_to_anchor=(1.01, 0.4, 0.225, 0.5),
4113        )
4114        plt.tight_layout()
4115        plt.title("Consensus Features")
4116
4117        return _finalize_plot(fig, return_fig=return_fig, path=path)

Generate Scan Time vs m/z plot of the consensus features scaled by size with option ('show_all') of leaving the individual m/z features in the figure.

Parameters
  • xb (float): Desired starting scan time value for the x-axis. Defaults to 0.
  • xt (float): Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data.
  • yb (float): Desired starting m/z value for the y-axis. Defaults to 0.
  • yt (float): Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data.
  • show_all (boolean): Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True.
  • return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
  • path (str or path-like, optional): If set, save the figure to this path. When return_fig is False, the figure is closed after saving and plt.show() is not called.
Returns
  • matplotlib.figure.Figure or None: A scalable figure that overlays the consensus features over all the m/z features identified in the collection if return_fig is True; otherwise None. Consensus features are scaled by how many m/z features are represented in the consensus. Figure can be scaled by inputting desired boundaries on the scan time (xb, xt) and m/z values (yb, yt).
def plot_cluster( self, cluster_id, to_plot=['EIC', 'MS1', 'MS2'], return_fig=False, path=None, plot_smoothed_eic=False, plot_eic_datapoints=False, eic_buffer_time=None, label_samples=False, molecular_metadata=None, spectral_library=None):
4119    def plot_cluster(
4120        self,
4121        cluster_id,
4122        to_plot=["EIC", "MS1", "MS2"],
4123        return_fig=False,
4124        path=None,
4125        plot_smoothed_eic=False,
4126        plot_eic_datapoints=False,
4127        eic_buffer_time=None,
4128        label_samples=False,
4129        molecular_metadata=None,
4130        spectral_library=None,
4131    ):
4132        """
4133        Plot a consensus mass feature cluster across all samples.
4134
4135        Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster,
4136        highlighting the representative mass feature.
4137
4138        Parameters
4139        ----------
4140        cluster_id : int
4141            The cluster ID to plot
4142        to_plot : list, optional
4143            List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror".
4144            Default is ["EIC", "MS1", "MS2"].
4145        return_fig : bool, optional
4146            If True, return the open figure (caller owns lifecycle).
4147            Default is False.
4148        path : str or path-like, optional
4149            If set, save the figure to this path. When ``return_fig`` is False,
4150            the figure is closed after saving and ``plt.show()`` is not called.
4151        plot_smoothed_eic : bool, optional
4152            If True, plots smoothed EICs. Default is False.
4153        plot_eic_datapoints : bool, optional
4154            If True, plots EIC data points. Default is False.
4155        eic_buffer_time : float, optional
4156            Time buffer around the peak for EIC plotting (minutes).
4157            If None, uses parameter setting. Default is None.
4158        label_samples : bool, optional
4159            If True, labels each sample in the legend. Default is False.
4160        molecular_metadata : dict, optional
4161            Dictionary mapping molecular IDs to MetaboliteMetadata objects.
4162            Required for MS2_mirror plots. Default is None.
4163        spectral_library : FlashEntropySearch, optional
4164            FlashEntropy spectral library containing MS2 spectra.
4165            Required for MS2_mirror plots to retrieve library spectra. Default is None.
4166
4167        Returns
4168        -------
4169        matplotlib.figure.Figure or None
4170            The figure object if return_fig=True, otherwise None
4171
4172        Raises
4173        ------
4174        ValueError
4175            If cluster_id is not found or if required data is not loaded
4176        """
4177        import matplotlib.pyplot as plt
4178
4179        # Get cluster summary for median values
4180        if cluster_id not in self.cluster_summary_dataframe.index:
4181            raise ValueError(
4182                f"Cluster {cluster_id} not found in cluster_summary_dataframe. "
4183                f"Run add_consensus_mass_features() first."
4184            )
4185
4186        cluster_summary = self.cluster_summary_dataframe.loc[cluster_id]
4187
4188        # Get representative mass feature info
4189        rep_info = self.get_most_representative_sample_for_cluster(cluster_id)
4190        rep_sample_id = rep_info["sample_id"]
4191        rep_mf_id = rep_info["mf_id"]
4192        rep_sample = self[rep_sample_id]
4193
4194        # Check if representative mass feature is loaded
4195        if rep_mf_id not in rep_sample.mass_features:
4196            raise ValueError(
4197                f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. "
4198                f"Run reload_representative_mass_features() or process_consensus_features() first."
4199            )
4200
4201        rep_mf = rep_sample.mass_features[rep_mf_id]
4202
4203        # Get eic buffer time
4204        if eic_buffer_time is None:
4205            eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time
4206
4207        # Adjust to_plot based on available data
4208        if rep_mf.mass_spectrum is None:
4209            to_plot = [x for x in to_plot if x != "MS1"]
4210        if len(rep_mf.ms2_mass_spectra) == 0:
4211            to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]]
4212
4213        # Check if EICs are available
4214        cluster_mfs = self.mass_features_dataframe[
4215            self.mass_features_dataframe["cluster"] == cluster_id
4216        ]
4217
4218        has_eics = False
4219        # Check regular features
4220        for _, row in cluster_mfs.iterrows():
4221            sample_id = int(row["sample_id"])
4222            sample = self[sample_id]
4223            if hasattr(sample, "eics") and sample.eics:
4224                if len(sample.eics) > 0:
4225                    has_eics = True
4226                    break
4227
4228        # Also check induced features if available
4229        induced_cluster_mfs = None
4230        if not has_eics and self.induced_mass_features_dataframe is not None:
4231            induced_cluster_mfs = self.induced_mass_features_dataframe[
4232                self.induced_mass_features_dataframe["cluster"] == cluster_id
4233            ]
4234            for _, row in induced_cluster_mfs.iterrows():
4235                sample_id = int(row["sample_id"])
4236                sample = self[sample_id]
4237                if hasattr(sample, "eics") and sample.eics:
4238                    if len(sample.eics) > 0:
4239                        has_eics = True
4240                        break
4241
4242        if not has_eics:
4243            to_plot = [x for x in to_plot if x != "EIC"]
4244            if len(to_plot) == 0:
4245                raise ValueError(
4246                    f"No plottable data available for cluster {cluster_id}. "
4247                    f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first."
4248                )
4249
4250        # Get induced features if not already retrieved
4251        if (
4252            induced_cluster_mfs is None
4253            and self.induced_mass_features_dataframe is not None
4254        ):
4255            induced_cluster_mfs = self.induced_mass_features_dataframe[
4256                self.induced_mass_features_dataframe["cluster"] == cluster_id
4257            ]
4258
4259        # Check if MS1 is deconvoluted
4260        deconvoluted = rep_mf._ms_deconvoluted_idx is not None
4261
4262        # Create figure
4263        fig, axs = plt.subplots(
4264            len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False
4265        )
4266
4267        fig.suptitle(
4268            f"Consensus Cluster {cluster_id}: "
4269            f"m/z = {cluster_summary['mz_median']:.4f} "
4270            f"(±{cluster_summary['mz_std']:.4f}); "
4271            f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min "
4272            f"(±{cluster_summary['scan_time_aligned_std']:.2f}); "
4273            f"{int(cluster_summary['sample_id_nunique'])} samples"
4274        )
4275
4276        i = 0
4277
4278        # EIC plot - show all samples using helper method
4279        if "EIC" in to_plot:
4280            self._plot_multiple_eics(
4281                axs[i][0],
4282                cluster_mfs,
4283                induced_cluster_mfs,
4284                rep_sample_id,
4285                rep_mf_id,
4286                cluster_summary["scan_time_aligned_median"],
4287                eic_buffer_time,
4288                plot_smoothed=plot_smoothed_eic,
4289                plot_datapoints=plot_eic_datapoints,
4290                label_samples=label_samples,
4291                lcms_collection=self,
4292            )
4293            i += 1
4294
4295        # MS1 plot - from representative using helper method
4296        if "MS1" in to_plot:
4297            rep_mf._plot_ms1_spectrum(
4298                axs[i][0], deconvoluted=deconvoluted, sample_name=rep_sample.sample_name
4299            )
4300            i += 1
4301
4302        # MS2 plot - from representative using helper method
4303        if "MS2" in to_plot:
4304            rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name)
4305            i += 1
4306
4307        # MS2 mirror plot - from representative using helper method
4308        if "MS2_mirror" in to_plot:
4309            rep_mf._plot_ms2_mirror(
4310                axs[i][0],
4311                molecular_metadata=molecular_metadata,
4312                spectral_library=spectral_library,
4313            )
4314            i += 1
4315
4316        plt.tight_layout()
4317
4318        return _finalize_plot(fig, return_fig=return_fig, path=path)

Plot a consensus mass feature cluster across all samples.

Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, highlighting the representative mass feature.

Parameters
  • cluster_id (int): The cluster ID to plot
  • to_plot (list, optional): List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". Default is ["EIC", "MS1", "MS2"].
  • return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
  • path (str or path-like, optional): If set, save the figure to this path. When return_fig is False, the figure is closed after saving and plt.show() is not called.
  • plot_smoothed_eic (bool, optional): If True, plots smoothed EICs. Default is False.
  • plot_eic_datapoints (bool, optional): If True, plots EIC data points. Default is False.
  • eic_buffer_time (float, optional): Time buffer around the peak for EIC plotting (minutes). If None, uses parameter setting. Default is None.
  • label_samples (bool, optional): If True, labels each sample in the legend. Default is False.
  • molecular_metadata (dict, optional): Dictionary mapping molecular IDs to MetaboliteMetadata objects. Required for MS2_mirror plots. Default is None.
  • spectral_library (FlashEntropySearch, optional): FlashEntropy spectral library containing MS2 spectra. Required for MS2_mirror plots to retrieve library spectra. Default is None.
Returns
  • matplotlib.figure.Figure or None: The figure object if return_fig=True, otherwise None
Raises
  • ValueError: If cluster_id is not found or if required data is not loaded
def get_representative_mass_features_for_all_clusters(self, representative_metric=None):
4320    def get_representative_mass_features_for_all_clusters(
4321        self, representative_metric=None
4322    ):
4323        """
4324        Get the most representative mass feature for all clusters in bulk.
4325
4326        This is much more efficient than calling get_most_representative_sample_for_cluster
4327        in a loop, as it processes all clusters in a single pass over the dataframe.
4328
4329        Parameters
4330        ----------
4331        representative_metric : str, optional
4332            The metric to use to determine the most representative sample.
4333            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4334            Options:
4335            - 'intensity': Selects the mass feature with the highest intensity
4336            - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans,
4337              or the highest intensity overall if none have MS2
4338            Default is None (uses parameter setting).
4339
4340        Returns
4341        -------
4342        :obj:`~pandas.DataFrame`
4343            DataFrame with one row per cluster containing:
4344            - cluster: cluster ID
4345            - sample_id: The sample ID of the most representative sample
4346            - mf_id: The mass feature ID in the sample
4347            - coll_mf_id: The collection-level mass feature ID (index)
4348            - has_ms2: Whether this mass feature has MS2 scan numbers
4349            - intensity: The intensity value of the representative mass feature
4350        """
4351        # Use default from parameters if not specified
4352        if representative_metric is None:
4353            representative_metric = (
4354                self.parameters.lcms_collection.consensus_representative_metric
4355            )
4356
4357        mf_df = self.mass_features_dataframe.copy()
4358        # Reset index to make coll_mf_id a column we can work with
4359        mf_df = mf_df.reset_index(drop=False)
4360
4361        # Handle special metric 'intensity_prefer_ms2'
4362        if representative_metric == "intensity_prefer_ms2":
4363            if "intensity" not in mf_df.columns:
4364                raise ValueError(
4365                    f"'intensity' column not found in mass_features_dataframe. "
4366                    f"Available columns: {mf_df.columns.tolist()}"
4367                )
4368
4369            # Add has_ms2 flag if ms2_scan_numbers column exists
4370            if "ms2_scan_numbers" in mf_df.columns:
4371
4372                def has_ms2_scans(val):
4373                    if val is None:
4374                        return False
4375                    try:
4376                        return len(val) > 0
4377                    except (TypeError, ValueError):
4378                        return False
4379
4380                mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans)
4381
4382                # Sort by has_ms2 (descending) then intensity (descending)
4383                # This ensures features with MS2 are preferred when intensities are equal
4384                mf_df = mf_df.sort_values(
4385                    ["has_ms2", "intensity"], ascending=[False, False]
4386                )
4387            else:
4388                mf_df["has_ms2"] = False
4389                mf_df = mf_df.sort_values("intensity", ascending=False)
4390
4391            # Group by cluster and take the first (highest intensity, preferring MS2)
4392            representatives = mf_df.groupby("cluster").first().reset_index()
4393
4394        else:
4395            # Standard metric - check if it exists
4396            if representative_metric not in mf_df.columns:
4397                raise ValueError(
4398                    f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}"
4399                )
4400
4401            # Add has_ms2 flag for consistency
4402            if "ms2_scan_numbers" in mf_df.columns:
4403
4404                def has_ms2_scans(val):
4405                    if val is None:
4406                        return False
4407                    try:
4408                        return len(val) > 0
4409                    except (TypeError, ValueError):
4410                        return False
4411
4412                mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans)
4413            else:
4414                mf_df["has_ms2"] = False
4415
4416            # Get the index of max value for each cluster
4417            idx = mf_df.groupby("cluster")[representative_metric].idxmax()
4418            representatives = mf_df.loc[idx].copy()
4419
4420        # Select only the columns we need
4421        result_cols = [
4422            "cluster",
4423            "sample_id",
4424            "mf_id",
4425            "coll_mf_id",
4426            "has_ms2",
4427            "intensity",
4428        ]
4429        representatives = representatives[result_cols]
4430
4431        return representatives

Get the most representative mass feature for all clusters in bulk.

This is much more efficient than calling get_most_representative_sample_for_cluster in a loop, as it processes all clusters in a single pass over the dataframe.

Parameters
  • representative_metric (str, optional): The metric to use to determine the most representative sample. If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. Options:
    • 'intensity': Selects the mass feature with the highest intensity
    • 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, or the highest intensity overall if none have MS2 Default is None (uses parameter setting).
Returns
  • ~pandas.DataFrame: DataFrame with one row per cluster containing:
    • cluster: cluster ID
    • sample_id: The sample ID of the most representative sample
    • mf_id: The mass feature ID in the sample
    • coll_mf_id: The collection-level mass feature ID (index)
    • has_ms2: Whether this mass feature has MS2 scan numbers
    • intensity: The intensity value of the representative mass feature
def get_sample_mf_map_for_representatives(self, representative_metric=None, include_cluster_id=True):
4433    def get_sample_mf_map_for_representatives(
4434        self, representative_metric=None, include_cluster_id=True
4435    ):
4436        """
4437        Build a mapping of sample_id -> list of representative mass feature IDs to load.
4438
4439        This is a DRY helper method used by both process_consensus_features() and
4440        ReadSavedLCMSCollection to determine which mass features should be loaded
4441        for each sample when loading representatives.
4442
4443        Parameters
4444        ----------
4445        representative_metric : str, optional
4446            The metric to use to determine the most representative sample.
4447            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4448            Default is None.
4449        include_cluster_id : bool, optional
4450            If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id.
4451            Default is True.
4452
4453        Returns
4454        -------
4455        dict
4456            Dictionary mapping sample_id (int) to list of mass feature identifiers.
4457            If include_cluster_id=True: list of tuples (mf_id, cluster_id)
4458            If include_cluster_id=False: list of mf_id integers
4459
4460        Examples
4461        --------
4462        >>> # Get map with cluster IDs for loading
4463        >>> sample_mf_map = collection.get_sample_mf_map_for_representatives()
4464        >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...}
4465        >>>
4466        >>> # Get map without cluster IDs for pipeline
4467        >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False)
4468        >>> # sample_mf_map = {0: [123, 456], 1: [789], ...}
4469        """
4470        # Get all representative mass features in bulk (much faster than looping)
4471        representatives = self.get_representative_mass_features_for_all_clusters(
4472            representative_metric=representative_metric
4473        )
4474
4475        # Build sample_mf_map
4476        sample_mf_map = {}
4477        for _, row in representatives.iterrows():
4478            sample_id = row["sample_id"]
4479            mf_id = row["mf_id"]
4480            cluster_id = row["cluster"]
4481
4482            if sample_id not in sample_mf_map:
4483                sample_mf_map[sample_id] = []
4484
4485            if include_cluster_id:
4486                sample_mf_map[sample_id].append((mf_id, cluster_id))
4487            else:
4488                sample_mf_map[sample_id].append(mf_id)
4489
4490        return sample_mf_map

Build a mapping of sample_id -> list of representative mass feature IDs to load.

This is a DRY helper method used by both process_consensus_features() and ReadSavedLCMSCollection to determine which mass features should be loaded for each sample when loading representatives.

Parameters
  • representative_metric (str, optional): The metric to use to determine the most representative sample. If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. Default is None.
  • include_cluster_id (bool, optional): If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. Default is True.
Returns
  • dict: Dictionary mapping sample_id (int) to list of mass feature identifiers. If include_cluster_id=True: list of tuples (mf_id, cluster_id) If include_cluster_id=False: list of mf_id integers
Examples
>>> # Get map with cluster IDs for loading
>>> sample_mf_map = collection.get_sample_mf_map_for_representatives()
>>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...}
>>>
>>> # Get map without cluster IDs for pipeline
>>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False)
>>> # sample_mf_map = {0: [123, 456], 1: [789], ...}
def get_most_representative_sample_for_cluster(self, cluster_id, representative_metric=None):
4492    def get_most_representative_sample_for_cluster(
4493        self, cluster_id, representative_metric=None
4494    ):
4495        """
4496        Get the most representative sample for a given cluster based on a metric.
4497
4498        Parameters
4499        ----------
4500        cluster_id : int
4501            The cluster ID to find the representative sample for.
4502        representative_metric : str, optional
4503            The metric to use to determine the most representative sample.
4504            If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
4505            Options:
4506            - 'intensity': Selects the mass feature with the highest intensity
4507            - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans,
4508              or the highest intensity overall if none have MS2
4509            Default is None (uses parameter setting).
4510
4511        Returns
4512        -------
4513        dict
4514            Dictionary containing:
4515            - 'sample_id': The sample ID of the most representative sample
4516            - 'sample_name': The sample name of the most representative sample
4517            - 'mf_id': The mass feature ID in the sample
4518            - 'coll_mf_id': The collection-level mass feature ID (index)
4519            - 'has_ms2': Whether this mass feature has MS2 scan numbers
4520            - 'intensity': The intensity value of the representative mass feature
4521
4522        Raises
4523        ------
4524        ValueError
4525            If cluster_id is not found or if representative_metric is not a valid column.
4526        """
4527        # Use the bulk method to get all representatives, then filter to this cluster
4528        # This follows DRY principle and ensures consistency
4529        all_representatives = self.get_representative_mass_features_for_all_clusters(
4530            representative_metric=representative_metric
4531        )
4532
4533        # Filter to the requested cluster
4534        cluster_rep = all_representatives[all_representatives["cluster"] == cluster_id]
4535
4536        if len(cluster_rep) == 0:
4537            # Try to provide helpful error message
4538            available_clusters = self.mass_features_dataframe["cluster"].unique()
4539            raise ValueError(
4540                f"Cluster {cluster_id} not found in mass_features_dataframe. "
4541                f"Available clusters: {sorted(available_clusters[:10].tolist())}... "
4542                f"(showing first 10 of {len(available_clusters)} total clusters)"
4543            )
4544
4545        # Get the representative row (should only be one)
4546        rep_row = cluster_rep.iloc[0]
4547
4548        # Get sample name from sample_id (convert to int for list indexing)
4549        sample_id = int(rep_row["sample_id"])
4550        sample_name = self.samples[sample_id]
4551
4552        return {
4553            "sample_id": sample_id,
4554            "sample_name": sample_name,
4555            "mf_id": rep_row["mf_id"],
4556            "coll_mf_id": rep_row["coll_mf_id"],
4557            "has_ms2": rep_row["has_ms2"],
4558            "intensity": rep_row["intensity"],
4559        }

Get the most representative sample for a given cluster based on a metric.

Parameters
  • cluster_id (int): The cluster ID to find the representative sample for.
  • representative_metric (str, optional): The metric to use to determine the most representative sample. If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. Options:
    • 'intensity': Selects the mass feature with the highest intensity
    • 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, or the highest intensity overall if none have MS2 Default is None (uses parameter setting).
Returns
  • dict: Dictionary containing:
    • 'sample_id': The sample ID of the most representative sample
    • 'sample_name': The sample name of the most representative sample
    • 'mf_id': The mass feature ID in the sample
    • 'coll_mf_id': The collection-level mass feature ID (index)
    • 'has_ms2': Whether this mass feature has MS2 scan numbers
    • 'intensity': The intensity value of the representative mass feature
Raises
  • ValueError: If cluster_id is not found or if representative_metric is not a valid column.
def reload_representative_mass_features( self, add_ms2=False, auto_process_ms2=True, ms2_spectrum_mode=None, ms2_scan_filter=None):
4561    def reload_representative_mass_features(
4562        self,
4563        add_ms2=False,
4564        auto_process_ms2=True,
4565        ms2_spectrum_mode=None,
4566        ms2_scan_filter=None,
4567    ):
4568        """
4569        Reload mass features for all representative samples in the cluster summary.
4570
4571        This method is useful when the collection was loaded with load_light=True,
4572        which stores mass features only in the collection dataframe. This reloads
4573        the specific mass features that are representatives for each cluster,
4574        allowing them to be accessed as LCMSMassFeature objects.
4575
4576        Parameters
4577        ----------
4578        add_ms2 : bool, optional
4579            If True, also loads and associates MS2 spectra with mass features. Default is False.
4580        auto_process_ms2 : bool, optional
4581            If True and add_ms2=True, auto-processes MS2 spectra. Default is True.
4582        ms2_spectrum_mode : str or None, optional
4583            Spectrum mode for MS2 spectra. If None, determines from parser. Default is None.
4584        ms2_scan_filter : str or None, optional
4585            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
4586
4587        Returns
4588        -------
4589        dict
4590            Dictionary mapping sample_id to list of reloaded mf_ids.
4591
4592        Raises
4593        ------
4594        ValueError
4595            If cluster_summary_dataframe is not set (run add_consensus_mass_features first).
4596
4597        Notes
4598        -----
4599        - Only reloads mass features that are cluster representatives
4600        - Uses get_most_representative_sample_for_cluster() to determine which to reload
4601        - More memory-efficient than reloading all mass features
4602        - Parallelized based on lcms_collection.cores parameter
4603        - MS2 association uses same logic as add_associated_ms2_dda()
4604
4605        See Also
4606        --------
4607        _reload_sample_mass_features : Low-level method to reload specific mass features
4608        get_most_representative_sample_for_cluster : Gets representative sample for cluster
4609        """
4610        # Validate prerequisites
4611        if (
4612            not hasattr(self, "cluster_summary_dataframe")
4613            or self.cluster_summary_dataframe is None
4614        ):
4615            raise ValueError(
4616                "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first."
4617            )
4618
4619        # Get all representative mass features in bulk (much faster than looping)
4620        representatives = self.get_representative_mass_features_for_all_clusters()
4621
4622        # Build a dictionary of sample_id -> list of mf_ids that are representatives
4623        sample_mf_map = {}
4624        for _, row in representatives.iterrows():
4625            sample_id = row["sample_id"]
4626            mf_id = row["mf_id"]
4627
4628            if sample_id not in sample_mf_map:
4629                sample_mf_map[sample_id] = []
4630            sample_mf_map[sample_id].append(mf_id)
4631
4632        # Reload mass features for each sample (parallelized)
4633        if self.parameters.lcms_collection.cores == 1:
4634            # Serial processing
4635            from tqdm import tqdm
4636
4637            for sample_id in tqdm(
4638                sample_mf_map.keys(),
4639                desc="Reloading representative mass features",
4640                unit="sample",
4641            ):
4642                mf_ids = sample_mf_map[sample_id]
4643                self._reload_sample_mass_features(
4644                    sample_id,
4645                    mf_ids_to_load=mf_ids,
4646                    add_ms2=add_ms2,
4647                    auto_process_ms2=auto_process_ms2,
4648                    ms2_spectrum_mode=ms2_spectrum_mode,
4649                    ms2_scan_filter=ms2_scan_filter,
4650                )
4651        else:
4652            # Parallel processing
4653            from tqdm import tqdm
4654
4655            if self.parameters.lcms_collection.cores > len(sample_mf_map):
4656                ncores = len(sample_mf_map)
4657            else:
4658                ncores = self.parameters.lcms_collection.cores
4659
4660            # Build arguments list for starmap
4661            args_list = [
4662                (
4663                    sample_id,
4664                    sample_mf_map[sample_id],
4665                    add_ms2,
4666                    auto_process_ms2,
4667                    ms2_spectrum_mode,
4668                    ms2_scan_filter,
4669                    False,
4670                )
4671                for sample_id in sample_mf_map.keys()
4672            ]
4673
4674            # Execute in parallel
4675            with spawn_pool(ncores) as pool:
4676                mp_result = pool.starmap(self._reload_sample_mass_features, args_list)
4677
4678            # Collect results back into samples
4679            for i, sample_id in enumerate(
4680                tqdm(
4681                    sample_mf_map.keys(),
4682                    desc="Collecting reloaded mass features",
4683                    unit="sample",
4684                )
4685            ):
4686                self[sample_id].mass_features = mp_result[i]
4687
4688        return sample_mf_map

Reload mass features for all representative samples in the cluster summary.

This method is useful when the collection was loaded with load_light=True, which stores mass features only in the collection dataframe. This reloads the specific mass features that are representatives for each cluster, allowing them to be accessed as LCMSMassFeature objects.

Parameters
  • add_ms2 (bool, optional): If True, also loads and associates MS2 spectra with mass features. Default is False.
  • auto_process_ms2 (bool, optional): If True and add_ms2=True, auto-processes MS2 spectra. Default is True.
  • ms2_spectrum_mode (str or None, optional): Spectrum mode for MS2 spectra. If None, determines from parser. Default is None.
  • ms2_scan_filter (str or None, optional): Filter string for MS2 scans (e.g., 'hcd'). Default is None.
Returns
  • dict: Dictionary mapping sample_id to list of reloaded mf_ids.
Raises
  • ValueError: If cluster_summary_dataframe is not set (run add_consensus_mass_features first).
Notes
  • Only reloads mass features that are cluster representatives
  • Uses get_most_representative_sample_for_cluster() to determine which to reload
  • More memory-efficient than reloading all mass features
  • Parallelized based on lcms_collection.cores parameter
  • MS2 association uses same logic as add_associated_ms2_dda()
See Also

_reload_sample_mass_features: Low-level method to reload specific mass features
get_most_representative_sample_for_cluster: Gets representative sample for cluster

def add_sparse_distance_matrix(self, features):
4879    def add_sparse_distance_matrix(self, features):
4880        if features is None:
4881            return None
4882        else:
4883            features = features.copy()
4884
4885        # Parameters for calculating distance between features
4886        dims = ["mz", "scan_time_aligned"]
4887        relative = [True, False]
4888        mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
4889        tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol]
4890        dist_weight = [1, 1]
4891
4892        # Check that the dimensions and tolerances are the same length
4893        if (
4894            len(dims) != len(tol)
4895            or len(dims) != len(relative)
4896            or len(dims) != len(dist_weight)
4897        ):
4898            raise ValueError(
4899                "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length"
4900            )
4901
4902        # Make connectivity matrix for masking within sample mass features
4903        ## Masking matrix cmat will mark all features from the same sample as 0
4904        ## To mask, a matrix can be multiplied by cmat and features from same
4905        ## samples are multiplied by 0, while features from different samples
4906        ## are multiplied by 1
4907
4908        if "sample_id" not in features.columns:
4909            cmat = None
4910        else:
4911            vals = features["sample_id"].values.reshape(-1, 1)
4912            cmat = scipy.spatial.distance.cdist(vals, vals)
4913            # Convert to binary (0 if same sample, 1 if different)
4914            cmat = np.where(cmat == 0, 0, 1)
4915            # Convert to coordinate matrix for sparse operations later
4916            cmat = sparse.coo_matrix(cmat)
4917
4918        # Compute inter-feature distances using sparse matrix approach
4919        distances = None  # clear the distances object before starting
4920        for i in range(len(dims)):  # iterate through all dimensions to be considered
4921            # Construct k-d tree
4922            values = features[dims[i]].values
4923
4924            tree = KDTree(values.reshape(-1, 1))
4925
4926            max_tol = tol[i]
4927            if relative[i] is True:
4928                # Maximum absolute tolerance
4929                max_tol = tol[i] * values.max()
4930
4931            # Compute sparse distance matrix
4932            # the larger the max_tol, the slower this operation is
4933            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
4934
4935            # Only consider forward case, exclude diagonal
4936            sdm = sparse.triu(sdm, k=1)
4937
4938            # Filter relative distances
4939            if relative[i] is True:
4940                # Compute relative distances
4941                rel_dists = sdm.data / values[sdm.row]
4942
4943                # Indices of relative distances less than tolerance
4944                idx = rel_dists <= tol[i]
4945
4946                # Reconstruct sparse distance matrix
4947                sdm = sparse.coo_matrix(
4948                    (rel_dists[idx], (sdm.row[idx], sdm.col[idx])),
4949                    shape=(len(values), len(values)),
4950                )
4951
4952            # Scaled distances wrt the maximum tolerance for the dimension
4953            sdm.data = sdm.data / tol[i]
4954
4955            # Stack distances for dimensions where na_allow is False
4956            if distances is None:
4957                sdm.data = sdm.data * dist_weight[i]
4958                # Replace zeros with epsilon to handle perfect matches
4959                sdm.data[sdm.data == 0] = 1e-10
4960                distances = sdm
4961            else:
4962                # Prepare sdm to match shape of existing distances
4963                distances_truth = distances.copy()
4964                # make new sparse matrix with same positions as previous
4965                # distance matrix but all ones for values
4966                distances_truth.data = np.ones_like(distances_truth.data)
4967
4968                # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them
4969                sdm.data[sdm.data == 0] = 1e-10
4970
4971                # multiply the new sparse matrix (sdm) by this mask to remove
4972                # data that doesn't exist in original sparse matrix
4973                sdm = distances_truth.multiply(sdm)
4974
4975                sdm.data = sdm.data * dist_weight[i]
4976                # Replace zeros with epsilon to handle perfect matches
4977                sdm.data[sdm.data == 0] = 1e-10
4978
4979                # use same process as before to remove data from previous
4980                # distances matrix that isn't in new distances matrix
4981                sdm_truth = sdm.copy()
4982                sdm_truth.data = np.ones_like(sdm_truth.data)
4983
4984                # remove the distances that are not sdm
4985                distances = distances.multiply(sdm_truth)
4986
4987                # Sum the new distances
4988                distances = distances + sdm
4989
4990        # Multiply by connectivity matrix for more masking
4991        distances = distances.multiply(cmat)
4992
4993        # Set attribute holding distance matrix
4994        self._sparse_distance_matrix = distances
def evaluate_clusters_for_repeats(self, features):
4996    def evaluate_clusters_for_repeats(self, features):
4997        raise NotImplementedError("evaluate_clusters_for_repeats not implemented yet")
4998        summary_df = self.cluster_summary_dataframe.copy()
4999
5000        # Arrange by decreasing median intensity
5001        summary_df = summary_df.sort_values(
5002            by="intensity_median", ascending=False
5003        ).reset_index(drop=True)
5004
5005        # Find clusters that are within the mz_tol and rt_tol of each other (on the medians)
5006        # Create a distance matrix
5007        # Define how to calculate the distance between features
5008        dims = ["mz_median", "scan_time_aligned_median"]
5009        relative = [True, False]
5010        mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5011        tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol]
5012
5013        # Compute inter-feature distances
5014        distances = None
5015        for i in range(len(dims)):
5016            # Construct k-d tree
5017            values = summary_df[dims[i]].values
5018            tree = KDTree(values.reshape(-1, 1))
5019
5020            max_tol = tol[i]
5021            if relative[i] is True:
5022                # Maximum absolute tolerance
5023                max_tol = tol[i] * values.max()
5024
5025            # Compute sparse distance matrix
5026            # the larger the max_tol, the slower this operation is
5027            sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix")
5028
5029            # Only consider forward case, exclude diagonal
5030            sdm = sparse.triu(sdm, k=1)
5031
5032            # Filter relative distances
5033            if relative[i] is True:
5034                # Compute relative distances
5035                rel_dists = sdm.data / values[sdm.row]  # or col?
5036
5037                # Indices of relative distances less than tolerance
5038                idx = rel_dists <= tol[i]
5039
5040                # Reconstruct sparse distance matrix
5041                sdm = sparse.coo_matrix(
5042                    (rel_dists[idx], (sdm.row[idx], sdm.col[idx])),
5043                    shape=(len(values), len(values)),
5044                )
5045
5046            # Cast as binary matrix
5047            sdm.data = np.ones_like(sdm.data)
5048
5049            # Stack distances
5050            if distances is None:
5051                distances = sdm
5052            else:
5053                distances = distances.multiply(sdm)
5054
5055        # Roll up features
5056        # Extract indices of within-tolerance points
5057        distances = distances.tocoo()
5058        pairs = np.stack(
5059            (distances.row, distances.col), axis=1
5060        )  # These are the index values of the clusters, not the cluster ids
5061        # Conver to cluster ids
5062        pairs_df = pd.DataFrame(pairs, columns=["parent", "child"])
5063        pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values
5064        pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values
5065        pairs_df = pairs_df.set_index("parent")
5066
5067        merge_these_clusters = []
5068        possible_overlaps = []
5069        root_parents = np.setdiff1d(
5070            np.unique(pairs_df.index.values), np.unique(pairs_df.child.values)
5071        )
5072        for parent in root_parents:
5073            parent_features = features[features["cluster"] == parent]
5074            children = pairs_df.loc[[parent], "child"].tolist()
5075            for child in children:
5076                overlap = self.check_merge(parent_features, child, features)
5077                if len(overlap) == 0:
5078                    merge_these_clusters.append((parent, child, len(overlap)))
5079                else:
5080                    possible_overlaps.append((parent, child, len(overlap)))
5081
5082        result_dict = {}
5083        result_dict["merge_these_clusters"] = merge_these_clusters
5084        result_dict["possible_overlaps"] = possible_overlaps
5085
5086        return result_dict
def check_merge(self, parent_features, child, features):
5088    def check_merge(self, parent_features, child, features):
5089        # Grab the features of the parent and children
5090        child_features = features[features["cluster"] == child]
5091
5092        # Check if there is an overlap between mf_coll_id in the parent and child clusters
5093        overlap = np.intersect1d(
5094            parent_features["sample_id"].values, child_features["sample_id"].values
5095        )
5096
5097        return overlap
def cluster_mass_features_agg_cluster(self, features):
5099    def cluster_mass_features_agg_cluster(self, features):
5100        if features is None:
5101            return None
5102
5103        features = features.copy()
5104
5105        self.add_sparse_distance_matrix(features)
5106
5107        distances = self._sparse_distance_matrix
5108
5109        # Convert to full matrix
5110        distances = distances.todense()
5111
5112        # Cast all 0s to 1s for a distance matrix
5113        distances[distances == 0] = 1
5114        distances = np.asarray(distances)
5115
5116        # Perform clustering
5117        try:
5118            clustering = AgglomerativeClustering(
5119                n_clusters=None,
5120                linkage="complete",
5121                # using complete linkage will prevent one sample from being assigned to multiple clusters
5122                metric="precomputed",
5123                distance_threshold=1,
5124            ).fit(distances)
5125            features["cluster"] = clustering.labels_
5126
5127        # All data points are singleton clusters
5128        except:
5129            features["cluster"] = np.arange(len(features.index))
5130
5131        return features
def cluster_inspection_plot(self, clu, return_fig=False, path=None):
5133    def cluster_inspection_plot(self, clu, return_fig=False, path=None):
5134        """
5135        Generate Scan Time vs m/z plot for a narrow range around a given
5136        cluster. This tool is meant to support the user in fine tuning the
5137        tolerances used for the clustering algorithm. The user-provided cluster
5138        ID is highlighted in larger, magenta marker and the ten largest of the
5139        remaining clusters are idenfitied with different colors while the
5140        smallest clusters are light gray.
5141
5142        Parameters
5143        -----------
5144        clu :  integer
5145            A cluster ID that exists in self.mass_features_dataframe
5146        return_fig : bool, optional
5147            If True, return the open figure (caller owns lifecycle).
5148            Default is False.
5149        path : str or path-like, optional
5150            If set, save the figure to this path. When ``return_fig`` is False,
5151            the figure is closed after saving and ``plt.show()`` is not called.
5152
5153        Returns
5154        --------
5155        matplotlib.figure.Figure or None
5156            A figure displaying a scan time vs m/z scatterplot of small region
5157            around a given cluster with the ten largest clusters in the region
5158            distinctly identified if ``return_fig`` is True; otherwise None.
5159
5160        Raises
5161        ------
5162        ValueError
5163            If cluster data haven't been added to the object yet
5164        """
5165
5166        if "cluster" not in self.mass_features_dataframe.columns:
5167            raise ValueError(
5168                "Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first"
5169            )
5170
5171        else:
5172            mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5173            rttol = self.parameters.lcms_collection.consensus_rt_tol
5174            clu_features = self.mass_features_dataframe.copy()
5175
5176            inclu = clu_features[clu_features.cluster == clu]
5177            exclu = clu_features[clu_features.cluster != clu]
5178
5179            dt_ymin = np.floor(min(inclu.mz)) - 1
5180            dt_ymax = np.ceil(max(inclu.mz)) + 1
5181            dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1
5182            dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1
5183
5184            exclu = exclu[
5185                (exclu.mz.between(dt_ymin, dt_ymax, inclusive="both"))
5186                & (exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive="both"))
5187            ]
5188
5189            bigclulist = list(exclu.cluster.value_counts()[:10].index)
5190            bigclu = exclu[exclu.cluster.isin(bigclulist)]
5191            smclu = exclu[~exclu.cluster.isin(bigclulist)]
5192
5193            colors = np.arange(0, 10)
5194            colordict = dict(zip(bigclulist, colors))
5195            bigclu["color"] = bigclu.cluster.apply(lambda x: colordict[x])
5196
5197            fig = plt.figure(figsize=(7.5, 5))
5198
5199            plt.scatter(
5200                inclu.scan_time_aligned,
5201                inclu.mz,
5202                c="m",
5203                s=3,
5204                label="Cluster " + str(clu),
5205            )
5206
5207            plt.scatter(
5208                bigclu.scan_time_aligned, bigclu.mz, c=bigclu.color, cmap="tab10", s=1.5
5209            )
5210
5211            plt.scatter(
5212                smclu.scan_time_aligned,
5213                smclu.mz,
5214                c="silver",
5215                s=2,
5216                label="Small clusters",
5217            )
5218
5219            plt.ylim(dt_ymin, dt_ymax)
5220            plt.xlim(dt_xmin, dt_xmax)
5221            plt.legend(ncol=2, bbox_to_anchor=(0.8, -0.1))
5222            plt.xlabel("Scan time")
5223            plt.ylabel("m/z")
5224            title_str = "Cluster " + str(clu)
5225            title_str += ": representing " + str(len(inclu.sample_id.unique()))
5226            title_str += " of " + str(len(clu_features.sample_id.unique()))
5227            title_str += " samples\n"
5228            title_str += "M/Z tolerance: " + str(mztol) + "\n"
5229            title_str += "Scan Time tolerance: " + str(rttol)
5230            plt.title(title_str, fontsize=10)
5231
5232            return _finalize_plot(fig, return_fig=return_fig, path=path)

Generate Scan Time vs m/z plot for a narrow range around a given cluster. This tool is meant to support the user in fine tuning the tolerances used for the clustering algorithm. The user-provided cluster ID is highlighted in larger, magenta marker and the ten largest of the remaining clusters are idenfitied with different colors while the smallest clusters are light gray.

Parameters
  • clu (integer): A cluster ID that exists in self.mass_features_dataframe
  • return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
  • path (str or path-like, optional): If set, save the figure to this path. When return_fig is False, the figure is closed after saving and plt.show() is not called.
Returns
  • matplotlib.figure.Figure or None: A figure displaying a scan time vs m/z scatterplot of small region around a given cluster with the ten largest clusters in the region distinctly identified if return_fig is True; otherwise None.
Raises
  • ValueError: If cluster data haven't been added to the object yet
def plot_cluster_outlier_frequency( self, dim_list=['mz', 'scan_time_aligned'], clu_size_thresh=0.5, return_fig=False, path=None):
5234    def plot_cluster_outlier_frequency(
5235        self,
5236        dim_list=["mz", "scan_time_aligned"],
5237        clu_size_thresh=0.5,
5238        return_fig=False,
5239        path=None,
5240    ):
5241        """
5242        Generate histogram showing the frequency of outlier occurrences by
5243        clustering dimension across all clusters
5244
5245        Parameters
5246        -----------
5247        dim_list :  list
5248            List of strings describing dimensions that can be used in
5249            clustering. Available list items:
5250                - 'mz'
5251                - 'scan_time_aligned'
5252                - 'half_height_width'
5253                - 'tailing_factor'
5254                - 'dispersity_index'
5255                - 'intensity'
5256                - 'persistence'
5257        clu_size_thresh : float
5258            Value between 0 and 1 that indicates what percentage of samples
5259            need to be present in a cluster before it's evaluated for outliers.
5260            Defaults to 0.5.
5261        return_fig : bool, optional
5262            If True, return the open figure (caller owns lifecycle).
5263            Default is False.
5264        path : str or path-like, optional
5265            If set, save the figure to this path. When ``return_fig`` is False,
5266            the figure is closed after saving and ``plt.show()`` is not called.
5267
5268        Returns
5269        --------
5270        matplotlib.figure.Figure or None
5271            A figure displaying the frequency of outlier occurrences across all
5272            clusters in the provided measurement dimensions if ``return_fig`` is
5273            True; otherwise None.
5274
5275        Raises
5276        ------
5277        ValueError
5278            If cluster data haven't been added to the object yet
5279        """
5280
5281        if not hasattr(self, "cluster_summary_dataframe"):
5282            raise ValueError(
5283                "cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first"
5284            )
5285
5286        mfdf = self.mass_features_dataframe.copy()
5287        summarydf = self.cluster_summary_dataframe
5288
5289        numsamples = len(self)
5290        sumdf = (
5291            summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh]
5292            .reset_index(drop=True)
5293            .copy()
5294        )
5295
5296        ## find the ranges for non-outlier values and add them to sumdf
5297        mergelist = ["cluster"]
5298        for dim in dim_list:
5299            maxtag = dim + "_outmax"
5300            mintag = dim + "_outmin"
5301            mergelist.append(maxtag)
5302            mergelist.append(mintag)
5303            # Calculate outlier thresholds using vectorized operations
5304            sumdf[mintag] = sumdf[dim + "_mean"] - 3 * sumdf[dim + "_std"]
5305            sumdf[maxtag] = sumdf[dim + "_mean"] + 3 * sumdf[dim + "_std"]
5306            ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's
5307            ## not flagged. This happens when there's not enough values to compute median/std for that
5308            ## dimension therefore can't have outliers
5309
5310        ## add ranges to mfdf and identify mass features that fall outside the ranges
5311        # Merge without dropping NaN - we'll handle it per-dimension
5312        outdf = pd.merge(mfdf, sumdf[mergelist], on="cluster")
5313
5314        outtags = ["cluster"]
5315        for dim in dim_list:
5316            dimtag = dim + "_outlier"
5317            outtags.append(dimtag)
5318            maxtag = dim + "_outmax"
5319            mintag = dim + "_outmin"
5320            # Only flag as outlier if thresholds are valid (not NaN)
5321            outdf[dimtag] = np.where(
5322                (outdf[maxtag].notna() & outdf[mintag].notna())
5323                & ((outdf[dim] > outdf[maxtag]) | (outdf[dim] < outdf[mintag])),
5324                True,
5325                False,
5326            )
5327
5328        ## identify number of outliers in each cluster
5329        outliers = outdf[outtags]
5330        outliers = outliers.groupby(["cluster"]).sum()
5331
5332        ## plot number of clusters that contain any outliers
5333        fig = plt.figure()
5334        plt.bar(dim_list, outliers.sum().values, width=0.5)
5335        plt.xticks(rotation=90)
5336        plt.title("Frequency of outliers across all clusters by category")
5337
5338        return _finalize_plot(fig, return_fig=return_fig, path=path)

Generate histogram showing the frequency of outlier occurrences by clustering dimension across all clusters

Parameters
  • dim_list (list): List of strings describing dimensions that can be used in clustering. Available list items: - 'mz' - 'scan_time_aligned' - 'half_height_width' - 'tailing_factor' - 'dispersity_index' - 'intensity' - 'persistence'
  • clu_size_thresh (float): Value between 0 and 1 that indicates what percentage of samples need to be present in a cluster before it's evaluated for outliers. Defaults to 0.5.
  • return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
  • path (str or path-like, optional): If set, save the figure to this path. When return_fig is False, the figure is closed after saving and plt.show() is not called.
Returns
  • matplotlib.figure.Figure or None: A figure displaying the frequency of outlier occurrences across all clusters in the provided measurement dimensions if return_fig is True; otherwise None.
Raises
  • ValueError: If cluster data haven't been added to the object yet
def fill_missing_cluster_features(self):
5515    def fill_missing_cluster_features(self):
5516        """
5517        Gap-filling for consensus mass features across collection samples.
5518
5519        For clusters present in multiple samples but missing from others, searches
5520        raw MS1 data to find peaks in expected m/z and retention time windows. This
5521        creates "induced" mass features for peaks that exist in the data but weren't
5522        detected in the initial peak detection.
5523
5524        Must be run after add_consensus_mass_features(). Results are accessible via
5525        induced_mass_features_dataframe property and included in collection_pivot_table
5526        and collection_consensus_report outputs.
5527
5528        Parameters
5529        ----------
5530        None
5531            Uses parameters from self.parameters.lcms_collection:
5532            - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain
5533              a cluster before gap-filling is attempted
5534            - gap_fill_expand_on_miss: If True, expands search window when no peak is found
5535
5536        Returns
5537        -------
5538        None
5539            Updates induced_mass_features attribute for each LCMSBase object and
5540            combines them into induced_mass_features_dataframe.
5541
5542        Raises
5543        ------
5544        ValueError
5545            If cluster_summary_dataframe is not set (must run add_consensus_mass_features first).
5546
5547        Notes
5548        -----
5549        - Loads raw MS1 data for each sample, which may be memory intensive
5550        - Induced features are integrated and metrics calculated automatically
5551        - Processing can be parallelized using parameters.lcms_collection.cores
5552
5553        See Also
5554        --------
5555        add_consensus_mass_features : Creates consensus features before gap-filling
5556        collection_pivot_table : Includes both regular and induced features
5557        collection_consensus_report : Reports on complete feature matrix
5558        """
5559
5560        # Validate prerequisites
5561        if (
5562            not hasattr(self, "cluster_summary_dataframe")
5563            or self.cluster_summary_dataframe is None
5564        ):
5565            raise ValueError(
5566                "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first."
5567            )
5568
5569        # Get parameters from settings
5570        min_cluster_presence = (
5571            self.parameters.lcms_collection.consensus_min_sample_fraction
5572        )
5573        expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss
5574
5575        # Validate parameters
5576        if not 0 <= min_cluster_presence <= 1:
5577            raise ValueError("consensus_min_sample_fraction must be between 0 and 1")
5578
5579        summarydf = self.cluster_summary_dataframe
5580        mfdf = self.mass_features_dataframe
5581
5582        sample_ct = len(self.samples)
5583
5584        # Identify clusters present in sufficient samples but not all samples
5585        missingdf = summarydf[
5586            [
5587                "cluster",
5588                "sample_id_nunique",
5589                "mz_min",
5590                "mz_max",
5591                "scan_time_aligned_min",
5592                "scan_time_aligned_max",
5593            ]
5594        ]
5595        missingdf = missingdf[
5596            missingdf.sample_id_nunique > min_cluster_presence * sample_ct
5597        ]
5598        missingdf = missingdf[missingdf.sample_id_nunique != sample_ct]
5599
5600        # Check if there are any clusters to gap-fill
5601        if len(missingdf) == 0:
5602            return
5603
5604        # One groupby. Sample ids stay in collection order, including samples
5605        # that have no mass features.
5606        missingdf["missing_samples"] = self._missing_sample_ids_by_cluster(
5607            mfdf, missingdf.cluster.to_numpy()
5608        )
5609
5610        # Calculate expanded search windows for expand_on_miss option
5611        mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6
5612        rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol
5613        missingdf["mz_max_allowed"] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max
5614        missingdf["mz_min_allowed"] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min
5615        # consensus_rt_tol is an absolute tolerance in minutes, same as clustering.
5616        missingdf["sta_max_allowed"] = missingdf.scan_time_aligned_max + rt_clu_tol
5617        missingdf["sta_min_allowed"] = missingdf.scan_time_aligned_min - rt_clu_tol
5618
5619        # Compute cluster dictionary once to avoid recomputing for each sample
5620        cluster_dict = self.cluster_feature_dictionary
5621
5622        # Process each sample to search for missing features
5623        if self.parameters.lcms_collection.cores == 1:
5624            for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"):
5625                self._search_for_targeted_mass_features_in_sample(
5626                    i, missingdf, cluster_dict, expand_on_miss
5627                )
5628
5629        if self.parameters.lcms_collection.cores > 1:
5630            if self.parameters.lcms_collection.cores > len(self):
5631                ncores = len(self)
5632            else:
5633                ncores = self.parameters.lcms_collection.cores
5634            with spawn_pool(ncores) as pool:
5635                mp_result = pool.starmap(
5636                    self._search_for_targeted_mass_features_in_sample,
5637                    [
5638                        (x, missingdf, cluster_dict, expand_on_miss, False)
5639                        for x in range(sample_ct)
5640                    ],
5641                )
5642
5643            for i in tqdm(
5644                range(sample_ct), desc="Collecting gap-filled features", unit="sample"
5645            ):
5646                self[i].induced_mass_features = mp_result[i]
5647
5648        self._combine_mass_features(induced_features=True)
5649
5650        # Mark that gap-filling has been performed
5651        self.missing_mass_features_searched = True
5652
5653        for sample_name in self.samples:
5654            self._lcms[sample_name].mass_features = {}

Gap-filling for consensus mass features across collection samples.

For clusters present in multiple samples but missing from others, searches raw MS1 data to find peaks in expected m/z and retention time windows. This creates "induced" mass features for peaks that exist in the data but weren't detected in the initial peak detection.

Must be run after add_consensus_mass_features(). Results are accessible via induced_mass_features_dataframe property and included in collection_pivot_table and collection_consensus_report outputs.

Parameters
  • None: Uses parameters from self.parameters.lcms_collection:
    • consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain a cluster before gap-filling is attempted
    • gap_fill_expand_on_miss: If True, expands search window when no peak is found
Returns
  • None: Updates induced_mass_features attribute for each LCMSBase object and combines them into induced_mass_features_dataframe.
Raises
  • ValueError: If cluster_summary_dataframe is not set (must run add_consensus_mass_features first).
Notes
  • Loads raw MS1 data for each sample, which may be memory intensive
  • Induced features are integrated and metrics calculated automatically
  • Processing can be parallelized using parameters.lcms_collection.cores
See Also

add_consensus_mass_features: Creates consensus features before gap-filling
collection_pivot_table: Includes both regular and induced features
collection_consensus_report: Reports on complete feature matrix

def process_samples_pipeline( self, operations, description=None, keep_raw_data=False, show_progress=True):
5656    def process_samples_pipeline(
5657        self, operations, description=None, keep_raw_data=False, show_progress=True
5658    ):
5659        """
5660        Execute a pipeline of operations on all samples in parallel.
5661
5662        This method provides a flexible framework for performing multiple
5663        sample-level operations in a single parallelized pass, which is more
5664        efficient than calling separate methods sequentially.
5665
5666        Parameters
5667        ----------
5668        operations : list of SampleOperation
5669            List of operations to perform on each sample, in order.
5670            Each operation should be an instance of a class derived from
5671            SampleOperation (see lc_calc_operations module).
5672        description : str or None, optional
5673            Progress bar description. If None, automatically generates description
5674            from operation descriptions (e.g., "gap-filling, reloading features").
5675            Default is None.
5676        keep_raw_data : bool, optional
5677            If True, keeps raw MS data loaded in memory after pipeline completes.
5678            If False, cleans up raw data to free memory. Default is False.
5679        show_progress : bool, optional
5680            If True, displays progress bars during processing. If False, runs silently.
5681            Default is True.
5682
5683        Returns
5684        -------
5685        dict
5686            Dictionary with results from pipeline execution, keyed by operation name.
5687            Structure: {operation_name: {sample_id: result, ...}, ...}
5688
5689        Raises
5690        ------
5691        ValueError
5692            If operations list is empty or contains invalid operations.
5693
5694        Notes
5695        -----
5696        - Operations are executed sequentially within each sample
5697        - Samples are processed in parallel based on parameters.lcms_collection.cores
5698        - Each operation can have conditional execution via can_execute()
5699        - Results are collected back via collect_results() method of each operation
5700        - Failed operations for a sample are logged but don't halt processing
5701        - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True
5702
5703        Examples
5704        --------
5705        >>> from corems.mass_spectra.calc.lc_calc_operations import (
5706        ...     GapFillOperation, ReloadFeaturesOperation
5707        ... )
5708        >>> ops = [
5709        ...     GapFillOperation('gap_fill', expand_on_miss=True),
5710        ...     ReloadFeaturesOperation('reload', add_ms2=True)
5711        ... ]
5712        >>> results = lcms_collection.process_samples_pipeline(ops)
5713
5714        See Also
5715        --------
5716        lc_calc_operations : Module containing built-in operation classes
5717        fill_and_process_features : Convenience method combining common operations
5718        """
5719        from corems.mass_spectra.calc.lc_calc_operations import SampleOperation
5720
5721        # Validate operations
5722        if not operations or len(operations) == 0:
5723            raise ValueError("operations list cannot be empty")
5724
5725        for op in operations:
5726            if not isinstance(op, SampleOperation):
5727                raise ValueError(
5728                    f"All operations must be SampleOperation instances, got {type(op)}"
5729                )
5730
5731        # Generate description from operations if not provided
5732        if description is None:
5733            operation_descriptions = [op.description for op in operations]
5734            description = ", ".join(operation_descriptions).capitalize()
5735
5736        # Prepare runtime parameters for each operation
5737        # This is where we gather collection-level data that operations need
5738        runtime_params = self._prepare_pipeline_runtime_params(operations)
5739        runtime_params["keep_raw_data"] = keep_raw_data
5740
5741        # Execute pipeline
5742        sample_ct = len(self.samples)
5743
5744        if self.parameters.lcms_collection.cores == 1:
5745            # Serial processing
5746            results_by_operation = {op.name: {} for op in operations}
5747
5748            if show_progress:
5749                from tqdm import tqdm
5750
5751                # Print description on its own line before progress bar
5752                print(f"\n{description.capitalize()}:")
5753                iterator = tqdm(range(sample_ct), unit="sample", ncols=80)
5754            else:
5755                iterator = range(sample_ct)
5756
5757            for sample_id in iterator:
5758                sample_results = self._execute_sample_pipeline(
5759                    sample_id, operations, runtime_params, inplace=True
5760                )
5761                # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True)
5762                # Skip 'sample_id' key which is added for tracking
5763                for op_name, result in sample_results.items():
5764                    if op_name != "sample_id":
5765                        results_by_operation[op_name][sample_id] = result
5766        else:
5767            # Parallel processing
5768            if self.parameters.lcms_collection.cores > sample_ct:
5769                ncores = sample_ct
5770            else:
5771                ncores = self.parameters.lcms_collection.cores
5772
5773            # Build arguments for each sample
5774            args_list = [
5775                (sample_id, operations, runtime_params, False)
5776                for sample_id in range(sample_ct)
5777            ]
5778
5779            # Execute in parallel with progress tracking
5780            results_by_operation = {op.name: {} for op in operations}
5781
5782            with spawn_pool(ncores) as pool:
5783                if show_progress:
5784                    from tqdm import tqdm
5785                    import time
5786
5787                    # Use starmap_async for parallel execution with progress tracking
5788                    async_result = pool.starmap_async(
5789                        self._execute_sample_pipeline, args_list
5790                    )
5791
5792                    # Poll for completion and update progress bar
5793                    print(description)
5794                    pbar = tqdm(
5795                        total=sample_ct,
5796                        desc="",
5797                        unit="sample",
5798                        position=0,
5799                        leave=True,
5800                        dynamic_ncols=True,
5801                    )
5802                    prev_completed = 0
5803                    while not async_result.ready():
5804                        # Get number of completed tasks by checking remaining
5805                        completed = sample_ct - async_result._number_left
5806                        if completed > prev_completed:
5807                            pbar.update(completed - prev_completed)
5808                            prev_completed = completed
5809                        time.sleep(0.5)  # Poll every 500ms to avoid spam
5810
5811                    # Final update to 100%
5812                    if prev_completed < sample_ct:
5813                        pbar.update(sample_ct - prev_completed)
5814                    pbar.close()
5815
5816                    # Get all results
5817                    mp_results = async_result.get()
5818                else:
5819                    # Execute without progress
5820                    mp_results = pool.starmap(self._execute_sample_pipeline, args_list)
5821
5822            # Collect results back into collection
5823            for result in mp_results:
5824                sample_id = result.get("sample_id")
5825                for op in operations:
5826                    op_result = result.get(op.name)
5827                    if op_result is not None:
5828                        op.collect_results(sample_id, op_result, self)
5829                        results_by_operation[op.name][sample_id] = op_result
5830
5831        return results_by_operation

Execute a pipeline of operations on all samples in parallel.

This method provides a flexible framework for performing multiple sample-level operations in a single parallelized pass, which is more efficient than calling separate methods sequentially.

Parameters
  • operations (list of SampleOperation): List of operations to perform on each sample, in order. Each operation should be an instance of a class derived from SampleOperation (see lc_calc_operations module).
  • description (str or None, optional): Progress bar description. If None, automatically generates description from operation descriptions (e.g., "gap-filling, reloading features"). Default is None.
  • keep_raw_data (bool, optional): If True, keeps raw MS data loaded in memory after pipeline completes. If False, cleans up raw data to free memory. Default is False.
  • show_progress (bool, optional): If True, displays progress bars during processing. If False, runs silently. Default is True.
Returns
  • dict: Dictionary with results from pipeline execution, keyed by operation name. Structure: {operation_name: {sample_id: result, ...}, ...}
Raises
  • ValueError: If operations list is empty or contains invalid operations.
Notes
  • Operations are executed sequentially within each sample
  • Samples are processed in parallel based on parameters.lcms_collection.cores
  • Each operation can have conditional execution via can_execute()
  • Results are collected back via collect_results() method of each operation
  • Failed operations for a sample are logged but don't halt processing
  • Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True
Examples
>>> from corems.mass_spectra.calc.lc_calc_operations import (
...     GapFillOperation, ReloadFeaturesOperation
... )
>>> ops = [
...     GapFillOperation('gap_fill', expand_on_miss=True),
...     ReloadFeaturesOperation('reload', add_ms2=True)
... ]
>>> results = lcms_collection.process_samples_pipeline(ops)
See Also

lc_calc_operations: Module containing built-in operation classes
fill_and_process_features: Convenience method combining common operations

def process_consensus_features( self, load_representatives=True, perform_gap_filling=True, add_ms1=False, add_ms2=False, ms2_scan_filter=None, molecular_formula_search=False, ms2_spectral_search=False, spectral_lib=None, molecular_metadata=None, gather_eics=False, group_features=False, keep_raw_data=False, show_progress=True):
6112    def process_consensus_features(
6113        self,
6114        load_representatives=True,
6115        perform_gap_filling=True,
6116        add_ms1=False,
6117        add_ms2=False,
6118        ms2_scan_filter=None,
6119        molecular_formula_search=False,
6120        ms2_spectral_search=False,
6121        spectral_lib=None,
6122        molecular_metadata=None,
6123        gather_eics=False,
6124        group_features=False,
6125        keep_raw_data=False,
6126        show_progress=True,
6127    ):
6128        """
6129        Process consensus mass features across the collection in a single parallelized pass.
6130
6131        This method provides a convenient interface to the sample processing pipeline,
6132        allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association,
6133        molecular formula search, and MS2 spectral search) to be performed efficiently in
6134        a single pass through all samples.
6135
6136        Parameters
6137        ----------
6138        load_representatives : bool, optional
6139            If True, loads representative mass features from HDF5. Default is True.
6140        perform_gap_filling : bool, optional
6141            If True, performs gap-filling for missing cluster features. Default is True.
6142            This operation loads raw MS1 data which can be reused by subsequent operations.
6143        group_features : bool, optional
6144            If True, run ``group_consensus_features()`` after gap-fill (and before
6145            molecular formula / MS2 search when those are also enabled).
6146            Collection-level natural-abundance **isotope and adduct** family
6147            labeling (singly charged only; not tracer/enriched). Default is False.
6148        add_ms1 : bool, optional
6149            If True and load_representatives=True, associates MS1 spectra with
6150            loaded features. Automatically uses raw data from gap-filling if available,
6151            otherwise uses parser. Spectrum mode is auto-detected. Default is False.
6152        add_ms2 : bool, optional
6153            If True and load_representatives=True, associates MS2 spectra with
6154            loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False.
6155        ms2_scan_filter : str or None, optional
6156            Filter string for MS2 scans (e.g., 'hcd'). Default is None.
6157        molecular_formula_search : bool, optional
6158            If True, performs molecular formula search on mass features using
6159            associated MS1 spectra. Requires add_ms1=True or that MS1 spectra
6160            are already associated. Uses parameters from
6161            parameters.mass_spectrum["ms1"].molecular_search. Default is False.
6162        ms2_spectral_search : bool, optional
6163            If True, performs MS2 spectral library search using FlashEntropy.
6164            Requires add_ms2=True and spectral_lib to be provided. Default is False.
6165        spectral_lib : FlashEntropy library, optional
6166            Pre-prepared FlashEntropy spectral library for MS2 search.
6167            Create using MSPInterface.get_metabolomics_spectra_library().
6168            Required if ms2_spectral_search=True. Default is None.
6169        molecular_metadata : pd.DataFrame, optional
6170            Molecular metadata corresponding to spectral_lib.
6171            Returned from MSPInterface.get_metabolomics_spectra_library().
6172            Stored as self.spectral_search_molecular_metadata for later export.
6173            Default is None.
6174        gather_eics : bool, optional
6175            If True, loads extracted ion chromatograms (EICs) from HDF5 for all
6176            mass features with assigned cluster_index (including gap-filled features).
6177            Enables access to EICs via get_eics_for_cluster(cluster_id) method.
6178            Requires that EICs were previously exported with export_eics=True.
6179            Default is False.
6180        keep_raw_data : bool, optional
6181            If True, keeps raw MS data loaded in memory after pipeline completes.
6182            If False, cleans up raw data to free memory. Default is False.
6183        show_progress : bool, optional
6184            If True, displays progress bars during processing. If False, runs silently.
6185            Default is True.
6186
6187        Returns
6188        -------
6189        dict
6190            Dictionary with pipeline results. Keys include:
6191            - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling)
6192            - 'reload': dict mapping sample_id to reloaded mass features (if reloading)
6193            - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search)
6194            - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search)
6195
6196        Raises
6197        ------
6198        ValueError
6199            If neither operation is enabled, or if required parameters are missing.
6200
6201        Notes
6202        -----
6203        - Must run add_consensus_mass_features() before calling this method
6204        - Processes samples in parallel based on parameters.lcms_collection.cores
6205        - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association
6206        - MS2 spectral search requires add_ms2=True and msp_file_path
6207        - FlashEntropy library is created once and reused across all samples
6208        - More efficient than calling individual methods separately
6209        - After gap-filling, sets missing_mass_features_searched = True
6210        - Mass features remain loaded in memory for downstream processing
6211        - For more advanced workflows, use process_samples_pipeline() directly
6212
6213        Examples
6214        --------
6215        >>> # Prepare spectral library for MS2 search
6216        >>> from corems.molecular_id.search.database_interfaces import MSPInterface
6217        >>> my_msp = MSPInterface(file_path='path/to/library.msp')
6218        >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library(
6219        ...     polarity='negative',
6220        ...     format='flashentropy',
6221        ...     normalize=True,
6222        ...     fe_kwargs={
6223        ...         'normalize_intensity': True,
6224        ...         'min_ms2_difference_in_da': 0.02,
6225        ...         'max_ms2_tolerance_in_da': 0.01,
6226        ...         'max_indexed_mz': 3000,
6227        ...         'precursor_ions_removal_da': None,
6228        ...         'noise_threshold': 0,
6229        ...     }
6230        ... )
6231        >>>
6232        >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search
6233        >>> results = lcms_collection.process_consensus_features(
6234        ...     load_representatives=True,
6235        ...     perform_gap_filling=True,
6236        ...     add_ms1=True,
6237        ...     add_ms2=True,
6238        ...     molecular_formula_search=True,
6239        ...     ms2_spectral_search=True,
6240        ...     spectral_lib=spectral_lib,
6241        ...     molecular_metadata=molecular_metadata
6242        ... )
6243
6244        See Also
6245        --------
6246        process_samples_pipeline : Generic pipeline executor for custom workflows
6247        fill_missing_cluster_features : Original gap-filling method
6248        reload_representative_mass_features : Original reload method
6249        """
6250        from corems.mass_spectra.calc.lc_calc_operations import (
6251            GapFillOperation,
6252            ReloadFeaturesOperation,
6253            MolecularFormulaSearchOperation,
6254            MS2SpectralSearchOperation,
6255            LoadEICsOperation,
6256        )
6257
6258        # Validate that at least one meaningful operation is enabled
6259        has_operations = (
6260            perform_gap_filling
6261            or load_representatives
6262            or molecular_formula_search
6263            or ms2_spectral_search
6264            or gather_eics
6265            or add_ms1
6266            or add_ms2
6267            or group_features
6268        )
6269
6270        if not has_operations:
6271            raise ValueError(
6272                "At least one operation must be enabled: perform_gap_filling, load_representatives, "
6273                "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, add_ms2, "
6274                "or group_features"
6275            )
6276
6277        # Validate prerequisites for gap-filling
6278        if perform_gap_filling:
6279            if (
6280                not hasattr(self, "cluster_summary_dataframe")
6281                or self.cluster_summary_dataframe is None
6282            ):
6283                raise ValueError(
6284                    "Cannot perform gap-filling: cluster_summary_dataframe not set. "
6285                    "You must run add_consensus_mass_features() before calling process_consensus_features()."
6286                )
6287
6288        # Validate prerequisites for MS2 spectral search
6289        if ms2_spectral_search:
6290            if spectral_lib is None:
6291                raise ValueError(
6292                    "MS2 spectral search requires spectral_lib to be provided. "
6293                    "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method."
6294                )
6295            # Check if mass features will be loaded OR are already loaded
6296            # (The operation's can_execute will check if MS2 spectra are actually present)
6297            if not load_representatives and not perform_gap_filling:
6298                # Check if at least one sample has mass features loaded
6299                # This allows MS2 search on already-loaded features
6300                has_loaded_features = any(
6301                    len(self[i].mass_features) > 0
6302                    if hasattr(self[i], "mass_features")
6303                    and self[i].mass_features is not None
6304                    else False
6305                    for i in range(len(self.samples))
6306                )
6307                if not has_loaded_features:
6308                    raise ValueError(
6309                        "MS2 spectral search requires mass features to be loaded. "
6310                        "Either set load_representatives=True or perform_gap_filling=True to load them, "
6311                        "or load them in a previous call to process_consensus_features() before calling "
6312                        "with ms2_spectral_search=True."
6313                    )
6314
6315        # Build pipeline in two phases so group_features runs after gap-fill
6316        # and before formula / MS2 search when those are requested in the same call.
6317        pre_operations = []
6318        post_operations = []
6319
6320        if perform_gap_filling:
6321            expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss
6322            pre_operations.append(
6323                GapFillOperation("gap_fill", expand_on_miss=expand_on_miss)
6324            )
6325
6326        if load_representatives:
6327            # MS1/MS2 association for representatives can happen in pre-pass;
6328            # formula/MS2 library search stay in post-pass after grouping.
6329            pre_operations.append(
6330                ReloadFeaturesOperation(
6331                    "reload",
6332                    add_ms1=add_ms1,
6333                    add_ms2=add_ms2,
6334                    auto_process_ms2=add_ms2,
6335                    ms2_scan_filter=ms2_scan_filter,
6336                )
6337            )
6338
6339        if molecular_formula_search:
6340            post_operations.append(MolecularFormulaSearchOperation("mf_search"))
6341
6342        if ms2_spectral_search:
6343            post_operations.append(
6344                MS2SpectralSearchOperation(
6345                    "ms2_search", ms2_scan_filter=ms2_scan_filter
6346                )
6347            )
6348            self._spectral_lib = spectral_lib
6349            self._spectral_search_molecular_metadata = molecular_metadata
6350
6351        if gather_eics:
6352            # After grouping when grouping is requested; otherwise with pre-pass
6353            # (or post-pass if only annotation ops are enabled).
6354            if group_features or not pre_operations:
6355                post_operations.append(LoadEICsOperation("load_eics"))
6356            else:
6357                pre_operations.append(LoadEICsOperation("load_eics"))
6358
6359        results = {}
6360
6361        def _run_ops(operations):
6362            if not operations:
6363                return {}
6364            return self.process_samples_pipeline(
6365                operations, keep_raw_data=keep_raw_data, show_progress=show_progress
6366            )
6367
6368        # Phase 1: gap-fill / reload (and eics if not grouping)
6369        if pre_operations:
6370            results.update(_run_ops(pre_operations))
6371
6372        # Post-processing for gap-fill must happen before grouping so heights include induced
6373        if perform_gap_filling:
6374            self._combine_mass_features(induced_features=True)
6375            self.missing_mass_features_searched = True
6376
6377            if (
6378                self.induced_mass_features_dataframe is not None
6379                and len(self.induced_mass_features_dataframe) > 0
6380            ):
6381                eics_mz = []
6382                for i, row in self.induced_mass_features_dataframe.iterrows():
6383                    sample_id = row["sample_id"]
6384                    sample = self[sample_id]
6385                    if row["mf_id"] in sample.induced_mass_features.keys():
6386                        eic_mz = sample.induced_mass_features[row["mf_id"]]._eic_mz
6387                        eics_mz.append(eic_mz)
6388                    else:
6389                        eics_mz.append(None)
6390                self.induced_mass_features_dataframe["_eic_mz"] = eics_mz
6391
6392        # Collection-level isotope + adduct grouping
6393        if group_features:
6394            t_group = time.perf_counter()
6395            labels = self.group_consensus_features()
6396            group_wall = time.perf_counter() - t_group
6397            results["group_features"] = {
6398                "n_clusters": len(labels),
6399                "n_grouped": int(labels["feature_group_id"].notna().sum()),
6400                "n_groups": int(labels["feature_group_id"].nunique(dropna=True)),
6401                "wall_seconds": group_wall,
6402                "stage_timings": getattr(self, "_last_feature_group_timings", None),
6403            }
6404
6405        # Phase 2: annotation / optional EICs after grouping
6406        if post_operations:
6407            # If only post_operations and no pre, still need a pipeline run
6408            post_results = _run_ops(post_operations)
6409            for k, v in post_results.items():
6410                results[k] = v
6411
6412        # Store molecular metadata if spectral search was performed
6413        if ms2_spectral_search and hasattr(self, "_spectral_search_molecular_metadata"):
6414            self.spectral_search_molecular_metadata = (
6415                self._spectral_search_molecular_metadata
6416            )
6417
6418        # Associate EICs while induced feature objects still exist (before any clear).
6419        if gather_eics:
6420            print("\nAssociating EICs with mass features:")
6421            from tqdm import tqdm
6422
6423            for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80):
6424                sample = self[sample_id]
6425                if sample.eics:
6426                    sample.associate_eics_with_mass_features(induced=False)
6427                    sample.associate_eics_with_mass_features(induced=True)
6428
6429        # Drop induced feature objects to free memory after grouping + post-ops
6430        if perform_gap_filling:
6431            for sample_name in self.samples:
6432                self._lcms[sample_name].induced_mass_features = {}
6433
6434        return results

Process consensus mass features across the collection in a single parallelized pass.

This method provides a convenient interface to the sample processing pipeline, allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, molecular formula search, and MS2 spectral search) to be performed efficiently in a single pass through all samples.

Parameters
  • load_representatives (bool, optional): If True, loads representative mass features from HDF5. Default is True.
  • perform_gap_filling (bool, optional): If True, performs gap-filling for missing cluster features. Default is True. This operation loads raw MS1 data which can be reused by subsequent operations.
  • group_features (bool, optional): If True, run group_consensus_features() after gap-fill (and before molecular formula / MS2 search when those are also enabled). Collection-level natural-abundance isotope and adduct family labeling (singly charged only; not tracer/enriched). Default is False.
  • add_ms1 (bool, optional): If True and load_representatives=True, associates MS1 spectra with loaded features. Automatically uses raw data from gap-filling if available, otherwise uses parser. Spectrum mode is auto-detected. Default is False.
  • add_ms2 (bool, optional): If True and load_representatives=True, associates MS2 spectra with loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False.
  • ms2_scan_filter (str or None, optional): Filter string for MS2 scans (e.g., 'hcd'). Default is None.
  • molecular_formula_search (bool, optional): If True, performs molecular formula search on mass features using associated MS1 spectra. Requires add_ms1=True or that MS1 spectra are already associated. Uses parameters from parameters.mass_spectrum["ms1"].molecular_search. Default is False.
  • ms2_spectral_search (bool, optional): If True, performs MS2 spectral library search using FlashEntropy. Requires add_ms2=True and spectral_lib to be provided. Default is False.
  • spectral_lib (FlashEntropy library, optional): Pre-prepared FlashEntropy spectral library for MS2 search. Create using MSPInterface.get_metabolomics_spectra_library(). Required if ms2_spectral_search=True. Default is None.
  • molecular_metadata (pd.DataFrame, optional): Molecular metadata corresponding to spectral_lib. Returned from MSPInterface.get_metabolomics_spectra_library(). Stored as self.spectral_search_molecular_metadata for later export. Default is None.
  • gather_eics (bool, optional): If True, loads extracted ion chromatograms (EICs) from HDF5 for all mass features with assigned cluster_index (including gap-filled features). Enables access to EICs via get_eics_for_cluster(cluster_id) method. Requires that EICs were previously exported with export_eics=True. Default is False.
  • keep_raw_data (bool, optional): If True, keeps raw MS data loaded in memory after pipeline completes. If False, cleans up raw data to free memory. Default is False.
  • show_progress (bool, optional): If True, displays progress bars during processing. If False, runs silently. Default is True.
Returns
  • dict: Dictionary with pipeline results. Keys include:
    • 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling)
    • 'reload': dict mapping sample_id to reloaded mass features (if reloading)
    • 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search)
    • 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search)
Raises
  • ValueError: If neither operation is enabled, or if required parameters are missing.
Notes
  • Must run add_consensus_mass_features() before calling this method
  • Processes samples in parallel based on parameters.lcms_collection.cores
  • Raw MS1 data loaded by gap-filling is automatically reused by MS1 association
  • MS2 spectral search requires add_ms2=True and msp_file_path
  • FlashEntropy library is created once and reused across all samples
  • More efficient than calling individual methods separately
  • After gap-filling, sets missing_mass_features_searched = True
  • Mass features remain loaded in memory for downstream processing
  • For more advanced workflows, use process_samples_pipeline() directly
Examples
>>> # Prepare spectral library for MS2 search
>>> from corems.molecular_id.search.database_interfaces import MSPInterface
>>> my_msp = MSPInterface(file_path='path/to/library.msp')
>>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library(
...     polarity='negative',
...     format='flashentropy',
...     normalize=True,
...     fe_kwargs={
...         'normalize_intensity': True,
...         'min_ms2_difference_in_da': 0.02,
...         'max_ms2_tolerance_in_da': 0.01,
...         'max_indexed_mz': 3000,
...         'precursor_ions_removal_da': None,
...         'noise_threshold': 0,
...     }
... )
>>>
>>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search
>>> results = lcms_collection.process_consensus_features(
...     load_representatives=True,
...     perform_gap_filling=True,
...     add_ms1=True,
...     add_ms2=True,
...     molecular_formula_search=True,
...     ms2_spectral_search=True,
...     spectral_lib=spectral_lib,
...     molecular_metadata=molecular_metadata
... )
See Also

process_samples_pipeline: Generic pipeline executor for custom workflows
fill_missing_cluster_features: Original gap-filling method
reload_representative_mass_features: Original reload method