diff --git a/README.rst b/README.rst index 2949b22..c954146 100644 --- a/README.rst +++ b/README.rst @@ -92,6 +92,7 @@ Supported file formats `AlphaDIA precursors TSV `_ ``alphadia`` ✅ ❌ `CBOR `_ ``cbor`` ✅ ✅ Requires the optional ``cbor2`` dependency (``pip install psm-utils[cbor]``) `DIA-NN TSV `_ ``diann`` ✅ ❌ + `DIA-NN report.parquet `_ ``diann_parquet`` ✅ ❌ `FlashLFQ generic TSV `_ ``flashlfq`` ✅ ✅ `FragPipe PSM TSV `_ ``fragpipe`` ✅ ❌ `ionbot CSV `_ ``ionbot`` ✅ ❌ diff --git a/psm_utils/io/__init__.py b/psm_utils/io/__init__.py index bb39378..f2fae8a 100644 --- a/psm_utils/io/__init__.py +++ b/psm_utils/io/__init__.py @@ -182,6 +182,12 @@ class FileType(TypedDict): "extension": ".tsv", "filename_pattern": r"^.*(?:_|\.)?diann\.tsv$", }, + "diann_parquet": { + "reader": diann.DIANNParquetReader, + "writer": None, + "extension": ".parquet", + "filename_pattern": r"^.*(?:_|\.)?report\.parquet$", + }, "parquet": { # List after more specific Parquet patterns to avoid matching conflicts "reader": parquet.ParquetReader, "writer": parquet.ParquetWriter, diff --git a/psm_utils/io/diann.py b/psm_utils/io/diann.py index 2d46a5b..6f24d09 100644 --- a/psm_utils/io/diann.py +++ b/psm_utils/io/diann.py @@ -2,14 +2,20 @@ Reader for PSM files from DIA-NN. Reads the '.tsv' file as defined on the -`DIA-NN documentation page `_. +`DIA-NN documentation page `_, +or the ``report.parquet`` main report written by DIA-NN 2.0 and later. Notes ----- - DIA-NN calculates q-values at both the run and library level. The run-level q-value is used as the PSM q-value. -- DIA-NN currently does not return precursor m/z values. - DIA-NN currently does not support C-terminal modifications in its searches. +- The classic ``.tsv`` main report does not contain precursor m/z values or decoy status; these + are set to ``None``/``False``, respectively. The ``report.parquet`` main report does contain + both, and they are used when available. +- The ``report.parquet`` main report does not contain a PSM-level score (no ``CScore`` column) + or an MS2 scan number (no ``MS2.Scan`` column). The score is set to ``None`` and + ``Precursor.Id`` is used as the spectrum identifier instead. """ @@ -22,6 +28,7 @@ from typing import Any, cast import pandas as pd +import pyarrow.parquet as pq # type: ignore[import] from psm_utils.io._base_classes import ReaderBase from psm_utils.io._utils import set_csv_field_size_limit @@ -90,11 +97,11 @@ def _get_peptide_spectrum_match( run=psm_dict["Run"], is_decoy=False, qvalue=psm_dict["Q.Value"], - pep=float(psm_dict["PEP"]), - score=float(psm_dict["CScore"]), + pep=psm_dict["PEP"], + score=psm_dict["CScore"], precursor_mz=None, # Not returned by DIA-NN :( - retention_time=float(psm_dict["RT"]), - ion_mobility=float(psm_dict["IM"]), + retention_time=psm_dict["RT"], + ion_mobility=psm_dict["IM"], protein_list=psm_dict["Protein.Ids"].split(";"), source="diann", rank=None, @@ -130,3 +137,69 @@ def from_dataframe(cls, dataframe: pd.DataFrame) -> PSMList: """Create a PSMList from a DIA-NN Pandas DataFrame.""" records = cast(list[dict[str, str]], dataframe.to_dict(orient="records")) return PSMList(psm_list=[cls._get_peptide_spectrum_match(entry) for entry in records]) + + +class DIANNParquetReader(ReaderBase): + """Reader for the DIA-NN 2.0+ ``report.parquet`` main report.""" + + def __init__(self, filename: str | Path, *args: Any, **kwargs: Any) -> None: + """ + Reader for DIA-NN ``report.parquet`` file. + + Parameters + ---------- + filename : str or Path + Path to PSM file. + *args + Additional positional arguments passed to the base class. + **kwargs + Additional keyword arguments passed to the base class. + + """ + super().__init__(filename, *args, **kwargs) + + def __iter__(self) -> Iterator[PSM]: + """Iterate over file and return PSMs one-by-one.""" + with pq.ParquetFile(self.filename) as pq_file: + for batch in pq_file.iter_batches(): + for row in batch.to_pylist(): + yield self._get_peptide_spectrum_match(row, self.filename) + + @staticmethod + def _get_peptide_spectrum_match( + psm_dict: dict[str, Any], filename: str | Path | None = None + ) -> PSM: + """Parse a single PSM from a DIA-NN ``report.parquet`` file.""" + rescoring_features: dict[str, Any] = {} + for ft in RESCORING_FEATURES: + try: + rescoring_features[ft] = psm_dict[ft] + except KeyError: + continue + + return PSM( + peptidoform=DIANNTSVReader._parse_peptidoform( + psm_dict["Modified.Sequence"], str(psm_dict["Precursor.Charge"]) + ), + spectrum_id=psm_dict["Precursor.Id"], + run=psm_dict["Run"], + is_decoy=bool(psm_dict["Decoy"]), + qvalue=psm_dict["Q.Value"], + pep=psm_dict["PEP"], + score=None, # Not returned by DIA-NN in the `report.parquet` main report :( + precursor_mz=psm_dict["Precursor.Mz"], + retention_time=psm_dict["RT"], + ion_mobility=psm_dict["IM"], + protein_list=psm_dict["Protein.Ids"].split(";"), + source="diann", + rank=None, + provenance_data=({"diann_filename": str(filename)} if filename else {}), + rescoring_features=rescoring_features, + metadata={}, + ) + + @classmethod + def from_dataframe(cls, dataframe: pd.DataFrame) -> PSMList: + """Create a PSMList from a DIA-NN ``report.parquet`` Pandas DataFrame.""" + records = cast(list[dict[str, Any]], dataframe.to_dict(orient="records")) + return PSMList(psm_list=[cls._get_peptide_spectrum_match(entry) for entry in records]) diff --git a/tests/test_data/test_diann.parquet b/tests/test_data/test_diann.parquet new file mode 100644 index 0000000..1b7241f Binary files /dev/null and b/tests/test_data/test_diann.parquet differ diff --git a/tests/test_io/test_diann.py b/tests/test_io/test_diann.py index 29884b8..c78d2bb 100644 --- a/tests/test_io/test_diann.py +++ b/tests/test_io/test_diann.py @@ -1,6 +1,6 @@ """Tests for psm_utils.io.diann.""" -from psm_utils.io.diann import DIANNTSVReader +from psm_utils.io.diann import DIANNParquetReader, DIANNTSVReader from psm_utils.psm import PSM test_psm = PSM( @@ -52,3 +52,43 @@ def test__parse_peptidoform(self): reader = DIANNTSVReader("./tests/test_data/test_diann.tsv") for (peptide, charge), expected in test_cases: assert reader._parse_peptidoform(peptide, charge) == expected + + +test_parquet_psm = PSM( + peptidoform="[UNIMOD:1]-AAAAEINVKDPKEDLETSVVDEGR/4", + spectrum_id="(UniMod:1)AAAAEINVKDPKEDLETSVVDEGR4", + run="LFQ_Orbitrap_AIF_Yeast_03", + collection=None, + spectrum=None, + is_decoy=False, + score=None, + qvalue=0.000548193, + pep=0.0104343, + precursor_mz=621.5723, + retention_time=75.2574, + ion_mobility=0.0, + protein_list=["P38156"], + rank=None, + source="diann", + metadata={}, + rescoring_features={ + "RT": 75.2574, + "Predicted.RT": 75.2713, + "iRT": 33.9222, + "Predicted.iRT": 33.8999, + "Ms1.Profile.Corr": 0.347567, + "Ms1.Area": 849940.0, + "IM": 0.0, + "iIM": 0.0, + "Predicted.IM": 0.0, + "Predicted.iIM": 0.0, + }, +) + + +class TestDIANNParquetReader: + def test_iter(self): + with DIANNParquetReader("./tests/test_data/test_diann.parquet") as reader: + for psm in reader: + psm.provenance_data = {} + assert psm == test_parquet_psm