From f1d081bc21a621e1f36ab3a41eea06b7baf95853 Mon Sep 17 00:00:00 2001 From: GeorgWa Date: Sun, 23 Nov 2025 22:15:40 +0100 Subject: [PATCH 1/7] add msfragger tsv reader --- .../constants/const_files/psm_reader.yaml | 29 +++ alphabase/psm_reader/msfragger_reader.py | 222 +++++++++++++++++- ...test_msfragger_modification_translation.py | 129 ++++++++++ .../unit/psm_reader/test_msfragger_psm_tsv.py | 86 +++++++ 4 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 tests/unit/psm_reader/test_msfragger_modification_translation.py create mode 100644 tests/unit/psm_reader/test_msfragger_psm_tsv.py diff --git a/alphabase/constants/const_files/psm_reader.yaml b/alphabase/constants/const_files/psm_reader.yaml index 2eb4a7dc..8dd1df8e 100644 --- a/alphabase/constants/const_files/psm_reader.yaml +++ b/alphabase/constants/const_files/psm_reader.yaml @@ -153,6 +153,35 @@ pfind: 'decoy': ['Target/Decoy', 'Targe/Decoy'] modification_mapping_type: null # no mapping required +msfragger_psm_tsv: + reader_type: msfragger_psm_tsv + rt_unit: second + column_mapping: + 'sequence': 'Peptide' + 'charge': 'Charge' + 'rt': 'Retention' + 'score': 'Hyperscore' + 'proteins': 'Protein' + 'decoy': 'Is Decoy' + 'raw_name': 'raw_name' + 'intensity': 'Intensity' + mass_mapped_mods: + - 'Oxidation@M' + - 'Carbamidomethyl@C' + - 'Phospho@S' + - 'Phospho@T' + - 'Phospho@Y' + - 'GlyGly@K' + - 'Acetyl@Any_N-term' + - 'Glu->pyro-Glu@E^Any_N-term' + - 'Gln->pyro-Glu@Q^Any_N-term' + - 'TMT6plex@K' + - 'TMT6plex@Any_N-term' + - 'TMTpro@K' + - 'TMTpro@Any_N-term' + mod_mass_tol: 0.1 + modification_mapping_type: 'maxquant' + msfragger_pepxml: reader_type: msfragger_pepxml rt_unit: second diff --git a/alphabase/psm_reader/msfragger_reader.py b/alphabase/psm_reader/msfragger_reader.py index b5e180eb..6ec281cc 100644 --- a/alphabase/psm_reader/msfragger_reader.py +++ b/alphabase/psm_reader/msfragger_reader.py @@ -22,6 +22,167 @@ def _is_fragger_decoy(proteins: List[str]) -> bool: return all(prot.lower().startswith("rev_") for prot in proteins) +class MSFraggerModificationTranslation: + """Translate MSFragger PSM.TSV modifications to alphabase format.""" + + def __init__( + self, + mass_mapped_mods: List[str], + mod_mass_tol: float = 0.1, + ): + """Initialize MSFragger modification translator. + + Parameters + ---------- + mass_mapped_mods : List[str] + List of modification names to match against (e.g., ['Phospho@S', 'Oxidation@M']) + mod_mass_tol : float + Mass tolerance for matching modifications in Daltons. Default: 0.1 + + """ + self.mass_mapped_mods = mass_mapped_mods + self.mod_mass_tol = mod_mass_tol + + def __call__(self, psm_df: pd.DataFrame) -> pd.DataFrame: + """Translate modifications from MSFragger Assigned Modifications column. + + Parameters + ---------- + psm_df : pd.DataFrame + DataFrame with 'Assigned Modifications' column + + Returns + ------- + pd.DataFrame + The input DataFrame with 'mods' and 'mod_sites' columns added + + """ + psm_df = psm_df.copy() + mods_list = [] + sites_list = [] + + for _, row in psm_df.iterrows(): + assigned_mods = row.get("Assigned Modifications", "") + + if not assigned_mods: + mods_list.append("") + sites_list.append("") + continue + + mods, sites = self._parse_assigned_modifications(assigned_mods) + mods_list.append(mods) + sites_list.append(sites) + + psm_df[PsmDfCols.MODS] = mods_list + psm_df[PsmDfCols.MOD_SITES] = sites_list + + return psm_df + + def _parse_assigned_modifications(self, assigned_mods: str) -> Tuple[str, str]: + """Parse MSFragger Assigned Modifications string. + + Directly maps mass shifts to modification names without conversion. + + Parameters + ---------- + assigned_mods : str + MSFragger format: "5S(79.9663), 7S(79.9663), N-term(304.2071)" + + Returns + ------- + tuple + (mods_str, sites_str) where mods and sites are semicolon-separated + + """ + if not assigned_mods: + return "", "" + + mod_entries = [m.strip() for m in assigned_mods.split(",")] + mods = [] + sites = [] + + for entry in mod_entries: + if not entry: + continue + + if entry.startswith("N-term"): + mass_shift = float(entry.split("(")[1].rstrip(")")) + mod_name = self._match_mod_by_mass(mass_shift, "Any_N-term") + mods.append(mod_name) + sites.append("0") + elif entry.startswith("C-term"): + mass_shift = float(entry.split("(")[1].rstrip(")")) + mod_name = self._match_mod_by_mass(mass_shift, "Any_C-term") + mods.append(mod_name) + sites.append("-1") + else: + parts = entry.split("(") + if len(parts) != 2: # noqa: PLR2004 + continue + + pos_aa_str, mass_str = parts + mass_shift = float(mass_str.rstrip(")")) + + pos_str = "" + aa = "" + for char in pos_aa_str: + if char.isdigit(): + pos_str += char + else: + aa += char + + if pos_str and aa: + pos = int(pos_str) + mod_name = self._match_mod_by_mass(mass_shift, aa) + mods.append(mod_name) + sites.append(str(pos)) + + return ";".join(mods), ";".join(sites) + + def _match_mod_by_mass(self, mass_shift: float, aa_or_term: str) -> str: + """Match mass shift to modification name. + + Parameters + ---------- + mass_shift : float + Mass shift in Daltons (e.g., 79.9663 for phosphorylation) + aa_or_term : str + Amino acid single letter code or terminal (Any_N-term, Any_C-term) + + Returns + ------- + str + Modification name in alphabase format (e.g., 'Phospho@S') + + Raises + ------ + ValueError + If no matching modification is found + + """ + for mod_name in self.mass_mapped_mods: + if mod_name not in MOD_MASS: + continue + + mod_mass = MOD_MASS[mod_name] + + if abs(mass_shift - mod_mass) < self.mod_mass_tol: + mod_base, mod_site = mod_name.split("@") + + if aa_or_term in ["Any_N-term", "Any_C-term"]: + if mod_site == aa_or_term or mod_site.endswith(aa_or_term): + return mod_name + else: + if mod_site == aa_or_term: + return mod_name + if mod_site in ["X", "Any"]: + return f"{mod_base}@{aa_or_term}" + + raise ValueError( + f"Unknown modification: mass_shift={mass_shift:.4f} at {aa_or_term}" + ) + + def _get_mods_from_masses( # noqa: PLR0912, C901 too many branches, too complex TODO: refactor sequence: str, msf_aa_mods: List[str], @@ -85,12 +246,69 @@ def _get_mods_from_masses( # noqa: PLR0912, C901 too many branches, too complex class MSFragger_PSM_TSV_Reader(PSMReaderBase): # noqa: N801 name should use CapWords convention TODO: refactor """Reader for MSFragger's psm.tsv file.""" + _reader_type = "msfragger_psm_tsv" + def __init__( self, + *, + column_mapping: Optional[dict] = None, + modification_mapping: Optional[dict] = None, + fdr: float = 0.01, + keep_decoy: bool = False, + rt_unit: Optional[str] = None, **kwargs, ): - """Constructor.""" - raise NotImplementedError("MSFragger_PSM_TSV_Reader for psm.tsv") + """Initialize MSFragger PSM TSV reader. + + See PSMReaderBase documentation for parameters. + + """ + super().__init__( + column_mapping=column_mapping, + modification_mapping=modification_mapping, + fdr=fdr, + keep_decoy=keep_decoy, + rt_unit=rt_unit, + **kwargs, + ) + self._mass_mapped_mods = psm_reader_yaml.get(self._reader_type, {}).get( + "mass_mapped_mods", [] + ) + self._mod_mass_tol = psm_reader_yaml.get(self._reader_type, {}).get( + "mod_mass_tol", 0.1 + ) + + def _translate_modifications(self) -> None: + pass + + def _load_file(self, filename: str) -> pd.DataFrame: + """Load MSFragger PSM TSV file.""" + return pd.read_csv(filename, sep="\t", keep_default_na=False) + + def _pre_process(self, df: pd.DataFrame) -> pd.DataFrame: + """MSFragger PSM TSV preprocessing.""" + df.fillna("", inplace=True) + df[PsmDfCols.RAW_NAME] = df["Spectrum"].str.split(".").apply(lambda x: x[0]) + df[PsmDfCols.SPEC_IDX] = ( + df["Spectrum"].str.split(".").apply(lambda x: int(x[1])) + ) + return df + + def _translate_decoy(self) -> None: + self._psm_df[PsmDfCols.DECOY] = ( + self._psm_df[PsmDfCols.DECOY] == "true" + ).astype(np.int8) + + def _translate_score(self) -> None: + """MSFragger Hyperscore is already in correct format (larger = better).""" + + def _load_modifications(self, origin_df: pd.DataFrame) -> None: # noqa: ARG002 + """Parse modifications from Assigned Modifications column.""" + translator = MSFraggerModificationTranslation( + mass_mapped_mods=self._mass_mapped_mods, + mod_mass_tol=self._mod_mass_tol, + ) + self._psm_df = translator(self._psm_df) class MSFraggerPepXMLReader(PSMReaderBase): diff --git a/tests/unit/psm_reader/test_msfragger_modification_translation.py b/tests/unit/psm_reader/test_msfragger_modification_translation.py new file mode 100644 index 00000000..dafe215e --- /dev/null +++ b/tests/unit/psm_reader/test_msfragger_modification_translation.py @@ -0,0 +1,129 @@ +"""Unit tests for MSFragger modification translation.""" + +import pandas as pd +import pytest + +from alphabase.constants.modification import MOD_MASS +from alphabase.psm_reader.keys import PsmDfCols +from alphabase.psm_reader.msfragger_reader import MSFraggerModificationTranslation +from alphabase.psm_reader.psm_reader import psm_reader_yaml + + +@pytest.fixture +def translator(): + """Fixture to create translator instance with default config.""" + mass_mapped_mods = psm_reader_yaml.get("msfragger_psm_tsv", {}).get( + "mass_mapped_mods", [] + ) + return MSFraggerModificationTranslation( + mass_mapped_mods=mass_mapped_mods, mod_mass_tol=0.1 + ) + + +@pytest.fixture +def test_psm_df(): + """Fixture for test DataFrame with modifications.""" + return pd.DataFrame( + { + "Peptide": ["PEPTIDE", "SEQUENCE", "ANOTHER"], + "Assigned Modifications": [ + "5S(79.9663), N-term(304.2071)", + "", + "6M(15.9949)", + ], + "Charge": [2, 3, 2], + } + ) + + +@pytest.mark.parametrize( + "mass_mapped_mods,mod_mass_tol,expected_mods,expected_tol", + [ + (["Phospho@S", "Oxidation@M"], 0.1, ["Phospho@S", "Oxidation@M"], 0.1), + (["TMT@K"], 0.2, ["TMT@K"], 0.2), + ], +) +def test_translator_initialization( + mass_mapped_mods, mod_mass_tol, expected_mods, expected_tol +): + """Test translator initialization with various parameters.""" + translator = MSFraggerModificationTranslation( + mass_mapped_mods=mass_mapped_mods, mod_mass_tol=mod_mass_tol + ) + assert translator.mass_mapped_mods == expected_mods + assert translator.mod_mass_tol == expected_tol + + +@pytest.mark.parametrize( + "assigned_mods,expected_mods,expected_sites", + [ + ("", "", ""), + ("5S(79.9663)", "Phospho@S", "5"), + ("6M(15.9949)", "Oxidation@M", "6"), + ("N-term(304.2071)", "TMTpro@Any_N-term", "0"), + ("5S79.9663", "", ""), # Malformed + ( + "5S(79.9663), 7T(79.9663), 9M(15.9949)", + "Phospho@S;Phospho@T;Oxidation@M", + "5;7;9", + ), + ], +) +def test_parse_modifications(translator, assigned_mods, expected_mods, expected_sites): + """Test parsing various modification formats with complete results.""" + mods, sites = translator._parse_assigned_modifications(assigned_mods) + assert mods == expected_mods + assert sites == expected_sites + + +@pytest.mark.parametrize( + "mass,aa,expected", + [ + (79.9663, "S", "Phospho@S"), + (15.9949, "M", "Oxidation@M"), + (304.2071, "K", "TMTpro@K"), + (79.96, "S", "Phospho@S"), # Within tolerance + ], +) +def test_mass_matching(translator, mass, aa, expected): + """Test mass matching for known modifications returns exact match.""" + assert translator._match_mod_by_mass(mass, aa) == expected + + +@pytest.mark.parametrize("mass,aa", [(79.5, "S"), (999.9999, "X")]) +def test_unknown_modification_raises(translator, mass, aa): + """Test that unknown modifications raise ValueError.""" + with pytest.raises(ValueError, match="Unknown modification"): + translator._match_mod_by_mass(mass, aa) + + +def test_dataframe_translation(translator, test_psm_df): + """Test end-to-end translation preserves original columns and adds complete mods.""" + result_df = translator(test_psm_df) + + # Check original columns preserved with exact values + assert result_df["Peptide"].tolist() == ["PEPTIDE", "SEQUENCE", "ANOTHER"] + assert result_df["Charge"].tolist() == [2, 3, 2] + assert len(result_df) == 3 + + # Check mod columns added + assert PsmDfCols.MODS in result_df.columns + assert PsmDfCols.MOD_SITES in result_df.columns + + # Check complete modification results for all rows + assert result_df[PsmDfCols.MODS].iloc[0] == "Phospho@S;TMTpro@Any_N-term" + assert result_df[PsmDfCols.MOD_SITES].iloc[0] == "5;0" + assert result_df[PsmDfCols.MODS].iloc[1] == "" + assert result_df[PsmDfCols.MOD_SITES].iloc[1] == "" + assert result_df[PsmDfCols.MODS].iloc[2] == "Oxidation@M" + assert result_df[PsmDfCols.MOD_SITES].iloc[2] == "6" + + +def test_database_integration(translator): + """Test configured modifications exist in database.""" + for mod_name in translator.mass_mapped_mods: + assert mod_name in MOD_MASS + mod_mass = MOD_MASS[mod_name] + aa = mod_name.split("@")[1] + if aa not in ["Any_N-term", "Any_C-term"] and len(aa) == 1: + assert translator._match_mod_by_mass(mod_mass, aa) == mod_name diff --git a/tests/unit/psm_reader/test_msfragger_psm_tsv.py b/tests/unit/psm_reader/test_msfragger_psm_tsv.py new file mode 100644 index 00000000..01b8efdd --- /dev/null +++ b/tests/unit/psm_reader/test_msfragger_psm_tsv.py @@ -0,0 +1,86 @@ +"""Unit tests for MSFragger PSM TSV reader.""" + +import pandas as pd +import pytest + +from alphabase.psm_reader import psm_reader_provider +from alphabase.psm_reader.keys import PsmDfCols +from alphabase.psm_reader.msfragger_reader import MSFragger_PSM_TSV_Reader + + +@pytest.fixture +def reader(): + """Fixture to create reader instance.""" + return psm_reader_provider.get_reader("msfragger_psm_tsv") + + +class TestReaderBasics: + """Tests for reader initialization and basic functionality.""" + + def test_reader_initialization(self, reader): + """Test reader is properly initialized with correct configuration.""" + assert isinstance(reader, MSFragger_PSM_TSV_Reader) + assert reader._reader_type == "msfragger_psm_tsv" + assert len(reader._mass_mapped_mods) > 0 + assert reader._mod_mass_tol == 0.1 + assert isinstance(reader.column_mapping, dict) + assert PsmDfCols.SEQUENCE in reader.column_mapping + + def test_custom_parameters(self): + """Test reader accepts custom parameters.""" + reader = psm_reader_provider.get_reader("msfragger_psm_tsv", keep_decoy=True) + assert reader._keep_decoy is True + + +class TestDataProcessing: + """Tests for data processing and integration.""" + + def test_preprocessing(self, reader): + """Test preprocessing extracts raw names, spec_idx, and fills NAs correctly.""" + df = pd.DataFrame( + { + "Spectrum": ["file1.01234.01234.3", "file2.01235.01235.2"], + "Peptide": ["PEPTIDE", None], + "Is Decoy": ["false", "true"], + "Assigned Modifications": ["5S(79.9663)", ""], + } + ) + + processed = reader._pre_process(df.copy()) + assert PsmDfCols.RAW_NAME in processed.columns + assert PsmDfCols.SPEC_IDX in processed.columns + assert processed[PsmDfCols.RAW_NAME].tolist() == ["file1", "file2"] + assert processed[PsmDfCols.SPEC_IDX].tolist() == [1234, 1235] + assert processed["Peptide"].tolist() == ["PEPTIDE", ""] + + def test_decoy_translation(self, reader): + """Test decoy translation converts string to int.""" + reader._psm_df = pd.DataFrame({PsmDfCols.DECOY: ["true", "false"]}) + reader._translate_decoy() + assert reader._psm_df[PsmDfCols.DECOY].tolist() == [1, 0] + + def test_modification_loading_integration(self, reader): + """Test modification loading produces complete correct results.""" + reader._psm_df = pd.DataFrame( + { + "Peptide": ["PEPTIDE", "SEQUENCE", "TEST"], + "Assigned Modifications": [ + "5S(79.9663)", + "", + "3M(15.9949), N-term(304.2071)", + ], + } + ) + reader._load_modifications(reader._psm_df) + + assert PsmDfCols.MODS in reader._psm_df.columns + assert PsmDfCols.MOD_SITES in reader._psm_df.columns + assert len(reader._psm_df) == 3 + + # Verify complete modification results for all rows + assert reader._psm_df[PsmDfCols.MODS].tolist() == [ + "Phospho@S", + "", + "Oxidation@M;TMTpro@Any_N-term", + ] + assert reader._psm_df[PsmDfCols.MOD_SITES].tolist() == ["5", "", "3;0"] From 2a40ae2247f81f0e8f84de3de445f6fb4e03bb4d Mon Sep 17 00:00:00 2001 From: GeorgWa Date: Sun, 23 Nov 2025 23:09:46 +0100 Subject: [PATCH 2/7] update config --- .../constants/const_files/psm_reader.yaml | 2 ++ alphabase/psm_reader/msfragger_reader.py | 2 +- .../unit/psm_reader/test_msfragger_psm_tsv.py | 28 +++++++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/alphabase/constants/const_files/psm_reader.yaml b/alphabase/constants/const_files/psm_reader.yaml index 8dd1df8e..23fb70a0 100644 --- a/alphabase/constants/const_files/psm_reader.yaml +++ b/alphabase/constants/const_files/psm_reader.yaml @@ -164,6 +164,8 @@ msfragger_psm_tsv: 'proteins': 'Protein' 'decoy': 'Is Decoy' 'raw_name': 'raw_name' + 'scan_num': 'scan_num' + 'precursor_mz': 'Observed M/Z' 'intensity': 'Intensity' mass_mapped_mods: - 'Oxidation@M' diff --git a/alphabase/psm_reader/msfragger_reader.py b/alphabase/psm_reader/msfragger_reader.py index 6ec281cc..612045cb 100644 --- a/alphabase/psm_reader/msfragger_reader.py +++ b/alphabase/psm_reader/msfragger_reader.py @@ -289,7 +289,7 @@ def _pre_process(self, df: pd.DataFrame) -> pd.DataFrame: """MSFragger PSM TSV preprocessing.""" df.fillna("", inplace=True) df[PsmDfCols.RAW_NAME] = df["Spectrum"].str.split(".").apply(lambda x: x[0]) - df[PsmDfCols.SPEC_IDX] = ( + df[PsmDfCols.SCAN_NUM] = ( df["Spectrum"].str.split(".").apply(lambda x: int(x[1])) ) return df diff --git a/tests/unit/psm_reader/test_msfragger_psm_tsv.py b/tests/unit/psm_reader/test_msfragger_psm_tsv.py index 01b8efdd..7cdc185e 100644 --- a/tests/unit/psm_reader/test_msfragger_psm_tsv.py +++ b/tests/unit/psm_reader/test_msfragger_psm_tsv.py @@ -36,7 +36,7 @@ class TestDataProcessing: """Tests for data processing and integration.""" def test_preprocessing(self, reader): - """Test preprocessing extracts raw names, spec_idx, and fills NAs correctly.""" + """Test preprocessing extracts raw names, scan_num, and fills NAs correctly.""" df = pd.DataFrame( { "Spectrum": ["file1.01234.01234.3", "file2.01235.01235.2"], @@ -48,9 +48,9 @@ def test_preprocessing(self, reader): processed = reader._pre_process(df.copy()) assert PsmDfCols.RAW_NAME in processed.columns - assert PsmDfCols.SPEC_IDX in processed.columns + assert PsmDfCols.SCAN_NUM in processed.columns assert processed[PsmDfCols.RAW_NAME].tolist() == ["file1", "file2"] - assert processed[PsmDfCols.SPEC_IDX].tolist() == [1234, 1235] + assert processed[PsmDfCols.SCAN_NUM].tolist() == [1234, 1235] assert processed["Peptide"].tolist() == ["PEPTIDE", ""] def test_decoy_translation(self, reader): @@ -84,3 +84,25 @@ def test_modification_loading_integration(self, reader): "Oxidation@M;TMTpro@Any_N-term", ] assert reader._psm_df[PsmDfCols.MOD_SITES].tolist() == ["5", "", "3;0"] + + def test_spec_idx_from_scan_num(self, reader): + """Test that spec_idx is correctly calculated as scan_num - 1 in full workflow.""" + import io + + # Create a minimal MSFragger PSM TSV + tsv_content = """Spectrum\tPeptide\tCharge\tRetention\tHyperscore\tProtein\tIs Decoy\tAssigned Modifications\tIntensity +file1.01234.01234.3\tPEPTIDEK\t2\t10.5\t50.0\tP12345\tfalse\t\t1000.0 +file2.01235.01235.2\tSEQUENCER\t3\t15.2\t60.0\tP67890\tfalse\t\t2000.0 +file3.00100.00100.2\tTESTK\t2\t5.0\t45.0\tP11111\tfalse\t\t500.0""" + + tsv_buffer = io.StringIO(tsv_content) + result = reader.import_file(tsv_buffer) + + # Verify both scan_num and spec_idx exist + assert PsmDfCols.SCAN_NUM in result.columns + assert PsmDfCols.SPEC_IDX in result.columns + + # Verify spec_idx = scan_num - 1 for all rows + assert len(result) == 3 + for _, row in result.iterrows(): + assert row[PsmDfCols.SPEC_IDX] == row[PsmDfCols.SCAN_NUM] - 1 From b6ba36bbb730cc1d8ae623cb34426694ce97670a Mon Sep 17 00:00:00 2001 From: GeorgWa Date: Sun, 23 Nov 2025 23:42:59 +0100 Subject: [PATCH 3/7] add integration test --- .../constants/const_files/psm_reader.yaml | 1 + alphabase/psm_reader/keys.py | 1 + alphabase/psm_reader/msfragger_reader.py | 12 +++++++----- .../reference_msfragger_psm_tsv.parquet | Bin 0 -> 11322 bytes tests/integration/test_psm_readers.py | 18 ++++++++++++++++++ .../unit/psm_reader/test_msfragger_psm_tsv.py | 4 +++- .../psm_reader/test_psm_reader_provider.py | 2 ++ 7 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 tests/integration/reference_data/reference_msfragger_psm_tsv.parquet diff --git a/alphabase/constants/const_files/psm_reader.yaml b/alphabase/constants/const_files/psm_reader.yaml index 23fb70a0..f76d864e 100644 --- a/alphabase/constants/const_files/psm_reader.yaml +++ b/alphabase/constants/const_files/psm_reader.yaml @@ -166,6 +166,7 @@ msfragger_psm_tsv: 'raw_name': 'raw_name' 'scan_num': 'scan_num' 'precursor_mz': 'Observed M/Z' + 'assigned_mods': 'Assigned Modifications' 'intensity': 'Intensity' mass_mapped_mods: - 'Oxidation@M' diff --git a/alphabase/psm_reader/keys.py b/alphabase/psm_reader/keys.py index f9746db4..64992b09 100644 --- a/alphabase/psm_reader/keys.py +++ b/alphabase/psm_reader/keys.py @@ -105,6 +105,7 @@ class PsmDfCols(metaclass=ConstantsClass): MODIFIED_SEQUENCE = "modified_sequence" SEQUENCE = "sequence" DECOY = "decoy" + ASSIGNED_MODS = "assigned_mods" MODS = "mods" SCORE = "score" TO_REMOVE = "to_remove" diff --git a/alphabase/psm_reader/msfragger_reader.py b/alphabase/psm_reader/msfragger_reader.py index 612045cb..6443c5ee 100644 --- a/alphabase/psm_reader/msfragger_reader.py +++ b/alphabase/psm_reader/msfragger_reader.py @@ -44,12 +44,12 @@ def __init__( self.mod_mass_tol = mod_mass_tol def __call__(self, psm_df: pd.DataFrame) -> pd.DataFrame: - """Translate modifications from MSFragger Assigned Modifications column. + """Translate modifications from MSFragger assigned modifications. Parameters ---------- psm_df : pd.DataFrame - DataFrame with 'Assigned Modifications' column + DataFrame with PsmDfCols.ASSIGNED_MODS column containing raw assigned modifications strings Returns ------- @@ -57,12 +57,11 @@ def __call__(self, psm_df: pd.DataFrame) -> pd.DataFrame: The input DataFrame with 'mods' and 'mod_sites' columns added """ - psm_df = psm_df.copy() mods_list = [] sites_list = [] for _, row in psm_df.iterrows(): - assigned_mods = row.get("Assigned Modifications", "") + assigned_mods = row.get(PsmDfCols.ASSIGNED_MODS, "") if not assigned_mods: mods_list.append("") @@ -303,11 +302,14 @@ def _translate_score(self) -> None: """MSFragger Hyperscore is already in correct format (larger = better).""" def _load_modifications(self, origin_df: pd.DataFrame) -> None: # noqa: ARG002 - """Parse modifications from Assigned Modifications column.""" + """Parse modifications from PsmDfCols.ASSIGNED_MODS column (mapped from 'Assigned Modifications').""" translator = MSFraggerModificationTranslation( mass_mapped_mods=self._mass_mapped_mods, mod_mass_tol=self._mod_mass_tol, ) + + # Parse modifications: self._psm_df[PsmDfCols.ASSIGNED_MODS] contains raw strings + # from "Assigned Modifications", and we parse them into 'mods' and 'mod_sites' self._psm_df = translator(self._psm_df) diff --git a/tests/integration/reference_data/reference_msfragger_psm_tsv.parquet b/tests/integration/reference_data/reference_msfragger_psm_tsv.parquet new file mode 100644 index 0000000000000000000000000000000000000000..f8f7c0c8967f6a14b2d15ec8174df00f9e3be9e8 GIT binary patch literal 11322 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 JHNsyF{}0oQEam_J literal 0 HcmV?d00001 diff --git a/tests/integration/test_psm_readers.py b/tests/integration/test_psm_readers.py index 898227e3..1894dd32 100644 --- a/tests/integration/test_psm_readers.py +++ b/tests/integration/test_psm_readers.py @@ -20,6 +20,7 @@ AlphaDiaReaderTsv, DiannReader, MaxQuantReader, + MSFragger_PSM_TSV_Reader, SageReaderTSV, SpectronautReader, SpectronautReportReader, @@ -193,6 +194,13 @@ _EVLHLLR_ 2 21.775629 0.837 EVLHLLR 440.274169715862 A0AVF1 TTC26 b 705.4294 0.02848413 1 6 noloss """ +TEST_DATA_MSFRAGGER_PSM_TSV = """Spectrum Spectrum File Peptide Modified Peptide Extended Peptide Prev AA Next AA Peptide Length Charge Retention Observed Mass Calibrated Observed Mass Observed M/Z Calibrated Observed M/Z Calculated Peptide Mass Calculated M/Z Delta Mass Expectation Hyperscore Nextscore Probability Qvalue Number of Enzymatic Termini Number of Missed Cleavages Protein Start Protein End Intensity Assigned Modifications Observed Modifications Purity Is Decoy Protein Protein ID Entry Name Gene +file1.01234.01234.3 interact-file1.pep.xml LENPNNK LENP[+80]NNK K.LENPNNK.S K S 7 3 228.78 1438.80 1438.79 479.9420 479.9396 1438.79 479.9383 0.01 0.04 16.18 15.64 0.99 0.0001 2 0 93 104 1000.0 7K(304.2071), N-term(304.2071) 0.0 false tr|Q06AA7|Q06AA7_PIG Q06AA7 Q06AA7_PIG TRA2B +file1.01235.01235.2 interact-file1.pep.xml PEPTIDE PEPTIDE R.PEPTIDE.K R K 7 2 120.50 750.35 750.34 375.6750 375.6700 750.34 375.6725 0.01 0.08 12.50 10.20 0.85 0.002 2 0 45 51 800.0 0.0 false tr|P12345|P12345_HUMAN P12345 P12345_HUMAN GENE1 +file2.01236.01236.3 interact-file2.pep.xml SEQUENCE SEQUENCE K.SEQUENCE.R K R 8 3 150.75 900.42 900.41 300.8067 300.8033 900.41 300.8050 0.01 0.02 18.90 16.50 0.99 0.0000 2 0 120 127 1500.0 0.0 false sp|P98765|P98765_MOUSE P98765 P98765_MOUSE GENE2 +file2.01237.01237.2 interact-file2.pep.xml TESTM TESTM[+16] R.TESTM.A R A 5 2 95.30 600.28 600.27 300.6383 300.6350 600.27 300.6375 0.01 0.10 10.20 8.50 0.75 0.005 2 0 88 92 500.0 5M(15.9949) 0.0 false tr|Q11111|Q11111_RAT Q11111 Q11111_RAT GENE3 +""" + TEST_DATA_ALPHADIA = """base_width_mobility base_width_rt rt_observed mobility_observed mono_ms1_intensity top_ms1_intensity sum_ms1_intensity weighted_ms1_intensity weighted_mass_deviation weighted_mass_error mz_observed mono_ms1_height top_ms1_height sum_ms1_height weighted_ms1_height isotope_intensity_correlation isotope_height_correlation n_observations intensity_correlation height_correlation intensity_fraction height_fraction intensity_fraction_weighted height_fraction_weighted mean_observation_score sum_b_ion_intensity sum_y_ion_intensity diff_b_y_ion_intensity f_masked fragment_scan_correlation template_scan_correlation fragment_frame_correlation top3_frame_correlation template_frame_correlation top3_b_ion_correlation n_b_ions top3_y_ion_correlation n_y_ions cycle_fwhm mobility_fwhm delta_frame_peak top_3_ms2_mass_error mean_ms2_mass_error n_overlapping mean_overlapping_intensity mean_overlapping_mass_error precursor_idx rank scan_center frame_start elution_group_idx frame_stop frame_center scan_stop score scan_start i_3 genes i_1 decoy mods channel rt_library mobility_library i_0 flat_frag_start_idx mod_sites i_2 flat_frag_stop_idx mz_library rt_calibrated charge mz_calibrated sequence proteins delta_rt n_K n_R n_P _decoy proba qval _candidate_idx valid candidate_idx run mod_seq_hash mod_seq_charge_hash pg_master pg pg_qval intensity 0.000000 38.684296 468.905731 0.000001 117961664.000000 117961664.000000 276840416.000000 95120688.000000 -0.197439 0.197439 781.368469 2849874.750000 2849874.750000 6788314.000000 2273957.500000 0.980586 0.999165 2.000000 0.992594 0.992708 1.000000 1.000000 1.000000 1.000000 0.999540 12.188647 15.848632 -3.659985 1.000000 0.000000 0.000000 0.798432 0.932817 0.908939 0.903936 1.000000 0.898678 11.000000 16.475826 0.000000 -0.950898 0.200608 0.123120 0.000000 0.000000 15.000000 138252 0 0 86989 144294 94514 90601 1 165.561066 0 0.067197 ACTN4_HUMAN;ACTN1_HUMAN 0.340936 0 Carbamidomethyl@C 0 649.565613 0.000000 0.416578 798982 10 0.175290 798994 781.369385 503.363037 2 781.368652 ELPPDQAEYCIAR O43707;P12814 -34.457306 0 1 2 0.000000 0.000000 0.000000 138252 True 138252 20231017_OA2_TiHe_ADIAMA_HeLa_200ng_Evo011_21min_F-40_05 356312820745498424 356312820745498426 O43707 O43707;P12814 0.000000 262684832.000000 0.000000 29.428162 799.346985 0.000001 172471648.000000 172471648.000000 372443296.000000 132807928.000000 -0.149760 0.149760 780.903625 6140298.500000 6140298.500000 13225659.000000 4718308.000000 0.999529 0.999349 2.000000 0.839249 0.848623 1.000000 1.000000 1.000000 1.000000 0.993265 12.386384 15.716099 -3.329715 1.000000 0.000000 0.000000 0.915426 0.954805 0.895610 0.949662 1.000000 0.924100 11.000000 16.276836 0.000000 4.326714 0.438759 0.270384 0.000000 0.000000 15.000000 70824 0 0 152005 77362 157724 154714 1 179.995453 0 0.054696 MDHM_HUMAN 0.353536 0 0 934.109009 0.000000 0.428233 395635 0.163536 395647 780.904480 801.701782 2 780.903748 VDFPQDQLTALTGR P40926 -2.354797 0 1 1 0.000000 0.000000 0.000000 70824 True 70824 20231017_OA2_TiHe_ADIAMA_HeLa_200ng_Evo011_21min_F-40_05 11614389154130684956 11614389154130684958 P40926 P40926 0.000000 475047904.000000 @@ -493,3 +501,13 @@ def test_msfragger_speclib_reader() -> None: reader.import_file(input_data) _assert_reference_df_equal(reader.psm_df, "msfragger_speclib") + + +def test_msfragger_psm_tsv_reader() -> None: + """Test the MSFragger PSM TSV reader.""" + input_data = StringIO(TEST_DATA_MSFRAGGER_PSM_TSV) + + reader = MSFragger_PSM_TSV_Reader() + reader.import_file(input_data) + + _assert_reference_df_equal(reader.psm_df, "msfragger_psm_tsv") diff --git a/tests/unit/psm_reader/test_msfragger_psm_tsv.py b/tests/unit/psm_reader/test_msfragger_psm_tsv.py index 7cdc185e..de800eb4 100644 --- a/tests/unit/psm_reader/test_msfragger_psm_tsv.py +++ b/tests/unit/psm_reader/test_msfragger_psm_tsv.py @@ -61,10 +61,12 @@ def test_decoy_translation(self, reader): def test_modification_loading_integration(self, reader): """Test modification loading produces complete correct results.""" + # Set up _psm_df with raw "Assigned Modifications" strings in PsmDfCols.ASSIGNED_MODS column + # (this simulates what happens after _translate_columns() maps the column) reader._psm_df = pd.DataFrame( { "Peptide": ["PEPTIDE", "SEQUENCE", "TEST"], - "Assigned Modifications": [ + PsmDfCols.ASSIGNED_MODS: [ "5S(79.9663)", "", "3M(15.9949), N-term(304.2071)", diff --git a/tests/unit/psm_reader/test_psm_reader_provider.py b/tests/unit/psm_reader/test_psm_reader_provider.py index 61b03ba1..4c147af1 100644 --- a/tests/unit/psm_reader/test_psm_reader_provider.py +++ b/tests/unit/psm_reader/test_psm_reader_provider.py @@ -10,6 +10,8 @@ "alphapept", "maxquant", "pfind", + "msfragger_psm_tsv", + "msfragger", "msfragger_pepxml", "diann", "spectronaut_report", From 5a9b2c4e37085bcb6f32115af607f33a1c93111a Mon Sep 17 00:00:00 2001 From: GeorgWa Date: Sun, 23 Nov 2025 23:46:16 +0100 Subject: [PATCH 4/7] update comments --- alphabase/psm_reader/msfragger_reader.py | 3 --- tests/unit/psm_reader/test_msfragger_psm_tsv.py | 7 +------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/alphabase/psm_reader/msfragger_reader.py b/alphabase/psm_reader/msfragger_reader.py index 6443c5ee..e07c923e 100644 --- a/alphabase/psm_reader/msfragger_reader.py +++ b/alphabase/psm_reader/msfragger_reader.py @@ -307,9 +307,6 @@ def _load_modifications(self, origin_df: pd.DataFrame) -> None: # noqa: ARG002 mass_mapped_mods=self._mass_mapped_mods, mod_mass_tol=self._mod_mass_tol, ) - - # Parse modifications: self._psm_df[PsmDfCols.ASSIGNED_MODS] contains raw strings - # from "Assigned Modifications", and we parse them into 'mods' and 'mod_sites' self._psm_df = translator(self._psm_df) diff --git a/tests/unit/psm_reader/test_msfragger_psm_tsv.py b/tests/unit/psm_reader/test_msfragger_psm_tsv.py index de800eb4..31c27fc7 100644 --- a/tests/unit/psm_reader/test_msfragger_psm_tsv.py +++ b/tests/unit/psm_reader/test_msfragger_psm_tsv.py @@ -61,8 +61,7 @@ def test_decoy_translation(self, reader): def test_modification_loading_integration(self, reader): """Test modification loading produces complete correct results.""" - # Set up _psm_df with raw "Assigned Modifications" strings in PsmDfCols.ASSIGNED_MODS column - # (this simulates what happens after _translate_columns() maps the column) + # Simulates what happens after _translate_columns() maps the column reader._psm_df = pd.DataFrame( { "Peptide": ["PEPTIDE", "SEQUENCE", "TEST"], @@ -79,7 +78,6 @@ def test_modification_loading_integration(self, reader): assert PsmDfCols.MOD_SITES in reader._psm_df.columns assert len(reader._psm_df) == 3 - # Verify complete modification results for all rows assert reader._psm_df[PsmDfCols.MODS].tolist() == [ "Phospho@S", "", @@ -91,7 +89,6 @@ def test_spec_idx_from_scan_num(self, reader): """Test that spec_idx is correctly calculated as scan_num - 1 in full workflow.""" import io - # Create a minimal MSFragger PSM TSV tsv_content = """Spectrum\tPeptide\tCharge\tRetention\tHyperscore\tProtein\tIs Decoy\tAssigned Modifications\tIntensity file1.01234.01234.3\tPEPTIDEK\t2\t10.5\t50.0\tP12345\tfalse\t\t1000.0 file2.01235.01235.2\tSEQUENCER\t3\t15.2\t60.0\tP67890\tfalse\t\t2000.0 @@ -100,11 +97,9 @@ def test_spec_idx_from_scan_num(self, reader): tsv_buffer = io.StringIO(tsv_content) result = reader.import_file(tsv_buffer) - # Verify both scan_num and spec_idx exist assert PsmDfCols.SCAN_NUM in result.columns assert PsmDfCols.SPEC_IDX in result.columns - # Verify spec_idx = scan_num - 1 for all rows assert len(result) == 3 for _, row in result.iterrows(): assert row[PsmDfCols.SPEC_IDX] == row[PsmDfCols.SCAN_NUM] - 1 From 51ccd8089050b222c1369c854280651765275f36 Mon Sep 17 00:00:00 2001 From: GeorgWa Date: Mon, 24 Nov 2025 20:32:15 +0100 Subject: [PATCH 5/7] update test --- .../unit/psm_reader/test_msfragger_modification_translation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/psm_reader/test_msfragger_modification_translation.py b/tests/unit/psm_reader/test_msfragger_modification_translation.py index dafe215e..c0df00cb 100644 --- a/tests/unit/psm_reader/test_msfragger_modification_translation.py +++ b/tests/unit/psm_reader/test_msfragger_modification_translation.py @@ -26,7 +26,7 @@ def test_psm_df(): return pd.DataFrame( { "Peptide": ["PEPTIDE", "SEQUENCE", "ANOTHER"], - "Assigned Modifications": [ + PsmDfCols.ASSIGNED_MODS: [ "5S(79.9663), N-term(304.2071)", "", "6M(15.9949)", From 834cbe014e528fe3ad9338e4a9bdd29a7b801fca Mon Sep 17 00:00:00 2001 From: GeorgWa Date: Tue, 25 Nov 2025 00:03:22 +0100 Subject: [PATCH 6/7] address reviews --- alphabase/psm_reader/msfragger_reader.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/alphabase/psm_reader/msfragger_reader.py b/alphabase/psm_reader/msfragger_reader.py index e07c923e..ce6f14d7 100644 --- a/alphabase/psm_reader/msfragger_reader.py +++ b/alphabase/psm_reader/msfragger_reader.py @@ -278,7 +278,11 @@ def __init__( ) def _translate_modifications(self) -> None: - pass + """No-op: modification translation is handled in _load_modifications. + + This override prevents the base class implementation from running. + Modifications are translated via MSFraggerModificationTranslation. + """ def _load_file(self, filename: str) -> pd.DataFrame: """Load MSFragger PSM TSV file.""" @@ -357,7 +361,11 @@ def __init__( # noqa: PLR0913, D417 # too many arguments in function definition self._mod_mass_tol = psm_reader_yaml["msfragger_pepxml"]["mod_mass_tol"] def _translate_modifications(self) -> None: - pass + """No-op: modification translation is handled in _load_modifications. + + This override prevents the base class implementation from running. + Modifications are translated via _get_mods_from_masses. + """ def _load_file(self, filename: str) -> pd.DataFrame: """Load a MsFragger output file to a DataFrame.""" From 3d687ba5577491b1b7dc8412d5305f7b8a12e243 Mon Sep 17 00:00:00 2001 From: GeorgWa Date: Sat, 6 Dec 2025 20:50:57 +0100 Subject: [PATCH 7/7] Address PR review comments for MSFragger reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename MSFragger_PSM_TSV_Reader to MSFraggerPsmTsvReader - Rename ASSIGNED_MODS to TMP_MODS (_tmp_mods) for clarity - Add MsFraggerTokens class for string constants - Make instance variables private (_mass_mapped_mods, _mod_mass_tol) - Improve variable names (pos_aa_str -> position_and_amino_acid, etc.) - Remove redundant empty check in __call__ - Combine preprocessing into single apply() call - Simplify docstrings, add output example - Add docs link to psm_reader.yaml - Document TMP_MODS in PsmDfCols docstring - Improve error message for unknown modifications - Update tests to reference yaml config instead of hardcoded values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../constants/const_files/psm_reader.yaml | 3 +- alphabase/psm_reader/__init__.py | 4 +- alphabase/psm_reader/keys.py | 6 +- alphabase/psm_reader/msfragger_reader.py | 103 ++++++++++-------- .../reference_msfragger_psm_tsv.parquet | Bin 11322 -> 11290 bytes tests/integration/test_psm_readers.py | 4 +- ...test_msfragger_modification_translation.py | 8 +- .../unit/psm_reader/test_msfragger_psm_tsv.py | 12 +- 8 files changed, 77 insertions(+), 63 deletions(-) diff --git a/alphabase/constants/const_files/psm_reader.yaml b/alphabase/constants/const_files/psm_reader.yaml index f76d864e..5aa5165a 100644 --- a/alphabase/constants/const_files/psm_reader.yaml +++ b/alphabase/constants/const_files/psm_reader.yaml @@ -153,6 +153,7 @@ pfind: 'decoy': ['Target/Decoy', 'Targe/Decoy'] modification_mapping_type: null # no mapping required +# https://fragpipe.nesvilab.org/docs/tutorial_fragpipe_outputs.html#psmtsv msfragger_psm_tsv: reader_type: msfragger_psm_tsv rt_unit: second @@ -166,7 +167,7 @@ msfragger_psm_tsv: 'raw_name': 'raw_name' 'scan_num': 'scan_num' 'precursor_mz': 'Observed M/Z' - 'assigned_mods': 'Assigned Modifications' + '_tmp_mods': 'Assigned Modifications' 'intensity': 'Intensity' mass_mapped_mods: - 'Oxidation@M' diff --git a/alphabase/psm_reader/__init__.py b/alphabase/psm_reader/__init__.py index b46c0024..9e846fcc 100644 --- a/alphabase/psm_reader/__init__.py +++ b/alphabase/psm_reader/__init__.py @@ -8,7 +8,7 @@ "SwathReader", "SpectronautReportReader", "MaxQuantReader", - "MSFragger_PSM_TSV_Reader", + "MSFraggerPsmTsvReader", "pFindReader", "MSFraggerPepXML", "MSFraggerPepXMLReader", @@ -47,9 +47,9 @@ register_readers as register_mq_readers, ) from alphabase.psm_reader.msfragger_reader import ( - MSFragger_PSM_TSV_Reader, MSFraggerPepXML, MSFraggerPepXMLReader, + MSFraggerPsmTsvReader, ) from alphabase.psm_reader.msfragger_reader import ( register_readers as register_fragger_readers, diff --git a/alphabase/psm_reader/keys.py b/alphabase/psm_reader/keys.py index 64992b09..fda6b0e9 100644 --- a/alphabase/psm_reader/keys.py +++ b/alphabase/psm_reader/keys.py @@ -30,6 +30,10 @@ class PsmDfCols(metaclass=ConstantsClass): Peptide amino-acid sequence without modification annotations (plain sequence of residues). DECOY Label indicating whether the matched sequence is a decoy (reverse/shuffled) entry used for FDR estimation. + TMP_MODS + Intermediate column containing raw modification strings from search engines (e.g., MSFragger's + "Assigned Modifications" format: "5S(79.9663), N-term(304.2071)"). This is translated into + the standardized MODS and MOD_SITES columns during processing and is not present in the final output. MODS List of modifications on the peptide in unimod standardization SCORE @@ -105,7 +109,7 @@ class PsmDfCols(metaclass=ConstantsClass): MODIFIED_SEQUENCE = "modified_sequence" SEQUENCE = "sequence" DECOY = "decoy" - ASSIGNED_MODS = "assigned_mods" + TMP_MODS = "_tmp_mods" MODS = "mods" SCORE = "score" TO_REMOVE = "to_remove" diff --git a/alphabase/psm_reader/msfragger_reader.py b/alphabase/psm_reader/msfragger_reader.py index ce6f14d7..74ac150a 100644 --- a/alphabase/psm_reader/msfragger_reader.py +++ b/alphabase/psm_reader/msfragger_reader.py @@ -18,6 +18,16 @@ ) +class MsFraggerTokens: + """String tokens used in MSFragger output formats.""" + + MOD_START = "(" + MOD_STOP = ")" + MOD_SEPARATOR = "," + N_TERM = "N-term" + C_TERM = "C-term" + + def _is_fragger_decoy(proteins: List[str]) -> bool: return all(prot.lower().startswith("rev_") for prot in proteins) @@ -40,8 +50,8 @@ def __init__( Mass tolerance for matching modifications in Daltons. Default: 0.1 """ - self.mass_mapped_mods = mass_mapped_mods - self.mod_mass_tol = mod_mass_tol + self._mass_mapped_mods = mass_mapped_mods + self._mod_mass_tol = mod_mass_tol def __call__(self, psm_df: pd.DataFrame) -> pd.DataFrame: """Translate modifications from MSFragger assigned modifications. @@ -49,7 +59,7 @@ def __call__(self, psm_df: pd.DataFrame) -> pd.DataFrame: Parameters ---------- psm_df : pd.DataFrame - DataFrame with PsmDfCols.ASSIGNED_MODS column containing raw assigned modifications strings + DataFrame with PsmDfCols.TMP_MODS column containing raw assigned modifications strings Returns ------- @@ -61,13 +71,7 @@ def __call__(self, psm_df: pd.DataFrame) -> pd.DataFrame: sites_list = [] for _, row in psm_df.iterrows(): - assigned_mods = row.get(PsmDfCols.ASSIGNED_MODS, "") - - if not assigned_mods: - mods_list.append("") - sites_list.append("") - continue - + assigned_mods = row.get(PsmDfCols.TMP_MODS, "") mods, sites = self._parse_assigned_modifications(assigned_mods) mods_list.append(mods) sites_list.append(sites) @@ -90,13 +94,16 @@ def _parse_assigned_modifications(self, assigned_mods: str) -> Tuple[str, str]: Returns ------- tuple - (mods_str, sites_str) where mods and sites are semicolon-separated + (mods_str, sites_str) where mods and sites are semicolon-separated. + Example: ("Phospho@S;Phospho@S;TMT@Any_N-term", "5;7;0") """ if not assigned_mods: return "", "" - mod_entries = [m.strip() for m in assigned_mods.split(",")] + mod_entries = [ + m.strip() for m in assigned_mods.split(MsFraggerTokens.MOD_SEPARATOR) + ] mods = [] sites = [] @@ -104,37 +111,44 @@ def _parse_assigned_modifications(self, assigned_mods: str) -> Tuple[str, str]: if not entry: continue - if entry.startswith("N-term"): - mass_shift = float(entry.split("(")[1].rstrip(")")) + if entry.startswith(MsFraggerTokens.N_TERM): + mass_shift = float( + entry.split(MsFraggerTokens.MOD_START)[1].rstrip( + MsFraggerTokens.MOD_STOP + ) + ) mod_name = self._match_mod_by_mass(mass_shift, "Any_N-term") mods.append(mod_name) sites.append("0") - elif entry.startswith("C-term"): - mass_shift = float(entry.split("(")[1].rstrip(")")) + elif entry.startswith(MsFraggerTokens.C_TERM): + mass_shift = float( + entry.split(MsFraggerTokens.MOD_START)[1].rstrip( + MsFraggerTokens.MOD_STOP + ) + ) mod_name = self._match_mod_by_mass(mass_shift, "Any_C-term") mods.append(mod_name) sites.append("-1") else: - parts = entry.split("(") + parts = entry.split(MsFraggerTokens.MOD_START) if len(parts) != 2: # noqa: PLR2004 continue - pos_aa_str, mass_str = parts - mass_shift = float(mass_str.rstrip(")")) + position_and_amino_acid, mass_string = parts + mass_shift = float(mass_string.rstrip(MsFraggerTokens.MOD_STOP)) - pos_str = "" - aa = "" - for char in pos_aa_str: + position = "" + amino_acid = "" + for char in position_and_amino_acid: if char.isdigit(): - pos_str += char + position += char else: - aa += char + amino_acid += char - if pos_str and aa: - pos = int(pos_str) - mod_name = self._match_mod_by_mass(mass_shift, aa) + if position and amino_acid: + mod_name = self._match_mod_by_mass(mass_shift, amino_acid) mods.append(mod_name) - sites.append(str(pos)) + sites.append(position) return ";".join(mods), ";".join(sites) @@ -159,13 +173,13 @@ def _match_mod_by_mass(self, mass_shift: float, aa_or_term: str) -> str: If no matching modification is found """ - for mod_name in self.mass_mapped_mods: + for mod_name in self._mass_mapped_mods: if mod_name not in MOD_MASS: continue mod_mass = MOD_MASS[mod_name] - if abs(mass_shift - mod_mass) < self.mod_mass_tol: + if abs(mass_shift - mod_mass) < self._mod_mass_tol: mod_base, mod_site = mod_name.split("@") if aa_or_term in ["Any_N-term", "Any_C-term"]: @@ -178,7 +192,9 @@ def _match_mod_by_mass(self, mass_shift: float, aa_or_term: str) -> str: return f"{mod_base}@{aa_or_term}" raise ValueError( - f"Unknown modification: mass_shift={mass_shift:.4f} at {aa_or_term}" + f"Unknown modification: mass_shift={mass_shift:.4f} at {aa_or_term}. " + f"Add the modification to 'mass_mapped_mods' in psm_reader.yaml or extend " + f"the reader's _mass_mapped_mods list before importing." ) @@ -242,7 +258,7 @@ def _get_mods_from_masses( # noqa: PLR0912, C901 too many branches, too complex ) -class MSFragger_PSM_TSV_Reader(PSMReaderBase): # noqa: N801 name should use CapWords convention TODO: refactor +class MSFraggerPsmTsvReader(PSMReaderBase): """Reader for MSFragger's psm.tsv file.""" _reader_type = "msfragger_psm_tsv" @@ -278,11 +294,7 @@ def __init__( ) def _translate_modifications(self) -> None: - """No-op: modification translation is handled in _load_modifications. - - This override prevents the base class implementation from running. - Modifications are translated via MSFraggerModificationTranslation. - """ + """No-op: modification translation is handled in _load_modifications.""" def _load_file(self, filename: str) -> pd.DataFrame: """Load MSFragger PSM TSV file.""" @@ -291,9 +303,8 @@ def _load_file(self, filename: str) -> pd.DataFrame: def _pre_process(self, df: pd.DataFrame) -> pd.DataFrame: """MSFragger PSM TSV preprocessing.""" df.fillna("", inplace=True) - df[PsmDfCols.RAW_NAME] = df["Spectrum"].str.split(".").apply(lambda x: x[0]) - df[PsmDfCols.SCAN_NUM] = ( - df["Spectrum"].str.split(".").apply(lambda x: int(x[1])) + df[[PsmDfCols.RAW_NAME, PsmDfCols.SCAN_NUM]] = ( + df["Spectrum"].str.split(".").apply(lambda x: pd.Series([x[0], int(x[1])])) ) return df @@ -306,7 +317,7 @@ def _translate_score(self) -> None: """MSFragger Hyperscore is already in correct format (larger = better).""" def _load_modifications(self, origin_df: pd.DataFrame) -> None: # noqa: ARG002 - """Parse modifications from PsmDfCols.ASSIGNED_MODS column (mapped from 'Assigned Modifications').""" + """Parse modifications from PsmDfCols.TMP_MODS column (mapped from 'Assigned Modifications').""" translator = MSFraggerModificationTranslation( mass_mapped_mods=self._mass_mapped_mods, mod_mass_tol=self._mod_mass_tol, @@ -361,11 +372,7 @@ def __init__( # noqa: PLR0913, D417 # too many arguments in function definition self._mod_mass_tol = psm_reader_yaml["msfragger_pepxml"]["mod_mass_tol"] def _translate_modifications(self) -> None: - """No-op: modification translation is handled in _load_modifications. - - This override prevents the base class implementation from running. - Modifications are translated via _get_mods_from_masses. - """ + """No-op: modification translation is handled in _load_modifications.""" def _load_file(self, filename: str) -> pd.DataFrame: """Load a MsFragger output file to a DataFrame.""" @@ -446,6 +453,6 @@ def __init__(self, *args, **kwargs): def register_readers() -> None: """Register MSFragger readers.""" - psm_reader_provider.register_reader("msfragger_psm_tsv", MSFragger_PSM_TSV_Reader) - psm_reader_provider.register_reader("msfragger", MSFragger_PSM_TSV_Reader) + psm_reader_provider.register_reader("msfragger_psm_tsv", MSFraggerPsmTsvReader) + psm_reader_provider.register_reader("msfragger", MSFraggerPsmTsvReader) psm_reader_provider.register_reader("msfragger_pepxml", MSFraggerPepXMLReader) diff --git a/tests/integration/reference_data/reference_msfragger_psm_tsv.parquet b/tests/integration/reference_data/reference_msfragger_psm_tsv.parquet index f8f7c0c8967f6a14b2d15ec8174df00f9e3be9e8..ca03741239a8839d17fcd01d59ffdbc303b50bb0 100644 GIT binary patch delta 1200 zcmZuvO=uHA6gF+vv`IzMf~H#2+Lnbj+Wd93Tk&T$ZPr z_-a3Be#i6g;g4N_Q&w!c3Ae0$=I2BFPk3s*WBLx`wgZ+=F8;Qw#{rjZU9e!gN3ODV zIJsXV{NoYWupNO!$E&)A+Q6Rst=n=!Eqs%nGtC;#H1i*8=61I5|C6tG4(~wT3FBQ3 z)5qFO*AKIqwIb};&y1SkIN#cSyU$Hm8Ns`Qr&@3stUZGrg#bcGA|DFIYU4fkciXZV z*<03p-qqw;_}1HP+4AAT;FXS)DtMBrP)%u30Z#Wjngg!G-EgBnezdB~@x10M7G?u@ z83z<2rN)<&L9vujy+xc~oWl#@a#9UvQ)+Y+Xx6hn4o>oIy9IArlLWZn?1tY1gPhS2 z!L6iy(JFx&HW~|xR6!A53?8yH?7ahv=29d!2_sHN-3U7iaEIU1vje^2(R{+HZg^mQ z;Nn``LsjN=BR-#z#R^q8TTzx%s!~a4icnW93B<0X{d#UHfN~ilUeSHU)pQOY!vTCX zJTqFJ%A;I%rcA1n=F>)WUiXT=q^49;s#s!oF#3Y^iQ`Da(KmwwxNH>{X#aP&ACnrR= zdyE`RP7pLnrFfdi)*9J$@=28pnNixWte|sOwh}N(Py6YcA@VT0HOO+9$t*8V z0(0)H+s%S8x(5JAum-~}A2J34 zhx#9g+Dg+yRr%6u4{0Q%9wO(EOL}S=^^{AZ+$!~AOO>h|nketBoy0^vbi_X1Z{Eyr zes5;?2j!k}w=`y-$hTURa(zp~gUJ*gjgI2|v8LrYUbW3LkG$*)>6I2LG8|aI_)-Ad&}SfdM4LdsQ7r5(l+PkWpF6HIy5+QPrheab7 z6q;(HjGuZcjLF4aI*&&e8V(8+QeBI0npNL(Ty*2(g$;%mGdD1J$#uMW;Bn($KF4%h zlY`x@O(C$Qh(@y7C?{)18-KsZ*?L+`Xcxi~e(jsVHTFY%=*jIVH@YMu-BaaQpb$=0 zKCg(&$=a=Et}bmC>pH*ST%~IBZbhC$&soo_13DNjMjfV~B0`wpWAZ5rnL>mBrMXd@)N;=sZs6 ePm$>mJM~QrB)@L8e1`WH72B`gk&$)C244d)PJwU$ diff --git a/tests/integration/test_psm_readers.py b/tests/integration/test_psm_readers.py index 1894dd32..3b9dbe3a 100644 --- a/tests/integration/test_psm_readers.py +++ b/tests/integration/test_psm_readers.py @@ -20,7 +20,7 @@ AlphaDiaReaderTsv, DiannReader, MaxQuantReader, - MSFragger_PSM_TSV_Reader, + MSFraggerPsmTsvReader, SageReaderTSV, SpectronautReader, SpectronautReportReader, @@ -507,7 +507,7 @@ def test_msfragger_psm_tsv_reader() -> None: """Test the MSFragger PSM TSV reader.""" input_data = StringIO(TEST_DATA_MSFRAGGER_PSM_TSV) - reader = MSFragger_PSM_TSV_Reader() + reader = MSFraggerPsmTsvReader() reader.import_file(input_data) _assert_reference_df_equal(reader.psm_df, "msfragger_psm_tsv") diff --git a/tests/unit/psm_reader/test_msfragger_modification_translation.py b/tests/unit/psm_reader/test_msfragger_modification_translation.py index c0df00cb..f3f6f24f 100644 --- a/tests/unit/psm_reader/test_msfragger_modification_translation.py +++ b/tests/unit/psm_reader/test_msfragger_modification_translation.py @@ -26,7 +26,7 @@ def test_psm_df(): return pd.DataFrame( { "Peptide": ["PEPTIDE", "SEQUENCE", "ANOTHER"], - PsmDfCols.ASSIGNED_MODS: [ + PsmDfCols.TMP_MODS: [ "5S(79.9663), N-term(304.2071)", "", "6M(15.9949)", @@ -50,8 +50,8 @@ def test_translator_initialization( translator = MSFraggerModificationTranslation( mass_mapped_mods=mass_mapped_mods, mod_mass_tol=mod_mass_tol ) - assert translator.mass_mapped_mods == expected_mods - assert translator.mod_mass_tol == expected_tol + assert translator._mass_mapped_mods == expected_mods + assert translator._mod_mass_tol == expected_tol @pytest.mark.parametrize( @@ -121,7 +121,7 @@ def test_dataframe_translation(translator, test_psm_df): def test_database_integration(translator): """Test configured modifications exist in database.""" - for mod_name in translator.mass_mapped_mods: + for mod_name in translator._mass_mapped_mods: assert mod_name in MOD_MASS mod_mass = MOD_MASS[mod_name] aa = mod_name.split("@")[1] diff --git a/tests/unit/psm_reader/test_msfragger_psm_tsv.py b/tests/unit/psm_reader/test_msfragger_psm_tsv.py index 31c27fc7..995f5e1b 100644 --- a/tests/unit/psm_reader/test_msfragger_psm_tsv.py +++ b/tests/unit/psm_reader/test_msfragger_psm_tsv.py @@ -3,9 +3,9 @@ import pandas as pd import pytest -from alphabase.psm_reader import psm_reader_provider +from alphabase.psm_reader import psm_reader_provider, psm_reader_yaml from alphabase.psm_reader.keys import PsmDfCols -from alphabase.psm_reader.msfragger_reader import MSFragger_PSM_TSV_Reader +from alphabase.psm_reader.msfragger_reader import MSFraggerPsmTsvReader @pytest.fixture @@ -19,10 +19,12 @@ class TestReaderBasics: def test_reader_initialization(self, reader): """Test reader is properly initialized with correct configuration.""" - assert isinstance(reader, MSFragger_PSM_TSV_Reader) + assert isinstance(reader, MSFraggerPsmTsvReader) assert reader._reader_type == "msfragger_psm_tsv" assert len(reader._mass_mapped_mods) > 0 - assert reader._mod_mass_tol == 0.1 + assert ( + reader._mod_mass_tol == psm_reader_yaml["msfragger_psm_tsv"]["mod_mass_tol"] + ) assert isinstance(reader.column_mapping, dict) assert PsmDfCols.SEQUENCE in reader.column_mapping @@ -65,7 +67,7 @@ def test_modification_loading_integration(self, reader): reader._psm_df = pd.DataFrame( { "Peptide": ["PEPTIDE", "SEQUENCE", "TEST"], - PsmDfCols.ASSIGNED_MODS: [ + PsmDfCols.TMP_MODS: [ "5S(79.9663)", "", "3M(15.9949), N-term(304.2071)",