corems.chroma_peak.factory.chroma_peak_classes

   1__author__ = "Yuri E. Corilo"
   2__date__ = "Jun 12, 2019"
   3
   4import matplotlib.pyplot as plt
   5import numpy as np
   6import pandas as pd
   7import copy
   8
   9from corems.chroma_peak.calc.ChromaPeakCalc import (
  10    GCPeakCalculation,
  11    LCMSMassFeatureCalculation,
  12)
  13from corems.encapsulation.plot_utils import _finalize_plot
  14from corems.mass_spectra.factory.chromat_data import EIC_Data
  15from corems.molecular_id.factory.EI_SQL import LowResCompoundRef
  16
  17
  18class ChromaPeakBase:
  19    """Base class for chromatographic peak (ChromaPeak) objects.
  20
  21    Parameters
  22    -------
  23    chromatogram_parent : Chromatogram
  24        The parent chromatogram object.
  25    mass_spectrum_obj : MassSpectrum
  26        The mass spectrum object.
  27    start_index : int
  28        The start index of the peak.
  29    index : int
  30        The index of the peak.
  31    final_index : int
  32        The final index of the peak.
  33
  34    Attributes
  35    --------
  36    start_scan : int
  37        The start scan of the peak.
  38    final_scan : int
  39        The final scan of the peak.
  40    apex_scan : int
  41        The apex scan of the peak.
  42    chromatogram_parent : Chromatogram
  43        The parent chromatogram object.
  44    mass_spectrum : MassSpectrum
  45        The mass spectrum object.
  46    _area : float
  47        The area of the peak.
  48
  49    Properties
  50    --------
  51    * retention_time : float.
  52        The retention time of the peak.
  53    * tic : float.
  54        The total ion current of the peak.
  55    * area : float.
  56        The area of the peak.
  57    * rt_list : list.
  58        The list of retention times within the peak.
  59    * tic_list : list.
  60        The list of total ion currents within the peak.
  61
  62    Methods
  63    --------
  64    * None
  65    """
  66
  67    def __init__(
  68        self, chromatogram_parent, mass_spectrum_obj, start_index, index, final_index
  69    ):
  70        self.start_scan = start_index
  71        self.final_scan = final_index
  72        self.apex_scan = int(index)
  73        self.chromatogram_parent = chromatogram_parent
  74        self.mass_spectrum = mass_spectrum_obj
  75        self._area = None
  76
  77    @property
  78    def retention_time(self):
  79        """Retention Time"""
  80        return self.mass_spectrum.retention_time
  81
  82    @property
  83    def tic(self):
  84        """Total Ion Current"""
  85        return self.mass_spectrum.tic
  86
  87    @property
  88    def area(self):
  89        """Peak Area"""
  90        return self._area
  91
  92    @property
  93    def rt_list(self):
  94        """Retention Time List"""
  95        return [
  96            self.chromatogram_parent.retention_time[i]
  97            for i in range(self.start_scan, self.final_scan + 1)
  98        ]
  99
 100    @property
 101    def tic_list(self):
 102        """Total Ion Current List"""
 103        return [
 104            self.chromatogram_parent.tic[i]
 105            for i in range(self.start_scan, self.final_scan + 1)
 106        ]
 107
 108
 109class LCMSMassFeature(ChromaPeakBase, LCMSMassFeatureCalculation):
 110    """Class representing a mass feature in a liquid chromatography (LC) chromatogram.
 111
 112    Parameters
 113    -------
 114    lcms_parent : LCMS
 115        The parent LCMSBase object.
 116    mz : float
 117        The observed mass to charge ratio of the feature.
 118    retention_time : float
 119        The retention time of the feature (in minutes), at the apex.
 120    intensity : float
 121        The intensity of the feature.
 122    apex_scan : int
 123        The scan number of the apex of the feature.
 124    persistence : float, optional
 125        The persistence of the feature. Default is None.
 126
 127    Attributes
 128    --------
 129    _mz_exp : float
 130        The observed mass to charge ratio of the feature.
 131    _mz_cal : float
 132        The calibrated mass to charge ratio of the feature.
 133    _retention_time : float
 134        The retention time of the feature (in minutes), at the apex.
 135    _apex_scan : int
 136        The scan number of the apex of the feature.
 137    _intensity : float
 138        The intensity of the feature.
 139    _persistence : float
 140        The persistence of the feature.
 141    _eic_data : EIC_Data
 142        The EIC data object associated with the feature.
 143    _eic_mz : float
 144        The m/z value used to extract the EIC data,
 145        sometimes different from the observed m/z due to calibration, centroiding, or other processing.
 146    _dispersity_index : float
 147        The dispersity index of the feature, in minutes.
 148    _normalized_dispersity_index : float
 149        The normalized dispersity index of the feature (unitless, fraction of total window used to calculate dispersity index).
 150    _half_height_width : numpy.ndarray
 151        The half height width of the feature (in minutes, as an array of min and max values).
 152    _tailing_factor : float
 153        The tailing factor of the feature.
 154        > 1 indicates tailing, < 1 indicates fronting, = 1 indicates symmetrical peak.
 155    _noise_score : tuple
 156        The noise score of the feature, as a tuple of (left, right) scores.
 157        Each score is a float, with higher values indicating better signal to noise.
 158    _gaussian_similarity : float
 159        The Gaussian similarity of the feature, as a float between 0 and 1.
 160        1 indicates a perfect Gaussian shape, 0 indicates a non-Gaussian shape.
 161    _ms_deconvoluted_idx : [int]
 162        The indexes of the mass_spectrum attribute in the deconvoluted mass spectrum.
 163    _type : str
 164        The type of mass feature. Default is "untargeted".
 165        Can be "untargeted", "targeted", or another customized type.
 166    is_calibrated : bool
 167        If True, the feature has been calibrated. Default is False.
 168    monoisotopic_mf_id : int
 169        Mass feature id that is the monoisotopic version of self.
 170        If self.id, then self is the monoisotopic feature). Default is None.
 171    isotopologue_type : str
 172        The isotopic class of the feature, i.e. "13C1", "13C2", "13C1 37Cl1" etc.
 173        Default is None.
 174    ms2_scan_numbers : list
 175        List of scan numbers of the MS2 spectra associated with the feature.
 176        Default is an empty list.
 177    ms2_mass_spectra : dict
 178        Dictionary of MS2 spectra associated with the feature (key = scan number for DDA).
 179        Default is an empty dictionary.
 180    ms2_similarity_results : list
 181        List of MS2 similarity results associated with the mass feature.
 182        Default is an empty list.
 183    id : int
 184        The ID of the feature, also the key in the parent LCMS object's
 185        `mass_features` dictionary.
 186    mass_spectrum_deconvoluted_parent : bool
 187        If True, the mass feature corresponds to the most intense peak in the deconvoluted mass spectrum. Default is None.
 188    associated_mass_features_deconvoluted : list
 189        List of mass features associated with the deconvoluted mass spectrum. Default is an empty list.
 190
 191    """
 192
 193    def __init__(
 194        self,
 195        lcms_parent,
 196        mz: float,
 197        retention_time: float,
 198        intensity: float,
 199        apex_scan: int,
 200        persistence: float = None,
 201        id: int = None,
 202    ):
 203        super().__init__(
 204            chromatogram_parent=lcms_parent,
 205            mass_spectrum_obj=None,
 206            start_index=None,
 207            index=apex_scan,
 208            final_index=None,
 209        )
 210        # Core attributes, marked as private
 211        self._mz_exp: float = mz
 212        self._mz_cal: float = None
 213        self._retention_time: float = retention_time
 214        self._apex_scan: int = apex_scan
 215        self._intensity: float = intensity
 216        self._persistence: float = persistence
 217        self._eic_data: EIC_Data = None
 218        # m/z used for EIC extraction; set on integrate (may differ from mz after cal)
 219        self._eic_mz: float = None
 220        self._dispersity_index: float = None
 221        self._normalized_dispersity_index: float = None
 222        self._half_height_width: np.ndarray = None
 223        self._ms_deconvoluted_idx = None
 224        self._tailing_factor: float = None
 225        self._noise_score: tuple = None
 226        self._gaussian_similarity: float = None
 227        self._type: str = "untargeted"
 228
 229        # Additional attributes
 230        self.monoisotopic_mf_id = None
 231        self.isotopologue_type = None
 232        self.ms2_scan_numbers = []
 233        self.ms2_mass_spectra = {}
 234        self.ms2_similarity_results = []
 235        self.mass_spectrum_deconvoluted_parent: bool = None
 236        self.associated_mass_features_deconvoluted = []
 237
 238        if id:
 239            self.id = id
 240        else:
 241            # get the parent's mass feature keys and add 1 to the max value to get the new key
 242            self.id = (
 243                max(lcms_parent.mass_features.keys()) + 1
 244                if lcms_parent.mass_features.keys()
 245                else 0
 246            )
 247
 248    def update_mz(self):
 249        """Update the mass to charge ratio from the mass spectrum object."""
 250        if self.mass_spectrum is None:
 251            raise ValueError(
 252                "The mass spectrum object is not set, cannot update the m/z from the MassSpectrum object"
 253            )
 254        if len(self.mass_spectrum.mz_exp) == 0:
 255            raise ValueError(
 256                "The mass spectrum object has no m/z values, cannot update the m/z from the MassSpectrum object until it is processed"
 257            )
 258        new_mz = self.ms1_peak.mz_exp
 259
 260        # calculate the difference between the new and old m/z, only update if it is close
 261        mz_diff = new_mz - self.mz
 262        if abs(mz_diff) < 0.01:
 263            self._mz_exp = new_mz
 264
 265    def _plot_ms1_spectrum(self, ax, deconvoluted=False, sample_name=None):
 266        """Internal method to plot MS1 spectrum on a given axis.
 267
 268        Parameters
 269        ----------
 270        ax : matplotlib.axes.Axes
 271            The axis to plot on.
 272        deconvoluted : bool, optional
 273            If True and deconvoluted spectrum exists, plot both raw and deconvoluted. Default is False.
 274        sample_name : str, optional
 275            Sample name to include in title. Default is None.
 276        """
 277        if self.mass_spectrum is None:
 278            raise ValueError("MS1 spectrum is not available")
 279
 280        title_prefix = "MS1 (deconvoluted)" if deconvoluted else "MS1 (raw)"
 281        if sample_name:
 282            ax.set_title(f"{title_prefix} - {sample_name}", loc="left")
 283        else:
 284            ax.set_title(title_prefix, loc="left")
 285
 286        if deconvoluted and self._ms_deconvoluted_idx is not None:
 287            # Plot both raw and deconvoluted
 288            ax.vlines(
 289                self.mass_spectrum.mz_exp,
 290                0,
 291                self.mass_spectrum.abundance,
 292                color="k",
 293                alpha=0.2,
 294                label="Raw MS1",
 295            )
 296            ax.vlines(
 297                self.mass_spectrum_deconvoluted.mz_exp,
 298                0,
 299                self.mass_spectrum_deconvoluted.abundance,
 300                color="k",
 301                label="Deconvoluted MS1",
 302            )
 303            ax.set_xlim(
 304                self.mass_spectrum_deconvoluted.mz_exp.min() * 0.8,
 305                self.mass_spectrum_deconvoluted.mz_exp.max() * 1.1,
 306            )
 307            ax.set_ylim(0, self.mass_spectrum_deconvoluted.abundance.max() * 1.1)
 308        else:
 309            # Plot raw only
 310            ax.vlines(
 311                self.mass_spectrum.mz_exp,
 312                0,
 313                self.mass_spectrum.abundance,
 314                color="k",
 315                label="Raw MS1",
 316            )
 317            ax.set_xlim(
 318                self.mass_spectrum.mz_exp.min() * 0.8,
 319                self.mass_spectrum.mz_exp.max() * 1.1,
 320            )
 321            ax.set_ylim(bottom=0)
 322
 323        # Highlight the feature m/z if close enough
 324        if abs(self.ms1_peak.mz_exp - self.mz) < 0.01:
 325            ax.vlines(
 326                self.ms1_peak.mz_exp,
 327                0,
 328                self.ms1_peak.abundance,
 329                color="m",
 330                label="Feature m/z",
 331            )
 332        else:
 333            if self.chromatogram_parent.parameters.lc_ms.verbose_processing:
 334                print(
 335                    f"The m/z of the mass feature {self.id} is different from the m/z of MS1 peak, "
 336                    "the MS1 peak will not be plotted"
 337                )
 338
 339        ax.legend(loc="upper left")
 340        ax.set_ylabel("Intensity")
 341        ax.set_xlabel("m/z")
 342        # Combining tick_params(labelleft=False) with set_title(loc="left") makes
 343        # tight_layout() produce NaN axis positions on matplotlib 3.11/numpy 2.5.
 344        ax.set_yticklabels([])
 345
 346    def _plot_ms2_spectrum(self, ax, sample_name=None):
 347        """Internal method to plot MS2 spectrum on a given axis.
 348
 349        Parameters
 350        ----------
 351        ax : matplotlib.axes.Axes
 352            The axis to plot on.
 353        sample_name : str, optional
 354            Sample name to include in title. Default is None.
 355        """
 356        if len(self.ms2_mass_spectra) == 0:
 357            raise ValueError("MS2 spectrum is not available")
 358
 359        if sample_name:
 360            ax.set_title(f"MS2 - {sample_name}", loc="left")
 361        else:
 362            ax.set_title("MS2", loc="left")
 363
 364        ax.vlines(self.best_ms2.mz_exp, 0, self.best_ms2.abundance, color="k")
 365        ax.set_ylabel("Intensity")
 366        ax.set_xlabel("m/z")
 367        ax.set_ylim(bottom=0)
 368        ax.yaxis.get_major_formatter().set_scientific(False)
 369        ax.yaxis.get_major_formatter().set_useOffset(False)
 370
 371    def _plot_ms2_mirror(self, ax, molecular_metadata=None, spectral_library=None):
 372        """Internal method to plot MS2 mirror spectrum on a given axis.
 373
 374        Plots experimental MS2 on top (positive) and library MS2 on bottom (negative/mirrored)
 375        if MS2 similarity results are available. If no MS2 similarity results exist,
 376        falls back to regular MS2 plot.
 377
 378        Parameters
 379        ----------
 380        ax : matplotlib.axes.Axes
 381            The axis to plot on.
 382        molecular_metadata : dict, optional
 383            Dictionary mapping molecular IDs to MetaboliteMetadata objects.
 384            If provided, uses metadata for compound names.
 385            Default is None.
 386        spectral_library : FlashEntropySearch or list of FlashEntropySearch, optional
 387            FlashEntropy spectral library (or list of libraries) containing MS2 spectra.
 388            If provided, uses library to retrieve MS2 spectra by ref_ms_id.
 389            Default is None.
 390
 391        Raises
 392        ------
 393        ValueError
 394            If MS2 similarity results exist but molecular_metadata or spectral_library is None.
 395        """
 396        if len(self.ms2_mass_spectra) == 0:
 397            ax.text(
 398                0.5,
 399                0.5,
 400                "No MS2 data available",
 401                ha="center",
 402                va="center",
 403                transform=ax.transAxes,
 404                fontsize=12,
 405            )
 406            ax.set_xlabel("m/z", fontsize=10)
 407            ax.set_ylabel("Relative Intensity (%)", fontsize=10)
 408            return
 409
 410        # Check if we have MS2 similarity results - if not, fall back to regular MS2 plot
 411        if len(self.ms2_similarity_results) == 0:
 412            self._plot_ms2_spectrum(ax)
 413            return
 414
 415        # If we have MS2 similarity results, we need both molecular_metadata and spectral_library
 416        if molecular_metadata is None or spectral_library is None:
 417            raise ValueError(
 418                "MS2 mirror plot requires both 'molecular_metadata' and 'spectral_library' "
 419                "parameters when MS2 similarity results are present. "
 420                "Please provide both parameters to plot_cluster() or plot()."
 421            )
 422
 423        # Get experimental MS2
 424        sample_ms2 = self.best_ms2
 425        sample_mz = sample_ms2.mz_exp
 426        sample_int = sample_ms2.abundance
 427
 428        # Normalize sample MS2
 429        if len(sample_int) > 0 and max(sample_int) > 0:
 430            sample_int = sample_int / max(sample_int) * 100
 431
 432        # Plot sample MS2 on top (positive)
 433        ax.vlines(
 434            sample_mz,
 435            0,
 436            sample_int,
 437            colors="blue",
 438            alpha=0.7,
 439            linewidths=1.5,
 440            label="Sample MS2",
 441        )
 442
 443        # Check if we have MS2 similarity results
 444        library_ms2_peaks = None
 445        entropy_similarity = None
 446        molecule_name = None
 447        mol_id = None
 448
 449        if len(self.ms2_similarity_results) > 0:
 450            # Get all results as dataframes and find the best match
 451            results_df = [x.to_dataframe() for x in self.ms2_similarity_results]
 452            results_df = pd.concat(results_df)
 453            results_df = results_df.sort_values(
 454                by="entropy_similarity", ascending=False
 455            )
 456
 457            # Get the best match
 458            best_result = results_df.iloc[0]
 459            entropy_similarity = best_result["entropy_similarity"]
 460            mol_id = best_result.get("ref_mol_id", None)
 461            ref_ms_id = best_result.get("ref_ms_id", None)
 462
 463            # Get library spectrum from spectral_library using ref_ms_id
 464            if spectral_library is not None and ref_ms_id is not None:
 465                # Handle both single library and list of libraries
 466                libraries = (
 467                    spectral_library
 468                    if isinstance(spectral_library, list)
 469                    else [spectral_library]
 470                )
 471
 472                # Search through all libraries to find the ref_ms_id
 473                for library in libraries:
 474                    try:
 475                        # Get the IDs in the spectral library
 476                        fe_spec_index = [x["id"] for x in library].index(ref_ms_id)
 477                        library_ms2_peaks = library[fe_spec_index]["peaks"]
 478                        break  # Found the spectrum, exit the loop
 479                    except ValueError:
 480                        # ref_ms_id not found in this library, continue to next
 481                        continue
 482
 483                # If ref_ms_id was not found in any library, raise an error
 484                if library_ms2_peaks is None:
 485                    raise ValueError(
 486                        f"Reference MS ID '{ref_ms_id}' not found in any of the provided spectral libraries. "
 487                        f"Please ensure the spectral library contains the matching reference spectrum."
 488                    )
 489
 490            # Get compound name from molecular_metadata using mol_id
 491            if molecular_metadata is not None and mol_id is not None:
 492                if mol_id in molecular_metadata:
 493                    metadata = molecular_metadata[mol_id]
 494                    # Get compound name from metadata
 495                    molecule_name = getattr(
 496                        metadata, "common_name", getattr(metadata, "name", "Unknown")
 497                    )
 498
 499        # Plot library MS2 on bottom (negative/mirrored)
 500        if library_ms2_peaks is not None and len(library_ms2_peaks) > 0:
 501            lib_mz = library_ms2_peaks[:, 0]
 502            lib_int = library_ms2_peaks[:, 1]
 503            # Normalize
 504            if len(lib_int) > 0 and max(lib_int) > 0:
 505                lib_int = lib_int / max(lib_int) * 100
 506            # Mirror to negative
 507            lib_int_mirror = -lib_int
 508
 509            # Create label with molecule name and molecular ID
 510            lib_label = "Library MS2"
 511            if molecule_name:
 512                lib_label += f" ({molecule_name})"
 513            if mol_id:
 514                lib_label += f" [ID: {mol_id}]"
 515
 516            ax.vlines(
 517                lib_mz,
 518                0,
 519                lib_int_mirror,
 520                colors="red",
 521                alpha=0.7,
 522                linewidths=1.5,
 523                label=lib_label,
 524            )
 525
 526        ax.axhline(0, color="black", linewidth=0.5)
 527        ax.set_xlabel("m/z", fontsize=10)
 528        ax.set_ylabel("Relative Intensity (%)", fontsize=10)
 529        ax.legend(fontsize=8, loc="upper right")
 530        ax.grid(True, alpha=0.3)
 531
 532        # Set y-axis to symmetric range
 533        ax.set_ylim(-105, 105)
 534
 535        # Add entropy similarity to the title if available
 536        if entropy_similarity is not None:
 537            ax.set_title(
 538                f"MS2 Mirror Plot (Entropy Similarity: {entropy_similarity:.3f})",
 539                loc="left",
 540            )
 541        else:
 542            ax.set_title("MS2 Mirror Plot", loc="left")
 543
 544    def _plot_single_eic(
 545        self,
 546        ax,
 547        plot_smoothed=False,
 548        plot_datapoints=False,
 549        eic_buffer_time=None,
 550        show_ms2_scan=True,
 551    ):
 552        """Internal method to plot a single EIC on a given axis.
 553
 554        Parameters
 555        ----------
 556        ax : matplotlib.axes.Axes
 557            The axis to plot on.
 558        plot_smoothed : bool, optional
 559            If True, plot smoothed EIC. Default is False.
 560        plot_datapoints : bool, optional
 561            If True, plot EIC datapoints. Default is False.
 562        eic_buffer_time : float, optional
 563            Time buffer around the peak (minutes). If None, uses parameter setting. Default is None.
 564        show_ms2_scan : bool, optional
 565            If True and MS2 scans exist, show vertical line at MS2 scan time. Default is True.
 566        """
 567        if self._eic_data is None:
 568            raise ValueError("EIC data is not available")
 569
 570        if eic_buffer_time is None:
 571            eic_buffer_time = self.chromatogram_parent.parameters.lc_ms.eic_buffer_time
 572
 573        ax.set_title("EIC", loc="left")
 574        ax.plot(self._eic_data.time, self._eic_data.eic, c="tab:blue", label="EIC")
 575
 576        if plot_datapoints:
 577            ax.scatter(
 578                self._eic_data.time,
 579                self._eic_data.eic,
 580                c="tab:blue",
 581                label="EIC Data Points",
 582            )
 583
 584        if plot_smoothed and hasattr(self._eic_data, "eic_smoothed"):
 585            ax.plot(
 586                self._eic_data.time,
 587                self._eic_data.eic_smoothed,
 588                c="tab:red",
 589                label="Smoothed EIC",
 590            )
 591
 592        # Fill integrated area if available
 593        if self.start_scan is not None:
 594            ax.fill_between(self.eic_rt_list, self.eic_list, color="b", alpha=0.2)
 595        else:
 596            if self.chromatogram_parent.parameters.lc_ms.verbose_processing:
 597                print(
 598                    f"No start and final scan numbers were provided for mass feature {self.id}"
 599                )
 600
 601        ax.set_ylabel("Intensity")
 602        ax.set_xlabel("Time (minutes)")
 603        ax.set_ylim(0, self.eic_list.max() * 1.1)
 604        ax.set_xlim(
 605            self.retention_time - eic_buffer_time,
 606            self.retention_time + eic_buffer_time,
 607        )
 608        ax.axvline(x=self.retention_time, color="k", label="MS1 scan time (apex)")
 609
 610        # Show MS2 scan time if available and requested
 611        if show_ms2_scan and len(self.ms2_scan_numbers) > 0:
 612            ax.axvline(
 613                x=self.chromatogram_parent.get_time_of_scan_id(
 614                    self.best_ms2.scan_number
 615                ),
 616                color="grey",
 617                linestyle="--",
 618                label="MS2 scan time",
 619            )
 620
 621        ax.legend(loc="upper left")
 622        ax.yaxis.get_major_formatter().set_useOffset(False)
 623
 624    def plot(
 625        self,
 626        to_plot=["EIC", "MS1", "MS2"],
 627        return_fig=True,
 628        path=None,
 629        plot_smoothed_eic=False,
 630        plot_eic_datapoints=False,
 631        molecular_metadata=None,
 632        spectral_library=None,
 633    ):
 634        """Plot the mass feature.
 635
 636        Parameters
 637        ----------
 638        to_plot : list, optional
 639            List of strings specifying what to plot, any iteration of
 640            "EIC", "MS2", "MS2_mirror", and "MS1".
 641            Default is ["EIC", "MS1", "MS2"].
 642        return_fig : bool, optional
 643            If True, return the open figure (caller owns lifecycle).
 644            Default is True.
 645        path : str or path-like, optional
 646            If set, save the figure to this path. When ``return_fig`` is False,
 647            the figure is closed after saving and ``plt.show()`` is not called.
 648        plot_smoothed_eic : bool, optional
 649            If True, the smoothed EIC is plotted. Default is False.
 650        plot_eic_datapoints : bool, optional
 651            If True, the EIC data points are plotted. Default is False.
 652        molecular_metadata : dict, optional
 653            Dictionary mapping molecular IDs to MetaboliteMetadata objects.
 654            Required if "MS2_mirror" is in to_plot. Default is None.
 655        spectral_library : FlashEntropySearch, optional
 656            FlashEntropy spectral library containing MS2 spectra.
 657            Required if "MS2_mirror" is in to_plot. Default is None.
 658
 659        Returns
 660        -------
 661        matplotlib.figure.Figure or None
 662            The figure object if `return_fig` is True (left open for the caller).
 663            Otherwise None; the figure is displayed with ``plt.show()`` when
 664            ``path`` is not set.
 665        """
 666        # Adjust to_plot list if there are not spectra added to the mass features
 667        if self.mass_spectrum is None:
 668            to_plot = [x for x in to_plot if x != "MS1"]
 669        if len(self.ms2_mass_spectra) == 0:
 670            to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]]
 671        if self._eic_data is None:
 672            to_plot = [x for x in to_plot if x != "EIC"]
 673
 674        # Check if MS2_mirror is requested without molecular_metadata
 675        if "MS2_mirror" in to_plot and molecular_metadata is None:
 676            raise ValueError(
 677                "molecular_metadata is required when 'MS2_mirror' is in to_plot"
 678            )
 679
 680        # Check if both MS2 and MS2_mirror are requested (not allowed)
 681        if "MS2" in to_plot and "MS2_mirror" in to_plot:
 682            # Remove regular MS2 if mirror is requested
 683            to_plot = [x for x in to_plot if x != "MS2"]
 684
 685        deconvoluted = self._ms_deconvoluted_idx is not None
 686
 687        fig, axs = plt.subplots(
 688            len(to_plot), 1, figsize=(9, len(to_plot) * 4), squeeze=False
 689        )
 690        fig.suptitle(
 691            f"Mass Feature {self.id}: m/z = {round(self.mz, ndigits=4)}; "
 692            f"time = {round(self.retention_time, ndigits=1)} minutes"
 693        )
 694
 695        i = 0
 696        # EIC plot
 697        if "EIC" in to_plot:
 698            self._plot_single_eic(
 699                axs[i][0],
 700                plot_smoothed=plot_smoothed_eic,
 701                plot_datapoints=plot_eic_datapoints,
 702            )
 703            i += 1
 704
 705        # MS1 plot
 706        if "MS1" in to_plot:
 707            self._plot_ms1_spectrum(axs[i][0], deconvoluted=deconvoluted)
 708            i += 1
 709
 710        # MS2 plot
 711        if "MS2" in to_plot:
 712            self._plot_ms2_spectrum(axs[i][0])
 713            i += 1
 714
 715        # MS2 mirror plot
 716        if "MS2_mirror" in to_plot:
 717            self._plot_ms2_mirror(
 718                axs[i][0],
 719                molecular_metadata=molecular_metadata,
 720                spectral_library=spectral_library,
 721            )
 722            i += 1
 723
 724        # Add space between subplots
 725        plt.tight_layout()
 726
 727        return _finalize_plot(fig, return_fig=return_fig, path=path)
 728
 729    @property
 730    def mz(self):
 731        """Mass to charge ratio of the mass feature"""
 732        # If the mass feature has been calibrated, return the calibrated m/z, otherwise return the measured m/z
 733        if self._mz_cal is not None:
 734            return self._mz_cal
 735        else:
 736            return self._mz_exp
 737
 738    @property
 739    def mass_spectrum_deconvoluted(self):
 740        """Returns the deconvoluted mass spectrum object associated with the mass feature, if deconvolution has been performed."""
 741        if self._ms_deconvoluted_idx is not None:
 742            ms_deconvoluted = copy.deepcopy(self.mass_spectrum)
 743            ms_deconvoluted.set_indexes(self._ms_deconvoluted_idx)
 744            return ms_deconvoluted
 745        else:
 746            raise ValueError(
 747                "Deconvolution has not been performed for mass feature " + str(self.id)
 748            )
 749
 750    @property
 751    def retention_time(self):
 752        """Retention time of the mass feature"""
 753        return self._retention_time
 754
 755    @retention_time.setter
 756    def retention_time(self, value):
 757        """Set the retention time of the mass feature"""
 758        if not isinstance(value, float):
 759            raise ValueError("The retention time of the mass feature must be a float")
 760        self._retention_time = value
 761
 762    @property
 763    def apex_scan(self):
 764        """Apex scan of the mass feature"""
 765        return self._apex_scan
 766
 767    @apex_scan.setter
 768    def apex_scan(self, value):
 769        """Set the apex scan of the mass feature"""
 770        if not isinstance(value, int):
 771            raise ValueError("The apex scan of the mass feature must be an integer")
 772        self._apex_scan = value
 773
 774    @property
 775    def intensity(self):
 776        """Intensity of the mass feature"""
 777        return self._intensity
 778
 779    @intensity.setter
 780    def intensity(self, value):
 781        """Set the intensity of the mass feature"""
 782        if not isinstance(value, float):
 783            raise ValueError("The intensity of the mass feature must be a float")
 784        self._intensity = value
 785
 786    @property
 787    def persistence(self):
 788        """Persistence of the mass feature"""
 789        return self._persistence
 790
 791    @persistence.setter
 792    def persistence(self, value):
 793        """Set the persistence of the mass feature"""
 794        if not isinstance(value, float):
 795            raise ValueError("The persistence of the mass feature must be a float")
 796        self._persistence = value
 797
 798    @property
 799    def eic_rt_list(self):
 800        """Retention time list between the beginning and end of the mass feature"""
 801        # Find index of the start and final scans in the EIC data
 802        start_index = self._eic_data.scans.tolist().index(self.start_scan)
 803        final_index = self._eic_data.scans.tolist().index(self.final_scan)
 804
 805        # Get the retention time list
 806        rt_list = self._eic_data.time[start_index : final_index + 1]
 807        return rt_list
 808
 809    @property
 810    def eic_list(self):
 811        """EIC List between the beginning and end of the mass feature"""
 812        # Find index of the start and final scans in the EIC data
 813        start_index = self._eic_data.scans.tolist().index(self.start_scan)
 814        final_index = self._eic_data.scans.tolist().index(self.final_scan)
 815
 816        # Get the retention time list
 817        eic = self._eic_data.eic[start_index : final_index + 1]
 818        return eic
 819
 820    @property
 821    def ms1_peak(self):
 822        """MS1 peak from associated mass spectrum that is closest to the mass feature's m/z"""
 823        # Find index array self.mass_spectrum.mz_exp that is closest to self.mz
 824        closest_mz = min(self.mass_spectrum.mz_exp, key=lambda x: abs(x - self.mz))
 825        closest_mz_index = self.mass_spectrum.mz_exp.tolist().index(closest_mz)
 826
 827        return self.mass_spectrum._mspeaks[closest_mz_index]
 828
 829    @property
 830    def tailing_factor(self):
 831        """Tailing factor of the mass feature"""
 832        return self._tailing_factor
 833
 834    @tailing_factor.setter
 835    def tailing_factor(self, value):
 836        """Set the tailing factor of the mass feature"""
 837        if not isinstance(value, float):
 838            raise ValueError("The tailing factor of the mass feature must be a float")
 839        self._tailing_factor = value
 840
 841    @property
 842    def dispersity_index(self):
 843        """Dispersity index of the mass feature"""
 844        return self._dispersity_index
 845
 846    @dispersity_index.setter
 847    def dispersity_index(self, value):
 848        """Set the dispersity index of the mass feature"""
 849        if not isinstance(value, float):
 850            raise ValueError("The dispersity index of the mass feature must be a float")
 851        self._dispersity_index = value
 852
 853    @property
 854    def normalized_dispersity_index(self):
 855        """Normalized dispersity index of the mass feature, unitless (fraction of total window used)"""
 856        return self._normalized_dispersity_index
 857
 858    @property
 859    def half_height_width(self):
 860        """Half height width of the mass feature, average of min and max values, in minutes"""
 861        return np.mean(self._half_height_width)
 862
 863    @property
 864    def noise_score(self):
 865        """Mean of left and right noise scores.
 866
 867        Returns
 868        -------
 869        float or np.nan
 870            Mean noise score, or np.nan if both sides are np.nan.
 871        """
 872        if self._noise_score is None:
 873            return np.nan
 874
 875        left, right = self._noise_score
 876        # Handle NaN values
 877        if np.isnan(left) and np.isnan(right):
 878            return np.nan
 879        elif np.isnan(left):
 880            return right
 881        elif np.isnan(right):
 882            return left
 883        else:
 884            return (left + right) / 2.0
 885
 886    @property
 887    def noise_score_min(self):
 888        """Minimum of left and right noise scores.
 889
 890        Returns
 891        -------
 892        float or np.nan
 893            Minimum noise score, or np.nan if both sides are np.nan.
 894        """
 895        if self._noise_score is None:
 896            return np.nan
 897
 898        left, right = self._noise_score
 899        # Handle NaN values - nanmin ignores NaN
 900        return np.nanmin([left, right])
 901
 902    @property
 903    def noise_score_max(self):
 904        """Maximum of left and right noise scores.
 905
 906        Returns
 907        -------
 908        float or np.nan
 909            Maximum noise score, or np.nan if both sides are np.nan.
 910        """
 911        if self._noise_score is None:
 912            return np.nan
 913
 914        left, right = self._noise_score
 915        # Handle NaN values - nanmax ignores NaN
 916        return np.nanmax([left, right])
 917
 918    @property
 919    def type(self):
 920        """Type of the mass feature.
 921
 922        Returns
 923        -------
 924        str
 925            The type of mass feature ("untargeted", "targeted", or "internal standard").
 926        """
 927        return self._type
 928
 929    @type.setter
 930    def type(self, value):
 931        """Set the type of the mass feature.
 932
 933        Parameters
 934        ----------
 935        value : str
 936            The type of mass feature. Should be one of: "untargeted", "targeted", "internal standard".
 937        """
 938        if not isinstance(value, str):
 939            raise ValueError("The type of the mass feature must be a string")
 940        self._type = value
 941
 942    @property
 943    def best_ms2(self):
 944        """Points to the best representative MS2 mass spectrum
 945
 946        Notes
 947        -----
 948        If there is only one MS2 mass spectrum, it will be returned
 949        If there are MS2 similarity results, this will return the MS2 mass spectrum with the highest entropy similarity score.
 950        If there are no MS2 similarity results, the best MS2 mass spectrum is determined by the closest scan time to the apex of the mass feature, with higher resolving power.  Checks for and disqualifies possible chimeric spectra.
 951
 952        Returns
 953        -------
 954        MassSpectrum or None
 955            The best MS2 mass spectrum.
 956        """
 957        if len(self.ms2_similarity_results) > 0:
 958            # the scan number with the highest similarity score
 959            results_df = [x.to_dataframe() for x in self.ms2_similarity_results]
 960            results_df = pd.concat(results_df)
 961            results_df = results_df.sort_values(
 962                by="entropy_similarity", ascending=False
 963            )
 964            best_scan_number = results_df.iloc[0]["query_spectrum_id"]
 965            return self.ms2_mass_spectra[best_scan_number]
 966
 967        ms2_scans = list(self.ms2_mass_spectra.keys())
 968        if len(ms2_scans) > 1:
 969            mz_diff_list = []  # List of mz difference between mz of mass feature and mass of nearest mz in each scan
 970            res_list = []  # List of maximum resolving power of peaks in each scan
 971            time_diff_list = []  # List of time difference between scan and apex scan in each scan
 972            for scan in ms2_scans:
 973                if len(self.ms2_mass_spectra[scan].mspeaks) > 0:
 974                    # Find mz closest to mass feature mz, return both the difference in mass and its resolution
 975                    closest_mz = min(
 976                        self.ms2_mass_spectra[scan].mz_exp,
 977                        key=lambda x: abs(x - self.mz),
 978                    )
 979                    if all(
 980                        np.isnan(self.ms2_mass_spectra[scan].resolving_power)
 981                    ):  # All NA for resolving power in peaks, not uncommon in CID spectra
 982                        res_list.append(2)  # Assumes very low resolving power
 983                    else:
 984                        res_list.append(
 985                            np.nanmax(self.ms2_mass_spectra[scan].resolving_power)
 986                        )
 987                    mz_diff_list.append(np.abs(closest_mz - self.mz))
 988                    time_diff_list.append(
 989                        np.abs(
 990                            self.chromatogram_parent.get_time_of_scan_id(scan)
 991                            - self.retention_time
 992                        )
 993                    )
 994                else:
 995                    res_list.append(np.nan)
 996                    mz_diff_list.append(np.nan)
 997                    time_diff_list.append(np.nan)
 998            # Convert diff_lists into logical scores (higher is better for each score)
 999            time_score = 1 - np.array(time_diff_list) / np.nanmax(
1000                np.array(time_diff_list)
1001            )
1002            res_score = np.array(res_list) / np.nanmax(np.array(res_list))
1003            # mz_score is 0 for possible chimerics, 1 for all others (already within mass tolerance before assigning)
1004            mz_score = np.zeros(len(ms2_scans))
1005            for i in np.arange(0, len(ms2_scans)):
1006                if mz_diff_list[i] < 0.8 and mz_diff_list[i] > 0.1:  # Possible chimeric
1007                    mz_score[i] = 0
1008                else:
1009                    mz_score[i] = 1
1010            # get the index of the best score and return the mass spectrum
1011            if len([np.nanargmax(time_score * res_score * mz_score)]) == 1:
1012                return self.ms2_mass_spectra[
1013                    ms2_scans[np.nanargmax(time_score * res_score * mz_score)]
1014                ]
1015            # remove the mz_score condition and try again
1016            elif len(np.argmax(time_score * res_score)) == 1:
1017                return self.ms2_mass_spectra[
1018                    ms2_scans[np.nanargmax(time_score * res_score)]
1019                ]
1020            else:
1021                raise ValueError(
1022                    "No best MS2 mass spectrum could be found for mass feature "
1023                    + str(self.id)
1024                )
1025        elif len(ms2_scans) == 1:  # if only one ms2 spectra, return it
1026            return self.ms2_mass_spectra[ms2_scans[0]]
1027        else:  # if no ms2 spectra, return None
1028            return None
1029
1030
1031class GCPeak(ChromaPeakBase, GCPeakCalculation):
1032    """Class representing a peak in a gas chromatography (GC) chromatogram.
1033
1034    Parameters
1035    ----------
1036    chromatogram_parent : Chromatogram
1037        The parent chromatogram object.
1038    mass_spectrum_obj : MassSpectrum
1039        The mass spectrum object associated with the peak.
1040    indexes : tuple
1041        The indexes of the peak in the chromatogram.
1042
1043    Attributes
1044    ----------
1045    _compounds : list
1046        List of compounds associated with the peak.
1047    _ri : float or None
1048        Retention index of the peak.
1049
1050    Methods
1051    -------
1052    * __len__(). Returns the number of compounds associated with the peak.
1053    * __getitem__(position).  Returns the compound at the specified position.
1054    * remove_compound(compounds_obj). Removes the specified compound from the peak.
1055    * clear_compounds(). Removes all compounds from the peak.
1056    * add_compound(compounds_dict, spectral_similarity_scores, ri_score=None, similarity_score=None). Adds a compound to the peak with the specified attributes.
1057    * ri().  Returns the retention index of the peak.
1058    * highest_ss_compound(). Returns the compound with the highest spectral similarity score.
1059    * highest_score_compound(). Returns the compound with the highest similarity score.
1060    * compound_names(). Returns a list of names of compounds associated with the peak.
1061    """
1062
1063    def __init__(self, chromatogram_parent, mass_spectrum_obj, indexes):
1064        self._compounds = []
1065        self._ri = None
1066        super().__init__(chromatogram_parent, mass_spectrum_obj, *indexes)
1067
1068    def __len__(self):
1069        return len(self._compounds)
1070
1071    def __getitem__(self, position):
1072        return self._compounds[position]
1073
1074    def remove_compound(self, compounds_obj):
1075        self._compounds.remove(compounds_obj)
1076
1077    def clear_compounds(self):
1078        self._compounds = []
1079
1080    def add_compound(
1081        self,
1082        compounds_dict,
1083        spectral_similarity_scores,
1084        ri_score=None,
1085        similarity_score=None,
1086    ):
1087        """Adds a compound to the peak with the specified attributes.
1088
1089        Parameters
1090        ----------
1091        compounds_dict : dict
1092            Dictionary containing the compound information.
1093        spectral_similarity_scores : dict
1094            Dictionary containing the spectral similarity scores.
1095        ri_score : float or None, optional
1096            The retention index score of the compound. Default is None.
1097        similarity_score : float or None, optional
1098            The similarity score of the compound. Default is None.
1099        """
1100        compound_obj = LowResCompoundRef(compounds_dict)
1101        compound_obj.spectral_similarity_scores = spectral_similarity_scores
1102        compound_obj.spectral_similarity_score = spectral_similarity_scores.get(
1103            "cosine_correlation"
1104        )
1105        # TODO check is the above line correct?
1106        compound_obj.ri_score = ri_score
1107        compound_obj.similarity_score = similarity_score
1108        self._compounds.append(compound_obj)
1109        if similarity_score:
1110            self._compounds.sort(key=lambda c: c.similarity_score, reverse=True)
1111        else:
1112            self._compounds.sort(
1113                key=lambda c: c.spectral_similarity_score, reverse=True
1114            )
1115
1116    @property
1117    def ri(self):
1118        """Returns the retention index of the peak.
1119
1120        Returns
1121        -------
1122        float or None
1123            The retention index of the peak.
1124        """
1125        return self._ri
1126
1127    @property
1128    def highest_ss_compound(self):
1129        """Returns the compound with the highest spectral similarity score.
1130
1131        Returns
1132        -------
1133        LowResCompoundRef or None
1134            The compound with the highest spectral similarity score.
1135        """
1136        if self:
1137            return max(self, key=lambda c: c.spectral_similarity_score)
1138        else:
1139            return None
1140
1141    @property
1142    def highest_score_compound(self):
1143        """Returns the compound with the highest similarity score.
1144
1145        Returns
1146        -------
1147        LowResCompoundRef or None
1148            The compound with the highest similarity score.
1149        """
1150        if self:
1151            return max(self, key=lambda c: c.similarity_score)
1152        else:
1153            return None
1154
1155    @property
1156    def compound_names(self):
1157        """Returns a list of names of compounds associated with the peak.
1158
1159        Returns
1160        -------
1161        list
1162            List of names of compounds associated with the peak.
1163        """
1164        if self:
1165            return [c.name for c in self]
1166        else:
1167            return []
1168
1169
1170class GCPeakDeconvolved(GCPeak):
1171    """Represents a deconvolved peak in a chromatogram.
1172
1173    Parameters
1174    ----------
1175    chromatogram_parent : Chromatogram
1176        The parent chromatogram object.
1177    mass_spectra : list
1178        List of mass spectra associated with the peak.
1179    apex_index : int
1180        Index of the apex mass spectrum in the `mass_spectra` list.
1181    rt_list : list
1182        List of retention times.
1183    tic_list : list
1184        List of total ion currents.
1185    """
1186
1187    def __init__(
1188        self, chromatogram_parent, mass_spectra, apex_index, rt_list, tic_list
1189    ):
1190        self._ri = None
1191        self._rt_list = list(rt_list)
1192        self._tic_list = list(tic_list)
1193        self.mass_spectra = list(mass_spectra)
1194        super().__init__(
1195            chromatogram_parent,
1196            self.mass_spectra[apex_index],
1197            (0, apex_index, len(self.mass_spectra) - 1),
1198        )
1199
1200    @property
1201    def rt_list(self):
1202        """Get the list of retention times.
1203
1204        Returns
1205        -------
1206        list
1207            The list of retention times.
1208        """
1209        return self._rt_list
1210
1211    @property
1212    def tic_list(self):
1213        """Get the list of total ion currents.
1214
1215        Returns
1216        -------
1217        list
1218            The list of total ion currents.
1219        """
1220        return self._tic_list
class ChromaPeakBase:
 19class ChromaPeakBase:
 20    """Base class for chromatographic peak (ChromaPeak) objects.
 21
 22    Parameters
 23    -------
 24    chromatogram_parent : Chromatogram
 25        The parent chromatogram object.
 26    mass_spectrum_obj : MassSpectrum
 27        The mass spectrum object.
 28    start_index : int
 29        The start index of the peak.
 30    index : int
 31        The index of the peak.
 32    final_index : int
 33        The final index of the peak.
 34
 35    Attributes
 36    --------
 37    start_scan : int
 38        The start scan of the peak.
 39    final_scan : int
 40        The final scan of the peak.
 41    apex_scan : int
 42        The apex scan of the peak.
 43    chromatogram_parent : Chromatogram
 44        The parent chromatogram object.
 45    mass_spectrum : MassSpectrum
 46        The mass spectrum object.
 47    _area : float
 48        The area of the peak.
 49
 50    Properties
 51    --------
 52    * retention_time : float.
 53        The retention time of the peak.
 54    * tic : float.
 55        The total ion current of the peak.
 56    * area : float.
 57        The area of the peak.
 58    * rt_list : list.
 59        The list of retention times within the peak.
 60    * tic_list : list.
 61        The list of total ion currents within the peak.
 62
 63    Methods
 64    --------
 65    * None
 66    """
 67
 68    def __init__(
 69        self, chromatogram_parent, mass_spectrum_obj, start_index, index, final_index
 70    ):
 71        self.start_scan = start_index
 72        self.final_scan = final_index
 73        self.apex_scan = int(index)
 74        self.chromatogram_parent = chromatogram_parent
 75        self.mass_spectrum = mass_spectrum_obj
 76        self._area = None
 77
 78    @property
 79    def retention_time(self):
 80        """Retention Time"""
 81        return self.mass_spectrum.retention_time
 82
 83    @property
 84    def tic(self):
 85        """Total Ion Current"""
 86        return self.mass_spectrum.tic
 87
 88    @property
 89    def area(self):
 90        """Peak Area"""
 91        return self._area
 92
 93    @property
 94    def rt_list(self):
 95        """Retention Time List"""
 96        return [
 97            self.chromatogram_parent.retention_time[i]
 98            for i in range(self.start_scan, self.final_scan + 1)
 99        ]
100
101    @property
102    def tic_list(self):
103        """Total Ion Current List"""
104        return [
105            self.chromatogram_parent.tic[i]
106            for i in range(self.start_scan, self.final_scan + 1)
107        ]

Base class for chromatographic peak (ChromaPeak) objects.

Parameters
  • chromatogram_parent (Chromatogram): The parent chromatogram object.
  • mass_spectrum_obj (MassSpectrum): The mass spectrum object.
  • start_index (int): The start index of the peak.
  • index (int): The index of the peak.
  • final_index (int): The final index of the peak.
Attributes
  • start_scan (int): The start scan of the peak.
  • final_scan (int): The final scan of the peak.
  • apex_scan (int): The apex scan of the peak.
  • chromatogram_parent (Chromatogram): The parent chromatogram object.
  • mass_spectrum (MassSpectrum): The mass spectrum object.
  • _area (float): The area of the peak.
Properties
  • retention_time : float. The retention time of the peak.
  • tic : float. The total ion current of the peak.
  • area : float. The area of the peak.
  • rt_list : list. The list of retention times within the peak.
  • tic_list : list. The list of total ion currents within the peak.
Methods
  • None
ChromaPeakBase( chromatogram_parent, mass_spectrum_obj, start_index, index, final_index)
68    def __init__(
69        self, chromatogram_parent, mass_spectrum_obj, start_index, index, final_index
70    ):
71        self.start_scan = start_index
72        self.final_scan = final_index
73        self.apex_scan = int(index)
74        self.chromatogram_parent = chromatogram_parent
75        self.mass_spectrum = mass_spectrum_obj
76        self._area = None
start_scan
final_scan
apex_scan
chromatogram_parent
mass_spectrum
retention_time
78    @property
79    def retention_time(self):
80        """Retention Time"""
81        return self.mass_spectrum.retention_time

Retention Time

tic
83    @property
84    def tic(self):
85        """Total Ion Current"""
86        return self.mass_spectrum.tic

Total Ion Current

area
88    @property
89    def area(self):
90        """Peak Area"""
91        return self._area

Peak Area

rt_list
93    @property
94    def rt_list(self):
95        """Retention Time List"""
96        return [
97            self.chromatogram_parent.retention_time[i]
98            for i in range(self.start_scan, self.final_scan + 1)
99        ]

Retention Time List

tic_list
101    @property
102    def tic_list(self):
103        """Total Ion Current List"""
104        return [
105            self.chromatogram_parent.tic[i]
106            for i in range(self.start_scan, self.final_scan + 1)
107        ]

Total Ion Current List

 110class LCMSMassFeature(ChromaPeakBase, LCMSMassFeatureCalculation):
 111    """Class representing a mass feature in a liquid chromatography (LC) chromatogram.
 112
 113    Parameters
 114    -------
 115    lcms_parent : LCMS
 116        The parent LCMSBase object.
 117    mz : float
 118        The observed mass to charge ratio of the feature.
 119    retention_time : float
 120        The retention time of the feature (in minutes), at the apex.
 121    intensity : float
 122        The intensity of the feature.
 123    apex_scan : int
 124        The scan number of the apex of the feature.
 125    persistence : float, optional
 126        The persistence of the feature. Default is None.
 127
 128    Attributes
 129    --------
 130    _mz_exp : float
 131        The observed mass to charge ratio of the feature.
 132    _mz_cal : float
 133        The calibrated mass to charge ratio of the feature.
 134    _retention_time : float
 135        The retention time of the feature (in minutes), at the apex.
 136    _apex_scan : int
 137        The scan number of the apex of the feature.
 138    _intensity : float
 139        The intensity of the feature.
 140    _persistence : float
 141        The persistence of the feature.
 142    _eic_data : EIC_Data
 143        The EIC data object associated with the feature.
 144    _eic_mz : float
 145        The m/z value used to extract the EIC data,
 146        sometimes different from the observed m/z due to calibration, centroiding, or other processing.
 147    _dispersity_index : float
 148        The dispersity index of the feature, in minutes.
 149    _normalized_dispersity_index : float
 150        The normalized dispersity index of the feature (unitless, fraction of total window used to calculate dispersity index).
 151    _half_height_width : numpy.ndarray
 152        The half height width of the feature (in minutes, as an array of min and max values).
 153    _tailing_factor : float
 154        The tailing factor of the feature.
 155        > 1 indicates tailing, < 1 indicates fronting, = 1 indicates symmetrical peak.
 156    _noise_score : tuple
 157        The noise score of the feature, as a tuple of (left, right) scores.
 158        Each score is a float, with higher values indicating better signal to noise.
 159    _gaussian_similarity : float
 160        The Gaussian similarity of the feature, as a float between 0 and 1.
 161        1 indicates a perfect Gaussian shape, 0 indicates a non-Gaussian shape.
 162    _ms_deconvoluted_idx : [int]
 163        The indexes of the mass_spectrum attribute in the deconvoluted mass spectrum.
 164    _type : str
 165        The type of mass feature. Default is "untargeted".
 166        Can be "untargeted", "targeted", or another customized type.
 167    is_calibrated : bool
 168        If True, the feature has been calibrated. Default is False.
 169    monoisotopic_mf_id : int
 170        Mass feature id that is the monoisotopic version of self.
 171        If self.id, then self is the monoisotopic feature). Default is None.
 172    isotopologue_type : str
 173        The isotopic class of the feature, i.e. "13C1", "13C2", "13C1 37Cl1" etc.
 174        Default is None.
 175    ms2_scan_numbers : list
 176        List of scan numbers of the MS2 spectra associated with the feature.
 177        Default is an empty list.
 178    ms2_mass_spectra : dict
 179        Dictionary of MS2 spectra associated with the feature (key = scan number for DDA).
 180        Default is an empty dictionary.
 181    ms2_similarity_results : list
 182        List of MS2 similarity results associated with the mass feature.
 183        Default is an empty list.
 184    id : int
 185        The ID of the feature, also the key in the parent LCMS object's
 186        `mass_features` dictionary.
 187    mass_spectrum_deconvoluted_parent : bool
 188        If True, the mass feature corresponds to the most intense peak in the deconvoluted mass spectrum. Default is None.
 189    associated_mass_features_deconvoluted : list
 190        List of mass features associated with the deconvoluted mass spectrum. Default is an empty list.
 191
 192    """
 193
 194    def __init__(
 195        self,
 196        lcms_parent,
 197        mz: float,
 198        retention_time: float,
 199        intensity: float,
 200        apex_scan: int,
 201        persistence: float = None,
 202        id: int = None,
 203    ):
 204        super().__init__(
 205            chromatogram_parent=lcms_parent,
 206            mass_spectrum_obj=None,
 207            start_index=None,
 208            index=apex_scan,
 209            final_index=None,
 210        )
 211        # Core attributes, marked as private
 212        self._mz_exp: float = mz
 213        self._mz_cal: float = None
 214        self._retention_time: float = retention_time
 215        self._apex_scan: int = apex_scan
 216        self._intensity: float = intensity
 217        self._persistence: float = persistence
 218        self._eic_data: EIC_Data = None
 219        # m/z used for EIC extraction; set on integrate (may differ from mz after cal)
 220        self._eic_mz: float = None
 221        self._dispersity_index: float = None
 222        self._normalized_dispersity_index: float = None
 223        self._half_height_width: np.ndarray = None
 224        self._ms_deconvoluted_idx = None
 225        self._tailing_factor: float = None
 226        self._noise_score: tuple = None
 227        self._gaussian_similarity: float = None
 228        self._type: str = "untargeted"
 229
 230        # Additional attributes
 231        self.monoisotopic_mf_id = None
 232        self.isotopologue_type = None
 233        self.ms2_scan_numbers = []
 234        self.ms2_mass_spectra = {}
 235        self.ms2_similarity_results = []
 236        self.mass_spectrum_deconvoluted_parent: bool = None
 237        self.associated_mass_features_deconvoluted = []
 238
 239        if id:
 240            self.id = id
 241        else:
 242            # get the parent's mass feature keys and add 1 to the max value to get the new key
 243            self.id = (
 244                max(lcms_parent.mass_features.keys()) + 1
 245                if lcms_parent.mass_features.keys()
 246                else 0
 247            )
 248
 249    def update_mz(self):
 250        """Update the mass to charge ratio from the mass spectrum object."""
 251        if self.mass_spectrum is None:
 252            raise ValueError(
 253                "The mass spectrum object is not set, cannot update the m/z from the MassSpectrum object"
 254            )
 255        if len(self.mass_spectrum.mz_exp) == 0:
 256            raise ValueError(
 257                "The mass spectrum object has no m/z values, cannot update the m/z from the MassSpectrum object until it is processed"
 258            )
 259        new_mz = self.ms1_peak.mz_exp
 260
 261        # calculate the difference between the new and old m/z, only update if it is close
 262        mz_diff = new_mz - self.mz
 263        if abs(mz_diff) < 0.01:
 264            self._mz_exp = new_mz
 265
 266    def _plot_ms1_spectrum(self, ax, deconvoluted=False, sample_name=None):
 267        """Internal method to plot MS1 spectrum on a given axis.
 268
 269        Parameters
 270        ----------
 271        ax : matplotlib.axes.Axes
 272            The axis to plot on.
 273        deconvoluted : bool, optional
 274            If True and deconvoluted spectrum exists, plot both raw and deconvoluted. Default is False.
 275        sample_name : str, optional
 276            Sample name to include in title. Default is None.
 277        """
 278        if self.mass_spectrum is None:
 279            raise ValueError("MS1 spectrum is not available")
 280
 281        title_prefix = "MS1 (deconvoluted)" if deconvoluted else "MS1 (raw)"
 282        if sample_name:
 283            ax.set_title(f"{title_prefix} - {sample_name}", loc="left")
 284        else:
 285            ax.set_title(title_prefix, loc="left")
 286
 287        if deconvoluted and self._ms_deconvoluted_idx is not None:
 288            # Plot both raw and deconvoluted
 289            ax.vlines(
 290                self.mass_spectrum.mz_exp,
 291                0,
 292                self.mass_spectrum.abundance,
 293                color="k",
 294                alpha=0.2,
 295                label="Raw MS1",
 296            )
 297            ax.vlines(
 298                self.mass_spectrum_deconvoluted.mz_exp,
 299                0,
 300                self.mass_spectrum_deconvoluted.abundance,
 301                color="k",
 302                label="Deconvoluted MS1",
 303            )
 304            ax.set_xlim(
 305                self.mass_spectrum_deconvoluted.mz_exp.min() * 0.8,
 306                self.mass_spectrum_deconvoluted.mz_exp.max() * 1.1,
 307            )
 308            ax.set_ylim(0, self.mass_spectrum_deconvoluted.abundance.max() * 1.1)
 309        else:
 310            # Plot raw only
 311            ax.vlines(
 312                self.mass_spectrum.mz_exp,
 313                0,
 314                self.mass_spectrum.abundance,
 315                color="k",
 316                label="Raw MS1",
 317            )
 318            ax.set_xlim(
 319                self.mass_spectrum.mz_exp.min() * 0.8,
 320                self.mass_spectrum.mz_exp.max() * 1.1,
 321            )
 322            ax.set_ylim(bottom=0)
 323
 324        # Highlight the feature m/z if close enough
 325        if abs(self.ms1_peak.mz_exp - self.mz) < 0.01:
 326            ax.vlines(
 327                self.ms1_peak.mz_exp,
 328                0,
 329                self.ms1_peak.abundance,
 330                color="m",
 331                label="Feature m/z",
 332            )
 333        else:
 334            if self.chromatogram_parent.parameters.lc_ms.verbose_processing:
 335                print(
 336                    f"The m/z of the mass feature {self.id} is different from the m/z of MS1 peak, "
 337                    "the MS1 peak will not be plotted"
 338                )
 339
 340        ax.legend(loc="upper left")
 341        ax.set_ylabel("Intensity")
 342        ax.set_xlabel("m/z")
 343        # Combining tick_params(labelleft=False) with set_title(loc="left") makes
 344        # tight_layout() produce NaN axis positions on matplotlib 3.11/numpy 2.5.
 345        ax.set_yticklabels([])
 346
 347    def _plot_ms2_spectrum(self, ax, sample_name=None):
 348        """Internal method to plot MS2 spectrum on a given axis.
 349
 350        Parameters
 351        ----------
 352        ax : matplotlib.axes.Axes
 353            The axis to plot on.
 354        sample_name : str, optional
 355            Sample name to include in title. Default is None.
 356        """
 357        if len(self.ms2_mass_spectra) == 0:
 358            raise ValueError("MS2 spectrum is not available")
 359
 360        if sample_name:
 361            ax.set_title(f"MS2 - {sample_name}", loc="left")
 362        else:
 363            ax.set_title("MS2", loc="left")
 364
 365        ax.vlines(self.best_ms2.mz_exp, 0, self.best_ms2.abundance, color="k")
 366        ax.set_ylabel("Intensity")
 367        ax.set_xlabel("m/z")
 368        ax.set_ylim(bottom=0)
 369        ax.yaxis.get_major_formatter().set_scientific(False)
 370        ax.yaxis.get_major_formatter().set_useOffset(False)
 371
 372    def _plot_ms2_mirror(self, ax, molecular_metadata=None, spectral_library=None):
 373        """Internal method to plot MS2 mirror spectrum on a given axis.
 374
 375        Plots experimental MS2 on top (positive) and library MS2 on bottom (negative/mirrored)
 376        if MS2 similarity results are available. If no MS2 similarity results exist,
 377        falls back to regular MS2 plot.
 378
 379        Parameters
 380        ----------
 381        ax : matplotlib.axes.Axes
 382            The axis to plot on.
 383        molecular_metadata : dict, optional
 384            Dictionary mapping molecular IDs to MetaboliteMetadata objects.
 385            If provided, uses metadata for compound names.
 386            Default is None.
 387        spectral_library : FlashEntropySearch or list of FlashEntropySearch, optional
 388            FlashEntropy spectral library (or list of libraries) containing MS2 spectra.
 389            If provided, uses library to retrieve MS2 spectra by ref_ms_id.
 390            Default is None.
 391
 392        Raises
 393        ------
 394        ValueError
 395            If MS2 similarity results exist but molecular_metadata or spectral_library is None.
 396        """
 397        if len(self.ms2_mass_spectra) == 0:
 398            ax.text(
 399                0.5,
 400                0.5,
 401                "No MS2 data available",
 402                ha="center",
 403                va="center",
 404                transform=ax.transAxes,
 405                fontsize=12,
 406            )
 407            ax.set_xlabel("m/z", fontsize=10)
 408            ax.set_ylabel("Relative Intensity (%)", fontsize=10)
 409            return
 410
 411        # Check if we have MS2 similarity results - if not, fall back to regular MS2 plot
 412        if len(self.ms2_similarity_results) == 0:
 413            self._plot_ms2_spectrum(ax)
 414            return
 415
 416        # If we have MS2 similarity results, we need both molecular_metadata and spectral_library
 417        if molecular_metadata is None or spectral_library is None:
 418            raise ValueError(
 419                "MS2 mirror plot requires both 'molecular_metadata' and 'spectral_library' "
 420                "parameters when MS2 similarity results are present. "
 421                "Please provide both parameters to plot_cluster() or plot()."
 422            )
 423
 424        # Get experimental MS2
 425        sample_ms2 = self.best_ms2
 426        sample_mz = sample_ms2.mz_exp
 427        sample_int = sample_ms2.abundance
 428
 429        # Normalize sample MS2
 430        if len(sample_int) > 0 and max(sample_int) > 0:
 431            sample_int = sample_int / max(sample_int) * 100
 432
 433        # Plot sample MS2 on top (positive)
 434        ax.vlines(
 435            sample_mz,
 436            0,
 437            sample_int,
 438            colors="blue",
 439            alpha=0.7,
 440            linewidths=1.5,
 441            label="Sample MS2",
 442        )
 443
 444        # Check if we have MS2 similarity results
 445        library_ms2_peaks = None
 446        entropy_similarity = None
 447        molecule_name = None
 448        mol_id = None
 449
 450        if len(self.ms2_similarity_results) > 0:
 451            # Get all results as dataframes and find the best match
 452            results_df = [x.to_dataframe() for x in self.ms2_similarity_results]
 453            results_df = pd.concat(results_df)
 454            results_df = results_df.sort_values(
 455                by="entropy_similarity", ascending=False
 456            )
 457
 458            # Get the best match
 459            best_result = results_df.iloc[0]
 460            entropy_similarity = best_result["entropy_similarity"]
 461            mol_id = best_result.get("ref_mol_id", None)
 462            ref_ms_id = best_result.get("ref_ms_id", None)
 463
 464            # Get library spectrum from spectral_library using ref_ms_id
 465            if spectral_library is not None and ref_ms_id is not None:
 466                # Handle both single library and list of libraries
 467                libraries = (
 468                    spectral_library
 469                    if isinstance(spectral_library, list)
 470                    else [spectral_library]
 471                )
 472
 473                # Search through all libraries to find the ref_ms_id
 474                for library in libraries:
 475                    try:
 476                        # Get the IDs in the spectral library
 477                        fe_spec_index = [x["id"] for x in library].index(ref_ms_id)
 478                        library_ms2_peaks = library[fe_spec_index]["peaks"]
 479                        break  # Found the spectrum, exit the loop
 480                    except ValueError:
 481                        # ref_ms_id not found in this library, continue to next
 482                        continue
 483
 484                # If ref_ms_id was not found in any library, raise an error
 485                if library_ms2_peaks is None:
 486                    raise ValueError(
 487                        f"Reference MS ID '{ref_ms_id}' not found in any of the provided spectral libraries. "
 488                        f"Please ensure the spectral library contains the matching reference spectrum."
 489                    )
 490
 491            # Get compound name from molecular_metadata using mol_id
 492            if molecular_metadata is not None and mol_id is not None:
 493                if mol_id in molecular_metadata:
 494                    metadata = molecular_metadata[mol_id]
 495                    # Get compound name from metadata
 496                    molecule_name = getattr(
 497                        metadata, "common_name", getattr(metadata, "name", "Unknown")
 498                    )
 499
 500        # Plot library MS2 on bottom (negative/mirrored)
 501        if library_ms2_peaks is not None and len(library_ms2_peaks) > 0:
 502            lib_mz = library_ms2_peaks[:, 0]
 503            lib_int = library_ms2_peaks[:, 1]
 504            # Normalize
 505            if len(lib_int) > 0 and max(lib_int) > 0:
 506                lib_int = lib_int / max(lib_int) * 100
 507            # Mirror to negative
 508            lib_int_mirror = -lib_int
 509
 510            # Create label with molecule name and molecular ID
 511            lib_label = "Library MS2"
 512            if molecule_name:
 513                lib_label += f" ({molecule_name})"
 514            if mol_id:
 515                lib_label += f" [ID: {mol_id}]"
 516
 517            ax.vlines(
 518                lib_mz,
 519                0,
 520                lib_int_mirror,
 521                colors="red",
 522                alpha=0.7,
 523                linewidths=1.5,
 524                label=lib_label,
 525            )
 526
 527        ax.axhline(0, color="black", linewidth=0.5)
 528        ax.set_xlabel("m/z", fontsize=10)
 529        ax.set_ylabel("Relative Intensity (%)", fontsize=10)
 530        ax.legend(fontsize=8, loc="upper right")
 531        ax.grid(True, alpha=0.3)
 532
 533        # Set y-axis to symmetric range
 534        ax.set_ylim(-105, 105)
 535
 536        # Add entropy similarity to the title if available
 537        if entropy_similarity is not None:
 538            ax.set_title(
 539                f"MS2 Mirror Plot (Entropy Similarity: {entropy_similarity:.3f})",
 540                loc="left",
 541            )
 542        else:
 543            ax.set_title("MS2 Mirror Plot", loc="left")
 544
 545    def _plot_single_eic(
 546        self,
 547        ax,
 548        plot_smoothed=False,
 549        plot_datapoints=False,
 550        eic_buffer_time=None,
 551        show_ms2_scan=True,
 552    ):
 553        """Internal method to plot a single EIC on a given axis.
 554
 555        Parameters
 556        ----------
 557        ax : matplotlib.axes.Axes
 558            The axis to plot on.
 559        plot_smoothed : bool, optional
 560            If True, plot smoothed EIC. Default is False.
 561        plot_datapoints : bool, optional
 562            If True, plot EIC datapoints. Default is False.
 563        eic_buffer_time : float, optional
 564            Time buffer around the peak (minutes). If None, uses parameter setting. Default is None.
 565        show_ms2_scan : bool, optional
 566            If True and MS2 scans exist, show vertical line at MS2 scan time. Default is True.
 567        """
 568        if self._eic_data is None:
 569            raise ValueError("EIC data is not available")
 570
 571        if eic_buffer_time is None:
 572            eic_buffer_time = self.chromatogram_parent.parameters.lc_ms.eic_buffer_time
 573
 574        ax.set_title("EIC", loc="left")
 575        ax.plot(self._eic_data.time, self._eic_data.eic, c="tab:blue", label="EIC")
 576
 577        if plot_datapoints:
 578            ax.scatter(
 579                self._eic_data.time,
 580                self._eic_data.eic,
 581                c="tab:blue",
 582                label="EIC Data Points",
 583            )
 584
 585        if plot_smoothed and hasattr(self._eic_data, "eic_smoothed"):
 586            ax.plot(
 587                self._eic_data.time,
 588                self._eic_data.eic_smoothed,
 589                c="tab:red",
 590                label="Smoothed EIC",
 591            )
 592
 593        # Fill integrated area if available
 594        if self.start_scan is not None:
 595            ax.fill_between(self.eic_rt_list, self.eic_list, color="b", alpha=0.2)
 596        else:
 597            if self.chromatogram_parent.parameters.lc_ms.verbose_processing:
 598                print(
 599                    f"No start and final scan numbers were provided for mass feature {self.id}"
 600                )
 601
 602        ax.set_ylabel("Intensity")
 603        ax.set_xlabel("Time (minutes)")
 604        ax.set_ylim(0, self.eic_list.max() * 1.1)
 605        ax.set_xlim(
 606            self.retention_time - eic_buffer_time,
 607            self.retention_time + eic_buffer_time,
 608        )
 609        ax.axvline(x=self.retention_time, color="k", label="MS1 scan time (apex)")
 610
 611        # Show MS2 scan time if available and requested
 612        if show_ms2_scan and len(self.ms2_scan_numbers) > 0:
 613            ax.axvline(
 614                x=self.chromatogram_parent.get_time_of_scan_id(
 615                    self.best_ms2.scan_number
 616                ),
 617                color="grey",
 618                linestyle="--",
 619                label="MS2 scan time",
 620            )
 621
 622        ax.legend(loc="upper left")
 623        ax.yaxis.get_major_formatter().set_useOffset(False)
 624
 625    def plot(
 626        self,
 627        to_plot=["EIC", "MS1", "MS2"],
 628        return_fig=True,
 629        path=None,
 630        plot_smoothed_eic=False,
 631        plot_eic_datapoints=False,
 632        molecular_metadata=None,
 633        spectral_library=None,
 634    ):
 635        """Plot the mass feature.
 636
 637        Parameters
 638        ----------
 639        to_plot : list, optional
 640            List of strings specifying what to plot, any iteration of
 641            "EIC", "MS2", "MS2_mirror", and "MS1".
 642            Default is ["EIC", "MS1", "MS2"].
 643        return_fig : bool, optional
 644            If True, return the open figure (caller owns lifecycle).
 645            Default is True.
 646        path : str or path-like, optional
 647            If set, save the figure to this path. When ``return_fig`` is False,
 648            the figure is closed after saving and ``plt.show()`` is not called.
 649        plot_smoothed_eic : bool, optional
 650            If True, the smoothed EIC is plotted. Default is False.
 651        plot_eic_datapoints : bool, optional
 652            If True, the EIC data points are plotted. Default is False.
 653        molecular_metadata : dict, optional
 654            Dictionary mapping molecular IDs to MetaboliteMetadata objects.
 655            Required if "MS2_mirror" is in to_plot. Default is None.
 656        spectral_library : FlashEntropySearch, optional
 657            FlashEntropy spectral library containing MS2 spectra.
 658            Required if "MS2_mirror" is in to_plot. Default is None.
 659
 660        Returns
 661        -------
 662        matplotlib.figure.Figure or None
 663            The figure object if `return_fig` is True (left open for the caller).
 664            Otherwise None; the figure is displayed with ``plt.show()`` when
 665            ``path`` is not set.
 666        """
 667        # Adjust to_plot list if there are not spectra added to the mass features
 668        if self.mass_spectrum is None:
 669            to_plot = [x for x in to_plot if x != "MS1"]
 670        if len(self.ms2_mass_spectra) == 0:
 671            to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]]
 672        if self._eic_data is None:
 673            to_plot = [x for x in to_plot if x != "EIC"]
 674
 675        # Check if MS2_mirror is requested without molecular_metadata
 676        if "MS2_mirror" in to_plot and molecular_metadata is None:
 677            raise ValueError(
 678                "molecular_metadata is required when 'MS2_mirror' is in to_plot"
 679            )
 680
 681        # Check if both MS2 and MS2_mirror are requested (not allowed)
 682        if "MS2" in to_plot and "MS2_mirror" in to_plot:
 683            # Remove regular MS2 if mirror is requested
 684            to_plot = [x for x in to_plot if x != "MS2"]
 685
 686        deconvoluted = self._ms_deconvoluted_idx is not None
 687
 688        fig, axs = plt.subplots(
 689            len(to_plot), 1, figsize=(9, len(to_plot) * 4), squeeze=False
 690        )
 691        fig.suptitle(
 692            f"Mass Feature {self.id}: m/z = {round(self.mz, ndigits=4)}; "
 693            f"time = {round(self.retention_time, ndigits=1)} minutes"
 694        )
 695
 696        i = 0
 697        # EIC plot
 698        if "EIC" in to_plot:
 699            self._plot_single_eic(
 700                axs[i][0],
 701                plot_smoothed=plot_smoothed_eic,
 702                plot_datapoints=plot_eic_datapoints,
 703            )
 704            i += 1
 705
 706        # MS1 plot
 707        if "MS1" in to_plot:
 708            self._plot_ms1_spectrum(axs[i][0], deconvoluted=deconvoluted)
 709            i += 1
 710
 711        # MS2 plot
 712        if "MS2" in to_plot:
 713            self._plot_ms2_spectrum(axs[i][0])
 714            i += 1
 715
 716        # MS2 mirror plot
 717        if "MS2_mirror" in to_plot:
 718            self._plot_ms2_mirror(
 719                axs[i][0],
 720                molecular_metadata=molecular_metadata,
 721                spectral_library=spectral_library,
 722            )
 723            i += 1
 724
 725        # Add space between subplots
 726        plt.tight_layout()
 727
 728        return _finalize_plot(fig, return_fig=return_fig, path=path)
 729
 730    @property
 731    def mz(self):
 732        """Mass to charge ratio of the mass feature"""
 733        # If the mass feature has been calibrated, return the calibrated m/z, otherwise return the measured m/z
 734        if self._mz_cal is not None:
 735            return self._mz_cal
 736        else:
 737            return self._mz_exp
 738
 739    @property
 740    def mass_spectrum_deconvoluted(self):
 741        """Returns the deconvoluted mass spectrum object associated with the mass feature, if deconvolution has been performed."""
 742        if self._ms_deconvoluted_idx is not None:
 743            ms_deconvoluted = copy.deepcopy(self.mass_spectrum)
 744            ms_deconvoluted.set_indexes(self._ms_deconvoluted_idx)
 745            return ms_deconvoluted
 746        else:
 747            raise ValueError(
 748                "Deconvolution has not been performed for mass feature " + str(self.id)
 749            )
 750
 751    @property
 752    def retention_time(self):
 753        """Retention time of the mass feature"""
 754        return self._retention_time
 755
 756    @retention_time.setter
 757    def retention_time(self, value):
 758        """Set the retention time of the mass feature"""
 759        if not isinstance(value, float):
 760            raise ValueError("The retention time of the mass feature must be a float")
 761        self._retention_time = value
 762
 763    @property
 764    def apex_scan(self):
 765        """Apex scan of the mass feature"""
 766        return self._apex_scan
 767
 768    @apex_scan.setter
 769    def apex_scan(self, value):
 770        """Set the apex scan of the mass feature"""
 771        if not isinstance(value, int):
 772            raise ValueError("The apex scan of the mass feature must be an integer")
 773        self._apex_scan = value
 774
 775    @property
 776    def intensity(self):
 777        """Intensity of the mass feature"""
 778        return self._intensity
 779
 780    @intensity.setter
 781    def intensity(self, value):
 782        """Set the intensity of the mass feature"""
 783        if not isinstance(value, float):
 784            raise ValueError("The intensity of the mass feature must be a float")
 785        self._intensity = value
 786
 787    @property
 788    def persistence(self):
 789        """Persistence of the mass feature"""
 790        return self._persistence
 791
 792    @persistence.setter
 793    def persistence(self, value):
 794        """Set the persistence of the mass feature"""
 795        if not isinstance(value, float):
 796            raise ValueError("The persistence of the mass feature must be a float")
 797        self._persistence = value
 798
 799    @property
 800    def eic_rt_list(self):
 801        """Retention time list between the beginning and end of the mass feature"""
 802        # Find index of the start and final scans in the EIC data
 803        start_index = self._eic_data.scans.tolist().index(self.start_scan)
 804        final_index = self._eic_data.scans.tolist().index(self.final_scan)
 805
 806        # Get the retention time list
 807        rt_list = self._eic_data.time[start_index : final_index + 1]
 808        return rt_list
 809
 810    @property
 811    def eic_list(self):
 812        """EIC List between the beginning and end of the mass feature"""
 813        # Find index of the start and final scans in the EIC data
 814        start_index = self._eic_data.scans.tolist().index(self.start_scan)
 815        final_index = self._eic_data.scans.tolist().index(self.final_scan)
 816
 817        # Get the retention time list
 818        eic = self._eic_data.eic[start_index : final_index + 1]
 819        return eic
 820
 821    @property
 822    def ms1_peak(self):
 823        """MS1 peak from associated mass spectrum that is closest to the mass feature's m/z"""
 824        # Find index array self.mass_spectrum.mz_exp that is closest to self.mz
 825        closest_mz = min(self.mass_spectrum.mz_exp, key=lambda x: abs(x - self.mz))
 826        closest_mz_index = self.mass_spectrum.mz_exp.tolist().index(closest_mz)
 827
 828        return self.mass_spectrum._mspeaks[closest_mz_index]
 829
 830    @property
 831    def tailing_factor(self):
 832        """Tailing factor of the mass feature"""
 833        return self._tailing_factor
 834
 835    @tailing_factor.setter
 836    def tailing_factor(self, value):
 837        """Set the tailing factor of the mass feature"""
 838        if not isinstance(value, float):
 839            raise ValueError("The tailing factor of the mass feature must be a float")
 840        self._tailing_factor = value
 841
 842    @property
 843    def dispersity_index(self):
 844        """Dispersity index of the mass feature"""
 845        return self._dispersity_index
 846
 847    @dispersity_index.setter
 848    def dispersity_index(self, value):
 849        """Set the dispersity index of the mass feature"""
 850        if not isinstance(value, float):
 851            raise ValueError("The dispersity index of the mass feature must be a float")
 852        self._dispersity_index = value
 853
 854    @property
 855    def normalized_dispersity_index(self):
 856        """Normalized dispersity index of the mass feature, unitless (fraction of total window used)"""
 857        return self._normalized_dispersity_index
 858
 859    @property
 860    def half_height_width(self):
 861        """Half height width of the mass feature, average of min and max values, in minutes"""
 862        return np.mean(self._half_height_width)
 863
 864    @property
 865    def noise_score(self):
 866        """Mean of left and right noise scores.
 867
 868        Returns
 869        -------
 870        float or np.nan
 871            Mean noise score, or np.nan if both sides are np.nan.
 872        """
 873        if self._noise_score is None:
 874            return np.nan
 875
 876        left, right = self._noise_score
 877        # Handle NaN values
 878        if np.isnan(left) and np.isnan(right):
 879            return np.nan
 880        elif np.isnan(left):
 881            return right
 882        elif np.isnan(right):
 883            return left
 884        else:
 885            return (left + right) / 2.0
 886
 887    @property
 888    def noise_score_min(self):
 889        """Minimum of left and right noise scores.
 890
 891        Returns
 892        -------
 893        float or np.nan
 894            Minimum noise score, or np.nan if both sides are np.nan.
 895        """
 896        if self._noise_score is None:
 897            return np.nan
 898
 899        left, right = self._noise_score
 900        # Handle NaN values - nanmin ignores NaN
 901        return np.nanmin([left, right])
 902
 903    @property
 904    def noise_score_max(self):
 905        """Maximum of left and right noise scores.
 906
 907        Returns
 908        -------
 909        float or np.nan
 910            Maximum noise score, or np.nan if both sides are np.nan.
 911        """
 912        if self._noise_score is None:
 913            return np.nan
 914
 915        left, right = self._noise_score
 916        # Handle NaN values - nanmax ignores NaN
 917        return np.nanmax([left, right])
 918
 919    @property
 920    def type(self):
 921        """Type of the mass feature.
 922
 923        Returns
 924        -------
 925        str
 926            The type of mass feature ("untargeted", "targeted", or "internal standard").
 927        """
 928        return self._type
 929
 930    @type.setter
 931    def type(self, value):
 932        """Set the type of the mass feature.
 933
 934        Parameters
 935        ----------
 936        value : str
 937            The type of mass feature. Should be one of: "untargeted", "targeted", "internal standard".
 938        """
 939        if not isinstance(value, str):
 940            raise ValueError("The type of the mass feature must be a string")
 941        self._type = value
 942
 943    @property
 944    def best_ms2(self):
 945        """Points to the best representative MS2 mass spectrum
 946
 947        Notes
 948        -----
 949        If there is only one MS2 mass spectrum, it will be returned
 950        If there are MS2 similarity results, this will return the MS2 mass spectrum with the highest entropy similarity score.
 951        If there are no MS2 similarity results, the best MS2 mass spectrum is determined by the closest scan time to the apex of the mass feature, with higher resolving power.  Checks for and disqualifies possible chimeric spectra.
 952
 953        Returns
 954        -------
 955        MassSpectrum or None
 956            The best MS2 mass spectrum.
 957        """
 958        if len(self.ms2_similarity_results) > 0:
 959            # the scan number with the highest similarity score
 960            results_df = [x.to_dataframe() for x in self.ms2_similarity_results]
 961            results_df = pd.concat(results_df)
 962            results_df = results_df.sort_values(
 963                by="entropy_similarity", ascending=False
 964            )
 965            best_scan_number = results_df.iloc[0]["query_spectrum_id"]
 966            return self.ms2_mass_spectra[best_scan_number]
 967
 968        ms2_scans = list(self.ms2_mass_spectra.keys())
 969        if len(ms2_scans) > 1:
 970            mz_diff_list = []  # List of mz difference between mz of mass feature and mass of nearest mz in each scan
 971            res_list = []  # List of maximum resolving power of peaks in each scan
 972            time_diff_list = []  # List of time difference between scan and apex scan in each scan
 973            for scan in ms2_scans:
 974                if len(self.ms2_mass_spectra[scan].mspeaks) > 0:
 975                    # Find mz closest to mass feature mz, return both the difference in mass and its resolution
 976                    closest_mz = min(
 977                        self.ms2_mass_spectra[scan].mz_exp,
 978                        key=lambda x: abs(x - self.mz),
 979                    )
 980                    if all(
 981                        np.isnan(self.ms2_mass_spectra[scan].resolving_power)
 982                    ):  # All NA for resolving power in peaks, not uncommon in CID spectra
 983                        res_list.append(2)  # Assumes very low resolving power
 984                    else:
 985                        res_list.append(
 986                            np.nanmax(self.ms2_mass_spectra[scan].resolving_power)
 987                        )
 988                    mz_diff_list.append(np.abs(closest_mz - self.mz))
 989                    time_diff_list.append(
 990                        np.abs(
 991                            self.chromatogram_parent.get_time_of_scan_id(scan)
 992                            - self.retention_time
 993                        )
 994                    )
 995                else:
 996                    res_list.append(np.nan)
 997                    mz_diff_list.append(np.nan)
 998                    time_diff_list.append(np.nan)
 999            # Convert diff_lists into logical scores (higher is better for each score)
1000            time_score = 1 - np.array(time_diff_list) / np.nanmax(
1001                np.array(time_diff_list)
1002            )
1003            res_score = np.array(res_list) / np.nanmax(np.array(res_list))
1004            # mz_score is 0 for possible chimerics, 1 for all others (already within mass tolerance before assigning)
1005            mz_score = np.zeros(len(ms2_scans))
1006            for i in np.arange(0, len(ms2_scans)):
1007                if mz_diff_list[i] < 0.8 and mz_diff_list[i] > 0.1:  # Possible chimeric
1008                    mz_score[i] = 0
1009                else:
1010                    mz_score[i] = 1
1011            # get the index of the best score and return the mass spectrum
1012            if len([np.nanargmax(time_score * res_score * mz_score)]) == 1:
1013                return self.ms2_mass_spectra[
1014                    ms2_scans[np.nanargmax(time_score * res_score * mz_score)]
1015                ]
1016            # remove the mz_score condition and try again
1017            elif len(np.argmax(time_score * res_score)) == 1:
1018                return self.ms2_mass_spectra[
1019                    ms2_scans[np.nanargmax(time_score * res_score)]
1020                ]
1021            else:
1022                raise ValueError(
1023                    "No best MS2 mass spectrum could be found for mass feature "
1024                    + str(self.id)
1025                )
1026        elif len(ms2_scans) == 1:  # if only one ms2 spectra, return it
1027            return self.ms2_mass_spectra[ms2_scans[0]]
1028        else:  # if no ms2 spectra, return None
1029            return None

Class representing a mass feature in a liquid chromatography (LC) chromatogram.

Parameters
  • lcms_parent (LCMS): The parent LCMSBase object.
  • mz (float): The observed mass to charge ratio of the feature.
  • retention_time (float): The retention time of the feature (in minutes), at the apex.
  • intensity (float): The intensity of the feature.
  • apex_scan (int): The scan number of the apex of the feature.
  • persistence (float, optional): The persistence of the feature. Default is None.
Attributes
  • _mz_exp (float): The observed mass to charge ratio of the feature.
  • _mz_cal (float): The calibrated mass to charge ratio of the feature.
  • _retention_time (float): The retention time of the feature (in minutes), at the apex.
  • _apex_scan (int): The scan number of the apex of the feature.
  • _intensity (float): The intensity of the feature.
  • _persistence (float): The persistence of the feature.
  • _eic_data (EIC_Data): The EIC data object associated with the feature.
  • _eic_mz (float): The m/z value used to extract the EIC data, sometimes different from the observed m/z due to calibration, centroiding, or other processing.
  • _dispersity_index (float): The dispersity index of the feature, in minutes.
  • _normalized_dispersity_index (float): The normalized dispersity index of the feature (unitless, fraction of total window used to calculate dispersity index).
  • _half_height_width (numpy.ndarray): The half height width of the feature (in minutes, as an array of min and max values).
  • _tailing_factor (float): The tailing factor of the feature. > 1 indicates tailing, < 1 indicates fronting, = 1 indicates symmetrical peak.
  • _noise_score (tuple): The noise score of the feature, as a tuple of (left, right) scores. Each score is a float, with higher values indicating better signal to noise.
  • _gaussian_similarity (float): The Gaussian similarity of the feature, as a float between 0 and 1. 1 indicates a perfect Gaussian shape, 0 indicates a non-Gaussian shape.
  • _ms_deconvoluted_idx ([int]): The indexes of the mass_spectrum attribute in the deconvoluted mass spectrum.
  • _type (str): The type of mass feature. Default is "untargeted". Can be "untargeted", "targeted", or another customized type.
  • is_calibrated (bool): If True, the feature has been calibrated. Default is False.
  • monoisotopic_mf_id (int): Mass feature id that is the monoisotopic version of self. If self.id, then self is the monoisotopic feature). Default is None.
  • isotopologue_type (str): The isotopic class of the feature, i.e. "13C1", "13C2", "13C1 37Cl1" etc. Default is None.
  • ms2_scan_numbers (list): List of scan numbers of the MS2 spectra associated with the feature. Default is an empty list.
  • ms2_mass_spectra (dict): Dictionary of MS2 spectra associated with the feature (key = scan number for DDA). Default is an empty dictionary.
  • ms2_similarity_results (list): List of MS2 similarity results associated with the mass feature. Default is an empty list.
  • id (int): The ID of the feature, also the key in the parent LCMS object's mass_features dictionary.
  • mass_spectrum_deconvoluted_parent (bool): If True, the mass feature corresponds to the most intense peak in the deconvoluted mass spectrum. Default is None.
  • associated_mass_features_deconvoluted (list): List of mass features associated with the deconvoluted mass spectrum. Default is an empty list.
LCMSMassFeature( lcms_parent, mz: float, retention_time: float, intensity: float, apex_scan: int, persistence: float = None, id: int = None)
194    def __init__(
195        self,
196        lcms_parent,
197        mz: float,
198        retention_time: float,
199        intensity: float,
200        apex_scan: int,
201        persistence: float = None,
202        id: int = None,
203    ):
204        super().__init__(
205            chromatogram_parent=lcms_parent,
206            mass_spectrum_obj=None,
207            start_index=None,
208            index=apex_scan,
209            final_index=None,
210        )
211        # Core attributes, marked as private
212        self._mz_exp: float = mz
213        self._mz_cal: float = None
214        self._retention_time: float = retention_time
215        self._apex_scan: int = apex_scan
216        self._intensity: float = intensity
217        self._persistence: float = persistence
218        self._eic_data: EIC_Data = None
219        # m/z used for EIC extraction; set on integrate (may differ from mz after cal)
220        self._eic_mz: float = None
221        self._dispersity_index: float = None
222        self._normalized_dispersity_index: float = None
223        self._half_height_width: np.ndarray = None
224        self._ms_deconvoluted_idx = None
225        self._tailing_factor: float = None
226        self._noise_score: tuple = None
227        self._gaussian_similarity: float = None
228        self._type: str = "untargeted"
229
230        # Additional attributes
231        self.monoisotopic_mf_id = None
232        self.isotopologue_type = None
233        self.ms2_scan_numbers = []
234        self.ms2_mass_spectra = {}
235        self.ms2_similarity_results = []
236        self.mass_spectrum_deconvoluted_parent: bool = None
237        self.associated_mass_features_deconvoluted = []
238
239        if id:
240            self.id = id
241        else:
242            # get the parent's mass feature keys and add 1 to the max value to get the new key
243            self.id = (
244                max(lcms_parent.mass_features.keys()) + 1
245                if lcms_parent.mass_features.keys()
246                else 0
247            )
monoisotopic_mf_id
isotopologue_type
ms2_scan_numbers
ms2_mass_spectra
ms2_similarity_results
mass_spectrum_deconvoluted_parent: bool
associated_mass_features_deconvoluted
def update_mz(self):
249    def update_mz(self):
250        """Update the mass to charge ratio from the mass spectrum object."""
251        if self.mass_spectrum is None:
252            raise ValueError(
253                "The mass spectrum object is not set, cannot update the m/z from the MassSpectrum object"
254            )
255        if len(self.mass_spectrum.mz_exp) == 0:
256            raise ValueError(
257                "The mass spectrum object has no m/z values, cannot update the m/z from the MassSpectrum object until it is processed"
258            )
259        new_mz = self.ms1_peak.mz_exp
260
261        # calculate the difference between the new and old m/z, only update if it is close
262        mz_diff = new_mz - self.mz
263        if abs(mz_diff) < 0.01:
264            self._mz_exp = new_mz

Update the mass to charge ratio from the mass spectrum object.

def plot( self, to_plot=['EIC', 'MS1', 'MS2'], return_fig=True, path=None, plot_smoothed_eic=False, plot_eic_datapoints=False, molecular_metadata=None, spectral_library=None):
625    def plot(
626        self,
627        to_plot=["EIC", "MS1", "MS2"],
628        return_fig=True,
629        path=None,
630        plot_smoothed_eic=False,
631        plot_eic_datapoints=False,
632        molecular_metadata=None,
633        spectral_library=None,
634    ):
635        """Plot the mass feature.
636
637        Parameters
638        ----------
639        to_plot : list, optional
640            List of strings specifying what to plot, any iteration of
641            "EIC", "MS2", "MS2_mirror", and "MS1".
642            Default is ["EIC", "MS1", "MS2"].
643        return_fig : bool, optional
644            If True, return the open figure (caller owns lifecycle).
645            Default is True.
646        path : str or path-like, optional
647            If set, save the figure to this path. When ``return_fig`` is False,
648            the figure is closed after saving and ``plt.show()`` is not called.
649        plot_smoothed_eic : bool, optional
650            If True, the smoothed EIC is plotted. Default is False.
651        plot_eic_datapoints : bool, optional
652            If True, the EIC data points are plotted. Default is False.
653        molecular_metadata : dict, optional
654            Dictionary mapping molecular IDs to MetaboliteMetadata objects.
655            Required if "MS2_mirror" is in to_plot. Default is None.
656        spectral_library : FlashEntropySearch, optional
657            FlashEntropy spectral library containing MS2 spectra.
658            Required if "MS2_mirror" is in to_plot. Default is None.
659
660        Returns
661        -------
662        matplotlib.figure.Figure or None
663            The figure object if `return_fig` is True (left open for the caller).
664            Otherwise None; the figure is displayed with ``plt.show()`` when
665            ``path`` is not set.
666        """
667        # Adjust to_plot list if there are not spectra added to the mass features
668        if self.mass_spectrum is None:
669            to_plot = [x for x in to_plot if x != "MS1"]
670        if len(self.ms2_mass_spectra) == 0:
671            to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]]
672        if self._eic_data is None:
673            to_plot = [x for x in to_plot if x != "EIC"]
674
675        # Check if MS2_mirror is requested without molecular_metadata
676        if "MS2_mirror" in to_plot and molecular_metadata is None:
677            raise ValueError(
678                "molecular_metadata is required when 'MS2_mirror' is in to_plot"
679            )
680
681        # Check if both MS2 and MS2_mirror are requested (not allowed)
682        if "MS2" in to_plot and "MS2_mirror" in to_plot:
683            # Remove regular MS2 if mirror is requested
684            to_plot = [x for x in to_plot if x != "MS2"]
685
686        deconvoluted = self._ms_deconvoluted_idx is not None
687
688        fig, axs = plt.subplots(
689            len(to_plot), 1, figsize=(9, len(to_plot) * 4), squeeze=False
690        )
691        fig.suptitle(
692            f"Mass Feature {self.id}: m/z = {round(self.mz, ndigits=4)}; "
693            f"time = {round(self.retention_time, ndigits=1)} minutes"
694        )
695
696        i = 0
697        # EIC plot
698        if "EIC" in to_plot:
699            self._plot_single_eic(
700                axs[i][0],
701                plot_smoothed=plot_smoothed_eic,
702                plot_datapoints=plot_eic_datapoints,
703            )
704            i += 1
705
706        # MS1 plot
707        if "MS1" in to_plot:
708            self._plot_ms1_spectrum(axs[i][0], deconvoluted=deconvoluted)
709            i += 1
710
711        # MS2 plot
712        if "MS2" in to_plot:
713            self._plot_ms2_spectrum(axs[i][0])
714            i += 1
715
716        # MS2 mirror plot
717        if "MS2_mirror" in to_plot:
718            self._plot_ms2_mirror(
719                axs[i][0],
720                molecular_metadata=molecular_metadata,
721                spectral_library=spectral_library,
722            )
723            i += 1
724
725        # Add space between subplots
726        plt.tight_layout()
727
728        return _finalize_plot(fig, return_fig=return_fig, path=path)

Plot the mass feature.

Parameters
  • to_plot (list, optional): List of strings specifying what to plot, any iteration of "EIC", "MS2", "MS2_mirror", and "MS1". Default is ["EIC", "MS1", "MS2"].
  • return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is True.
  • 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, the smoothed EIC is plotted. Default is False.
  • plot_eic_datapoints (bool, optional): If True, the EIC data points are plotted. Default is False.
  • molecular_metadata (dict, optional): Dictionary mapping molecular IDs to MetaboliteMetadata objects. Required if "MS2_mirror" is in to_plot. Default is None.
  • spectral_library (FlashEntropySearch, optional): FlashEntropy spectral library containing MS2 spectra. Required if "MS2_mirror" is in to_plot. Default is None.
Returns
  • matplotlib.figure.Figure or None: The figure object if return_fig is True (left open for the caller). Otherwise None; the figure is displayed with plt.show() when path is not set.
mz
730    @property
731    def mz(self):
732        """Mass to charge ratio of the mass feature"""
733        # If the mass feature has been calibrated, return the calibrated m/z, otherwise return the measured m/z
734        if self._mz_cal is not None:
735            return self._mz_cal
736        else:
737            return self._mz_exp

Mass to charge ratio of the mass feature

mass_spectrum_deconvoluted
739    @property
740    def mass_spectrum_deconvoluted(self):
741        """Returns the deconvoluted mass spectrum object associated with the mass feature, if deconvolution has been performed."""
742        if self._ms_deconvoluted_idx is not None:
743            ms_deconvoluted = copy.deepcopy(self.mass_spectrum)
744            ms_deconvoluted.set_indexes(self._ms_deconvoluted_idx)
745            return ms_deconvoluted
746        else:
747            raise ValueError(
748                "Deconvolution has not been performed for mass feature " + str(self.id)
749            )

Returns the deconvoluted mass spectrum object associated with the mass feature, if deconvolution has been performed.

retention_time
751    @property
752    def retention_time(self):
753        """Retention time of the mass feature"""
754        return self._retention_time

Retention time of the mass feature

apex_scan
763    @property
764    def apex_scan(self):
765        """Apex scan of the mass feature"""
766        return self._apex_scan

Apex scan of the mass feature

intensity
775    @property
776    def intensity(self):
777        """Intensity of the mass feature"""
778        return self._intensity

Intensity of the mass feature

persistence
787    @property
788    def persistence(self):
789        """Persistence of the mass feature"""
790        return self._persistence

Persistence of the mass feature

eic_rt_list
799    @property
800    def eic_rt_list(self):
801        """Retention time list between the beginning and end of the mass feature"""
802        # Find index of the start and final scans in the EIC data
803        start_index = self._eic_data.scans.tolist().index(self.start_scan)
804        final_index = self._eic_data.scans.tolist().index(self.final_scan)
805
806        # Get the retention time list
807        rt_list = self._eic_data.time[start_index : final_index + 1]
808        return rt_list

Retention time list between the beginning and end of the mass feature

eic_list
810    @property
811    def eic_list(self):
812        """EIC List between the beginning and end of the mass feature"""
813        # Find index of the start and final scans in the EIC data
814        start_index = self._eic_data.scans.tolist().index(self.start_scan)
815        final_index = self._eic_data.scans.tolist().index(self.final_scan)
816
817        # Get the retention time list
818        eic = self._eic_data.eic[start_index : final_index + 1]
819        return eic

EIC List between the beginning and end of the mass feature

ms1_peak
821    @property
822    def ms1_peak(self):
823        """MS1 peak from associated mass spectrum that is closest to the mass feature's m/z"""
824        # Find index array self.mass_spectrum.mz_exp that is closest to self.mz
825        closest_mz = min(self.mass_spectrum.mz_exp, key=lambda x: abs(x - self.mz))
826        closest_mz_index = self.mass_spectrum.mz_exp.tolist().index(closest_mz)
827
828        return self.mass_spectrum._mspeaks[closest_mz_index]

MS1 peak from associated mass spectrum that is closest to the mass feature's m/z

tailing_factor
830    @property
831    def tailing_factor(self):
832        """Tailing factor of the mass feature"""
833        return self._tailing_factor

Tailing factor of the mass feature

dispersity_index
842    @property
843    def dispersity_index(self):
844        """Dispersity index of the mass feature"""
845        return self._dispersity_index

Dispersity index of the mass feature

normalized_dispersity_index
854    @property
855    def normalized_dispersity_index(self):
856        """Normalized dispersity index of the mass feature, unitless (fraction of total window used)"""
857        return self._normalized_dispersity_index

Normalized dispersity index of the mass feature, unitless (fraction of total window used)

half_height_width
859    @property
860    def half_height_width(self):
861        """Half height width of the mass feature, average of min and max values, in minutes"""
862        return np.mean(self._half_height_width)

Half height width of the mass feature, average of min and max values, in minutes

noise_score
864    @property
865    def noise_score(self):
866        """Mean of left and right noise scores.
867
868        Returns
869        -------
870        float or np.nan
871            Mean noise score, or np.nan if both sides are np.nan.
872        """
873        if self._noise_score is None:
874            return np.nan
875
876        left, right = self._noise_score
877        # Handle NaN values
878        if np.isnan(left) and np.isnan(right):
879            return np.nan
880        elif np.isnan(left):
881            return right
882        elif np.isnan(right):
883            return left
884        else:
885            return (left + right) / 2.0

Mean of left and right noise scores.

Returns
  • float or np.nan: Mean noise score, or np.nan if both sides are np.nan.
noise_score_min
887    @property
888    def noise_score_min(self):
889        """Minimum of left and right noise scores.
890
891        Returns
892        -------
893        float or np.nan
894            Minimum noise score, or np.nan if both sides are np.nan.
895        """
896        if self._noise_score is None:
897            return np.nan
898
899        left, right = self._noise_score
900        # Handle NaN values - nanmin ignores NaN
901        return np.nanmin([left, right])

Minimum of left and right noise scores.

Returns
  • float or np.nan: Minimum noise score, or np.nan if both sides are np.nan.
noise_score_max
903    @property
904    def noise_score_max(self):
905        """Maximum of left and right noise scores.
906
907        Returns
908        -------
909        float or np.nan
910            Maximum noise score, or np.nan if both sides are np.nan.
911        """
912        if self._noise_score is None:
913            return np.nan
914
915        left, right = self._noise_score
916        # Handle NaN values - nanmax ignores NaN
917        return np.nanmax([left, right])

Maximum of left and right noise scores.

Returns
  • float or np.nan: Maximum noise score, or np.nan if both sides are np.nan.
type
919    @property
920    def type(self):
921        """Type of the mass feature.
922
923        Returns
924        -------
925        str
926            The type of mass feature ("untargeted", "targeted", or "internal standard").
927        """
928        return self._type

Type of the mass feature.

Returns
  • str: The type of mass feature ("untargeted", "targeted", or "internal standard").
best_ms2
 943    @property
 944    def best_ms2(self):
 945        """Points to the best representative MS2 mass spectrum
 946
 947        Notes
 948        -----
 949        If there is only one MS2 mass spectrum, it will be returned
 950        If there are MS2 similarity results, this will return the MS2 mass spectrum with the highest entropy similarity score.
 951        If there are no MS2 similarity results, the best MS2 mass spectrum is determined by the closest scan time to the apex of the mass feature, with higher resolving power.  Checks for and disqualifies possible chimeric spectra.
 952
 953        Returns
 954        -------
 955        MassSpectrum or None
 956            The best MS2 mass spectrum.
 957        """
 958        if len(self.ms2_similarity_results) > 0:
 959            # the scan number with the highest similarity score
 960            results_df = [x.to_dataframe() for x in self.ms2_similarity_results]
 961            results_df = pd.concat(results_df)
 962            results_df = results_df.sort_values(
 963                by="entropy_similarity", ascending=False
 964            )
 965            best_scan_number = results_df.iloc[0]["query_spectrum_id"]
 966            return self.ms2_mass_spectra[best_scan_number]
 967
 968        ms2_scans = list(self.ms2_mass_spectra.keys())
 969        if len(ms2_scans) > 1:
 970            mz_diff_list = []  # List of mz difference between mz of mass feature and mass of nearest mz in each scan
 971            res_list = []  # List of maximum resolving power of peaks in each scan
 972            time_diff_list = []  # List of time difference between scan and apex scan in each scan
 973            for scan in ms2_scans:
 974                if len(self.ms2_mass_spectra[scan].mspeaks) > 0:
 975                    # Find mz closest to mass feature mz, return both the difference in mass and its resolution
 976                    closest_mz = min(
 977                        self.ms2_mass_spectra[scan].mz_exp,
 978                        key=lambda x: abs(x - self.mz),
 979                    )
 980                    if all(
 981                        np.isnan(self.ms2_mass_spectra[scan].resolving_power)
 982                    ):  # All NA for resolving power in peaks, not uncommon in CID spectra
 983                        res_list.append(2)  # Assumes very low resolving power
 984                    else:
 985                        res_list.append(
 986                            np.nanmax(self.ms2_mass_spectra[scan].resolving_power)
 987                        )
 988                    mz_diff_list.append(np.abs(closest_mz - self.mz))
 989                    time_diff_list.append(
 990                        np.abs(
 991                            self.chromatogram_parent.get_time_of_scan_id(scan)
 992                            - self.retention_time
 993                        )
 994                    )
 995                else:
 996                    res_list.append(np.nan)
 997                    mz_diff_list.append(np.nan)
 998                    time_diff_list.append(np.nan)
 999            # Convert diff_lists into logical scores (higher is better for each score)
1000            time_score = 1 - np.array(time_diff_list) / np.nanmax(
1001                np.array(time_diff_list)
1002            )
1003            res_score = np.array(res_list) / np.nanmax(np.array(res_list))
1004            # mz_score is 0 for possible chimerics, 1 for all others (already within mass tolerance before assigning)
1005            mz_score = np.zeros(len(ms2_scans))
1006            for i in np.arange(0, len(ms2_scans)):
1007                if mz_diff_list[i] < 0.8 and mz_diff_list[i] > 0.1:  # Possible chimeric
1008                    mz_score[i] = 0
1009                else:
1010                    mz_score[i] = 1
1011            # get the index of the best score and return the mass spectrum
1012            if len([np.nanargmax(time_score * res_score * mz_score)]) == 1:
1013                return self.ms2_mass_spectra[
1014                    ms2_scans[np.nanargmax(time_score * res_score * mz_score)]
1015                ]
1016            # remove the mz_score condition and try again
1017            elif len(np.argmax(time_score * res_score)) == 1:
1018                return self.ms2_mass_spectra[
1019                    ms2_scans[np.nanargmax(time_score * res_score)]
1020                ]
1021            else:
1022                raise ValueError(
1023                    "No best MS2 mass spectrum could be found for mass feature "
1024                    + str(self.id)
1025                )
1026        elif len(ms2_scans) == 1:  # if only one ms2 spectra, return it
1027            return self.ms2_mass_spectra[ms2_scans[0]]
1028        else:  # if no ms2 spectra, return None
1029            return None

Points to the best representative MS2 mass spectrum

Notes

If there is only one MS2 mass spectrum, it will be returned If there are MS2 similarity results, this will return the MS2 mass spectrum with the highest entropy similarity score. If there are no MS2 similarity results, the best MS2 mass spectrum is determined by the closest scan time to the apex of the mass feature, with higher resolving power. Checks for and disqualifies possible chimeric spectra.

Returns
  • MassSpectrum or None: The best MS2 mass spectrum.
1032class GCPeak(ChromaPeakBase, GCPeakCalculation):
1033    """Class representing a peak in a gas chromatography (GC) chromatogram.
1034
1035    Parameters
1036    ----------
1037    chromatogram_parent : Chromatogram
1038        The parent chromatogram object.
1039    mass_spectrum_obj : MassSpectrum
1040        The mass spectrum object associated with the peak.
1041    indexes : tuple
1042        The indexes of the peak in the chromatogram.
1043
1044    Attributes
1045    ----------
1046    _compounds : list
1047        List of compounds associated with the peak.
1048    _ri : float or None
1049        Retention index of the peak.
1050
1051    Methods
1052    -------
1053    * __len__(). Returns the number of compounds associated with the peak.
1054    * __getitem__(position).  Returns the compound at the specified position.
1055    * remove_compound(compounds_obj). Removes the specified compound from the peak.
1056    * clear_compounds(). Removes all compounds from the peak.
1057    * add_compound(compounds_dict, spectral_similarity_scores, ri_score=None, similarity_score=None). Adds a compound to the peak with the specified attributes.
1058    * ri().  Returns the retention index of the peak.
1059    * highest_ss_compound(). Returns the compound with the highest spectral similarity score.
1060    * highest_score_compound(). Returns the compound with the highest similarity score.
1061    * compound_names(). Returns a list of names of compounds associated with the peak.
1062    """
1063
1064    def __init__(self, chromatogram_parent, mass_spectrum_obj, indexes):
1065        self._compounds = []
1066        self._ri = None
1067        super().__init__(chromatogram_parent, mass_spectrum_obj, *indexes)
1068
1069    def __len__(self):
1070        return len(self._compounds)
1071
1072    def __getitem__(self, position):
1073        return self._compounds[position]
1074
1075    def remove_compound(self, compounds_obj):
1076        self._compounds.remove(compounds_obj)
1077
1078    def clear_compounds(self):
1079        self._compounds = []
1080
1081    def add_compound(
1082        self,
1083        compounds_dict,
1084        spectral_similarity_scores,
1085        ri_score=None,
1086        similarity_score=None,
1087    ):
1088        """Adds a compound to the peak with the specified attributes.
1089
1090        Parameters
1091        ----------
1092        compounds_dict : dict
1093            Dictionary containing the compound information.
1094        spectral_similarity_scores : dict
1095            Dictionary containing the spectral similarity scores.
1096        ri_score : float or None, optional
1097            The retention index score of the compound. Default is None.
1098        similarity_score : float or None, optional
1099            The similarity score of the compound. Default is None.
1100        """
1101        compound_obj = LowResCompoundRef(compounds_dict)
1102        compound_obj.spectral_similarity_scores = spectral_similarity_scores
1103        compound_obj.spectral_similarity_score = spectral_similarity_scores.get(
1104            "cosine_correlation"
1105        )
1106        # TODO check is the above line correct?
1107        compound_obj.ri_score = ri_score
1108        compound_obj.similarity_score = similarity_score
1109        self._compounds.append(compound_obj)
1110        if similarity_score:
1111            self._compounds.sort(key=lambda c: c.similarity_score, reverse=True)
1112        else:
1113            self._compounds.sort(
1114                key=lambda c: c.spectral_similarity_score, reverse=True
1115            )
1116
1117    @property
1118    def ri(self):
1119        """Returns the retention index of the peak.
1120
1121        Returns
1122        -------
1123        float or None
1124            The retention index of the peak.
1125        """
1126        return self._ri
1127
1128    @property
1129    def highest_ss_compound(self):
1130        """Returns the compound with the highest spectral similarity score.
1131
1132        Returns
1133        -------
1134        LowResCompoundRef or None
1135            The compound with the highest spectral similarity score.
1136        """
1137        if self:
1138            return max(self, key=lambda c: c.spectral_similarity_score)
1139        else:
1140            return None
1141
1142    @property
1143    def highest_score_compound(self):
1144        """Returns the compound with the highest similarity score.
1145
1146        Returns
1147        -------
1148        LowResCompoundRef or None
1149            The compound with the highest similarity score.
1150        """
1151        if self:
1152            return max(self, key=lambda c: c.similarity_score)
1153        else:
1154            return None
1155
1156    @property
1157    def compound_names(self):
1158        """Returns a list of names of compounds associated with the peak.
1159
1160        Returns
1161        -------
1162        list
1163            List of names of compounds associated with the peak.
1164        """
1165        if self:
1166            return [c.name for c in self]
1167        else:
1168            return []

Class representing a peak in a gas chromatography (GC) chromatogram.

Parameters
  • chromatogram_parent (Chromatogram): The parent chromatogram object.
  • mass_spectrum_obj (MassSpectrum): The mass spectrum object associated with the peak.
  • indexes (tuple): The indexes of the peak in the chromatogram.
Attributes
  • _compounds (list): List of compounds associated with the peak.
  • _ri (float or None): Retention index of the peak.
Methods
  • __len__(). Returns the number of compounds associated with the peak.
  • __getitem__(position). Returns the compound at the specified position.
  • remove_compound(compounds_obj). Removes the specified compound from the peak.
  • clear_compounds(). Removes all compounds from the peak.
  • add_compound(compounds_dict, spectral_similarity_scores, ri_score=None, similarity_score=None). Adds a compound to the peak with the specified attributes.
  • ri(). Returns the retention index of the peak.
  • highest_ss_compound(). Returns the compound with the highest spectral similarity score.
  • highest_score_compound(). Returns the compound with the highest similarity score.
  • compound_names(). Returns a list of names of compounds associated with the peak.
GCPeak(chromatogram_parent, mass_spectrum_obj, indexes)
1064    def __init__(self, chromatogram_parent, mass_spectrum_obj, indexes):
1065        self._compounds = []
1066        self._ri = None
1067        super().__init__(chromatogram_parent, mass_spectrum_obj, *indexes)
def remove_compound(self, compounds_obj):
1075    def remove_compound(self, compounds_obj):
1076        self._compounds.remove(compounds_obj)
def clear_compounds(self):
1078    def clear_compounds(self):
1079        self._compounds = []
def add_compound( self, compounds_dict, spectral_similarity_scores, ri_score=None, similarity_score=None):
1081    def add_compound(
1082        self,
1083        compounds_dict,
1084        spectral_similarity_scores,
1085        ri_score=None,
1086        similarity_score=None,
1087    ):
1088        """Adds a compound to the peak with the specified attributes.
1089
1090        Parameters
1091        ----------
1092        compounds_dict : dict
1093            Dictionary containing the compound information.
1094        spectral_similarity_scores : dict
1095            Dictionary containing the spectral similarity scores.
1096        ri_score : float or None, optional
1097            The retention index score of the compound. Default is None.
1098        similarity_score : float or None, optional
1099            The similarity score of the compound. Default is None.
1100        """
1101        compound_obj = LowResCompoundRef(compounds_dict)
1102        compound_obj.spectral_similarity_scores = spectral_similarity_scores
1103        compound_obj.spectral_similarity_score = spectral_similarity_scores.get(
1104            "cosine_correlation"
1105        )
1106        # TODO check is the above line correct?
1107        compound_obj.ri_score = ri_score
1108        compound_obj.similarity_score = similarity_score
1109        self._compounds.append(compound_obj)
1110        if similarity_score:
1111            self._compounds.sort(key=lambda c: c.similarity_score, reverse=True)
1112        else:
1113            self._compounds.sort(
1114                key=lambda c: c.spectral_similarity_score, reverse=True
1115            )

Adds a compound to the peak with the specified attributes.

Parameters
  • compounds_dict (dict): Dictionary containing the compound information.
  • spectral_similarity_scores (dict): Dictionary containing the spectral similarity scores.
  • ri_score (float or None, optional): The retention index score of the compound. Default is None.
  • similarity_score (float or None, optional): The similarity score of the compound. Default is None.
ri
1117    @property
1118    def ri(self):
1119        """Returns the retention index of the peak.
1120
1121        Returns
1122        -------
1123        float or None
1124            The retention index of the peak.
1125        """
1126        return self._ri

Returns the retention index of the peak.

Returns
  • float or None: The retention index of the peak.
highest_ss_compound
1128    @property
1129    def highest_ss_compound(self):
1130        """Returns the compound with the highest spectral similarity score.
1131
1132        Returns
1133        -------
1134        LowResCompoundRef or None
1135            The compound with the highest spectral similarity score.
1136        """
1137        if self:
1138            return max(self, key=lambda c: c.spectral_similarity_score)
1139        else:
1140            return None

Returns the compound with the highest spectral similarity score.

Returns
  • LowResCompoundRef or None: The compound with the highest spectral similarity score.
highest_score_compound
1142    @property
1143    def highest_score_compound(self):
1144        """Returns the compound with the highest similarity score.
1145
1146        Returns
1147        -------
1148        LowResCompoundRef or None
1149            The compound with the highest similarity score.
1150        """
1151        if self:
1152            return max(self, key=lambda c: c.similarity_score)
1153        else:
1154            return None

Returns the compound with the highest similarity score.

Returns
  • LowResCompoundRef or None: The compound with the highest similarity score.
compound_names
1156    @property
1157    def compound_names(self):
1158        """Returns a list of names of compounds associated with the peak.
1159
1160        Returns
1161        -------
1162        list
1163            List of names of compounds associated with the peak.
1164        """
1165        if self:
1166            return [c.name for c in self]
1167        else:
1168            return []

Returns a list of names of compounds associated with the peak.

Returns
  • list: List of names of compounds associated with the peak.
class GCPeakDeconvolved(GCPeak):
1171class GCPeakDeconvolved(GCPeak):
1172    """Represents a deconvolved peak in a chromatogram.
1173
1174    Parameters
1175    ----------
1176    chromatogram_parent : Chromatogram
1177        The parent chromatogram object.
1178    mass_spectra : list
1179        List of mass spectra associated with the peak.
1180    apex_index : int
1181        Index of the apex mass spectrum in the `mass_spectra` list.
1182    rt_list : list
1183        List of retention times.
1184    tic_list : list
1185        List of total ion currents.
1186    """
1187
1188    def __init__(
1189        self, chromatogram_parent, mass_spectra, apex_index, rt_list, tic_list
1190    ):
1191        self._ri = None
1192        self._rt_list = list(rt_list)
1193        self._tic_list = list(tic_list)
1194        self.mass_spectra = list(mass_spectra)
1195        super().__init__(
1196            chromatogram_parent,
1197            self.mass_spectra[apex_index],
1198            (0, apex_index, len(self.mass_spectra) - 1),
1199        )
1200
1201    @property
1202    def rt_list(self):
1203        """Get the list of retention times.
1204
1205        Returns
1206        -------
1207        list
1208            The list of retention times.
1209        """
1210        return self._rt_list
1211
1212    @property
1213    def tic_list(self):
1214        """Get the list of total ion currents.
1215
1216        Returns
1217        -------
1218        list
1219            The list of total ion currents.
1220        """
1221        return self._tic_list

Represents a deconvolved peak in a chromatogram.

Parameters
  • chromatogram_parent (Chromatogram): The parent chromatogram object.
  • mass_spectra (list): List of mass spectra associated with the peak.
  • apex_index (int): Index of the apex mass spectrum in the mass_spectra list.
  • rt_list (list): List of retention times.
  • tic_list (list): List of total ion currents.
GCPeakDeconvolved(chromatogram_parent, mass_spectra, apex_index, rt_list, tic_list)
1188    def __init__(
1189        self, chromatogram_parent, mass_spectra, apex_index, rt_list, tic_list
1190    ):
1191        self._ri = None
1192        self._rt_list = list(rt_list)
1193        self._tic_list = list(tic_list)
1194        self.mass_spectra = list(mass_spectra)
1195        super().__init__(
1196            chromatogram_parent,
1197            self.mass_spectra[apex_index],
1198            (0, apex_index, len(self.mass_spectra) - 1),
1199        )
mass_spectra
rt_list
1201    @property
1202    def rt_list(self):
1203        """Get the list of retention times.
1204
1205        Returns
1206        -------
1207        list
1208            The list of retention times.
1209        """
1210        return self._rt_list

Get the list of retention times.

Returns
  • list: The list of retention times.
tic_list
1212    @property
1213    def tic_list(self):
1214        """Get the list of total ion currents.
1215
1216        Returns
1217        -------
1218        list
1219            The list of total ion currents.
1220        """
1221        return self._tic_list

Get the list of total ion currents.

Returns
  • list: The list of total ion currents.