From a18ed80d734681671a9d2cfa221bf4dadcbb0cc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:07:06 +0000 Subject: [PATCH] PSM tables: switch modifications between labels, ProForma and OpenMS; ProForma in exports Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- content/results_database_search.py | 7 +- content/results_filtered.py | 7 +- content/results_rescoring.py | 7 +- src/common/peptide_format.py | 108 +++++++++++++++++++++++++++++ src/common/results_helpers.py | 7 ++ src/common/sequence_display.py | 89 ++++++++++++++++++++++++ tests/test_peptide_format.py | 39 +++++++++++ tests/test_result_exports.py | 3 +- tests/test_sequence_display.py | 76 ++++++++++++++++++++ 9 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 src/common/peptide_format.py create mode 100644 src/common/sequence_display.py create mode 100644 tests/test_peptide_format.py create mode 100644 tests/test_sequence_display.py diff --git a/content/results_database_search.py b/content/results_database_search.py index cd04e69..397644b 100644 --- a/content/results_database_search.py +++ b/content/results_database_search.py @@ -3,7 +3,8 @@ from pathlib import Path from src.common.common import page_setup from src.common.results_helpers import get_workflow_dir -from openms_insight import Table, Heatmap, LinePlot, SequenceView, StateManager +from src.common.sequence_display import psm_table, sequence_mode_selector +from openms_insight import Heatmap, LinePlot, SequenceView, StateManager params = page_setup() st.title("Database Search Results") @@ -56,7 +57,6 @@ state_manager = StateManager() # Load components from cache (no data parameter needed) -table = Table(cache_id=f"table_{cache_id_prefix}", cache_path=str(cache_dir)) heatmap = Heatmap(cache_id=f"heatmap_{cache_id_prefix}", cache_path=str(cache_dir)) seq_view = SequenceView(cache_id=f"seqview_{cache_id_prefix}", cache_path=str(cache_dir)) line_plot = LinePlot(cache_id=f"lineplot_{cache_id_prefix}", cache_path=str(cache_dir)) @@ -66,7 +66,8 @@ heatmap(state_manager=state_manager, height=350) st.subheader("PSM Table") -table(state_manager=state_manager, height=533) +mode = sequence_mode_selector() +psm_table(cache_dir, f"table_{cache_id_prefix}", mode, state_manager) st.subheader("Peptide Sequence") seq_view(key=f"seqview_{cache_id_prefix}", state_manager=state_manager, height=533) diff --git a/content/results_filtered.py b/content/results_filtered.py index d6f31aa..3ed76c9 100644 --- a/content/results_filtered.py +++ b/content/results_filtered.py @@ -3,7 +3,8 @@ from pathlib import Path from src.common.common import page_setup from src.common.results_helpers import get_workflow_dir -from openms_insight import Table, Heatmap, LinePlot, SequenceView, StateManager +from src.common.sequence_display import psm_table, sequence_mode_selector +from openms_insight import Heatmap, LinePlot, SequenceView, StateManager params = page_setup() st.title("Filtered PSMs") @@ -57,7 +58,6 @@ state_manager = StateManager() # Load components from cache (no data parameter needed) -table = Table(cache_id=f"table_{cache_id_prefix}", cache_path=str(cache_dir)) heatmap = Heatmap(cache_id=f"heatmap_{cache_id_prefix}", cache_path=str(cache_dir)) seq_view = SequenceView(cache_id=f"seqview_{cache_id_prefix}", cache_path=str(cache_dir)) line_plot = LinePlot(cache_id=f"lineplot_{cache_id_prefix}", cache_path=str(cache_dir)) @@ -67,7 +67,8 @@ heatmap(state_manager=state_manager, height=350) st.subheader("PSM Table") -table(state_manager=state_manager, height=533) +mode = sequence_mode_selector() +psm_table(cache_dir, f"table_{cache_id_prefix}", mode, state_manager) st.subheader("Peptide Sequence") seq_view(key=f"seqview_{cache_id_prefix}", state_manager=state_manager, height=533) diff --git a/content/results_rescoring.py b/content/results_rescoring.py index caa8af0..d5f1b94 100644 --- a/content/results_rescoring.py +++ b/content/results_rescoring.py @@ -3,7 +3,8 @@ from pathlib import Path from src.common.common import page_setup from src.common.results_helpers import get_workflow_dir -from openms_insight import Table, Heatmap, LinePlot, SequenceView, StateManager +from src.common.sequence_display import psm_table, sequence_mode_selector +from openms_insight import Heatmap, LinePlot, SequenceView, StateManager params = page_setup() st.title("Rescoring Results") @@ -57,7 +58,6 @@ state_manager = StateManager() # Load components from cache (no data parameter needed) -table = Table(cache_id=f"table_{cache_id_prefix}", cache_path=str(cache_dir)) heatmap = Heatmap(cache_id=f"heatmap_{cache_id_prefix}", cache_path=str(cache_dir)) seq_view = SequenceView(cache_id=f"seqview_{cache_id_prefix}", cache_path=str(cache_dir)) line_plot = LinePlot(cache_id=f"lineplot_{cache_id_prefix}", cache_path=str(cache_dir)) @@ -67,7 +67,8 @@ heatmap(state_manager=state_manager, height=350) st.subheader("PSM Table") -table(state_manager=state_manager, height=533) +mode = sequence_mode_selector() +psm_table(cache_dir, f"table_{cache_id_prefix}", mode, state_manager) st.subheader("Peptide Sequence") seq_view(key=f"seqview_{cache_id_prefix}", state_manager=state_manager, height=533) diff --git a/src/common/peptide_format.py b/src/common/peptide_format.py new file mode 100644 index 0000000..8232a43 --- /dev/null +++ b/src/common/peptide_format.py @@ -0,0 +1,108 @@ +"""Peptide sequences with modifications, written for reading or for export. + +OpenMS writes a modified peptide as ``.(TMT6plex)PEPM(Oxidation)K``. Two other +forms are offered here: + +- labels: each modified residue carries a short label, ``tmt-PEPM(ox)K``, for + scanning a table by eye; +- ProForma 2.0 (HUPO-PSI), ``[TMT6plex]-PEPM[Oxidation]K``, the standard form + other tools read, and what exports use. + +Both are built with pyOpenMS from the OpenMS string, so any modification +OpenMS knows is handled; one without a Unimod name falls back to its mass shift. +It makes no Streamlit calls. +""" +from functools import lru_cache + +from pyopenms import AASequence + +# Short labels for common Unimod modifications; any other one shows its full name +SHORT_LABELS = { + "Oxidation": "ox", + "Phospho": "ph", + "Carbamidomethyl": "cam", + "Acetyl": "ac", + "Deamidated": "de", + "Methyl": "me", + "Dimethyl": "me2", + "Trimethyl": "me3", + "GG": "gg", + "Gln->pyro-Glu": "pyro", + "Glu->pyro-Glu": "pyro", + "Ammonia-loss": "-nh3", + "Carbamyl": "carb", + "Nitro": "no2", + "Amidated": "am", + "TMT6plex": "tmt", + "TMTpro": "tmtpro", + "iTRAQ4plex": "itraq", + "iTRAQ8plex": "itraq8", +} + +DISPLAY_MODES = ("Labels", "ProForma", "OpenMS") + + +def _name(mod) -> str: + """Unimod name of a modification, or its signed mass shift when it has none.""" + name = mod.getId() + if name: + return name + return f"{mod.getDiffMonoMass():+.4f}" + + +def _modified_parts(sequence: AASequence): + """(N-term mod or None, [(residue letter, mod or None)], C-term mod or None).""" + n_term = sequence.getNTerminalModification() if sequence.hasNTerminalModification() else None + c_term = sequence.getCTerminalModification() if sequence.hasCTerminalModification() else None + residues = [] + for i in range(sequence.size()): + residue = sequence.getResidue(i) + mod = residue.getModification() if residue.isModified() else None + residues.append((residue.getOneLetterCode(), mod)) + return n_term, residues, c_term + + +@lru_cache(maxsize=65536) +def to_proforma(openms_sequence: str) -> str: + """ProForma 2.0 form of an OpenMS peptide string; unparsable strings come back as they are.""" + try: + sequence = AASequence.fromString(openms_sequence) + except Exception: # not a peptide pyOpenMS can read: show it unchanged + return openms_sequence + n_term, residues, c_term = _modified_parts(sequence) + text = "".join(f"{aa}[{_name(mod)}]" if mod else aa for aa, mod in residues) + if n_term: + text = f"[{_name(n_term)}]-{text}" + if c_term: + text = f"{text}-[{_name(c_term)}]" + return text + + +@lru_cache(maxsize=65536) +def to_labels(openms_sequence: str) -> str: + """Residues followed by short modification labels, e.g. ``tmt-PEPM(ox)K``.""" + try: + sequence = AASequence.fromString(openms_sequence) + except Exception: # not a peptide pyOpenMS can read: show it unchanged + return openms_sequence + + def label(mod) -> str: + name = _name(mod) + return SHORT_LABELS.get(name, name) + + n_term, residues, c_term = _modified_parts(sequence) + text = "".join(f"{aa}({label(mod)})" if mod else aa for aa, mod in residues) + if n_term: + text = f"{label(n_term)}-{text}" + if c_term: + text = f"{text}-{label(c_term)}" + return text + + +def format_sequence(openms_sequence: str, mode: str) -> str: + """``openms_sequence`` in one of DISPLAY_MODES.""" + if mode == "ProForma": + return to_proforma(openms_sequence) + if mode == "Labels": + return to_labels(openms_sequence) + return openms_sequence diff --git a/src/common/results_helpers.py b/src/common/results_helpers.py index c434b23..77ef17e 100644 --- a/src/common/results_helpers.py +++ b/src/common/results_helpers.py @@ -12,6 +12,7 @@ except ImportError: # pyOpenMS <= 3.4.x PeptideIdentificationList = None from src.workflow.ParameterManager import ParameterManager +from src.common.peptide_format import to_proforma def get_workflow_dir(workspace): """Get the workflow directory path.""" @@ -250,6 +251,11 @@ def write_psm_exports(id_df: pl.DataFrame, seq_view, export_dir: Path, prefix: s as the view), together with the PSM's run, RT, m/z, score and proteins. """ export_dir.mkdir(parents=True, exist_ok=True) + # Exports carry the ProForma form next to the OpenMS string, for other tools + if "sequence" in id_df.columns: + id_df = id_df.with_columns( + pl.col("sequence").map_elements(to_proforma, return_dtype=pl.Utf8).alias("proforma") + ) id_df.write_csv(export_dir / f"{prefix}_psms.tsv", separator="\t") fragments = seq_view.export_fragment_ions() @@ -258,6 +264,7 @@ def write_psm_exports(id_df: pl.DataFrame, seq_view, export_dir: Path, prefix: s pl.col("id_idx").cast(fragments.schema["sequence_id"]).alias("sequence_id"), "filename", "rt", + "proforma", pl.col("mz").alias("precursor_mz"), "score", "protein_accession", diff --git a/src/common/sequence_display.py b/src/common/sequence_display.py new file mode 100644 index 0000000..3e7572e --- /dev/null +++ b/src/common/sequence_display.py @@ -0,0 +1,89 @@ +"""PSM table with a switch for how modified peptides are written. + +The workflow caches each run's PSM table with OpenMS sequence strings. When the +user picks another form (see src/common/peptide_format.py), the cached table's +data is reused for a second table cache with that form in the Sequence column. +It is built once per run and form, keeps the same row ids and links, so the +spectrum and sequence views follow it as they follow the original. +""" +import json +import shutil +from pathlib import Path + +import polars as pl +import streamlit as st +from openms_insight import Table + +from src.common.peptide_format import DISPLAY_MODES, format_sequence + +# The choice lives under MODE_KEY; the radio's own key is dropped by Streamlit +# whenever a page without it runs, so it is seeded from MODE_KEY on each render +MODE_KEY = "sequence-display-mode" +WIDGET_KEY = f"_{MODE_KEY}" +DISPLAY_FIELD = "sequence_display" + + +def sequence_mode_selector() -> str: + """Radio for the modification display; the choice is shared by every PSM page.""" + if WIDGET_KEY not in st.session_state: + st.session_state[WIDGET_KEY] = st.session_state.get(MODE_KEY, DISPLAY_MODES[0]) + mode = st.radio( + "Modifications", + DISPLAY_MODES, + key=WIDGET_KEY, + horizontal=True, + help=( + "Labels: modified residues carry a short label, e.g. M(ox), S(ph), C(cam). " + "ProForma: the HUPO-PSI standard, e.g. M[Oxidation]; PSM downloads always include it. " + "OpenMS: the string as OpenMS writes it." + ), + ) + st.session_state[MODE_KEY] = mode + return mode + + +def display_table_id(cache_dir: Path, table_id: str, mode: str) -> str: + """Cache id of ``table_id`` with its sequences in ``mode``, built on first use.""" + if mode == "OpenMS": + return table_id + base = Path(cache_dir) / table_id + manifest = json.loads((base / "manifest.json").read_text()) + # A new run regenerates the base cache, which gets a new generation id + generation = str(manifest.get("cache_generation", ""))[:8] + variant_prefix = f"{table_id}__{mode.lower()}_" + variant_id = f"{variant_prefix}{generation}" + if (Path(cache_dir) / variant_id).is_dir(): + return variant_id + + for stale in Path(cache_dir).glob(f"{variant_prefix}*"): + shutil.rmtree(stale, ignore_errors=True) + data = pl.read_parquet(base / "preprocessed" / "data.parquet") + data = data.with_columns( + pl.col("sequence") + .map_elements(lambda s: format_sequence(s, mode), return_dtype=pl.Utf8) + .alias(DISPLAY_FIELD) + ) + config = manifest["config"] + column_definitions = [ + {**col, "field": DISPLAY_FIELD} if col.get("field") == "sequence" else col + for col in config["column_definitions"] + ] + Table( + cache_id=variant_id, + data=data.lazy(), + cache_path=str(cache_dir), + interactivity=manifest.get("interactivity") or None, + column_definitions=column_definitions, + initial_sort=config.get("initial_sort"), + index_field=config.get("index_field", "id"), + go_to_fields=config.get("go_to_fields"), + layout=config.get("layout", "fitDataFill"), + page_size=config.get("page_size", 100), + ) + return variant_id + + +def psm_table(cache_dir: Path, table_id: str, mode: str, state_manager, height: int = 533) -> None: + """Render the cached PSM table ``table_id`` with its sequences in ``mode``.""" + table = Table(cache_id=display_table_id(cache_dir, table_id, mode), cache_path=str(cache_dir)) + table(state_manager=state_manager, height=height) diff --git a/tests/test_peptide_format.py b/tests/test_peptide_format.py new file mode 100644 index 0000000..ae2d54d --- /dev/null +++ b/tests/test_peptide_format.py @@ -0,0 +1,39 @@ +"""Tests for the peptide display and export forms (labels, ProForma).""" +import pytest + +from src.common.peptide_format import format_sequence, to_labels, to_proforma + +MODIFIED = ".(TMT6plex)PEPM(Oxidation)TIDEC(Carbamidomethyl)S(Phospho)K(TMT6plex)" + + +def test_proforma_uses_unimod_names_and_terminal_dashes(): + assert to_proforma(MODIFIED) == "[TMT6plex]-PEPM[Oxidation]TIDEC[Carbamidomethyl]S[Phospho]K[TMT6plex]" + assert to_proforma("PEPTIDEK.(Amidated)") == "PEPTIDEK-[Amidated]" + assert to_proforma("PEPTIDEK") == "PEPTIDEK" + + +def test_labels_shorten_known_modifications(): + assert to_labels(MODIFIED) == "tmt-PEPM(ox)TIDEC(cam)S(ph)K(tmt)" + # A mass-only modification resolves to its Unimod name + assert to_labels("PEPN[+0.984]K") == "PEPN(de)K" + + +def test_mass_shift_without_a_name(): + # No Unimod entry at this mass, so ProForma carries the signed mass shift + proforma = to_proforma("PEPK[+123.4567]") + assert proforma.startswith("PEPK[+") and proforma.endswith("]") + assert abs(float(proforma[5:-1]) - 123.4567) < 1e-3 + + +@pytest.mark.parametrize("mode, expected", [ + ("Labels", "PEPM(ox)K"), + ("ProForma", "PEPM[Oxidation]K"), + ("OpenMS", "PEPM(Oxidation)K"), +]) +def test_format_sequence_modes(mode, expected): + assert format_sequence("PEPM(Oxidation)K", mode) == expected + + +def test_unreadable_strings_pass_through(): + assert to_proforma("not a peptide!") == "not a peptide!" + assert to_labels("not a peptide!") == "not a peptide!" diff --git a/tests/test_result_exports.py b/tests/test_result_exports.py index 5cde0c9..b2f740e 100644 --- a/tests/test_result_exports.py +++ b/tests/test_result_exports.py @@ -64,13 +64,14 @@ def test_write_psm_exports(tmp_path): psms = pl.read_csv(tmp_path / "exports" / "run_filter_psms.tsv", separator="\t") assert psms.height == 2 assert set(id_df.columns) <= set(psms.columns) + assert psms["proforma"].to_list() == ["PEPTIDEK", "ELVISK"] frags = pl.read_csv(tmp_path / "exports" / "run_filter_fragment_ions.tsv", separator="\t") matched = {(r["sequence"], r["ion"], r["filename"]) for r in frags.iter_rows(named=True) if r["charge"] == 1} assert ("PEPTIDEK", "y3", "A.mzML") in matched assert ("ELVISK", "y2", "B.mzML") in matched # PSM context comes along with every ion - assert {"rt", "precursor_mz", "score", "protein_accession"} <= set(frags.columns) + assert {"rt", "precursor_mz", "score", "protein_accession", "proforma"} <= set(frags.columns) def test_insight_table_cache_follows_source_file(tmp_path, monkeypatch): diff --git a/tests/test_sequence_display.py b/tests/test_sequence_display.py new file mode 100644 index 0000000..144a0d6 --- /dev/null +++ b/tests/test_sequence_display.py @@ -0,0 +1,76 @@ +"""Tests for the PSM table variants with another modification display.""" +import json + +import polars as pl +import pytest + +pytest.importorskip("openms_insight") + +# src.common.sequence_display imports streamlit, so it is imported inside the +# tests, after test_parameter_defaults has imported its modules with a mock. + + +def _base_table(cache_dir): + from openms_insight import Table + + data = pl.DataFrame({ + "id_idx": [0, 1], + "scan_id": [1, 2], + "file_index": [0, 0], + "sequence": ["PEPM(Oxidation)K", "PEPTIDEK"], + "score": [0.01, 0.02], + }) + Table( + cache_id="table_run", + data=data.lazy(), + cache_path=str(cache_dir), + interactivity={"identification": "id_idx"}, + column_definitions=[ + {"field": "sequence", "title": "Sequence"}, + {"field": "score", "title": "Score", "sorter": "number"}, + ], + initial_sort=[{"column": "score", "dir": "asc"}], + index_field="id_idx", + ) + + +def test_variant_table_shows_the_chosen_form(tmp_path): + from src.common.sequence_display import DISPLAY_FIELD, display_table_id + + _base_table(tmp_path) + assert display_table_id(tmp_path, "table_run", "OpenMS") == "table_run" + + variant = display_table_id(tmp_path, "table_run", "ProForma") + assert variant.startswith("table_run__proforma_") + data = pl.read_parquet(tmp_path / variant / "preprocessed" / "data.parquet") + assert data.sort("id_idx")[DISPLAY_FIELD].to_list() == ["PEPM[Oxidation]K", "PEPTIDEK"] + manifest = json.loads((tmp_path / variant / "manifest.json").read_text()) + # Same links and row ids as the original, with the Sequence column swapped + assert manifest["interactivity"] == {"identification": "id_idx"} + assert manifest["config"]["index_field"] == "id_idx" + assert manifest["config"]["column_definitions"][0] == {"field": DISPLAY_FIELD, "title": "Sequence"} + + # Built once: asking again reuses it + assert display_table_id(tmp_path, "table_run", "ProForma") == variant + + +def test_variant_follows_a_rebuilt_base(tmp_path): + from openms_insight import Table + from src.common.sequence_display import display_table_id + + _base_table(tmp_path) + first = display_table_id(tmp_path, "table_run", "Labels") + # A new run regenerates the base table + Table( + cache_id="table_run", + data=pl.DataFrame({"id_idx": [0], "sequence": ["ELVISK"], "score": [0.5]}).lazy(), + cache_path=str(tmp_path), + column_definitions=[{"field": "sequence", "title": "Sequence"}], + index_field="id_idx", + regenerate_cache=True, + ) + second = display_table_id(tmp_path, "table_run", "Labels") + assert second != first + assert not (tmp_path / first).exists() + data = pl.read_parquet(tmp_path / second / "preprocessed" / "data.parquet") + assert data["sequence_display"].to_list() == ["ELVISK"]