Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions content/results_database_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions content/results_filtered.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions content/results_rescoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
108 changes: 108 additions & 0 deletions src/common/peptide_format.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions src/common/results_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand All @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions src/common/sequence_display.py
Original file line number Diff line number Diff line change
@@ -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)
39 changes: 39 additions & 0 deletions tests/test_peptide_format.py
Original file line number Diff line number Diff line change
@@ -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!"
3 changes: 2 additions & 1 deletion tests/test_result_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading