diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index cd91f32..77edfeb 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -20,10 +20,13 @@ requirements: - versioningit run: + - bioconductor-xcms - pymzml - qiime2 {{ qiime2_epoch }}.* - q2-types {{ qiime2_epoch }}.* - q2templates {{ qiime2_epoch }}.* + - r-base + - r-devtools test: requires: diff --git a/pyproject.toml b/pyproject.toml index 2a9df68..eae0ce1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ file = "q2_ms/_version.py" [tool.setuptools] include-package-data = true +script-files = [ + "q2_ms/assets/find_peaks_centwave.R" +] [tool.setuptools.packages.find] where = ["."] diff --git a/q2_ms/assets/find_peaks_centwave.R b/q2_ms/assets/find_peaks_centwave.R new file mode 100644 index 0000000..41847ac --- /dev/null +++ b/q2_ms/assets/find_peaks_centwave.R @@ -0,0 +1,66 @@ +#!/usr/bin/env Rscript --vanilla + +library(xcms) +library(MsExperiment) +library(MsIO) +library(jsonlite) +library(optparse) + +# Define command-line options +option_list <- list( + make_option(opt_str = "--spectra", type = "character"), + make_option(opt_str = "--sample_metadata", type = "character"), + make_option(opt_str = "--ppm", type = "numeric"), + make_option(opt_str = "--min_peakwidth", type = "numeric"), + make_option(opt_str = "--max_peakwidth", type = "numeric"), + make_option(opt_str = "--snthresh", type = "numeric"), + make_option(opt_str = "--prefilter_k", type = "numeric"), + make_option(opt_str = "--prefilter_i", type = "numeric"), + make_option(opt_str = "--mz_center_fun", type = "character"), + make_option(opt_str = "--integrate", type = "integer"), + make_option(opt_str = "--mzdiff", type = "numeric"), + make_option(opt_str = "--fitgauss", type = "logical"), + make_option(opt_str = "--noise", type = "numeric"), + make_option(opt_str = "--first_baseline_check", type = "logical"), + make_option(opt_str = "--ms_level", type = "integer"), + make_option(opt_str = "--threads", type = "integer"), + make_option(opt_str = "--output_path", type = "character") +) + +# Parse arguments +optParser <- OptionParser(option_list = option_list) +opt <- parse_args(optParser) + +# Get full paths to mzML files and read them into an MsExperiment object +mzmlFiles <- list.files(opt$spectra, pattern = "\\.mzML$", full.names = TRUE) + +# Create sample metadata +sampleData <- read.table(file = opt$sample_metadata, header = TRUE, sep = "\t") + +# Read the mzML files and sample data into an MsExperiment object +msexperiment <- readMsExperiment(spectraFiles = mzmlFiles, sampleData = sampleData) + +# Load default parameters for CentWave +CentWaveParams <- CentWaveParam( + ppm = opt$ppm, + peakwidth = c(opt$min_peakwidth, opt$max_peakwidth), + snthresh = opt$snthresh, + prefilter = c(opt$prefilter_k, opt$prefilter_i), + mzCenterFun = opt$mz_center_fun, + integrate = opt$integrate, + mzdiff = opt$mzdiff, + fitgauss = opt$fitgauss, + noise = opt$noise, + firstBaselineCheck = opt$first_baseline_check, +) + +# Find peaks using the CentWave algorithm +XCMSExperiment <- findChromPeaks( + object = msexperiment, + param = CentWaveParams, + msLevel = opt$ms_level, + BPPARAM = MulticoreParam(workers = opt$threads) +) + +# Export the XCMSExperiment object to the directory format +saveMsObject(XCMSExperiment, param = PlainTextParam(path = opt$output_path)) diff --git a/q2_ms/citations.bib b/q2_ms/citations.bib index 2608865..03ac9be 100644 --- a/q2_ms/citations.bib +++ b/q2_ms/citations.bib @@ -8,3 +8,30 @@ @article{kosters2018pymzml year={2018}, publisher={Oxford University Press} } +@article{smith2006xcms, + title={XCMS: processing mass spectrometry data for metabolite profiling using nonlinear peak alignment, matching, and identification}, + author={Smith, Colin A and Want, Elizabeth J and O'Maille, Grace and Abagyan, Ruben and Siuzdak, Gary}, + journal={Analytical chemistry}, + volume={78}, + number={3}, + pages={779--787}, + year={2006}, + publisher={ACS Publications} +} +@article{tautenhahn2008highly, + title={Highly sensitive feature detection for high resolution LC/MS}, + author={Tautenhahn, Ralf and B{\"o}ttcher, Christoph and Neumann, Steffen}, + journal={BMC bioinformatics}, + volume={9}, + pages={1--16}, + year={2008}, + publisher={Springer} +} +@Manual{msexperiment2024, + title = {MsExperiment: Infrastructure for Mass Spectrometry Experiments}, + author = {Laurent Gatto and Johannes Rainer and Sebastian Gibb}, + year = {2024}, + note = {R package version 1.8.0}, + url = {https://bioconductor.org/packages/MsExperiment}, + doi = {10.18129/B9.bioc.MsExperiment}, + } diff --git a/q2_ms/plugin_setup.py b/q2_ms/plugin_setup.py index 9319567..4345c7e 100644 --- a/q2_ms/plugin_setup.py +++ b/q2_ms/plugin_setup.py @@ -6,10 +6,26 @@ # The full license is in the file LICENSE, distributed with this software. # ---------------------------------------------------------------------------- from q2_types.sample_data import SampleData -from qiime2.plugin import Citations, Plugin +from qiime2.core.type import Bool, Choices, Float, Int, Properties, Range, Str +from qiime2.plugin import Citations, Metadata, Plugin from q2_ms import __version__ from q2_ms.types import mzML, mzMLDirFmt, mzMLFormat +from q2_ms.types._format import ( + MSBackendDataFormat, + MSExperimentLinkMColsFormat, + MSExperimentSampleDataFormat, + MSExperimentSampleDataLinksSpectra, + SpectraSlotsFormat, + XCMSExperimentChromPeakDataFormat, + XCMSExperimentChromPeaksFormat, + XCMSExperimentDirFmt, + XCMSExperimentFeatureDefinitionsFormat, + XCMSExperimentFeaturePeakIndexFormat, + XCMSExperimentJSONFormat, +) +from q2_ms.types._type import XCMSExperiment +from q2_ms.xcms.find_peaks_centwave import find_peaks_centwave citations = Citations.load("citations.bib", package="q2_ms") @@ -22,14 +38,138 @@ short_description="A QIIME 2 plugin for MS data processing.", ) +plugin.methods.register_function( + function=find_peaks_centwave, + inputs={"spectra": SampleData[mzML]}, + outputs=[("xcms_experiment", XCMSExperiment % Properties("Peaks"))], + parameters={ + "sample_metadata": Metadata, + "ppm": Float, + "min_peakwidth": Float, + "max_peakwidth": Float, + "snthresh": Float, + "prefilter_k": Float, + "prefilter_i": Float, + "mz_center_fun": Str + % Choices(["wMean", "mean", "apex", "wMeanApex3", "meanApex3"]), + "integrate": Int % Range(1, 3), + "mzdiff": Float, + "fitgauss": Bool, + "noise": Float, + "first_baseline_check": Bool, + "ms_level": Int, + "threads": Int, + }, + input_descriptions={"spectra": "Spectra data as mzML files."}, + output_descriptions={ + "xcms_experiment": ( + "XCMSExperiment object with chromatographic peak information exported to " + "plain text." + ) + }, + parameter_descriptions={ + "sample_metadata": ( + "Metadata with the sample annotations. The index column should be called " + "'sampleid' and should be identical to the filename. The second column " + "should be called 'samplegroup'." + ), + "ppm": ( + "Defines the maximal tolerated m/z deviation in consecutive scans in parts " + "per million (ppm) for the initial ROI definition." + ), + "min_peakwidth": ( + "Defines the minimal expected approximate peak width in chromatographic " + "space in seconds." + ), + "max_peakwidth": ( + "Defines the maximal expected approximate peak width in chromatographic " + "space in seconds." + ), + "snthresh": "Defines the signal to noise ratio cutoff.", + "prefilter_k": ( + "Specifies the prefilter step for the first analysis step (ROI detection). " + "Mass traces are only retained if they contain at least k peaks." + ), + "prefilter_i": ( + "Specifies the prefilter step for the first analysis step (ROI detection). " + "Mass traces are only retained if they contain peaks with intensity >= i." + ), + "mz_center_fun": ( + "Name of the function to calculate the m/z center of the chromatographic " + 'peak. Allowed are: "wMean": intensity weighted mean of the peaks ' + 'm/z values, "mean": mean of the peaks m/z values, "apex": use the m/z ' + 'value at the peak apex, "wMeanApex3": intensity weighted mean of the ' + "m/z value at the peak apex and the m/z values left and right of it and " + '"meanApex3": mean of the m/z value of the peak apex and the m/z values ' + "left and right of it." + ), + "integrate": ( + "Integration method. For integrate = 1 peak limits are found through " + "descent on the mexican hat filtered data, for integrate = 2 the descent " + "is done on the real data. The latter method is more accurate but prone " + "to noise, while the former is more robust, but less exact." + ), + "mzdiff": ( + "Represents the minimum difference in m/z dimension required for peaks with" + " overlapping retention times; can be negative to allow overlap. During " + "peak post-processing, peaks defined to be overlapping are reduced to the " + "one peak with the largest signal." + ), + "fitgauss": ( + "Whether or not a Gaussian should be fitted to each peak. This affects " + "mostly the retention time position of the peak." + ), + "noise": ( + "Allowing to set a minimum intensity required for centroids to be " + "considered in the first analysis step (centroids with intensity < noise " + "are omitted from ROI detection)." + ), + "first_baseline_check": ( + "If TRUE continuous data within regions of interest is checked to be above " + "the first baseline." + ), + "ms_level": ( + "Defines the MS level on which the peak detection should be performed." + ), + "threads": "Number of threads to be used.", + }, + name="Find chromatographic peaks with centWave", + description=( + "This function uses the XCMS and the centWave algorithm to perform peak " + "density and wavelet based chromatographic peak detection for high resolution " + "LC/MS data." + ), + citations=[ + citations["kosters2018pymzml"], + citations["tautenhahn2008highly"], + citations["smith2006xcms"], + citations["msexperiment2024"], + ], +) + # Registrations plugin.register_semantic_types( mzML, + XCMSExperiment, ) plugin.register_semantic_type_to_format(SampleData[mzML], artifact_format=mzMLDirFmt) +plugin.register_semantic_type_to_format( + XCMSExperiment, artifact_format=XCMSExperimentDirFmt +) plugin.register_formats( mzMLFormat, mzMLDirFmt, + MSBackendDataFormat, + MSExperimentLinkMColsFormat, + MSExperimentSampleDataFormat, + MSExperimentSampleDataLinksSpectra, + SpectraSlotsFormat, + XCMSExperimentChromPeakDataFormat, + XCMSExperimentChromPeaksFormat, + XCMSExperimentDirFmt, + XCMSExperimentFeatureDefinitionsFormat, + XCMSExperimentFeaturePeakIndexFormat, + XCMSExperimentJSONFormat, ) diff --git a/q2_ms/tests/__init__.py b/q2_ms/tests/__init__.py new file mode 100644 index 0000000..688e933 --- /dev/null +++ b/q2_ms/tests/__init__.py @@ -0,0 +1,7 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2024, QIIME 2 development team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file LICENSE, distributed with this software. +# ---------------------------------------------------------------------------- diff --git a/q2_ms/tests/tests_utils.py b/q2_ms/tests/tests_utils.py new file mode 100644 index 0000000..b39d2de --- /dev/null +++ b/q2_ms/tests/tests_utils.py @@ -0,0 +1,84 @@ +import importlib +import subprocess +import unittest +from unittest.mock import call, patch + +from qiime2.plugin.testing import TestPluginBase + +from q2_ms.utils import EXTERNAL_CMD_WARNING, run_command, run_r_script + + +class TestRunCommand(TestPluginBase): + package = "q2_ms.tests" + + @patch("subprocess.run") + @patch("builtins.print") + def test_run_command_verbose(self, mock_print, mock_subprocess_run): + # Mock command and working directory + cmd = ["echo", "Hello"] + cwd = "/test/directory" + + # Run the function with verbose=True + run_command(cmd, cwd=cwd, verbose=True) + + # Check if subprocess.run was called with the correct arguments + mock_subprocess_run.assert_called_once_with(cmd, check=True, cwd=cwd, env=None) + + # Check if the correct print statements were called + mock_print.assert_has_calls( + [ + call(EXTERNAL_CMD_WARNING), + call("\nCommand:", end=" "), + call("echo Hello", end="\n\n"), + ] + ) + + @patch("subprocess.run") + @patch("builtins.print") + def test_run_command_non_verbose(self, mock_print, mock_subprocess_run): + # Mock command and working directory + cmd = ["echo", "Hello"] + cwd = "/test/directory" + + # Run the function with verbose=False + run_command(cmd, cwd=cwd, verbose=False) + + # Check if subprocess.run was called with the correct arguments + mock_subprocess_run.assert_called_once_with(cmd, check=True, cwd=cwd, env=None) + + # Ensure no print statements were made + mock_print.assert_not_called() + + @patch("subprocess.run") + def test_run_r_script_success(self, mock_subprocess): + # Call function + run_r_script( + params={"param1": "value1", "param2": 42}, + script_name="test_script", + package_name="q2_ms", + ) + + # Check if subprocess.run was called correctly + expected_script_path = str( + importlib.resources.files("q2_ms") / "assets/test_script.R" + ) + expected_cmd = [ + "/usr/local/bin/Rscript", + "--vanilla", + expected_script_path, + "--param1", + "value1", + "--param2", + "42", + ] + + mock_subprocess.assert_called_once_with( + expected_cmd, check=True, cwd=None, env=unittest.mock.ANY + ) + + @patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "cmd")) + def test_run_r_script_failure(self, mock_subprocess): + with self.assertRaises(Exception) as context: + run_r_script({}, "", "q2_ms") + + self.assertIn("q2_ms", str(context.exception)) diff --git a/q2_ms/types/__init__.py b/q2_ms/types/__init__.py index 0afa253..0bc553f 100644 --- a/q2_ms/types/__init__.py +++ b/q2_ms/types/__init__.py @@ -5,7 +5,37 @@ # # The full license is in the file LICENSE, distributed with this software. # ---------------------------------------------------------------------------- -from q2_ms.types._format import mzMLDirFmt, mzMLFormat -from q2_ms.types._type import mzML +from q2_ms.types._format import ( + MSBackendDataFormat, + MSExperimentLinkMColsFormat, + MSExperimentSampleDataFormat, + MSExperimentSampleDataLinksSpectra, + SpectraSlotsFormat, + XCMSExperimentChromPeakDataFormat, + XCMSExperimentChromPeaksFormat, + XCMSExperimentDirFmt, + XCMSExperimentFeatureDefinitionsFormat, + XCMSExperimentFeaturePeakIndexFormat, + XCMSExperimentJSONFormat, + mzMLDirFmt, + mzMLFormat, +) +from q2_ms.types._type import XCMSExperiment, mzML -__all__ = ["mzMLFormat", "mzMLDirFmt", "mzML"] +__all__ = [ + "mzMLFormat", + "mzMLDirFmt", + "mzML", + "MSBackendDataFormat", + "MSExperimentLinkMColsFormat", + "MSExperimentSampleDataFormat", + "MSExperimentSampleDataLinksSpectra", + "SpectraSlotsFormat", + "XCMSExperimentChromPeakDataFormat", + "XCMSExperimentChromPeaksFormat", + "XCMSExperimentDirFmt", + "XCMSExperimentFeatureDefinitionsFormat", + "XCMSExperimentFeaturePeakIndexFormat", + "XCMSExperimentJSONFormat", + "XCMSExperiment", +] diff --git a/q2_ms/types/_format.py b/q2_ms/types/_format.py index 11947ea..48a3f34 100644 --- a/q2_ms/types/_format.py +++ b/q2_ms/types/_format.py @@ -5,9 +5,11 @@ # # The full license is in the file LICENSE, distributed with this software. # ---------------------------------------------------------------------------- +import json import os import sys +import pandas as pd import pymzml from qiime2.core.exceptions import ValidationError from qiime2.plugin import model @@ -35,3 +37,305 @@ class mzMLDirFmt(model.DirectoryFormat): @mzml.set_path_maker def mzml_path_maker(self, sample_id): return f"{sample_id}.mzML" + + +class MSBackendDataFormat(model.TextFileFormat): + def _validate(self): + header_exp = [ + "msLevel", + "rtime", + "acquisitionNum", + "dataOrigin", + "polarity", + "precScanNum", + "precursorMz", + "precursorIntensity", + "precursorCharge", + "collisionEnergy", + "peaksCount", + "totIonCurrent", + "basePeakMZ", + "basePeakIntensity", + "ionisationEnergy", + "lowMZ", + "highMZ", + "injectionTime", + "spectrumId", + "dataStorage", + "scanIndex", + ] + + header_obs_1 = pd.read_csv(str(self), sep="\t", nrows=0).columns.tolist() + header_obs_2 = pd.read_csv( + str(self), sep="\t", skiprows=1, nrows=1 + ).columns.tolist() + + if (not set(header_exp).issubset(set(header_obs_2))) or header_obs_1[ + 0 + ] != "# MsBackendMzR": + raise ValidationError( + "Header does not match MSBackendDataFormat. It must consist of the " + "following two line with at least these columns:\n" + "# MsBackendMzR\n" + "\t".join(header_exp) + "\n\nFound instead:\n" + f"{header_obs_1[0]}\n" + "\t".join(header_obs_2) + ) + + def _validate_(self, level): + self._validate() + + +class MSExperimentLinkMColsFormat(model.TextFileFormat): + def _validate(self): + with open(str(self), "r") as file: + first_line = file.readline().strip() + + if first_line != '"subsetBy"': + raise ValidationError( + "Header does not match MSExperimentLinkMColsFormat. It must " + "consist of the following line:\n" + '"subsetBy"\n\n' + "Found instead:\n" + f"{first_line}" + ) + + def _validate_(self, level): + self._validate() + + +class MSExperimentSampleDataLinksSpectra(model.TextFileFormat): + def _validate(self): + first_line = pd.read_csv(str(self), sep="\t", nrows=0).columns.tolist() + + if len(first_line) != 2: + raise ValidationError( + "File does not match MSExperimentLinkMColsFormat. " + "It must consist of exactly two columns." + ) + + def _validate_(self, level): + self._validate() + + +class MSExperimentSampleDataFormat(model.TextFileFormat): + def _validate(self): + header_obs = pd.read_csv(str(self), sep="\t", nrows=0).columns.tolist() + + if len(header_obs) != 3 or header_obs[2] != "spectraOrigin": + raise ValidationError( + "Header does not match MSExperimentSampleDataFormat. It must consist " + "of three columns where the third column is called 'spectraOrigin':" + + "\n\nFound instead:\n" + + ", ".join(header_obs) + ) + + def _validate_(self, level): + self._validate() + + +class XCMSExperimentJSONFormat(model.TextFileFormat): + def _validate(self): + try: + with self.open() as file: + content = file.read() + data = json.loads(content) + + if not isinstance(data, list): + raise ValidationError( + "File does not match XCMSExperimentJSONFormat. " + "The root element must be a list." + ) + + parsed_item = json.loads(data[0]) + + required_keys = {"type", "attributes", "value"} + if not required_keys.issubset(parsed_item.keys()): + raise ValidationError( + "File does not match XCMSExperimentJSONFormat. " + "JSON object must contain the keys: " + ", ".join(required_keys) + ) + + except json.JSONDecodeError as e: + raise ValidationError(f"File is not valid JSON: {e}") + + def _validate_(self, level): + self._validate() + + +class SpectraSlotsFormat(model.TextFileFormat): + def _validate(self): + expected_keys = { + "processingQueueVariables", + "processing", + "processingChunkSize", + "backend", + } + + with self.open() as file: + lines = file.readlines() + + keys = set() + for line in lines: + if "=" not in line: + continue + + key = line.split("=", 1)[0].strip() + keys.add(key) + + if keys != expected_keys: + raise ValidationError( + "File does not match SpectraSlotsFormat. " + "File must have the following structure:\n" + "processingQueueVariables = ...\n" + "processing = ...\n" + "processingChunkSize = ...\n" + "backend = ...\n" + ) + + def _validate_(self, level): + self._validate() + + +class XCMSExperimentChromPeakDataFormat(model.TextFileFormat): + def _validate(self): + header_exp = ["ms_level", "is_filled"] + header_obs = pd.read_csv(str(self), sep="\t", nrows=0).columns.tolist() + + if header_exp != header_obs: + raise ValidationError( + "Header does not match XCMSExperimentChromPeakDataFormat. It must " + "consist of the following columns:\n" + + ", ".join(header_exp) + + "\n\nFound instead:\n" + + ", ".join(header_obs) + ) + + def _validate_(self, level): + self._validate() + + +class XCMSExperimentChromPeaksFormat(model.TextFileFormat): + def _validate(self): + header_exp = [ + "mz", + "mzmin", + "mzmax", + "rt", + "rtmin", + "rtmax", + "into", + "intb", + "maxo", + "sn", + "sample", + ] + header_obs = pd.read_csv(str(self), sep="\t", nrows=0).columns.tolist() + + if header_exp != header_obs: + raise ValidationError( + "Header does not match XCMSExperimentChromPeaksFormat. It must " + "consist of the following columns:\n" + + ", ".join(header_exp) + + "\n\nFound instead:\n" + + ", ".join(header_obs) + ) + + def _validate_(self, level): + self._validate() + + +class XCMSExperimentFeatureDefinitionsFormat(model.TextFileFormat): + def _validate(self): + header_exp = [ + "mzmed", + "mzmin", + "mzmax", + "rtmed", + "rtmin", + "rtmax", + "npeaks", + "peakidx", + "ms_level", + ] + header_obs = pd.read_csv(str(self), sep="\t", nrows=0).columns.tolist() + + if not set(header_exp).issubset(set(header_obs)): + raise ValidationError( + "Header does not match XCMSExperimentFeatureDefinitionsFormat. It must " + "at least consist of the following columns:\n" + + ", ".join(header_exp) + + "\n\nFound instead:\n" + + ", ".join(header_obs) + ) + + def _validate_(self, level): + self._validate() + + +class XCMSExperimentFeaturePeakIndexFormat(model.TextFileFormat): + def _validate(self): + header_exp = ["feature_index", "peak_index"] + header_obs = pd.read_csv(str(self), sep="\t", nrows=0).columns.tolist() + + if header_exp != header_obs: + raise ValidationError( + "Header does not match XCMSExperimentFeaturePeakIndexFormat. It must " + "consist of the following columns:\n" + + ", ".join(header_exp) + + "\n\nFound instead:\n" + + ", ".join(header_obs) + ) + + def _validate_(self, level): + self._validate() + + +class XCMSExperimentDirFmt(model.DirectoryFormat): + ms_backend_data = model.File( + pathspec="ms_backend_data.txt", + format=MSBackendDataFormat, + ) + ms_experiment_link_mcols = model.File( + pathspec="ms_experiment_link_mcols.txt", + format=MSExperimentLinkMColsFormat, + ) + ms_experiment_sample_data_links_spectra = model.File( + pathspec="ms_experiment_sample_data_links_spectra.txt", + format=MSExperimentSampleDataLinksSpectra, + ) + ms_experiment_sample_data = model.File( + pathspec="ms_experiment_sample_data.txt", + format=MSExperimentSampleDataFormat, + ) + spectra_processing_queue = model.File( + pathspec="spectra_processing_queue.json", + format=XCMSExperimentJSONFormat, + ) + spectra_slots = model.File( + pathspec="spectra_slots.txt", + format=SpectraSlotsFormat, + ) + xcms_experiment_process_history = model.File( + pathspec="xcms_experiment_process_history.json", + format=XCMSExperimentJSONFormat, + optional=True, + ) + xcms_experiment_chrom_peak_data = model.File( + pathspec="xcms_experiment_chrom_peak_data.txt", + format=XCMSExperimentChromPeakDataFormat, + optional=True, + ) + xcms_experiment_chrom_peaks = model.File( + pathspec="xcms_experiment_chrom_peaks.txt", + format=XCMSExperimentChromPeaksFormat, + optional=True, + ) + xcms_experiment_feature_definitions = model.File( + pathspec="xcms_experiment_feature_definitions.txt", + format=XCMSExperimentFeatureDefinitionsFormat, + optional=True, + ) + xcms_experiment_feature_peak_index = model.File( + pathspec="xcms_experiment_feature_peak_index.txt", + format=XCMSExperimentFeaturePeakIndexFormat, + optional=True, + ) diff --git a/q2_ms/types/_type.py b/q2_ms/types/_type.py index bda1310..9d62221 100644 --- a/q2_ms/types/_type.py +++ b/q2_ms/types/_type.py @@ -9,3 +9,4 @@ from qiime2.core.type import SemanticType mzML = SemanticType("mzML", variant_of=SampleData.field["type"]) +XCMSExperiment = SemanticType("XCMSExperiment") diff --git a/q2_ms/types/tests/data/XCMSExperiment/ms_backend_data.txt b/q2_ms/types/tests/data/XCMSExperiment/ms_backend_data.txt new file mode 100644 index 0000000..4170133 --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/ms_backend_data.txt @@ -0,0 +1,4 @@ +# MsBackendMzR +"msLevel" "rtime" "acquisitionNum" "dataOrigin" "polarity" "precScanNum" "precursorMz" "precursorIntensity" "precursorCharge" "collisionEnergy" "peaksCount" "totIonCurrent" "basePeakMZ" "basePeakIntensity" "ionisationEnergy" "lowMZ" "highMZ" "mergedScan" "mergedResultScanNum" "mergedResultStartScanNum" "mergedResultEndScanNum" "injectionTime" "spectrumId" "ionMobilityDriftTime" "dataStorage" "scanIndex" +"1" 1 2551.457 33 "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/KO/ko15.CDF" -1 -1 -1 -1 -1 -1 1 950104 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 "scan=33" -1 "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/KO/ko15.CDF" 33 +"2" 1 2553.022 34 "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/KO/ko15.CDF" -1 -1 -1 -1 -1 -1 1 950831 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 "scan=34" -1 "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/KO/ko15.CDF" 34 diff --git a/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_link_mcols.txt b/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_link_mcols.txt new file mode 100644 index 0000000..1eb24ea --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_link_mcols.txt @@ -0,0 +1,2 @@ +"subsetBy" +"1" 1 diff --git a/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_sample_data.txt b/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_sample_data.txt new file mode 100644 index 0000000..0c65b1d --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_sample_data.txt @@ -0,0 +1,9 @@ +"sample_name" "sample_group" "spectraOrigin" +"1" "ko15" "KO" "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/KO/ko15.CDF" +"2" "ko16" "KO" "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/KO/ko16.CDF" +"3" "ko21" "KO" "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/KO/ko21.CDF" +"4" "ko22" "KO" "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/KO/ko22.CDF" +"5" "wt15" "WT" "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/WT/wt15.CDF" +"6" "wt16" "WT" "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/WT/wt16.CDF" +"7" "wt21" "WT" "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/WT/wt21.CDF" +"8" "wt22" "WT" "/Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/library/faahKO/cdf/WT/wt22.CDF" diff --git a/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_sample_data_links_spectra.txt b/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_sample_data_links_spectra.txt new file mode 100644 index 0000000..a9127bd --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/ms_experiment_sample_data_links_spectra.txt @@ -0,0 +1,4 @@ +1 1 +1 2 +1 3 +1 4 diff --git a/q2_ms/types/tests/data/XCMSExperiment/spectra_processing_queue.json b/q2_ms/types/tests/data/XCMSExperiment/spectra_processing_queue.json new file mode 100644 index 0000000..6dc213f --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/spectra_processing_queue.json @@ -0,0 +1 @@ +["{\"type\":\"list\",\"attributes\":{},\"value\":[]}"] diff --git a/q2_ms/types/tests/data/XCMSExperiment/spectra_slots.txt b/q2_ms/types/tests/data/XCMSExperiment/spectra_slots.txt new file mode 100644 index 0000000..cb105c5 --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/spectra_slots.txt @@ -0,0 +1,4 @@ +processingQueueVariables = +processing = Filter: select retention time [2550..4250] on MS level(s) 1 [Fri Jan 10 10:44:05 2025] +processingChunkSize = Inf +backend = MsBackendMzR diff --git a/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_chrom_peak_data.txt b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_chrom_peak_data.txt new file mode 100644 index 0000000..6482241 --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_chrom_peak_data.txt @@ -0,0 +1,5 @@ +"ms_level" "is_filled" +"CP0001" 1 FALSE +"CP0002" 1 FALSE +"CP0003" 1 FALSE +"CP0004" 1 FALSE diff --git a/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_chrom_peaks.txt b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_chrom_peaks.txt new file mode 100644 index 0000000..4df5c60 --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_chrom_peaks.txt @@ -0,0 +1,5 @@ +"mz" "mzmin" "mzmax" "rt" "rtmin" "rtmax" "into" "intb" "maxo" "sn" "sample" +"CP0001" 594 594 594 2601.535 2581.191 2637.529 161042.173000001 146073.265238597 7850 11 1 +"CP0002" 577 577 577 2604.665 2581.191 2626.574 136105.181931035 128067.924357041 6215 11 1 +"CP0003" 307 307 307 2618.75 2592.145 2645.354 284782.39285294 264907.040801006 16872 20 1 +"CP0004" 302 302 302 2617.185 2595.275 2640.659 687146.624275862 669778.14276588 30552 43 1 diff --git a/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_feature_definitions.txt b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_feature_definitions.txt new file mode 100644 index 0000000..f6fb5da --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_feature_definitions.txt @@ -0,0 +1,5 @@ +"mzmed" "mzmin" "mzmax" "rtmed" "rtmin" "rtmax" "npeaks" "KO" "WT" "peakidx" "ms_level" +"FT001" 200.100006103516 200.100006103516 200.100006103516 2902.63382922675 2882.60316302447 2922.66449542903 2 2 0 NA 1 +"FT002" 205 205 205 2789.9005512558 2782.95486324401 2796.53064719492 8 4 4 NA 1 +"FT003" 206 206 206 2789.40482144152 2781.38918200882 2794.21878124394 7 3 4 NA 1 +"FT004" 207.100006103516 207.100006103516 207.100006103516 2718.5596680034 2714.04673814199 2727.3469901939 7 4 3 NA 1 diff --git a/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_feature_peak_index.txt b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_feature_peak_index.txt new file mode 100644 index 0000000..c6def82 --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_feature_peak_index.txt @@ -0,0 +1,5 @@ +"feature_index" "peak_index" +"1" 1 458 +"2" 1 1161 +"3" 2 44 +"4" 2 443 diff --git a/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_process_history.json b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_process_history.json new file mode 100644 index 0000000..c2061de --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment/xcms_experiment_process_history.json @@ -0,0 +1 @@ +["{\"type\":\"list\",\"attributes\":{},\"value\":[{\"type\":\"S4\",\"attributes\":{\"param\":{\"type\":\"S4\",\"attributes\":{\"ppm\":{\"type\":\"double\",\"attributes\":{},\"value\":[25]},\"peakwidth\":{\"type\":\"double\",\"attributes\":{},\"value\":[20,80]},\"snthresh\":{\"type\":\"double\",\"attributes\":{},\"value\":[10]},\"prefilter\":{\"type\":\"double\",\"attributes\":{},\"value\":[6,5000]},\"mzCenterFun\":{\"type\":\"character\",\"attributes\":{},\"value\":[\"wMean\"]},\"integrate\":{\"type\":\"integer\",\"attributes\":{},\"value\":[1]},\"mzdiff\":{\"type\":\"double\",\"attributes\":{},\"value\":[-0.001]},\"fitgauss\":{\"type\":\"logical\",\"attributes\":{},\"value\":[false]},\"noise\":{\"type\":\"double\",\"attributes\":{},\"value\":[5000]},\"verboseColumns\":{\"type\":\"logical\",\"attributes\":{},\"value\":[false]},\"roiList\":{\"type\":\"list\",\"attributes\":{},\"value\":[]},\"firstBaselineCheck\":{\"type\":\"logical\",\"attributes\":{},\"value\":[true]},\"roiScales\":{\"type\":\"double\",\"attributes\":{},\"value\":[]},\"extendLengthMSW\":{\"type\":\"logical\",\"attributes\":{},\"value\":[false]},\"verboseBetaColumns\":{\"type\":\"logical\",\"attributes\":{},\"value\":[false]}},\"value\":{\"class\":\"CentWaveParam\",\"package\":\"xcms\"}},\"msLevel\":{\"type\":\"integer\",\"attributes\":{},\"value\":[1]},\"type\":{\"type\":\"character\",\"attributes\":{},\"value\":[\"Peak detection\"]},\"date\":{\"type\":\"character\",\"attributes\":{},\"value\":[\"Fri Jan 10 12:09:13 2025\"]},\"info\":{\"type\":\"character\",\"attributes\":{},\"value\":[]},\"fileIndex\":{\"type\":\"integer\",\"attributes\":{},\"value\":[1,2,3,4,5,6,7,8]},\"error\":{\"type\":\"NULL\"}},\"value\":{\"class\":\"XProcessHistory\",\"package\":\"xcms\"}}]}"] diff --git a/q2_ms/types/tests/data/XCMSExperiment_json_invalid/spectra_processing_queue_keys_check.json b/q2_ms/types/tests/data/XCMSExperiment_json_invalid/spectra_processing_queue_keys_check.json new file mode 100644 index 0000000..15d14aa --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment_json_invalid/spectra_processing_queue_keys_check.json @@ -0,0 +1 @@ +["{\"type\":\"list\",\"items\":{},\"value\":[]}"] diff --git a/q2_ms/types/tests/data/XCMSExperiment_json_invalid/spectra_processing_queue_list_check.json b/q2_ms/types/tests/data/XCMSExperiment_json_invalid/spectra_processing_queue_list_check.json new file mode 100644 index 0000000..0a59506 --- /dev/null +++ b/q2_ms/types/tests/data/XCMSExperiment_json_invalid/spectra_processing_queue_list_check.json @@ -0,0 +1 @@ +"{\"type\":\"list\",\"attributes\":{},\"value\":[]}" diff --git a/q2_ms/types/tests/test_formats.py b/q2_ms/types/tests/test_formats.py index 7c70c45..b7a8d32 100644 --- a/q2_ms/types/tests/test_formats.py +++ b/q2_ms/types/tests/test_formats.py @@ -8,10 +8,24 @@ from qiime2.core.exceptions import ValidationError from qiime2.plugin.testing import TestPluginBase -from q2_ms.types._format import mzMLDirFmt, mzMLFormat +from q2_ms.types._format import ( + MSBackendDataFormat, + MSExperimentLinkMColsFormat, + MSExperimentSampleDataFormat, + MSExperimentSampleDataLinksSpectra, + SpectraSlotsFormat, + XCMSExperimentChromPeakDataFormat, + XCMSExperimentChromPeaksFormat, + XCMSExperimentDirFmt, + XCMSExperimentFeatureDefinitionsFormat, + XCMSExperimentFeaturePeakIndexFormat, + XCMSExperimentJSONFormat, + mzMLDirFmt, + mzMLFormat, +) -class TestMSTypesAndFormats(TestPluginBase): +class TestmzMLFormats(TestPluginBase): package = "q2_ms.types.tests" def test_mzml_dir_fmt_validate_positive(self): @@ -28,3 +42,153 @@ def test_mzml_format_validate_negative(self): format = mzMLFormat(filepath, mode="r") with self.assertRaises(ValidationError): format.validate() + + +class TestXCMSExperimentFormats(TestPluginBase): + package = "q2_ms.types.tests" + + def test_ms_backend_data_format_validate_positive(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = MSBackendDataFormat(filepath, mode="r") + format.validate() + + def test_ms_backend_data_format_validate_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_experiment_link_mcols.txt") + format = MSBackendDataFormat(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_ms_experiment_link_mcols_validate_positive(self): + filepath = self.get_data_path("XCMSExperiment/ms_experiment_link_mcols.txt") + format = MSExperimentLinkMColsFormat(filepath, mode="r") + format.validate() + + def test_ms_experiment_link_mcols_validate_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = MSExperimentLinkMColsFormat(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_ms_experiment_sample_data_links_spectra_validate_positive(self): + filepath = self.get_data_path( + "XCMSExperiment/ms_experiment_sample_data_links_spectra.txt" + ) + format = MSExperimentSampleDataLinksSpectra(filepath, mode="r") + format.validate() + + def test_ms_experiment_sample_data_links_spectra_validate_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = MSExperimentSampleDataLinksSpectra(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_ms_experiment_sample_data_validate_positive(self): + filepath = self.get_data_path("XCMSExperiment/ms_experiment_sample_data.txt") + format = MSExperimentSampleDataFormat(filepath, mode="r") + format.validate() + + def test_ms_experiment_sample_data_validate_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = MSExperimentSampleDataFormat(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_xcms_experiment_json_queue_validate_positive(self): + filepath = self.get_data_path("XCMSExperiment/spectra_processing_queue.json") + format = XCMSExperimentJSONFormat(filepath, mode="r") + format.validate() + + def test_xcms_experiment_json_history_validate_positive(self): + filepath = self.get_data_path( + "XCMSExperiment/xcms_experiment_process_history.json" + ) + format = XCMSExperimentJSONFormat(filepath, mode="r") + format.validate() + + def test_spectra_processing_queue_validate_negative_json(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = XCMSExperimentJSONFormat(filepath, mode="r") + with self.assertRaisesRegex(ValidationError, "JSON"): + format.validate() + + def test_spectra_processing_queue_validate_negative_list(self): + filepath = self.get_data_path( + "XCMSExperiment_json_invalid/spectra_processing_queue_list_check.json" + ) + format = XCMSExperimentJSONFormat(filepath, mode="r") + with self.assertRaisesRegex(ValidationError, "list"): + format.validate() + + def test_spectra_processing_queue_validate_negative_keys(self): + filepath = self.get_data_path( + "XCMSExperiment_json_invalid/spectra_processing_queue_keys_check.json" + ) + format = XCMSExperimentJSONFormat(filepath, mode="r") + with self.assertRaisesRegex(ValidationError, "keys"): + format.validate() + + def test_spectra_slots_validate_positive(self): + filepath = self.get_data_path("XCMSExperiment/spectra_slots.txt") + format = SpectraSlotsFormat(filepath, mode="r") + format.validate() + + def test_spectra_slots_validate_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = SpectraSlotsFormat(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_xcms_experiment_chrom_peak_data_positive(self): + filepath = self.get_data_path( + "XCMSExperiment/xcms_experiment_chrom_peak_data.txt" + ) + format = XCMSExperimentChromPeakDataFormat(filepath, mode="r") + format.validate() + + def test_xcms_experiment_chrom_peak_data_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = XCMSExperimentChromPeakDataFormat(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_xcms_experiment_chrom_peaks_positive(self): + filepath = self.get_data_path("XCMSExperiment/xcms_experiment_chrom_peaks.txt") + format = XCMSExperimentChromPeaksFormat(filepath, mode="r") + format.validate() + + def test_xcms_experiment_chrom_peaks_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = XCMSExperimentChromPeaksFormat(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_xcms_experiment_feature_definitions_positive(self): + filepath = self.get_data_path( + "XCMSExperiment/xcms_experiment_feature_definitions.txt" + ) + format = XCMSExperimentFeatureDefinitionsFormat(filepath, mode="r") + format.validate() + + def test_xcms_experiment_feature_definitions_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = XCMSExperimentFeatureDefinitionsFormat(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_xcms_experiment_feature_peak_index_positive(self): + filepath = self.get_data_path( + "XCMSExperiment/xcms_experiment_feature_peak_index.txt" + ) + format = XCMSExperimentFeaturePeakIndexFormat(filepath, mode="r") + format.validate() + + def test_xcms_experiment_feature_peak_index_negative(self): + filepath = self.get_data_path("XCMSExperiment/ms_backend_data.txt") + format = XCMSExperimentFeaturePeakIndexFormat(filepath, mode="r") + with self.assertRaises(ValidationError): + format.validate() + + def test_xcms_experiment_dir_fmt_positive(self): + filepath = self.get_data_path("XCMSExperiment") + format = XCMSExperimentDirFmt(filepath, mode="r") + format.validate() diff --git a/q2_ms/utils.py b/q2_ms/utils.py new file mode 100644 index 0000000..a5b2d94 --- /dev/null +++ b/q2_ms/utils.py @@ -0,0 +1,44 @@ +import importlib +import os +import subprocess + +EXTERNAL_CMD_WARNING = ( + "Running external command line application(s). " + "This may print messages to stdout and/or stderr.\n" + "The command(s) being run are below. These commands " + "cannot be manually re-run as they will depend on " + "temporary files that no longer exist." +) + + +def run_command(cmd, cwd, verbose=True, env=None): + if verbose: + print(EXTERNAL_CMD_WARNING) + print("\nCommand:", end=" ") + print(" ".join(cmd), end="\n\n") + subprocess.run(cmd, check=True, cwd=cwd, env=env) + + +def run_r_script(params, script_name, package_name): + script_path = str(importlib.resources.files("q2_ms") / f"assets/{script_name}.R") + cmd = ["/usr/local/bin/Rscript", "--vanilla", script_path] + + for key, value in params.items(): + cmd.extend([f"--{key}", str(value)]) + + # Add /usr/local/bin to PATH to use system installation of Rscript + env = os.environ.copy() + env["PATH"] = "/usr/local/bin:" + env["PATH"] + + # Unset Conda-related R variables to prevent it from overriding the system R library + for var in ["R_LIBS", "R_LIBS_USER", "R_HOME", "CONDA_PREFIX"]: + env.pop(var, None) + + try: + run_command(cmd, verbose=True, cwd=None, env=env) + except subprocess.CalledProcessError as e: + raise Exception( + f"An error was encountered while running {package_name}, " + f"(return code {e.returncode}), please inspect " + "stdout and stderr to learn more." + ) diff --git a/q2_ms/xcms/__init__.py b/q2_ms/xcms/__init__.py new file mode 100644 index 0000000..688e933 --- /dev/null +++ b/q2_ms/xcms/__init__.py @@ -0,0 +1,7 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2024, QIIME 2 development team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file LICENSE, distributed with this software. +# ---------------------------------------------------------------------------- diff --git a/q2_ms/xcms/find_peaks_centwave.py b/q2_ms/xcms/find_peaks_centwave.py new file mode 100644 index 0000000..09d1e13 --- /dev/null +++ b/q2_ms/xcms/find_peaks_centwave.py @@ -0,0 +1,46 @@ +import copy +import os +import tempfile + +from qiime2 import Metadata + +from q2_ms.types import XCMSExperimentDirFmt, mzMLDirFmt +from q2_ms.utils import run_r_script + + +def find_peaks_centwave( + spectra: mzMLDirFmt, + sample_metadata: Metadata, + ppm: float = 25, + min_peakwidth: float = 20, + max_peakwidth: float = 50, + snthresh: float = 10, + prefilter_k: float = 3, + prefilter_i: float = 100, + mz_center_fun: str = "wMean", + integrate: int = 1, + mzdiff: float = -0.001, + fitgauss: bool = False, + noise: float = 0, + first_baseline_check: bool = True, + ms_level: int = 1, + threads: int = 1, +) -> XCMSExperimentDirFmt: + # Create parameters dict + params = copy.copy(locals()) + + # Innit XCMSExperimentDirFmt + xcms_experiment = XCMSExperimentDirFmt() + + # Add output path to params + params["output_path"] = str(xcms_experiment) + + with tempfile.TemporaryDirectory() as tmp_dir: + tsv_path = os.path.join(tmp_dir, "sample_metadata.tsv") + sample_metadata.to_dataframe().to_csv(tsv_path, sep="\t") + params["sample_metadata"] = tsv_path + + # Run R script + run_r_script(params, "find_peaks_centwave.R", "XCMS") + + return xcms_experiment diff --git a/q2_ms/xcms/tests/__init__.py b/q2_ms/xcms/tests/__init__.py new file mode 100644 index 0000000..688e933 --- /dev/null +++ b/q2_ms/xcms/tests/__init__.py @@ -0,0 +1,7 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2024, QIIME 2 development team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file LICENSE, distributed with this software. +# ---------------------------------------------------------------------------- diff --git a/q2_ms/xcms/tests/tests_find_peaks_centwave.py b/q2_ms/xcms/tests/tests_find_peaks_centwave.py new file mode 100644 index 0000000..e69de29