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

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)
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
start_scan
final_scan
apex_scan
chromatogram_parent
mass_spectrum
retention_time
77    @property
78    def retention_time(self):
79        """Retention Time"""
80        return self.mass_spectrum.retention_time

Retention Time

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

Total Ion Current

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

Peak Area

rt_list
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        ]

Retention Time List

tic_list
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        ]

Total Ion Current List

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

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, the figure is returned. Default is True.
  • 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. Otherwise None and the figure is displayed.
mz
688    @property
689    def mz(self):
690        """Mass to charge ratio of the mass feature"""
691        # If the mass feature has been calibrated, return the calibrated m/z, otherwise return the measured m/z
692        if self._mz_cal is not None:
693            return self._mz_cal
694        else:
695            return self._mz_exp

Mass to charge ratio of the mass feature

mass_spectrum_deconvoluted
697    @property
698    def mass_spectrum_deconvoluted(self):
699        """Returns the deconvoluted mass spectrum object associated with the mass feature, if deconvolution has been performed."""
700        if self._ms_deconvoluted_idx is not None:
701            ms_deconvoluted = copy.deepcopy(self.mass_spectrum)
702            ms_deconvoluted.set_indexes(self._ms_deconvoluted_idx)
703            return ms_deconvoluted
704        else:
705            raise ValueError(
706                "Deconvolution has not been performed for mass feature " + str(self.id)
707            )

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

retention_time
709    @property
710    def retention_time(self):
711        """Retention time of the mass feature"""
712        return self._retention_time

Retention time of the mass feature

apex_scan
721    @property
722    def apex_scan(self):
723        """Apex scan of the mass feature"""
724        return self._apex_scan

Apex scan of the mass feature

intensity
733    @property
734    def intensity(self):
735        """Intensity of the mass feature"""
736        return self._intensity

Intensity of the mass feature

persistence
745    @property
746    def persistence(self):
747        """Persistence of the mass feature"""
748        return self._persistence

Persistence of the mass feature

eic_rt_list
757    @property
758    def eic_rt_list(self):
759        """Retention time list between the beginning and end of the mass feature"""
760        # Find index of the start and final scans in the EIC data
761        start_index = self._eic_data.scans.tolist().index(self.start_scan)
762        final_index = self._eic_data.scans.tolist().index(self.final_scan)
763
764        # Get the retention time list
765        rt_list = self._eic_data.time[start_index : final_index + 1]
766        return rt_list

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

eic_list
768    @property
769    def eic_list(self):
770        """EIC List between the beginning and end of the mass feature"""
771        # Find index of the start and final scans in the EIC data
772        start_index = self._eic_data.scans.tolist().index(self.start_scan)
773        final_index = self._eic_data.scans.tolist().index(self.final_scan)
774
775        # Get the retention time list
776        eic = self._eic_data.eic[start_index : final_index + 1]
777        return eic

EIC List between the beginning and end of the mass feature

ms1_peak
779    @property
780    def ms1_peak(self):
781        """MS1 peak from associated mass spectrum that is closest to the mass feature's m/z"""
782        # Find index array self.mass_spectrum.mz_exp that is closest to self.mz
783        closest_mz = min(self.mass_spectrum.mz_exp, key=lambda x: abs(x - self.mz))
784        closest_mz_index = self.mass_spectrum.mz_exp.tolist().index(closest_mz)
785
786        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
788    @property
789    def tailing_factor(self):
790        """Tailing factor of the mass feature"""
791        return self._tailing_factor

Tailing factor of the mass feature

dispersity_index
800    @property
801    def dispersity_index(self):
802        """Dispersity index of the mass feature"""
803        return self._dispersity_index

Dispersity index of the mass feature

normalized_dispersity_index
812    @property
813    def normalized_dispersity_index(self):
814        """Normalized dispersity index of the mass feature, unitless (fraction of total window used)"""
815        return self._normalized_dispersity_index

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

half_height_width
817    @property
818    def half_height_width(self):
819        """Half height width of the mass feature, average of min and max values, in minutes"""
820        return np.mean(self._half_height_width)

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

noise_score
822    @property
823    def noise_score(self):
824        """Mean of left and right noise scores.
825
826        Returns
827        -------
828        float or np.nan
829            Mean noise score, or np.nan if both sides are np.nan.
830        """
831        if self._noise_score is None:
832            return np.nan
833
834        left, right = self._noise_score
835        # Handle NaN values
836        if np.isnan(left) and np.isnan(right):
837            return np.nan
838        elif np.isnan(left):
839            return right
840        elif np.isnan(right):
841            return left
842        else:
843            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
845    @property
846    def noise_score_min(self):
847        """Minimum of left and right noise scores.
848
849        Returns
850        -------
851        float or np.nan
852            Minimum noise score, or np.nan if both sides are np.nan.
853        """
854        if self._noise_score is None:
855            return np.nan
856
857        left, right = self._noise_score
858        # Handle NaN values - nanmin ignores NaN
859        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
861    @property
862    def noise_score_max(self):
863        """Maximum of left and right noise scores.
864
865        Returns
866        -------
867        float or np.nan
868            Maximum noise score, or np.nan if both sides are np.nan.
869        """
870        if self._noise_score is None:
871            return np.nan
872
873        left, right = self._noise_score
874        # Handle NaN values - nanmax ignores NaN
875        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
877    @property
878    def type(self):
879        """Type of the mass feature.
880
881        Returns
882        -------
883        str
884            The type of mass feature ("untargeted", "targeted", or "internal standard").
885        """
886        return self._type

Type of the mass feature.

Returns
  • str: The type of mass feature ("untargeted", "targeted", or "internal standard").
best_ms2
901    @property
902    def best_ms2(self):
903        """Points to the best representative MS2 mass spectrum
904
905        Notes
906        -----
907        If there is only one MS2 mass spectrum, it will be returned
908        If there are MS2 similarity results, this will return the MS2 mass spectrum with the highest entropy similarity score.
909        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.
910
911        Returns
912        -------
913        MassSpectrum or None
914            The best MS2 mass spectrum.
915        """
916        if len(self.ms2_similarity_results) > 0:
917            # the scan number with the highest similarity score
918            results_df = [x.to_dataframe() for x in self.ms2_similarity_results]
919            results_df = pd.concat(results_df)
920            results_df = results_df.sort_values(
921                by="entropy_similarity", ascending=False
922            )
923            best_scan_number = results_df.iloc[0]["query_spectrum_id"]
924            return self.ms2_mass_spectra[best_scan_number]
925
926        ms2_scans = list(self.ms2_mass_spectra.keys())
927        if len(ms2_scans) > 1:
928            mz_diff_list = []  # List of mz difference between mz of mass feature and mass of nearest mz in each scan
929            res_list = []  # List of maximum resolving power of peaks in each scan
930            time_diff_list = []  # List of time difference between scan and apex scan in each scan
931            for scan in ms2_scans:
932                if len(self.ms2_mass_spectra[scan].mspeaks) > 0:
933                    # Find mz closest to mass feature mz, return both the difference in mass and its resolution
934                    closest_mz = min(
935                        self.ms2_mass_spectra[scan].mz_exp,
936                        key=lambda x: abs(x - self.mz),
937                    )
938                    if all(
939                        np.isnan(self.ms2_mass_spectra[scan].resolving_power)
940                    ):  # All NA for resolving power in peaks, not uncommon in CID spectra
941                        res_list.append(2)  # Assumes very low resolving power
942                    else:
943                        res_list.append(
944                            np.nanmax(self.ms2_mass_spectra[scan].resolving_power)
945                        )
946                    mz_diff_list.append(np.abs(closest_mz - self.mz))
947                    time_diff_list.append(
948                        np.abs(
949                            self.chromatogram_parent.get_time_of_scan_id(scan)
950                            - self.retention_time
951                        )
952                    )
953                else:
954                    res_list.append(np.nan)
955                    mz_diff_list.append(np.nan)
956                    time_diff_list.append(np.nan)
957            # Convert diff_lists into logical scores (higher is better for each score)
958            time_score = 1 - np.array(time_diff_list) / np.nanmax(
959                np.array(time_diff_list)
960            )
961            res_score = np.array(res_list) / np.nanmax(np.array(res_list))
962            # mz_score is 0 for possible chimerics, 1 for all others (already within mass tolerance before assigning)
963            mz_score = np.zeros(len(ms2_scans))
964            for i in np.arange(0, len(ms2_scans)):
965                if mz_diff_list[i] < 0.8 and mz_diff_list[i] > 0.1:  # Possible chimeric
966                    mz_score[i] = 0
967                else:
968                    mz_score[i] = 1
969            # get the index of the best score and return the mass spectrum
970            if len([np.nanargmax(time_score * res_score * mz_score)]) == 1:
971                return self.ms2_mass_spectra[
972                    ms2_scans[np.nanargmax(time_score * res_score * mz_score)]
973                ]
974            # remove the mz_score condition and try again
975            elif len(np.argmax(time_score * res_score)) == 1:
976                return self.ms2_mass_spectra[
977                    ms2_scans[np.nanargmax(time_score * res_score)]
978                ]
979            else:
980                raise ValueError(
981                    "No best MS2 mass spectrum could be found for mass feature "
982                    + str(self.id)
983                )
984        elif len(ms2_scans) == 1:  # if only one ms2 spectra, return it
985            return self.ms2_mass_spectra[ms2_scans[0]]
986        else:  # if no ms2 spectra, return None
987            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.
 990class GCPeak(ChromaPeakBase, GCPeakCalculation):
 991    """Class representing a peak in a gas chromatography (GC) chromatogram.
 992
 993    Parameters
 994    ----------
 995    chromatogram_parent : Chromatogram
 996        The parent chromatogram object.
 997    mass_spectrum_obj : MassSpectrum
 998        The mass spectrum object associated with the peak.
 999    indexes : tuple
1000        The indexes of the peak in the chromatogram.
1001
1002    Attributes
1003    ----------
1004    _compounds : list
1005        List of compounds associated with the peak.
1006    _ri : float or None
1007        Retention index of the peak.
1008
1009    Methods
1010    -------
1011    * __len__(). Returns the number of compounds associated with the peak.
1012    * __getitem__(position).  Returns the compound at the specified position.
1013    * remove_compound(compounds_obj). Removes the specified compound from the peak.
1014    * clear_compounds(). Removes all compounds from the peak.
1015    * add_compound(compounds_dict, spectral_similarity_scores, ri_score=None, similarity_score=None). Adds a compound to the peak with the specified attributes.
1016    * ri().  Returns the retention index of the peak.
1017    * highest_ss_compound(). Returns the compound with the highest spectral similarity score.
1018    * highest_score_compound(). Returns the compound with the highest similarity score.
1019    * compound_names(). Returns a list of names of compounds associated with the peak.
1020    """
1021
1022    def __init__(self, chromatogram_parent, mass_spectrum_obj, indexes):
1023        self._compounds = []
1024        self._ri = None
1025        super().__init__(chromatogram_parent, mass_spectrum_obj, *indexes)
1026
1027    def __len__(self):
1028        return len(self._compounds)
1029
1030    def __getitem__(self, position):
1031        return self._compounds[position]
1032
1033    def remove_compound(self, compounds_obj):
1034        self._compounds.remove(compounds_obj)
1035
1036    def clear_compounds(self):
1037        self._compounds = []
1038
1039    def add_compound(
1040        self,
1041        compounds_dict,
1042        spectral_similarity_scores,
1043        ri_score=None,
1044        similarity_score=None,
1045    ):
1046        """Adds a compound to the peak with the specified attributes.
1047
1048        Parameters
1049        ----------
1050        compounds_dict : dict
1051            Dictionary containing the compound information.
1052        spectral_similarity_scores : dict
1053            Dictionary containing the spectral similarity scores.
1054        ri_score : float or None, optional
1055            The retention index score of the compound. Default is None.
1056        similarity_score : float or None, optional
1057            The similarity score of the compound. Default is None.
1058        """
1059        compound_obj = LowResCompoundRef(compounds_dict)
1060        compound_obj.spectral_similarity_scores = spectral_similarity_scores
1061        compound_obj.spectral_similarity_score = spectral_similarity_scores.get(
1062            "cosine_correlation"
1063        )
1064        # TODO check is the above line correct?
1065        compound_obj.ri_score = ri_score
1066        compound_obj.similarity_score = similarity_score
1067        self._compounds.append(compound_obj)
1068        if similarity_score:
1069            self._compounds.sort(key=lambda c: c.similarity_score, reverse=True)
1070        else:
1071            self._compounds.sort(
1072                key=lambda c: c.spectral_similarity_score, reverse=True
1073            )
1074
1075    @property
1076    def ri(self):
1077        """Returns the retention index of the peak.
1078
1079        Returns
1080        -------
1081        float or None
1082            The retention index of the peak.
1083        """
1084        return self._ri
1085
1086    @property
1087    def highest_ss_compound(self):
1088        """Returns the compound with the highest spectral similarity score.
1089
1090        Returns
1091        -------
1092        LowResCompoundRef or None
1093            The compound with the highest spectral similarity score.
1094        """
1095        if self:
1096            return max(self, key=lambda c: c.spectral_similarity_score)
1097        else:
1098            return None
1099
1100    @property
1101    def highest_score_compound(self):
1102        """Returns the compound with the highest similarity score.
1103
1104        Returns
1105        -------
1106        LowResCompoundRef or None
1107            The compound with the highest similarity score.
1108        """
1109        if self:
1110            return max(self, key=lambda c: c.similarity_score)
1111        else:
1112            return None
1113
1114    @property
1115    def compound_names(self):
1116        """Returns a list of names of compounds associated with the peak.
1117
1118        Returns
1119        -------
1120        list
1121            List of names of compounds associated with the peak.
1122        """
1123        if self:
1124            return [c.name for c in self]
1125        else:
1126            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)
1022    def __init__(self, chromatogram_parent, mass_spectrum_obj, indexes):
1023        self._compounds = []
1024        self._ri = None
1025        super().__init__(chromatogram_parent, mass_spectrum_obj, *indexes)
def remove_compound(self, compounds_obj):
1033    def remove_compound(self, compounds_obj):
1034        self._compounds.remove(compounds_obj)
def clear_compounds(self):
1036    def clear_compounds(self):
1037        self._compounds = []
def add_compound( self, compounds_dict, spectral_similarity_scores, ri_score=None, similarity_score=None):
1039    def add_compound(
1040        self,
1041        compounds_dict,
1042        spectral_similarity_scores,
1043        ri_score=None,
1044        similarity_score=None,
1045    ):
1046        """Adds a compound to the peak with the specified attributes.
1047
1048        Parameters
1049        ----------
1050        compounds_dict : dict
1051            Dictionary containing the compound information.
1052        spectral_similarity_scores : dict
1053            Dictionary containing the spectral similarity scores.
1054        ri_score : float or None, optional
1055            The retention index score of the compound. Default is None.
1056        similarity_score : float or None, optional
1057            The similarity score of the compound. Default is None.
1058        """
1059        compound_obj = LowResCompoundRef(compounds_dict)
1060        compound_obj.spectral_similarity_scores = spectral_similarity_scores
1061        compound_obj.spectral_similarity_score = spectral_similarity_scores.get(
1062            "cosine_correlation"
1063        )
1064        # TODO check is the above line correct?
1065        compound_obj.ri_score = ri_score
1066        compound_obj.similarity_score = similarity_score
1067        self._compounds.append(compound_obj)
1068        if similarity_score:
1069            self._compounds.sort(key=lambda c: c.similarity_score, reverse=True)
1070        else:
1071            self._compounds.sort(
1072                key=lambda c: c.spectral_similarity_score, reverse=True
1073            )

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
1075    @property
1076    def ri(self):
1077        """Returns the retention index of the peak.
1078
1079        Returns
1080        -------
1081        float or None
1082            The retention index of the peak.
1083        """
1084        return self._ri

Returns the retention index of the peak.

Returns
  • float or None: The retention index of the peak.
highest_ss_compound
1086    @property
1087    def highest_ss_compound(self):
1088        """Returns the compound with the highest spectral similarity score.
1089
1090        Returns
1091        -------
1092        LowResCompoundRef or None
1093            The compound with the highest spectral similarity score.
1094        """
1095        if self:
1096            return max(self, key=lambda c: c.spectral_similarity_score)
1097        else:
1098            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
1100    @property
1101    def highest_score_compound(self):
1102        """Returns the compound with the highest similarity score.
1103
1104        Returns
1105        -------
1106        LowResCompoundRef or None
1107            The compound with the highest similarity score.
1108        """
1109        if self:
1110            return max(self, key=lambda c: c.similarity_score)
1111        else:
1112            return None

Returns the compound with the highest similarity score.

Returns
  • LowResCompoundRef or None: The compound with the highest similarity score.
compound_names
1114    @property
1115    def compound_names(self):
1116        """Returns a list of names of compounds associated with the peak.
1117
1118        Returns
1119        -------
1120        list
1121            List of names of compounds associated with the peak.
1122        """
1123        if self:
1124            return [c.name for c in self]
1125        else:
1126            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):
1129class GCPeakDeconvolved(GCPeak):
1130    """Represents a deconvolved peak in a chromatogram.
1131
1132    Parameters
1133    ----------
1134    chromatogram_parent : Chromatogram
1135        The parent chromatogram object.
1136    mass_spectra : list
1137        List of mass spectra associated with the peak.
1138    apex_index : int
1139        Index of the apex mass spectrum in the `mass_spectra` list.
1140    rt_list : list
1141        List of retention times.
1142    tic_list : list
1143        List of total ion currents.
1144    """
1145
1146    def __init__(
1147        self, chromatogram_parent, mass_spectra, apex_index, rt_list, tic_list
1148    ):
1149        self._ri = None
1150        self._rt_list = list(rt_list)
1151        self._tic_list = list(tic_list)
1152        self.mass_spectra = list(mass_spectra)
1153        super().__init__(
1154            chromatogram_parent,
1155            self.mass_spectra[apex_index],
1156            (0, apex_index, len(self.mass_spectra) - 1),
1157        )
1158
1159    @property
1160    def rt_list(self):
1161        """Get the list of retention times.
1162
1163        Returns
1164        -------
1165        list
1166            The list of retention times.
1167        """
1168        return self._rt_list
1169
1170    @property
1171    def tic_list(self):
1172        """Get the list of total ion currents.
1173
1174        Returns
1175        -------
1176        list
1177            The list of total ion currents.
1178        """
1179        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)
1146    def __init__(
1147        self, chromatogram_parent, mass_spectra, apex_index, rt_list, tic_list
1148    ):
1149        self._ri = None
1150        self._rt_list = list(rt_list)
1151        self._tic_list = list(tic_list)
1152        self.mass_spectra = list(mass_spectra)
1153        super().__init__(
1154            chromatogram_parent,
1155            self.mass_spectra[apex_index],
1156            (0, apex_index, len(self.mass_spectra) - 1),
1157        )
mass_spectra
rt_list
1159    @property
1160    def rt_list(self):
1161        """Get the list of retention times.
1162
1163        Returns
1164        -------
1165        list
1166            The list of retention times.
1167        """
1168        return self._rt_list

Get the list of retention times.

Returns
  • list: The list of retention times.
tic_list
1170    @property
1171    def tic_list(self):
1172        """Get the list of total ion currents.
1173
1174        Returns
1175        -------
1176        list
1177            The list of total ion currents.
1178        """
1179        return self._tic_list

Get the list of total ion currents.

Returns
  • list: The list of total ion currents.