diff --git a/conftest.py b/conftest.py index 1ff0818bd..3e54f5263 100644 --- a/conftest.py +++ b/conftest.py @@ -83,7 +83,7 @@ def bruker_transient(ftms_file_location): return bruker_transient -@pytest.fixture +@pytest.fixture(scope="module") def lcms_obj(): """Returns an LCMS object for the tests""" file_raw = ( diff --git a/corems/chroma_peak/calc/subset.py b/corems/chroma_peak/calc/subset.py new file mode 100644 index 000000000..45158e3f7 --- /dev/null +++ b/corems/chroma_peak/calc/subset.py @@ -0,0 +1,196 @@ +# This file contains functions for subsetting dataframes that contain mass feature data. +# This is based on the deimos package, found here: https://github.com/pnnl/deimos/blob/master/deimos/subset.py with some modifications. + +import multiprocessing as mp +from functools import partial + +import numpy as np +import pandas as pd + +class MultiSamplePartitions: + ''' + Generator object that will lazily build and return each partition constructed + from multiple samples. + + Attributes + ---------- + features : :obj:`~pandas.DataFrame` + Input feature coordinates and intensities. + split_on : str + Dimension to partition the data. + size : int + Target partition size. + tol : float + Largest allowed distance between unique `split_on` observations. + n_partitions : int + Number of partitions in the data. + + ''' + + def __init__(self, + features, + split_on: str = 'mz', + size: int = 500, + tol: float = 25E-6, + relative: bool = False): + ''' + Initialize :obj:`~deimos.subset.Partitions` instance. + + Parameters + ---------- + features : :obj:`~pandas.DataFrame` + Input feature coordinates and intensities. + split_on : str + Dimension to partition the data. + size : int + Target partition size. + tol : float + Largest allowed distance between unique `split_on` observations. + + ''' + if not isinstance(split_on, str): + raise TypeError(f"Expected 'split_on' to be a string, got {type(split_on).__name__}") + if not isinstance(size, int): + raise TypeError(f"Expected 'size' to be an integer, got {type(size).__name__}") + if not isinstance(tol, float): + raise TypeError(f"Expected 'tol' to be a float, got {type(tol).__name__}") + if not isinstance(relative, bool): + raise TypeError(f"Expected 'relative' to be a boolean, got {type(relative).__name__}") + + self.features = features + self.split_on = split_on + self.size = size + self.tol = tol + self.relative = relative + + self._compute_splits() + + def _compute_splits(self): + ''' + Determines data splits for partitioning. + + ''' + + self.counter = 0 + + idx = self.features.groupby(by=self.split_on).size().sort_index() + + counts = idx.values + idx = idx.index + + if self.relative: + dxs = np.diff(idx) / idx[:-1] + else: + dxs = np.diff(idx) + + # if relative, convert tol to absolute + bins = [] + current_count = counts[0] + current_bin = [idx[0]] + self._counts = [] + + for i, dx in zip(range(1, len(idx)), dxs): + if (current_count + counts[i] <= self.size) or (dx <= self.tol): + current_bin.append(idx[i]) + current_count += counts[i] + + else: + bins.append(np.array(current_bin)) + self._counts.append(current_count) + + current_bin = [idx[i]] + current_count = counts[i] + + # Add last unadded bin + bins.append(np.array(current_bin)) + self._counts.append(current_count) + + self.bounds = np.array([[x.min(), x.max()] for x in bins]) + + # Number of partitions in the data + self.n_partitions = len(bins) + + def __iter__(self): + return self + + def __next__(self): + if self.counter < len(self.bounds): + q = '({} >= {}) & ({} <= {})'.format(self.split_on, + self.bounds[self.counter][0], + self.split_on, + self.bounds[self.counter][1]) + + subset = self.features.query(q) + + self.counter += 1 + if len(subset.index) > 1: + return subset + else: + return None + + raise StopIteration + + def map(self, func, processes=1, **kwargs): + ''' + Maps `func` to each partition, then returns the combined result. + + Parameters + ---------- + func : function + Function to apply to partitions. + processes : int + Number of parallel processes. If less than 2, a serial mapping is + applied. + kwargs + Keyword arguments passed to `func`. + + Returns + ------- + :obj:`~pandas.DataFrame` + Combined result of `func` applied to partitions. + + ''' + + # Serial + if processes < 2: + result = [func(x, **kwargs) for x in self] + + # Parallel + else: + with mp.Pool(processes=processes) as p: + result = list(p.imap(partial(func, **kwargs), self)) + + # Add partition index + for i in range(len(result)): + if result[i] is not None: + result[i]['partition_idx'] = i + + # Combine partitions + return pd.concat(result, ignore_index=True) + +def multi_sample_partition(features, split_on='mz', size=500, tol=25E-6, relative=True): + ''' + Partitions data along a given dimension. For use with features across + multiple samples, e.g. in alignment. + + Parameters + ---------- + features : :obj:`~pandas.DataFrame` + Input feature coordinates and intensities. + split_on : str + Dimension to partition the data. + size : int + Target partition size. + tol : float + Largest allowed distance between unique `split_on` observations. + relative : bool + If `True`, the `tol` parameter is interpreted as a relative tolerance. + + Returns + ------- + :obj:`~deimos.subset.Partitions` + A generator object that will lazily build and return each partition. + + ''' + + return MultiSamplePartitions(features, split_on, size, tol, relative) diff --git a/corems/chroma_peak/factory/chroma_peak_classes.py b/corems/chroma_peak/factory/chroma_peak_classes.py index b4de9044a..12df343dc 100644 --- a/corems/chroma_peak/factory/chroma_peak_classes.py +++ b/corems/chroma_peak/factory/chroma_peak_classes.py @@ -122,7 +122,7 @@ class LCMSMassFeature(ChromaPeakBase, LCMSMassFeatureCalculation): The scan number of the apex of the feature. persistence : float, optional The persistence of the feature. Default is None. - + Attributes -------- _mz_exp : float @@ -139,6 +139,9 @@ class LCMSMassFeature(ChromaPeakBase, LCMSMassFeatureCalculation): 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 @@ -156,6 +159,9 @@ class LCMSMassFeature(ChromaPeakBase, LCMSMassFeatureCalculation): 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 @@ -191,7 +197,7 @@ def __init__( intensity: float, apex_scan: int, persistence: float = None, - id: int = None, + id: int = None ): super().__init__( chromatogram_parent=lcms_parent, @@ -215,6 +221,7 @@ def __init__( self._tailing_factor: float = None self._noise_score: tuple = None self._gaussian_similarity: float = None + self._type: str = "untargeted" # Additional attributes self.monoisotopic_mf_id = None @@ -252,12 +259,341 @@ def update_mz(self): if abs(mz_diff) < 0.01: self._mz_exp = new_mz + def _plot_ms1_spectrum(self, ax, deconvoluted=False, sample_name=None): + """Internal method to plot MS1 spectrum on a given axis. + + Parameters + ---------- + ax : matplotlib.axes.Axes + The axis to plot on. + deconvoluted : bool, optional + If True and deconvoluted spectrum exists, plot both raw and deconvoluted. Default is False. + sample_name : str, optional + Sample name to include in title. Default is None. + """ + if self.mass_spectrum is None: + raise ValueError("MS1 spectrum is not available") + + title_prefix = "MS1 (deconvoluted)" if deconvoluted else "MS1 (raw)" + if sample_name: + ax.set_title(f"{title_prefix} - {sample_name}", loc="left") + else: + ax.set_title(title_prefix, loc="left") + + if deconvoluted and self._ms_deconvoluted_idx is not None: + # Plot both raw and deconvoluted + ax.vlines( + self.mass_spectrum.mz_exp, + 0, + self.mass_spectrum.abundance, + color="k", + alpha=0.2, + label="Raw MS1", + ) + ax.vlines( + self.mass_spectrum_deconvoluted.mz_exp, + 0, + self.mass_spectrum_deconvoluted.abundance, + color="k", + label="Deconvoluted MS1", + ) + ax.set_xlim( + self.mass_spectrum_deconvoluted.mz_exp.min() * 0.8, + self.mass_spectrum_deconvoluted.mz_exp.max() * 1.1, + ) + ax.set_ylim( + 0, self.mass_spectrum_deconvoluted.abundance.max() * 1.1 + ) + else: + # Plot raw only + ax.vlines( + self.mass_spectrum.mz_exp, + 0, + self.mass_spectrum.abundance, + color="k", + label="Raw MS1", + ) + ax.set_xlim( + self.mass_spectrum.mz_exp.min() * 0.8, + self.mass_spectrum.mz_exp.max() * 1.1, + ) + ax.set_ylim(bottom=0) + + # Highlight the feature m/z if close enough + if abs(self.ms1_peak.mz_exp - self.mz) < 0.01: + ax.vlines( + self.ms1_peak.mz_exp, + 0, + self.ms1_peak.abundance, + color="m", + label="Feature m/z", + ) + else: + if self.chromatogram_parent.parameters.lc_ms.verbose_processing: + print( + f"The m/z of the mass feature {self.id} is different from the m/z of MS1 peak, " + "the MS1 peak will not be plotted" + ) + + ax.legend(loc="upper left") + ax.set_ylabel("Intensity") + ax.set_xlabel("m/z") + ax.yaxis.set_tick_params(labelleft=False) + + def _plot_ms2_spectrum(self, ax, sample_name=None): + """Internal method to plot MS2 spectrum on a given axis. + + Parameters + ---------- + ax : matplotlib.axes.Axes + The axis to plot on. + sample_name : str, optional + Sample name to include in title. Default is None. + """ + if len(self.ms2_mass_spectra) == 0: + raise ValueError("MS2 spectrum is not available") + + if sample_name: + ax.set_title(f"MS2 - {sample_name}", loc="left") + else: + ax.set_title("MS2", loc="left") + + ax.vlines( + self.best_ms2.mz_exp, 0, self.best_ms2.abundance, color="k" + ) + ax.set_ylabel("Intensity") + ax.set_xlabel("m/z") + ax.set_ylim(bottom=0) + ax.yaxis.get_major_formatter().set_scientific(False) + ax.yaxis.get_major_formatter().set_useOffset(False) + + def _plot_ms2_mirror(self, ax, molecular_metadata=None, spectral_library=None): + """Internal method to plot MS2 mirror spectrum on a given axis. + + Plots experimental MS2 on top (positive) and library MS2 on bottom (negative/mirrored) + if MS2 similarity results are available. If no MS2 similarity results exist, + falls back to regular MS2 plot. + + Parameters + ---------- + ax : matplotlib.axes.Axes + The axis to plot on. + molecular_metadata : dict, optional + Dictionary mapping molecular IDs to MetaboliteMetadata objects. + If provided, uses metadata for compound names. + Default is None. + spectral_library : FlashEntropySearch or list of FlashEntropySearch, optional + FlashEntropy spectral library (or list of libraries) containing MS2 spectra. + If provided, uses library to retrieve MS2 spectra by ref_ms_id. + Default is None. + + Raises + ------ + ValueError + If MS2 similarity results exist but molecular_metadata or spectral_library is None. + """ + if len(self.ms2_mass_spectra) == 0: + ax.text(0.5, 0.5, 'No MS2 data available', + ha='center', va='center', transform=ax.transAxes, fontsize=12) + ax.set_xlabel('m/z', fontsize=10) + ax.set_ylabel('Relative Intensity (%)', fontsize=10) + return + + # Check if we have MS2 similarity results - if not, fall back to regular MS2 plot + if len(self.ms2_similarity_results) == 0: + self._plot_ms2_spectrum(ax) + return + + # If we have MS2 similarity results, we need both molecular_metadata and spectral_library + if molecular_metadata is None or spectral_library is None: + raise ValueError( + "MS2 mirror plot requires both 'molecular_metadata' and 'spectral_library' " + "parameters when MS2 similarity results are present. " + "Please provide both parameters to plot_cluster() or plot()." + ) + + # Get experimental MS2 + sample_ms2 = self.best_ms2 + sample_mz = sample_ms2.mz_exp + sample_int = sample_ms2.abundance + + # Normalize sample MS2 + if len(sample_int) > 0 and max(sample_int) > 0: + sample_int = sample_int / max(sample_int) * 100 + + # Plot sample MS2 on top (positive) + ax.vlines(sample_mz, 0, sample_int, colors='blue', alpha=0.7, linewidths=1.5, label='Sample MS2') + + # Check if we have MS2 similarity results + library_ms2_peaks = None + entropy_similarity = None + molecule_name = None + mol_id = None + + if len(self.ms2_similarity_results) > 0: + # Get all results as dataframes and find the best match + results_df = [x.to_dataframe() for x in self.ms2_similarity_results] + results_df = pd.concat(results_df) + results_df = results_df.sort_values(by='entropy_similarity', ascending=False) + + # Get the best match + best_result = results_df.iloc[0] + entropy_similarity = best_result['entropy_similarity'] + mol_id = best_result.get('ref_mol_id', None) + ref_ms_id = best_result.get('ref_ms_id', None) + + # Get library spectrum from spectral_library using ref_ms_id + if spectral_library is not None and ref_ms_id is not None: + # Handle both single library and list of libraries + libraries = spectral_library if isinstance(spectral_library, list) else [spectral_library] + + # Search through all libraries to find the ref_ms_id + for library in libraries: + try: + # Get the IDs in the spectral library + fe_spec_index = [x["id"] for x in library].index(ref_ms_id) + library_ms2_peaks = library[fe_spec_index]['peaks'] + break # Found the spectrum, exit the loop + except ValueError: + # ref_ms_id not found in this library, continue to next + continue + + # If ref_ms_id was not found in any library, raise an error + if library_ms2_peaks is None: + raise ValueError( + f"Reference MS ID '{ref_ms_id}' not found in any of the provided spectral libraries. " + f"Please ensure the spectral library contains the matching reference spectrum." + ) + + # Get compound name from molecular_metadata using mol_id + if molecular_metadata is not None and mol_id is not None: + if mol_id in molecular_metadata: + metadata = molecular_metadata[mol_id] + # Get compound name from metadata + molecule_name = getattr(metadata, 'common_name', getattr(metadata, 'name', 'Unknown')) + + # Plot library MS2 on bottom (negative/mirrored) + if library_ms2_peaks is not None and len(library_ms2_peaks) > 0: + lib_mz = library_ms2_peaks[:, 0] + lib_int = library_ms2_peaks[:, 1] + # Normalize + if len(lib_int) > 0 and max(lib_int) > 0: + lib_int = lib_int / max(lib_int) * 100 + # Mirror to negative + lib_int_mirror = -lib_int + + # Create label with molecule name and molecular ID + lib_label = f'Library MS2' + if molecule_name: + lib_label += f' ({molecule_name})' + if mol_id: + lib_label += f' [ID: {mol_id}]' + + ax.vlines(lib_mz, 0, lib_int_mirror, colors='red', alpha=0.7, linewidths=1.5, label=lib_label) + + ax.axhline(0, color='black', linewidth=0.5) + ax.set_xlabel('m/z', fontsize=10) + ax.set_ylabel('Relative Intensity (%)', fontsize=10) + ax.legend(fontsize=8, loc='upper right') + ax.grid(True, alpha=0.3) + + # Set y-axis to symmetric range + ax.set_ylim(-105, 105) + + # Add entropy similarity to the title if available + if entropy_similarity is not None: + ax.set_title(f'MS2 Mirror Plot (Entropy Similarity: {entropy_similarity:.3f})', loc='left') + else: + ax.set_title('MS2 Mirror Plot', loc='left') + + def _plot_single_eic(self, ax, plot_smoothed=False, plot_datapoints=False, + eic_buffer_time=None, show_ms2_scan=True): + """Internal method to plot a single EIC on a given axis. + + Parameters + ---------- + ax : matplotlib.axes.Axes + The axis to plot on. + plot_smoothed : bool, optional + If True, plot smoothed EIC. Default is False. + plot_datapoints : bool, optional + If True, plot EIC datapoints. Default is False. + eic_buffer_time : float, optional + Time buffer around the peak (minutes). If None, uses parameter setting. Default is None. + show_ms2_scan : bool, optional + If True and MS2 scans exist, show vertical line at MS2 scan time. Default is True. + """ + if self._eic_data is None: + raise ValueError("EIC data is not available") + + if eic_buffer_time is None: + eic_buffer_time = self.chromatogram_parent.parameters.lc_ms.eic_buffer_time + + ax.set_title("EIC", loc="left") + ax.plot( + self._eic_data.time, self._eic_data.eic, c="tab:blue", label="EIC" + ) + + if plot_datapoints: + ax.scatter( + self._eic_data.time, + self._eic_data.eic, + c="tab:blue", + label="EIC Data Points", + ) + + if plot_smoothed and hasattr(self._eic_data, 'eic_smoothed'): + ax.plot( + self._eic_data.time, + self._eic_data.eic_smoothed, + c="tab:red", + label="Smoothed EIC", + ) + + # Fill integrated area if available + if self.start_scan is not None: + ax.fill_between( + self.eic_rt_list, self.eic_list, color="b", alpha=0.2 + ) + else: + if self.chromatogram_parent.parameters.lc_ms.verbose_processing: + print( + f"No start and final scan numbers were provided for mass feature {self.id}" + ) + + ax.set_ylabel("Intensity") + ax.set_xlabel("Time (minutes)") + ax.set_ylim(0, self.eic_list.max() * 1.1) + ax.set_xlim( + self.retention_time - eic_buffer_time, + self.retention_time + eic_buffer_time, + ) + ax.axvline( + x=self.retention_time, color="k", label="MS1 scan time (apex)" + ) + + # Show MS2 scan time if available and requested + if show_ms2_scan and len(self.ms2_scan_numbers) > 0: + ax.axvline( + x=self.chromatogram_parent.get_time_of_scan_id( + self.best_ms2.scan_number + ), + color="grey", + linestyle="--", + label="MS2 scan time", + ) + + ax.legend(loc="upper left") + ax.yaxis.get_major_formatter().set_useOffset(False) + 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, ): """Plot the mass feature. @@ -265,7 +601,7 @@ def plot( ---------- to_plot : list, optional List of strings specifying what to plot, any iteration of - "EIC", "MS2", and "MS1". + "EIC", "MS2", "MS2_mirror", and "MS1". Default is ["EIC", "MS1", "MS2"]. return_fig : bool, optional If True, the figure is returned. Default is True. @@ -273,6 +609,12 @@ def plot( 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 ------- @@ -280,171 +622,57 @@ def plot( The figure object if `return_fig` is True. Otherwise None and the figure is displayed. """ - - # EIC plot preparation - eic_buffer_time = self.chromatogram_parent.parameters.lc_ms.eic_buffer_time - # Adjust to_plot list if there are not spectra added to the mass features if self.mass_spectrum is None: to_plot = [x for x in to_plot if x != "MS1"] if len(self.ms2_mass_spectra) == 0: - to_plot = [x for x in to_plot if x != "MS2"] + to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]] if self._eic_data is None: to_plot = [x for x in to_plot if x != "EIC"] - if self._ms_deconvoluted_idx is not None: - deconvoluted = True - else: - deconvoluted = False + + # Check if MS2_mirror is requested without molecular_metadata + if "MS2_mirror" in to_plot and molecular_metadata is None: + raise ValueError("molecular_metadata is required when 'MS2_mirror' is in to_plot") + + # Check if both MS2 and MS2_mirror are requested (not allowed) + if "MS2" in to_plot and "MS2_mirror" in to_plot: + # Remove regular MS2 if mirror is requested + to_plot = [x for x in to_plot if x != "MS2"] + + deconvoluted = self._ms_deconvoluted_idx is not None fig, axs = plt.subplots( len(to_plot), 1, figsize=(9, len(to_plot) * 4), squeeze=False ) fig.suptitle( - "Mass Feature " - + str(self.id) - + ": m/z = " - + str(round(self.mz, ndigits=4)) - + "; time = " - + str(round(self.retention_time, ndigits=1)) - + " minutes" + f"Mass Feature {self.id}: m/z = {round(self.mz, ndigits=4)}; " + f"time = {round(self.retention_time, ndigits=1)} minutes" ) i = 0 # EIC plot if "EIC" in to_plot: - if self._eic_data is None: - raise ValueError( - "EIC data is not available, cannot plot the mass feature's EIC" - ) - axs[i][0].set_title("EIC", loc="left") - axs[i][0].plot( - self._eic_data.time, self._eic_data.eic, c="tab:blue", label="EIC" - ) - if plot_eic_datapoints: - axs[i][0].scatter( - self._eic_data.time, - self._eic_data.eic, - c="tab:blue", - label="EIC Data Points", - ) - if plot_smoothed_eic: - axs[i][0].plot( - self._eic_data.time, - self._eic_data.eic_smoothed, - c="tab:red", - label="Smoothed EIC", - ) - if self.start_scan is not None: - axs[i][0].fill_between( - self.eic_rt_list, self.eic_list, color="b", alpha=0.2 - ) - else: - if self.chromatogram_parent.parameters.lc_ms.verbose_processing: - print( - "No start and final scan numbers were provided for mass feature " - + str(self.id) - ) - axs[i][0].set_ylabel("Intensity") - axs[i][0].set_xlabel("Time (minutes)") - axs[i][0].set_ylim(0, self.eic_list.max() * 1.1) - axs[i][0].set_xlim( - self.retention_time - eic_buffer_time, - self.retention_time + eic_buffer_time, - ) - axs[i][0].axvline( - x=self.retention_time, color="k", label="MS1 scan time (apex)" + self._plot_single_eic( + axs[i][0], + plot_smoothed=plot_smoothed_eic, + plot_datapoints=plot_eic_datapoints ) - if len(self.ms2_scan_numbers) > 0: - axs[i][0].axvline( - x=self.chromatogram_parent.get_time_of_scan_id( - self.best_ms2.scan_number - ), - color="grey", - linestyle="--", - label="MS2 scan time", - ) - axs[i][0].legend(loc="upper left") - axs[i][0].yaxis.get_major_formatter().set_useOffset(False) i += 1 # MS1 plot if "MS1" in to_plot: - if deconvoluted: - axs[i][0].set_title("MS1 (deconvoluted)", loc="left") - axs[i][0].vlines( - self.mass_spectrum.mz_exp, - 0, - self.mass_spectrum.abundance, - color="k", - alpha=0.2, - label="Raw MS1", - ) - axs[i][0].vlines( - self.mass_spectrum_deconvoluted.mz_exp, - 0, - self.mass_spectrum_deconvoluted.abundance, - color="k", - label="Deconvoluted MS1", - ) - axs[i][0].set_xlim( - self.mass_spectrum_deconvoluted.mz_exp.min() * 0.8, - self.mass_spectrum_deconvoluted.mz_exp.max() * 1.1, - ) - axs[i][0].set_ylim( - 0, self.mass_spectrum_deconvoluted.abundance.max() * 1.1 - ) - else: - axs[i][0].set_title("MS1 (raw)", loc="left") - axs[i][0].vlines( - self.mass_spectrum.mz_exp, - 0, - self.mass_spectrum.abundance, - color="k", - label="Raw MS1", - ) - axs[i][0].set_xlim( - self.mass_spectrum.mz_exp.min() * 0.8, - self.mass_spectrum.mz_exp.max() * 1.1, - ) - axs[i][0].set_ylim(bottom=0) - - if (self.ms1_peak.mz_exp - self.mz) < 0.01: - axs[i][0].vlines( - self.ms1_peak.mz_exp, - 0, - self.ms1_peak.abundance, - color="m", - label="Feature m/z", - ) - - else: - if self.chromatogram_parent.parameters.lc_ms.verbose_processing: - print( - "The m/z of the mass feature " - + str(self.id) - + " is different from the m/z of MS1 peak, the MS1 peak will not be plotted" - ) - axs[i][0].legend(loc="upper left") - axs[i][0].set_ylabel("Intensity") - axs[i][0].set_xlabel("m/z") - axs[i][0].yaxis.set_tick_params(labelleft=False) + self._plot_ms1_spectrum(axs[i][0], deconvoluted=deconvoluted) i += 1 # MS2 plot if "MS2" in to_plot: - axs[i][0].set_title("MS2", loc="left") - axs[i][0].vlines( - self.best_ms2.mz_exp, 0, self.best_ms2.abundance, color="k" - ) - axs[i][0].set_ylabel("Intensity") - axs[i][0].set_xlabel("m/z") - axs[i][0].set_ylim(bottom=0) - axs[i][0].yaxis.get_major_formatter().set_scientific(False) - axs[i][0].yaxis.get_major_formatter().set_useOffset(False) - axs[i][0].set_xlim( - self.best_ms2.mz_exp.min() * 0.8, self.best_ms2.mz_exp.max() * 1.1 - ) - axs[i][0].yaxis.set_tick_params(labelleft=False) + self._plot_ms2_spectrum(axs[i][0]) + i += 1 + + # MS2 mirror plot + if "MS2_mirror" in to_plot: + self._plot_ms2_mirror(axs[i][0], molecular_metadata=molecular_metadata, spectral_library=spectral_library) + i += 1 # Add space between subplots plt.tight_layout() @@ -643,6 +871,30 @@ def noise_score_max(self): # Handle NaN values - nanmax ignores NaN return np.nanmax([left, right]) + @property + def type(self): + """Type of the mass feature. + + Returns + ------- + str + The type of mass feature ("untargeted", "targeted", or "internal standard"). + """ + return self._type + + @type.setter + def type(self, value): + """Set the type of the mass feature. + + Parameters + ---------- + value : str + The type of mass feature. Should be one of: "untargeted", "targeted", "internal standard". + """ + if not isinstance(value, str): + raise ValueError("The type of the mass feature must be a string") + self._type = value + @property def best_ms2(self): """Points to the best representative MS2 mass spectrum diff --git a/corems/encapsulation/factory/parameters.py b/corems/encapsulation/factory/parameters.py index 237b14438..ce3d9a382 100644 --- a/corems/encapsulation/factory/parameters.py +++ b/corems/encapsulation/factory/parameters.py @@ -6,6 +6,7 @@ TransientSetting, MassSpecPeakSetting, MassSpectrumSetting, + LCMSCollectionSettings, ) from corems.encapsulation.factory.processingSetting import ( CompoundSearchSettings, @@ -284,6 +285,49 @@ def print(self): print(" {}: {}".format(k4, v4)) +class LCMSCollectionParameters: + """LCMSCollectionParameters class is used to store the parameters used for the processing of the LCMS collection + + Each attribute is a class that contains the parameters for the processing of the LCMS collection, + see the corems.encapsulation.factory.processingSetting module for more details. + + Parameters + ---------- + use_defaults: bool, optional + if True, the class will be instantiated with the default values, otherwise the current values will be used. + Default is False. + + Attributes + ----------- + lcms_collection: LCMSCollectionSettings + LCMSCollectionSettings object + + Notes + ----- + One can use the use_defaults parameter to reset the parameters to the default values. + Alternatively, to use the current values - modify the class's contents before instantiating the class. + """ + + lcms_collection = LCMSCollectionSettings() + + def __init__(self, use_defaults=False) -> None: + if not use_defaults: + self.lcms_collection = dataclasses.replace(LCMSCollectionParameters.lcms_collection) + else: + self.lcms_collection = LCMSCollectionSettings() + + def copy(self): + """Create a copy of the LCMSCollectionParameters object""" + new_lcms_collection_parameters = LCMSCollectionParameters() + new_lcms_collection_parameters.lcms_collection = dataclasses.replace(self.lcms_collection) + return new_lcms_collection_parameters + + def __eq__(self, value: object) -> bool: + # Check that the object is of the same type + if not isinstance(value, LCMSCollectionParameters): + return False + return self.lcms_collection == value.lcms_collection + def default_parameters(file_location): # pragma: no cover """Generate parameters dictionary with the default parameters for data processing To gather parameters from instrument data during the data parsing step, a parameters dictionary with the default parameters needs to be generated. diff --git a/corems/encapsulation/factory/processingSetting.py b/corems/encapsulation/factory/processingSetting.py index eb1b4a417..7fa6b0f56 100644 --- a/corems/encapsulation/factory/processingSetting.py +++ b/corems/encapsulation/factory/processingSetting.py @@ -1101,3 +1101,144 @@ def __post_init__(self): else: # will get the first number of all possible covalances, which should be the most commum self.used_atom_valences[atom] = covalence[0] +@dataclasses.dataclass +class LCMSCollectionSettings: + """Settings for LCMS collection class + + Attributes + ---------- + cores : int, optional + Number of cores to use for processing. Default is 1. + drop_isotopologues : bool, optional + If True, drop isotopologues from all analyses. + Note that this will keep mass features identified as monoisotopes and any largest ion in deconvoluted mass spectrum. + It will also keep mass features not identified as isotopologues or monoisotopes. + Default is True. + mass_feature_anchor_technique: list, optional + List of mass feature anchor techniques for retention time alignment. + Default is ['absolute_intensity']. + mass_feature_anchor_techniques_available: tuple, optional + Tuple of available mass feature anchor techniques for retention time alignment. + Default is ('deconvoluted_mass_spectra', 'absolute_intensity', 'relative_intensity'). + mass_feature_anchor_absolute_intensity_threshold: int, optional + Absolute intensity threshold for mass feature anchor for retention time alignment. + Used when mass_feature_anchor_technique includes 'relative_intensity'. + Default is 10000. + mass_feature_anchor_relative_intensity_threshold: float, optional + Relative intensity threshold (0-1) for mass feature anchor for retention time alignment. + Removes the lower fraction of mass features by intensity from consideration. + For example, 0.6 removes the lower 60% of intensity features. + Used when mass_feature_anchor_technique includes 'relative_intensity'. + Default is 0.6. + alignment_minimum_matches: int, optional + Minimum number of matched features required to attempt retention time alignment. + If fewer matches are found between samples, alignment will be skipped for that sample. + This is particularly useful when aligning blank samples or samples with very few features. + Default is 5. + alignment_hold_out_fraction: float, optional + Hold out fraction for testing retention time alignment. + Default is 0.3. + alignment_acceptance_technique: list, optional + List of alignment acceptance techniques for retention time alignment. + Default is ['fraction_improved', 'mean_squared_error_improved']. + alignment_acceptance_techniques_available: tuple, optional + Tuple of available alignment acceptance techniques for retention time alignment. + Default is ('fraction_improved', 'mean_squared_error_improved'). + alignment_acceptance_fraction_improved_threshold: float, optional + Threshold for the improved fraction of the hold out mass features for accepting retention time alignment. + Default is 0.5. + alignment_mz_tol_ppm: int, optional + m/z tolerance in ppm for retention time alignment, in ppm. Default is 5. + alignment_rt_tol: float, optional + Retention time tolerance for retention time alignment, in minutes. Default is 0.3. + consensus_mz_tol_ppm: int, optional + m/z tolerance in ppm for consensus mass feature alignment. Default is 5. + The recommendation is that this value should be the same as alignment_mz_tol_ppm. + consensus_rt_tol: float, optional + Retention time tolerance for consensus mass feature alignment, in minutes. Default is 0.2. + consensus_partition_size: int, optional + Partition size for consensus mass feature alignment. Default is 5000. + consensus_min_sample_fraction : float, optional + Minimum fraction of samples (0-1) that must contain a cluster. + Used for filtering consensus features and for gap-filling threshold. + Default is 0.5 (50%). Higher values focus on more prevalent features. + gap_fill_expand_on_miss : bool, optional + If True, expands search window using consensus_mz_tol_ppm and consensus_rt_tol + when no peak is found in the initial cluster boundaries during gap-filling. + Default is False. + consensus_representative_metric : str, optional + Metric used to determine the most representative sample for a consensus mass feature. + Options: + - 'intensity': Selects the mass feature with the highest intensity value + - 'intensity_prefer_ms2': Selects the mass feature with the highest intensity among + those that have MS2 scan numbers assigned. If no features have MS2 scans, falls + back to selecting the highest intensity feature overall. + Default is 'intensity_prefer_ms2'. + consensus_representative_metrics_available : tuple, optional + Tuple of available metrics for determining the most representative sample. + Default is ('intensity', 'intensity_prefer_ms2'). + """ + # Settings for general processing + cores: int = 1 + drop_isotopologues: bool = False + + # Settings for doing mass feature alignment + _mass_feature_anchor_technique: list = dataclasses.field(default_factory=lambda: ["relative_intensity"]) + mass_feature_anchor_techniques_available: tuple = ("deconvoluted_mass_spectra", "absolute_intensity", "relative_intensity") + mass_feature_anchor_absolute_intensity_threshold: int = 10000 + mass_feature_anchor_relative_intensity_threshold: float = 0.6 + alignment_minimum_matches: int = 5 + alignment_hold_out_fraction: float = 0.3 + _alignment_acceptance_technique: list = dataclasses.field(default_factory=lambda: ["fraction_improved", "mean_squared_error_improved"]) + alignment_acceptance_techniques_available: tuple = ("fraction_improved", "mean_squared_error_improved") + alignment_acceptance_fraction_improved_threshold: float = 0.5 + alignment_mz_tol_ppm: int = 5 + alignment_rt_tol: float = 0.4 + + # Consensus mass feature settings + consensus_mz_tol_ppm: int = alignment_mz_tol_ppm + consensus_rt_tol: float = 0.3 + consensus_partition_size: int = 10000 + filter_consensus_mass_features: bool = True + consensus_min_sample_fraction: float = 0.5 + + # Gap-filling settings + gap_fill_expand_on_miss: bool = True + + # Consensus mass feature visualization parameters + consensus_representative_metric: str = 'intensity_prefer_ms2' + consensus_representative_metrics_available: tuple = ('intensity', 'intensity_prefer_ms2') + + def __post_init__(self): + self.consensus_mz_tol_ppm = self.alignment_mz_tol_ppm + self._validate_alignment_acceptance_technique(self.alignment_acceptance_technique) + self._validate_mass_feature_anchor_technique(self.mass_feature_anchor_technique) + + def _validate_alignment_acceptance_technique(self, techniques): + for technique in techniques: + if technique not in self.alignment_acceptance_techniques_available: + raise ValueError(f"Alignment acceptance technique '{technique}' is not available. Alignment acceptance technique must be passed as a list. Available techniques: {self.alignment_acceptance_techniques_available}") + + def _validate_mass_feature_anchor_technique(self, techniques): + for technique in techniques: + if technique not in self.mass_feature_anchor_techniques_available: + raise ValueError(f"Mass feature anchor technique '{technique}' is not available. Alignment acceptance technique must be passed as a list. Available techniques: {self.mass_feature_anchor_techniques_available}") + + @property + def alignment_acceptance_technique(self): + return self._alignment_acceptance_technique + + @alignment_acceptance_technique.setter + def alignment_acceptance_technique(self, value): + self._validate_alignment_acceptance_technique(value) + self._alignment_acceptance_technique = value + + @property + def mass_feature_anchor_technique(self): + return self._mass_feature_anchor_technique + + @mass_feature_anchor_technique.setter + def mass_feature_anchor_technique(self, value): + self._validate_mass_feature_anchor_technique(value) + self._mass_feature_anchor_technique = value + diff --git a/corems/encapsulation/input/parameter_from_json.py b/corems/encapsulation/input/parameter_from_json.py index b36605100..ba8a899f0 100644 --- a/corems/encapsulation/input/parameter_from_json.py +++ b/corems/encapsulation/input/parameter_from_json.py @@ -11,9 +11,7 @@ MassSpectrumSetting, DataInputSetting, ) -from corems.encapsulation.factory.processingSetting import MassSpecPeakSetting -from corems.encapsulation.factory.processingSetting import GasChromatographSetting -from corems.encapsulation.factory.processingSetting import CompoundSearchSettings +from corems.encapsulation.factory.processingSetting import MassSpecPeakSetting, GasChromatographSetting, CompoundSearchSettings, LCMSCollectionSettings def load_and_set_toml_parameters_ms(mass_spec_obj, parameters_path=False): @@ -497,3 +495,85 @@ def _set_dict_data(data_loaded, parameter_label, instance_ParameterClass): setattr(classe, item, value) return classes[0] + + +def load_and_set_json_parameters_lcms_collection(lcms_collection, parameters_path): + """Load parameters from a json file and set the parameters in the LCMS collection object + + Parameters + ---------- + lcms_collection : LCMSCollection + corems LCMSCollection object + parameters_path : str or Path + path to the parameters file saved as a .json + + Raises + ------ + FileNotFoundError + if the file is not found + """ + file_path = Path(parameters_path) + + if file_path.exists(): + with open(file_path, "r", encoding="utf8") as stream: + data_loaded = json.load(stream) + _set_dict_data_lcms_collection(data_loaded, lcms_collection) + else: + raise FileNotFoundError(f"Could not locate {file_path}") + + +def load_and_set_toml_parameters_lcms_collection(lcms_collection, parameters_path): + """Load parameters from a toml file and set the parameters in the LCMS collection object + + Parameters + ---------- + lcms_collection : LCMSCollection + corems LCMSCollection object + parameters_path : str or Path + path to the parameters file saved as a .toml + + Raises + ------ + FileNotFoundError + if the file is not found + """ + file_path = Path(parameters_path) + + if file_path.exists(): + with open(file_path, "r", encoding="utf8") as stream: + data_loaded = toml.load(stream) + _set_dict_data_lcms_collection(data_loaded, lcms_collection) + else: + raise FileNotFoundError(f"Could not locate {file_path}") + + +def _set_dict_data_lcms_collection(data_loaded, lcms_collection): + """Set the parameters in the LCMS collection object from a dict + + This function is called by load_and_set_json_parameters_lcms_collection and + load_and_set_toml_parameters_lcms_collection and should not be called directly. + + Parameters + ---------- + data_loaded : dict + dict with the parameters + lcms_collection : LCMSCollection + corems LCMSCollection object + """ + classes = [LCMSCollectionSettings()] + labels = ["LCMSCollection"] + + label_class = zip(labels, classes) + + if data_loaded: + for label, classe in label_class: + class_data = data_loaded.get(label) + # not always we will have all the settings + # this allows a class data to be none and continue + # to import the other classes + if class_data: + for attr, value in class_data.items(): + if hasattr(classe, attr): + setattr(classe, attr, value) + + lcms_collection.parameters.lcms_collection = classes[0] diff --git a/corems/encapsulation/output/parameter_to_dict.py b/corems/encapsulation/output/parameter_to_dict.py index f754e0bd7..7759bcee0 100644 --- a/corems/encapsulation/output/parameter_to_dict.py +++ b/corems/encapsulation/output/parameter_to_dict.py @@ -1,8 +1,8 @@ from corems.encapsulation.factory.parameters import ( MSParameters, GCMSParameters, - LCMSParameters, -) + LCMSParameters + ) def get_dict_all_default_data(): @@ -111,3 +111,32 @@ def get_dict_data_gcms(gcms): "MolecularSearch": gcms.molecular_search_settings.__dict__, "GasChromatograph": gcms.chromatogram_settings.__dict__, } + + +def get_dict_data_lcms_collection(lcms_collection): + """Return a dictionary with all parameters for LCMSCollection object + + Parameters + ---------- + lcms_collection: LCMSCollection + LCMSCollection object + + Returns + ------- + dict + dictionary with all parameters for LCMSCollection object + """ + output_dict = {} + output_dict["LCMSCollection"] = lcms_collection.parameters.lcms_collection.__dict__ + return output_dict + + +def get_dict_lcms_collection_default_data(): + """Return a dictionary with all default parameters for LCMS Collection""" + from corems.encapsulation.factory.processingSetting import LCMSCollectionSettings + + default_params = LCMSCollectionSettings() + + output_dict = {} + output_dict["LCMSCollection"] = default_params.__dict__ + return output_dict diff --git a/corems/encapsulation/output/parameter_to_json.py b/corems/encapsulation/output/parameter_to_json.py index b353941b1..44a1992b2 100644 --- a/corems/encapsulation/output/parameter_to_json.py +++ b/corems/encapsulation/output/parameter_to_json.py @@ -259,3 +259,74 @@ def dump_lcms_settings_toml( ) as outfile: output = toml.dumps(data_dict) outfile.write(output) + + +def dump_lcms_collection_settings_json( + filename="SettingsCoreMS.json", file_path=None, lcms_collection=None +): + """Write JSON file with LCMS collection settings data. + + Parameters + ---------- + filename : str, optional + The name of the JSON file. Defaults to 'SettingsCoreMS.json'. + file_path : str or Path, optional + The path where the JSON file will be saved. If not provided, the file will be saved in the current working directory. + lcms_collection : LCMSCollection, optional + The LCMS collection object containing the settings data. If not provided, the settings data will be retrieved from the default settings. + """ + from corems.encapsulation.output.parameter_to_dict import ( + get_dict_data_lcms_collection, + get_dict_lcms_collection_default_data, + ) + + if lcms_collection is None: + data_dict = get_dict_lcms_collection_default_data() + else: + data_dict = get_dict_data_lcms_collection(lcms_collection) + + if not file_path: + file_path = Path.cwd() / filename + + with open( + file_path, + "w", + encoding="utf8", + ) as outfile: + outfile.write(json.dumps(data_dict, indent=4)) + + +def dump_lcms_collection_settings_toml( + filename="SettingsCoreMS.toml", file_path=None, lcms_collection=None +): + """Write TOML file with LCMS collection settings data. + + Parameters + ---------- + filename : str, optional + The name of the TOML file. Defaults to 'SettingsCoreMS.toml'. + file_path : str or Path, optional + The path where the TOML file will be saved. If not provided, the file will be saved in the current working directory. + lcms_collection : LCMSCollection, optional + The LCMS collection object containing the settings data. If not provided, the settings data will be retrieved from the default settings. + """ + from corems.encapsulation.output.parameter_to_dict import ( + get_dict_data_lcms_collection, + get_dict_lcms_collection_default_data, + ) + + if lcms_collection is None: + data_dict = get_dict_lcms_collection_default_data() + else: + data_dict = get_dict_data_lcms_collection(lcms_collection) + + if not file_path: + file_path = Path.cwd() / filename + + with open( + file_path, + "w", + encoding="utf8", + ) as outfile: + output = toml.dumps(data_dict) + outfile.write(output) diff --git a/corems/mass_spectra/calc/lc_calc.py b/corems/mass_spectra/calc/lc_calc.py index 46fad37f5..a81dfc7ba 100644 --- a/corems/mass_spectra/calc/lc_calc.py +++ b/corems/mass_spectra/calc/lc_calc.py @@ -1,14 +1,20 @@ import numpy as np import pandas as pd +import warnings, scipy, multiprocessing from ripser import ripser from scipy import sparse from scipy.spatial import KDTree +from sklearn.svm import SVR +from sklearn.cluster import AgglomerativeClustering +import matplotlib.pyplot as plt +from tqdm import tqdm from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature from corems.mass_spectra.calc import SignalProcessing as sp from corems.mass_spectra.factory.chromat_data import EIC_Data from corems.mass_spectrum.input.numpyArray import ms_from_array_profile +warnings.filterwarnings("ignore", category=RuntimeWarning) def find_closest(A, target): """Find the index of closest value in A to each value in target. @@ -187,7 +193,7 @@ def find_nearest_scan(self, rt): return real_scan - def add_peak_metrics(self, remove_by_metrics=True): + def add_peak_metrics(self, remove_by_metrics=True, induced_features=False): """Add peak metrics to the mass features. This function calculates the peak metrics for each mass feature and adds them to the mass feature objects. @@ -198,16 +204,23 @@ def add_peak_metrics(self, remove_by_metrics=True): If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters. + induced_features : bool, optional + Whether the mass features to be integrated were induced. Default is False. """ # Check that at least some mass features have eic data - if not any([mf._eic_data is not None for mf in self.mass_features.values()]): + if induced_features: + mf_dict_values = self.induced_mass_features.values() + else: + mf_dict_values = self.mass_features.values() + + if not any([mf._eic_data is not None for mf in mf_dict_values]): raise ValueError( "No mass features have EIC data. Run integrate_mass_features first." ) - for mass_feature in self.mass_features.values(): + for mass_feature in mf_dict_values: # Check if the mass feature has been integrated - if mass_feature._eic_data is not None: + if mass_feature._eic_data is not None and mass_feature.area is not None: # Calculate peak metrics mass_feature.calc_half_height_width() mass_feature.calc_tailing_factor() @@ -217,7 +230,7 @@ def add_peak_metrics(self, remove_by_metrics=True): # Remove mass features by peak metrics if designated in parameters if self.parameters.lc_ms.remove_mass_features_by_peak_metrics and remove_by_metrics: - self._remove_mass_features_by_peak_metrics() + self._remove_mass_features_by_peak_metrics(induced_features=induced_features) def get_average_mass_spectrum( self, @@ -344,7 +357,8 @@ def get_average_mass_spectrum( ms.process_mass_spec() return ms - def find_mass_features(self, ms_level=1, grid=True): + def find_mass_features(self, ms_level=1, grid=True, assign_ms2_scans=False, ms2_scan_filter=None, + targeted_search=False, target_search_dict=None, accumulate_features=False): """Find mass features within an LCMSBase object Note that this is a wrapper function that calls the find_mass_features_ph function, but can be extended to support other peak picking methods in the future. @@ -356,6 +370,32 @@ def find_mass_features(self, ms_level=1, grid=True): grid : bool, optional If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), used for persistent homology peak picking for profile data only. Default is True. + assign_ms2_scans : bool, optional + If True, assign MS2 scan numbers to mass features after peak picking. + This populates the ms2_scan_numbers attribute on each mass feature, which enables + choosing representative features based on MS2 availability. Default is False. + ms2_scan_filter : str or None, optional + Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). + If None, all MS2 scans are considered. Default is None. + targeted_search : bool, optional + If True, perform targeted mass feature search using the target_search_dict. + This mode filters data to only m/z and RT windows of interest and bypasses + intensity and persistence thresholds. Default is False. + target_search_dict : dict or None, optional + Dictionary containing target search parameters. Required if targeted_search is True. + Must contain: + - 'target_mz_list': list of target m/z values + - 'target_rt_list': list of target retention times (in minutes) + - 'mz_tolerance_ppm': m/z tolerance in ppm + - 'rt_tolerance': retention time tolerance (in minutes) + Optionally can contain: + - 'type': type label for mass features (e.g., "internal standard") + If not provided, defaults to "targeted" + Default is None. + accumulate_features : bool, optional + If True, new mass features will be added to existing features rather than replacing them. + This allows multiple sequential calls to find_mass_features to build up a combined set. + Default is False (replace existing features for backwards compatibility). Raises ------ @@ -364,18 +404,39 @@ def find_mass_features(self, ms_level=1, grid=True): If persistent homology peak picking is attempted on non-profile mode data. If data is not gridded and grid is False. If peak picking method is not implemented. + If targeted_search is True but target_search_dict is None or invalid. Returns ------- None, but assigns the mass_features and eics attributes to the object. """ + # Validate targeted search parameters + if targeted_search: + if target_search_dict is None: + raise ValueError("target_search_dict must be provided when targeted_search is True") + required_keys = ['target_mz_list', 'target_rt_list', 'mz_tolerance_ppm', 'rt_tolerance'] + for key in required_keys: + if key not in target_search_dict: + raise ValueError(f"target_search_dict must contain '{key}'") + if len(target_search_dict['target_mz_list']) != len(target_search_dict['target_rt_list']): + raise ValueError("target_mz_list and target_rt_list must have the same length") + pp_method = self.parameters.lc_ms.peak_picking_method if pp_method == "persistent homology": msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] if all(msx_scan_df["ms_format"] == "profile"): - self.find_mass_features_ph(ms_level=ms_level, grid=grid) + # Determine mass feature type + if targeted_search: + mf_type = target_search_dict.get('type', 'targeted') + else: + mf_type = 'untargeted' + self.find_mass_features_ph(ms_level=ms_level, grid=grid, + targeted_search=targeted_search, + target_search_dict=target_search_dict, + mf_type=mf_type, + accumulate_features=accumulate_features) else: raise ValueError( "MS{} scans are not profile mode, which is required for persistent homology peak picking.".format( @@ -385,7 +446,16 @@ def find_mass_features(self, ms_level=1, grid=True): elif pp_method == "centroided_persistent_homology": msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] if all(msx_scan_df["ms_format"] == "centroid"): - self.find_mass_features_ph_centroid(ms_level=ms_level) + # Determine mass feature type + if targeted_search: + mf_type = target_search_dict.get('type', 'targeted') + else: + mf_type = 'untargeted' + self.find_mass_features_ph_centroid(ms_level=ms_level, + targeted_search=targeted_search, + target_search_dict=target_search_dict, + mf_type=mf_type, + accumulate_features=accumulate_features) else: raise ValueError( "MS{} scans are not centroid mode, which is required for persistent homology centroided peak picking.".format( @@ -395,12 +465,27 @@ def find_mass_features(self, ms_level=1, grid=True): else: raise ValueError("Peak picking method not implemented") + # Cluster mass features to remove redundant features + self.cluster_mass_features(drop_children=True) + + # Optionally assign MS2 scan numbers to mass features during peak picking + # This helps with choosing representative features that have MS2 data + if assign_ms2_scans: + try: + self._find_ms2_scans_for_mass_features( + mf_ids=None, # Process all mass features + scan_filter=ms2_scan_filter + ) + except ValueError: + # No MS2 scans found - this is okay, just skip + pass + # Remove noisey mass features if designated in parameters - if self.parameters.lc_ms.remove_redundant_mass_features: + if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search: self._remove_redundant_mass_features() def integrate_mass_features( - self, drop_if_fail=True, drop_duplicates=True, ms_level=1 + self, drop_if_fail=True, drop_duplicates=True, ms_level=1, induced_features=False ): """Integrate mass features and extract EICs. @@ -417,6 +502,8 @@ def integrate_mass_features( Default is True. ms_level : int, optional The MS level to use. Default is 1. + induced_features : bool, optional + Whether the mass features to be intergrated were induced. Default is False. Raises ------ @@ -432,17 +519,31 @@ def integrate_mass_features( ----- drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection). """ + # Check if there is data if ms_level in self._ms_unprocessed.keys(): raw_data = self._ms_unprocessed[ms_level].copy() else: raise ValueError("No MS level " + str(ms_level) + " data found") - if self.mass_features is not None: - mf_df = self.mass_features_to_df().copy() + + # Check if mass_spectrum exists on each mass feature + if induced_features: + mf_dict = self.induced_mass_features + if len(mf_dict) == 0: + raise ValueError( + "No induced mass features found, did you run fill_missing_cluster_features() first?" + ) + + ## remove not found induced mass features by mz <= 0 (-99 indicator) + # also remove any where mz is nan + mf_dict = {k:v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz)} + else: - raise ValueError( - "No mass features found, did you run find_mass_features() first?" - ) + mf_dict = self.mass_features + if len(mf_dict) == 0: + raise ValueError( + "No mass features found, did you run find_mass_features() first?" + ) # Subset scan data to only include correct ms_level scan_df_sub = self.scan_df[ @@ -452,7 +553,7 @@ def integrate_mass_features( raise ValueError("No MS level " + ms_level + " data found in scan data") scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy() - mzs_to_extract = np.unique(mf_df["mz"].values) + mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()]) mzs_to_extract.sort() # Pre-sort raw_data by mz for faster filtering @@ -486,15 +587,15 @@ def integrate_mass_features( self.eics[mz] = myEIC # Get limits of mass features using EIC centroid detector and integrate - mf_df["area"] = np.nan - for idx, mass_feature in mf_df.iterrows(): + for idx, mass_feature in list(mf_dict.items()): mz = mass_feature.mz apex_scan = mass_feature.apex_scan # Pull EIC data and find apex scan index myEIC = self.eics[mz] - self.mass_features[idx]._eic_data = myEIC - apex_index = np.where(myEIC.scans == apex_scan)[0][0] + mf_dict[idx]._eic_data = myEIC + mf_dict[idx]._eic_mz = mz + apex_index = np.searchsorted(myEIC.scans, apex_scan) # Find left and right limits of peak using EIC centroid detector, add to EICData centroid_eics = self.eic_centroid_detector( @@ -518,7 +619,7 @@ def integrate_mass_features( else: consecutive_scans = 0 if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min: - self.mass_features.pop(idx) + mf_dict.pop(idx) continue # Add start and final scan to mass_features and EICData left_scan, right_scan = ( @@ -527,25 +628,28 @@ def integrate_mass_features( ) mf_scan_apex = [(left_scan, int(apex_scan), right_scan)] myEIC.apexes = myEIC.apexes + mf_scan_apex - self.mass_features[idx].start_scan = left_scan - self.mass_features[idx].final_scan = right_scan + mf_dict[idx].start_scan = left_scan + mf_dict[idx].final_scan = right_scan # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData area = np.trapz( myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], ) - mf_df.at[idx, "area"] = area myEIC.areas = myEIC.areas + [area] self.eics[mz] = myEIC - self.mass_features[idx]._area = area + mf_dict[idx]._area = area else: if drop_if_fail is True: - self.mass_features.pop(idx) + mf_dict.pop(idx) if drop_duplicates: # Prepare mass feature dataframe - mf_df = self.mass_features_to_df() + if induced_features: + mf_df = self.mass_features_to_df(induced_features = True).copy() + mf_df = mf_df[mf_df.start_scan.notna()] + else: + mf_df = self.mass_features_to_df(induced_features = False).copy() # For each mass feature, find all mass features within the clustering tolerance ppm and drop if their start and end times are within another mass feature # Keep the first mass feature (highest persistence) @@ -569,6 +673,13 @@ def integrate_mass_features( ): if idx2 in self.mass_features.keys(): self.mass_features.pop(idx2) + + # Filter MS2 scans to only include those within integration bounds + # This ensures MS2 scans outside start_scan to final_scan are removed + if induced_features: + self._filter_ms2_scans_by_integration_bounds(mf_dict=self.induced_mass_features) + else: + self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features) def find_c13_mass_features(self): """Mark likely C13 isotopes and connect to monoisoitopic mass features. @@ -943,7 +1054,7 @@ def _remove_redundant_mass_features( k: v for k, v in self.mass_features.items() if k not in non_representative_mf_id } - def _remove_mass_features_by_peak_metrics(self) -> None: + def _remove_mass_features_by_peak_metrics(self, induced_features=False) -> None: """Remove mass features based on peak metrics defined in mass_feature_attribute_filter_dict. This method filters mass features based on various peak shape metrics and quality indicators @@ -966,18 +1077,31 @@ def _remove_mass_features_by_peak_metrics(self) -> None: - {'dispersity_index': {'value': 0.1, 'operator': '<'}} - Keep features with dispersity_index < 0.1 - {'gaussian_similarity': {'value': 0.7, 'operator': '>='}} - Keep features with gaussian_similarity >= 0.7 + Parameters + ---------- + induced_features : bool, optional + If True, filter induced_mass_features instead of regular mass_features. Default is False. + Returns ------- None - Modifies self.mass_features in place by removing filtered features. + Modifies self.mass_features or self.induced_mass_features in place by removing filtered features. Raises ------ ValueError If no mass features are found, if an invalid attribute is specified, or if filter specification is malformed. """ - if self.mass_features is None or len(self.mass_features) == 0: - raise ValueError("No mass features found, run find_mass_features() first") + # Select the appropriate mass features dictionary + if induced_features: + mf_dict = self.induced_mass_features + mf_type = "induced mass features" + else: + mf_dict = self.mass_features + mf_type = "mass features" + + if mf_dict is None or len(mf_dict) == 0: + raise ValueError(f"No {mf_type} found, run {'gap filling' if induced_features else 'find_mass_features()'} first") filter_dict = self.parameters.lc_ms.mass_feature_attribute_filter_dict @@ -986,15 +1110,15 @@ def _remove_mass_features_by_peak_metrics(self) -> None: return verbose = self.parameters.lc_ms.verbose_processing - initial_count = len(self.mass_features) + initial_count = len(mf_dict) if verbose: - print(f"Filtering mass features using peak metrics. Initial count: {initial_count}") + print(f"Filtering {mf_type} using peak metrics. Initial count: {initial_count}") # List to collect IDs of mass features to remove features_to_remove = [] - for mf_id, mass_feature in self.mass_features.items(): + for mf_id, mass_feature in mf_dict.items(): should_remove = False for attribute_name, filter_spec in filter_dict.items(): @@ -1080,9 +1204,18 @@ def _remove_mass_features_by_peak_metrics(self) -> None: # Remove filtered mass features for mf_id in features_to_remove: - del self.mass_features[mf_id] + del mf_dict[mf_id] - # Clean up unassociated EICs and ms1 data + if verbose and len(features_to_remove) > 0: + print(f"Removed {len(features_to_remove)} {mf_type} based on peak metrics. Remaining: {len(mf_dict)}") + + # Update the appropriate dictionary + if induced_features: + self.induced_mass_features = mf_dict + else: + self.mass_features = mf_dict + + # Clean up unassociated EICs and ms1 data (only for regular features) self._remove_unassociated_eics() self._remove_unassociated_ms1_spectra() @@ -1808,7 +1941,51 @@ def _grid_data(self, data): return new_data_w - def find_mass_features_ph(self, ms_level=1, grid=True): + def _filter_data_by_targets(self, data, target_search_dict): + """Filter MS data to only include m/z and RT windows around target values. + + Parameters + ---------- + data : pd.DataFrame + MS data with 'mz' and 'scan_time' columns + target_search_dict : dict + Dictionary with target_mz_list, target_rt_list, mz_tolerance_ppm, rt_tolerance + + Returns + ------- + pd.DataFrame + Filtered data containing only points within target windows + """ + target_mz_list = target_search_dict['target_mz_list'] + target_rt_list = target_search_dict['target_rt_list'] + mz_tolerance_ppm = target_search_dict['mz_tolerance_ppm'] + rt_tolerance = target_search_dict['rt_tolerance'] + + # Create a mask for data points that fall within any target window + mask = np.zeros(len(data), dtype=bool) + + for target_mz, target_rt in zip(target_mz_list, target_rt_list): + # Calculate m/z window + mz_tol = target_mz * mz_tolerance_ppm / 1e6 + mz_min = target_mz - mz_tol + mz_max = target_mz + mz_tol + + # Calculate RT window + rt_min = target_rt - rt_tolerance + rt_max = target_rt + rt_tolerance + + # Create mask for this target + target_mask = ( + (data['mz'] >= mz_min) & (data['mz'] <= mz_max) & + (data['scan_time'] >= rt_min) & (data['scan_time'] <= rt_max) + ) + + # Combine with overall mask + mask |= target_mask + + return data[mask].reset_index(drop=True) + + def find_mass_features_ph(self, ms_level=1, grid=True, targeted_search=False, target_search_dict=None, mf_type="untargeted", accumulate_features=False): """Find mass features within an LCMSBase object using persistent homology. Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id) @@ -1819,6 +1996,14 @@ def find_mass_features_ph(self, ms_level=1, grid=True): The MS level to use. Default is 1. grid : bool, optional If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True. + targeted_search : bool, optional + If True, perform targeted search mode. Default is False. + target_search_dict : dict or None, optional + Dictionary with target parameters for targeted search. Default is None. + mf_type : str, optional + Type label for the mass features. Default is "untargeted". + accumulate_features : bool, optional + If True, add to existing features rather than replacing them. Default is False. Raises ------ @@ -1847,14 +2032,30 @@ def find_mass_features_ph(self, ms_level=1, grid=True): # Drop rows with missing intensity values and reset index data = data.dropna(subset=["intensity"]).reset_index(drop=True) + + # Add scan_time for filtering if in targeted mode + if targeted_search: + data = data.merge(self.scan_df[["scan", "scan_time"]], on="scan", how="left") - # Threshold data + # Threshold data (bypass thresholds in targeted mode) dims = ["mz", "scan_time"] - threshold = self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max() - persistence_threshold = ( - self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max() - ) - data_thres = data[data["intensity"] > threshold].reset_index(drop=True).copy() + if targeted_search: + # In targeted mode, bypass intensity and persistence thresholds + threshold = 0 + persistence_threshold = 0 + # Filter data to only target windows + data_thres = self._filter_data_by_targets(data, target_search_dict) + if len(data_thres) == 0: + if self.parameters.lc_ms.verbose_processing: + print("No data found in target windows") + self.mass_features = {} + return + else: + threshold = self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max() + persistence_threshold = ( + self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max() + ) + data_thres = data[data["intensity"] > threshold].reset_index(drop=True).copy() # Check if gridded, if not, grid gridded_mz = self.check_if_grid(data_thres) @@ -1866,20 +2067,24 @@ def find_mass_features_ph(self, ms_level=1, grid=True): else: data_thres = self.grid_data(data_thres) - # Add scan_time - data_thres = data_thres.merge(self.scan_df[["scan", "scan_time"]], on="scan") + # Add scan_time (skip if already present from targeted mode) + if 'scan_time' not in data_thres.columns: + data_thres = data_thres.merge(self.scan_df[["scan", "scan_time"]], on="scan") # Process in chunks if required if len(data_thres) > 10000: return self._find_mass_features_ph_partition( - data_thres, dims, persistence_threshold + data_thres, dims, persistence_threshold, mf_type, accumulate_features ) else: # Process all at once return self._find_mass_features_ph_single( - data_thres, dims, persistence_threshold + data_thres, dims, persistence_threshold, mf_type, accumulate_features + ) + return self._find_mass_features_ph_single( + data_thres, dims, persistence_threshold, mf_type ) - def _find_mass_features_ph_single(self, data_thres, dims, persistence_threshold): + def _find_mass_features_ph_single(self, data_thres, dims, persistence_threshold, mf_type="untargeted", accumulate_features=False): """Process all data at once (original logic).""" # Build factors factors = { @@ -1912,9 +2117,9 @@ def _find_mass_features_ph_single(self, data_thres, dims, persistence_threshold) mass_features_df = mass_features_df.reset_index(drop=True) # Populate mass_features attribute - self._populate_mass_features(mass_features_df) + self._populate_mass_features(mass_features_df, mf_type, accumulate_features) - def _find_mass_features_ph_partition(self, data_thres, dims, persistence_threshold): + def _find_mass_features_ph_partition(self, data_thres, dims, persistence_threshold, mf_type="untargeted", accumulate_features=False): """Partition the persistent homology mass feature detection for large datasets. This method splits the input data into overlapping scan partitions, processes each partition to detect mass features @@ -1928,6 +2133,10 @@ def _find_mass_features_ph_partition(self, data_thres, dims, persistence_thresho List of dimension names (e.g., ["mz", "scan_time"]) used for feature detection. persistence_threshold : float Minimum persistence value required for a detected mass feature to be retained. + mf_type : str, optional + Type label for the mass features. Default is "untargeted". + accumulate_features : bool, optional + If True, add to existing features rather than replacing them. Default is False. Returns ------- @@ -2031,7 +2240,7 @@ def _find_mass_features_ph_partition(self, data_thres, dims, persistence_thresho combined_features = combined_features.reset_index(drop=True) # Populate mass_features attribute - self._populate_mass_features(combined_features) + self._populate_mass_features(combined_features, mf_type, accumulate_features) else: self.mass_features = {} @@ -2094,7 +2303,7 @@ def _process_partition_ph(self, partition_data, index, dims, persistence_thresho return mass_features - def _populate_mass_features(self, mass_features_df): + def _populate_mass_features(self, mass_features_df, mf_type="untargeted", accumulate_features=False): """Populate the mass_features attribute from a DataFrame. Parameters @@ -2102,33 +2311,63 @@ def _populate_mass_features(self, mass_features_df): mass_features_df : pd.DataFrame DataFrame containing mass feature information. Note that the order of this DataFrame will determine the order of mass features in the mass_features attribute. + mf_type : str, optional + Type label for the mass features. Default is "untargeted". + accumulate_features : bool, optional + If True, new features will be added to existing features rather than replacing them. + Mass feature IDs will be offset to avoid conflicts. Default is False. Returns ------- - None, but assigns the mass_features attribute to the object. + None, but assigns or updates the mass_features attribute to the object. """ # Rename scan column to apex_scan mass_features_df = mass_features_df.rename( columns={"scan": "apex_scan", "scan_time": "retention_time"} ) - # Populate mass_features attribute - self.mass_features = {} - for row in mass_features_df.itertuples(): + # Initialize or preserve existing mass_features attribute + if accumulate_features and self.mass_features is not None and len(self.mass_features) > 0: + # Find the maximum existing ID to offset new IDs and avoid conflicts + id_offset = max(self.mass_features.keys()) + 1 + initial_count = len(self.mass_features) + else: + # Replace mode (default/backwards compatible) + self.mass_features = {} + id_offset = 0 + initial_count = 0 + + # Add new mass features + for idx, row in enumerate(mass_features_df.itertuples()): row_dict = mass_features_df.iloc[row.Index].to_dict() lcms_feature = LCMSMassFeature(self, **row_dict) - self.mass_features[lcms_feature.id] = lcms_feature + lcms_feature.type = mf_type + # Use sequential ID starting from id_offset to avoid conflicts with existing features + new_id = idx + id_offset + lcms_feature._id = new_id # Update the internal ID + self.mass_features[new_id] = lcms_feature if self.parameters.lc_ms.verbose_processing: - print("Found " + str(len(mass_features_df)) + " initial mass features") + if accumulate_features and initial_count > 0: + print(f"Found {len(mass_features_df)} new mass features (total: {len(self.mass_features)})") + else: + print("Found " + str(len(mass_features_df)) + " initial mass features") - def find_mass_features_ph_centroid(self, ms_level=1): + def find_mass_features_ph_centroid(self, ms_level=1, targeted_search=False, target_search_dict=None, mf_type="untargeted", accumulate_features=False): """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data. Parameters ---------- ms_level : int, optional The MS level to use. Default is 1. + targeted_search : bool, optional + If True, perform targeted search mode. Default is False. + target_search_dict : dict or None, optional + Dictionary with target parameters for targeted search. Default is None. + mf_type : str, optional + Type label for the mass features. Default is "untargeted". + accumulate_features : bool, optional + If True, add to existing features rather than replacing them. Default is False. Raises ------ @@ -2150,20 +2389,37 @@ def find_mass_features_ph_centroid(self, ms_level=1): # Work with reference instead of copy data = self._ms_unprocessed[ms_level] - # Calculate threshold first to avoid multiple operations - max_intensity = data["intensity"].max() - threshold = self.parameters.lc_ms.ph_inten_min_rel * max_intensity - - # Create single filter condition and apply to required columns only - valid_mask = data["intensity"].notna() & (data["intensity"] > threshold) - required_cols = ["mz", "intensity", "scan"] - data_thres = data.loc[valid_mask, required_cols].copy() - data_thres["persistence"] = data_thres["intensity"] - - # Merge with required scan data + # Merge with scan data first (needed for filtering in targeted mode) scan_subset = self.scan_df[["scan", "scan_time"]] - mf_df = data_thres.merge(scan_subset, on="scan", how="inner") - del data_thres, scan_subset + data_with_time = data.merge(scan_subset, on="scan", how="inner") + + # Calculate threshold and filter (bypass in targeted mode) + if targeted_search: + # In targeted mode, bypass intensity threshold + threshold = 0 + valid_mask = data_with_time["intensity"].notna() + required_cols = ["mz", "intensity", "scan", "scan_time"] + data_thres = data_with_time.loc[valid_mask, required_cols].copy() + + # Filter to target windows + data_thres = self._filter_data_by_targets(data_thres, target_search_dict) + + if len(data_thres) == 0: + if self.parameters.lc_ms.verbose_processing: + print("No data found in target windows") + self.mass_features = {} + return + else: + # Normal mode with threshold + max_intensity = data_with_time["intensity"].max() + threshold = self.parameters.lc_ms.ph_inten_min_rel * max_intensity + valid_mask = data_with_time["intensity"].notna() & (data_with_time["intensity"] > threshold) + required_cols = ["mz", "intensity", "scan", "scan_time"] + data_thres = data_with_time.loc[valid_mask, required_cols].copy() + + data_thres["persistence"] = data_thres["intensity"] + mf_df = data_thres + del data_thres, scan_subset, data_with_time # Order by scan_time and then mz to ensure features near in rt are processed together # It's ok that different scans are in different partitions; we will roll up later @@ -2236,11 +2492,12 @@ def find_mass_features_ph_centroid(self, ms_level=1): for idx, row in mass_features.iterrows(): row_dict = row.to_dict() lcms_feature = LCMSMassFeature(self, **row_dict) + lcms_feature.type = mf_type self.mass_features[lcms_feature.id] = lcms_feature if self.parameters.lc_ms.verbose_processing: print("Found " + str(len(mass_features)) + " initial mass features") - + def cluster_mass_features(self, drop_children=True, sort_by="persistence"): """Cluster mass features @@ -2298,4 +2555,3233 @@ def cluster_mass_features(self, drop_children=True, sort_by="persistence"): if k not in cluster_daughters } else: - return cluster_daughters \ No newline at end of file + return cluster_daughters + + +class LCMSCollectionCalculations: + """Methods for performing calculations related to LCMSCollection objects. + + Notes + ----- + This class is intended as a mixin for the LCMSCollection class. + """ + + @staticmethod + def _plot_multiple_eics(ax, cluster_mfs, induced_cluster_mfs, rep_sample_id, rep_mf_id, + median_rt, eic_buffer_time, plot_smoothed=False, + plot_datapoints=False, label_samples=False, lcms_collection=None): + """Internal method to plot multiple EICs from different samples on a given axis. + + Parameters + ---------- + ax : matplotlib.axes.Axes + The axis to plot on. + cluster_mfs : pd.DataFrame + DataFrame containing cluster mass features (non-induced). + induced_cluster_mfs : pd.DataFrame or None + DataFrame containing induced (gap-filled) mass features. + rep_sample_id : int + Sample ID of the representative mass feature. + rep_mf_id : int + Mass feature ID of the representative mass feature. + median_rt : float + Median retention time for the cluster. + eic_buffer_time : float + Time buffer around the peak (minutes). + plot_smoothed : bool, optional + If True, plot smoothed EICs. Default is False. + plot_datapoints : bool, optional + If True, plot EIC datapoints. Default is False. + label_samples : bool, optional + If True, label each sample individually. Default is False. + lcms_collection : LCMSCollection, optional + The parent collection object for accessing samples. Required. + """ + ax.set_title("EICs from all samples", loc="left") + + # Track if we've added labels for legend (to avoid duplicates) + rep_labeled = False + regular_labeled = False + induced_labeled = False + + # Plot regular (non-induced) mass features + for _, row in cluster_mfs.iterrows(): + sample_id = int(row['sample_id']) + mf_id = row['mf_id'] + sample = lcms_collection[sample_id] + sample_name = row['sample_name'] + + # Get EIC using eic_mz column from dataframe + eic_mz = row.get('_eic_mz') + if eic_mz is not None and not pd.isna(eic_mz) and hasattr(sample, 'eics') and sample.eics: + eic_data = sample.eics.get(eic_mz) + else: + eic_data = None + + if eic_data: + # Determine line style and width + if sample_id == rep_sample_id and mf_id == rep_mf_id: + # Representative feature - bold line + linewidth = 2.5 + alpha = 1.0 + color = 'tab:blue' + if label_samples: + label = f"{sample_name} (representative)" + else: + label = "Representative" if not rep_labeled else None + rep_labeled = True + else: + # Other features - thinner line + linewidth = 1.0 + alpha = 0.5 + color = 'tab:blue' + if label_samples: + label = sample_name + else: + label = "Regular features" if not regular_labeled else None + regular_labeled = True + + ax.plot( + eic_data.time, + eic_data.eic, + c=color, + linewidth=linewidth, + alpha=alpha, + linestyle='-', + label=label + ) + + if plot_datapoints: + ax.scatter( + eic_data.time, + eic_data.eic, + c=color, + alpha=alpha, + s=10 + ) + + if plot_smoothed and hasattr(eic_data, 'eic_smoothed'): + ax.plot( + eic_data.time, + eic_data.eic_smoothed, + c=color, + linestyle='--', + alpha=alpha * 0.8, + linewidth=linewidth * 0.8 + ) + + # Plot induced (gap-filled) mass features if available + if induced_cluster_mfs is not None and not induced_cluster_mfs.empty: + for _, row in induced_cluster_mfs.iterrows(): + sample_id = int(row['sample_id']) + mf_id = row['mf_id'] + sample = lcms_collection[sample_id] + sample_name = row['sample_name'] + + # Get EIC using eic_mz column from dataframe + eic_mz = row.get('_eic_mz') + if eic_mz is not None and not pd.isna(eic_mz) and hasattr(sample, 'eics') and sample.eics: + eic_data = sample.eics.get(eic_mz) + else: + eic_data = None + + if eic_data: + # Induced features - even thinner line + linewidth = 0.5 + alpha = 0.4 + color = 'tab:orange' + + if label_samples: + label = f"{sample_name} (induced)" + else: + label = "Gap-filled features" if not induced_labeled else None + induced_labeled = True + + ax.plot( + eic_data.time, + eic_data.eic, + c=color, + linewidth=linewidth, + alpha=alpha, + linestyle='-', + label=label + ) + + if plot_datapoints: + ax.scatter( + eic_data.time, + eic_data.eic, + c=color, + alpha=alpha, + s=5 + ) + + if plot_smoothed and hasattr(eic_data, 'eic_smoothed'): + ax.plot( + eic_data.time, + eic_data.eic_smoothed, + c=color, + linestyle='--', + alpha=alpha * 0.8, + linewidth=linewidth * 0.8 + ) + + # Add vertical line at median RT + ax.axvline( + x=median_rt, + color='k', + linestyle='--', + alpha=0.7, + label='Median RT' + ) + + ax.set_ylabel("Intensity") + ax.set_xlabel("Time (minutes)") + ax.set_xlim( + median_rt - eic_buffer_time, + median_rt + eic_buffer_time, + ) + ax.legend(loc='upper left', fontsize=8) + ax.yaxis.get_major_formatter().set_useOffset(False) + + def clean_sparse_matrix(self, sparse_matrix): + """Clean a sparse matrix by removing duplicates and sorting. + + Parameters + ---------- + sparse_matrix : :obj:`~numpy.array` + A sparse matrix to clean. + + Returns + ------- + :obj:`~numpy.array` + A cleaned sparse matrix. + """ + for match in sparse_matrix: + match.sort() + sparse_matrix.sort() + dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0) + return dereplicated_sparse_matrix + + def match_mfs(self, mf_c, mf_i): + """Match mass features between two LCMS objects. + + Parameters + ---------- + mf_c : :obj:`~pandas.DataFrame` + The mass features to match against. + mf_i : :obj:`~pandas.DataFrame` + The mass features to match. + + Returns + ------- + :obj:`~pandas.DataFrame` + The matched mass features from mf_c. + :obj:`~pandas.DataFrame` + The matched mass features from mf_i. + + Notes + ----- + This function has been adapted from the original implementation in the Deimos package: + https://github.com/pnnl/deimos + """ + if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1: + return None, None + + # Prepare dataframes + mf_c = mf_c.copy() + mf_c["id_i"] = 0 + mf_i = mf_i.copy() + mf_i["id_i"] = 1 + + # Set dimensions for matching + dims = ["mz", "scan_time"] + relative = [True, False] + mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6 + rt_tol = self.parameters.lcms_collection.alignment_rt_tol + tol = [mz_tol, rt_tol] + + # Compute inter-feature distances + idx = [] + for i, f in enumerate(dims): + # vectors + v1 = mf_c[f].values.reshape(-1, 1) + v2 = mf_i[f].values.reshape(-1, 1) + + # Distances + d = scipy.spatial.distance.cdist(v1, v2) + + if relative[i] is True: + # Divisor + basis = np.repeat(v1, v2.shape[0], axis=1) + fix = np.repeat(v2, v1.shape[0], axis=1).T + basis = np.where(basis == 0, fix, basis) + + # Divide + d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0) + + # Check tol + idx.append(d <= tol[i]) + + # Stack truth arrays + idx = np.prod(np.dstack(idx), axis=-1, dtype=bool) + + # Compute normalized 3d distance + v1 = mf_c[dims].values / tol + v2 = mf_i[dims].values / tol + dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock") + + # Separate features within tolerance from those outside + # Features outside tolerance should be inf, features within tolerance keep their distance + # Use idx mask: True for within tolerance, False for outside + dist3d_within_tol = np.where(idx, dist3d, np.inf) + + # Normalize to 0-1 (only affects within-tolerance distances) + mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0 + if mx > 0: + # Lower distance is better - normalize only the within-tolerance values + dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf) + else: + # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs + dist3d_within_tol = np.where(idx, 1e-10, np.inf) + + # Use the masked distance matrix + dist3d = dist3d_within_tol + + # Min over dims + mincols = np.min(dist3d, axis=0, keepdims=True) + + # Zero out mincols over dims + dist3d[dist3d != mincols] = np.inf + + # Min over clusters + minrows = np.min(dist3d, axis=1, keepdims=True) + + # Where max and nonzero + ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf)) + + # Reorder + mf_c = mf_c.iloc[ii] + mf_i = mf_i.iloc[jj] + + if len(mf_c.index) < 1 or len(mf_i.index) < 1: + return None, None + + return mf_c, mf_i + + def fit_rts(self, a, b, align="scan_time", **kwargs): + """ + Fit a support vector regressor to matched features. + + Parameters + ---------- + a : :obj:`~pandas.DataFrame` + First set of input feature coordinates and intensities; the center object and the object to align to. + b : :obj:`~pandas.DataFrame` + Second set of input feature coordinates and intensities; the object to align to the center object. + align : str + Dimension to align. + kwargs + Keyword arguments for support vector regressor + (:class:`sklearn.svm.SVR`). + + Returns + ------- + :obj:`~function` + An interpolation function where one can input a retention time and get the predicted retention time. + + Notes + ----- + This function has been adapted from the original implementation in the Deimos package: + https://github.com/pnnl/deimos + + """ + + # Uniqueify + x = a[align].values + y = b[align].values + arr = np.vstack((x, y)).T + arr = np.unique(arr, axis=0) + + # Safety check: ensure we have data to work with + if len(arr) == 0: + warnings.warn("No data points available for retention time fitting. Returning identity function.") + return lambda x: x + + # Check kwargs + if "kernel" in kwargs: + kernel = kwargs.get("kernel") + else: + kernel = "linear" + + # Construct interpolation axis + newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000) + + # Linear kernel + if kernel == "linear": + reg = scipy.stats.linregress(x, y) + newy = reg.slope * newx + reg.intercept + + # Other kernels + else: + # Fit + svr = SVR(**kwargs) + svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0]) + + # Predict + newy = svr.predict(newx.reshape(-1, 1)) + + # Pad x and y_pred with zeros to force interpolation to start at 0 + newx = np.concatenate(([0], newx)) + newy = np.concatenate(([0], newy)) + + # Pad x and y_pred with max time to force interpolation to end at max time to force interpolation to match at end max time + max_time = self[0].scan_df["scan_time"].max() + newx = np.concatenate((newx, [max_time])) + newy = np.concatenate((newy, [max_time])) + + # Return an interpolation function for the x and y_pred + def interp(x): + pred_y = np.interp(x, newx, newy) + return pred_y + + return interp + + def get_anchor_mass_features(self, mf_df): + """ + Get the anchor mass features from a DataFrame of mass features. + + Parameters + ---------- + mf_df : :obj:`~pandas.DataFrame` + The mass features to filter to just the anchor mass features. + + Returns + ------- + :obj:`~pandas.DataFrame` + The anchor mass features dataframe. + """ + mf_df = mf_df.copy() + + if ( + "deconvoluted_mass_spectra" + in self.parameters.lcms_collection.mass_feature_anchor_technique + ): + # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent + mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"]) + mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]] + + if ( + "absolute_intensity" + in self.parameters.lcms_collection.mass_feature_anchor_technique + ): + # Drop features that have an intensity lower than the threshold + threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold + mf_df = mf_df[mf_df["intensity"] > threshold] + + if ( + "relative_intensity" + in self.parameters.lcms_collection.mass_feature_anchor_technique + ): + # Drop features in the lower fraction of intensities + threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold + intensity_threshold = mf_df["intensity"].quantile(threshold_quantile) + mf_df = mf_df[mf_df["intensity"] >= intensity_threshold] + + return mf_df + + def attempt_alignment(self, matches_c, matches_i): + """ + Check if alignment is needed for the LCMS objects in the collection. + """ + + # Hold out a subset of matches_c and matches_i for spline fitting + matches_c.reset_index(drop=False, inplace=True) + matches_i.reset_index(drop=False, inplace=True) + + # Check if there are enough matches to attempt alignment + minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches + if len(matches_c) < minimum_matches: + # Return False (no alignment) and identity function (returns original time) + # which isn't used but is a placeholder to avoid errors in downstream code since + # the function expects a callable to be returned + return False, lambda x: x + + # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c + matches_c = matches_c.sort_values(by="scan_time") + matches_i = matches_i.iloc[matches_c.index.values] + + hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction + # starting with an array of length len(matches_c), select equally spaced indices to hold out + idx_holdout = matches_c.index.values[ + np.arange(0, len(matches_c), int(1 / hold_out_fraction)) + ] + + matches_c_holdout = matches_c.loc[idx_holdout].copy() + matches_i_holdout = matches_i.loc[idx_holdout].copy() + + # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index + matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name") + matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name") + + # Reset the scan_time to the original scan_time + matches_i = matches_i.copy() + matches_i["scan_time"] = matches_i["scan_time_og"] + + # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features + spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000) + + # Check if the spline fitting improved the alignment for the holdout matches + matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"]) + og_diff = np.abs( + matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"] + ) + fit_diff = np.abs( + matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"] + ) + + if ( + "fraction_improved" + in self.parameters.lcms_collection.alignment_acceptance_technique + ): + fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff) + use_spline_alignment = ( + fraction_improved + > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold + ) + if ( + "mean_squared_error_improved" + in self.parameters.lcms_collection.alignment_acceptance_technique + ): + mse_og = np.mean(og_diff**2) + mse = np.mean(fit_diff**2) + use_spline_alignment = mse < mse_og + # Convert to boolean + use_spline_alignment = bool(use_spline_alignment) + + return use_spline_alignment, spl + + def align_lcms_objects(self, overwrite=False): + """ + Align LCMS objects in the collection. + + Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. + First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, + starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects + are then fit to the center LCMS object using the matched mass features. + + Returns + ------- + None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object. + + Notes + ----- + This function has been adapted from the original implementation in the Deimos package: + https://github.com/pnnl/deimos + """ + + # Prepare the center LCMS object + center_obj_ids = self.manifest_dataframe[ + self.manifest_dataframe["center"] + ].collection_id.values + + full_mf_df = self.mass_features_dataframe + # re-index to sample_name for faster lookups + full_mf_df = full_mf_df.reset_index().set_index("sample_name") + samples_with_features = set(full_mf_df.index.get_level_values("sample_name")) + + if "scan_time_aligned" in full_mf_df.columns and not overwrite: + raise ValueError("Mass features have already been aligned") + + def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline): + """Set scan_time_aligned for one sample using spline or identity mapping.""" + if use_alignment and spline is not None: + self[sample_idx]._scan_info["scan_time_aligned"] = { + k: spline(v) for k, v in self[sample_idx]._scan_info["scan_time"].items() + } + return True + + self[sample_idx]._scan_info["scan_time_aligned"] = self[sample_idx]._scan_info[ + "scan_time" + ].copy() + return False + + def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline): + """Return next sample index/dataframe with features, aligning empty samples on the way.""" + i = start_idx + while 0 <= i < len(self): + sample_name = self.samples[i] + if sample_name in samples_with_features: + mf_df_i = full_mf_df.loc[sample_name].copy() + mf_df_i["scan_time_og"] = mf_df_i["scan_time"] + mf_df_i = mf_df_i.reset_index(drop=False) + if use_alignment and spline is not None: + # Use previous step transform as a better matching starting point. + mf_df_i["scan_time"] = spline(mf_df_i["scan_time"]) + return i, mf_df_i + + _set_scan_time_alignment_for_sample(i, use_alignment, spline) + self.rt_alignment_attempted = True + i += index_step + + return i, None + + anchor_mf_dfs = [] + for center_obj_id in center_obj_ids: + # Get the anchor mass features from the center LCMS object + mf_df_c = full_mf_df.loc[self.samples[center_obj_id]] + mf_df_c = self.get_anchor_mass_features(mf_df_c) + anchor_mf_dfs.append(mf_df_c) + + # Set scan_time_aligned to scan_time for the center LCMS object + center_scan_df = self[center_obj_id].scan_df.copy() + center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"] + self[center_obj_id].scan_df = center_scan_df + + # Store alignment data for center object (identity mapping) + center_sample_name = self.samples[center_obj_id] + + index_steps = (1, -1) + # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing) + for index_step in index_steps: + # Initialize spline for propagation to samples without features + spl = None + use_spline_alignment = False + + # Loop through the other LCMS objects in this direction. + i, mf_df_i = _get_feature_df_at_or_after( + center_obj_id + index_step, + index_step, + use_spline_alignment, + spl, + ) + + while mf_df_i is not None: + mf_df_i = self.get_anchor_mass_features(mf_df_i) + + # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object. + matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) + + if matches_c is not None: + use_spline_alignment, spl = self.attempt_alignment( + matches_c, matches_i + ) + + # Record if we used alignment for this sample + sample_name = self.samples[i] + self._manifest_dict[sample_name]["use_rt_alignment"] = ( + use_spline_alignment + ) + + if use_spline_alignment: + # Set new retention times on scan_df for lc_obj using the spline fitting + matches_i["scan_time_fit"] = spl(matches_i["scan_time"]) + + self.rt_aligned = _set_scan_time_alignment_for_sample( + i, use_spline_alignment, spl + ) + self.rt_alignment_attempted = True + + i, mf_df_i = _get_feature_df_at_or_after( + i + index_step, + index_step, + use_spline_alignment, + spl, + ) + else: + # If no matches are found, propagate prior alignment from this index step. + sample_name = self.samples[i] + used_previous_alignment = use_spline_alignment and spl is not None + self._manifest_dict[sample_name]["use_rt_alignment"] = ( + used_previous_alignment + ) + + self.rt_aligned = _set_scan_time_alignment_for_sample( + i, used_previous_alignment, spl + ) + self.rt_alignment_attempted = True + + i, mf_df_i = _get_feature_df_at_or_after( + i + index_step, + index_step, + used_previous_alignment, + spl, + ) + + # Now align each batch using the center objects as anchors with the other batches + mf_df_c = anchor_mf_dfs[0] + for i in center_obj_ids[1:]: + mf_df_i = full_mf_df.loc[self.samples[i]].copy() + mf_df_i["scan_time_og"] = mf_df_i["scan_time"] + mf_df_i = self.get_anchor_mass_features(mf_df_i) + + matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) + if matches_c is not None: + use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i) + + # Record if we used alignment for this sample + sample_name = self.samples[i] + self._manifest_dict[sample_name]["use_rt_alignment"] = ( + use_spline_alignment + ) + + if use_spline_alignment: + # Set new retention times on all this object's + new_times = spl(self[i].scan_df["scan_time"]) + new_scan_info = self[i].scan_df.copy() + new_scan_info["scan_time_aligned"] = new_times + self[i].scan_df = new_scan_info + + + # Get the batch that this object belongs to + batch = self.manifest[self.samples[i]]["batch"] + + for j in range(len(self)): + if self.manifest[self.samples[j]]["batch"] == batch: + if j != i: + sample_name_j = self.samples[j] + self._manifest_dict[sample_name_j]["use_rt_alignment"] = ( + use_spline_alignment + ) + new_scan_info = self[j].scan_df.copy() + aligned_times = spl(self[j].scan_df["scan_time_aligned"]) + new_scan_info["scan_time_aligned"] = aligned_times + self[j].scan_df = new_scan_info + + # Set final mass_features_dataframe with the aligned scan_time + center_sample_name = self.samples[center_obj_ids[0]] + self._manifest_dict[center_sample_name]["use_rt_alignment"] = False + new_scan_info = self[center_obj_ids[0]].scan_df.copy() + new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"] + + def add_consensus_mass_features(self): + """ + Create consensus mass features by clustering aligned features across samples. + + This method clusters mass features from all samples in the collection based on + their m/z and aligned retention time proximity. Features that cluster together + across samples are assigned a common cluster ID, creating consensus features + that represent the same compound detected across multiple samples. + + The clustering process: + 1. Partitions features by m/z to avoid large sparse matrices and enable parallelization + 2. Clusters features within each partition using hierarchical clustering + 3. Merges partition-boundary clusters that represent the same feature + 4. Filters out clusters not present in minimum fraction of samples + + Must be run after align_lcms_objects(). Results are stored in the + mass_features_dataframe with a 'cluster' column added. + + Parameters + ---------- + None + Uses parameters from self.parameters.lcms_collection: + - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm) + - consensus_rt_tol: retention time tolerance for clustering (minutes) + - consensus_partition_size: target partition size for managing memory and parallelization + - consensus_min_sample_fraction: minimum fraction of samples a cluster + must appear in to be retained (0-1) + - cores: number of CPU cores to use for parallel partition processing + + Returns + ------- + None + Updates self.mass_features_dataframe in place by adding 'cluster' column + and filtering to retain only clusters meeting minimum sample presence. + + Raises + ------ + ValueError + If mass features have not been aligned (run align_lcms_objects() first). + + Notes + ----- + - Partitioning prevents memory issues with large sparse distance matrices + - Each partition is processed in parallel (up to cores limit) + - Clusters not meeting consensus_min_sample_fraction are automatically removed + - Access cluster_summary_dataframe property for summary statistics + - Use fill_missing_cluster_features() for gap-filling after clustering + + See Also + -------- + align_lcms_objects : Aligns retention times before consensus clustering + cluster_summary_dataframe : Property that generates summary statistics for clusters + fill_missing_cluster_features : Gap-fill missing features in clusters + """ + # Get the combined mass features from all LCMS objects, keep the original index as a separate column + combined_mfs = self.mass_features_dataframe.copy() + combined_mfs["coll_mf_id"] = combined_mfs.index + + # Check if the mass features have been aligned + if "scan_time_aligned" not in combined_mfs.columns: + raise ValueError( + "Mass features have not been aligned, run align_lcms_objects() first" + ) + + # Partition the mass features by mz so we can parallelize the matching before clustering + from corems.chroma_peak.calc import subset as corems_subset + + # get max mz from combined_mfs and calculate tolerance from ppm + mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 + n_partition_size = self.parameters.lcms_collection.consensus_partition_size + lazy_partitions = corems_subset.multi_sample_partition( + combined_mfs, + split_on="mz", + size=n_partition_size, + tol=mz_tol, + relative=True, + ) + + # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning + if np.array(lazy_partitions._counts).max() > 2 * n_partition_size: + warnings.warn( + "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol." + ) + + # Cluster the mass features within each partition + if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions: + cores_to_use = lazy_partitions.n_partitions + else: + cores_to_use = self.parameters.lcms_collection.cores + # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use) + mfs_with_clusters = lazy_partitions.map( + self.cluster_mass_features_agg_cluster, processes=cores_to_use + ) + + # Clean up cluster id names after partitioning + new_cluster_ids = ( + mfs_with_clusters[["cluster", "partition_idx"]] + .drop_duplicates() + .reset_index(drop=True) + ) + new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index + mfs_with_clusters = mfs_with_clusters.merge( + new_cluster_ids, on=["cluster", "partition_idx"] + ) + mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"] + mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"]) + + # Embed a new cluster id into the mass features dataframe and set as index + mfs_with_clusters["idx"] = mfs_with_clusters.index + + try: + # Check if any clusters can be merged into a single cluster + eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) + + # Merge clusters identified in eval_dict + while len(eval_dict["merge_these_clusters"]) > 0: + list_of_clusters_to_merge = [ + [x[0], x[1]] for x in eval_dict["merge_these_clusters"] + ] + # Convert to a dataframe with columns "new_cluster" and "cluster" + df = pd.DataFrame( + np.array(list_of_clusters_to_merge), columns=["new_cluster", "cluster"] + ) + # Drop duplicates of "child" clusters + df = df.drop_duplicates("cluster", keep="first") + df = df.drop_duplicates("new_cluster", keep="first") + mfs_with_clusters = mfs_with_clusters.merge(df, on="cluster", how="left") + mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna( + mfs_with_clusters["cluster"] + ) + mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"]) + + # Re-evaluate clusters for repeats + eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) + self.mass_features_dataframe = mfs_with_clusters + + except: + mfs_with_clusters.set_index('coll_mf_id', inplace = True) + self.mass_features_dataframe = mfs_with_clusters + + # Filter out clusters that don't meet minimum sample fraction + self._filter_clusters_by_sample_presence() + + # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template? + + def _filter_clusters_by_sample_presence(self): + """ + Filter out clusters that don't meet the minimum sample fraction threshold. + + Removes clusters (and their associated mass features) from the mass_features_dataframe + if they don't appear in at least consensus_min_sample_fraction of samples. + + This is called automatically at the end of add_consensus_mass_features(). + + Returns + ------- + None + Updates self.mass_features_dataframe in place by removing clusters that don't + meet the minimum sample presence threshold. + """ + if self.mass_features_dataframe is None or len(self.mass_features_dataframe) == 0: + return + + min_sample_fraction = self.parameters.lcms_collection.consensus_min_sample_fraction + + # Validate parameter + if not 0 <= min_sample_fraction <= 1: + raise ValueError("consensus_min_sample_fraction must be between 0 and 1") + + # Calculate minimum number of samples required + total_samples = len(self.samples) + min_samples_required = min_sample_fraction * total_samples + + # Count unique samples per cluster + cluster_sample_counts = ( + self.mass_features_dataframe.groupby('cluster')['sample_id'] + .nunique() + .reset_index(name='sample_count') + ) + + # Identify clusters to keep + clusters_to_keep = cluster_sample_counts[ + cluster_sample_counts['sample_count'] > min_samples_required + ]['cluster'].values + + # Filter mass features dataframe + self.mass_features_dataframe = self.mass_features_dataframe[ + self.mass_features_dataframe['cluster'].isin(clusters_to_keep) + ] + + def summarize_clusters(self): + """ + Generate summary statistics for consensus mass feature clusters. + + Computes aggregate statistics (median, mean, std, min, max) for each cluster + across all samples. Combines both regular mass features and induced mass features + (from gap-filling) when available to provide complete cluster statistics. + + Must be run after add_consensus_mass_features() which creates the cluster assignments. + Results are stored in cluster_summary_dataframe property and used by plotting methods. + + Parameters + ---------- + None + Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. + Both must contain 'cluster' column. + + Returns + ------- + :obj:`~pandas.DataFrame` or None + DataFrame with one row per cluster containing summary statistics: + - cluster: cluster ID + - mz_{median,mean,std,max,min}: m/z statistics + - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics + - half_height_width_{median,mean,std,max,min}: peak width statistics + - tailing_factor_{median,mean,std,max,min}: peak shape statistics + - dispersity_index_{median,mean,std,max,min}: peak quality statistics + - sample_id_nunique: number of unique samples containing the cluster + - intensity_{max,median,mean,std,min}: intensity statistics + - persistence_{max,median,mean,std,min}: persistence statistics + + Returns None if mass_features_dataframe is empty. + + Notes + ----- + - Summary DataFrame is automatically stored in cluster_summary_dataframe property + - Includes both regular and induced (gap-filled) mass features when available + - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster + - Sample count (sample_id_nunique) indicates cluster prevalence across samples + - Filters applied by consensus_min_sample_fraction affect which clusters appear + + See Also + -------- + add_consensus_mass_features : Creates clusters before summarization + fill_missing_cluster_features : Creates induced mass features via gap-filling + plot_consensus_mz_features : Visualizes cluster summaries + plot_mz_features_per_cluster : Shows cluster size distribution + """ + # First check if there are minimum columns in the features dataframe + if len(self.mass_features_dataframe.columns) < 1: + return None + + # Combine regular and induced mass features + mf_df = self.mass_features_dataframe.copy() + mf_df = mf_df.reset_index(drop=False) + + # Check if induced mass features are available and combine them + if self.induced_mass_features_dataframe is not None and len(self.induced_mass_features_dataframe) > 0: + imf_df = self.induced_mass_features_dataframe.copy() + imf_df = imf_df.reset_index(drop=False) + # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination + # Combine regular and induced features + mf_df = pd.concat([mf_df, imf_df], axis=0) + mf_df = mf_df.reset_index(drop=True) + + # Filter out any rows with NaN cluster values before converting to int + if 'cluster' in mf_df.columns: + mf_df = mf_df.dropna(subset=['cluster']) + mf_df['cluster'] = mf_df['cluster'].astype(int) + + # Build aggregation dictionary based on available columns + agg_dict = { + "mz": ["median", "mean", "std", "max", "min"], + "scan_time_aligned": ["median", "mean", "std", "max", "min"], + "sample_id": ["nunique"], + "intensity": ["max", "median", "mean", "std", "min"], + } + + # Add optional columns if they exist + optional_columns = { + "half_height_width": ["median", "mean", "std", "max", "min"], + "tailing_factor": ["median", "mean", "std", "max", "min"], + "dispersity_index": ["median", "mean", "std", "max", "min"], + "persistence": ["max", "median", "mean", "std", "min"], + } + + for col, funcs in optional_columns.items(): + if col in mf_df.columns: + agg_dict[col] = funcs + + summary_df = ( + mf_df.groupby("cluster") + .agg(agg_dict) + .reset_index() + ) + + # Fix the column names + summary_df.columns = [ + "_".join(col).strip() + for col in summary_df.columns.values + if col != "cluster" + ] + summary_df = summary_df.rename(columns={"cluster_": "cluster"}) + # Set cluster as the index for easy lookup + summary_df = summary_df.set_index('cluster') + return summary_df + + def plot_mz_features_per_cluster(self, return_fig = False): + """ + Plot the number of mass features in a cluster against how many clusters + contain that number of mass features + + Parameters + ----------- + return_fig : boolean + Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. + + Returns + -------- + matplotlib.pyplot.Figure + A figure displaying the frequency with which clusters contain the given number of m/z features + + Raises + ------ + Warning + If consensus features haven't been added to the object yet + """ + + if not hasattr(self, 'cluster_summary_dataframe'): + raise ValueError( + 'cluster_summary_dataframe is not set, must run add_consensus_mass_features() first' + ) + else: + sum_data = self.cluster_summary_dataframe + fig, ax = plt.subplots() + sum_data.sample_id_nunique.value_counts().sort_index().plot(ax = ax, kind = 'bar') + plt.xlabel('Number of mass features in a cluster') + plt.ylabel('Number of clusters with this many mass features') + if return_fig: + plt.close(fig) + return fig + else: + plt.show() + + def plot_mz_features_across_samples(self, alpha = 0.75, s = 0.005, return_fig = False): + """ + Generate Scan Time vs m/z plot of all the mass features across all + samples in collection where intensity of color on the plot indicates + density of mass features, NOT INTENSITY + + Parameters + ----------- + alpha : float + Desired transparency for plotted m/z features. Defaults to 0.75. + s : float + Desired size of plotted m/z features. Defaults to 0.005. + return_fig : boolean + Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. + + Returns + -------- + matplotlib.pyplot.Figure + A figure displaying a scan time vs m/z scatterplot of all the m/z features identified in the collection. + Parameters alpha (transparency) and s (marker size) allow the user to emphasize the density of features. + Intensity of features is not represented. + """ + df = self.mass_features_dataframe.copy() + fig = plt.figure() + plt.scatter( + df.scan_time_aligned, + df.mz, + c = 'tab:gray', + alpha = alpha, + s = s + ) + + plt.xlabel('Scan time') + plt.ylabel('m/z') + plt.ylim(0, np.ceil(np.max(df.mz))) + plt.xlim(0, np.ceil(np.max(df.scan_time))) + plt.title('All mass features, all samples') + + if return_fig: + plt.close(fig) + return fig + else: + plt.show() + + def plot_consensus_mz_features(self, xb = 'xb', xt = 'xt', yb = 'yb', yt = 'yt', show_all = True, return_fig = False): + """ + Generate Scan Time vs m/z plot of the consensus features scaled by size + with option ('show_all') of leaving the individual m/z features in the figure. + + Parameters + ----------- + xb : float + Desired starting scan time value for the x-axis. Defaults to 0. + xt : float + Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data. + yb : float + Desired starting m/z value for the y-axis. Defaults to 0. + yt : float + Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data. + show_all : boolean + Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True. + return_fig : boolean + Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. + + Returns + -------- + matplotlib.pyplot.Figure + A scalable figure that overlays the consensus features over all the m/z features identified in the collection. + Consensus features are scaled by how many m/z features are represented in the consensus. Figure can be scaled by + inputting desired boundaries on the scan time (xb, xt) and m/z values (yb, yt). + """ + df = self.cluster_summary_dataframe.copy() + mfdf = self.mass_features_dataframe.copy() + + fig = plt.figure() + if show_all: + plt.scatter( + mfdf.scan_time_aligned, + mfdf.mz, + c = 'tab:gray', + s = 1 + ) + + m = plt.scatter( + df.scan_time_aligned_median, + df.mz_median, + c = 'tab:orange', + alpha = 0.7, + s = (df.sample_id_nunique**2)/5 + ) + + plt.xlabel('Scan time') + plt.ylabel('m/z') + + if xt == 'xt': + xt = np.ceil(np.max(mfdf.mz)) + if yt == 'yt': + yt = np.ceil(np.max(mfdf.scan_time)) + if xb == 'xb': + xb = 0 + if yb == 'yb': + yb = 0 + plt.ylim(xb, xt) + plt.xlim(yb, yt) + + kw = dict( + prop = 'sizes', + num = max(1, int(len(df.sample_id_nunique.unique())/3)), + color = 'tab:orange', + alpha = 0.7, + func = lambda s: np.sqrt(s*5) + ) + + plt.legend( + *m.legend_elements(**kw), + title = 'Features\nper cluster', + bbox_to_anchor = (1.01, 0.4, 0.225, 0.5) + ) + plt.tight_layout() + plt.title('Consensus Features') + + if return_fig: + plt.close(fig) + return fig + else: + plt.show() + + def plot_cluster( + self, + cluster_id, + to_plot=["EIC", "MS1", "MS2"], + return_fig=False, + plot_smoothed_eic=False, + plot_eic_datapoints=False, + eic_buffer_time=None, + label_samples=False, + molecular_metadata=None, + spectral_library=None, + ): + """ + Plot a consensus mass feature cluster across all samples. + + Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, + highlighting the representative mass feature. + + Parameters + ---------- + cluster_id : int + The cluster ID to plot + to_plot : list, optional + List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". + Default is ["EIC", "MS1", "MS2"]. + return_fig : bool, optional + If True, returns the figure object. Default is False. + plot_smoothed_eic : bool, optional + If True, plots smoothed EICs. Default is False. + plot_eic_datapoints : bool, optional + If True, plots EIC data points. Default is False. + eic_buffer_time : float, optional + Time buffer around the peak for EIC plotting (minutes). + If None, uses parameter setting. Default is None. + label_samples : bool, optional + If True, labels each sample in the legend. Default is False. + molecular_metadata : dict, optional + Dictionary mapping molecular IDs to MetaboliteMetadata objects. + Required for MS2_mirror plots. Default is None. + spectral_library : FlashEntropySearch, optional + FlashEntropy spectral library containing MS2 spectra. + Required for MS2_mirror plots to retrieve library spectra. Default is None. + + Returns + ------- + matplotlib.figure.Figure or None + The figure object if return_fig=True, otherwise None + + Raises + ------ + ValueError + If cluster_id is not found or if required data is not loaded + """ + import matplotlib.pyplot as plt + + # Get cluster summary for median values + if cluster_id not in self.cluster_summary_dataframe.index: + raise ValueError( + f"Cluster {cluster_id} not found in cluster_summary_dataframe. " + f"Run add_consensus_mass_features() first." + ) + + cluster_summary = self.cluster_summary_dataframe.loc[cluster_id] + + # Get representative mass feature info + rep_info = self.get_most_representative_sample_for_cluster(cluster_id) + rep_sample_id = rep_info['sample_id'] + rep_mf_id = rep_info['mf_id'] + rep_sample = self[rep_sample_id] + + # Check if representative mass feature is loaded + if rep_mf_id not in rep_sample.mass_features: + raise ValueError( + f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. " + f"Run reload_representative_mass_features() or process_consensus_features() first." + ) + + rep_mf = rep_sample.mass_features[rep_mf_id] + + # Get eic buffer time + if eic_buffer_time is None: + eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time + + # Adjust to_plot based on available data + if rep_mf.mass_spectrum is None: + to_plot = [x for x in to_plot if x != "MS1"] + if len(rep_mf.ms2_mass_spectra) == 0: + to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]] + + # Check if EICs are available + cluster_mfs = self.mass_features_dataframe[ + self.mass_features_dataframe['cluster'] == cluster_id + ] + + has_eics = False + # Check regular features + for _, row in cluster_mfs.iterrows(): + sample_id = int(row['sample_id']) + sample = self[sample_id] + if hasattr(sample, 'eics') and sample.eics: + if len(sample.eics) > 0: + has_eics = True + break + + # Also check induced features if available + induced_cluster_mfs = None + if not has_eics and self.induced_mass_features_dataframe is not None: + induced_cluster_mfs = self.induced_mass_features_dataframe[ + self.induced_mass_features_dataframe['cluster'] == cluster_id + ] + for _, row in induced_cluster_mfs.iterrows(): + sample_id = int(row['sample_id']) + sample = self[sample_id] + if hasattr(sample, 'eics') and sample.eics: + if len(sample.eics) > 0: + has_eics = True + break + + if not has_eics: + to_plot = [x for x in to_plot if x != "EIC"] + if len(to_plot) == 0: + raise ValueError( + f"No plottable data available for cluster {cluster_id}. " + f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first." + ) + + # Get induced features if not already retrieved + if induced_cluster_mfs is None and self.induced_mass_features_dataframe is not None: + induced_cluster_mfs = self.induced_mass_features_dataframe[ + self.induced_mass_features_dataframe['cluster'] == cluster_id + ] + + # Check if MS1 is deconvoluted + deconvoluted = rep_mf._ms_deconvoluted_idx is not None + + # Create figure + fig, axs = plt.subplots( + len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False + ) + + fig.suptitle( + f"Consensus Cluster {cluster_id}: " + f"m/z = {cluster_summary['mz_median']:.4f} " + f"(±{cluster_summary['mz_std']:.4f}); " + f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min " + f"(±{cluster_summary['scan_time_aligned_std']:.2f}); " + f"{int(cluster_summary['sample_id_nunique'])} samples" + ) + + i = 0 + + # EIC plot - show all samples using helper method + if "EIC" in to_plot: + self._plot_multiple_eics( + axs[i][0], + cluster_mfs, + induced_cluster_mfs, + rep_sample_id, + rep_mf_id, + cluster_summary['scan_time_aligned_median'], + eic_buffer_time, + plot_smoothed=plot_smoothed_eic, + plot_datapoints=plot_eic_datapoints, + label_samples=label_samples, + lcms_collection=self + ) + i += 1 + + # MS1 plot - from representative using helper method + if "MS1" in to_plot: + rep_mf._plot_ms1_spectrum( + axs[i][0], + deconvoluted=deconvoluted, + sample_name=rep_sample.sample_name + ) + i += 1 + + # MS2 plot - from representative using helper method + if "MS2" in to_plot: + rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name) + i += 1 + + # MS2 mirror plot - from representative using helper method + if "MS2_mirror" in to_plot: + rep_mf._plot_ms2_mirror(axs[i][0], molecular_metadata=molecular_metadata, spectral_library=spectral_library) + i += 1 + + plt.tight_layout() + + if return_fig: + plt.close(fig) + return fig + else: + plt.show() + return None + + def get_representative_mass_features_for_all_clusters(self, representative_metric=None): + """ + Get the most representative mass feature for all clusters in bulk. + + This is much more efficient than calling get_most_representative_sample_for_cluster + in a loop, as it processes all clusters in a single pass over the dataframe. + + Parameters + ---------- + representative_metric : str, optional + The metric to use to determine the most representative sample. + If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. + Options: + - 'intensity': Selects the mass feature with the highest intensity + - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, + or the highest intensity overall if none have MS2 + Default is None (uses parameter setting). + + Returns + ------- + :obj:`~pandas.DataFrame` + DataFrame with one row per cluster containing: + - cluster: cluster ID + - sample_id: The sample ID of the most representative sample + - mf_id: The mass feature ID in the sample + - coll_mf_id: The collection-level mass feature ID (index) + - has_ms2: Whether this mass feature has MS2 scan numbers + - intensity: The intensity value of the representative mass feature + """ + # Use default from parameters if not specified + if representative_metric is None: + representative_metric = self.parameters.lcms_collection.consensus_representative_metric + + mf_df = self.mass_features_dataframe.copy() + # Reset index to make coll_mf_id a column we can work with + mf_df = mf_df.reset_index(drop=False) + + # Handle special metric 'intensity_prefer_ms2' + if representative_metric == 'intensity_prefer_ms2': + if 'intensity' not in mf_df.columns: + raise ValueError( + f"'intensity' column not found in mass_features_dataframe. " + f"Available columns: {mf_df.columns.tolist()}" + ) + + # Add has_ms2 flag if ms2_scan_numbers column exists + if 'ms2_scan_numbers' in mf_df.columns: + def has_ms2_scans(val): + if val is None: + return False + try: + return len(val) > 0 + except (TypeError, ValueError): + return False + + mf_df['has_ms2'] = mf_df['ms2_scan_numbers'].apply(has_ms2_scans) + + # Sort by has_ms2 (descending) then intensity (descending) + # This ensures features with MS2 are preferred when intensities are equal + mf_df = mf_df.sort_values(['has_ms2', 'intensity'], ascending=[False, False]) + else: + mf_df['has_ms2'] = False + mf_df = mf_df.sort_values('intensity', ascending=False) + + # Group by cluster and take the first (highest intensity, preferring MS2) + representatives = mf_df.groupby('cluster').first().reset_index() + + else: + # Standard metric - check if it exists + if representative_metric not in mf_df.columns: + raise ValueError( + f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}" + ) + + # Add has_ms2 flag for consistency + if 'ms2_scan_numbers' in mf_df.columns: + def has_ms2_scans(val): + if val is None: + return False + try: + return len(val) > 0 + except (TypeError, ValueError): + return False + mf_df['has_ms2'] = mf_df['ms2_scan_numbers'].apply(has_ms2_scans) + else: + mf_df['has_ms2'] = False + + # Get the index of max value for each cluster + idx = mf_df.groupby('cluster')[representative_metric].idxmax() + representatives = mf_df.loc[idx].copy() + + # Select only the columns we need + result_cols = ['cluster', 'sample_id', 'mf_id', 'coll_mf_id', 'has_ms2', 'intensity'] + representatives = representatives[result_cols] + + return representatives + + def get_sample_mf_map_for_representatives(self, representative_metric=None, include_cluster_id=True): + """ + Build a mapping of sample_id -> list of representative mass feature IDs to load. + + This is a DRY helper method used by both process_consensus_features() and + ReadSavedLCMSCollection to determine which mass features should be loaded + for each sample when loading representatives. + + Parameters + ---------- + representative_metric : str, optional + The metric to use to determine the most representative sample. + If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. + Default is None. + include_cluster_id : bool, optional + If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. + Default is True. + + Returns + ------- + dict + Dictionary mapping sample_id (int) to list of mass feature identifiers. + If include_cluster_id=True: list of tuples (mf_id, cluster_id) + If include_cluster_id=False: list of mf_id integers + + Examples + -------- + >>> # Get map with cluster IDs for loading + >>> sample_mf_map = collection.get_sample_mf_map_for_representatives() + >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...} + >>> + >>> # Get map without cluster IDs for pipeline + >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False) + >>> # sample_mf_map = {0: [123, 456], 1: [789], ...} + """ + # Get all representative mass features in bulk (much faster than looping) + representatives = self.get_representative_mass_features_for_all_clusters( + representative_metric=representative_metric + ) + + # Build sample_mf_map + sample_mf_map = {} + for _, row in representatives.iterrows(): + sample_id = row['sample_id'] + mf_id = row['mf_id'] + cluster_id = row['cluster'] + + if sample_id not in sample_mf_map: + sample_mf_map[sample_id] = [] + + if include_cluster_id: + sample_mf_map[sample_id].append((mf_id, cluster_id)) + else: + sample_mf_map[sample_id].append(mf_id) + + return sample_mf_map + + def get_most_representative_sample_for_cluster(self, cluster_id, representative_metric=None): + """ + Get the most representative sample for a given cluster based on a metric. + + Parameters + ---------- + cluster_id : int + The cluster ID to find the representative sample for. + representative_metric : str, optional + The metric to use to determine the most representative sample. + If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. + Options: + - 'intensity': Selects the mass feature with the highest intensity + - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, + or the highest intensity overall if none have MS2 + Default is None (uses parameter setting). + + Returns + ------- + dict + Dictionary containing: + - 'sample_id': The sample ID of the most representative sample + - 'sample_name': The sample name of the most representative sample + - 'mf_id': The mass feature ID in the sample + - 'coll_mf_id': The collection-level mass feature ID (index) + - 'has_ms2': Whether this mass feature has MS2 scan numbers + - 'intensity': The intensity value of the representative mass feature + + Raises + ------ + ValueError + If cluster_id is not found or if representative_metric is not a valid column. + """ + # Use the bulk method to get all representatives, then filter to this cluster + # This follows DRY principle and ensures consistency + all_representatives = self.get_representative_mass_features_for_all_clusters( + representative_metric=representative_metric + ) + + # Filter to the requested cluster + cluster_rep = all_representatives[all_representatives['cluster'] == cluster_id] + + if len(cluster_rep) == 0: + # Try to provide helpful error message + available_clusters = self.mass_features_dataframe['cluster'].unique() + raise ValueError( + f"Cluster {cluster_id} not found in mass_features_dataframe. " + f"Available clusters: {sorted(available_clusters[:10].tolist())}... " + f"(showing first 10 of {len(available_clusters)} total clusters)" + ) + + # Get the representative row (should only be one) + rep_row = cluster_rep.iloc[0] + + # Get sample name from sample_id (convert to int for list indexing) + sample_id = int(rep_row['sample_id']) + sample_name = self.samples[sample_id] + + return { + 'sample_id': sample_id, + 'sample_name': sample_name, + 'mf_id': rep_row['mf_id'], + 'coll_mf_id': rep_row['coll_mf_id'], + 'has_ms2': rep_row['has_ms2'], + 'intensity': rep_row['intensity'] + } + + def reload_representative_mass_features(self, add_ms2=False, auto_process_ms2=True, ms2_spectrum_mode=None, ms2_scan_filter=None): + """ + Reload mass features for all representative samples in the cluster summary. + + This method is useful when the collection was loaded with load_light=True, + which stores mass features only in the collection dataframe. This reloads + the specific mass features that are representatives for each cluster, + allowing them to be accessed as LCMSMassFeature objects. + + Parameters + ---------- + add_ms2 : bool, optional + If True, also loads and associates MS2 spectra with mass features. Default is False. + auto_process_ms2 : bool, optional + If True and add_ms2=True, auto-processes MS2 spectra. Default is True. + ms2_spectrum_mode : str or None, optional + Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. + ms2_scan_filter : str or None, optional + Filter string for MS2 scans (e.g., 'hcd'). Default is None. + + Returns + ------- + dict + Dictionary mapping sample_id to list of reloaded mf_ids. + + Raises + ------ + ValueError + If cluster_summary_dataframe is not set (run add_consensus_mass_features first). + + Notes + ----- + - Only reloads mass features that are cluster representatives + - Uses get_most_representative_sample_for_cluster() to determine which to reload + - More memory-efficient than reloading all mass features + - Parallelized based on lcms_collection.cores parameter + - MS2 association uses same logic as add_associated_ms2_dda() + + See Also + -------- + _reload_sample_mass_features : Low-level method to reload specific mass features + get_most_representative_sample_for_cluster : Gets representative sample for cluster + """ + # Validate prerequisites + if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: + raise ValueError( + "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." + ) + + # Get all representative mass features in bulk (much faster than looping) + representatives = self.get_representative_mass_features_for_all_clusters() + + # Build a dictionary of sample_id -> list of mf_ids that are representatives + sample_mf_map = {} + for _, row in representatives.iterrows(): + sample_id = row['sample_id'] + mf_id = row['mf_id'] + + if sample_id not in sample_mf_map: + sample_mf_map[sample_id] = [] + sample_mf_map[sample_id].append(mf_id) + + # Reload mass features for each sample (parallelized) + if self.parameters.lcms_collection.cores == 1: + # Serial processing + from tqdm import tqdm + for sample_id in tqdm(sample_mf_map.keys(), desc="Reloading representative mass features", unit="sample"): + mf_ids = sample_mf_map[sample_id] + self._reload_sample_mass_features(sample_id, mf_ids_to_load=mf_ids, add_ms2=add_ms2, + auto_process_ms2=auto_process_ms2, ms2_spectrum_mode=ms2_spectrum_mode, + ms2_scan_filter=ms2_scan_filter) + else: + # Parallel processing + import multiprocessing + from tqdm import tqdm + + if self.parameters.lcms_collection.cores > len(sample_mf_map): + ncores = len(sample_mf_map) + else: + ncores = self.parameters.lcms_collection.cores + + pool = multiprocessing.Pool(ncores) + + # Build arguments list for starmap + args_list = [ + (sample_id, sample_mf_map[sample_id], add_ms2, auto_process_ms2, + ms2_spectrum_mode, ms2_scan_filter, False) + for sample_id in sample_mf_map.keys() + ] + + # Execute in parallel + mp_result = pool.starmap(self._reload_sample_mass_features, args_list) + pool.close() + pool.join() + + # Collect results back into samples + for i, sample_id in enumerate(tqdm(sample_mf_map.keys(), desc="Collecting reloaded mass features", unit="sample")): + self[sample_id].mass_features = mp_result[i] + + return sample_mf_map + + def _associate_ms2_with_mass_features(self, sample, local_mf_ids, auto_process=True, + spectrum_mode=None, scan_filter=None): + """ + Associate MS2 spectra with specific mass features in a sample. + + Uses the LCMSBase helper method to find and load MS2 scans for the specified mass features. + + Parameters + ---------- + sample : LCMSBase + The sample object containing mass features and scan data. + local_mf_ids : list of int + List of local (sample-level) mass feature IDs to find MS2 for. + auto_process : bool, optional + If True, auto-processes the MS2 spectra. Default is True. + spectrum_mode : str or None, optional + Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. + scan_filter : str or None, optional + Filter string for MS2 scans (e.g., 'hcd'). Default is None. + + Returns + ------- + dict + Dictionary of scan_number -> MassSpectrum objects for the loaded MS2 spectra. + """ + # Check if we have scan data + if not hasattr(sample, 'scan_df') or sample.scan_df is None: + return {} + + # Separate mass features into those that need scan finding vs those that already have scans + mfs_needing_scan_finding = [] + unique_dda_scans = set() + + for mf_id in local_mf_ids: + if mf_id not in sample.mass_features: + continue + mf = sample.mass_features[mf_id] + # If this mass feature already has MS2 scans, add them to our set + if mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0: + # Convert to integers in case they come from HDF5 as numpy types + unique_dda_scans.update([int(scan) for scan in mf.ms2_scan_numbers]) + else: + # Otherwise, we need to find scans for this mass feature + mfs_needing_scan_finding.append(mf_id) + + # Only run the scan finding for mass features that need it + if mfs_needing_scan_finding: + found_scans = sample._find_ms2_scans_for_mass_features( + mf_ids=mfs_needing_scan_finding, + scan_filter=scan_filter + ) + unique_dda_scans.update(found_scans) + + if len(unique_dda_scans) == 0: + return {} + + # Get ms2 parameters from sample + #TODO KRH: deal with different ms2 scan types here (CID vs HCD), may need to add scan translator to the initializeion + ms_params = sample.parameters.mass_spectrum['ms2'] + + # Load MS2 spectra (convert set to list) + sample.add_mass_spectra( + scan_list=list(unique_dda_scans), + auto_process=auto_process, + spectrum_mode=spectrum_mode, + ms_level=2, + use_parser=True, + ms_params=ms_params, + ) + + # Associate MS2 spectra with mass features + for mf_id in local_mf_ids: + if mf_id not in sample.mass_features: + continue + if sample.mass_features[mf_id].ms2_scan_numbers is not None and len(sample.mass_features[mf_id].ms2_scan_numbers) > 0: + for dda_scan in sample.mass_features[mf_id].ms2_scan_numbers: + if dda_scan in sample._ms: + sample.mass_features[mf_id].ms2_mass_spectra[dda_scan] = sample._ms[dda_scan] + + # Return only the MS2 spectra we loaded (for parallel processing) + return {scan: sample._ms[scan] for scan in unique_dda_scans if scan in sample._ms} + + def _reload_sample_mass_features(self, sample_id, mf_ids_to_load=None, add_ms2=False, + auto_process_ms2=True, ms2_spectrum_mode=None, ms2_scan_filter=None, + inplace=True): + """ + Reload specific mass features for a sample from HDF5. + + This is useful when the collection was loaded with load_light=True, + which stores mass features only in the collection dataframe and not + as LCMSMassFeature objects in individual samples. + + Parameters + ---------- + sample_id : int + The sample ID to reload mass features for. + mf_ids_to_load : list of str, optional + List of collection-level mf_ids (format: '{sample_id}_{local_mf_id}') to load. + If None, loads all mass features for the sample. + add_ms2 : bool, optional + If True, also loads and associates MS2 spectra. Default is False. + auto_process_ms2 : bool, optional + If True, auto-processes MS2 spectra. Default is True. + ms2_spectrum_mode : str or None, optional + Spectrum mode for MS2 spectra. Default is None. + ms2_scan_filter : str or None, optional + Filter string for MS2 scans. Default is None. + inplace : bool, optional + If True, updates the sample's mass_features in place. If False, returns the + mass_features dictionary (for multiprocessing). Default is True. + + Returns + ------- + dict or None + If inplace=False, returns dictionary of mass features. + Otherwise returns None and updates object in place. + """ + sample = self[sample_id] + sample_name = self.samples[sample_id] + + # Check if we have a collection parser that can reload + if not hasattr(self, 'collection_parser') or self.collection_parser is None: + print("Warning: Cannot reload mass features - no collection_parser available") + if not inplace: + return {} + return + + # Get the HDF5 file for this sample + hdf5_file = self.collection_parser.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" + + if not hdf5_file.exists(): + print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") + if not inplace: + return {} + return + + # Import here to avoid circular imports + from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra + + # If specific mf_ids requested, use them directly + local_mf_ids_to_load = None + if mf_ids_to_load is not None: + # mf_ids_to_load is already a list of sample-level mf_ids (integers) + # No parsing needed - they come from the mf_id column in the dataframe + local_mf_ids_to_load = set(mf_ids_to_load) + + # Reload mass features from HDF5 + with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: + # Load mass features - if specific IDs requested, only load those + parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) + + # If add_ms2, associate MS2 spectra with the loaded mass features + if add_ms2 and local_mf_ids_to_load is not None: + self._associate_ms2_with_mass_features( + sample, + list(local_mf_ids_to_load), + auto_process=auto_process_ms2, + spectrum_mode=ms2_spectrum_mode, + scan_filter=ms2_scan_filter + ) + + # Return mass features if not inplace (for multiprocessing) + if not inplace: + return sample.mass_features + + def add_sparse_distance_matrix(self, features): + if features is None: + return None + else: + features = features.copy() + + # Parameters for calculating distance between features + dims = ["mz", "scan_time_aligned"] + relative = [True, False] + mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 + tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] + dist_weight = [1, 1] + + # Check that the dimensions and tolerances are the same length + if ( + len(dims) != len(tol) + or len(dims) != len(relative) + or len(dims) != len(dist_weight) + ): + raise ValueError( + "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length" + ) + + # Make connectivity matrix for masking within sample mass features + ## Masking matrix cmat will mark all features from the same sample as 0 + ## To mask, a matrix can be multiplied by cmat and features from same + ## samples are multiplied by 0, while features from different samples + ## are multiplied by 1 + + if "sample_id" not in features.columns: + cmat = None + else: + vals = features["sample_id"].values.reshape(-1, 1) + cmat = scipy.spatial.distance.cdist(vals, vals) + # Convert to binary (0 if same sample, 1 if different) + cmat = np.where(cmat == 0, 0, 1) + # Convert to coordinate matrix for sparse operations later + cmat = sparse.coo_matrix(cmat) + + # Compute inter-feature distances using sparse matrix approach + distances = None # clear the distances object before starting + for i in range(len(dims)): # iterate through all dimensions to be considered + # Construct k-d tree + values = features[dims[i]].values + + tree = KDTree(values.reshape(-1, 1)) + + max_tol = tol[i] + if relative[i] is True: + # Maximum absolute tolerance + max_tol = tol[i] * values.max() + + # Compute sparse distance matrix + # the larger the max_tol, the slower this operation is + sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") + + # Only consider forward case, exclude diagonal + sdm = sparse.triu(sdm, k=1) + + # Filter relative distances + if relative[i] is True: + # Compute relative distances + rel_dists = sdm.data / values[sdm.row] + + # Indices of relative distances less than tolerance + idx = rel_dists <= tol[i] + + # Reconstruct sparse distance matrix + sdm = sparse.coo_matrix( + (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), + shape=(len(values), len(values)), + ) + + # Scaled distances wrt the maximum tolerance for the dimension + sdm.data = sdm.data / tol[i] + + # Stack distances for dimensions where na_allow is False + if distances is None: + sdm.data = sdm.data * dist_weight[i] + # Replace zeros with epsilon to handle perfect matches + sdm.data[sdm.data == 0] = 1e-10 + distances = sdm + else: + # Prepare sdm to match shape of existing distances + distances_truth = distances.copy() + # make new sparse matrix with same positions as previous + # distance matrix but all ones for values + distances_truth.data = np.ones_like(distances_truth.data) + + # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them + sdm.data[sdm.data == 0] = 1e-10 + + # multiply the new sparse matrix (sdm) by this mask to remove + # data that doesn't exist in original sparse matrix + sdm = distances_truth.multiply(sdm) + + sdm.data = sdm.data * dist_weight[i] + # Replace zeros with epsilon to handle perfect matches + sdm.data[sdm.data == 0] = 1e-10 + + # use same process as before to remove data from previous + # distances matrix that isn't in new distances matrix + sdm_truth = sdm.copy() + sdm_truth.data = np.ones_like(sdm_truth.data) + + # remove the distances that are not sdm + distances = distances.multiply(sdm_truth) + + # Sum the new distances + distances = distances + sdm + + # Multiply by connectivity matrix for more masking + distances = distances.multiply(cmat) + + # Set attribute holding distance matrix + self._sparse_distance_matrix = distances + + def evaluate_clusters_for_repeats(self, features): + raise NotImplementedError('evaluate_clusters_for_repeats not implemented yet') + summary_df = self.cluster_summary_dataframe.copy() + + # Arrange by decreasing median intensity + summary_df = summary_df.sort_values( + by="intensity_median", ascending=False + ).reset_index(drop=True) + + # Find clusters that are within the mz_tol and rt_tol of each other (on the medians) + # Create a distance matrix + # Define how to calculate the distance between features + dims = ["mz_median", "scan_time_aligned_median"] + relative = [True, False] + mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 + tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] + + # Compute inter-feature distances + distances = None + for i in range(len(dims)): + # Construct k-d tree + values = summary_df[dims[i]].values + tree = KDTree(values.reshape(-1, 1)) + + max_tol = tol[i] + if relative[i] is True: + # Maximum absolute tolerance + max_tol = tol[i] * values.max() + + # Compute sparse distance matrix + # the larger the max_tol, the slower this operation is + sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") + + # Only consider forward case, exclude diagonal + sdm = sparse.triu(sdm, k=1) + + # Filter relative distances + if relative[i] is True: + # Compute relative distances + rel_dists = sdm.data / values[sdm.row] # or col? + + # Indices of relative distances less than tolerance + idx = rel_dists <= tol[i] + + # Reconstruct sparse distance matrix + sdm = sparse.coo_matrix( + (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), + shape=(len(values), len(values)), + ) + + # Cast as binary matrix + sdm.data = np.ones_like(sdm.data) + + # Stack distances + if distances is None: + distances = sdm + else: + distances = distances.multiply(sdm) + + # Roll up features + # Extract indices of within-tolerance points + distances = distances.tocoo() + pairs = np.stack( + (distances.row, distances.col), axis=1 + ) # These are the index values of the clusters, not the cluster ids + # Conver to cluster ids + pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]) + pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values + pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values + pairs_df = pairs_df.set_index("parent") + + merge_these_clusters = [] + possible_overlaps = [] + root_parents = np.setdiff1d( + np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) + ) + for parent in root_parents: + parent_features = features[features["cluster"] == parent] + children = pairs_df.loc[[parent], "child"].tolist() + for child in children: + overlap = self.check_merge(parent_features, child, features) + if len(overlap) == 0: + merge_these_clusters.append((parent, child, len(overlap))) + else: + possible_overlaps.append((parent, child, len(overlap))) + + result_dict = {} + result_dict["merge_these_clusters"] = merge_these_clusters + result_dict["possible_overlaps"] = possible_overlaps + + return result_dict + + def check_merge(self, parent_features, child, features): + # Grab the features of the parent and children + child_features = features[features["cluster"] == child] + + # Check if there is an overlap between mf_coll_id in the parent and child clusters + overlap = np.intersect1d( + parent_features["sample_id"].values, child_features["sample_id"].values + ) + + return overlap + + def cluster_mass_features_agg_cluster(self, features): + if features is None: + return None + + features = features.copy() + + self.add_sparse_distance_matrix(features) + + distances = self._sparse_distance_matrix + + # Convert to full matrix + distances = distances.todense() + + # Cast all 0s to 1s for a distance matrix + distances[distances == 0] = 1 + distances = np.asarray(distances) + + # Perform clustering + try: + clustering = AgglomerativeClustering( + n_clusters=None, + linkage="complete", + # using complete linkage will prevent one sample from being assigned to multiple clusters + metric="precomputed", + distance_threshold=1, + ).fit(distances) + features["cluster"] = clustering.labels_ + + # All data points are singleton clusters + except: + features["cluster"] = np.arange(len(features.index)) + + return features + + def cluster_inspection_plot(self, clu, return_fig = False): + """ + Generate Scan Time vs m/z plot for a narrow range around a given + cluster. This tool is meant to support the user in fine tuning the + tolerances used for the clustering algorithm. The user-provided cluster + ID is highlighted in larger, magenta marker and the ten largest of the + remaining clusters are idenfitied with different colors while the + smallest clusters are light gray. + + Parameters + ----------- + clu : integer + A cluster ID that exists in self.mass_features_dataframe + return_fig : boolean + Indicates whether to plot cluster inspection figure (False) or + return figure object (True). Defaults to False. + + Returns + -------- + matplotlib.pyplot.Figure + A figure displaying a scan time vs m/z scatterplot of small region + around a given cluster with the ten largest clusters in the region + distinctly identified + + Raises + ------ + Warning + If cluster data haven't been added to the object yet + """ + + if 'cluster' not in self.mass_features_dataframe.columns: + raise ValueError( + 'Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first' + ) + + else: + mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 + rttol = self.parameters.lcms_collection.consensus_rt_tol + clu_features = self.mass_features_dataframe.copy() + + inclu = clu_features[clu_features.cluster == clu] + exclu = clu_features[clu_features.cluster != clu] + + dt_ymin = np.floor(min(inclu.mz)) - 1 + dt_ymax = np.ceil(max(inclu.mz)) + 1 + dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1 + dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1 + + exclu = exclu[ + ( + exclu.mz.between(dt_ymin, dt_ymax, inclusive = 'both') + ) & ( + exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive = 'both') + ) + ] + + bigclulist = list(exclu.cluster.value_counts()[:10].index) + bigclu = exclu[exclu.cluster.isin(bigclulist)] + smclu = exclu[~exclu.cluster.isin(bigclulist)] + + colors = np.arange(0, 10) + colordict = dict(zip(bigclulist, colors)) + bigclu['color'] = bigclu.cluster.apply(lambda x: colordict[x]) + + fig = plt.figure(figsize = (7.5, 5)) + + plt.scatter( + inclu.scan_time_aligned, + inclu.mz, + c = 'm', + s = 3, + label = 'Cluster ' + str(clu) + ) + + plt.scatter( + bigclu.scan_time_aligned, + bigclu.mz, + c = bigclu.color, + cmap = 'tab10', + s = 1.5 + ) + + plt.scatter( + smclu.scan_time_aligned, + smclu.mz, + c = 'silver', + s = 2, + label = 'Small clusters' + ) + + plt.ylim(dt_ymin, dt_ymax) + plt.xlim(dt_xmin, dt_xmax) + plt.legend(ncol = 2, bbox_to_anchor = (0.8, -0.1)) + plt.xlabel('Scan time') + plt.ylabel('m/z') + title_str = 'Cluster ' + str(clu) + title_str += ': representing ' + str(len(inclu.sample_id.unique())) + title_str += ' of ' + str(len(clu_features.sample_id.unique())) + title_str += ' samples\n' + title_str += 'M/Z tolerance: ' + str(mztol) + '\n' + title_str += 'Scan Time tolerance: ' + str(rttol) + plt.title(title_str, fontsize = 10) + + if return_fig: + plt.close(fig) + return fig + else: + plt.show() + + def plot_cluster_outlier_frequency(self, dim_list = ['mz', 'scan_time_aligned'], clu_size_thresh = 0.5, return_fig = False): + """ + Generate histogram showing the frequency of outlier occurrences by + clustering dimension across all clusters + + Parameters + ----------- + dim_list : list + List of strings describing dimensions that can be used in + clustering. Available list items: + - 'mz' + - 'scan_time_aligned' + - 'half_height_width' + - 'tailing_factor' + - 'dispersity_index' + - 'intensity' + - 'persistence' + clu_size_thresh : float + Value between 0 and 1 that indicates what percentage of samples + need to be present in a cluster before it's evaluated for outliers. + Defaults to 0.5. + return_fig : boolean + Indicates whether to plot cluster inspection figure (False) or + return figure object (True). Defaults to False. + + Returns + -------- + matplotlib.pyplot.Figure + A figure displaying the frequency of outlier occurrences across all + clusters in the provided measurement dimensions + + Raises + ------ + Warning + If cluster data haven't been added to the object yet + """ + + if not hasattr(self, 'cluster_summary_dataframe'): + raise ValueError( + 'cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first' + ) + + mfdf = self.mass_features_dataframe.copy() + summarydf = self.cluster_summary_dataframe + + numsamples = len(self) + sumdf = summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh].reset_index(drop = True).copy() + + ## find the ranges for non-outlier values and add them to sumdf + mergelist = ['cluster'] + for dim in dim_list: + maxtag = dim + '_outmax' + mintag = dim + '_outmin' + mergelist.append(maxtag) + mergelist.append(mintag) + # Calculate outlier thresholds using vectorized operations + sumdf[mintag] = sumdf[dim + '_mean'] - 3*sumdf[dim + '_std'] + sumdf[maxtag] = sumdf[dim + '_mean'] + 3*sumdf[dim + '_std'] + ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's + ## not flagged. This happens when there's not enough values to compute median/std for that + ## dimension therefore can't have outliers + + ## add ranges to mfdf and identify mass features that fall outside the ranges + # Merge without dropping NaN - we'll handle it per-dimension + outdf = pd.merge(mfdf, sumdf[mergelist], on = 'cluster') + + outtags = ['cluster'] + for dim in dim_list: + dimtag = dim + '_outlier' + outtags.append(dimtag) + maxtag = dim + '_outmax' + mintag = dim + '_outmin' + # Only flag as outlier if thresholds are valid (not NaN) + outdf[dimtag] = np.where( + (outdf[maxtag].notna() & outdf[mintag].notna()) & + (((outdf[dim] > outdf[maxtag])) | ((outdf[dim] < outdf[mintag]))), + True, + False + ) + + ## identify number of outliers in each cluster + outliers = outdf[outtags] + outliers = outliers.groupby(['cluster']).sum() + + ## plot number of clusters that contain any outliers + fig = plt.figure() + plt.bar(dim_list, outliers.sum().values, width = 0.5) + plt.xticks(rotation = 90) + plt.title('Frequency of outliers across all clusters by category') + + if return_fig: + plt.close(fig) + return fig + else: + plt.show() + + def _search_for_targeted_mass_features_in_sample(self, obj_idx, missingdf, cluster_dict, expand_on_miss=False, inplace=True): + """ + Helper method to search for missing mass features in a single sample. + + Internal method called by fill_missing_cluster_features() to perform + gap-filling for one sample in the collection. + + Parameters + ---------- + obj_idx : int + Index of the sample being processed + missingdf : pd.DataFrame + DataFrame containing cluster information with columns: + 'cluster', 'sample_id_nunique', 'mz_min', 'mz_max', + 'scan_time_aligned_min', 'scan_time_aligned_max', 'mz_min_allowed', + 'mz_max_allowed', 'scan_time_aligned_min_allowed', + 'scan_time_aligned_max_allowed', 'missing_samples' + cluster_dict : dict + Pre-computed cluster feature dictionary to avoid recomputation + expand_on_miss : bool + If True, expands search window when no peak found initially + inplace : bool + If True, assigns induced_mass_features in place. If False, returns the + induced features dictionary (for multiprocessing) + + Returns + ------- + dict or None + If inplace=False, returns dictionary of induced mass features. + Otherwise returns None and updates object in place. + """ + ## Use the pre-computed cluster dictionary passed as parameter + + ## to get clusters missing data based on sample index: + sampledf = missingdf[ + missingdf.missing_samples.apply(lambda x: obj_idx in x) + ].reset_index(drop = True).copy() + + # Skip if no missing features for this sample + if len(sampledf) == 0: + if not inplace: + return {} + return + + self.load_raw_data(obj_idx, 1) + + ## this is the line that bugs due to _ms_unprocessed not having key 1 + ms1df = self[obj_idx]._ms_unprocessed[1].copy() + scan_df = self[obj_idx].scan_df[['scan', 'scan_time_aligned']] + ms1df = pd.merge(ms1df, scan_df, on = 'scan') + + # Pre-extract all values from sampledf to avoid repeated .iloc calls + clusters = sampledf.cluster.values + mz_mins = sampledf.mz_min.values + mz_maxs = sampledf.mz_max.values + st_mins = sampledf.scan_time_aligned_min.values + st_maxs = sampledf.scan_time_aligned_max.values + + if expand_on_miss: + mz_mins_allowed = sampledf.mz_min_allowed.values + mz_maxs_allowed = sampledf.mz_max_allowed.values + st_mins_allowed = sampledf.sta_min_allowed.values + st_maxs_allowed = sampledf.sta_max_allowed.values + + # Pre-filter ms1df to reduce search space + mz_global_min = mz_mins.min() + mz_global_max = mz_maxs.max() + st_global_min = st_mins.min() + st_global_max = st_maxs.max() + + if expand_on_miss: + mz_global_min = min(mz_global_min, mz_mins_allowed.min()) + mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) + st_global_min = min(st_global_min, st_mins_allowed.min()) + st_global_max = max(st_global_max, st_maxs_allowed.max()) + + ms1df_filtered = ms1df[ + (ms1df.mz >= mz_global_min) & + (ms1df.mz <= mz_global_max) & + (ms1df.scan_time_aligned >= st_global_min) & + (ms1df.scan_time_aligned <= st_global_max) + ].copy() + + # Generate set_ids for all features + set_ids = [f'c{clusters[i]}_{i}_i' for i in range(len(sampledf))] + + # Use batch method to process all features at once + if expand_on_miss: + # First try with normal bounds + peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( + ms1df_filtered, + mz_mins, + mz_maxs, + st_mins, + st_maxs, + set_ids, + obj_idx=obj_idx, + st_aligned=True + ) + + # Retry failed features with expanded bounds + failed_indices = [i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99] + if failed_indices: + failed_ids = [set_ids[i] for i in failed_indices] + retry_peaks = self[obj_idx].search_for_targeted_mass_features_batch( + ms1df_filtered, + mz_mins_allowed[failed_indices], + mz_maxs_allowed[failed_indices], + st_mins_allowed[failed_indices], + st_maxs_allowed[failed_indices], + failed_ids, + obj_idx=obj_idx, + st_aligned=True + ) + peaks_dict.update(retry_peaks) + else: + peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( + ms1df_filtered, + mz_mins, + mz_maxs, + st_mins, + st_maxs, + set_ids, + obj_idx=obj_idx, + st_aligned=True + ) + + # Assign peaks to induced_mass_features and cluster_dict + for i in range(len(sampledf)): + peak = peaks_dict[set_ids[i]] + self[obj_idx].induced_mass_features[peak.id] = peak + cluster_dict[clusters[i]] += [set_ids[i]] + + # TODO KRH: Let's try to avoid these steps unless asked for by parameters to pick up speed + if False: + self[obj_idx].add_associated_ms1(induced_features = True) + # need to set drop_if_fail to false for induced features as they will fail + self[obj_idx].add_peak_metrics(induced_features = True) + + self[obj_idx].integrate_mass_features(drop_if_fail = False, induced_features = True) + + if not inplace: + return self[obj_idx].induced_mass_features + + def fill_missing_cluster_features(self): + """ + Gap-filling for consensus mass features across collection samples. + + For clusters present in multiple samples but missing from others, searches + raw MS1 data to find peaks in expected m/z and retention time windows. This + creates "induced" mass features for peaks that exist in the data but weren't + detected in the initial peak detection. + + Must be run after add_consensus_mass_features(). Results are accessible via + induced_mass_features_dataframe property and included in collection_pivot_table + and collection_consensus_report outputs. + + Parameters + ---------- + None + Uses parameters from self.parameters.lcms_collection: + - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain + a cluster before gap-filling is attempted + - gap_fill_expand_on_miss: If True, expands search window when no peak is found + + Returns + ------- + None + Updates induced_mass_features attribute for each LCMSBase object and + combines them into induced_mass_features_dataframe. + + Raises + ------ + ValueError + If cluster_summary_dataframe is not set (must run add_consensus_mass_features first). + + Notes + ----- + - Loads raw MS1 data for each sample, which may be memory intensive + - Induced features are integrated and metrics calculated automatically + - Processing can be parallelized using parameters.lcms_collection.cores + + See Also + -------- + add_consensus_mass_features : Creates consensus features before gap-filling + collection_pivot_table : Includes both regular and induced features + collection_consensus_report : Reports on complete feature matrix + """ + + # Validate prerequisites + if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: + raise ValueError( + "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." + ) + + # Get parameters from settings + min_cluster_presence = self.parameters.lcms_collection.consensus_min_sample_fraction + expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss + + # Validate parameters + if not 0 <= min_cluster_presence <= 1: + raise ValueError("consensus_min_sample_fraction must be between 0 and 1") + + summarydf = self.cluster_summary_dataframe + mfdf = self.mass_features_dataframe + + sample_ct = len(self.samples) + + # Identify clusters present in sufficient samples but not all samples + missingdf = summarydf[[ + 'cluster', + 'sample_id_nunique', + 'mz_min', + 'mz_max', + 'scan_time_aligned_min', + 'scan_time_aligned_max' + ]] + missingdf = missingdf[missingdf.sample_id_nunique > min_cluster_presence * sample_ct] + missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] + + # Check if there are any clusters to gap-fill + if len(missingdf) == 0: + return + + # Find which samples are missing for each cluster + # Use range(sample_ct) to include all samples, even those with no mass features + all_sample_ids = list(range(sample_ct)) + missing_samples_list = [] + for c in missingdf.cluster.to_numpy(): + cludf = mfdf[mfdf.cluster == c] + missing = [x for x in all_sample_ids if x not in cludf.sample_id.unique()] + missing_samples_list.append(missing) + missingdf['missing_samples'] = missing_samples_list + + # Calculate expanded search windows for expand_on_miss option + mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 + rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol + missingdf['mz_max_allowed'] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max + missingdf['mz_min_allowed'] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min + missingdf['sta_max_allowed'] = missingdf.scan_time_aligned_max + rt_clu_tol * missingdf.scan_time_aligned_max + missingdf['sta_min_allowed'] = missingdf.scan_time_aligned_min - rt_clu_tol * missingdf.scan_time_aligned_min + + # Compute cluster dictionary once to avoid recomputing for each sample + cluster_dict = self.cluster_feature_dictionary + + # Process each sample to search for missing features + if self.parameters.lcms_collection.cores == 1: + for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"): + self._search_for_targeted_mass_features_in_sample(i, missingdf, cluster_dict, expand_on_miss) + + if self.parameters.lcms_collection.cores > 1: + if self.parameters.lcms_collection.cores > len(self): + ncores = len(self) + else: + ncores = self.parameters.lcms_collection.cores + pool = multiprocessing.Pool(ncores) + mp_result = pool.starmap( + self._search_for_targeted_mass_features_in_sample, + [(x, missingdf, cluster_dict, expand_on_miss, False) for x in range(sample_ct)] + ) + + for i in tqdm(range(sample_ct), desc="Collecting gap-filled features", unit="sample"): + self[i].induced_mass_features = mp_result[i] + + self._combine_mass_features(induced_features = True) + + # Mark that gap-filling has been performed + self.missing_mass_features_searched = True + + for sample_name in self.samples: + self._lcms[sample_name].mass_features = {} + + def process_samples_pipeline(self, operations, description=None, keep_raw_data=False, show_progress=True): + """ + Execute a pipeline of operations on all samples in parallel. + + This method provides a flexible framework for performing multiple + sample-level operations in a single parallelized pass, which is more + efficient than calling separate methods sequentially. + + Parameters + ---------- + operations : list of SampleOperation + List of operations to perform on each sample, in order. + Each operation should be an instance of a class derived from + SampleOperation (see lc_calc_operations module). + description : str or None, optional + Progress bar description. If None, automatically generates description + from operation descriptions (e.g., "gap-filling, reloading features"). + Default is None. + keep_raw_data : bool, optional + If True, keeps raw MS data loaded in memory after pipeline completes. + If False, cleans up raw data to free memory. Default is False. + show_progress : bool, optional + If True, displays progress bars during processing. If False, runs silently. + Default is True. + + Returns + ------- + dict + Dictionary with results from pipeline execution, keyed by operation name. + Structure: {operation_name: {sample_id: result, ...}, ...} + + Raises + ------ + ValueError + If operations list is empty or contains invalid operations. + + Notes + ----- + - Operations are executed sequentially within each sample + - Samples are processed in parallel based on parameters.lcms_collection.cores + - Each operation can have conditional execution via can_execute() + - Results are collected back via collect_results() method of each operation + - Failed operations for a sample are logged but don't halt processing + - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True + + Examples + -------- + >>> from corems.mass_spectra.calc.lc_calc_operations import ( + ... GapFillOperation, ReloadFeaturesOperation + ... ) + >>> ops = [ + ... GapFillOperation('gap_fill', expand_on_miss=True), + ... ReloadFeaturesOperation('reload', add_ms2=True) + ... ] + >>> results = lcms_collection.process_samples_pipeline(ops) + + See Also + -------- + lc_calc_operations : Module containing built-in operation classes + fill_and_process_features : Convenience method combining common operations + """ + from corems.mass_spectra.calc.lc_calc_operations import SampleOperation + + # Validate operations + if not operations or len(operations) == 0: + raise ValueError("operations list cannot be empty") + + for op in operations: + if not isinstance(op, SampleOperation): + raise ValueError(f"All operations must be SampleOperation instances, got {type(op)}") + + # Generate description from operations if not provided + if description is None: + operation_descriptions = [op.description for op in operations] + description = ", ".join(operation_descriptions).capitalize() + + # Prepare runtime parameters for each operation + # This is where we gather collection-level data that operations need + runtime_params = self._prepare_pipeline_runtime_params(operations) + runtime_params['keep_raw_data'] = keep_raw_data + + # Execute pipeline + sample_ct = len(self.samples) + + if self.parameters.lcms_collection.cores == 1: + # Serial processing + results_by_operation = {op.name: {} for op in operations} + + if show_progress: + from tqdm import tqdm + # Print description on its own line before progress bar + print(f"\n{description.capitalize()}:") + iterator = tqdm(range(sample_ct), unit="sample", ncols=80) + else: + iterator = range(sample_ct) + + for sample_id in iterator: + sample_results = self._execute_sample_pipeline( + sample_id, operations, runtime_params, inplace=True + ) + # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True) + # Skip 'sample_id' key which is added for tracking + for op_name, result in sample_results.items(): + if op_name != 'sample_id': + results_by_operation[op_name][sample_id] = result + else: + # Parallel processing + import multiprocessing + + if self.parameters.lcms_collection.cores > sample_ct: + ncores = sample_ct + else: + ncores = self.parameters.lcms_collection.cores + + pool = multiprocessing.Pool(ncores) + + # Build arguments for each sample + args_list = [ + (sample_id, operations, runtime_params, False) + for sample_id in range(sample_ct) + ] + + # Execute in parallel with progress tracking + results_by_operation = {op.name: {} for op in operations} + + if show_progress: + from tqdm import tqdm + import time + + # Use starmap_async for parallel execution with progress tracking + async_result = pool.starmap_async(self._execute_sample_pipeline, args_list) + + # Poll for completion and update progress bar + print(description) + pbar = tqdm( + total=sample_ct, + desc="", + unit="sample", + position=0, + leave=True, + dynamic_ncols=True + ) + prev_completed = 0 + while not async_result.ready(): + # Get number of completed tasks by checking remaining + completed = sample_ct - async_result._number_left + if completed > prev_completed: + pbar.update(completed - prev_completed) + prev_completed = completed + time.sleep(0.5) # Poll every 500ms to avoid spam + + # Final update to 100% + if prev_completed < sample_ct: + pbar.update(sample_ct - prev_completed) + pbar.close() + + # Get all results + mp_results = async_result.get() + else: + # Execute without progress + mp_results = pool.starmap(self._execute_sample_pipeline, args_list) + + pool.close() + pool.join() + + # Collect results back into collection + for result in mp_results: + sample_id = result.get('sample_id') + for op in operations: + op_result = result.get(op.name) + if op_result is not None: + op.collect_results(sample_id, op_result, self) + results_by_operation[op.name][sample_id] = op_result + + return results_by_operation + + def _prepare_pipeline_runtime_params(self, operations): + """ + Prepare runtime parameters needed by operations in the pipeline. + + This method gathers collection-level data that operations need, + such as cluster information for gap-filling or mf_ids for reloading. + + Parameters + ---------- + operations : list of SampleOperation + List of operations that will be executed + + Returns + ------- + dict + Dictionary of runtime parameters for operations + """ + from corems.mass_spectra.calc.lc_calc_operations import ( + GapFillOperation, ReloadFeaturesOperation, MS2SpectralSearchOperation, + LoadEICsOperation + ) + + runtime_params = {} + + # Check if any operation needs gap-fill parameters + needs_gap_fill = any(isinstance(op, GapFillOperation) for op in operations) + if needs_gap_fill: + # Prepare gap-fill parameters (same as fill_missing_cluster_features) + min_cluster_presence = self.parameters.lcms_collection.consensus_min_sample_fraction + expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss + + summarydf = self.cluster_summary_dataframe + mfdf = self.mass_features_dataframe + sample_ct = len(self.samples) + + # Identify clusters needing gap-filling + # Note: cluster_summary_dataframe has 'cluster' as index, need to reset it + missingdf = summarydf.reset_index()[[ + 'cluster', + 'sample_id_nunique', + 'mz_min', + 'mz_max', + 'scan_time_aligned_min', + 'scan_time_aligned_max' + ]].copy() + missingdf = missingdf[missingdf.sample_id_nunique > min_cluster_presence * sample_ct] + missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] + + if len(missingdf) > 0: + # Find which samples are missing for each cluster + # Use range(sample_ct) to include all samples, even those with no mass features + all_sample_ids = list(range(sample_ct)) + missing_samples_list = [] + for c in missingdf.cluster.to_numpy(): + cludf = mfdf[mfdf.cluster == c] + missing = [x for x in all_sample_ids if x not in cludf.sample_id.unique()] + missing_samples_list.append(missing) + missingdf['missing_samples'] = missing_samples_list + + # Calculate expanded search windows + mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 + rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol + missingdf['mz_max_allowed'] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max + missingdf['mz_min_allowed'] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min + missingdf['sta_max_allowed'] = missingdf.scan_time_aligned_max + rt_clu_tol * missingdf.scan_time_aligned_max + missingdf['sta_min_allowed'] = missingdf.scan_time_aligned_min - rt_clu_tol * missingdf.scan_time_aligned_min + + runtime_params['missingdf'] = missingdf + runtime_params['cluster_dict'] = self.cluster_feature_dictionary + runtime_params['expand_on_miss'] = expand_on_miss + + # Check if any operation needs reload parameters + needs_reload = any(isinstance(op, ReloadFeaturesOperation) for op in operations) + if needs_reload: + # Use DRY helper method to build sample_mf_map + sample_mf_map = self.get_sample_mf_map_for_representatives(include_cluster_id=False) + runtime_params['sample_mf_map'] = sample_mf_map + + # Check if any operation needs MS2 spectral search parameters + needs_ms2_search = any(isinstance(op, MS2SpectralSearchOperation) for op in operations) + if needs_ms2_search: + # Pass through pre-prepared spectral library + if hasattr(self, '_spectral_lib') and self._spectral_lib is not None: + runtime_params['fe_lib'] = self._spectral_lib + if hasattr(self, '_spectral_search_molecular_metadata'): + runtime_params['molecular_metadata'] = self._spectral_search_molecular_metadata + + # Check if any operation needs EIC loading parameters + needs_eic_loading = any(isinstance(op, LoadEICsOperation) for op in operations) + if needs_eic_loading: + # Build cluster_mz_dict: map of sample_id -> list of m/z values in clusters + mfdf = self.mass_features_dataframe + cluster_mz_dict = {} + + # Get all mass features that belong to clusters (cluster is not NaN) + clustered_mf = mfdf[mfdf['cluster'].notna()] + + # Group by sample_id and collect all m/z values associated with eics + for sample_id in clustered_mf['sample_id'].unique(): + sample_df = clustered_mf[clustered_mf['sample_id'] == sample_id] + sample = self[sample_id] # Get the LCMS object for this sample + + # Extract _eic_mz from actual mass feature objects, not from dataframe + eic_mz_list = [] + for mf_id in sample_df['mf_id'].values: + if mf_id in sample.mass_features: + mf = sample.mass_features[mf_id] + if hasattr(mf, '_eic_mz') and mf._eic_mz is not None: + eic_mz_list.append(mf._eic_mz) + + # Use the collected m/z values, or fallback to empty list if none found + cluster_mz_dict[sample_id] = list(set(eic_mz_list)) if eic_mz_list else [] + + runtime_params['cluster_mz_dict'] = cluster_mz_dict + + return runtime_params + + def _execute_sample_pipeline(self, sample_id, operations, runtime_params, inplace=True): + """ + Execute a pipeline of operations on a single sample. + + This is the worker function called (potentially in parallel) for each sample. + + Parameters + ---------- + sample_id : int + Sample ID to process + operations : list of SampleOperation + Operations to execute in order + runtime_params : dict + Runtime parameters prepared by _prepare_pipeline_runtime_params + inplace : bool, optional + If True, updates sample in place. If False, returns results for + multiprocessing. Default is True. + + Returns + ------- + dict + Dictionary with results from each operation, keyed by operation name. + If inplace=True, returns results that need to be collected. + If inplace=False, returns all results for multiprocessing collection. + """ + results = {} + + # Check if any operations need raw MS data + needs_raw_data = {} # {ms_level: True/False} + for op in operations: + needs_raw, ms_level = op.needs_raw_ms_data() + if needs_raw and ms_level: + needs_raw_data[ms_level] = True + + # Load raw data once if any operations need it + # Note: For gap-filling, it loads data internally, so we just track it here + for ms_level in needs_raw_data.keys(): + # Gap-filling loads its own data, but we want to keep track that it's loaded + # Other operations can then use the loaded data + pass + + for op in operations: + # Check if operation can execute on this sample + sample = self[sample_id] + if not op.can_execute(sample, self): + # Skip this operation for this sample if prerequisites aren't met + # This allows processing to continue for samples that don't have + # all required data (e.g., MS2 spectra) + results[op.name] = None + continue + + # Prepare operation-specific runtime params + op_runtime_params = {} + + # Add gap-fill params if this is a gap-fill operation + from corems.mass_spectra.calc.lc_calc_operations import ( + GapFillOperation, ReloadFeaturesOperation, MS2SpectralSearchOperation, LoadEICsOperation + ) + + if isinstance(op, GapFillOperation): + if 'missingdf' in runtime_params: + op_runtime_params['missingdf'] = runtime_params['missingdf'] + op_runtime_params['cluster_dict'] = runtime_params['cluster_dict'] + op_runtime_params['expand_on_miss'] = runtime_params['expand_on_miss'] + + elif isinstance(op, ReloadFeaturesOperation): + if 'sample_mf_map' in runtime_params: + sample_mf_map = runtime_params['sample_mf_map'] + # Always pass mf_ids_to_load to ensure we only load what's needed + # If sample not in map, it has no representatives - pass empty list + op_runtime_params['mf_ids_to_load'] = sample_mf_map.get(sample_id, []) + + elif isinstance(op, MS2SpectralSearchOperation): + # Add MS2 spectral search parameters + if 'fe_lib' in runtime_params: + op_runtime_params['fe_lib'] = runtime_params['fe_lib'] + if 'molecular_metadata' in runtime_params: + op_runtime_params['molecular_metadata'] = runtime_params['molecular_metadata'] + + elif isinstance(op, LoadEICsOperation): + # Add EIC loading parameters + if 'cluster_mz_dict' in runtime_params: + op_runtime_params['cluster_mz_dict'] = runtime_params['cluster_mz_dict'] + + # Execute the operation + result = op.execute(sample_id, self, **op_runtime_params) + results[op.name] = result + + # If inplace, collect immediately + if inplace and result is not None: + op.collect_results(sample_id, result, self) + + # Clean up raw data if requested + keep_raw_data = runtime_params.get('keep_raw_data', False) + if not keep_raw_data: + for ms_level in needs_raw_data.keys(): + if ms_level in self[sample_id]._ms_unprocessed: + del self[sample_id]._ms_unprocessed[ms_level] + + # Include sample_id in results for tracking (especially important for imap_unordered) + results['sample_id'] = sample_id + return results + + def process_consensus_features(self, load_representatives=True, perform_gap_filling=True, + add_ms1=False, add_ms2=False, + ms2_scan_filter=None, molecular_formula_search=False, + ms2_spectral_search=False, spectral_lib=None, + molecular_metadata=None, + gather_eics=False, + keep_raw_data=False, + show_progress=True): + """ + Process consensus mass features across the collection in a single parallelized pass. + + This method provides a convenient interface to the sample processing pipeline, + allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, + molecular formula search, and MS2 spectral search) to be performed efficiently in + a single pass through all samples. + + Parameters + ---------- + load_representatives : bool, optional + If True, loads representative mass features from HDF5. Default is True. + perform_gap_filling : bool, optional + If True, performs gap-filling for missing cluster features. Default is True. + This operation loads raw MS1 data which can be reused by subsequent operations. + add_ms1 : bool, optional + If True and load_representatives=True, associates MS1 spectra with + loaded features. Automatically uses raw data from gap-filling if available, + otherwise uses parser. Spectrum mode is auto-detected. Default is False. + add_ms2 : bool, optional + If True and load_representatives=True, associates MS2 spectra with + loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False. + ms2_scan_filter : str or None, optional + Filter string for MS2 scans (e.g., 'hcd'). Default is None. + molecular_formula_search : bool, optional + If True, performs molecular formula search on mass features using + associated MS1 spectra. Requires add_ms1=True or that MS1 spectra + are already associated. Uses parameters from + parameters.mass_spectrum["ms1"].molecular_search. Default is False. + ms2_spectral_search : bool, optional + If True, performs MS2 spectral library search using FlashEntropy. + Requires add_ms2=True and spectral_lib to be provided. Default is False. + spectral_lib : FlashEntropy library, optional + Pre-prepared FlashEntropy spectral library for MS2 search. + Create using MSPInterface.get_metabolomics_spectra_library(). + Required if ms2_spectral_search=True. Default is None. + molecular_metadata : pd.DataFrame, optional + Molecular metadata corresponding to spectral_lib. + Returned from MSPInterface.get_metabolomics_spectra_library(). + Stored as self.spectral_search_molecular_metadata for later export. + Default is None. + gather_eics : bool, optional + If True, loads extracted ion chromatograms (EICs) from HDF5 for all + mass features with assigned cluster_index (including gap-filled features). + Enables access to EICs via get_eics_for_cluster(cluster_id) method. + Requires that EICs were previously exported with export_eics=True. + Default is False. + keep_raw_data : bool, optional + If True, keeps raw MS data loaded in memory after pipeline completes. + If False, cleans up raw data to free memory. Default is False. + show_progress : bool, optional + If True, displays progress bars during processing. If False, runs silently. + Default is True. + + Returns + ------- + dict + Dictionary with pipeline results. Keys include: + - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling) + - 'reload': dict mapping sample_id to reloaded mass features (if reloading) + - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search) + - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search) + + Raises + ------ + ValueError + If neither operation is enabled, or if required parameters are missing. + + Notes + ----- + - Must run add_consensus_mass_features() before calling this method + - Processes samples in parallel based on parameters.lcms_collection.cores + - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association + - MS2 spectral search requires add_ms2=True and msp_file_path + - FlashEntropy library is created once and reused across all samples + - More efficient than calling individual methods separately + - After gap-filling, sets missing_mass_features_searched = True + - Mass features remain loaded in memory for downstream processing + - For more advanced workflows, use process_samples_pipeline() directly + + Examples + -------- + >>> # Prepare spectral library for MS2 search + >>> from corems.molecular_id.search.database_interfaces import MSPInterface + >>> my_msp = MSPInterface(file_path='path/to/library.msp') + >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( + ... polarity='negative', + ... format='flashentropy', + ... normalize=True, + ... fe_kwargs={ + ... 'normalize_intensity': True, + ... 'min_ms2_difference_in_da': 0.02, + ... 'max_ms2_tolerance_in_da': 0.01, + ... 'max_indexed_mz': 3000, + ... 'precursor_ions_removal_da': None, + ... 'noise_threshold': 0, + ... } + ... ) + >>> + >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search + >>> results = lcms_collection.process_consensus_features( + ... load_representatives=True, + ... perform_gap_filling=True, + ... add_ms1=True, + ... add_ms2=True, + ... molecular_formula_search=True, + ... ms2_spectral_search=True, + ... spectral_lib=spectral_lib, + ... molecular_metadata=molecular_metadata + ... ) + + See Also + -------- + process_samples_pipeline : Generic pipeline executor for custom workflows + fill_missing_cluster_features : Original gap-filling method + reload_representative_mass_features : Original reload method + """ + from corems.mass_spectra.calc.lc_calc_operations import ( + GapFillOperation, ReloadFeaturesOperation, MolecularFormulaSearchOperation, + MS2SpectralSearchOperation, LoadEICsOperation + ) + + # Validate that at least one meaningful operation is enabled + has_operations = ( + perform_gap_filling or + load_representatives or + molecular_formula_search or + ms2_spectral_search or + gather_eics or + add_ms1 or + add_ms2 + ) + + if not has_operations: + raise ValueError( + "At least one operation must be enabled: perform_gap_filling, load_representatives, " + "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, or add_ms2" + ) + + # Validate prerequisites for gap-filling + if perform_gap_filling: + if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: + raise ValueError( + "Cannot perform gap-filling: cluster_summary_dataframe not set. " + "You must run add_consensus_mass_features() before calling process_consensus_features()." + ) + + # Validate prerequisites for MS2 spectral search + if ms2_spectral_search: + if spectral_lib is None: + raise ValueError( + "MS2 spectral search requires spectral_lib to be provided. " + "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method." + ) + # Check if mass features will be loaded OR are already loaded + # (The operation's can_execute will check if MS2 spectra are actually present) + if not load_representatives and not perform_gap_filling: + # Check if at least one sample has mass features loaded + # This allows MS2 search on already-loaded features + has_loaded_features = any( + len(self[i].mass_features) > 0 if hasattr(self[i], 'mass_features') and self[i].mass_features is not None else False + for i in range(len(self.samples)) + ) + if not has_loaded_features: + raise ValueError( + "MS2 spectral search requires mass features to be loaded. " + "Either set load_representatives=True or perform_gap_filling=True to load them, " + "or load them in a previous call to process_consensus_features() before calling " + "with ms2_spectral_search=True." + ) + + # Build pipeline + operations = [] + + if perform_gap_filling: + expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss + operations.append(GapFillOperation('gap_fill', expand_on_miss=expand_on_miss)) + + if load_representatives: + operations.append(ReloadFeaturesOperation( + 'reload', + add_ms1=add_ms1, + add_ms2=add_ms2, + auto_process_ms2=add_ms2, # Auto-process MS2 if add_ms2 is enabled + ms2_scan_filter=ms2_scan_filter + )) + + if molecular_formula_search: + operations.append(MolecularFormulaSearchOperation('mf_search')) + + if ms2_spectral_search: + operations.append(MS2SpectralSearchOperation( + 'ms2_search', + ms2_scan_filter=ms2_scan_filter + )) + # Store spectral library and metadata for runtime preparation + self._spectral_lib = spectral_lib + self._spectral_search_molecular_metadata = molecular_metadata + + if gather_eics: + operations.append(LoadEICsOperation('load_eics')) + + # Execute pipeline (description auto-generated from operations) + results = self.process_samples_pipeline( + operations, + keep_raw_data=keep_raw_data, + show_progress=show_progress + ) + + # Store molecular metadata if spectral search was performed + if ms2_spectral_search and hasattr(self, '_spectral_search_molecular_metadata'): + # This allows users to access the metadata for reporting + self.spectral_search_molecular_metadata = self._spectral_search_molecular_metadata + # Post-processing + if perform_gap_filling: + # Combine induced mass features into dataframe + self._combine_mass_features(induced_features=True) + # Mark that gap-filling has been performed + self.missing_mass_features_searched = True + + # Add ._eic_mz to induced_mass_features_dataframe if it exists + if self.induced_mass_features_dataframe is not None and len(self.induced_mass_features_dataframe) > 0: + eics_mz = [] + for i, row in self.induced_mass_features_dataframe.iterrows(): + sample_id = row['sample_id'] + sample = self[sample_id] + if row['mf_id'] in sample.induced_mass_features.keys(): + eic_mz = sample.induced_mass_features[row['mf_id']]._eic_mz + eics_mz.append(eic_mz) + else: + eics_mz.append(None) + self.induced_mass_features_dataframe['_eic_mz'] = eics_mz + + # Clear mass features from samples to free memory + for sample_name in self.samples: + self._lcms[sample_name].induced_mass_features = {} + + # Associate EICs with mass features if they were loaded + # This must happen after all operations complete to work on the actual sample objects + if gather_eics: + print("\nAssociating EICs with mass features:") + from tqdm import tqdm + + for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80): + sample = self[sample_id] + if sample.eics: # Only if EICs were loaded + # Associate EICs with regular mass features + sample.associate_eics_with_mass_features(induced=False) + # Associate EICs with induced mass features + sample.associate_eics_with_mass_features(induced=True) + + return results \ No newline at end of file diff --git a/corems/mass_spectra/calc/lc_calc_operations.py b/corems/mass_spectra/calc/lc_calc_operations.py new file mode 100644 index 000000000..d26646fd9 --- /dev/null +++ b/corems/mass_spectra/calc/lc_calc_operations.py @@ -0,0 +1,1127 @@ +""" +Sample-level operations for LCMS collection processing pipelines. + +This module provides a framework for defining reusable, composable operations +that can be executed on individual samples in a parallelized manner. + +Classes +------- +SampleOperation + Base class for all sample-level operations +GapFillOperation + Gap-fill missing cluster features for a sample +ReloadFeaturesOperation + Reload mass features from HDF5 for a sample + +""" + +from abc import ABC, abstractmethod +import pandas as pd + + +class SampleOperation(ABC): + """ + Base class for operations that can be performed on a sample. + + All sample operations must inherit from this class and implement all + abstract methods. This ensures proper integration with the pipeline framework. + + Parameters + ---------- + name : str + Name of the operation (for logging and identification) + **kwargs + Additional keyword arguments stored as operation parameters + + Attributes + ---------- + name : str + Operation name + params : dict + Dictionary of operation parameters + description : str + Human-readable description for progress messages (must override in subclasses) + """ + + def __init__(self, name, **kwargs): + self.name = name + self.params = kwargs + + @property + @abstractmethod + def description(self): + """ + Human-readable description for progress messages. + + This property must be overridden in subclasses to provide a meaningful + description that will be shown in progress bars (e.g., "gap-filling", + "reloading features", etc.). + + Returns + ------- + str + Brief description of what this operation does + """ + pass + + @abstractmethod + def needs_raw_ms_data(self): + """ + Declare whether this operation needs raw MS data loaded. + + Subclasses must implement this method to specify raw data requirements. + The pipeline executor will ensure raw data is loaded before executing + operations that need it, and can clean it up afterwards. + + Returns + ------- + tuple of (bool, int or None) + (needs_raw_data, ms_level) + - needs_raw_data: True if operation needs raw MS data + - ms_level: MS level needed (1 for MS1, 2 for MS2, etc.) or None + + Examples + -------- + >>> def needs_raw_ms_data(self): + ... return True, 1 # Needs MS1 data + >>> def needs_raw_ms_data(self): + ... return False, None # No raw data needed + """ + pass + + @abstractmethod + def can_execute(self, sample, collection): + """ + Check if this operation can be executed on the sample. + + Subclasses must implement this method to define prerequisites. + Return True if the operation can execute, False otherwise. + + Parameters + ---------- + sample : LCMSBase + The sample to check + collection : LCMSBaseCollection + The collection containing the sample + + Returns + ------- + bool + True if operation can execute, False otherwise + + Examples + -------- + >>> def can_execute(self, sample, collection): + ... return True # Can always execute + >>> def can_execute(self, sample, collection): + ... return hasattr(sample, 'mass_features') and sample.mass_features + """ + pass + + @abstractmethod + def execute(self, sample_id, collection, **runtime_params): + """ + Execute the operation on a sample. + + This method must be implemented by subclasses. + + Parameters + ---------- + sample_id : int + Sample ID to process + collection : LCMSBaseCollection + The collection containing the sample + **runtime_params + Runtime parameters passed from the pipeline + + Returns + ------- + result + Operation result (can be None if operation modifies sample in place) + """ + pass + + @abstractmethod + def collect_results(self, sample_id, result, collection): + """ + Collect results back into collection after parallel execution. + + Subclasses must implement this method to handle result collection. + If the operation modifies samples in place and doesn't need to collect + results, simply implement as `pass`. + + Parameters + ---------- + sample_id : int + Sample ID that was processed + result + Result returned from execute() + collection : LCMSBaseCollection + The collection to update + + Examples + -------- + >>> def collect_results(self, sample_id, result, collection): + ... pass # Operation modifies sample in place + >>> def collect_results(self, sample_id, result, collection): + ... collection[sample_id].induced_mass_features = result + """ + pass + + def __repr__(self): + return f"{self.__class__.__name__}(name='{self.name}')" + + +class GapFillOperation(SampleOperation): + """ + Gap-fill missing cluster features for a sample. + + Searches raw MS1 data to find peaks in expected m/z and retention time + windows for clusters that are present in other samples but missing from + this sample. + + Uses time range filtering for efficient data loading - only loads the + retention time windows where gaps need to be filled, plus a buffer + (controlled by eic_buffer_time parameter) for complete EIC extraction. + Multiple time ranges are automatically merged if they overlap. + + Parameters + ---------- + name : str + Operation name + expand_on_miss : bool, optional + If True, expands search window when no peak is found. Default is False. + + Notes + ----- + Requires that add_consensus_mass_features() has been run on the collection. + This operation loads raw MS1 data which will be available for subsequent operations. + Time range filtering significantly reduces memory usage and loading time for + large datasets with sparse gaps. + """ + + @property + def description(self): + """Human-readable description for progress messages.""" + return "gap-filling" + + def needs_raw_ms_data(self): + """This operation needs raw MS1 data.""" + return True, 1 + + def can_execute(self, sample, collection): + """Check if cluster summary exists.""" + return hasattr(collection, 'cluster_summary_dataframe') and \ + collection.cluster_summary_dataframe is not None + + def execute(self, sample_id, collection, **runtime_params): + """ + Execute gap-filling for a single sample. + + Parameters + ---------- + sample_id : int + Sample index to process + collection : LCMSBaseCollection + The collection + **runtime_params + Runtime parameters including: + - missingdf : pd.DataFrame - Cluster information and missing samples (optional) + - cluster_dict : dict - Cluster feature dictionary (optional) + - expand_on_miss : bool - Whether to expand search window on miss (optional) + If these are not provided, returns empty dict (no gaps to fill). + + Returns + ------- + dict + Dictionary of induced mass features (empty if no gaps to fill) + """ + # Extract gap-fill parameters from runtime_params + # If not present, there are no gaps to fill, so return early + if 'missingdf' not in runtime_params: + return {} + + missingdf = runtime_params['missingdf'] + cluster_dict = runtime_params['cluster_dict'] + expand_on_miss = runtime_params['expand_on_miss'] + + # This is essentially the same logic as _search_for_targeted_mass_features_in_sample + # but extracted into an operation + + # Get clusters missing data for this sample + sampledf = missingdf[ + missingdf.missing_samples.apply(lambda x: sample_id in x) + ].reset_index(drop=True).copy() + + # Skip if no missing features for this sample + if len(sampledf) == 0: + return {} + + # Get buffer time from LCMS parameters for EIC extraction + # This ensures we capture the full chromatographic peak beyond cluster bounds + buffer_rt = collection[sample_id].parameters.lc_ms.eic_buffer_time + + # Calculate time ranges for efficient loading with buffer for EIC extraction + time_ranges = [] + + for _, row in sampledf.iterrows(): + rt_min = row['scan_time_aligned_min'] + rt_max = row['scan_time_aligned_max'] + + # If expand_on_miss, also consider the allowed bounds + if expand_on_miss: + rt_min = min(rt_min, row['sta_min_allowed']) + rt_max = max(rt_max, row['sta_max_allowed']) + + # Apply buffer AFTER considering expand_on_miss bounds + # This ensures buffer is added beyond even the expanded search window + time_ranges.append((max(0, rt_min - buffer_rt), rt_max + buffer_rt)) + + # Merge overlapping time ranges to reduce number of separate loads + time_ranges = sorted(time_ranges) + merged_ranges = [] + if time_ranges: + current_min, current_max = time_ranges[0] + for rt_min, rt_max in time_ranges[1:]: + if rt_min <= current_max: # Overlapping or adjacent + current_max = max(current_max, rt_max) + else: + merged_ranges.append((current_min, current_max)) + current_min, current_max = rt_min, rt_max + merged_ranges.append((current_min, current_max)) + + # Load raw data for this sample with time range filtering + collection.load_raw_data(sample_id, 1, time_range=merged_ranges) + + # Get MS1 data + ms1df = collection[sample_id]._ms_unprocessed[1].copy() + scan_df = collection[sample_id].scan_df[['scan', 'scan_time_aligned']] + ms1df = pd.merge(ms1df, scan_df, on='scan') + + # Pre-extract all values from sampledf + clusters = sampledf.cluster.values + mz_mins = sampledf.mz_min.values + mz_maxs = sampledf.mz_max.values + st_mins = sampledf.scan_time_aligned_min.values + st_maxs = sampledf.scan_time_aligned_max.values + + if expand_on_miss: + mz_mins_allowed = sampledf.mz_min_allowed.values + mz_maxs_allowed = sampledf.mz_max_allowed.values + st_mins_allowed = sampledf.sta_min_allowed.values + st_maxs_allowed = sampledf.sta_max_allowed.values + + # Pre-filter ms1df to reduce search space + mz_global_min = mz_mins.min() + mz_global_max = mz_maxs.max() + st_global_min = st_mins.min() + st_global_max = st_maxs.max() + + if expand_on_miss: + mz_global_min = min(mz_global_min, mz_mins_allowed.min()) + mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) + st_global_min = min(st_global_min, st_mins_allowed.min()) + st_global_max = max(st_global_max, st_maxs_allowed.max()) + + ms1df_filtered = ms1df[ + (ms1df.mz >= mz_global_min) & + (ms1df.mz <= mz_global_max) & + (ms1df.scan_time_aligned >= st_global_min) & + (ms1df.scan_time_aligned <= st_global_max) + ].copy() + + # Generate set_ids for all features + set_ids = [f'c{clusters[i]}_{i}_i' for i in range(len(sampledf))] + + # Use batch method to process all features at once + if expand_on_miss: + # First try with normal bounds + peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( + ms1df_filtered, + mz_mins, + mz_maxs, + st_mins, + st_maxs, + set_ids, + obj_idx=sample_id, + st_aligned=True + ) + + # Retry failed features with expanded bounds + failed_indices = [i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99] + if failed_indices: + failed_ids = [set_ids[i] for i in failed_indices] + retry_peaks = collection[sample_id].search_for_targeted_mass_features_batch( + ms1df_filtered, + mz_mins_allowed[failed_indices], + mz_maxs_allowed[failed_indices], + st_mins_allowed[failed_indices], + st_maxs_allowed[failed_indices], + failed_ids, + obj_idx=sample_id, + st_aligned=True + ) + peaks_dict.update(retry_peaks) + else: + peaks_dict = collection[sample_id].search_for_targeted_mass_features_batch( + ms1df_filtered, + mz_mins, + mz_maxs, + st_mins, + st_maxs, + set_ids, + obj_idx=sample_id, + st_aligned=True + ) + + # Build induced_mass_features dict and update cluster_dict + induced_mass_features = {} + for i in range(len(sampledf)): + peak = peaks_dict[set_ids[i]] + induced_mass_features[peak.id] = peak + cluster_dict[clusters[i]] += [set_ids[i]] + + # Integrate mass features (don't fail on bad integration) + collection[sample_id].induced_mass_features = induced_mass_features + collection[sample_id].integrate_mass_features(drop_if_fail=False, induced_features=True) + + # Add MS1 spectra and peak metrics to successfully detected induced features + # Only process features that were successfully detected (apex_scan != -99) + # This is critical for having m/z values in the pivot table for gap-filled features + successful_induced = {k: v for k, v in induced_mass_features.items() if v.apex_scan != -99} + + if len(successful_induced) > 0: + # Use the already-loaded raw data (use_parser=False) for efficiency + collection[sample_id].add_associated_ms1( + auto_process=True, + use_parser=False, + spectrum_mode=None, + induced_features=True + ) + + # Return the induced features (some may have been filtered out) + return collection[sample_id].induced_mass_features + + def collect_results(self, sample_id, result, collection): + """Collect induced mass features back into sample.""" + collection[sample_id].induced_mass_features = result + + +class ReloadFeaturesOperation(SampleOperation): + """ + Reload mass features from HDF5 and optionally add MS1/MS2 spectra. + + This is useful when the collection was loaded with load_light=True, + which stores mass features only in the collection dataframe and not + as LCMSMassFeature objects in individual samples. + + Parameters + ---------- + name : str + Operation name + add_ms1 : bool, optional + If True, adds MS1 spectra to mass features. Automatically uses raw MS1 data + if available (e.g., from gap-filling), otherwise uses parser. Spectrum mode + is auto-detected. Default is False. + add_ms2 : bool, optional + If True, also loads and associates MS2 spectra. Spectrum mode is auto-detected. + Default is False. + auto_process_ms2 : bool, optional + If True and add_ms2=True, auto-processes MS2 spectra. Default is True. + ms2_scan_filter : str or None, optional + Filter string for MS2 scans. Default is None. + + Notes + ----- + MS1 spectra association automatically uses raw MS1 data if loaded by a previous + operation (e.g., GapFillOperation). This is efficient when multiple operations + need MS1 data in the same pipeline. All spectrum modes are auto-detected from + the data. + """ + + @property + def description(self): + """Human-readable description for progress messages.""" + return "reloading features" + + def needs_raw_ms_data(self): + """This operation doesn't need raw data.""" + return False, None + + def can_execute(self, sample, collection): + """Check if collection parser is available.""" + return hasattr(collection, 'collection_parser') and \ + collection.collection_parser is not None + + def execute(self, sample_id, collection, mf_ids_to_load=None, **runtime_params): + """ + Execute feature reloading for a single sample. + + Parameters + ---------- + sample_id : int + Sample ID to reload features for + collection : LCMSBaseCollection + The collection + mf_ids_to_load : list of str, optional + List of collection-level mf_ids to load + **runtime_params + Additional runtime parameters (ignored) + + Returns + ------- + dict + Dictionary of reloaded mass features + """ + # Get parameters + add_ms1 = self.params.get('add_ms1', False) + add_ms2 = self.params.get('add_ms2', False) + auto_process_ms2 = self.params.get('auto_process_ms2', True) + ms2_scan_filter = self.params.get('ms2_scan_filter', None) + + sample = collection[sample_id] + sample_name = collection.samples[sample_id] + + # Auto-determine if we should use parser for MS1 (check if raw data is available) + has_raw_ms1 = 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty + use_parser_for_ms1 = not has_raw_ms1 # Use parser only if raw data not available + + # Spectrum modes will be auto-detected (None = auto-detect) + spectrum_mode_ms1 = None + ms2_spectrum_mode = None + + # Check if we have a collection parser + if not hasattr(collection, 'collection_parser') or collection.collection_parser is None: + print(f"Warning: Cannot reload mass features for {sample_name} - no collection_parser available") + return {} + + # Get the HDF5 file for this sample + hdf5_file = collection.collection_parser.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" + + if not hdf5_file.exists(): + print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") + return {} + + # Import here to avoid circular imports + from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra + + # If specific mf_ids requested, extract the local mf_ids we need + local_mf_ids_to_load = None + if mf_ids_to_load is not None: + # mf_ids_to_load is already a list of sample-level mf_ids (integers) + # No parsing needed - they come from the mf_id column in the dataframe + if len(mf_ids_to_load) == 0: + # No features to load for this sample - return empty dict + return {} + local_mf_ids_to_load = set(mf_ids_to_load) + + # Reload mass features from HDF5 + with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: + parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) + + # If add_ms1, associate MS1 spectra with the loaded mass features + if add_ms1 and len(sample.mass_features) > 0: + # Check if raw MS1 data is already loaded (e.g., from gap-filling) + has_raw_ms1 = 1 in sample._ms_unprocessed and not sample._ms_unprocessed[1].empty + + if has_raw_ms1 and not use_parser_for_ms1: + # Use already-loaded raw data (more efficient) + sample.add_associated_ms1( + auto_process=True, + use_parser=False, + spectrum_mode=spectrum_mode_ms1 + ) + else: + # Use parser to get MS1 spectra + sample.add_associated_ms1( + auto_process=True, + use_parser=True, + spectrum_mode=spectrum_mode_ms1 + ) + + # If add_ms2, associate MS2 spectra with the loaded mass features + if add_ms2 and len(sample.mass_features) > 0: + # Get the IDs of loaded mass features (use what was actually loaded) + mf_ids_for_ms2 = list(sample.mass_features.keys()) + + collection._associate_ms2_with_mass_features( + sample, + mf_ids_for_ms2, + auto_process=auto_process_ms2, + spectrum_mode=ms2_spectrum_mode, + scan_filter=ms2_scan_filter + ) + + # Return both mass_features and _ms so they can be collected in multiprocessing + return {'mass_features': sample.mass_features, '_ms': sample._ms} + + def collect_results(self, sample_id, result, collection): + """ + Collect reloaded mass features back into sample. + + This operation loads a subset of mass features (e.g., representatives) + into sample.mass_features for processing, while preserving the full + mass_features_dataframe at the collection level. Sets a lock flag to + prevent automatic rebuilding of the collection dataframe from individual + samples. Also collects loaded mass spectra. + + Parameters + ---------- + sample_id : int + Sample ID that was processed + result : dict + Dictionary with 'mass_features' and '_ms' from execute() + collection : LCMSBaseCollection + The collection + """ + # Update sample.mass_features with loaded features + if isinstance(result, dict) and 'mass_features' in result: + collection[sample_id].mass_features = result['mass_features'] + # Also collect the _ms dictionary (MS1 and MS2 spectra) + if '_ms' in result: + collection[sample_id]._ms.update(result['_ms']) + else: + # Backward compatibility - if result is just mass_features dict + collection[sample_id].mass_features = result + + # Lock the collection dataframe to prevent rebuilding from individual samples + # (since we've only loaded a subset, rebuilding would lose data) + collection._mass_features_locked = True + + +class MolecularFormulaSearchOperation(SampleOperation): + """ + Perform molecular formula search on mass features using associated MS1 spectra. + + This operation runs molecular formula search on all mass features in a sample + that have associated MS1 spectra. Requires MS1 spectra to be loaded and + processed before execution. + + Parameters + ---------- + name : str + Operation name (for logging) + **kwargs + Additional parameters passed to parent class + + Examples + -------- + >>> op = MolecularFormulaSearchOperation('mf_search') + >>> # Use in pipeline + >>> results = collection.process_samples_pipeline([op]) + + Notes + ----- + This operation requires that MS1 spectra have been associated with mass + features (e.g., via ReloadFeaturesOperation with add_ms1=True). The + molecular formula search uses parameters from the collection's + parameters.mass_spectrum["ms1"].molecular_search settings. + """ + + @property + def description(self): + """Human-readable description for progress messages.""" + return "molecular formula search" + + def __init__(self, name='molecular_formula_search', **kwargs): + super().__init__(name, **kwargs) + + def needs_raw_ms_data(self): + """ + This operation doesn't need raw data - it works on processed MS1 spectra + that are already associated with mass features. + + Returns + ------- + tuple + (False, None) - no raw data needed + """ + return False, None + + def can_execute(self, sample, collection, **runtime_params): + """ + Check if molecular formula search can be executed. + + Requires that the sample has mass features with associated MS1 spectra. + + Parameters + ---------- + sample : LCMSObject + The sample object + collection : LCMSCollection + The collection containing the sample + **runtime_params + Runtime parameters (not used) + + Returns + ------- + bool + True if sample has mass features with MS1 spectra + """ + # Check if sample has mass features + if not hasattr(sample, 'mass_features') or not sample.mass_features: + return False + + # Check if at least some mass features have MS1 spectra + has_ms1 = any( + hasattr(mf, 'mass_spectrum') and mf.mass_spectrum is not None + for mf in sample.mass_features.values() + ) + + return has_ms1 + + def execute(self, sample_id, collection, **runtime_params): + """ + Execute molecular formula search on a sample. + + Creates a SearchMolecularFormulasLC object and runs mass feature search, + which annotates mass features with molecular formula assignments. + + Parameters + ---------- + sample_id : str + Sample identifier + collection : LCMSCollection + The collection containing the sample + **runtime_params + Runtime parameters (not used) + + Returns + ------- + int + Number of mass features that were searched + """ + from corems.molecular_id.search.molecularFormulaSearch import SearchMolecularFormulasLC + import time + import sqlalchemy.exc + import sqlite3 + + sample = collection[sample_id] + + # Verify that mass features exist + if not hasattr(sample, 'mass_features') or not sample.mass_features: + return 0 # No mass features to search + + # Verify that mass features have MS1 spectra associated + if not hasattr(sample, '_ms') or not sample._ms: + raise RuntimeError( + f"Sample {sample_id} does not have MS1 spectra loaded in _ms dictionary. " + "Molecular formula search requires MS1 spectra to be associated with mass features. " + "Ensure add_ms1=True when reloading features." + ) + + # Prepare data for bulk molecular formula search + # Group mass features by their apex scan + scan_to_mf = {} + for mf_id, mf in sample.mass_features.items(): + apex_scan = mf.apex_scan + if apex_scan not in scan_to_mf: + scan_to_mf[apex_scan] = [] + scan_to_mf[apex_scan].append(mf) + + # Build lists of mass spectra and corresponding peaks + mass_spectrum_list = [] + ms_peaks_list = [] + + for scan_num, mf_list in scan_to_mf.items(): + # Get the mass spectrum for this scan + if scan_num not in sample._ms: + continue # Skip if spectrum not loaded + + mass_spectrum = sample._ms[scan_num] + + # Verify spectrum is processed (has peaks) + if not hasattr(mass_spectrum, '_mspeaks') or not mass_spectrum._mspeaks: + continue # Skip unprocessed spectra + + # Get the MS1 peaks for each mass feature at this scan + peaks_for_scan = [] + for mf in mf_list: + try: + # Use the ms1_peak property which finds the closest peak + ms1_peak = mf.ms1_peak + peaks_for_scan.append(ms1_peak) + except (AttributeError, IndexError): + # Skip if ms1_peak can't be determined + continue + + if peaks_for_scan: + mass_spectrum_list.append(mass_spectrum) + ms_peaks_list.append(peaks_for_scan) + + # Run molecular formula search if we have data, with retry logic for database locks + if mass_spectrum_list and ms_peaks_list: + max_retries = 10 + retry_delay = 2 # seconds + + for attempt in range(max_retries): + try: + mol_search = SearchMolecularFormulasLC(sample) + mol_search.bulk_run_molecular_formula_search(mass_spectrum_list, ms_peaks_list) + break # Success, exit retry loop + except (sqlalchemy.exc.OperationalError, sqlite3.OperationalError) as e: + if attempt < max_retries - 1: + # Database is locked, retry after delay + print(f"Sample {sample_id}: Database locked during molecular formula search, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})...") + time.sleep(retry_delay) + else: + # Max retries exceeded, re-raise the exception + raise RuntimeError( + f"Sample {sample_id}: Molecular formula search failed after {max_retries} attempts due to database lock. " + "Try reducing parallel cores or increasing database timeout." + ) from e + + # Return count of features searched + return len(sample.mass_features) + + def collect_results(self, sample_id, result, collection): + """ + Collect results (no-op as search modifies mass features in place). + + The molecular formula search modifies mass features in place by adding + molecular formula assignments, so no explicit result collection is needed. + + Parameters + ---------- + sample_id : str + Sample identifier + result : int + Number of features searched + collection : LCMSCollection + The collection containing the sample + """ + # Search modifies mass features in place, nothing to collect + pass + + +class MS2SpectralSearchOperation(SampleOperation): + """ + Perform MS2 spectral search using entropy-based matching. + + This operation performs spectral library search on MS2 spectra associated + with mass features using FlashEntropy for fast similarity scoring. Requires + MS2 spectra to be loaded and processed before execution. + + Parameters + ---------- + name : str + Operation name (for logging) + ms2_scan_filter : str or None, optional + Filter string for MS2 scans (e.g., 'hcd'). If None, uses all MS2 scans. + Default is None. + peak_sep_da : float, optional + Peak separation in Daltons for spectral matching. Default is 0.01. + **kwargs + Additional parameters passed to parent class + + Examples + -------- + >>> op = MS2SpectralSearchOperation('ms2_search', ms2_scan_filter='hcd') + >>> # Use in pipeline - requires fe_lib in runtime_params + >>> results = collection.process_samples_pipeline([op]) + + Notes + ----- + This operation requires: + - MS2 spectra to be associated with mass features + - FlashEntropy library (fe_lib) to be provided in runtime_params + - MS2 spectra must be processed (centroided) + + The spectral search modifies mass features in place by adding spectral + match scores and metadata. + """ + + @property + def description(self): + """Human-readable description for progress messages.""" + return "MS2 spectral search" + + def __init__(self, name='ms2_spectral_search', ms2_scan_filter=None, **kwargs): + super().__init__(name, **kwargs) + self.params['ms2_scan_filter'] = ms2_scan_filter + + def needs_raw_ms_data(self): + """ + This operation doesn't need raw data - it works on processed MS2 spectra + that are already associated with mass features. + + Returns + ------- + tuple + (False, None) - no raw data needed + """ + return False, None + + def can_execute(self, sample, collection, **runtime_params): + """ + Check if MS2 spectral search can be executed. + + Requires that the sample has mass features with MS2 spectra associated. + + Parameters + ---------- + sample : LCMSObject + The sample object + collection : LCMSCollection + The collection containing the sample + **runtime_params + Runtime parameters (not used) + + Returns + ------- + bool + True if sample has mass features with MS2 spectra + """ + # Check if sample has mass features + if not hasattr(sample, 'mass_features') or not sample.mass_features: + return False + + # Check if any mass features have MS2 spectra associated + has_ms2 = any( + hasattr(mf, 'ms2_mass_spectra') and mf.ms2_mass_spectra + for mf in sample.mass_features.values() + ) + + return has_ms2 + + def execute(self, sample_id, collection, fe_lib=None, molecular_metadata=None, **runtime_params): + """ + Execute MS2 spectral search on a sample. + + Performs entropy-based spectral library search on all MS2 spectra + in the sample that match the scan filter criteria. + + Parameters + ---------- + sample_id : str + Sample identifier + collection : LCMSCollection + The collection containing the sample + fe_lib : FlashEntropy library + Pre-computed FlashEntropy library for spectral matching + molecular_metadata : pd.DataFrame, optional + Metadata for molecules in the spectral library + **runtime_params + Runtime parameters (not used) + + Returns + ------- + int + Number of MS2 spectra searched + """ + sample = collection[sample_id] + + # Get parameters + ms2_scan_filter = self.params.get('ms2_scan_filter') + + # Verify that we have a spectral library + if fe_lib is None: + raise ValueError( + f"Sample {sample_id}: MS2 spectral search requires fe_lib (FlashEntropy library) " + "to be provided in runtime parameters. Create the library at the collection level " + "and pass it to the pipeline." + ) + + # Extract peak_sep_da from FlashEntropy library configuration + # peak_sep_da should be 2 * max_ms2_tolerance_in_da to match the min_ms2_difference_in_da + # used when creating the library + tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da + if tolerance_da is None: + raise ValueError( + f"Sample {sample_id}: Could not extract max_ms2_tolerance_in_da from FlashEntropy library. " + "Ensure the library was created with this parameter specified." + ) + peak_sep_da = 2 * tolerance_da + + # Verify that sample has _ms dictionary + if not hasattr(sample, '_ms') or not sample._ms: + return 0 # No MS2 spectra to search + + # Get MS2 scan numbers based on filter + if ms2_scan_filter is not None: + # Filter by scan text + ms2_scan_df = sample.scan_df[ + sample.scan_df.scan_text.str.contains(ms2_scan_filter) & + (sample.scan_df.ms_level == 2) + ] + else: + # All MS2 scans + ms2_scan_df = sample.scan_df[sample.scan_df.ms_level == 2] + + # Get scans that are actually loaded in _ms + ms2_scans_to_search = [ + scan for scan in ms2_scan_df.scan.tolist() + if scan in sample._ms.keys() + ] + + if not ms2_scans_to_search: + return 0 # No MS2 spectra to search + + # Perform spectral search using the sample's fe_search method + sample.fe_search( + scan_list=ms2_scans_to_search, + fe_lib=fe_lib, + peak_sep_da=peak_sep_da + ) + + # Return the spectral search results for collection + # (needed for multiprocessing - results populated in worker need to be returned) + return sample.spectral_search_results + + def collect_results(self, sample_id, result, collection): + """ + Collect spectral search results back into the sample. + + In multiprocessing, the worker's modifications don't persist to the + main process, so we need to explicitly collect and reassign the results. + This also re-associates the results with mass features. + + Parameters + ---------- + sample_id : str + Sample identifier + result : dict + Dictionary of spectral search results from execute() + collection : LCMSCollection + The collection containing the sample + """ + # Assign the spectral search results back to the sample + if result: + collection[sample_id].spectral_search_results.update(result) + + # Re-associate results with mass features (same logic as fe_search) + sample = collection[sample_id] + if len(sample.mass_features) > 0: + for mass_feature_id, mass_feature in sample.mass_features.items(): + scan_ids = mass_feature.ms2_scan_numbers + for ms2_scan_id in scan_ids: + precursor_mz = mass_feature.mz + try: + sample.spectral_search_results[ms2_scan_id][precursor_mz] + except KeyError: + pass + else: + sample.mass_features[ + mass_feature_id + ].ms2_similarity_results.append( + sample.spectral_search_results[ms2_scan_id][precursor_mz] + ) + + +class LoadEICsOperation(SampleOperation): + """ + Load extracted ion chromatograms (EICs) from HDF5 for regular mass features. + + Loads EICs for regular mass features that belong to consensus clusters from HDF5. + Induced (gap-filled) features already have EICs from integrate_mass_features, + so no additional loading is needed for them. + + This operation enables downstream visualization and analysis of chromatographic + peaks across all samples in a cluster. + + Notes + ----- + Requires that mass features have been loaded and cluster_index assigned. + Regular mass feature EICs must have been previously saved to HDF5 with export_eics=True. + Induced mass features already have EICs populated during gap-filling. + """ + + @property + def description(self): + """Human-readable description for progress messages.""" + return "loading EICs" + + def needs_raw_ms_data(self): + """This operation doesn't need raw data - induced features already have EICs.""" + return False, None + + def can_execute(self, sample, collection): + """ + Check if EIC loading can be executed. + + This operation can always execute if the sample exists - the actual work + is determined by cluster_mz_dict in runtime_params. If cluster_mz_dict is + empty or None, execute() will simply return 0 (no EICs loaded). + + Returns + ------- + bool + True (always executable - runtime_params control actual work) + """ + return True + + def execute(self, sample_id, collection, cluster_mz_dict=None, **runtime_params): + """ + Load EICs from HDF5 for a single sample. + + Loads EICs for regular mass features that belong to consensus clusters. + Induced (gap-filled) mass features already have EICs from integrate_mass_features, + so no additional loading is needed for them. + + The cluster_mz_dict parameter (passed from collection level) maps sample_id + to a list of m/z values that belong to clusters for that sample. + + Parameters + ---------- + sample_id : int + Sample index to process + collection : LCMSBaseCollection + The collection + cluster_mz_dict : dict, optional + Dictionary mapping sample_id to list of m/z values in clusters for that sample. + If None, will not load any EICs. Default is None. + **runtime_params + Additional runtime parameters (ignored) + + Returns + ------- + dict + Dictionary of loaded EIC_Data objects, keyed by m/z value + """ + from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra + + sample = collection[sample_id] + + # If no cluster info provided or no m/z values for this sample, return early + if cluster_mz_dict is None or sample_id not in cluster_mz_dict: + return {} + + # Get m/z values for this sample that belong to clusters + sample_cluster_mz = set(cluster_mz_dict[sample_id]) + if len(sample_cluster_mz) == 0: + return {} + + # Load EICs for each of the sample_cluster_mz + hdf5_path = sample.file_location + if hdf5_path and hdf5_path.exists(): + reader = ReadCoreMSHDFMassSpectra(str(hdf5_path)) + reader.import_eics(sample, mz_list=list(sample_cluster_mz)) + # Return the loaded EICs for multiprocessing collection + # (modifications in worker process don't persist to main process) + return sample.eics.copy() + + return {} + + def collect_results(self, sample_id, result, collection): + """ + Collect loaded EICs back into sample. + + In multiprocessing, the worker's modifications don't persist to the + main process, so we need to explicitly collect and reassign the EICs. + This also re-associates EICs with mass features. + + Parameters + ---------- + sample_id : int + Sample ID that was processed + result : dict + Dictionary of EIC_Data objects keyed by m/z, returned from execute() + collection : LCMSBaseCollection + The collection + """ + if result: + # Update sample.eics with loaded EICs + collection[sample_id].eics.update(result) + # Note: EIC association with mass features happens after pipeline completes + # to avoid multiprocessing issues (modifications in worker processes don't + # persist to main process objects) diff --git a/corems/mass_spectra/factory/lc_class.py b/corems/mass_spectra/factory/lc_class.py index 35341f1bb..bcca9b8e6 100644 --- a/corems/mass_spectra/factory/lc_class.py +++ b/corems/mass_spectra/factory/lc_class.py @@ -3,13 +3,16 @@ import numpy as np import pandas as pd import warnings +import multiprocessing + import matplotlib.pyplot as plt -from corems.encapsulation.factory.parameters import LCMSParameters -from corems.mass_spectra.calc.lc_calc import LCCalculations, PHCalculations +from corems.encapsulation.factory.parameters import LCMSParameters, LCMSCollectionParameters +from corems.mass_spectra.calc.lc_calc import LCCalculations, PHCalculations, LCMSCollectionCalculations from corems.molecular_id.search.lcms_spectral_search import LCMSSpectralSearch from corems.mass_spectrum.input.numpyArray import ms_from_array_profile, ms_from_array_centroid from corems.mass_spectra.calc.lc_calc import find_closest +from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature class MassSpectraBase: @@ -81,14 +84,19 @@ def __init__( self.file_location = file_location self.analyzer = analyzer self.instrument_label = instrument_label + self._raw_file_location = None # Add the spectra parser class to the object if it is not None if spectra_parser is not None: self.spectra_parser_class = spectra_parser.__class__ - # Check that spectra_pasrser.sample_name is same as sample_name etc, raise warning if not + if self.spectra_parser_class.__name__ == "ReadCoreMSHDFMassSpectra": + self.raw_file_location = spectra_parser.get_raw_file_location() + + # Check that spectra_parser.sample_name is same as sample_name etc, raise warning if not if ( self.sample_name is not None and self.sample_name != self.spectra_parser.sample_name + and self.spectra_parser_class.__name__ != "ReadCoreMSHDFMassSpectra" ): warnings.warn( "sample_name provided to MassSpectraBase object does not match sample_name provided to spectra parser object", @@ -118,7 +126,21 @@ def __init__( @property def spectra_parser(self): """Returns an instance of the spectra parser class.""" - return self.spectra_parser_class(self.file_location) + # Check if a file exists at the raw_file_location + if not Path(self.raw_file_location).exists(): + raise FileNotFoundError( + f"Raw file not found at location: {self.raw_file_location}, update raw_file_location property to point to correct location." + ) + return self.spectra_parser_class(self.raw_file_location) + + @property + def raw_file_location(self): + """Returns the file_location unless the _raw_file_location is not None.""" + return self._raw_file_location if self._raw_file_location is not None else self.file_location + + @raw_file_location.setter + def raw_file_location(self, value): + self._raw_file_location = value def add_mass_spectrum(self, mass_spec): """Adds a mass spectrum to the dataset. @@ -390,6 +412,13 @@ class LCMSBase(MassSpectraBase, LCCalculations, PHCalculations, LCMSSpectralSear mass_features : dictionary of LCMSMassFeature objects A dictionary containing mass features for the dataset. Key is mass feature ID. Initialized as an empty dictionary. + induced_mass_features: dictionary of LCMSMassFeature objects + A dictionary containing mass features from a collection that don't + satisfy criteria for initial mass features. Key is mass feature ID. + Initialized as an empty dictionary. + missing_mass_features: pandas.DataFrame + A table of clusters in a given sample for which a mass feature was + sought and not found spectral_search_results : dictionary of MS2SearchResults objects A dictionary containing spectral search results for the dataset. Key is scan number : precursor mz. Initialized as an empty dictionary. @@ -414,6 +443,9 @@ class LCMSBase(MassSpectraBase, LCCalculations, PHCalculations, LCMSSpectralSear Sets the scan number list from the data in the _ms dictionary. * plot_composite_mz_features(binsize = 1e-4, ph_int_min_thresh = 0.001, mf_plot = True, ms2_plot = True, return_fig = False) Generates plot of M/Z features comparing scan time vs M/Z value + * search_for_targeted_mass_feature(ms1df: pd.DataFrame, sample: pd.Series, tol_flag = 0) + Searches for mass features in specific M/Z and scan time windows that + were missed by the persistent homology search """ def __init__( @@ -434,8 +466,77 @@ def __init__( self._tic_list = [] self.eics = {} self.mass_features = {} + self.induced_mass_features = {} self.spectral_search_results = {} + def get_eic_mz_for_mass_feature(self, mf_mz, tolerance=0.0001): + """Get the EIC dictionary key (m/z) that best matches a mass feature's m/z. + + Finds the closest EIC m/z key within the specified tolerance. + + Parameters + ---------- + mf_mz : float + The m/z value of the mass feature to match. + tolerance : float, optional + Maximum m/z difference for matching. Default is 0.0001 Da. + + Returns + ------- + float or None + The EIC dictionary key (m/z) of the closest matching EIC, + or None if no EIC is within tolerance. + """ + if not hasattr(self, 'eics') or not self.eics: + return None + + best_eic_mz = None + best_diff = tolerance + for eic_mz in self.eics.keys(): + diff = abs(mf_mz - eic_mz) + if diff < best_diff: + best_diff = diff + best_eic_mz = eic_mz + return best_eic_mz + + def associate_eics_with_mass_features(self, tolerance=0.0001, induced=False): + """Associate EICs with mass features using tolerance-based m/z matching. + + Associates EIC_Data objects from self.eics with mass features by finding + the closest EIC within the specified m/z tolerance. This is more robust + than exact matching which can fail due to floating point precision issues. + + Parameters + ---------- + tolerance : float, optional + Maximum m/z difference for matching EICs to mass features. Default is 0.0001 Da. + induced : bool, optional + If True, associates EICs with induced_mass_features instead of mass_features. + Default is False. + + Notes + ----- + For each mass feature, this method finds the EIC with the closest m/z value + within the tolerance window and assigns it to the mass feature's _eic_data attribute. + If multiple EICs are within tolerance, the one with the smallest m/z difference is chosen. + """ + # Select which mass features dictionary to use + mf_dict = self.induced_mass_features if induced else self.mass_features + + # Use the _eic_mz attribute on each mass_feature to find the closest matching EIC + for idx in mf_dict.keys(): + mf_mz = mf_dict[idx]._eic_mz + # Find closest EIC within tolerance + best_match = None + best_diff = tolerance + for eic_mz, eic_data in self.eics.items(): + diff = abs(mf_mz - eic_mz) + if diff < best_diff: + best_diff = diff + best_match = eic_data + if best_match is not None: + mf_dict[idx]._eic_data = best_match + def get_parameters_json(self): """Returns the parameters stored for the LC-MS object in JSON format. @@ -470,6 +571,120 @@ def remove_unprocessed_data(self, ms_level=None): raise ValueError("ms_level must be 1 or 2") self._ms_unprocessed[ms_level] = None + def _filter_ms2_scans_by_integration_bounds(self, mf_dict=None): + """Filter MS2 scans to only include those within integration bounds. + + Removes MS2 scan numbers that fall outside the start_scan to final_scan range + for each mass feature. This should be called after integration sets the bounds. + + Parameters + ---------- + mf_dict : dict, optional + Dictionary of mass features to filter. If None, uses self.mass_features. + + Returns + ------- + int + Number of MS2 scans removed across all mass features. + """ + if mf_dict is None: + mf_dict = self.mass_features + + total_removed = 0 + + for mf_id, mf in mf_dict.items(): + # Only filter if integration bounds are set and MS2 scans exist + if (hasattr(mf, 'start_scan') and hasattr(mf, 'final_scan') and + mf.start_scan is not None and mf.final_scan is not None and + mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0): + + # Filter scan numbers to only those within bounds + original_count = len(mf.ms2_scan_numbers) + mf.ms2_scan_numbers = [ + scan for scan in mf.ms2_scan_numbers + if mf.start_scan <= scan <= mf.final_scan + ] + removed = original_count - len(mf.ms2_scan_numbers) + total_removed += removed + + return total_removed + + def _find_ms2_scans_for_mass_features(self, mf_ids=None, scan_filter=None): + """Find MS2 scans associated with mass features. + + This helper method finds MS2 scans that match mass features based on RT and m/z tolerances. + It updates the ms2_scan_numbers attribute on each mass feature. + + Parameters + ---------- + mf_ids : list of int, optional + List of mass feature IDs to find MS2 for. If None, finds for all mass features. + scan_filter : str, optional + Filter string for MS2 scans (e.g., 'hcd'). Default is None. + + Returns + ------- + list + List of unique MS2 scan numbers found across all mass features. + + Raises + ------ + ValueError + If no MS2 scans are found in the dataset. + """ + # Get mass features to process + if mf_ids is None: + mf_ids = list(self.mass_features.keys()) + + # Get mass features dataframe + mf_df = self.mass_features_to_df() + mf_df = mf_df.loc[mf_ids].copy() + + # Find ms2 scans that have a precursor m/z value + ms2_scans = self.scan_df[self.scan_df.ms_level == 2] + ms2_scans = ms2_scans[~ms2_scans.precursor_mz.isna()] + ms2_scans = ms2_scans[ms2_scans.tic > 0] + + if len(ms2_scans) == 0: + raise ValueError("No DDA scans found in dataset") + + if scan_filter is not None: + ms2_scans = ms2_scans[ms2_scans.scan_text.str.contains(scan_filter)] + + # Get tolerances from parameters + time_tol = self.parameters.lc_ms.ms2_dda_rt_tolerance + mz_tol = self.parameters.lc_ms.ms2_dda_mz_tolerance + + # For each mass feature, find the ms2 scans that are within the roi scan time and mz range + dda_scans = [] + for i, row in mf_df.iterrows(): + ms2_scans_filtered = ms2_scans[ + ms2_scans.scan_time.between( + row.scan_time - time_tol, row.scan_time + time_tol + ) + ] + ms2_scans_filtered = ms2_scans_filtered[ + ms2_scans_filtered.precursor_mz.between( + row.mz - mz_tol, row.mz + mz_tol + ) + ] + scan_list = ms2_scans_filtered.scan.tolist() + if scan_list: + # Filter scans by integration bounds if they exist + mf = self.mass_features[i] + if (hasattr(mf, 'start_scan') and hasattr(mf, 'final_scan') and + mf.start_scan is not None and mf.final_scan is not None): + # Only keep scans within integration bounds + scan_list = [s for s in scan_list if mf.start_scan <= s <= mf.final_scan] + + if scan_list: # Only add if there are still scans after filtering + self.mass_features[i].ms2_scan_numbers = ( + scan_list + list(self.mass_features[i].ms2_scan_numbers) + ) + dda_scans.extend(scan_list) + + return list(set(dda_scans)) + def add_associated_ms2_dda( self, auto_process=True, @@ -515,48 +730,19 @@ def add_associated_ms2_dda( # reconfigure ms_params to get the correct mass spectrum parameters from the key ms_params = self.parameters.mass_spectrum[ms_params_key] - mf_df = self.mass_features_to_df().copy() - # Find ms2 scans that have a precursor m/z value - ms2_scans = self.scan_df[self.scan_df.ms_level == 2] - ms2_scans = ms2_scans[~ms2_scans.precursor_mz.isna()] - # drop ms2 scans that have no tic - ms2_scans = ms2_scans[ms2_scans.tic > 0] - if ms2_scans is None: - raise ValueError("No DDA scans found in dataset") - - if scan_filter is not None: - ms2_scans = ms2_scans[ms2_scans.scan_text.str.contains(scan_filter)] - # set tolerance in rt space (in minutes) and mz space (in daltons) - time_tol = self.parameters.lc_ms.ms2_dda_rt_tolerance - mz_tol = self.parameters.lc_ms.ms2_dda_mz_tolerance - - # for each mass feature, find the ms2 scans that are within the roi scan time and mz range - dda_scans = [] - for i, row in mf_df.iterrows(): - ms2_scans_filtered = ms2_scans[ - ms2_scans.scan_time.between( - row.scan_time - time_tol, row.scan_time + time_tol - ) - ] - ms2_scans_filtered = ms2_scans_filtered[ - ms2_scans_filtered.precursor_mz.between( - row.mz - mz_tol, row.mz + mz_tol - ) - ] - dda_scans = dda_scans + ms2_scans_filtered.scan.tolist() - self.mass_features[i].ms2_scan_numbers = ( - ms2_scans_filtered.scan.tolist() - + self.mass_features[i].ms2_scan_numbers - ) - # add to _ms attribute + # Find MS2 scans for all mass features + dda_scans = self._find_ms2_scans_for_mass_features(scan_filter=scan_filter) + + # Load MS2 spectra self.add_mass_spectra( - scan_list=list(set(dda_scans)), + scan_list=dda_scans, auto_process=auto_process, spectrum_mode=spectrum_mode, use_parser=use_parser, ms_params=ms_params, ) - # associate appropriate _ms attribute to appropriate mass feature's ms2_mass_spectra attribute + + # Associate appropriate _ms attribute to appropriate mass feature's ms2_mass_spectra attribute for mf_id in self.mass_features: if self.mass_features[mf_id].ms2_scan_numbers is not None: for dda_scan in self.mass_features[mf_id].ms2_scan_numbers: @@ -566,7 +752,7 @@ def add_associated_ms2_dda( ] def add_associated_ms1( - self, auto_process=True, use_parser=True, spectrum_mode=None + self, auto_process=True, use_parser=True, spectrum_mode=None, induced_features=False ): """Add MS1 spectra associated with mass features to the dataset. @@ -580,6 +766,8 @@ def add_associated_ms1( The spectrum mode to use for the mass spectra. If None, method will use the spectrum mode from the spectra parser to ascertain the spectrum mode (this allows for mixed types). Defaults to None. (faster if defined, otherwise will check each scan) + induced_features : bool, optional + If True, add associated MS1 of the induced mass features instead of the primary mass features Raises ------ @@ -594,14 +782,23 @@ def add_associated_ms1( raise ValueError( "mass_features not set, must run find_mass_features() first" ) + + if induced_features: + mf_dict = self.induced_mass_features + else: + mf_dict = self.mass_features + scans_to_average = self.parameters.lc_ms.ms1_scans_to_average + + ## sketchy work around for induced mass features + scan_list = [ + int(mf_dict[x].apex_scan) for x in mf_dict if int(mf_dict[x].apex_scan) != -99 + ] if scans_to_average == 1: # Add to LCMSobj self.add_mass_spectra( - scan_list=[ - int(mf.apex_scan) for mf in self.mass_features.values() - ], + scan_list = scan_list, auto_process=auto_process, use_parser=use_parser, spectrum_mode=spectrum_mode, @@ -611,7 +808,7 @@ def add_associated_ms1( elif ( (scans_to_average - 1) % 2 ) == 0: # scans_to_average = 3, 5, 7 etc, mirror l/r around apex - apex_scans = list(set([int(mf.apex_scan) for mf in self.mass_features.values()])) + apex_scans = list(set(scan_list)) # Check if all apex scans are profile mode, raise error if not if not all(self.scan_df.loc[apex_scans, "ms_format"] == "profile"): raise ValueError("All apex scans must be profile mode for averaging") @@ -693,17 +890,33 @@ def get_scans_from_apex(ms1_scans, apex_scan, scans_to_average): ) # Associate the ms1 spectra with the mass features - for mf_id in self.mass_features: - self.mass_features[mf_id].mass_spectrum = self._ms[ - self.mass_features[mf_id].apex_scan - ] - self.mass_features[mf_id].update_mz() - - def mass_features_to_df(self): + for k in mf_dict.keys(): + ## another induced feature work around + if mf_dict[k].apex_scan != -99: + mf_dict[k].mass_spectrum = self._ms[ + mf_dict[k].apex_scan + ] + mf_dict[k].update_mz() + + def mass_features_to_df(self, induced_features=False, drop_na_cols=False, include_cols=None): """Returns a pandas dataframe summarizing the mass features. The dataframe contains the following columns: mf_id, mz, apex_scan, scan_time, intensity, persistence, area, monoisotopic_mf_id, and isotopologue_type. The index is set to mf_id (mass feature ID). + Parameters + ----------- + induced_features : bool, optional + If True, calls the induced_mass_features dictionary. Defaults to False. + drop_na_cols : bool, optional + If True, drops columns that are entirely NA. Defaults to False. + include_cols : list of str, optional + If provided, only includes the specified columns in the output (in addition to 'mf_id' which is always included as the index). + If None, includes all available columns. Defaults to None. + + Raises + -------- + ValueError + If the sample provided doesn't contain the mass feature data. Returns -------- @@ -711,6 +924,7 @@ def mass_features_to_df(self): A pandas dataframe of mass features with the following columns: mf_id, mz, apex_scan, scan_time, intensity, persistence, area. """ + import pandas as pd def mass_spectrum_to_string( mass_spec, normalize=True, min_normalized_abun=0.01 @@ -744,6 +958,16 @@ def mass_spectrum_to_string( ] return "; ".join(mz_abun_str) + if induced_features: + mf_dict = self.induced_mass_features + else: + mf_dict = self.mass_features + + if len(mf_dict) == 0: + # Return an empty dataframe with the expected structure + # This allows collection processing to continue even if some samples have no features + return pd.DataFrame() + cols_in_df = [ "id", "apex_scan", @@ -763,32 +987,44 @@ def mass_spectrum_to_string( "monoisotopic_mf_id", "isotopologue_type", "mass_spectrum_deconvoluted_parent", + "ms2_scan_numbers", + "type" ] + df_mf_list = [] - for mf_id in self.mass_features.keys(): + for mf_id in mf_dict.keys(): # Find cols_in_df that are in single_mf df_keys = list( - set(cols_in_df).intersection(self.mass_features[mf_id].__dir__()) + set(cols_in_df).intersection(mf_dict[mf_id].__dir__()) ) dict_mf = {} # Get the values for each key in df_keys from the mass feature object for key in df_keys: - dict_mf[key] = getattr(self.mass_features[mf_id], key) - # Special handling for mass_spectrum and associated_mass_features_deconvoluted, since they are not single values - if len(self.mass_features[mf_id].ms2_scan_numbers) > 0: + value = getattr(mf_dict[mf_id], key) + # Wrap list/array values in a list so pandas treats them as single cell values + if key == 'ms2_scan_numbers' and isinstance(value, (list, np.ndarray)): + dict_mf[key] = [value] + else: + dict_mf[key] = value + if len(mf_dict[mf_id].ms2_scan_numbers) > 0: # Add MS2 spectra info - best_ms2_spectrum = self.mass_features[mf_id].best_ms2 - dict_mf["ms2_spectrum"] = mass_spectrum_to_string(best_ms2_spectrum) - if len(self.mass_features[mf_id].associated_mass_features_deconvoluted) > 0: + best_ms2_spectrum = mf_dict[mf_id].best_ms2 + if best_ms2_spectrum is not None: + dict_mf["ms2_spectrum"] = mass_spectrum_to_string(best_ms2_spectrum) + if len(mf_dict[mf_id].associated_mass_features_deconvoluted) > 0: dict_mf["associated_mass_features"] = ", ".join( map( str, - self.mass_features[mf_id].associated_mass_features_deconvoluted, + mf_dict[mf_id].associated_mass_features_deconvoluted, ) ) + if mf_dict[mf_id]._half_height_width is not None: + dict_mf["half_height_width"] = mf_dict[ + mf_id + ].half_height_width # Check if EIC for mass feature is set df_mf_single = pd.DataFrame(dict_mf, index=[mf_id]) - df_mf_single["mz"] = self.mass_features[mf_id].mz + df_mf_single["mz"] = mf_dict[mf_id].mz df_mf_list.append(df_mf_single) df_mf = pd.concat(df_mf_list) @@ -803,6 +1039,7 @@ def mass_spectrum_to_string( # reorder columns col_order = [ "mf_id", + "type", "scan_time", "mz", "apex_scan", @@ -823,6 +1060,7 @@ def mass_spectrum_to_string( "isotopologue_type", "mass_spectrum_deconvoluted_parent", "associated_mass_features", + "ms2_scan_numbers", "ms2_spectrum", ] # drop columns that are not in col_order @@ -832,12 +1070,40 @@ def mass_spectrum_to_string( # reset index to mf_id df_mf = df_mf.set_index("mf_id") df_mf.index.name = "mf_id" - + + if 'half_height_width' in df_mf.columns: + df_mf["half_height_width"] = df_mf["half_height_width"].astype('float64') + if 'tailing_factor' in df_mf.columns: + df_mf["tailing_factor"] = df_mf["tailing_factor"].astype('float64') + if 'dispersity_index' in df_mf.columns: + df_mf["dispersity_index"] = df_mf["dispersity_index"].astype('float64') + if 'normalized_dispersity_index' in df_mf.columns: + df_mf["normalized_dispersity_index"] = df_mf["normalized_dispersity_index"].astype('float64') + + # Filter columns if include_cols is specified + if include_cols is not None: + # Ensure include_cols is a list + if not isinstance(include_cols, list): + raise ValueError("include_cols must be a list of column names") + # Keep only requested columns that exist in the dataframe + available_cols = [col for col in include_cols if col in df_mf.columns] + df_mf = df_mf[available_cols] + + # Drop columns that are entirely NA if requested + if drop_na_cols: + df_mf = df_mf.dropna(axis=1, how='all') + return df_mf - def mass_features_ms1_annot_to_df(self): + def mass_features_ms1_annot_to_df(self, suppress_warnings=False): """Returns a pandas dataframe summarizing the MS1 annotations for the mass features in the dataset. + Parameters + ----------- + suppress_warnings : bool, optional + If True, suppresses the warning when no MS1 annotations are found. + Useful when calling from collection-level methods. Default is False. + Returns -------- pandas.DataFrame @@ -847,7 +1113,8 @@ def mass_features_ms1_annot_to_df(self): Raises ------ Warning - If no MS1 annotations were found for the mass features in the dataset. + If no MS1 annotations were found for the mass features in the dataset + (unless suppress_warnings=True). """ annot_df_list_ms1 = [] for mf_id in self.mass_features.keys(): @@ -887,21 +1154,25 @@ def mass_features_ms1_annot_to_df(self): else: annot_ms1_df_full = None - # Warn that no ms1 annotations were found - warnings.warn( - "No MS1 annotations found for mass features in dataset, were MS1 spectra added and processed within the dataset?", - UserWarning, - ) + # Warn that no ms1 annotations were found (unless suppressed) + if not suppress_warnings: + warnings.warn( + "No MS1 annotations found for mass features in dataset, were MS1 spectra added and processed within the dataset?", + UserWarning, + ) return annot_ms1_df_full - def mass_features_ms2_annot_to_df(self, molecular_metadata=None): + def mass_features_ms2_annot_to_df(self, molecular_metadata=None, suppress_warnings=False): """Returns a pandas dataframe summarizing the MS2 annotations for the mass features in the dataset. Parameters ----------- molecular_metadata : dict of MolecularMetadata objects A dictionary of MolecularMetadata objects, keyed by ref_mol_id. Defaults to None. + suppress_warnings : bool, optional + If True, suppresses the warning when no MS2 annotations are found. + Useful when calling from collection-level methods. Default is False. Returns -------- @@ -912,7 +1183,8 @@ def mass_features_ms2_annot_to_df(self, molecular_metadata=None): Raises ------ Warning - If no MS2 annotations were found for the mass features in the dataset. + If no MS2 annotations were found for the mass features in the dataset + (unless suppress_warnings=True). """ annot_df_list_ms2 = [] for mf_id in self.mass_features.keys(): @@ -946,11 +1218,12 @@ def mass_features_ms2_annot_to_df(self, molecular_metadata=None): annot_ms2_df_full.index.name = "mf_id" else: annot_ms2_df_full = None - # Warn that no ms2 annotations were found - warnings.warn( - "No MS2 annotations found for mass features in dataset, were MS2 spectra added and searched against a database?", - UserWarning, - ) + # Warn that no ms2 annotations were found (unless suppressed) + if not suppress_warnings: + warnings.warn( + "No MS2 annotations found for mass features in dataset, were MS2 spectra added and searched against a database?", + UserWarning, + ) return annot_ms2_df_full @@ -1079,6 +1352,166 @@ def plot_composite_mz_features(self, binsize = 1e-4, ph_int_min_thresh = 0.001, else: plt.show() + + def search_for_targeted_mass_features_batch( + self, + ms1df, + mz_mins, + mz_maxs, + st_mins, + st_maxs, + set_ids, + obj_idx=0, + st_aligned=False + ): + """ + Returns multiple LCMSMassFeatures from a specific sample within specific mass and time ranges. + Vectorized batch version of search_for_targeted_mass_feature for improved performance. + + Parameters + ----------- + ms1df : pd.DataFrame + Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample. + mz_mins : np.ndarray + Array of lower bounds of m/z values to use to find peaks. + mz_maxs : np.ndarray + Array of upper bounds of m/z values to use to find peaks. + st_mins : np.ndarray + Array of lower bounds of scan times to use to find peaks. + st_maxs : np.ndarray + Array of upper bounds of scan times to use to find peaks. + set_ids : np.ndarray or list + Array of strings used as IDs in LCMSMassFeatures. + obj_idx : int + Identifies index of sample in a collection. Defaults to 0. + st_aligned : bool + Whether to use scan_time_aligned or scan_time. Defaults to False. + + Returns + -------- + dict + Dictionary mapping set_id to LCMSMassFeature objects. + + Raises + ------ + ValueError + If appropriate scan time data is not contained in ms1df or if array lengths don't match. + """ + # Validate inputs + n_features = len(mz_mins) + if not all(len(arr) == n_features for arr in [mz_maxs, st_mins, st_maxs, set_ids]): + raise ValueError("All input arrays must have the same length") + + # Validate scan time column + time_col = 'scan_time_aligned' if st_aligned else 'scan_time' + if time_col not in ms1df.columns: + raise ValueError(f"{time_col} not contained in ms1df") + + # Pre-extract columns for faster access + mz_vals = ms1df.mz.values + st_vals = ms1df[time_col].values + scan_vals = ms1df.scan.values + intensity_vals = ms1df.intensity.values + + # Process all features + results = {} + for i in range(n_features): + # Vectorized filtering + mask = ( + (mz_vals >= mz_mins[i]) & (mz_vals <= mz_maxs[i]) & + (st_vals >= st_mins[i]) & (st_vals <= st_maxs[i]) + ) + + if not mask.any(): + row_dict = { + 'apex_scan': -99, + 'mz': np.nan, + 'intensity': np.nan, + 'retention_time': np.nan, + 'persistence': np.nan, + 'id': set_ids[i] + } + else: + # Find max intensity within filtered region + filtered_intensities = intensity_vals[mask] + max_idx = np.argmax(filtered_intensities) + + # Get indices of filtered data + filtered_indices = np.where(mask)[0] + peak_idx = filtered_indices[max_idx] + + row_dict = { + 'apex_scan': scan_vals[peak_idx], + 'mz': mz_vals[peak_idx], + 'intensity': intensity_vals[peak_idx], + 'retention_time': st_vals[peak_idx], + 'persistence': np.nan, + 'id': set_ids[i] + } + + results[set_ids[i]] = LCMSMassFeature(self, **row_dict) + + return results + + def search_for_targeted_mass_feature( + self, + ms1df, + mz_min, + mz_max, + st_min, + st_max, + set_id, + obj_idx = 0, + st_aligned = False + ): + """ + Returns an LCMSMassFeature from a specific sample within a specific mass and time range. Returns an empty + LCMSMassFeature if no satisfactory peak is found in the given window. + + Parameters + ----------- + ms1df : Pandas DataFrame + Dataframe containing all the possible MS1 values to consider, collected by calling _ms_unprocessed[1] on the sample. + mz_min : float + Identifies lower bound of the weights to use to find a peak. + mz_max : float + Identifies upper bound of the weights to use to find a peak. + st_min : float + Identifies lower bound of the scan times to use to find a peak. + st_max : float + Identifies upper bound of the scan times to use to find a peak. + set_id : str + Indicates string used as ID in LCMSMassFeature. + obj_idx : int + Identifies index of sample in a collection that LCMSMassFeature should be assigned to. Defaults to 0 and is not used + if data provided is an LCMSBase instead of an LCMSCollection. + st_aligned : boolean + Indicates whether to call scan time from scan_time or from scan_time_aligned if using a collection. Defaults to False. + + Returns + -------- + LCMSMassFeature + Object from ChromaPeak that contains data on selected MS1 peak. If no peak is found, will contain missing + information and list the apex scan value as -99. + + Raises + ------ + Warning + If appropriate scan time data is not contained in ms1df. + """ + # Convert single feature to arrays and call batch method + results = self.search_for_targeted_mass_features_batch( + ms1df, + np.array([mz_min]), + np.array([mz_max]), + np.array([st_min]), + np.array([st_max]), + [set_id], + obj_idx=obj_idx, + st_aligned=st_aligned + ) + return results[set_id] + def __len__(self): """ @@ -1285,3 +1718,990 @@ def tic(self, tic_list): A list of TIC values for the dataset. """ self._tic_list = np.array(tic_list) + +class LCMSCollection(LCMSCollectionCalculations): + """A class representing a collection of liquid chromatography-mass spectrometry (LC-MS) runs. + These runs can be from the same or different samples, but must be from the same instrument and have the same parameters + for the initial processing steps. The LCMS objects are stored in an ordered dictionary with the sample name as the key. + + Parameters + ----------- + + Attributes + ----------- + + Methods + -------- + + Notes + ------ + This class is not intended to be instantiated directly, but rather instantiated using a parser object and then interacted with. + #TODO KRH: add docstrings + """ + + def __init__( + self, + collection_location, + manifest, + collection_parser=None + ): + self.collection_location = collection_location + self._manifest_dict = manifest + self.collection_parser = collection_parser + self.raw_files_relocated = False + + # These attributes are generally set by the parser during instantiation of this class + self._lcms = {} + self._combined_mass_features = None + self._combined_induced_mass_features = None + self.consensus_mass_features = {} + self._parameters = LCMSCollectionParameters() + self.isotopes_dropped = False + self._mass_features_locked = False # Prevents rebuilding mass_features_dataframe from samples + + # These attributes are set during processing + self.rt_aligned = False + self.rt_alignment_attempted = False + self.missing_mass_features_searched = False + + def _reorder_lcms_objects(self): + """ + Reorders the LCMS objects in the collection based on the order in the manifest. + """ + ordered_samples = self.samples + self._lcms = {k: self._lcms[k] for k in ordered_samples} + + def __getitem__(self, index): + if isinstance(index, (float, np.floating, np.ndarray)): + index = int(index) + elif isinstance(index, np.integer): + index = int(index) + samp_name = self.samples[index] + self._lcms[samp_name] + return self._lcms[samp_name] + + def __len__(self): + return len(self.samples) + + def _prepare_lcms_mass_features_for_combination(self, lcms_obj, induced_features = False): + """ + Prepares the mass features in the LCMS objects in the collection for combination. + """ + if induced_features: + mf_df = lcms_obj.mass_features_to_df(induced_features = True) + # Check if lcms_obj has attribute light_mf_df + elif hasattr(lcms_obj, "light_mf_df"): + mf_df = lcms_obj.light_mf_df + else: + mf_df = lcms_obj.mass_features_to_df() + + # If dataframe is empty, add minimal required columns and return + if len(mf_df) == 0: + import pandas as pd + mf_df["sample_name"] = [] + mf_df["sample_id"] = [] + mf_df["coll_mf_id"] = [] + mf_df["mf_id"] = [] + mf_df["_eic_mz"] = [] # Include _eic_mz for consistency with non-empty dataframes + if induced_features: + mf_df["cluster"] = [] + return mf_df + + # Remove index + mf_df = mf_df.reset_index(drop=False) + # Add sample name and sample id to the dataframe + mf_df["sample_name"] = lcms_obj.sample_name + # Ensure sample_id is stored as an integer to avoid float indices later + try: + mf_df["sample_id"] = int(self.manifest[lcms_obj.sample_name]["collection_id"]) + except Exception: + mf_df["sample_id"] = self.manifest[lcms_obj.sample_name]["collection_id"] + mf_df["coll_mf_id"] = mf_df["sample_id"].astype(str) + "_" + mf_df["mf_id"].astype(str) + + # For induced features, extract cluster from mf_id (format: c{cluster}_{index}_i) + # and add as a column since cluster_index attribute may not be set on the object + if induced_features: + def extract_cluster(mf_id): + # mf_id format: c{cluster}_{index}_i + # Example: c123_5_i -> cluster 123 + if isinstance(mf_id, str) and mf_id.startswith('c') and '_i' in mf_id: + parts = mf_id.split('_') + if len(parts) >= 2: + cluster_str = parts[0][1:] # Remove 'c' prefix + try: + return int(cluster_str) + except ValueError: + return None + return None + + mf_df['cluster'] = mf_df['mf_id'].apply(extract_cluster) + + # Check if scan_df has scan_time_aligned and add to mf_df if so + if "scan_time_aligned" in lcms_obj.scan_df.columns: + scan_df = lcms_obj.scan_df[["scan", "scan_time_aligned"]].copy() + scan_df = scan_df.rename(columns={"scan": "apex_scan"}) + mf_df = mf_df.merge(scan_df, on="apex_scan") + + return mf_df + + def _combine_mass_features(self, induced_features = False): + """ + Concatenates the mass features from all the LCMS objects in the collection. + + Returns + -------- + None, sets the _combined_mass_features or _combined_induced_mass_feature attribute. + + Notes + ----- + If _mass_features_locked is True (e.g., when only representative features are loaded), + this method will skip rebuilding the regular mass features dataframe to preserve + the full collection-level dataframe. Induced features are always rebuilt since they + are created during processing. + """ + + # Skip rebuilding regular mass features if locked (preserves full dataframe) + if not induced_features and self._mass_features_locked: + return + + ## TODO: See why this function runs slower on multiprocessing, + ## especially for induced features + ## has only been considered so far on ~20 samples +# if self.parameters.lcms_collection.cores == 1: +# # Prepare mass features for combination sequentially +# mf_df_list = [] +# for lcms_obj in self: +# mf_df = self._prepare_lcms_mass_features_for_combination(lcms_obj, induced_features) +# mf_df_list.append(mf_df) + +# if self.parameters.lcms_collection.cores > 1: +# # Parallelize the mass feature preparation +# if self.parameters.lcms_collection.cores > len(self): +# ncores = len(self) +# else: +# ncores = self.parameters.lcms_collection.cores +# pool = multiprocessing.Pool(ncores) +# mf_df_list = pool.starmap( +# self._prepare_lcms_mass_features_for_combination, +# [(lcms_obj, induced_features) for lcms_obj in self] +# ) + + # Prepare mass features for combination sequentially + mf_df_list = [] + for lcms_obj in self: + # Skip samples with no induced mass features if processing induced features + if induced_features: + if not hasattr(lcms_obj, 'induced_mass_features') or len(lcms_obj.induced_mass_features) == 0: + continue + mf_df = self._prepare_lcms_mass_features_for_combination(lcms_obj, induced_features) + mf_df_list.append(mf_df) + + # If no mass features were collected (e.g., no induced features exist), return early + if len(mf_df_list) == 0: + # Add a warning here, not sure how one might reach this state, clearly saying if they are induced features or not + warnings.warn("No mass features found to combine in the collection.", UserWarning) + if induced_features: + self._combined_induced_mass_features = None + else: + self._combined_mass_features = None + return + + combined_mass_features = pd.concat(mf_df_list) + # Ensure sample_id and cluster columns have integer dtypes where possible + if "sample_id" in combined_mass_features.columns: + try: + combined_mass_features["sample_id"] = combined_mass_features["sample_id"].astype(int) + except Exception: + combined_mass_features["sample_id"] = pd.to_numeric( + combined_mass_features["sample_id"], errors="coerce" + ).astype("Int64") + if "cluster" in combined_mass_features.columns: + try: + combined_mass_features["cluster"] = combined_mass_features["cluster"].astype(int) + except Exception: + combined_mass_features["cluster"] = pd.to_numeric( + combined_mass_features["cluster"], errors="coerce" + ).astype("Int64") + # Move coll_mf_id, sample_name, sample_id, and mf_id to front + cols = combined_mass_features.columns.tolist() + top_cols = ["coll_mf_id", "sample_name", "sample_id", "mf_id", "mz", "scan_time_aligned", "cluster"] + cols = [x for x in top_cols + [col for col in cols if col not in top_cols] if x in cols] + combined_mass_features = combined_mass_features[cols] + # Make coll_mf_id the index + combined_mass_features = combined_mass_features.set_index("coll_mf_id") + if induced_features == True: + self._combined_induced_mass_features = combined_mass_features + else: + self._combined_mass_features = combined_mass_features + + def _check_mass_features_df(self, induced_features = False): + """Checks if the mass features dataframe has expected columns. If not, adds them. + + Returns + -------- + pandas.DataFrame + A pandas dataframe of mass features in the collection. + + Notes + ------ + If scan_time_aligned is not in the _combined_mass_features or + _combined_induced_mass_features, tries to add it. + + """ + + if induced_features: + cmf_df = self._combined_induced_mass_features + else: + cmf_df = self._combined_mass_features + # Check if parameters are set to drop isotopologues and drop if so + if self.parameters.lcms_collection.drop_isotopologues: + if not self.isotopes_dropped: + self._drop_isotopologues() + # Check if scan_time_aligned is in combined_mass_features, try to add if not + if cmf_df is not None and "scan_time_aligned" not in cmf_df.columns: + cmb_mf = cmf_df.copy() + cmb_mf = cmb_mf.reset_index(drop=False) + lcms_aligned = [True for x in self if "scan_time_aligned" in x.scan_df.columns] + if len(lcms_aligned) == len(self): + # Add scan_time_aligned to combined_mass_features dataframe + scan_time_aligned_list = [] + for lcms_obj in self: + scan_time_df_i = lcms_obj.scan_df[["scan", "scan_time_aligned"]] + scan_time_df_i["sample_name"] = lcms_obj.sample_name + scan_time_aligned_list.append(scan_time_df_i) + scan_time_aligned_df = pd.concat(scan_time_aligned_list) + # Rename scan to apex_scan + scan_time_aligned_df = scan_time_aligned_df.rename(columns={"scan": "apex_scan"}) + cmb_mf_merged = cmb_mf.merge(scan_time_aligned_df, on=["apex_scan", "sample_name"]) + cmb_mf_merged = cmb_mf_merged.set_index("coll_mf_id") + # Merge scan_time_aligned_df with combined_mass_features on apex_scan and sample_name + if induced_features: + self._combined_induced_mass_features = cmb_mf_merged + else: + self._combined_mass_features = cmb_mf_merged + + def plot_tics(self, ms_level=1, type = "raw", plot_legend=False): + """Plots the TICs for all the LCMS objects in the collection. + + Parameters + ----------- + ms_level : int, optional + The MS level to plot the TICs for. Defaults to 1. + type : str, optional + The type of TIC to plot, either "raw" or "corrected" or "both". Defaults to "raw". + plot_legend : bool, optional + If True, plots a legend on the TIC plot that labels each sample. Defaults to False. + """ + to_plot = [] + if type == "both": + to_plot = ["raw", "corrected"] + else: + to_plot = [type] + + fig, axs = plt.subplots( + len(to_plot), 1, figsize=(10, 5 * len(to_plot)), sharex=True, squeeze=False + ) + + for i, plot_type in enumerate(to_plot): + ax = axs[i, 0] + colors = iter(plt.cm.rainbow(np.linspace(0, 1, len(self)))) + for lcms_obj in self: + c = next(colors) + # check if lcms_obj is the center of the collection + self.manifest_dataframe[self.manifest_dataframe['center']].collection_id.values + + + scan_df = lcms_obj.scan_df + scan_df = scan_df[scan_df.ms_level == ms_level] + if plot_type == "corrected": + # Check that scan_time_aligned is key in scan_df + if "scan_time_aligned" not in scan_df.columns: + raise ValueError(f"scan_time_aligned not found in scan_df for {lcms_obj.sample_name}") + else: + ax.plot(scan_df.scan_time_aligned, scan_df.tic, label=lcms_obj.sample_name, c=c, linewidth=0.3) + elif plot_type == "raw": + ax.plot(scan_df.scan_time, scan_df.tic, label=lcms_obj.sample_name, c=c, linewidth=0.3) + ax.set_xlabel("Retention Time (min," + f" {plot_type})" ) + ax.set_ylabel("TIC") + if plot_legend: + ax.legend() + plt.show() + + def plot_alignments(self, plot_legend=False): + """Plots the alignment of the LCMS objects in the collection. + + Parameters + ----------- + plot_legend : bool, optional + If True, plots a legend on the alignment plot that labels each sample. Defaults to False. + """ + fig, ax = plt.subplots(figsize=(10, 5)) + colors = iter(plt.cm.rainbow(np.linspace(0, 1, len(self)))) + + for lcms_obj in self: + c = next(colors) + scan_df = lcms_obj.scan_df + if "scan_time_aligned" not in scan_df.columns: + raise ValueError(f"scan_time_aligned not found in scan_df for {lcms_obj.sample_name}") + scan_df['time_diff'] = scan_df.scan_time - scan_df.scan_time_aligned + ax.plot(scan_df.scan_time_aligned, scan_df.time_diff, label=lcms_obj.sample_name, c=c, linewidth=0.3) + + ax.set_xlabel("Aligned Retention Time (min)") + ax.set_ylabel("Time Difference (min)") + if plot_legend: + ax.legend() + plt.show() + + def _drop_isotopologues(self): + """Drops isotopologues from the mass features in combined_mass_features dataframe.""" + cmb_mf_df = self._combined_mass_features + + # Keep monos or if no monos + cmb_monos = cmb_mf_df[cmb_mf_df.monoisotopic_mf_id == cmb_mf_df.mf_id] + cmb_nomonos = cmb_mf_df[cmb_mf_df.monoisotopic_mf_id.isnull()] + # Keep deconvoluted parent or if no deconvoluted parent + cmb_decon_parent = cmb_mf_df[cmb_mf_df.mass_spectrum_deconvoluted_parent | cmb_mf_df.monoisotopic_mf_id.isnull()] + + cmb_mf_df2 = pd.concat([cmb_monos, cmb_nomonos, cmb_decon_parent]) + cmb_mf_df2 = cmb_mf_df2[~cmb_mf_df2.index.duplicated(keep='first')] + self.isotopes_dropped = True + self._combined_mass_features = cmb_mf_df2 + + + def load_raw_data(self, sample_idx: int, ms_level = 1, time_range = None) -> None: + """Load raw data for a specific sample index in the collection. + + Parameters + ----------- + sample_idx : int + The index of the sample in the collection. + ms_level : int, optional + The MS level to load raw data for. Defaults to 1. + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be a single tuple (min, max) or + a list of tuples for multiple ranges. If None, loads all data. Defaults to None. + + Raises + ------- + IndexError + If the sample index is out of range. + ValueError + If raw data for the specified MS level is already loaded for the sample index. + ValueError + If the spectra parser is not set for the LCMS object or if the parser type does not support loading raw data. + + Returns + -------- + None, but updates the LCMS object with the raw data for the specified MS level. + """ + if sample_idx < 0 or sample_idx >= len(self.samples): + raise IndexError("Sample index out of range.") + + # Check that the sample does not already have raw data loaded + if ms_level in self[sample_idx]._ms_unprocessed: + raise ValueError(f"Raw data for MS{ms_level} already loaded for sample index {sample_idx}. Drop data first if you want to reload it.") + + # Check the parser type of the LCMS object + if self[sample_idx].spectra_parser is None: + raise ValueError("Spectra parser is not set for this LCMS object.") + + # Instantiate the parser and load the raw data using the correct method + parser = self[sample_idx].spectra_parser + parser_class_name = self[sample_idx].spectra_parser_class.__name__ + scan_df = self[sample_idx].scan_df + + # Get raw data for the specified MS level using the appropriate method + if parser_class_name == "ImportMassSpectraThermoMSFileReader": + self[sample_idx]._ms_unprocessed[ms_level] = parser.get_ms_raw( + spectra=f"ms{ms_level}", + scan_df=scan_df, + time_range=time_range + )[f"ms{ms_level}"] + + elif parser_class_name == "MZMLSpectraParser": + data = parser.load() + self[sample_idx]._ms_unprocessed[ms_level] = parser.get_ms_raw( + spectra=f"ms{ms_level}", + scan_df=scan_df, + data=data, + time_range=time_range + )[f"ms{ms_level}"] + + elif parser_class_name == "ReadCoreMSHDFMassSpectra": + raise ValueError( + "ReadCoreMSHDFMassSpectra does not have a method to load raw data. Need to instantiate the original parser to access the raw data." + ) + + def drop_raw_data(self, sample_idx: int, ms_level = 1) -> None: + """Drop raw data for a specific sample index in the collection. + + Parameters + ----------- + sample_idx : int + The index of the sample in the collection. + ms_level : int, optional + The MS level to drop raw data for. Defaults to 1. + + Raises + ------- + IndexError + If the sample index is out of range. + ValueError + If raw data for the specified MS level is not loaded for the sample index. + + Returns + -------- + None + """ + if sample_idx < 0 or sample_idx >= len(self.samples): + raise IndexError("Sample index out of range.") + + # Check that the sample has raw data loaded + if ms_level not in self[sample_idx]._ms_unprocessed: + raise ValueError(f"No raw data for MS{ms_level} found for sample index {sample_idx}. Load data first if you want to drop it.") + + # Drop the raw data + del self[sample_idx]._ms_unprocessed[ms_level] + + def update_raw_file_locations(self, new_raw_folder): + """Update the raw file locations for all LCMS objects in the collection. + + This method updates the path to the original raw data files (.raw, .mzML, etc.) + that were used to create the processed HDF5 files stored in .corems folders. + + Parameters + ----------- + new_raw_folder : str or Path + The new folder location containing the raw data files (.raw, .mzML, etc.). + The method will look for raw files with the same base name as each sample. + + Raises + ------- + FileNotFoundError + If the new raw folder does not exist. + FileNotFoundError + If a raw file for a sample is not found in the new folder. + + Returns + -------- + None, but updates the raw_file_location for each LCMS object in the collection. + + Examples + -------- + If raw files were moved from /old/path/ to /new/path/: + >>> lcms_collection.update_raw_file_locations("/new/path/") + """ + from pathlib import Path + + if isinstance(new_raw_folder, str): + new_raw_folder = Path(new_raw_folder) + + if not new_raw_folder.exists(): + raise FileNotFoundError(f"Raw data folder does not exist: {new_raw_folder}") + + # Common raw file extensions + raw_extensions = ['.raw', '.mzML', '.mzml'] + + for sample_name in self.samples: + lcms_obj = self._lcms[sample_name] + + # Try to find the raw file with common extensions + new_raw_file = None + for ext in raw_extensions: + candidate = new_raw_folder / f"{sample_name}{ext}" + if candidate.exists(): + new_raw_file = candidate + break + + if new_raw_file is None: + raise FileNotFoundError( + f"Raw file for sample '{sample_name}' not found in {new_raw_folder}. " + f"Tried extensions: {', '.join(raw_extensions)}" + ) + + # Update the raw file location and set flag that raw files have been relocated + lcms_obj.raw_file_location = new_raw_file + self.raw_files_relocated = True + + def collection_pivot_table(self, attribute = 'coll_mf_id', verbose = True): + """Generate a pivot table of all regular and induced mass features in + a collection. Default attribute presented is the mass feature ID, also + prints a list of other available attributes. + + Parameters + ----------- + attribute : str + The desired attribute to be presented in the pivot table. Defaults + to mass feature ID + verbose : boolean + Print out all the possible values the fill the pivot table and list + attributes that are not collected for induced mass features + + Returns + -------- + pd.DataFrame + A DataFrame that displays one given attribute across all clusters + and samples in a collection + + """ + + mf_pivot = self.mass_features_dataframe.copy() + mf_pivot.reset_index(inplace = True) + + # Only include induced mass features if gap-filling has been performed + if self.induced_mass_features_dataframe is not None: + imf_pivot = self.induced_mass_features_dataframe.copy() + imf_pivot.reset_index(inplace = True) + # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination + mf_pivot = pd.concat([mf_pivot, imf_pivot], axis = 0) + mf_pivot.reset_index(drop = True, inplace = True) + else: + imf_pivot = None + + mf_pivot['cluster'] = mf_pivot['cluster'].astype(int) + + if verbose: + print( + 'Attributes available for pivot table:\n', + [x for x in mf_pivot.columns if x not in ['cluster', 'sample_name', 'mf_id', 'partition_idx', 'idx']] + ) + if imf_pivot is not None: + print( + '\nAttributes that have no value for induced mass features:\n', + imf_pivot.columns[imf_pivot.isna().all()].tolist() + ) + + # Create pivot table and reindex to include all samples (even those with no features) + pivot = mf_pivot.pivot(index = 'cluster', columns = 'sample_name', values = attribute) + + # Reindex columns to include all samples in the collection + all_samples = self.samples + pivot = pivot.reindex(columns=all_samples) + + return pivot + + def cluster_representatives_table(self): + """Generate a table of representative mass features from each consensus cluster. + + This method returns a DataFrame containing all attributes for the + representative mass feature from each consensus cluster. The representative + is selected using the same logic as process_consensus_features(). + + Returns + -------- + pd.DataFrame + A DataFrame with one row per cluster containing all attributes for + each cluster's representative mass feature. Includes: + - cluster: cluster ID (as a column for easy joining) + - polarity: ionization polarity from the collection + - n_samples_detected: number of samples where the cluster was detected + - All other mass feature attributes from the representative + + Notes + ----- + The representative metric used is determined by + self.parameters.lcms_collection.consensus_representative_metric and + is the same metric used by process_consensus_features() for consistency. + Common options include 'intensity' (highest intensity) or + 'intensity_prefer_ms2' (highest intensity with preference for MS2 data). + """ + + mf_df = self.mass_features_dataframe.copy() + mf_df.reset_index(inplace = True) + + # Include induced mass features if they exist (from gap-filling) + if self.induced_mass_features_dataframe is not None: + imf_df = self.induced_mass_features_dataframe.copy() + imf_df.reset_index(inplace = True) + # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination + mf_df = pd.concat([mf_df, imf_df], axis = 0) + mf_df.reset_index(drop = True, inplace = True) + mf_df['cluster'] = mf_df['cluster'].astype(int) + + # Calculate number of samples per cluster + cluster_sample_counts = mf_df.groupby('cluster')['sample_id'].nunique().to_dict() + + # Use the same representative selection logic as process_consensus_features + # This uses the configured representative_metric from parameters + representatives = self.get_representative_mass_features_for_all_clusters() + + # Get the coll_mf_ids of the representatives + representative_ids = representatives['coll_mf_id'].tolist() + + # Filter mf_df to only include representative features + consensus_report = mf_df[mf_df.coll_mf_id.isin(representative_ids)].copy() + + # Add polarity (get from first sample in collection) + if len(self) > 0: + polarity = self[0].polarity + else: + polarity = 'unknown' + consensus_report['polarity'] = polarity + + # Add number of samples detected + consensus_report['n_samples_detected'] = consensus_report['cluster'].map(cluster_sample_counts) + + # Reorder columns to put cluster at the front + cols = consensus_report.columns.tolist() + if 'cluster' in cols: + cols.remove('cluster') + cols = ['cluster'] + cols + consensus_report = consensus_report[cols] + + # Sort by cluster and return with cluster as a regular column + return consensus_report.sort_values(by='cluster') + + def feature_annotations_table( + self, + molecular_metadata=None, + drop_unannotated=False, + report_best_only=False + ): + """Generate a comprehensive annotation table for all loaded mass features across samples. + + This method consolidates MS1 molecular formula assignments and MS2 spectral + search results for all mass features across all samples in the collection. + Only includes representative mass features (one per cluster per sample). + + Parameters + ---------- + molecular_metadata : dict, optional + Dictionary of MolecularMetadata objects, keyed by metabref_mol_id. + Required for including molecular metadata in MS2 annotations. + Default is None. + drop_unannotated : bool, optional + If True, drops rows where all annotation columns (everything except + cluster, MS2 Spectrum, and representative_sample) are NaN. + Default is False. + report_best_only : bool, optional + If True, only includes the best MS2 annotation per mass feature based on confidence score. + Default is False, which includes all MS2 annotations for each mass feature. + + Returns + ------- + pd.DataFrame + Consolidated annotation report with columns including: + - cluster: cluster ID + - sample_name: sample name + - sample_id: sample ID + - Mass Feature ID: mass feature ID within the sample + - Mass feature attributes (mz, scan_time, intensity, etc.) + - MS1 annotations (if molecular_formula_search was run) + - MS2 annotations (if ms2_spectral_search was run) + + Notes + ----- + This method uses the standard LCMSMetabolomicsExport.to_report() workflow + for each sample, then consolidates all results and adds cluster information. + + Only mass features that are loaded in each sample's mass_features dict + are included (typically the representative features if load_representatives + was used in process_consensus_features). + + Raises + ------ + ValueError + If no representative features have been loaded. Call process_consensus_features + with load_representatives=True first. + ValueError + If no samples with loaded mass features are found in the collection. + """ + from corems.mass_spectra.output.export import LCMSMetabolomicsExport + import warnings + + # Check if representative features have been loaded + # Count samples with mass features loaded + samples_with_features = sum( + 1 for lcms_obj in self + if hasattr(lcms_obj, 'mass_features') and len(lcms_obj.mass_features) > 0 + ) + + if samples_with_features == 0: + raise ValueError( + "No representative mass features have been loaded into individual samples. " + "Call process_consensus_features() with load_representatives=True before " + "calling feature_annotations_table()." + ) + + # Collect reports from all samples + all_sample_reports = [] + has_any_ms2_annotations = False + + for sample_id, lcms_obj in enumerate(self): + # Skip samples with no loaded mass features + if not hasattr(lcms_obj, 'mass_features') or len(lcms_obj.mass_features) == 0: + continue + + sample_name = self.samples[sample_id] + + # Create exporter and generate report using standard workflow + # Suppress individual warnings - we'll warn at collection level if needed + exporter = LCMSMetabolomicsExport("temp", lcms_obj) + sample_report = exporter.to_report(molecular_metadata=molecular_metadata, suppress_warnings=True) + + # Check if this sample has any MS2 annotations + ms2_cols = [col for col in sample_report.columns if 'Entropy Similarity' in col or 'spectral_similarity' in col.lower()] + if ms2_cols and sample_report[ms2_cols].notna().any().any(): + has_any_ms2_annotations = True + + # Add sample information + sample_report['representative_sample'] = sample_name + sample_report['sample_id'] = sample_id + + # Get cluster information from the mass_features_dataframe + # Build coll_mf_id for each row to look up cluster + sample_report['coll_mf_id'] = sample_report['sample_id'].astype(str) + "_" + sample_report['Mass Feature ID'].astype(str) + + # Get cluster from mass_features_dataframe + if self.mass_features_dataframe is not None and 'cluster' in self.mass_features_dataframe.columns: + mf_df = self.mass_features_dataframe.reset_index() + cluster_lookup = mf_df.set_index('coll_mf_id')['cluster'].to_dict() + sample_report['cluster'] = sample_report['coll_mf_id'].map(cluster_lookup) + else: + sample_report['cluster'] = None + + # Drop temporary coll_mf_id column + sample_report = sample_report.drop(columns=['coll_mf_id']) + + all_sample_reports.append(sample_report) + + # Combine all sample reports + if len(all_sample_reports) == 0: + raise ValueError("No samples with loaded mass features found in collection") + + collection_report = pd.concat(all_sample_reports, ignore_index=True) + + # Warn only if NO samples in the collection have MS2 annotations + if not has_any_ms2_annotations: + warnings.warn( + "No MS2 annotations found across any samples in collection, were MS2 spectra added and searched against a database?", + UserWarning, + ) + + # Reorder columns to match specified order + desired_cols = [ + 'cluster', + 'Isotopologue Type', + 'Is Largest Ion after Deconvolution', + 'MS2 Spectrum', + 'Calculated m/z', + 'm/z Error (ppm)', + 'm/z Error Score', + 'Isotopologue Similarity', + 'Confidence Score', + 'Ion Formula', + 'Ion Type', + 'Molecular Formula', + 'inchikey', + 'name', + 'ref_ms_id', + 'Entropy Similarity', + 'Library mzs in Query (fraction)', + 'Spectra with Annotation (n)', + 'representative_sample' + ] + + # Include only desired columns that exist, maintaining order + cols = [col for col in desired_cols if col in collection_report.columns] + collection_report = collection_report[cols] + + # Optionally drop rows without any annotations + if drop_unannotated: + # Columns to exclude from the "all NA" check + exclude_cols = ['cluster', 'MS2 Spectrum', 'representative_sample'] + # Get annotation columns (everything except the excluded ones) + annot_cols = [col for col in collection_report.columns if col not in exclude_cols] + # Keep rows where at least one annotation column is not NA + if len(annot_cols) > 0: + collection_report = collection_report[collection_report[annot_cols].notna().any(axis=1)] + + # Sort by cluster, then by annotation quality + sort_cols = ['cluster'] + if 'Entropy Similarity' in collection_report.columns: + sort_cols.extend(['Entropy Similarity', 'Confidence Score']) + collection_report = collection_report.sort_values( + by=sort_cols, + ascending=[True, False, False] + ) + elif 'Confidence Score' in collection_report.columns: + sort_cols.append('Confidence Score') + collection_report = collection_report.sort_values( + by=sort_cols, + ascending=[True, False] + ) + else: + collection_report = collection_report.sort_values(by=sort_cols) + + if report_best_only: + # Keep only the best annotation per cluster based on the first annotation column available + if 'Entropy Similarity' in collection_report.columns: + best_annot_col = 'Entropy Similarity' + elif 'Confidence Score' in collection_report.columns: + best_annot_col = 'Confidence Score' + else: + best_annot_col = None + + if best_annot_col is not None: + collection_report = collection_report.sort_values(by=['cluster', best_annot_col], ascending=[True, False]) + collection_report = collection_report.drop_duplicates(subset=['cluster'], keep='first') + + return collection_report + + @property + def parameters(self): + """ + LCMSCollectionParameters : The parameters used for the LCMS collection. + """ + return self._parameters + + @parameters.setter + def parameters(self, paramsinstance): + """ + Sets the parameters used for the LCMS analysis collection. + + Parameters + ----------- + paramsinstance : LCMSCollectionParameters + The parameters used for the LC-MS analysis. + """ + self._parameters = paramsinstance + + @property + def mass_features_dataframe(self): + self._check_mass_features_df() + return self._combined_mass_features + + @mass_features_dataframe.setter + def mass_features_dataframe(self, df): + # Check that the dataframe has the expected columns + expected_cols = ["sample_name", "sample_id", "mz", "scan_time"] + if not all([col in df.columns for col in expected_cols]): + raise ValueError(f"Expected columns not found in dataframe: {expected_cols}") + + # Check that coll_mf_id is the index and it is unique + if df.index.name != "coll_mf_id": + raise ValueError("coll_mf_id must be the index of the dataframe") + if not df.index.is_unique: + raise ValueError("coll_mf_id must be unique") + self._combined_mass_features = df + + @property + def induced_mass_features_dataframe(self): + self._check_mass_features_df(induced_features = True) + if self._combined_induced_mass_features is not None and len(self._combined_induced_mass_features) > 0: + # The cluster column is extracted from mf_id in _prepare_lcms_mass_features_for_combination + # mf_id format for induced features: c{cluster}_{index}_i + pass + return self._combined_induced_mass_features + + @induced_mass_features_dataframe.setter + def induced_mass_features_dataframe(self, df): + # Check that the dataframe has the expected columns + expected_cols = ["sample_name", "sample_id", "mz", "scan_time"] + if not all([col in df.columns for col in expected_cols]): + raise ValueError(f"Expected columns not found in dataframe: {expected_cols}") + + # Check that coll_mf_id is the index and it is unique + if df.index.name != "coll_mf_id": + raise ValueError("coll_mf_id must be the index of the dataframe") + if not df.index.is_unique: + raise ValueError("coll_mf_id must be unique") + self._combined_induced_mass_features = df + + @property + def cluster_summary_dataframe(self): + return self.summarize_clusters() + + @property + def samples(self): + manifest_df = self.manifest_dataframe + # order by batch, then by order + manifest_df = manifest_df.sort_values(by=['batch', 'order']) + return manifest_df.index.tolist() + + @property + def manifest(self): + return self._manifest_dict + + @property + def manifest_dataframe(self): + return pd.DataFrame(self._manifest_dict).T + + @property + def raw_files(self): + """Returns a list of raw files in the collection.""" + return [x.raw_file_location for x in self] + + @property + def rt_alignments(self): + """Returns a dictionary of retention time alignments for the collection.""" + if self.rt_aligned: + _rt_alignments = {} + # Construct a dictionary of aligned retention times (stored on each LCMS object within the collection, not the collection itself) + for i, lcms_obj in enumerate(self): + aligned_times = [x for k, x in sorted(lcms_obj._scan_info["scan_time_aligned"].items())] + _rt_alignments[i] = aligned_times + return _rt_alignments + else: + return None + + @property + def cluster_feature_dictionary(self): + """Generates a dictionary with clusters for keys and mass feature IDs as entries""" + df = self.mass_features_dataframe + cluster_dict = df.groupby('cluster').apply(lambda x: x.index.tolist()).to_dict() + return cluster_dict + + def get_eics_for_cluster(self, cluster_id): + """ + Retrieve all EICs for mass features in a specific cluster across all samples. + + Returns a dictionary mapping sample names to EIC_Data objects for the given cluster. + Useful for visualizing and comparing chromatographic peaks across samples. + + Parameters + ---------- + cluster_id : int + The cluster ID to retrieve EICs for + + Returns + ------- + dict + Dictionary with structure: {sample_name: EIC_Data object} + Only includes samples where the EIC was loaded. + + Examples + -------- + >>> # Load EICs first + >>> collection.process_consensus_features(gather_eics=True, ...) + >>> + >>> # Get all EICs for cluster 5 + >>> eics = collection.get_eics_for_cluster(5) + >>> for sample_name, eic_data in eics.items(): + ... print(f"{sample_name}: {len(eic_data.scans)} scans") + + Notes + ----- + Requires that EICs have been loaded using gather_eics=True in + process_consensus_features() or manually loaded via LoadEICsOperation. + """ + eics_by_sample = {} + + # Iterate through all samples + for sample_id, sample in enumerate(self): + sample_name = self.samples[sample_id] + + # Check if sample has EICs loaded + if not hasattr(sample, 'eics') or not sample.eics: + continue + + # Find mass features in this cluster for this sample + # Check both regular and induced mass features + for mf in list(sample.mass_features.values()) + list(sample.induced_mass_features.values()): + if hasattr(mf, 'cluster_index') and mf.cluster_index == cluster_id: + # Get the EIC for this mass feature's m/z + if mf.mz in sample.eics: + eics_by_sample[sample_name] = sample.eics[mf.mz] + break # Found the EIC for this sample, move to next sample + + return eics_by_sample \ No newline at end of file diff --git a/corems/mass_spectra/input/corems_hdf5.py b/corems/mass_spectra/input/corems_hdf5.py index a20e331ee..e9dc5cf38 100644 --- a/corems/mass_spectra/input/corems_hdf5.py +++ b/corems/mass_spectra/input/corems_hdf5.py @@ -3,8 +3,15 @@ from threading import Thread +import h5py +import toml +import json +import multiprocessing from pathlib import Path +import datetime +from typing import Union, Tuple, List, Optional +import numpy as np import pandas as pd import warnings @@ -13,7 +20,7 @@ load_and_set_json_parameters_lcms, load_and_set_toml_parameters_lcms, ) -from corems.mass_spectra.factory.lc_class import LCMSBase, MassSpectraBase +from corems.mass_spectra.factory.lc_class import LCMSBase, MassSpectraBase, LCMSCollection from corems.mass_spectra.factory.chromat_data import EIC_Data from corems.mass_spectra.input.parserbase import SpectraParserInterface from corems.mass_spectrum.input.coremsHDF5 import ReadCoreMSHDF_MassSpectrum @@ -22,6 +29,172 @@ from corems.mass_spectra.input.mzml import MZMLSpectraParser +def create_manifest_from_folder( + folder_path: Path, + output_path: Path = None, + batch_time_threshold_hours: float = 12.0, + center_name: str = None, + overwrite: bool = False +) -> Path: + """ + Create a manifest CSV file for ReadCoreMSHDFMassSpectraCollection from CoreMS HDF5 files. + + Scans a folder for .corems subdirectories and generates a manifest with columns: + sample_name, batch, order, center, time. Files are batched by creation time, and + one sample is designated as the retention time alignment center. + + Parameters + ---------- + folder_path : Path + Path to folder containing .corems subdirectories with HDF5 files. + output_path : Path, optional + Output manifest CSV path. Default: folder_path/manifest.csv. + batch_time_threshold_hours : float, optional + Time gap in hours for batch separation. Default: 12.0. + center_name : str, optional + Sample name to designate as RT alignment center (must exist in samples). + If None, the middle sample (by creation time) is used. + overwrite : bool, optional + Whether to overwrite existing manifest. Default: False. + + Returns + ------- + Path + Path to created manifest file. + + Raises + ------ + FileNotFoundError + If folder_path doesn't exist or contains no .corems subdirectories. + FileExistsError + If output file exists and overwrite is False. + ValueError + If no HDF5 files found, or center_name doesn't match any sample. + """ + if not folder_path.exists(): + raise FileNotFoundError(f"Folder {folder_path} does not exist.") + + # Set default output path if not provided + if output_path is None: + output_path = folder_path / "manifest.csv" + + # Check if output file exists + if output_path.exists() and not overwrite: + raise FileExistsError( + f"Manifest file {output_path} already exists. " + "Set overwrite=True to replace it." + ) + + # Find all .corems subdirectories + corems_dirs = sorted([d for d in folder_path.iterdir() if d.is_dir() and d.suffix == ".corems"]) + + if not corems_dirs: + raise FileNotFoundError( + f"No .corems subdirectories found in {folder_path}. " + "Ensure the folder contains processed CoreMS data." + ) + + # Collect sample information + sample_data = [] + + for corems_dir in corems_dirs: + sample_name = corems_dir.stem # Remove .corems extension + hdf5_file = corems_dir / f"{sample_name}.hdf5" + + if not hdf5_file.exists(): + print(f"Warning: HDF5 file not found for {sample_name}, skipping.") + continue + + # Get creation time using the ReadCoreMSHDFMassSpectra method + try: + # Use context manager to ensure file is properly closed + with ReadCoreMSHDFMassSpectra(str(hdf5_file)) as parser: + # Use the get_original_creation_time() method which checks HDF5 attrs first, + # then falls back to original parser if needed + creation_time = parser.get_original_creation_time() + + # Skip sample if creation time unavailable + if creation_time is None: + print(f"Warning: Could not get original creation time for {sample_name}, skipping.") + continue + + except Exception as e: + print(f"Warning: Error getting creation time for {sample_name}: {e}, skipping.") + continue + + sample_data.append({ + 'sample_name': sample_name, + 'creation_time': creation_time, + 'hdf5_path': hdf5_file + }) + + if not sample_data: + raise ValueError( + f"No valid HDF5 files found in {folder_path}. " + "Ensure .corems subdirectories contain .hdf5 files." + ) + + # Sort by creation time + sample_data.sort(key=lambda x: x['creation_time']) + + # Assign batches based on time threshold + batch_assignments = [] + current_batch = 1 + + for i, sample in enumerate(sample_data): + if i == 0: + batch_assignments.append(current_batch) + else: + time_diff = sample['creation_time'] - sample_data[i-1]['creation_time'] + time_diff_hours = time_diff.total_seconds() / 3600 + + if time_diff_hours > batch_time_threshold_hours: + current_batch += 1 + + batch_assignments.append(current_batch) + + # Determine which sample should be the center for retention time alignment + sample_names = [s['sample_name'] for s in sample_data] + + if center_name is not None: + # Validate that center_name is in the discovered samples + if center_name not in sample_names: + raise ValueError( + f"Specified center_name '{center_name}' not found in discovered samples. " + f"Available samples: {', '.join(sample_names)}" + ) + center_sample = center_name + else: + # Use the middle sample (by creation time) as center + middle_idx = len(sample_data) // 2 + center_sample = sample_data[middle_idx]['sample_name'] + print(f"Auto-selected center sample: {center_sample} (index {middle_idx} of {len(sample_data)}, middle by creation time)") + + # Create manifest dataframe with center column as TRUE/FALSE + manifest_df = pd.DataFrame({ + 'sample_name': sample_names, + 'batch': batch_assignments, + 'order': list(range(1, len(sample_data) + 1)), + 'center': ['TRUE' if name == center_sample else 'FALSE' for name in sample_names], + 'time': [s['creation_time'].strftime('%Y-%m-%dT%H:%M:%SZ') for s in sample_data] + }) + + # Sort manifest by time before saving to ensure proper order + manifest_df = manifest_df.sort_values('time').reset_index(drop=True) + # Update order column to reflect sorted order + manifest_df['order'] = list(range(1, len(manifest_df) + 1)) + + # Save manifest + manifest_df.to_csv(output_path, index=False) + + print(f"Manifest created successfully at {output_path}") + print(f"Total samples: {len(sample_data)}") + print(f"Number of batches: {current_batch}") + print(f"Batch assignments: {dict(zip(range(1, current_batch + 1), [batch_assignments.count(b) for b in range(1, current_batch + 1)]))}") + + return output_path + + class ReadCoreMSHDFMassSpectra( SpectraParserInterface, ReadCoreMSHDF_MassSpectrum, Thread ): @@ -108,6 +281,21 @@ def __init__(self, file_location: str): self.parameters_location = [x for x in add_files if x.suffix == ".toml"][0] else: self.parameters_location = None + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - closes the HDF5 file.""" + if hasattr(self, 'h5pydata') and self.h5pydata is not None: + self.h5pydata.close() + return False + + def close(self): + """Explicitly close the HDF5 file.""" + if hasattr(self, 'h5pydata') and self.h5pydata is not None: + self.h5pydata.close() def get_mass_spectrum_from_scan(self, scan_number): """Return mass spectrum data object from scan number.""" @@ -153,7 +341,7 @@ def load(self) -> None: """ """ pass - def get_ms_raw(self, spectra=None, scan_df=None) -> dict: + def get_ms_raw(self, spectra=None, scan_df=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None) -> dict: """ """ # Warn if spectra or scan_df are not None that they are not used for CoreMS HDF5 files and should be rerun after instantiation if spectra is not None or scan_df is not None: @@ -170,7 +358,7 @@ def get_ms_raw(self, spectra=None, scan_df=None) -> dict: ) return ms_unprocessed - def get_scan_df(self) -> pd.DataFrame: + def get_scan_df(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None) -> pd.DataFrame: scan_info = {} dict_group_load = self.h5pydata["scan_info"] dict_group_keys = dict_group_load.keys() @@ -182,9 +370,18 @@ def get_scan_df(self) -> pd.DataFrame: str_df = str_df.stack().str.decode("utf-8").unstack() for col in str_df: scan_df[col] = str_df[col] + + # Apply time range filtering if specified + if time_range is not None: + time_ranges = self._normalize_time_range(time_range) + mask = pd.Series([False] * len(scan_df), index=scan_df.index) + for start_time, end_time in time_ranges: + mask |= (scan_df.scan_time >= start_time) & (scan_df.scan_time <= end_time) + scan_df = scan_df[mask] + return scan_df - def run(self, mass_spectra, load_raw=True) -> None: + def run(self, mass_spectra, load_raw=True, load_light=False) -> None: """Runs the importer functions to populate a LCMS or MassSpectraBase object. Notes @@ -204,6 +401,9 @@ def run(self, mass_spectra, load_raw=True) -> None: The LCMS or MassSpectraBase object to populate with mass spectra, generally instantiated with only the file_location, analyzer, and instrument_label attributes. load_raw : bool If True, load raw data (unprocessed) from HDF5 files for overall lcms object and individual mass spectra. Default is True. + load_light : bool + If True, only load the parameters, mass features, and scan info. Default is False. + Returns ------- None, but populates several attributes on the LCMS or MassSpectraBase object. @@ -213,7 +413,7 @@ def run(self, mass_spectra, load_raw=True) -> None: # Populate the parameters attribute on the LCMS object self.import_parameters(mass_spectra) - if "mass_spectra" in self.h5pydata: + if "mass_spectra" in self.h5pydata and not load_light: # Populate the _ms list on the LCMS object self.import_mass_spectra(mass_spectra, load_raw=load_raw) @@ -221,7 +421,7 @@ def run(self, mass_spectra, load_raw=True) -> None: # Populate the _scan_info attribute on the LCMS object self.import_scan_info(mass_spectra) - if "ms_unprocessed" in self.h5pydata and load_raw: + if "ms_unprocessed" in self.h5pydata and load_raw and not load_light: # Populate the _ms_unprocessed attribute on the LCMS object self.import_ms_unprocessed(mass_spectra) @@ -229,11 +429,11 @@ def run(self, mass_spectra, load_raw=True) -> None: # Populate the mass_features attribute on the LCMS object self.import_mass_features(mass_spectra) - if "eics" in self.h5pydata: + if "eics" in self.h5pydata and not load_light: # Populate the eics attribute on the LCMS object self.import_eics(mass_spectra) - if "spectral_search_results" in self.h5pydata: + if "spectral_search_results" in self.h5pydata and not load_light: # Populate the spectral_search_results attribute on the LCMS object self.import_spectral_search_results(mass_spectra) @@ -314,13 +514,15 @@ def import_parameters(self, mass_spectra) -> None: "Parameters file must be in JSON format, TOML format is not yet supported." ) - def import_mass_features(self, mass_spectra) -> None: + def import_mass_features(self, mass_spectra, mf_ids=None) -> None: """Imports the mass features from the HDF5 file. Parameters ---------- mass_spectra : LCMSBase | MassSpectraBase The MassSpectraBase or LCMSBase object to populate with mass features. + mf_ids : list, optional + A list of mass feature IDs to import. If None, all mass features are imported. Returns ------- @@ -331,6 +533,8 @@ def import_mass_features(self, mass_spectra) -> None: dict_group_load = self.h5pydata["mass_features"] dict_group_keys = dict_group_load.keys() for k in dict_group_keys: + if mf_ids is not None and int(k) not in mf_ids: + continue # Instantiate the MassFeature object mass_feature = LCMSMassFeature( mass_spectra, @@ -374,13 +578,18 @@ def import_mass_features(self, mass_spectra) -> None: mass_spectra._ms[ms2_scan] ) - def import_eics(self, mass_spectra): + def import_eics(self, mass_spectra, mz_list=None, mz_tolerance=0.0001): """Imports the extracted ion chromatograms from the HDF5 file. Parameters ---------- mass_spectra : LCMSBase | MassSpectraBase The MassSpectraBase or LCMSBase object to populate with extracted ion chromatograms. + mz_list : list of float, optional + List of m/z values to load EICs for. If None, loads all EICs. Default is None. + mz_tolerance : float, optional + Tolerance in Daltons for matching m/z values when mz_list is provided. + Default is 0.0001 Da. Returns ------- @@ -390,7 +599,17 @@ def import_eics(self, mass_spectra): """ dict_group_load = self.h5pydata["eics"] dict_group_keys = dict_group_load.keys() + + # Prefilter dict_group_keys if mz_list is provided to EICs within tolerance + if mz_list is not None: + target_mz_array = np.array(sorted(mz_list)) + mzs = [float(k) for k in dict_group_keys if np.abs(float(k)-target_mz_array).min() < mz_tolerance] + dict_group_keys = [str(mz) for mz in mzs] + for k in dict_group_keys: + # Check if we should load this EIC (filter by m/z if list provided) + eic_mz = dict_group_load[k].attrs["mz"] + my_eic = EIC_Data( scans=dict_group_load[k]["scans"][:], time=dict_group_load[k]["time"][:], @@ -403,13 +622,64 @@ def import_eics(self, mass_spectra): if key == "apexes" and len(my_eic.apexes) > 0: my_eic.apexes = [tuple(x) for x in my_eic.apexes] # Add to mass_spectra object - mass_spectra.eics[dict_group_load[k].attrs["mz"]] = my_eic - - # Add to mass features - for idx in mass_spectra.mass_features.keys(): - mz = mass_spectra.mass_features[idx].mz - if mz in mass_spectra.eics.keys(): - mass_spectra.mass_features[idx]._eic_data = mass_spectra.eics[mz] + mass_spectra.eics[eic_mz] = my_eic + + # Associate EICs with mass features using tolerance-based matching + mass_spectra.associate_eics_with_mass_features() + + @staticmethod + def _load_eics_from_hdf5_group(eics_group, lcms_obj, mz_filter=None): + """Load EICs from an HDF5 group. + + This is a static helper method that can be reused to load EIC data + from any HDF5 group in a consistent format. + + Parameters + ---------- + eics_group : h5py.Group + The HDF5 group containing EIC data. + lcms_obj : LCMSBase + The LCMS object to associate EICs with (for reference, not modified). + mz_filter : list, optional + List of m/z values to load. If None, loads all EICs. Default is None. + Uses tolerance-based matching (0.0001). + + Returns + ------- + dict + Dictionary of EIC_Data objects keyed by m/z value. + """ + from corems.mass_spectra.factory.chromat_data import EIC_Data + + loaded_eics = {} + tolerance = 0.0001 + + for eic_key_str in eics_group.keys(): + eic_mz = float(eic_key_str) if eic_key_str.replace('.', '', 1).replace('-', '', 1).isdigit() else eics_group[eic_key_str].attrs.get("mz") + + # If mz_filter is provided, check if this EIC matches any requested m/z + if mz_filter is not None: + if not any(abs(eic_mz - mz) < tolerance for mz in mz_filter): + continue + + eic_data = eics_group[eic_key_str] + + # Create EIC_Data object from datasets + eic = EIC_Data( + scans=list(eic_data["scans"][:]) if "scans" in eic_data else [], + time=list(eic_data["time"][:]) if "time" in eic_data else [], + eic=list(eic_data["eic"][:]) if "eic" in eic_data else [], + apexes=list(eic_data["apexes"][:]) if "apexes" in eic_data else [], + ) + + # Load any additional datasets + for key in eic_data.keys(): + if key not in ["scans", "time", "eic", "apexes"]: + setattr(eic, key, eic_data[key][:]) + + loaded_eics[eic_mz] = eic + + return loaded_eics def import_spectral_search_results(self, mass_spectra): """Imports the spectral search results from the HDF5 file. @@ -437,7 +707,11 @@ def import_spectral_search_results(self, mass_spectra): ) for key in ms2_results_load[k][k2].keys() - {"precursor_mz"}: - setattr(ms2_search_res, key, list(ms2_results_load[k][k2][key][:])) + data = list(ms2_results_load[k][k2][key][:]) + if data and isinstance(data[0], bytes): + data = [x.decode("utf-8") for x in data] + setattr(ms2_search_res, key, data) + overall_results_dict[int(k)][ ms2_results_load[k][k2].attrs["precursor_mz"] ] = ms2_search_res @@ -464,7 +738,7 @@ def import_spectral_search_results(self, mass_spectra): ] ) - def get_mass_spectra_obj(self, load_raw=True) -> MassSpectraBase: + def get_mass_spectra_obj(self, load_raw=True, load_light=False, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None) -> MassSpectraBase: """ Return mass spectra data object, populating the _ms list on MassSpectraBase object from the HDF5 file. @@ -472,6 +746,14 @@ def get_mass_spectra_obj(self, load_raw=True) -> MassSpectraBase: ---------- load_raw : bool If True, load raw data (unprocessed) from HDF5 files for overall spectra object and individual mass spectra. Default is True. + load_light : bool + If True, only load the parameters, mass features, and scan info. Default is False. + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, loads all scans. Note: For HDF5 files, this parameter is accepted for + interface consistency but not currently used in filtering. """ # Instantiate the LCMS object @@ -483,12 +765,12 @@ def get_mass_spectra_obj(self, load_raw=True) -> MassSpectraBase: ) # This will populate the _ms list on the LCMS or MassSpectraBase object - self.run(spectra_obj, load_raw=load_raw) + self.run(spectra_obj, load_raw=load_raw, load_light=load_light) return spectra_obj def get_lcms_obj( - self, load_raw=True, use_original_parser=True, raw_file_path=None + self, load_raw=True, load_light=False, use_original_parser=True, raw_file_path=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None ) -> LCMSBase: """ Return LCMSBase object, populating attributes on the LCMSBase object from the HDF5 file. @@ -497,12 +779,21 @@ def get_lcms_obj( ---------- load_raw : bool If True, load raw data (unprocessed) from HDF5 files for overall lcms object and individual mass spectra. Default is True. + load_light : bool + If True, only load the parameters, mass features, and scan info. Default is False. use_original_parser : bool If True, use the original parser to populate the LCMS object. Default is True. raw_file_path : str The location of the raw file to parse if attempting to use original parser. Default is None, which attempts to get the raw file path from the HDF5 file. If the original file path has moved, this parameter can be used to specify the new location. + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, loads all scans. Note: For HDF5 files, this parameter is accepted for + interface consistency. If use_original_parser=True, time_range can be passed to the + original parser for filtering. """ # Instantiate the LCMS object lcms_obj = LCMSBase( @@ -513,7 +804,7 @@ def get_lcms_obj( ) # This will populate the majority of the attributes on the LCMS object - self.run(lcms_obj, load_raw=load_raw) + self.run(lcms_obj, load_raw=load_raw, load_light=load_light) # Set final attributes of the LCMS object lcms_obj.polarity = self.h5pydata.attrs["polarity"] @@ -542,7 +833,7 @@ def get_raw_file_location(self): return self.h5pydata.attrs["original_file_location"] else: return None - + def add_original_parser(self, mass_spectra, raw_file_path=None): """ Add the original parser to the mass spectra object. @@ -564,31 +855,91 @@ def add_original_parser(self, mass_spectra, raw_file_path=None): raise ValueError( "Raw file path not found in HDF5 file attributes, cannot instantiate original parser." ) - + # Set the raw file path on the mass_spectra object so the parser knows where to find the raw file mass_spectra.raw_file_location = raw_file_path if og_parser_type == "ImportMassSpectraThermoMSFileReader": # Check that the parser can be instantiated with the raw file path - parser = ImportMassSpectraThermoMSFileReader(raw_file_path) + parser_class = ImportMassSpectraThermoMSFileReader elif og_parser_type == "MZMLSpectraParser": # Check that the parser can be instantiated with the raw file path - parser = MZMLSpectraParser(raw_file_path) + parser_class = MZMLSpectraParser # Set the spectra parser class on the mass_spectra object so the spectra_parser property can be used with the original parser - mass_spectra.spectra_parser_class = parser.__class__ + mass_spectra.spectra_parser_class = parser_class return mass_spectra + def get_original_creation_time(self): + """ + Get the creation time of the original raw data file. + + First checks if creation_time is saved in the HDF5 file attributes. + If not found, attempts to instantiate the original parser and get the creation time. + + Returns + ------- + datetime + The creation time of the original raw data file, or None if not available. + """ + # Check if creation_time is saved in HDF5 attributes + if "creation_time" in self.h5pydata.attrs: + from datetime import datetime + return datetime.fromisoformat(self.h5pydata.attrs["creation_time"]) + + # Fall back to using original parser to get creation time + try: + # Get the original parser type and raw file path + og_parser_type = self.h5pydata.attrs.get("parser_type") + raw_file_path = self.get_raw_file_location() + + if og_parser_type is None or raw_file_path is None: + warnings.warn( + "Cannot retrieve creation time: parser_type or original_file_location not found in HDF5 attributes." + ) + return None + + # Check if raw file exists + from pathlib import Path + if not Path(raw_file_path).exists(): + warnings.warn( + f"Cannot retrieve creation time: original raw file not found at {raw_file_path}" + ) + return None + + # Instantiate the original parser + if og_parser_type == "ImportMassSpectraThermoMSFileReader": + parser = ImportMassSpectraThermoMSFileReader(raw_file_path) + elif og_parser_type == "MZMLSpectraParser": + parser = MZMLSpectraParser(raw_file_path) + else: + warnings.warn( + f"Unknown parser type: {og_parser_type}, cannot retrieve creation time." + ) + return None + + # Get creation time from parser + return parser.get_creation_time() + + except Exception as e: + warnings.warn( + f"Failed to retrieve creation time from original parser: {e}" + ) + return None + def get_creation_time(self): """ - Raise a NotImplemented Warning, as creation time is not available in CoreMS HDF5 files and returning None. + Get the creation time of the original raw data file. + + This is an alias for get_original_creation_time() for backward compatibility. + + Returns + ------- + datetime + The creation time of the original raw data file, or None if not available. """ - warnings.warn( - "Creation time is not available in CoreMS HDF5 files, returning None." - "This should be accessed through the original parser.", - ) - return None + return self.get_original_creation_time() def get_instrument_info(self): """ @@ -599,3 +950,761 @@ def get_instrument_info(self): "This should be accessed through the original parser.", ) return None + + def get_scans_in_time_range( + self, + time_range: Union[Tuple[float, float], List[Tuple[float, float]]], + ms_level: Optional[int] = None + ) -> List[int]: + """Return scan numbers within specified retention time range(s). + + Parameters + ---------- + time_range : tuple or list of tuples + Retention time range(s) in minutes. Can be: + - Single range: (start_time, end_time) + - Multiple ranges: [(start1, end1), (start2, end2), ...] + ms_level : int, optional + If specified, only return scans of this MS level (e.g., 1 for MS1, 2 for MS2). + If None, returns scans of all MS levels. + + Returns + ------- + list of int + List of scan numbers within the specified time range(s) and MS level. + """ + # Normalize time range to list of tuples + time_ranges = self._normalize_time_range(time_range) + + # Get all scan data + scan_df = self.get_scan_df() + + # Filter by time range + mask = pd.Series([False] * len(scan_df), index=scan_df.index) + for start_time, end_time in time_ranges: + mask |= (scan_df.scan_time >= start_time) & (scan_df.scan_time <= end_time) + + filtered_df = scan_df[mask] + + # Filter by MS level if specified + if ms_level is not None: + filtered_df = filtered_df[filtered_df.ms_level == ms_level] + + return filtered_df.scan.tolist() + + +class ReadCoreMSHDFMassSpectraCollection: + """Read a collection of CoreMS HDF5 files and populate an LCMSCollection object. + + Parameters + ---------- + folder_location : Path + Folder containing .corems subdirectories with HDF5 files. + manifest_file : Path, optional + Manifest CSV with columns: sample_name, order, batch, center, time. + One sample must have center='TRUE' for RT alignment. + If None, checks if auto-generated manifest_auto.csv exists in the folder. If not, + auto-generates from folder contents. Default: None. + cores : int, optional + Number of cores for multiprocessing. Default: 1. + auto_manifest_batch_threshold_hours : float, optional + Time gap (hours) for auto-generated batch separation. Default: 12.0. + auto_manifest_center_name : str, optional + Sample name for RT alignment center when auto-generating. + Must match a discovered sample. If None, uses middle sample. Default: None. + + Attributes + ---------- + folder_location : Path + Folder containing CoreMS HDF5 files. + manifest_filepath : Path + Path to manifest file. + manifest : dict + Manifest data indexed by sample_name. + """ + def __init__( + self, + folder_location: Path, + manifest_file: Path = None, + cores: int = 1, + auto_manifest_batch_threshold_hours: float = 12.0, + auto_manifest_center_name: str = None + ): + # Check for folder location + folder_location = Path(folder_location) + if not folder_location.exists(): + raise FileNotFoundError(f"Folder location {folder_location} not found.") + + # Auto-generate manifest if not provided + if manifest_file is None: + # Check if manifest_auto.csv already exists + auto_manifest_path = folder_location / "manifest_auto.csv" + if auto_manifest_path.exists(): + print(f"No manifest file provided. Using existing manifest_auto.csv from {folder_location}") + manifest_file = auto_manifest_path + else: + print(f"No manifest file provided. Auto-generating manifest from {folder_location}") + manifest_file = create_manifest_from_folder( + folder_path=folder_location, + output_path=auto_manifest_path, + batch_time_threshold_hours=auto_manifest_batch_threshold_hours, + center_name=auto_manifest_center_name, + overwrite=True + ) + else: + manifest_file = Path(manifest_file) + if not manifest_file.exists(): + raise FileNotFoundError(f"Manifest file {manifest_file} not found.") + + # Check if the manifest file is a CSV + if manifest_file.suffix != ".csv": + raise ValueError("Manifest file must be a CSV.") + + self.folder_location = folder_location + self._manifest_dict = None + self._parse_manifest(manifest_file) + self._validate_manifest() + self._validate_parameters() + self._validate_cores(cores) + + def _validate_cores(self, cores): + # Check if the cores parameter is an integer greater than 0 and less than the number of cores available + if not isinstance(cores, int) or cores < 1: + raise ValueError("Cores must be an integer greater than 0.") + if cores > multiprocessing.cpu_count(): + raise ValueError( + f"Cores must be less than or equal to the number of cores available ({multiprocessing.cpu_count()})." + ) + self._cores = cores + + def _parse_manifest(self, manifest_file): + """Parse the manifest file and set the manifest dictionary.""" + self.manifest_filepath = manifest_file + manifest = pd.read_csv(manifest_file) + # Check if the following columns exisit in the manifest file + if not all( + col in manifest.columns for col in ["sample_name", "order", "batch"] + ): + raise ValueError( + "Manifest file must contain the following columns: 'sample_name', 'order', 'batch'." + ) + # Set index to the 'sample_name' column + manifest.set_index("sample_name", inplace=True) + self._manifest_dict = manifest.to_dict(orient="index") + + def _validate_manifest(self): + """Validate the manifest dictionary against the CoreMS folder location.""" + # Check if the folder location contains HDF5 files for each sample + for sample_name in self._manifest_dict.keys(): + corems_dir = self.folder_location / f"{sample_name}.corems" + if not corems_dir.exists(): + raise FileNotFoundError(f"CoreMS folder for {sample_name} not found.") + hdf5_file = corems_dir / f"{sample_name}.hdf5" + if not hdf5_file.exists(): + raise FileNotFoundError(f"HDF5 file for {sample_name} not found.") + + # Check that at least one sample has center='TRUE' for retention time alignment + center_values = [sample_data.get('center') for sample_data in self._manifest_dict.values()] + if not any(center_val == 'TRUE' or center_val == True for center_val in center_values): + raise ValueError( + "Manifest must contain at least one sample with center='TRUE' for retention time alignment. " + "None of the samples in the manifest have center='TRUE'." + ) + + def _validate_parameters(self): + """Validate that the parameters used for all samples within a batch are the same.""" + # Check if parameters files are saved as JSON or TOML + if self.parameters_files[0].suffix == ".json": + importer = json + suffix = ".json" + + elif self.parameters_files[0].suffix == ".toml": + importer = toml + suffix = ".toml" + + manfiest_df = self.manifest_dataframe + + # Split up samples by batch + batches = manfiest_df["batch"].unique() + + for batch in batches: + samples = manfiest_df[manfiest_df["batch"] == batch].index + # check if self.parameters_files end with .json or .toml + batch_param_files = [ + self.folder_location / f"{sample_name}.corems/{sample_name}{suffix}" + for sample_name in self._manifest_dict.keys() + if sample_name in samples + ] + with open( + batch_param_files[0], + "r", + encoding="utf8", + ) as stream: + first_parameters = importer.load(stream) + for parameters_file in batch_param_files[1:]: + with open( + parameters_file, + "r", + encoding="utf8", + ) as stream: + parameters = importer.load(stream) + if parameters != first_parameters: + raise ValueError( + f"Parameters files for samples in batch {batch} are not equal." + ) + + def get_lcms_obj(self, sample_name: str, load_raw=False, load_light=True, use_original_parser=True, raw_file_path=None) -> LCMSBase: + """Return a LCMSBase object for a given sample name within the collection. + + Parameters + ---------- + sample_name : str + The sample name to retrieve the LCMS object for. + load_raw : bool + If True, load raw data from HDF5 files. Default is False. + load_light : bool + If True, only load the parameters, mass features, and scan info are initially loaded for each lcms object. Default is True. + """ + hdf5_file = self.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" + with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: + lcms_obj = parser.get_lcms_obj(load_raw=load_raw, load_light=load_light, use_original_parser=use_original_parser, raw_file_path=raw_file_path) + if load_light: + mf_df = lcms_obj.mass_features_to_df() + # Add ._eic_mz to mf_df for each mass_feature + eic_mz_list = [] + for mf_id, mf in lcms_obj.mass_features.items(): + if hasattr(mf, "_eic_mz"): + eic_mz_list.append(mf._eic_mz) + else: + eic_mz_list.append(None) + mf_df["_eic_mz"] = eic_mz_list + lcms_obj.mass_features = {} + lcms_obj.light_mf_df = mf_df + return lcms_obj + + def get_lcms_collection(self, load_raw = False, load_light = True, use_original_parser = True) -> LCMSCollection: + """Return a LCMSCollection object + + Parameters + ---------- + load_raw : bool + If True, load raw data from HDF5 files. Default is False. + load_light : bool + If True, only load the parameters, mass features, and scan info are initially loaded for each lcms object. + After concatenating the mass_features, remove the mass_features attribute from the individual LCMS objects for memory efficiency. Default is True. + Default is True. + """ + # Instantiate the LCMSCollection object + lcms_coll = LCMSCollection( + collection_location=self.folder_location, + manifest=self.manifest, + collection_parser=self + ) + + # Set the number of cores on the LCMSCollection object from the ReadCoreMSHDFMassSpectraCollection object + lcms_coll.parameters.lcms_collection.cores = self._cores + + # Add LCMS objects to the collection + samples = self._manifest_dict.keys() + + # Initialize the LCMS object dictionary + if self._cores > 1: + if self._cores > len(samples): + ncores = len(samples) + else: + ncores = self._cores + # Create a pool of workers (one for each core or sample, whichever is smaller) + pool = multiprocessing.Pool(ncores) + args = [(sample, load_raw, load_light, use_original_parser) for sample in samples] + lcms_objs = pool.starmap(self.get_lcms_obj, args) + for sample_name, lcms_obj in zip(samples, lcms_objs): + lcms_coll._lcms[sample_name] = lcms_obj + + elif self._cores == 1: + # Load the LCMS objects sequentially + for sample_name in samples: + lcms_coll._lcms[sample_name] = self.get_lcms_obj(sample_name, load_raw=load_raw, load_light=load_light, use_original_parser=use_original_parser) + + else: + raise ValueError("Number of cores must be greater than 0 and set on the ReadCoreMSHDFMassSpectraCollection object.") + + # Check that all LCMS objects have the same polarity + if len(set([x.polarity for k, x in lcms_coll._lcms.items()])) != 1: + raise ValueError("All samples must have the same polarity.") + + # Set ids on the LCMS objects in the manifest + i = 0 + for sample in lcms_coll.samples: + lcms_coll._manifest_dict[sample]["collection_id"] = i + i += 1 + + # Reorder the LCMS objects + lcms_coll._reorder_lcms_objects() + + # Collect the mass features from the LCMS objects and combine them into a single dataframe for the collection + lcms_coll._combine_mass_features() + + # If load_light, remove the mass_feature attribute from the individual LCMS objects + if load_light: + for sample_name in lcms_coll.samples: + lcms_coll._lcms[sample_name].mass_features = {} + # Remove the light_mf_df attribute from the individual LCMS objects + del lcms_coll._lcms[sample_name].light_mf_df + + + return lcms_coll + + @property + def manifest(self): + return self._manifest_dict + + @property + def manifest_dataframe(self): + return pd.DataFrame(self._manifest_dict).T + + @property + def hdf5_files(self): + return [ + self.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" + for sample_name in self._manifest_dict.keys() + ] + + @property + def parameters_files(self): + # Check if parameters files are saved as JSON or TOML + json_files = [ + self.folder_location / f"{sample_name}.corems/{sample_name}.json" + for sample_name in self._manifest_dict.keys() + ] + toml_files = [ + self.folder_location / f"{sample_name}.corems/{sample_name}.toml" + for sample_name in self._manifest_dict.keys() + ] + if all([x.exists() for x in json_files]): + return json_files + elif all([x.exists() for x in toml_files]): + return toml_files + else: + raise ValueError("Parameters files are not saved for all samples.") + +class ReadSavedLCMSCollection(ReadCoreMSHDFMassSpectraCollection): + """ + Subclass to read and re-instantiate a LCMSCollection from a saved HDF5 file. + + + Parameters + ---------- + collection_hdf5_path : str or Path + Path to the saved LCMSCollection HDF5 file. + cores : int, optional + Number of cores for processing. Default is 1. + """ + + def __init__( + self, + collection_hdf5_path: str, + cores: int = 1 + ): + # Convert to Path objects + self.collection_hdf5_path = Path(collection_hdf5_path) + + # Validate the collection file exists + if not self.collection_hdf5_path.exists(): + raise FileNotFoundError(f"Collection HDF5 file {self.collection_hdf5_path} not found.") + + # Validate cores + self._validate_cores(cores) + + # Load metadata from saved collection + self._load_collection_metadata() + + if not self.folder_location.exists(): + raise FileNotFoundError(f"Folder location {self.folder_location} not found.") + + # Load the mass spectra data + self._validate_manifest() + + # Set the parameters file location + self.parameters_location = self._get_parameters_location() + + def _get_parameters_location(self): + """Find the parameters file (JSON or TOML) associated with the collection HDF5 file.""" + # Check for TOML file first (preferred) + toml_path = self.collection_hdf5_path.with_suffix('.toml') + if toml_path.exists(): + return toml_path + + # Check for JSON file + json_path = self.collection_hdf5_path.with_suffix('.json') + if json_path.exists(): + return json_path + + # No parameters file found + return None + + def _load_collection_metadata(self): + """Load metadata and manifest from the saved collection HDF5 file.""" + with h5py.File(self.collection_hdf5_path, 'r') as f: + self.folder_location = Path(f.attrs.get('lcms_objects_folder', '')) + self.missing_mass_features_searched = f.attrs.get('missing_mass_features_searched', False) + + # Call the _load_manifest function to process the manifest + self._manifest_dict = self._load_manifest(f) + + def _load_manifest(self, hdf_handle): + """Load and clean the manifest from the HDF5 file.""" + manifest_json = hdf_handle.attrs.get('manifest', '{}') + if isinstance(manifest_json, bytes): + manifest_json = manifest_json.decode('utf-8') + loaded_manifest = json.loads(manifest_json) + + # Convert integer values for 'use_rt_alignment' back to booleans + def convert_back_to_bool(data): + if isinstance(data, dict): + # Process each key-value pair recursively + return {k: (bool(v) if k == 'use_rt_alignment' and isinstance(v, int) else convert_back_to_bool(v)) for k, v in data.items()} + elif isinstance(data, list): + # Recursively process lists + return [convert_back_to_bool(item) for item in data] + else: + # Return non-dict/list types unchanged + return data + + # Clean the loaded manifest + return convert_back_to_bool(loaded_manifest) + + def _load_rt_alignments(self, lcms_collection): + """Load retention time alignments from the saved collection HDF5 file.""" + # First Set the rt_aligned flag from the collection-level attribute saved directly + with h5py.File(self.collection_hdf5_path, 'r') as f: + lcms_collection.rt_aligned = f.attrs.get('rt_aligned', False) + lcms_collection.rt_alignment_attempted = f.attrs.get('rt_alignment_attempted', False) + + if lcms_collection.rt_aligned: + with h5py.File(self.collection_hdf5_path, 'r') as f: + if "rt_alignments" in f: + # Iterate over the group `rt_alignments` containing datasets and add to the corresponding lcms object + rt_alignments_group = f["rt_alignments"] + for sample_idx, lcms_obj in zip(rt_alignments_group.keys(), lcms_collection): + alignment_data = rt_alignments_group[sample_idx][:] + scan_df = lcms_obj.scan_df + scan_df["scan_time_aligned"] = alignment_data + lcms_obj.scan_df = scan_df + elif lcms_collection.rt_alignment_attempted: + # This means it was attempted and not used, so we populate the "scan_time_aligned" + for lcms_obj in lcms_collection: + scan_df = lcms_obj.scan_df + scan_df["scan_time_aligned"] = scan_df["scan_time"] + lcms_obj.scan_df = scan_df + + def _load_cluster_assignments(self, lcms_collection): + """Load cluster assignments from the saved collection HDF5 file.""" + with h5py.File(self.collection_hdf5_path, 'r') as f: + if "cluster_assignments" in f: + # Access the group containing cluster assignments + cluster_grp = f["cluster_assignments"] + + # Reload index and cluster data + index = cluster_grp["index"][:] # Extract index + index = [idx.decode('utf-8') for idx in index] # Convert byte strings back to regular strings + cluster_data = cluster_grp["cluster"][:] # Extract cluster column + + # Reassemble the DataFrame + cluster_df = pd.DataFrame({"cluster": cluster_data}, index=index) + + # Assign cluster data back to lcms_collection.mass_features_dataframe + lcms_collection.mass_features_dataframe = lcms_collection.mass_features_dataframe.join(cluster_df, how='left') + + # Drop rows with NaN cluster values + lcms_collection.mass_features_dataframe.dropna(subset=['cluster'], inplace=True) + + def get_lcms_collection(self, load_raw=False, load_light=False, load_representatives=False, load_eics=False, load_ms1=False, load_ms2=False): + """Get the LCMS collection from the saved HDF5 file. + + Parameters + ---------- + load_raw : bool, optional + If True, load raw data. Default is False. + load_light : bool, optional + If True, load light data (minimal). Default is False. + load_representatives : bool, optional + If True, load representative mass features from clusters. Default is False. + load_eics : bool, optional + If True, load EIC data for clustered mass features. Default is False. + load_ms1 : bool, optional + If True, load MS1 spectra for loaded mass features. Default is False. + load_ms2 : bool, optional + If True, load MS2 spectra for loaded mass features. Default is False. + + Returns + ------- + LCMSCollection + The loaded LCMS collection object. + """ + # First load the LCMSCollection object exactly as in the parent class + lcms_collection = super().get_lcms_collection(load_raw=load_raw, load_light=load_light) + + # Set the missing_mass_features_searched flag from saved metadata + lcms_collection.missing_mass_features_searched = self.missing_mass_features_searched + + # Load parameters if a parameters file exists + if self.parameters_location: + self._load_parameters(lcms_collection) + + # Add retention time alignments if they exist + self._load_rt_alignments(lcms_collection) + + # Add cluster assignments if they exist + self._load_cluster_assignments(lcms_collection) + + # Load induced mass features if they exist + self._load_induced_mass_features(lcms_collection) + + # Load EICs for induced mass features from collection HDF5 + if lcms_collection.missing_mass_features_searched and load_eics: + self._load_induced_eics_from_collection(lcms_collection) + + # Combine induced mass features into the collection-level dataframe if any were loaded + if lcms_collection.missing_mass_features_searched: + lcms_collection._combine_mass_features(induced_features=True) + + # Load representative mass features if requested + if load_representatives: + self._load_representative_mass_features(lcms_collection) + + # Load MS1 and/or MS2 spectra for loaded mass features if requested + if load_ms1 or load_ms2: + # Reuse the existing ReloadFeaturesOperation from the pipeline system + from corems.mass_spectra.calc.lc_calc_operations import ReloadFeaturesOperation + + operations = [ReloadFeaturesOperation('reload_spectra', add_ms1=load_ms1, add_ms2=load_ms2)] + lcms_collection.process_samples_pipeline(operations, keep_raw_data=False, show_progress=False) + + # Load EICs for clustered features if requested + if load_eics: + # Reuse the existing LoadEICsOperation from the pipeline system + from corems.mass_spectra.calc.lc_calc_operations import LoadEICsOperation + + operations = [LoadEICsOperation('load_eics')] + lcms_collection.process_samples_pipeline(operations, keep_raw_data=False, show_progress=False) + + # Associate EICs with mass features (same as in process_consensus_features) + for sample_id in range(len(lcms_collection.samples)): + sample = lcms_collection[sample_id] + if sample.eics: # Only if EICs were loaded + # Associate EICs with regular mass features + sample.associate_eics_with_mass_features(induced=False) + # Associate EICs with induced mass features + sample.associate_eics_with_mass_features(induced=True) + + return lcms_collection + + def _load_parameters(self, lcms_collection): + """Load collection-level parameters from the saved parameters file.""" + from corems.encapsulation.input.parameter_from_json import ( + load_and_set_json_parameters_lcms_collection, + load_and_set_toml_parameters_lcms_collection, + ) + + if self.parameters_location.suffix == ".json": + load_and_set_json_parameters_lcms_collection(lcms_collection, self.parameters_location) + elif self.parameters_location.suffix == ".toml": + load_and_set_toml_parameters_lcms_collection(lcms_collection, self.parameters_location) + else: + warnings.warn(f"Unknown parameter file format: {self.parameters_location.suffix}. Skipping parameter loading.") + + def _load_induced_mass_features(self, lcms_collection): + """Load induced mass features from the saved collection HDF5 file. + + Induced mass features are gap-filled features that exist at the collection level. + This method loads them from the collection HDF5 file with all their attributes + and datasets, and distributes them to individual LCMS objects. + + Parameters + ---------- + lcms_collection : LCMSCollection + The LCMS collection object to populate with induced mass features. + """ + with h5py.File(self.collection_hdf5_path, 'r') as f: + if "induced_mass_features" not in f: + return + + # Access the top-level induced mass features group + imf_group = f["induced_mass_features"] + + # Iterate through each sample's induced mass features + for sample_idx in imf_group.keys(): + lcms_obj = lcms_collection[int(sample_idx)] + sample_group = imf_group[sample_idx] + + # Load each mass feature for this sample + for mf_id_str in sample_group.keys(): + mf_group = sample_group[mf_id_str] + + # Note: Induced mass feature IDs are strings like 'c2923_0_i', not integers + # Keep them as strings since that's how they're stored + mf_id = mf_id_str + + # Instantiate the LCMSMassFeature object with required attributes + mass_feature = LCMSMassFeature( + lcms_obj, + mz=mf_group.attrs["_mz_exp"], + retention_time=mf_group.attrs["_retention_time"], + intensity=mf_group.attrs["_intensity"], + apex_scan=mf_group.attrs["_apex_scan"], + persistence=mf_group.attrs.get("_persistence", 0), + id=mf_id, + ) + + # Populate additional attributes from HDF5 attributes + for key in mf_group.attrs.keys() - { + "_mz_exp", + "_mz_cal", + "_retention_time", + "_intensity", + "_apex_scan", + "_persistence", + }: + setattr(mass_feature, key, mf_group.attrs[key]) + + # Populate attributes from HDF5 datasets (arrays) + for key in mf_group.keys(): + setattr(mass_feature, key, mf_group[key][:]) + # Convert _noise_score from array to tuple + if key == "_noise_score": + mass_feature._noise_score = tuple(mass_feature._noise_score) + + # Add to the LCMS object's induced_mass_features dictionary + lcms_obj.induced_mass_features[mf_id] = mass_feature + + def _load_induced_eics_from_collection(self, lcms_collection): + """Load EICs for induced mass features from the collection HDF5 file. + + Induced mass features are gap-filled features. Their EICs are saved at the + collection level and need to be loaded and associated with the induced mass features. + + Parameters + ---------- + lcms_collection : LCMSCollection + The LCMS collection object with induced mass features to associate EICs with. + """ + with h5py.File(self.collection_hdf5_path, 'r') as f: + if "induced_eics" not in f: + return + + # Access the top-level induced EICs group + induced_eics_group = f["induced_eics"] + + # Iterate through each sample's induced EICs + for sample_idx in induced_eics_group.keys(): + lcms_obj = lcms_collection[int(sample_idx)] + sample_group = induced_eics_group[sample_idx] + + # Use the static helper to load EICs + loaded_eics = ReadCoreMSHDFMassSpectra._load_eics_from_hdf5_group(sample_group, lcms_obj) + + # Ensure eics dictionary exists (should already be initialized in __init__) + if not hasattr(lcms_obj, 'eics') or lcms_obj.eics is None: + lcms_obj.eics = {} + + # Add to lcms_obj.eics dictionary + for eic_mz, eic in loaded_eics.items(): + lcms_obj.eics[eic_mz] = eic + + # Associate EICs with induced mass features after all samples processed + # This is done outside the loop to handle all samples at once + for lcms_obj in lcms_collection: + if len(lcms_obj.induced_mass_features) > 0: + lcms_obj.associate_eics_with_mass_features(induced=True) + + def _load_representative_mass_features(self, lcms_collection): + """Load representative mass features for all clusters from HDF5 files. + + This method uses the same logic as process_consensus_features() when loading + representatives, calling get_sample_mf_map_for_representatives() (DRY helper) + to determine which features to load. + + Parameters + ---------- + lcms_collection : LCMSCollection + The LCMS collection object to populate with representative mass features. + """ + # Get cluster assignments from the mass_features_dataframe + if "cluster" not in lcms_collection.mass_features_dataframe.columns: + return + + # Use DRY helper method to build sample_mf_map with cluster IDs + sample_mf_map = lcms_collection.get_sample_mf_map_for_representatives(include_cluster_id=True) + + # Load mass features for each sample + for sample_id, mf_list in sample_mf_map.items(): + lcms_obj = lcms_collection[sample_id] + + # Load each mass feature + for mf_id, cluster_id in mf_list: + self._load_single_mass_feature(lcms_obj, mf_id, cluster_id) + + def _load_single_mass_feature(self, lcms_obj, feature_id, cluster_index=None): + """Load a single mass feature from an LCMS object's HDF5 file. + + Parameters + ---------- + lcms_obj : LCMSBase + The LCMS object to add the mass feature to. + feature_id : int + The ID of the mass feature to load. + cluster_index : int, optional + The cluster index to assign to the loaded mass feature. + """ + hdf5_path = lcms_obj.file_location.with_suffix('.hdf5') + + if not hdf5_path.exists(): + return + + with h5py.File(hdf5_path, 'r') as f: + if 'mass_features' not in f: + return + + mf_group = f['mass_features'] + feature_id_str = str(feature_id) + + if feature_id_str not in mf_group: + return + + mf_data = mf_group[feature_id_str] + + # Create LCMSMassFeature object + mass_feature = LCMSMassFeature( + lcms_obj, + mz=mf_data.attrs["_mz_exp"], + retention_time=mf_data.attrs["_retention_time"], + intensity=mf_data.attrs["_intensity"], + apex_scan=mf_data.attrs["_apex_scan"], + persistence=mf_data.attrs.get("_persistence", 0), + id=feature_id, + ) + + # Set cluster_index if provided + if cluster_index is not None: + mass_feature.cluster_index = cluster_index + + # Populate additional attributes from HDF5 attributes + for key in mf_data.attrs.keys() - { + "_mz_exp", + "_mz_cal", + "_retention_time", + "_intensity", + "_apex_scan", + "_persistence", + }: + setattr(mass_feature, key, mf_data.attrs[key]) + + # Populate attributes from HDF5 datasets (arrays) + for key in mf_data.keys(): + setattr(mass_feature, key, mf_data[key][:]) + # Convert _noise_score from array to tuple + if key == "_noise_score": + mass_feature._noise_score = tuple(mass_feature._noise_score) + + # Add to the LCMS object's mass_features dictionary + lcms_obj.mass_features[feature_id] = mass_feature diff --git a/corems/mass_spectra/input/mzml.py b/corems/mass_spectra/input/mzml.py index 4b106c465..ebc166f63 100644 --- a/corems/mass_spectra/input/mzml.py +++ b/corems/mass_spectra/input/mzml.py @@ -1,5 +1,6 @@ from collections import defaultdict from pathlib import Path +from typing import Optional, Union, List, Tuple import numpy as np import pandas as pd @@ -95,20 +96,27 @@ def load(self): data = pymzml.run.Reader(self.file_location) return data - def get_scan_df(self, data): + def get_scan_df(self, data=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """ Return scan data as a pandas DataFrame. Parameters ---------- - data : pymzml.run.Reader - The mass spectra data. + data : pymzml.run.Reader, optional + The mass spectra data. If None, will load the data. + time_range : tuple or list of tuples, optional + Retention time range(s) to filter scans. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, returns all scans. Returns ------- pandas.DataFrame A pandas DataFrame containing metadata for each scan, including scan number, MS level, polarity, and scan time. """ + if data is None: + data = self.load() # Scan dict # instatinate scan dict, with empty lists of size of scans n_scans = data.get_spectrum_count() @@ -159,10 +167,19 @@ def get_scan_df(self, data): scan_dict["ms_format"][i] = None scan_df = pd.DataFrame(scan_dict) + + # Apply time range filtering if specified + if time_range is not None: + time_ranges = self._normalize_time_range(time_range) + # Create a mask for scans within any of the time ranges + mask = np.zeros(len(scan_df), dtype=bool) + for start_time, end_time in time_ranges: + mask |= (scan_df["scan_time"] >= start_time) & (scan_df["scan_time"] <= end_time) + scan_df = scan_df[mask].reset_index(drop=True) return scan_df - def get_ms_raw(self, spectra, scan_df, data): + def get_ms_raw(self, spectra, scan_df, data=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """Return a dictionary of mass spectra data as a pandas DataFrame. Parameters @@ -172,8 +189,13 @@ def get_ms_raw(self, spectra, scan_df, data): Options: None, "ms1", "ms2", "all". scan_df : pandas.DataFrame Scan dataframe. Output from get_scan_df(). - data : pymzml.run.Reader - The mass spectra data. + data : pymzml.run.Reader, optional + The mass spectra data. If None, will load the data. + time_range : tuple or list of tuples, optional + Retention time range(s) to filter scans. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, returns all scans. Note: filtering is typically done at scan_df level. Returns ------- @@ -181,6 +203,8 @@ def get_ms_raw(self, spectra, scan_df, data): A dictionary containing the mass spectra data as pandas DataFrames, with keys corresponding to the MS level. """ + if data is None: + data = self.load() if spectra == "all": scan_df_forspec = scan_df elif spectra == "ms1": @@ -205,7 +229,7 @@ def get_ms_raw(self, spectra, scan_df, data): # First pass: get nrows N = defaultdict(lambda: 0) for i, spec in enumerate(data): - if i in scan_df_forspec.scan: + if spec.ID in scan_df_forspec.scan.values: # Get ms level level = "ms{}".format(spec.ms_level) @@ -214,7 +238,7 @@ def get_ms_raw(self, spectra, scan_df, data): # Second pass: parse for i, spec in enumerate(data): - if i in scan_df_forspec.scan: + if spec.ID in scan_df_forspec.scan.values: # Number of rows n = spec.mz.shape[0] @@ -273,7 +297,7 @@ def get_ms_raw(self, spectra, scan_df, data): return res - def run(self, spectra="all", scan_df=None): + def run(self, spectra="all", scan_df=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """Parse the mzML file and return a dictionary of spectra dataframes and a scan metadata dataframe. Parameters @@ -283,6 +307,11 @@ def run(self, spectra="all", scan_df=None): Other options: None, "ms1", "ms2". scan_df : pandas.DataFrame, optional Scan dataframe. If not provided, the scan dataframe is created from the mzML file. + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, loads all scans. Returns ------- @@ -296,7 +325,7 @@ def run(self, spectra="all", scan_df=None): data = self.load() if scan_df is None: - scan_df = self.get_scan_df(data) + scan_df = self.get_scan_df(data, time_range=time_range) if spectra != "none": res = self.get_ms_raw(spectra, scan_df, data) @@ -440,9 +469,16 @@ def set_metadata( return mass_spectrum_objects - def get_mass_spectra_obj(self): + def get_mass_spectra_obj(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """Instatiate a MassSpectraBase object from the mzML file. + Parameters + ---------- + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, loads all scans. Returns ------- @@ -450,7 +486,7 @@ def get_mass_spectra_obj(self): The MassSpectra object containing the parsed mass spectra. The object is instatiated with the mzML file, analyzer, instrument, sample name, and scan dataframe. """ - _, scan_df = self.run(spectra=False) + _, scan_df = self.run(spectra=False, time_range=time_range) mass_spectra_obj = MassSpectraBase( self.file_location, self.analyzer, @@ -463,13 +499,18 @@ def get_mass_spectra_obj(self): return mass_spectra_obj - def get_lcms_obj(self, spectra="all"): + def get_lcms_obj(self, spectra="all", time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """Instatiates a LCMSBase object from the mzML file. Parameters ---------- spectra : str, optional Which mass spectra data to include in the output. Default is all. Other options: none, ms1, ms2. + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, loads all scans. Useful for targeted workflows to improve performance. Returns ------- @@ -478,10 +519,10 @@ def get_lcms_obj(self, spectra="all"): The object is instatiated with the mzML file, analyzer, instrument, sample name, scan dataframe, and mz dataframe(s), as well as lists of scan numbers, retention times, and TICs. """ - _, scan_df = self.run(spectra="none") # first run it to just get scan info + _, scan_df = self.run(spectra="none", time_range=time_range) # first run it to just get scan info if spectra != "none": res, scan_df = self.run( - scan_df=scan_df, spectra=spectra + scan_df=scan_df, spectra=spectra, time_range=time_range ) # second run to parse data lcms_obj = LCMSBase( self.file_location, @@ -507,6 +548,53 @@ def get_lcms_obj(self, spectra="all"): return lcms_obj + def get_scans_in_time_range( + self, + time_range: Union[Tuple[float, float], List[Tuple[float, float]]], + ms_level: Optional[int] = None + ) -> List[int]: + """ + Return scan numbers within specified retention time range(s). + + This method provides efficient filtering of scans by retention time, + which is particularly useful for targeted workflows where only specific + time windows are of interest. + + Parameters + ---------- + time_range : tuple or list of tuples + Retention time range(s) in minutes. Can be: + - Single range: (start_time, end_time) + - Multiple ranges: [(start1, end1), (start2, end2), ...] + ms_level : int, optional + If specified, only return scans of this MS level (e.g., 1 for MS1, 2 for MS2). + If None, returns scans of all MS levels. + + Returns + ------- + list of int + List of scan numbers within the specified time range(s) and MS level. + + Examples + -------- + Get MS1 scans between 1.0 and 2.0 minutes: + + >>> scans = parser.get_scans_in_time_range((1.0, 2.0), ms_level=1) + + Get scans in multiple time windows: + + >>> scans = parser.get_scans_in_time_range([(0.5, 1.5), (3.0, 4.0)]) + """ + # Get scan dataframe filtered by time range + scan_df = self.get_scan_df(time_range=time_range) + + # Further filter by MS level if specified + if ms_level is not None: + scan_df = scan_df[scan_df.ms_level == ms_level] + + # Return list of scan numbers + return scan_df.scan.tolist() + def get_instrument_info(self): """ Return instrument information. diff --git a/corems/mass_spectra/input/parserbase.py b/corems/mass_spectra/input/parserbase.py index fec7992c2..6971c8d79 100644 --- a/corems/mass_spectra/input/parserbase.py +++ b/corems/mass_spectra/input/parserbase.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod import datetime +import numbers +from typing import Optional, Union, List, Tuple class SpectraParserInterface(ABC): @@ -16,10 +18,20 @@ class SpectraParserInterface(ABC): Return MassSpectraBase object with several attributes populated * get_mass_spectrum_from_scan(scan_number). Return MassSpecBase data object from scan number. + * get_scans_in_time_range(time_range). + Return scan numbers within specified retention time range(s). Notes ----- This is an abstract class and should not be instantiated directly. + + Time Range Filtering + -------------------- + Many methods support optional time_range parameter to load only scans within + specific retention time windows. This significantly improves performance for + targeted workflows. Time ranges can be specified as: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes """ @abstractmethod @@ -37,23 +49,66 @@ def run(self): pass @abstractmethod - def get_scan_df(self): + def get_scan_df(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """ Return scan data as a pandas DataFrame. + + Parameters + ---------- + time_range : tuple or list of tuples, optional + Retention time range(s) to filter scans. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, returns all scans. + + Returns + ------- + pd.DataFrame + DataFrame containing scan information, optionally filtered by time range. """ pass @abstractmethod - def get_ms_raw(self, spectra, scan_df): - """ - Return a dictionary of mass spectra data as a pandas DataFrame. + def get_ms_raw(self, spectra, scan_df, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): + """ + Return a dictionary of mass spectra data as pandas DataFrames. + + Parameters + ---------- + spectra : str or dict + Specifies which spectra to load (e.g., 'ms1', 'ms2', or custom dict) + scan_df : pd.DataFrame + Scan information DataFrame + time_range : tuple or list of tuples, optional + Retention time range(s) to filter scans. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, returns all scans. + + Returns + ------- + dict + Dictionary of raw mass spectra data, optionally filtered by time range. """ pass @abstractmethod - def get_mass_spectra_obj(self): + def get_mass_spectra_obj(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """ Return mass spectra data object. + + Parameters + ---------- + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, loads all scans. Useful for targeted workflows to improve performance. + + Returns + ------- + MassSpectraBase + Mass spectra object, optionally filtered to specified time range(s). """ pass @@ -75,6 +130,46 @@ def get_mass_spectra_from_scan_list( """ pass + @abstractmethod + def get_scans_in_time_range( + self, + time_range: Union[Tuple[float, float], List[Tuple[float, float]]], + ms_level: Optional[int] = None + ) -> List[int]: + """ + Return scan numbers within specified retention time range(s). + + This method provides efficient filtering of scans by retention time, + which is particularly useful for targeted workflows where only specific + time windows are of interest. + + Parameters + ---------- + time_range : tuple or list of tuples + Retention time range(s) in minutes. Can be: + - Single range: (start_time, end_time) + - Multiple ranges: [(start1, end1), (start2, end2), ...] + ms_level : int, optional + If specified, only return scans of this MS level (e.g., 1 for MS1, 2 for MS2). + If None, returns scans of all MS levels. + + Returns + ------- + list of int + List of scan numbers within the specified time range(s) and MS level. + + Examples + -------- + Get MS1 scans between 1.0 and 2.0 minutes: + + >>> scans = parser.get_scans_in_time_range((1.0, 2.0), ms_level=1) + + Get scans in multiple time windows: + + >>> scans = parser.get_scans_in_time_range([(0.5, 1.5), (3.0, 4.0)]) + """ + pass + @abstractmethod def get_instrument_info(self): """ @@ -98,3 +193,47 @@ def get_creation_time(self) -> datetime.datetime: The creation time of the mass spectra data. """ pass + + @staticmethod + def _normalize_time_range( + time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] + ) -> Optional[List[Tuple[float, float]]]: + """ + Normalize time range input to a list of tuples. + + Helper method for implementations to standardize time_range parameter. + Converts single tuple to list of tuples for consistent handling. + + Parameters + ---------- + time_range : tuple, list of tuples, or None + Input time range(s) + + Returns + ------- + list of tuples or None + Normalized time ranges as list of (start, end) tuples, or None if input is None. + + Examples + -------- + >>> SpectraParserInterface._normalize_time_range((1.0, 2.0)) + [(1.0, 2.0)] + + >>> SpectraParserInterface._normalize_time_range([(1.0, 2.0), (3.0, 4.0)]) + [(1.0, 2.0), (3.0, 4.0)] + + >>> SpectraParserInterface._normalize_time_range(None) + None + """ + if time_range is None: + return None + + # Check if it's a single tuple (two numbers) + if isinstance(time_range, tuple) and len(time_range) == 2: + # Use numbers.Number to catch int, float, and numpy scalar types + if isinstance(time_range[0], numbers.Number) and isinstance(time_range[1], numbers.Number): + # Convert to float to ensure consistency (handles numpy scalars) + return [(float(time_range[0]), float(time_range[1]))] + + # Otherwise assume it's already a list of tuples + return list(time_range) diff --git a/corems/mass_spectra/input/rawFileReader.py b/corems/mass_spectra/input/rawFileReader.py index d8a01761d..b1244d021 100644 --- a/corems/mass_spectra/input/rawFileReader.py +++ b/corems/mass_spectra/input/rawFileReader.py @@ -22,7 +22,7 @@ from s3path import S3Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from corems.encapsulation.constant import Labels from corems.mass_spectra.factory.lc_class import MassSpectraBase, LCMSBase from corems.mass_spectra.factory.chromat_data import EIC_Data, TIC_Data @@ -41,6 +41,7 @@ clr.AddReference("ThermoFisher.CommonCore.Data") clr.AddReference("ThermoFisher.CommonCore.MassPrecisionEstimator") +from System.Collections.Generic import List as DotNetList from ThermoFisher.CommonCore.RawFileReader import RawFileReaderAdapter from ThermoFisher.CommonCore.Data import ToleranceUnits, Extensions from ThermoFisher.CommonCore.Data.Business import ( @@ -53,7 +54,6 @@ from ThermoFisher.CommonCore.Data.Interfaces import IChromatogramSettings from ThermoFisher.CommonCore.Data.Business import MassOptions, FileHeaderReaderFactory from ThermoFisher.CommonCore.Data.FilterEnums import MSOrderType -from System.Collections.Generic import List class ThermoBaseClass: @@ -775,7 +775,7 @@ def get_centroid_mass_spec(averageScan, d_params: dict): elif isinstance(self.scans, list): d_params = self.set_metadata(scans_list=self.scans) - scans = List[int]() + scans = DotNetList[int]() for scan in self.scans: scans.Add(scan) @@ -1237,7 +1237,7 @@ def get_average_mass_spectrum_by_scanlist( # assumes scans is full scan or reduced profile scan - scans = List[int]() + scans = DotNetList[int]() for scan in scans_list: scans.Add(scan) @@ -1333,7 +1333,63 @@ def __init__( def load(self): pass - def get_scan_df(self): + def get_scans_in_time_range( + self, + time_range: Union[Tuple[float, float], List[Tuple[float, float]]], + ms_level: Optional[int] = None + ) -> List[int]: + """Return scan numbers within specified retention time range(s). + + Parameters + ---------- + time_range : tuple or list of tuples + Retention time range(s) in minutes. Can be: + - Single range: (start_time, end_time) + - Multiple ranges: [(start1, end1), (start2, end2), ...] + ms_level : int, optional + If specified, only return scans of this MS level (e.g., 1 for MS1, 2 for MS2). + If None, returns scans of all MS levels. + + Returns + ------- + list of int + List of scan numbers within the specified time range(s) and MS level. + """ + # Normalize time range to list of tuples + time_ranges = self._normalize_time_range(time_range) + + # Get all scan data + scan_df = self.get_scan_df() + + # Filter by time range + mask = pd.Series([False] * len(scan_df), index=scan_df.index) + for start_time, end_time in time_ranges: + mask |= (scan_df.scan_time >= start_time) & (scan_df.scan_time <= end_time) + + filtered_df = scan_df[mask] + + # Filter by MS level if specified + if ms_level is not None: + filtered_df = filtered_df[filtered_df.ms_level == ms_level] + + return filtered_df.scan.tolist() + + def get_scan_df(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): + """Return scan data as a pandas DataFrame. + + Parameters + ---------- + time_range : tuple or list of tuples, optional + Retention time range(s) to filter scans. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, returns all scans. + + Returns + ------- + pd.DataFrame + DataFrame containing scan information, optionally filtered by time range. + """ # This automatically brings in all the data self.chromatogram_settings.scans = (-1, -1) @@ -1376,9 +1432,39 @@ def get_scan_df(self): else: scan_df.loc[scan_df.scan == i, "ms_format"] = "profile" + # Filter by time range if specified + if time_range is not None: + time_ranges = self._normalize_time_range(time_range) + mask = pd.Series([False] * len(scan_df), index=scan_df.index) + for start_time, end_time in time_ranges: + mask |= (scan_df.scan_time >= start_time) & (scan_df.scan_time <= end_time) + scan_df = scan_df[mask].reset_index(drop=True) + return scan_df - def get_ms_raw(self, spectra, scan_df): + def get_ms_raw(self, spectra, scan_df, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): + """Return a dictionary of mass spectra data as pandas DataFrames. + + Parameters + ---------- + spectra : str + Specifies which spectra to load (e.g., 'all', 'ms1', 'ms2') + scan_df : pd.DataFrame + Scan information DataFrame + time_range : tuple or list of tuples, optional + Retention time range(s) to filter scans. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, returns all scans. Note: filtering is typically done at scan_df level. + + Returns + ------- + dict + Dictionary of raw mass spectra data, optionally filtered by time range. + """ + # Note: time_range filtering is handled at the scan_df level before calling this method + # The parameter is here for interface consistency with SpectraParserInterface + if spectra == "all": scan_df_forspec = scan_df elif spectra == "ms1": @@ -1484,7 +1570,7 @@ def get_ms_raw(self, spectra, scan_df): return res - def run(self, spectra="all", scan_df=None): + def run(self, spectra="all", scan_df=None, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """ Extracts mass spectra data from a raw file. @@ -1494,6 +1580,11 @@ def run(self, spectra="all", scan_df=None): Which mass spectra data to include in the output. Default is all. Other options: none, ms1, ms2. scan_df : pandas.DataFrame, optional Scan dataframe. If not provided, the scan dataframe is created from the mzML file. + time_range : tuple or list of tuples, optional + Retention time range(s) to filter scans. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, returns all scans. Returns ------- @@ -1505,11 +1596,11 @@ def run(self, spectra="all", scan_df=None): """ # Prepare scan_df if scan_df is None: - scan_df = self.get_scan_df() + scan_df = self.get_scan_df(time_range=time_range) # Prepare mass spectra data if spectra != "none": - res = self.get_ms_raw(spectra=spectra, scan_df=scan_df) + res = self.get_ms_raw(spectra=spectra, scan_df=scan_df, time_range=time_range) else: res = None @@ -1626,15 +1717,23 @@ def get_mass_spectrum_from_scan( return mass_spectrum_obj - def get_mass_spectra_obj(self): + def get_mass_spectra_obj(self, time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """Instatiate a MassSpectraBase object from the binary data file file. + Parameters + ---------- + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, loads all scans. Useful for targeted workflows to improve performance. + Returns ------- MassSpectraBase The MassSpectra object containing the parsed mass spectra. The object is instatiated with the mzML file, analyzer, instrument, sample name, and scan dataframe. """ - _, scan_df = self.run(spectra="none") + _, scan_df = self.run(spectra="none", time_range=time_range) mass_spectra_obj = MassSpectraBase( self.file_location, self.analyzer, @@ -1647,22 +1746,27 @@ def get_mass_spectra_obj(self): return mass_spectra_obj - def get_lcms_obj(self, spectra="all"): + def get_lcms_obj(self, spectra="all", time_range: Optional[Union[Tuple[float, float], List[Tuple[float, float]]]] = None): """Instatiates a LCMSBase object from the mzML file. Parameters ---------- spectra : str, optional Which mass spectra data to include in the output. Default is "all". Other options: "none", "ms1", "ms2". + time_range : tuple or list of tuples, optional + Retention time range(s) to load. Can be: + - Single range: (start_time, end_time) in minutes + - Multiple ranges: [(start1, end1), (start2, end2), ...] in minutes + If None, loads all scans. Useful for targeted workflows to improve performance. Returns ------- LCMSBase LCMS object containing mass spectra data. The object is instatiated with the file location, analyzer, instrument, sample name, scan info, mz dataframe (as specifified), polarity, as well as the attributes holding the scans, retention times, and tics. """ - _, scan_df = self.run(spectra="none") # first run it to just get scan info + _, scan_df = self.run(spectra="none", time_range=time_range) # first run it to just get scan info res, scan_df = self.run( - scan_df=scan_df, spectra=spectra + scan_df=scan_df, spectra=spectra, time_range=time_range ) # second run to parse data lcms_obj = LCMSBase( self.file_location, @@ -1683,7 +1787,7 @@ def get_lcms_obj(self, spectra="all"): # Check if polarity is mixed if len(set(scan_df.polarity)) > 1: raise ValueError("Mixed polarities detected in scan data") - lcms_obj.polarity = scan_df.polarity[0] + lcms_obj.polarity = scan_df.polarity.iloc[0] lcms_obj._scans_number_list = list(scan_df.scan) lcms_obj._retention_time_list = list(scan_df.scan_time) lcms_obj._tic_list = list(scan_df.tic) diff --git a/corems/mass_spectra/output/export.py b/corems/mass_spectra/output/export.py index b46d32744..8bdec35e0 100644 --- a/corems/mass_spectra/output/export.py +++ b/corems/mass_spectra/output/export.py @@ -21,6 +21,8 @@ from corems.encapsulation.output.parameter_to_json import ( dump_lcms_settings_json, dump_lcms_settings_toml, + dump_lcms_collection_settings_json, + dump_lcms_collection_settings_toml, ) from corems.mass_spectrum.output.export import HighResMassSpecExport from corems.molecular_formula.factory.MolecularFormulaFactory import MolecularFormula @@ -29,6 +31,7 @@ ion_type_dict = { # adduct : [atoms to add, atoms to subtract when calculating formula of ion "M+": [{}, {}], + "[M]+": [{}, {}], "protonated": [{"H": 1}, {}], "[M+H]+": [{"H": 1}, {}], "[M+NH4]+": [{"N": 1, "H": 4}, {}], # ammonium @@ -991,6 +994,15 @@ def to_hdf(self, overwrite=False, export_raw=True): hdf_handle.attrs["original_file_location"] = ( self.mass_spectra.file_location._str ) + + # Save creation time from original parser if available + try: + if hasattr(self.mass_spectra, 'spectra_parser') and self.mass_spectra.spectra_parser is not None: + creation_time = self.mass_spectra.spectra_parser.get_creation_time() + if creation_time is not None: + hdf_handle.attrs["creation_time"] = creation_time.isoformat() + except Exception: + pass # If creation time cannot be retrieved, skip it if "mass_spectra" not in hdf_handle: mass_spectra_group = hdf_handle.create_group("mass_spectra") @@ -1021,134 +1033,27 @@ class LCMSExport(HighResMassSpectraExport): def __init__(self, out_file_path, mass_spectra): super().__init__(out_file_path, mass_spectra, output_type="hdf5") - def to_hdf(self, overwrite=False, save_parameters=True, parameter_format="toml"): - """Export the data to an HDF5. - + @staticmethod + def _save_mass_features_dict_to_hdf5(mass_features_dict, mass_features_group, overwrite=False): + """Save a dictionary of mass features to an HDF5 group. + + This is a helper method that can be reused by different export classes. + Parameters ---------- + mass_features_dict : dict + Dictionary of mass features to save, keyed by mass feature ID. + mass_features_group : h5py.Group + The HDF5 group to save the mass features to. overwrite : bool, optional - Whether to overwrite the output file. Default is False. - save_parameters : bool, optional - Whether to save the parameters as a separate json or toml file. Default is True. - parameter_format : str, optional - The format to save the parameters in. Default is 'toml'. - - Raises - ------ - ValueError - If parameter_format is not 'json' or 'toml'. + Whether to overwrite existing mass features. Default is False. """ - export_profile_spectra = ( - self.mass_spectra.parameters.lc_ms.export_profile_spectra - ) - - # Parameterized export: all spectra (default) or only relevant spectra - export_only_relevant = self.mass_spectra.parameters.lc_ms.export_only_relevant_mass_spectra - if export_only_relevant: - relevant_scan_numbers = set() - # Add MS1 spectra associated with mass features (apex scans) and best MS2 spectra - for mass_feature in self.mass_spectra.mass_features.values(): - relevant_scan_numbers.add(mass_feature.apex_scan) - if mass_feature.best_ms2 is not None: - relevant_scan_numbers.add(mass_feature.best_ms2.scan_number) - if overwrite: - if self.output_file.with_suffix(".hdf5").exists(): - self.output_file.with_suffix(".hdf5").unlink() - - with h5py.File(self.output_file.with_suffix(".hdf5"), "a") as hdf_handle: - if not hdf_handle.attrs.get("date_utc"): - # Set metadata for all mass spectra - timenow = str( - datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z") - ) - hdf_handle.attrs["date_utc"] = timenow - hdf_handle.attrs["filename"] = self.mass_spectra.file_location.name - hdf_handle.attrs["data_structure"] = "mass_spectra" - hdf_handle.attrs["analyzer"] = self.mass_spectra.analyzer - hdf_handle.attrs["instrument_label"] = ( - self.mass_spectra.instrument_label - ) - hdf_handle.attrs["sample_name"] = self.mass_spectra.sample_name - hdf_handle.attrs["polarity"] = self.mass_spectra.polarity - hdf_handle.attrs["parser_type"] = ( - self.mass_spectra.spectra_parser_class.__name__ - ) - hdf_handle.attrs["original_file_location"] = ( - self.mass_spectra.file_location._str - ) - - if "mass_spectra" not in hdf_handle: - mass_spectra_group = hdf_handle.create_group("mass_spectra") - else: - mass_spectra_group = hdf_handle.get("mass_spectra") - - # Export logic based on parameter - for mass_spectrum in self.mass_spectra: - if export_only_relevant: - if mass_spectrum.scan_number in relevant_scan_numbers: - group_key = str(int(mass_spectrum.scan_number)) - self.add_mass_spectrum_to_hdf5( - hdf_handle, mass_spectrum, group_key, mass_spectra_group, export_profile_spectra - ) - else: - group_key = str(int(mass_spectrum.scan_number)) - self.add_mass_spectrum_to_hdf5( - hdf_handle, mass_spectrum, group_key, mass_spectra_group, export_profile_spectra - ) - - # Write scan info, ms_unprocessed, mass features, eics, and ms2_search results to the hdf5 file - with h5py.File(self.output_file.with_suffix(".hdf5"), "a") as hdf_handle: - # Add scan_info to hdf5 file - if "scan_info" not in hdf_handle: - scan_info_group = hdf_handle.create_group("scan_info") - for k, v in self.mass_spectra._scan_info.items(): - array = np.array(list(v.values())) - if array.dtype.str[0:2] == " 0: - if "mass_features" not in hdf_handle: - mass_features_group = hdf_handle.create_group("mass_features") - else: - mass_features_group = hdf_handle.get("mass_features") - - # Create group for each mass feature, with key as the mass feature id - for k, v in self.mass_spectra.mass_features.items(): + # Create group for each mass feature, with key as the mass feature id + for k, v in mass_features_dict.items(): + if str(k) not in mass_features_group or overwrite: + if str(k) in mass_features_group and overwrite: + del mass_features_group[str(k)] mass_features_group.create_group(str(k)) # Loop through each of the mass feature attributes and add them as attributes (if single value) or datasets (if array) for k2, v2 in v.__dict__.items(): @@ -1216,43 +1121,156 @@ def to_hdf(self, overwrite=False, save_parameters=True, parameter_format="toml") elif isinstance(v2, np.float64): v2 = np.float32(v2) mass_features_group[str(k)].attrs[str(k2)] = v2 - else: - raise TypeError( - f"Attribute {k2} is not an integer, float, or string and cannot be added to the hdf5 file" - ) + + @staticmethod + def _save_eics_dict_to_hdf5(eics_dict, eics_group, overwrite=False): + """Save a dictionary of EICs to an HDF5 group. + + This is a static helper method that can be reused by different export classes + to save EIC data in a consistent format. + + Parameters + ---------- + eics_dict : dict + Dictionary of EIC_Data objects, keyed by m/z value. + eics_group : h5py.Group + The HDF5 group to save the EICs to. + overwrite : bool, optional + Whether to overwrite existing EICs. Default is False. + """ + for mz, eic_data in eics_dict.items(): + mz_str = str(mz) + if mz_str not in eics_group or overwrite: + if mz_str in eics_group and overwrite: + del eics_group[mz_str] + eic_grp = eics_group.create_group(mz_str) + eic_grp.attrs["mz"] = mz + + # Save all EIC_Data attributes as datasets + for attr_name, attr_value in eic_data.__dict__.items(): + if attr_value is not None: + array = np.array(attr_value) + # Apply data type optimization and compression + if array.dtype == np.int64: + array = array.astype(np.int32) + elif array.dtype == np.float64: + array = array.astype(np.float32) + elif array.dtype.str[0:2] == " 0 and export_eics: - if "eics" not in hdf_handle: + if "eics" not in hdf_handle or overwrite: + if "eics" in hdf_handle and overwrite: + del hdf_handle["eics"] eic_group = hdf_handle.create_group("eics") else: eic_group = hdf_handle.get("eics") - # Create group for each eic - for k, v in self.mass_spectra.eics.items(): - eic_group.create_group(str(k)) - eic_group[str(k)].attrs["mz"] = k - # Loop through each of the attributes and add them as datasets (if array) - for k2, v2 in v.__dict__.items(): - if v2 is not None: - array = np.array(v2) - # Apply data type optimization and compression - if array.dtype == np.int64: - array = array.astype(np.int32) - elif array.dtype == np.float64: - array = array.astype(np.float32) - elif array.dtype.str[0:2] == " 0: - if "spectral_search_results" not in hdf_handle: + if "spectral_search_results" not in hdf_handle or overwrite: + if "spectral_search_results" in hdf_handle and overwrite: + del hdf_handle["spectral_search_results"] spectral_search_results = hdf_handle.create_group( "spectral_search_results" ) @@ -1260,49 +1278,52 @@ def to_hdf(self, overwrite=False, save_parameters=True, parameter_format="toml") spectral_search_results = hdf_handle.get("spectral_search_results") # Create group for each search result by ms2_scan / precursor_mz for k, v in self.mass_spectra.spectral_search_results.items(): - # If parameter is set, only export spectral search results for relevant scans - if export_only_relevant and k not in relevant_scan_numbers: - continue - spectral_search_results.create_group(str(k)) - for k2, v2 in v.items(): - spectral_search_results[str(k)].create_group(str(k2)) - spectral_search_results[str(k)][str(k2)].attrs[ - "precursor_mz" - ] = v2.precursor_mz - spectral_search_results[str(k)][str(k2)].attrs[ - "query_spectrum_id" - ] = v2.query_spectrum_id - # Loop through each of the attributes and add them as datasets (if array) - for k3, v3 in v2.__dict__.items(): - if v3 is not None and k3 not in [ - "query_spectrum", - "precursor_mz", - "query_spectrum_id", - ]: - if k3 == "query_frag_types" or k3 == "ref_frag_types": - v3 = [", ".join(x) for x in v3] - if all(v3 is not None for v3 in v3): - array = np.array(v3) - if array.dtype.str[0:2] == ">> from corems.mass_spectra.output.export import LCMSCollectionExporter + >>> exporter = LCMSCollectionExporter("my_collection", lcms_collection) + >>> exporter.export_to_hdf5(overwrite=True) + + The resulting HDF5 file will contain collection-level metadata and can be used + to reconstruct the collection state for further analysis. + + See Also + -------- + LCMSExport : Export individual LCMS objects to HDF5 + LCMSCollection : The collection object being exported + """ + def __init__(self, out_file_path, mass_spectra_collection): + self.out_file_path = Path(out_file_path) + self.mass_spectra_collection = mass_spectra_collection + + def export_to_hdf5( + self, + overwrite = False, + save_parameters=True, + parameter_format="toml", + update_lcms_objects=True): + """Export the LCMS collection to an HDF5 file. + + This method saves the collection-level data to an HDF5 file, including: + - Basic metadata (date, folder location, gap-filling status) + - Sample manifest + - Retention time alignments (if available) + - Cluster assignments (if available) + - Induced mass features for each LCMS object (if gap-filling was performed) + + Individual LCMS objects in the collection are not exported by this method. + Use LCMSExport for exporting individual LCMS objects. + + Parameters + ---------- + overwrite : bool, optional + If True, overwrites the output file if it already exists and replaces + existing groups within the HDF5 file. If False, appends new data to + existing file without overwriting existing groups. Default is False. + save_parameters : bool, optional + If True, saves the collection-level parameters to a separate file in the specified format. + Default is True. + parameter_format : str, optional + The format for saving parameters, either "json" or "toml". Default is "toml". + update_lcms_objects : bool, optional + If True, updates the individual LCMS object HDF5 files with new raw file locations and any additional + information produced during the processing of the collection (e.g. cluster mass feature associations). Default is True. + + Notes + ----- + The HDF5 file structure includes: + - Attributes: date_utc, lcms_objects_folder, missing_mass_features_searched, manifest + - Groups: rt_alignments, cluster_assignments (if available) + + Induced mass features are saved to the individual LCMS object HDF5 files + within the .corems folder structure, not in the collection-level HDF5 file. + + Examples + -------- + >>> exporter = LCMSCollectionExporter("my_collection", lcms_collection) + >>> exporter.export_to_hdf5(overwrite=True) + """ + if overwrite: + if self.out_file_path.with_suffix(".hdf5").exists(): + self.out_file_path.with_suffix(".hdf5").unlink() + + with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle: + # Add basic attributes to the HDF5 file, always overwrite these + timenow = str( + datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z") + ) + hdf_handle.attrs["date_utc"] = timenow + hdf_handle.attrs["lcms_objects_folder"] = str(self.mass_spectra_collection.collection_parser.folder_location) + hdf_handle.attrs["missing_mass_features_searched"] = self.mass_spectra_collection.missing_mass_features_searched + hdf_handle.attrs["rt_aligned"] = self.mass_spectra_collection.rt_aligned + hdf_handle.attrs["rt_alignment_attempted"] = self.mass_spectra_collection.rt_alignment_attempted + + # Add the manifest to the HDF5 file, always overwrite this + hdf_handle.attrs["manifest"] = self._convert_manifest_to_json() + + # Save retention time alignments if they exist, only overwrite if specified + self._save_rt_alignments_to_hdf5(hdf_handle, overwrite) + + # Save cluster assignments if they exist, only overwrite if specified + self._save_cluster_assignments_to_hdf5(hdf_handle, overwrite) + + # Save new raw file locations to each LCMS object's HDF5 file if needed + if hasattr(self.mass_spectra_collection, 'raw_files_relocated') and self.mass_spectra_collection.raw_files_relocated: + self._update_raw_file_locations_in_hdf5() + + # Save induced mass features to the collection with associations to each individual, only if lcms_collection.missing_mass_features_searched is True + if self.mass_spectra_collection.missing_mass_features_searched: + self._save_induced_mass_features_to_hdf5(overwrite) + # Save EICs for induced mass features at collection level + self._save_induced_eics_to_hdf5(overwrite) + + # Build cluster mass feature map to know which features to update + # This uses the same logic as process_consensus_features to determine loaded features + cluster_mf_map = self._build_cluster_mf_map() + + # Save updated mass features for each LCMS object + # This implements selective update: only loaded features are updated, non-cluster features are preserved + if update_lcms_objects: + self._save_lcms_objects_to_hdf5(cluster_mf_map, overwrite) + + # Save collection-level parameters as separate file + if save_parameters: + # Check if parameter_format is valid + if parameter_format not in ["json", "toml"]: + raise ValueError("parameter_format must be 'json' or 'toml'") + + if parameter_format == "json": + dump_lcms_collection_settings_json( + filename=self.out_file_path.with_suffix(".json"), + lcms_collection=self.mass_spectra_collection, + ) + elif parameter_format == "toml": + dump_lcms_collection_settings_toml( + filename=self.out_file_path.with_suffix(".toml"), + lcms_collection=self.mass_spectra_collection, + ) + + def _save_rt_alignments_to_hdf5(self, hdf_handle, overwrite): + """Save retention time alignments to HDF5 file.""" + # If no rt_alignments, return early + if not self.mass_spectra_collection.rt_aligned: + return + + # If rt_alignments exist, save them + if self.mass_spectra_collection.rt_aligned: + group_name = "rt_alignments" + # grab dictionary of rt_alignments + rt_alignments = self.mass_spectra_collection.rt_alignments + + if rt_alignments: + # Check if group exists and handle overwrite logic + if group_name in hdf_handle: + if not overwrite: + return + del hdf_handle[group_name] + + grp = hdf_handle.create_group(group_name) + + # Save each alignment as a dataset + for sample_idx, alignment_data in rt_alignments.items(): + grp.create_dataset(str(sample_idx), data=alignment_data) + + def _convert_manifest_to_json(self): + """Clean the manifest for export to HDF5.""" + manifest = self.mass_spectra_collection.collection_parser.manifest + + # Process the manifest to convert numpy.bool_ or bool values for the 'use_rt_alignment' key + def convert_bool_values(data): + if isinstance(data, dict): + # Process each key-value pair recursively + return {k: (int(v) if k == 'use_rt_alignment' and isinstance(v, (bool, np.bool_)) else convert_bool_values(v)) for k, v in data.items()} + elif isinstance(data, list): + # Recursively process lists + return [convert_bool_values(item) for item in data] + else: + # Return non-dict/list types unchanged + return data + + # Clean the whole manifest + cleaned_manifest = convert_bool_values(manifest) + # Serialize the cleaned manifest into JSON format + json_manifest = json.dumps(cleaned_manifest) + return json_manifest + + def _save_cluster_assignments_to_hdf5(self, hdf_handle, overwrite): + """Save cluster assignments to HDF5 file.""" + # Check if column "cluster" is present in self.mass_features_dataframe + if "cluster" in self.mass_spectra_collection.mass_features_dataframe.columns: + group_name = "cluster_assignments" + cluster_assignments = self.mass_spectra_collection.mass_features_dataframe[["cluster"]].copy() + + # Check if group exists and handle overwrite logic + if group_name in hdf_handle: + if not overwrite: + return + del hdf_handle[group_name] + + grp = hdf_handle.create_group(group_name) + + # Save the index, converting strings to bytes + grp.create_dataset("index", data=cluster_assignments.index.astype(str).values.astype('S')) + + # Save the "cluster" column + grp.create_dataset("cluster", data=cluster_assignments["cluster"].values) + + def _build_cluster_mf_map(self): + """Build a mapping of which mass features should be saved for each sample. + + This uses the same logic as process_consensus_features to determine which + mass features were loaded and should be updated in HDF5 files. + + Returns + ------- + dict + Dictionary mapping sample_id to list of tuples (mf_id, cluster_id). + Only includes samples that have loaded representative features. + Returns empty dict if no clusters exist. + + Notes + ----- + This follows the DRY principle by using the same get_sample_mf_map_for_representatives + method used by process_consensus_features and ReadSavedLCMSCollection. + """ + # Check if clusters exist + if "cluster" not in self.mass_spectra_collection.mass_features_dataframe.columns: + return {} + + # Check if cluster_summary_dataframe exists (needed by get_sample_mf_map_for_representatives) + if not hasattr(self.mass_spectra_collection, 'cluster_summary_dataframe') or \ + self.mass_spectra_collection.cluster_summary_dataframe is None: + return {} + + # Use the same DRY helper method that process_consensus_features uses + # This ensures consistency across the codebase + cluster_mf_map = self.mass_spectra_collection.get_sample_mf_map_for_representatives( + include_cluster_id=True + ) + + return cluster_mf_map + + def _update_raw_file_locations_in_hdf5(self): + """Update raw file locations in each LCMS object's HDF5 file. + + This method updates the 'original_file_location' attribute in each LCMS object's + HDF5 file to reflect the new raw file location after files have been relocated. + """ + for lcms_obj in self.mass_spectra_collection: + # Get the HDF5 file path for this LCMS object + hdf5_path = lcms_obj.file_location.with_suffix('.hdf5') + + if hdf5_path.exists(): + with h5py.File(hdf5_path, 'a') as hdf_handle: + # Update the original_file_location attribute + if 'original_file_location' in hdf_handle.attrs: + hdf_handle.attrs['original_file_location'] = str(lcms_obj.raw_file_location) + # If the attribute does not exist, create it + else: + hdf_handle.attrs.create('original_file_location', str(lcms_obj.raw_file_location)) + + def _save_induced_mass_features_to_hdf5(self, overwrite): + """Save induced mass features to the collection HDF5 file. + + Induced mass features are gap-filled features that only exist at the collection level. + They are saved with full detail (all attributes and datasets) in the collection HDF5 file + and distributed to individual LCMS objects when the collection is loaded. + + The induced mass features are stored in the collection's induced_mass_features_dataframe + and are regenerated as LCMSMassFeature objects for saving. + + Parameters + ---------- + overwrite : bool + If True, overwrites existing induced mass features group. If False, skips if group exists. + """ + # Check if we have any induced mass features to save + if (self.mass_spectra_collection.induced_mass_features_dataframe is None or + self.mass_spectra_collection.induced_mass_features_dataframe.empty): + return + + # Open the collection HDF5 file to save induced mass features + with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle: + group_name = "induced_mass_features" + + # Check if group exists and handle overwrite logic + if group_name in hdf_handle: + if not overwrite: + return + del hdf_handle[group_name] + + # Create top-level group for induced mass features + imf_group = hdf_handle.create_group(group_name) + + # Get the induced mass features dataframe + induced_df = self.mass_spectra_collection.induced_mass_features_dataframe + + # Get unique sample IDs from the dataframe + sample_ids = induced_df['sample_id'].unique() + + # Iterate through each sample and save its induced mass features + for sample_id in sample_ids: + # Filter dataframe to this sample + sample_df = induced_df[induced_df['sample_id'] == sample_id].copy() + + if sample_df.empty: + continue + + # Regenerate mass features from the dataframe + regenerated_features = self._regenerate_mass_features_from_sample_df( + sample_df, sample_id + ) + + if not regenerated_features: + continue + + # Create a subgroup for this sample's induced mass features + sample_group = imf_group.create_group(str(sample_id)) + + # Use the static helper method from LCMSExport to save the mass features + LCMSExport._save_mass_features_dict_to_hdf5( + regenerated_features, + sample_group, + overwrite=overwrite + ) + + def _save_induced_eics_to_hdf5(self, overwrite): + """Save EICs for induced mass features to the collection HDF5 file. + + Induced mass features are gap-filled features created during process_consensus_features. + Their associated EICs need to be saved at the collection level so they can be reloaded. + + The induced mass features are identified from the collection's induced_mass_features_dataframe, + and their EICs are retrieved from the individual LCMS objects. + + Parameters + ---------- + overwrite : bool + If True, overwrites existing induced EICs group. If False, skips if group exists. + """ + # Check if we have any induced mass features to save + if (self.mass_spectra_collection.induced_mass_features_dataframe is None or + self.mass_spectra_collection.induced_mass_features_dataframe.empty): + return + + # Open the collection HDF5 file to save induced EICs + with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle: + group_name = "induced_eics" + + # Check if group exists and handle overwrite logic + if group_name in hdf_handle: + if not overwrite: + return + del hdf_handle[group_name] + + # Create top-level group for induced EICs + induced_eics_group = hdf_handle.create_group(group_name) + + # Get the induced mass features dataframe + induced_df = self.mass_spectra_collection.induced_mass_features_dataframe + + # Get unique sample IDs from the dataframe + sample_ids = induced_df['sample_id'].unique() + + # Iterate through each sample and save EICs for its induced mass features + for sample_id in sample_ids: + lcms_obj = self.mass_spectra_collection[sample_id] + + # Filter dataframe to this sample + sample_df = induced_df[induced_df['sample_id'] == sample_id].copy() + + if sample_df.empty: + continue + + # Collect EICs for induced mass features using _eic_mz from dataframe + induced_eics = {} + for _, row in sample_df.iterrows(): + # Get the EIC m/z from the dataframe + eic_mz = row.get('_eic_mz') + + if eic_mz is not None and pd.notna(eic_mz): + # Try to get the EIC from the LCMS object + if hasattr(lcms_obj, 'eics') and lcms_obj.eics and eic_mz in lcms_obj.eics: + induced_eics[eic_mz] = lcms_obj.eics[eic_mz] + + if not induced_eics: + continue + + # Create a subgroup for this sample's induced EICs + sample_group = induced_eics_group.create_group(str(sample_id)) + + # Use the static helper method from LCMSExport to save the EICs + LCMSExport._save_eics_dict_to_hdf5(induced_eics, sample_group, overwrite) + + def _regenerate_mass_features_from_sample_df(self, sample_df, sample_id): + """Regenerate induced mass features from a sample-specific dataframe. + + This method creates LCMSMassFeature objects from rows in the induced_mass_features_dataframe + for a specific sample. The regenerated features are used for saving to HDF5. + + Parameters + ---------- + sample_df : pd.DataFrame + DataFrame containing induced mass features for a specific sample. + sample_id : int + The sample ID (index in the collection). + + Returns + ------- + dict + Dictionary of regenerated LCMSMassFeature objects keyed by feature ID. + """ + from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature + + if sample_df.empty: + return {} + + # Get the corresponding LCMS object for proper parent reference + lcms_obj = self.mass_spectra_collection[sample_id] + + # Regenerate mass features from the dataframe + regenerated_features = {} + + for _, row in sample_df.iterrows(): + # Extract the original ID from mf_id (format: c{cluster}_{index}_i) + # This is the ID used in lcms_obj.induced_mass_features dict + original_id = row['mf_id'] + + # Create a new LCMSMassFeature with proper parent reference + # Note: dataframe uses 'scan_time' but __init__ parameter is 'retention_time' + mass_feature = LCMSMassFeature( + lcms_parent=lcms_obj, + mz=row['mz'], + retention_time=row['scan_time'], # Column is 'scan_time' in dataframe + intensity=row['intensity'], + apex_scan=int(row['apex_scan']), + persistence=row.get('persistence', None) if 'persistence' in row else None, + id=original_id # Use the original string ID from gap-filling + ) + + # Set additional attributes dynamically from dataframe columns + # Skip columns already handled in __init__ or structural metadata + skip_cols = { + 'sample_id', 'mf_id', 'mz', 'scan_time', 'scan_time_aligned', + 'intensity', 'apex_scan', 'persistence'} + + # Iterate through all columns and set via property setters + for col_name in row.index: + if col_name in skip_cols or pd.isna(row[col_name]): + continue + + # Convert value to appropriate type + value = row[col_name] + + # Set via property (public interface handles private attributes) + # Don't save empty lists + if isinstance(value, list) and len(value) == 0: + continue + try: + setattr(mass_feature, col_name, value) + except (AttributeError, TypeError): + pass # Skip attributes that don't exist or can't be set + + # Set cluster_index if present + if 'cluster' in row and pd.notna(row['cluster']): + mass_feature.cluster_index = int(row['cluster']) + + regenerated_features[mass_feature.id] = mass_feature + + return regenerated_features + + def _save_lcms_objects_to_hdf5(self, cluster_mf_map, overwrite): + """Save updated mass features for each LCMS object. + + This method implements a "selective update" strategy for mass features: + - For mass features specified in cluster_mf_map (loaded representatives), we selectively + update them by deleting their old entries and re-saving with new attributes. + - Non-cluster features (not loaded) are never touched/overwritten. + + Note: EICs are NOT saved here. Induced feature EICs are saved at the collection level. + + Parameters + ---------- + cluster_mf_map : dict + Dictionary mapping sample_id to list of tuples (mf_id, cluster_id). + This explicitly defines which mass features should be updated. + overwrite : bool + If True, allows overwriting of existing data. If False, skips if data exists. + """ + for sample_id, lcms_obj in enumerate(self.mass_spectra_collection): + hdf5_path = lcms_obj.file_location.with_suffix('.hdf5') + + if not hdf5_path.exists(): + # If HDF5 doesn't exist, we can't do selective update, raise error + raise FileNotFoundError( + f"HDF5 file for LCMS object {lcms_obj.sample_name} not found at {hdf5_path}" + ) + + # Check if this sample has any loaded features in the map + if sample_id not in cluster_mf_map or not cluster_mf_map[sample_id]: + # Nothing loaded for this sample, nothing to update + continue + + # Extract mf_ids from the map (cluster_mf_map contains tuples of (mf_id, cluster_id)) + mf_ids_to_update = [mf_id for mf_id, cluster_id in cluster_mf_map[sample_id]] + + # Perform selective update of mass features + self._selective_update_mass_features(lcms_obj, hdf5_path, mf_ids_to_update, overwrite) + + # Save any new mass spectra that were added during processing + self._save_new_mass_spectra(lcms_obj, hdf5_path, overwrite) + + def _save_new_mass_spectra(self, lcms_obj, hdf5_path, overwrite): + """Save new mass spectra that were added during processing. + + This method checks what mass spectra are in lcms_obj._ms and saves any + that aren't already in the HDF5 file's mass_spectra group. Uses the + existing add_mass_spectrum_to_hdf5 method for consistency with original + export logic. + + Parameters + ---------- + lcms_obj : LCMSBase + The LCMS object with potentially new mass spectra. + hdf5_path : Path + Path to the HDF5 file. + overwrite : bool + If True, allows overwriting existing spectra. + """ + # Check if there are any mass spectra to save + if not hasattr(lcms_obj, '_ms') or not lcms_obj._ms: + return + + # Create an LCMS exporter instance for this LCMS object + # This gives us access to add_mass_spectrum_to_hdf5 method inherited from HighResMassSpecExport + # Turn hdf5_path into str without suffix for LCMSExport + hdf5_path_str = str(hdf5_path.with_suffix('')) + exporter = LCMSExport( + out_file_path=hdf5_path_str, + mass_spectra=lcms_obj + ) + + # Open HDF5 file and check existing mass spectra + with h5py.File(hdf5_path, 'a') as hdf_handle: + # Create mass_spectra group if it doesn't exist + if 'mass_spectra' not in hdf_handle: + ms_group = hdf_handle.create_group('mass_spectra') + existing_scan_numbers = set() + else: + ms_group = hdf_handle['mass_spectra'] + existing_scan_numbers = set(int(k) for k in ms_group.keys()) + + # Find new mass spectra (in _ms but not in HDF5) + new_scan_numbers = set(lcms_obj._ms.keys()) - existing_scan_numbers + + if not new_scan_numbers: + return + + # Save new mass spectra using existing add_mass_spectrum_to_hdf5 method + export_profile = lcms_obj.parameters.lc_ms.export_profile_spectra + for scan_number in new_scan_numbers: + mass_spec = lcms_obj._ms[scan_number] + scan_group_name = str(scan_number) + + # Delete existing group if overwrite is True + if scan_group_name in ms_group and overwrite: + del ms_group[scan_group_name] + elif scan_group_name in ms_group: + continue + + # Use the existing method from HighResMassSpecExport + exporter.add_mass_spectrum_to_hdf5( + hdf_handle=hdf_handle, + mass_spectrum=mass_spec, + group_key=scan_group_name, + mass_spectra_group=ms_group, + export_raw=export_profile + ) + + def _selective_update_mass_features(self, lcms_obj, hdf5_path, mf_ids_to_update, overwrite): + """Selectively update mass features in HDF5 file. + + This method deletes only the mass features specified in mf_ids_to_update, + then re-saves them with their potentially updated attributes. Non-cluster features + in the HDF5 file are left untouched. + + Parameters + ---------- + lcms_obj : LCMSBase + The LCMS object with mass features to update. + hdf5_path : Path + Path to the HDF5 file. + mf_ids_to_update : list of int + List of mass feature IDs that should be updated. This explicitly defines + which features were loaded and should be saved. + overwrite : bool + If True, allows overwriting. If False, skips if group exists. + """ + if not mf_ids_to_update: + return + + # Open HDF5 file and delete specified feature IDs, then re-save + with h5py.File(hdf5_path, 'a') as hdf_handle: + if 'mass_features' not in hdf_handle: + return + + mf_group = hdf_handle['mass_features'] + + # Delete features that are being updated + for feature_id in mf_ids_to_update: + feature_id_str = str(feature_id) + if feature_id_str in mf_group: + del mf_group[feature_id_str] + + # Re-save updated features (only those that exist in mass_features dict) + updated_features = { + mf.id: mf for mf in lcms_obj.mass_features.values() + if mf.id in mf_ids_to_update + } + + if updated_features: + LCMSExport._save_mass_features_dict_to_hdf5( + updated_features, + mf_group, + overwrite=overwrite + ) + diff --git a/corems/mass_spectrum/input/baseClass.py b/corems/mass_spectrum/input/baseClass.py index bdabe904c..58825c7ba 100644 --- a/corems/mass_spectrum/input/baseClass.py +++ b/corems/mass_spectrum/input/baseClass.py @@ -254,10 +254,15 @@ def get_dataframe(self) -> DataFrame: ) elif self.data_type == "pks": + # Predator .pks columns are positional: peak location (m/z), relative + # peak height (normalized 0-100), absolute abundance, resolving power, + # frequency, S/N. Use the absolute abundance as the intensity -- the + # relative peak height is per-spectrum normalized and not comparable + # across spectra, so it is named so header_translate drops it. names = [ "m/z", - "I", - "Scaled Peak Height", + "Relative Abundance", + "Abundance", "Resolving Power", "Frequency", "S/N", diff --git a/corems/molecular_id/search/database_interfaces.py b/corems/molecular_id/search/database_interfaces.py index e8b4b9e6a..7134b7997 100644 --- a/corems/molecular_id/search/database_interfaces.py +++ b/corems/molecular_id/search/database_interfaces.py @@ -1193,6 +1193,31 @@ def _read_msp_file(self): df[column] = pd.to_numeric(df[column], errors="raise") except: pass + + # Standardize spectra ID column name + # Check for common variations and create a standard 'spectra_id' column + spectra_id_variants = ['spectrum_id', 'gnps_spectra_id'] + for variant in spectra_id_variants: + if variant in df.columns and 'spectra_id' not in df.columns: + df['spectra_id'] = df[variant] + break + + # If no spectra_id column exists after checking variants, create one with sequential IDs + if 'spectra_id' not in df.columns: + df['spectra_id'] = [f"spectrum_{i:06d}" for i in range(len(df))] + + # Standardize compound name column + # Ensure 'compound_name' column exists, using 'name' field + if 'name' in df.columns and 'compound_name' not in df.columns: + df['compound_name'] = df['name'] + elif 'compound_name' not in df.columns: + warnings.warn( + "MSP file does not contain 'name' or 'compound_name' field. " + "Compound names will be set to 'Unknown'. This may affect plot labels and annotations.", + UserWarning + ) + df['compound_name'] = 'Unknown' + return df def _to_df(self, input_dataframe, normalize=True): @@ -1344,12 +1369,8 @@ def _check_msp_compatibility(self): ) # Check if the MSP file contains the required columns for metabolite metadata - # inchikey, by name, not null # either formula or molecular_formula, not null - if not all(self._data_frame["inchikey"].notnull()): - raise ValueError( - "Input field on MSP 'inchikey' must contain only non-null values." - ) + if ( "formula" not in self._data_frame.columns and "molecular_formula" not in self._data_frame.columns @@ -1372,11 +1393,42 @@ def get_metabolomics_spectra_library( format="fe", normalize=True, fe_kwargs={}, + molecular_id_field="inchikey", ): """ Prepare metabolomics spectra library and associated metabolite metadata - Note: this uses the inchikey as the index for the metabolite metadata dataframe and for connecting to the spectra, so it must be in the input + Parameters + ---------- + polarity : str + Polarity of the spectra to extract. Must be 'positive' or 'negative'. + metabolite_metadata_mapping : dict, optional + Mapping of MSP field names to MetaboliteMetadata attribute names. + Default uses common mappings (e.g., 'molecular_formula' -> 'formula'). + format : str, optional + Output format for the spectral library. Options: 'fe', 'flashentropy', 'msp', 'df'. + Default is 'fe' (FlashEntropy). + normalize : bool, optional + Whether to normalize spectra. Default is True. + fe_kwargs : dict, optional + Additional keyword arguments for FlashEntropy library creation. + molecular_id_field : str, optional + Field name to use as the unique molecular identifier for linking spectra to metadata. + Default is 'inchikey'. The specified field must exist in the MSP file and contain + non-null values for all entries. + + Returns + ------- + tuple + (spectral_library, metabolite_metadata_dict) where spectral_library is in the + requested format and metabolite_metadata_dict maps molecular IDs to MetaboliteMetadata objects. + + Notes + ----- + The molecular_id_field parameter allows flexibility for different MSP file formats: + - Use 'inchikey' for standard metabolite databases (default) + - Use 'name' or 'spectra_id' for custom or isotope-labeled standards + - The specified field must exist and contain non-null values for all entries """ # Check if the MSP file is compatible with the get_metabolomics_spectra_library method @@ -1405,9 +1457,31 @@ def get_metabolomics_spectra_library( "precursortype":"ion_type" } db_df.rename(columns=metabolite_metadata_mapping, inplace=True) - db_df["molecular_data_id"] = db_df["inchikey"] - - + + # Create molecular_data_id from the specified field + if molecular_id_field not in db_df.columns: + raise ValueError( + f"Specified molecular_id_field '{molecular_id_field}' not found in MSP data. " + f"Available columns: {', '.join(db_df.columns)}" + ) + + if not db_df[molecular_id_field].notnull().all(): + raise ValueError( + f"Specified molecular_id_field '{molecular_id_field}' contains null values. " + f"All entries must have non-null values for the molecular ID field." + ) + + # Use the specified field as the molecular ID + db_df["molecular_data_id"] = db_df[molecular_id_field].astype(str) + + # Ensure 'id' field exists for spectra identification + # If not present, create from spectra_id or use a sequential index + if "id" not in db_df.columns: + if "spectra_id" in db_df.columns: + db_df["id"] = db_df["spectra_id"].astype(str) + else: + # Generate sequential IDs + db_df["id"] = [f"spectrum_{i:06d}" for i in range(len(db_df))] # Check if the resulting dataframe has the required columns for the flash entropy search required_columns = ["molecular_data_id", "precursor_mz", "ion_type", "id"] @@ -1432,7 +1506,7 @@ def get_metabolomics_spectra_library( metabolite_metadata_df.drop_duplicates(subset=["molecular_data_id"], inplace=True) metabolite_metadata_df["id"] = metabolite_metadata_df["molecular_data_id"] - # Convert to a dictionary using the inchikey as the key + # Convert to a dictionary using the molecular_data_id as the key metabolite_metadata_dict = metabolite_metadata_df.to_dict( orient="records" ) diff --git a/corems/molecular_id/search/lcms_spectral_search.py b/corems/molecular_id/search/lcms_spectral_search.py index dd9c9a7df..95f33269a 100644 --- a/corems/molecular_id/search/lcms_spectral_search.py +++ b/corems/molecular_id/search/lcms_spectral_search.py @@ -127,6 +127,7 @@ def fe_search( use_mass_features=True, peak_sep_da=0.01, get_additional_metrics=True, + accumulate_results=False, ): """ Search LCMS spectra using a FlashEntropy approach. @@ -149,6 +150,10 @@ def fe_search( instance, by default 0.01. get_additional_metrics : bool, optional If True, get additional metrics from FlashEntropy search, by default True. + accumulate_results : bool, optional + If True, accumulate results with existing spectral_search_results instead of + replacing them. This allows searching the same scans with multiple libraries + without overwriting previous results, by default False. Returns ------- @@ -301,21 +306,43 @@ def fe_search( ) # Add MS2SearchResults to the existing spectral search results dictionary - self.spectral_search_results.update(overall_results_dict) + if accumulate_results: + # Merge results with existing spectral_search_results + for scan_id, precursor_dict in overall_results_dict.items(): + if scan_id in self.spectral_search_results: + # Scan already has results, merge precursor_mz dictionaries + self.spectral_search_results[scan_id].update(precursor_dict) + else: + # New scan, add entire dictionary + self.spectral_search_results[scan_id] = precursor_dict + else: + # Replace existing results (original behavior) + self.spectral_search_results.update(overall_results_dict) # If there are mass features, associate the results with each mass feature if len(self.mass_features) > 0: + # Determine which results to associate with mass features + if accumulate_results: + # When accumulating, only associate new results from this search + # to avoid duplicating previously associated results + results_to_associate = overall_results_dict + else: + # When not accumulating, clear existing associations and re-associate all results + for mass_feature_id in self.mass_features.keys(): + self.mass_features[mass_feature_id].ms2_similarity_results = [] + results_to_associate = self.spectral_search_results + for mass_feature_id, mass_feature in self.mass_features.items(): scan_ids = mass_feature.ms2_scan_numbers for ms2_scan_id in scan_ids: precursor_mz = mass_feature.mz try: - self.spectral_search_results[ms2_scan_id][precursor_mz] + results_to_associate[ms2_scan_id][precursor_mz] except KeyError: pass else: self.mass_features[ mass_feature_id ].ms2_similarity_results.append( - self.spectral_search_results[ms2_scan_id][precursor_mz] + results_to_associate[ms2_scan_id][precursor_mz] ) diff --git a/examples/README.md b/examples/README.md index 741888385..2681104ea 100644 --- a/examples/README.md +++ b/examples/README.md @@ -60,7 +60,40 @@ Process liquid chromatography mass spectrometry data with MS2 analysis: --- -### 4. Mass_Recalibration_Tutorial.ipynb +### 4. LCMS_Targeted_Search_Tutorial.ipynb +**Finding specific compounds by m/z and retention time** + +Perform targeted searches for known compounds using CoreMS: +- Define target compounds with expected m/z and retention time values +- Configure tolerances for m/z (ppm) and RT (minutes) +- Perform selective peak picking for specific targets +- Verify recovery and measure deviations from expected values +- Associate MS2 data with target compounds +- Export results with persistent metadata labels + +**Data Format:** Thermo `.raw` files +**Recommended For:** Internal standard verification, quality control, spike-in recovery studies, targeted metabolomics +**Use Cases:** Internal standards, QC monitoring, quantitative workflows + +--- + +### 5. LCMS_Collection_Tutorial.ipynb +**Multi-sample metabolomics workflow with consensus features** + +Process multiple LC-MS samples as a collection for cross-sample comparison: +- Load and align multiple LC-MS samples +- Generate consensus mass features across samples +- Perform gap filling to find missing features +- Create pivot tables showing feature distribution +- Apply molecular annotations to collection data +- Export and visualize collection-level results + +**Data Format:** Thermo `.raw` files +**Recommended For:** Multi-sample metabolomics studies, comparative analyses, LC-MS users + +--- + +### 6. Mass_Recalibration_Tutorial.ipynb **Improving mass accuracy through calibration** Master mass recalibration techniques for high-resolution data: @@ -73,7 +106,7 @@ Master mass recalibration techniques for high-resolution data: --- -### 5. Noise_Thresholding_Methods.ipynb +### 7. Noise_Thresholding_Methods.ipynb **Comparing noise filtering approaches** Compare different noise thresholding methods for peak detection: @@ -86,7 +119,7 @@ Compare different noise thresholding methods for peak detection: --- -### 6. ResolvingPowerFilter_ICR.ipynb +### 8. ResolvingPowerFilter_ICR.ipynb **Filtering peaks based on resolving power** Compare different approaches to resolving power-based peak filtering for FT-ICR MS: @@ -100,7 +133,7 @@ Compare different approaches to resolving power-based peak filtering for FT-ICR --- -### 7. Setting_MSParameters.ipynb +### 9. Setting_MSParameters.ipynb **Configuring global parameters in CoreMS** Understand how to control data processing behavior: diff --git a/examples/notebooks/LCMS_Collection_Tutorial.ipynb b/examples/notebooks/LCMS_Collection_Tutorial.ipynb new file mode 100644 index 000000000..bf00e9bd7 --- /dev/null +++ b/examples/notebooks/LCMS_Collection_Tutorial.ipynb @@ -0,0 +1,5754 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8c58bc4b", + "metadata": {}, + "source": [ + "# LC-MS Collection Processing\n", + "## Multi-Sample Metabolomics Workflow with Consensus Features\n", + "\n", + "This notebook demonstrates how to process multiple LC-MS samples as a collection using CoreMS, enabling cross-sample comparison, alignment, and consensus feature detection.\n", + "\n", + "### Workflow Overview\n", + "1. Prepare individual LC-MS samples and export to HDF5\n", + "2. Load samples as an LCMSCollection\n", + "3. Align retention times across samples\n", + "4. Generate consensus mass features (clustering)\n", + "5. Perform gap filling for missing features\n", + "6. Create pivot tables and cluster representatives\n", + "7. Add molecular annotations (MS1 formula search and MS2 spectral matching)\n", + "8. Export collection results\n", + "9. Visualize collection-level data\n", + "\n", + "### Key Concepts\n", + "- **Consensus Features**: Clusters of mass features that represent the same chemical entity across samples\n", + "- **Gap Filling**: Searching raw data for features missing in a sample but present in others\n", + "- **Representative Features**: The best representative mass feature from each consensus cluster\n", + "- **Collection Pivot Table**: Matrix showing feature presence/absence or intensity across all samples\n", + "\n", + "### Data Format\n", + "This tutorial uses the same Thermo Fisher RAW format LC-MS data as the LCMS_Tutorial, but processes it as a collection of 3 samples with different feature levels to demonstrate gap filling and collection-level analysis." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c6d8a470", + "metadata": {}, + "outputs": [], + "source": [ + "# Import required packages\n", + "import numpy as np\n", + "import pandas as pd\n", + "from pathlib import Path\n", + "import shutil\n", + "\n", + "from corems.mass_spectra.input.rawFileReader import ImportMassSpectraThermoMSFileReader\n", + "from corems.mass_spectra.output.export import LCMSMetabolomicsExport, LCMSCollectionExport\n", + "from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectraCollection\n", + "from corems.encapsulation.factory.parameters import LCMSParameters\n", + "from corems.molecular_id.search.database_interfaces import MSPInterface\n", + "from corems.encapsulation.factory.parameters import hush_output\n", + "\n", + "# Running this keeps the notebook output cleaner and is recommended unless debugging\n", + "hush_output()\n", + "\n", + "# Suppress warnings for cleaner output\n", + "import warnings\n", + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "markdown", + "id": "da3b4cb0", + "metadata": {}, + "source": [ + "## Step 1: Prepare Individual Samples\n", + "\n", + "For this tutorial, we'll create 3 samples with different levels of mass features to demonstrate gap filling:\n", + "- **Sample 1**: Full set of mass features (reference sample)\n", + "- **Sample 2**: Partial set (first 50 features only)\n", + "- **Sample 3**: No mass features (extreme case for gap filling)\n", + "\n", + "In a real workflow, you would process actual different samples, but this demonstrates the collection functionality." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "11d13a80", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tutorial data will be saved to: /Users/heal742/LOCAL/corems_dev/corems/examples/notebooks/tutorial_collection_data\n" + ] + } + ], + "source": [ + "# Set up paths for this tutorial\n", + "raw_file_path = Path('../../tests/tests_data/lcms/Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801.raw')\n", + "processed_folder = Path('./tutorial_collection_data')\n", + "\n", + "# Clean up any existing tutorial data\n", + "if processed_folder.exists():\n", + " shutil.rmtree(processed_folder)\n", + "processed_folder.mkdir()\n", + "\n", + "print(f\"Tutorial data will be saved to: {processed_folder.absolute()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "55da94d5", + "metadata": {}, + "source": [ + "### Configure Processing Parameters\n", + "\n", + "Set parameters appropriate for this dataset. These control peak picking, noise thresholding, and other processing steps." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "96802b8e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Parameters configured for LC-MS processing\n" + ] + } + ], + "source": [ + "# Load and configure the raw data\n", + "parser = ImportMassSpectraThermoMSFileReader(str(raw_file_path))\n", + "lcms_obj = parser.get_lcms_obj(spectra=\"ms1\")\n", + "\n", + "# Set parameters for fast processing\n", + "lcms_obj.parameters = LCMSParameters(use_defaults=True)\n", + "\n", + "# Persistent homology peak picking parameters\n", + "lcms_obj.parameters.lc_ms.peak_picking_method = \"persistent homology\"\n", + "lcms_obj.parameters.lc_ms.ph_inten_min_rel = 0.001\n", + "lcms_obj.parameters.lc_ms.ph_persis_min_rel = 0.05\n", + "lcms_obj.parameters.lc_ms.ph_smooth_it = 0\n", + "lcms_obj.parameters.lc_ms.ms1_scans_to_average = 3\n", + "\n", + "# MS1 parameters\n", + "ms1_params = lcms_obj.parameters.mass_spectrum['ms1']\n", + "ms1_params.mass_spectrum.noise_threshold_method = \"relative_abundance\"\n", + "ms1_params.mass_spectrum.noise_threshold_min_relative_abundance = 0.1\n", + "ms1_params.mass_spectrum.noise_min_mz = 0\n", + "ms1_params.mass_spectrum.min_picking_mz = 0\n", + "ms1_params.mass_spectrum.noise_max_mz = np.inf\n", + "ms1_params.mass_spectrum.max_picking_mz = np.inf\n", + "ms1_params = lcms_obj.parameters.mass_spectrum['ms1']\n", + "\n", + "# Molecular formula search parameters\n", + "ms1_params.molecular_search.url_database = \"\" # This will run with a locally generated sqlite database\n", + "ms1_params.molecular_search.min_ppm_error = -5\n", + "ms1_params.molecular_search.max_ppm_error = 5\n", + "ms1_params.molecular_search.isRadical = False # Do not search for radical species, only protonated; the report will report the ion formula\n", + "ms1_params.molecular_search.usedAtoms = { # Elements and their min/max counts\n", + " 'C': (1, 90),\n", + " 'H': (4, 200),\n", + " 'O': (0, 30),\n", + " 'N': (0, 3),\n", + " 'P': (0, 2),\n", + " 'S': (0, 2),\n", + "}\n", + "\n", + "# MS2 parameters\n", + "ms2_params = lcms_obj.parameters.mass_spectrum['ms2']\n", + "ms2_params.mass_spectrum.noise_threshold_method = \"relative_abundance\"\n", + "ms2_params.mass_spectrum.noise_threshold_min_relative_abundance = 1.0\n", + "\n", + "print(\"Parameters configured for LC-MS processing\")" + ] + }, + { + "cell_type": "markdown", + "id": "98b0feab", + "metadata": {}, + "source": [ + "### Process and Create Sample 1 (Full Features)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "b8528773", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Finding mass features...\n", + "Found 129 initial mass features\n", + "Found 126 mass features in Sample 1\n", + "✓ Exported Sample 1: 126 features\n" + ] + } + ], + "source": [ + "# Find and integrate mass features, and find ms2 scans that are associated with these features\n", + "print(\"Finding mass features...\")\n", + "lcms_obj.find_mass_features(assign_ms2_scans=True)\n", + "lcms_obj.integrate_mass_features(drop_if_fail=True)\n", + "\n", + "num_features = len(lcms_obj.mass_features)\n", + "print(f\"Found {num_features} mass features in Sample 1\")\n", + "\n", + "# Save all features for creating sample variations\n", + "all_mass_features = dict(lcms_obj.mass_features)\n", + "all_eics = dict(lcms_obj.eics)\n", + "\n", + "# Export Sample 1 with all features\n", + "sample_name_1 = \"test_sample_01\"\n", + "exporter1 = LCMSMetabolomicsExport(str(processed_folder / sample_name_1), lcms_obj)\n", + "exporter1.to_hdf(overwrite=True)\n", + "print(f\"✓ Exported Sample 1: {num_features} features\")" + ] + }, + { + "cell_type": "markdown", + "id": "2093f6eb", + "metadata": {}, + "source": [ + "### Create Sample 2 (Partial Features)\n", + "\n", + "Take only the first 50 mass features to simulate a sample with fewer detected features." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "edca055c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Exported Sample 2: 50 features\n" + ] + } + ], + "source": [ + "# Create Sample 2 with partial features\n", + "sample_name_2 = \"test_sample_02\"\n", + "first_50_mf_ids = list(lcms_obj.mass_features.keys())[:50]\n", + "lcms_obj.mass_features = {mf_id: all_mass_features[mf_id] for mf_id in first_50_mf_ids}\n", + "\n", + "exporter2 = LCMSMetabolomicsExport(str(processed_folder / sample_name_2), lcms_obj)\n", + "exporter2.to_hdf(overwrite=True)\n", + "print(f\"✓ Exported Sample 2: 50 features\")" + ] + }, + { + "cell_type": "markdown", + "id": "53352f5f", + "metadata": {}, + "source": [ + "### Create Sample 3 (No Features)\n", + "\n", + "Clear all features to simulate an extreme case where gap filling will be needed for all features." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "fa186ab4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Exported Sample 3: 0 features (will be gap-filled)\n" + ] + } + ], + "source": [ + "# Create Sample 3 with no features\n", + "sample_name_3 = \"test_sample_03\"\n", + "lcms_obj.mass_features = {}\n", + "lcms_obj.eics = {}\n", + "\n", + "exporter3 = LCMSMetabolomicsExport(str(processed_folder / sample_name_3), lcms_obj)\n", + "exporter3.to_hdf(overwrite=True)\n", + "print(f\"✓ Exported Sample 3: 0 features (will be gap-filled)\")" + ] + }, + { + "cell_type": "markdown", + "id": "2594b04e", + "metadata": {}, + "source": [ + "### Create Collection Manifest\n", + "\n", + "A manifest file defines the sample metadata including batch, order, and which sample is the center (reference) for alignment." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "921ca2b2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Created manifest with 3 samples\n", + "\n", + "Sample preparation complete!\n" + ] + } + ], + "source": [ + "# Create manifest file\n", + "import csv\n", + "manifest_path = processed_folder / \"manifest.csv\"\n", + "with open(manifest_path, 'w', newline='') as f:\n", + " writer = csv.writer(f)\n", + " writer.writerow(['sample_name', 'batch', 'order', 'center'])\n", + " writer.writerow(['test_sample_01', 1, 1, True]) # Sample 1 is center (reference)\n", + " writer.writerow(['test_sample_02', 1, 2, False])\n", + " writer.writerow(['test_sample_03', 1, 3, False])\n", + "\n", + "print(f\"✓ Created manifest with 3 samples\")\n", + "print(\"\\nSample preparation complete!\")" + ] + }, + { + "cell_type": "markdown", + "id": "90e9bf0f", + "metadata": {}, + "source": [ + "## Step 2: Load the Collection\n", + "\n", + "Load all samples as a collection. The manifest defines which samples to include and their metadata. If not manifest is provided, samples can be loaded directly and a manifest will be generated automatically using the collection times to deduce batch/order, which is important for alignment. For our case, we will use the manifest we created since we have specific batch/order information." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3f61de41", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded collection with 3 samples\n", + "Total mass features across all samples: 176\n" + ] + } + ], + "source": [ + "# Load collection from processed samples\n", + "parser = ReadCoreMSHDFMassSpectraCollection(\n", + " folder_location=processed_folder,\n", + " manifest_file=manifest_path,\n", + " cores=1\n", + ")\n", + "\n", + "# Load collection (light loading for efficiency - doesn't load all raw MS data)\n", + "lcms_collection = parser.get_lcms_collection()\n", + "\n", + "print(f\"Loaded collection with {len(lcms_collection)} samples\")\n", + "print(f\"Total mass features across all samples: {len(lcms_collection.mass_features_dataframe)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4cf55d10", + "metadata": {}, + "source": [ + "### Examine Collection Structure" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "820ce6ab", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Samples in collection:\n", + " 0: test_sample_01 - 0 features\n", + " 1: test_sample_02 - 0 features\n", + " 2: test_sample_03 - 0 features\n", + "\n", + "Manifest:\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "object", + "type": "string" + }, + { + "name": "batch", + "rawType": "object", + "type": "string" + }, + { + "name": "order", + "rawType": "object", + "type": "string" + }, + { + "name": "center", + "rawType": "object", + "type": "string" + }, + { + "name": "collection_id", + "rawType": "object", + "type": "string" + } + ], + "ref": "8bd0c909-8058-4a6d-b68a-075bfea404de", + "rows": [ + [ + "test_sample_01", + "1", + "1", + "True", + "0" + ], + [ + "test_sample_02", + "1", + "2", + "False", + "1" + ], + [ + "test_sample_03", + "1", + "3", + "False", + "2" + ] + ], + "shape": { + "columns": 4, + "rows": 3 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
batchordercentercollection_id
test_sample_0111True0
test_sample_0212False1
test_sample_0313False2
\n", + "
" + ], + "text/plain": [ + " batch order center collection_id\n", + "test_sample_01 1 1 True 0\n", + "test_sample_02 1 2 False 1\n", + "test_sample_03 1 3 False 2" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# View sample names\n", + "print(\"Samples in collection:\")\n", + "for i, sample_name in enumerate(lcms_collection.samples):\n", + " num_features = len(lcms_collection[i].mass_features)\n", + " print(f\" {i}: {sample_name} - {num_features} features\")\n", + "\n", + "# View manifest dataframe\n", + "print(\"\\nManifest:\")\n", + "display(lcms_collection.manifest_dataframe)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "094b8400", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Mass Features Dataframe (all features from all samples):\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "coll_mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "sample_name", + "rawType": "object", + "type": "string" + }, + { + "name": "sample_id", + "rawType": "int64", + "type": "integer" + }, + { + "name": "mf_id", + "rawType": "float64", + "type": "float" + }, + { + "name": "mz", + "rawType": "float64", + "type": "float" + }, + { + "name": "type", + "rawType": "object", + "type": "string" + }, + { + "name": "scan_time", + "rawType": "float64", + "type": "float" + }, + { + "name": "apex_scan", + "rawType": "float64", + "type": "float" + }, + { + "name": "start_scan", + "rawType": "float64", + "type": "float" + }, + { + "name": "final_scan", + "rawType": "float64", + "type": "float" + }, + { + "name": "intensity", + "rawType": "float64", + "type": "float" + }, + { + "name": "persistence", + "rawType": "float64", + "type": "float" + }, + { + "name": "area", + "rawType": "float32", + "type": "float" + }, + { + "name": "tailing_factor", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index", + "rawType": "float64", + "type": "float" + }, + { + "name": "normalized_dispersity_index", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "monoisotopic_mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "isotopologue_type", + "rawType": "object", + "type": "string" + }, + { + "name": "mass_spectrum_deconvoluted_parent", + "rawType": "object", + "type": "string" + }, + { + "name": "ms2_scan_numbers", + "rawType": "object", + "type": "string" + }, + { + "name": "_eic_mz", + "rawType": "float64", + "type": "float" + } + ], + "ref": "a471e8e6-e476-43d8-84e7-41922081a3a7", + "rows": [ + [ + "0_0", + "test_sample_01", + "0", + "0.0", + "301.21661376953125", + "untargeted", + "8.895636666666666", + "1882.0", + "1828.0", + "2008.0", + "66775328.0", + "66708546.0", + "35045576.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[1874 1910]", + "301.21661376953125" + ], + [ + "0_1", + "test_sample_01", + "0", + "1.0", + "367.35748291015625", + "untargeted", + "19.152648333333335", + "4069.0", + "4024.0", + "4312.0", + "48137056.0", + "48070260.0", + "30641268.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[4036 4037 4070]", + "367.35748291015625" + ], + [ + "0_10", + "test_sample_01", + "0", + "10.0", + "698.62890625", + "untargeted", + "23.816803333333333", + "5212.0", + "5176.0", + "5338.0", + "17265106.0", + "17198326.0", + "7113439.5", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[5195 5196 5231 5232]", + "698.62890625" + ], + [ + "0_100", + "test_sample_01", + "0", + "100.0", + "569.1968994140625", + "untargeted", + "4.421146666666667", + "775.0", + "721.0", + "856.0", + "4048302.0", + "3981509.0", + "1949091.8", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[749 750 785 786]", + "569.1968994140625" + ], + [ + "0_101", + "test_sample_01", + "0", + "101.0", + "300.2048645019531", + "untargeted", + "7.376469999999999", + "1513.0", + "1477.0", + "1585.0", + "4030582.25", + "3963787.0", + "1566851.1", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "300.2048645019531" + ], + [ + "0_102", + "test_sample_01", + "0", + "102.0", + "456.35662841796875", + "untargeted", + "8.96547", + "1900.0", + "1855.0", + "1999.0", + "4012202.0", + "3943451.0", + "2064801.6", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "456.35662841796875" + ], + [ + "0_103", + "test_sample_01", + "0", + "103.0", + "527.4675903320312", + "untargeted", + "17.55847", + "3718.0", + "3682.0", + "3826.0", + "4000847.75", + "3934066.0", + "1553304.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[3703 3704 3737 3738]", + "527.4675903320312" + ], + [ + "0_104", + "test_sample_01", + "0", + "104.0", + "736.5104370117188", + "untargeted", + "20.793636666666668", + "4483.0", + "4438.0", + "4609.0", + "3974837.75", + "3908057.0", + "621550.9", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "736.5104370117188" + ], + [ + "0_105", + "test_sample_01", + "0", + "105.0", + "256.2359619140625", + "untargeted", + "9.071805", + "1927.0", + "1891.0", + "2062.0", + "3900504.25", + "3830292.0", + "1473387.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "256.2359619140625" + ], + [ + "0_106", + "test_sample_01", + "0", + "106.0", + "384.3563232421875", + "untargeted", + "10.071803333333333", + "2170.0", + "2143.0", + "2323.0", + "3833882.0", + "3767107.0", + "1835834.8", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "384.3563232421875" + ] + ], + "shape": { + "columns": 23, + "rows": 10 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
sample_namesample_idmf_idmztypescan_timeapex_scanstart_scanfinal_scanintensity...dispersity_indexnormalized_dispersity_indexnoise_scorenoise_score_minnoise_score_maxmonoisotopic_mf_idisotopologue_typemass_spectrum_deconvoluted_parentms2_scan_numbers_eic_mz
coll_mf_id
0_0test_sample_0100.0301.216614untargeted8.8956371882.01828.02008.066775328.00...NaNNaNNaNNaNNaNNoneNoneNone[1874, 1910]301.216614
0_1test_sample_0101.0367.357483untargeted19.1526484069.04024.04312.048137056.00...NaNNaNNaNNaNNaNNoneNoneNone[4036, 4037, 4070]367.357483
0_10test_sample_01010.0698.628906untargeted23.8168035212.05176.05338.017265106.00...NaNNaNNaNNaNNaNNoneNoneNone[5195, 5196, 5231, 5232]698.628906
0_100test_sample_010100.0569.196899untargeted4.421147775.0721.0856.04048302.00...NaNNaNNaNNaNNaNNoneNoneNone[749, 750, 785, 786]569.196899
0_101test_sample_010101.0300.204865untargeted7.3764701513.01477.01585.04030582.25...NaNNaNNaNNaNNaNNoneNoneNone[]300.204865
0_102test_sample_010102.0456.356628untargeted8.9654701900.01855.01999.04012202.00...NaNNaNNaNNaNNaNNoneNoneNone[]456.356628
0_103test_sample_010103.0527.467590untargeted17.5584703718.03682.03826.04000847.75...NaNNaNNaNNaNNaNNoneNoneNone[3703, 3704, 3737, 3738]527.467590
0_104test_sample_010104.0736.510437untargeted20.7936374483.04438.04609.03974837.75...NaNNaNNaNNaNNaNNoneNoneNone[]736.510437
0_105test_sample_010105.0256.235962untargeted9.0718051927.01891.02062.03900504.25...NaNNaNNaNNaNNaNNoneNoneNone[]256.235962
0_106test_sample_010106.0384.356323untargeted10.0718032170.02143.02323.03833882.00...NaNNaNNaNNaNNaNNoneNoneNone[]384.356323
\n", + "

10 rows × 23 columns

\n", + "
" + ], + "text/plain": [ + " sample_name sample_id mf_id mz type \\\n", + "coll_mf_id \n", + "0_0 test_sample_01 0 0.0 301.216614 untargeted \n", + "0_1 test_sample_01 0 1.0 367.357483 untargeted \n", + "0_10 test_sample_01 0 10.0 698.628906 untargeted \n", + "0_100 test_sample_01 0 100.0 569.196899 untargeted \n", + "0_101 test_sample_01 0 101.0 300.204865 untargeted \n", + "0_102 test_sample_01 0 102.0 456.356628 untargeted \n", + "0_103 test_sample_01 0 103.0 527.467590 untargeted \n", + "0_104 test_sample_01 0 104.0 736.510437 untargeted \n", + "0_105 test_sample_01 0 105.0 256.235962 untargeted \n", + "0_106 test_sample_01 0 106.0 384.356323 untargeted \n", + "\n", + " scan_time apex_scan start_scan final_scan intensity ... \\\n", + "coll_mf_id ... \n", + "0_0 8.895637 1882.0 1828.0 2008.0 66775328.00 ... \n", + "0_1 19.152648 4069.0 4024.0 4312.0 48137056.00 ... \n", + "0_10 23.816803 5212.0 5176.0 5338.0 17265106.00 ... \n", + "0_100 4.421147 775.0 721.0 856.0 4048302.00 ... \n", + "0_101 7.376470 1513.0 1477.0 1585.0 4030582.25 ... \n", + "0_102 8.965470 1900.0 1855.0 1999.0 4012202.00 ... \n", + "0_103 17.558470 3718.0 3682.0 3826.0 4000847.75 ... \n", + "0_104 20.793637 4483.0 4438.0 4609.0 3974837.75 ... \n", + "0_105 9.071805 1927.0 1891.0 2062.0 3900504.25 ... \n", + "0_106 10.071803 2170.0 2143.0 2323.0 3833882.00 ... \n", + "\n", + " dispersity_index normalized_dispersity_index noise_score \\\n", + "coll_mf_id \n", + "0_0 NaN NaN NaN \n", + "0_1 NaN NaN NaN \n", + "0_10 NaN NaN NaN \n", + "0_100 NaN NaN NaN \n", + "0_101 NaN NaN NaN \n", + "0_102 NaN NaN NaN \n", + "0_103 NaN NaN NaN \n", + "0_104 NaN NaN NaN \n", + "0_105 NaN NaN NaN \n", + "0_106 NaN NaN NaN \n", + "\n", + " noise_score_min noise_score_max monoisotopic_mf_id \\\n", + "coll_mf_id \n", + "0_0 NaN NaN None \n", + "0_1 NaN NaN None \n", + "0_10 NaN NaN None \n", + "0_100 NaN NaN None \n", + "0_101 NaN NaN None \n", + "0_102 NaN NaN None \n", + "0_103 NaN NaN None \n", + "0_104 NaN NaN None \n", + "0_105 NaN NaN None \n", + "0_106 NaN NaN None \n", + "\n", + " isotopologue_type mass_spectrum_deconvoluted_parent \\\n", + "coll_mf_id \n", + "0_0 None None \n", + "0_1 None None \n", + "0_10 None None \n", + "0_100 None None \n", + "0_101 None None \n", + "0_102 None None \n", + "0_103 None None \n", + "0_104 None None \n", + "0_105 None None \n", + "0_106 None None \n", + "\n", + " ms2_scan_numbers _eic_mz \n", + "coll_mf_id \n", + "0_0 [1874, 1910] 301.216614 \n", + "0_1 [4036, 4037, 4070] 367.357483 \n", + "0_10 [5195, 5196, 5231, 5232] 698.628906 \n", + "0_100 [749, 750, 785, 786] 569.196899 \n", + "0_101 [] 300.204865 \n", + "0_102 [] 456.356628 \n", + "0_103 [3703, 3704, 3737, 3738] 527.467590 \n", + "0_104 [] 736.510437 \n", + "0_105 [] 256.235962 \n", + "0_106 [] 384.356323 \n", + "\n", + "[10 rows x 23 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# View mass features dataframe (first few rows)\n", + "print(\"\\nMass Features Dataframe (all features from all samples):\")\n", + "display(lcms_collection.mass_features_dataframe.head(10))" + ] + }, + { + "cell_type": "markdown", + "id": "63c30c97", + "metadata": {}, + "source": [ + "### Configure Collection Parameters\n", + "\n", + "Set parameters for alignment, clustering, and gap filling." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "112ccb3c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Collection parameters configured\n" + ] + } + ], + "source": [ + "# Alignment parameters\n", + "lcms_collection.parameters.lcms_collection.mass_feature_anchor_technique = ['relative_intensity']\n", + "lcms_collection.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold = 0.0\n", + "lcms_collection.parameters.lcms_collection.alignment_mz_tol_ppm = 5\n", + "lcms_collection.parameters.lcms_collection.alignment_rt_tol = 0.2 # 12 seconds\n", + "\n", + "print(\"Collection parameters configured\")" + ] + }, + { + "cell_type": "markdown", + "id": "7f4a79ac", + "metadata": {}, + "source": [ + "## Step 3: Align Retention Times\n", + "\n", + "Align retention times across samples to account for chromatographic drift." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "d989ed7a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RT aligned before alignment: False\n", + "RT alignment attempted: True\n", + "RT aligned after alignment: False\n", + " Sample 0 has scan_time_aligned: True\n", + " Sample 1 has scan_time_aligned: True\n", + " Sample 2 has scan_time_aligned: True\n" + ] + } + ], + "source": [ + "# Perform RT alignment\n", + "print(f\"RT aligned before alignment: {lcms_collection.rt_aligned}\")\n", + "\n", + "lcms_collection.align_lcms_objects()\n", + "\n", + "print(f\"RT alignment attempted: {lcms_collection.rt_alignment_attempted}\")\n", + "print(f\"RT aligned after alignment: {lcms_collection.rt_aligned}\")\n", + "\n", + "# Check that scan_time_aligned was added to all samples\n", + "for i, lcms_obj in enumerate(lcms_collection):\n", + " has_aligned = 'scan_time_aligned' in lcms_obj.scan_df.columns\n", + " print(f\" Sample {i} has scan_time_aligned: {has_aligned}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b52ce5c8", + "metadata": {}, + "source": [ + "### Visualize TICs and Alignment\n", + "\n", + "In our case, the samples will not use any alignment since they are exact replicas. Therefore the aligned plot (lower) will be overlapping. Even if samples do not need alignment, running `align_lcms_objects()` is important as it will trigger the collection-level flag that alignment was attempted, which is a prerequisit for finding consensus mass features" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "4442c8d6", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA04AAANQCAYAAAAffD9qAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/OQEPoAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzdeXhTZdo/8O/J1jRNkzTdWwplK4vUUsAF0VFHZBll3Bgc9NXRUd/XhZ8w6Iw6jjvC6AiuqKOO67ij4sbo4IIgIspSEGVvS1u6r2mSZj3n90cgbZq0Sdukadrv57p6ac55zjl3Spue+zzPcz+CJEkSiIiIiIiIqEuyaAdAREREREQ00DFxIiIiIiIiCoKJExERERERURBMnIiIiIiIiIJg4kRERERERBQEEyciIiIiIqIgmDgREREREREFwcSJiIiIiIgoCCZOREREREREQTBxIiIiIiIiCmJIJ04bN27EvHnzkJWVBUEQsHbt2h6f4/PPP8epp56KxMREpKam4pJLLkFpaWnYYyUiIiIiougZ0omTxWJBQUEBVq9e3avjS0pKcMEFF+DXv/41ioqK8Pnnn6O+vh4XX3xxmCMlIiIiIqJoEiRJkqIdxEAgCAI++OADXHjhhd5tdrsdd955J9588000Nzdj0qRJeOihh3DWWWcBANasWYOFCxfCbrdDJvPkoB9//DEuuOAC2O12KJXKKLwTIiIiIiIKtyHd4xTMokWLsGXLFrz11lvYvXs3fve732HOnDk4ePAgAGDq1KmQyWR46aWX4Ha70dLSgtdeew0zZ85k0kRERERENIiwx+mYzj1OZWVlGDVqFMrKypCVleVtN3PmTJx88slYvnw5AOCbb77BggUL0NDQALfbjenTp2PdunUwGAxReBdERERERBQJ7HHqwk8//QS32428vDxotVrv1zfffIPDhw8DAKqrq3HdddfhD3/4A3788Ud88803UKlUmD9/PpiPEhERERENHopoBzBQmc1myOVybN++HXK53GefVqsFAKxevRp6vR4PP/ywd9+///1v5OTkYOvWrTj11FP7NWYiIiIiIooMJk5dKCwshNvtRm1tLc4444yAbaxWq7coxHHHkyxRFCMeIxERERER9Y8hPVTPbDajqKgIRUVFADzlxYuKilBWVoa8vDxcfvnluPLKK/H++++jpKQEP/zwA1asWIFPP/0UAHDeeefhxx9/xP3334+DBw9ix44duPrqqzFixAgUFhZG8Z0REREREVE4DeniEBs2bMDZZ5/tt/0Pf/gDXn75ZTidTixbtgyvvvoqjh49ipSUFJx66qm47777kJ+fDwB466238PDDD+PAgQPQaDSYPn06HnroIYwfP76/3w4REREREUXIkE6ciIiIiIiIQjGkh+oRERERERGFgokTERERERFREEOuqp4oiqisrERiYiIEQYh2OEREREREFCWSJKG1tRVZWVl+1bI7G3KJU2VlJXJycqIdBhERERERDRDl5eUYNmxYt22GXOKUmJgIwPPN0el0UY6GiIiIiIiixWQyIScnx5sjdGfIJU7Hh+fpdDomTkREREREFNIUHhaHICIiIiIiCoKJExERERERURBMnIiIiIiIiIIYcnOciIiIiCi63G43nE5ntMOgIUKpVEIul/f5PEyciIiIiKjfmM1mVFRUQJKkaIdCQ4QgCBg2bBi0Wm2fzsPEiYiIiIj6hdvtRkVFBTQaDVJTU0OqZEbUF5Ikoa6uDhUVFRg7dmyfep6YOBERERFRv3A6nZAkCampqYiPj492ODREpKamorS0FE6ns0+JE4tDEBEREVG/Yk8T9adw/bwxcSIiIiIiIgqCiRMREREREVEQUU2cNm7ciHnz5iErKwuCIGDt2rVBj7Hb7bjzzjsxYsQIxMXFITc3Fy+++GLkgyUiIiIiIj+h3sfHuqgmThaLBQUFBVi9enXIxyxYsABffvkl/vWvf2H//v148803MW7cuAhGSURERERD3VlnnYUlS5aE7XxXXXUVLrzwwrCdbzB59913MX78eKjVauTn52PdunU++99//33MmjULycnJEAQBRUVF/RJXVKvqzZ07F3Pnzg25/WeffYZvvvkGxcXFMBqNAIDc3NwIRUdEfVVTVAzR6UbmSWOjHQoRERHFgO+++w4LFy7EihUrcP755+ONN97AhRdeiB07dmDSpEkAPJ0vp59+OhYsWIDrrruu32KLqTlOH330EaZNm4aHH34Y2dnZyMvLw6233oq2trYuj7Hb7TCZTD5fRNQ/mn4+hOp1/412GERERH1y1VVX4ZtvvsHjjz8OQRAgCAJKS0uxZ88ezJ07F1qtFunp6bjiiitQX1/vPW7NmjXIz89HfHw8kpOTMXPmTFgsFtx777145ZVX8OGHH3rPt2HDhm5jcDgcWLRoETIzM6FWqzFixAisWLHCu3/VqlXIz89HQkICcnJycOONN8JsNnv3v/zyyzAYDPjkk08wbtw4aDQazJ8/H1arFa+88gpyc3ORlJSEm2++GW6323tcbm4uHnjgASxcuBAJCQnIzs4OOlqsvLwcCxYsgMFggNFoxAUXXIDS0tKQvtePP/445syZgz//+c+YMGECHnjgAUyZMgVPPfWUt80VV1yBu+++GzNnzgzpnOESU+s4FRcX49tvv4VarcYHH3yA+vp63HjjjWhoaMBLL70U8JgVK1bgvvvu6+dIiQgAJFGEULw32mEQEdEA5rAAdb/07zVTJwKqhNDbP/744zhw4AAmTZqE+++/HwCgVCpx8skn49prr8Wjjz6KtrY23HbbbViwYAG++uorVFVVYeHChXj44Ydx0UUXobW1FZs2bYIkSbj11luxd+9emEwm7z3s8dFUXXniiSfw0Ucf4Z133sHw4cNRXl6O8vJy736ZTIYnnngCI0eORHFxMW688Ub85S9/wdNPP+1tY7Va8cQTT+Ctt95Ca2srLr74Ylx00UUwGAxYt24diouLcckll2DGjBm49NJLvcf94x//wF//+lfcd999+Pzzz7F48WLk5eXh3HPP9YvT6XRi9uzZmD59OjZt2gSFQoFly5Zhzpw52L17N1QqVbfvc8uWLVi6dKnPttmzZw+IOVQxlTiJoghBEPD6669Dr9cD8GTX8+fPx9NPPx1wIbU77rjD55tvMpmQk5PTbzETDWWSyw1lY3W0wyAiIuoTvV4PlUoFjUaDjIwMAMCyZctQWFiI5cuXe9u9+OKLyMnJwYEDB2A2m+FyuXDxxRdjxIgRAID8/Hxv2/j4eNjtdu/5gikrK8PYsWNx+umnQxAE7zmP6zj/Kjc3F8uWLcP111/vkzg5nU4888wzGD16NABg/vz5eO2111BTUwOtVouJEyfi7LPPxtdff+2TOM2YMQO33347ACAvLw+bN2/Go48+GjBxevvttyGKIl544QXv+kkvvfQSDAYDNmzYgFmzZnX7Pqurq5Genu6zLT09HdXV0b+fiKnEKTMzE9nZ2d6kCQAmTJgASZJQUVGBsWP951HExcUhLi6uP8MkouPcLkiymBoRTERE/UyVAGSfFO0oem7Xrl34+uuvodVq/fYdPnwYs2bNwjnnnIP8/HzMnj0bs2bNwvz585GUlNSr61111VU499xzMW7cOMyZMwfnn3++TxLyxRdfYMWKFdi3bx9MJhNcLhdsNhusVis0Gg0AQKPReJMmwJOQ5Obm+ryH9PR01NbW+lx7+vTpfq8fe+yxgHHu2rULhw4dQmJios92m82Gw4cP9+q9DxQxdUczY8YMVFZW+ozXPHDgAGQyGYYNGxbFyIgoENEtQpLJox0GERFR2JnNZsybNw9FRUU+XwcPHsSvfvUryOVyrF+/Hv/5z38wceJEPPnkkxg3bhxKSkp6db0pU6agpKQEDzzwANra2rBgwQLMnz8fAFBaWorzzz8fJ554It577z1s377dOw/J4XB4z6FUKn3OKQhCwG2iKPYqRsDzfZk6darf9+XAgQO47LLLgh6fkZGBmpoan201NTUh98xFUlQTJ7PZ7P1mAkBJSQmKiopQVlYGwDPM7sorr/S2v+yyy5CcnIyrr74av/zyCzZu3Ig///nP+OMf/xhwmB4RRZnLBchjqmObiIgoIJVK5VM0YcqUKfj555+Rm5uLMWPG+HwlJHgmUAmCgBkzZuC+++7Dzp07oVKp8MEHHwQ8Xyh0Oh0uvfRSPP/883j77bfx3nvvobGxEdu3b4coili5ciVOPfVU5OXlobKyMmzv/fvvv/d7PWHChIBtp0yZgoMHDyItLc3v+9Jx1FhXpk+fji+//NJn2/r16/16vaIhqonTtm3bUFhYiMLCQgDA0qVLUVhYiLvvvhsAUFVV5U2iAECr1WL9+vVobm7GtGnTcPnll2PevHl44oknohI/EXVPEkVIQkx1bBMREQWUm5uLrVu3orS0FPX19bjpppvQ2NiIhQsX4scff8Thw4fx+eef4+qrr4bb7cbWrVuxfPlybNu2DWVlZXj//fdRV1fnTThyc3Oxe/du7N+/H/X19XA6nd1ef9WqVXjzzTexb98+HDhwAO+++y4yMjJgMBgwZswYOJ1OPPnkkyguLsZrr72GZ599NmzvffPmzXj44Ydx4MABrF69Gu+++y4WL14csO3ll1+OlJQUXHDBBdi0aRNKSkqwYcMG3HzzzaioqAh6rcWLF+Ozzz7DypUrsW/fPtx7773Ytm0bFi1a5G3T2NiIoqIi/PKLp6rI/v37UVRUFPF5UFG9oznrrLMgSZLf18svvwzAUzaxc2nG8ePHY/369bBarSgvL8fKlSvZ20Q0ULndkOTyPnX5ExERDQS33nor5HI5Jk6ciNTUVDgcDmzevBlutxuzZs1Cfn4+lixZAoPBAJlMBp1Oh40bN+I3v/kN8vLy8Le//Q0rV670rmF63XXXYdy4cZg2bRpSU1OxefPmbq+fmJiIhx9+GNOmTcNJJ52E0tJSrFu3DjKZDAUFBVi1ahUeeughTJo0Ca+//rpPqfK+uuWWW7wdHsuWLcOqVaswe/bsgG01Gg02btyI4cOH4+KLL8aECRNwzTXXwGazQafTBb3WaaedhjfeeAPPPfccCgoKsGbNGqxdu9a7hhPgWaKosLAQ5513HgDg97//PQoLC8OaLAYiSJIkRfQKA4zJZIJer0dLS0tI/3hE1Hu7V70Gafu3OOGl1VCoOGSPiGios9lsKCkpwciRI6FWq6MdDoUgNzcXS5Ys8anaF2u6+7nrSW7AMTREFDGS2w1JqYLocEU7FCIiIqI+YeJERJHjFiEpVHDZuh+3TURENNQtX74cWq024Nfx4X2DQVfvUavVYtOmTdEOr1scO0NEkeN2AwolXOxxIiIi6tb111+PBQsWBNwXzfn8paWlYT3f8WragWRnZ4f1WuHGxImIIkYSPUP1JCcTJyIiou4YjUYYjcZohxFxY8aMiXYIvcahekQUMZLoBpQquOwcqkdERESxjYkTEUWOKEJQqiCyx4mIiIhiHBMnIooct6fHiYkTERERxTomTkQUOaIIQRUH0emOdiREREREfcLEiYgix+2CoFLB7eAcJyIiIoptTJyIKHJECYJKBdHFoXpERESDlSAIWLt2bbTDiDgmTkQUMZLoYnEIIiIaFM466ywsWbIkbOe76qqrcOGFF4btfIPJu+++i/Hjx0OtViM/Px/r1q3z7nM6nbjtttuQn5+PhIQEZGVl4corr0RlZWXE42LiRESRI4qQxcVB4hwnIiIiCsF3332HhQsX4pprrsHOnTtx4YUX4sILL8SePXsAAFarFTt27MBdd92FHTt24P3338f+/fvx29/+NuKxMXEiosiRJAhKJUQn5zgREVHsuuqqq/DNN9/g8ccfhyAIEAQBpaWl2LNnD+bOnQutVov09HRcccUVqK+v9x63Zs0a5OfnIz4+HsnJyZg5cyYsFgvuvfdevPLKK/jwww+959uwYUO3MTgcDixatAiZmZlQq9UYMWIEVqxY4d2/atUqby9MTk4ObrzxRpjNZu/+l19+GQaDAZ988gnGjRsHjUaD+fPnw2q14pVXXkFubi6SkpJw8803w+1uf+CZm5uLBx54AAsXLkRCQgKys7OxevXqbmMtLy/HggULYDAYYDQaccEFF6C0tDSk7/Xjjz+OOXPm4M9//jMmTJiABx54AFOmTMFTTz0FANDr9Vi/fj0WLFiAcePG4dRTT8VTTz2F7du3o6ysLKRr9JYiomcnoiFPUMg5x4mIiLrkgAt1MAdvGEap0ELVg9vgxx9/HAcOHMCkSZNw//33AwCUSiVOPvlkXHvttXj00UfR1taG2267DQsWLMBXX32FqqoqLFy4EA8//DAuuugitLa2YtOmTZAkCbfeeiv27t0Lk8mEl156CQBgNBq7jeGJJ57ARx99hHfeeQfDhw9HeXk5ysvLvftlMhmeeOIJjBw5EsXFxbjxxhvxl7/8BU8//bS3jdVqxRNPPIG33noLra2tuPjii3HRRRfBYDBg3bp1KC4uxiWXXIIZM2bg0ksv9R73j3/8A3/9619x33334fPPP8fixYuRl5eHc8891y9Op9OJ2bNnY/r06di0aRMUCgWWLVuGOXPmYPfu3VCpVN2+zy1btmDp0qU+22bPnt3tHKqWlhYIggCDwdDtufuKiRMRRZQgl0NycageERHFLr1eD5VKBY1Gg4yMDADAsmXLUFhYiOXLl3vbvfjii8jJycGBAwdgNpvhcrlw8cUXY8SIEQCA/Px8b9v4+HjY7Xbv+YIpKyvD2LFjcfrpp0MQBO85j+s4/yo3NxfLli3D9ddf75M4OZ1OPPPMMxg9ejQAYP78+XjttddQU1MDrVaLiRMn4uyzz8bXX3/tkzjNmDEDt99+OwAgLy8PmzdvxqOPPhowcXr77bchiiJeeOEFCIIAAHjppZdgMBiwYcMGzJo1q9v3WV1djfT0dJ9t6enpqK6uDtjeZrPhtttuw8KFC6HT6bo9d18xcSKiiJKrVHDb7dEOg4iIBigVFMiGIdph9NiuXbvw9ddfQ6vV+u07fPgwZs2ahXPOOQf5+fmYPXs2Zs2ahfnz5yMpKalX17vqqqtw7rnnYty4cZgzZw7OP/98nyTkiy++wIoVK7Bv3z6YTCa4XC7YbDZYrVZoNBoAgEaj8SZNgCchyc3N9XkP6enpqK2t9bn29OnT/V4/9thjAePctWsXDh06hMTERJ/tNpsNhw8f7tV774rT6cSCBQsgSRKeeeaZsJ47ECZORBRRgkIGycIeJyIiGlzMZjPmzZuHhx56yG9fZmYm5HI51q9fj++++w7//e9/8eSTT+LOO+/E1q1bMXLkyB5fb8qUKSgpKcF//vMffPHFF1iwYAFmzpyJNWvWoLS0FOeffz5uuOEGPPjggzAajfj2229xzTXXwOFweBMnpVLpc05BEAJuE0Wxx/EdZzabMXXqVLz++ut++1JTU4Men5GRgZqaGp9tNTU1fj1zx5OmI0eO4Kuvvop4bxPAxImIIkyQKyC5OceJiIhim0ql8imaMGXKFLz33nvIzc2FQhH4lloQBMyYMQMzZszA3XffjREjRuCDDz7A0qVL/c4XCp1Oh0svvRSXXnop5s+fjzlz5qCxsRHbt2+HKIpYuXIlZDJP7bd33nmn92+2k++//97v9YQJEwK2nTJlCt5++22kpaX1KpmZPn06vvzyS5+hh+vXr/fp9TqeNB08eBBff/01kpOTe3yd3mBVPSKKKJlCwTlOREQU83Jzc7F161aUlpaivr4eN910ExobG7Fw4UL8+OOPOHz4MD7//HNcffXVcLvd2Lp1K5YvX45t27ahrKwM77//Purq6rwJR25uLnbv3o39+/ejvr4eziAVaFetWoU333wT+/btw4EDB/Duu+8iIyMDBoMBY8aMgdPpxJNPPoni4mK89tprePbZZ8P23jdv3oyHH34YBw4cwOrVq/Huu+9i8eLFAdtefvnlSElJwQUXXIBNmzahpKQEGzZswM0334yKioqg11q8eDE+++wzrFy5Evv27cO9996Lbdu2YdGiRQA8SdP8+fOxbds2vP7663C73aiurkZ1dTUcDkfY3nMgTJyIKKLkKgUkliMnIqIYd+utt0Iul2PixIlITU2Fw+HA5s2b4Xa7MWvWLOTn52PJkiUwGAyQyWTQ6XTYuHEjfvOb3yAvLw9/+9vfsHLlSsydOxcAcN1112HcuHGYNm0aUlNTsXnz5m6vn5iYiIcffhjTpk3DSSedhNLSUqxbtw4ymQwFBQVYtWoVHnroIUyaNAmvv/66T6nyvrrllluwbds2FBYWYtmyZVi1ahVmz54dsK1Go8HGjRsxfPhwXHzxxZgwYQKuueYa2Gy2kHqgTjvtNLzxxht47rnnUFBQgDVr1mDt2rWYNGkSAODo0aP46KOPUFFRgcmTJyMzM9P79d1334XtPQciSJIkRfQKA4zJZIJer0dLS0u/jIUkGsp2LLkfqRdfhOain5F/8++jHQ4REUWZzWZDSUkJRo4cCbVaHe1wKAS5ublYsmSJz9C5WNPdz11PcgP2OBFRRMmUnONEREREsY+JExFFlEyhAFy9r85DREQ0FCxfvhxarTbg1/HhfYNBV+9Rq9Vi06ZN0Q6vW6yqR0QRJYtTQHJzjhMREVF3rr/+eixYsCDgvvj4+H6Opl1paWlYz1dUVNTlvuzs7LBeK9yYOBFRRMkVCkg9LLdKREQ01BiNRhiNxmiHEXFjxoyJdgi9xqF6RBRRMpUCYOJEREREMY6JExFFlFylBFwcqkdERESxLaqJ08aNGzFv3jxkZWVBEASsXbs25GM3b94MhUKByZMnRyw+Iuo7uYpD9YiIiCj2RTVxslgsKCgowOrVq3t0XHNzM6688kqcc845EYqMiMKFQ/WIiIhoMIhqcYi5c+f2qrzi9ddfj8suuwxyubxHvVRE1P8UTJyIiIhoEIi5OU4vvfQSiouLcc8990Q7FCIKgUKtZDlyIiKiQaynU25iVUwlTgcPHsTtt9+Of//731AoQusss9vtMJlMPl9E1H9kCjkgSdEOg4iIqE/OOussLFmyJGznu+qqq3DhhReG7XyDybvvvovx48dDrVYjPz8f69at89l/7733Yvz48UhISEBSUhJmzpyJrVu3RjyumEmc3G43LrvsMtx3333Iy8sL+bgVK1ZAr9d7v3JyciIYJREFxMSJiIiIQvDdd99h4cKFuOaaa7Bz505ceOGFuPDCC7Fnzx5vm7y8PDz11FP46aef8O233yI3NxezZs1CXV1dRGOLmcSptbUV27Ztw6JFi6BQKKBQKHD//fdj165dUCgU+OqrrwIed8cdd6ClpcX7VV5e3s+RExEREVEsu+qqq/DNN9/g8ccfhyAIEAQBpaWl2LNnD+bOnQutVov09HRcccUVqK+v9x63Zs0a5OfnIz4+HsnJyZg5cyYsFgvuvfdevPLKK/jwww+959uwYUO3MTgcDixatAiZmZlQq9UYMWIEVqxY4d2/atUq5OfnIyEhATk5ObjxxhthNpu9+19++WUYDAZ88sknGDduHDQaDebPnw+r1YpXXnkFubm5SEpKws033wx3h7nJubm5eOCBB7Bw4UIkJCQgOzs7aGG38vJyLFiwAAaDAUajERdccAFKS0tD+l4//vjjmDNnDv785z9jwoQJeOCBBzBlyhQ89dRT3jaXXXYZZs6ciVGjRuGEE07AqlWrYDKZsHv37pCu0VsxkzjpdDr89NNPKCoq8n5df/31GDduHIqKinDKKacEPC4uLg46nc7ni4iIiIgoVI8//jimT5+O6667DlVVVaiqqkJiYiJ+/etfo7CwENu2bcNnn32GmpoaLFiwAABQVVWFhQsX4o9//CP27t2LDRs24OKLL4YkSbj11luxYMECzJkzx3u+0047rdsYnnjiCXz00Ud45513sH//frz++uvIzc317pfJZHjiiSfw888/45VXXsFXX32Fv/zlLz7nsFqteOKJJ/DWW2/hs88+w4YNG3DRRRdh3bp1WLduHV577TX885//xJo1a3yO+8c//oGCggLs3LkTt99+OxYvXoz169cHjNPpdGL27NlITEzEpk2bsHnzZmi1WsyZMwcOhyPo93rLli2YOXOmz7bZs2djy5YtAds7HA4899xz0Ov1KCgoCHr+vohqVT2z2YxDhw55X5eUlKCoqAhGoxHDhw/HHXfcgaNHj+LVV1+FTCbDpEmTfI5PS0uDWq32205EA4wgRDsCIiIaqCwW4Jdf+veaEycCCQkhN9fr9VCpVNBoNMjIyAAALFu2DIWFhVi+fLm33YsvvoicnBwcOHAAZrMZLpcLF198MUaMGAEAyM/P97aNj4+H3W73ni+YsrIyjB07FqeffjoEQfCe87iO869yc3OxbNkyXH/99Xj66ae9251OJ5555hmMHj0aADB//ny89tprqKmpgVarxcSJE3H22Wfj66+/xqWXXuo9bsaMGbj99tsBeIbJbd68GY8++ijOPfdcvzjffvttiKKIF154AcKxv/8vvfQSDAYDNmzYgFmzZnX7Pqurq5Genu6zLT09HdXV1T7bPvnkE/z+97+H1WpFZmYm1q9fj5SUlG7P3VdR7XHatm0bCgsLUVhYCABYunQpCgsLcffddwPwZOplZWXRDJGIwoFznIiIaJDZtWsXvv76a2i1Wu/X+PHjAQCHDx9GQUEBzjnnHOTn5+N3v/sdnn/+eTQ1NfX6eldddRWKioowbtw43Hzzzfjvf//rs/+LL77AOeecg+zsbCQmJuKKK65AQ0MDrFart41Go/EmTYAnIcnNzYVW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V+Lhhx+OeNLkdDqRnZ2N++67L6JxEBGFG3uciIiIiIiIgmCPExERERERURBMnIiIiIiIiIJg4kRERERERBQEEyciIiIiIqIgmDgREREREREFwcSJiIiIiIgoCCZOREREREREQTBxIiIiIiIiCoKJExERERERURBMnIiIiIiIiIJg4kRERERERBQEEyciIiIiIqIgmDgREREREREFwcSJiIiIiIgoCCZOREREREREQTBxIiIiIiIiCoKJExERERERURBMnIiIiIiIiIJg4kRERERERBQEEyciIiIiIqIgmDgREREREREFwcSJiIiIiIgoCCZOREREREREQTBxIiIiIiIiCoKJExERERERURBMnIiIiIiIiIJg4kRERERERBQEEyciIiIiIqIgmDgREREREREFwcSJiIiIiIgoCCZOREREREREQXTqxGn9+vUYP348unTpAkEQsGLFiia38d133+GCCy5AfHw8kpOTcf311yM/Pz/ssRIRERERUeR06sTJarUiJycHS5Ysadb5R44cwTXXXIM//OEPyMvLw3fffYfy8nJcd911YY6UiIiIiIgiSZBlWY50EO2BIAj47LPPMGHCBO82h8OBRx55BO+//z6qqqowaNAgPPPMMxg1ahQAYPny5ZgyZQocDgdEsS4H/fLLL3HNNdfA4XAgJiYmAu+EiIiIiIjCrVP3OAUzffp0bN68GR988AF27dqFG264AWPHjsWBAwcAAEOHDoUoinjrrbfg8XhQXV2Nd955B6NHj2bSRERERETUgbDH6ZSze5wKCgrQs2dPFBQUoEuXLt7jRo8ejeHDh2P+/PkAgHXr1mHSpEk4efIkPB4PRowYgZUrV8JoNEbgXRARERERUWtgj1MDfv31V3g8HvTt2xc6nc77tW7dOhw6dAgAUFxcjDvuuAO33norfvnlF6xbtw4qlQoTJ04E81EiIiIioo5DGekA2iuLxQKFQoFt27ZBoVD47NPpdACAJUuWwGAw4Nlnn/Xu+89//oPMzExs2bIFF1xwQZvGTERERERErYOJUwNyc3Ph8XhQWlqKiy++OOAxNpvNWxTitNNJliRJrR4jERERERG1jU49VM9isSAvLw95eXkA6sqL5+XloaCgAH379sVNN92EW265BZ9++imOHDmCn3/+GQsWLMDXX38NALjqqqvwyy+/4Mknn8SBAwewfft23HbbbejevTtyc3Mj+M6IiIiIiCicOnVxiLVr1+Kyyy7z237rrbdi6dKlcLlcmDdvHt5++20cP34cSUlJuOCCCzB37lxkZ2cDAD744AM8++yz2L9/P7RaLUaMGIFnnnkG/fv3b+u3Q0REREREraRTJ05ERERERESh6NRD9YiIiIiIiELBxImIiIiIiCiITldVT5IknDhxAvHx8RAEIdLhEBERERFRhMiyjJqaGnTp0sWvWvbZOl3idOLECWRmZkY6DCIiIiIiaieOHTuGrl27NnpMp0uc4uPjAdR9c/R6fYSjISIiIiKiSDGbzcjMzPTmCI3pdInT6eF5er2eiRMREREREYU0hYfFIYiIiIiIiIJg4kRERERERBQEEyciIiIiIqIgOt0cJyIiIiKKLI/HA5fLFekwqJOIiYmBQqFocTtMnIiIiIiozVgsFhQWFkKW5UiHQp2EIAjo2rUrdDpdi9ph4kREREREbcLj8aCwsBBarRbJyckhVTIjaglZllFWVobCwkL06dOnRT1PTJyIiIiIqE24XC7Isozk5GTExsZGOhzqJJKTk5Gfnw+Xy9WixInFIYiIiIioTbGnidpSuH7fmDgREREREREFwcSJiIiIiIgoiIgmTuvXr8f48ePRpUsXCIKAFStWBD3H4XDgkUceQffu3aFWq5GVlYU333yz9YMlIiIiIiI/od7HR7uIJk5WqxU5OTlYsmRJyOdMmjQJq1evxhtvvIF9+/bh/fffR79+/VoxSiIiIiLq7EaNGoWZM2eGrb2pU6diwoQJYWuvI/n444/Rv39/aDQaZGdnY+XKlT77P/30U1x55ZVITEyEIAjIy8trk7giWlVv3LhxGDduXMjHf/vtt1i3bh0OHz4Mk8kEAMjKymql6IiopUryDkNyeZB+Xp9Ih0JERERRYNOmTZgyZQoWLFiAq6++Gu+99x4mTJiA7du3Y9CgQQDqOl8uuugiTJo0CXfccUebxRZVc5y++OILDBs2DM8++ywyMjLQt29fPPDAA6itrW3wHIfDAbPZ7PNFRG2j8reDKF7530iHQURE1CJTp07FunXr8MILL0AQBAiCgPz8fOzevRvjxo2DTqdDamoqbr75ZpSXl3vPW758ObKzsxEbG4vExESMHj0aVqsVTzzxBJYtW4bPP//c297atWsbjcHpdGL69OlIT0+HRqNB9+7dsWDBAu/+RYsWITs7G3FxccjMzMTdd98Ni8Xi3b906VIYjUZ89dVX6NevH7RaLSZOnAibzYZly5YhKysLCQkJuPfee+HxeLznZWVl4amnnsKUKVMQFxeHjIyMoKPFjh07hkmTJsFoNMJkMuGaa65Bfn5+SN/rF154AWPHjsXf/vY3DBgwAE899RSGDBmCl156yXvMzTffjDlz5mD06NEhtRkuUbWO0+HDh/Hjjz9Co9Hgs88+Q3l5Oe6++26cPHkSb731VsBzFixYgLlz57ZxpEQEALIkQTi8J9JhEBFRO+a0AmW/t+01kwcCqrjQj3/hhRewf/9+DBo0CE8++SQAICYmBsOHD8ftt9+O5557DrW1tfj73/+OSZMm4YcffkBRURGmTJmCZ599Ftdeey1qamqwYcMGyLKMBx54AHv27IHZbPbew54eTdWQF198EV988QU++ugjdOvWDceOHcOxY8e8+0VRxIsvvogePXrg8OHDuPvuu/Hggw/i5Zdf9h5js9nw4osv4oMPPkBNTQ2uu+46XHvttTAajVi5ciUOHz6M66+/HiNHjsTkyZO95/3jH//Aww8/jLlz5+K7777DjBkz0LdvX1xxxRV+cbpcLowZMwYjRozAhg0boFQqMW/ePIwdOxa7du2CSqVq9H1u3rwZs2bN8tk2ZsyYdjGHKqoSJ0mSIAgC3n33XRgMBgB12fXEiRPx8ssvB1xIbfbs2T7ffLPZjMzMzDaLmagzk90exFQURzoMIiKiFjEYDFCpVNBqtUhLSwMAzJs3D7m5uZg/f773uDfffBOZmZnYv38/LBYL3G43rrvuOnTv3h0AkJ2d7T02NjYWDofD214wBQUF6NOnDy666CIIguBt87T686+ysrIwb9483HnnnT6Jk8vlwiuvvIJevXoBACZOnIh33nkHJSUl0Ol0GDhwIC677DKsWbPGJ3EaOXIkHnroIQBA3759sXHjRjz33HMBE6cPP/wQkiTh3//+t3f9pLfeegtGoxFr167FlVde2ej7LC4uRmpqqs+21NRUFBdH/n4iqhKn9PR0ZGRkeJMmABgwYABkWUZhYSH69PGfR6FWq6FWq9syTCI6zeOGLEbViGAiImpjqjgg47xIR9F0O3fuxJo1a6DT6fz2HTp0CFdeeSUuv/xyZGdnY8yYMbjyyisxceJEJCQkNOt6U6dOxRVXXIF+/fph7NixuPrqq32SkO+//x4LFizA3r17YTab4Xa7YbfbYbPZoNVqAQBardabNAF1CUlWVpbPe0hNTUVpaanPtUeMGOH3+vnnnw8Y586dO3Hw4EHEx8f7bLfb7Th06FCz3nt7EVV3NCNHjsSJEyd8xmvu378foiiia9euEYyMiAKRPBJkURHpMIiIiMLOYrFg/PjxyMvL8/k6cOAALrnkEigUCqxatQrffPMNBg4ciMWLF6Nfv344cuRIs643ZMgQHDlyBE899RRqa2sxadIkTJw4EQCQn5+Pq6++Gueeey4++eQTbNu2zTsPyel0etuIiYnxaVMQhIDbJElqVoxA3fdl6NChft+X/fv348Ybbwx6flpaGkpKSny2lZSUhNwz15oimjhZLBbvNxMAjhw5gry8PBQUFACoG2Z3yy23eI+/8cYbkZiYiNtuuw2///471q9fj7/97W/485//HHCYHhFFmNsNKKKqY5uIiCgglUrlUzRhyJAh+O2335CVlYXevXv7fMXF1U2gEgQBI0eOxNy5c7Fjxw6oVCp89tlnAdsLhV6vx+TJk/H666/jww8/xCeffIKKigps27YNkiRh4cKFuOCCC9C3b1+cOHEibO/9p59+8ns9YMCAgMcOGTIEBw4cQEpKit/3pf6osYaMGDECq1ev9tm2atUqv16vSIho4rR161bk5uYiNzcXADBr1izk5uZizpw5AICioiJvEgUAOp0Oq1atQlVVFYYNG4abbroJ48ePx4svvhiR+ImocbIkQRaiqmObiIgooKysLGzZsgX5+fkoLy/HPffcg4qKCkyZMgW//PILDh06hO+++w633XYbPB4PtmzZgvnz52Pr1q0oKCjAp59+irKyMm/CkZWVhV27dmHfvn0oLy+Hy+Vq9PqLFi3C+++/j71792L//v34+OOPkZaWBqPRiN69e8PlcmHx4sU4fPgw3nnnHbz66qthe+8bN27Es88+i/3792PJkiX4+OOPMWPGjIDH3nTTTUhKSsI111yDDRs24MiRI1i7di3uvfdeFBYWBr3WjBkz8O2332LhwoXYu3cvnnjiCWzduhXTp0/3HlNRUYG8vDz8/ntdVZF9+/YhLy+v1edBRfSOZtSoUZBl2e9r6dKlAOrKJp5dmrF///5YtWoVbDYbjh07hoULF7K3iai98nggKxQt6vInIiJqDx544AEoFAoM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7/2+mEwm2O12HDp0qNnv/2yXXXYZ8vLysGnTJowdOxaTJk3yizvcItrjNGrUKMiN3FAtXbq00fOfeOIJPPHEE+ENioiIiIjaTlwccN55kY6iySwWC8aPH49nnnnGb196ejoUCgVWrVqFTZs24b///S8WL16MRx55BFu2bEGPHj2afL0hQ4bgyJEj+Oabb/D9999j0qRJGD16NJYvX478/HxcffXVuOuuu/D000/DZDLhxx9/xLRp0+B0OqHVagEAMTExPm0KghBwmyRJTY7vNIvFgqFDh+Ldd9/125ecnBz0/LS0NJSUlPhsKykp8euZi4uLQ+/evdG7d29ccMEF6NOnD9544w3Mnj272bEHE9HEiYiIiIgoGqhUKp+iCUOGDMEnn3yCrKysBpfJEQQBI0eOxMiRIzFnzhx0794dn332GWbNmuXXXij0ej0mT56MyZMnY+LEiRg7diwqKiqwbds2SJKEhQsXQhTrBpR99NFHzX+zZ/npp5/8Xg8YMCDgsUOGDMGHH36IlJSUZtUWGDFiBFavXu0z9HDVqlV+vV5nkyQJDoejyddriqgpDkFEUYxznIiIKMplZWVhy5YtyM/PR3l5Oe655x5UVFRgypQp+OWXX3Do0CF89913uO222+DxeLBlyxbMnz8fW7duRUFBAT799FOUlZV5E46srCzs2rUL+/btQ3l5OVyuxheLX7RoEd5//33s3bsX+/fvx8cff4y0tDQYjUb07t0bLpcLixcvxuHDh/HOO+/g1VdfDdt737hxI5599lns378fS5Yswccff4wZM2YEPPamm25CUlISrrnmGmzYsAFHjhzB2rVrce+996KwsDDotWbMmIFvv/0WCxcuxN69e/HEE094K2sDgNVqxcMPP4yffvoJR48exbZt2/DnP/8Zx48fxw033BC29xwIEycian2c40RERFHugQcegEKhwMCBA5GcnAyn04mNGzfC4/HgyiuvRHZ2NmbOnAmj0QhRFKHX67F+/Xr88Y9/RN++ffHoo49i4cKFGDduHADgjjvuQL9+/TBs2DAkJydj48aNjV4/Pj4ezz77LIYNG4bzzjsP+fn5WLlyJURRRE5ODhYtWoRnnnkGgwYNwrvvvutTqryl7r//fm9tgnnz5mHRokUYM2ZMwGO1Wi3Wr1+Pbt264brrrsOAAQMwbdo02O32kHqgLrzwQrz3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FSdOnMCGDRuwc+dOANZ/x+xpl8ceewyrVq3C6tWrcezYMcybNw8HDx5sFN/PP/+MwYMHO2zqeiIiJk5ERG1EoVBg6tSpWLx4MaqqqrBgwQLMmTMHCxcuRHx8PIYPH46vvvoKUVFRAICQkBDMnz8fzzzzDAIDAzF16lQAsHmePe6++24MHz4ct956K/z9/fHxxx83KuPi4oJvv/0W58+fx8CBA3HPPfdg2LBheOutt1rnB9KGJEnC119/jZtvvhkTJkxAjx49cN999+HUqVPNJhETJ07E119/jfLy8naMtl7DtODWhrE1mDNnDmbMmIG5c+ciPj4eo0ePNj9X1drtNWnSJKxcuRKrV69GQkIChgwZgjVr1ph/z5RKJb777jsEBATgT3/6ExISErBo0SLzUD5rv2P2tMvo0aMxZ84czJw5E/3798epU6fw6KOPNopv7dq1Fs9fERG1N0kIIRwdBBERkaPce++96NevH2bNmtWu1926dStGjRqFEydOwNvbu12vfb355ptvMGPGDBw4cAAKBZ8yICLHYI8TERF1aS+//DLc3Nza/bpff/01Zs+ezaTJDlVVVVi9ejWTJiJyKPY4ERERERER2cAeJyIiIiIiIhuYOBEREREREdnAxImIiIiIiMgGJk5EREREREQ2MHEiIiIiIiKygYkTERERERGRDUyciIiIiIiIbGDiREREREREZAMTJyIiIiIiIhuYOBEREREREdnw/wH8TnH28ZK/oAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Plot TICs (Total Ion Chromatograms) before and after alignment\n", + "# Enable inline plotting for Jupyter notebooks\n", + "%matplotlib inline\n", + "\n", + "lcms_collection.plot_tics(ms_level=1, type=\"both\", plot_legend=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "39a6f24b", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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vvtOjRo1spnfrl07OTk52Zw/+/fv17Fjxzh/THC941OcnTt3ShLnTwUoLCzU5cuXOW9uUlePT3HK+rzh1sRKYtSoUerfv7/at2+vDh066I033tDFixc1cODAii6tyouLi9MjjzyigIAAnTx5UhMnTpSjo6NiYmIqurQqJzs72+b/YqWmpmrnzp3y8fFRw4YNNXLkSE2ePFlNmzZVo0aN9NJLL6l+/fqKjIysuKKrkJKOj4+Pj1555RX16tVLfn5+Onz4sF544QU1adJE4eHhFVh11TBkyBAtXrxYX375pTw8PKzPr3h5ecnV1VVeXl6KjY3VqFGj5OPjI09PTw0bNkyhoaG6++67K7j6yu96x+fw4cNavHixevTooVq1amn37t167rnn1KVLF7Vq1aqCq6/cxo0bp+7du6thw4a6cOGCFi9erA0bNmjt2rWcNzeBko6PKedNuY3HCNO9+eabRsOGDQ1nZ2ejQ4cOxtatWyu6JBiGER0dbdSrV89wdnY2GjRoYERHRxuHDh2q6LKqpPj4eENSkal///6GYfw2hP1LL71k+Pr6GtWrVze6du1q7N+/v2KLrkJKOj45OTlGt27djDp16hhOTk5GQECAMWjQICM9Pb2iy64Sijsukoz58+db+1y6dMl49tlnjZo1axpubm7GY489Zpw6dariiq5Crnd8jh07ZnTp0sXw8fExqlevbjRp0sR4/vnnjczMzIotvAr429/+ZgQEBBjOzs5GnTp1jK5duxrffPONdT7nTcUq6fiYcd5YDMMwyibSAQAAAADswTNiAAAAAGAyghgAAAAAmIwgBgAAAAAmI4gBAAAAgMkIYgAAAABgMoIYAAAAAJiMIAYAAAAAJiOIAUAltmHDBlksFp0/f16StGDBAnl7e1doTVfdTLWUpZtlv+677z6NHDmywrbfpUsXLV68+E+t4+WXX9add95ZqmXuvvtuff75539quwBgBoIYANzikpKS5OjoqIceeui6faOjo3XgwAETqiobFovFOnl6euquu+7Sl19+Wap1DBgwQJGRkeVSX2BgoN544w2btvL+jtPS0my+l+KmBQsWaMWKFZo0aVK51VGSr776ShkZGXriiSf+1Hri4uK0fv36Ui0zfvx4jR07VoWFhX9q2wBQ3ghiAHCL+/DDDzVs2DBt2rRJJ0+eLLGvq6ur6tata1JlZWP+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Yvu0aNFCycnJNm1bt24t1Xak367WtWvXTq+99ppmz56ttm3baunSpapbt648PT2LXcbZ2VkFBQU2bfYsZ4/i1v1HLVq0UGJiovr3729tS0xMVHBw8A1v1yxt27bVTz/9pCZNmti9TJs2bZSenq5ff/1VNWvWLMfqrm3Pnj1q06ZNhWwbAOzFrYkAcAtatWqVfv31V8XGxqply5Y2U69evey+PXHYsGH68MMP9fHHH+vgwYOaPHmydu/ebb0yYY9mzZqpb9++6tevn1asWKHU1FT997//1dSpU/W///u/kqThw4drzZo1+te//qWDBw/qrbfe0po1a25o30eOHKl3331XJ06cUN++fVW7dm317NlTCQkJSk1N1YYNGzR8+HD9/PPPkn4bLXD37t3av3+/zp49q/z8fLuWs0dgYKA2bdqkEydO6OzZs8X2ef7557VgwQK98847OnjwoGbOnKkVK1YoLi7uhvbfTGPGjNGWLVusg6wcPHhQX375ZYkDarRp00a1a9dWYmKiiZXaSkhIKPI/KADgZkMQA4Bb0IcffqiwsLBibz/s1auXtm/frt27d193PX379tW4ceMUFxentm3bKjU1VQMGDLjmMz7XMn/+fPXr10+jR49WUFCQIiMjtW3bNuvta3fffbfef/99zZ49W61bt9Y333yj8ePHl2obV0VERKhRo0Z67bXX5Obmpk2bNqlhw4aKiopSixYtFBsbq9zcXOuVrkGDBikoKEjt27dXnTp1lJiYaNdy9nj11VeVlpamxo0bX/NWyMjISM2ePVv/+te/dMcdd+jdd9/V/Pnzdd99993Q/pupVatW2rhxow4cOKC//OUvatOmjSZMmGBzO+wfOTo6auDAgcXe9mmGEydOaMuWLRo4cGCFbB8A7GUxjFKMcwwAqPQefPBB+fn5FXnXFWCv9PR03XHHHdqxY4cCAgJM3faYMWP066+/6r333jN1uwBQWjwjBgBVWE5OjubNm6fw8HA5Ojrq008/1bfffqt169ZVdGm4hfn5+enDDz/UsWPHTA9idevW1ahRo0zdJgDcCK6IAUAVdunSJT3yyCP6/vvvlZubq6CgII0fP15RUVEVXRoAAJUaQQwAAAAATMZgHQAAAABgMoIYAAAAAJiMIAYAAAAAJiOIAQAAAIDJCGIAAAAAYDKCGAAAAACYjCAGAAAAACYjiAEAAACAyQhiAAAAAGCy/wdqzR9y56PLYgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generated alignment plot showing RT differences (no differences for our exampl, which is expected)\n" + ] + } + ], + "source": [ + "# Plot alignment differences (no differences for this example, no alignment was used)\n", + "lcms_collection.plot_alignments(plot_legend=True)\n", + "print(\"Generated alignment plot showing RT differences (no differences for our exampl, which is expected)\")" + ] + }, + { + "cell_type": "markdown", + "id": "dcafc268", + "metadata": {}, + "source": [ + "## Step 4: Generate Consensus Mass Features (Clustering)\n", + "\n", + "Cluster mass features across samples to identify consensus features - features representing the same chemical entity across multiple samples." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "670d8d60", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generated 50 consensus clusters\n", + "\n", + "Cluster Summary (first 10 clusters):\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "cluster", + "rawType": "int64", + "type": "integer" + }, + { + "name": "mz_median", + "rawType": "float64", + "type": "float" + }, + { + "name": "mz_mean", + "rawType": "float64", + "type": "float" + }, + { + "name": "mz_std", + "rawType": "float64", + "type": "float" + }, + { + "name": "mz_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "mz_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "scan_time_aligned_median", + "rawType": "float64", + "type": "float" + }, + { + "name": "scan_time_aligned_mean", + "rawType": "float64", + "type": "float" + }, + { + "name": "scan_time_aligned_std", + "rawType": "float64", + "type": "float" + }, + { + "name": "scan_time_aligned_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "scan_time_aligned_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "sample_id_nunique", + "rawType": "int64", + "type": "integer" + }, + { + "name": "intensity_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "intensity_median", + "rawType": "float64", + "type": "float" + }, + { + "name": "intensity_mean", + "rawType": "float64", + "type": "float" + }, + { + "name": "intensity_std", + "rawType": "float64", + "type": "float" + }, + { + "name": "intensity_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "tailing_factor_median", + "rawType": "float64", + "type": "float" + }, + { + "name": "tailing_factor_mean", + "rawType": "float64", + "type": "float" + }, + { + "name": "tailing_factor_std", + "rawType": "float64", + "type": "float" + }, + { + "name": "tailing_factor_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "tailing_factor_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index_median", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index_mean", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index_std", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "persistence_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "persistence_median", + "rawType": "float64", + "type": "float" + }, + { + "name": "persistence_mean", + "rawType": "float64", + "type": "float" + }, + { + "name": "persistence_std", + "rawType": "float64", + "type": "float" + }, + { + "name": "persistence_min", + "rawType": "float64", + "type": "float" + } + ], + "ref": "0eb8abc7-0d90-45fc-89f4-82162cebb1f5", + "rows": [ + [ + "0", + "301.21661376953125", + "301.21661376953125", + "0.0", + "301.21661376953125", + "301.21661376953125", + "8.895636666666666", + "8.895636666666666", + "0.0", + "8.895636666666666", + "8.895636666666666", + "2", + "66775328.0", + "66775328.0", + "66775328.0", + "0.0", + "66775328.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "66708546.0", + "66708546.0", + "66708546.0", + "0.0", + "66708546.0" + ], + [ + "1", + "302.2206115722656", + "302.2206115722656", + "0.0", + "302.2206115722656", + "302.2206115722656", + "8.895636666666666", + "8.895636666666666", + "0.0", + "8.895636666666666", + "8.895636666666666", + "2", + "14249711.0", + "14249711.0", + "14249711.0", + "0.0", + "14249711.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "14142901.0", + "14142901.0", + "14142901.0", + "0.0", + "14142901.0" + ], + [ + "2", + "367.35748291015625", + "367.35748291015625", + "0.0", + "367.35748291015625", + "367.35748291015625", + "19.152648333333335", + "19.152648333333335", + "0.0", + "19.152648333333335", + "19.152648333333335", + "2", + "48137056.0", + "48137056.0", + "48137056.0", + "0.0", + "48137056.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "48070260.0", + "48070260.0", + "48070260.0", + "0.0", + "48070260.0" + ], + [ + "3", + "368.3611755371094", + "368.3611755371094", + "0.0", + "368.3611755371094", + "368.3611755371094", + "19.152648333333335", + "19.152648333333335", + "0.0", + "19.152648333333335", + "19.152648333333335", + "2", + "12717404.0", + "12717404.0", + "12717404.0", + "0.0", + "12717404.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "12650608.0", + "12650608.0", + "12650608.0", + "0.0", + "12650608.0" + ], + [ + "4", + "698.62890625", + "698.62890625", + "0.0", + "698.62890625", + "698.62890625", + "23.816803333333333", + "23.816803333333333", + "0.0", + "23.816803333333333", + "23.816803333333333", + "2", + "17265106.0", + "17265106.0", + "17265106.0", + "0.0", + "17265106.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "17198326.0", + "17198326.0", + "17198326.0", + "0.0", + "17198326.0" + ], + [ + "6", + "699.6312255859375", + "699.6312255859375", + "0.0", + "699.6312255859375", + "699.6312255859375", + "23.816803333333333", + "23.816803333333333", + "0.0", + "23.816803333333333", + "23.816803333333333", + "2", + "7861987.5", + "7861987.5", + "7861987.5", + "0.0", + "7861987.5", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "7795191.0", + "7795191.0", + "7795191.0", + "0.0", + "7795191.0" + ], + [ + "9", + "227.20188903808594", + "227.20188903808594", + "0.0", + "227.20188903808594", + "227.20188903808594", + "7.376469999999999", + "7.376469999999999", + "0.0", + "7.376469999999999", + "7.376469999999999", + "2", + "9600092.0", + "9600092.0", + "9600092.0", + "0.0", + "9600092.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "9533297.0", + "9533297.0", + "9533297.0", + "0.0", + "9533297.0" + ], + [ + "10", + "299.20111083984375", + "299.20111083984375", + "0.0", + "299.20111083984375", + "299.20111083984375", + "7.376469999999999", + "7.376469999999999", + "0.0", + "7.376469999999999", + "7.376469999999999", + "2", + "18258992.0", + "18258992.0", + "18258992.0", + "0.0", + "18258992.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "18192197.0", + "18192197.0", + "18192197.0", + "0.0", + "18192197.0" + ], + [ + "12", + "455.3526611328125", + "455.3526611328125", + "0.0", + "455.3526611328125", + "455.3526611328125", + "8.96547", + "8.96547", + "0.0", + "8.96547", + "8.96547", + "2", + "12939420.0", + "12939420.0", + "12939420.0", + "0.0", + "12939420.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "12872638.0", + "12872638.0", + "12872638.0", + "0.0", + "12872638.0" + ], + [ + "15", + "735.5070190429688", + "735.5070190429688", + "0.0", + "735.5070190429688", + "735.5070190429688", + "20.793636666666668", + "20.793636666666668", + "0.0", + "20.793636666666668", + "20.793636666666668", + "2", + "8329064.0", + "8329064.0", + "8329064.0", + "0.0", + "8329064.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "8262284.0", + "8262284.0", + "8262284.0", + "0.0", + "8262284.0" + ] + ], + "shape": { + "columns": 31, + "rows": 10 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
mz_medianmz_meanmz_stdmz_maxmz_minscan_time_aligned_medianscan_time_aligned_meanscan_time_aligned_stdscan_time_aligned_maxscan_time_aligned_min...dispersity_index_mediandispersity_index_meandispersity_index_stddispersity_index_maxdispersity_index_minpersistence_maxpersistence_medianpersistence_meanpersistence_stdpersistence_min
cluster
0301.216614301.2166140.0301.216614301.2166148.8956378.8956370.08.8956378.895637...NaNNaNNaNNaNNaN66708546.066708546.066708546.00.066708546.0
1302.220612302.2206120.0302.220612302.2206128.8956378.8956370.08.8956378.895637...NaNNaNNaNNaNNaN14142901.014142901.014142901.00.014142901.0
2367.357483367.3574830.0367.357483367.35748319.15264819.1526480.019.15264819.152648...NaNNaNNaNNaNNaN48070260.048070260.048070260.00.048070260.0
3368.361176368.3611760.0368.361176368.36117619.15264819.1526480.019.15264819.152648...NaNNaNNaNNaNNaN12650608.012650608.012650608.00.012650608.0
4698.628906698.6289060.0698.628906698.62890623.81680323.8168030.023.81680323.816803...NaNNaNNaNNaNNaN17198326.017198326.017198326.00.017198326.0
6699.631226699.6312260.0699.631226699.63122623.81680323.8168030.023.81680323.816803...NaNNaNNaNNaNNaN7795191.07795191.07795191.00.07795191.0
9227.201889227.2018890.0227.201889227.2018897.3764707.3764700.07.3764707.376470...NaNNaNNaNNaNNaN9533297.09533297.09533297.00.09533297.0
10299.201111299.2011110.0299.201111299.2011117.3764707.3764700.07.3764707.376470...NaNNaNNaNNaNNaN18192197.018192197.018192197.00.018192197.0
12455.352661455.3526610.0455.352661455.3526618.9654708.9654700.08.9654708.965470...NaNNaNNaNNaNNaN12872638.012872638.012872638.00.012872638.0
15735.507019735.5070190.0735.507019735.50701920.79363720.7936370.020.79363720.793637...NaNNaNNaNNaNNaN8262284.08262284.08262284.00.08262284.0
\n", + "

10 rows × 31 columns

\n", + "
" + ], + "text/plain": [ + " mz_median mz_mean mz_std mz_max mz_min \\\n", + "cluster \n", + "0 301.216614 301.216614 0.0 301.216614 301.216614 \n", + "1 302.220612 302.220612 0.0 302.220612 302.220612 \n", + "2 367.357483 367.357483 0.0 367.357483 367.357483 \n", + "3 368.361176 368.361176 0.0 368.361176 368.361176 \n", + "4 698.628906 698.628906 0.0 698.628906 698.628906 \n", + "6 699.631226 699.631226 0.0 699.631226 699.631226 \n", + "9 227.201889 227.201889 0.0 227.201889 227.201889 \n", + "10 299.201111 299.201111 0.0 299.201111 299.201111 \n", + "12 455.352661 455.352661 0.0 455.352661 455.352661 \n", + "15 735.507019 735.507019 0.0 735.507019 735.507019 \n", + "\n", + " scan_time_aligned_median scan_time_aligned_mean \\\n", + "cluster \n", + "0 8.895637 8.895637 \n", + "1 8.895637 8.895637 \n", + "2 19.152648 19.152648 \n", + "3 19.152648 19.152648 \n", + "4 23.816803 23.816803 \n", + "6 23.816803 23.816803 \n", + "9 7.376470 7.376470 \n", + "10 7.376470 7.376470 \n", + "12 8.965470 8.965470 \n", + "15 20.793637 20.793637 \n", + "\n", + " scan_time_aligned_std scan_time_aligned_max scan_time_aligned_min \\\n", + "cluster \n", + "0 0.0 8.895637 8.895637 \n", + "1 0.0 8.895637 8.895637 \n", + "2 0.0 19.152648 19.152648 \n", + "3 0.0 19.152648 19.152648 \n", + "4 0.0 23.816803 23.816803 \n", + "6 0.0 23.816803 23.816803 \n", + "9 0.0 7.376470 7.376470 \n", + "10 0.0 7.376470 7.376470 \n", + "12 0.0 8.965470 8.965470 \n", + "15 0.0 20.793637 20.793637 \n", + "\n", + " ... dispersity_index_median dispersity_index_mean \\\n", + "cluster ... \n", + "0 ... NaN NaN \n", + "1 ... NaN NaN \n", + "2 ... NaN NaN \n", + "3 ... NaN NaN \n", + "4 ... NaN NaN \n", + "6 ... NaN NaN \n", + "9 ... NaN NaN \n", + "10 ... NaN NaN \n", + "12 ... NaN NaN \n", + "15 ... NaN NaN \n", + "\n", + " dispersity_index_std dispersity_index_max dispersity_index_min \\\n", + "cluster \n", + "0 NaN NaN NaN \n", + "1 NaN NaN NaN \n", + "2 NaN NaN NaN \n", + "3 NaN NaN NaN \n", + "4 NaN NaN NaN \n", + "6 NaN NaN NaN \n", + "9 NaN NaN NaN \n", + "10 NaN NaN NaN \n", + "12 NaN NaN NaN \n", + "15 NaN NaN NaN \n", + "\n", + " persistence_max persistence_median persistence_mean \\\n", + "cluster \n", + "0 66708546.0 66708546.0 66708546.0 \n", + "1 14142901.0 14142901.0 14142901.0 \n", + "2 48070260.0 48070260.0 48070260.0 \n", + "3 12650608.0 12650608.0 12650608.0 \n", + "4 17198326.0 17198326.0 17198326.0 \n", + "6 7795191.0 7795191.0 7795191.0 \n", + "9 9533297.0 9533297.0 9533297.0 \n", + "10 18192197.0 18192197.0 18192197.0 \n", + "12 12872638.0 12872638.0 12872638.0 \n", + "15 8262284.0 8262284.0 8262284.0 \n", + "\n", + " persistence_std persistence_min \n", + "cluster \n", + "0 0.0 66708546.0 \n", + "1 0.0 14142901.0 \n", + "2 0.0 48070260.0 \n", + "3 0.0 12650608.0 \n", + "4 0.0 17198326.0 \n", + "6 0.0 7795191.0 \n", + "9 0.0 9533297.0 \n", + "10 0.0 18192197.0 \n", + "12 0.0 12872638.0 \n", + "15 0.0 8262284.0 \n", + "\n", + "[10 rows x 31 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Generate consensus features\n", + "lcms_collection.add_consensus_mass_features()\n", + "\n", + "cluster_summary = lcms_collection.cluster_summary_dataframe\n", + "print(f\"Generated {len(cluster_summary)} consensus clusters\")\n", + "\n", + "# View cluster summary\n", + "print(\"\\nCluster Summary (first 10 clusters):\")\n", + "display(cluster_summary.head(10))" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "a8a9d7d8", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAmkAAAHsCAYAAACJ5DokAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/OQEPoAAAACXBIWXMAAA9hAAAPYQGoP6dpAABSZklEQVR4nO3deVxWdf7//+clsq9iLKKguJQiakWp5NaCYmll4polmktjWONWZr/c2mhsUrPJbJlRZ9IWLa0sLbO0RbI0LcVERBQTATdAJfbz+8OP17crlxSBc8DH/Xa7bsN1zvuc9+ucuWbO0/fZbIZhGAIAAICl1DG7AAAAAJyNkAYAAGBBhDQAAAALIqQBAABYECENAADAgghpAAAAFkRIAwAAsCBCGgAAgAUR0gAAACyIkAYAAGBBhDTgMqSlpenBBx9U06ZN5ebmJh8fH3Xq1EkvvfSSfv/9d7PLq5VmzJghm812zs+CBQuqpM9PP/1UM2bMqJJ1A8D51DW7AKCm+uSTT9S/f3+5urpq6NChioyMVHFxsb799ls9+uijSk5O1uuvv252mbXWq6++Ki8vL4dpHTp0qJK+Pv30U73yyisENQDVipAGVEB6eroGDRqkxo0b68svv1SDBg3s8xISErRnzx598sknJlZY+/Xr109XXXWV2WVcllOnTsnT09PsMgBYFKc7gQqYNWuWTp48qX//+98OAe2M5s2b6+9//7v9e2lpqZ5++mk1a9ZMrq6uatKkiZ544gkVFRU5LNekSRP17t1b3377rdq3by83Nzc1bdpU//3vfx3alZSUaObMmWrRooXc3NxUv359de7cWWvXrnVot2vXLvXr10/+/v5yc3PTDTfcoI8++sihzaJFi2Sz2fTdd99pwoQJCggIkKenp+655x4dPnzYoe3mzZsVGxurq666Su7u7goPD9cDDzxgn79+/XrZbDatX7/eYbl9+/bJZrNp0aJF9mlZWVkaPny4GjVqJFdXVzVo0EB333239u3bd979fineeustRUVFyd3dXf7+/ho0aJAOHDjg0Oabb75R//79FRYWJldXV4WGhmr8+PEOp6qHDRumV155RZIcTq1e6vYOGzZMXl5eSktL0x133CFvb28NGTJEklReXq65c+eqdevWcnNzU1BQkB588EEdP37cYb1/tf8B1C6MpAEV8PHHH6tp06a66aab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tYi8rsq76zPwfvWp8l7OYc86Hf3gUWX87qa01hPUr39/M6oAAPwF00fS/qy8vFxFRUWKioqSs7Oz1q1bZ5+XkpKijIwMRUdHS5Kio6O1fft25eTk2NusXbtWPj4+ioiIOG8frq6u9sd+nPkAktTr3tGaPOP52n0Rf2GuSn7Pl7uKuBYOACzM1JG0KVOm6Pbbb1dYWJhOnDihpUuXav369frss8/k6+urESNGaMKECfL395ePj48efvhhRUdHq2PHjpKkHj16KCIiQvfff79mzZqlrKwsPfnkk0pISJCrq6uZmwZYV0QfpWzZpS37ctU6MtLsagAA52FqSMvJydHQoUN16NAh+fr6qm3btvrss8/UvXt3SdKcOXNUp04dxcXFqaioSLGxsZo/f759eScnJ61atUpjxoxRdHS0PD09FR8fr6eeesqsTQKsz6muug17Ut3MrgMAcEGWe06aGXhuDADgSsDxrmax3DVpAAAAIKQBOBfDkH79+PRrq0oKza4GAK5Ipj+CA8B5lBZLKZ9K3g2ksA7V23f+QR39aKrKfs/Xru371XUY78MFgOrGSBpwuQ5ukX7bXPnrTftSeR89of3/GaYP3/1v5a//QjwDlfq7n7JVXz/uO1G9fQMAJBHSgMtzJFVH3xqp394cos/emle56/YP15Giujqqevrl1z2Vu+6/UtdFv0WO1Qe23mocWc2jeAAASZzuBC6Pq7eO/14mm1yUvCdDsZW57oBr9HPEFG3fmWLK88z69etXux/qCwAWR0gDLod3sHa0mqRdv+5S88jrK331fQcMVt9KXysAoCYgpAGXqc/AoWaXAACohbgmDQAAwIIIaQAAABZESAMAALAgQhoAAIAFEdJqon3fSZsXSoV5jtNLi6StS6SU1adf6wMAAGos7u6saUqLlbNsvJxOZStt63a1HzX7/83b963y1jyj/KJy/XzNJPUePMq8OgEAwGVhJK2mcXLWzlP1dET1tOVgseO8gGt0sMhDhxSkrSkHzKkPAABUCkbSahqbTUciR2lDcrJat27tOM+3kXZGTlHyueYBAIAaxWYYXLyUn58vX19f5eXlycfHx+xycCXKPSCVl0r+4WZXAqAW43hXs3C6E/grRSelkzlVt/78TB1+va8y5t2hNUv+VXX9AABqFEIacCElhfrtlTuV8c9u+uJ//6yaPgxDBQWnJEl7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AFgQIQ0AAMCCCGkAAAAWREgDYGn79u2TzWazv7IGAK4UhDTgCnT48GGNGTNGYWFhcnV1VXBwsGJjY/Xdd9+ZWtewYcPOeoVVaGioDh06pMjISHOKAgCTXNHv7gSuVHFxcSouLtbixYvVtGlTZWdna926dTp69KjZpZ3FyclJwcHBZpcBANWOkTTgCpObm6tvvvlG//jHP3TLLbeocePGat++vaZMmaK77rrLod2DDz6ooKAgubm5KTIyUqtWrZIkHT16VIMHD1bDhg3l4eGhNm3a6O2333bo5+abb9Yjjzyixx57TP7+/goODtaMGTPOW9eMGTO0ePFiffjhh7LZbLLZbFq/fv1ZpzvXr18vm82mzz77TNddd53c3d116623KicnR6tXr1arVq3k4+Oje++9VwUFBfb1l5eXKzExUeHh4XJ3d1e7du20fPnyytuxAFDJGEkDrjBeXl7y8vLSypUr1bFjR7m6up7Vpry8XLfffrtOnDiht956S82aNdPOnTvl5OQkSSosLFRUVJQmT54sHx8fffLJJ7r//vvVrFkztW/f3r6exYsXa8KECdq0aZOSkpI0bNgwderUSd27dz+rz0mTJunXX39Vfn6+Fi5cKEny9/dXZmbmObdjxowZ+te//iUPDw8NGDBAAwYMkKurq5YuXaqTJ0/qnnvu0csvv6zJkydLkhITE/XWW29pwYIFatGihb7++mvdd999CggIULdu3S57vwJApTP7De8Aqt/y5cuNevXqGW5ubsZNN91kTJkyxfj555/t8z/77DOjTp06RkpKykWvs1evXsbEiRPt37t162Z07tzZoc2NN95oTJ48+bzriI+PN+6++26Haenp6YYkY+vWrYZhGMZXX31lSDK++OILe5vExERDkpGWlmaf9uCDDxqxsbGGYRhGYWGh4eHhYWzcuNFh3SNGjDAGDx580dsIANWJ053AFSguLk6ZmZn66KOP1LNnT61fv17XX3+9Fi1aJEnatm2bGjVqpKuvvvqcy5eVlenpp59WmzZt5O/vLy8vL3322WfKyMhwaNe2bVuH7w0aNFBOTk6lbMMf1x0UFCQPDw81bdrUYdqZvvbs2aOCggJ1797dPpLo5eWl//73v0pLS6uUegCgsnG6E7hCubm5qXv37urevbumTp2qkSNHavr06Ro2bJjc3d0vuOwLL7ygl156SXPnzlWbNm3k6empcePGqbi42KGds7Ozw3ebzaby8vJKqf+P67bZbBfs6+TJk5KkTz75RA0bNnRod67TvQBgBYQ0AJKkiIgIrVy5UtLpUarffvtNu3fvPudo2nfffae7775b9913n6TT17Dt3r1bERERl1WDi4uLysrKLmsd5xIRESFXV1dlZGRw/RmAGoOQBlxhjh49qv79++uBBx5Q27Zt5e3trc2bN2vWrFm6++67JUndunVT165dFRcXp9mzZ6t58+batWuXbDabevbsqRYtWmj58uXauHGj6tWrp9mzZys7O/uyQ1qTJk302WefKSUlRfXr15evr29lbLK8vb01adIkjR8/XuXl5ercubPy8vL03XffycfHR/Hx8ZXSDwBUJkIacIXx8vJShw4dNGfOHKWlpamkpEShoaEaNWqUnnjiCXu7999/X5MmTdLgwYN16tQpNW/eXM8//7wk6cknn9TevXsVGxsrDw8PjR49Wn369FFeXt5l1TZq1CitX79eN9xwg06ePKmvvvpKTZo0uax1nvH0008rICBAiYmJ2rt3r/z8/HT99dc7bDMAWInNMAzD7CIAAADgiLs7AQAALIiQBgAAYEGENAAAAAsipAEAAFgQIQ0AAMCCCGkAAAAWREgDAACwIEIaAACABRHSAAAALIiQBgAAYEGENAAAAAsipAEAAFjQ/w8O4CABjGkZlgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# View an overview plot of the consensus features\n", + "lcms_collection.plot_consensus_mz_features()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "ed583149", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Total clusters in dictionary: 50\n", + "\n", + "Example - Cluster 0 contains these features:\n", + " 0_0\n", + " 1_0\n", + "The features are tagged as sampleid_massfeatureid.\n", + "So for this example, the first cluster contains the first mass feature (_0) from the first (0_) and second (1_) samples\n" + ] + } + ], + "source": [ + "# View cluster feature dictionary (which features belong to which cluster)\n", + "cluster_dict = lcms_collection.cluster_feature_dictionary\n", + "print(f\"\\nTotal clusters in dictionary: {len(cluster_dict)}\")\n", + "print(\"\\nExample - Cluster 0 contains these features:\")\n", + "if 0 in cluster_dict:\n", + " for feature in cluster_dict[0][:5]: # Show first 5\n", + " print(f\" {feature}\")\n", + "print(\"The features are tagged as sampleid_massfeatureid.\")\n", + "print(\"So for this example, the first cluster contains the first mass feature (_0) from the first (0_) and second (1_) samples\")" + ] + }, + { + "cell_type": "markdown", + "id": "80fc47e9", + "metadata": {}, + "source": [ + "## Step 5: Process consensus mass features\n", + "\n", + "Now that we have the consensus mass features, we can process them. To do that we'll use the `process_consensus_features` step. In this step you'll trigger certain actions on each of the samples within your collection.\n", + "\n", + "For this example, we will perform gap filling. Later on we'll perform the annotations with molecular formula searching and MS2 spectra matching, though you can trigger all these steps at once. If your parameters on your collection are set for multi-core (by setting the `lcms_collection.parameters.lcms_collection.cores` parameter), this would perform these actions using multicore processing on a sample-by-sample basis. " + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "f88226f6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Performing gap filling...\n", + "\n", + "Gap-filling:\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|█████████████████████████████████████████| 3/3 [00:21<00:00, 7.07s/sample]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Total induced features: 50\n", + " Sample 0: 0 gap-filled features\n", + " Sample 1: 0 gap-filled features\n", + " Sample 2: 50 gap-filled features\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "# Perform gap filling\n", + "print(\"Performing gap filling...\")\n", + "pipeline_results = lcms_collection.process_consensus_features(\n", + " load_representatives=False,\n", + " perform_gap_filling=True,\n", + " add_ms1=False,\n", + " add_ms2=False,\n", + " molecular_formula_search=False,\n", + " ms2_spectral_search=False,\n", + " spectral_lib=False,\n", + " molecular_metadata=None,\n", + " gather_eics=False,\n", + " keep_raw_data=False\n", + ")\n", + "\n", + "# Check induced (gap-filled) features\n", + "induced_df = lcms_collection.induced_mass_features_dataframe\n", + "print(f\"\\nTotal induced features: {len(induced_df)}\")\n", + "\n", + "# Check per sample\n", + "for sample_id in range(len(lcms_collection)):\n", + " sample_induced = len(induced_df[induced_df['sample_id'] == sample_id])\n", + " print(f\" Sample {sample_id}: {sample_induced} gap-filled features\")" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "9638f884", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Induced Features (first 10):\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "coll_mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "sample_name", + "rawType": "object", + "type": "string" + }, + { + "name": "sample_id", + "rawType": "int64", + "type": "integer" + }, + { + "name": "mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "mz", + "rawType": "float64", + "type": "float" + }, + { + "name": "scan_time_aligned", + "rawType": "float64", + "type": "float" + }, + { + "name": "cluster", + "rawType": "int64", + "type": "integer" + }, + { + "name": "type", + "rawType": "object", + "type": "string" + }, + { + "name": "scan_time", + "rawType": "float64", + "type": "float" + }, + { + "name": "apex_scan", + "rawType": "float32", + "type": "float" + }, + { + "name": "start_scan", + "rawType": "int64", + "type": "integer" + }, + { + "name": "final_scan", + "rawType": "int64", + "type": "integer" + }, + { + "name": "intensity", + "rawType": "float32", + "type": "float" + }, + { + "name": "persistence", + "rawType": "float64", + "type": "float" + }, + { + "name": "area", + "rawType": "float64", + "type": "float" + }, + { + "name": "tailing_factor", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index", + "rawType": "float64", + "type": "float" + }, + { + "name": "normalized_dispersity_index", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "monoisotopic_mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "isotopologue_type", + "rawType": "object", + "type": "string" + }, + { + "name": "mass_spectrum_deconvoluted_parent", + "rawType": "object", + "type": "string" + }, + { + "name": "ms2_scan_numbers", + "rawType": "object", + "type": "string" + }, + { + "name": "_eic_mz", + "rawType": "float32", + "type": "float" + } + ], + "ref": "b02ee544-5967-4ef8-a3a5-ec9ec34262d4", + "rows": [ + [ + "2_c0_0_i", + "test_sample_03", + "2", + "c0_0_i", + "301.21684599466016", + "8.895636666666666", + "0", + "untargeted", + "8.895636666666666", + "1882.0", + "1828", + "2008", + "66775330.0", + null, + "35045576.273014136", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "301.2166" + ], + [ + "2_c1_1_i", + "test_sample_03", + "2", + "c1_1_i", + "302.2202272054653", + "8.895636666666666", + "1", + "untargeted", + "8.895636666666666", + "1882.0", + "1828", + "2008", + "14249711.0", + null, + "7564256.872230931", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "302.2206" + ], + [ + "2_c2_2_i", + "test_sample_03", + "2", + "c2_2_i", + "367.3574902204513", + "19.152648333333335", + "2", + "untargeted", + "19.152648333333335", + "4069.0", + "4024", + "4312", + "48137056.0", + null, + "30641267.270276234", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "367.35748" + ], + [ + "2_c3_3_i", + "test_sample_03", + "2", + "c3_3_i", + "368.36090778141954", + "19.152648333333335", + "3", + "untargeted", + "19.152648333333335", + "4069.0", + "4024", + "4303", + "12717404.0", + null, + "8073609.919955936", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "368.36118" + ], + [ + "2_c4_4_i", + "test_sample_03", + "2", + "c4_4_i", + "698.6295110553453", + "23.816803333333333", + "4", + "untargeted", + "23.816803333333333", + "5212.0", + "5176", + "5338", + "17265106.0", + null, + "7113439.441603203", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "698.6289" + ], + [ + "2_c6_5_i", + "test_sample_03", + "2", + "c6_5_i", + "699.6327552911274", + "23.816803333333333", + "6", + "untargeted", + "23.816803333333333", + "5212.0", + "5176", + "5293", + "7861987.5", + null, + "3248197.977534156", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "699.6312" + ], + [ + "2_c9_6_i", + "test_sample_03", + "2", + "c9_6_i", + "227.2018719032787", + "7.376469999999999", + "9", + "untargeted", + "7.376469999999999", + "1513.0", + "1477", + "1657", + "9600092.0", + null, + "3792812.043434581", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "227.20189" + ], + [ + "2_c10_7_i", + "test_sample_03", + "2", + "c10_7_i", + "299.2014941880232", + "7.376469999999999", + "10", + "untargeted", + "7.376469999999999", + "1513.0", + "1477", + "1585", + "18258992.0", + null, + "7192845.185983743", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "299.2011" + ], + [ + "2_c12_8_i", + "test_sample_03", + "2", + "c12_8_i", + "455.3524308281572", + "8.96547", + "12", + "untargeted", + "8.96547", + "1900.0", + "1855", + "1999", + "12939420.0", + null, + "6434091.988552084", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "455.35266" + ], + [ + "2_c15_9_i", + "test_sample_03", + "2", + "c15_9_i", + "735.505748072386", + "20.793636666666668", + "15", + "untargeted", + "20.793636666666668", + "4483.0", + "4438", + "4600", + "8329064.0", + null, + "1303996.363192852", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "735.507" + ] + ], + "shape": { + "columns": 25, + "rows": 10 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
sample_namesample_idmf_idmzscan_time_alignedclustertypescan_timeapex_scanstart_scan...dispersity_indexnormalized_dispersity_indexnoise_scorenoise_score_minnoise_score_maxmonoisotopic_mf_idisotopologue_typemass_spectrum_deconvoluted_parentms2_scan_numbers_eic_mz
coll_mf_id
2_c0_0_itest_sample_032c0_0_i301.2168468.8956370untargeted8.8956371882.01828...NaNNaNNaNNaNNaNNoneNoneNone[]301.216614
2_c1_1_itest_sample_032c1_1_i302.2202278.8956371untargeted8.8956371882.01828...NaNNaNNaNNaNNaNNoneNoneNone[]302.220612
2_c2_2_itest_sample_032c2_2_i367.35749019.1526482untargeted19.1526484069.04024...NaNNaNNaNNaNNaNNoneNoneNone[]367.357483
2_c3_3_itest_sample_032c3_3_i368.36090819.1526483untargeted19.1526484069.04024...NaNNaNNaNNaNNaNNoneNoneNone[]368.361176
2_c4_4_itest_sample_032c4_4_i698.62951123.8168034untargeted23.8168035212.05176...NaNNaNNaNNaNNaNNoneNoneNone[]698.628906
2_c6_5_itest_sample_032c6_5_i699.63275523.8168036untargeted23.8168035212.05176...NaNNaNNaNNaNNaNNoneNoneNone[]699.631226
2_c9_6_itest_sample_032c9_6_i227.2018727.3764709untargeted7.3764701513.01477...NaNNaNNaNNaNNaNNoneNoneNone[]227.201889
2_c10_7_itest_sample_032c10_7_i299.2014947.37647010untargeted7.3764701513.01477...NaNNaNNaNNaNNaNNoneNoneNone[]299.201111
2_c12_8_itest_sample_032c12_8_i455.3524318.96547012untargeted8.9654701900.01855...NaNNaNNaNNaNNaNNoneNoneNone[]455.352661
2_c15_9_itest_sample_032c15_9_i735.50574820.79363715untargeted20.7936374483.04438...NaNNaNNaNNaNNaNNoneNoneNone[]735.507019
\n", + "

10 rows × 25 columns

\n", + "
" + ], + "text/plain": [ + " sample_name sample_id mf_id mz scan_time_aligned \\\n", + "coll_mf_id \n", + "2_c0_0_i test_sample_03 2 c0_0_i 301.216846 8.895637 \n", + "2_c1_1_i test_sample_03 2 c1_1_i 302.220227 8.895637 \n", + "2_c2_2_i test_sample_03 2 c2_2_i 367.357490 19.152648 \n", + "2_c3_3_i test_sample_03 2 c3_3_i 368.360908 19.152648 \n", + "2_c4_4_i test_sample_03 2 c4_4_i 698.629511 23.816803 \n", + "2_c6_5_i test_sample_03 2 c6_5_i 699.632755 23.816803 \n", + "2_c9_6_i test_sample_03 2 c9_6_i 227.201872 7.376470 \n", + "2_c10_7_i test_sample_03 2 c10_7_i 299.201494 7.376470 \n", + "2_c12_8_i test_sample_03 2 c12_8_i 455.352431 8.965470 \n", + "2_c15_9_i test_sample_03 2 c15_9_i 735.505748 20.793637 \n", + "\n", + " cluster type scan_time apex_scan start_scan ... \\\n", + "coll_mf_id ... \n", + "2_c0_0_i 0 untargeted 8.895637 1882.0 1828 ... \n", + "2_c1_1_i 1 untargeted 8.895637 1882.0 1828 ... \n", + "2_c2_2_i 2 untargeted 19.152648 4069.0 4024 ... \n", + "2_c3_3_i 3 untargeted 19.152648 4069.0 4024 ... \n", + "2_c4_4_i 4 untargeted 23.816803 5212.0 5176 ... \n", + "2_c6_5_i 6 untargeted 23.816803 5212.0 5176 ... \n", + "2_c9_6_i 9 untargeted 7.376470 1513.0 1477 ... \n", + "2_c10_7_i 10 untargeted 7.376470 1513.0 1477 ... \n", + "2_c12_8_i 12 untargeted 8.965470 1900.0 1855 ... \n", + "2_c15_9_i 15 untargeted 20.793637 4483.0 4438 ... \n", + "\n", + " dispersity_index normalized_dispersity_index noise_score \\\n", + "coll_mf_id \n", + "2_c0_0_i NaN NaN NaN \n", + "2_c1_1_i NaN NaN NaN \n", + "2_c2_2_i NaN NaN NaN \n", + "2_c3_3_i NaN NaN NaN \n", + "2_c4_4_i NaN NaN NaN \n", + "2_c6_5_i NaN NaN NaN \n", + "2_c9_6_i NaN NaN NaN \n", + "2_c10_7_i NaN NaN NaN \n", + "2_c12_8_i NaN NaN NaN \n", + "2_c15_9_i NaN NaN NaN \n", + "\n", + " noise_score_min noise_score_max monoisotopic_mf_id \\\n", + "coll_mf_id \n", + "2_c0_0_i NaN NaN None \n", + "2_c1_1_i NaN NaN None \n", + "2_c2_2_i NaN NaN None \n", + "2_c3_3_i NaN NaN None \n", + "2_c4_4_i NaN NaN None \n", + "2_c6_5_i NaN NaN None \n", + "2_c9_6_i NaN NaN None \n", + "2_c10_7_i NaN NaN None \n", + "2_c12_8_i NaN NaN None \n", + "2_c15_9_i NaN NaN None \n", + "\n", + " isotopologue_type mass_spectrum_deconvoluted_parent \\\n", + "coll_mf_id \n", + "2_c0_0_i None None \n", + "2_c1_1_i None None \n", + "2_c2_2_i None None \n", + "2_c3_3_i None None \n", + "2_c4_4_i None None \n", + "2_c6_5_i None None \n", + "2_c9_6_i None None \n", + "2_c10_7_i None None \n", + "2_c12_8_i None None \n", + "2_c15_9_i None None \n", + "\n", + " ms2_scan_numbers _eic_mz \n", + "coll_mf_id \n", + "2_c0_0_i [] 301.216614 \n", + "2_c1_1_i [] 302.220612 \n", + "2_c2_2_i [] 367.357483 \n", + "2_c3_3_i [] 368.361176 \n", + "2_c4_4_i [] 698.628906 \n", + "2_c6_5_i [] 699.631226 \n", + "2_c9_6_i [] 227.201889 \n", + "2_c10_7_i [] 299.201111 \n", + "2_c12_8_i [] 455.352661 \n", + "2_c15_9_i [] 735.507019 \n", + "\n", + "[10 rows x 25 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# View induced features dataframe\n", + "print(\"\\nInduced Features (first 10):\")\n", + "display(induced_df.head(10))" + ] + }, + { + "cell_type": "markdown", + "id": "8aec7d50", + "metadata": {}, + "source": [ + "## Step 6: Create Pivot Tables\n", + "\n", + "Pivot tables provide a matrix view of features across samples, useful for comparing intensities and detecting missing values." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "4ea2a311", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Pivot table BEFORE gap filling:\n", + "Shape: (50, 3)\n", + "\n", + "Sample 3 NAs: 50 / 50\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "cluster", + "rawType": "int64", + "type": "integer" + }, + { + "name": "test_sample_01", + "rawType": "object", + "type": "string" + }, + { + "name": "test_sample_02", + "rawType": "object", + "type": "string" + }, + { + "name": "test_sample_03", + "rawType": "float64", + "type": "float" + } + ], + "ref": "d7922a36-e7cd-4604-a617-5d71b6f47390", + "rows": [ + [ + "0", + "0_0", + "1_0", + null + ], + [ + "1", + "0_19", + "1_19", + null + ], + [ + "2", + "0_1", + "1_1", + null + ], + [ + "3", + "0_24", + "1_24", + null + ], + [ + "4", + "0_10", + "1_10", + null + ], + [ + "6", + "0_42", + "1_42", + null + ], + [ + "9", + "0_28", + "1_28", + null + ], + [ + "10", + "0_9", + "1_9", + null + ], + [ + "12", + "0_21", + "1_21", + null + ], + [ + "15", + "0_35", + "1_35", + null + ] + ], + "shape": { + "columns": 3, + "rows": 10 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
sample_nametest_sample_01test_sample_02test_sample_03
cluster
00_01_0NaN
10_191_19NaN
20_11_1NaN
30_241_24NaN
40_101_10NaN
60_421_42NaN
90_281_28NaN
100_91_9NaN
120_211_21NaN
150_351_35NaN
\n", + "
" + ], + "text/plain": [ + "sample_name test_sample_01 test_sample_02 test_sample_03\n", + "cluster \n", + "0 0_0 1_0 NaN\n", + "1 0_19 1_19 NaN\n", + "2 0_1 1_1 NaN\n", + "3 0_24 1_24 NaN\n", + "4 0_10 1_10 NaN\n", + "6 0_42 1_42 NaN\n", + "9 0_28 1_28 NaN\n", + "10 0_9 1_9 NaN\n", + "12 0_21 1_21 NaN\n", + "15 0_35 1_35 NaN" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Create pivot table BEFORE gap filling (to compare)\n", + "# First, reload collection without gap filling to see the difference\n", + "parser2 = ReadCoreMSHDFMassSpectraCollection(\n", + " folder_location=processed_folder,\n", + " manifest_file=manifest_path,\n", + " cores=1\n", + ")\n", + "collection_before = parser2.get_lcms_collection(load_raw=False, load_light=False)\n", + "collection_before.parameters.lcms_collection.cluster_size_min_samples = 1\n", + "collection_before.align_lcms_objects()\n", + "collection_before.add_consensus_mass_features()\n", + "\n", + "pivot_before = collection_before.collection_pivot_table(verbose=False)\n", + "print(\"Pivot table BEFORE gap filling:\")\n", + "print(f\"Shape: {pivot_before.shape}\")\n", + "print(f\"\\nSample 3 NAs: {pivot_before['test_sample_03'].isna().sum()} / {len(pivot_before)}\")\n", + "display(pivot_before.head(10))" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "e9d00373", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Pivot table AFTER gap filling:\n", + "Shape: (50, 3)\n", + "\n", + "Sample 3 NAs: 0 / 50\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "cluster", + "rawType": "int64", + "type": "integer" + }, + { + "name": "test_sample_01", + "rawType": "object", + "type": "string" + }, + { + "name": "test_sample_02", + "rawType": "object", + "type": "string" + }, + { + "name": "test_sample_03", + "rawType": "object", + "type": "string" + } + ], + "ref": "afba4f8b-9b38-4758-a2b6-e839732a9cac", + "rows": [ + [ + "0", + "0_0", + "1_0", + "2_c0_0_i" + ], + [ + "1", + "0_19", + "1_19", + "2_c1_1_i" + ], + [ + "2", + "0_1", + "1_1", + "2_c2_2_i" + ], + [ + "3", + "0_24", + "1_24", + "2_c3_3_i" + ], + [ + "4", + "0_10", + "1_10", + "2_c4_4_i" + ], + [ + "6", + "0_42", + "1_42", + "2_c6_5_i" + ], + [ + "9", + "0_28", + "1_28", + "2_c9_6_i" + ], + [ + "10", + "0_9", + "1_9", + "2_c10_7_i" + ], + [ + "12", + "0_21", + "1_21", + "2_c12_8_i" + ], + [ + "15", + "0_35", + "1_35", + "2_c15_9_i" + ] + ], + "shape": { + "columns": 3, + "rows": 10 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
sample_nametest_sample_01test_sample_02test_sample_03
cluster
00_01_02_c0_0_i
10_191_192_c1_1_i
20_11_12_c2_2_i
30_241_242_c3_3_i
40_101_102_c4_4_i
60_421_422_c6_5_i
90_281_282_c9_6_i
100_91_92_c10_7_i
120_211_212_c12_8_i
150_351_352_c15_9_i
\n", + "
" + ], + "text/plain": [ + "sample_name test_sample_01 test_sample_02 test_sample_03\n", + "cluster \n", + "0 0_0 1_0 2_c0_0_i\n", + "1 0_19 1_19 2_c1_1_i\n", + "2 0_1 1_1 2_c2_2_i\n", + "3 0_24 1_24 2_c3_3_i\n", + "4 0_10 1_10 2_c4_4_i\n", + "6 0_42 1_42 2_c6_5_i\n", + "9 0_28 1_28 2_c9_6_i\n", + "10 0_9 1_9 2_c10_7_i\n", + "12 0_21 1_21 2_c12_8_i\n", + "15 0_35 1_35 2_c15_9_i" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Gap filling filled 50 features in Sample 3\n" + ] + } + ], + "source": [ + "# Create pivot table AFTER gap filling\n", + "pivot_after = lcms_collection.collection_pivot_table(verbose=False)\n", + "print(\"\\nPivot table AFTER gap filling:\")\n", + "print(f\"Shape: {pivot_after.shape}\")\n", + "print(f\"\\nSample 3 NAs: {pivot_after['test_sample_03'].isna().sum()} / {len(pivot_after)}\")\n", + "display(pivot_after.head(10))\n", + "\n", + "# Show the difference\n", + "print(f\"\\nGap filling filled {pivot_before['test_sample_03'].isna().sum() - pivot_after['test_sample_03'].isna().sum()} features in Sample 3\")" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "e40ba01c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Pivot table with intensities:\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "cluster", + "rawType": "int64", + "type": "integer" + }, + { + "name": "test_sample_01", + "rawType": "float64", + "type": "float" + }, + { + "name": "test_sample_02", + "rawType": "float64", + "type": "float" + }, + { + "name": "test_sample_03", + "rawType": "float64", + "type": "float" + } + ], + "ref": "e32374bd-f492-4a18-9079-bf7599435528", + "rows": [ + [ + "0", + "66775328.0", + "66775328.0", + "66775328.0" + ], + [ + "1", + "14249711.0", + "14249711.0", + "14249711.0" + ], + [ + "2", + "48137056.0", + "48137056.0", + "48137056.0" + ], + [ + "3", + "12717404.0", + "12717404.0", + "12717404.0" + ], + [ + "4", + "17265106.0", + "17265106.0", + "17265106.0" + ], + [ + "6", + "7861987.5", + "7861987.5", + "7861987.5" + ], + [ + "9", + "9600092.0", + "9600092.0", + "9600092.0" + ], + [ + "10", + "18258992.0", + "18258992.0", + "18258992.0" + ], + [ + "12", + "12939420.0", + "12939420.0", + "12939420.0" + ], + [ + "15", + "8329064.0", + "8329064.0", + "8329064.0" + ] + ], + "shape": { + "columns": 3, + "rows": 10 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
sample_nametest_sample_01test_sample_02test_sample_03
cluster
066775328.066775328.066775328.0
114249711.014249711.014249711.0
248137056.048137056.048137056.0
312717404.012717404.012717404.0
417265106.017265106.017265106.0
67861987.57861987.57861987.5
99600092.09600092.09600092.0
1018258992.018258992.018258992.0
1212939420.012939420.012939420.0
158329064.08329064.08329064.0
\n", + "
" + ], + "text/plain": [ + "sample_name test_sample_01 test_sample_02 test_sample_03\n", + "cluster \n", + "0 66775328.0 66775328.0 66775328.0\n", + "1 14249711.0 14249711.0 14249711.0\n", + "2 48137056.0 48137056.0 48137056.0\n", + "3 12717404.0 12717404.0 12717404.0\n", + "4 17265106.0 17265106.0 17265106.0\n", + "6 7861987.5 7861987.5 7861987.5\n", + "9 9600092.0 9600092.0 9600092.0\n", + "10 18258992.0 18258992.0 18258992.0\n", + "12 12939420.0 12939420.0 12939420.0\n", + "15 8329064.0 8329064.0 8329064.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Create pivot table with intensity values\n", + "pivot_intensity = lcms_collection.collection_pivot_table(attribute='intensity', verbose=False)\n", + "print(\"\\nPivot table with intensities:\")\n", + "display(pivot_intensity.head(10))" + ] + }, + { + "cell_type": "markdown", + "id": "f4b2508c", + "metadata": {}, + "source": [ + "## Step 7: Cluster Representatives\n", + "\n", + "Get the best representative mass feature for each consensus cluster." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "f11922ae", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Cluster representatives: 50 clusters\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "int64", + "type": "integer" + }, + { + "name": "cluster", + "rawType": "int64", + "type": "integer" + }, + { + "name": "coll_mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "sample_name", + "rawType": "object", + "type": "string" + }, + { + "name": "sample_id", + "rawType": "int64", + "type": "integer" + }, + { + "name": "mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "mz", + "rawType": "float64", + "type": "float" + }, + { + "name": "type", + "rawType": "object", + "type": "string" + }, + { + "name": "scan_time", + "rawType": "float64", + "type": "float" + }, + { + "name": "apex_scan", + "rawType": "float64", + "type": "float" + }, + { + "name": "start_scan", + "rawType": "float64", + "type": "float" + }, + { + "name": "final_scan", + "rawType": "float64", + "type": "float" + }, + { + "name": "intensity", + "rawType": "float64", + "type": "float" + }, + { + "name": "persistence", + "rawType": "float64", + "type": "float" + }, + { + "name": "area", + "rawType": "float64", + "type": "float" + }, + { + "name": "tailing_factor", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index", + "rawType": "float64", + "type": "float" + }, + { + "name": "normalized_dispersity_index", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "monoisotopic_mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "isotopologue_type", + "rawType": "object", + "type": "string" + }, + { + "name": "mass_spectrum_deconvoluted_parent", + "rawType": "object", + "type": "string" + }, + { + "name": "ms2_scan_numbers", + "rawType": "object", + "type": "string" + }, + { + "name": "_eic_mz", + "rawType": "float64", + "type": "float" + }, + { + "name": "scan_time_aligned", + "rawType": "float64", + "type": "float" + }, + { + "name": "partition_idx", + "rawType": "float64", + "type": "float" + }, + { + "name": "idx", + "rawType": "float64", + "type": "float" + }, + { + "name": "polarity", + "rawType": "object", + "type": "string" + }, + { + "name": "n_samples_detected", + "rawType": "int64", + "type": "integer" + } + ], + "ref": "37c8489f-1993-49ff-84f0-310414bf7b80", + "rows": [ + [ + "0", + "0", + "0_0", + "test_sample_01", + "0", + "0.0", + "301.21661376953125", + "untargeted", + "8.895636666666666", + "1882.0", + "1828.0", + "2008.0", + "66775328.0", + "66708546.0", + "35045576.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[1874 1910]", + "301.21661376953125", + "8.895636666666666", + "0.0", + "0.0", + "negative", + "3" + ], + [ + "2", + "1", + "0_19", + "test_sample_01", + "0", + "19.0", + "302.2206115722656", + "untargeted", + "8.895636666666666", + "1882.0", + "1828.0", + "2008.0", + "14249711.0", + "14142901.0", + "7564257.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "302.2206115722656", + "8.895636666666666", + "0.0", + "2.0", + "negative", + "3" + ], + [ + "4", + "2", + "0_1", + "test_sample_01", + "0", + "1.0", + "367.35748291015625", + "untargeted", + "19.152648333333335", + "4069.0", + "4024.0", + "4312.0", + "48137056.0", + "48070260.0", + "30641268.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[4036 4037 4070]", + "367.35748291015625", + "19.152648333333335", + "0.0", + "4.0", + "negative", + "3" + ], + [ + "6", + "3", + "0_24", + "test_sample_01", + "0", + "24.0", + "368.3611755371094", + "untargeted", + "19.152648333333335", + "4069.0", + "4024.0", + "4303.0", + "12717404.0", + "12650608.0", + "8073610.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "368.3611755371094", + "19.152648333333335", + "0.0", + "6.0", + "negative", + "3" + ], + [ + "8", + "4", + "0_10", + "test_sample_01", + "0", + "10.0", + "698.62890625", + "untargeted", + "23.816803333333333", + "5212.0", + "5176.0", + "5338.0", + "17265106.0", + "17198326.0", + "7113439.5", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[5195 5196 5231 5232]", + "698.62890625", + "23.816803333333333", + "0.0", + "8.0", + "negative", + "3" + ], + [ + "10", + "6", + "0_42", + "test_sample_01", + "0", + "42.0", + "699.6312255859375", + "untargeted", + "23.816803333333333", + "5212.0", + "5176.0", + "5293.0", + "7861987.5", + "7795191.0", + "3248198.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]", + "699.6312255859375", + "23.816803333333333", + "0.0", + "11.0", + "negative", + "3" + ], + [ + "12", + "9", + "0_28", + "test_sample_01", + "0", + "28.0", + "227.20188903808594", + "untargeted", + "7.376469999999999", + "1513.0", + "1477.0", + "1657.0", + "9600092.0", + "9533297.0", + "3792812.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[1496 1532]", + "227.20188903808594", + "7.376469999999999", + "0.0", + "15.0", + "negative", + "3" + ], + [ + "14", + "10", + "0_9", + "test_sample_01", + "0", + "9.0", + "299.20111083984375", + "untargeted", + "7.376469999999999", + "1513.0", + "1477.0", + "1585.0", + "18258992.0", + "18192197.0", + "7192845.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[1493 1494 1523]", + "299.20111083984375", + "7.376469999999999", + "0.0", + "17.0", + "negative", + "3" + ], + [ + "16", + "12", + "0_21", + "test_sample_01", + "0", + "21.0", + "455.3526611328125", + "untargeted", + "8.96547", + "1900.0", + "1855.0", + "1999.0", + "12939420.0", + "12872638.0", + "6434092.0", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[1865 1866 1912 1913]", + "455.3526611328125", + "8.96547", + "0.0", + "20.0", + "negative", + "3" + ], + [ + "18", + "15", + "0_35", + "test_sample_01", + "0", + "35.0", + "735.5070190429688", + "untargeted", + "20.793636666666668", + "4483.0", + "4438.0", + "4600.0", + "8329064.0", + "8262284.0", + "1303996.375", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[4461 4462 4493 4494]", + "735.5070190429688", + "20.793636666666668", + "0.0", + "24.0", + "negative", + "3" + ] + ], + "shape": { + "columns": 30, + "rows": 10 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
clustercoll_mf_idsample_namesample_idmf_idmztypescan_timeapex_scanstart_scan...monoisotopic_mf_idisotopologue_typemass_spectrum_deconvoluted_parentms2_scan_numbers_eic_mzscan_time_alignedpartition_idxidxpolarityn_samples_detected
000_0test_sample_0100.0301.216614untargeted8.8956371882.01828.0...NoneNoneNone[1874, 1910]301.2166148.8956370.00.0negative3
210_19test_sample_01019.0302.220612untargeted8.8956371882.01828.0...NoneNoneNone[]302.2206128.8956370.02.0negative3
420_1test_sample_0101.0367.357483untargeted19.1526484069.04024.0...NoneNoneNone[4036, 4037, 4070]367.35748319.1526480.04.0negative3
630_24test_sample_01024.0368.361176untargeted19.1526484069.04024.0...NoneNoneNone[]368.36117619.1526480.06.0negative3
840_10test_sample_01010.0698.628906untargeted23.8168035212.05176.0...NoneNoneNone[5195, 5196, 5231, 5232]698.62890623.8168030.08.0negative3
1060_42test_sample_01042.0699.631226untargeted23.8168035212.05176.0...NoneNoneNone[]699.63122623.8168030.011.0negative3
1290_28test_sample_01028.0227.201889untargeted7.3764701513.01477.0...NoneNoneNone[1496, 1532]227.2018897.3764700.015.0negative3
14100_9test_sample_0109.0299.201111untargeted7.3764701513.01477.0...NoneNoneNone[1493, 1494, 1523]299.2011117.3764700.017.0negative3
16120_21test_sample_01021.0455.352661untargeted8.9654701900.01855.0...NoneNoneNone[1865, 1866, 1912, 1913]455.3526618.9654700.020.0negative3
18150_35test_sample_01035.0735.507019untargeted20.7936374483.04438.0...NoneNoneNone[4461, 4462, 4493, 4494]735.50701920.7936370.024.0negative3
\n", + "

10 rows × 30 columns

\n", + "
" + ], + "text/plain": [ + " cluster coll_mf_id sample_name sample_id mf_id mz \\\n", + "0 0 0_0 test_sample_01 0 0.0 301.216614 \n", + "2 1 0_19 test_sample_01 0 19.0 302.220612 \n", + "4 2 0_1 test_sample_01 0 1.0 367.357483 \n", + "6 3 0_24 test_sample_01 0 24.0 368.361176 \n", + "8 4 0_10 test_sample_01 0 10.0 698.628906 \n", + "10 6 0_42 test_sample_01 0 42.0 699.631226 \n", + "12 9 0_28 test_sample_01 0 28.0 227.201889 \n", + "14 10 0_9 test_sample_01 0 9.0 299.201111 \n", + "16 12 0_21 test_sample_01 0 21.0 455.352661 \n", + "18 15 0_35 test_sample_01 0 35.0 735.507019 \n", + "\n", + " type scan_time apex_scan start_scan ... monoisotopic_mf_id \\\n", + "0 untargeted 8.895637 1882.0 1828.0 ... None \n", + "2 untargeted 8.895637 1882.0 1828.0 ... None \n", + "4 untargeted 19.152648 4069.0 4024.0 ... None \n", + "6 untargeted 19.152648 4069.0 4024.0 ... None \n", + "8 untargeted 23.816803 5212.0 5176.0 ... None \n", + "10 untargeted 23.816803 5212.0 5176.0 ... None \n", + "12 untargeted 7.376470 1513.0 1477.0 ... None \n", + "14 untargeted 7.376470 1513.0 1477.0 ... None \n", + "16 untargeted 8.965470 1900.0 1855.0 ... None \n", + "18 untargeted 20.793637 4483.0 4438.0 ... None \n", + "\n", + " isotopologue_type mass_spectrum_deconvoluted_parent \\\n", + "0 None None \n", + "2 None None \n", + "4 None None \n", + "6 None None \n", + "8 None None \n", + "10 None None \n", + "12 None None \n", + "14 None None \n", + "16 None None \n", + "18 None None \n", + "\n", + " ms2_scan_numbers _eic_mz scan_time_aligned partition_idx \\\n", + "0 [1874, 1910] 301.216614 8.895637 0.0 \n", + "2 [] 302.220612 8.895637 0.0 \n", + "4 [4036, 4037, 4070] 367.357483 19.152648 0.0 \n", + "6 [] 368.361176 19.152648 0.0 \n", + "8 [5195, 5196, 5231, 5232] 698.628906 23.816803 0.0 \n", + "10 [] 699.631226 23.816803 0.0 \n", + "12 [1496, 1532] 227.201889 7.376470 0.0 \n", + "14 [1493, 1494, 1523] 299.201111 7.376470 0.0 \n", + "16 [1865, 1866, 1912, 1913] 455.352661 8.965470 0.0 \n", + "18 [4461, 4462, 4493, 4494] 735.507019 20.793637 0.0 \n", + "\n", + " idx polarity n_samples_detected \n", + "0 0.0 negative 3 \n", + "2 2.0 negative 3 \n", + "4 4.0 negative 3 \n", + "6 6.0 negative 3 \n", + "8 8.0 negative 3 \n", + "10 11.0 negative 3 \n", + "12 15.0 negative 3 \n", + "14 17.0 negative 3 \n", + "16 20.0 negative 3 \n", + "18 24.0 negative 3 \n", + "\n", + "[10 rows x 30 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "All clusters have 1 representative: True\n" + ] + } + ], + "source": [ + "# Get cluster representatives\n", + "reps_table = lcms_collection.cluster_representatives_table()\n", + "print(f\"Cluster representatives: {len(reps_table)} clusters\")\n", + "display(reps_table.head(10))\n", + "\n", + "# Verify each cluster has exactly one representative\n", + "cluster_counts = reps_table['cluster'].value_counts()\n", + "print(f\"\\nAll clusters have 1 representative: {all(count == 1 for count in cluster_counts)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a218300f", + "metadata": {}, + "source": [ + "## Step 8: Feature Annotations\n", + "\n", + "Next we will annotate the features in MS1 (molecular formula searching) and MS2 (comparison to an MS2 spectral database).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "a52580c8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Reloading features, ms2 spectral search, loading eics:\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|█████████████████████████████████████████| 3/3 [00:12<00:00, 4.12s/sample]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Associating EICs with mass features:\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|███████████████████████████████████████| 3/3 [00:00<00:00, 1808.15sample/s]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Molecular formula search complete\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "# First we need to prepare a search space for the MS2 spectral search using a small testing MSP file\n", + "msp_file = Path('../../tests/tests_data/lcms/test_db.msp')\n", + "my_msp = MSPInterface(file_path=str(msp_file))\n", + "spectral_library, molecular_metadata = my_msp.get_metabolomics_spectra_library(\n", + " polarity=\"negative\",\n", + " format=\"flashentropy\",\n", + " normalize=True,\n", + " fe_kwargs={\n", + " \"normalize_intensity\": True,\n", + " \"min_ms2_difference_in_da\": 0.02,\n", + " \"max_ms2_tolerance_in_da\": 0.01,\n", + " \"max_indexed_mz\": 3000,\n", + " \"precursor_ions_removal_da\": None,\n", + " \"noise_threshold\": 0,\n", + " },\n", + ")\n", + "\n", + "# Set MS2 score threshold for each sample to enable finding spectral matches\n", + "# This threshold determines the minimum similarity score required for a match\n", + "for lcms_obj in lcms_collection:\n", + " lcms_obj.parameters.lc_ms.ms2_min_fe_score = 0.3\n", + " \n", + "pipeline_results = lcms_collection.process_consensus_features(\n", + " load_representatives=True, # Load representative features for processing\n", + " perform_gap_filling=False, # No gap filling this time, already done\n", + " add_ms1=True, # Need to add and process MS1 data for molecular formula searching, but only for representative features\n", + " add_ms2=True, # Need to add and process MS2 data for spectral searching, but only for representative features\n", + " molecular_formula_search=False, # Perform molecular formula searching\n", + " ms2_spectral_search=True, # Perform MS2 spectral searching\n", + " spectral_lib=spectral_library, # Provide the spectral library we created above\n", + " molecular_metadata=molecular_metadata, # Provide molecular metadata for annotations\n", + " gather_eics=True # Gather EICs to plot later on\n", + ")\n", + "print(\"✓ Molecular formula search complete\")" + ] + }, + { + "cell_type": "markdown", + "id": "fabc2123", + "metadata": {}, + "source": [ + "### View Feature Annotations Table\n", + "\n", + "The feature annotations table combines cluster information with molecular annotations from both MS1 and MS2 searches." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "286c4eec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Annotations table: 7 rows\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "int64", + "type": "integer" + }, + { + "name": "cluster", + "rawType": "int64", + "type": "integer" + }, + { + "name": "Isotopologue Type", + "rawType": "object", + "type": "string" + }, + { + "name": "Is Largest Ion after Deconvolution", + "rawType": "object", + "type": "string" + }, + { + "name": "MS2 Spectrum", + "rawType": "object", + "type": "string" + }, + { + "name": "Calculated m/z", + "rawType": "float64", + "type": "float" + }, + { + "name": "m/z Error (ppm)", + "rawType": "float64", + "type": "float" + }, + { + "name": "m/z Error Score", + "rawType": "float64", + "type": "float" + }, + { + "name": "Isotopologue Similarity", + "rawType": "float64", + "type": "float" + }, + { + "name": "Confidence Score", + "rawType": "float64", + "type": "float" + }, + { + "name": "Ion Formula", + "rawType": "object", + "type": "string" + }, + { + "name": "Ion Type", + "rawType": "object", + "type": "string" + }, + { + "name": "Molecular Formula", + "rawType": "object", + "type": "string" + }, + { + "name": "inchikey", + "rawType": "object", + "type": "string" + }, + { + "name": "name", + "rawType": "object", + "type": "string" + }, + { + "name": "ref_ms_id", + "rawType": "object", + "type": "string" + }, + { + "name": "Entropy Similarity", + "rawType": "float32", + "type": "float" + }, + { + "name": "Library mzs in Query (fraction)", + "rawType": "float64", + "type": "float" + }, + { + "name": "Spectra with Annotation (n)", + "rawType": "float64", + "type": "float" + }, + { + "name": "representative_sample", + "rawType": "object", + "type": "string" + } + ], + "ref": "645bc1ee-a119-4768-b783-f8dbf254c05d", + "rows": [ + [ + "1", + "2", + null, + null, + "367.3582:1.0", + null, + null, + null, + null, + null, + "C24 H47 O2", + "[M-H]-", + "C24H48O2", + "QZZGJDVWLFXDLK-UHFFFAOYSA-N", + "Lignoceric Acid", + "CCMSLIB00004684071", + "0.7833995", + "0.5", + "2.0", + "test_sample_01" + ], + [ + "22", + "12", + null, + null, + "455.3532:1.0; 456.3585:0.01", + null, + null, + null, + null, + null, + "C30 H47 O3", + "[M-H]-", + "C30H48O3", + "WCGUUGGRBIKTOS-UHFFFAOYSA-N", + "Urs-12-en-28-oic acid, 3-hydroxy-, (3beta)-", + "CCMSLIB00010125334", + "0.5768537", + "0.14285714285714285", + "2.0", + "test_sample_01" + ], + [ + "23", + "12", + null, + null, + "455.3532:1.0; 456.3585:0.01", + null, + null, + null, + null, + null, + "C30 H47 O3", + "[M-H]-", + "C30H48O3", + "MIJYXULNPSFWEK-UHFFFAOYSA-N", + "3-Hydroxyolean-12-en-28-oic acid", + "CCMSLIB00010113749", + "0.5429697", + "0.1111111111111111", + "2.0", + "test_sample_01" + ], + [ + "15", + "55", + null, + null, + "455.3531:1.0", + null, + null, + null, + null, + null, + "C30 H47 O3", + "[M-H]-", + "C30H48O3", + "WCGUUGGRBIKTOS-UHFFFAOYSA-N", + "Urs-12-en-28-oic acid, 3-hydroxy-, (3beta)-", + "CCMSLIB00010125334", + "0.64587337", + "0.14285714285714285", + "2.0", + "test_sample_01" + ], + [ + "16", + "55", + null, + null, + "455.3531:1.0", + null, + null, + null, + null, + null, + "C30 H47 O3", + "[M-H]-", + "C30H48O3", + "MIJYXULNPSFWEK-UHFFFAOYSA-N", + "3-Hydroxyolean-12-en-28-oic acid", + "CCMSLIB00010113749", + "0.6061404", + "0.1111111111111111", + "2.0", + "test_sample_01" + ] + ], + "shape": { + "columns": 19, + "rows": 5 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
clusterIsotopologue TypeIs Largest Ion after DeconvolutionMS2 SpectrumCalculated m/zm/z Error (ppm)m/z Error ScoreIsotopologue SimilarityConfidence ScoreIon FormulaIon TypeMolecular Formulainchikeynameref_ms_idEntropy SimilarityLibrary mzs in Query (fraction)Spectra with Annotation (n)representative_sample
12NoneNone367.3582:1.0NaNNaNNaNNaNNaNC24 H47 O2[M-H]-C24H48O2QZZGJDVWLFXDLK-UHFFFAOYSA-NLignoceric AcidCCMSLIB000046840710.7834000.5000002.0test_sample_01
2212NoneNone455.3532:1.0; 456.3585:0.01NaNNaNNaNNaNNaNC30 H47 O3[M-H]-C30H48O3WCGUUGGRBIKTOS-UHFFFAOYSA-NUrs-12-en-28-oic acid, 3-hydroxy-, (3beta)-CCMSLIB000101253340.5768540.1428572.0test_sample_01
2312NoneNone455.3532:1.0; 456.3585:0.01NaNNaNNaNNaNNaNC30 H47 O3[M-H]-C30H48O3MIJYXULNPSFWEK-UHFFFAOYSA-N3-Hydroxyolean-12-en-28-oic acidCCMSLIB000101137490.5429700.1111112.0test_sample_01
1555NoneNone455.3531:1.0NaNNaNNaNNaNNaNC30 H47 O3[M-H]-C30H48O3WCGUUGGRBIKTOS-UHFFFAOYSA-NUrs-12-en-28-oic acid, 3-hydroxy-, (3beta)-CCMSLIB000101253340.6458730.1428572.0test_sample_01
1655NoneNone455.3531:1.0NaNNaNNaNNaNNaNC30 H47 O3[M-H]-C30H48O3MIJYXULNPSFWEK-UHFFFAOYSA-N3-Hydroxyolean-12-en-28-oic acidCCMSLIB000101137490.6061400.1111112.0test_sample_01
\n", + "
" + ], + "text/plain": [ + " cluster Isotopologue Type Is Largest Ion after Deconvolution \\\n", + "1 2 None None \n", + "22 12 None None \n", + "23 12 None None \n", + "15 55 None None \n", + "16 55 None None \n", + "\n", + " MS2 Spectrum Calculated m/z m/z Error (ppm) \\\n", + "1 367.3582:1.0 NaN NaN \n", + "22 455.3532:1.0; 456.3585:0.01 NaN NaN \n", + "23 455.3532:1.0; 456.3585:0.01 NaN NaN \n", + "15 455.3531:1.0 NaN NaN \n", + "16 455.3531:1.0 NaN NaN \n", + "\n", + " m/z Error Score Isotopologue Similarity Confidence Score Ion Formula \\\n", + "1 NaN NaN NaN C24 H47 O2 \n", + "22 NaN NaN NaN C30 H47 O3 \n", + "23 NaN NaN NaN C30 H47 O3 \n", + "15 NaN NaN NaN C30 H47 O3 \n", + "16 NaN NaN NaN C30 H47 O3 \n", + "\n", + " Ion Type Molecular Formula inchikey \\\n", + "1 [M-H]- C24H48O2 QZZGJDVWLFXDLK-UHFFFAOYSA-N \n", + "22 [M-H]- C30H48O3 WCGUUGGRBIKTOS-UHFFFAOYSA-N \n", + "23 [M-H]- C30H48O3 MIJYXULNPSFWEK-UHFFFAOYSA-N \n", + "15 [M-H]- C30H48O3 WCGUUGGRBIKTOS-UHFFFAOYSA-N \n", + "16 [M-H]- C30H48O3 MIJYXULNPSFWEK-UHFFFAOYSA-N \n", + "\n", + " name ref_ms_id \\\n", + "1 Lignoceric Acid CCMSLIB00004684071 \n", + "22 Urs-12-en-28-oic acid, 3-hydroxy-, (3beta)- CCMSLIB00010125334 \n", + "23 3-Hydroxyolean-12-en-28-oic acid CCMSLIB00010113749 \n", + "15 Urs-12-en-28-oic acid, 3-hydroxy-, (3beta)- CCMSLIB00010125334 \n", + "16 3-Hydroxyolean-12-en-28-oic acid CCMSLIB00010113749 \n", + "\n", + " Entropy Similarity Library mzs in Query (fraction) \\\n", + "1 0.783400 0.500000 \n", + "22 0.576854 0.142857 \n", + "23 0.542970 0.111111 \n", + "15 0.645873 0.142857 \n", + "16 0.606140 0.111111 \n", + "\n", + " Spectra with Annotation (n) representative_sample \n", + "1 2.0 test_sample_01 \n", + "22 2.0 test_sample_01 \n", + "23 2.0 test_sample_01 \n", + "15 2.0 test_sample_01 \n", + "16 2.0 test_sample_01 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Get annotations table (from collection with formula search if available)\n", + "annotations_table = lcms_collection.feature_annotations_table(\n", + " molecular_metadata=molecular_metadata, # Pass molecular_metadata to include MS2 spectral match info\n", + " drop_unannotated=True # Only show features with annotations\n", + ")\n", + "print(f\"Annotations table: {len(annotations_table)} rows\")\n", + "display(annotations_table.head())" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "3ce506a0", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA9wAAASdCAYAAACy81RaAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/OQEPoAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzdd3hT5dsH8G+Stkn3oKVllLaUskcZAmXIqlRkyhBQGZWhAiJWfwLKVkBAhi+CCLJkKIIyFJRRQUSryKggsoSyaZndbdImz/tHTGzadCc9Tfv9XFeuJk/OuE96Mu7zLJkQQoCIiIiIiIiILEoudQBEREREREREFRETbiIiIiIiIiIrYMJNREREREREZAVMuImIiIiIiIisgAk3ERERERERkRUw4SYiIiIiIiKyAibcRERERERERFbAhJuIiIiIiIjICphwExEREREREVkBE26iCkwmk2HWrFlSh0FU4SxcuBD169eHTqeTOhSbsGrVKtSqVQtqtVrqUKgcCAwMxMiRIy2+3XHjxuGpp56y+HbLgylTpqBNmzZSh2GTZs2aBZlMJnUYVIkx4aYSu3LlCl5++WXUrl0bKpUKbm5uaN++PT766CNkZGRIHV6FFhsbixdffBH+/v5QKpXw8vJCeHg41q9fD61WWyYx3LlzB7NmzUJsbGyZ7A8ALly4gLfffhuhoaFwdXVFtWrV0LNnT5w4caLMYiiITqeDj48PFi5cKHUoRjt37kRERASqV68OpVKJmjVrYuDAgfjrr7/MLp+SkoK3334bQUFBUCqVqFGjBgYOHIj09HTjMp07d4ZMJjN7s7e3LzSmNWvWoFOnTvD19YVSqURQUBAiIyNx7dq1PMvmt58PPvjAZLnAwMB8lw0JCSnRNvOTnJyMBQsWYPLkyZDLLfM1unbtWjRo0AAqlQohISFYvnx5kddVq9WYPHkyqlevDkdHR7Rp0wYHDx40u+yvv/6KDh06wMnJCX5+fpg4cSJSU1Otvs2RI0dCo9Hg008/LfJxmZP7/+zs7IzWrVvj888/BwBcu3Yt3/9v7pu5880aDhw4gFGjRqFx48ZQKBQIDAzMd9l//vkHAwcOhKenJ5ycnNChQwccPny4SPu5e/cupkyZgi5dusDV1RUymQxHjhwxu2x+7+Gnn366BEdYPsTFxeGzzz7DO++8Y5Ht6XQ6LFy4EEFBQVCpVGjatCm++OKLIq+fmJiIsWPHwsfHB87OzujSpQtOnTpldtk9e/agRYsWUKlUqFWrFmbOnIns7GyTZSZNmoQ///wTe/bsKdVxvfHGG2jRogW8vLzg5OSEBg0aYNasWWY/B4jIMuykDoBs0969ezFo0CAolUoMHz4cjRs3hkajwbFjx/C///0P586dw+rVq6UOs0L67LPP8Morr8DX1xfDhg1DSEgIUlJSEB0djVGjRuHu3bsW+8FRkDt37mD27NkIDAxEaGio1fcH6I997dq1GDBgAMaNG4ekpCR8+umnaNu2LX744QeEh4eXSRz5OX78OB48eICePXtKGkdOZ8+ehaenJ15//XV4e3sjPj4e69atQ+vWrRETE4NmzZoZl01KSkKnTp1w69YtjB07FnXq1MH9+/fx888/Q61Ww8nJCQDw7rvvYvTo0Sb7SUtLwyuvvILu3bsXGtPp06cRFBSEPn36wNPTE3FxcVizZg2+++47/Pnnn6hevbrJ8k899RSGDx9uUta8eXOTx8uWLcvzg/H69euYNm2a2ZiKss38rFu3DtnZ2Rg6dGiRli/Mp59+ildeeQUDBgxAVFQUfv75Z0ycOBHp6emYPHlyoeuPHDkSO3bswKRJkxASEoINGzbgmWeeweHDh9GhQwfjcrGxsejWrRsaNGiAJUuW4NatW/jwww9x+fJlfP/991bdpkqlwogRI7BkyRK89tprpaptCg0NxZtvvglAn2R+9tlnGDFiBNRqNZ5//nls2rTJZPnFixfj1q1bWLp0qUm5j49PiWMojq1bt2Lbtm1o0aJFnnM7p5s3byIsLAwKhQL/+9//4OzsjPXr16N79+6Ijo7Gk08+WeB+Ll68iAULFiAkJARNmjRBTExMgcvXrFkT8+fPNykrKD5LunjxosUuVhl89NFHCAoKQpcuXSyyvXfffRcffPABxowZgyeeeAK7d+/G888/D5lMhiFDhhS4rk6nQ8+ePfHnn3/if//7H7y9vbFy5Up07twZJ0+eNLkI+P3336Nfv37o3Lkzli9fjrNnz+L999/HvXv38MknnxiX8/PzQ9++ffHhhx+iT58+JT6uP/74Ax07dkRkZCRUKhVOnz6NDz74AIcOHcLRo0ct/n8hIgCCqJiuXr0qXFxcRP369cWdO3fyPH/58mWxbNkyCSKr+GJiYoRCoRAdOnQQycnJeZ7/448/xPr1642PAYiZM2daJZY//vhDADDZnyWkpqbm+9yJEydESkqKSdmDBw+Ej4+PaN++vUXjKInp06eLgIAAqcMoVHx8vLCzsxMvv/yySfmrr74qPDw8xNWrV4u9zU2bNgkAYsuWLSWK6cSJEwKAmD9/vkk5ADF+/PgSbfO9994TAMQvv/xisW0KIUTTpk3Fiy++WOhy69evF4V9zaanp4sqVaqInj17mpS/8MILwtnZWTx69KjA9X///XcBQCxatMhYlpGRIYKDg0VYWJjJsj169BDVqlUTSUlJxrI1a9YIAGL//v1W3aYQ//2Po6OjCzymggQEBOR5re7duydcXFxEgwYNzK7Ts2dPSd+Xt2/fFhqNptBYxo0bJ+zs7MSFCxeMZWlpacLf31+0aNGi0P0kJyeLhw8fCiGE2L59uwAgDh8+bHbZTp06iUaNGhXvQMoxjUYjvL29xbRp0wpddubMmYWeD7du3RL29vYmnxM6nU507NhR1KxZU2RnZxe4/rZt2wQAsX37dmPZvXv3hIeHhxg6dKjJsg0bNhTNmjUTWVlZxrJ3331XyGQycf78eZNld+zYIWQymbhy5Uphh1ksH374oQAgYmJiLLrd8mLmzJmFfhYTWRMvY1GxLVy4EKmpqVi7di2qVauW5/k6derg9ddfNz7Ozs7Ge++9h+DgYCiVSgQGBuKdd97J05cvMDAQvXr1wrFjx9C6dWuoVCrUrl3b2FTQICsrC7Nnz0ZISAhUKhWqVKmCDh065GnueOHCBQwcOBBeXl5QqVRo1apVnqZYGzZsgEwmwy+//IKoqChj069nn30W9+/fN1n2xIkTiIiIgLe3NxwdHREUFISXXnrJ+PyRI0fMNuEzNHHcsGGDsSw+Ph6RkZGoWbMmlEolqlWrhr59+xbaxHH27NmQyWTYsmULXF1d8zzfqlWrAvvFjRw50mxzRnP9mw4ePIgOHTrAw8MDLi4uqFevnrHm/MiRI3jiiScAAJGRkcbmiDmP8ffff8fTTz8Nd3d3ODk5oVOnTvjll1/M7vfvv//G888/D09PT5Pas9xatmwJFxcXk7IqVaqgY8eOOH/+vEl5eno6Lly4gAcPHuS7PYPOnTujcePGOHPmDDp16gQnJyfUqVMHO3bsAAD89NNPaNOmDRwdHVGvXj0cOnTI7Hb27t1rrN02HJu5mzX6LhZH1apV4eTkhMTERGNZYmIi1q9fj7FjxyIoKAgajaZY/W23bt0KZ2dn9O3bt0QxGc7LnDHllJGRgczMzGJtc+vWrQgKCkK7du0sts24uDicOXPGYq0pDh8+jIcPH2LcuHEm5ePHj0daWhr27t1b4Po7duyAQqHA2LFjjWUqlQqjRo1CTEwMbt68CUDfDP7gwYN48cUX4ebmZlx2+PDhcHFxwVdffWXVbQL696+Xlxd2795tUv7gwQNcuHDBpNtCcfj4+KB+/fq4cuVKida3turVqxepq8XPP/+M5s2bo169esYyJycn9OnTB6dOncLly5cLXN/V1RVeXl7Fii07O7vYTYkN33VfffUVZs+ejRo1asDV1RUDBw5EUlIS1Go1Jk2ahKpVq8LFxQWRkZFmv+9zfg4W57vYnGPHjuHBgwcWe1/u3r0bWVlZJu9LmUyGV199Fbdu3Sq09cCOHTvg6+uL/v37G8t8fHzw3HPPYffu3cbX4++//8bff/+NsWPHws7uv0an48aNgxDC+B1kYDi+3O+hu3fv4sKFC8jKyirR8Rb2+ZvT8uXL0ahRIzg5OcHT0xOtWrXC1q1bjc9fv34d48aNQ7169eDo6IgqVapg0KBBeX7fGP7nx44dw8SJE+Hj4wMPDw+8/PLL0Gg0SExMxPDhw+Hp6QlPT0+8/fbbEEIY1zf8tvrwww+xdOlSBAQEwNHREZ06dcq3y1RumzdvRsuWLeHo6AgvLy8MGTLE+PlmcPnyZQwYMAB+fn5QqVSoWbMmhgwZgqSkpCLtgwhgH24qgW+//Ra1a9fO90dsbqNHj8aMGTPQokULLF26FJ06dcL8+fPNNsky9F976qmnsHjxYnh6emLkyJE4d+6ccZlZs2Zh9uzZ6NKlCz7++GO8++67qFWrlknfqHPnzqFt27Y4f/48pkyZgsWLF8PZ2Rn9+vXDzp078+z3tddew59//omZM2fi1VdfxbfffosJEyYYn7937x66d++Oa9euYcqUKVi+fDleeOEF/Pbbb8V56YwGDBiAnTt3IjIyEitXrsTEiRORkpKCGzdu5LtOenq6sVlhrVq1SrTfojp37hx69eoFtVqNOXPmYPHixejTp48xYW7QoAHmzJkDABg7diw2bdqETZs2GZs8/vjjj3jyySeRnJyMmTNnYt68eUhMTETXrl1x/PjxPPsbNGgQ0tPTMW/ePIwZM6bY8cbHx8Pb29uk7Pjx42jQoAE+/vjjIm3j8ePH6NWrF9q0aYOFCxdCqVRiyJAh2LZtG4YMGYJnnnkGH3zwAdLS0jBw4ECkpKTkieH06dN45plnAAD9+/c3vi6G26RJkwDoE96CpKam4sGDB4XeivOFn5iYiPv37+Ps2bMYPXo0kpOT0a1bN+Pzx44dQ2ZmJurUqYOBAwfCyckJjo6OaN++faH99O/fv4+DBw+iX79+cHZ2LnJMDx8+xL1793DixAlERkYCgElMBhs2bICzszMcHR3RsGFDkx93+Tl9+jTOnz+P559/3uzzJdkmoO+vDAAtWrTI89zjx49N/j+GRCb3/y1nYnn69GkA+otlObVs2RJyudz4fEHHWbduXZOEFwBat24NAMb/3dmzZ5GdnZ1nPw4ODggNDTXZjzW2adCiRYs8F94+/vhjNGjQwOxnQ1FkZ2fj1q1b8PT0LNH65uT+X+Z3K+lFAnPUajUcHR3zlBu6cpw8edJi+wKAS5cuwdnZGa6urvDz88P06dOLlbDNnz8f+/fvx5QpU/DSSy/hm2++wSuvvIKXXnoJly5dwqxZs9C/f39s2LABCxYsKNI2C/suzs+vv/4KmUxmtluIuf+ZTqfLU57zosDp06fh7OyMBg0amGzL8B4oyvuyRYsWeZpnt27dGunp6bh06ZLJdnK/h6pXr46aNWvm2Y+7uzuCg4PzvIemTp2KBg0a4Pbt2wXGZZCdnY0HDx7gzp07OHDgAKZNmwZXV1fj8eVnzZo1mDhxIho2bIhly5Zh9uzZCA0Nxe+//25c5o8//sCvv/6KIUOG4P/+7//wyiuvIDo6Gp07dzb7fnnttddw+fJlzJ49G3369MHq1asxffp09O7dG1qtFvPmzUOHDh2waNGiPN1FAODzzz/H//3f/2H8+PGYOnUq/vrrL3Tt2hUJCQkFHsvcuXMxfPhwhISEYMmSJZg0aZLxN5bhwoNGo0FERAR+++03vPbaa1ixYgXGjh2Lq1evFuniBJGR1FXsZFuSkpIEANG3b98iLR8bGysAiNGjR5uUv/XWWwKA+PHHH41lAQEBAoA4evSosezevXtCqVSKN99801jWrFmzPE0Kc+vWrZto0qSJyMzMNJbpdDrRrl07ERISYiwzNPkMDw8XOp3OWP7GG28IhUIhEhMThRBC7Ny5UwAQf/zxR777PHz4sNkmfHFxcSZNrx8/fpynuWZR/PnnnwKAeP3114u8DnI1KR8xYoTZpnS5m1stXbpUABD379/Pd9v5NSnX6XQiJCREREREmLym6enpIigoSDz11FN59pu7iV1xHD16VMhkMjF9+nSTcsP/oyhN6jt16iQAiK1btxrLLly4IAAIuVwufvvtN2P5/v37zR732rVrhaOjo0hPTze7j/v374tatWqJJk2aFNhsXgj9/wlAobdOnToVemwG9erVM67n4uIipk2bJrRarfH5JUuWCACiSpUqonXr1mLLli1i5cqVwtfXV3h6eprtPmKwfPlyAUDs27evyPEIIYRSqTTGVKVKFfF///d/eZZp166dWLZsmdi9e7f45JNPROPGjQUAsXLlygK3/eabbwoA4u+//7bYNoUQYtq0aQJAnq4NQvz3GVbYLec5OX78eKFQKMzuy8fHRwwZMqTAeBo1aiS6du2ap/zcuXMCgFi1apUQ4r8mxjk/Xw0GDRok/Pz8rLpNg7FjxwpHR0eTMsPnQH7Nn3MKCAgQ3bt3F/fv3xf3798XZ8+eFcOGDSuwm0BJmpSX5H9ZFAXF0rt3b+Hh4ZGnu1BYWJgAID788MMi76ewJuUvvfSSmDVrlvj666/F559/Lvr06SMAiOeee67QbRs+Wxs3bmxsKi+EEEOHDhUymUz06NEjT/y5jzkgIECMGDHC+Lio38X5efHFF0WVKlXMPleU/2Puz/SePXuK2rVr59lWWlqaACCmTJlSYDzOzs7ipZdeylO+d+9eAUD88MMPQgghFi1aJACIGzdu5Fn2iSeeEG3bts1T3r179zzdJwzfGXFxcQXGZRATE2Ny7PXq1SvS+69v376FdkUw9x1o2N/nn39uLDP8z3P/VggLCxMymUy88sorxrLs7GxRs2ZNk+88w28rR0dHcevWLWO5oUvMG2+8YSzL/Rvn2rVrQqFQiLlz55rEefbsWWFnZ2csP336dJ6uAUQlwUHTqFiSk5MBwGxzZnP27dsHAIiKijIpf/PNN/Hhhx9i7969JgOcNGzYEB07djQ+9vHxQb169XD16lVjmYeHB86dO4fLly/nGX0YAB49eoQff/wRc+bMQUpKiklNZEREBGbOnInbt2+jRo0axvKxY8eaNKnu2LEjli5diuvXr6Np06bw8PAAAHz33Xdo1qxZkZoH5sfR0REODg44cuQIRo0aVeRameK+9qVhON7du3cjMjKyWIOoxMbG4vLly5g2bRoePnxo8ly3bt2wadMm6HQ6k22+8sorJYrz3r17eP755xEUFIS3337b5LnOnTubND8rjIuLi0mri3r16sHDwwM1atQwmYrFcD/nOQnoz/UuXbqYraHSarUYOnQoUlJS8OOPPxZaC/z222/jxRdfLDTm4tTorV+/HsnJybh69SrWr1+PjIwMaLVa4//BUBsrk8kQHR1tbLrfvHlzhIWFYcWKFXj//ffNbnvr1q3w8fEp9nQ833//PTIzM3H+/Hls3rwZaWlpeZbJXZPz0ksvoWXLlnjnnXcwcuRIs6+3TqfDl19+iebNm+epoSrpNg0ePnwIOzu7PF0bAGDLli0mMzQcOHAAixYtytPdpXbt2sb7GRkZcHBwMLsvlUpV6IwPGRkZUCqVZtc1PJ/zb37L5tyPNbZp4OnpiYyMDKSnpxtrbmfNmlWs6QsPHDiQZ8CzyMhILFq0qMjbKEzu/2V+cv4vS8tQozt48GDMnTsXzs7OWLlypXEWBkvO/rF27VqTx8OGDcPYsWOxZs0avPHGG2jbtm2h2xg+fLjJd2GbNm3wxRdfmHS1MpT/3//9H7Kzs02aTZtT2Hdxfh4+fJjv52Hu99/nn3+OAwcOYPPmzSbljRo1Mt4v6nsgP5Z6Dxm+93Py9PTMU/O9YcMGky5dhWnYsCEOHjyItLQ0/Prrrzh06FCRuhZ4eHjg1q1b+OOPP4zdynLL+fmZlZWF5ORk1KlTBx4eHjh16hSGDRtmsvyoUaNM/udt2rRBTEwMRo0aZSxTKBRo1aqV2VYe/fr1M/k917p1a7Rp0wb79u3DkiVLzMb4zTffQKfT4bnnnjPpdubn54eQkBAcPnwY77zzDtzd3QEA+/fvxzPPPGP8zCIqLibcVCyGJoa5m9Pm5/r165DL5ahTp45JuZ+fHzw8PHD9+nWTcnNNpT09PfH48WPj4zlz5qBv376oW7cuGjdujKeffhrDhg0zfhn/888/EEJg+vTpmD59utm47t27Z/IBnXu/hi9uw347deqEAQMGYPbs2Vi6dCk6d+6Mfv364fnnnzf7RVkQpVKJBQsW4M0334Svry/atm2LXr16Yfjw4fDz88t3veK+9qUxePBgfPbZZxg9ejSmTJmCbt26oX///hg4cGChybehn+GIESPyXSYpKcnkx1FQUFCxY0xLS0OvXr2QkpKCY8eOmU2AiqNmzZp5+rG7u7vD398/TxkAk3MyKysLBw8ezDPir8G0adPw448/Yu/evQgODi40loYNG6Jhw4bFPYQChYWFGe8PGTLEmIh++OGHAP77kdS7d2+T17Jt27YICgoyNqXO7erVq4iJicGECRMK/TGdm+FiW48ePdC3b180btwYLi4uBTYhdXBwwIQJE/DKK6/g5MmTZvv8//TTT7h9+zbeeOONIsVRlG0WRfv27U0e37p1CwAK7Ffq6OgIjUZj9rnMzMwCk3/D+ub62hv6phvWN/zNb9mc+7HGNg0MF8FKM0p5mzZt8P7770Or1eKvv/7C+++/j8ePH+d74aIkcv8vy0KPHj2wfPlyTJkyxdhloU6dOpg7dy7efvvtUn/GFebNN9/EmjVrcOjQoSIl3Lm/Nw2fjeY+M3U6HZKSklClSpVibTP3d3FB8rvAmvv9d+zYMahUqkLfl0V5D5R2/ZK+h0o7p7Sbm5vx+Pv27YutW7eib9++OHXqlMnMFblNnjwZhw4dQuvWrVGnTh10794dzz//vMn7JSMjA/Pnz8f69etx+/Ztk/+LuW5QxTmPzJ0H5ipe6tatm2cMiZwuX74MIYTZdQEYLyQFBQUhKioKS5YswZYtW9CxY0f06dMHL774ojFOoqJgwk3F4ubmhurVqxd5QAqDon45KBQKs+U5P7CffPJJXLlyBbt378aBAwfw2WefYenSpVi1ahVGjx4NnU4HAHjrrbcQERFhdnu5LwAUtl+ZTIYdO3bgt99+w7fffov9+/fjpZdewuLFi/Hbb7/BxcUl32M0Ny/2pEmT0Lt3b+zatQv79+/H9OnTMX/+fPz444/5Tk1Up04d2NnZ4ezZs2afL4qixujo6IijR4/i8OHD2Lt3L3744Qds27YNXbt2xYEDB/J9vQAYX/9FixblO11Y7h+Ohf14yU2j0aB///44c+YM9u/fj8aNGxdrfXPyO6ainJPHjh1DcnKysf92Trt27cKCBQvw3nvvFXmO26SkpCLVZjk4OBR7kCRA/yO2a9eu2LJlizHhNkwH5Ovrm2f5qlWr5vuD19D3+YUXXih2HDkFBwejefPm2LJlS6F9Ng0/xB49emT2+S1btkAulxdr2q7CtmlQpUoVZGdnIyUlxSKtTapVqwatVot79+6Z9O3XaDR4+PBhodM0VatWzWy/zbt37wL47/9qGODSUJ572Zz7scY2DR4/fmwcH6CkvL29jclCREQE6tevj169euGjjz7K05qqpO7fv2/2szs3FxcXiybCEyZMQGRkJM6cOWPsC2+oja5bt67F9mNOUd8DBqX5zCzuNgtbt0qVKkVKyouqWrVqOHz4cJ7kNvd7oKD183tf5Fw/53sod4J59+5ds32qHz9+nGfMktLq378/hg0bhi+//LLAhLtBgwa4ePEivvvuO/zwww/4+uuvsXLlSsyYMQOzZ88GoO+TvX79ekyaNAlhYWFwd3c3TqVm+H2QU3HOo+K0WiuITqeDTCbD999/b3Y/Od/TixcvxsiRI42/OSdOnIj58+fjt99+Q82aNS0SD1V8HDSNiq1Xr164cuVKoaN0AkBAQAB0Ol2e0VUTEhKQmJiIgICAEsXg5eWFyMhIfPHFF7h58yaaNm1qbJJoaOJnb2+P8PBws7eS/lBu27Yt5s6dixMnTmDLli04d+4cvvzySwD/XYnPPZBG7lp8g+DgYLz55ps4cOAA/vrrL2g0GixevDjffTs5OaFr1644evRonlE0i8rT09PsQB/mYpTL5ejWrRuWLFmCv//+G3PnzsWPP/6Iw4cPA8g/eTfU4BquoJu7laZJvk6nw/DhwxEdHY2tW7eiU6dOJd6WpezduxcNGzbMMwL8pUuXMGLECPTr169Yc6O//vrrqFatWqG3nCPgFldGRoZJbUPLli0BwGyidefOnXznLN66dSuCg4OLVCNW3JjyY2jOby4mtVqNr7/+Gp07dy7WnMIFbTOn+vXrA9CPVm4JhotShmbDBidOnIBOpyt0jvvQ0FBcunQpT9NTwyBGhvUbN24MOzu7PPvRaDSIjY012Y81tmkQFxdntpl/afTs2ROdOnXCvHnzzHZLKIknnniiSO9BwwUrS3J2dkZYWBhatmwJhUKBQ4cOGQcwtKaivgfKo/r16+Px48cWGzk6NDQU6enpeWa/yP0eKGj9U6dO5Ukwf//9dzg5ORkvnuT3/r9z5w5u3bpVZu8htVptbIVQGGdnZwwePBjr16/HjRs30LNnT8ydO9dYe79jxw6MGDECixcvNg6C26FDB6sNMmZu9P5Lly6ZnZHFIDg4GEIIBAUFmf2Nkvv7rEmTJpg2bRqOHj2Kn3/+Gbdv38aqVassfShUgTHhpmJ7++234ezsjNGjR5sdBfLKlSv46KOPAMBY47ds2TKTZQz9agxTKBVH7n7BLi4uqFOnjrFJVtWqVdG5c2d8+umnZq8wF2WKkdweP36c58qq4YvQsN+AgAAoFAocPXrUZLmVK1eaPE5PT88zFVFwcDBcXV0LnYZp5syZEEJg2LBhZvtbnTx5Ehs3bsx3/eDgYCQlJeHMmTPGsrt37+YZud1cDUfu4zX0Q879JdqyZUsEBwfjww8/NBtjSV7/nF577TVs27YNK1euLDDhLM60YKW1b9++POdyamoqnn32WdSoUQMbN24sVhPAt99+GwcPHiz0VtAFGoN79+7lKbt27Rqio6NNRsatV68emjVrht27d5u8ZgcOHMDNmzfN9s8ubCRwQP95kHO6puzsbLM1UcePH8fZs2dNYjJ3rqSkpGDZsmXw9vY2XiTIad++fUhMTMy3xr0k28zJ0DQ/9w9kc0aOHFlojUzXrl3h5eWFTz75xKT8k08+gZOTk8l5ZW76rIEDB0Kr1WL16tXGMrVajfXr16NNmzbGWjN3d3eEh4dj8+bNJt1SNm3ahNTUVAwaNMiq2zQ4depUkWe4KI7Jkyfj4cOHWLNmjUW2t2XLliK9B4cPH26R/eXn119/xTfffINRo0aZNGEtzTRQycnJeb5rhBDGMRryaxlWnoWFhUEIUaSR3GfNmlXoFJx9+/aFvb29yfe3EAKrVq1CjRo1TM5hc/+LgQMHIiEhAd98842x7MGDB9i+fTt69+5t7IrWqFEj1K9fH6tXrzZpUfHJJ59AJpNh4MCBJnElJSXhypUrJX4PJSYmmj1nPvvsMwB5R0vPLffvLwcHBzRs2BBCCON2FQpFns+95cuXF6nFSEns2rXL5ELx8ePH8fvvv6NHjx75rtO/f38oFArMnj07T6xCCONxJicnIzs72+T5Jk2aQC6XF2vaTCI2KadiCw4OxtatWzF48GA0aNAAw4cPR+PGjaHRaPDrr79i+/btxvk1mzVrhhEjRmD16tVITExEp06dcPz4cWzcuBH9+vUzGTCtqBo2bIjOnTsb53Q9ceIEduzYYdIMdcWKFejQoQOaNGmCMWPGoHbt2khISEBMTAxu3bqFP//8s1j73LhxI1auXIlnn30WwcHBSElJwZo1a+Dm5ma8qODu7o5BgwZh+fLlkMlkCA4OxnfffZcn4bl06RK6deuG5557Dg0bNoSdnR127tyJhIQEs1Ol5dSuXTusWLEC48aNQ/369TFs2DCEhIQgJSUFR44cwZ49e/Id2ArQ992dPHkynn32WUycOBHp6en45JNPULduXZNp1ebMmYOjR4+iZ8+eCAgIwL1797By5UrUrFnT2L81ODgYHh4eWLVqFVxdXeHs7Iw2bdogKCgIn332GXr06IFGjRohMjISNWrUwO3bt3H48GG4ubnh22+/Ldbrb7Bs2TKsXLkSYWFhcHJyyjPozbPPPmu8EHD8+HF06dIFM2fOLNaATMUVFxeH8+fP50mYZs+ejb///hvTpk3LM2dqcHCwSZ/q3CzZh7tJkybo1q0bQkND4enpicuXL2Pt2rXIysrCBx98YLLs0qVLjbURL7/8MpKSkrBkyRLUrVsXr776ap5tb9myBUDBzckN03wZfuCmpqbC398fgwcPRqNGjeDs7IyzZ89i/fr1cHd3Nxl3YcWKFdi1axd69+6NWrVq4e7du1i3bh1u3LiBTZs2me2zu2XLFiiVSgwYMMBsPCXZZk61a9dG48aNcejQoTyDQ+3atatIAw81bdrUOOaEo6Mj3nvvPYwfPx6DBg1CREQEfv75Z2zevBlz58416TLw8ccfY/bs2Th8+DA6d+4MQN+fedCgQZg6dSru3buHOnXqYOPGjbh27VqegbHmzp2Ldu3aoVOnThg7dixu3bqFxYsXo3v37ibdHayxTUB/QfDRo0d55mo3TPWY87iKq0ePHmjcuDGWLFmC8ePHl6oVDWDZPtxnzpzBnj17AOjHGElKSjJ+Tjdr1gy9e/cGoG9p9Nxzz6FPnz7w8/PDuXPnsGrVKjRt2hTz5s0z2ebUqVOxceNGxMXFmdTkGbZrmEpz06ZNOHbsGAD9WBKA/qLH0KFDMXToUNSpUwcZGRnYuXMnfvnlF4wdO9bslHflXYcOHVClShUcOnQIXbt2NXku9/dEftq1a2dsIVezZk1MmjQJixYtQlZWFp544gns2rULP//8M7Zs2WLSDNnc/2LgwIFo27YtIiMj8ffff8Pb2xsrV66EVqs1Nr02WLRoEfr06YPu3btjyJAh+Ouvv/Dxxx9j9OjReWqyDx06BCFEnvfQyJEjzZ4PuR05cgQTJ07EwIEDERISAo1Gg59//hnffPMNWrVqVehgnd27d4efnx/at28PX19fnD9/Hh9//DF69uxpbDnYq1cvbNq0Ce7u7mjYsCFiYmJw6NChQvvvl1SdOnXQoUMHvPrqq1Cr1Vi2bBmqVKmSZyDVnIKDg/H+++9j6tSpuHbtGvr16wdXV1fExcVh586dGDt2LN566y38+OOPmDBhAgYNGoS6desiOzsbmzZtgkKhyPc7hsisshoOnSqeS5cuiTFjxojAwEDh4OAgXF1dRfv27cXy5ctNpuPKysoSs2fPFkFBQcLe3l74+/uLqVOnmiwjhH6aEHPTfXXq1MlkKoj3339ftG7dWnh4eAhHR0dRv359MXfuXJPpSYQQ4sqVK2L48OHCz89P2Nvbixo1aohevXqJHTt2GJcxTEuRe7qv3FN8nTp1SgwdOlTUqlVLKJVKUbVqVdGrVy9x4sQJk/Xu378vBgwYIJycnISnp6d4+eWXxV9//WUy5ciDBw/E+PHjRf369YWzs7Nwd3cXbdq0EV999VWRX/uTJ0+K559/XlSvXl3Y29sLT09P0a1bN7Fx40aTqZ5gZtqaAwcOiMaNGwsHBwdRr149sXnz5jxTZkRHR4u+ffuK6tWrCwcHB1G9enUxdOhQcenSJZNt7d69WzRs2FDY2dnlmVbl9OnTon///qJKlSpCqVSKgIAA8dxzz4no6GjjMob9FjT9WE6FTZeVc0qU4k4LZm6qk/zOSeSYgujjjz8W7u7uIisrq8ix5pwOx9pmzpwpWrVqJTw9PYWdnZ2oXr26GDJkiDhz5ozZ5Q8ePCjatm0rVCqV8PLyEsOGDRN3797Ns5xWqxU1atQQLVq0KHD/AQEBJlMCqdVq8frrr4umTZsKNzc3YW9vLwICAsSoUaPyTGlz4MAB8dRTTxnfwx4eHqJ79+4m51BOSUlJQqVSif79++cbT3G3ac6SJUuEi4tLnulvSjOV1OrVq0W9evWEg4ODCA4OFkuXLjWZKkeI/KfPysjIEG+99Zbw8/MTSqVSPPHEE8Zph3L7+eefRbt27YRKpRI+Pj5i/PjxeaahstY2J0+eLGrVqpXnuN58800hk8nE+fPnzW4/p/zek0IIsWHDhjyfQ0KUbFowSzJ8zxT2WfDo0SPRt29f4efnJxwcHERQUJCYPHmy2dcyv2mgCjrvDK5evSoGDRokAgMDhUqlEk5OTqJly5Zi1apVef435hg+W3NPlZTf96m5z/n8pgUr7Lu4IBMnThR16tTJU16U96S580ar1Yp58+aJgIAA4eDgIBo1aiQ2b96cZ/v5/S8ePXokRo0aJapUqSKcnJxEp06d8p1adOfOnSI0NFQolUpRs2ZNMW3atDy/aYQQYvDgwaJDhw55ygcMGCAcHR3F48eP83+BhBD//POPGD58uKhdu7ZwdHQUKpVKNGrUSMycObPQ6SqFEOLTTz8VTz75pPF7PTg4WPzvf/8TSUlJxmUeP34sIiMjhbe3t3BxcRERERHiwoULRf6f5/e7YMSIEcLZ2dn42DAt2KJFi8TixYuFv7+/UCqVomPHjuLPP/80u83cvv76a9GhQwfh7OwsnJ2dRf369cX48ePFxYsXhRD698pLL70kgoODjd+JXbp0EYcOHSr0tSLKSSaEhUYgICKqhJ555hm4uLgUOCIqVSxJSUmoXbs2Fi5caDJ1DeVPrVYjMDAQU6ZMweuvv27yXOvWrREQEIDt27dLFB1VBFevXkX9+vXx/fffG1vWVCTx8fEICgrCl19+maeG29fXF8OHD7fo1Hjl3bVr1xAUFIRFixbhrbfekjocogKxDzcRUSl07ty5yNNPUcXg7u6Ot99+G4sWLTI76i7ltX79etjb2+OVV14xKU9OTsaff/6JOXPmSBQZVRS1a9fGqFGj8nSVqSiWLVuGJk2a5Em2z507h4yMDEyePFmiyIioMKzhJiIiIiIim8EabrIlrOEmIiIiIiIisgLWcBMRERERERFZAWu4iYiIiIiIiKyACTcRERERERGRFTDhJiIiIiIiIrICJtxEREREREREVsCEm4iIiIiIiMgKmHBXcJs2bUL9+vVhb28PDw8PqcOxiFmzZkEmk5mUBQYGYuTIkdIEZGUbNmyATCbDtWvXpA6FiIiIiIiKgQl3CRmSoPxuv/32m3FZmUyGCRMm5NlGcnIyZs+ejWbNmsHFxQWOjo5o3LgxJk+ejDt37pQ6xgsXLmDkyJEIDg7GmjVrsHr16lJvk4iIiIiIiIrGTuoAbN2cOXMQFBSUp7xOnToFrnf16lWEh4fjxo0bGDRoEMaOHQsHBwecOXMGa9euxc6dO3Hp0qVSxXbkyBHodDp89NFHhcZDRERERERElsWEu5R69OiBVq1aFWud7Oxs9O/fHwkJCThy5Ag6dOhg8vzcuXOxYMGCUsd27949ACi0KbkQApmZmXB0dCz1PomIiIiIiEiPTcol8PXXX+PPP//Eu+++myfZBgA3NzfMnTvX+Pjy5csYMGAA/Pz8oFKpULNmTQwZMgRJSUn57iMwMBAzZ84EAPj4+EAmk2HWrFnG53r16oX9+/ejVatWcHR0xKeffgpAX/M+aNAgeHl5wcnJCW3btsXevXtNtn3kyBHIZDJ89dVXmD17NmrUqAFXV1cMHDgQSUlJUKvVmDRpEqpWrQoXFxdERkZCrVYX+rr8/PPPGDRoEGrVqgWlUgl/f3+88cYbyMjIKHTdovryyy/RsmVLuLq6ws3NDU2aNMFHH31kfP7Ro0d466230KRJE7i4uMDNzQ09evTAn3/+afHXwNDVYMuWLahXrx5UKhVatmyJo0ePFulYvv/+e3Ts2BHOzs5wdXVFz549ce7cOZNl4uPjERkZiZo1a0KpVKJatWro27cv+4MTEREREZUB1nCXUlJSEh48eGBSJpPJUKVKlXzX2bNnDwBg2LBhhW5fo9EgIiICarUar732Gvz8/HD79m189913SExMhLu7u9n1li1bhs8//xw7d+7EJ598AhcXFzRt2tT4/MWLFzF06FC8/PLLGDNmDOrVq4eEhAS0a9cO6enpmDhxIqpUqYKNGzeiT58+2LFjB5599lmTfcyfPx+Ojo6YMmUK/vnnHyxfvhz29vaQy+V4/PgxZs2ahd9++w0bNmxAUFAQZsyYUeCxbt++Henp6Xj11VdRpUoVHD9+HMuXL8etW7ewffv2Ql+rwhw8eBBDhw5Ft27djC0Izp8/j19++QWvv/46AP0Fh127dmHQoEEICgpCQkICPv30U3Tq1Al///03qlevbtHX4KeffsK2bdswceJEKJVKrFy5Ek8//TSOHz+Oxo0b53ssmzZtwogRIxAREYEFCxYgPT0dn3zyCTp06IDTp08jMDAQADBgwACcO3cOr732GgIDA3Hv3j0cPHgQN27cMC5DRERERERWIqhE1q9fLwCYvSmVSpNlAYjx48cbHzdv3ly4u7sXaT+nT58WAMT27duLHePMmTMFAHH//n2T8oCAAAFA/PDDDyblkyZNEgDEzz//bCxLSUkRQUFBIjAwUGi1WiGEEIcPHxYAROPGjYVGozEuO3ToUCGTyUSPHj1MthsWFiYCAgIKjTc9PT1P2fz584VMJhPXr1/Pc1y5j2nEiBEFbv/1118Xbm5uIjs7O99lMjMzjcdpEBcXJ5RKpZgzZ46xzBKvgeF8OXHihLHs+vXrQqVSiWeffdZYZjjX4uLihBD6/4mHh4cYM2aMyfbi4+OFu7u7sfzx48cCgFi0aFEBrwoREREREVkLm5SX0ooVK3Dw4EGT2/fff1/gOsnJyXB1dS3S9g012Pv370d6enqp4zUICgpCRESESdm+ffvQunVrk2buLi4uGDt2LK5du4a///7bZPnhw4fD3t7e+LhNmzYQQuCll14yWa5Nmza4efMmsrOzC4wpZx/ytLQ0PHjwAO3atYMQAqdPny72Mebm4eGBtLQ0HDx4MN9llEol5HL920Kr1eLhw4dwcXFBvXr1cOrUqTzLl/Y1CAsLQ8uWLY2Pa9Wqhb59+2L//v3QarVmYzx48CASExMxdOhQPHjwwHhTKBRo06YNDh8+DED/ejo4OODIkSN4/PhxIa8OERERERFZGhPuUmrdujXCw8NNbl26dClwHTc3N6SkpBRp+0FBQYiKisJnn30Gb29vREREYMWKFQX23y7qdnO7fv066tWrl6e8QYMGxudzqlWrlsljw8UBf3//POU6na7QmG/cuIGRI0fCy8sLLi4u8PHxQadOnQCg1McLAOPGjUPdunXRo0cP1KxZEy+99BJ++OEHk2V0Oh2WLl2KkJAQKJVKeHt7w8fHB2fOnDEbQ2lfg5CQkDzbrFu3LtLT03H//n2zx3H58mUAQNeuXeHj42NyO3DggHGwPKVSiQULFuD777+Hr68vnnzySSxcuBDx8fEFvUxERERERGQhTLglUL9+fSQlJeHmzZtFWn7x4sU4c+YM3nnnHWRkZGDixIlo1KgRbt26VeIYLDEiuUKhKFa5ECLfbWm1Wjz11FPYu3cvJk+ejF27duHgwYPYsGEDAH0iXFpVq1ZFbGws9uzZgz59+uDw4cPo0aMHRowYYVxm3rx5iIqKwpNPPonNmzdj//79OHjwIBo1amQ2Bku+BkVliGPTpk15WlccPHgQu3fvNi47adIkXLp0CfPnz4dKpcL06dPRoEEDi7QYICIiIiKignHQNAn07t0bX3zxBTZv3oypU6cWaZ0mTZqgSZMmmDZtGn799Ve0b98eq1atwvvvv2+xuAICAnDx4sU85RcuXDA+by1nz57FpUuXsHHjRgwfPtxYXlDz75JwcHBA79690bt3b+h0OowbNw6ffvoppk+fjjp16mDHjh3o0qUL1q5da7JeYmIivL29LRoL8F9tdU6XLl2Ck5MTfHx8zK4THBwMQH8BITw8vNB9BAcH480338Sbb76Jy5cvIzQ0FIsXL8bmzZtLFzwRERERERWINdwSGDhwIJo0aYK5c+ciJiYmz/MpKSl49913Aej7e+fu99ukSRPI5fIiTbVVHM888wyOHz9uElNaWhpWr16NwMBANGzY0KL7y8lQI5yzBlgIYTJlV2k9fPjQ5LFcLjeO3G54LRUKRZ5a6O3bt+P27dsWiyOnmJgYk77hN2/exO7du9G9e/d8a8kjIiLg5uaGefPmISsrK8/zhqbo6enpyMzMNHkuODgYrq6uFj93iIiIiIgoL9Zwl9L3339vrAHOqV27dqhdu7bZdezt7fHNN98gPDwcTz75JJ577jm0b98e9vb2OHfuHLZu3QpPT0/MnTsXP/74IyZMmIBBgwahbt26yM7OxqZNm6BQKDBgwACLHsuUKVPwxRdfoEePHpg4cSK8vLywceNGxMXF4euvvzYOJmYN9evXR3BwMN566y3cvn0bbm5u+Prrry062Nfo0aPx6NEjdO3aFTVr1sT169exfPlyhIaGGvup9+rVC3PmzEFkZCTatWuHs2fPYsuWLfn+L0urcePGiIiIMJkWDABmz56d7zpubm745JNPMGzYMLRo0QJDhgyBj48Pbty4gb1796J9+/b4+OOPcenSJXTr1g3PPfccGjZsCDs7O+zcuRMJCQkYMmSIVY6HiIiIiIj+w4S7lPKbW3r9+vUFJml16tRBbGwsli5dip07d2LXrl3Q6XSoU6cORo8ejYkTJwIAmjVrhoiICHz77be4ffs2nJyc0KxZM3z//fdo27atRY/F19cXv/76KyZPnozly5cjMzMTTZs2xbfffouePXtadF+52dvb49tvv8XEiRON/Y2fffZZTJgwAc2aNbPIPl588UWsXr0aK1euRGJiIvz8/DB48GDMmjXLeDHhnXfeQVpaGrZu3Ypt27ahRYsW2Lt3L6ZMmWKRGHLr1KkTwsLCMHv2bNy4cQMNGzbEhg0bTOZMN+f5559H9erV8cEHH2DRokVQq9WoUaMGOnbsiMjISAD6gduGDh2K6OhobNq0CXZ2dqhfvz6++uori1+sISIiIiKivGTCEqM4EVGxyWQyjB8/Hh9//LHUoRARERERkRWwDzcRERERERGRFTDhJiIiIiIiIrICJtxEREREREREVsBB04gkwuETiIiIiIgqNtZwExEREREREVkBE24iIiIiIiIiK2DCTURERERERGQFTLiJiIiIiIiIrIAJdykcPXoUvXv3RvXq1SGTybBr165ib2P//v1o27YtXF1d4ePjgwEDBuDatWsWj5WIiIiIiIjKFhPuUkhLS0OzZs2wYsWKEq0fFxeHvn37omvXroiNjcX+/fvx4MED9O/f38KREhERERERUVmTCc5NZBEymQw7d+5Ev379jGVqtRrvvvsuvvjiCyQmJqJx48ZYsGABOnfuDADYsWMHhg4dCrVaDblcf+3j22+/Rd++faFWq2Fvby/BkRAREREREZElsIbbiiZMmICYmBh8+eWXOHPmDAYNGoSnn34aly9fBgC0bNkScrkc69evh1arRVJSEjZt2oTw8HAm20RERERERDaONdwWkruG+8aNG6hduzZu3LiB6tWrG5cLDw9H69atMW/ePADATz/9hOeeew4PHz6EVqtFWFgY9u3bBw8PDwmOgoiIiIiIiCyFNdxWcvbsWWi1WtStWxcuLi7G208//YQrV64AAOLj4zFmzBiMGDECf/zxB3766Sc4ODhg4MCB4HUQIiIiIiIi22YndQAVVWpqKhQKBU6ePAmFQmHynIuLCwBgxYoVcHd3x8KFC43Pbd68Gf7+/vj999/Rtm3bMo2ZiIiIiIiILIcJt5U0b94cWq0W9+7dQ8eOHc0uk56ebhwszcCQnOt0OqvHSERERERERNbDJuWlkJqaitjYWMTGxgLQT/MVGxuLGzduoG7dunjhhRcwfPhwfPPNN4iLi8Px48cxf/587N27FwDQs2dP/PHHH5gzZw4uX76MU6dOITIyEgEBAWjevLmER0ZERERERESlxUHTSuHIkSPo0qVLnvIRI0Zgw4YNyMrKwvvvv4/PP/8ct2/fhre3N9q2bYvZs2ejSZMmAIAvv/wSCxcuxKVLl+Dk5ISwsDAsWLAA9evXL+vDISIiIiIiIgtiwk1ERERERERkBWxSTkRERERERGQFTLiJiIiIiIiIrIAJdzEJIZCcnMx5somIiIiIiCoYS+d7nBasmJKTk+Hh4YGbN2/Czc1N6nCIiIiIiIjIQpKTk+Hv74/ExES4u7uXentMuIspJSUFAODv7y9xJERERERERGQNKSkpTLil4OrqCgCs4SYiIiIi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UtKd85b4pUft2rXN/aR2mP/Vq1fVt29fBQcHy8XFRf7+/qpfv77V8OkNGzaodevWKlCggFxcXBQYGKg333xTN27csNpW586d5enpqZMnT+q5556Tp6en8uXLp6+++kqStGfPHtWpU0ceHh4KCgpKMgojsQ2sX79eL7/8snx9feXl5aVOnTopIiLigccSGxurQYMGqUiRImad7733nmJjY1N1LhLNnz9fAQEBatmypTnNz89Pbdq00aJFi6y2FxgYKAcHfmUBAADgycFfr48oODhYVatW1f/+9z9z2m+//aaoqCi1bds2yfJBQUGSpO+//94qmNrCpk2b5Ovra+7zXr1799b+/fs1cOBA9e/fX5K0detWbdq0SW3bttW4cePUq1cvrVq1SrVq1dL169clST4+PipdurTWr19vbmvjxo2yWCy6cuWK9u/fb07fsGGDatSoYbXfkiVLys3NTX/88Ue6HesXX3yh/Pnzq3jx4vrhhx/0ww8/6IMPPpAkXb9+XWFhYZo5c6Y6deqkcePG6ZlnntGAAQP01ltvmdtYsWKF2rVrpxw5cmjkyJEaMWKEatWqZdZZs2ZNvfHGG5Kk999/39xPiRIlUlVjr169NGnSJL3wwguaOHGi3nnnHbm5uenAgQPmMvPmzdP169f1yiuvaPz48WrYsKHGjx+vTp06JdlefHy8GjVqpMDAQI0aNUrBwcF67bXXNH36dD377LOqWLGiRo4cqezZs6tTp046duxYkm289tprOnDggAYPHqxOnTpp1qxZatGiRYptMyEhQc2aNdOYMWPUtGlTjR8/Xi1atNDYsWP14osvpupcJNqxY4fKly+fJEhXrlxZ169ft7rEAgAAAHjiGHgo06ZNMyQZW7duNSZMmGBkz57duH79umEYhtG6dWujdu3ahmEYRlBQkNGkSRNzvevXrxvFihUzJBlBQUFG586djSlTphjnz59PcX/z5s0zJBlr1qxJdY3Vq1c3KlSocN/aq1evbty+fdtqXuIx3G3z5s2GJOP77783p7366qtGQECA+f6tt94yatasafj7+xuTJk0yDMMwLl++bFgsFuPLL79Mss2nnnrKaNSoUaqPJTVKlSplhIWFJZk+dOhQw8PDw/jnn3+spvfv399wdHQ0Tp48aRiGYfTp08fw8vJKck7u9jCfQyJvb2/j1VdfTXGZ5M7/8OHD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kMTLAe79OchoCxYskKurq5YvX66uXbuqUaNGj3QH+kQpfXaOjo5q37695s+fr4iICC1cuFDt2rW775cIKQkJCdH169eTDM9P/CIjJCQk1duaPXu2ChcurCpVqiSZd/78eUl3hr/fzTAMxcfHJztU/F43btyQYRhWlyZI//e0gXtv/AYAAPAkIXCnE09PT02aNEmDBw9W06ZN77vcrl27kr0m8cSJE9q/f7/VsN9r166pcePGOn36tH799dckw3zTqnbt2ho6dKgmTJig3LlzP9K2HiRPnjwKCQnRjBkzrB75s3fvXv3+++9q3LixTff/qDUlBu/kHleUnKpVqyoiIuKhrhVOq7TW5ujoqAYNGmjRokVWQ8LPnz+v2bNnq3r16laPo5Okb775xuqRX5MmTdLt27fVqFEjq+XatWsni8WiPn366OjRoynenTzR9evX7/tYqt9++01S8sP+M5Kjo6MsFotVkDx+/LgWLlz4SNt90GfXsWNHRURE6OWXX1ZMTEyS85nax4I1b95cTk5OmjhxojnNMAx9/fXXypcvn6pVq2ZOP3v2rA4ePJjsI9527NihAwcO3PdLxKeeekqSNGfOHKvpixcv1rVr1xQaGmpOu3DhQpL1IyMjtWDBAgUGBlo9Dk6Stm3bJovFoqpVqz7weAEAAB5X9huDmwmFh4c/cJkVK1Zo0KBBatasmapUqSJPT08dPXpUU6dOVWxsrPnMXunOEM6//vpLXbt21YEDB6x6qzw9PdWiRYs01efg4KAPP/wwTes8itGjR6tRo0aqWrWqunXrphs3bmj8+PHy9va2Os6MlNqaKlSoIEn64IMP1LZtWzk5Oalp06b37ZVv0qSJsmXLppUrV6pnz55J5m/YsCHZ65LLli2rsmXLpukY0lqbJH3yySdasWKFqlevrt69eytbtmyaPHmyYmNjNWrUqCTL37p1S3Xr1lWbNm106NAhTZw4UdWrV08ydNjPz0/PPvus5s2bJx8fHzVp0uSB9V+/fl3VqlVTlSpV9OyzzyowMFCRkZFauHChNmzYoBYtWlgFNXto0qSJPv/8cz377LNq3769Lly4oK+++kpFihTR7t27H3q7D/rsQkNDVbp0ac2bN08lSpRI8ti+xMdnPejGafnz51ffvn01evRoxcXFqVKlSub5nTVrllWv+YABAzRjxgwdO3YsyQiGxEtc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aG3ZskVVq1aVJFWtWlWRkZHatm2buczq1auVkJCgp59+OsNrBgAAGeunn35ShQoVFBISouLFi6tOnToZdn358ePH5ePjk+b1Bg8eLIvFop9//tmcZhiGChYsaLW9lI6te/fuKlasmMqVK6dnnnlGW7dufdTDAQCkwhPVwx0TE6MjR46Y748dO6adO3cqZ86cKlCggPr27atPPvlERYsWVcGCBfXRRx8pb968atGihSSpRIkSevbZZ9WjRw99/fXXiouL02uvvaa2bdtyh3IAADK5s2fPqmfPntq2bZuCgoIkSdu3b38ietwqVKigqVOnmjd4XbVqlXLlyqWIiAhJDz625s2b67vvvpOTk5OWLFmi1q1b6/jx43Y5FgDISp6oHu6///5boaGhCg0NlSS99dZbCg0N1cCBAyVJ7733nl5//XX17NlTlSpVUkxMjJYtW2Y1Dn/WrFkqXry46tatq8aNG6t69er65ptv7HI8AAAg45w/f16Ojo7KmTOnOa18+fJmKH3nnXdUqVIlhYSEqGbNmjp06JC5nMVi0aeffqqnn35awcHBWrhwoYYPH66KFSuqaNGiWrt2raT/68V+5513VLZsWZUqVUorV65Mtp6tW7eqTp06qlixokJDQzVv3rz71l69enX9+++/5k1ep06dqq5du6b62Jo2baps2e70s1SpUkWnT5/W7du303L6AAAP4Ynq4a5Vq5YMw7jvfIvFoo8//lgff/zxfZfJmTOnZs+ebYvyAADAA9jqAR+pucdN2bJlVb16dQUFBSksLEzVqlVT+/btlS9fPklSv379NGbMGEnSnDlz1KdPHy1btsxc39PTU1u2bNGqVavUvHlzTZgwQX///bfmzZund9991xymHRUVpRIlSmjMmDH6888/1axZM/37779WtURGRqpnz5769ddflSdPHl26dEnly5dXtWrVzHru9dJLL2nGjBl6+eWXtXXrVn3yyScaMGBAqo7tbl9++aUaN25sBnAAgO3wmxYAAGSY1q1ts91ffnnwMg4ODlqwYIEOHjyodevW6bffftOnn36qv//+W0WKFNGKFSs0fvx4Xb161Xyk2d1efPFFSVLFihV17do1tW3bVpJUuXJlHT582FwuW7Zs6ty5s6Q7vcl58+bVjh07VKBAAXOZTZs26ejRo2rUqJHVPg4dOnTfwB0eHq769evL09NT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ShEREapUqVKy+1m7dm2ah4ovXbpUCxYs0I4dO+Tp6amuXbtazY+KilLZsmU1ZMgQLVu2TH369FHjxo114cIFtW/fXmvXrlWZMmU0a9YstWrVSvv27dO6dev08ccfa9OmTcqTJ4/Z63X06FENHjxYy5cvl5eXl44cOaIaNWqYIevAgQOaOHGipkyZYtaWaPjw4frnn3+0bds2ubi46OLFi8keT79+/cwAO2fOHPXp00fLli3TlStX1KJFC82fP181atRQQkKCIiMjk6zfsWNH9erVS926ddOePXtUsWJFq5BbtWpVLV68WK+88kqazvP9PP3002YP4d9//62BAwcm2+O+fft21a9fP8n0qlWr6vfff7eatmfPHvXu3VvLli0z2/TdBg0apCtXrujnn39+4PbTIkeOHCpatKj27dunNm3aKH/+/Fq6dKlatGihP//8U5GRkWrUqJGkO4F72rRp+vDDD7VmzRq1adNGTk5OWrNmjV566SX98ccf+vbbbx+4z0OHDln1em7bts3sof7ss89UqFAhlSlTRi+//HKK23FyclJoaKj27dunJk2apLjszJkz5efnp82bNz/SEOd7a797iH2iF198UW5ubpLufG6Jo2BmzZplfmERGhqqadOmSbrzu+nubf7yyy/y8PDQsmXLNGnSJJ08eVJNmjTRkCFD5ODgoO3bt+uFF15Ist+qVatq27Zt6tq1q7766iuVL19e8fHx+uuvv8xl+vXrp27duunLL79UlSpV1Lx5c9WsWdOcP3v2bDVs2FC5c+fWSy+9pKlTp6Z4SUbu3Ln18ssva9CgQan67DMaPdwAAAD3aNOmjbJnzy5HR0d169ZNK1euNOe1bt3aDNvSnT8OK1asqNKlS+u7776zGpbp5OSkl156yXzfu3dvzZgxQ7GxsZo3b54qV66soKCg+9ZRrVo1nTx5UsuXL5ejo6OKFStmNf/tt9/W0aNH1b17d125ckU1atSwCuTSnRDVo0cPzZkzRx4eHsnu57///lNAQECqzk2iVatWmefCYrFY9XBKkqurq1q2bCnpzh/h//77r6Q7X2YkXrsp3RkhcObMGZ0+fVpLly5Vx44dlSdPHkl3evnd3d21bNkyHTlyRDVr1lRISIhatWolBwcHnTx5UpJUqFAhhYWFJVvnkiVL1KdPH3Noc3JBUpJWrFihqlWrqnTp0vr4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81O4UUb9+vX17LPPasOGDVq/fr2uu+469ezZU19++aWkU4G+/vrr1a1bN3322Wdat26dHnzwQXk8p+KUnp6unJycUre7775bqampatu27Xmff+bMmbIsy9F9RGhzK4Pn2y4qFrdyOHDgQG3dulUffvih/vOf/6h3797q16+fPv/883LZb4SOi82gJN188806efKkPv30U23YsEEtW7bUzTffrNzc3LM+73PPPaff//73euWVV7R27VpVq1ZN3bt3V2FhoeP7jNDjRg6PHTumrKwsPf7448rKytLChQu1detW9ejRo1z2GaHFrffC095//32tWbNGdevWvbAdMIAfatasaV577TVjjDHt2rUzEyZM8Pt3i4uLjdfrNZMnTz7vup9//rmpV6+eycnJMZLM+++/f6EjI8KURwYD3S4qnvLIYbVq1cz8+fNLLatVq5b505/+FPjAiDiBZDAvL89IMv/61798ywoKCowkk5GRccbfsW3bJCcnm+eff9637NChQyYmJsb89a9/DdJeINw5ncMz+eyzz4wks2vXrgsfHBGjvDK4Z88eU69ePbNp0ybToEEDM2PGjIBn5bANzqmkpERvvvmmjh49qrS0NB04cEBr165VYmKi0tPTlZSUpI4dO2rVqlVn3caHH36o77//XkOGDDnncx07dkz9+/fXSy+9pOTk5GDvCsJUeWXwQraLiqM83wvT09P11ltv6YcffpBt23rzzTdVWFioTp06BXmvEE4uJIO1a9dWkyZNNH/+fB09elQnT57Uq6++qsTERLVp0+aMz7Nz507l5uaqa9euvmUJCQlq166dMjMzHd9PhLbyyuGZ5Ofny7Is1ahRw4E9Q7gozwzatq0BAwZozJgxuuKKKy586IArOiqEjRs3mmrVqplKlSqZhIQE849//MMYY0xmZqaRZGrVqmVmz55tsrKyzIgRI0x0dLT5+uuvz7itG264wdxwww3nfc57773X3HXXXb774gh3hVbeGbyQ7SLyufFeePDgQdOtWzcjyVSuXNnEx8ebTz75JKj7hfBxsRn87rvvTJs2bYxlWaZSpUqmTp06Jisr66zP9+9//9tIMvv27Su1/NZbbzX9+vVzZicR8so7h//r+PHj5qqrrjL9+/cP+r4hPLiRwWeeecb88pe/NLZtG2PMBR/hpnDjjIqKisy2bdvM+vXrzbhx48wl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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# View an example consensus mass feature with MS2 mirror plot\n", + "# Note that for this example, the EICs overlap because the samples are replicates.\n", + "lcms_collection.plot_cluster(\n", + " cluster_id=2, \n", + " to_plot=[\"EIC\", \"MS1\", \"MS2_mirror\"], # Default is MS2 without a mirror\n", + " molecular_metadata=molecular_metadata,\n", + " spectral_library=spectral_library\n", + " # Needs the molecular_metadata object generated alongside the ms2 database above to plot the library match\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "b7554b67", + "metadata": {}, + "source": [ + "## Step 9: Export Collection Results\n", + "\n", + "Export the collection to HDF5 for later reloading, and save tables to CSV." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "7dc0e769", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Collection exported to tutorial_collection_data/collection_export.hdf5\n" + ] + } + ], + "source": [ + "# Ensure output directory exists\n", + "processed_folder.mkdir(parents=True, exist_ok=True)\n", + "\n", + "# Export collection to HDF5\n", + "collection_save_path = processed_folder / \"collection_export\"\n", + "exporter = LCMSCollectionExport(\n", + " out_file_path=str(collection_save_path),\n", + " mass_spectra_collection=lcms_collection\n", + ")\n", + "exporter.export_to_hdf5(overwrite=True, save_parameters=True)\n", + "\n", + "print(f\"✓ Collection exported to {collection_save_path}.hdf5\")" + ] + }, + { + "cell_type": "markdown", + "id": "375abdcc", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This tutorial demonstrated:\n", + "\n", + "1. ✓ **Sample Preparation**: Processing and exporting individual LC-MS samples to HDF5\n", + "2. ✓ **Collection Loading**: Loading multiple samples as a collection with metadata\n", + "3. ✓ **RT Alignment**: Aligning retention times across samples\n", + "4. ✓ **Consensus Features**: Clustering features across samples to identify common entities\n", + "5. ✓ **Gap Filling**: Searching for missing features in raw data\n", + "6. ✓ **Pivot Tables**: Creating sample × feature matrices for comparison\n", + "7. ✓ **Cluster Representatives**: Identifying best representative features\n", + "8. ✓ **Molecular Annotations**: Adding MS1 formula search and MS2 spectral matching\n", + "9. ✓ **Export**: Saving collection data\n", + "\n", + "### Key Takeaways\n", + "\n", + "- **Collections enable cross-sample analysis** - Identify features present across multiple samples and detect missing features\n", + "- **Gap filling improves coverage** - Recover features that were missed during initial peak picking\n", + "- **Consensus features reduce redundancy** - Group equivalent features across samples\n", + "- **Representative features simplify analysis** - Work with one feature per cluster instead of all variants\n", + "- **Integrated pipeline is efficient** - `process_consensus_features()` combines multiple operations in one pass and can leverage multicore processing" + ] + }, + { + "cell_type": "markdown", + "id": "42cb004b", + "metadata": {}, + "source": [ + "## Cleanup\n", + "\n", + "Remove tutorial data files." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "3fb1fb6f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Cleaned up tutorial data from tutorial_collection_data\n" + ] + } + ], + "source": [ + "# Clean up tutorial data\n", + "if processed_folder.exists():\n", + " shutil.rmtree(processed_folder)\n", + " print(f\"✓ Cleaned up tutorial data from {processed_folder}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.10.18)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/LCMS_Targeted_Search_Tutorial.ipynb b/examples/notebooks/LCMS_Targeted_Search_Tutorial.ipynb new file mode 100644 index 000000000..d704c4180 --- /dev/null +++ b/examples/notebooks/LCMS_Targeted_Search_Tutorial.ipynb @@ -0,0 +1,857 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a93184eb", + "metadata": {}, + "source": [ + "# LC-MS Targeted Search\n", + "## Finding Specific Compounds by m/z and Retention Time\n", + "\n", + "This notebook demonstrates how to perform targeted searches in LC-MS data using CoreMS. Targeted searches allow you to specifically look for compounds with known m/z and retention time values, such as internal standards or specific metabolites of interest.\n", + "\n", + "### Workflow Overview\n", + "1. Load LC-MS data from Thermo RAW files\n", + "2. Configure parameters for targeted search\n", + "3. Define target compounds (m/z and retention time)\n", + "4. Perform targeted peak picking\n", + "5. Integrate and characterize target peaks\n", + "6. Associate MS2 data with targets\n", + "7. Visualize and export results\n", + "\n", + "### Use Cases\n", + "- **Internal Standards**: Verify presence and quantify internal standards\n", + "- **Known Metabolites**: Target specific metabolites in complex samples\n", + "- **Quality Control**: Monitor specific compounds across batches\n", + "- **Spike-in Recovery**: Track spiked compounds through sample processing\n", + "\n", + "### Data Format\n", + "This tutorial uses Thermo Fisher RAW format LC-MS data files. The targeted search approach is ideal when you have prior knowledge of expected compounds." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "506eb64c", + "metadata": {}, + "outputs": [], + "source": [ + "# Import required packages\n", + "import numpy as np\n", + "import shutil\n", + "from pathlib import Path\n", + "\n", + "from corems.mass_spectra.input.rawFileReader import ImportMassSpectraThermoMSFileReader\n", + "from corems.mass_spectra.output.export import LCMSMetabolomicsExport\n", + "from corems.encapsulation.factory.parameters import LCMSParameters" + ] + }, + { + "cell_type": "markdown", + "id": "a1e239d5", + "metadata": {}, + "source": [ + "## Step 1: Load LC-MS Data\n", + "\n", + "First, we'll load the raw data file and create an LCMS object." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cdfa0d60", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded data file: Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801.raw\n", + "Number of scans: 6840\n", + "Retention time range: 0.01 - 34.02 minutes\n" + ] + } + ], + "source": [ + "# Point to the data file location\n", + "file_path = '../../tests/tests_data/lcms/Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801.raw'\n", + "\n", + "# Create parser for Thermo RAW files\n", + "parser = ImportMassSpectraThermoMSFileReader(file_path)\n", + "\n", + "# Instantiate LCMS object with MS1 data\n", + "lcms_obj = parser.get_lcms_obj(spectra=\"ms1\")\n", + "\n", + "print(f\"Loaded data file: {Path(file_path).name}\")\n", + "print(f\"Number of scans: {len(lcms_obj.scan_df)}\")\n", + "print(f\"Retention time range: {lcms_obj.scan_df.scan_time.min():.2f} - {lcms_obj.scan_df.scan_time.max():.2f} minutes\")" + ] + }, + { + "cell_type": "markdown", + "id": "8f6936f3", + "metadata": {}, + "source": [ + "## Step 2: Configure Processing Parameters\n", + "\n", + "Set parameters for data processing. For targeted searches, we can use faster settings since we're only looking for specific features." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "f23ca31d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Parameters configured\n" + ] + } + ], + "source": [ + "# Set parameters to defaults for reproducible processing\n", + "lcms_obj.parameters = LCMSParameters(use_defaults=True)\n", + "\n", + "# Configure persistent homology parameters for peak picking\n", + "lcms_obj.parameters.lc_ms.peak_picking_method = \"persistent homology\"\n", + "lcms_obj.parameters.lc_ms.ph_inten_min_rel = 0.0005\n", + "lcms_obj.parameters.lc_ms.ph_persis_min_rel = 0.05\n", + "lcms_obj.parameters.lc_ms.ph_smooth_it = 0\n", + "\n", + "# Configure MS1 parameters\n", + "ms1_params = lcms_obj.parameters.mass_spectrum['ms1']\n", + "ms1_params.mass_spectrum.noise_threshold_method = \"relative_abundance\"\n", + "ms1_params.mass_spectrum.noise_threshold_min_relative_abundance = 0.1\n", + "ms1_params.mass_spectrum.noise_min_mz = 0\n", + "ms1_params.mass_spectrum.min_picking_mz = 0\n", + "ms1_params.mass_spectrum.noise_max_mz = np.inf\n", + "ms1_params.mass_spectrum.max_picking_mz = np.inf\n", + "ms1_params.ms_peak.legacy_resolving_power = False\n", + "\n", + "# Configure MS2 parameters (same as MS1 for this dataset)\n", + "ms2_params = ms1_params.copy()\n", + "lcms_obj.parameters.mass_spectrum['ms2'] = ms2_params\n", + "\n", + "print(\"✓ Parameters configured\")" + ] + }, + { + "cell_type": "markdown", + "id": "8ccd61d8", + "metadata": {}, + "source": [ + "## Step 3: Define Target Compounds\n", + "\n", + "Now we'll define the specific compounds we want to find. For each target, we need:\n", + "- **m/z value**: The expected mass-to-charge ratio\n", + "- **Retention time**: The expected elution time in minutes\n", + "- **Tolerances**: How much deviation to allow in m/z (ppm) and RT (minutes)\n", + "- **Type label**: A descriptive label for these targets (e.g., \"internal standard\")\n", + "\n", + "In this example, we're targeting two compounds that we know exist in the data." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "06e07fb3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Targeting 2 compounds:\n", + " 1. m/z = 301.2166, RT = 8.8956 min\n", + " 2. m/z = 698.6289, RT = 23.8168 min\n" + ] + } + ], + "source": [ + "# Define target compounds\n", + "# These values are known to exist in our test dataset\n", + "target_mz_list = [\n", + " 301.2166, # Target compound 1\n", + " 698.6289 # Target compound 2\n", + "]\n", + "\n", + "target_rt_list = [\n", + " 8.8956, # Expected RT for compound 1 (minutes)\n", + " 23.8168 # Expected RT for compound 2 (minutes)\n", + "]\n", + "\n", + "# Create targeted search dictionary\n", + "target_search_dict = {\n", + " \"target_mz_list\": target_mz_list,\n", + " \"target_rt_list\": target_rt_list,\n", + " \"mz_tolerance_ppm\": 5, # Allow ±5 ppm deviation in m/z\n", + " \"rt_tolerance\": 0.5, # Allow ±0.5 minute deviation in RT\n", + " \"type\": \"internal standard\" # Label for these features\n", + "}\n", + "\n", + "print(f\"Targeting {len(target_mz_list)} compounds:\")\n", + "for i, (mz, rt) in enumerate(zip(target_mz_list, target_rt_list), 1):\n", + " print(f\" {i}. m/z = {mz:.4f}, RT = {rt:.4f} min\")" + ] + }, + { + "cell_type": "markdown", + "id": "e4aa55b4", + "metadata": {}, + "source": [ + "## Step 4: Perform Targeted Peak Picking\n", + "\n", + "Run the targeted search to find mass features that match our targets. The search will only look for features within the specified m/z and RT tolerances." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7ec2667a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found 5 initial mass features\n", + "✓ Found 5 mass features matching target criteria\n" + ] + } + ], + "source": [ + "# Perform targeted search\n", + "lcms_obj.find_mass_features(\n", + " targeted_search=True,\n", + " target_search_dict=target_search_dict\n", + ")\n", + "\n", + "print(f\"✓ Found {len(lcms_obj.mass_features)} mass features matching target criteria\")" + ] + }, + { + "cell_type": "markdown", + "id": "31afbd49", + "metadata": {}, + "source": [ + "## Step 5: Integrate Mass Features\n", + "\n", + "Integrate the chromatographic peaks to calculate areas, heights, and other quantitative metrics." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "28e211c9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Integrated 3 mass features\n", + "\n", + "Mass features after integration:\n" + ] + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "mf_id", + "rawType": "int64", + "type": "integer" + }, + { + "name": "type", + "rawType": "object", + "type": "string" + }, + { + "name": "scan_time", + "rawType": "float64", + "type": "float" + }, + { + "name": "mz", + "rawType": "float64", + "type": "float" + }, + { + "name": "apex_scan", + "rawType": "float64", + "type": "float" + }, + { + "name": "start_scan", + "rawType": "int64", + "type": "integer" + }, + { + "name": "final_scan", + "rawType": "int64", + "type": "integer" + }, + { + "name": "intensity", + "rawType": "float64", + "type": "float" + }, + { + "name": "persistence", + "rawType": "float64", + "type": "float" + }, + { + "name": "area", + "rawType": "float64", + "type": "float" + }, + { + "name": "tailing_factor", + "rawType": "float64", + "type": "float" + }, + { + "name": "dispersity_index", + "rawType": "float64", + "type": "float" + }, + { + "name": "normalized_dispersity_index", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score_min", + "rawType": "float64", + "type": "float" + }, + { + "name": "noise_score_max", + "rawType": "float64", + "type": "float" + }, + { + "name": "monoisotopic_mf_id", + "rawType": "object", + "type": "string" + }, + { + "name": "isotopologue_type", + "rawType": "object", + "type": "string" + }, + { + "name": "mass_spectrum_deconvoluted_parent", + "rawType": "object", + "type": "string" + }, + { + "name": "ms2_scan_numbers", + "rawType": "object", + "type": "string" + } + ], + "ref": "431f0d52-3578-4fc3-a72e-2e1324b320aa", + "rows": [ + [ + "0", + "internal standard", + "8.895636666666666", + "301.21661376953125", + "1882.0", + "1828", + "2008", + "66775328.0", + "66768418.0", + "35045576.273014136", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]" + ], + [ + "1", + "internal standard", + "23.816803333333333", + "698.62890625", + "5212.0", + "5176", + "5338", + "17265106.0", + "17258196.0", + "7113439.441603203", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]" + ], + [ + "3", + "internal standard", + "23.33747", + "698.6254272460938", + "5095.0", + "5059", + "5149", + "567761.5625", + "560851.0", + "231250.28136407814", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "[]" + ] + ], + "shape": { + "columns": 19, + "rows": 3 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
typescan_timemzapex_scanstart_scanfinal_scanintensitypersistenceareatailing_factordispersity_indexnormalized_dispersity_indexnoise_scorenoise_score_minnoise_score_maxmonoisotopic_mf_idisotopologue_typemass_spectrum_deconvoluted_parentms2_scan_numbers
mf_id
0internal standard8.895637301.2166141882.0182820086.677533e+0766768418.03.504558e+07NaNNaNNaNNaNNaNNaNNoneNoneNone[]
1internal standard23.816803698.6289065212.0517653381.726511e+0717258196.07.113439e+06NaNNaNNaNNaNNaNNaNNoneNoneNone[]
3internal standard23.337470698.6254275095.0505951495.677616e+05560851.02.312503e+05NaNNaNNaNNaNNaNNaNNoneNoneNone[]
\n", + "
" + ], + "text/plain": [ + " type scan_time mz apex_scan start_scan \\\n", + "mf_id \n", + "0 internal standard 8.895637 301.216614 1882.0 1828 \n", + "1 internal standard 23.816803 698.628906 5212.0 5176 \n", + "3 internal standard 23.337470 698.625427 5095.0 5059 \n", + "\n", + " final_scan intensity persistence area tailing_factor \\\n", + "mf_id \n", + "0 2008 6.677533e+07 66768418.0 3.504558e+07 NaN \n", + "1 5338 1.726511e+07 17258196.0 7.113439e+06 NaN \n", + "3 5149 5.677616e+05 560851.0 2.312503e+05 NaN \n", + "\n", + " dispersity_index normalized_dispersity_index noise_score \\\n", + "mf_id \n", + "0 NaN NaN NaN \n", + "1 NaN NaN NaN \n", + "3 NaN NaN NaN \n", + "\n", + " noise_score_min noise_score_max monoisotopic_mf_id isotopologue_type \\\n", + "mf_id \n", + "0 NaN NaN None None \n", + "1 NaN NaN None None \n", + "3 NaN NaN None None \n", + "\n", + " mass_spectrum_deconvoluted_parent ms2_scan_numbers \n", + "mf_id \n", + "0 None [] \n", + "1 None [] \n", + "3 None [] " + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Integrate the targeted mass features\n", + "lcms_obj.integrate_mass_features(drop_if_fail=True)\n", + "\n", + "print(f\"✓ Integrated {len(lcms_obj.mass_features)} mass features\")\n", + "print(\"\\nMass features after integration:\")\n", + "\n", + "# Display summary dataframe\n", + "mf_df = lcms_obj.mass_features_to_df()\n", + "mf_df.head()" + ] + }, + { + "cell_type": "markdown", + "id": "82e61667", + "metadata": {}, + "source": [ + "## Step 6: Verify Target Recovery\n", + "\n", + "Check that we found features close to each of our targets and calculate the deviation from expected values." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "64f9713f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Target Recovery Summary:\n", + "----------------------------------------------------------------------\n", + "Target 1: ✓ FOUND\n", + " Expected: m/z = 301.2166, RT = 8.8956 min\n", + " Observed: m/z = 301.2166, RT = 8.8956 min\n", + " Deviation: Δm/z = 0.05 ppm, ΔRT = 0.000 min\n", + " Intensity: 6.68e+07\n", + "\n", + "Target 2: ✓ FOUND\n", + " Expected: m/z = 698.6289, RT = 23.8168 min\n", + " Observed: m/z = 698.6289, RT = 23.8168 min\n", + " Deviation: Δm/z = 0.01 ppm, ΔRT = 0.000 min\n", + " Intensity: 1.73e+07\n", + "\n" + ] + } + ], + "source": [ + "# Verify that we found all targets\n", + "print(\"Target Recovery Summary:\")\n", + "print(\"-\" * 70)\n", + "\n", + "for i, (target_mz, target_rt) in enumerate(zip(target_mz_list, target_rt_list), 1):\n", + " # Calculate deviations\n", + " mz_diff_ppm = abs(mf_df['mz'] - target_mz) / target_mz * 1e6\n", + " rt_diff = abs(mf_df['scan_time'] - target_rt)\n", + " \n", + " # Find closest match\n", + " closest_idx = (mz_diff_ppm + rt_diff * 100).idxmin() # Combined score\n", + " \n", + " if mz_diff_ppm[closest_idx] < 10 and rt_diff[closest_idx] < 1.0:\n", + " print(f\"Target {i}: ✓ FOUND\")\n", + " print(f\" Expected: m/z = {target_mz:.4f}, RT = {target_rt:.4f} min\")\n", + " print(f\" Observed: m/z = {mf_df.loc[closest_idx, 'mz']:.4f}, RT = {mf_df.loc[closest_idx, 'scan_time']:.4f} min\")\n", + " print(f\" Deviation: Δm/z = {mz_diff_ppm[closest_idx]:.2f} ppm, ΔRT = {rt_diff[closest_idx]:.3f} min\")\n", + " print(f\" Intensity: {mf_df.loc[closest_idx, 'intensity']:.2e}\")\n", + " else:\n", + " print(f\"Target {i}: ✗ NOT FOUND within tolerance\")\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "id": "4fbcf21a", + "metadata": {}, + "source": [ + "## Step 7: Add MS1 and MS2 Data\n", + "\n", + "Add associated MS1 average spectra and MS2 fragmentation spectra to the mass features for further characterization." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "85ff3bd5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Added MS1 spectra\n", + "✓ Added 8 MS2 spectra\n", + "\n", + "MS2 Data Summary:\n", + " Mass features with MS2: 3 / 3\n", + " Total MS2 scans associated: 8\n" + ] + } + ], + "source": [ + "# Since the search results are stored as mass features (in the lcms_obj.mass_features dictionary), we can now add associated MS1 and MS2 data to these features just like we would for untargeted features.\n", + "# Add MS1 average spectra\n", + "lcms_obj.add_associated_ms1(\n", + " use_parser=False, \n", + " spectrum_mode=\"profile\"\n", + ")\n", + "print(f\"✓ Added MS1 spectra\")\n", + "\n", + "# Add MS2 DDA spectra\n", + "initial_ms_count = len(lcms_obj._ms)\n", + "lcms_obj.add_associated_ms2_dda(\n", + " use_parser=True, \n", + " spectrum_mode=\"centroid\"\n", + ")\n", + "ms2_added = len(lcms_obj._ms) - initial_ms_count\n", + "print(f\"✓ Added {ms2_added} MS2 spectra\")\n", + "\n", + "# Check which mass features have MS2 data\n", + "ms2_counts = []\n", + "for mf_id, mf in lcms_obj.mass_features.items():\n", + " ms2_count = len(mf.ms2_scan_numbers) if mf.ms2_scan_numbers else 0\n", + " ms2_counts.append(ms2_count)\n", + " \n", + "print(f\"\\nMS2 Data Summary:\")\n", + "print(f\" Mass features with MS2: {sum(1 for c in ms2_counts if c > 0)} / {len(lcms_obj.mass_features)}\")\n", + "print(f\" Total MS2 scans associated: {sum(ms2_counts)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "fdf828bd", + "metadata": {}, + "source": [ + "## Step 8: Visualize Targeted Mass Features\n", + "\n", + "Plot the extracted ion chromatograms (EICs) and mass spectra for the targeted compounds." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "e061cc66", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plotting mass feature 0:\n", + " m/z = 301.2168\n", + " RT = 8.8956 min\n", + " Intensity = 6.68e+07\n" + ] + }, + { + "data": { + "image/png": "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33//vcLtlUolFi5ciJdffhmjR49GaGgoDh48iB9++AGLFi0y6rpY0yUU7ldMTAwuXLhQJvGcP38+zp8/j3fffRe//vqr0X3Nmzc3GiN3r7ock9ehQwcMHDgQnTt3houLC65cuYLVq1ejqKgIH3zwgVHdDz/8EI8//jgGDx6McePG4ezZs/jiiy8wbdo0wxg+vffeew+Abgp4APj+++9x6NAhALoxiHoTJkzA/v37jX5gCAsLw/Xr1zFr1iwcOnTIsB2gW2+t9JTwBw4cwIEDBwDovvTm5OQYjt2vXz/069cPANClSxdMmTIFa9asQXFxsaGVc9OmTQgPDzd05e3WrRsGDRqEdevWQa1WY/DgwUhISMDnn38OpVKJ1157zeg8BUGoVotpt27dAADvvPMOxo0bB1tbWzz22GNVTrJzPx5++GE8//zzWLx4MaKjozF48GDY2triypUr2LRpEz799NNKk/K6HJP3xhtv4H//+x/69u2LGTNmwM3NDdu3b8f//vc/TJs2zagLdXnPhQ8++ABnz55Fz549YWNjg23btmH37t1477337muSqvo0ZcoUfPLJJwgNDcXUqVORnJyMVatWoV27dkYTFSmVSrRt2xYbN25Ey5Yt4erqivbt26N9+/bVfv9u27Yt+vfvj27dusHV1RXHjh3D5s2bK53YhohMzBxTehLRXaWXUKhMeUsorF69WgwODhblcrnYunVrce3ateLcuXONphaPjIwUhw8fLvr6+ooymUz09fUVx48fbzQV/FdffSX269dPdHNzE+Vyudi8eXPxjTfeEDMzMyuNST+d94cfflhpvWvXrokTJkwQvb29RVtbW7FJkybisGHDxM2bNxvq5Ofni//3f/8n+vj4iEqlUuzTp48YFRVV7jTgv/76q9i2bVvRxsamzHIBH3/8sdikSRNRLpeLffr0EY8dO1bhEgqbNm0qN96TJ0+KI0eONPw/AgICxDFjxoiRkZGVnqfe119/LbZq1UqUyWRi8+bNxWXLlhlNRV86huouoVDeEhflPSdEUTdt+8svvyyKoih+8cUXopOTk1hUVGRUp7JlEMpbhsJU5s6dK3bv3l10cXERbWxsRF9fX3HcuHHi6dOny63/yy+/iJ07dxblcrnYtGlT8d133xULCwvL1Kvo3O792NMvT1Hdbe99Lupfb+Vd7n1sCwsLxXnz5okBAQGira2t2KJFC3HZsmVlYs/NzRUXLFggtm3bVlQqlaKTk5M4bNgw8eTJk0b1srKyyl2CoSILFy4UmzRpIkokEqOp+itaQuHe9yT9ud67HMvEiRNFOzu7Msf7+uuvxW7duolKpVJ0cHAQO3ToIM6aNUuMj4+vVrx15ciRI+KQIUMM7z8tW7YUFy1aVOY1Ud5zYfv27WKPHj1EBwcHUaVSiQ8++KD4888/V/vYpV+LehW9b5b3vlTREgrlveeW95z74YcfxKCgIFEmk4mdO3cWd+3aVWafoiiKhw8fFrt16ybKZLIy+6nO+/d7770n9ujRQ3R2dhaVSqXYunVrcdGiReW+NomofgiiaML5l4mIyKweffRR2Nvb4+effzZ3KFTH/vjjDwwbNgynTp1Chw4dzB0OERFZEHbXJCKyYv379zesoUXWZe/evRg3bhwTPCIiKoMteURERERERFaEs2sSERERERFZESZ5REREREREVoRJHhERERERkRVhkkdERERERGRFmOQRERERERFZESZ5REREREREVoRJHhERERERkRVhkkdERERERGRFmORZuYiICAiCUOHln3/+AQAIgoAZM2aU2V6tVmP+/Pno1KkT7O3toVQq0b59e7z55puIj4+v79MhIiIiIqIq2Jg7AKofCxYsQLNmzcqUt2jRosJtrl+/jpCQEMTFxWH06NF47rnnIJPJcPr0aaxevRq//PILLl++bMqwiYiIiIiohpjkNRJDhgxB9+7dq12/uLgYI0eORFJSEvbt24eHHnrI6P5FixZhyZIldR0mERERERHVErtrUrm2bNmCU6dO4Z133imT4AGAo6MjFi1aZIbIiIiIiIioMmzJayQyMzORmppqVCYIAtzc3Mqt/9tvvwEAnn32WZPHRkREREREdYdJXiMREhJSpkwulyM/P7/c+hcuXICTkxP8/PxMHRoREREREdUhJnmNxIoVK9CyZUujMqlUWmF9tVoNBwcHU4dFRERERER1jEleI9GjR48aTbzi6OiI69evmzAiIiIiIiIyBU68QuVq3bo1MjMzcfPmTXOHQkRERERENcAkj8r12GOPAQB++OEHM0dCREREREQ1wSSPyjVq1Ch06NABixYtQlRUVJn7s7Ky8M4775ghMiIiIiIiqgzH5DUS//vf/3Dx4sUy5b1790ZQUFCZcltbW2zduhUhISHo168fxowZgz59+sDW1hbnzp3D+vXr4eLiwrXyiIiIiIgsDJO8RmLOnDnllq9du7bcJA8AWrRogejoaCxbtgy//PILtm3bBq1WixYtWmDatGl45ZVXTBkyERERERHdB0EURdHcQRAREREREVHd4Jg8IiIiIiIiK8Ikj4iIiIiIyIowySMiIiIiIrIiTPKIiIiIiIisCJM8IiIiIiIiK8Ikj4iIiIiIyIowySMiIiIiIrIiTPKIiIiIiIisCJM8K3fgwAE89thj8PX1hSAI2LZtW433sWvXLjz44INwcHCAh4cHnnzyScTGxtZ5rEREREREVHtM8qxcTk4OOnXqhBUrVtzX9jExMRg+fDgeeeQRREdHY9euXUhNTcXIkSPrOFIiIiIiIqoLgiiKormDoPohCAJ++eUXjBgxwlBWUFCAd955Bz/99BMyMjLQvn17LFmyBP379wcAbN68GePHj0dBQQEkEt1vAr///juGDx+OgoIC2NramuFMiIiIiIioImzJa+RmzJiBqKgobNiwAadPn8bo0aMRFhaGK1euAAC6desGiUSCtWvXQqPRIDMzE99//z1CQkKY4BERERERWSC25DUi97bkxcXFISgoCHFxcfD19TXUCwkJQY8ePfD+++8DAPbv348xY8YgLS0NGo0GvXr1wh9//AFnZ2cznAUREREREVWGLXmN2JkzZ6DRaNCyZUvY29sbLvv378e1a9cAAImJiZg+fTomTpyIo0ePYv/+/ZDJZBg1ahT4+wARERERkeWxMXcAZD7Z2dmQSqU4fvw4pFKp0X329vYAgBUrVsDJyQlLly413PfDDz/Az88PR44cwYMPPlivMRMRERERUeWY5DViXbp0gUajQXJyMvr27VtundzcXMOEK3r6hFCr1Zo8RiIiIiIiqhl217Ry2dnZiI6ORnR0NADdkgjR0dGIi4tDy5Yt8fTTT2PChAnYunUrYmJi8O+//2Lx4sXYsWMHAGDo0KE4evQoFixYgCtXruDEiROYPHkyAgIC0KVLFzOeGRERERERlYcTr1i5ffv2YcCAAWXKJ06ciIiICBQVFeG9997Dd999h9u3b8Pd3R0PPvgg5s+fjw4dOgAANmzYgKVLl+Ly5ctQqVTo1asXlixZgtatW9f36RARERERURWY5BEREREREVkRdtckIiIiIiKyIkzyiIiIiIiIrAiTPCIiIiIiIivCJRSskFarRXx8PBwcHCAIgrnDISIiIiKiOiCKIrKysuDr61tmmbPSmORZofj4ePj5+Zk7DCIiIiIiMoGbN2+iadOmFd7PJM8KOTg4ANA9+I6OjmaOhoiITCUnJwe+vr4AdD/w2dnZmTkiIiIyJbVaDT8/P8P3/YowybNC+i6ajo6OTPKIiKyYVCo1XHd0dGSSR0TUSFQ1JItJHhERkQUrKipCREQEAGDSpEmwtbU1b0BERGTxmOQRERFZMFEUER8fb7hORERUFSZ5jZhGo0FRUZG5wyCyGLa2tkbd34iIiIgaIiZ5jZAoikhMTERGRoa5QyGyOM7OzvD29ubyI0RERNRgMclrhPQJnqenJ1QqFb/MEkH340dubi6Sk5MBAD4+PmaOiIiIiOj+MMlrZDQajSHBc3NzM3c4RBZFqVQCAJKTk+Hp6cmum0RERNQgVbxMOlkl/Rg8lUpl5kiILJP+tcHxqkRERNRQsSWvkWIXTaLy8bVBlog/zBERUU0wySMiIrJgMpkMb7zxhrnDICKiBoTdNYmIiIiIiKwIkzxqMCZNmgRBEMpcwsLCAACBgYFYvny50TYnT57E6NGj4eXlBYVCgeDgYEyfPh2XL182wxkQEREREZkekzxqUMLCwpCQkGB0+emnn8qtu337djz44IMoKCjAjz/+iAsXLuCHH36Ak5MTZs+eXc+RExHdn6KiIkRERCAiIoITAhERUbVwTB41KHK5HN7e3lXWy83NxeTJk/Hoo4/il19+MZQ3a9YMPXv25ELwRNRgiKKIGzduGK4TERFVhUkeQRRF5BVp6v24SlupyWYy3LVrF1JTUzFr1qxy73d2djbJcYmIiIiIzI1JHiGvSIO2c3bV+3HPLwiFSlazp+D27dthb29vVPb222/j7bffNiq7cuUKAKB169a1C5KIiIiIqIFhkkcNyoABA7By5UqjMldX1zL12KWJiIiIiBorJnkEpa0U5xeEmuW4NWVnZ4cWLVpUWa9ly5YAgIsXL6JXr141Pg4RERERUUPFJI8gCEKNu01ausGDB8Pd3R1Lly41mnhFLyMjg+PyiIiIiMgqWdc3e7J6BQUFSExMNCqzsbGBu7u7UZmdnR2+/fZbjB49Go8//jheeeUVtGjRAqmpqfj5558RFxeHDRs21GfoRET3zdbW1twhEBFRA8IkjxqUnTt3wsfHx6isVatWuHjxYpm6w4cPx+HDh7F48WI89dRTUKvV8PPzwyOPPIL33nuvvkImIqoVmUxWZnIpIiKiyggiZ6iwOmq1Gk5OTsjMzISjo6PRffn5+YiJiUGzZs2gUCjMFCGR5eJrhBqSnJwcw4zD2dnZsLOzM3NERERkSpV9zy9NUo8xERERERERkYmxuyYREZEFKy4uxs8//wwAGDNmDGxs+NFNRESV4ycFERGRBdNqtbhy5YrhuvF9d0dcxKbkoh27axIREdhdk4iIqMG6eSfXcH3bscRKahIRUWPCJI+IiKiBupyUZbj+5yUmeUREpMMkrxYOHDiAxx57DL6+vhAEAdu2bau0/qRJkyAIQplLu3btDHXmzZtX5v7WrVub+EyIiKghuph4N8m7lqbGtaTcSmoTEVFjwSSvFnJyctCpUyesWLGiWvU//fRTJCQkGC43b96Eq6srRo8ebVSvXbt2RvUOHTpkivCJiKiBu5KcbXT7l2MJZoqEiIgsCSdeqYUhQ4ZgyJAh1a7v5OQEJycnw+1t27YhPT0dkydPNqpnY2MDb2/vOouTiIis0+UktdHtXecT8PrQ5maKhoiILAVb8sxo9erVCAkJQUBAgFH5lStX4Ovri6CgIDz99NOIi4urdD8FBQVQq9VGFyIism45BcWIu5NnuC0AuJKWievJ7LJJRNTYMckzk/j4ePzvf//DtGnTjMp79uyJiIgI7Ny5EytXrkRMTAz69u2LrKysCvYELF682NBK6OTkBD8/P1OHTw1A//798dprr5nt+P369cP69evNdvzyFBYWIjAwEMeOHTN3KETVJpPJMHfuXMydOxcymcxQfjkpC+LdFRTQ2t0VAPDLUXbZJCJq7Jjkmcm6devg7OyMESNGGJUPGTIEo0ePRseOHREaGoo//vgDGRkZhoVwyxMeHo7MzEzD5ebNmyaO3jz0E9e88MILZe57+eWXIQgCJk2aZChLSUnBiy++CH9/f8jlcnh7eyM0NBR///23oc7XX3+N/v37w9HREYIgICMjox7OpG7t27ev3Ni3bt2KhQsXmiWm3377DUlJSRg3bpxZjl8RmUyG119/HW+++aa5QyGqtUuJxj/+PdhU181/13kmeUREjR2TPDMQRRFr1qzBs88+a/SrbHmcnZ3RsmVLXL16tcI6crkcjo6ORhdr5efnhw0bNiAv724Xpfz8fKxfvx7+/v5GdZ988kmcPHkS69atw+XLl/Hbb7+hf//+SEtLM9TJzc1FWFgY3n777Xo7h/ri6uoKBwcHsxz7s88+w+TJkyGRWN5bzNNPP41Dhw7h3Llz5g6FqFYu3pPkPeDrBQHA5bRMxLDLJhFRo2Z538Aagf379+Pq1auYOnVqlXWzs7Nx7do1+Pj41ENklq9r167w8/PD1q1bDWVbt26Fv78/unTpYijLyMjAwYMHsWTJEgwYMAABAQHo0aMHwsPD8fjjjxvqvfbaa3jrrbfw4IMPVjuGzZs3o0OHDlAqlXBzc0NISAhycnIM969Zswbt2rWDXC6Hj48PZsyYYbjvk08+QYcOHWBnZwc/Pz+89NJLyM6+OzteREQEnJ2dsWvXLrRp0wb29vYICwtDQkL5v8zHxsZiwIABAAAXFxej1sx7u2sGBgbivffew4QJE2Bvb4+AgAD89ttvSElJwfDhw2Fvb4+OHTuW6cp46NAh9O3bF0qlEn5+fnjllVeMzvdeKSkp+Ouvv/DYY48ZlVf33Ldt24bg4GAoFAqEhoaWaZn+9ddf0bVrVygUCgQFBWH+/PkoLi4GACxYsAC+vr5GifzQoUMxYMAAaLVaw/+pT58+2LBhQ4XnQGRJiouLsWnTJmzatMnwXAeACwnG46+dFXK00XfZ5CybRESNGpO8WsjOzkZ0dDSio6MBADExMYiOjjZMlBIeHo4JEyaU2W716tXo2bMn2rdvX+a+119/Hfv370dsbCwOHz6MJ554AlKpFOPHjzfZeYiiiJycnHq/iKUHk9TAlClTsHbtWsPtNWvWlJmh1N7eHvb29ti2bRsKCgpq9f8pLSEhAePHj8eUKVNw4cIF7Nu3DyNHjjScy8qVK/Hyyy/jueeew5kzZ/Dbb7+hRYsWhu0lEgk+++wznDt3DuvWrcNff/2FWbNmGR0jNzcXH330Eb7//nscOHAAcXFxeP3118uNx8/PD1u2bAEAXLp0CQkJCfj0008rjH/ZsmXo06cPTp48iaFDh+LZZ5/FhAkT8Mwzz+DEiRNo3rw5JkyYYDifa9euISwsDE8++SROnz6NjRs34tChQ0aJ670OHToElUqFNm3aGJVX99wXLVqE7777Dn///TcyMjKMunwePHgQEyZMwKuvvorz58/jq6++QkREBBYtWgQAeOeddxAYGGgY67pixQocPnwY69atM2pV7NGjBw4ePFjhORBZEq1Wi/Pnz+P8+fOGHytEUSzTkgcAffx0PwjuOs+F0YmIGjWR7tvevXtFAGUuEydOFEVRFCdOnCg+/PDDRttkZGSISqVS/Prrr8vd59ixY0UfHx9RJpOJTZo0EceOHStevXq1RnFlZmaKAMTMzMwy9+Xl5Ynnz58X8/LyDGXZ2dnlnoepL9nZ2TU6r4kTJ4rDhw8Xk5OTRblcLsbGxoqxsbGiQqEQU1JSxOHDhxv+96Ioips3bxZdXFxEhUIh9u7dWwwPDxdPnTpV7r71j2V6enqlMRw/flwEIMbGxpZ7v6+vr/jOO+9U+5w2bdokurm5GW6vXbtWBGD0mK9YsUL08vKqcB8Vxf7www+Lr776quF2QECA+MwzzxhuJyQkiADE2bNnG8qioqJEAGJCQoIoiqI4depU8bnnnjPa78GDB0WJRGL0HCpt2bJlYlBQUMUnXaKic//nn38MZRcuXBABiEeOHBFFURQHDhwovv/++0b7+f7770UfHx/D7WvXrokODg7im2++KSqVSvHHH38sc+xPP/1UDAwMLDeu8l4jROZUUFAgzps3T5w3b55YUFAgiqIoJmTkiQFvbhcDZm4xvKcePJgt7jmUJwa+uV0MeHO7GJOcY+bIiYiorlX2Pb80rpNXC/3796+0NSoiIqJMmZOTE3JzKx4rwS5kVfPw8MDQoUMREREBURQxdOhQuLu7l6n35JNPYujQoTh48CD++ecf/O9//8PSpUvx7bffGk3QUhOdOnXCwIED0aFDB4SGhmLw4MEYNWoUXFxckJycjPj4eAwcOLDC7f/8808sXrwYFy9ehFqtRnFxMfLz85GbmwuVSgUAUKlUaN787jpXPj4+SE5Ovq9479WxY0fDdS8vLwBAhw4dypQlJyfD29sbp06dwunTp/Hjjz8a6oiiCK1Wi5iYmDKtdQCQl5cHhUJRprw6525jY4MHHnjAsE3r1q3h7OyMCxcuoEePHjh16hT+/vtvQ8sdAGg0GqP9BAUF4aOPPsLzzz+PsWPH4qmnnioTi1KprPR1SGTpLiTqump6KFS4UarcRaFAG3dXnE+9g1+OJeK/Q4LMEyAREZkVkzyCSqUyGhtVn8e9X1OmTDF0GVyxYkWF9RQKBQYNGoRBgwZh9uzZmDZtGubOnXvfSZ5UKsWePXtw+PBh7N69G59//jneeecdHDlypNxEs7TY2FgMGzYML774IhYtWgRXV1ccOnQIU6dORWFhoeH/YWtra7SdIAj33bX1XqX3LQhChWX6LmHZ2dl4/vnn8corr5TZ170T3ei5u7sjPT3dqKy6516V7OxszJ8/HyNHjixzX+nE8sCBA5BKpYiNjUVxcTFsbIzf6u7cuQMPD49qHZPIEl1M0HXV9FaVnWirt58Pzqfewc5zCUzyiIgaKSZ5BEEQYGdnZ+4waiQsLAyFhYUQBAGhoaHV3q5t27bYtm1brY4tCAL69OmDPn36YM6cOQgICMAvv/yCmTNnIjAwEJGRkYbJUEo7fvw4tFotPv74Y8P4sMqWxqgu/QytGo2m1vu6V9euXXH+/HmjcYVV6dKlCxITE5Geng4XFxcA1T/34uJiHDt2DD169ACgG2eYkZFhaDHs2rUrLl26VGk8GzduxNatW7Fv3z6MGTMGCxcuxPz5843qnD171miiHqKG5mJJS56vyr7MfQ828cbqk+dwKTUDN1LyEOChrO/wiIjIzDjxCjVIUqkUFy5cwPnz5yGVSsvcn5aWhkceeQQ//PADTp8+jZiYGGzatAlLly7F8OHDDfUSExMRHR1tWKLizJkziI6Oxp07d8o97pEjR/D+++/j2LFjiIuLw9atW5GSkmJIQubNm4ePP/4Yn332Ga5cuYITJ07g888/BwC0aNECRUVF+Pzzz3H9+nV8//33WLVqVa3/FwEBARAEAdu3b0dKSkqdtsq++eabOHz4MGbMmIHo6GhcuXIFv/76a6UTr3Tp0gXu7u5G6xFW99xtbW3xn//8B0eOHMHx48cxadIkPPjgg4akb86cOfjuu+8wf/58nDt3DhcuXMCGDRvw7rvvAgBu3bqFF198EUuWLMFDDz2EtWvX4v3338c///xjdJyDBw9i8ODBdfEvIjIL/Rp5fg5lkzwXpQKt3DjLJhFRY8YkjxqsytYEtLe3R8+ePbFs2TL069cP7du3x+zZszF9+nR88cUXhnqrVq1Cly5dMH36dABAv3790KVLF/z2228VHvPAgQN49NFH0bJlS7z77rv4+OOPMWTIEADAxIkTsXz5cnz55Zdo164dhg0bhitXrgDQjef75JNPsGTJErRv3x4//vgjFi9eXOv/Q5MmTTB//ny89dZb8PLyqjQBq6mOHTti//79uHz5Mvr27YsuXbpgzpw58PX1rXAbqVSKyZMnG43jq+65q1QqvPnmm3jqqafQp08f2NvbY+PGjYb7Q0NDsX37duzevRsPPPAAHnzwQSxbtgwBAQEQRRGTJk1Cjx49DP+D0NBQvPjii3jmmWcMyW9UVBQyMzMxatSouvo3EdWrwmItribrns8BLuW/B/bx48LoRESNmSDW1WAfshhqtRpOTk7IzMwskwTl5+cjJiYGzZo1K3dyDKK6kJiYiHbt2uHEiRMICAio1jYRERF47bXXkJGRYdLYxo4di06dOuHtt98u936+RsjSiKKIoqIiALrW7ouJWRjy6UEopTb4rH8fDB7sAAA4eDAbSqWu6/2dvHw8tz0SIoADrz8Cf3d22SQisgaVfc8vjS15RFTnvL29sXr1asOakZaisLAQHTp0wH//+19zh0JUbYIgQCaTQSaTQRCEUuPxHKFQCOVu46pUoJWbbkzsL8e4Zh4RUWPDJI+ITGLEiBHo27evucMwIpPJ8O6770KpZKsGNVz6RdB9VA4Qys/xAOhm2QTYZZOIqDFikkdEFmHSpEkm76pJ1BAVFxdj27Zt2LZtG4qLiw3LJzSxc6h0uweb6pK888npuJmWZ/I4iYjIcjDJIyIismBarRanTp3CqVOnoNVqDd01AyuYdEXPTalAK1ddl81t7LJJRNSoMMkjIiJqIDJyCpGkLgAANHOtvCUPAHr761rzdp5jl00iosaESR4REVEDcalk6QQ3uRIu9jZV1u/VRLeUwrnkdNy6k2/S2IiIyHIwySMiImogLifpumr6qBxhU3WOBzeVEi0NXTbZmkdE1FgwySMiImogLifqWvJ8VVV31dTrUzLL5v/YZZOIqNFgkkdERNRAXE7Szazp51D5pCul9Wqq67J5Pikdt9llk4ioUWCSR2SlAgMDsXz5cnOHQUR16ErJmLygaky6ouemUqKFizNEAJFnU00UGRERWRImedRgTJo0CYIg4IUXXihz38svvwxBEDBp0iRDWUpKCl588UX4+/tDLpfD29sboaGh+PvvvwEAd+7cwX/+8x+0atUKSqUS/v7+eOWVV5CZmVlfp1QnIiIi4OzsXKb86NGjeO655+o/ICKqU7a2tnj99dcxZspLyC4SYSuRwM/Frkb7CHTWJYWxKVwvj4ioMajGsG0iy+Hn54cNGzZg2bJlUCqVAID8/HysX78e/v7+RnWffPJJFBYWYt26dQgKCkJSUhIiIyORlpYGAIiPj0d8fDw++ugjtG3bFjdu3MALL7yA+Ph4bN68ud7Pra55eHiYOwQiqgOCIMDOzg6x19UABHgrHaBUCDXah6edCgBwMyPXBBESEZGlYUseNShdu3aFn58ftm7daijbunUr/P390aVLF0NZRkYGDh48iCVLlmDAgAEICAhAjx49EB4ejscffxwA0L59e2zZsgWPPfYYmjdvjkceeQSLFi3C77//juLi4gpj+PLLLxEcHAyFQgEvLy+MGjXKcJ9Wq8XSpUvRokULyOVy+Pv7Y9GiRYb733zzTbRs2RIqlQpBQUGYPXs2ioqKDPfPmzcPnTt3xvfff4/AwEA4OTlh3LhxyMrKKjeWffv2YfLkycjMzIQgCBAEAfPmzQNQtrumIAj46quvMGzYMKhUKrRp0wZRUVG4evUq+vfvDzs7O/Tu3RvXrl0zOsavv/6Krl27QqFQICgoCPPnz6/0/0NEpnExUfc+4KtygKSGn95eJUnebSZ5RESNApM8MigsLKzwcu+X+srqlk5aKqpbG1OmTMHatWsNt9esWYPJkycb1bG3t4e9vT22bduGgoKCau87MzMTjo6OsKlgbvJjx47hlVdewYIFC3Dp0iXs3LkT/fr1M9wfHh6ODz74ALNnz8b58+exfv16eHl5Ge53cHBAREQEzp8/j08//RTffPMNli1bZnSMa9euYdu2bdi+fTu2b9+O/fv344MPPig3nt69e2P58uVwdHREQkICEhIS8Prrr1d4fgsXLsSECRMQHR2N1q1b46mnnsLzzz+P8PBwHDt2DKIoYsaMGYb6Bw8exIQJE/Dqq6/i/Pnz+OqrrxAREWGUuBKRaRUXF2PHjh1IOHsYEmjha1f9SVf0vOx1SV5CFpM8IqLGgN01yWDx4sUV3hccHIynnnrKcPujjz4qk8zpBQQEGI2N+/TTT5Gba/zFYu7cufcd5zPPPIPw8HDcuHEDAPD3339jw4YN2Ldvn6GOjY0NIiIiMH36dKxatQpdu3bFww8/jHHjxqFjx47l7jc1NRULFy6sdBxbXFwc7OzsMGzYMDg4OCAgIMDQgpiVlYVPP/0UX3zxBSZOnAgAaN68OR566CHD9u+++67hemBgIF5//XVs2LABs2bNMpRrtVpERETAwUE3hubZZ59FZGRkuYmVTCaDk5MTBEGAt7d3Vf86TJ48GWPGjAGga1Xs1asXZs+ejdDQUADAq6++apQwz58/H2+99ZbhfIKCgrBw4ULMmjWrVo8hEVWfVqvFsWPHYAdAAlfD+Lqa0LfkpefnIztXA3uVtI6jJCIiS8KWPGpwPDw8MHToUERERGDt2rUYOnQo3N3dy9R78sknER8fj99++w1hYWHYt28funbtioiIiDJ11Wo1hg4dirZt2xq6O5Zn0KBBCAgIQFBQEJ599ln8+OOPhgT2woULKCgowMCBAyvcfuPGjejTpw+8vb1hb2+Pd999F3FxcUZ1AgMDDQkeAPj4+CA5ObmK/0r1lE5w9S2MHTp0MCrLz8+HWq1bcPnUqVNYsGCBoWXU3t4e06dPR0JCQpnEnYjqR7MazKyp5yCzhbKkh0IMJ18hIrJ6bMkjg/Dw8Arvk9wzAKSyLoGCYDwhwKuvvlq7wMoxZcoUQ7fCFStWVFhPoVBg0KBBGDRoEGbPno1p06Zh7ty5Ri2NWVlZCAsLg4ODA3755RfY2tpWuD8HBwecOHEC+/btw+7duzFnzhzMmzcPR48eNUwEU5GoqCg8/fTTmD9/PkJDQ+Hk5IQNGzbg448/Nqp37/EFQYBWq61039VVet/6x6m8Mv3xsrOzMX/+fIwcObLMvhQKRZ3ERETV5yiTw8NBXuPtBEGAp50SNzKzEJOciw4B9iaIjoiILAWTPDKQyWRmr1tdYWFhKCwshCAIhq6G1dG2bVts27bNcFutViM0NBRyuRy//fZbtRIXGxsbhISEICQkBHPnzoWzszP++usvPProo1AqlYiMjMS0adPKbHf48GEEBATgnXfeMZTpu5zWhkwmg0ajqfV+ytO1a1dcunQJLVq0MMn+iahmvBT2uN+3VC87FW5kZuFGKlvhiYisHZM8apCkUikuXLhguH6vtLQ0jB49GlOmTEHHjh3h4OCAY8eOYenSpRg+fDgAXYI3ePBg5Obm4ocffoBarTZ0U/Tw8Ch3v9u3b8f169fRr18/uLi44I8//oBWq0WrVq2gUCjw5ptvYtasWZDJZOjTpw9SUlJw7tw5TJ06FcHBwYiLi8OGDRvwwAMPYMeOHfjll19q/b8IDAxEdnY2IiMj0alTJ6hUKqhUqlrvFwDmzJmDYcOGwd/fH6NGjYJEIsGpU6dw9uxZvPfee3VyDCKqPh9VzSdd0dNPvhKXziSPiMjaMcmjBsvRseIvO/b29ujZsyeWLVuGa9euoaioCH5+fpg+fTrefvttAMCJEydw5MgRACjTUhUTE4PAwMAy+3V2dsbWrVsxb9485OfnIzg4GD/99BPatWsHAJg9ezZsbGwwZ84cxMfHw8fHx7B4++OPP47//ve/mDFjBgoKCjB06FDMnj270jGA1dG7d2+88MILGDt2LNLS0jB37txa71MvNDQU27dvx4IFC7BkyRLY2tqidevW5bZUEpHpNbW//26W+slXbjHJIyKyeoIoiqK5g6C6pVar4eTkZFgOoLT8/HzExMSgWbNmHFNFVA6+RsjSFBQUGJZRadJuBro0czPcl5eXg759dYnfwYPZUCrtKtzPiYRkLDp0FP6ODjjwdr8K6xERkeWq7Ht+aZxdsxYOHDiAxx57DL6+vhAEwWisV3n27dtnWLC69CUxMdGo3ooVKxAYGAiFQoGePXvi33//NeFZEBGRJUvL02BTfgdsLuiIZm5O970fz5KWvOScPGg0/H2XiMiaMcmrhZycHHTq1KnS2R3Lc+nSJcPC1QkJCfD09DTct3HjRsycORNz587FiRMn0KlTJ4SGhtbZFPpERNSwXErMRrYoh73cFQ529z/KwtNONwNwvqYYyRnlr3NKRETWgWPyamHIkCEYMmRIjbfz9PSEs7Nzufd98sknmD59umFB6lWrVmHHjh1Ys2YN3nrrrdqES0REDdCFRN2EUD4qR5QzH1S1yaRSuCoUuJOfj2vJufBxq/uZj4mIyDKwJc8MOnfuDB8fHwwaNAh///23obywsBDHjx9HSEiIoUwikSAkJARRUVHmCJWIiMzsUkImutvcRLB4BVpt7ZZL0c+wGZvCyVeIiKwZk7x65OPjg1WrVmHLli3YsmUL/Pz80L9/f5w4cQIAkJqaCo1GAy8vL6PtvLy8yozbK62goMAw/X/pZQCIiKjhu5ygRgfbJKjyLtQ+ySsZl3cjjUkeEZE1Y3fNetSqVSu0atXKcLt37964du0ali1bhu+///6+97t48WLMnz+/Rttotdr7Ph6RNeNrgyxJQbEGManZeFBeN/vzKhmXdyuDSR4RkTVjkmdmPXr0wKFDhwAA7u7ukEqlSEpKMqqTlJQEb2/vCvcRHh6OmTNnGm6r1Wr4+fmVW1cmk0EikSA+Ph4eHh6QyWQQBKEOzoSoYRNFEYWFhUhJSYFEIoFMxvFKZH7XknNQrK27mTA9S7pr3maSR0Rk1ZjkmVl0dDR8fHwA6BKwbt26ITIyEiNGjACga1WIjIzEjBkzKtyHXC6HXF69n3klEgmaNWuGhIQExMfH1zp+ImujUqng7+8PiYS92cn8LiXVbfd775LumglqJnlERNaMSV4tZGdn4+rVq4bbMTExiI6OhqurK/z9/REeHo7bt2/ju+++AwAsX74czZo1Q7t27ZCfn49vv/0Wf/31F3bv3m3Yx8yZMzFx4kR0794dPXr0wPLly5GTk2OYbbMuyGQy+Pv7o7i4GBpN7cZ3EFkTqVQKGxsbtm6TxbiUmF2n+9OPyUvNy0NegRZKOX/MICKyRkzyauHYsWMYMGCA4ba+y+TEiRMRERGBhIQExMXFGe4vLCzE//3f/+H27dtQqVTo2LEj/vzzT6N9jB07FikpKZgzZw4SExPRuXNn7Ny5s8xkLLUlCAJsbW1ha2tbp/slIqK6cy2lbpM8Z4UcthIJirRaxCbnoY2fXZ3un4iILIMgimLddfYni6BWq+Hk5ITMzEw4OjqaOxwiIrpPAz/ehxspajyrPAkACAsLh43N3fGieXk56NvXHgBw8GA2lMqqk7ZXdu7H7axsrBzTE0O6upsmcCIiMonqfs9nSx4REZEFKtZoEXcnF8WQoHn7KWjqJodUWvveF972KtzOykZsKsflERFZKyZ5REREFuhmeh6KNCJsJVIEeDWFSlk3Y0X14/Li7jDJIyKyVhxxTUREZIGuJevG43kq7KGQ191kQPok71Y6kzwiImvFljwiIiILdD1Vl+R5KZS4cmUfACA4uC8kEmmt9uulXysvk0keEZG1YkseERGRBbqekgMA8FSocOXKfly5sh9abe2XvfG0UwIAknJywanXiIisE5M8IiIiC6RP8vQLmNcVz5L95RQVITWzqE73TUREloFJHhERkQW6VtJd09fRvk73q7SxgZNctwzD9WR22SQiskZM8oiIiCxMZl4R0rILAQBNnOq2JQ+4O/lKbAqTPCIia8Qkj4iIyMJcT9G14jnJ5HBS1f0cafrJV26kMckjIrJGTPKIiIgszN1JV+xhW/v1z8vQj8u7yWUUiIisEpM8IiIiC3OtpCXPQ2Fnkv0b1srLyDPJ/omIyLy4Th4REZGF0bfkeansIZXa4KGHpgEApNK6+djWJ3kJarbkERFZIyZ5REREFka/EHoTBzsIggTOzk3qdP/6MXkpubkoKBQhlwl1un8iIjIvdtckIiKyIBqtiNiSCVH8nOp2+QQ9V6UCUkGARhRxMy3fJMcgIiLzYZJHRERkQW6n56GwWAsbQQJfZyW0Wg2uXfsb1679Da1WUyfHkAoCPFRKAEAM18ojIrI6TPKIiIgsiH4RdA+FHeQyAVqtBhcu/IkLF/6ssyQPKLWMQiqTPCIia8Mkj4iIyIIYlk9Q2kEqNd1x9JOvxN1hkkdEZG2Y5BEREVkQ/ULoniZaPkFP35LHtfKIiKwPkzwiIiILom/J87E3zaQrevqWvNsZTPKIiKwNkzwiIiILol8IvYmjiVvySpK8xOxciKJJD0VERPWMSR4REZGFyMovQnJWAQDAz7l+WvLUhYXIzCk26bGIiKh+MckjIiKyEDGpuq6aDrYyuNjZmvRYdjJb2NnqjnE9Kc+kxyIiovplY+4AiIiISMcws6bCHiX5F6RSGzz44ETD9brkZa/C9fRMxKTkomtzhzrdNxERmQ+TPCIiIgthmFlTaQdB0JUJggTu7oEmOZ6XnRLX0zNxI42TrxARWRN21yQiIrIQ11L1a+SZdjyenn5cHpdRICKyLmzJIyIishD67ppNHO7OrKnVahAXdxwA4O/fDRJJ3a2Qrk/ybjHJIyKyKkzyiIiILIBWKyImVddds6mjfalyDc6e/Z+uvGnnuk3yShZET1AzySMisibsrlkLBw4cwGOPPQZfX18IgoBt27ZVWn/r1q0YNGgQPDw84OjoiF69emHXrl1GdebNmwdBEIwurVu3NuFZEBGRJUhQ5yO/SAupIKCJs7JejqlvyUvKyUVxMRfLIyKyFkzyaiEnJwedOnXCihUrqlX/wIEDGDRoEP744w8cP34cAwYMwGOPPYaTJ08a1WvXrh0SEhIMl0OHDpkifCIisiDXknWteO4KFZSK+vl4dlcpIQAo0mpx+05BvRyTiIhMj901a2HIkCEYMmRItesvX77c6Pb777+PX3/9Fb///ju6dOliKLexsYG3t3ddhUlERA2AfmZND4U9pHXXI7NSNhIJ3FVKpOTm4XpyLgI8FfVzYCIiMim25JmRVqtFVlYWXF1djcqvXLkCX19fBAUF4emnn0ZcXJyZIiQiovpyXT+zpsKuipp1S99lMzaF4/KIiKwFkzwz+uijj5CdnY0xY8YYynr27ImIiAjs3LkTK1euRExMDPr27YusrKwK91NQUAC1Wm10ISKihkU/s6aPXf0sn6Cnn3zl5p28ej0uERGZDrtrmsn69esxf/58/Prrr/D09DSUl+7+2bFjR/Ts2RMBAQH4+eefMXXq1HL3tXjxYsyfP9/kMRMRkenou2s2dTJPS97NDLbkERFZC7bkmcGGDRswbdo0/PzzzwgJCam0rrOzM1q2bImrV69WWCc8PByZmZmGy82bN+s6ZCIiMqHcwmLEZ+YDAPydjVvyJBIbPPDAeDzwwHhIJHX/26ynnW4mz9tM8oiIrAZb8urZTz/9hClTpmDDhg0YOnRolfWzs7Nx7do1PPvssxXWkcvlkMvldRkmERHVo5iS8Xh2NrZwsZMZ3SeRSODl1dJkx9a35CVkMckjIrIWbMmrhezsbERHRyM6OhoAEBMTg+joaMNEKeHh4ZgwYYKh/vr16zFhwgR8/PHH6NmzJxITE5GYmIjMzExDnddffx379+9HbGwsDh8+jCeeeAJSqRTjx4+v13MjIqL6ox+P56m0h0xWReU6ph+Tl56fj+xcTf0enIiITIJJXi0cO3YMXbp0MSx/MHPmTHTp0gVz5swBACQkJBjNjPn111+juLgYL7/8Mnx8fAyXV1991VDn1q1bGD9+PFq1aoUxY8bAzc0N//zzDzw8POr35IiIqN7okzwPhR0Ewfg+rVaDmzejcfNmNLTauk/CHGUyKErWbIhJ4eQrRETWgN01a6F///4QRbHC+yMiIoxu79u3r8p9btiwoZZRERFRQ3OtZNIVL2XZmTW1Wg1OnfoVAODj0xYSSd0uoicIArzsVbiRmYWY5Fx0CKjf2T2JiKjusSWPiIjIzK6n6pI8X4f6nVlTz7NkXN6NVI7LIyKyBkzyiIiIzEgURcSUdNds6mieJM+wjEI6kzwiImvAJI+IiMiMktQFyCnUQCIIaOpspiTPnkkeEZE1YZJHRERkRvpF0N3kKqgU5vlY1rfk3c5kkkdEZA2Y5BEREZnRtZI18jwVdrAx03Ro+iQvKScXGk3FE4oREVHDwCSPiIjIjPQteR4K83TVBAAvOyUEAAUaDeLvFJotDiIiqhtcQoGIiMiM9GvkeduVv3SBRGKDrl1HGa6bgq1UCjelEql5ebiWlAM/D7lJjkNERPWj0bbkTZw4EQcOHDB3GERE1Mjp18hrUsHMmhKJBL6+7eDr2w4Siek+tr1LJl+JSeG4PCKihq7RJnmZmZkICQlBcHAw3n//fdy+fdvcIRERUSOTX6TB7Yw8AIC/s3kXIdcneTfSmOQRETV0jTbJ27ZtG27fvo0XX3wRGzduRGBgIIYMGYLNmzejqKjI3OEREVEjEJuWA1EElFIbuNvLyq2j1WoRH38O8fHnoNVqTRaLt72uJTEuPcdkxyAiovrRaJM8APDw8MDMmTNx6tQpHDlyBC1atMCzzz4LX19f/Pe//8WVK1fMHSIREVkx/Xg8T6U95HKh3DpabTFOnNiMEyc2Q6stNlks3lwrj4jIajTqJE8vISEBe/bswZ49eyCVSvHoo4/izJkzaNu2LZYtW2bu8IiIyEqVnllTKD/Hqzf6ZRQSs3IhchUFIqIGrdEmeUVFRdiyZQuGDRuGgIAAbNq0Ca+99hri4+Oxbt06/Pnnn/j555+xYMECc4dKRERWSt+S56U073g84G5LXlZRIe6oOWyBiKgha7RLKPj4+ECr1WL8+PH4999/0blz5zJ1BgwYAGdn53qPjYiIGgf9Qui+9uZbI09PZWsLR5kM6sJCXE3KhZuTk7lDIiKi+9Rok7xly5Zh9OjRUCgUFdZxdnZGTExMPUZFRESNyY2SJK+Jk/mTPEDXmqe+U4jrybno2ZJJHhFRQ9Vou2vu3bu33Fk0c3JyMGXKFDNEREREjUleoQYZebrPIU97pZmj0dHPsBmbxhk2iYgaskab5K1btw55eXllyvPy8vDdd9+ZISIiImpMEjJ1n0FyqRROSsvoWKMflxd3hzNsEhE1ZJbxqVKP1Go1RFGEKIrIysoy6q6p0Wjwxx9/wNPT04wREhFRY5CQmQ8AcJYpIZNVPLWmRCJFp07DDddNycuwjAJb8oiIGrJGl+Q5OztDEAQIgoCWLVuWuV8QBMyfP98MkRERUWMSn6FryXOyVUBSSb8aiUQKP7/O9RKTT0l3zQQ1W/KIiBqyRpfk7d27F6Io4pFHHsGWLVvg6upquE8mkyEgIAC+vr5mjJCIiBoDfUuei7ziCcDqm3fJWnl38vORnauBvcq0LYdERGQajS7Je/jhhwEAMTEx8Pf3h2Du1WeJiKhRupvkVT7pilarRUrKVQCAh0cLSCpr9qslR7kMCqkU+RoNriXnolOgg8mORUREptOokrzTp0+jffv2kEgkyMzMxJkzZyqs27Fjx3qMjIiIGhv9xCtuyspb8rTaYhw9+hMAICwsHBKJzGQxCYIAL3s73MhU4zqTPCKiBqtRJXmdO3dGYmIiPD090blzZwiCAFEUy9QTBAEajcYMERIRUWORkKFryXNTWsbyCXo+9ircyFQjNpXj8oiIGqpGleTFxMTAw8PDcJ2IiMhc9C15HnaWMyYP4DIKRETWoFEleQEBAeVeJyIiqk85BcVQ5xcDADwdLKslz6tkhs24O1xGgYiooWrUi6Hv2LHDcHvWrFlwdnZG7969cePGDTNGRkRE1k7fiqeQ2sBBYVm/t/qUtOTFZ7Ilj4iooWq0Sd77778PZck4iKioKHzxxRdYunQp3N3d8d///tfM0RERkTWLz9AvhK6Ara2Zg7mHV8kyCsm5uSgsKjtunYiILF+jTfJu3ryJFi1aAAC2bduGUaNG4bnnnsPixYtx8ODBau3jwIEDeOyxx+Dr6wtBELBt27Yqt9m3bx+6du0KuVyOFi1aICIiokydFStWIDAwEAqFAj179sS///5bk1MjIiILl5ipT/KUlS6Ebg5uKiWkggCNKOJGSp65wyEiovtgYR8t9cfe3h5paWkAgN27d2PQoEEAAIVCgby86n2o5eTkoFOnTlixYkW16sfExGDo0KEYMGAAoqOj8dprr2HatGnYtWuXoc7GjRsxc+ZMzJ07FydOnECnTp0QGhqK5OTkGp4hERFZqviS7prVWQhdIpGiffshaN9+CCQS0y9OLhUEQ2ve1SR22SQiaogsayBAPRo0aBCmTZuGLl264PLly3j00UcBAOfOnUNgYGC19jFkyBAMGTKk2sdctWoVmjVrho8//hgA0KZNGxw6dAjLli1DaGgoAOCTTz7B9OnTMXnyZMM2O3bswJo1a/DWW2/V4AyJiMhS6ZdPcJJVPemKRCJFYGAPU4dkxNtehfjsHMSmMMkjImqIGm1L3ooVK9CrVy+kpKRgy5YtcHNzAwAcP34c48ePN8kxo6KiEBISYlQWGhqKqKgoAEBhYSGOHz9uVEcikSAkJMRQh4iIGj59S557FQuhm4t3yQybN9I4wyYRUUPUaFvynJ2d8cUXX5Qpnz9/vsmOmZiYCC8vL6MyLy8vqNVq5OXlIT09HRqNptw6Fy9erHC/BQUFKCgoMNxWq9V1GzgREdUp/Zg8d1XVLXmiqEVaWhwAwM3NH4Jg+t9nDWvlpbMlj4ioIWq0SR4AZGRk4N9//0VycjK0Wq2hXBAEPPvss2aMrGYWL15s0uSUiIjqVkJJkudhX3VLnkZTjH/+WQcACAsLh42NzKSxAXeTvNtcRoGIqEFqtEne77//jqeffhrZ2dlwdHSEIAiG+0yV5Hl7eyMpKcmoLCkpCY6OjlAqlZBKpZBKpeXW8fb2rnC/4eHhmDlzpuG2Wq2Gn59f3QZPRER1Qp1fhOyCkoXQ7Syzu6aXna67ZlJ2DjQaEVKpUMUWRERkSRrtmLz/+7//w5QpU5CdnY2MjAykp6cbLnfu3DHJMXv16oXIyEijsj179qBXr14AAJlMhm7duhnV0Wq1iIyMNNQpj1wuh6Ojo9GFiIgsk76rpsrGFvZKy/yt1ctOCQFAvkaD+DuF5g6HiIhqqNEmebdv38Yrr7wClUp13/vIzs5GdHQ0oqOjAeiWSIiOjkZcnG7sRHh4OCZMmGCo/8ILL+D69euYNWsWLl68iC+//BI///yz0eLrM2fOxDfffIN169bhwoULePHFF5GTk2OYbZOIiBq2+AzdpCuWuBC6nq1UCjelbrzgtSROvkJE1NBY5k+I9SA0NBTHjh1DUFDQfe/j2LFjGDBggOG2vsvkxIkTERERgYSEBEPCBwDNmjXDjh078N///heffvopmjZtim+//dawfAIAjB07FikpKZgzZw4SExPRuXNn7Ny5s8xkLERE1DAllFoIXbDgXpDe9iqk5uUhJiUX/eFq7nCIiKgGGm2SN3ToULzxxhs4f/48OnToANt7fk59/PHHq9xH//79IYpihfdHRESUu83Jkycr3e+MGTMwY8aMKo9PREQNT0KpljxL5m2vwtmUNNxI4+QrREQNTaNN8qZPnw4AWLBgQZn7BEGARqOp75CIiKgR0LfkucgtPcnTTb4Sl87umkREDU2jTfJKL5lARERUX/RJnn7MW1UkEinatAkxXK8v+mUUbnGtPCKiBqfRJnml5efnQ6Gw7F9UiYjIOsRn6rpruquq97kjkUjRvHkfU4ZULi87XZKXkJULUYRFjx8kIiJjjXZ2TY1Gg4ULF6JJkyawt7fH9evXAQCzZ8/G6tWrzRwdERFZI1EUkZBRshC6XfVa8sxF35KXVVSIO+oiM0dDREQ10WiTvEWLFiEiIgJLly6FTCYzlLdv3x7ffvutGSMjIiJrpc4rRl6Rbsy3p0P1WvJEUYuMjNvIyLgNUay/oQYqW1s4lnw+Xk1il00iooak0SZ53333Hb7++ms8/fTTkErvjnHo1KkTLl68aMbIiIjIWum7atrbyGCnqN74Oo2mGIcOfYtDh76FRlNsyvDK0LfmXU9mkkdE1JA02iTv9u3baNGiRZlyrVaLoiJ2SyEiorqXUJLkOckUsGkAo+L1M2zGpnGGTSKihqTRJnlt27bFwYMHy5Rv3rwZXbp0MUNERERk7QwLocsVDWIiE31LXtwdtuQRETUkDeB3RNOYM2cOJk6ciNu3b0Or1WLr1q24dOkSvvvuO2zfvt3c4RERkRXST7riIrPsSVf0vLiMAhFRg9RoW/KGDx+O33//HX/++Sfs7OwwZ84cXLhwAb///jsGDRpk7vCIiMgK6cfkOVv4Quh6PiXdNePV7K5JRNSQNNqWPADo27cv9uzZY+4wiIiokdC35LkpG0aS512yVt6d/Hxk52lgr6y/xdiJiOj+NdqWvKCgIKSlpZUpz8jIQFBQkBkiIiIia5eo1iV57qqG0V3TUS6DQiqFCOB6Up65wyEiompqtElebGwsNBpNmfKCggLcvn3bDBEREZE1E0UR8Rm6RMnTvvpJnkQiRXDwwwgOfhgSSf22pAmCYJhh81oyu2wSETUUja675m+//Wa4vmvXLjg5ORluazQaREZGIjAw0AyRERGRNUvPLUJBsW4xcw87ebW3k0ikaNWqv4miqpq3vQqxmWrEpnLyFSKihqLRJXkjRowAoPt1cuLEiUb32draIjAwEB9//LEZIiMiImumb8VzsJVBVc2F0C0Bl1EgImp4Gl2Sp9XqfkVt1qwZjh49Cnd3dzNHREREjUGifo08mbJGC6GLoojs7BQAgL29B4R6XmBP310z7g67axIRNRSNLsnTi4mJMXcIRETUiCTol0+Q1WwhdI2mCPv3rwQAhIWFw8ZGZorwKqRvyYvPZEseEVFD0WiTPACIjIxEZGQkkpOTDS18emvWrDFTVEREZI3i9S158oYxs6aeV8kyCsm5uSgsEiGzrd+WRCIiqrlGO7vm/PnzMXjwYERGRiI1NRXp6elGFyIiorqk767pImsYa+TpuamUkAoCNKKIGylcRoGIqCFotC15q1atQkREBJ599llzh0JERI2AfuIVN1XDSvKkggAvOxXis3NwLSkXwb4qc4dERERVaLQteYWFhejdu7e5wyAiokYiIbNhLYRemn5cXkwKx+URETUEjTbJmzZtGtavX2/uMIiIqBHQakVDkudp37Ba8gDOsElE1NA02u6a+fn5+Prrr/Hnn3+iY8eOsLW1Nbr/k08+MVNkRERkbe7kFqJIo4UAwKNBJnm6lrwbXCuPiKhBaLRJ3unTp9G5c2cAwNmzZ80bDBERWbWEDF0rnoNMDqW8Zp1oJBIpgoJ6Ga6bgz7Ju81lFIiIGoRGm+Tt3bvX3CEQEVEjEV+yRp6LTAlpDfM0iUSKtm0HmyCq6vOy03XXTMrOgUYjQirlMgpERJas0SV5I0eOrLKOIAjYsmVLPURDRESNgX75hJouhG4pvOyUEADkazSIv1MIPw+5uUMiIqJKNLokz8nJydwhEBFRI6NvyXO6jzXyRFFEXl4mAECpdIJghizRViqFm1KJ1Lw8XEvKYZJHRGThGl2St3bt2jrf54oVK/Dhhx8iMTERnTp1wueff44ePXqUW7d///7Yv39/mfJHH30UO3bsAABMmjQJ69atM7o/NDQUO3furPPYiYjI9PRj8lzkNV8+QaMpwl9/fQoACAsLh42NrE5jqy5vexVS8/IQk5KL/nA1SwxERFQ9jXYJhbqyceNGzJw5E3PnzsWJEyfQqVMnhIaGIjk5udz6W7duRUJCguFy9uxZSKVSjB492qheWFiYUb2ffvqpPk6HiIhMIKGkJc+9gS2EXpphhs00Tr5CRGTpmOTV0ieffILp06dj8uTJaNu2LVatWgWVSoU1a9aUW9/V1RXe3t6Gy549e6BSqcokeXK53Kiei4tLfZwOERGZwN2F0BtykleyVl4618ojIrJ0TPJqobCwEMePH0dISIihTCKRICQkBFFRUdXax+rVqzFu3DjYlcxcprdv3z54enqiVatWePHFF5GWllbhPgoKCqBWq40uRERkGbRaEUlq/ULoNe+uaSl8SlryYtOyzRwJERFVhUleLaSmpkKj0cDLy8uo3MvLC4mJiVVu/++//+Ls2bOYNm2aUXlYWBi+++47REZGYsmSJdi/fz+GDBkCjUZT7n4WL14MJycnw8XPz+/+T4qIiOpUanYBijQiBADudg13wpJmLrqJy25kZiG3oPzPIyIisgyNbuIVS7J69Wp06NChzCQt48aNM1zv0KEDOnbsiObNm2Pfvn0YOHBgmf2Eh4dj5syZhttqtZqJHhGRhYgv6arpJFNAUcOF0C2Jp0oJO1tb5BQV4WxcNnoEc7ZqIiJL1XA/bSyAu7s7pFIpkpKSjMqTkpLg7e1d6bY5OTnYsGEDpk6dWuVxgoKC4O7ujqtXr5Z7v1wuh6Ojo9GFiIgsQ2Kp5RNsGvBPq4IgoHlJa97JG5lmjoaIiCrDJK8WZDIZunXrhsjISEOZVqtFZGQkevXqVem2mzZtQkFBAZ555pkqj3Pr1i2kpaXBx8en1jETEVH9is/QL4R+f+PxBEGCgIDuCAjoDkEw78d2UEmSd/Y2kzwiIkvWgH9TtAwzZ87ExIkT0b17d/To0QPLly9HTk4OJk+eDACYMGECmjRpgsWLFxttt3r1aowYMQJubm5G5dnZ2Zg/fz6efPJJeHt749q1a5g1axZatGiB0NDQejsvIiKqG/rlE1zk9zezplRqgw4dhtZlSPctyEXXU+RCEpM8IiJLxiSvlsaOHYuUlBTMmTMHiYmJ6Ny5M3bu3GmYjCUuLg4SifEvr5cuXcKhQ4ewe/fuMvuTSqU4ffo01q1bh4yMDPj6+mLw4MFYuHAh5PKGO2CfiKix0i+fcD8LoVsafXfN2IwsFBRpIbdlhyAiIkvEJK8OzJgxAzNmzCj3vn379pUpa9WqFURRLLe+UqnErl276jI8IiIyo9qukSeKIgoLdQuQy2QqCIJQZ7HVlJedCipbG+QWFePMjSx0b8HJV4iILBF/giMiIjKhhAxdd837TfI0miLs2fMR9uz5CBpNUV2GVmOCICDImZOvEBFZOiZ5REREJqLRikjKKgDQsBdCL00/+cq5eLWZIyEiooowySMiIjKRlKwCaLQiJIIAd3vrGFetH5d3PpEteURElopJHhERkYnEl1ojT2ZrvrF0dUnfkheTrkZhkdbM0RARUXmY5BEREZlIgmGNvIa9EHpp3vYqKGxsUKTV4kxctrnDISKicjDJIyIiMhH9Gnn3uxC6JZIIAoKcdevlnYpjl00iIkvEJI+IiMhEDGvkye5vZk1L1dxV12XzzG0meURElshKOo8QERFZHn1Lnovi/pM8QZCgadNOhuuWgJOvEBFZNiZ5REREJhKfoV8I/f67a0qlNujceUQdRVQ3mpWslRdzR42iYhG2NtYxqQwRkbWwjJ8EiYiIrFBiSXdNDzvr6q7p62AHhVSKQq0W529y8hUiIkvDJI+IiMgEijVaJGeVJHn295/kiaKI4uJCFBcXQhTFugqvViSCgGYlXTZP3mCXTSIiS8Mkj4iIyASSsgqgFQGpIMBVdf8LoWs0Rdi5czF27lwMjaaoDiOsnSAX3QybnHyFiMjyMMkjIiIygYQM/fIJ1rMQemn6RdEvcPIVIiKLwySPiIjIBPTLJzjLlFazEHpp+hk2r5VMvkJERJaDSR4REZEJ6JdPcLKyNfL0fB3sIZdKUaDR4MItTr5CRGRJmOQRERGZgH75BBf5/S+fYMmkgoBAZ924vOg4tZmjISKi0pjkERERmYC+Jc+1FguhWzp9l80ztzguj4jIkjDJIyIiMgH9Gnm1WQjd0uknXznPyVeIiCyKFQ4FJyIiMr94Q5JXu5Y8QZDAx6et4bolMUy+kqaGRiNCKrW+WUSJiBoiJnlERER1LL9Ig9TsAgCAl0PtWvKkUht06za6LsKqc00c7CCTSJCvKcal+By09bM3d0hERAR21yQiIqpzsWk5EEVAaWMDF5WtucMxGalEgoCSyVdOxLLLJhGRpWCSR0REVMdiUnIAAJ4Ke8jl1t2FkZOvEBFZHnbXJCIiqmPXU/VJnh2EWuZ4xcWF2LlzMQAgLCwcNjay2oZXpzj5ChGR5WFLHhERUR27lqJbHNxTaf1j1PQteVdLJl8hIiLzY5JHRERUx66XdNf0sbczcySm19TRHrYSCfKKi3E5Idfc4RAREZjkERER1SlRFHG9pCWvqZP1J3k2EgkCnHSTr5zk5CtERBaBSR4REVEdupNTCHV+MQQ0jiQPAIJcdEnemdtM8oiILAGTvDqwYsUKBAYGQqFQoGfPnvj3338rrBsREQFBEIwuCoXxQrmiKGLOnDnw8fGBUqlESEgIrly5YurTICKiOqCfdMVFroSDSmrmaOqHflze+QQmeUREloBJXi1t3LgRM2fOxNy5c3HixAl06tQJoaGhSE5OrnAbR0dHJCQkGC43btwwun/p0qX47LPPsGrVKhw5cgR2dnYIDQ1Ffn6+qU+HiIhqSb98gofCDjaNZA7roFKTr2i1nHyFiMjcmOTV0ieffILp06dj8uTJaNu2LVatWgWVSoU1a9ZUuI0gCPD29jZcvLy8DPeJoojly5fj3XffxfDhw9GxY0d89913iI+Px7Zt2+rhjIiIqDaupdbtzJqCIIGnZzA8PYMhCJb5se3n5AAbiQQ5RUW4mphn7nCIiBo9y/y0aCAKCwtx/PhxhISEGMokEglCQkIQFRVV4XbZ2dkICAiAn58fhg8fjnPnzhnui4mJQWJiotE+nZyc0LNnzwr3WVBQALVabXQhIiLz0M+s6aWsm/F4UqkNevR4Cj16PAWp1DKbBm0lEvg7OgDg5CtERJaASV4tpKamQqPRGLXEAYCXlxcSExPL3aZVq1ZYs2YNfv31V/zwww/QarXo3bs3bt26BQCG7Wqyz8WLF8PJyclw8fPzq+2pERHRfYopGZPXxLFxTLqi19xV12Xz9C0meURE5sYkr5716tULEyZMQOfOnfHwww9j69at8PDwwFdffXXf+wwPD0dmZqbhcvPmzTqMmIiIqqtYo8WNNF2S19TR+hdCL00/Lu9cPJM8IiJzs8x+Hw2Eu7s7pFIpkpKSjMqTkpLg7e1drX3Y2tqiS5cuuHr1KgAYtktKSoKPj4/RPjt37lzuPuRyOeRy+X2cARER1aVb6Xko0oiwlUjg7aSoeoNqKC4uxJ49HwEABg16HTY2sjrZb11r7eYCADiffAd5BRoo5Y1jZlEiIkvElrxakMlk6NatGyIjIw1lWq0WkZGR6NWrV7X2odFocObMGUNC16xZM3h7exvtU61W48iRI9XeJxERmYe+q6aHwg4KuVBn+9VoiqDRFNXZ/kzBz9EeLgoFCrVaHLh4x9zhEBE1akzyamnmzJn45ptvsG7dOly4cAEvvvgicnJyMHnyZADAhAkTEB4ebqi/YMEC7N69G9evX8eJEyfwzDPP4MaNG5g2bRoA3cybr732Gt577z389ttvOHPmDCZMmABfX1+MGDHCHKdIRETVdC1FN7Omh8Iekkb2CSsIArp4uwMA9l5IMXM0RESNG7tr1tLYsWORkpKCOXPmIDExEZ07d8bOnTsNE6fExcVBUuqTPj09HdOnT0diYiJcXFzQrVs3HD58GG3btjXUmTVrFnJycvDcc88hIyMDDz30EHbu3Flm0XQiIrIs+oXQPetoZs2GprO3B/6KvYWomFRzh0JE1KgJoihy1VIro1ar4eTkhMzMTDg6Opo7HCKiRmPc11H45/odTGjZCcM7Na2TfRYXF2LnzsUAgLCwcKMxeXl5OejbVzfBy8GD2VCaObnMKijE5N/2QARw8I2B8HPjj5NERHWput/zG1lnEiIiItPRj8lrbDNr6jnIZWju4gwA+PMMu2wSEZkLkzwiIqI6kF1QjCR1AQCgqXPj7K4JAJ1LxuXtv8wkj4jIXJjkERER1YGYFF0rnoOtDC52tnW2X0EQ4OoaAFfXAAhC3c3YaSqdvT0AAMdupqJYwxEhRETmwIlXiIiI6sD11Lsza9rWXY4HqdQWvXtPqrsdmliwqzOUNjbILirC0WuZ6NXS2dwhERE1OmzJIyIiqgPXU+6ukdcAGtxMxkYiQQdPNwDAX+fZZZOIyByY5BEREdUB/fIJXo10+YTSupR02fz7GpM8IiJzYJJHRERUB2JKumv6OtTtzJrFxYXYvftD7N79IYqLC+t036aiH5d3MTUDGTlFZo6GiKjxYZJHRERUS6IoGiZeaepU9y15hYW5KCzMrfP9moqnnQo+9nbQiiIiz3JhdCKi+sYkj4iIqJaS1AXIKdRAIgho4qQydzgWQd9lc98lJnlERPWNSR4REVEt6WfWdJOroFLwoxW4u17eP7EpEEUupUBEVJ/4SURERFRLpWfWtOHiRACAdh5usBEkSMnNw4XbOeYOh4ioUWGSR0REVEv6JM9TwZk19RQ2Nmjt7gIAiDzLWTaJiOoTkzwiIqJa0nfX9Lar25k1Gzr9uLyDV5nkERHVJyZ5REREtRRTskaer0Pdt+QJggAnJ184OflCaGCrrOuXUjiVcAf5RRozR0NE1Hhw5AAREVEtFBRrcPOObnkDf+e6T/KkUlv07Tu9zvdbHwKcHOAslyOjoACHLqYjpIO7uUMiImoU2JJHRERUC3FpudCKgEJqA3d7ubnDsSiCIBhm2fzrArtsEhHVFyZ5REREtXA99e7MmnJ5w+pOWR/0XTajrnO9PCKi+sIkj4iIqBZKz6wpMcGnqkZThMjI5YiMXA6NpqjuD2BiHb3cIQCIyVAj/k6+ucMhImoUmOQRERHVwvUU3cyaHkrTzKwpiiLy8jKRl5fZIBcVd5LL0czZCQCw5wxb84iI6gOTPCIiolowzKxpzzXyKqIfl3fgCsflERHVByZ5REREtaAfk9fUkUleRfTj8v6NS4VW2/BaI4mIGhomeURERPcpI7cQd3IKAQBNnZjkVaSlmwsUUimyCgtx/Lra3OEQEVk9JnlERET36VrJpCvOMgUc7bj0bEVsJRK099R12Yw8zy6bRESmxiSPiIjoPsWUWj7B1tbMwVi4LiXj8g5dY5JHRGRq/NmRiIjoPuln1vRUmK6rpiAIsLf3MFxvqPTj8i4kpyMjpwjOdsyKiYhMhUkeERHRfTKskacyzfIJACCV2qJ//5dMtv/64m1vBy87FZJycrH3XBqe6OFt7pCIiKwWu2sSERHdJ313zSYOnHSlOrqUtObtv8Qum0REpsQkrw6sWLECgYGBUCgU6NmzJ/79998K637zzTfo27cvXFxc4OLigpCQkDL1J02aBEEQjC5hYWGmPg0iIqoBjVZETFrJ8glOpmvJsyb6LptRsVwUnYjIlJjk1dLGjRsxc+ZMzJ07FydOnECnTp0QGhqK5OTkcuvv27cP48ePx969exEVFQU/Pz8MHjwYt2/fNqoXFhaGhIQEw+Wnn36qj9MhIqJqis/IQ2GxFjaCBL5OSpMdR6Mpwr59X2Lfvi+h0RSZ7Dj1ob2HG6SCgKScXFyOzzF3OEREVotJXi198sknmD59OiZPnoy2bdti1apVUKlUWLNmTbn1f/zxR7z00kvo3LkzWrdujW+//RZarRaRkZFG9eRyOby9vQ0XFxeX+jgdIiKqJv0i6O4KFRRy002IIooisrNTkJ2dAlFs2AuJK21t0MpN93m25yy7bBIRmQqTvFooLCzE8ePHERISYiiTSCQICQlBVFRUtfaRm5uLoqIiuLq6GpXv27cPnp6eaNWqFV588UWkpaVVuI+CggKo1WqjCxERmVbpmTWlUjMH04Dox+UduMIkj4jIVJjk1UJqaio0Gg28vLyMyr28vJCYmFitfbz55pvw9fU1ShTDwsLw3XffITIyEkuWLMH+/fsxZMgQaDSacvexePFiODk5GS5+fn73f1JERFQt+pk1PRQcj1cT3Xw9AQD/3kzG8dgM8wZDRGSlmOSZ0QcffIANGzbgl19+gUKhMJSPGzcOjz/+ODp06IARI0Zg+/btOHr0KPbt21fufsLDw5GZmWm43Lx5s57OgIio8dLPrOltz5k1ayLAyRF9/ZpABBC++Qw02obdBZWIyBIxyasFd3d3SKVSJCUlGZUnJSXB27vy9X8++ugjfPDBB9i9ezc6duxYad2goCC4u7vj6tWr5d4vl8vh6OhodCEiItPSd9ds6sgkr6Ymdm4NpY0NLqeqsfbgDXOHQ0RkdZjk1YJMJkO3bt2MJk3RT6LSq1evCrdbunQpFi5ciJ07d6J79+5VHufWrVtIS0uDj49PncRNRES1k1tYjPjMfACAvzO7a9aUi0KBp9q3AgAs+/MSktUFZo6IiMi6MMmrpZkzZ+Kbb77BunXrcOHCBbz44ovIycnB5MmTAQATJkxAeHi4of6SJUswe/ZsrFmzBoGBgUhMTERiYiKys3W/CGdnZ+ONN97AP//8g9jYWERGRmL48OFo0aIFQkNDzXKORERkTN9V087GFi52MpMeSxAEKJVOUCqdIAimm8WzvoW2CECAoyNyiooxf9sFc4dDRGRVbMwdQEM3duxYpKSkYM6cOUhMTETnzp2xc+dOw2QscXFxkEju5tIrV65EYWEhRo0aZbSfuXPnYt68eZBKpTh9+jTWrVuHjIwM+Pr6YvDgwVi4cCHkcnm9nhsREZVPn+R5KOwgM22OB6nUFgMHvmbag5iBVBDwQvf2ePuvw9hx/jaevuKP3sGuVW9IRERVEsSGvugOlaFWq+Hk5ITMzEyOzyMiMoHPIq/gkz2X0cOjKd7s38lsceTl5aBvX1130YMHs6FUNrzxgV8ePY3I2Jto5uqA3f/3EGyl7GRERFSR6n7P5zspERFRDRnWyGuASZWlebZja9jb2iLmTha++ivW3OEQEVkFJnlEREQ1pO+u6VMPyydoNEU4ePAbHDz4DTSaIpMfr745yGV4tlNrAMAX+y8jISPfzBERETV8TPKIiIhqQBRFw0Lofk6mn1lTFEVkZsYjMzMe1jrC4pFAPwS7OCO/WIPZW8+bOxwiogaPSR4REVENJKrzkVVQDAFAEyeVucOxChJBwPPd20MA8OflBPx1PsXcIRERNWhM8oiIiGrg1+h4AIC/vTPslVIzR2M9mjk7Iax5IABg9rZzKCjWmDcgIqIGjEkeERFRNWm1Ijb8GwcA6OXpBxsuRFSnxndoCSeZHLfVOfh8z3Vzh0NE1GAxySMiIqqmqOtpiE3LhUJqg/5BvuYOx+rY2dpicpc2AICvD13FjdRcM0dERNQwMckjIiKqpvUlrXhd3Xzh7sxmPFN4yM8Xbd3dUKjRYtams8guKDZ3SEREDQ6TPCIiompIzS7A7nOJAIABfv4QhPo7tkymgkzWOCZ5EQQBz3drB6kg4MiNFPRe/Bc+/fMKMnOtb/kIIiJT4c+QRERE1bD5+C0UaUQE2Dujg59TvR3XxkaGwYPfqLfjWYKmjg54o1c3rDlxAcn5OVj252V8feA6JvYOwNSHmsHNXm7uEImILBqTPCIioipotSJ+KjXhiq2tmQNqBB5o4oWuvp44cD0BWy5eRUJuFr7cdw1rDsXi6Qf98Xy/IHg6KswdJhGRRWKSR0REVIWo62m4UTLhygBOuFJvpIKAAc198XCQDw7fSMKW81cRl5OJ1Ydi8H3UDYx9wA8v9G+OJs5Kc4dKRGRRmOQRERFVYf2RuxOuuNXzhCsaTRGOHPkRANCz59OQShtfM6JEEPBQoDf6BHjh6K1UbDp3Bdez0vH9Pzew/t84PNm1KWYMaAF/t8YxbpGIqCpM8oiIiCqRklWAXSUTrjziH1CvE64AgCiKuHPnhuF6YyYIAnr4eeCBpu44lXAHP5+9gkuZafj52E1sOXELY7v74ZWBwfB2YjdOImrcmOQRERFVYvPxWyjW6iZcad/U0dzhEHTJXmdfN3T2dcO5pHT8dOYKLqSnYP2/cdhy/Bae6RWAl/o35wQtRNRocQkFIiKiCmi1IjYc1XXV7O3lzwlXLFA7Lxe8F9IDcx/qheaOrijQaLH6UAz6LtmLj3ZdQmYel14gosaHSR4REVEFDl+7O+FK/2Y+5g6HKtHRxxVLBj+It3r1gJ+dE3KLNPhi71U89MFf+OKvq7iclMWF1Ymo0WB3TSIiogrol03o5tak3idcoZoTBAEPNPVA9ybuOBSbhA3nLiExLxsf7b6Ej3ZfAgA4KGzg66xEE2clfJwURtebudtxWQYisgr8xCIiIipH6QlXBvj71/uEK3T/BEFA32be6B3ohb1X4/HH1Vik5OUgV1OErPxiXErMwqXErHK3DXK3Q7+WHujTwh0PBrnCQcE+ukTU8DDJIyIiKoclTbjSGJdNqAtSQUBIcBOEBDeBVgtk5xcjMSsPyVn5SMnNQ2puHtLy8pFRmIf0gjykFeTiemoOrqfmIOJwLKSCgM7+zniohTv6Brujk58zbKUc6UJElo9JHhER0T20WtHQVdPcE67Y2MgwZMjb5gvASkgkgKPKBo4qB7T0cii3TkZuEU7eTsOppBRcuJOK1IJcHL+RjuM30vFp5BXYyWzQo5kLuge6onuACzr5OUNhK63nMyEiqhqTPCIionscvpaGuDuccKWxcVbZYkCwNwYEewMAbmfk4vitVJxOTsXlzFTkFBZh76UU7L2UAgCwkQho38QJDwS6oFuAK7oHusCdyzYQkQVgkkdERHSP9f/qFh/nhCuNWxNnFZo4++Nx+EOjFXE5WY3TCXdwOT0d19V3oC4qQPTNDETfzMA3B2MAAIFudujq74zO/s7o4ueC1j4O7OJJRPWOn1xERESlpGQVYPe5JACWMeGKRlOM48d/BgB06zYGUik/us1BKhHQxtsJbbydADSDKIq4lZ6Hs0l3cDE1Hdcy05GYl4XYtBzEpuVg68nbAAC5jQTtfZ3QpSTx6+znjCbOSgjmfmIRkVXjJwUREVEpm47ftJgJVwBAFLVITr5iuE6WQRAE+Lmq4OeqwhA0BQBk5hbhbGI6LqZk4HpmBuKyM5BbXITjcek4Hpdu2NZOZgN3Bxnc7eVws5PBzV4Od3tZqeu62652MihlUthIJLCVCkwMiajamOQRERGV0GpFbPj3JgDzT7hCDY+TyhZ9gjzRJ8gTgO75dONODs4nZeDKnQzEZGUgPleNnMJi5KQV40Zabo32LxUE2EgF2EglsJXo/tpIBAgCIIq6OiJ0V+7e1hGga42USgTYSARISv5KJRJIJYBUIim5LUBpK4VKJoVSVvLXVgqlzAYqo9v663fLFSXbqWQ2kNlIIIq6aERRF5chJvFunAIESCSARBAgFXTnwmSWqPaY5NWBFStW4MMPP0RiYiI6deqEzz//HD169Kiw/qZNmzB79mzExsYiODgYS5YswaOPPmq4XxRFzJ07F9988w0yMjLQp08frFy5EsHBwfVxOkREjVJ+kQa/Rt/mhCtUZyQSAc3c7dHM3R4oae3LK9Tgdnoe0vMKkZlfgIz8QqgLCqEuKEBWUSGyigqQXVyI7KIC5BQXGe1PI4rQFIsoKLbuFl2JoEv6JBIBEgGwkUggs5FAbnPvXylkUgnkthLYSCQARGhFQCuK0Gh1SaVWFEsuAETdLKtSiaDbvyAYrksld48pt5FAYVuSzNpKobAtuS2TQmFT8tdWAolwt3VVAHQJKkpuC7qygmIt8oo0yC3UIK9Ig7zC4lLXdRdAN+mPs0oGZ5UtXEr+OitlcLHT3a5sFldRvHuugO78mCgTk7xa2rhxI2bOnIlVq1ahZ8+eWL58OUJDQ3Hp0iV4enqWqX/48GGMHz8eixcvxrBhw7B+/XqMGDECJ06cQPv27QEAS5cuxWeffYZ169ahWbNmmD17NkJDQ3H+/HkoFIr6PkUiIqtUrNHi9O1MRF1Lw99XU3HsRjoKS748c8IVMhWlTIoWXvZV1tNqgcIiLfKLtCjWiijWlPzVaqEp+VusFZGhFuHXFCj9O7D++33phEMritBqSxJFrRbFGl0ipBFFFGtFaDS660UaLfIKNcgvSUz0CUmuPjkx3NZdzy0s1v0tSVrqIgHVJ2q6zAwAtEBBrXfboMlsdC2t+oRVFO8mtPoW0tJspQJsJBLYSAXIpLq/+mTZpqQVWCa92xpsXF66/t392Eolhv3alty2kUoMSbHCVgKFjfTu9ZK/8lJlWhEo0uief0UaLQo1WsPtQo3uuS0RBEOrstFFuHtdo9XVLywuuWju+VusRbFWW7IviaEFW39eUokAW6m+ZVt/vkJJ+d36+h8BirW6Hwv0r5W7r8OS11Gp6/r77i0v3ZouKXU+EsN56R47rajrBXDv46y/5OVkV+s5I4hieU8Nqq6ePXvigQcewBdffAEA0Gq18PPzw3/+8x+89dZbZeqPHTsWOTk52L59u6HswQcfROfOnbFq1SqIoghfX1/83//9H15//XUAQGZmJry8vBAREYFx48ZVGZNarYaTkxMyMzPh6Gj+8SRE5lCs0SK3SIN8/S+mJV9KSt8u0mghlZTq9lTyJq//ALMp+UAz/kVXCrmNBBIJfyVtaLRaEZeSsnD4WhoOX03FkZg7yC4oNqrjJJMj2MEdT7dvDX9Py/hRrbi4EDt3LgYAhIWFw8ZGZrgvLy8HffvqEoaDB7OhVNqZJUYyn8REwM8P6NjR3JHoaLQi8oo0KCzWQoCudQzC3ZYtQRCMWr20oi65FEsSUF0yKpZc171ui7UiCou1KCjWlPzVGm4XlNwu1oiGFkCh5K9Ucve6pCTz1ZTsX9/ap/t790u0puRY+s+JgiKtIbHNL7r7N79Ia0iw9F+kS3+l1ndJldvc7d6q7warkEmh0pfJpBBFIDOvCOk5hUjPLUJmnu5vRm4hMnKLUKzlV3W6S1uQi5vLx1T5PZ8/U9ZCYWEhjh8/jvDwcEOZRCJBSEgIoqKiyt0mKioKM2fONCoLDQ3Ftm3bAAAxMTFITExESEiI4X4nJyf07NkTUVFR1Ury9CIvJMHOvmb9/YG7b1b3o/Y/Gdz/Dmp7bHOet9hgz1u3tf4XYv0HnlYs/Quj7rp+nIWhG47htu66WPLrnu6XPd0vfEXFd28Xllwv/SFbustL6dv5RRoUaUz7oSi3kRg+tPWJn6208l87bUt+RdTdX86vqVIBtiXbSCTC3S9EJV+GdF9WSn6dL/nCpP9CU7qO/kuU4T6gzLgYEXe7+BjKSpcb3V92W5TU0WpLj7m5+5wo1oooKNJ9Ccuv4K/+i5r+i5W+e5X+S9jdL3wwtDiI5dTVf1HTfw/Sx3D3i1fFj6PKxhYtHN3Q0skNHb3cEORuD6WSCTzR/ZJKBNjLbQAuF1gnRFFEdkExMnJ1XXdLJ636937JPZ+l+hbfomIRRdq7rWZFJa3Buuu61uGikvuKtaWuG8pE3eewVl+3ZDvt3Za3Io2IgiIN8ot1n88FJQlwfvHdZDi/qGwLb+nPSFnJ56e+Ba10K5im1Ht+6Yu+JU5mo//81e1HZqNridTvz9D6Vqo1XFPyP9C3shVr9HW0pa7rzrN0fl16TGvpv/rWwHvL7r1d+jNLf7n7Y4Puc06454eKuz9c3L2enyvFzWo8d5jk1UJqaio0Gg28vLyMyr28vHDx4sVyt0lMTCy3fmJiouF+fVlFde5VUFCAgoK7/RgyMzMBAP9ZdxgSuaoGZ0RkfSQCoLCVQGlrA4VMUpKU2UBpq0u89N0uijR339SLtXc//Io1+g8r3YeCXl4BkJdjxhOj+2IrlaCZnQv8la4IdnZDkKsjlAqhpHubiPT0LKSnV7WX+qXRFCI/Px8AkJCghlR6tyUvP//ukzAhQQ2FQlPv8ZF5ZWYCLi6AWm3uSMiUnCr6xl4y1rA0KQC5AMBo4iih5B7zEEXdWFKJIDSomWL1rb6WNM5RrVbD7y3jluPyMMmzAosXL8b8+fPLlN9eOan+gyEisnDXzR1ArXxQ4T1jxvjWYxxERGROWVlZcHJyqvB+Jnm14O7uDqlUiqSkJKPypKQkeHt7l7uNt7d3pfX1f5OSkuDj42NUp3PnzuXuMzw83KgLqFarxZ07d+Dm5mbyXx3UajX8/Pxw8+ZNjv9rZPjYN1587BsvPvaNFx/7xouPvWURRRFZWVnw9a38hz0mebUgk8nQrVs3REZGYsSIEQB0CVZkZCRmzJhR7ja9evVCZGQkXnvtNUPZnj170KtXLwBAs2bN4O3tjcjISENSp1arceTIEbz44ovl7lMul0MuN+787uzsXKtzqylHR0e+8BspPvaNFx/7xouPfePFx77x4mNvOSprwdNjkldLM2fOxMSJE9G9e3f06NEDy5cvR05ODiZPngwAmDBhApo0aYLFi3Uzo7366qt4+OGH8fHHH2Po0KHYsGEDjh07hq+//hqAbsKE1157De+99x6Cg4MNSyj4+voaEkkiIiIiIqKKMMmrpbFjxyIlJQVz5sxBYmIiOnfujJ07dxomTomLi4NEIjHU7927N9avX493330Xb7/9NoKDg7Ft2zbDGnkAMGvWLOTk5OC5555DRkYGHnroIezcuZNr5BERERERUZW4Th7VSkFBARYvXozw8PAyXUbJuvGxb7z42DdefOwbLz72jRcf+4aJSR4REREREZEVkVRdhYiIiIiIiBoKJnlERERERERWhEkeERERERGRFWGSR0REREREZEWY5FGFAgMDIQhCmcvLL79cbv2IiIgydbnsQ8Ok0Wgwe/ZsNGvWDEqlEs2bN8fChQtR1TxN+/btQ9euXSGXy9GiRQtERETUT8BUZ+7nsd+3b1+57xWJiYn1GDnVVlZWFl577TUEBARAqVSid+/eOHr0aKXb8DVvHWr62PM133AdOHAAjz32GHx9fSEIArZt22Z0vyiKmDNnDnx8fKBUKhESEoIrV65Uud8VK1YgMDAQCoUCPXv2xL///muiM6DqYpJHFTp69CgSEhIMlz179gAARo8eXeE2jo6ORtvcuHGjvsKlOrRkyRKsXLkSX3zxBS5cuIAlS5Zg6dKl+PzzzyvcJiYmBkOHDsWAAQMQHR2N1157DdOmTcOuXbvqMXKqrft57PUuXbpk9Pr39PSsh4iprkybNg179uzB999/jzNnzmDw4MEICQnB7du3y63P17z1qOljr8fXfMOTk5ODTp06YcWKFeXev3TpUnz22WdYtWoVjhw5Ajs7O4SGhiI/P7/CfW7cuBEzZ87E3LlzceLECXTq1AmhoaFITk421WlQdYhE1fTqq6+KzZs3F7Vabbn3r127VnRycqrfoMgkhg4dKk6ZMsWobOTIkeLTTz9d4TazZs0S27VrZ1Q2duxYMTQ01CQxkmncz2O/d+9eEYCYnp5u4ujIVHJzc0WpVCpu377dqLxr167iO++8U+42fM1bh/t57Pmatw4AxF9++cVwW6vVit7e3uKHH35oKMvIyBDlcrn4008/VbifHj16iC+//LLhtkajEX19fcXFixebJG6qHrbkUbUU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7Nq1qx599FENGzZMNWrUUFhYmN577z1lZ2frvvvu00UXXaRGjRoVCylbt25Vz549FRgYqLCwMA0YMEB//PHHaXsrKCjQ559/rptuusllfnR0tP7973/rnnvuUWBgoKKiovT111/r999/1y233KLAwEC1bNlSa9eutZb56+2azz77rFq3bq2PP/5Y0dHRCg4O1l133aXMzEyX7bz++usu227durWeffZZa1yS+vTpI4fDYb2WpK+++kpt27aVn5+fGjZsqHHjxunkyZOSJGOMnn32WdWvX19Op1MRERF67LHHrGW9vb11ww03aNasWac9NgCA0iPkAQBQCjNmzFBoaKhWr16tRx99VEOGDNEdd9yhjh07av369erevbsGDBig48ePS5LS0tJ07bXXqk2bNlq7dq0WLFigw4cP68477zztNjZv3qz09HRdccUVxcYmTpyoq6++Whs2bFCvXr00YMAA3XPPPfrb3/6m9evX65JLLtE999yjM30K49dff9XcuXM1b948zZs3T0uXLtVLL71U6mOwZs0aSdKHH36oQ4cOWa+XL1+ue+65R48//ri2b9+ud955R9OnT9cLL7wgSfriiy80ceJEvfPOO/rll180d+5ctWjRwmXd7du31/Lly0vdCwDg9Ah5AACUQqtWrfSvf/1LjRs31ujRo+Xn56fQ0FA9+OCDaty4scaMGaMjR45o8+bNkqS33npLbdq00YsvvqimTZuqTZs2mjZtmhYvXqyff/65xG3s3btX3t7eJd5mecMNN+jvf/+7ta2MjAxdeeWVuuOOO3TppZdq1KhR2rFjhw4fPnzafSgsLNT06dPVvHlzderUSQMGDFBCQkKpj0Ht2rUlSSEhIQoPD7dejxs3Tk899ZQGDhyohg0b6vrrr9fzzz+vd955R5K0b98+hYeHKzY2VvXr11f79u314IMPuqw7IiJC+/fvV2FhYan7AQCUjJAHAEAptGzZ0vrZ29tbtWrVcrkaFRYWJklKTU2V9OcDVBYvXqzAwEBratq0qaQ/r6iVJCcnR06ns8SHw5y6/aJtnWn7JYmOjtZFF11kva5bt+4Z60tr06ZNeu6551z29cEHH9ShQ4d0/Phx3XHHHcrJyVHDhg314IMPas6cOdatnEX8/f1VWFio3Nzc8+4HAKq6au5uAACAysDHx8fltcPhcJlXFMyKrkRlZWXppptu0ssvv1xsXXXr1i1xG6GhoTp+/Ljy8vLk6+t72u0XbetM2y/tPpxa7+XlVex2z/z8/NOur0hWVpbGjRunW2+9tdiYn5+fIiMjlZSUpIULFyo+Pl4PP/ywXnnlFS1dutTq6ejRo6pevbr8/f3Puj0AwJkR8gAAuADatm2rL774QtHR0apWrXR/blu3bi1J2r59u/VzRapdu7YOHTpkvc7IyFBycrJLjY+PjwoKClzmtW3bVklJSWrUqNFp1+3v76+bbrpJN910k4YOHaqmTZtqy5Ytatu2raQ/H1LTpk2bctwbAKi6uF0TAIALYOjQoTp69Kj69eunNWvW6Ndff9X333+v++67r1hIKlK7dm21bdtWK1asqOBu/3Tttdfq448/1vLly7VlyxYNHDhQ3t7eLjXR0dFKSEhQSkqKjh07JkkaM2aMPvroI40bN07btm3Tjh07NGvWLP3rX/+S9OeTPj/44ANt3bpVu3fv1n//+1/5+/srKirKWu/y5cvVvXv3ittZALAxQh4AABdARESEfvzxRxUUFKh79+5q0aKFhg0bppCQkDN+4fcDDzygmTNnVmCn/2/06NHq0qWLbrzxRvXq1Uu9e/fWJZdc4lLz6quvKj4+XpGRkdaVt7i4OM2bN08//PCDrrzySl111VWaOHGiFeJCQkL03nvv6eqrr1bLli21cOFCffPNN6pVq5Yk6cCBA1q5cqXuu+++it1hALAphznTs5YBAECFysnJUZMmTfTpp58qJibG3e1UiFGjRunYsWN699133d0KANgCn8kDAMCD+Pv766OPPjrjl6bbTZ06dTR8+HB3twEAtsGVPAAAAACwET6TBwAAAAA2QsgDAAAAABsh5AEAAACAjRDyAAAAAMBGCHkAAAAAYCOEPAAAAACwEUIeAAAAANgIIQ8AAAAAbISQBwAAAAA2QsgDAAAAABsh5AEAAACAjRDyAAAAAMBGCHkAAAAAYCOEPAAAAACwEUIePMINN9ygBx980N1tSJLuuusu3Xnnne5uAwAAACgTQp6NTZ8+XQ6HQw6HQytWrCg2boxRZGSkHA6HbrzxRpexrKwsjR07Vs2bN1f16tVVq1YttW7dWo8//rgOHjxo1R06dEhPPfWUunXrposuukgOh0NLliw5pz5//PFH/fDDDxo1alSZ9rO8jRo1Sl988YU2bdrk7lYAAACAc0bIqwL8/Pz0ySefFJu/dOlS/fbbb3I6nS7z8/Pz1blzZ73yyivq1KmTXnvtNT399NNq27atPvnkE/38889WbVJSkl5++WUdOHBALVq0KFN/r7zyiq677jo1atSoTMuXtzZt2uiKK67Qq6++6u5WAAAAgHNWzd0N4MK74YYbNHv2bL355puqVu3/T/knn3yidu3a6Y8//nCpnzt3rjZs2KCZM2fq7rvvdhk7ceKE8vLyrNft2rXTkSNHVLNmTX3++ee64447zqm31NRUffvtt5o6depZa7Ozs1W9evVzWn9Z3XnnnRo7dqzefvttBQYGVsg2AQAAgPLAlbwqoF+/fjpy5Iji4+OteXl5efr888+LhThJ+vXXXyVJV199dbExPz8/BQUFWa8vuugi1axZs8y9ffvttzp58qRiY2Nd5hfdarp06VI9/PDDqlOnji6++GJJ0t69e/Xwww+rSZMm8vf3V61atXTHHXdoz5491vJpaWny9vbWm2++ac37448/5OXlpVq1askYY80fMmSIwsPDXbZ//fXXKzs72+WYAQAAAJUBIa8KiI6OVkxMjP73v/9Z8+bPn6/09HTdddddxeqjoqIkSR999JFLGLoQVq5cqVq1alnb/KuHH35Y27dv15gxY/TUU09JktasWaOVK1fqrrvu0ptvvqmHHnpICQkJ6tq1q44fPy5JCgkJUfPmzbVs2TJrXStWrJDD4dDRo0e1fft2a/7y5cvVqVMnl+02a9ZM/v7++vHHH8t7lwEAAIALits1q4i7775bo0ePVk5Ojvz9/TVz5kx16dJFERERxWp79+6tJk2aaMyYMfrggw/UrVs3derUSTfeeKPq1KlTrn3t3LlT0dHRpx2vWbOmEhIS5O3tbc3r1auXbr/9dpe6m266STExMfriiy80YMAASVKnTp30+eefWzXLly/XNddco507d2r58uW6/PLLrcA3ePBgl/VVq1ZNkZGRLmEQAAAAqAy4kldF3HnnncrJydG8efOUmZmpefPmlXirpiT5+/tr1apVGjlypKQ/b528//77VbduXT366KPKzc0tt76OHDmiGjVqnHb8wQcfdAl4Rf0Vyc/P15EjR9SoUSOFhIRo/fr11linTp10+PBhJSUlSfoz5HXu3FmdOnXS8uXLJf15dc8YU+xKniTVqFGj2OcVAQAAAE9HyKsiateurdjY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2223KTg4WO+8806Z9gsAAACVCyEPlUq3bt00ZcoU63X16tUlSZs2bdKPP/6oF154wRorKCjQiRMndPz4cQUEBGjhwoUaP368du7cqYyMDJ08edJl/HxdccUV1s/Z2dn69ddfdf/991vhTJJOnjyp4ODgM66nZcuW1s/e3t6qVauWWrRoYc0LCwuTJKWmplrzTr1V81RPP/20EhMTtXbtWvn7+5/7TgEAAKDSIeTB0imrU4VspyC/QLm7csu0bPXq1dWoUaNi87OysjRu3DiXK2lF/Pz8tGfPHt14440aMmSIXnjhBdWsWVMrVqzQ/fffr7y8vDOGPIfDIWOMy7z8/PwSezu1H0l677331KFDB5e6os8Qns5fH9jicDhc5jkcDklSYWGhJCkvL08LFizQ008/7bLcf//7X02cOFFLlixRvXr1zrhNAAAA2AchzwZyc3OVm/v/oSkjI6NM6/GufubwUW6K56Pz1rZtWyUlJZUYACVp3bp1Kiws1Kuvviovrz+fN/TZZ5+51Pj6+qqgoPjnBWvXru3yGbdffvlFx48fP2M/YWFhioiI0O7du9W/f/9z3Z1zsmTJEtWoUUOtWrWy5iUmJuqBBx7QO++8Y93SCQAAgKqBkGcD48eP17hx49zdhluNGTNGN954o+rXr6/bb79dXl5e2rRpk7Zu3ap///vfatSokfLz8zVp0iTddNNN+vHHHzV16lSXdURHRysrK0sJCQlq1aqVAgICFBAQoGuvvVZvvfWWYmJiVFBQoFGjRpXq6xHGjRunxx57TMHBwerRo4dyc3O1du1aHTt2TMOHDy+3ff/6669dbtVMSUlRnz59dNdddykuLk4pKSmS/ryC6IlfNwEAAIDyxVco2MDo0aOVnp5uTfv373d3SxUuLi5O8+bN0w8//KArr7xSV111lSZOnKioqChJfz7M5LXXXtPLL7+s5s2ba+bMmRo/frzLOjp27KiHHnpIffv2Ve3atTVhwgRJ0quvvqrIyEh16tRJd999t/7xj3+U6jN8DzzwgN5//319+OGHatGihbp06aLp06erQYMG5brvfw15O3fu1OHDhzVjxgzVrVvXmq688spy3S4AAAA8k8P89cNGqPQyMjIUHBys9PR0BQUFuYydOHFCycnJatCggfz8/NzSX0F+gY5v+vN2x4BWAfL2qaDbRG1o/fr1uvbaa/X7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CHkAAAAAYCOEPAAAAACwEUIeAAAAANgIIQ8AAAAAbISQBwAAAAA2QsizuenTp8vhcMjhcGjFihXFxo0xioyMlMPh0I033mjNz8rK0tixY9W8eXNVr15dtWrVUuvWrfX444/r4MGDVl1CQoIGDRqkSy+9VAEBAWrYsKEeeOABHTp0qEL2DwAAAICrau5uABXDz89Pn3zyia655hqX+UuXLtVvv/0mp9NpzcvPz1fnzp21c+dODRw4UI8++qiysrK0bds2ffLJJ+rTp48iIiIkSaNGjdLRo0d1xx13qHHjxtq9e7feeustzZs3Txs3blR4eHiF7icAAABQ1RHyqogbbrhBs2fP1ptvvqlq1f7/tH/yySdq166d/vjjD2ve3LlztWHDBs2cOVN33323y3pOnDihvLw86/Vrr72ma665Rl5e/39RuEePHurSpYveeust/fvf/76AewUAAADgr7hds4ro16+fjhw5ovj4eGteXl6ePv/882JB7tdff5UkXX311cXW4+fnp6CgIOt1586dXQJe0byaNWtqx44d5bkLAAAAAEqBkFdFREdHKyYmRv/73/+sefPnz1d6erruuusul9qoqChJ0kcffSRjzDlvKysrS1lZWQoNDT2/pgEAAACcM0JeFXL33Xdr7ty5ysnJkSTNnDlTXbp0sT5fV6R3795q0qSJxowZowYNGui+++7TtGnTlJqaWqrtvP7668rLy1Pfvn3LfR8AAAAAnBkhrwq58847lZOTo3nz5ikzM1Pz5s0rdqumJPn7+2vVqlUaOXKkpD+f0Hn//ferbt26evTRR5Wbm3vabSxbtkzjxo3TnXfeqWuvvfaC7QsAAACAkhHyqpDatWsrNjZWn3zyib788ksVFBTo9ttvL7E2ODhYEyZM0J49e7Rnzx598MEHatKkid566y09//zzJS6zc+dO9enTR82bN9f7779/IXcFAAAAwGkQ8qqYu+++W/Pnz9fUqVPVs2dPhYSEnHWZqKgoDRo0SD/++KNCQkI0c+bMYjX79+9X9+7dFRwcrO+++04XXXTRBegeAAAAwNkQ8qqYPn36yMvLSz/99FOJt2qeSY0aNXTJJZcU+6LzI0eOqHv37srNzdX333+vunXrlmfLAAAAAM4B35NXxQQGBmrKlCnas2ePbrrpphJrNm3apHr16hV7OubevXu1fft2NWnSxJqXnZ2tG264QQcOHNDixYvVuHHjC9o/AAAAgDMj5FVBAwcOPON4fHy8xo4dq5tvvllXXXWVAgMDtXv3bk2bNk25ubl69tlnrdr+/ftr9erVGjRokHbs2OHy3XiBgYHq3bv3BdoLAAAAACUh5KGY2267TZmZmfrhhx+0aNEiHT16VDVq1FD79u01YsQIdevWzarduHGjJGnatGmaNm2ay3qioqIIeQAAAEAFc5iyfNs1AAAAAMAj8eAVAAAAALARQh4AAAAA2AghDwAAAABshJAHAAAAADZCyAMAAAAAGyHkAQAAAICNuPV78qZMmaIpU6Zoz549kqTLL79cY8aMUc+ePSVJJ06c0IgRIzRr1izl5uYqLi5Ob7/9tsLCwqx17Nu3T0OGDNHixYsVGBiogQMHavz48apW7f93bcmSJRo+fLi2bdumyMhI/etf/9K9997r0svkyZP1yiuvKCUlRa1atdKkSZPUvn17a9yTejmbwsJCHTx4UBdddJEcDkeplwMAAADguYwxyszMVEREhLy8znC9zrjR119/bb799lvz888/m6SkJPP0008bHx8fs3XrVmOMMQ899JCJjIw0CQkJZu3ateaqq64yHTt2tJY/efKkad68uYmNjTUbNmww3333nQkNDTWjR4+2anbv3m0CAgLM8OHDzfbt282kSZOMt7e3WbBggVUza9Ys4+vra6ZNm2a2bdtmHnzwQRMSEmIOHz5s1XhSL2ezf/9+I4mJiYmJiYmJiYmJyYbT/v37z5gHPO7L0GvWrKlXXnlFt99+u2rXrq1PPvlEt99+uyRp586duuyyy5SYmKirrrpK8+fP14033qiDBw9aV9SmTp2qUaNG6ffff5evr69GjRqlb7/9Vlu3brW2cddddyktLU0LFiyQJHXo0EFXXnml3nrrLUl/XgmLjIzUo48+qqeeekrp6eke00tppKenKyQkRPv371dQUFCZzwUAAAAAz5GRkaHIyEilpaUpODj4tHVuvV3zVAUFBZo9e7ays7MVExOjdevWKT8/X7GxsVZN06ZNVb9+fStYJSYmqkWLFi63TMbFxWnIkCHatm2b2rRpo8TERJd1FNUMGzZMkpSXl6d169Zp9OjR1riXl5diY2OVmJgoSR7VS0lyc3OVm5trvc7MzJQkBQUFEfIAAAAAmznbR7Lc/uCVLVu2KDAwUE6nUw899JDm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3JoDAAAAAJybMoW8F198Uf7+/pKkxMREvfXWW5owYYJCQ0P1xBNPlGuDAAAAAIDSO6evUCiyf/9+NWrUSJI0d+5c3X777Ro8eLCuvvpqde3atTz7AwAAAACcgzJdyQsMDNSRI0ckST/88IOuv/56SZKfn59ycnLKrzsAAAAAwDkp05W866+/Xg888IDatGmjn3/+WTfccIMkadu2bYqOji7P/gAAAAAA56BMV/ImT56smJgY/f777/riiy9Uq1YtSdK6devUr1+/Uq9n2bJluummmxQRESGHw6G5c+e6jN97771yOBwuU48ePVxqjh49qv79+ysoKEghISG6//77lZWV5VKzefNmderUSX5+foqMjNSECROK9TJ79mw1bdpUfn5+atGihb777juXcWOMxowZo7p168rf31+xsbH65Zdf3NILAAAAAJxOmUJeSEiI3nrrLX311VcuoWvcuHH65z//Wer1ZGdnq1WrVpo8efJpa3r06KFDhw5Z0//+9z+X8f79+2vbtm2Kj4/XvHnztGzZMg0ePNgaz8jIUPfu3RUVFaV169bplVde0bPPPqt3333Xqlm5cqX69eun+++/Xxs2bFDv3r3Vu3dvbd261aqZMGGC3nzzTU2dOlWrVq1S9erVFRcXpxMnTlR4LwAAAABwOg5jjCnLgmlpaVq9erVSU1NVWFj4/yt0ODRgwIBzb8Th0Jw5c9S7d29r3r333qu0tLRiV/iK7NixQ82aNdOaNWt0xRVXSJIWLFigG264Qb/99psiIiI0ZcoU/fOf/1RKSop8fX0lSU899ZTmzp2rnTt3SpL69u2r7OxszZs3z1r3VVddpdatW2vq1KkyxigiIkIjRozQP/7xD0lSenq6wsLCNH36dN11110V1ktpZGRkKDg4WOnp6QoKCirVMkBllZ2drcDAQElSVlaWqlev7uaOAAAALozSvs8v05W8b775RvXr11ePHj30yCOP6PHHH3eZytOSJUtUp04dNWnSREOGDLEe+CL9+fUNISEhVqiSpNjYWHl5eWnVqlVWTefOna1QJUlxcXFKSkrSsWPHrJrY2FiX7cbFxSkxMVGSlJycrJSUFJea4OBgdejQwaqpqF4AAAAA4EzKFPJGjBihQYMGKSsrS2lpaTp27Jg1HT16tNya69Gjhz766CMlJCTo5Zdf1tKlS9WzZ08VFBRIklJS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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the first targeted mass feature\n", + "if len(lcms_obj.mass_features) > 0:\n", + " first_mf_id = list(lcms_obj.mass_features.keys())[0]\n", + " first_mf = lcms_obj.mass_features[first_mf_id]\n", + " \n", + " # Determine what to plot based on available data\n", + " to_plot = [\"EIC\", \"MS1\"]\n", + " if len(first_mf.ms2_mass_spectra) > 0:\n", + " to_plot.append(\"MS2\")\n", + " \n", + " print(f\"Plotting mass feature {first_mf_id}:\")\n", + " print(f\" m/z = {first_mf.mz:.4f}\")\n", + " print(f\" RT = {first_mf.retention_time:.4f} min\")\n", + " print(f\" Intensity = {first_mf.intensity:.2e}\")\n", + " \n", + " first_mf.plot(to_plot=to_plot, return_fig=False)" + ] + }, + { + "cell_type": "markdown", + "id": "d5721028", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This tutorial demonstrated:\n", + "\n", + "1. ✓ **Targeted Search Setup**: Defining target compounds with m/z and RT values\n", + "2. ✓ **Selective Peak Picking**: Finding only features matching target criteria\n", + "3. ✓ **Integration & Quantification**: Calculating areas and intensities for targets\n", + "4. ✓ **Target Verification**: Confirming recovery and measuring deviations\n", + "5. ✓ **MS2 Association**: Linking fragmentation spectra to target compounds\n", + "6. ✓ **Data Export**: Saving results with persistent metadata\n", + "7. ✓ **Visualization**: Plotting EICs and spectra for quality control\n", + "\n", + "### Key Advantages of Targeted Search\n", + "\n", + "- **Speed**: Much faster than untargeted analysis (only processes specific regions)\n", + "- **Sensitivity**: Can use optimized parameters for known compounds\n", + "- **Quality Control**: Easily verify internal standards and spike-in recovery\n", + "- **Quantification**: Direct integration of known compounds for quantitative workflows\n", + "- **Organization**: Target features labeled with 'type' attribute for easy filtering\n", + "\n", + "### Next Steps\n", + "\n", + "- Use targeted search for internal standard verification in batch processing\n", + "- Combine with spectral library matching for MS2 confirmation\n", + "- Apply to quality control workflows for method validation\n", + "- Integrate with quantitative workflows for targeted metabolomics\n", + "\n", + "### Parameters to Adjust\n", + "\n", + "- `mz_tolerance_ppm`: Increase for lower resolution instruments or uncertain m/z values\n", + "- `rt_tolerance`: Increase for chromatographic drift or gradient variations\n", + "- `type`: Change label to categorize different target groups (e.g., \"QC\", \"IS\", \"metabolite\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/support_code/nmdc/lipidomics/lipidomics_workflow.py b/support_code/nmdc/lipidomics/lipidomics_workflow.py index 8f87da57a..7097f8ce7 100644 --- a/support_code/nmdc/lipidomics/lipidomics_workflow.py +++ b/support_code/nmdc/lipidomics/lipidomics_workflow.py @@ -198,6 +198,7 @@ def add_mass_features(myLCMSobj, scan_translator): # Count and report how many mass features are left after integration print("Number of mass features after integration: ", len(myLCMSobj.mass_features)) #filter_and_plot_mass_features(myLCMSobj) + """ print("Annotating C13 mass features") myLCMSobj.find_c13_mass_features() print("Deconvoluting mass features") @@ -212,6 +213,7 @@ def add_mass_features(myLCMSobj, scan_translator): myLCMSobj.add_associated_ms2_dda( spectrum_mode="centroid", ms_params_key=param_key, scan_filter=scan_filter ) + """ def filter_and_plot_mass_features(myLCMSobj): """Filter mass features based on peak shape metrics and plot composite feature map @@ -630,7 +632,7 @@ def run_lipid_workflow( db_location, scan_translator=None, verbose=True, - ms1_molecular_search=True, + ms1_molecular_search=False, # whether to do ms1 molecular search cores=1, ): """Run lipidomics workflow @@ -659,10 +661,16 @@ def run_lipid_workflow( files_list = list(file_dir.glob("*.raw")) out_paths_list = [out_dir / f.stem for f in files_list] - # Run signal processing, get associated ms1, add associated ms2, do ms1 molecular search, and export temp results + # Run signal processing, get associated ms1, add associated ms2, and export temp results if cores == 1 or len(files_list) == 1: mz_dicts = [] for file_in, file_out in list(zip(files_list, out_paths_list)): + # Check if folder already exists + if Path(str(file_out) + ".corems").exists(): + print("File already exists, skipping: ", file_out) + continue + if verbose: + print("Processing file: ", file_in) mz_dict = run_lipid_sp_ms1( file_in=str(file_in), out_path=str(file_out), @@ -688,6 +696,9 @@ def run_lipid_workflow( mz_dicts = pool.starmap(run_lipid_sp_ms1, args) pool.close() pool.join() + + # Skip ms2 spectral search for now, will add back once we have an improved MetabRefLCInterface method + """ # Prepare ms2 spectral search space metadata = prep_metadata(mz_dicts, out_dir, db_location) @@ -703,16 +714,17 @@ def run_lipid_workflow( mz_dicts = pool.starmap(run_lipid_ms2, args) pool.close() pool.join() + """ print("Finished processing, data are written in " + str(out_dir)) if __name__ == "__main__": # Set input variables to run - cores = 1 - file_dir = Path("/Users/heal742/LOCAL/corems_dev/corems/tmp_data/thermo_raw_mini") - out_dir = Path("tmp_data/_test_250115") + cores = 5 + file_dir = Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test") + out_dir = Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test_out2") params_toml = Path( - "/Users/heal742/LOCAL/05_NMDC/02_MetaMS/data_processing/configurations/emsl_lipidomics_corems_params.toml" + "/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/blanchard_collection_params.toml" ) db_location = Path("tests/tests_data/lcms/202412_lipid_ref.sqlite") verbose = True diff --git a/support_code/nmdc/lipidomics/manifest_examples.py b/support_code/nmdc/lipidomics/manifest_examples.py new file mode 100644 index 000000000..50913abcc --- /dev/null +++ b/support_code/nmdc/lipidomics/manifest_examples.py @@ -0,0 +1,49 @@ +""" +Simple examples demonstrating manifest creation utility usage. +""" + +from pathlib import Path +from corems.mass_spectra.input.corems_hdf5 import create_manifest_from_folder + +# Example 1: Basic usage - auto-select middle sample as center +print("Example 1: Basic usage (middle sample as center)") +print("-" * 60) +manifest_path = create_manifest_from_folder( + folder_path=Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test_out2"), + overwrite=True +) +print() + +# Example 2: Custom batch threshold +print("Example 2: Custom batch threshold (24 hours)") +print("-" * 60) +manifest_path = create_manifest_from_folder( + folder_path=Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test_out2"), + output_path=Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test_out2/manifest_24hr.csv"), + batch_time_threshold_hours=24.0, + overwrite=True +) +print() + +# Example 3: Specify a sample as center by name +print("Example 3: Specify center sample by name") +print("-" * 60) +manifest_path = create_manifest_from_folder( + folder_path=Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test_out2"), + output_path=Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test_out2/manifest_custom_center.csv"), + batch_time_threshold_hours=12.0, + center_name="Blanch_Nat_Lip_C_7_AB_M_07_POS_23Jan18_Brandi-WCSH5801", + overwrite=True +) +print() + +# Example 4: Strict batch threshold +print("Example 4: Strict batch threshold (1 hour)") +print("-" * 60) +manifest_path = create_manifest_from_folder( + folder_path=Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test_out2"), + output_path=Path("/Volumes/LaCie/nmdc_data/collection_testing/blanchard_lipid/mini_collection_test_out2/manifest_1hr.csv"), + batch_time_threshold_hours=1.0, + overwrite=True +) +print() diff --git a/support_code/nmdc/metabolomics/lcms_metabolomics_targeted_search.py b/support_code/nmdc/metabolomics/lcms_metabolomics_targeted_search.py new file mode 100644 index 000000000..6d52c0282 --- /dev/null +++ b/support_code/nmdc/metabolomics/lcms_metabolomics_targeted_search.py @@ -0,0 +1,59 @@ +from pathlib import Path + +from corems.mass_spectra.input.rawFileReader import ImportMassSpectraThermoMSFileReader + +if __name__ == "__main__": + # ============================================================================= + # Configuration + # ============================================================================= + # Paths + base_path = Path("/Volumes/LaCie/nmdc_data/collection_testing/dev_test/") + raw_data_path = base_path / "raw" + + # Instantiate LCMS object from the first raw data file + first_raw_file = next(raw_data_path.glob("*.raw")) + parser = ImportMassSpectraThermoMSFileReader(first_raw_file) + lcms_obj = parser.get_lcms_obj(spectra="ms1") + assert lcms_obj is not None, "Failed to instantiate LCMS object." + + # Set parameters for the LCMS object, only ones we NEED to change from defaults to get things running + # Ideally, we'd load carefully chosen parameters from a file here for general processing + lcms_obj.parameters.mass_spectrum['ms2'].mass_spectrum.noise_threshold_method = "relative_abundance" + + # Prepare a search dictionary for a targeted search + # Here are examples of target m/z and RT values + # Could derive this from an msp file. + target_mz_list = [254.2837, 521.325988, 503.3134460449219, 317.2839050292969] + target_rt_list = [5.83, 6.017, 7.977, 6.8658] + target_search_dict = { + "target_mz_list": target_mz_list, + "target_rt_list": target_rt_list, + "mz_tolerance_ppm": 5, #QUESTION: per target or bulk? + "rt_tolerance": 0.5, #QUESTION: per target or bulk? + "type": "internal standard" + } + + # Look for mass features in targeted search mode + lcms_obj.find_mass_features( + targeted_search = True, + target_search_dict=target_search_dict + ) + # Alternatively, or additionally, we could do normal (untargeted) mass feature finding here + # lcms_obj.find_mass_features(targeted_search=False) + + # Let's integrate, add ms1 and ms2 + lcms_obj.integrate_mass_features() + lcms_obj.add_associated_ms1(use_parser=False, spectrum_mode="profile") + lcms_obj.add_associated_ms2_dda(use_parser=True, spectrum_mode="centroid") + + # Here we could do formula searching and MS2 search against a MSP database with MS2s of the search targets (similar to test_lcms_metabolomics.py) + # BUT, we'd need a compatible msp file, and that's not ultra simple + + # Check out the mass feature dataframe + mf_df = lcms_obj.mass_features_to_df(drop_na_cols=True) + + # Can visualize as well + lcms_obj.mass_features[0].plot(return_fig = False) + + # Here could do some post-processing to report the "best hit" per target and include confidence metrics + print("here") \ No newline at end of file diff --git a/support_code/nmdc/metabolomics/metabolomics_collection.py b/support_code/nmdc/metabolomics/metabolomics_collection.py new file mode 100644 index 000000000..13997d221 --- /dev/null +++ b/support_code/nmdc/metabolomics/metabolomics_collection.py @@ -0,0 +1,628 @@ +from pathlib import Path +import time +import pandas as pd +import numpy as np +from multiprocessing import Pool +import shutil + +from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectraCollection +from corems.mass_spectra.output.export import LCMSMetabolomicsExport +from corems.mass_spectra.input.rawFileReader import ImportMassSpectraThermoMSFileReader +from corems.encapsulation.factory.parameters import LCMSParameters +from corems.molecular_id.search.database_interfaces import MSPInterface +from corems.encapsulation.factory.parameters import hush_output +from corems.mass_spectra.output.export import LCMSCollectionExport + +""" +Example showing the new pipeline-based sample processing approach. + +The new approach combines multiple sample-level operations (gap-filling, +feature reloading, MS1/MS2 searches, etc.) into a single parallelized pass, +which is more efficient than processing samples multiple times. + +Two usage patterns: +1. High-level convenience method: process_consensus_features() +2. Advanced pipeline builder: process_samples_pipeline() with custom operations +""" + +def summarize_processing_results(lcms_collection): + """ + Summarize the processing state of the LCMS collection. + + Reports on completed processing steps by inspecting the collection + and sample objects directly. Useful for verifying which operations + were performed during process_consensus_features(). + + Parameters + ---------- + lcms_collection : LCMSCollection + The LCMS collection to summarize + """ + print("\n" + "="*60) + print("LCMS Collection Processing Summary") + print("="*60) + + # Basic collection info + n_samples = len(lcms_collection) + + # Collection-level feature count (from dataframe - all features) + collection_mf_count = len(lcms_collection.mass_features_dataframe) if lcms_collection.mass_features_dataframe is not None else 0 + + # Sample-level loaded feature count (from mass_features dict - loaded for processing) + loaded_mf_count = sum(len(lcms_obj.mass_features) for lcms_obj in lcms_collection) + + # Cluster count + if 'cluster' in lcms_collection.mass_features_dataframe.columns: + n_clusters = lcms_collection.mass_features_dataframe['cluster'].nunique() + else: + n_clusters = 0 + + print(f"\nSamples: {n_samples}") + print(f"Total features in collection: {collection_mf_count}") + print(f"Representative features loaded: {loaded_mf_count}") + if n_clusters > 0: + print(f"Consensus clusters: {n_clusters}") + + # Gap filling - check for induced mass features + induced_counts = [len(lcms_obj.induced_mass_features) for lcms_obj in lcms_collection] + total_induced = sum(induced_counts) + samples_with_induced = sum(1 for c in induced_counts if c > 0) + if total_induced > 0: + print(f"\nGap Filling: ✓ Complete") + print(f" {samples_with_induced}/{n_samples} samples have induced features ({total_induced} total)") + + # Feature loading - check if mass features have MS1/MS2 spectra + mf_with_ms1 = 0 + mf_with_ms2 = 0 + mf_with_ms2_scans = 0 + total_ms2_spectra = 0 + total_ms2_scan_numbers = 0 + + for lcms_obj in lcms_collection: + for mf in lcms_obj.mass_features.values(): + if hasattr(mf, 'mass_spectrum') and mf.mass_spectrum is not None: + mf_with_ms1 += 1 + if hasattr(mf, 'ms2_mass_spectra') and mf.ms2_mass_spectra: + mf_with_ms2 += 1 + total_ms2_spectra += len(mf.ms2_mass_spectra) + if hasattr(mf, 'ms2_scan_numbers') and mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0: + mf_with_ms2_scans += 1 + total_ms2_scan_numbers += len(mf.ms2_scan_numbers) + + if mf_with_ms1 > 0 or mf_with_ms2 > 0: + print(f"\nMS Data Association: ✓ Complete") + if mf_with_ms1 > 0: + print(f" MS1: {mf_with_ms1}/{loaded_mf_count} loaded features ({mf_with_ms1/loaded_mf_count*100:.1f}%)") + if mf_with_ms2 > 0: + print(f" MS2: {mf_with_ms2}/{loaded_mf_count} loaded features ({total_ms2_spectra} spectra)") + if mf_with_ms2_scans > 0: + print(f" MS2 scan numbers: {mf_with_ms2_scans}/{loaded_mf_count} loaded features ({total_ms2_scan_numbers} scans)") + + # Molecular formula search + mf_with_formulas = 0 + total_formulas = 0 + + for lcms_obj in lcms_collection: + for mf in lcms_obj.mass_features.values(): + if hasattr(mf, 'mass_spectrum') and mf.mass_spectrum is not None: + try: + ms1_peak = mf.ms1_peak + if hasattr(ms1_peak, 'molecular_formulas') and ms1_peak.molecular_formulas: + mf_with_formulas += 1 + total_formulas += len(ms1_peak.molecular_formulas) + except (AttributeError, IndexError): + pass + + if mf_with_formulas > 0: + print(f"\nMolecular Formula Search: ✓ Complete") + print(f" {mf_with_formulas}/{loaded_mf_count} loaded features assigned ({total_formulas} total formulas)") + print(f" Average {total_formulas/mf_with_formulas:.1f} formulas per feature") + + # MS2 spectral search + mf_with_spectral_matches = 0 + total_spectral_matches = 0 + scans_searched = 0 + + for lcms_obj in lcms_collection: + if hasattr(lcms_obj, 'spectral_search_results') and lcms_obj.spectral_search_results: + scans_searched += len(lcms_obj.spectral_search_results) + + for mf in lcms_obj.mass_features.values(): + if hasattr(mf, 'ms2_similarity_results') and mf.ms2_similarity_results: + mf_with_spectral_matches += 1 + total_spectral_matches += len(mf.ms2_similarity_results) + + if mf_with_spectral_matches > 0: + print(f"\nMS2 Spectral Search: ✓ Complete") + print(f" {scans_searched} MS2 scans with library search results") + print(f" {mf_with_spectral_matches}/{loaded_mf_count} loaded features matched ({total_spectral_matches} total matches)") + if hasattr(lcms_collection, 'spectral_search_molecular_metadata'): + print(f" Library size: {len(lcms_collection.spectral_search_molecular_metadata)} entries") + + # Check for loaded EICs + total_eics_loaded = 0 + samples_with_eics = 0 + + for lcms_obj in lcms_collection: + if hasattr(lcms_obj, 'eics') and lcms_obj.eics: + samples_with_eics += 1 + total_eics_loaded += len(lcms_obj.eics) + + if total_eics_loaded > 0: + print(f"\nEIC Loading: ✓ Complete") + print(f" {total_eics_loaded} EICs loaded across {samples_with_eics}/{n_samples} samples") + print(f" Average {total_eics_loaded/samples_with_eics:.1f} EICs per sample") + + # Memory management check + raw_data_present = any(1 in lcms_obj._ms_unprocessed and not lcms_obj._ms_unprocessed[1].empty + for lcms_obj in lcms_collection) + if not raw_data_present: + print(f"\nMemory: ✓ Raw MS1 data cleaned") + + print("\n" + "="*60) + + +def validate_save_load(lcms_collection, collection_save_path, ncores=1): + """ + Validate that the LCMS collection can be saved and reloaded correctly. + + This function tests the save/load functionality by reloading the collection + from HDF5 and comparing various attributes to ensure data integrity. + + Parameters + ---------- + lcms_collection : LCMSCollection + The original LCMS collection to validate against + collection_save_path : Path + Path to the saved collection HDF5 file (without extension) + ncores : int, optional + Number of cores to use for loading. Default is 1. + + Returns + ------- + LCMSCollection + The reloaded LCMS collection for further testing + """ + print("\n" + "="*60) + print("SAVE/LOAD VALIDATION TEST") + print("="*60) + + # Reload the collection from HDF5 + from corems.mass_spectra.input.corems_hdf5 import ReadSavedLCMSCollection + reader = ReadSavedLCMSCollection( + collection_hdf5_path=str(collection_save_path.with_suffix('.hdf5')), + cores=ncores) + lcms_collection2 = reader.get_lcms_collection( + load_raw=False, load_light=True, + load_representatives=True, load_eics=True, + load_ms1=True, load_ms2=True) + + # Check that len of mass features matches + mf_count_1 = len(lcms_collection.mass_features_dataframe) + mf_count_2 = len(lcms_collection2.mass_features_dataframe) + if mf_count_1 == mf_count_2: + print(f"✓ Mass feature count matches after reload: {mf_count_1}") + else: + print(f"✗ Mass feature count mismatch after reload! Original: {mf_count_1}, Reloaded: {mf_count_2}") + + # Check that the len of induced mass features matches + induced_count_1 = len(lcms_collection.induced_mass_features_dataframe) + induced_count_2 = len(lcms_collection2.induced_mass_features_dataframe) + if induced_count_1 == induced_count_2: + print(f"✓ Induced mass feature count matches after reload: {induced_count_1}") + else: + print(f"✗ Induced mass feature count mismatch after reload! Original: {induced_count_1}, Reloaded: {induced_count_2}") + + # Check that the len of loaded EICs matches + total_eics_1 = sum(len(lcms_obj.eics) for lcms_obj in lcms_collection) + total_eics_2 = sum(len(lcms_obj.eics) for lcms_obj in lcms_collection2) + if total_eics_1 == total_eics_2: + print(f"✓ Total loaded EIC count matches after reload: {total_eics_1}") + else: + print(f"✗ Total loaded EIC count mismatch after reload! Original: {total_eics_1}, Reloaded: {total_eics_2}") + + # Check that the _ms dictionary matches (mass spectra loaded) + total_ms_1 = sum(len(lcms_obj._ms) for lcms_obj in lcms_collection) + total_ms_2 = sum(len(lcms_obj._ms) for lcms_obj in lcms_collection2) + if total_ms_1 == total_ms_2: + print(f"✓ Total loaded mass spectra (_ms) count matches after reload: {total_ms_1}") + else: + print(f"✗ Total loaded mass spectra (_ms) count mismatch after reload! Original: {total_ms_1}, Reloaded: {total_ms_2}") + + # Check that we can replot the first cluster from the reloaded collection + if len(lcms_collection2.cluster_summary_dataframe) > 0: + first_cluster_id = lcms_collection2.cluster_summary_dataframe.index[0] + print(f"Re-plotting cluster {first_cluster_id} from reloaded collection") + lcms_collection2.plot_cluster( + cluster_id=first_cluster_id, + to_plot=["EIC", "MS1", "MS2"], + plot_smoothed_eic=False, + plot_eic_datapoints=False + ) + + # Check that scan numbers in _ms match for first sample + if len(lcms_collection) > 0 and len(lcms_collection2) > 0: + ms_scans_1 = set(lcms_collection[0]._ms.keys()) + ms_scans_2 = set(lcms_collection2[0]._ms.keys()) + if ms_scans_1 == ms_scans_2: + print(f"✓ Mass spectra scan numbers match for first sample: {len(ms_scans_1)} scans") + else: + print(f"✗ Mass spectra scan numbers mismatch for first sample!") + print(f" Original: {len(ms_scans_1)} scans, Reloaded: {len(ms_scans_2)} scans") + only_in_1 = ms_scans_1 - ms_scans_2 + only_in_2 = ms_scans_2 - ms_scans_1 + if only_in_1: + print(f" Only in original: {list(sorted(only_in_1))[:10]}...") + if only_in_2: + print(f" Only in reloaded: {list(sorted(only_in_2))[:10]}...") + + print("\n" + "="*60) + + return lcms_collection2 + + +def preprocess_raw_samples(raw_data_path, processed_folder, ncores=1, reprocess=False): + """ + Preprocess raw LCMS sample files into HDF5 format. + + Parameters + ---------- + raw_data_path : Path + Path to folder containing raw data files + processed_folder : Path + Path to folder where processed HDF5 files will be saved + ncores : int, optional + Number of cores to use for parallel processing. Default is 1. + reprocess : bool, optional + If True, deletes existing processed folder and reprocesses all files. + If False, skips preprocessing. Default is False. + + Returns + ------- + list or None + List of processed HDF5 file paths if reprocess=True, None otherwise + """ + if not reprocess: + print("\n=== Skipping sample preprocessing (using existing processed data) ===") + return None + + # Delete existing processed dir if reprocessing + if processed_folder.exists(): + shutil.rmtree(processed_folder) + + # Create processed folder + processed_folder.mkdir(parents=True, exist_ok=True) + + # Find all raw files (adjust extension based on your data format) + raw_files = list(raw_data_path.glob("*.raw")) + list(raw_data_path.glob("*.mzML")) + + if not raw_files: + raise ValueError(f"No raw files found in {raw_data_path}") + + print(f"\n=== Preprocessing {len(raw_files)} samples in parallel using {ncores} cores ===") + start_time = time.time() + + # Get configured parameters once (will be shared across all workers) + params = get_configured_lcms_parameters() + + # Prepare arguments for parallel processing + process_args = [(raw_file, processed_folder, params) for raw_file in raw_files] + + # Process samples in parallel + with Pool(processes=ncores) as pool: + processed_files = pool.map(process_single_sample, process_args) + + print(f"Preprocessing complete: {time.time() - start_time:.1f} seconds using {ncores} cores") + print(f"Processed {len(processed_files)} samples\n") + + return processed_files + + +def get_configured_lcms_parameters(): + """ + Create and configure LCMSParameters for sample processing. + + Returns + ------- + LCMSParameters + Configured parameters object with all processing settings + """ + # Suppress verbose output before creating parameters + hush_output() + + # Create parameters (use_defaults=False respects hush_output) + params = LCMSParameters() + + # Persistent homology parameters + params.lc_ms.peak_picking_method = "persistent homology" + params.lc_ms.ph_inten_min_rel = 0.0005 + params.lc_ms.ph_persis_min_rel = 0.01 + params.lc_ms.ph_smooth_it = 0 + params.lc_ms.ms2_min_fe_score = 0.3 + params.lc_ms.ms1_scans_to_average = 1 + + # MSParameters for ms1 mass spectra + ms1_params = params.mass_spectrum['ms1'] + ms1_params.mass_spectrum.noise_threshold_method = "relative_abundance" + ms1_params.mass_spectrum.noise_threshold_min_relative_abundance = 0.1 + ms1_params.mass_spectrum.noise_min_mz = 0 + ms1_params.mass_spectrum.min_picking_mz = 0 + ms1_params.mass_spectrum.noise_max_mz = np.inf + ms1_params.mass_spectrum.max_picking_mz = np.inf + ms1_params.ms_peak.legacy_resolving_power = False + ms1_params.molecular_search.url_database = "" + ms1_params.molecular_search.usedAtoms = { + 'C': (5, 30), + 'H': (18, 200), + 'O': (1, 23), + 'N': (0, 3), + 'P': (0, 1), + 'S': (0, 1), + } + + # Settings for ms2 data (HCD scans) + ms2_params_hcd = ms1_params.copy() + params.mass_spectrum['ms2'] = ms2_params_hcd + + # Reporting settings + params.lc_ms.export_eics = True + params.lc_ms.export_profile_spectra = False + + # Peak metrics filtering settings + params.lc_ms.remove_mass_features_by_peak_metrics = True + params.lc_ms.mass_feature_attribute_filter_dict = { + 'dispersity_index': {'value': 0.5, 'operator': '<'} + } + + return params + + +def process_single_sample(args): + """ + Process a single LCMS sample file. + + Parameters, params) + + Returns + ------- + str + Path to the processed HDF5 file + """ + raw_file_path, processed_folder, params = args + + # Import the raw data + print(f"Processing {raw_file_path.name}...\n") + parser = ImportMassSpectraThermoMSFileReader(str(raw_file_path)) + lcms_obj = parser.get_lcms_obj(spectra="ms1") + + # Use the pre-configured parameters + lcms_obj.parameters = params + + # Get configured parameters + lcms_obj.parameters = get_configured_lcms_parameters() + + # Use persistent homology to find mass features in the lc-ms data and integrate + # Assign MS2 scan numbers during peak picking for choosing representatives with MS2 + lcms_obj.find_mass_features(assign_ms2_scans=True, ms2_scan_filter=None) + lcms_obj.integrate_mass_features(drop_if_fail=True) + + # Add peak metrics and filter mass features based on the new parameters + lcms_obj.add_peak_metrics(remove_by_metrics=True) + + # Save to HDF5 + output_name = raw_file_path.stem + exporter = LCMSMetabolomicsExport(str(processed_folder / output_name), lcms_obj) + exporter.to_hdf(overwrite=True) + + print(f"✓ Completed {raw_file_path.name} - {len(lcms_obj.mass_features)} mass features") + return str(processed_folder / f"{output_name}.hdf5") + +if __name__ == "__main__": + # ============================================================================= + # Configuration + # ============================================================================= + ncores = 1 + reprocess_samples = False # Set to True to reprocess raw data + perform_ms2_search = True # Set to True to perform MS2 spectral library search + + # Paths + base_path = Path("/Volumes/LaCie/nmdc_data/collection_testing/dev_test/") + collection_save_path = base_path / "collection" + raw_data_path = base_path / "raw" + processed_folder = base_path / "processed2" + msp_file_location = Path("/Users/heal742/LOCAL/05_NMDC/02_MetaMS/metams/test_data/test_lcms_metab_data/20250407_database.msp") + new_raw_data_path = raw_data_path # Update raw file paths in collection if moved after the first step + + # ============================================================================= + # Step 1: Preprocess Individual Samples (Optional) + # ============================================================================= + if reprocess_samples: + print("\n=== Preprocessing Raw Samples and Doing Initial Peak Picking===") + preprocess_raw_samples( + raw_data_path=raw_data_path, + processed_folder=processed_folder, + ncores=ncores, + reprocess=reprocess_samples + ) + + # ============================================================================= + # Step 2: Load LCMS Collection + # ============================================================================= + print("\n=== Loading LCMS Collection ===") + parser = ReadCoreMSHDFMassSpectraCollection( + folder_location=processed_folder, + cores=ncores + ) + print(f"Found {len(parser.manifest)} samples") + + # Load collection (light loading for efficiency) + start_time = time.time() + lcms_collection = parser.get_lcms_collection(load_raw=False, load_light=True) + print(f"Loaded in {time.time() - start_time:.1f} seconds") + print(f"Total mass features: {len(lcms_collection.mass_features_dataframe)}") + + # Update raw file locations + lcms_collection.update_raw_file_locations(new_raw_folder=str(new_raw_data_path)) + + # ============================================================================= + # Step 3: Align Retention Times Across Samples + # ============================================================================= + print("\n=== Aligning Retention Times ===") + start_time = time.time() + lcms_collection.align_lcms_objects() + print(f"Alignment complete: {time.time() - start_time:.1f} seconds") + + # ============================================================================= + # Step 4: Generate Consensus Mass Features + # ============================================================================= + print("\n=== Generating Consensus Mass Features ===") + start_time = time.time() + lcms_collection.add_consensus_mass_features() + print(f"Generated {len(lcms_collection.cluster_summary_dataframe)} consensus clusters") + print(f"Consensus generation: {time.time() - start_time:.1f} seconds") + + # ============================================================================= + # Step 5: Prepare MS2 Spectral Library + # ============================================================================= + if perform_ms2_search: + print("\n=== Preparing MS2 Spectral Library ===") + my_msp = MSPInterface(file_path=msp_file_location) + spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( + polarity="positive", + format="flashentropy", + normalize=True, + fe_kwargs={ + "normalize_intensity": True, + "min_ms2_difference_in_da": 0.02, + "max_ms2_tolerance_in_da": 0.01, + "max_indexed_mz": 3000, + "precursor_ions_removal_da": None, + "noise_threshold": 0, + }, + ) + print(f"Loaded spectral library: {len(molecular_metadata)} entries") + else: + spectral_lib = None + molecular_metadata = None + print("Skipping MS2 spectral library preparation") + + # ============================================================================= + # Step 6: Process Consensus Features with Integrated Pipeline + # ============================================================================= + print("\n=== Processing Consensus Features ===") + start_time = time.time() + pipeline_results = lcms_collection.process_consensus_features( + load_representatives=True, + perform_gap_filling=True, + add_ms1=True, + add_ms2=True, + molecular_formula_search=True, + ms2_spectral_search=perform_ms2_search, + spectral_lib=spectral_lib, + molecular_metadata=molecular_metadata, + gather_eics=True, + keep_raw_data=False + ) + print(f"Pipeline complete: {time.time() - start_time:.1f} seconds using {ncores} cores") + + # ============================================================================= + # Step 6.5: Test Consensus Cluster Plotting + # ============================================================================= + print("\n=== Testing Consensus Cluster Plotting ===") + + # Plot the first cluster as a test + if len(lcms_collection.cluster_summary_dataframe) > 0: + first_cluster_id = lcms_collection.cluster_summary_dataframe.index[0] + # 3116 is a good one to look at :) + print(f"Plotting cluster {first_cluster_id}") + lcms_collection.plot_cluster( + cluster_id=first_cluster_id, + to_plot=["EIC", "MS1", "MS2"], + plot_smoothed_eic=False, + plot_eic_datapoints=False + ) + + # ============================================================================= + # Step 7: Summarize Processing Results + # ============================================================================= + summarize_processing_results(lcms_collection) + + + # ============================================================================= + # Step 8: Save and Export Results + # ============================================================================= + print("\n=== Exporting LCMS Collection ===") + # Create pivot tables summarizing the collection across samples + pivot_table_intensity = lcms_collection.collection_pivot_table(attribute='intensity', verbose=False) + pivot_table_ids = lcms_collection.collection_pivot_table(verbose=False) + pivot_table_intensity.to_csv("example_collection_pivot_intensity.csv") + + # Describe each cluster with its representative mass feature + cluster_reps = lcms_collection.cluster_representatives_table() + cluster_reps.to_csv("example_cluster_representatives.csv", index=False) + print(f"Cluster representatives table: {len(cluster_reps)} clusters") + + # Summarize the annotations for each cluster + feature_annotations = lcms_collection.feature_annotations_table( + molecular_metadata=molecular_metadata, + drop_unannotated=True + ) + print(f"Feature annotations table: {len(feature_annotations)} rows across {feature_annotations['cluster'].nunique()} clusters") + + + # Plot the first mass feature with an annotation with MS2_mirror + if not feature_annotations.empty: + # Find first annotated cluster with non-null value in ref_ms_id + first_annotated_cluster = feature_annotations.loc[ + feature_annotations['ref_ms_id'].notnull(), 'cluster' + ].iloc[0] + print(f"Plotting annotated feature for cluster {first_annotated_cluster}") + lcms_collection.plot_cluster( + cluster_id=first_annotated_cluster, + to_plot=["EIC", "MS1", "MS2_mirror"], + molecular_metadata=molecular_metadata, + spectral_library=spectral_lib + ) + + # Save the feature annotations table to CSV for inspection + feature_annotations.to_csv("example_feature_annotations.csv", index=False) + + # Save the entire collection to HDF5 + exporter = LCMSCollectionExport( + out_file_path=str(collection_save_path), + mass_spectra_collection=lcms_collection) + exporter.export_to_hdf5(overwrite=True, save_parameters=True, parameter_format="toml") + + # ============================================================================= + # Step 9: Validate Save/Load Functionality + # ============================================================================= + lcms_collection2 = validate_save_load(lcms_collection, collection_save_path, ncores=ncores) + + + """ + # Make some more plots + lcms_collection.plot_mz_features_across_samples() + lcms_collection.plot_mz_features_per_cluster() + lcms_collection.plot_consensus_mz_features() ## zoomed out + lcms_collection.plot_consensus_mz_features(xb = 10, xt = 15, yb = 500, yt = 600) ## zoomed in + lcms_collection.cluster_inspection_plot(0) + + dim_list = [ + 'mz', + 'scan_time_aligned', + 'half_height_width', + 'tailing_factor', + 'dispersity_index', + 'intensity', + 'persistence' + ] + lcms_collection.plot_cluster_outlier_frequency(dim_list, clu_size_thresh = 0.25) + + # Create pivot tables and reports + results = lcms_collection.collection_pivot_table() + results1 = lcms_collection.collection_pivot_table(attribute = 'intensity') + results2 = lcms_collection.collection_consensus_report(how = 'intensity') + results3 = lcms_collection.collection_consensus_report(how = 'median') + + #TODO KRH: Add visualization of matched spectrum with consensus mass feature + """ \ No newline at end of file diff --git a/tests/test_lcms_collection.py b/tests/test_lcms_collection.py new file mode 100644 index 000000000..1141a52cf --- /dev/null +++ b/tests/test_lcms_collection.py @@ -0,0 +1,853 @@ +# %% Import libs +import numpy as np +import pytest +import pandas as pd +import copy as copy + +from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectraCollection +from corems.mass_spectra.output.export import LCMSMetabolomicsExport, LCMSCollectionExport +from corems.encapsulation.factory.parameters import LCMSParameters, LCMSCollectionParameters +from corems.molecular_id.search.database_interfaces import MSPInterface + + +@pytest.fixture(scope="module") +def lcms_collection_folder(tmp_path_factory, lcms_obj): + """ + Creates a temporary folder with processed LCMS objects for collection testing. + + This fixture creates 3 samples with different levels of mass features: + - Sample 1: Full set of mass features (all found features) + - Sample 2: Partial set (first 50 mass features only) + - Sample 3: No mass features (tests gap filling on completely empty sample) + + This setup allows comprehensive testing of gap filling functionality. + """ + # Create a temporary folder for processed data (module-safe) + processed_folder = tmp_path_factory.mktemp("processed_lcms_collection") + + # Set parameters on the LCMS object that are reasonable for testing + lcms_obj.parameters = LCMSParameters(use_defaults=True) + + # Set persistent homology parameters for fast testing + lcms_obj.parameters.lc_ms.peak_picking_method = "persistent homology" + lcms_obj.parameters.lc_ms.ph_inten_min_rel = 0.001 + lcms_obj.parameters.lc_ms.ph_persis_min_rel = 0.05 + lcms_obj.parameters.lc_ms.ph_smooth_it = 0 + lcms_obj.parameters.lc_ms.ms1_scans_to_average = 3 + + # MS1 parameters for quick testing + ms1_params = lcms_obj.parameters.mass_spectrum['ms1'] + ms1_params.mass_spectrum.noise_threshold_method = "relative_abundance" + ms1_params.mass_spectrum.noise_threshold_min_relative_abundance = 0.1 + ms1_params.mass_spectrum.noise_min_mz = 0 + ms1_params.mass_spectrum.min_picking_mz = 0 + ms1_params.mass_spectrum.noise_max_mz = np.inf + ms1_params.mass_spectrum.max_picking_mz = np.inf + + # Process SAMPLE 1: Find and integrate mass features (FULL) + lcms_obj.find_mass_features(assign_ms2_scans=True) + lcms_obj.integrate_mass_features(drop_if_fail=True) + + # Export sample 1 with ALL mass features + sample_name_1 = "test_sample_01" + exporter1 = LCMSMetabolomicsExport(str(processed_folder / sample_name_1), lcms_obj) + exporter1.to_hdf(overwrite=True) + + # Create SAMPLE 2: Partial set (first 50 mass features only) + sample_name_2 = "test_sample_02" + + # Take only the first 50 mass features + first_50_mf_ids = list(lcms_obj.mass_features.keys())[:50] + first_mass_features = {mf_id: lcms_obj.mass_features[mf_id] for mf_id in first_50_mf_ids} + lcms_obj.mass_features = first_mass_features + + # Export sample 2 with partial mass features + exporter2 = LCMSMetabolomicsExport(str(processed_folder / sample_name_2), lcms_obj) + exporter2.to_hdf(overwrite=True) + + # Create SAMPLE 3: No mass features at all (EMPTY) + sample_name_3 = "test_sample_03" + + # Clear all mass features and EICs + lcms_obj.mass_features = {} + lcms_obj.eics = {} + + # Export sample 3 with no mass features + exporter3 = LCMSMetabolomicsExport(str(processed_folder / sample_name_3), lcms_obj) + exporter3.to_hdf(overwrite=True) + + # Create a manifest file to explicitly set sample 1 as the center + # This is important because sample 1 has all features, making it the best reference + import csv + manifest_path = processed_folder / "manifest.csv" + with open(manifest_path, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['sample_name', 'batch', 'order', 'center']) + writer.writerow(['test_sample_01', 1, 1, True]) # Sample 1 is center (has all features) + writer.writerow(['test_sample_02', 1, 2, False]) # Sample 2 (partial features) + writer.writerow(['test_sample_03', 1, 3, False]) # Sample 3 (no features) + + return processed_folder + + +@pytest.fixture(scope="module") +def lcms_collection(lcms_collection_folder): + """ + Creates an LCMSCollection object from processed LCMS data. + + Returns a collection with 3 samples for testing collection-level operations: + - Sample 1: Full set of mass features + - Sample 2: Partial set (first 50 mass features) + - Sample 3: No mass features (for gap filling testing) + """ + # Load the collection from the processed folder + manifest_file = lcms_collection_folder / "manifest.csv" + parser = ReadCoreMSHDFMassSpectraCollection( + folder_location=lcms_collection_folder, + manifest_file=manifest_file, + cores=1 + ) + + # Get the LCMS collection without light loading since sample 3 has no mass features + # load_light=True would fail on sample 3 because it calls mass_features_to_df() + collection = parser.get_lcms_collection(load_raw=False, load_light=False) + + # Adjust collection parameters to allow clusters with only 1 sample + # This ensures features aren't filtered out before gap filling can occur + collection.parameters.lcms_collection.cluster_size_min_samples = 1 + collection.parameters.lcms_collection.cluster_size_min_sample_percentage = 0.0 + + # Set more lenient anchor feature parameters for alignment + # Use relative_intensity with threshold 0 to accept all features as anchors + collection.parameters.lcms_collection.mass_feature_anchor_technique = ['relative_intensity'] + collection.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold = 0.0 + + # Set reasonable alignment tolerances + collection.parameters.lcms_collection.alignment_mz_tol_ppm = 5 # Tight m/z tolerance + collection.parameters.lcms_collection.alignment_rt_tol = 0.2 # 12 second RT tolerance + + # Set MS2 processing parameters for centroid data + for lcms_obj in collection: + ms2_params = lcms_obj.parameters.mass_spectrum['ms2'] + ms2_params.mass_spectrum.noise_threshold_method = "relative_abundance" + ms2_params.mass_spectrum.noise_threshold_min_relative_abundance = 1.0 + + return collection + + +def test_lcms_collection_creation(lcms_collection): + """Test that an LCMSCollection can be created and has expected properties.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Check that the collection was created + assert lcms_collection is not None + + # Check number of samples (should be 3) + assert len(lcms_collection) == 3 + assert len(lcms_collection.samples) == 3 + + # Check that we can access individual LCMS objects + assert lcms_collection[0] is not None + assert lcms_collection[1] is not None + assert lcms_collection[2] is not None + + # Check that manifest was created + assert lcms_collection.manifest is not None + assert len(lcms_collection.manifest) == 3 + + # Check manifest dataframe + manifest_df = lcms_collection.manifest_dataframe + assert len(manifest_df) == 3 + assert 'batch' in manifest_df.columns + assert 'order' in manifest_df.columns + + +def test_lcms_collection_mass_features_dataframe(lcms_collection): + """Test that mass features from all samples are combined correctly.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Get mass features dataframe + mf_df = lcms_collection.mass_features_dataframe + + # Check that dataframe exists and has data + assert mf_df is not None + assert len(mf_df) > 0 + + # Check that required columns exist + required_columns = ['mf_id', 'sample_name', 'mz', 'scan_time', 'intensity'] + for col in required_columns: + assert col in mf_df.columns, f"Missing required column: {col}" + + # Check that we have features from sample 1 (sample 2 has partial, sample 3 has none initially) + unique_samples = mf_df['sample_name'].unique() + assert len(unique_samples) >= 1 + + # Check that coll_mf_id exists (collection-level unique ID) - it's the index + assert mf_df.index.name == 'coll_mf_id' or 'coll_mf_id' in mf_df.columns + + +def test_lcms_collection_rt_alignment(lcms_collection): + """Test retention time alignment across samples in the collection.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Check initial state + assert not lcms_collection.rt_aligned + + # Test plotting TICs before alignment + try: + import matplotlib + matplotlib.use('Agg') # Use non-interactive backend for testing + lcms_collection.plot_tics(ms_level=1, type="raw", plot_legend=False) + except Exception as e: + pytest.fail(f"plot_tics raised an exception: {e}") + + # Perform alignment - this should succeed + lcms_collection.align_lcms_objects() + + # Check that alignment was attempted (it may not use spline if already well-aligned) + assert lcms_collection.rt_alignment_attempted + + # Check that alignment was rejected + assert not lcms_collection.rt_aligned + + # Check that scan_time_aligned was added to all samples + for i, lcms_obj in enumerate(lcms_collection): + sample_name = lcms_collection.samples[i] + assert 'scan_time_aligned' in lcms_obj.scan_df.columns, f"Missing scan_time_aligned in {sample_name}" + + # Test plotting after alignment - both raw and corrected TICs + try: + lcms_collection.plot_tics(ms_level=1, type="both", plot_legend=True) + except Exception as e: + pytest.fail(f"plot_tics with type='both' raised an exception: {e}") + + # Test plotting alignments (shows time differences) + try: + lcms_collection.plot_alignments(plot_legend=True) + except Exception as e: + pytest.fail(f"plot_alignments raised an exception: {e}") + + +def test_lcms_collection_consensus_features(lcms_collection): + """Test generation of consensus mass features (clustering).""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Ensure alignment is done first + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + + # Generate consensus features + lcms_collection.add_consensus_mass_features() + + # Check cluster summary dataframe + cluster_summary = lcms_collection.cluster_summary_dataframe + assert cluster_summary is not None + assert len(cluster_summary) > 0 + + # Check that cluster column was added to mass features + mf_df = lcms_collection.mass_features_dataframe + assert 'cluster' in mf_df.columns + + # Check cluster feature dictionary + cluster_dict = lcms_collection.cluster_feature_dictionary + assert cluster_dict is not None + assert len(cluster_dict) > 0 + + # Verify that all clusters in summary are in the dictionary + for cluster_id in cluster_summary.index: + assert cluster_id in cluster_dict + + +def test_lcms_collection_gap_filling(lcms_collection): + """Test gap filling to create induced mass features.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + # Setup: align and cluster first + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + lcms_collection.add_consensus_mass_features() + + # Verify initial state of each sample before gap filling + sample_1_mf_count = len(lcms_collection[0].mass_features) + sample_2_mf_count = len(lcms_collection[1].mass_features) + sample_3_mf_count = len(lcms_collection[2].mass_features) + + # Perform gap filling + pipeline_results = lcms_collection.process_consensus_features( + load_representatives=False, + perform_gap_filling=True, + add_ms1=False, + add_ms2=False, + molecular_formula_search=False, + ms2_spectral_search=False, + spectral_lib=False, + molecular_metadata=None, + gather_eics=True, + keep_raw_data=False + ) + + # Check that induced mass features dataframe exists + induced_df = lcms_collection.induced_mass_features_dataframe + assert induced_df is not None + + # With sample 3 having missing features, gap filling should create induced features + assert len(induced_df) > 0, "Gap filling should create induced mass features in sample 3" + + # Check that induced mass features have proper columns + assert 'cluster' in induced_df.columns + assert 'sample_name' in induced_df.columns + assert 'mf_id' in induced_df.columns + + # Check induced features per sample in the dataframe (not individual objects) + sample_3_induced = len(induced_df[induced_df['sample_id'] == 2]) + + # Sample 3 should have induced features (started with 0, all 50 clusters are missing) + assert sample_3_induced == 50, "Sample 3 should have 50 induced mass features (one for each cluster)" + + # By design, individual sample objects should have empty induced_mass_features dict + # because they are collected into the induced_mass_features_dataframe + assert len(lcms_collection[0].induced_mass_features) == 0 + assert len(lcms_collection[1].induced_mass_features) == 0 + assert len(lcms_collection[2].induced_mass_features) == 0 + + +def test_lcms_collection_pivot_table(lcms_collection): + """Test creation of pivot tables for collection data.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Setup: ensure we have clustered features + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + lcms_collection.add_consensus_mass_features() + + # Create pivot table with default attribute (coll_mf_id) before gap-filling + pivot_df = lcms_collection.collection_pivot_table(verbose=False) + + # Check that pivot table was created + assert pivot_df is not None + assert isinstance(pivot_df, pd.DataFrame) + + # Check that all samples are included (even sample 3 with no features) + assert len(pivot_df.columns) == len(lcms_collection.samples) + assert 'test_sample_03' in pivot_df.columns + + # Sample 3 should have all NAs before gap-filling + assert pivot_df['test_sample_03'].isna().all(), "Sample 3 should have all NAs before gap-filling" + + # Perform gap-filling + lcms_collection.process_consensus_features( + load_representatives=False, + perform_gap_filling=True, + add_ms1=False, + add_ms2=False, + molecular_formula_search=False, + ms2_spectral_search=False, + spectral_lib=False, + molecular_metadata=None, + gather_eics=True, + keep_raw_data=False + ) + + # Create pivot table again after gap-filling + pivot_df_after = lcms_collection.collection_pivot_table(verbose=False) + + # Sample 3 should no longer have all NAs after gap-filling + assert not pivot_df_after['test_sample_03'].isna().all(), "Sample 3 should have filled features after gap-filling" + + # Check that sample 3 has exactly 50 non-NA values (one for each cluster) + assert pivot_df_after['test_sample_03'].notna().sum() == 50, "Sample 3 should have 50 gap-filled features" + + # Create pivot table with intensity attribute + pivot_intensity = lcms_collection.collection_pivot_table( + attribute='intensity', + verbose=False + ) + assert pivot_intensity is not None + assert len(pivot_intensity.columns) == len(lcms_collection.samples) + + +def test_lcms_collection_cluster_representatives(lcms_collection): + """Test extraction of representative features for each cluster.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Setup: ensure we have clustered features + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + lcms_collection.add_consensus_mass_features() + + # Get cluster representatives table + reps_table = lcms_collection.cluster_representatives_table() + + # Check that table was created + assert reps_table is not None + assert isinstance(reps_table, pd.DataFrame) + assert len(reps_table) > 0 + + # Check required columns + required_cols = ['cluster', 'coll_mf_id', 'mz', 'scan_time', 'intensity'] + for col in required_cols: + assert col in reps_table.columns, f"Missing column: {col}" + + # Check that each cluster has exactly one representative + cluster_counts = reps_table['cluster'].value_counts() + assert all(count == 1 for count in cluster_counts) + + +def test_lcms_collection_export_import_hdf5(lcms_collection, tmp_path): + """Test exporting and re-importing a collection from HDF5.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Setup: align and cluster + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + lcms_collection.add_consensus_mass_features() + + # Export collection to HDF5 + export_path = tmp_path / "test_collection" + exporter = LCMSCollectionExport( + out_file_path=str(export_path), + mass_spectra_collection=lcms_collection + ) + exporter.export_to_hdf5(overwrite=True, save_parameters=True) + + # Check that HDF5 file was created + hdf5_path = export_path.with_suffix('.hdf5') + assert hdf5_path.exists() + + # Re-import the collection + from corems.mass_spectra.input.corems_hdf5 import ReadSavedLCMSCollection + reader = ReadSavedLCMSCollection( + collection_hdf5_path=str(hdf5_path), + cores=1 + ) + collection2 = reader.get_lcms_collection( + load_raw=False, + load_light=True + ) + + # Verify the reloaded collection + assert len(collection2) == len(lcms_collection) + + # Check that mass features match + mf_count_1 = len(lcms_collection.mass_features_dataframe) + mf_count_2 = len(collection2.mass_features_dataframe) + assert mf_count_1 == mf_count_2 + + # Check that cluster count matches if clustering was done + if len(lcms_collection.cluster_summary_dataframe) > 0: + cluster_count_1 = len(lcms_collection.cluster_summary_dataframe) + cluster_count_2 = len(collection2.cluster_summary_dataframe) + assert cluster_count_1 == cluster_count_2 + + +def test_lcms_collection_drop_isotopologues(lcms_collection): + """Test dropping isotopologues from the collection.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Get initial mass features count + initial_mf_count = len(lcms_collection.mass_features_dataframe) + + # Drop isotopologues + lcms_collection._drop_isotopologues() + + # Check that flag was set + assert lcms_collection.isotopes_dropped + + # Mass features count should be less than or equal to initial + # (equal if no isotopologues were found) + final_mf_count = len(lcms_collection.mass_features_dataframe) + assert final_mf_count <= initial_mf_count + + +def test_lcms_collection_feature_annotations_table(lcms_collection, msp_file_location): + """Test creation of feature annotations table with molecular metadata.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Setup: align and cluster + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + lcms_collection.add_consensus_mass_features() + + # Load molecular metadata from MSP file with same parameters as test_lcms_metabolomics + my_msp = MSPInterface(file_path=msp_file_location) + msp_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( + polarity="negative", + format="flashentropy", + normalize=True, + fe_kwargs={ + "normalize_intensity": True, + "min_ms2_difference_in_da": 0.02, # for cleaning spectra + "max_ms2_tolerance_in_da": 0.01, # for setting search space + "max_indexed_mz": 3000, + "precursor_ions_removal_da": None, + "noise_threshold": 0, + }, + ) + + # Set MS2 score threshold to match test_lcms_metabolomics + for lcms_obj in lcms_collection: + lcms_obj.parameters.lc_ms.ms2_min_fe_score = 0.3 + + # Process consensus features with MS1, MS2, and spectral search + # This should add molecular annotations before creating the annotations table + pipeline_results = lcms_collection.process_consensus_features( + load_representatives=True, + perform_gap_filling=False, + add_ms1=True, + add_ms2=True, + molecular_formula_search=False, + ms2_spectral_search=True, + spectral_lib=msp_lib, + molecular_metadata=molecular_metadata, + gather_eics=False, + keep_raw_data=False + ) + + # Create annotations table with metadata + annotations_table = lcms_collection.feature_annotations_table( + molecular_metadata=molecular_metadata, + drop_unannotated=False + ) + + assert annotations_table is not None + assert isinstance(annotations_table, pd.DataFrame) + assert len(annotations_table) > 0 + + # Check that cluster information is present + assert 'cluster' in annotations_table.columns + + # Check that we got some spectral matches after processing + # Look for Entropy Similarity column which indicates MS2 spectral search results + if 'Entropy Similarity' in annotations_table.columns: + matched_features = annotations_table[annotations_table['Entropy Similarity'].notna()] + assert len(matched_features) > 0, "Should have at least some MS2 spectral matches after search" + else: + # If column doesn't exist, the test should fail + raise AssertionError("Expected 'Entropy Similarity' column in annotations table after MS2 spectral search") + + +def test_lcms_collection_plot_cluster_with_ms2_mirror(lcms_collection, msp_file_location): + """Test plot_cluster with MS2_mirror option using molecular_metadata.""" + import matplotlib + matplotlib.use('Agg') # Use non-interactive backend for testing + + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Setup: align and cluster + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + lcms_collection.add_consensus_mass_features() + + # Load molecular metadata from MSP file + my_msp = MSPInterface(file_path=msp_file_location) + msp_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( + polarity="negative", + format="flashentropy", + normalize=True, + fe_kwargs={ + "normalize_intensity": True, + "min_ms2_difference_in_da": 0.02, + "max_ms2_tolerance_in_da": 0.01, + "max_indexed_mz": 3000, + "precursor_ions_removal_da": None, + "noise_threshold": 0, + }, + ) + + # Set MS2 score threshold + for lcms_obj in lcms_collection: + lcms_obj.parameters.lc_ms.ms2_min_fe_score = 0.3 + + # Process consensus features with MS2 spectral search and gather EICs + pipeline_results = lcms_collection.process_consensus_features( + load_representatives=True, + perform_gap_filling=False, + add_ms1=True, + add_ms2=True, + molecular_formula_search=False, + ms2_spectral_search=True, + spectral_lib=msp_lib, + molecular_metadata=molecular_metadata, + gather_eics=True, + keep_raw_data=False + ) + + # Get a cluster with MS2 data + cluster_summary = lcms_collection.cluster_summary_dataframe + assert len(cluster_summary) > 0, "Should have clusters for plotting tests" + + # Find a cluster with MS2 similarity results + cluster_with_ms2 = None + for cluster_id in cluster_summary.index[:10]: # Check first 10 clusters + rep_info = lcms_collection.get_most_representative_sample_for_cluster(cluster_id) + rep_sample = lcms_collection[rep_info['sample_id']] + if rep_info['mf_id'] in rep_sample.mass_features: + rep_mf = rep_sample.mass_features[rep_info['mf_id']] + if len(rep_mf.ms2_similarity_results) > 0: + cluster_with_ms2 = cluster_id + break + + # Test plot_cluster with MS2_mirror + if cluster_with_ms2 is not None: + try: + lcms_collection.plot_cluster( + cluster_with_ms2, + to_plot=["EIC", "MS1", "MS2_mirror"], + molecular_metadata=molecular_metadata, + spectral_library=msp_lib + ) + except Exception as e: + pytest.fail(f"plot_cluster with MS2_mirror raised exception: {e}") + else: + # If no MS2 matches found, test that MS2_mirror gracefully falls back + cluster_id = cluster_summary.index[0] + try: + lcms_collection.plot_cluster( + cluster_id, + to_plot=["EIC", "MS2_mirror"], + molecular_metadata=molecular_metadata + ) + except Exception as e: + pytest.fail(f"plot_cluster with MS2_mirror (no matches) raised exception: {e}") + + +def test_lcms_collection_molecular_formula_search(lcms_collection, postgres_database): + """Test molecular formula search on consensus features.""" + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Setup: align and cluster + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + lcms_collection.add_consensus_mass_features() + + # Set molecular search parameters for all samples (negative mode) + for lcms_obj in lcms_collection: + ms1_params = lcms_obj.parameters.mass_spectrum['ms1'] + ms1_params.molecular_search.url_database = postgres_database + ms1_params.molecular_search.error_method = "None" + ms1_params.molecular_search.min_ppm_error = -5 + ms1_params.molecular_search.max_ppm_error = 5 + ms1_params.molecular_search.mz_error_range = 1 + ms1_params.molecular_search.isProtonated = True # Deprotonated in negative mode + ms1_params.molecular_search.isRadical = False + ms1_params.molecular_search.isAdduct = False + ms1_params.molecular_search.usedAtoms = { + 'C': (1, 90), + 'H': (4, 200), + 'O': (0, 30), + 'N': (0, 3), + 'P': (0, 2), + 'S': (0, 2), + } + + # Process consensus features with molecular formula search + pipeline_results = lcms_collection.process_consensus_features( + load_representatives=True, + perform_gap_filling=False, + add_ms1=True, + add_ms2=False, + molecular_formula_search=True, + ms2_spectral_search=False, + spectral_lib=False, + molecular_metadata=None, + gather_eics=False, + keep_raw_data=False + ) + + # Get annotations table + annotations_table = lcms_collection.feature_annotations_table( + molecular_metadata=None, + drop_unannotated=False + ) + + assert annotations_table is not None + assert isinstance(annotations_table, pd.DataFrame) + assert len(annotations_table) > 0 + + # Check that molecular formula columns are present (column is called 'Ion Formula') + assert 'Ion Formula' in annotations_table.columns + assert 'Calculated m/z' in annotations_table.columns + + # Check that at least some features got molecular formula assignments + assigned_formulas = annotations_table[annotations_table['Ion Formula'].notna()] + assert len(assigned_formulas) > 0, "Should have at least some molecular formula assignments" + + # Verify the formulas are reasonable (contain expected elements) + first_formula = assigned_formulas['Ion Formula'].iloc[0] + assert 'C' in first_formula or 'H' in first_formula, "Molecular formulas should contain C or H" + + # Check that m/z error columns exist (indicates matching happened) + assert 'm/z Error (ppm)' in annotations_table.columns + assert 'm/z Error Score' in annotations_table.columns + + +def test_lcms_collection_update_raw_file_locations(lcms_collection, tmp_path): + """Test updating raw file locations in the collection.""" + import shutil + + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Create a new path for raw files + new_raw_folder = tmp_path / "new_raw_location" + new_raw_folder.mkdir() + + # Copy the original raw file to the new location for each sample + # The samples all use the same original raw file but have different sample names + for lcms_obj in lcms_collection: + original_raw = lcms_obj.raw_file_location + # Create a copy with the sample name in the new location + new_raw_file = new_raw_folder / f"{lcms_obj.sample_name}.raw" + shutil.copy2(original_raw, new_raw_file) + + # Update raw file locations + lcms_collection.update_raw_file_locations(str(new_raw_folder)) + + # Check that paths were updated + for lcms_obj in lcms_collection: + assert str(new_raw_folder) in str(lcms_obj.raw_file_location) + + # Check that flag was set + assert lcms_collection.raw_files_relocated + + +def test_lcms_collection_minimal_workflow(lcms_collection): + """ + Test a minimal end-to-end workflow with the collection. + + This test mirrors the workflow in metabolomics_collection.py: + 1. Load collection + 2. Align retention times + 3. Generate consensus features + 4. Perform gap filling + 5. Create reports + """ + # Make a test-wide deep copy of the collection for use in multiple tests without modifying the original + lcms_collection = copy.deepcopy(lcms_collection) + + # Step 1: Collection is loaded via fixture + assert len(lcms_collection) > 0 + + # Step 2: Align retention times + lcms_collection.align_lcms_objects() + + # Step 3: Generate consensus features + lcms_collection.add_consensus_mass_features() + cluster_count = len(lcms_collection.cluster_summary_dataframe) + assert cluster_count > 0 + + # Step 4: Perform gap filling using process_consensus_features + # Load representatives and add MS1 so we can create annotations table + lcms_collection.process_consensus_features( + load_representatives=True, + perform_gap_filling=True, + add_ms1=True, + add_ms2=False, + molecular_formula_search=False, + ms2_spectral_search=False, + spectral_lib=False, + molecular_metadata=None, + gather_eics=True, + keep_raw_data=False + ) + + # Step 5: Create reports + pivot_table = lcms_collection.collection_pivot_table(verbose=False) + assert pivot_table is not None + + cluster_reps = lcms_collection.cluster_representatives_table() + assert len(cluster_reps) == cluster_count + + # Create annotations table (requires load_representatives=True and add_ms1=True) + annotations = lcms_collection.feature_annotations_table( + molecular_metadata=None, + drop_unannotated=False + ) + assert annotations is not None + assert len(annotations) > 0 + + # Verify we have cluster information in the annotations + assert 'cluster' in annotations.columns + + # Verify workflow completed successfully + assert lcms_collection.rt_aligned or lcms_collection.rt_alignment_attempted + assert cluster_count > 0 + print(f"\nWorkflow completed: {cluster_count} consensus clusters from {len(lcms_collection)} samples") + + +def test_lcms_collection_plotting_methods(lcms_collection): + """ + Test plotting methods for consensus features and clusters. + + Tests both before and after gap filling to ensure plots work in both states: + - plot_consensus_mz_features(): Shows distribution of consensus features across m/z + - plot_cluster(): Shows mass features within a specific cluster + """ + import matplotlib + matplotlib.use('Agg') # Use non-interactive backend for testing + + # Setup: align and cluster + if not lcms_collection.rt_alignment_attempted: + lcms_collection.align_lcms_objects() + lcms_collection.add_consensus_mass_features() + + # Get cluster information + cluster_summary = lcms_collection.cluster_summary_dataframe + assert len(cluster_summary) > 0, "Should have clusters for plotting tests" + + # Pick a cluster ID for testing plot_cluster + cluster_id = cluster_summary.index[0] + + # Test plot_consensus_mz_features BEFORE gap filling + try: + lcms_collection.plot_consensus_mz_features() + except Exception as e: + pytest.fail(f"plot_consensus_mz_features before gap filling raised exception: {e}") + + # Test plot_cluster BEFORE gap filling + try: + lcms_collection.plot_cluster(cluster_id, to_plot=["EIC"]) + except Exception as e: + pytest.fail(f"plot_cluster before gap filling raised exception: {e}") + + # Perform gap filling + lcms_collection.process_consensus_features( + load_representatives=False, + perform_gap_filling=True, + add_ms1=False, + add_ms2=False, + molecular_formula_search=False, + ms2_spectral_search=False, + spectral_lib=False, + molecular_metadata=None, + gather_eics=True, + keep_raw_data=False + ) + + # Verify gap filling occurred + induced_df = lcms_collection.induced_mass_features_dataframe + assert len(induced_df) > 0, "Should have induced features after gap filling" + + # Test plot_consensus_mz_features AFTER gap filling + try: + lcms_collection.plot_consensus_mz_features(show_all=True) + except Exception as e: + pytest.fail(f"plot_consensus_mz_features after gap filling raised exception: {e}") + + # Test plot_cluster AFTER gap filling (with sample labels to test more options) + try: + lcms_collection.plot_cluster(cluster_id, to_plot=["EIC"], label_samples=True) + except Exception as e: + pytest.fail(f"plot_cluster after gap filling raised exception: {e}") + diff --git a/tests/test_lcms_metabolomics.py b/tests/test_lcms_metabolomics.py index 6d90b832a..d7f15652a 100644 --- a/tests/test_lcms_metabolomics.py +++ b/tests/test_lcms_metabolomics.py @@ -3,6 +3,7 @@ import numpy as np from corems.mass_spectra.output.export import LCMSMetabolomicsExport +from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra from corems.molecular_id.search.database_interfaces import MSPInterface from corems.encapsulation.factory.parameters import LCMSParameters, reset_lcms_parameters, reset_ms_parameters from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra @@ -123,25 +124,44 @@ def test_lcms_metabolomics(postgres_database, lcms_obj, msp_file_location): assert report['Ion Formula'][1] == 'C24 H47 O2' assert report['chebi'][1] == 28866 - # Test parameter re-import by loading the HDF5 file and comparing parameters + # Test plotting mass feature with MS2 mirror plot + # Get mass feature ID from the second row of the report + mass_feature_id_to_plot = report['Mass Feature ID'][1] + mass_feature_to_plot = lcms_obj.mass_features[mass_feature_id_to_plot] + + # Plot with MS2 mirror plot + fig = mass_feature_to_plot.plot( + to_plot=["EIC", "MS1", "MS2_mirror"], + return_fig=True, + molecular_metadata=metabolite_metadata_negative, + spectral_library=msp_negative + ) + assert fig is not None + + # Reload the saved lcms object and check that mass features are still present parser = ReadCoreMSHDFMassSpectra( "Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801_metab.corems/Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801_metab.hdf5" ) - lcms_obj_reimported = parser.get_lcms_obj() + myLCMSobj2 = parser.get_lcms_obj() + + # Check that the parameters match + assert myLCMSobj2.parameters == lcms_obj.parameters - # Check that the parameters match, including the new peak metrics filtering parameters - assert lcms_obj_reimported.parameters == lcms_obj.parameters, \ - "Re-imported parameters should match original parameters" - # Specifically check the new peak metrics filtering parameters - assert lcms_obj_reimported.parameters.lc_ms.remove_mass_features_by_peak_metrics - assert lcms_obj_reimported.parameters.lc_ms.mass_feature_attribute_filter_dict == { + assert myLCMSobj2.parameters.lc_ms.remove_mass_features_by_peak_metrics + assert myLCMSobj2.parameters.lc_ms.mass_feature_attribute_filter_dict == { 'dispersity_index': {'value': 0.5, 'operator': '<'} } - - # Test that the number of mass features is preserved in the re-import - assert len(lcms_obj_reimported.mass_features) == len(lcms_obj.mass_features), \ - "Re-imported LCMS object should have the same number of mass features as original" + + # Check that the spectra parser class is the same as the original parser and that we can plot a mass spectrum using the original parser + assert myLCMSobj2.spectra_parser_class.__name__ == "ImportMassSpectraThermoMSFileReader" + myLCMSobj2.spectra_parser.get_mass_spectrum_from_scan(1, spectrum_mode="profile").plot_centroid() + + # Check that the mass features dataframe is the same as the original + df2 = myLCMSobj2.mass_features_to_df() + df1 = lcms_obj.mass_features_to_df() + assert df2.shape == df1.shape + myLCMSobj2.mass_features[0].plot(return_fig=False) # Delete the "Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801.corems" directory shutil.rmtree( @@ -151,4 +171,127 @@ def test_lcms_metabolomics(postgres_database, lcms_obj, msp_file_location): # Reset the MSParameters to the original values reset_lcms_parameters() + reset_ms_parameters() + + +def test_lcms_metabolomics_targeted_search(lcms_obj): + """Test the targeted search functionality for LCMS metabolomics""" + + # Set parameters to the defaults for reproducible testing + lcms_obj.parameters = LCMSParameters(use_defaults=True) + + # Set parameters on the LCMS object that are reasonable for testing + lcms_obj.parameters.lc_ms.peak_picking_method = "persistent homology" + lcms_obj.parameters.lc_ms.ph_inten_min_rel = 0.0005 + lcms_obj.parameters.lc_ms.ph_persis_min_rel = 0.05 + lcms_obj.parameters.lc_ms.ph_smooth_it = 0 + + # MSParameters for ms1 and ms2 mass spectra + ms1_params = lcms_obj.parameters.mass_spectrum['ms1'] + ms1_params.mass_spectrum.noise_threshold_method = "relative_abundance" + ms1_params.mass_spectrum.noise_threshold_min_relative_abundance = 0.1 + ms1_params.mass_spectrum.noise_min_mz, ms1_params.mass_spectrum.min_picking_mz = 0, 0 + ms1_params.mass_spectrum.noise_max_mz, ms1_params.mass_spectrum.max_picking_mz = np.inf, np.inf + ms1_params.ms_peak.legacy_resolving_power = False + + # Copy settings for ms2 data + ms2_params_hcd = ms1_params.copy() + lcms_obj.parameters.mass_spectrum['ms2'] = ms2_params_hcd + + # Prepare a targeted search dictionary with specific m/z and RT values + # These values are known to exist in the test data + target_mz_list = [301.2166, 698.6289] + target_rt_list = [8.8956, 23.8168] + target_search_dict = { + "target_mz_list": target_mz_list, + "target_rt_list": target_rt_list, + "mz_tolerance_ppm": 5, + "rt_tolerance": 0.5, + "type": "internal standard" + } + + # Perform targeted search for mass features + lcms_obj.find_mass_features( + targeted_search=True, + target_search_dict=target_search_dict + ) + + # Verify that mass features were found + assert len(lcms_obj.mass_features) > 0, "No mass features found in targeted search" + + # Verify that the number of mass features matches or is close to the number of targets + # (may find multiple features per target depending on clustering) + assert len(lcms_obj.mass_features) >= len(target_mz_list), \ + f"Expected at least {len(target_mz_list)} mass features, found {len(lcms_obj.mass_features)}" + + # Integrate the mass features + lcms_obj.integrate_mass_features(drop_if_fail=True) + + # Verify that mass features still exist after integration + assert len(lcms_obj.mass_features) > 0, "No mass features remaining after integration" + + # Add associated MS1 data + lcms_obj.add_associated_ms1(use_parser=False, spectrum_mode="profile") + + # Add associated MS2 data + og_ms_len = len(lcms_obj._ms) + lcms_obj.add_associated_ms2_dda(use_parser=True, spectrum_mode="centroid") + + # Verify MS2 data was added + assert len(lcms_obj._ms) >= og_ms_len, "MS2 data should be added" + + # Check that mass features have the expected properties + mf_df = lcms_obj.mass_features_to_df(drop_na_cols=True) + assert not mf_df.empty, "Mass features dataframe should not be empty" + + # Verify that at least one mass feature is close to each target m/z and RT value + for target_mz, target_rt in zip(target_mz_list, target_rt_list): + # Calculate m/z difference in ppm for all features + mz_diff_ppm = abs(mf_df['mz'] - target_mz) / target_mz * 1e6 + # Calculate RT difference in minutes (scan_time is in minutes) + rt_diff = abs(mf_df['scan_time'] - target_rt) + + # Check if any feature is within tolerance for both m/z and RT + matches = (mz_diff_ppm < 10) & (rt_diff < 1.0) + + assert matches.any(), \ + f"No mass feature found for target m/z={target_mz}, RT={target_rt}. " \ + f"Closest m/z diff: {mz_diff_ppm.min():.2f} ppm, closest RT diff: {rt_diff.min():.3f} min" + + # Verify that the type attribute is set correctly + assert 'type' in mf_df.columns, "Type column should be present in mass features dataframe" + assert (mf_df['type'] == 'internal standard').all(), \ + "All targeted mass features should have type 'internal standard'" + + # Test HDF5 export/import to verify type attribute persists + shutil.rmtree("test_targeted_search.corems", ignore_errors=True) + exporter = LCMSMetabolomicsExport("test_targeted_search", lcms_obj) + exporter.to_hdf(overwrite=True) + + # Reload the saved lcms object and check that type attribute persists + parser = ReadCoreMSHDFMassSpectra( + "test_targeted_search.corems/test_targeted_search.hdf5" + ) + lcms_obj_reloaded = parser.get_lcms_obj() + + # Check that mass features were reloaded + assert len(lcms_obj_reloaded.mass_features) == len(lcms_obj.mass_features), \ + "Reloaded object should have the same number of mass features" + + # Verify type attribute persisted through export/import + for mf_id, mf in lcms_obj_reloaded.mass_features.items(): + assert mf.type == 'internal standard', \ + f"Mass feature {mf_id} should have type 'internal standard' after reload" + + # Verify type column in dataframe after reload + mf_df_reloaded = lcms_obj_reloaded.mass_features_to_df(drop_na_cols=True) + assert 'type' in mf_df_reloaded.columns, "Type column should persist in reloaded dataframe" + assert (mf_df_reloaded['type'] == 'internal standard').all(), \ + "All mass features should have type 'internal standard' after reload" + + # Cleanup + shutil.rmtree("test_targeted_search.corems", ignore_errors=True) + + # Reset the parameters to the original values + reset_lcms_parameters() reset_ms_parameters() \ No newline at end of file diff --git a/tests/test_time_range_filtering.py b/tests/test_time_range_filtering.py new file mode 100644 index 000000000..6e7adf48d --- /dev/null +++ b/tests/test_time_range_filtering.py @@ -0,0 +1,312 @@ +""" +Test time range filtering functionality for LC-MS parsers. + +This module tests the time_range parameter across different parser implementations +to ensure efficient loading of targeted retention time windows. +""" +from pathlib import Path +import pytest +import os +import tempfile +import shutil +import time + +from corems.mass_spectra.input.mzml import MZMLSpectraParser +from corems.mass_spectra.input import rawFileReader +from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra +from corems.mass_spectra.output.export import LCMSExport +from corems.encapsulation.factory.parameters import LCMSParameters + + +# Module-level fixtures +@pytest.fixture(scope="module") +def mzml_file(): + """Path to test mzML file.""" + return ( + Path.cwd() + / "tests/tests_data/lcms/" + / "test_centroid_neg_RP_metab.mzML" + ) + + +@pytest.fixture(scope="module") +def thermo_file(): + """Path to test Thermo RAW file.""" + return ( + Path.cwd() + / "tests/tests_data/lcms/" + / "Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801.raw" + ) + + +@pytest.fixture +def mzml_parser(mzml_file): + """Instantiate mzML parser.""" + return MZMLSpectraParser(mzml_file) + + +@pytest.fixture +def thermo_parser(thermo_file): + """Instantiate Thermo parser.""" + return rawFileReader.ImportMassSpectraThermoMSFileReader(thermo_file) + + +@pytest.fixture +def hdf5_parser(mzml_file): + """Create temporary HDF5 file and return parser.""" + temp_dir = tempfile.mkdtemp() + base_name = "test_time_range" + base_path = os.path.join(temp_dir, base_name) + + # Parse mzML and create HDF5 file + parser = MZMLSpectraParser(mzml_file) + lcms_obj = parser.get_lcms_obj(spectra="ms1") + lcms_obj.parameters = LCMSParameters(use_defaults=True) + + # Save to HDF5 using LCMSExport + exporter = LCMSExport(base_path, lcms_obj) + exporter.to_hdf(overwrite=True) + + # The actual HDF5 file will be at base_path.corems/base_name.hdf5 + hdf5_path = os.path.join(temp_dir, f"{base_name}.corems", f"{base_name}.hdf5") + + hdf5_parser = ReadCoreMSHDFMassSpectra(hdf5_path) + + yield hdf5_parser + + # Cleanup + try: + corems_dir = os.path.join(temp_dir, f"{base_name}.corems") + if os.path.exists(corems_dir): + shutil.rmtree(corems_dir) + os.rmdir(temp_dir) + except Exception: + pass + + +@pytest.fixture(params=['mzml', 'thermo', 'hdf5']) +def parser(request, mzml_parser, thermo_parser, hdf5_parser): + """Parametrized fixture that provides all three parser types.""" + if request.param == 'mzml': + return mzml_parser + elif request.param == 'thermo': + return thermo_parser + else: + return hdf5_parser + + +class TestTimeRangeFiltering: + """Test time range filtering across all parser implementations.""" + + def test_get_scans_in_time_range_single_range(self, parser): + """Test getting scans within a single time range.""" + all_scan_df = parser.get_scan_df() + time_range = (1.0, 2.0) + scans_in_range = parser.get_scans_in_time_range(time_range) + + assert isinstance(scans_in_range, list) + assert len(scans_in_range) > 0 + + # Verify all scans are in range + for scan_num in scans_in_range: + scan_time = all_scan_df[all_scan_df.scan == scan_num].scan_time.values[0] + assert time_range[0] <= scan_time <= time_range[1] + + def test_get_scans_in_time_range_multiple_ranges(self, parser): + """Test getting scans within multiple time ranges.""" + all_scan_df = parser.get_scan_df() + time_ranges = [(0.5, 1.5), (3.0, 4.0)] + scans_in_range = parser.get_scans_in_time_range(time_ranges) + + assert isinstance(scans_in_range, list) + assert len(scans_in_range) > 0 + + # Verify all scans are in at least one range + for scan_num in scans_in_range: + scan_time = all_scan_df[all_scan_df.scan == scan_num].scan_time.values[0] + assert any(start <= scan_time <= end for start, end in time_ranges) + + def test_get_scans_in_time_range_with_ms_level(self, parser): + """Test filtering by both time range and MS level.""" + all_scan_df = parser.get_scan_df() + time_range = (1.0, 3.0) + ms1_scans = parser.get_scans_in_time_range(time_range, ms_level=1) + + assert len(ms1_scans) > 0 + + # Verify all returned scans are MS1 and in time range + for scan_num in ms1_scans: + scan_info = all_scan_df[all_scan_df.scan == scan_num].iloc[0] + assert scan_info.ms_level == 1 + assert time_range[0] <= scan_info.scan_time <= time_range[1] + + def test_get_scan_df_with_time_range(self, parser): + """Test get_scan_df with time range filtering.""" + all_scan_df = parser.get_scan_df() + time_range = (1.0, 2.0) + filtered_scan_df = parser.get_scan_df(time_range=time_range) + + assert len(filtered_scan_df) < len(all_scan_df) + assert len(filtered_scan_df) > 0 + assert all(time_range[0] <= t <= time_range[1] for t in filtered_scan_df.scan_time) + + def test_get_scan_df_with_multiple_time_ranges(self, parser): + """Test get_scan_df with multiple time ranges.""" + time_ranges = [(0.5, 1.0), (2.0, 2.5)] + filtered_scan_df = parser.get_scan_df(time_range=time_ranges) + + for scan_time in filtered_scan_df.scan_time: + assert any(start <= scan_time <= end for start, end in time_ranges) + + def test_edge_cases(self, parser): + """Test edge cases for time range filtering.""" + all_scan_df = parser.get_scan_df() + min_time = all_scan_df.scan_time.min() + max_time = all_scan_df.scan_time.max() + + # Range covering all data + scans = parser.get_scans_in_time_range((0, 999)) + assert len(scans) == len(all_scan_df) + + # Range with no data + scans = parser.get_scans_in_time_range((999, 1000)) + assert len(scans) == 0 + + # Range at exact boundaries + scans = parser.get_scans_in_time_range((min_time, max_time)) + assert len(scans) == len(all_scan_df) + + def test_normalize_time_range_helper(self): + """Test the _normalize_time_range static helper method.""" + from corems.mass_spectra.input.parserbase import SpectraParserInterface + + assert SpectraParserInterface._normalize_time_range((1.0, 2.0)) == [(1.0, 2.0)] + assert SpectraParserInterface._normalize_time_range([(1.0, 2.0), (3.0, 4.0)]) == [(1.0, 2.0), (3.0, 4.0)] + assert SpectraParserInterface._normalize_time_range(None) is None + + +class TestParserSpecificFeatures: + """Test parser-specific features and workflows.""" + + def test_mzml_lcms_obj_with_time_range(self, mzml_parser): + """Test loading mzML LCMS object with time range filtering.""" + all_scan_df = mzml_parser.get_scan_df() + time_range = (1.0, 2.0) + + filtered_lcms = mzml_parser.get_lcms_obj(spectra="ms1", time_range=time_range) + + assert len(filtered_lcms.scan_df) < len(all_scan_df) + assert len(filtered_lcms.scan_df) > 0 + assert all(time_range[0] <= t <= time_range[1] for t in filtered_lcms.scan_df.scan_time) + assert len(filtered_lcms._scans_number_list) == len(filtered_lcms.scan_df) + assert len(filtered_lcms._retention_time_list) == len(filtered_lcms.scan_df) + + def test_thermo_lcms_obj_with_time_range(self, thermo_parser): + """Test loading Thermo LCMS object with time range filtering.""" + all_scan_df = thermo_parser.get_scan_df() + time_range = (0.5, 1.0) + + filtered_lcms = thermo_parser.get_lcms_obj(spectra="ms1", time_range=time_range) + + assert len(filtered_lcms.scan_df) < len(all_scan_df) + assert len(filtered_lcms.scan_df) > 0 + assert all(time_range[0] <= t <= time_range[1] for t in filtered_lcms.scan_df.scan_time) + assert len(filtered_lcms._scans_number_list) == len(filtered_lcms.scan_df) + assert len(filtered_lcms._retention_time_list) == len(filtered_lcms.scan_df) + + def test_hdf5_lcms_obj_with_time_range(self, hdf5_parser): + """Test HDF5 LCMS object accepts time_range parameter.""" + all_scan_df = hdf5_parser.get_scan_df() + time_range = (1.0, 2.0) + + # HDF5 parser accepts time_range for interface consistency + _ = hdf5_parser.get_lcms_obj( + load_raw=True, + load_light=False, + use_original_parser=False, + time_range=time_range + ) + + # Verify scan_df can be filtered + filtered_scan_df = hdf5_parser.get_scan_df(time_range=time_range) + assert len(filtered_scan_df) < len(all_scan_df) + assert len(filtered_scan_df) > 0 + + def test_thermo_performance_benchmark(self, thermo_parser): + """Test that time range filtering provides measurable performance improvement.""" + target_rt = 5.0 + rt_window = 2.0 + time_range = (target_rt - rt_window, target_rt + rt_window) + + # Time filtered load + start_filtered = time.time() + filtered_lcms = thermo_parser.get_lcms_obj(spectra="ms1", time_range=time_range) + filtered_lcms.parameters = LCMSParameters(use_defaults=True) + filtered_lcms.parameters.lc_ms.peak_picking_method = "persistent homology" + filtered_lcms.parameters.lc_ms.ph_inten_min_rel = 0.01 + filtered_lcms.parameters.lc_ms.ph_persis_min_rel = 0.05 + filtered_lcms.find_mass_features() + time_filtered = time.time() - start_filtered + + # Time full load + start_full = time.time() + full_lcms = thermo_parser.get_lcms_obj(spectra="ms1") + full_lcms.parameters = LCMSParameters(use_defaults=True) + full_lcms.parameters.lc_ms.peak_picking_method = "persistent homology" + full_lcms.parameters.lc_ms.ph_inten_min_rel = 0.01 + full_lcms.parameters.lc_ms.ph_persis_min_rel = 0.05 + full_lcms.find_mass_features() + time_full = time.time() - start_full + + speedup = time_full / time_filtered if time_filtered > 0 else 0 + + print(f"\n{'='*60}") + print("Time Range Filtering Performance Test") + print(f"{'='*60}") + print(f"Time range: {time_range[0]}-{time_range[1]} minutes") + print(f"Filtered load: {len(filtered_lcms.scan_df)} scans in {time_filtered:.2f}s") + print(f"Full load: {len(full_lcms.scan_df)} scans in {time_full:.2f}s") + print(f"Speedup: {speedup:.2f}x") + print(f"{'='*60}") + + assert len(filtered_lcms.scan_df) < len(full_lcms.scan_df) + assert time_filtered < time_full + assert speedup >= 1.2, f"Expected at least 1.2x speedup, got {speedup:.2f}x" + + +class TestMassFeatureIntegration: + """Test time range filtering integration with mass feature detection.""" + + def test_targeted_search_with_time_range(self, mzml_file): + """Test targeted search with time-filtered data.""" + parser = MZMLSpectraParser(mzml_file) + target_time_range = (1.0, 2.0) + + lcms_obj = parser.get_lcms_obj(spectra="ms1", time_range=target_time_range) + lcms_obj.parameters = LCMSParameters(use_defaults=True) + lcms_obj.parameters.lc_ms.peak_picking_method = "centroided_persistent_homology" + lcms_obj.parameters.mass_spectrum["ms1"].mass_spectrum.noise_threshold_method = "relative_abundance" + lcms_obj.find_mass_features() + + assert len(lcms_obj.mass_features) > 0 + + # Verify all features are within target time range + for mf_id, mf in lcms_obj.mass_features.items(): + assert target_time_range[0] <= mf.retention_time <= target_time_range[1] + + def test_multiple_time_windows_for_standards(self, mzml_file): + """Test loading multiple time windows for internal standards.""" + parser = MZMLSpectraParser(mzml_file) + standard_windows = [(0.3, 0.7), (1.8, 2.2), (3.3, 3.7)] + + lcms_obj = parser.get_lcms_obj(spectra="ms1", time_range=standard_windows) + lcms_obj.parameters = LCMSParameters(use_defaults=True) + lcms_obj.parameters.lc_ms.peak_picking_method = "centroided_persistent_homology" + lcms_obj.parameters.mass_spectrum["ms1"].mass_spectrum.noise_threshold_method = "relative_abundance" + lcms_obj.find_mass_features() + + # Verify scan times are only from specified windows + for scan_time in lcms_obj.scan_df.scan_time: + assert any(start <= scan_time <= end for start, end in standard_windows) + + diff --git a/tests/test_wf_lipidomics.py b/tests/test_wf_lipidomics.py index 8dd22de6d..356be77ce 100644 --- a/tests/test_wf_lipidomics.py +++ b/tests/test_wf_lipidomics.py @@ -45,7 +45,8 @@ def test_import_lcmsobj_mzml(): auto_process=True, use_parser=True, spectrum_mode="centroid" ) mass_features_df = myLCMSobj.mass_features_to_df() - assert mass_features_df.shape == (1183, 17) + assert mass_features_df.shape[0] == 1183 + assert mass_features_df.shape[1] > 15 # Reset the MSParameters to the original values reset_lcms_parameters() @@ -148,7 +149,8 @@ def test_lipidomics_workflow(postgres_database, lcms_obj, lipidomics_sqlite_path # Export the mass features to a pandas dataframe df = lcms_obj.mass_features_to_df() - assert df.shape == (128, 19) + assert df.shape[0] == 128 + assert df.shape[1] > 15 # Plot a mass feature lcms_obj.mass_features[0].plot(return_fig=False) @@ -200,13 +202,25 @@ def test_lipidomics_workflow(postgres_database, lcms_obj, lipidomics_sqlite_path parser = ReadCoreMSHDFMassSpectra( "Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801.corems/Blanch_Nat_Lip_C_12_AB_M_17_NEG_25Jan18_Brandi-WCSH5801.hdf5" ) + + # Check that creation_time was saved and can be retrieved + creation_time = parser.get_original_creation_time() + assert creation_time is not None + assert creation_time.year == 2018 # Based on the filename date + myLCMSobj2 = parser.get_lcms_obj() # Check that the parameters match assert myLCMSobj2.parameters == lcms_obj.parameters + + # Check that the spectra parser class is the same as the original parser and that we can plot a mass spectrum using the original parser assert myLCMSobj2.spectra_parser_class.__name__ == "ImportMassSpectraThermoMSFileReader" + myLCMSobj2.spectra_parser.get_mass_spectrum_from_scan(1, spectrum_mode="profile").plot_centroid() + + # Check that the mass features dataframe is the same as the original df2 = myLCMSobj2.mass_features_to_df() - assert df2.shape == (128, 19) + assert df2.shape[0] == 128 + assert df2.shape[1] > 15 myLCMSobj2.mass_features[0].mass_spectrum.to_dataframe() assert myLCMSobj2.mass_features[0].ms1_peak[0].string == "C20 H30 O2" assert myLCMSobj2.mass_features_ms1_annot_to_df().shape[0] > 130