From 1c21ffbe43eb368174871c74e648ed4282e8c396 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:12:34 +0000 Subject: [PATCH 01/18] Add an Identification QC page: score distribution, precursor error, PSMs per file First step towards feature parity with the ProSE web app's QC tab. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- app.py | 1 + content/results_qc.py | 84 ++++++++++++++++++++++ src/common/qc_plots.py | 157 +++++++++++++++++++++++++++++++++++++++++ tests/test_qc_plots.py | 106 ++++++++++++++++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 content/results_qc.py create mode 100644 src/common/qc_plots.py create mode 100644 tests/test_qc_plots.py diff --git a/app.py b/app.py index 3e3b047..746e5f0 100644 --- a/app.py +++ b/app.py @@ -29,6 +29,7 @@ st.Page(Path("content", "results_database_search.py"), title="Database Search", icon="๐Ÿ”ฌ"), st.Page(Path("content", "results_rescoring.py"), title="Rescoring", icon="๐Ÿ“ˆ"), st.Page(Path("content", "results_filtered.py"), title="Filtered PSMs", icon="๐ŸŽฏ"), + st.Page(Path("content", "results_qc.py"), title="Identification QC", icon="๐Ÿฉบ"), st.Page(Path("content", "results_abundance.py"), title="Protein Abundance", icon="๐Ÿ“‹"), st.Page(Path("content", "results_download.py"), title="Download", icon="โฌ‡๏ธ"), ], diff --git a/content/results_qc.py b/content/results_qc.py new file mode 100644 index 0000000..ab74a88 --- /dev/null +++ b/content/results_qc.py @@ -0,0 +1,84 @@ +"""Identification QC Page.""" +from pathlib import Path + +import pandas as pd +import streamlit as st + +from src.common.common import page_setup, show_fig +from src.common.qc_plots import psm_qc_table, precursor_error_histogram, psms_per_file, score_distribution +from src.common.results_helpers import get_workflow_dir +from src.workflow.ParameterManager import ParameterManager + +params = page_setup() + +st.title("Identification QC") +st.markdown( + """ +Quality checks of the identifications across all runs: how well targets separate +from decoys, how well the precursor masses are calibrated, and whether one run +identified clearly fewer spectra than the others. +""" +) + +if "workspace" not in st.session_state: + st.warning("Please initialize your workspace first.") + st.stop() + +workflow_dir = get_workflow_dir(st.session_state["workspace"]) +results_dir = workflow_dir / "results" +workflow_params = ParameterManager(workflow_dir, "TOPP Workflow").get_parameters_from_json() +fdr = min(float(workflow_params.get("psm-fdr-percent", 1.0)), 100.0) / 100.0 +mzml_names = [Path(f).name for f in workflow_params.get("mzML-files", []) or []] + + +@st.cache_data(show_spinner="Reading identifications...") +def load_qc_table(paths: tuple[str, ...], mtimes: tuple[float, ...], mzml_names: tuple[str, ...]) -> pd.DataFrame: + # mtimes only key the cache, so a re-run of the workflow invalidates it + return pd.concat([psm_qc_table(Path(p), list(mzml_names)) for p in paths], ignore_index=True) + + +def qc_table(directory: Path) -> pd.DataFrame | None: + files = sorted(directory.glob("*.idXML")) + if not files: + return None + return load_qc_table( + tuple(str(f) for f in files), tuple(f.stat().st_mtime for f in files), tuple(mzml_names) + ) + + +# All PSMs before filtering, so decoys and the FDR cutoff are visible +all_psms = qc_table(results_dir / "percolator_results") +if all_psms is None: + all_psms = qc_table(results_dir / "comet_results") +filtered = qc_table(results_dir / "psm_filter") + +if all_psms is None and filtered is None: + st.info("No identification results available yet. Please run the workflow first.") + st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") + st.stop() + +if all_psms is not None and not all_psms.empty: + st.subheader("Score distribution") + st.caption( + "All PSMs before FDR filtering. Decoys model the incorrect matches: targets to the " + "right of the decoy peak are the correct identifications. The dashed line is the " + "lowest target score that passes the PSM FDR." + ) + if not all_psms["decoy"].any(): + st.warning("No decoy PSMs found, so the score distribution cannot show the incorrect matches.") + show_fig(score_distribution(all_psms, fdr), "qc-score-distribution") + +if filtered is not None and not filtered.empty: + st.subheader("Precursor mass error") + st.caption( + f"Target PSMs at the PSM FDR ({fdr * 100:.3g} %). A median far from 0 ppm points to a " + "calibration offset; a distribution as wide as the precursor tolerance suggests the " + "tolerance could be tighter." + ) + show_fig(precursor_error_histogram(filtered), "qc-precursor-error") + + st.subheader("PSMs per file") + st.caption(f"Target PSMs at the PSM FDR ({fdr * 100:.3g} %).") + show_fig(psms_per_file(filtered), "qc-psms-per-file") +elif all_psms is not None: + st.info("No filtered PSMs yet: the precursor error and PSMs per file appear once filtering has run.") diff --git a/src/common/qc_plots.py b/src/common/qc_plots.py new file mode 100644 index 0000000..199aa5c --- /dev/null +++ b/src/common/qc_plots.py @@ -0,0 +1,157 @@ +"""Identification QC: score distribution, precursor mass error and PSMs per file. + +``psm_qc_table`` reads one idXML into a flat pandas table (one row per PSM, top +hit only); the plot functions take the concatenated tables of all runs and +return Plotly figures, so the page only decides where to show them. +""" +from pathlib import Path + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from src.common.results_helpers import extract_filename_from_idxml, load_idxml + +# Spacing of the 13C isotope peaks (Da): the precursor may have been picked on one of them +C13_C12_DIFF = 1.0033548 +MAX_ISOTOPE_ERROR = 3 + +# Percolator writes its scores as PSI-MS meta values on each hit +PERCOLATOR_SCORE_KEYS = ("MS:1001492", "Percolator_score") +Q_VALUE_KEYS = ("MS:1001491", "q-value", "Percolator_qvalue") + +TARGET_COLOR = "#2a78d6" +DECOY_COLOR = "#eb6834" + +QC_COLUMNS = ["file", "decoy", "score", "score_type", "percolator_score", "q_value", "charge", "precursor_error_ppm"] + + +def _meta(obj, keys): + for key in keys: + if obj.metaValueExists(key): + try: + return float(obj.getMetaValue(key)) + except (TypeError, ValueError): + return np.nan + return np.nan + + +def _is_decoy(hit, decoy_prefixes=("DECOY_", "rev_")) -> bool: + """A hit is a decoy if PeptideIndexer said so, else if every protein carries a decoy prefix.""" + if hit.metaValueExists("target_decoy"): + value = hit.getMetaValue("target_decoy") + if isinstance(value, bytes): + value = value.decode() + return str(value) == "decoy" + accessions = [ev.getProteinAccession() for ev in hit.getPeptideEvidences()] + return bool(accessions) and all(a.startswith(decoy_prefixes) for a in accessions) + + +def precursor_error_ppm(observed_mz: float, theoretical_mz: float, charge: int) -> float: + """Precursor error in ppm, after removing a 13C isotope error of up to three peaks.""" + if not charge or theoretical_mz <= 0: + return np.nan + isotope = round((observed_mz - theoretical_mz) * charge / C13_C12_DIFF) + if abs(isotope) <= MAX_ISOTOPE_ERROR: + observed_mz -= isotope * C13_C12_DIFF / charge + return (observed_mz - theoretical_mz) / theoretical_mz * 1e6 + + +def psm_qc_table(idxml_path: Path, mzml_names=None) -> pd.DataFrame: + """One row per spectrum (its top hit) of an idXML file.""" + _, peptides = load_idxml(idxml_path) + file = extract_filename_from_idxml(Path(idxml_path), mzml_names) + records = [] + for pep in peptides: + hits = pep.getHits() + if not hits: + continue + hit = hits[0] + charge = hit.getCharge() + theoretical = hit.getSequence().getMZ(charge) if charge else np.nan + records.append({ + "file": file, + "decoy": _is_decoy(hit), + "score": hit.getScore(), + "score_type": pep.getScoreType(), + "percolator_score": _meta(hit, PERCOLATOR_SCORE_KEYS), + "q_value": _meta(hit, Q_VALUE_KEYS), + "charge": charge, + "precursor_error_ppm": precursor_error_ppm(pep.getMZ(), theoretical, charge), + }) + return pd.DataFrame(records, columns=QC_COLUMNS) + + +def _score_axis(df: pd.DataFrame) -> tuple[pd.Series, str]: + """The score to plot, higher is better: Percolator's SVM score, else the search engine's.""" + if df["percolator_score"].notna().all(): + return df["percolator_score"], "Percolator score" + score_type = str(df["score_type"].iloc[0]) if len(df) else "score" + if "e-value" in score_type.lower() or "expect" in score_type.lower(): + return -np.log10(df["score"].clip(lower=1e-300)), "-log10(E-value)" + return df["score"], score_type + + +def score_distribution(df: pd.DataFrame, fdr: float | None = None) -> go.Figure: + """Overlaid histograms of target and decoy PSM scores. + + Decoys should sit at low scores and targets show a second mode of correct + matches at high scores; the dashed line marks the lowest target score that + passes the PSM FDR. + """ + scores, label = _score_axis(df) + fig = go.Figure() + for is_decoy, name, color in ((False, "Targets", TARGET_COLOR), (True, "Decoys", DECOY_COLOR)): + values = scores[df["decoy"] == is_decoy].dropna() + fig.add_trace(go.Histogram(x=values, name=f"{name} ({len(values):,})", marker_color=color, opacity=0.6, nbinsx=60)) + if fdr is not None and fdr < 1.0: + passing = scores[(~df["decoy"]) & (df["q_value"] <= fdr)].dropna() + if len(passing): + fig.add_vline( + x=passing.min(), line_dash="dash", line_color="grey", + annotation_text=f"PSM FDR {fdr * 100:.3g} % (q โ‰ค {fdr:.3g})", + ) + fig.update_layout( + barmode="overlay", + title="Score distribution of target and decoy PSMs", + xaxis_title=label, + yaxis_title="PSMs", + legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0), + ) + return fig + + +def precursor_error_histogram(df: pd.DataFrame) -> go.Figure: + """Precursor mass error of target PSMs; the median shows the calibration offset.""" + errors = df.loc[~df["decoy"], "precursor_error_ppm"].dropna() + fig = go.Figure(go.Histogram(x=errors, marker_color=TARGET_COLOR, nbinsx=60, name="Target PSMs")) + if len(errors): + median = float(errors.median()) + fig.add_vline(x=median, line_dash="dash", line_color="grey", annotation_text=f"median {median:+.2f} ppm") + radius = max(1.0, float(errors.abs().quantile(0.99))) + fig.update_xaxes(range=[-radius * 1.05, radius * 1.05]) + fig.update_layout( + title=f"Precursor mass error of target PSMs ({len(errors):,})", + xaxis_title="Precursor error (ppm), isotope errors removed", + yaxis_title="PSMs", + showlegend=False, + ) + return fig + + +def psms_per_file(df: pd.DataFrame) -> go.Figure: + """Target PSMs per run, so a failed or weak run stands out.""" + counts = df.loc[~df["decoy"]].groupby("file").size() + files = sorted(df["file"].unique()) + counts = counts.reindex(files, fill_value=0) + fig = go.Figure(go.Bar( + x=counts.values, y=counts.index, orientation="h", marker_color=TARGET_COLOR, + text=[f"{v:,}" for v in counts.values], textposition="auto", + )) + fig.update_layout( + title="Target PSMs per file", + xaxis_title="Target PSMs", + yaxis=dict(autorange="reversed"), + height=max(250, 40 * len(files) + 120), + ) + return fig diff --git a/tests/test_qc_plots.py b/tests/test_qc_plots.py new file mode 100644 index 0000000..3a1ec81 --- /dev/null +++ b/tests/test_qc_plots.py @@ -0,0 +1,106 @@ +import math + +import pandas as pd +import pyopenms as poms +import pytest + +from src.common.qc_plots import ( + C13_C12_DIFF, + precursor_error_histogram, + precursor_error_ppm, + psm_qc_table, + psms_per_file, + score_distribution, +) + +try: # pyOpenMS >= 3.5.0 + from pyopenms import PeptideIdentificationList +except ImportError: # pyOpenMS <= 3.4.x + PeptideIdentificationList = None + + +def _psm(sequence, charge, mz_offset_ppm, decoy, svm, q, isotope=0): + seq = poms.AASequence.fromString(sequence) + theo = seq.getMZ(charge) + hit = poms.PeptideHit() + hit.setSequence(seq) + hit.setCharge(charge) + hit.setScore(0.01) + hit.setMetaValue("target_decoy", "decoy" if decoy else "target") + hit.setMetaValue("MS:1001492", svm) + hit.setMetaValue("MS:1001491", q) + pep = poms.PeptideIdentification() + pep.setScoreType("pep") + pep.setMZ(theo * (1 + mz_offset_ppm * 1e-6) + isotope * C13_C12_DIFF / charge) + pep.setRT(100.0) + pep.setHits([hit]) + return pep + + +def _write_idxml(path, peptides): + if PeptideIdentificationList: + pep_list = PeptideIdentificationList() + for p in peptides: + pep_list.push_back(p) + else: + pep_list = peptides + poms.IdXMLFile().store(str(path), [poms.ProteinIdentification()], pep_list) + + +def test_precursor_error_removes_isotope_error(): + theo = 500.0 + assert precursor_error_ppm(theo * (1 + 5e-6), theo, 2) == pytest.approx(5.0, abs=1e-6) + shifted = theo * (1 + 5e-6) + C13_C12_DIFF / 2 + assert precursor_error_ppm(shifted, theo, 2) == pytest.approx(5.0, abs=1e-6) + assert math.isnan(precursor_error_ppm(theo, theo, 0)) + + +def test_psm_qc_table_reads_hits(tmp_path): + path = tmp_path / "run1_comet_perc.idXML" + _write_idxml(path, [ + _psm("PEPTIDEK", 2, 3.0, False, 2.5, 0.001), + _psm("PEPTIDER", 2, -4.0, False, 1.0, 0.02, isotope=1), + _psm("KEDITPEP", 2, 0.0, True, -1.0, 0.5), + ]) + df = psm_qc_table(path, ["run1.mzML"]) + assert list(df["file"].unique()) == ["run1.mzML"] + assert df["decoy"].tolist() == [False, False, True] + assert df["percolator_score"].tolist() == [2.5, 1.0, -1.0] + assert df["q_value"].tolist() == pytest.approx([0.001, 0.02, 0.5]) + assert df["precursor_error_ppm"].tolist()[:2] == pytest.approx([3.0, -4.0], abs=1e-3) + + +def test_figures_build(tmp_path): + df = pd.DataFrame({ + "file": ["a.mzML", "a.mzML", "b.mzML", "b.mzML"], + "decoy": [False, True, False, False], + "score": [0.01, 0.9, 0.02, 0.03], + "score_type": ["pep"] * 4, + "percolator_score": [2.0, -1.0, 1.5, 0.5], + "q_value": [0.001, 0.5, 0.005, 0.02], + "charge": [2, 2, 3, 2], + "precursor_error_ppm": [1.0, 5.0, -2.0, 0.5], + }) + fig = score_distribution(df, fdr=0.01) + assert fig.layout.xaxis.title.text == "Percolator score" + assert [t.name for t in fig.data] == ["Targets (3)", "Decoys (1)"] + # the cutoff line sits at the lowest passing target score + assert fig.layout.shapes[0].x0 == 1.5 + + err = precursor_error_histogram(df) + assert len(err.data[0].x) == 3 # decoys left out + + bars = psms_per_file(df) + assert list(bars.data[0].y) == ["a.mzML", "b.mzML"] + assert list(bars.data[0].x) == [1, 2] + + +def test_score_distribution_falls_back_to_evalue(): + df = pd.DataFrame({ + "file": ["a.mzML"] * 2, "decoy": [False, True], "score": [1e-5, 1.0], + "score_type": ["expect"] * 2, "percolator_score": [float("nan")] * 2, + "q_value": [float("nan")] * 2, "charge": [2, 2], "precursor_error_ppm": [0.0, 0.0], + }) + fig = score_distribution(df) + assert fig.layout.xaxis.title.text == "-log10(E-value)" + assert list(fig.data[0].x) == pytest.approx([5.0]) From 475436de9ee0c051dae44bfefca279fea51edd24 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:13:11 +0000 Subject: [PATCH 02/18] Add a Proteins page: protein table with coverage, linked peptides and PSMs Second step towards feature parity with the ProSE web app (protein table, coverage map and drill-down). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- app.py | 1 + content/results_proteins.py | 118 +++++++++++++++++++++++ src/common/insight_tables.py | 14 ++- src/common/protein_table.py | 175 +++++++++++++++++++++++++++++++++++ tests/test_insight_table.py | 9 ++ tests/test_protein_table.py | 71 ++++++++++++++ 6 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 content/results_proteins.py create mode 100644 src/common/protein_table.py create mode 100644 tests/test_protein_table.py diff --git a/app.py b/app.py index 3e3b047..f30589c 100644 --- a/app.py +++ b/app.py @@ -29,6 +29,7 @@ st.Page(Path("content", "results_database_search.py"), title="Database Search", icon="๐Ÿ”ฌ"), st.Page(Path("content", "results_rescoring.py"), title="Rescoring", icon="๐Ÿ“ˆ"), st.Page(Path("content", "results_filtered.py"), title="Filtered PSMs", icon="๐ŸŽฏ"), + st.Page(Path("content", "results_proteins.py"), title="Proteins", icon="๐Ÿงฉ"), st.Page(Path("content", "results_abundance.py"), title="Protein Abundance", icon="๐Ÿ“‹"), st.Page(Path("content", "results_download.py"), title="Download", icon="โฌ‡๏ธ"), ], diff --git a/content/results_proteins.py b/content/results_proteins.py new file mode 100644 index 0000000..93d5532 --- /dev/null +++ b/content/results_proteins.py @@ -0,0 +1,118 @@ +"""Protein Identification Results Page.""" +from pathlib import Path + +import streamlit as st +from openms_insight import StateManager + +from src.common.common import page_setup +from src.common.insight_tables import show_insight_table +from src.common.protein_table import build_tables, collect_psms, coverage_html, read_fasta +from src.common.results_helpers import get_workflow_dir +from src.WorkflowTest import WorkflowTest + +params = page_setup() +wf = WorkflowTest() + +st.title("Proteins") +st.markdown( + """ +Proteins identified by the FDR-filtered PSMs of all runs, with their sequence +coverage. **Select a protein** to list its peptides and see its coverage map; +**select a peptide** to list its PSMs. +""" +) + +workflow_dir = get_workflow_dir(st.session_state["workspace"]) +filter_dir = workflow_dir / "results" / "psm_filter" +cache_dir = workflow_dir / "results" / "insight_cache" +idxml_files = sorted(filter_dir.glob("*.idXML")) if filter_dir.exists() else [] + +if not idxml_files: + st.info("No filtered PSMs available yet. Please run the workflow first.") + st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") + st.stop() + +fasta_path = None +if wf.params.get("fasta-file"): + try: + fasta_path = Path(wf.file_manager.get_files([wf.params["fasta-file"]])[0]) + except (ValueError, IndexError): + fasta_path = None +if fasta_path is None or not fasta_path.is_file(): + st.warning("The FASTA database of this run was not found, so coverage and descriptions cannot be shown.") + +mzml_names = [Path(f).name for f in wf.params.get("mzML-files", []) or []] +source_files = [*idxml_files, *([fasta_path] if fasta_path and fasta_path.is_file() else [])] + + +@st.cache_data(show_spinner="Building protein tables...") +def load_tables(paths: tuple[str, ...], fasta: str | None, mtimes: tuple[float, ...], mzml_names: tuple[str, ...]): + # mtimes only key the cache, so a re-run of the workflow invalidates it + psms = collect_psms([Path(p) for p in paths], list(mzml_names)) + fasta_entries = read_fasta(fasta) if fasta else {} + files = sorted(psms["file"].unique()) + return (*build_tables(psms, fasta_entries, files), fasta_entries) + + +proteins, peptides, psms_long, coverage, fasta_entries = load_tables( + tuple(str(p) for p in idxml_files), + str(fasta_path) if fasta_path and fasta_path.is_file() else None, + tuple(p.stat().st_mtime for p in source_files), + tuple(mzml_names), +) + +if proteins.empty: + st.info("No target proteins were identified.") + st.stop() + +c1, c2, c3 = st.columns(3) +c1.metric("Proteins", f"{len(proteins):,}") +c2.metric("Peptides", f"{peptides['peptide'].nunique():,}") +c3.metric("Target PSMs", f"{psms_long.drop_duplicates(['file', 'scan', 'peptide']).shape[0]:,}") + +state_manager = StateManager("proteins_page") +# Columns the linked tables filter on: already shown by the selection above them +HIDDEN = {"visible": False} + + +def decimals(n: int) -> dict: + return {"formatter": "money", "formatterParams": {"thousand": False, "precision": n}} + + +st.subheader("Proteins") +show_insight_table( + proteins, name="proteins", cache_dir=cache_dir, source_files=source_files, + title="proteins", height=450, state_manager=state_manager, + interactivity={"protein": "accession"}, + column_overrides={"coverage (%)": decimals(1)}, +) + +selected = state_manager.get_selection("protein") +if selected in coverage and selected in fasta_entries: + description, sequence = fasta_entries[selected] + mask = coverage[selected] + st.subheader(f"Coverage of {selected}") + st.caption( + f"{description} ยท {len(sequence)} residues ยท {mask.sum()} covered ({100 * mask.mean():.1f} %). " + "Covered residues are highlighted; every occurrence of a peptide counts." + ) + st.html(coverage_html(sequence, mask)) + +st.subheader("Peptides of the selected protein") +show_insight_table( + peptides, name="protein_peptides", cache_dir=cache_dir, source_files=source_files, + title="peptides", height=350, state_manager=state_manager, + filters={"protein": "accession"}, interactivity={"peptide": "peptide"}, + column_overrides={"accession": HIDDEN}, +) + +st.subheader("PSMs of the selected peptide") +show_insight_table( + psms_long, name="protein_psms", cache_dir=cache_dir, source_files=source_files, + title="psms", height=350, state_manager=state_manager, + filters={"protein": "accession", "peptide": "peptide"}, + column_overrides={ + "accession": HIDDEN, "peptide": HIDDEN, + "RT (s)": decimals(1), "m/z": decimals(4), + }, +) diff --git a/src/common/insight_tables.py b/src/common/insight_tables.py index 14cdcad..776a35e 100644 --- a/src/common/insight_tables.py +++ b/src/common/insight_tables.py @@ -54,7 +54,7 @@ def _cache_key(source_files, extra: str) -> str: return hashlib.sha1("|".join(parts).encode()).hexdigest()[:10] -def _to_polars(df: pd.DataFrame) -> tuple[pl.DataFrame, list[dict]]: +def _to_polars(df: pd.DataFrame, column_overrides: dict | None = None) -> tuple[pl.DataFrame, list[dict]]: """Convert to polars with Tabulator-safe field names and column definitions. Tabulator reads a "." in a field name as a nested lookup, so such columns get @@ -73,6 +73,7 @@ def _to_polars(df: pd.DataFrame) -> tuple[pl.DataFrame, list[dict]]: if pd.api.types.is_numeric_dtype(df[column]): col_def["sorter"] = "number" col_def["hozAlign"] = "right" + col_def.update((column_overrides or {}).get(column, {})) column_definitions.append(col_def) df = df.rename(columns=renames) for column in df.columns: @@ -91,6 +92,8 @@ def show_insight_table( title: str | None = None, height: int = 500, page_size: int = 100, + state_manager=None, + column_overrides: dict | None = None, **table_kwargs, ) -> None: """Render ``df`` as a server-side paginated openms-insight table. @@ -115,6 +118,11 @@ def show_insight_table( title: Title, also used as the CSV download's file name. height: Component height in pixels. page_size: Rows per page. + state_manager: Shared ``StateManager`` for tables linked through + ``filters``/``interactivity``; defaults to openms-insight's own. + column_overrides: Extra Tabulator column settings per column name, + e.g. a ``formatter``. openms-insight stores floats as float32, so + a column of rounded values needs one to display without noise. **table_kwargs: Passed on to ``Table`` (e.g. ``initial_sort``). """ cache_dir = Path(cache_dir) if cache_dir else _default_cache_dir() @@ -127,7 +135,7 @@ def show_insight_table( for stale in cache_dir.glob(f"{name}_" + "?" * 10): shutil.rmtree(stale, ignore_errors=True) cache_dir.mkdir(parents=True, exist_ok=True) - pl_df, column_definitions = _to_polars(df) + pl_df, column_definitions = _to_polars(df, column_overrides) Table( cache_id=cache_id, data=pl_df.lazy(), @@ -140,4 +148,4 @@ def show_insight_table( ) table = Table(cache_id=cache_id, cache_path=str(cache_dir)) - table(key=f"insight_table_{name}", height=height) + table(key=f"insight_table_{name}", height=height, state_manager=state_manager) diff --git a/src/common/protein_table.py b/src/common/protein_table.py new file mode 100644 index 0000000..f8842bd --- /dev/null +++ b/src/common/protein_table.py @@ -0,0 +1,175 @@ +"""Protein-level identification tables: proteins, their peptides and PSMs, and sequence coverage. + +The tables are built from the FDR-filtered PSMs of every run and the target +FASTA database, so they exist for LFQ and TMT alike. Decoy hits are left out. +""" +import html +import re +from pathlib import Path + +import numpy as np +import pandas as pd +from pyopenms import FASTAFile + +from src.common.results_helpers import extract_filename_from_idxml, extract_scan_from_ref, load_idxml + +DECOY_PREFIXES = ("DECOY_", "decoy_", "rev_", "REV_", "XXX_", "reversed_") +Q_VALUE_KEYS = ("MS:1001491", "q-value") + +PSM_COLUMNS = ["file", "scan", "rt", "mz", "charge", "peptide", "sequence", "score", "q_value", "proteins"] + + +def _is_decoy_accession(accession: str) -> bool: + return accession.startswith(DECOY_PREFIXES) + + +def read_fasta(path) -> dict[str, tuple[str, str]]: + """Accession -> (description, sequence) of the target entries of a FASTA file.""" + entries = [] + FASTAFile().load(str(path), entries) + return { + e.identifier: (e.description, e.sequence) + for e in entries + if not _is_decoy_accession(e.identifier) + } + + +def gene_name(description: str) -> str: + """The gene from a UniProt description (``GN=``), or an empty string.""" + match = re.search(r"\bGN=(\S+)", description or "") + return match.group(1) if match else "" + + +def protein_name(description: str) -> str: + """A UniProt description without its ``OS=``/``OX=``/``GN=``/``PE=``/``SV=`` fields.""" + return re.split(r"\s+[A-Z]{2}=", description or "", maxsplit=1)[0] + + +def collect_psms(idxml_paths, mzml_names=None) -> pd.DataFrame: + """Top hits of all runs, one row per target PSM, with their target proteins.""" + records = [] + for path in idxml_paths: + file = extract_filename_from_idxml(Path(path), mzml_names) + for pep in load_idxml(path)[1]: + hits = pep.getHits() + if not hits: + continue + hit = hits[0] + proteins = sorted({ + ev.getProteinAccession() for ev in hit.getPeptideEvidences() + if not _is_decoy_accession(ev.getProteinAccession()) + }) + if not proteins: + continue # a decoy hit + ref = pep.getMetaValue("spectrum_reference") if pep.metaValueExists("spectrum_reference") else "" + if isinstance(ref, bytes): + ref = ref.decode() + q_value = np.nan + for key in Q_VALUE_KEYS: + if hit.metaValueExists(key): + q_value = float(hit.getMetaValue(key)) + break + seq = hit.getSequence() + records.append({ + "file": file, + "scan": extract_scan_from_ref(ref), + "rt": pep.getRT(), + "mz": pep.getMZ(), + "charge": hit.getCharge(), + "peptide": seq.toString(), + "sequence": seq.toUnmodifiedString(), + "score": hit.getScore(), + "q_value": q_value, + "proteins": proteins, + }) + return pd.DataFrame(records, columns=PSM_COLUMNS) + + +def coverage_mask(protein_sequence: str, peptides) -> np.ndarray: + """Per residue, whether some peptide covers it (every occurrence of each peptide counts).""" + mask = np.zeros(len(protein_sequence), dtype=bool) + for peptide in set(peptides): + if not peptide: + continue + start = protein_sequence.find(peptide) + while start != -1: + mask[start:start + len(peptide)] = True + start = protein_sequence.find(peptide, start + 1) + return mask + + +def build_tables(psms: pd.DataFrame, fasta: dict[str, tuple[str, str]], files: list[str]): + """Proteins, peptide-to-protein and PSM-to-protein tables. + + Peptides and PSMs are in long format (one row per protein they map to), so + selecting a protein filters them by its accession. + + Returns: + (proteins, peptides, psms_long, coverage) where ``coverage`` maps an + accession to its residue mask. + """ + long = psms.explode("proteins").rename(columns={"proteins": "accession"}) + long["shared"] = psms["proteins"].str.len().reindex(long.index).gt(1) + + rows, coverage = [], {} + for accession, group in long.groupby("accession", sort=False): + description, sequence = fasta.get(accession, ("", "")) + mask = coverage_mask(sequence, group["sequence"]) + coverage[accession] = mask + per_file = group.groupby("file").size() + row = { + "accession": accession, + "gene": gene_name(description), + "description": protein_name(description), + "coverage (%)": round(100 * mask.mean(), 1) if len(mask) else np.nan, + "peptides": group["sequence"].nunique(), + "unique peptides": group.loc[~group["shared"], "sequence"].nunique(), + "PSMs": len(group), + "found in": f"{per_file.size} of {len(files)}", + "length": len(sequence) or np.nan, + } + for file in files: + row[f"PSMs {Path(file).stem}"] = int(per_file.get(file, 0)) + rows.append(row) + proteins = pd.DataFrame(rows) + if not proteins.empty: + proteins = proteins.sort_values(["PSMs", "peptides"], ascending=False, ignore_index=True) + # the description last: it is the widest column, and the one to truncate + proteins = proteins[[c for c in proteins.columns if c != "description"] + ["description"]] + if not proteins["gene"].astype(bool).any(): + proteins = proteins.drop(columns="gene") + + peptides = ( + long.groupby(["accession", "peptide"], sort=False) + .agg(PSMs=("file", "size"), files=("file", "nunique"), + best_q=("q_value", "min"), charges=("charge", lambda c: ",".join(map(str, sorted(set(c))))), + shared=("shared", "first")) + .reset_index() + .rename(columns={"best_q": "best q-value", "files": "found in files"}) + .sort_values(["accession", "PSMs"], ascending=[True, False], ignore_index=True) + ) + psms_long = long[["accession", "peptide", "file", "scan", "rt", "mz", "charge", "score", "q_value"]].rename( + columns={"rt": "RT (s)", "mz": "m/z", "q_value": "q-value"} + ).reset_index(drop=True) + return proteins, peptides, psms_long, coverage + + +def coverage_html(sequence: str, mask: np.ndarray, block: int = 10, per_row: int = 50) -> str: + """The protein sequence in blocks of ``block`` residues, covered residues highlighted.""" + lines = [] + for row_start in range(0, len(sequence), per_row): + blocks = [] + for start in range(row_start, min(row_start + per_row, len(sequence)), block): + chars = [] + for i in range(start, min(start + block, len(sequence))): + residue = html.escape(sequence[i]) + chars.append(f'{residue}' if mask[i] else residue) + blocks.append("".join(chars)) + lines.append(f'{row_start + 1:>5} ' + " ".join(blocks)) + return ( + "" + '
' + "\n".join(lines) + "
" + ) + diff --git a/tests/test_insight_table.py b/tests/test_insight_table.py index 6730d01..ea418ba 100644 --- a/tests/test_insight_table.py +++ b/tests/test_insight_table.py @@ -99,3 +99,12 @@ def test_table_without_source_files_is_cached_by_content(tmp_path, monkeypatch): assert rendered[2] != rendered[0] # The cache of the previous version of the table is removed assert [p.name for p in tmp_path.iterdir()] == [rendered[2]] + + +def test_to_polars_applies_column_overrides(): + df = pd.DataFrame({"a.b": [1.25], "c": ["x"]}) + _, col_defs = _to_polars(df, {"a.b": {"formatter": "money"}, "c": {"visible": False}}) + by_title = {d["title"]: d for d in col_defs} + assert by_title["a.b"]["formatter"] == "money" + assert by_title["a.b"]["field"] == "a_b" + assert by_title["c"]["visible"] is False diff --git a/tests/test_protein_table.py b/tests/test_protein_table.py new file mode 100644 index 0000000..5df4102 --- /dev/null +++ b/tests/test_protein_table.py @@ -0,0 +1,71 @@ +import numpy as np +import pandas as pd + +from src.common.protein_table import ( + build_tables, + coverage_html, + coverage_mask, + gene_name, + protein_name, + read_fasta, +) + + +def test_coverage_mask_counts_every_occurrence(): + mask = coverage_mask("PEPTIDEKAAAPEPTIDEK", ["PEPTIDEK", "AA"]) + assert mask.tolist() == [True] * 8 + [True, True, True] + [True] * 8 + assert coverage_mask("ACDEFG", ["XYZ"]).sum() == 0 + + +def test_uniprot_description_fields(): + desc = "Serum albumin OS=Homo sapiens OX=9606 GN=ALB PE=1 SV=2" + assert gene_name(desc) == "ALB" + assert protein_name(desc) == "Serum albumin" + assert gene_name("no gene here") == "" + + +def test_read_fasta_skips_decoys(tmp_path): + fasta = tmp_path / "db.fasta" + fasta.write_text(">sp|P1|A_HUMAN Protein A GN=A\nPEPTIDEKAAA\n>rev_sp|P1|A_HUMAN\nAAAKEDITPEP\n") + entries = read_fasta(fasta) + assert list(entries) == ["sp|P1|A_HUMAN"] + assert entries["sp|P1|A_HUMAN"][1] == "PEPTIDEKAAA" + + +def test_build_tables(): + fasta = { + "P1": ("Protein one GN=ONE", "PEPTIDEKSAMPLERAAAA"), + "P2": ("Protein two GN=TWO", "SAMPLERGGGG"), + } + psms = pd.DataFrame({ + "file": ["a.mzML", "a.mzML", "b.mzML"], + "scan": [1, 2, 3], + "rt": [10.0, 20.0, 30.0], + "mz": [400.0, 500.0, 400.0], + "charge": [2, 2, 3], + "peptide": ["PEPTIDEK", "SAMPLER", "PEPTIDEK"], + "sequence": ["PEPTIDEK", "SAMPLER", "PEPTIDEK"], + "score": [0.01, 0.02, 0.03], + "q_value": [0.001, 0.002, 0.003], + "proteins": [["P1"], ["P1", "P2"], ["P1"]], + }) + proteins, peptides, psms_long, coverage = build_tables(psms, fasta, ["a.mzML", "b.mzML"]) + + p1 = proteins.set_index("accession").loc["P1"] + assert p1["peptides"] == 2 + assert p1["unique peptides"] == 1 # SAMPLER is shared with P2 + assert p1["PSMs"] == 3 + assert p1["found in"] == "2 of 2" + assert p1["coverage (%)"] == round(100 * 15 / 19, 1) + assert p1["PSMs a"] == 2 and p1["PSMs b"] == 1 + assert proteins.iloc[0]["accession"] == "P1" # most PSMs first + + assert set(peptides.loc[peptides["accession"] == "P2", "peptide"]) == {"SAMPLER"} + assert len(psms_long) == 4 # the shared PSM appears under both proteins + assert coverage["P2"].tolist() == [True] * 7 + [False] * 4 + + +def test_coverage_html_marks_covered_residues(): + out = coverage_html("ACDEFGHIKLM", np.array([True, True] + [False] * 9)) + assert out.count('') == 2 + assert "ACDEFGHIKL M" in out.replace('A', "A").replace('C', "C") From 00fd9723cd160dc4bed9e55decba75c95a8768a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 06:00:25 +0000 Subject: [PATCH 03/18] Filtered PSMs: fragment mass error plot and b/y ion table for the selected PSM Third step towards feature parity with the ProSE web app's PSM view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- content/results_filtered.py | 32 ++++++++ src/common/fragment_plots.py | 139 +++++++++++++++++++++++++++++++++++ tests/test_fragment_plots.py | 63 ++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 src/common/fragment_plots.py create mode 100644 tests/test_fragment_plots.py diff --git a/content/results_filtered.py b/content/results_filtered.py index d6f31aa..347fcac 100644 --- a/content/results_filtered.py +++ b/content/results_filtered.py @@ -4,6 +4,9 @@ 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.common import show_fig +from src.common.fragment_plots import fragment_error_plot, ion_table, ion_table_html, load_psm_fragments +from src.workflow.ParameterManager import ParameterManager params = page_setup() st.title("Filtered PSMs") @@ -75,6 +78,35 @@ st.subheader("MS2 Spectrum") line_plot(key=f"lineplot_{cache_id_prefix}", state_manager=state_manager, height=450, sequence_view_key=f"seqview_{cache_id_prefix}") +st.subheader("Fragment ions") +fragments_tsv = workflow_dir / "results" / "exports" / f"{cache_id_prefix}_fragment_ions.tsv" +psm_id = state_manager.get_selection("identification") +if not fragments_tsv.is_file(): + st.info("No fragment ion export for this run. Re-run the workflow to create it.") +elif psm_id is None: + st.info("Select a PSM in the table to see its fragment mass errors and ion table.") +else: + fragments = load_psm_fragments(fragments_tsv, int(psm_id)) + if fragments.empty: + st.info("No fragment ion of this PSM matched a peak within the tolerance.") + else: + # The search's fragment tolerance: user value, else the workflow's default for it + workflow_params = ParameterManager(workflow_dir, "TOPP Workflow").get_parameters_from_json() + comet = "CometAdapter-TMT" if workflow_params.get("analysis-mode", "LFQ") == "TMT" else "CometAdapter" + comet_params = {**workflow_params.get("_defaults", {}).get(comet, {}), **workflow_params.get(comet, {})} + tolerance = float(comet_params.get("fragment_mass_tolerance", 0.02)) + tolerance_ppm = comet_params.get("fragment_error_units", "Da") != "Da" + + first = fragments.iloc[0] + st.caption( + f"{first['sequence']}, charge {int(first['precursor_charge'])}. Matched with the search's fragment " + f"tolerance (ยฑ{tolerance:g} {'ppm' if tolerance_ppm else 'Da'}, shaded)." + ) + show_fig(fragment_error_plot(fragments, tolerance, tolerance_ppm), f"fragment-errors-{cache_id_prefix}-{psm_id}") + values, matched = ion_table(first["sequence"], int(first["precursor_charge"]), fragments) + st.markdown("**b/y ions** (theoretical m/z; matched ions highlighted, neutral losses not counted)") + st.html(ion_table_html(values, matched)) + st.markdown("---") st.markdown("**Next step:** View abundance quantification") st.page_link("content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹") diff --git a/src/common/fragment_plots.py b/src/common/fragment_plots.py new file mode 100644 index 0000000..1859f36 --- /dev/null +++ b/src/common/fragment_plots.py @@ -0,0 +1,139 @@ +"""Fragment-level evidence for one PSM: mass error plot and b/y ion table. + +Both read the run's ``_fragment_ions.tsv`` export (written by the +workflow with the same ion types and tolerance as the SequenceView), filtered +to the selected PSM. +""" +import html +from pathlib import Path + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import polars as pl +from pyopenms import AASequence, Residue + +B_COLOR = "#2a78d6" +Y_COLOR = "#eb6834" + + +def load_psm_fragments(tsv_path: Path, sequence_id: int) -> pd.DataFrame: + """The fragment ions matched in one PSM's spectrum, read lazily from the run's export.""" + return ( + pl.scan_csv(tsv_path, separator="\t") + .filter(pl.col("sequence_id") == sequence_id) + .collect() + .to_pandas() + ) + + +def _is_plain(ion: str, ion_type: str, number: int) -> bool: + """Whether an ion label is the plain fragment, not a neutral-loss one (``b4`` vs ``b4-H2O``).""" + return ion == f"{ion_type}{number}" + + +def fragment_error_plot(fragments: pd.DataFrame, tolerance: float, tolerance_ppm: bool) -> go.Figure: + """Mass error of every matched fragment against its m/z, in the search tolerance's unit. + + Dots are sized by peak intensity; the shaded band is the fragment + tolerance. Errors spread evenly around 0 within the band are what a + correct match looks like; a trend with m/z points to a calibration issue. + """ + unit = "ppm" if tolerance_ppm else "Da" + error_col = "mass_error_ppm" if tolerance_ppm else "mass_error_da" + fig = go.Figure() + fig.add_hrect(y0=-tolerance, y1=tolerance, fillcolor="#a1a1aa", opacity=0.15, line_width=0) + max_intensity = fragments["intensity"].max() if len(fragments) else 1.0 + for ion_type, color in (("b", B_COLOR), ("y", Y_COLOR)): + part = fragments[fragments["ion_type"] == ion_type] + if part.empty: + continue + labels = [f"{ion}{'+' * int(z)}" for ion, z in zip(part["ion"], part["charge"])] + fig.add_trace(go.Scatter( + x=part["observed_mz"], y=part[error_col], mode="markers", name=f"{ion_type} ions", + marker=dict(color=color, size=6 + 14 * np.sqrt(part["intensity"] / max_intensity), opacity=0.8), + text=labels, + hovertemplate="%{text}
m/z %{x:.4f}
error %{y:.3f} " + unit + "", + )) + fig.add_hline(y=0, line_color="grey", line_width=1) + fig.update_layout( + title=f"Fragment mass error ({len(fragments)} matched ions)", + xaxis_title="m/z", + yaxis_title=f"Mass error ({unit})", + yaxis_range=[-tolerance * 1.2, tolerance * 1.2], + legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0), + ) + return fig + + +def ion_table(sequence: str, precursor_charge: int, fragments: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]: + """Theoretical b and y m/z per position, and which of them a peak matched. + + One row per residue; b ions count from the N-terminus and y ions from the + C-terminus, at charges 1 up to ``precursor_charge - 1`` (at most 2). Only + plain ions count as matches, not their neutral losses. + + Returns: + (values, matched): the m/z table and a boolean table of the same shape. + """ + peptide = AASequence.fromString(sequence) + n = peptide.size() + charges = list(range(1, max(1, min(int(precursor_charge) - 1, 2)) + 1)) + plain = fragments[[ + _is_plain(i, t, k) for i, t, k in zip(fragments["ion"], fragments["ion_type"], fragments["ion_number"]) + ]] if len(fragments) else fragments + matched_keys = set(zip(plain["ion_type"], plain["ion_number"], plain["charge"])) if len(plain) else set() + + def column(ion_type, z): + return f"{ion_type}{'+' * z}" + + values = {"#": list(range(1, n + 1)), "residue": [peptide.getResidue(i).getOneLetterCode() for i in range(n)]} + matched = {"#": [False] * n, "residue": [False] * n} + for z in charges: + b_vals, b_hit, y_vals, y_hit = [], [], [], [] + for i in range(1, n + 1): + # b_i covers residues 1..i (no b_n), y_i the last i residues; row i shows b_i and y_(n-i+1) + if i < n: + b_vals.append(peptide.getPrefix(i).getMonoWeight(Residue.ResidueType.BIon, z) / z) + b_hit.append(("b", i, z) in matched_keys) + else: + b_vals.append(np.nan) + b_hit.append(False) + k = n - i + 1 + if k < n: + y_vals.append(peptide.getSuffix(k).getMonoWeight(Residue.ResidueType.YIon, z) / z) + y_hit.append(("y", k, z) in matched_keys) + else: + y_vals.append(np.nan) + y_hit.append(False) + values[column("b", z)], matched[column("b", z)] = b_vals, b_hit + values[column("y", z)], matched[column("y", z)] = y_vals, y_hit + values["#y"] = [n - i + 1 for i in range(1, n + 1)] + matched["#y"] = [False] * n + order = ["#"] + [column("b", z) for z in charges] + ["residue"] + [column("y", z) for z in charges] + ["#y"] + return pd.DataFrame(values)[order], pd.DataFrame(matched)[order] + + +def ion_table_html(values: pd.DataFrame, matched: pd.DataFrame) -> str: + """The ion table as HTML, matched b cells in blue and y cells in orange.""" + head = "".join(f"{html.escape('#' if c == '#y' else c)}" for c in values.columns) + rows = [] + for r in range(len(values)): + cells = [] + for c in values.columns: + v = values.iat[r, values.columns.get_loc(c)] + text = "" if isinstance(v, float) and np.isnan(v) else (f"{v:.4f}" if isinstance(v, float) else html.escape(str(v))) + cls = "" + if matched.iat[r, matched.columns.get_loc(c)]: + cls = ' class="hit-b"' if c.startswith("b") else ' class="hit-y"' + elif c == "residue": + cls = ' class="res"' + cells.append(f"{text}") + rows.append("" + "".join(cells) + "") + return ( + "" + f'{head}{"".join(rows)}
' + ) diff --git a/tests/test_fragment_plots.py b/tests/test_fragment_plots.py new file mode 100644 index 0000000..77498ce --- /dev/null +++ b/tests/test_fragment_plots.py @@ -0,0 +1,63 @@ +import pandas as pd +import pytest +from pyopenms import AASequence, Residue + +from src.common.fragment_plots import fragment_error_plot, ion_table, ion_table_html, load_psm_fragments + + +def _fragments(): + return pd.DataFrame({ + "sequence_id": [0, 0, 0, 0], + "sequence": ["PEPTIDEK"] * 4, + "precursor_charge": [2] * 4, + "ion": ["b2", "b2-H2O", "y3", "y3"], + "ion_type": ["b", "b", "y", "y"], + "ion_number": [2, 2, 3, 3], + "charge": [1, 1, 1, 1], + "theoretical_mz": [227.1, 209.1, 391.2, 391.2], + "observed_mz": [227.1, 209.1, 391.2, 391.21], + "intensity": [100.0, 50.0, 200.0, 20.0], + "mass_error_da": [0.001, -0.002, 0.0005, 0.01], + "mass_error_ppm": [4.0, -9.0, 1.2, 25.0], + "peak_id": [1, 2, 3, 4], + }) + + +def test_ion_table_values_and_matches(): + values, matched = ion_table("PEPTIDEK", 2, _fragments()) + assert list(values.columns) == ["#", "b+", "residue", "y+", "#y"] + peptide = AASequence.fromString("PEPTIDEK") + assert values.loc[1, "b+"] == pytest.approx(peptide.getPrefix(2).getMonoWeight(Residue.ResidueType.BIon, 1)) + # row i holds y_(n-i+1): y3 sits in row 6 (index 5) + assert values.loc[5, "#y"] == 3 + assert values.loc[5, "y+"] == pytest.approx(peptide.getSuffix(3).getMonoWeight(Residue.ResidueType.YIon, 1)) + assert matched.loc[1, "b+"] and matched.loc[5, "y+"] + assert matched["b+"].sum() == 1 # b2-H2O is a neutral loss, not counted + assert pd.isna(values.loc[7, "b+"]) and pd.isna(values.loc[0, "y+"]) + + +def test_ion_table_doubly_charged_columns(): + values, _ = ion_table("PEPTIDEK", 3, _fragments()) + assert list(values.columns) == ["#", "b+", "b++", "residue", "y+", "y++", "#y"] + + +def test_ion_table_html_highlights_matches(): + values, matched = ion_table("PEPTIDEK", 2, _fragments()) + out = ion_table_html(values, matched) + assert out.count('class="hit-b"') == 1 and out.count('class="hit-y"') == 1 + + +def test_fragment_error_plot_uses_tolerance_unit(): + fig = fragment_error_plot(_fragments(), 20, True) + assert fig.layout.yaxis.title.text == "Mass error (ppm)" + assert [t.name for t in fig.data] == ["b ions", "y ions"] + fig_da = fragment_error_plot(_fragments(), 0.02, False) + assert list(fig_da.data[0].y) == [0.001, -0.002] + + +def test_load_psm_fragments_filters_one_psm(tmp_path): + tsv = tmp_path / "run_fragment_ions.tsv" + frags = _fragments() + pd.concat([frags, frags.assign(sequence_id=1)]).to_csv(tsv, sep="\t", index=False) + assert len(load_psm_fragments(tsv, 1)) == 4 + assert load_psm_fragments(tsv, 7).empty From 24ba0fb28b049fb73da96602f6e8eb40d4f053b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:05:16 +0000 Subject: [PATCH 04/18] Add cRAP contaminants to the search database, flag and hide them in results A new "Add common contaminants (cRAP)" option (on by default) appends the 116 cRAP proteins to the search database with CON__ accessions. With generated decoys, DecoyDatabase reads the FASTA and the contaminants together, so contaminants get decoys too. A FASTA with its own decoys gets contaminant decoys in its decoy style. The database step is now its own method, prepare_search_database(). PSM tables gain a Contaminant column. get_abundance_data() leaves contaminant proteins out by default, so downstream analysis never sees them. The Abundance page flags them and has a "Show contaminants" toggle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- content/results_abundance.py | 33 +- example-data/db/contaminants/crap.fasta | 945 ++++++++++++++++++++++++ src/WorkflowTest.py | 115 ++- src/common/contaminants.py | 71 ++ src/common/results_helpers.py | 21 +- tests/test_contaminants.py | 164 ++++ 6 files changed, 1319 insertions(+), 30 deletions(-) create mode 100644 example-data/db/contaminants/crap.fasta create mode 100644 src/common/contaminants.py create mode 100644 tests/test_contaminants.py diff --git a/content/results_abundance.py b/content/results_abundance.py index 4c98cb5..1a5fd8b 100644 --- a/content/results_abundance.py +++ b/content/results_abundance.py @@ -6,6 +6,7 @@ from src.common.results_helpers import get_workflow_dir, get_abundance_data from src.workflow.ParameterManager import ParameterManager from src.common.insight_tables import show_insight_table +from src.common.contaminants import CONTAMINANT_PREFIX, contaminant_mask params = page_setup() st.title("Abundance Quantification") @@ -43,6 +44,16 @@ cache_dir = workflow_dir / "results" / "insight_cache" table_sources = [csv_file, workflow_dir / "params.json"] +show_contaminants = st.toggle( + "Show contaminants", + value=False, + key="abundance-show-contaminants", + help=f"Proteins with {CONTAMINANT_PREFIX} accessions come from the cRAP contaminants added to the " + "search database. They are hidden here and left out of the downstream analysis.", +) +# The tables differ with the toggle, so it is part of their cache key +contaminant_key = f"contaminants={show_contaminants}" + def render_protein_table(pivot_df, is_lfq=True): """Common function to render the protein-level abundance table""" pivot_df = pivot_df.copy() @@ -69,13 +80,15 @@ def render_protein_table(pivot_df, is_lfq=True): # Filter to available columns, then sort and display available_cols = [c for c in display_cols if c in pivot_df.columns] - view_df = pivot_df[available_cols] + view_df = pivot_df[available_cols].copy() + view_df.insert(1, "Contaminant", contaminant_mask(view_df[id_col]).to_numpy()) show_insight_table( view_df, name="abundance_protein", cache_dir=cache_dir, source_files=table_sources, + extra_key=contaminant_key, title="protein_abundance", ) @@ -86,7 +99,13 @@ def render_protein_table(pivot_df, is_lfq=True): st.info("No data found in this file.") st.stop() - result = get_abundance_data(st.session_state["workspace"]) + result = get_abundance_data(st.session_state["workspace"], include_contaminants=show_contaminants) + if not show_contaminants: + all_proteins = get_abundance_data(st.session_state["workspace"], include_contaminants=True) + if all_proteins is not None and result is not None: + hidden = len(all_proteins[0]) - len(result[0]) + if hidden: + st.caption(f"{hidden} contaminant protein(s) hidden.") if analysis_mode == "LFQ": protein_tab, psm_tab = st.tabs(["Protein Table", "PSM-level Quantification Table"]) @@ -106,11 +125,18 @@ def render_protein_table(pivot_df, is_lfq=True): "peptide sequences, charge states, and intensities across samples. " "Each row represents one peptide-spectrum match detected from the MS/MS analysis." ) + psm_df = df.copy() + if "ProteinName" in psm_df.columns: + is_con = contaminant_mask(psm_df["ProteinName"]).to_numpy() + psm_df.insert(1, "Contaminant", is_con) + if not show_contaminants: + psm_df = psm_df[~is_con] show_insight_table( - df, + psm_df, name="abundance_psm", cache_dir=cache_dir, source_files=table_sources, + extra_key=contaminant_key, title="psm_quantification", ) @@ -130,6 +156,7 @@ def render_protein_table(pivot_df, is_lfq=True): name="abundance_intensity_matrix", cache_dir=cache_dir, source_files=table_sources, + extra_key=contaminant_key, title="intensity_matrix", ) diff --git a/example-data/db/contaminants/crap.fasta b/example-data/db/contaminants/crap.fasta new file mode 100644 index 0000000..7ac4ddb --- /dev/null +++ b/example-data/db/contaminants/crap.fasta @@ -0,0 +1,945 @@ +>sp|ALBU_BOVIN| +MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIA +FSQYLQQCPFDEHVKLVNELTEFAKTCVADESHAGCEKSLHTLFGDELCK +VASLRETYGDMADCCEKQEPERNECFLSHKDDSPDLPKLKPDPNTLCDEF +KADEKKFWGKYLYEIARRHPYFYAPELLYYANKYNGVFQECCQAEDKGAC +LLPKIETMREKVLASSARQRLRCASIQKFGERALKAWSVARLSQKFPKAE +FVEVTKLVTDLTKVHKECCHGDLLECADDRADLAKYICDNQDTISSKLKE +CCDKPLLEKSHCIAEVEKDAIPENLPPLTADFAEDKDVCKNYQEAKDAFL +GSFLYEYSRRHPEYAVSVLLRLAKEYEATLEECCAKDDPHACYSTVFDKL +KHLVDEPQNLIKQNCDQFEKLGEYGFQNALIVRYTRKVPQVSTPTLVEVS +RSLGKVGTRCCTKPESERMPCTEDYLSLILNRLCVLHEKTPVSEKVTKCC +TESLVNRRPCFSALTPDETYVPKAFDEKLFTFHADICTLPDTEKQIKKQT +ALVELLKHKPKATEEQLKTVMENFVAFVDKCCAADDKEACFAVEGPKLVV +STQTALA +>sp|AMYS_HUMAN| +MKLFWLLFTIGFCWAQYSSNTQQGRTSIVHLFEWRWVDIALECERYLAPK +GFGGVQVSPPNENVAIHNPFRPWWERYQPVSYKLCTRSGNEDEFRNMVTR +CNNVGVRIYVDAVINHMCGNAVSAGTSSTCGSYFNPGSRDFPAVPYSGWD +FNDGKCKTGSGDIENYNDATQVRDCRLSGLLDLALGKDYVRSKIAEYMNH +LIDIGVAGFRIDASKHMWPGDIKAILDKLHNLNSNWFPEGSKPFIYQEVI +DLGGEPIKSSDYFGNGRVTEFKYGAKLGTVIRKWNGEKMSYLKNWGEGWG +FMPSDRALVFVDNHDNQRGHGAGGASILTFWDARLYKMAVGFMLAHPYGF +TRVMSSYRWPRYFENGKDVNDWVGPPNDNGVTKEVTINPDTTCGNDWVCE +HRWRQIRNMVNFRNVVDGQPFTNWYDNGSNQVAFGRGNRGFIVFNNDDWT +FSLTLQTGLPAGTYCDVISGDKINGNCTGIKIYVSDDGKAHFSISNSAED +PFIAIHAESKL +>sp|CAS1_BOVIN| +MKLLILTCLVAVALARPKHPIKHQGLPQEVLNENLLRFFVAPFPEVFGKE +KVNELSKDIGSESTEDQAMEDIKQMEAESISSSEEIVPNSVEQKHIQKED +VPSERYLGYLEQLLRLKKYKVPQLEIVPNSAEERLHSMKEGIHAQQKEPM +IGVNQELAYFYPELFRQFYQLDAYPSGAWYYVPLGTQYTDAPSFSDIPNP +IGSENSEKTTMPLW +>sp|CAS2_BOVIN| +MKFFIFTCLLAVALAKNTMEHVSSSEESIISQETYKQEKNMAINPSKENL +CSTFCKEVVRNANEEEYSIGSSSEESAEVATEEVKITVDDKHYQKALNEI +NQFYQKFPQYLQYLYQGPIVLNPWDQVKRNAVPITPTLNREQLSTSEENS +KKTVDMESTEVFTKKTKLTEEEKNRLNFLKKISQRYQKFALPQYLKTVYQ +HQKAMKPWIQPKTKVIPYVRYL +>sp|CASB_BOVIN| +MKVLILACLVALALARELEELNVPGEIVESLSSSEESITRINKKIEKFQS +EEQQQTEDELQDKIHPFAQTQSLVYPFPGPIPNSLPQNIPPLTQTPVVVP +PFLQPEVMGVSKVKEAMAPKHKEMPFPKYPVEPFTESQSLTLTDVENLHL +PLPLLQSWMHQPHQPLPPTVMFPPQSVLSLSQSKVLPVPQKAVPYPQRDM +PIQAFLLYQEPVLGPVRGPFPIIV +>sp|CASK_BOVIN| +MMKSFFLVVTILALTLPFLGAQEQNQEQPIRCEKDERFFSDKIAKYIPIQ +YVLSRYPSYGLNYYQQKPVALINNQFLPYPYYAKPAAVRSPAQILQWQVL +SNTVPAKSCQAQPTTMARHPHPHLSFMAIPPKKNQDKTEIPTINTIASGE +PTSTPTTEAVESTVATLEDSPEVIESPPEINTVQVTSTAV +>sp|CTRA_BOVIN| +CGVPAIQPVLSGLSRIVNGEEAVPGSWPWQVSLQDKTGFHFCGGSLINEN +WVVTAAHCGVTTSDVVVAGEFDQGSSSEKIQKLKIAKVFKNSKYNSLTIN +NDITLLKLSTAASFSQTVSAVCLPSASDDFAAGTTCVTTGWGLTRYTNAN +TPDRLQQASLPLLSNTNCKKYWGTKIKDAMICAGASGVSSCMGDSGGPLV +CKKNGAWTLVGIVSWGSSTCSTSTPGVYARVTALVNWVQQTLAAN +>sp|CTRB_BOVIN| +CGVPAIQPVLSGLARIVNGEDAVPGSWPWQVSLQDSTGFHFCGGSLISED +WVVTAAHCGVTTSDVVVAGEFDQGLETEDTQVLKIGKVFKNPKFSILTVR +NDITLLKLATPAQFSETVSAVCLPSADEDFPAGMLCATTGWGKTKYNALK +TPDKLQQATLPIVSNTDCRKYWGSRVTDVMICAGASGVSSCMGDSGGPLV +CQKNGAWTLAGIVSWGSSTCSTSTPAVYARVTALMPWVQETLAAN +>sp|K1C10_HUMAN| +MSVRYSSSKHYSSSRSGGGGGGGGCGGGGGVSSLRISSSKGSLGGGFSSG +GFSGGSFSRGSSGGGCFGGSSGGYGGLGGFGGGSFHGSYGSSSFGGSYGG +SFGGGNFGGGSFGGGSFGGGGFGGGGFGGGFGGGFGGDGGLLSGNEKVTM +QNLNDRLASYLDKVRALEESNYELEGKIKEWYEKHGNSHQGEPRDYSKYY +KTIDDLKNQILNLTTDNANILLQIDNARLAADDFRLKYENEVALRQSVEA +DINGLRRVLDELTLTKADLEMQIESLTEELAYLKKNHEEEMKDLRNVSTG +DVNVEMNAAPGVDLTQLLNNMRSQYEQLAEQNRKDAEAWFNEKSKELTTE +IDNNIEQISSYKSEITELRRNVQALEIELQSQLALKQSLEASLAETEGRY +CVQLSQIQAQISALEEQLQQIRAETECQNTEYQQLLDIKIRLENEIQTYR +SLLEGEGSSGGGGRGGGSFGGGYGGGSSGGGSSGGGYGGGHGGSSGGGYG +GGSSGGGSSGGGYGGGSSSGGHGGGSSSGGHGGSSSGGYGGGSSGGGGGG +YGGGSSGGGSSSGGGYGGGSSSGGHKSSSSGSVGESSSKGPRY +>sp|K1C15_SHEEP| +MATTLLQTSSSTFGGSSTRGGSLLAGGGGFGGGSLYGGGGSRTISASSAR +FVSSGSAGGYGGGFGGGAGSGYGGGFGGGFGGGFGSGFGDFGGGDGGLLS +GNEKITMQNLNDRLASYLEKVRALEEANADLEVKIRDWYQRQSPTSPERD +YSPYFKTTDELRDKILAAAIDNSRVILEIDNARLAADDFRLKYENEMALR +QSVEADINGLRRVLDELTLTKTDLEMQIESLNEELAYLKKNHEEEMKEFS +NQLAGQVNVEMDAAPGVDLTRVLSEMREQYEAMAEKNRRDAEAWFFSKTE +ELNKEVASNTEMIQTSKSEITDLRRTIQGLEIELQSQLSMKAGLESTLAE +TDGRYAAQLQQIQGLISSIEAQLSELRSEMEAQNQEYKMLLDIKTRLEQE +IATYHSLLEGQDARMAGIGTGEASLGGGGGGKVRINVEESVDGKVVSSRK +REI +>sp|K1C9_HUMAN| +MSCRQFSSSYLSRSGGGGGGGLGSGGSIRSSYSRFSSSGGRGGGGRFSSS +SGYGGGSSRVCGRGGGGSFGYSYGGGSGGGFSASSLGGGFGGGSRGFGGA +SGGGYSSSGGFGGGFGGGSGGGFGGGYGSGFGGLGGFGGGAGGGDGGILT +ANEKSTMQELNSRLASYLDKVQALEEANNDLENKIQDWYDKKGPAAIQKN +YSPYYNTIDDLKDQIVDLTVGNNKTLLDIDNTRMTLDDFRIKFEMEQNLR +QGVDADINGLRQVLDNLTMEKSDLEMQYETLQEELMALKKNHKEEMSQLT +GQNSGDVNVEINVAPGKDLTKTLNDMRQEYEQLIAKNRKDIENQYETQIT +QIEHEVSSSGQEVQSSAKEVTQLRHGVQELEIELQSQLSKKAALEKSLED +TKNRYCGQLQMIQEQISNLEAQITDVRQEIECQNQEYSLLLSIKMRLEKE +IETYHNLLEGGQEDFESSGAGKIGLGGRGGSGGSYGRGSRGGSGGSYGGG +GSGGGYGGGSGSRGGSGGSYGGGSGSGGGSGGGYGGGSGGGHSGGSGGGH +SGGSGGNYGGGSGSGGGSGGGYGGGSGSRGGSGGSHGGGSGFGGESGGSY +GGGEEASGSGGGYGGGSGKSSHS +>sp|K1H1_HUMAN| +MPYNFCLPSLSCRTSCSSRPCVPPSCHSCTLPGACNIPANVSNCNWFCEG +SFNGSEKETMQFLNDRLASYLEKVRQLERDNAELENLIRERSQQQEPLLC +PSYQSYFKTIEELQQKILCTKSENARLVVQIDNAKLAADDFRTKYQTELS +LRHVVESDINGLRRILDELTLCKSDLEAQVESLKEELLCLKSNHEQEVNT +LRCQLGDRLNVEVDAAPTVDLNRVLNETRSQYEALVETNRREVEQWFTTQ +TEELNKQVVSSSEQLQSYQAEIIELRRTVNALEIELQAQHNLRDSLENTL +TESEARYSSQLSQVQSLITNVESQLAEIRSDLERQNQEYQVLLDVRARLE +CEINTYRSLLESEDCNLPSNPCATTNACSKPIGPCLSNPCTSCVPPAPCT +PCAPRPRCGPCNSFVR +>sp|K1H2_HUMAN| +MTSSCCVTNNLQASLKSCPRPASVCSSGVNCRPELCLGYVCQPMACLPSV +CLPTTFRPASCLSKTYLSSSCQAASGISGSMGPGSWYSEGAFNGNEKETM +QFLNDRLASYLTRVRQLEQENAELESRIQEASHSQVLTMTPDYQSHFRTI +DQLQQKILCTKAENARMVVNIDNAKLAADDFRAKYEAELAMRQLVEADIN +GLRRILDDLTLCKADLEAQVESLKEELMCLKKNHEEEVGSLRCQLGDRLN +IEVDAAPPVDLTRVLEEMRCQYEAMVEANRRDVEEWFNMQMEELNQQVAT +SSEQLQNYQSDIIDLRRTVNTLEIELQAQHSLRDSLENTLTESEARYSSQ +LAQMQCMITNVEAQLAEIRAELERQNQEYQVLLDVRARLEGEINTYRSLL +ESEDCKLPCNPCSTPSCTTCVPSPCVTRTVCVPRTVGMPCSPCPQGRY +>sp|K1H4_HUMAN| +MSYSCCLPSLGCRTSCSSRPCVPPSCHGYTLPGACNIPANVSNCNWFCEG +SFNGSEKETMQFLNDRLASYLEKVRQLERDNAELEKLIQERSQQQEPLLC +PSYQSYFKTIEELQQKILCAKAENARLVVNIDNAKLASDDFRSKYQTEQS +LRLLVESDINSIRRILDELTLCKSDLESQVESLREELICLKKNHEEEVNT +LRSPLGDRLNVEVDTAPTVDLNQVLNETRSQYEALVEINRREVEQWFATQ +TEELNKQVVSSSEQLQSCQAEIIELRRTVNALEIELQAQHNLRDSLENTL +TESEAHYSSQLSQVQSLITNVESQLAEIRCDLERQNQEYQVLLDVRARLE +CEINTYRSLLESEDCKLPCNPCATTNASGNSCGPCGTSQKGCCN +>sp|K1H5_HUMAN| +MYSSSPCKLPSLSPVARSFSACSVGLGRSSYRATSCLPALCLPAGGFATS +YSGGGGWFGEGILTGNEKETMQSLNDRLAGYLEKVRHVEQENASLESRIR +EWCEQQVPYMCPDYQSYFRTIEELQKKTLCSKAENARLVVEIDNAKLAAD +DFRTKYETEVSLRQLVESDINGLRRILDDLTLCKSDLEAQVESLKEELLC +LKKNHEEEVNSLRCQLGDRLNVEVDAAPPVDLNRVLEEMRCQYETLVENN +RRDAEDWLDTQSEELNQQVVSSSEQLQSCQAEIIELRRTVNALEIELQAQ +HSMRDALESTLAETEARYSSQLAQMQCMITNVEAQLAEIRADLERQNQEY +QVLLDVRARLECEINTYRGLLESEDSKLPCNPCAPDYSPSKSCLPCLPAA +SCGPSAARTNCSPRPICVPCPGGRF +>sp|K1H6_HUMAN| +MATQTCTPTFSTGSIKGLCGTAGGISRVSSIRSVGSCRVPSLAGAAGYIS +SARSGLSGLGSCLPGSYLSSECHTSGFVGSGGWFCEGSFNGSEKETMQFL +NDRLANYLEKVRQLERENAELESRIQEWYEFQIPYICPDYQSYFKTIEDF +QQKILLTKSENARLVLQIDNAKLAADDFRTKYETELSLRQLVEADINGLR +RILDELTLCKADLEAQVESLKEELMCLKKNHEEEVSVLRCQLGDRLNVEV +DAAPPVDLNKILEDMRCQYEALVENNRRDVEAWFNTQTEELNQQVVSSSE +QLQCCQTEIIELRRTVNALEIELQAQHSMRNSLESTLAETEARYSSQLAQ +MQCLISNVEAQLSEIRCDLERQNQEYQVLLDVKARLEGEIATYRHLLEGE +DCKLPPQPCATACKPVIRVPSVPPVPCVPSVPCTPAPQVGTQIRTITEEI +RDGKVISSREHVQSRPL +>sp|K1H7_HUMAN| +MTSFYSTSSCPLGCTMAPGARNVFVSPIDVGCQPVAEANAASMCLLANVA +HANRVRVGSTPLGRPSLCLPPTSHTACPLPGTCHIPGNIGICGAYGKNTL +NGHEKETMKFLNDRLANYLEKVRQLEQENAELETTLLERSKCHESTVCPD +YQSYFRTIEELQQKILCSKAENARLIVQIDNAKLAADDFRIKLESERSLH +QLVEADKCGTQKLLDDATLAKADLEAQQESLKEEQLSLKSNHEQEVKILR +SQLGEKFRIELDIEPTIDLNRVLGEMRAQYEAMVETNHQDVEQWFQAQSE +GISLQAMSCSEELQCCQSEILELRCTVNALEVERQAQHTLKDCLQNSLCE +AEDRYGTELAQMQSLISNLEEQLSEIRADLERQNQEYQVLLDVKARLENE +IATYRNLLESEDCKLPCNPCSTPASCTSCPSCGPVTGGSPSGHGASMGR +>sp|K1H8_HUMAN| +MTSSYSSSSCPLGCTMAPGARNVSVSPIDIGCQPGAEANIAPMCLLANVA +HANRVRVGSTPLGRPSLCLPPTCHTACPLPGTCHIPGNIGICGAYGENTL +NGHEKETMQFLNDRLANYLEKVRQLEQENAELEATLLERSKCHESTVCPD +YQSYFHTIEELQQKILCSKAENARLIVQIDNAKLAADDFRIKLESERSLR +QLVEADKCGTQKLLDDATLAKADLEAQQESLKEEQLSLKSNHEQEVKILR +SQLGEKLRIELDIEPTIDLNRVLGEMRAQYEAMLETNRQDVEQWFQAQSE +GISLQDMSCSEELQCCQSEILELRCTVNALEVERQAQHTLKDCLQNSLCE +AEDRFGTELAQMQSLISNVEEQLSEIRADLERQNQEYQVLLDVKTRLENE +IATYRNLLESEDCKLPCNPCSTPPSCVTAPCAPRPSCGPCTTCGPTCGAS +TTGSRF +>sp|K1HA_HUMAN| +MSYSCGLPSLSCRTSCSSRPCVPPSCHGCTLPGACNIPANVSNCNWFCEG +SFNGSEKETMQFLNDRLASYLEKVRQLERDNAELENLIRERSQQQEPLVC +ASYQSYFKTIEELQQKILCSKSENARLVVQIDNAKLASDDFRTKYETELS +LRHVVESDINGLRRILDELTLCRSDLEAQVESLKEELLCLKQNHEQEVNT +LRCQLGDRLNVEVDAAPTVDLNQVLNETRSQYEALVETNRREVEQWFATQ +TEELNKQVVSSSEQLQSYQAEIIELRRTVNALEIELQAQHNLRDSLENTL +TESEARYSSQLSQVQRLITNVESQLAEIRSDLERQNQEYQVLLDVRARLE +CEINTYRSLLESEDCKLPSNPCATTNACDKSTGPCISNPCGLRARCGPCN +TFGY +>sp|K1HB_HUMAN| +MPYNFCLPSLSCRTSCSSRPCVPPSCHGYTLPGACNIPANVSNCNWFCEG +SFNGSEKETMQFLNDRLASYLEKVRQLERDNAELENLIRERSQQQEPLLC +PSYQSYFKTIEELQQKILCSKSENARLVVQIDNAKLAADDFRTKYQTEQS +LRQLVESDINSLRRILDELTLCRSDLEAQMESLKEELLSLKQNHEQEVNT +LRCQLGDRLNVEVDAAPAVDLNQVLNETRNQYEALVETNRREVEQWFATQ +TEELNKQVVSSSEQLQSYQAEIIELRRTVNALEIELQAQHNLRYSLENTL +TESEARYSSQLSQVQSLITNVESQLAEIRSDLERQNQEYQVLLDVRARLE +CEINTYRSLLESEDCKLPSNPCATTNACEKPIGSCVTNPCGPRSRCGPCN +TFGY +>sp|K1M1_SHEEP| +SFNFCLPNLSFRSSCSSRPCVPSSCCGTTLPGACNIPANVGSCNWFCEGS +FDGNEKETMQFLNDRLASYLEKVRQLERENAELESRILERSQQQEPLVCP +NYQSYFRTIEELQQKILCAKSENARLVVQIDNAKLAADDFRTKYETELGL +RQLVESDINGLRRILDELTLCKSDLEAQVESLKEELICLKSNHEEEVNTL +RSQLGDRLNVEVDAAPTVDLNRVLNETRAQYEALVETNRRDVEEWYIRQT +EELNKQVVSSSEQLQSCQTEIIELRRTVNALEVELQAQHNLRDSLENTLT +ETEARYSCQLNQVQSLISNVESQLAEIRGDLERQNQEYQVLLDVRARLEC +EINTYRGLLDSEDCKLPCNPCATTNACGKTITPCISSPCAPAAPCTPCVP +RSRCGPCNSYVR +>sp|K1M2_SHEEP| +SFNFCLPNLSFRSSCSSRPCVPSSCCGTTLPGACNIPASVGSCNWFCEGS +FNGNEKETMQFLNDRLASYLEKVRQLERENAELERRILERSQQQEPLVCP +NYQSYFRTIEELQQKILCGKSENARLVVQIDNAKLASDDFRTKYETEVSL +RQLVEADLNGLRRILDELTLCKSDLEARVESLKEELICLKQNHEQEVNTL +RSQLGDRLNVEVDAAPTVDLNHVLNETRAQYEALVETNRRDVEEWYIRQT +EELNKQVVSSSEQLQSCQAEIIELRRTVNALEVELQAQHNLRDSLENTLT +ETEARYSCQLNQVQSLIVSVESQLAEIRSDLERQNQEYQVLLDVRARLEC +EINTYRGLLDSEDCKLPCNPCATTNTCGKPIGPCISNPCVSRTRCGPCNT +FVH +>sp|K22E_HUMAN| +MSCQISCKSRGRGGGGGGFRGFSSGSAVVSGGSRRSTSSFSCLSRHGGGG +GGFGGGGFGSRSLVGLGGTKSISISVAGGGGGFGAAGGFGGRGGGFGGGS +GFGGGSGFGGGSGFSGGGFGGGGFGGGRFGGFGGPGGVGGLGGPGGFGPG +GYPGGIHEVSVNQSLLQPLNVKVDPEIQNVKAQEREQIKTLNNKFASFID +KVRFLEQQNQVLQTKWELLQQMNVGTRPINLEPIFQGYIDSLKRYLDGLT +AERTSQNSELNNMQDLVEDYKKKYEDEINKRTAAENDFVTLKKDVDNAYM +IKVELQSKVDLLNQEIEFLKVLYDAEISQIHQSVTDTNVILSMDNSRNLD +LDSIIAEVKAQYEEIAQRSKEEAEALYHSKYEELQVTVGRHGDSLKEIKI +EISELNRVIQRLQGEIAHVKKQCKNVQDAIADAEQRGEHALKDARNKLND +LEEALQQAKEDLARLLRDYQELMNVKLALDVEIATYRKLLEGEECRMSGD +LSSNVTVSVTSSTISSNVASKAAFGGSGGRGSSSGGGYSSGSSSYGSGGR +QSGSRGGSGGGGSISGGGYGSGGGSGGRYGSGGGSKGGSISGGGYGSGGG +KHSSGGGSRGGSSSGGGYGSGGGGSSSVKGSSGEAFGSSVTFSFR +>sp|K2C1_HUMAN| +SRQFSSRSGYRSGGGFSSGSAGIINYQRRTTSSSTRRSGGGGGRFSSCGG +GGGSFGAGGGFGSRSLVNLGGSKSISISVARGGGRGSGFGGGYGGGGFGG +GGFGGGGFGGGGIGGGGFGGFGSGGGGFGGGGFGGGGYGGGYGPVCPPGG +IQEVTINQSLLQPLNVEIDPEIQKVKSREREQIKSLNNQFASFIDKVRFL +EQQNQVLQTKWELLQQVDTSTRTHNLEPYFESFINNLRRRVDQLKSDQSR +LDSELKNMQDMVEDYRNKYEDEINKRTNAENEFVTIKKDVDGAYMTKVDL +QAKLDNLQQEIDFLTALYQAELSQMQTQISETNVILSMDNNRSLDLDSII +AEVKAQNEDIAQKSKAEAESLYQSKYEELQITAGRHGDSVRNSKIEISEL +NRVIQRLRSEIDNVKKQISNLQQSISDAEQRGENALKDAKNKLNDLEDAL +QQAKEDLARLLRDYQELMNTKLALDLEIATYRTLLEGEESRMSGECAPNV +SVSVSTSHTTISGGGSRGGGGGGYGSGGSSYGSGGGSYGSGGGGGGGRGS +YGSGGSSYGSGGGSYGSGGGGGGHGSYGSGSSSGGYRGGSGGGGGGSSGG +RGSGGGSSGGSIGGRGSSSGGVKSSGGSSSVRFVSTTYSGVTR +>sp|K2M1_SHEEP| +QNRQCCESNLEPLFSGYIETLRREAECAEADSGRLSSELNSLQEVLEGYK +RRYEEEVALRATAENEFVALKKDVDCAYLRKSDLEANVEALIQETDFLRR +LYEEEIRVL +>sp|K2M2_SHEEP| +CGFSTVGSGFGSRAFSCVSACGPRPGRCCITAAPYRGISCYRGLTGGFGS +RSVCGGFRAGSCGRSFGYRSGGVCGPSPPCITTVSVNESLLTPLNLEIDP +NAQCVKQEEKEQIKCLNNRFAAFIDKVRFLEQQNKLLETKLQFFQNRQCC +ESNLEPLFEGYIETLRREAECVEADSGRLSSELNHVQEVLEGYKKKYEQE +VALRATAENEFVALKKDVDCAYVRKSDLEANSEALIQEIDFLRRLYQEEI +RVLQANISDTSVIVKMDNSRDLNMDCIVAEIKAQYDDIASRSRAEAESWY +RSKCEEIKATVIRHGETLRRTKEEINELNRVIQRLTAEVENAKCQNSKLE +AAVTQAEQQGEVALNDARCKLAGLEEALQKAKQDMACLLKEYQEVMNSKL +GLDIEIATYRRLLEGEEQRLCEGVGAVNVCVSSSRGGVVCGDLCVSGSRP +VTGSVCSAPCSGNLAVSTGLCAPCGQLNTTCGGGSCSLGRC +>sp|K2M3_SHEEP| +SCRSYRISPGYSVTRTFSSCSAVAPKTGSRCCISAAPYRGVSCYRGLTGF +GSRSVSALGSCGPRIAVSGFRAGSCGRSFGYRSGGVGGLSPSCITTVSVN +ESLLTPLNLEIDPNAQCVKHQEKEQIKNLNSRFAAFIDKVRFLEQQNKLL +ETKWQFYQNQRCCESNLEPLFNGYIETLRREAEHVEADSGRLASELDHVQ +EVLEGYKKKYEEEVALRATAENEFVVLKKDVDCAYLRKSDLEANVEALVE +ESNFLKRLYDEEIQILNAHISDTSVIVKMDNSRDLNMDCVVAEIKAQYDD +IASRSRAEAESWYRSKCEEMKATVIRHGETLRRTKEEINELNRVIQRLTA +EIENAKCQRTKLEAAVAEAEQQGEAALNDARSKLAGLEEALQKAKQDMAC +LLKEYQEVMNSKLGLDIEIATYRRLLEGEEQRLCEGVGSVNVCVSSRGGV +ACGGLTYSSTAGRQIASGPVATGGSITVLAPDSCQPRASSFSCGSSRSVR +FA +>sp|KRA33_SHEEP| +ACCARLCCSVPTSPATTICSSDKFCRCGVCLPSTCPHTVWLLQPTCCCDN +RPPPYHVPQPSVPTCFLLNSSQPTPGLESINLTTYTQSSCEPCIPSCC +>sp|KRA34_SHEEP| +ACCARLCCSVPTSPATTICSSDKFCRCGVCLPSTCPHTVWFLQPTCCCDN +RPPPCHIPQPSVPTCFLLNSSQPTPGLESINLTTYTQPSCEPCIPSCC +>sp|KRA3A_SHEEP| +TGSCCGPTFSSLSCGGGCLQPCCYRDPCCCRPVSSTQTVSRPVTFVSRCT +RPICEPCRRPVCCDPCSLQEGCCRPITCCPTSCQAVVCRPCCWATTCCQP +VSVQCPCCRPTSCPSAPRTTCRTFRTSPCC +>sp|KRA3_SHEEP| +TGSCCGPTFSSLSCGGGCLQPRYYRDPCCCRPVSCQTVSRPVTFVPRCTR +PICEPCRRPVCCDPCSLQEGCCRPITCCPTSCQAVVCRPCCWATTCCQPV +SVQCPCCRPTSCQPAPCSRTTCRTFRTSPCC +>sp|KRA61_SHEEP| +CGYYGNYYGGLGCGSYSYGGLGCGYGSCYGSGFRRLGCGYGCGYGYGSRS +LCGSGYGYGSRSLCGSGYGCGSGYGSGFGYYY +>sp|KRB2A_SHEEP| +ACCSTSFCGFPICSTGGTCGSSPCQPTCCQTSCCQPTSIQTSCCQPISIQ +TSCCQPTSIQTSCCQPTCLQTSGCETGCGIGGSIGYGQVGSSGAVSSRTR +WCRPDCRVEGTSLPPCCVVSCTPPSCCQLYYAQASCCRPSYCGQSCCRPA +CCCQPTCIEPICEPSCCEPTC +>sp|KRB2B_SHEEP| +ACCSTSFCGFPICSSVGTCGSSCGQPTCSQTSCCQPTSIQTSCCQPISIQ +TSCCQPTCLQTSGCETGCGIGGSIGYDQVGSSGAVSSRTRWCRPDCRVEG +TSLPPCCVVSCTSPSCCQLYYAQASCCRPSYCGQSCCRPACCCQPTCIEP +VCEPTC +>sp|KRB2C_SHEEP| +ACCSTSFCGFPICSTAGTCGSSCCRSTCSQTSCCQPTSIQTSCCQPTCLQ +TSGCETGCGIGGSTGYGQVGSSGAVSSRTRWCRPDCRVEGTSLPPCCVVS +CTSPSCCQLYYAQASCCRPSYCGQSCCRPACCCQPTCTEPVCEPTCSQPI +C +>sp|KRB2D_SHEEP| +ACCSTSFCGFPTCSTGGTCGSNFCQPTCCQTSCCQPTSIQTSCCQPTSIQ +TSCCQPTSIQTSCCQPISIQTSCCQPTCLQTSGCETGCGIGGSIGYGQVG +SSGAVSSRTKWCRPDCRVEGTSLPPCCVVSCTSPSCCQLYYAQASCCRPS +YCGQSCCRPACCCQPTCIEPVCEPTCCEPTC +>sp|KRHB1_HUMAN| +MTCGSGFGGRAFSCISACGPRPGRCCITAAPYRGISCYRGLTGGFGSHSV +CGGFRAGSCGRSFGYRSGGVCGPSPPCITTVSVNESLLTPLNLEIDPNAQ +CVKQEEKEQIKSLNSRFAAFIDKVRFLEQQNKLLETKLQFYQNRECCQSN +LEPLFEGYIETLRREAECVEADSGRLASELNHVQEVLEGYKKKYEEEVSL +RATAENEFVALKKDVDCAYLRKSDLEANVEALIQEIDFLRRLYEEEIRIL +QSHISDTSVVVKLDNSRDLNMDCIIAEIKAQYDDIVTRSRAEAESWYRSK +CEEMKATVIRHGETLRRTKEEINELNRMIQRLTAEVENAKCQNSKLEAAV +AQSEQQGEAALSDARCKLAELEGALQKAKQDMACLIREYQEVMNSKLGLD +IEIATYRRLLEGEEQRLCEGIGAVNVCVSSSRGGVVCGDLCVSGSRPVTG +SVCSAPCNGNVAVSTGLCAPCGQLNTTCGGGSCGVGSCGISSLGVGSCGS +SCRKC +>sp|KRHB2_HUMAN| +MSYHSFQPGSRCGSQSFSSYSAVMPRMVTHYAVSKGPCRPGGGRGLRALG +CLGSRSLCNVGFGRPRVASRCGGTLPGFGYRLGATCGPSACITPVTINES +LLVPLALEIDPTVQRVKRDEKEQIKCLNNRFASFINKVRFLEQKNKLLET +KWNFMQQQRCCQTNIEPIFEGYISALRRQLDCVSGDRVRLESELCSLQAA +LEGYKKKYEEELSLRPCVENEFVALKKDVDTAFLMKADLETNAEALVQEI +DFLKSLYEEEICLLQSQISETSVIVKMDNSRELDVDGIIAEIKAQYDDIA +SRSKAEAEAWYQCRYEELRVTAGNHCDNLRNRKNEILEMNKLIQRLQQET +ENVKAQRCKLEGAIAEAEQQGEAALNDAKCKLAGLEEALQKAKQDMACLL +KEYQEVMNSKLGLDIEIATYRRLLEGEEHRLCEGIGPVNISVSSSKGAFL +YEPCGVSMPVLSTGVLRSNGGCSIVGTGELYVPCEPQGLLSCGSGRKSSM +TLGAGGSSPSHKH +>sp|KRHB3_HUMAN| +MTCGFNSIGCGFRPGNFSCVSACGPRPSRCCITAAPYRGISCYRGLTGGF +GSHSVCGGFRAGSCGRDFGYRSGGVCGPSPPCITTVSVNESLLTPLNLEI +DPNAQCVKQEEKEQIKSLNSRFAAFIDKVRFLEQQNKLLETKLQFYQNCE +CCQSNLEPLFAGYIETLRREAECVEADSGRLASELNHVQEVLEGYKKKYE +EEVALRATAENEFVALKKDVDCAYLRKSDLEANVEALIQEIDFLRRLYEE +EIRILQSHISDTSVVVKLDNSRDLNMDCMVAEIKAQYDDIATRSRAEAES +WYRSKCEEMKATVIRHGETLRRTKEEINELNRMIQRLTAEVENAKCQNSK +LEAAVAQSEQQGEAALSDARCKLAELEGALQKAKQDMACLIREYQEVMNS +KLGLDIEIATYRRLLEGEEQRLCEGVEAVNVCVSSSRGGVVCGDLCVSGS +RPVTGSVCSAPCNGNLVVSTGLCKPCGQLNTTCGGGSCGQGRY +>sp|KRHB4_HUMAN| +MSCRSYRVSSGHRVGNFSSCSAMTPQNLNRFRANSVSCWSGPGFRGLGSF +GSRSVITFGSYSPRIAAVGSRPIHCGVRFGAGCGMGFGDGRGVGLGPRAD +SCVGLGFGAGSGIGYGFGGPGFGYRVGGVGVPAAPSITAVTVNKSLLTPL +NLEIDPNAQRVKKDEKEQIKTLNNKFASFIDKVRFLEQQNKLLETKWSFL +QEQKCIRSNLEPLFESYITNLRRQLEVLVSDQARLQAERNHLQDVLEGFK +KKYEEEVVCRANAENEFVALKKDVDAAFMNKSDLEANVDTLTQEIDFLKT +LYMEEIQLLQSHISETSVIVKMDNSRDLNLDGIIAEVKAQYEEVARRSRA +DAEAWYQTKYEEMQVTAGQHCDNLRNIRNEINELTRLIQRLKAEIEHAKA +QRAKLEAAVAEAEQQGEATLSDAKCKLADLECALQQAKQDMARQLREYQE +LMNAKLGLDIEIATYRRLLEGEESRLCEGVGPVNISVSSSRGGLVCGPEP +LVAGSTLSRGGVTFSGSSSVCATSGVLASCGPSLGGARVAPATGDLLSTG +TRSGSMLISEACVPSVPCPLPTQGGFSSCSGGRSSSVRFVSTTTSCRTKY + +>sp|KRHB5_HUMAN| +MSCRSYRISSGCGVTRNFSSCSAVAPKTGNRCCISAAPYRGVSCYRGLTG +FGSRSLCNLGSCGPRIAVGGFRAGSCGRSFGYRSGGVCGPSPPCITTVSV +NESLLTPLNLEIDPNAQCVKQEEKEQIKSLNSRFAAFIDKVRFLEQQNKL +LETKWQFYQNQRCCESNLEPLFSGYIETLRREAECVEADSGRLASELNHV +QEVLEGYKKKYEEEVALRATAENEFVVLKKDVDCAYLRKSDLEANVEALV +EESSFLRRLYEEEIRVLQAHISDTSVIVKMDNSRDLNMDCIIAEIKAQYD +DVASRSRAEAESWYRSKCEEMKATVIRHGETLRRTKEEINELNRMIQRLT +AEIENAKCQRAKLEAAVAEAEQQGEAALSDARCKLAELEGALQKAKQDMA +CLLKEYQEVMNSKLGLDIEIATYRRLLEGEEHRLCEGVGSVNVCVSSSRG +GVSCGGLSYSTTPGRQITSGPSAIGGSITVVAPDSCAPCQPRSSSFSCGS +SRSVRFA +>sp|KRHB6_HUMAN| +MTCGSYCGGRAFSCISACGPRPGRCCITAAPYRGISCYRGLTGGFGSHSV +CGGFRAGSCGRSFGYRSGGVCGPSPPCITTVSVNESLLTPLNLEIDPNAQ +CVKQEEKEQIKSLNSRFAAFIDKVRFLEQQNKLLETKLQFYQNRECCQSN +LEPLFEGYIETLRREAECVEADSGRLASELNHVQEVLEGYKKKYEEEVSL +RATAENEFVALKKDVDCAYLRKSDLEANVEALIQEIDFLRRLYEEEIRVL +QSHISDTSVVVKLDNSRDLNMDCIIAEIKAQYDDIVTRSRAEAESWYRSK +CEEMKATVIRHGETLRRTKEEINELNRMIQRLTAEVENAKCQNSKLEAAV +AQSEQQGEAALSDARCKLAELEGALQKAKQDMACLIREYQEVMNSKLGLD +IEIATYRRLLEGEEQRLCEGVGSVNVCVSSSRGGVVCGDLCASTTAPVVS +TRVSSVPSNSNVVVGTTNACAPSARVGVCGGSCKRC +>sp|KRUC_SHEEP| +MGCSGCSGGCGSSCGGCGSRCGGCSSSCCVPVCCCKPVCCCVPACSCSSC +GKGGCGSSCGGSKGGCGSCGGSKGGCGSCGGCGSSCCKPVCCCVPACSCS +SCGKGGCGSCGGSKGGCGSCGGSKGGCGSCGGCGSGCGSSCCVPVCCCVP +ACSCSSCGKGGCGSCGCSQSSCCVPVCCQRKI +>sp|LALBA_BOVIN| +MMSFVSLLLVGILFHATQAEQLTKCEVFRELKDLKGYGGVSLPEWVCTTF +HTSGYDTQAIVQNNDSTEYGLFQINNKIWCKDDQNPHSSNICNISCDKFL +DDDLTDDIMCVKKILDKVGINYWLAHKALCSEKLDQWLCEKL +>sp|LYSC_LYSEN| +GVSGSCNIDVVCPEGNGHRDVIRSVAAYSKQGTMWCTGSLVNNSANDKKM +YFLTANHCGMTTAAIASSMVVYWNYQNSTCRAPGSSSSGANGDGSLAQSQ +TGAVVRATNAASDFTLLELNTAANPAYNLFWAGWDRRDQNFAGATAIHHP +NVAEKRISHSTVATEISGYNGATGTSHLHVFWQASGGVTEPGSSGSPIYS +PEKRVLGQLHGGPSSCSATGADRSDYYGRVFTSWTGGGTSATRLSDWLDA +AGTGAQFIDGLDSTGTPPV +>sp|PEPA_BOVIN| +MSVVKIPLVKKKSLRQNLIENGKLKEFMRTHKYNLGSKYIREAATLVSEQ +PLQNYLDTEYFGTIGIGTPAQDFTVIFDTGSSNLWVPSIYCSSEACTNHN +RFNPQDSSTYEATSETLSITYGTGSMTGILGYDTVQVGGISDTNQIFGLS +ETEPGSFLYYAPFDGILGLAYPSISSSGATPVFDNIWDQGLVSQDLFSVY +LSSNEESGSVVIFGDIDSSYYSGSLNWVPVSVEGYWQITVDSITMNGESI +ACSDGCQAIVDTGTSLLAGPTTAISNIQSYIGASEDSSGEVVISCSSIDS +LPDIVFTINGVQYPVPPSAYILQSNGICSSGFEGMDISTSSGDLWILGDV +FIRQYFTVFDRGNNQIGLAPVA +>sp|PEPA_PIG| +MKWLLLLSLVVLSECLVKVPLVRKKSLRQNLIKNGKLKDFLKTHKHNPAS +KYFPEAAALIGDEPLENYLDTEYFGTIGIGTPAQDFTVIFDTGSSNLWVP +SVYCSSLACSDHNQFNPDDSSTFEATSQELSITYGTGSMTGILGYDTVQV +GGISDTNQIFGLSETEPGSFLYYAPFDGILGLAYPSISASGATPVFDNLW +DQGLVSQDLFSVYLSSNDDSGSVVLLGGIDSSYYTGSLNWVPVSVEGYWQ +ITLDSITMDGETIACSGGCQAIVDTGTSLLTGPTSAIAINIQSDIGASEN +SDGEMVISCSSIDSLPDIVFTINGVQYPLSPSAYILQDDDSCTSGFEGMD +VPTSSGELWILGDVFIRQYYTVFDRANNKVGLAPVA +>sp|PEPB_PIG| +MERIILRKGKSIREAMEEQGVLEKFLKNRPKIDPAAKYHFNNDAVAYEPF +TNYLDSFYFGEISIGTP +>sp|PEPC_PIG| +SVIKVPLKKLKSIRQAMKEKGLLEEFLKTHKYDPAQRYRIGDISVALEPM +AYLEAAYFGEISIGTPPQNFLVL +>sp|SSPA_STAAU| +MKGKFLKVSSLFVATLTTATLVSSPAANALSSKAMDNHPQQTQSSKQQTP +KIQKGGNLKPLEQREHANVILPNNDRHQITDTTNGHYAPVTYIQVEAPTG +TFIASGVVVGKDTLLTNKHVVDATHGDPHALKAFPSAINQDNYPNGGFTA +EQITKYSGEGDLAIVKFSPNEQNKHIGEVVKPATMSNNAETQVNQNITVT +GYPGDKPVATMWESKGKITYLKGEAMQYDLSTTGGNSGSPVFNEKNEVIG +IHWGGVPNEFNGAVFINENVRNFLKQNIEDIHFANDDQPNNPDNPDNPNN +PDNPNNPDEPNNPDNPNNPDNPDNGDNNNSDNPDAA +>sp|TRY1_BOVIN| +FIFLALLGAAVAFPVDDDDKIVGGYTCGANTVPYQVSLNSGYHFCGGSLI +NSQWVVSAAHCYKSGIQVRLGEDNINVVEGNEQFISASKSIVHPSYNSNT +LNNDIMLIKLKSAASLNSRVASISLPTSCASAGTQCLISGWGNTKSSGTS +YPDVLKCLKAPILSDSSCKSAYPGQITSNMFCAGYLEGGKDSCQGDSGGP +VVCSGKLQGIVSWGSGCAQKNKPGVYTKVCNYVSWIKQTIASN +>sp|TRY2_BOVIN| +MHPLLILAFVGAAVAFPSDDDDKIVGGYTCAENSVPYQVSLNAGYHFCGG +SLINDQWVVSAAHCYQYHIQVRLGEYNIDVLEGGEQFIDASKIIRHPKYS +SWTLDNDILLIKLSTPAVINARVSTLLLPSACASAGTECLISGWGNTLSS +GVNYPDLLQCLVAPLLSHADCEASYPGQITNNMICAGFLEGGKDSCQGDS +GGPVACNGQLQGIVSWGYGCAQKGKPGVYTKVCNYVDWIQETIAANS +>sp|TRYP_PIG| +FPTDDDDKIVGGYTCAANSIPYQVSLNSGSHFCGGSLINSQWVVSAAHCY +KSRIQVRLGEHNIDVLEGNEQFINAAKIITHPNFNGNTLDNDIMLIKLSS +PATLNSRVATVSLPRSCAAAGTECLISGWGNTKSSGSSYPSLLQCLKAPV +LSDSSCKSSYPGQITGNMICVGFLEGGKDSCQGDSGGPVVCNGQLQGIVS +WGYGCAQKNKPGVYTKVCNYVNWIQQTIAAN +>sp|ALBU_HUMAN| +MKWVTFISLLFLFSSAYSRGVFRRDAHKSEVAHRFKDLGEENFKALVLIA +FAQYLQQCPFEDHVKLVNEVTEFAKTCVADESAENCDKSLHTLFGDKLCT +VATLRETYGEMADCCAKQEPERNECFLQHKDDNPNLPRLVRPEVDVMCTA +FHDNEETFLKKYLYEIARRHPYFYAPELLFFAKRYKAAFTECCQAADKAA +CLLPKLDELRDEGKASSAKQRLKCASLQKFGERAFKAWAVARLSQRFPKA +EFAEVSKLVTDLTKVHTECCHGDLLECADDRADLAKYICENQDSISSKLK +ECCEKPLLEKSHCIAEVENDEMPADLPSLAADFVESKDVCKNYAEAKDVF +LGMFLYEYARRHPDYSVVLLLRLAKTYETTLEKCCAAADPHECYAKVFDE +FKPLVEEPQNLIKQNCELFEQLGEYKFQNALLVRYTKKVPQVSTPTLVEV +SRNLGKVGSKCCKHPEAKRMPCAEDYLSVVLNQLCVLHEKTPVSDRVTKC +CTESLVNRRPCFSALEVDETYVPKEFNAETFTFHADICTLSEKERQIKKQ +TALVELVKHKPKATKEQLKAVMDDFAAFVEKCCKADDKETCFAEEGKKLV +AASQAALGL +>sp|ANT3_HUMAN| +MYSNVIGTVTSGKRKVYLLSLLLIGFWDCVTCHGSPVDICTAKPRDIPMN +PMCIYRSPEKKATEDEGSEQKIPEATNRRVWELSKANSRFATTFYQHLAD +SKNDNDNIFLSPLSISTAFAMTKLGACNDTLQQLMEVFKFDTISEKTSDQ +IHFFFAKLNCRLYRKANKSSKLVSANRLFGDKSLTFNETYQDISELVYGA +KLQPLDFKENAEQSRAAINKWVSNKTEGRITDVIPSEAINELTVLVLVNT +IYFKGLWKSKFSPENTRKELFYKADGESCSASMMYQEGKFRYRRVAEGTQ +VLELPFKGDDITMVLILPKPEKSLAKVEKELTPEVLQEWLDELEEMMLVV +HMPRFRIEDGFSLKEQLQDMGLVDLFSPEKSKLPGIVAEGRDDLYVSDAF +HKAFLEVNEEGSEAAASTAVVIAGRSLNPNRVTFKANRPFLVFIREVPLN +TIIFMGRVANPCVK +>sp|ANXA5_HUMAN| +AQVLRGTVTDFPGFDERADAETLRKAMKGLGTDEESILTLLTSRSNAQRQ +EISAAFKTLFGRDLLDDLKSELTGKFEKLIVALMKPSRLYDAYELKHALK +GAGTNEKVLTEIIASRTPEELRAIKQVYEEEYGSSLEDDVVGDTSGYYQR +MLVVLLQANRDPDAGIDEAQVEQDAQALFQAGELKWGTDEEKFITIFGTR +SVSHLRKVFDKYMTISGFQIEETIDRETSGNLEQLLLAVVKSIRSIPAYL +AETLYYAMKGAGTDDHTLIRVMVSRSEIDLFNIRKEFRKNFATSLYSMIK +GDTSGDYKKALLLLCGEDD +>sp|B2MG_HUMAN| +MSRSVALAVLALLSLSGLEAIQRTPKIQVYSRHPAENGKSNFLNCYVSGF +HPSDIEVDLLKNGERIEKVEHSDLSFSKDWSFYLLYYTEFTPTEKDEYAC +RVNHVTLSQPKIVKWDRDM +>sp|BID_HUMAN| +MDCEVNNGSSLRDECITNLLVFGFLQSCSDNSFRRELDALGHELPVLAPQ +WEGYDELQTDGNRSSHSRLGRIEADSESQEDIIRNIARHLAQVGDSMDRS +IPPGLVNGLALQLRNTSRSEEDRNRDLATALEQLLQAYPRDMEKEKTMLV +LALLLAKKVASHTPSLLRDVFHTTVNFINQNLRTYVRSLARNGMD +>sp|CAH1_HUMAN| +ASPDWGYDDKNGPEQWSKLYPIANGNNQSPVDIKTSETKHDTSLKPISVS +YNPATAKEIINVGHSFHVNFEDNDNRSVLKGGPFSDSYRLFQFHFHWGST +NEHGSEHTVDGVKYSAELHVAHWNSAKYSSLAEAASKADGLAVIGVLMKV +GEANPKLQKVLDALQAIKTKGKRAPFTNFDPSTLLPSSLDFWTYPGSLTH +PPLYESVTWIICKESISVSSEQLAQFRSLLSNVEGDNAVPMQHNNRPTQP +LKGRTVRASF +>sp|CAH2_HUMAN| +SHHWGYGKHNGPEHWHKDFPIAKGERQSPVDIDTHTAKYDPSLKPLSVSY +DQATSLRILNNGHAFNVEFDDSQDKAVLKGGPLDGTYRLIQFHFHWGSLD +GQGSEHTVDKKKYAAELHLVHWNTKYGDFGKAVQQPDGLAVLGIFLKVGS +AKPGLQKVVDVLDSIKTKGKSADFTNFDPRGLLPESLDYWTYPGSLTTPP +LLECVTWIVLKEPISVSSEQVLKFRKLNFNGEGEPEELMVDNWRPAQPLK +NRQIKASFK +>sp|CATA_HUMAN| +ADSRDPASDQMQHWKEQRAAQKADVLTTGAGNPVGDKLNVITVGPRGPLL +VQDVVFTDEMAHFDRERIPERVVHAKGAGAFGYFEVTHDITKYSKAKVFE +HIGKKTPIAVRFSTVAGESGSADTVRDPRGFAVKFYTEDGNWDLVGNNTP +IFFIRDPILFPSFIHSQKRNPQTHLKDPDMVWDFWSLRPESLHQVSFLFS +DRGIPDGHRHMNGYGSHTFKLVNANGEAVYCKFHYKTDQGIKNLSVEDAA +RLSQEDPDYGIRDLFNAIATGKYPSWTFYIQVMTFNQAETFPFNPFDLTK +VWPHKDYPLIPVGKLVLNRNPVNYFAEVEQIAFDPSNMPPGIEASPDKML +QGRLFAYPDTHRHRLGPNYLHIPVNCPYRARVANYQRDGPMCMQDNQGGA +PNYYPNSFGAPEQQPSALEHSIQYSGEVRRFNTANDDNVTQVRAFYVNVL +NEEQRKRLCENIAGHLKDAQIFIQKKAVKNFTEVHPDYGSHIQALLDKYN +AEKPKNAIHTFVQSGSHLAAREKANL +>sp|CATD_HUMAN| +MQPSSLLPLALCLLAAPASALVRIPLHKFTSIRRTMSEVGGSVEDLIAKG +PVSKYSQAVPAVTEGPIPEVLKNYMDAQYYGEIGIGTPPQCFTVVFDTGS +SNLWVPSIHCKLLDIACWIHHKYNSDKSSTYVKNGTSFDIHYGSGSLSGY +LSQDTVSVPCQSASSASALGGVKVERQVFGEATKQPGITFIAAKFDGILG +MAYPRISVNNVLPVFDNLMQQKLVDQNIFSFYLSRDPDAQPGGELMLGGT +DSKYYKGSLSYLNVTRKAYWQVHLDQVEVASGLTLCKEGCEAIVDTGTSL +MVGPVDEVRELQKAIGAVPLIQGEYMIPCEKVSTLPAITLKLGGKGYKLS +PEDYTLKVSQAGKTLCLSGFMGMDIPPPSGPLWILGDVFIGRYYTVFDRD +NNRVGFAEAARL +>sp|CATG_HUMAN| +MQPLLLLLAFLLPTGAEAGEIIGGRESRPHSRPYMAYLQIQSPAGQSRCG +GFLVREDFVLTAAHCWGSNINVTLGAHNIQRRENTQQHITARRAIRHPQY +NQRTIQNDIMLLQLSRRVRRNRNVNPVALPRAQEGLRPGTLCTVAGWGRV +SMRRGTDTLREVQLRVQRDRQCLRIFGSYDPRRQICVGDRRERKAAFKGD +SGGPLLCNNVAHGIVSYGKSSGVPPEVFTRVSSFLPWIRTTMRSFKLLDQ +METPL +>sp|CO5_HUMAN| +MGLLGILCFLIFLGKTWGQEQTYVISAPKIFRVGASENIVIQVYGYTEAF +DATISIKSYPDKKFSYSSGHVHLSSENKFQNSAILTIQPKQLPGGQNPVS +YVYLEVVSKHFSKSKRMPITYDNGFLFIHTDKPVYTPDQSVKVRVYSLND +DLKPAKRETVLTFIDPEGSEVDMVEEIDHIGIISFPDFKIPSNPRYGMWT +IKAKYKEDFSTTGTAYFEVKEYVLPHFSVSIEPEYNFIGYKNFKNFEITI +KARYFYNKVVTEADVYITFGIREDLKDDQKEMMQTAMQNTMLINGIAQVT +FDSETAVKELSYYSLEDLNNKYLYIAVTVIESTGGFSEEAEIPGIKYVLS +PYKLNLVATPLFLKPGIPYPIKVQVKDSLDQLVGGVPVILNAQTIDVNQE +TSDLDPSKSVTRVDDGVASFVLNLPSGVTVLEFNVKTDAPDLPEENQARE +GYRAIAYSSLSQSYLYIDWTDNHKALLVGEHLNIIVTPKSPYIDKITHYN +YLILSKGKIIHFGTREKFSDASYQSINIPVTQNMVPSSRLLVYYIVTGEQ +TAELVSDSVWLNIEEKCGNQLQVHLSPDADAYSPGQTVSLNMATGMDSWV +ALAAVDSAVYGVQRGAKKPLERVFQFLEKSDLGCGAGGGLNNANVFHLAG +LTFLTNANADDSQENDEPCKEILRPRRTLQKKIEEIAAKYKHSVVKKCCY +DGACVNNDETCEQRAARISLGPRCIKAFTECCVVASQLRANISHKDMQLG +RLHMKTLLPVSKPEIRSYFPESWLWEVHLVPRRKQLQFALPDSLTTWEIQ +GIGISNTGICVADTVKAKVFKDVFLEMNIPYSVVRGEQIQLKGTVYNYRT +SGMQFCVKMSAVEGICTSESPVIDHQGTKSSKCVRQKVEGSSSHLVTFTV +LPLEIGLHNINFSLETWFGKEILVKTLRVVPEGVKRESYSGVTLDPRGIY +GTISRRKEFPYRIPLDLVPKTEIKRILSVKGLLVGEILSAVLSQEGINIL +THLPKGSAEAELMSVVPVFYVFHYLETGNHWNIFHSDPLIEKQKLKKKLK +EGMLSIMSYRNADYSYSVWKGGSASTWLTAFALRVLGQVNKYVEQNQNSI +CNSLLWLVENYQLDNGSFKENSQYQPIKLQGTLPVEARENSLYLTAFTVI +GIRKAFDICPLVKIDTALIKADNFLLENTLPAQSTFTLAISAYALSLGDK +THPQFRSIVSALKREALVKGNPPIYRFWKDNLQHKDSSVPNTGTARMVET +TAYALLTSLNLKDINYVNPVIKWLSEEQRYGGGFYSTQDTINAIEGLTEY +SLLVKQLRLSMDIDVSYKHKGALHNYKMTDKNFLGRPVEVLLNDDLIVST +GFGSGLATVHVTTVVHKTSTSEEVCSFYLKIDTQDIEASHYRGYGNSDYK +RIVACASYKPSREESSSGSSHAVMDISLPTGISANEEDLKALVEGVDQLF +TDYQIKDGHVILQLNSIPSSDFLCVRFRIFELFEVGFLSPATFTVYEYHR +PDKQCTMFYSTSNIKIQKVCEGAACKCVEADCGQMQEELDLTISAETRKQ +TACKPEIAYAYKVSITSITVENVFVKYKATLLDIYKTGEAVAEKDSEITF +IKKVTCTNAELVKGRQYLIMGKEALQIKYNFSFRYIYPLDSLTWIEYWPR +DTTCSSCQAFLANLDEFAEDIFLNGC +>sp|CRP_HUMAN| +MEKLLCFLVLTSLSHAFGQTDMSRKAFVFPKESDTSYVSLKAPLTKPLKA +FTVCLHFYTELSSTRGYSIFSYATKRQDNEILIFWSKDIGYSFTVGGSEI +LFEVPEVTVAPVHICTSWESASGIVEFWVDGKPRVRKSLKKGYTVGAEAS +IILGQEQDSFGGNFEGSQSLVGDIGNVNMWDFVLSPDEINTIYLGGPFSP +NVLNWRALKYEVQGEVFTKPQLWP +>sp|CYB5_HUMAN| +AEQSDEAVKYYTLEEIQKHNHSKSTWLILHHKVYDLTKFLEEHPGGEEVL +REQAGGDATENFEDVGHSTDAREMSKTFIIGELHPDDRPKLNKPPETLIT +TIDSSSSWWTNWVIPAISAVAVALMYRLYMAED +>sp|CYC_HUMAN| +GDVEKGKKIFIMKCSQCHTVEKGGKHKTGPNLHGLFGRKTGQAPGYSYTA +ANKNKGIIWGEDTLMEYLENPKKYIPGTKMIFVGIKKKEERADLIAYLKK +ATNE +>sp|EGF_HUMAN| +MLLTLIILLPVVSKFSFVSLSAPQHWSCPEGTLAGNGNSTCVGPAPFLIF +SHGNSIFRIDTEGTNYEQLVVDAGVSVIMDFHYNEKRIYWVDLERQLLQR +VFLNGSRQERVCNIEKNVSGMAINWINEEVIWSNQQEGIITVTDMKGNNS +HILLSALKYPANVAVDPVERFIFWSSEVAGSLYRADLDGVGVKALLETSE +KITAVSLDVLDKRLFWIQYNREGSNSLICSCDYDGGSVHISKHPTQHNLF +AMSLFGDRIFYSTWKMKTIWIANKHTGKDMVRINLHSSFVPLGELKVVHP +LAQPKAEDDTWEPEQKLCKLRKGNCSSTVCGQDLQSHLCMCAEGYALSRD +RKYCEDVNECAFWNHGCTLGCKNTPGSYYCTCPVGFVLLPDGKRCHQLVS +CPRNVSECSHDCVLTSEGPLCFCPEGSVLERDGKTCSGCSSPDNGGCSQL +CVPLSPVSWECDCFPGYDLQLDEKSCAASGPQPFLLFANSQDIRHMHFDG +TDYGTLLSQQMGMVYALDHDPVENKIYFAHTALKWIERANMDGSQRERLI +EEGVDVPEGLAVDWIGRRFYWTDRGKSLIGRSDLNGKRSKIITKENISQP +RGIAVHPMAKRLFWTDTGINPRIESSSLQGLGRLVIASSDLIWPSGITID +FLTDKLYWCDAKQSVIEMANLDGSKRRRLTQNDVGHPFAVAVFEDYVWFS +DWAMPSVIRVNKRTGKDRVRLQGSMLKPSSLVVVHPLAKPGADPCLYQNG +GCEHICKKRLGTAWCSCREGFMKASDGKTCLALDGHQLLAGGEVDLKNQV +TPLDILSKTRVSEDNITESQHMLVAEIMVSDQDDCAPVGCSMYARCISEG +EDATCQCLKGFAGDGKLCSDIDECEMGVPVCPPASSKCINTEGGYVCRCS +EGYQGDGIHCLDIDECQLGVHSCGENASCTNTEGGYTCMCAGRLSEPGLI +CPDSTPPPHLREDDHHYSVRNSDSECPLSHDGYCLHDGVCMYIEALDKYA +CNCVVGYIGERCQYRDLKWWELRHAGHGQQQKVIVVAVCVVVLVMLLLLS +LWGAHYYRTQKLLSKNPKNPYEESSRDVRSRRPADTEDGMSSCPQPWFVV +IKEHQDLKNGGQPVAGEDGQAADGSMQPTSWRQEPQLCGMGTEQGCWIPV +SSDKGSCPQVMERSFHMPSYGTQTLEGGVEKPHSLLSANPLWQQRALDPP +HQMELTQ +>sp|FABPH_HUMAN| +VDAFLGTWKLVDSKNFDDYMKSLGVGFATRQVASMTKPTTIIEKNGDILT +LKTHSTFKNTEISFKLGVEFDETTADDRKVKSIVTLDGGKLVHLQKWDGQ +ETTLVRELIDGKLILTLTHGTAVCTRTYEKEA +>sp|GELS_HUMAN| +MAPHRPAPALLCALSLALCALSLPVRAATASRGASQAGAPQGRVPEARPN +SMVVEHPEFLKAGKEPGLQIWRVEKFDLVPVPTNLYGDFFTGDAYVILKT +VQLRNGNLQYDLHYWLGNECSQDESGAAAIFTVQLDDYLNGRAVQHREVQ +GFESATFLGYFKSGLKYKKGGVASGFKHVVPNEVVVQRLFQVKGRRVVRA +TEVPVSWESFNNGDCFILDLGNNIHQWCGSNSNRYERLKATQVSKGIRDN +ERSGRARVHVSEEGTEPEAMLQVLGPKPALPAGTEDTAKEDAANRKLAKL +YKVSNGAGTMSVSLVADENPFAQGALKSEDCFILDHGKDGKIFVWKGKQA +NTEERKAALKTASDFITKMDYPKQTQVSVLPEGGETPLFKQFFKNWRDPD +QTDGLGLSYLSSHIANVERVPFDAATLHTSTAMAAQHGMDDDGTGQKQIW +RIEGSNKVPVDPATYGQFYGGDSYIILYNYRHGGRQGQIIYNWQGAQSTQ +DEVAASAILTAQLDEELGGTPVQSRVVQGKEPAHLMSLFGGKPMIIYKGG +TSREGGQTAPASTRLFQVRANSAGATRAVEVLPKAGALNSNDAFVLKTPS +AAYLWVGTGASEAEKTGAQELLRVLRAQPVQVAEGSEPDGFWEALGGKAA +YRTSPRLKDKKMDAHPPRLFACSNKIGRFVIEEVPGELMQEDLATDDVML +LDTWDQVFVWVGKDSQEEEKTEALTSAKRYIETDPANRDRRTPITVVKQG +FEPPSFVGWFLGWDDDYWSVDPLDRAMAELAA +>sp|GSTA1_HUMAN| +AEKPKLHYFNARGRMESTRWLLAAAGVEFEEKFIKSAEDLDKLRNDGYLM +FQQVPMVEIDGMKLVQTRAILNYIASKYNLYGKDIKERALIDMYIEGIAD +LGEMILLLPVCPPEEKDAKLALIKEKIKNRYFPAFEKVLKSHGQDYLVGN +KLSRADIHLVELLYYVEELDSSLISSFPLLKALKTRISNLPTVKKFLQPG +SPRKPPMDEKSLEEARKIFRF +>sp|GSTP1_HUMAN| +PPYTVVYFPVRGRCAALRMLLADQGQSWKEEVVTVETWQEGSLKASCLYG +QLPKFQDGDLTLYQSNTILRHLGRTLGLYGKDQQEAALVDMVNDGVEDLR +CKYISLIYTNYEAGKDDYVKALPGQLKPFETLLSQNQGGKTFIVGDQISF +ADYNLLDLLLIHEVLAPGCLDAFPLLSAYVGRLSARPKLKAFLASPEYVN +LPINGNGKQ +>sp|HBA_HUMAN| +VLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSH +GSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKL +LSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR +>sp|HBB_HUMAN| +VHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLST +PDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDP +ENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH +>sp|IGF2_HUMAN| +MGIPMGKSMLVLLTFLAFASCCIAAYRPSETLCGGELVDTLQFVCGDRGF +YFSRPASRVSRRSRGIVEECCFRSCDLALLETYCATPAKSERDVSTPPTV +LPDNFPRYPVGKFFQYDTWKQSTQRLRRGLPALLRARRGHVLAKELEAFR +EAKRHRPLIALPTQDPAHGGAPPEMASNRK +>sp|IL8_HUMAN| +MTSKLAVALLAAFLISAALCEGAVLPRSAKELRCQCIKTYSKPFHPKFIK +ELRVIESGPHCANTEIIVKLSDGRELCLDPKENWVQRVVEKFLKRAENS +>sp|KCRM_HUMAN| +MPFGNTHNKFKLNYKPEEEYPDLSKHNNHMAKVLTLELYKKLRDKETPSG +FTVDDVIQTGVDNPGHPFIMTVGCVAGDEESYEVFKELFDPIISDRHGGY +KPTDKHKTDLNHENLKGGDDLDPNYVLSSRVRTGRSIKGYTLPPHCSRGE +RRAVEKLSVEALNSLTGEFKGKYYPLKSMTEKEQQQLIDDHFLFDKPVSP +LLLASGMARDWPDARGIWHNDNKSFLVWVNEEDHLRVISMEKGGNMKEVF +RRFCVGLQKIEEIFKKAGHPFMWNQHLGYVLTCPSNLGTGLRGGVHVKLA +HLSKHPKFEEILTRLRLQKRGTGGVDTAAVGSVFDVSNADRLGSSEVEQV +QLVVDGVKLMVEMEKKLEKGQSIDDMIPAQK +>sp|LALBA_HUMAN| +MRFFVPLFLVGILFPAILAKQFTKCELSQLLKDIDGYGGIALPELICTMF +HTSGYDTQAIVENNESTEYGLFQISNKLWCKSSQVPQSRNICDISCDKFL +DDDITDDIMCAKKILDIKGIDYWLAHKALCTEKLEQWLCEKL +>sp|LEP_HUMAN| +MHWGTLCGFLWLWPYLFYVQAVPIQKVQDDTKTLIKTIVTRINDISHTQS +VSSKQKVTGLDFIPGLHPILTLSKMDQTLAVYQQILTSMPSRNVIQISND +LENLRDLLHVLAFSKSCHLPWASGLETLDSLGGVLEASGYSTEVVALSRL +QGSLQDMLWQLDLSPGC +>sp|LYSC_HUMAN| +MKALIVLGLVLLSVTVQGKVFERCELARTLKRLGMDGYRGISLANWMCLA +KWESGYNTRATNYNAGDRSTDYGIFQINSRYWCNDGKTPGAVNACHLSCS +ALLQDNIADAVACAKRVVRDPQGIRAWVAWRNRCQNRDVRQYVQGCGV +>sp|MYG_HUMAN| +GLSDGEWQLVLNVWGKVEADIPGHGQEVLIRLFKGHPETLEKFDKFKHLK +SEDEMKASEDLKKHGATVLTALGGILKKKGHHEAEIKPLAQSHATKHKIP +VKYLEFISECIIQVLQSKHPGDFGADAQGAMNKALELFRKDMASNYKELG +FQG +>sp|NEDD8_HUMAN| +MLIKVKTLTGKEIEIDIEPTDKVERIKERVEEKEGIPPQQQRLIYSGKQM +NDEKTAADYKILGGSVLHLVLALRGGGGLRQ +>sp|NQO1_HUMAN| +MVGRRALIVLAHSERTSFNYAMKEAAAAALKKKGWEVVESDLYAMNFNPI +ISRKDITGKLKDPANFQYPAESVLAYKEGHLSPDIVAEQKKLEAADLVIF +QFPLQWFGVPAILKGWFERVFIGEFAYTYAAMYDKGPFRSKKAVLSITTG +GSGSMYSLQGIHGDMNVILWPIQSGILHFCGFQVLEPQLTYSIGHTPADA +RIQILEGWKKRLENIWDETPLYFAPSSLFDLNFQAGFLMKKEVQDEEKNK +KFGLSVGHHLGKSIPTDNQIKARK +>sp|NQO2_HUMAN| +AGKKVLIVYAHQEPKSFNGSLKNVAVDELSRQGCTVTVSDLYAMNFEPRA +TDKDITGTLSNPEVFNYGVETHEAYKQRSLASDITDEQKKVREADLVIFQ +FPLYWFSVPAILKGWMDRVLCQGFAFDIPGFYDSGLLQGKLALLSVTTGG +TAEMYTKTGVNGDSRYFLWPLQHGTLHFCGFKVLAPQISFAPEIASEEER +KGMVAAWSQRLQTIWKEEPIPCTAHWHFGQ +>sp|PDGFB_HUMAN| +MNRCWALFLSLCCYLRLVSAEGDPIPEELYEMLSDHSIRSFDDLQRLLHG +DPGEEDGAELDLNMTRSHSGGELESLARGRRSLGSLTIAEPAMIAECKTR +TEVFEISRRLIDRTNANFLVWPPCVEVQRCSGCCNNRNVQCRPTQVQLRP +VQVRKIEIVRKKPIFKKATVTLEDHLACKCETVAAARPVTRSPGGSQEQR +AKTPQTRVTIRTVRVRRPPKGKHRKFKHTHDKTALKETLGA +>sp|PPIA_HUMAN| +VNPTVFFDIAVDGEPLGRVSFELFADKVPKTAENFRALSTGEKGFGYKGS +CFHRIIPGFMCQGGDFTRHNGTGGKSIYGEKFEDENFILKHTGPGILSMA +NAGPNTNGSQFFICTAKTEWLDGKHVVFGKVKEGMNIVEAMERFGSRNGK +TSKKITIADCGQLE +>sp|PRDX1_HUMAN| +MSSGNAKIGHPAPNFKATAVMPDGQFKDISLSDYKGKYVVFFFYPLDFTF +VCPTEIIAFSDRAEEFKKLNCQVIGASVDSHFCHLAWVNTPKKQGGLGPM +NIPLVSDPKRTIAQDYGVLKADEGISFRGLFIIDDKGILRQITVNDLPVG +RSVDETLRLVQAFQFTDKHGEVCPAGWKPGSDTIKPDVQKSKEYFSKQK +>sp|RASH_HUMAN| +MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGET +CLLDILDTAGQEEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHQYREQI +KRVKDSDDVPMVLVGNKCDLAARTVESRQAQDLARSYGIPYIETSAKTRQ +GVEDAFYTLVREIRQHKLRKLNPPDESGPGCMSCKCVLS +>sp|RETBP_HUMAN| +MKWVWALLLLAALGSGRAERDCRVSSFRVKENFDKARFSGTWYAMAKKDP +EGLFLQDNIVAEFSVDETGQMSATAKGRVRLLNNWDVCADMVGTFTDTED +PAKFKMKYWGVASFLQKGNDDHWIVDTDYDTYAVQYSCRLLNLDGTCADS +YSFVFSRDPNGLPPEAQKIVRQRQEELCLARQYRLIVHNGYCDGRSERNL +L +>sp|SODC_HUMAN| +ATKAVCVLKGDGPVQGIINFEQKESNGPVKVWGSIKGLTEGLHGFHVHEF +GDNTAGCTSAGPHFNPLSRKHGGPKDEERHVGDLGNVTADKDGVADVSIE +DSVISLSGDHCIIGRTLVVHEKADDLGKGGNEESTKTGNAGSRLACGVIG +IAQ +>sp|SUMO1_HUMAN| +MSDQEAKPSTEDLGDKKEGEYIKLKVIGQDSSEIHFKVKMTTHLKKLKES +YCQRQGVPMNSLRFLFEGQRIADNHTPKELGMEEEDVIEVYQEQTGGHST +V +>sp|SYH_HUMAN| +MAERAALEELVKLQGERVRGLKQQKASAELIEEEVAKLLKLKAQLGPDES +KQKFVLKTPKGTRDYSPRQMAVREKVFDVIIRCFKRHGAEVIDTPVFELK +ETLMGKYGEDSKLIYDLKDQGGELLSLRYDLTVPFARYLAMNKLTNIKRY +HIAKVYRRDNPAMTRGRYREFYQCDFDIAGNFDPMIPDAECLKIMCEILS +SLQIGDFLVKVNDRRILDGMFAICGVSDSKFRTICSSVDKLDKVSWEEVK +NEMVGEKGLAPEVADRIGDYVQQHGGVSLVEQLLQDPKLSQNKQALEGLG +DLKLLFEYLTLFGIDDKISFDLSLARGLDYYTGVIYEAVLLQTPAQAGEE +PLGVGSVAAGGRYDGLVGMFDPKGRKVPCVGLSIGVERIFSIVEQRLEAL +EEKIRTTETQVLVASAQKKLLEERLKLVSELWDAGIKAELLYKKNPKLLN +QLQYCEEAGIPLVAIIGEQELKDGVIKLRSVTSREEVDVRREDLVEEIKR +RTGQPLCIC +>sp|TAU_HUMAN| +AEPRQEFEVMEDHAGTYGLGDRKDQGGYTMHQDQEGDTDAGLKESPLQTP +TEDGSEEPGSETSDAKSTPTAEDVTAPLVDEGAPGKQAAAQPHTEIPEGT +TAEEAGIGDTPSLEDEAAGHVTQEPESGKVVQEGFLREPGPPGLSHQLMS +GMPGAPLLPEGPREATRQPSGTGPEDTEGGRHAPELLKHQLLGDLHQEGP +PLKGAGGKERPGSKEEVDEDRDVDESSPQDSPPSKASPAQDGRPPQTAAR +EATSIPGFPAEGAIPLPVDFLSKVSTEIPASEPDGPSVGRAKGQDAPLEF +TFHVEITPNVQKEQAHSEEHLGRAAFPGAPGEGPEARGPSLGEDTKEADL +PEPSEKQPAAAPRGKPVSRVPQLKARMVSKSKDGTGSDDKKAKTSTRSSA +KTLKNRPCLSPKLPTPGSSDPLIQPSSPAVCPEPPSSPKHVSSVTSRTGS +SGAKEMKLKGADGKTKIATPRGAAPPGQKGQANATRIPAKTPPAPKTPPS +SGEPPKSGDRSGYSSPGSPGTPGSRSRTPSLPTPPTREPKKVAVVRTPPK +SPSSAKSRLQTAPVPMPDLKNVKSKIGSTENLKHQPGGGKVQIINKKLDL +SNVQSKCGSKDNIKHVPGGGSVQIVYKPVDLSKVTSKCGSLGNIHHKPGG +GQVEVKSEKLDFKDRVQSKIGSLDNITHVPGGGNKKIETHKLTFRENAKA +KTDHGAEIVYKSPVVSGDTSPRHLSNVSSTGSIDMVDSPQLATLADEVSA +SLAKQGL +>sp|THIO_HUMAN| +VKQIESKTAFQEALDAAGDKLVVVDFSATWCGPCKMIKPFFHSLSEKYSN +VIFLEVDVDDCQDVASECEVKCMPTFQFFKKGQKVGEFSGANKEKLEATI +NELV +>sp|TNFA_HUMAN| +MSTESMIRDVELAEEALPKKTGGPQGSRRCLFLSLFSFLIVAGATTLFCL +LHFGVIGPQREEFPRDLSLISPLAQAVRSSSRTPSDKPVAHVVANPQAEG +QLQWLNRRANALLANGVELRDNQLVVPSEGLYLIYSQVLFKGQGCPSTHV +LLTHTISRIAVSYQTKVNLLSAIKSPCQRETPEGAEAKPWYEPIYLGGVF +QLEKGDRLSAEINRPDYLDFAESGQVYFGIIAL +>sp|TRFE_HUMAN| +MRLAVGALLVCAVLGLCLAVPDKTVRWCAVSEHEATKCQSFRDHMKSVIP +SDGPSVACVKKASYLDCIRAIAANEADAVTLDAGLVYDAYLAPNNLKPVV +AEFYGSKEDPQTFYYAVAVVKKDSGFQMNQLRGKKSCHTGLGRSAGWNIP +IGLLYCDLPEPRKPLEKAVANFFSGSCAPCADGTDFPQLCQLCPGCGCST +LNQYFGYSGAFKCLKDGAGDVAFVKHSTIFENLANKADRDQYELLCLDNT +RKPVDEYKDCHLAQVPSHTVVARSMGGKEDLIWELLNQAQEHFGKDKSKE +FQLFSSPHGKDLLFKDSAHGFLKVPPRMDAKMYLGYEYVTAIRNLREGTC +PEAPTDECKPVKWCALSHHERLKCDEWSVNSVGKIECVSAETTEDCIAKI +MNGEADAMSLDGGFVYIAGKCGLVPVLAENYNKSDNCEDTPEAGYFAVAV +VKKSASDLTWDNLKGKKSCHTAVGRTAGWNIPMGLLYNKINHCRFDEFFS +EGCAPGSKKDSSLCKLCMGSGLNLCEPNNKEGYYGYTGAFRCLVEKGDVA +FVKHQTVPQNTGGKNPDPWAKNLNEKDYELLCLDGTRKPVEEYANCHLAR +APNHAVVTRKDKEACVHKILRQQQHLFGSNVTDCSGNFCLFRSETKDLLF +RDDTVCLAKLHDRNTYEKYLGEEYVKAVGNLRKCSTSSLLEACTFRRP +>sp|TRFL_HUMAN| +MKLVFLVLLFLGALGLCLAGRRRSVQWCAVSQPEATKCFQWQRNMRKVRG +PPVSCIKRDSPIQCIQAIAENRADAVTLDGGFIYEAGLAPYKLRPVAAEV +YGTERQPRTHYYAVAVVKKGGSFQLNELQGLKSCHTGLRRTAGWNVPIGT +LRPFLNWTGPPEPIEAAVARFFSASCVPGADKGQFPNLCRLCAGTGENKC +AFSSQEPYFSYSGAFKCLRDGAGDVAFIRESTVFEDLSDEAERDEYELLC +PDNTRKPVDKFKDCHLARVPSHAVVARSVNGKEDAIWNLLRQAQEKFGKD +KSPKFQLFGSPSGQKDLLFKDSAIGFSRVPPRIDSGLYLGSGYFTAIQNL +RKSEEEVAARRARVVWCAVGEQELRKCNQWSGLSEGSVTCSSASTTEDCI +ALVLKGEADAMSLDGGYVYTAGKCGLVPVLAENYKSQQSSDPDPNCVDRP +VEGYLAVAVVRRSDTSLTWNSVKGKKSCHTAVDRTAGWNIPMGLLFNQTG +SCKFDEYFSQSCAPGSDPRSNLCALCIGDEQGENKCVPNSNERYYGYTGA +FRCLAENAGDVAFVKDVTVLQNTDGNNNEAWAKDLKLADFALLCLDGKRK +PVTEARSCHLAMAPNHAVVSRMDKVERLKQVLLHQQAKFGRNGSDCPDKF +CLFQSETKNLLFNDNTECLARLHGKTTYEKYLGPQYVAGITNLKKCSTSP +LLEACEFLRK +>sp|UB2E1_HUMAN| +MSDDDSRASTSSSSSSSSNQQTEKETNTPKKKESKVSMSKNSKLLSTSAK +RIQKELADITLDPPPNCSAGPKGDNIYEWRSTILGPPGSVYEGGVFFLDI +TFTPEYPFKPPKVTFRTRIYHCNINSQGVICLDILKDNWSPALTISKVLL +SICSLLTDCNPADPLVGSIATQYMTNRAEHDRMARQWTKRYAT +>sp|UBE2C_HUMAN| +MASQNRDPAATSVAAARKGAEPSGGAARGPVGKRLQQELMTLMMSGDKGI +SAFPESDNLFKWVGTIHGAAGTVYEDLRYKLSLEFPSGYPYNAPTVKFLT +PCYHPNVDTQGNICLDILKEKWSALYDVRTILLSIQSLLGEPNIDSPLNT +HAAELWKNPTAFKKYLQETYSKQVTSQEP +>sp|UBE2I_HUMAN| +MSGIALSRLAQERKAWRKDHPFGFVAVPTKNPDGTMNLMNWECAIPGKKG +TPWEGGLFKLRMLFKDDYPSSPPKCKFEPPLFHPNVYPSGTVCLSILEED +KDWRPAITIKQILLGIQELLNEPNIQDPAQAEAYTIYCQNRVEYEKRVRA +QAKKFAPS +>sp|RS27A_HUMAN| +MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQL +EDGRTLSDYNIQKESTLHLVLRLRGGAKKRKKKSYTTPKKNKHKRKKVKL +AVLKYYKVDENGKISRLRRECPSDECGAGVFMASHFDRHYCGKCCLTYCF +NKPEDK +>sp|GAG_SCVLA| +MLRFVTKNSQDKSSDLFSICSDRGTFVAHNRVRTDFKFDNLVFNRVYGVS +QKFTLVGNPTVCFNEGSSYLEGIAKKYLTLDGGLAIDNVLNELRSTCGIP +GNAVASHAYNITSWRWYDNHVALLMNMLRAYHLQVLTEQGQYSAGDIPMY +HDGHVKIKLPVTIDDTAGPTQFAWPSDRSTDSYPDWAQFSESFPSIDVPY +LDVRPLTVTEVNFVLMMMSKWHRRTNLAIDYEAPQLADKFAYRHALTVQD +ADEWIEGDRTDDQFRPPSSKVMLSALRKYVNHNRLYNQFYTAAQLLAQIM +MKPVPNCAEGYAWLMHDALVNIPKFGSIRGRYPFLLSGDAALIQATALED +WSAIMAKPELVFTYAMQVSVALNTGLYLRRVKKTGFGTTIDDSYEDGAFL +QPETFVQAALACCTGQDAPLNGMSDVYVTYPDLLEFDAVTQVPITVIEPA +GYNIVDDHLVVVGVPVACSPYMIFPVAAFDTANPYCGNFVIKAANKYLRK +GAVYDKLEAWKLAWALRVAGYDTHFKVYGDTHGLTKFYADNGDTWTHIPE +FVTDGDVMEVFVTAIERRARHFVELPRLNSPAFFRSVEVSTTIYDTHVQA +GAHAVYHASRINLDYVKPVSTGIQVINAGELKNYWGSVRRTQQGLGVVGL +TMPAVMPTGEPTAGAAHEELIEQADNVLVE +>sp|CYC_HORSE| +MGDVEKGKKIFVQKCAQCHTVEKGGKHKTGPNLHGLFGRKTGQAPGFTYT +DANKNKGITWKEETLMEYLENPKKYIPGTKMIFAGIKKKTEREDLIAYLK +KATNE +>sp|CAH2_BOVIN| +MSHHWGYGKHNGPEHWHKDFPIANGERQSPVDIDTKAVVQDPALKPLALV +YGEATSRRMVNNGHSFNVEYDDSQDKAVLKDGPLTGTYRLVQFHFHWGSS +DDQGSEHTVDRKKYAAELHLVHWNTKYGDFGTAAQQPDGLAVVGVFLKVG +DANPALQKVLDALDSIKTKGKSTDFPNFDPGSLLPNVLDYWTYPGSLTTP +PLLESVTWIVLKEPISVSSQQMLKFRTLNFNAEGEPELLMLANWRPAQPL +KNRQVRGFPK +>sp|ADH1_YEAST| +MSIPETQKGVIFYESHGKLEHKDIPVPKPKANELLINVKYSGVCHTDLHA +WHGDWPLPVKLPLVGGHEGAGVVVGMGENVKGWKIGDYAGIKWLNGSCMA +CEYCELGNESNCPHADLSGYTHDGSFQQYATADAVQAAHIPQGTDLAQVA +PILCAGITVYKALKSANLMAGHWVAISGAAGGLGSLAVQYAKAMGYRVLG +IDGGEGKEELFRSIGGEVFIDFTKEKDIVGAVLKATDGGAHGVINVSVSE +AAIEASTRYVRANGTTVLVGMPAGAKCCSDVFNQVVKSISIVGSYVGNRA +DTREALDFFARGLVKSPIKVVGLSTLPEIYEKMEKGQIVGRYVVDTSK +>sp|ALDOA_RABIT| +MPHSHPALTPEQKKELSDIAHRIVAPGKGILAADESTGSIAKRLQSIGTE +NTEENRRFYRQLLLTADDRVNPCIGGVILFHETLYQKADDGRPFPQVIKS +KGGVVGIKVDKGVVPLAGTNGETTTQGLDGLSERCAQYKKDGADFAKWRC +VLKIGEHTPSALAIMENANVLARYASICQQNGIVPIVEPEILPDGDHDLK +RCQYVTEKVLAAVYKALSDHHIYLEGTLLKPNMVTPGHACTQKYSHEEIA +MATVTALRRTVPPAVTGVTFLSGGQSEEEASINLNAINKCPLLKPWALTF +SYGRALQASALKAWGGKKENLKAAQEEYVKRALANSLACQGKYTPSGQAG +AAASESLFIS +>sp|LYSC_CHICK| +MRSLLILVLCFLPLAALGKVFGRCELAAAMKRHGLDNYRGYSLGNWVCAA +KFESNFNTQATNRNTDGSTDYGILQINSRWWCNDGRTPGSRNLCNIPCSA +LLSSDITASVNCAKKIVSDGNGMNAWVAWRNRCKGTDVQAWIRGCRL +>sp|MYG_HORSE| +MGLSDGEWQQVLNVWGKVEADIAGHGQEVLIRLFTGHPETLEKFDKFKHL +KTEAEMKASEDLKKHGTVVLTALGGILKKKGHHEAELKPLAQSHATKHKI +PIKYLEFISDAIIHVLHSKHPGDFGADAQGAMTKALELFRNDIAAKYKEL +GFQG +>sp|OVAL_CHICK| +MGSIGAASMEFCFDVFKELKVHHANENIFYCPIAIMSALAMVYLGAKDST +RTQINKVVRFDKLPGFGDSIEAQCGTSVNVHSSLRDILNQITKPNDVYSF +SLASRLYAEERYPILPEYLQCVKELYRGGLEPINFQTAADQARELINSWV +ESQTNGIIRNVLQPSSVDSQTAMVLVNAIVFKGLWEKAFKDEDTQAMPFR +VTEQESKPVQMMYQIGLFRVASMASEKMKILELPFASGTMSMLVLLPDEV +SGLEQLESIINFEKLTEWTSSNVMEERKIKVYLPRMKMEEKYNLTSVLMA +MGITDVFSSSANLSGISSAESLKISQAVHAAHAEINEAGREVVGSAEAGV +DAASVSEEFRADHPFLFCIKHIATNAVLFFGRCVSP +>sp|BGAL_ECOLI| +MTMITDSLAVVLQRRDWENPGVTQLNRLAAHPPFASWRNSEEARTDRPSQ +QLRSLNGEWRFAWFPAPEAVPESWLECDLPEADTVVVPSNWQMHGYDAPI +YTNVTYPITVNPPFVPTENPTGCYSLTFNVDESWLQEGQTRIIFDGVNSA +FHLWCNGRWVGYGQDSRLPSEFDLSAFLRAGENRLAVMVLRWSDGSYLED +QDMWRMSGIFRDVSLLHKPTTQISDFHVATRFNDDFSRAVLEAEVQMCGE +LRDYLRVTVSLWQGETQVASGTAPFGGEIIDERGGYADRVTLRLNVENPK +LWSAEIPNLYRAVVELHTADGTLIEAEACDVGFREVRIENGLLLLNGKPL +LIRGVNRHEHHPLHGQVMDEQTMVQDILLMKQNNFNAVRCSHYPNHPLWY +TLCDRYGLYVVDEANIETHGMVPMNRLTDDPRWLPAMSERVTRMVQRDRN +HPSVIIWSLGNESGHGANHDALYRWIKSVDPSRPVQYEGGGADTTATDII +CPMYARVDEDQPFPAVPKWSIKKWLSLPGETRPLILCEYAHAMGNSLGGF +AKYWQAFRQYPRLQGGFVWDWVDQSLIKYDENGNPWSAYGGDFGDTPNDR +QFCMNGLVFADRTPHPALTEAKHQQQFFQFRLSGQTIEVTSEYLFRHSDN +ELLHWMVALDGKPLASGEVPLDVAPQGKQLIELPELPQPESAGQLWLTVR +VVQPNATAWSEAGHISAWQQWRLAENLSVTLPAASHAIPHLTTSEMDFCI +ELGNKRWQFNRQSGFLSQMWIGDKKQLLTPLRDQFTRAPLDNDIGVSEAT +RIDPNAWVERWKAAGHYQAEAALLQCTADTLADAVLITTAHAWQHQGKTL +FISRKTYRIDGSGQMAITVDVEVASDTPHPARIGLNCQLAQVAERVNWLG +LGPQENYPDRLTAACFDRWDLPLSDMYTPYVFPSENGLRCGTRELNYGPH +QWRGDFQFNISRYSQQQLMETSHRHLLHAEEGTWLNIDGFHMGIGGDDSW +SPSVSAEFQLSAGRYHYQLVWCQK +>sp|DHE3_BOVIN| +MYRYLGEALLLSRAGPAALGSASADSAALLGWARGQPAAAPQPGLVPPAR +RHYSEAAADREDDPNFFKMVEGFFDRGASIVEDKLVEDLKTRETEEQKRN +RVRSILRIIKPCNHVLSLSFPIRRDDGSWEVIEGYRAQHSQHRTPCKGGI +RYSTDVSVDEVKALASLMTYKCAVVDVPFGGAKAGVKINPKNYTDNELEK +ITRRFTMELAKKGFIGPGVDVPAPDMSTGEREMSWIADTYASTIGHYDIN +AHACVTGKPISQGGIHGRISATGRGVFHGIENFINEASYMSILGMTPGFG +DKTFVVQGFGNVGLHSMRYLHRFGAKCITVGESDGSIWNPDGIDPKELED +FKLQHGTILGFPKAKIYEGSILEVDCDILIPAASEKQLTKSNAPRVKAKI +IAEGANGPTTPEADKIFLERNIMVIPDLYLNAGGVTVSYFEWLNNLNHVS +YGRLTFKYERDSNYHLLMSVQESLERKFGKHGGTIPIVPTAEFQDRISGA +SEKDIVHSGLAYTMERSARQIMRTAMKYNLGLDLRTAAYVNAIEKVFRVY +NEAGVTFT +>sp|GFP_AEQVI| +MSKGEELFTGVVPILVELDGDVNGHKFSVSGEGEGDATYGKLTLKFICTT +GKLPVPWPTLVTTFSYGVQCFSRYPDHMKQHDFFKSAMPEGYVQERTIF +FKDDGNYKTRAEVKFEGDTLVNRIELKGIDFKEDGNILGHKLEYNYNSH +NVYIMADKQKNGIKVNFKIRHNIEDGSVQLADHYQQNTPIGDGPVLLPD +NHYLSTQSALSKDPNEKRDHMVLLEFVTAAGITHGMDELYK +>sp|SRPP_HEVBR| +MAEEVEEERLKYLDFVRAAGVYAVDSFSTLYLYAKDISGPLKPGVDTIEN +VVKTVVTPVYYIPLEAVKFVDKTVDVSVTSLDGVVPPVIKQVSAQTYSVA +QDAPRIVLDVASSVFNTGVQEGAKALYANLEPKAEQYAVITWRALNKLPL +VPQVANVVVPTAVYFSEKYNDVVRGTTEQGYRVSSYLPLLPTEKITKVFG +DEAS +>sp|REF_HEVBR| +MAEDEDNQQGQGEGLKYLGFVQDAATYAVTTFSNVYLFAKDKSGPLQPGV +DIIEGPVKNVAVPLYNRFSYIPNGALKFVDSTVVASVTIIDRSLPPIVKD +ASIQVVSAIRAAPEAARSLASSLPGQTKILAKVFYGEN +>sp|PLMP_GRIFR| +MFSSVMVALVSLAVAVSANPGLSLKVSGPEAVDGVNNLKVVTTITNTGDE +TLKLLNDPRGALHTMPTDTFAITNESGETPSFIGVKVKYVPSMAAKSTGE +NVFAVIAPGQSVNVEHDLSAAYNFTSSGAGTYALEALNVFNYIDPETNEP +VEIWADAEAHTTAVSGKLAVVRATPTLTRPVTYNGCSSSEQSALAAAASA +AQSYVAESLSYLQTHTAATPRYTTWFGSYISSRHSTVLQHYTDMNSNDFS +SYSFDCTCTAAGTFAYVYPNRFGTVYLCGAFWKAPTTGTDSQAGTLVHES +SHFTRNGGTKDYAYGQAAAKSLATMDPDKAVMNADNHEYFSENNPAQS +>KKA1_ECOLX +MSHIQRETSCSRPRLNSNLDADLYGYKWARDNVGQSGATIYRLYGKPDAP +ELFLKHGKGSVANDVTDEMVRLNWLTEFMPLPTIKHFIRTPDDAWLLTTA +IPGKTAFQVLEEYPDSGENIVDALAVFLRRLHSIPVCNCPFNSDRVFRLA +QAQSRMNNGLVDASDFDDERNGWPVEQVWKEMHKLLPFSPDSVVTHGDFS +LDNLIFDEGKLIGCIDVGRVGIADRYQDLAILWNCLGEFSPSLQKRLFQK +YGIDNPDMNKLQFHLMLDEFF diff --git a/src/WorkflowTest.py b/src/WorkflowTest.py index 30d19b4..149ec68 100644 --- a/src/WorkflowTest.py +++ b/src/WorkflowTest.py @@ -16,6 +16,7 @@ from src.common.common import page_setup from src.common.results_helpers import get_abundance_data from src.common.results_helpers import parse_idxml, build_spectra_cache, load_idxml, write_psm_exports +from src.common.contaminants import CONTAMINANT_PREFIX, append_fasta, write_contaminants from openms_insight import Table, Heatmap, LinePlot, SequenceView # Accession prefixes that mark decoy entries in a FASTA database. @@ -169,6 +170,21 @@ def filter_psms(self, in_files: list, out_files: list, tool_instance_name: str = tool_instance_name=tool_instance_name, ) + def render_contaminant_option(self): + """Checkbox for appending the cRAP contaminants to the search database.""" + self.ui.input_widget( + key="add-contaminants", + default=True, + name="Add common contaminants (cRAP)", + widget_type="checkbox", + help=( + "Search the 116 proteins of the common Repository of Adventitious Proteins " + "(keratins, trypsin, BSA, ...) with your database. Their accessions start with " + f"{CONTAMINANT_PREFIX}; they are flagged in the PSM tables and left out of the " + "protein tables and the downstream analysis unless you show them." + ), + ) + def render_lfq_tabs(self): st.subheader("LFQ Analysis Mode") t = st.tabs(["**Identification**", "**Rescoring**", "**Filtering**", "**Library Generation**", "**Quantification**", "**Group Selection**"]) @@ -185,6 +201,7 @@ def render_lfq_tabs(self): help="Generate decoy sequences for FDR calculation. Switched off automatically when the selected FASTA already contains decoys (e.g. DECOY_ or rev_ accessions).", reactive=True, ) + self.render_contaminant_option() # Reload params to get current checkbox value after it was saved self.params = self.parameter_manager.get_parameters_from_json() @@ -447,6 +464,7 @@ def render_tmt_tabs(self): help="Generate decoy sequences for FDR calculation. Switched off automatically when the selected FASTA already contains decoys (e.g. DECOY_ or rev_ accessions).", reactive=True, ) + self.render_contaminant_option() # Reload params to get current checkbox value after it was saved self.params = self.parameter_manager.get_parameters_from_json() @@ -744,6 +762,69 @@ def _has_identifications(self, idxml_files: list, stage: str) -> bool: self.logger.log("Workflow stopped due to error") return False + def prepare_search_database(self, fasta_path: Path): + """Build the database to search: the FASTA, the cRAP contaminants and decoys. + + Returns ``(database_fasta, decoy_string)``, or None when a TOPP tool failed. + """ + # Contaminants (cRAP with CON__ accessions) are searched with the targets + add_contaminants = self.params.get("add-contaminants", True) + db_dir = Path(self.workflow_dir, "results", "database") + con_fasta = db_dir / "contaminants.fasta" + if add_contaminants: + n_con = write_contaminants(con_fasta) + self.logger.log(f"๐Ÿงซ Adding {n_con} contaminant proteins (cRAP, {CONTAMINANT_PREFIX} accessions)") + + if self.params.get("generate-decoys", True): + # Get decoy_string from DecoyDatabase params + decoy_string = self.params.get("DecoyDatabase", {}).get("decoy_string", "rev_") + if add_contaminants: + # DecoyDatabase reads both files, so the contaminants get decoys too + decoy_fasta = db_dir / f"{fasta_path.stem}.con.decoy.fasta" + decoy_inputs = [str(fasta_path), str(con_fasta)] + stale = decoy_fasta.exists() and decoy_fasta.stat().st_mtime < fasta_path.stat().st_mtime + else: + decoy_fasta = fasta_path.with_suffix(".decoy.fasta") + decoy_inputs = [str(fasta_path)] + stale = False + + if not decoy_fasta.exists() or stale: + self.logger.log("๐Ÿงฌ Generating decoy database...") + st.info("Generating decoy FASTA database...") + db_dir.mkdir(parents=True, exist_ok=True) + if not self.executor.run_topp( + "DecoyDatabase", + {"in": [decoy_inputs], "out": [str(decoy_fasta)]}, + ): + self.logger.log("Workflow stopped due to error") + return None + self.logger.log("โœ… Decoy database ready") + st.success(f"Using decoy FASTA: {decoy_fasta.name}") + database_fasta = decoy_fasta + else: + # Get decoy_string from the active mode's CometAdapter params + comet_instance = "CometAdapter-TMT" if self.params.get("analysis-mode", "LFQ") == "TMT" else "CometAdapter" + decoy_string = self.params.get(comet_instance, {}).get("PeptideIndexing:decoy_string", "rev_") + self.logger.log("๐Ÿ“„ Using existing FASTA database") + st.info(f"Using original FASTA: {fasta_path.name}") + database_fasta = fasta_path + if add_contaminants: + # The FASTA has its own decoys; give the contaminants decoys in the same style. + # Own instance name, so stored DecoyDatabase settings don't repeat these options. + con_decoy_fasta = db_dir / "contaminants.decoy.fasta" + if not self.executor.run_topp( + "DecoyDatabase", + {"in": [str(con_fasta)], "out": [str(con_decoy_fasta)]}, + {"decoy_string": decoy_string, "decoy_string_position": "prefix"}, + tool_instance_name="DecoyDatabase-contaminants", + ): + self.logger.log("Workflow stopped due to error") + return None + database_fasta = db_dir / f"{fasta_path.stem}.con.fasta" + append_fasta([fasta_path, con_decoy_fasta], database_fasta) + st.info(f"Added contaminants: {database_fasta.name}") + return database_fasta, decoy_string + def execution(self) -> bool: """ Refactored TOPP workflow execution: @@ -772,31 +853,11 @@ def execution(self) -> bool: self.logger.log(f"๐Ÿ“‚ Loaded {len(in_mzML)} sample(s)") - if self.params.get("generate-decoys", True): - decoy_fasta = fasta_path.with_suffix(".decoy.fasta") - # Get decoy_string from DecoyDatabase params - decoy_string = self.params.get("DecoyDatabase", {}).get("decoy_string", "rev_") + prepared = self.prepare_search_database(fasta_path) + if prepared is None: + return False + database_fasta, decoy_string = prepared - if not decoy_fasta.exists(): - self.logger.log("๐Ÿงฌ Generating decoy database...") - st.info("Generating decoy FASTA database...") - if not self.executor.run_topp( - "DecoyDatabase", - {"in": [str(fasta_path)], "out": [str(decoy_fasta)]}, - ): - self.logger.log("Workflow stopped due to error") - return False - self.logger.log("โœ… Decoy database ready") - st.success(f"Using decoy FASTA: {decoy_fasta.name}") - database_fasta = decoy_fasta - else: - # Get decoy_string from the active mode's CometAdapter params - comet_instance = "CometAdapter-TMT" if self.params.get("analysis-mode", "LFQ") == "TMT" else "CometAdapter" - decoy_string = self.params.get(comet_instance, {}).get("PeptideIndexing:decoy_string", "rev_") - self.logger.log("๐Ÿ“„ Using existing FASTA database") - st.info(f"Using original FASTA: {fasta_path.name}") - database_fasta = fasta_path - current_mode = self.params.get("analysis-mode", "LFQ") st.write(f"Current analysis mode: **{current_mode}**") @@ -904,6 +965,7 @@ def execution(self) -> bool: {"field": "rt", "title": "RT", "sorter": "number"}, {"field": "score", "title": "Score", "sorter": "number"}, {"field": "protein_accession", "title": "Proteins"}, + {"field": "contaminant", "title": "Contaminant", "formatter": "tickCross", "sorter": "boolean"}, ], initial_sort=[{"column": "score", "dir": "asc"}], index_field="id_idx", @@ -1004,6 +1066,7 @@ def execution(self) -> bool: {"field": "rt", "title": "RT", "sorter": "number"}, {"field": "score", "title": "Score", "sorter": "number"}, {"field": "protein_accession", "title": "Proteins"}, + {"field": "contaminant", "title": "Contaminant", "formatter": "tickCross", "sorter": "boolean"}, ], initial_sort=[{"column": "score", "dir": "asc"}], index_field="id_idx", @@ -1097,6 +1160,7 @@ def execution(self) -> bool: {"field": "rt", "title": "RT", "sorter": "number"}, {"field": "score", "title": "Score", "sorter": "number"}, {"field": "protein_accession", "title": "Proteins"}, + {"field": "contaminant", "title": "Contaminant", "formatter": "tickCross", "sorter": "boolean"}, ], initial_sort=[{"column": "score", "dir": "asc"}], index_field="id_idx", @@ -1417,6 +1481,7 @@ def execution(self) -> bool: {"field": "rt", "title": "RT", "sorter": "number"}, {"field": "score", "title": "Score", "sorter": "number"}, {"field": "protein_accession", "title": "Proteins"}, + {"field": "contaminant", "title": "Contaminant", "formatter": "tickCross", "sorter": "boolean"}, ], initial_sort=[{"column": "score", "dir": "asc"}], index_field="id_idx", @@ -1513,6 +1578,7 @@ def execution(self) -> bool: {"field": "rt", "title": "RT", "sorter": "number"}, {"field": "score", "title": "Score", "sorter": "number"}, {"field": "protein_accession", "title": "Proteins"}, + {"field": "contaminant", "title": "Contaminant", "formatter": "tickCross", "sorter": "boolean"}, ], initial_sort=[{"column": "score", "dir": "asc"}], index_field="id_idx", @@ -1603,6 +1669,7 @@ def execution(self) -> bool: {"field": "rt", "title": "RT", "sorter": "number"}, {"field": "score", "title": "Score", "sorter": "number"}, {"field": "protein_accession", "title": "Proteins"}, + {"field": "contaminant", "title": "Contaminant", "formatter": "tickCross", "sorter": "boolean"}, ], initial_sort=[{"column": "score", "dir": "asc"}], index_field="id_idx", diff --git a/src/common/contaminants.py b/src/common/contaminants.py new file mode 100644 index 0000000..5b7ccc1 --- /dev/null +++ b/src/common/contaminants.py @@ -0,0 +1,71 @@ +"""Common contaminants (cRAP) for the search database. + +The workflow appends the cRAP proteins to the user's FASTA with ``CON__`` before +their accessions, so every protein and PSM table can flag them, and the +protein-level results and downstream analysis leave them out by default. +""" +from pathlib import Path + +import pandas as pd +from pyopenms import FASTAFile + +# Accession prefix of the contaminants appended to the database (as MaxQuant and ProSE) +CONTAMINANT_PREFIX = "CON__" + +# The common Repository of Adventitious Proteins (cRAP, thegpm.org/crap), 116 entries +CRAP_FASTA = Path("example-data", "db", "contaminants", "crap.fasta") + +# Separators between the accessions of a protein group or of a PSM's proteins +_ACCESSION_SEPARATORS = (";", ",", "/") + + +def write_contaminants(out_path, crap_path=CRAP_FASTA) -> int: + """Write the cRAP entries with ``CON__`` accessions to ``out_path``. + + Entries that already carry the prefix keep it. Returns the number of entries. + """ + entries = [] + FASTAFile().load(str(crap_path), entries) + for entry in entries: + if not entry.identifier.startswith(CONTAMINANT_PREFIX): + entry.identifier = CONTAMINANT_PREFIX + entry.identifier + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + FASTAFile().store(str(out_path), entries) + return len(entries) + + +def append_fasta(parts, out_path) -> None: + """Write the entries of the FASTA files ``parts``, in order, to ``out_path``.""" + entries = [] + for part in parts: + part_entries = [] + FASTAFile().load(str(part), part_entries) + entries.extend(part_entries) + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + FASTAFile().store(str(out_path), entries) + + +def is_contaminant(accessions) -> bool: + """Whether a protein, protein group or PSM maps to a contaminant. + + ``accessions`` is one accession or several joined by ``;``, ``,`` or ``/`` + (ProteinQuantifier groups). A group counts as a contaminant when any of its + members is one, as in MaxQuant. + """ + if not isinstance(accessions, str) or not accessions: + return False + for sep in _ACCESSION_SEPARATORS[1:]: + accessions = accessions.replace(sep, _ACCESSION_SEPARATORS[0]) + return any(a.strip().startswith(CONTAMINANT_PREFIX) for a in accessions.split(_ACCESSION_SEPARATORS[0])) + + +def contaminant_mask(accessions: pd.Series) -> pd.Series: + """Boolean Series: True where the row's accessions include a contaminant.""" + return accessions.map(is_contaminant).astype(bool) + + +def drop_contaminants(df: pd.DataFrame, column: str) -> pd.DataFrame: + """``df`` without the rows whose ``column`` names a contaminant.""" + if df is None or column not in df.columns: + return df + return df[~contaminant_mask(df[column])] diff --git a/src/common/results_helpers.py b/src/common/results_helpers.py index c434b23..ce7df01 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.contaminants import drop_contaminants, is_contaminant def get_workflow_dir(workspace): """Get the workflow directory path.""" @@ -141,6 +142,7 @@ def extract_filename_from_idxml(idxml_path: Path, mzml_names=None) -> str: "rt": pl.Float64, "score": pl.Float64, "protein_accession": pl.Utf8, + "contaminant": pl.Boolean, } @@ -179,6 +181,7 @@ def parse_idxml(idxml_path: Path, filename_to_index: dict | None = None) -> tupl filename = spectra_data[0] for h in pep.getHits(): + accessions = ";".join([ev.getProteinAccession() for ev in h.getPeptideEvidences()]) records.append({ "id_idx": len(records), "scan_id": scan_id, @@ -189,7 +192,8 @@ def parse_idxml(idxml_path: Path, filename_to_index: dict | None = None) -> tupl "mz": pep.getMZ(), "rt": pep.getRT(), "score": h.getScore(), - "protein_accession": ";".join([ev.getProteinAccession() for ev in h.getPeptideEvidences()]), + "protein_accession": accessions, + "contaminant": is_contaminant(accessions), }) # Keep the columns when nothing was identified, so callers selecting @@ -441,11 +445,13 @@ def load_abundance_data(workspace_path: str, csv_mtime: float, params_mtime: flo return pivot_df, expr_df, group_map -def get_abundance_data(workspace: Path) -> tuple | None: +def get_abundance_data(workspace: Path, include_contaminants: bool = False) -> tuple | None: """Wrapper that handles cache key (workspace + CSV mtime + params mtime). Args: workspace: Path to the workspace directory + include_contaminants: Keep the proteins with CON__ accessions (cRAP). + They are left out by default, so downstream analysis never sees them. Returns: Tuple of (pivot_df, expr_df, group_map) or None if data unavailable @@ -465,7 +471,16 @@ def get_abundance_data(workspace: Path) -> tuple | None: params_file = workflow_dir / "params.json" params_mtime = params_file.stat().st_mtime if params_file.exists() else 0.0 - return load_abundance_data(str(workspace), csv_mtime, params_mtime) + result = load_abundance_data(str(workspace), csv_mtime, params_mtime) + if result is None or include_contaminants: + return result + pivot_df, expr_df, group_map = result + id_col = pivot_df.columns[0] + return ( + drop_contaminants(pivot_df, id_col), + expr_df[~expr_df.index.to_series().map(is_contaminant).to_numpy(dtype=bool)], + group_map, + ) def get_id_column(workspace: Path, pivot_df: pd.DataFrame) -> str: diff --git a/tests/test_contaminants.py b/tests/test_contaminants.py new file mode 100644 index 0000000..12dfd3d --- /dev/null +++ b/tests/test_contaminants.py @@ -0,0 +1,164 @@ +"""Tests for the cRAP contaminants: database preparation and flagging in results.""" +import json +from pathlib import Path +from types import SimpleNamespace + +import pandas as pd +import pyopenms as poms + +from src.common.contaminants import ( + CONTAMINANT_PREFIX, + CRAP_FASTA, + append_fasta, + contaminant_mask, + drop_contaminants, + is_contaminant, + write_contaminants, +) + + +def _fasta(path, entries): + out = [] + for identifier, sequence in entries: + e = poms.FASTAEntry() + e.identifier = identifier + e.sequence = sequence + out.append(e) + poms.FASTAFile().store(str(path), out) + + +def _ids(path): + entries = [] + poms.FASTAFile().load(str(path), entries) + return [e.identifier for e in entries] + + +def test_write_contaminants_prefixes_every_crap_entry(tmp_path): + out = tmp_path / "con.fasta" + n = write_contaminants(out) + ids = _ids(out) + assert n == len(ids) == len(_ids(CRAP_FASTA)) == 116 + assert all(i.startswith(CONTAMINANT_PREFIX) for i in ids) + # An already prefixed entry is not prefixed twice + write_contaminants(tmp_path / "again.fasta", crap_path=out) + assert _ids(tmp_path / "again.fasta") == ids + + +def test_append_fasta_keeps_order(tmp_path): + _fasta(tmp_path / "a.fasta", [("sp|P1|A", "PEPTIDEK")]) + _fasta(tmp_path / "b.fasta", [("CON__sp|K1|B", "ELVISK"), ("rev_CON__sp|K1|B", "KSIVLE")]) + append_fasta([tmp_path / "a.fasta", tmp_path / "b.fasta"], tmp_path / "out" / "ab.fasta") + assert _ids(tmp_path / "out" / "ab.fasta") == ["sp|P1|A", "CON__sp|K1|B", "rev_CON__sp|K1|B"] + + +def test_is_contaminant(): + assert is_contaminant("CON__sp|ALBU_BOVIN|") + assert is_contaminant("sp|P1|A;CON__sp|K2C1_HUMAN|") + assert is_contaminant("sp|P1|A,CON__X") + assert is_contaminant("sp|P1|A/CON__X") + assert not is_contaminant("sp|P1|A") + assert not is_contaminant("rev_CON__X") # a decoy, not a contaminant + assert not is_contaminant(None) + assert not is_contaminant(float("nan")) + mask = contaminant_mask(pd.Series(["CON__X", "P1", None])) + assert mask.tolist() == [True, False, False] + df = pd.DataFrame({"ProteinName": ["CON__X", "P1"], "s1": [1.0, 2.0]}) + assert drop_contaminants(df, "ProteinName")["ProteinName"].tolist() == ["P1"] + + +def _workflow(tmp_path, params): + """A WorkflowTest with only what prepare_search_database touches.""" + from src.WorkflowTest import WorkflowTest + + calls = [] + + def run_topp(tool, io, custom_params={}, tool_instance_name=None): + calls.append((tool, io, custom_params, tool_instance_name)) + # Stand in for DecoyDatabase: the targets, then each entry reversed with the decoy string + decoy = custom_params.get("decoy_string", "rev_") + inputs = io["in"][0] if isinstance(io["in"][0], list) else io["in"] + entries = [] + for part in inputs: + part_entries = [] + poms.FASTAFile().load(str(part), part_entries) + entries.extend(part_entries) + decoys = [] + for e in entries: + d = poms.FASTAEntry() + d.identifier = decoy + e.identifier + d.sequence = e.sequence[::-1] + decoys.append(d) + poms.FASTAFile().store(io["out"][0], entries + decoys) + return True + + wf = object.__new__(WorkflowTest) + wf.params = params + wf.workflow_dir = tmp_path / "topp-workflow" + wf.logger = SimpleNamespace(log=lambda *a, **k: None) + wf.executor = SimpleNamespace(run_topp=run_topp) + return wf, calls + + +def test_generated_decoys_cover_the_contaminants(tmp_path): + fasta = tmp_path / "db.fasta" + _fasta(fasta, [("sp|P1|A", "PEPTIDEK")]) + wf, calls = _workflow(tmp_path, {"generate-decoys": True}) + + database, decoy_string = wf.prepare_search_database(fasta) + + assert decoy_string == "rev_" + assert len(calls) == 1 and len(calls[0][1]["in"][0]) == 2 # FASTA and contaminants in one call + ids = _ids(database) + assert "sp|P1|A" in ids and "rev_sp|P1|A" in ids + assert sum(i.startswith(CONTAMINANT_PREFIX) for i in ids) == 116 + assert sum(i.startswith("rev_" + CONTAMINANT_PREFIX) for i in ids) == 116 + # The user's FASTA is left alone + assert _ids(fasta) == ["sp|P1|A"] + + +def test_fasta_with_decoys_gets_contaminants_in_its_decoy_style(tmp_path): + fasta = tmp_path / "db.fasta" + _fasta(fasta, [("sp|P1|A", "PEPTIDEK"), ("DECOY_sp|P1|A", "KEDITPEP")]) + params = {"generate-decoys": False, "CometAdapter": {"PeptideIndexing:decoy_string": "DECOY_"}} + wf, calls = _workflow(tmp_path, params) + + database, decoy_string = wf.prepare_search_database(fasta) + + assert decoy_string == "DECOY_" + assert calls[0][2]["decoy_string"] == "DECOY_" + assert calls[0][3] == "DecoyDatabase-contaminants" + ids = _ids(database) + assert ids[:2] == ["sp|P1|A", "DECOY_sp|P1|A"] + assert sum(i.startswith("DECOY_" + CONTAMINANT_PREFIX) for i in ids) == 116 + + +def test_contaminants_can_be_switched_off(tmp_path): + fasta = tmp_path / "db.fasta" + _fasta(fasta, [("sp|P1|A", "PEPTIDEK"), ("rev_sp|P1|A", "KEDITPEP")]) + wf, calls = _workflow(tmp_path, {"generate-decoys": False, "add-contaminants": False}) + + database, _ = wf.prepare_search_database(fasta) + + assert database == fasta and calls == [] + + +def test_abundance_data_leaves_contaminants_out_by_default(tmp_path): + from src.common.results_helpers import get_abundance_data + + workflow_dir = tmp_path / "topp-workflow" + quant_dir = workflow_dir / "results" / "quant_results" + quant_dir.mkdir(parents=True) + (workflow_dir / "params.json").write_text(json.dumps({"analysis-mode": "LFQ"})) + rows = [] + for protein in ("sp|P1|A", "CON__sp|ALBU_BOVIN|", "sp|P2|B;CON__sp|TRYP_PIG|"): + for sample in ("s1", "s2"): + rows.append({"ProteinName": protein, "PeptideSequence": "PEPTIDEK", + "Reference": f"{sample}.mzML", "Intensity": 1000.0}) + pd.DataFrame(rows).to_csv(quant_dir / "quant.csv", index=False) + + pivot_df, expr_df, _ = get_abundance_data(Path(tmp_path)) + assert pivot_df["ProteinName"].tolist() == ["sp|P1|A"] + assert expr_df.index.tolist() == ["sp|P1|A"] + + pivot_all, expr_all, _ = get_abundance_data(Path(tmp_path), include_contaminants=True) + assert len(pivot_all) == len(expr_all) == 3 From 9b7b41aa9cbf069978c7232370cd0586a6e8bed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 06:03:52 +0000 Subject: [PATCH 05/18] Add a Run Overview page: key figures and a per-file summary Workflow Results > Overview shows key figures for the last run (quantified proteins, peptides, target PSMs at the PSM FDR, PSMs per MS2 spectrum, median precursor error, files, samples, conditions). Below them, a per-file table lists MS2 spectra, target and decoy PSMs, ID rate, peptides, median precursor mass error, whether Percolator rescored the file, and CometAdapter's search time. src/common/overview.py reads only what the run already wrote: the input mzML files (MS2 spectra counted without peak data), the Percolator and IDFilter idXMLs, and the run-time log. The page caches it on file mtimes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- app.py | 1 + content/results_overview.py | 110 +++++++++++++++++++++++++ src/common/overview.py | 158 ++++++++++++++++++++++++++++++++++++ tests/test_overview.py | 117 ++++++++++++++++++++++++++ 4 files changed, 386 insertions(+) create mode 100644 content/results_overview.py create mode 100644 src/common/overview.py create mode 100644 tests/test_overview.py diff --git a/app.py b/app.py index 3e3b047..6cd19d0 100644 --- a/app.py +++ b/app.py @@ -26,6 +26,7 @@ st.Page(Path("content", "workflow_run.py"), title="Run", icon="๐Ÿš€"), ], "Workflow Results": [ + st.Page(Path("content", "results_overview.py"), title="Overview", icon="๐Ÿงพ"), st.Page(Path("content", "results_database_search.py"), title="Database Search", icon="๐Ÿ”ฌ"), st.Page(Path("content", "results_rescoring.py"), title="Rescoring", icon="๐Ÿ“ˆ"), st.Page(Path("content", "results_filtered.py"), title="Filtered PSMs", icon="๐ŸŽฏ"), diff --git a/content/results_overview.py b/content/results_overview.py new file mode 100644 index 0000000..d634631 --- /dev/null +++ b/content/results_overview.py @@ -0,0 +1,110 @@ +"""Run Overview Page: key figures and a per-file summary of the last run.""" +from pathlib import Path + +import streamlit as st + +from src.common.common import page_setup +from src.common.insight_tables import show_insight_table +from src.common.overview import run_overview +from src.common.results_helpers import get_abundance_data, get_workflow_dir +from src.workflow.ParameterManager import ParameterManager + +params = page_setup() +st.title("Run Overview") + +st.markdown( + """ +Key figures of the last run and how each file did: MS2 spectra, identified PSMs +at the PSM FDR, peptides, precursor mass error, rescoring and search time. +""" +) + +if "workspace" not in st.session_state: + st.warning("Please initialize your workspace first.") + st.stop() + +workflow_dir = get_workflow_dir(st.session_state["workspace"]) +filter_dir = workflow_dir / "results" / "psm_filter" +cache_dir = workflow_dir / "results" / "insight_cache" +workflow_params = ParameterManager(workflow_dir, "TOPP Workflow").get_parameters_from_json() +mzml_files = [str(f) for f in workflow_params.get("mzML-files", [])] + +if not filter_dir.is_dir() or not any(filter_dir.glob("*.idXML")): + st.info("No results available yet. Please run the workflow first.") + st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") + st.stop() + +log_file = workflow_dir / "logs" / "commands-and-run-times.log" +sources = sorted(filter_dir.glob("*.idXML")) + [log_file] + + +def _mtimes(paths) -> tuple: + return tuple(Path(p).stat().st_mtime if Path(p).exists() else 0.0 for p in paths) + + +@st.cache_data(show_spinner="Summarizing the run...") +def _overview(workflow_dir: str, files: tuple, mtimes: tuple): + """run_overview, cached on the inputs' and results' modification times.""" + return run_overview(workflow_dir, list(files)) + + +per_file, figures = _overview( + str(workflow_dir), tuple(mzml_files), _mtimes(mzml_files + [str(p) for p in sources]) +) + +abundance = get_abundance_data(st.session_state["workspace"]) +proteins = samples = conditions = None +if abundance is not None: + pivot_df, expr_df, group_map = abundance + proteins = len(pivot_df) + samples = len(expr_df.columns) + conditions = len({str(g) for g in group_map.values() if str(g).strip()}) or None + +fdr = float(workflow_params.get("psm-fdr-percent", 1.0)) + + +def _show(value, unit: str = "") -> str: + return "โ€“" if value is None else f"{value:,}{unit}" + + +st.subheader("Key figures") +row1 = st.columns(4) +row1[0].metric("Quantified proteins", _show(proteins), help="Proteins in the quantification result.") +row1[1].metric("Peptides", _show(figures["Peptides"]), help=f"Distinct target peptide sequences at {fdr:g} % PSM FDR, over all files.") +row1[2].metric("Target PSMs", _show(figures["Target PSMs"]), help=f"Target PSMs at {fdr:g} % PSM FDR, over all files.") +row1[3].metric("PSMs per MS2 spectrum", _show(figures["PSMs per MS2 (%)"], " %"), help="Target PSMs divided by the MS2 spectra of all files.") +row2 = st.columns(4) +row2[0].metric( + "Median precursor error", + _show(figures["Median precursor error (ppm)"], " ppm"), + help="Median m/z error of the target PSMs, after correcting picks of a 13C isotope peak.", +) +row2[1].metric("Files", _show(figures["Files"])) +row2[2].metric("Samples", _show(samples), help="Sample columns of the quantification result.") +row2[3].metric("Conditions", _show(conditions), help="Distinct groups set on the Configure page.") + +st.subheader("Files") +st.caption( + f"PSM counts are at {fdr:g} % PSM FDR. ID rate is target PSMs per MS2 spectrum. Mass error is " + "the median precursor m/z error of the file's target PSMs. Percolator is whether it rescored " + "the file. Search is CometAdapter's run time in seconds in the last run." +) +# One row per file; the fractional columns go as text, since the table stores +# floats as float32 and would show 36.8 as 36.79999923706055 +per_file_view = per_file.copy() +for col in ("ID rate (%)", "Mass error (ppm)"): + per_file_view[col] = per_file_view[col].map(lambda v: "" if v is None or v != v else f"{v:g}") +show_insight_table( + per_file_view, + name="overview_files", + cache_dir=cache_dir, + source_files=sources, + title="run_overview", + height=300, + # Share the page width between the columns rather than folding the last ones away + layout="fitColumns", +) + +st.markdown("---") +st.markdown("**Next step:** Look at the identifications") +st.page_link("content/results_filtered.py", label="Go to Filtered PSMs", icon="๐ŸŽฏ") diff --git a/src/common/overview.py b/src/common/overview.py new file mode 100644 index 0000000..e9b0dc2 --- /dev/null +++ b/src/common/overview.py @@ -0,0 +1,158 @@ +"""Run overview: key figures and a per-file table of a finished workflow run. + +Reads only what the workflow already wrote: the input mzML files (MS2 spectra +are counted without loading peaks), the Percolator and IDFilter idXMLs, and the +run-time log. It makes no Streamlit calls, so the page caches it and tests call it. +""" +import re +from pathlib import Path + +import numpy as np +import pandas as pd +from pyopenms import AASequence, MSExperiment, MzMLFile, PeakFileOptions + +from src.common.results_helpers import extract_filename_from_idxml, load_idxml + +# Mass difference between the 13C and 12C isotopes; precursor picks up to +# three isotope peaks off the monoisotope are corrected before the error is taken +C13_DELTA = 1.0033548378 +MAX_ISOTOPE_ERROR = 3 + +PER_FILE_COLUMNS = [ + "File", + "MS2 spectra", + "Target PSMs", + "Decoy PSMs", + "ID rate (%)", + "Peptides", + "Mass error (ppm)", + "Percolator", + "Search (s)", +] + + +def count_ms2_spectra(mzml_path) -> int | None: + """Number of MS2 spectra in an mzML, read without peak data; None if unreadable.""" + try: + options = PeakFileOptions() + options.setMSLevels([2]) + options.setFillData(False) + mzml = MzMLFile() + mzml.setOptions(options) + exp = MSExperiment() + mzml.load(str(mzml_path), exp) + return exp.getNrSpectra() + except Exception: # missing or unreadable file: shown as empty, not an error + return None + + +def precursor_error_ppm(sequence: AASequence, charge: int, observed_mz: float) -> float | None: + """Precursor m/z error in ppm, after correcting a 13C isotope pick of up to three peaks.""" + if charge <= 0: + return None + theoretical = sequence.getMZ(charge) + errors = [ + (observed_mz - k * C13_DELTA / charge - theoretical) / theoretical * 1e6 + for k in range(MAX_ISOTOPE_ERROR + 1) + ] + return min(errors, key=abs) + + +def read_psms(idxml_path) -> pd.DataFrame: + """Top hit of every spectrum in an idXML: sequence, decoy flag and precursor error.""" + _, peptides = load_idxml(idxml_path) + rows = [] + for pep in peptides: + hits = pep.getHits() + if not hits: + continue + hit = hits[0] + target_decoy = hit.getMetaValue("target_decoy") if hit.metaValueExists("target_decoy") else "" + if isinstance(target_decoy, bytes): + target_decoy = target_decoy.decode() + sequence = hit.getSequence() + rows.append({ + "sequence": sequence.toString(), + "decoy": str(target_decoy).startswith("decoy"), + "ppm": precursor_error_ppm(sequence, hit.getCharge(), pep.getMZ()), + }) + return pd.DataFrame(rows, columns=["sequence", "decoy", "ppm"]) + + +def search_times(log_path, mzml_files) -> dict: + """Seconds CometAdapter took per input file, from the latest run in the run-time log.""" + path = Path(log_path) + if not path.is_file(): + return {} + text = path.read_text(encoding="utf-8", errors="ignore") + times = {} + blocks = re.findall( + r"Process finished:\n(CometAdapter\b.*?)\nTotal time to run command: ([\d.]+) seconds", + text, + flags=re.DOTALL, + ) + for command, seconds in blocks: + match = re.search(r"\s-in\s+(\S+)", command) + if match: + # Later runs append to the log, so the last block per file wins + times[Path(match.group(1)).name] = float(seconds) + return {Path(f).name: times[Path(f).name] for f in mzml_files if Path(f).name in times} + + +def _by_input(directory: Path, names: list) -> dict: + """idXMLs in ``directory`` keyed by the input mzML file name they came from.""" + if not directory.is_dir(): + return {} + return {extract_filename_from_idxml(p, names): p for p in sorted(directory.glob("*.idXML"))} + + +def run_overview(workflow_dir, mzml_files) -> tuple[pd.DataFrame, dict]: + """Per-file table (PER_FILE_COLUMNS) and run-wide key figures of a finished run. + + PSM counts are at the PSM FDR set for the run (the IDFilter output). Files + the run did not reach show empty cells. + """ + workflow_dir = Path(workflow_dir) + results = workflow_dir / "results" + names = [Path(f).name for f in mzml_files] + filtered = _by_input(results / "psm_filter", names) + rescored = _by_input(results / "percolator_results", names) + times = search_times(workflow_dir / "logs" / "commands-and-run-times.log", mzml_files) + + rows = [] + all_psms = [] + for mzml in mzml_files: + name = Path(mzml).name + ms2 = count_ms2_spectra(mzml) + psms = read_psms(filtered[name]) if name in filtered else None + targets = psms[~psms["decoy"]] if psms is not None else None + if targets is not None: + all_psms.append(targets) + rows.append({ + "File": name, + "MS2 spectra": ms2, + "Target PSMs": len(targets) if targets is not None else None, + "Decoy PSMs": int(psms["decoy"].sum()) if psms is not None else None, + "ID rate (%)": round(100 * len(targets) / ms2, 1) if targets is not None and ms2 else None, + "Peptides": targets["sequence"].nunique() if targets is not None else None, + "Mass error (ppm)": ( + round(float(targets["ppm"].median()), 2) + if targets is not None and targets["ppm"].notna().any() else None + ), + "Percolator": name in rescored, + "Search (s)": round(times[name]) if name in times else None, + }) + per_file = pd.DataFrame(rows, columns=PER_FILE_COLUMNS) + + targets = pd.concat(all_psms) if all_psms else pd.DataFrame(columns=["sequence", "decoy", "ppm"]) + ms2_total = per_file["MS2 spectra"].dropna().sum() if per_file["MS2 spectra"].notna().any() else None + key_figures = { + "Target PSMs": len(targets), + "Peptides": targets["sequence"].nunique(), + "PSMs per MS2 (%)": round(100 * len(targets) / ms2_total, 1) if ms2_total else None, + "Median precursor error (ppm)": ( + round(float(np.nanmedian(targets["ppm"].astype(float))), 2) if targets["ppm"].notna().any() else None + ), + "Files": len(mzml_files), + } + return per_file, key_figures diff --git a/tests/test_overview.py b/tests/test_overview.py new file mode 100644 index 0000000..27087ac --- /dev/null +++ b/tests/test_overview.py @@ -0,0 +1,117 @@ +"""Tests for the run overview (key figures and per-file table).""" +import pandas as pd +import pyopenms as poms +import pytest + +# src.common.overview is imported inside the tests, not at collection: importing +# it pulls in src.workflow.ParameterManager with the real streamlit, and +# test_parameter_defaults (collected later) needs to import it with a mock. + + +def _mzml(path, n_ms1=2, n_ms2=5): + exp = poms.MSExperiment() + for level, n in ((1, n_ms1), (2, n_ms2)): + for i in range(n): + spec = poms.MSSpectrum() + spec.setMSLevel(level) + spec.setRT(float(i)) + spec.set_peaks(([100.0, 200.0], [1.0, 2.0])) + exp.addSpectrum(spec) + poms.MzMLFile().store(str(path), exp) + + +def _idxml(path, hits): + """hits: (sequence, charge, observed m/z, "target" or "decoy").""" + prot = poms.ProteinIdentification() + prot.setIdentifier("run") + peptides = [] + for seq, charge, mz, td in hits: + pep = poms.PeptideIdentification() + pep.setIdentifier("run") + pep.setMZ(mz) + hit = poms.PeptideHit() + hit.setSequence(poms.AASequence.fromString(seq)) + hit.setCharge(charge) + hit.setMetaValue("target_decoy", td) + pep.setHits([hit]) + peptides.append(pep) + if hasattr(poms, "PeptideIdentificationList"): + container = poms.PeptideIdentificationList() + for pep in peptides: + container.push_back(pep) + peptides = container + path.parent.mkdir(parents=True, exist_ok=True) + poms.IdXMLFile().store(str(path), [prot], peptides) + + +def _mz(seq, charge, ppm=0.0, isotope=0): + theo = poms.AASequence.fromString(seq).getMZ(charge) + return theo * (1 + ppm / 1e6) + isotope * 1.0033548378 / charge + + +def test_count_ms2_spectra(tmp_path): + from src.common.overview import count_ms2_spectra + + _mzml(tmp_path / "a.mzML", n_ms1=3, n_ms2=7) + assert count_ms2_spectra(tmp_path / "a.mzML") == 7 + assert count_ms2_spectra(tmp_path / "missing.mzML") is None + + +def test_precursor_error_corrects_isotope_picks(): + from src.common.overview import precursor_error_ppm + + seq = poms.AASequence.fromString("PEPTIDEK") + assert precursor_error_ppm(seq, 2, _mz("PEPTIDEK", 2, ppm=3.0)) == pytest.approx(3.0, abs=0.01) + assert precursor_error_ppm(seq, 2, _mz("PEPTIDEK", 2, ppm=-2.0, isotope=1)) == pytest.approx(-2.0, abs=0.05) + assert precursor_error_ppm(seq, 0, 500.0) is None + + +def test_search_times_take_the_last_run(tmp_path): + from src.common.overview import search_times + + log = tmp_path / "commands-and-run-times.log" + log.write_text( + "Process finished:\nCometAdapter -in /data/a.mzML -out x.idXML\nTotal time to run command: 10.00 seconds\n\n" + "Process finished:\nPercolatorAdapter -in x.idXML\nTotal time to run command: 3.00 seconds\n\n" + "Process finished:\nCometAdapter -in /data/a.mzML -out x.idXML\nTotal time to run command: 12.50 seconds\n\n" + ) + assert search_times(log, ["/data/a.mzML", "/data/b.mzML"]) == {"a.mzML": 12.5} + assert search_times(tmp_path / "none.log", ["/data/a.mzML"]) == {} + + +def test_run_overview(tmp_path): + from src.common.overview import run_overview + + wf = tmp_path / "topp-workflow" + results = wf / "results" + a, b = tmp_path / "a.mzML", tmp_path / "b.mzML" + _mzml(a, n_ms2=4) + _mzml(b, n_ms2=10) + _idxml(results / "psm_filter" / "a_filter.idXML", [ + ("PEPTIDEK", 2, _mz("PEPTIDEK", 2, ppm=2.0), "target"), + ("PEPTIDEK", 3, _mz("PEPTIDEK", 3, ppm=4.0), "target"), + ("ELVISK", 2, _mz("ELVISK", 2, ppm=-1.0), "decoy"), + ]) + _idxml(results / "percolator_results" / "a_per.idXML", [("PEPTIDEK", 2, 500.0, "target")]) + (wf / "logs").mkdir(parents=True) + (wf / "logs" / "commands-and-run-times.log").write_text( + f"Process finished:\nCometAdapter -in {a} -out x\nTotal time to run command: 5.00 seconds\n\n" + ) + + per_file, figures = run_overview(wf, [str(a), str(b)]) + + row_a, row_b = per_file.to_dict("records") + assert row_a["MS2 spectra"] == 4 + assert row_a["Target PSMs"] == 2 and row_a["Decoy PSMs"] == 1 + assert row_a["Peptides"] == 1 + assert row_a["ID rate (%)"] == 50.0 + assert row_a["Mass error (ppm)"] == pytest.approx(3.0, abs=0.05) + assert row_a["Percolator"] and row_a["Search (s)"] == 5 + # A file the run did not reach shows empty cells, not zeros + assert row_b["MS2 spectra"] == 10 and pd.isna(row_b["Target PSMs"]) + assert not row_b["Percolator"] + + assert figures["Target PSMs"] == 2 + assert figures["Peptides"] == 1 + assert figures["PSMs per MS2 (%)"] == round(100 * 2 / 14, 1) + assert figures["Files"] == 2 From aed714c18967ee9236e9e40c2f02ec4fb3dd9f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 06:04:18 +0000 Subject: [PATCH 06/18] Add a Samples page: PSMs, peptides and proteins per sample, by condition Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- app.py | 1 + content/results_samples.py | 142 +++++++++++++++++++++++++++++++++++ src/common/samples.py | 147 +++++++++++++++++++++++++++++++++++++ tests/test_samples.py | 108 +++++++++++++++++++++++++++ 4 files changed, 398 insertions(+) create mode 100644 content/results_samples.py create mode 100644 src/common/samples.py create mode 100644 tests/test_samples.py diff --git a/app.py b/app.py index 3e3b047..c1abf04 100644 --- a/app.py +++ b/app.py @@ -30,6 +30,7 @@ st.Page(Path("content", "results_rescoring.py"), title="Rescoring", icon="๐Ÿ“ˆ"), st.Page(Path("content", "results_filtered.py"), title="Filtered PSMs", icon="๐ŸŽฏ"), st.Page(Path("content", "results_abundance.py"), title="Protein Abundance", icon="๐Ÿ“‹"), + st.Page(Path("content", "results_samples.py"), title="Samples", icon="๐Ÿงช"), st.Page(Path("content", "results_download.py"), title="Download", icon="โฌ‡๏ธ"), ], # Steps run in order; each one works on the previous step's output. diff --git a/content/results_samples.py b/content/results_samples.py new file mode 100644 index 0000000..4c0ffd4 --- /dev/null +++ b/content/results_samples.py @@ -0,0 +1,142 @@ +"""Samples Page: PSMs, peptides and proteins per sample, grouped by condition.""" +from pathlib import Path + +import plotly.express as px +import streamlit as st + +from src.common.common import page_setup, show_fig +from src.common.insight_tables import show_insight_table +from src.common.results_helpers import get_abundance_data, get_sample_group_map, get_workflow_dir +from src.common.samples import COUNT_COLUMNS, channel_samples, condition_summary, lfq_samples +from src.workflow.ParameterManager import ParameterManager + +params = page_setup() +st.title("Samples") + +st.markdown( + """ +How many PSMs, peptides and proteins each sample contributed, grouped by the +conditions set on the Configure page. A sample far below the others in its +condition points to a failed run or a loading problem. +""" +) + +if "workspace" not in st.session_state: + st.warning("Please initialize your workspace first.") + st.stop() + +workflow_dir = get_workflow_dir(st.session_state["workspace"]) +results_dir = workflow_dir / "results" +cache_dir = results_dir / "insight_cache" +workflow_params = ParameterManager(workflow_dir, "TOPP Workflow").get_parameters_from_json() +analysis_mode = workflow_params.get("analysis-mode", "LFQ") +fdr = float(workflow_params.get("psm-fdr-percent", 1.0)) + + +def _mtimes(paths) -> tuple: + return tuple(Path(p).stat().st_mtime if Path(p).exists() else 0.0 for p in paths) + + +@st.cache_data(show_spinner="Counting per sample...") +def _lfq_samples(workflow_dir: str, files: tuple, groups: tuple, mtimes: tuple): + """lfq_samples, cached on the group assignments and the results' modification times.""" + return lfq_samples(workflow_dir, list(files), dict(groups)) + + +if analysis_mode == "LFQ": + filter_dir = results_dir / "psm_filter" + if not filter_dir.is_dir() or not any(filter_dir.glob("*.idXML")): + st.info("No results available yet. Please run the workflow first.") + st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") + st.stop() + mzml_files = [str(f) for f in workflow_params.get("mzML-files", [])] + group_map = { + key[len("mzML-group-"):]: value + for key, value in workflow_params.items() + if key.startswith("mzML-group-") and value + } + sources = sorted(filter_dir.glob("*.idXML")) + sorted((results_dir / "quant_results").glob("*.csv")) + samples = _lfq_samples( + str(workflow_dir), tuple(mzml_files), tuple(sorted(group_map.items())), _mtimes(sources) + ) + st.caption( + f"PSMs, peptides and proteins are the targets identified at {fdr:g} % PSM FDR, from the " + "top hit of each spectrum. Quantified proteins and peptides have an intensity above 0 in " + "the sample's ProteomicsLFQ result." + ) +else: + abundance = get_abundance_data(st.session_state["workspace"]) + if abundance is None: + st.info("No quantification result available yet. Please run the workflow first.") + st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") + st.stop() + pivot_df, _, group_map = abundance + sample_group_map = get_sample_group_map(st.session_state["workspace"], pivot_df, group_map) + # The report's first columns are the protein and its description; the channels follow + sample_columns = [c for c in pivot_df.columns[4:] if c != "PeptideSequence"] + samples = channel_samples(pivot_df, sample_columns, sample_group_map) + sources = sorted((results_dir / "quant_results").glob("*.csv")) + st.caption( + "Each sample is an isobaric channel. Quantified proteins have an intensity above 0 in the " + "channel. Identifications are made per file, not per channel: see the Overview page." + ) + +if samples.empty: + st.info("No samples in the last run.") + st.stop() + +def _table_height(rows: int) -> int: + """Room for every row up to a page of 12, plus the header and pager.""" + return 140 + 34 * min(rows, 12) + + +counts = [c for c in COUNT_COLUMNS if samples[c].notna().any()] +group_key = "|".join(f"{s}={c}" for s, c in zip(samples["Sample"], samples["Condition"])) + +st.subheader("Per sample") +metric = st.selectbox("Count", counts, key="samples-metric") +fig = px.bar( + samples, + x="Sample", + y=metric, + color="Condition", + category_orders={"Sample": samples["Sample"].tolist()}, +) +# One full-width bar per sample: px gives each condition its own offset group, +# which would leave room beside every bar for the other conditions +fig.update_traces(offsetgroup=None, alignmentgroup=None) +fig.update_layout(xaxis_title=None, legend_title_text="Condition", bargap=0.2) +# Slant the sample names once they no longer fit side by side +label_chars = sum(len(s) for s in samples["Sample"]) +fig.update_xaxes(tickangle=-35 if label_chars > 90 else 0, automargin=True) +show_fig(fig, f"samples_{metric.lower().replace(' ', '_')}") + +show_insight_table( + samples[["Sample", "Condition"] + counts], + name="samples_per_sample", + cache_dir=cache_dir, + source_files=sources, + extra_key=group_key, + title="samples", + height=_table_height(len(samples)), + layout="fitColumns", +) + +st.subheader("Per condition") +summary = condition_summary(samples) +# Means go as text, since the table stores floats as float32 and would show 36.8 as 36.79999923706055 +for col in summary.columns[2:]: + summary[col] = summary[col].map(lambda v: "" if v != v else f"{v:g}") +show_insight_table( + summary, + name="samples_per_condition", + cache_dir=cache_dir, + source_files=sources, + extra_key=group_key, + title="conditions", + height=_table_height(len(summary)), + layout="fitColumns", +) +if (samples["Condition"] == "Unassigned").any(): + st.caption("Samples without a condition are shown as Unassigned. Set conditions on the Configure page.") + st.page_link("content/workflow_configure.py", label="Go to Configure", icon="โš™๏ธ") diff --git a/src/common/samples.py b/src/common/samples.py new file mode 100644 index 0000000..9ae1cc8 --- /dev/null +++ b/src/common/samples.py @@ -0,0 +1,147 @@ +"""Per-sample counts of a finished run: PSMs, peptides and proteins, by condition. + +Reads only what the workflow already wrote: the IDFilter idXMLs (identifications +at the run's PSM FDR) and the quantification result. It makes no Streamlit +calls, so the page caches it and tests call it. +""" +from pathlib import Path + +import pandas as pd + +from src.common.results_helpers import extract_filename_from_idxml, load_idxml + +UNASSIGNED = "Unassigned" + +SAMPLE_COLUMNS = [ + "Sample", + "Condition", + "Target PSMs", + "Peptides", + "Proteins", + "Quantified proteins", + "Quantified peptides", +] + +# Columns the bar chart can show, in the order the page offers them +COUNT_COLUMNS = SAMPLE_COLUMNS[2:] + + +def _is_decoy(hit) -> bool: + if not hit.metaValueExists("target_decoy"): + return False + value = hit.getMetaValue("target_decoy") + if isinstance(value, bytes): + value = value.decode() + return str(value).startswith("decoy") + + +def identification_counts(idxml_path) -> dict: + """Target PSMs, distinct peptides and distinct proteins of one idXML. + + Only the top hit of each spectrum counts, and decoy hits are left out. + Proteins are the accessions the target peptides map to. + """ + _, peptides = load_idxml(idxml_path) + psms = 0 + sequences, proteins = set(), set() + for pep in peptides: + hits = pep.getHits() + if not hits or _is_decoy(hits[0]): + continue + hit = hits[0] + psms += 1 + sequences.add(hit.getSequence().toString()) + proteins.update(ev.getProteinAccession() for ev in hit.getPeptideEvidences()) + return {"Target PSMs": psms, "Peptides": len(sequences), "Proteins": len(proteins)} + + +def lfq_quantified_counts(quant_csv) -> dict: + """Quantified proteins and peptides per input file name, from ProteomicsLFQ's CSV. + + A protein (group) or peptide counts for a file when its intensity there is above 0. + """ + try: + df = pd.read_csv(quant_csv, usecols=["ProteinName", "PeptideSequence", "Reference", "Intensity"]) + except (OSError, ValueError): + return {} + df = df[pd.to_numeric(df["Intensity"], errors="coerce") > 0] + counts = df.groupby("Reference").agg( + proteins=("ProteinName", "nunique"), peptides=("PeptideSequence", "nunique") + ) + return { + Path(str(ref)).name: {"Quantified proteins": int(row.proteins), "Quantified peptides": int(row.peptides)} + for ref, row in counts.iterrows() + } + + +def _condition(group_map: dict, *names) -> str: + for name in names: + value = group_map.get(name) + if value is not None and str(value).strip(): + return str(value).strip() + return UNASSIGNED + + +def _order(df: pd.DataFrame) -> pd.DataFrame: + """Samples grouped by condition (unassigned last), in their original order within one.""" + conditions = sorted(c for c in df["Condition"].unique() if c != UNASSIGNED) + if UNASSIGNED in set(df["Condition"]): + conditions.append(UNASSIGNED) + df = df.copy() + df["Condition"] = pd.Categorical(df["Condition"], categories=conditions, ordered=True) + df = df.sort_values("Condition", kind="stable").reset_index(drop=True) + df["Condition"] = df["Condition"].astype(str) + return df + + +def lfq_samples(workflow_dir, mzml_files, group_map: dict) -> pd.DataFrame: + """One row per input file (SAMPLE_COLUMNS); files the run did not reach show empty cells. + + ``group_map`` maps an input file name to its condition, as set on the Configure page. + """ + results = Path(workflow_dir) / "results" + names = [Path(f).name for f in mzml_files] + filter_dir = results / "psm_filter" + idxmls = {} + if filter_dir.is_dir(): + idxmls = {extract_filename_from_idxml(p, names): p for p in sorted(filter_dir.glob("*.idXML"))} + quant_csvs = sorted((results / "quant_results").glob("*.csv")) if (results / "quant_results").is_dir() else [] + quantified = lfq_quantified_counts(quant_csvs[0]) if quant_csvs else {} + + rows = [] + for name in names: + ids = identification_counts(idxmls[name]) if name in idxmls else {} + rows.append({ + "Sample": Path(name).stem, + "Condition": _condition(group_map, name, Path(name).stem), + **{col: ids.get(col) for col in ("Target PSMs", "Peptides", "Proteins")}, + **{col: quantified.get(name, {}).get(col) for col in ("Quantified proteins", "Quantified peptides")}, + }) + return _order(pd.DataFrame(rows, columns=SAMPLE_COLUMNS)) + + +def channel_samples(pivot_df: pd.DataFrame, sample_columns, sample_group_map: dict) -> pd.DataFrame: + """One row per isobaric channel: the proteins quantified in it (intensity above 0). + + Identifications are per file, not per channel, so those columns stay empty. + """ + rows = [] + for col in sample_columns: + values = pd.to_numeric(pivot_df[col], errors="coerce") + rows.append({ + "Sample": col, + "Condition": _condition(sample_group_map, col), + "Quantified proteins": int((values > 0).sum()), + }) + return _order(pd.DataFrame(rows, columns=SAMPLE_COLUMNS)) + + +def condition_summary(samples: pd.DataFrame) -> pd.DataFrame: + """Per condition: the number of samples and the mean of each count over them.""" + counts = [c for c in COUNT_COLUMNS if samples[c].notna().any()] + numeric = samples[["Condition"] + counts].copy() + numeric[counts] = numeric[counts].apply(pd.to_numeric, errors="coerce") + summary = numeric.groupby("Condition", sort=False).agg( + Samples=("Condition", "size"), **{f"Mean {c}": (c, "mean") for c in counts} + ) + return summary.round(1).reset_index() diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 0000000..8270312 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,108 @@ +"""Tests for the per-sample counts (Samples page).""" +import pandas as pd +import pyopenms as poms + +# src.common.samples is imported inside the tests, not at collection: importing +# it pulls in src.workflow.ParameterManager with the real streamlit, and +# test_parameter_defaults needs to import it with a mock. + + +def _idxml(path, hits): + """hits: (sequence, protein accession, "target" or "decoy").""" + prot = poms.ProteinIdentification() + prot.setIdentifier("run") + peptides = [] + for seq, accession, td in hits: + pep = poms.PeptideIdentification() + pep.setIdentifier("run") + hit = poms.PeptideHit() + hit.setSequence(poms.AASequence.fromString(seq)) + hit.setCharge(2) + hit.setMetaValue("target_decoy", td) + evidence = poms.PeptideEvidence() + evidence.setProteinAccession(accession) + hit.setPeptideEvidences([evidence]) + pep.setHits([hit]) + peptides.append(pep) + protein_hits = [] + for accession in sorted({h[1] for h in hits}): + protein_hit = poms.ProteinHit() + protein_hit.setAccession(accession) + protein_hits.append(protein_hit) + prot.setHits(protein_hits) + if hasattr(poms, "PeptideIdentificationList"): + container = poms.PeptideIdentificationList() + for pep in peptides: + container.push_back(pep) + peptides = container + path.parent.mkdir(parents=True, exist_ok=True) + poms.IdXMLFile().store(str(path), [prot], peptides) + + +def _quant_csv(path, rows): + """rows: (protein, peptide, reference, intensity).""" + path.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame(rows, columns=["ProteinName", "PeptideSequence", "Reference", "Intensity"]).to_csv(path, index=False) + + +def test_identification_counts_skip_decoys(tmp_path): + from src.common.samples import identification_counts + + _idxml(tmp_path / "a.idXML", [ + ("PEPTIDE", "P1", "target"), + ("PEPTIDE", "P1", "target"), + ("PEPTIDEK", "P2", "target"), + ("EDITPEP", "DECOY_P1", "decoy"), + ]) + assert identification_counts(tmp_path / "a.idXML") == {"Target PSMs": 3, "Peptides": 2, "Proteins": 2} + + +def test_lfq_samples_group_by_condition(tmp_path): + from src.common.samples import lfq_samples + + results = tmp_path / "results" + _idxml(results / "psm_filter" / "ctrl1_filter.idXML", [("PEPTIDE", "P1", "target")]) + _idxml(results / "psm_filter" / "case1_filter.idXML", [ + ("PEPTIDE", "P1", "target"), ("PEPTIDEK", "P2", "target"), + ]) + _idxml(results / "psm_filter" / "case2_filter.idXML", [("PEPTIDE", "P1", "target")]) + _quant_csv(results / "quant_results" / "quant.csv", [ + ("P1", "PEPTIDE", "ctrl1.mzML", 10.0), + ("P1", "PEPTIDE", "case1.mzML", 5.0), + ("P2", "PEPTIDEK", "case1.mzML", 3.0), + ("P2", "PEPTIDEK", "case2.mzML", 0.0), + ]) + files = ["in/ctrl1.mzML", "in/case1.mzML", "in/case2.mzML", "in/blank.mzML"] + groups = {"ctrl1.mzML": "control", "case1.mzML": "case", "case2.mzML": "case"} + + df = lfq_samples(tmp_path, files, groups) + + # Conditions sorted, unassigned last; file order kept within a condition + assert df["Sample"].tolist() == ["case1", "case2", "ctrl1", "blank"] + assert df["Condition"].tolist() == ["case", "case", "control", "Unassigned"] + case1 = df.iloc[0] + assert (case1["Target PSMs"], case1["Peptides"], case1["Proteins"]) == (2, 2, 2) + assert (case1["Quantified proteins"], case1["Quantified peptides"]) == (2, 2) + # An intensity of 0 is not quantified + assert pd.isna(df.iloc[1]["Quantified proteins"]) + # A file the run did not reach has empty cells + assert df.iloc[3][["Target PSMs", "Quantified proteins"]].isna().all() + + +def test_channel_samples_and_condition_summary(): + from src.common.samples import channel_samples, condition_summary + + pivot = pd.DataFrame({ + "protein": ["P1", "P2", "P3"], + "sample1[126]": [1.0, 2.0, 0.0], + "sample2[127N]": [1.0, None, None], + "sample3[127C]": [1.0, 1.0, 1.0], + }) + groups = {"sample1[126]": "A", "sample2[127N]": "A", "sample3[127C]": "B"} + df = channel_samples(pivot, ["sample1[126]", "sample2[127N]", "sample3[127C]"], groups) + assert df["Quantified proteins"].tolist() == [2, 1, 3] + assert df["Target PSMs"].isna().all() + + summary = condition_summary(df) + assert summary.columns.tolist() == ["Condition", "Samples", "Mean Quantified proteins"] + assert summary.set_index("Condition")["Mean Quantified proteins"].to_dict() == {"A": 1.5, "B": 3.0} From a18ed80d734681671a9d2cfa221bf4dadcbb0cc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:07:06 +0000 Subject: [PATCH 07/18] 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"] From 7e67b887fb25a3033868433f6b1371294ae77ec8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:05:27 +0000 Subject: [PATCH 08/18] Add an Open Search preset and a Mass Shifts page with Unimod interpretation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- app.py | 1 + content/results_mass_shifts.py | 168 +++++++++++++++++++++ presets.json | 26 ++++ src/WorkflowTest.py | 9 +- src/common/mass_shifts.py | 261 +++++++++++++++++++++++++++++++++ tests/test_mass_shifts.py | 157 ++++++++++++++++++++ 6 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 content/results_mass_shifts.py create mode 100644 src/common/mass_shifts.py create mode 100644 tests/test_mass_shifts.py diff --git a/app.py b/app.py index 3e3b047..b8fc8e1 100644 --- a/app.py +++ b/app.py @@ -29,6 +29,7 @@ st.Page(Path("content", "results_database_search.py"), title="Database Search", icon="๐Ÿ”ฌ"), st.Page(Path("content", "results_rescoring.py"), title="Rescoring", icon="๐Ÿ“ˆ"), st.Page(Path("content", "results_filtered.py"), title="Filtered PSMs", icon="๐ŸŽฏ"), + st.Page(Path("content", "results_mass_shifts.py"), title="Mass Shifts", icon="๐Ÿ“"), st.Page(Path("content", "results_abundance.py"), title="Protein Abundance", icon="๐Ÿ“‹"), st.Page(Path("content", "results_download.py"), title="Download", icon="โฌ‡๏ธ"), ], diff --git a/content/results_mass_shifts.py b/content/results_mass_shifts.py new file mode 100644 index 0000000..2c6513f --- /dev/null +++ b/content/results_mass_shifts.py @@ -0,0 +1,168 @@ +"""Mass Shifts Page: precursor mass shifts of the identified PSMs and their Unimod matches.""" +import hashlib +import shutil +from pathlib import Path + +import polars as pl +import streamlit as st +from openms_insight import LinePlot, StateManager, Table + +from src.common.common import page_setup +from src.common.mass_shifts import MIN_PSMS, mass_shift_summary, psm_mass_shifts +from src.common.results_helpers import get_workflow_dir +from src.workflow.ParameterManager import ParameterManager + +params = page_setup() +st.title("Mass Shifts") + +st.markdown( + """ +The difference between each identified PSM's observed precursor mass and the +theoretical mass of its peptide. With the **Open Search** preset (ยฑ500 Da +precursor tolerance) a peptide can be identified with a modification the search +did not include, and recurring shifts reveal which ones. Each shift is matched +to Unimod. Click a peak or a row to list its PSMs. +""" +) + +if "workspace" not in st.session_state: + st.warning("Please initialize your workspace first.") + st.stop() + +workflow_dir = get_workflow_dir(st.session_state["workspace"]) +filter_dir = workflow_dir / "results" / "psm_filter" +cache_dir = workflow_dir / "results" / "insight_cache" +workflow_params = ParameterManager(workflow_dir, "TOPP Workflow").get_parameters_from_json() + +idxmls = sorted(filter_dir.glob("*.idXML")) if filter_dir.is_dir() else [] +if not idxmls: + st.info("No results available yet. Please run the workflow first.") + st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") + st.stop() + +mzml_names = [Path(f).name for f in workflow_params.get("mzML-files", [])] +mtimes = tuple(p.stat().st_mtime for p in idxmls) + + +@st.cache_data(show_spinner="Computing mass shifts...") +def _summary(paths: tuple, names: tuple, mtimes: tuple): + """mass_shift_summary of the filtered PSMs, cached on the idXMLs' modification times.""" + return mass_shift_summary(psm_mass_shifts(list(paths), list(names))) + + +shifts, histogram, psms = _summary(tuple(str(p) for p in idxmls), tuple(mzml_names), mtimes) + +comet = workflow_params.get("CometAdapter", {}) +tolerance = comet.get("precursor_mass_tolerance") +units = comet.get("precursor_error_units") +if units == "Da" and tolerance and float(tolerance) >= 100: + st.caption(f"Open search: precursor tolerance ยฑ{float(tolerance):g} Da.") +else: + st.caption( + "This run used a closed search, so only shifts within the precursor tolerance appear " + "(0 Da and isotope picks). Apply the **Open Search** preset on the Configure page to " + "look for unexpected modifications." + ) + st.page_link("content/workflow_configure.py", label="Go to Configure", icon="โš™๏ธ") + +if psms.empty: + st.info("No target PSMs in the filtered results.") + st.stop() + +modified = shifts[~shifts["Interpretation"].isin(["Unmodified"]) & ~shifts["Interpretation"].str.contains("isotope")] +cols = st.columns(4) +cols[0].metric("Target PSMs", f"{len(psms):,}") +cols[1].metric("Shifts", f"{len(shifts):,}", help=f"Recurring shifts with at least {MIN_PSMS} PSMs.") +cols[2].metric( + "PSMs with a modification shift", + f"{int(modified['PSMs'].sum()):,}", + help="PSMs in shifts other than 0 Da and isotope picks.", +) +cols[3].metric( + "Unexplained shifts", + f"{int((shifts['Interpretation'] == '').sum()):,}", + help="Shifts with no Unimod modification within 0.02 Da.", +) + + +def _fixed(precision: int) -> dict: + """Column options showing a number with ``precision`` decimals (the table stores float32).""" + return {"sorter": "number", "hozAlign": "right", "formatter": "money", + "formatterParams": {"precision": precision, "thousand": False}} + + +# One set of caches per result: keyed on the idXMLs, their modification times +# and the detected shifts (so a change in how shifts are found rebuilds them) +key = hashlib.sha1( + ("|".join(f"{p}:{m}" for p, m in zip(idxmls, mtimes)) + shifts.to_csv()).encode() +).hexdigest()[:10] +ids = {name: f"mass_shift_{name}_{key}" for name in ("histogram", "shifts", "psms")} +if not all((cache_dir / cache_id).is_dir() for cache_id in ids.values()): + for stale in cache_dir.glob("mass_shift_*"): + shutil.rmtree(stale, ignore_errors=True) + cache_dir.mkdir(parents=True, exist_ok=True) + LinePlot( + cache_id=ids["histogram"], + data=pl.from_pandas(histogram).lazy(), + cache_path=str(cache_dir), + x_column="delta", + y_column="psms", + title="Precursor mass shifts", + x_label="Mass shift (Da)", + y_label="PSMs", + highlight_column="peak", + annotation_column="label", + interactivity={"mass_shift": "shift_id"}, + ) + Table( + cache_id=ids["shifts"], + data=pl.from_pandas(shifts).lazy(), + cache_path=str(cache_dir), + interactivity={"mass_shift": "shift_id"}, + index_field="shift_id", + title="mass_shifts", + layout="fitColumns", + column_definitions=[ + {"field": "Observed ฮ” (Da)", "title": "Observed ฮ” (Da)", **_fixed(4)}, + {"field": "PSMs", "title": "PSMs", "sorter": "number", "hozAlign": "right"}, + {"field": "Interpretation", "title": "Interpretation"}, + {"field": "Unimod ฮ” (Da)", "title": "Unimod ฮ” (Da)", **_fixed(4)}, + {"field": "Deviation (mDa)", "title": "Deviation (mDa)", **_fixed(1)}, + {"field": "Residues", "title": "Residues"}, + {"field": "Other matches", "title": "Other matches"}, + ], + ) + psm_data = pl.from_pandas(psms.reset_index(drop=True)).with_row_index("psm_id") + Table( + cache_id=ids["psms"], + data=psm_data.lazy(), + cache_path=str(cache_dir), + filters={"mass_shift": "shift_id"}, + index_field="psm_id", + title="mass_shift_psms", + initial_sort=[{"column": "score", "dir": "asc"}], + column_definitions=[ + {"field": "sequence", "title": "Sequence"}, + {"field": "delta", "title": "ฮ” (Da)", **_fixed(4)}, + {"field": "charge", "title": "Z", "sorter": "number"}, + {"field": "mz", "title": "m/z", **_fixed(4)}, + {"field": "rt", "title": "RT", **_fixed(1)}, + {"field": "score", "title": "Score", "sorter": "number"}, + {"field": "file", "title": "File"}, + ], + ) + +state_manager = StateManager() +LinePlot(cache_id=ids["histogram"], cache_path=str(cache_dir))( + key="mass_shift_histogram", state_manager=state_manager, height=400 +) + +st.subheader("Shifts") +Table(cache_id=ids["shifts"], cache_path=str(cache_dir))( + key="mass_shift_table", state_manager=state_manager, height=140 + 34 * min(len(shifts), 10) +) + +st.subheader("PSMs of the selected shift") +Table(cache_id=ids["psms"], cache_path=str(cache_dir))( + key="mass_shift_psm_table", state_manager=state_manager, height=420 +) diff --git a/presets.json b/presets.json index d1878af..ea25c99 100644 --- a/presets.json +++ b/presets.json @@ -15,6 +15,32 @@ "fragment_mass_tolerance": 0.50025, "fragment_bin_offset": 0.4 } + }, + "Open Search": { + "_description": "Open search: precursor tolerance \u00b1500 Da without isotope errors, so peptides are identified with modifications the search did not include. See the Mass Shifts results page for the shifts and their Unimod matches.", + "CometAdapter": { + "precursor_mass_tolerance": 500.0, + "precursor_error_units": "Da", + "isotope_error": "off" + }, + "CometAdapter-TMT": { + "precursor_mass_tolerance": 500.0, + "precursor_error_units": "Da", + "isotope_error": "off" + } + }, + "Closed Search": { + "_description": "Back to a standard (closed) search: the default precursor tolerance (20 ppm label-free, 4.5 ppm TMT) with 13C isotope errors 0/1.", + "CometAdapter": { + "precursor_mass_tolerance": 20.0, + "precursor_error_units": "ppm", + "isotope_error": "0/1" + }, + "CometAdapter-TMT": { + "precursor_mass_tolerance": 4.5, + "precursor_error_units": "ppm", + "isotope_error": "0/1" + } } } } diff --git a/src/WorkflowTest.py b/src/WorkflowTest.py index 30d19b4..a589116 100644 --- a/src/WorkflowTest.py +++ b/src/WorkflowTest.py @@ -226,7 +226,8 @@ def render_lfq_tabs(self): st.info(comet_info) comet_include = [":enzyme", "missed_cleavages", "fixed_modifications", "variable_modifications", - "instrument", "fragment_mass_tolerance", "fragment_error_units", "fragment_bin_offset"] + "instrument", "fragment_mass_tolerance", "fragment_error_units", "fragment_bin_offset", + "precursor_mass_tolerance", "precursor_error_units", "isotope_error"] if not self.params.get("generate-decoys", True): # Only show decoy_string when not generating decoys comet_include.append("PeptideIndexing:decoy_string") @@ -490,7 +491,8 @@ def render_tmt_tabs(self): st.write(Path(self.workflow_dir, "results")) comet_include = [":enzyme", "missed_cleavages", "fixed_modifications", "variable_modifications", - "instrument", "fragment_mass_tolerance", "fragment_error_units", "fragment_bin_offset"] + "instrument", "fragment_mass_tolerance", "fragment_error_units", "fragment_bin_offset", + "precursor_mass_tolerance", "precursor_error_units", "isotope_error"] if not self.params.get("generate-decoys", True): # Only show decoy_string when not generating decoys comet_include.append("PeptideIndexing:decoy_string") @@ -512,7 +514,8 @@ def render_tmt_tabs(self): ) with t[1]: comet_include = [":enzyme", "missed_cleavages", "fixed_modifications", "variable_modifications", - "instrument", "fragment_mass_tolerance", "fragment_error_units", "fragment_bin_offset", "PeptideIndexing:IL_equivalent"] + "instrument", "fragment_mass_tolerance", "fragment_error_units", "fragment_bin_offset", "PeptideIndexing:IL_equivalent", + "precursor_mass_tolerance", "precursor_error_units", "isotope_error"] self.ui.input_TOPP( "CometAdapter", custom_defaults={ diff --git a/src/common/mass_shifts.py b/src/common/mass_shifts.py new file mode 100644 index 0000000..e9d44ae --- /dev/null +++ b/src/common/mass_shifts.py @@ -0,0 +1,261 @@ +"""Precursor mass shifts of identified PSMs, and the Unimod modifications they match. + +In an open search (precursor tolerance of hundreds of Da) a PSM's precursor may +carry a modification the search did not consider. The shift between observed +and theoretical precursor mass then shows which ones: shifts recurring across +many PSMs form peaks, and each peak is matched to the Unimod modifications of +that mass (pyOpenMS ModificationsDB). In a closed search the same view shows +the 0 Da peak and the isotope picks. + +Reads only the IDFilter idXMLs. It makes no Streamlit calls, so the page caches +it and tests call it. +""" +from pathlib import Path + +import numpy as np +import pandas as pd +from pyopenms import ModificationsDB, ResidueModification + +from src.common.results_helpers import extract_filename_from_idxml, load_idxml + +PROTON = 1.007276466812 +C13_DELTA = 1.0033548378 + +# Width of a histogram bin, and the gap that always separates two shifts; +# closer peaks (deamidation +0.984 Da, a 13C pick +1.003 Da) are split at their valley +BIN_WIDTH = 0.005 +CLUSTER_GAP = 0.02 +# A shift needs this many PSMs to be reported +MIN_PSMS = 3 +# Unimod matches must be this close to the observed shift +MATCH_TOLERANCE = 0.02 + +# Shown first when several modifications share a mass +COMMON_MODIFICATIONS = [ + "Oxidation", "Phospho", "Carbamidomethyl", "Acetyl", "Deamidated", "Methyl", + "Dimethyl", "Trimethyl", "GG", "Amidated", "Ammonia-loss", "Gln->pyro-Glu", + "Glu->pyro-Glu", "Carbamyl", "Formyl", "Sodium", "Potassium", "Dehydrated", + "Dioxidation", "Trioxidation", "Nitro", "TMT6plex", "TMTpro", "iTRAQ4plex", +] + +# Shifts that are not modifications: a monoisotopic peak missed by n isotopes +ISOTOPE_LABELS = {1: "13C isotope peak", 2: "2 ร— 13C isotope peak", 3: "3 ร— 13C isotope peak"} + +SHIFT_COLUMNS = [ + "shift_id", "Observed ฮ” (Da)", "PSMs", "Interpretation", "Unimod ฮ” (Da)", + "Deviation (mDa)", "Residues", "Other matches", +] + + +def _is_decoy(hit) -> bool: + if not hit.metaValueExists("target_decoy"): + return False + value = hit.getMetaValue("target_decoy") + if isinstance(value, bytes): + value = value.decode() + return str(value).startswith("decoy") + + +def psm_mass_shifts(idxml_paths, mzml_names=None) -> pd.DataFrame: + """Target top hits with their precursor mass shift (observed โˆ’ theoretical, in Da).""" + rows = [] + for path in idxml_paths: + source = extract_filename_from_idxml(Path(path), mzml_names) + _, peptides = load_idxml(path) + for pep in peptides: + hits = pep.getHits() + if not hits or _is_decoy(hits[0]): + continue + hit = hits[0] + charge = hit.getCharge() + if charge <= 0: + continue + sequence = hit.getSequence() + observed = pep.getMZ() * charge - charge * PROTON + rows.append({ + "file": source, + "sequence": sequence.toString(), + "charge": charge, + "mz": pep.getMZ(), + "rt": pep.getRT(), + "score": hit.getScore(), + "delta": observed - sequence.getMonoWeight(), + }) + return pd.DataFrame(rows, columns=["file", "sequence", "charge", "mz", "rt", "score", "delta"]) + + +def _split_points(values: np.ndarray) -> list[float]: + """Mass shifts at which one gap-free group of sorted shifts holds two peaks. + + The group is histogrammed in 1 mDa bins and smoothed; between two maxima it is + split at the lowest point when that valley is below half the smaller maximum, + so two overlapping peaks (deamidation and a 13C pick) separate while one + noisy peak does not. + """ + step = 0.001 + first = values.min() + counts = np.bincount(((values - first) / step).astype(int)) + kernel = np.exp(-0.5 * (np.arange(-6, 7) / 2.0) ** 2) + smooth = np.convolve(counts, kernel / kernel.sum(), mode="same") + maxima = [ + i for i in range(len(smooth)) + if smooth[i] > 0 + and (i == 0 or smooth[i] > smooth[i - 1]) + and (i == len(smooth) - 1 or smooth[i] >= smooth[i + 1]) + ] + splits = [] + kept = maxima[:1] + for i in maxima[1:]: + left = kept[-1] + valley = left + int(np.argmin(smooth[left:i + 1])) + if smooth[valley] < 0.5 * min(smooth[left], smooth[i]): + splits.append(first + (valley + 0.5) * step) + kept.append(i) + elif smooth[i] > smooth[left]: + kept[-1] = i + return splits + + +def _clusters(deltas: np.ndarray) -> list[np.ndarray]: + """Indices of ``deltas`` grouped into shifts. + + Sorted shifts more than CLUSTER_GAP apart start a new group, and a group with + two peaks is split between them. + """ + if len(deltas) == 0: + return [] + order = np.argsort(deltas) + breaks = np.where(np.diff(deltas[order]) > CLUSTER_GAP)[0] + 1 + clusters = [] + for group in np.split(order, breaks): + values = deltas[group] + cuts = _split_points(values) if len(group) >= 2 * MIN_PSMS else [] + position = np.searchsorted(cuts, values) + clusters.extend(group[position == k] for k in range(len(cuts) + 1)) + return [c for c in clusters if len(c)] + + +def unimod_matches(shift: float, tolerance: float = MATCH_TOLERANCE) -> list[dict]: + """Unimod modifications within ``tolerance`` of ``shift``, one entry per name. + + Each entry has the name, its ฮ” mass, the deviation of the observed shift from + it and the residues it is defined on; common modifications come first, then + the closest. + """ + full_ids = [] + ModificationsDB().searchModificationsByDiffMonoMass( + full_ids, float(shift), float(tolerance), "", ResidueModification.TermSpecificity.ANYWHERE + ) + by_name = {} + db = ModificationsDB() + for full_id in full_ids: + full_id = full_id.decode() if isinstance(full_id, bytes) else str(full_id) + mod = db.getModification(full_id) + name = mod.getId() + # Run-time placeholders and PSI-MOD duplicates of Unimod entries + if not name or name.startswith(("CUSTOM", "[", "MOD:")): + continue + entry = by_name.setdefault(name, { + "name": name, + "delta": mod.getDiffMonoMass(), + "residues": set(), + }) + origin = mod.getOrigin() + term = mod.getTermSpecificity() + if term == ResidueModification.TermSpecificity.N_TERM: + entry["residues"].add("N-term") + elif term == ResidueModification.TermSpecificity.C_TERM: + entry["residues"].add("C-term") + elif origin and origin != "X": + entry["residues"].add(origin) + matches = [] + for entry in by_name.values(): + entry["deviation"] = shift - entry["delta"] + entry["residues"] = "".join(sorted(r for r in entry["residues"] if len(r) == 1)) + "".join( + f" {r}" for r in sorted(r for r in entry["residues"] if len(r) > 1) + ) + matches.append(entry) + rank = {name: i for i, name in enumerate(COMMON_MODIFICATIONS)} + matches.sort(key=lambda m: (rank.get(m["name"], len(rank)), abs(m["deviation"]), m["name"])) + return matches + + +def _interpret(shift: float) -> dict: + """Interpretation of one observed shift: unmodified, isotope pick, or Unimod matches. + + A shift close to both an isotope pick and a modification (deamidation, +0.984 Da, + is 19 mDa from one 13C) goes to whichever is closer; the other is listed too. + """ + if abs(shift) <= MATCH_TOLERANCE: + return {"Interpretation": "Unmodified", "Unimod ฮ” (Da)": 0.0, + "Deviation (mDa)": round(shift * 1000, 1), "Residues": "", "Other matches": ""} + candidates = [ + {"name": label, "delta": n * C13_DELTA, "deviation": shift - n * C13_DELTA, "residues": ""} + for n, label in ISOTOPE_LABELS.items() + if abs(shift - n * C13_DELTA) <= MATCH_TOLERANCE + ] + matches = unimod_matches(shift) + if candidates and (not matches or abs(candidates[0]["deviation"]) < abs(matches[0]["deviation"])): + matches = candidates + matches + else: + matches = matches + candidates + if not matches: + return {"Interpretation": "", "Unimod ฮ” (Da)": None, "Deviation (mDa)": None, + "Residues": "", "Other matches": ""} + best = matches[0] + return { + "Interpretation": best["name"], + "Unimod ฮ” (Da)": round(best["delta"], 4), + "Deviation (mDa)": round(best["deviation"] * 1000, 1), + "Residues": best["residues"].strip(), + "Other matches": ", ".join(m["name"] for m in matches[1:6]), + } + + +def mass_shift_summary(psms: pd.DataFrame, min_psms: int = MIN_PSMS) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: + """Detected shifts, the histogram and the PSMs, linked by ``shift_id``. + + Returns: + shifts: SHIFT_COLUMNS, one row per shift with at least ``min_psms`` PSMs, + most PSMs first. + histogram: one row per non-empty BIN_WIDTH bin (``delta``, ``psms``), + with the tallest bin of each shift marked (``peak``) and labelled. + psms: the input PSMs with the ``shift_id`` they belong to (-1 for none). + """ + psms = psms.copy() + psms["shift_id"] = -1 + deltas = psms["delta"].to_numpy(dtype=float) + + shift_rows = [] + for members in _clusters(deltas): + if len(members) < min_psms: + continue + observed = float(np.median(deltas[members])) + shift_rows.append({"members": members, "Observed ฮ” (Da)": round(observed, 4), "PSMs": len(members), + **_interpret(observed)}) + shift_rows.sort(key=lambda r: (-r["PSMs"], r["Observed ฮ” (Da)"])) + for shift_id, row in enumerate(shift_rows): + row["shift_id"] = shift_id + psms.iloc[row.pop("members"), psms.columns.get_loc("shift_id")] = shift_id + shifts = pd.DataFrame(shift_rows, columns=SHIFT_COLUMNS) + for col in ("Observed ฮ” (Da)", "Unimod ฮ” (Da)", "Deviation (mDa)"): + shifts[col] = pd.to_numeric(shifts[col], errors="coerce") + + psms["bin"] = np.round(deltas / BIN_WIDTH).astype(int) if len(psms) else pd.Series(dtype=int) + histogram = ( + psms.groupby("bin") + .agg(psms=("delta", "size"), shift_id=("shift_id", "max")) + .reset_index() + ) + histogram["delta"] = histogram["bin"] * BIN_WIDTH + histogram["peak"] = False + histogram["label"] = "" + labels = dict(zip(shifts["shift_id"], shifts["Interpretation"])) + observed = dict(zip(shifts["shift_id"], shifts["Observed ฮ” (Da)"])) + for shift_id, group in histogram[histogram["shift_id"] >= 0].groupby("shift_id"): + top = group["psms"].idxmax() + histogram.loc[top, "peak"] = True + label = labels.get(shift_id) or "?" + shift = observed[shift_id] if abs(observed[shift_id]) >= 0.0005 else 0.0 + histogram.loc[top, "label"] = f"{shift:+.3f} {label}" + return shifts, histogram[["bin", "delta", "psms", "shift_id", "peak", "label"]], psms.drop(columns="bin") diff --git a/tests/test_mass_shifts.py b/tests/test_mass_shifts.py new file mode 100644 index 0000000..c4113f6 --- /dev/null +++ b/tests/test_mass_shifts.py @@ -0,0 +1,157 @@ +"""Tests for the precursor mass shifts and their Unimod interpretation (Mass Shifts page).""" +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pyopenms as poms + +# src.common.mass_shifts is imported inside the tests, not at collection: it pulls in +# src.workflow.ParameterManager with the real streamlit, and test_parameter_defaults +# needs to import it with a mock. + +PROTON = 1.007276466812 + + +def _idxml(path, hits): + """hits: (sequence, charge, mass shift in Da, "target" or "decoy").""" + prot = poms.ProteinIdentification() + prot.setIdentifier("run") + hit_p = poms.ProteinHit() + hit_p.setAccession("P1") + prot.setHits([hit_p]) + peptides = [] + for seq, charge, shift, td in hits: + aa = poms.AASequence.fromString(seq) + pep = poms.PeptideIdentification() + pep.setIdentifier("run") + pep.setMZ((aa.getMonoWeight() + shift + charge * PROTON) / charge) + hit = poms.PeptideHit() + hit.setSequence(aa) + hit.setCharge(charge) + hit.setMetaValue("target_decoy", td) + evidence = poms.PeptideEvidence() + evidence.setProteinAccession("P1") + hit.setPeptideEvidences([evidence]) + pep.setHits([hit]) + peptides.append(pep) + if hasattr(poms, "PeptideIdentificationList"): + container = poms.PeptideIdentificationList() + for pep in peptides: + container.push_back(pep) + peptides = container + path.parent.mkdir(parents=True, exist_ok=True) + poms.IdXMLFile().store(str(path), [prot], peptides) + + +def test_psm_mass_shifts_reads_targets_with_their_shift(tmp_path): + from src.common.mass_shifts import psm_mass_shifts + + _idxml(tmp_path / "runA_filter.idXML", [ + ("PEPTIDEK", 2, 0.0, "target"), + ("PEPTIDEK", 3, 15.9949, "target"), + ("EDITPEPK", 2, 0.0, "decoy"), + ]) + psms = psm_mass_shifts([tmp_path / "runA_filter.idXML"], ["runA.mzML"]) + assert len(psms) == 2 + assert set(psms["file"]) == {"runA.mzML"} + assert np.allclose(sorted(psms["delta"]), [0.0, 15.9949], atol=1e-4) + + +def test_unimod_matches_put_common_modifications_first(): + from src.common.mass_shifts import unimod_matches + + matches = unimod_matches(79.9663) + assert matches[0]["name"] == "Phospho" + assert set("STY") <= set(matches[0]["residues"]) + assert abs(matches[0]["deviation"]) < 1e-3 + assert not any(m["name"].startswith("MOD:") for m in matches) + + +def test_summary_finds_and_interprets_shifts(): + from src.common.mass_shifts import mass_shift_summary + + rng = np.random.default_rng(1) + deltas = np.concatenate([ + rng.normal(0.0, 0.002, 40), + rng.normal(15.9949, 0.002, 12), + rng.normal(1.00335, 0.002, 6), + rng.normal(-17.0265, 0.002, 4), + [123.4, 250.7], # lone shifts: below the minimum PSM count + ]) + psms = pd.DataFrame({ + "file": "a.mzML", "sequence": "PEPTIDEK", "charge": 2, "mz": 500.0, + "rt": 1.0, "score": 0.01, "delta": deltas, + }) + + shifts, histogram, linked = mass_shift_summary(psms) + + # Most PSMs first, ids in that order + assert shifts["shift_id"].tolist() == [0, 1, 2, 3] + assert shifts["PSMs"].tolist() == [40, 12, 6, 4] + assert shifts["Interpretation"].tolist() == [ + "Unmodified", "Oxidation", "13C isotope peak", "Ammonia-loss", + ] + assert abs(shifts.loc[1, "Deviation (mDa)"]) < 3 + assert "M" in shifts.loc[1, "Residues"] + + # Every PSM is linked to its shift; the lone ones to none + assert (linked["shift_id"] == -1).sum() == 2 + assert linked.groupby("shift_id").size().to_dict() == {-1: 2, 0: 40, 1: 12, 2: 6, 3: 4} + + # One labelled peak per shift in the histogram + peaks = histogram[histogram["peak"]] + assert sorted(peaks["shift_id"]) == [0, 1, 2, 3] + assert peaks.set_index("shift_id").loc[1, "label"].endswith("Oxidation") + assert histogram["psms"].sum() == len(psms) + + +def test_deamidation_and_isotope_pick_stay_apart(): + from src.common.mass_shifts import mass_shift_summary + + rng = np.random.default_rng(2) + # Spread enough that the two peaks touch + deltas = np.concatenate([rng.normal(0.984, 0.003, 30), rng.normal(1.00335, 0.003, 30)]) + psms = pd.DataFrame({ + "file": "a.mzML", "sequence": "PEPTIDEK", "charge": 2, "mz": 500.0, + "rt": 1.0, "score": 0.01, "delta": deltas, + }) + shifts, _, _ = mass_shift_summary(psms) + assert sorted(shifts["Interpretation"]) == ["13C isotope peak", "Deamidated"] + assert all(abs(n - 30) <= 2 for n in shifts["PSMs"]) + + +def test_one_noisy_peak_is_not_split(): + from src.common.mass_shifts import mass_shift_summary + + deltas = np.random.default_rng(3).normal(15.9949, 0.003, 500) + psms = pd.DataFrame({ + "file": "a.mzML", "sequence": "PEPTIDEK", "charge": 2, "mz": 500.0, + "rt": 1.0, "score": 0.01, "delta": deltas, + }) + shifts, _, _ = mass_shift_summary(psms) + assert shifts["PSMs"].tolist() == [500] + + +def test_unexplained_shift_has_no_interpretation(): + from src.common.mass_shifts import mass_shift_summary + + psms = pd.DataFrame({ + "file": "a.mzML", "sequence": "PEPTIDEK", "charge": 2, "mz": 500.0, + "rt": 1.0, "score": 0.01, "delta": [333.333] * 5, + }) + shifts, _, _ = mass_shift_summary(psms) + assert shifts.loc[0, "Interpretation"] == "" + assert pd.isna(shifts.loc[0, "Unimod ฮ” (Da)"]) + + +def test_open_search_preset_is_valid_comet_parameters(): + presets = json.loads(Path("presets.json").read_text())["topp-workflow"] + for name, expected_units in (("Open Search", "Da"), ("Closed Search", "ppm")): + for tool in ("CometAdapter", "CometAdapter-TMT"): + params = presets[name][tool] + assert params["precursor_error_units"] == expected_units + # CometAdapter's valid strings + assert params["precursor_error_units"] in ("amu", "ppm", "Da") + assert params["isotope_error"] in ("off", "0/1", "0/1/2", "0/1/2/3", "-8/-4/0/4/8", "-1/0/1/2/3") + assert presets["Open Search"]["CometAdapter"]["precursor_mass_tolerance"] == 500.0 From 985734ee8d16d65d70a7159698b1f13cf44b0eab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:00:11 +0000 Subject: [PATCH 09/18] Presets: Orbitrap HCD, timsTOF and Ion Trap CID instrument presets Replace the High Res MS / Low Res MS presets, which only set the label-free Comet search, with three instrument presets that set the fragment bins, precursor tolerance and isotope errors for both the label-free and the TMT search. A test checks every Comet preset against the valid strings and fragment-bin checks of CometAdapter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Wo2FHPpi5a8X6GppNxEk --- presets.json | 55 +++++++++++++++++++++++++++---- tests/test_comet_presets.py | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 tests/test_comet_presets.py diff --git a/presets.json b/presets.json index d1878af..fc435f5 100644 --- a/presets.json +++ b/presets.json @@ -1,19 +1,60 @@ { "topp-workflow": { - "High Res MS": { - "_description": "Optimized for high-resolution MS2 (Orbitrap, TOF) when using ppm fragment tolerance", + "Orbitrap HCD": { + "_description": "Orbitrap with high-resolution HCD MS2, tryptic digest: 10 ppm precursor tolerance, 0.02 Da fragment bins (Comet's recommendation for high-resolution MS2). Also returns to a closed search.", "CometAdapter": { "instrument": "high_res", - "fragment_mass_tolerance": 0.015, - "fragment_bin_offset": 0.0 + "fragment_mass_tolerance": 0.01, + "fragment_bin_offset": 0.0, + "precursor_mass_tolerance": 10.0, + "precursor_error_units": "ppm", + "isotope_error": "0/1" + }, + "CometAdapter-TMT": { + "instrument": "high_res", + "fragment_mass_tolerance": 0.01, + "fragment_bin_offset": 0.0, + "precursor_mass_tolerance": 10.0, + "precursor_error_units": "ppm", + "isotope_error": "0/1" + } + }, + "timsTOF": { + "_description": "timsTOF (PASEF) with high-resolution MS2, tryptic digest: 20 ppm precursor tolerance and wider 0.05 Da fragment bins for the TOF's lower fragment mass accuracy. Also returns to a closed search.", + "CometAdapter": { + "instrument": "high_res", + "fragment_mass_tolerance": 0.025, + "fragment_bin_offset": 0.0, + "precursor_mass_tolerance": 20.0, + "precursor_error_units": "ppm", + "isotope_error": "0/1" + }, + "CometAdapter-TMT": { + "instrument": "high_res", + "fragment_mass_tolerance": 0.025, + "fragment_bin_offset": 0.0, + "precursor_mass_tolerance": 20.0, + "precursor_error_units": "ppm", + "isotope_error": "0/1" } }, - "Low Res MS": { - "_description": "Optimized for low-resolution MS2 (Ion trap) when using ppm fragment tolerance", + "Ion Trap CID": { + "_description": "Low-resolution ion trap MS2 (CID, e.g. LTQ or Orbitrap Velos/Elite with ion trap readout): 1.0005 Da fragment bins with a 0.4 Da offset and 20 ppm precursor tolerance. Also returns to a closed search.", "CometAdapter": { "instrument": "low_res", "fragment_mass_tolerance": 0.50025, - "fragment_bin_offset": 0.4 + "fragment_bin_offset": 0.4, + "precursor_mass_tolerance": 20.0, + "precursor_error_units": "ppm", + "isotope_error": "0/1" + }, + "CometAdapter-TMT": { + "instrument": "low_res", + "fragment_mass_tolerance": 0.50025, + "fragment_bin_offset": 0.4, + "precursor_mass_tolerance": 20.0, + "precursor_error_units": "ppm", + "isotope_error": "0/1" } } } diff --git a/tests/test_comet_presets.py b/tests/test_comet_presets.py new file mode 100644 index 0000000..7d94b9a --- /dev/null +++ b/tests/test_comet_presets.py @@ -0,0 +1,64 @@ +""" +Checks the CometAdapter settings in presets.json against what CometAdapter accepts. + +CometAdapter (OpenMS release/3.5.0, src/topp/CometAdapter.cpp) refuses to run +without -force when the fragment bin size (2 x fragment_mass_tolerance) or the +bin offset do not fit the instrument type, and only accepts listed strings for +the unit and isotope options. A preset that breaks these rules fails at the +search step, so it is caught here instead. +""" +import json +from pathlib import Path + +import pytest + +PRESETS = json.loads(Path(__file__).parent.parent.joinpath("presets.json").read_text())["topp-workflow"] +COMET_INSTANCES = ("CometAdapter", "CometAdapter-TMT") + +# Valid strings from CometAdapter.cpp +INSTRUMENTS = {"low_res", "high_res"} +PRECURSOR_UNITS = {"amu", "ppm", "Da"} +ISOTOPE_ERRORS = {"off", "0/1", "0/1/2", "0/1/2/3", "-8/-4/0/4/8", "-1/0/1/2/3"} + +COMET_PRESETS = [ + (name, instance, preset[instance]) + for name, preset in PRESETS.items() + for instance in COMET_INSTANCES + if instance in preset +] + + +@pytest.mark.parametrize("name,instance,params", COMET_PRESETS, ids=lambda v: v if isinstance(v, str) else "") +def test_comet_preset_values_are_valid(name, instance, params): + if "instrument" in params: + assert params["instrument"] in INSTRUMENTS + if "precursor_error_units" in params: + assert params["precursor_error_units"] in PRECURSOR_UNITS + if "isotope_error" in params: + assert params["isotope_error"] in ISOTOPE_ERRORS + if "fragment_error_units" in params: + assert params["fragment_error_units"] == "Da" + + +@pytest.mark.parametrize( + "name,instance,params", + [p for p in COMET_PRESETS if "instrument" in p[2]], + ids=lambda v: v if isinstance(v, str) else "", +) +def test_instrument_presets_pass_comet_bin_checks(name, instance, params): + """An instrument preset sets the fragment bins CometAdapter accepts for that instrument.""" + bin_size = 2 * params["fragment_mass_tolerance"] + offset = params["fragment_bin_offset"] + if params["instrument"] == "low_res": + assert bin_size >= 0.8 and offset > 0.2 + else: + assert bin_size <= 0.1 and offset <= 0.1 + + +def test_instrument_presets_set_both_search_modes(): + """Instrument presets apply to the label-free and the TMT search alike.""" + for name, preset in PRESETS.items(): + instruments = [preset[i].get("instrument") for i in COMET_INSTANCES if i in preset] + if any(instruments): + assert all(i in preset for i in COMET_INSTANCES), name + assert preset["CometAdapter"] == preset["CometAdapter-TMT"], name From 238e8acc92f225231c636da244c342b8f998dde6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:03:05 +0000 Subject: [PATCH 10/18] Install Mono so FileConverter can convert Thermo RAW files Same change as the streamlit-template PR: the images ship the .NET Framework ThermoRawFileParser that FileConverter runs through `mono`, but not Mono. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FkEGenJns3nTKscxHae7JJ --- Dockerfile | 6 ++++++ Dockerfile.arm | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8f5a5cd..a7547d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -132,6 +132,12 @@ FROM compile-openms AS run-app RUN apt-get update && apt-get install -y --no-install-recommends redis-server nginx \ && rm -rf /var/lib/apt/lists/* +# Mono runs the ThermoRawFileParser (THIRDPARTY/All, a .NET Framework build) +# that FileConverter calls to convert Thermo RAW files to mzML on Linux. +RUN apt-get update && apt-get install -y --no-install-recommends mono-complete \ + && rm -rf /var/lib/apt/lists/* \ + && mono --version + # Create Redis data directory. Default 0755 root-owned is enough: the docker # entrypoint runs as root (can write regardless of mode), and the apptainer # entrypoint relocates Redis state to /tmp/openms-runtime-* so this dir is diff --git a/Dockerfile.arm b/Dockerfile.arm index 798cb20..2a2d403 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -160,6 +160,12 @@ FROM compile-openms AS run-app RUN apt-get update && apt-get install -y --no-install-recommends redis-server nginx \ && rm -rf /var/lib/apt/lists/* +# Mono runs the ThermoRawFileParser (THIRDPARTY/All, a .NET Framework build) +# that FileConverter calls to convert Thermo RAW files to mzML on Linux. +RUN apt-get update && apt-get install -y --no-install-recommends mono-complete \ + && rm -rf /var/lib/apt/lists/* \ + && mono --version + # Create Redis data directory. Default 0755 root-owned is enough: the docker # entrypoint runs as root (can write regardless of mode), and the apptainer # entrypoint relocates Redis state to /tmp/openms-runtime-* so this dir is From 07bb0689b0bd205e240af5e7e0194a000836ac23 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:03:06 +0000 Subject: [PATCH 11/18] Add PRIDE dataset import for reprocessing public data A new "Import from PRIDE" tab on the File Upload page looks up a PRIDE accession through the PRIDE Archive v3 API, shows the project metadata and its files, and downloads the selected runs, FASTA and SDRF files in a background process with progress and cancel. mzML files are used as they are, Thermo RAW files are converted with FileConverter. Downloads go over HTTPS and resume partial files. An SDRF file (from the dataset, or uploaded) can then set up the workflow: the imported files are selected, the analysis mode, LFQ sample groups or TMT channel groups, and the Comet enzyme, modifications (resolved against the pyOpenMS ModificationsDB) and tolerances are written to the parameters. Online deployments cap a dataset import at pride_import.max_download_gb_online (settings.json, 50 GB by default). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FkEGenJns3nTKscxHae7JJ --- settings.json | 3 + src/WorkflowTest.py | 14 +- src/pride.py | 687 ++++++++++++++++++++++++++++++++++++++++++++ src/pride_ui.py | 380 ++++++++++++++++++++++++ tests/test_pride.py | 269 +++++++++++++++++ 5 files changed, 1350 insertions(+), 3 deletions(-) create mode 100644 src/pride.py create mode 100644 src/pride_ui.py create mode 100644 tests/test_pride.py diff --git a/settings.json b/settings.json index 997ef00..ccf03e0 100644 --- a/settings.json +++ b/settings.json @@ -38,6 +38,9 @@ "example-data/workspaces" ] }, + "pride_import": { + "max_download_gb_online": 50 + }, "max_threads": { "local": 4, "online": 2 diff --git a/src/WorkflowTest.py b/src/WorkflowTest.py index 30d19b4..181df59 100644 --- a/src/WorkflowTest.py +++ b/src/WorkflowTest.py @@ -16,6 +16,7 @@ from src.common.common import page_setup from src.common.results_helpers import get_abundance_data from src.common.results_helpers import parse_idxml, build_spectra_cache, load_idxml, write_psm_exports +from src.pride_ui import pride_import_section from openms_insight import Table, Heatmap, LinePlot, SequenceView # Accession prefixes that mark decoy entries in a FASTA database. @@ -48,14 +49,18 @@ def __init__(self) -> None: super().__init__("TOPP Workflow", st.session_state["workspace"]) def upload(self) -> None: - t = st.tabs(["MS data (mzML)", "FASTA database"]) + t = st.tabs(["MS data (mzML)", "FASTA database", "Import from PRIDE"]) + fallback = { + "mzML-files": [str(f) for f in Path("example-data", "mzML").glob("*.mzML")], + "fasta-file": [str(f) for f in Path("example-data", "db").glob("*.fasta")], + } with t[0]: self.ui.upload_widget( key="mzML-files", name="MS data", file_types="mzML", - fallback=[str(f) for f in Path("example-data", "mzML").glob("*.mzML")], + fallback=fallback["mzML-files"], ) with t[1]: @@ -63,9 +68,12 @@ def upload(self) -> None: key="fasta-file", name="Protein FASTA database", file_types=("fasta", "fa"), - fallback=[str(f) for f in Path("example-data", "db").glob("*.fasta")], + fallback=fallback["fasta-file"], ) + with t[2]: + pride_import_section(self, fallback) + @st.fragment def configure(self) -> None: # reactive=True so Group Selection tab updates when selection changes diff --git a/src/pride.py b/src/pride.py new file mode 100644 index 0000000..dd8520a --- /dev/null +++ b/src/pride.py @@ -0,0 +1,687 @@ +"""Import PRIDE Archive datasets into the workflow's input files. + +Everything here runs without Streamlit: the download job is started as its own +process by ``src/pride_ui.py`` and reports progress through a status file, so +the page can be left and reopened while a dataset is downloading. + +PRIDE Archive REST API (v3): https://www.ebi.ac.uk/pride/ws/archive/v3/webjars/swagger-ui/index.html +SDRF-Proteomics: https://github.com/bigbio/proteomics-sample-metadata +""" +from __future__ import annotations + +import csv +import gzip +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +import requests + +PRIDE_API = "https://www.ebi.ac.uk/pride/ws/archive/v3" +PRIDE_PROJECT_PAGE = "https://www.ebi.ac.uk/pride/archive/projects/{accession}" +REQUEST_TIMEOUT = 60 +FILES_PAGE_SIZE = 100 +CHUNK_SIZE = 1 << 20 + +ACCESSION_RE = re.compile(r"^P[XR]D\d{6}$") + +# File kinds the import knows what to do with. +KIND_MZML = "mzML" +KIND_RAW = "Thermo RAW" +KIND_FASTA = "FASTA" +KIND_SDRF = "SDRF" +KIND_OTHER = "other" + + +def normalize_accession(text: str) -> str: + """Return a PRIDE accession (PXD/PRD + 6 digits) from user input. + + Accepts the bare accession, lower case, or a PRIDE project URL. + """ + match = re.search(r"P[XR]D\d{6}", (text or "").strip().upper()) + if not match: + raise ValueError(f"'{text}' is not a PRIDE accession (e.g. PXD000001).") + return match.group(0) + + +@dataclass +class PrideFile: + name: str + category: str + size: int + url: str + kind: str + + @property + def size_mb(self) -> float: + return round(self.size / 1e6, 1) + + +def file_kind(name: str, category: str = "") -> str: + """Classify a dataset file by name (and PRIDE category for SDRF files).""" + lower = name.lower() + if lower.endswith(".gz"): + lower = lower[:-3] + if lower.endswith(".mzml"): + return KIND_MZML + if lower.endswith(".raw"): + return KIND_RAW + if lower.endswith((".fasta", ".fa", ".faa")): + return KIND_FASTA + if "sdrf" in lower and lower.endswith((".tsv", ".txt", ".csv")): + return KIND_SDRF + if "EXPERIMENTAL DESIGN" in category.upper() and lower.endswith(".tsv"): + return KIND_SDRF + return KIND_OTHER + + +def _session() -> requests.Session: + session = requests.Session() + session.headers["Accept"] = "application/json" + return session + + +def _names(entries) -> list[str]: + """Names from PRIDE's lists of CV params / plain strings.""" + names = [] + for entry in entries or []: + name = entry.get("name") if isinstance(entry, dict) else entry + if name and name not in names: + names.append(str(name)) + return names + + +def fetch_project(accession: str, session: requests.Session = None) -> dict: + """Project metadata, reduced to what the import page shows.""" + session = session or _session() + response = session.get(f"{PRIDE_API}/projects/{accession}", timeout=REQUEST_TIMEOUT) + if response.status_code == 404: + raise LookupError(f"{accession} was not found in PRIDE (or is not public yet).") + response.raise_for_status() + data = response.json() + references = [] + for ref in data.get("references") or []: + line = ref.get("referenceLine") or "" + doi = ref.get("doi") or "" + if doi and doi not in line: + line = f"{line} doi:{doi}".strip() + if line: + references.append(line) + return { + "accession": data.get("accession", accession), + "title": data.get("title", ""), + "description": data.get("projectDescription", ""), + "sample_protocol": data.get("sampleProcessingProtocol", ""), + "data_protocol": data.get("dataProcessingProtocol", ""), + "submission_date": data.get("submissionDate", ""), + "publication_date": data.get("publicationDate", ""), + "organisms": _names(data.get("organisms")), + "instruments": _names(data.get("instruments")), + "ptms": _names(data.get("identifiedPTMStrings")), + "quantification": _names(data.get("quantificationMethods")), + "experiment_types": _names(data.get("experimentTypes")), + "keywords": _names(data.get("keywords")), + "references": references, + "url": PRIDE_PROJECT_PAGE.format(accession=accession), + } + + +def _download_url(entry: dict) -> str: + """HTTPS download link for a PRIDE file entry. + + PRIDE lists FTP and Aspera locations; the FTP server serves the same paths + over HTTPS, which works through web proxies and needs no FTP client. + """ + locations = entry.get("publicFileLocations") or [] + for location in locations: + value = location.get("value", "") + if value.startswith("ftp://"): + return "https://" + value[len("ftp://"):] + if value.startswith("https://"): + return value + return "" + + +def fetch_files(accession: str, session: requests.Session = None) -> list[PrideFile]: + """All files of a project, with HTTPS download links.""" + session = session or _session() + files, page = [], 0 + while True: + response = session.get( + f"{PRIDE_API}/projects/{accession}/files", + params={"pageSize": FILES_PAGE_SIZE, "page": page}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + data = response.json() + # v3 returns a plain list; older HAL responses nest it in _embedded + entries = data if isinstance(data, list) else data.get("_embedded", {}).get("files", []) + for entry in entries: + name = entry.get("fileName", "") + category = entry.get("fileCategory") or {} + category = category.get("value", "") if isinstance(category, dict) else str(category) + files.append(PrideFile( + name=name, + category=category, + size=int(entry.get("fileSizeBytes") or 0), + url=_download_url(entry), + kind=file_kind(name, category), + )) + if len(entries) < FILES_PAGE_SIZE: + break + page += 1 + return files + + +def raw_conversion_available() -> tuple[bool, str]: + """Whether FileConverter can turn Thermo RAW files into mzML here. + + FileConverter calls the ThermoRawFileParser shipped with OpenMS, which on + Linux and macOS runs on Mono. + """ + if not shutil.which("FileConverter"): + return False, "The OpenMS FileConverter tool is not installed." + if sys.platform != "win32" and not shutil.which("mono"): + return False, "Mono is not installed, which ThermoRawFileParser needs on Linux and macOS." + return True, "" + + +# --------------------------------------------------------------------------- +# Download job +# --------------------------------------------------------------------------- + +def status_path(job_dir: Path) -> Path: + return Path(job_dir, "status.json") + + +def read_status(job_dir: Path) -> dict: + try: + with open(status_path(job_dir), "r", encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def write_status(job_dir: Path, status: dict) -> None: + """Write the status file atomically, so the page never reads half a file.""" + path = status_path(job_dir) + tmp = path.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(status, f, indent=2) + os.replace(tmp, path) + + +def is_running(job_dir: Path) -> bool: + status = read_status(job_dir) + if status.get("state") != "running": + return False + try: + os.kill(int(status.get("pid", 0)), 0) + except (OSError, ValueError): + return False + return True + + +def cancel(job_dir: Path) -> None: + status = read_status(job_dir) + try: + os.kill(int(status.get("pid", 0)), signal.SIGTERM) + except (OSError, ValueError): + pass + + +def _log(job_dir: Path, message: str) -> None: + with open(Path(job_dir, "import.log"), "a", encoding="utf-8") as f: + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}\n") + + +def download(url: str, dest: Path, expected_size: int = 0, on_progress=None, + session: requests.Session = None) -> Path: + """Stream ``url`` to ``dest``, resuming a partial ``.part`` file if there is one.""" + if dest.exists() and (not expected_size or dest.stat().st_size == expected_size): + if on_progress: + on_progress(dest.stat().st_size) + return dest + session = session or requests.Session() + part = dest.with_name(dest.name + ".part") + offset = part.stat().st_size if part.exists() else 0 + headers = {"Range": f"bytes={offset}-"} if offset else {} + with session.get(url, stream=True, headers=headers, timeout=REQUEST_TIMEOUT) as response: + if response.status_code == 416: # the .part file is already complete + response.close() + else: + response.raise_for_status() + if offset and response.status_code != 206: + offset = 0 # server ignored the range: start over + with open(part, "ab" if offset else "wb") as f: + done = offset + for chunk in response.iter_content(CHUNK_SIZE): + f.write(chunk) + done += len(chunk) + if on_progress: + on_progress(done) + if expected_size and part.stat().st_size != expected_size: + raise IOError( + f"{dest.name}: downloaded {part.stat().st_size} bytes, PRIDE lists {expected_size}." + ) + os.replace(part, dest) + return dest + + +def _gunzip(path: Path) -> Path: + out = path.with_suffix("") + with gzip.open(path, "rb") as src, open(out, "wb") as dst: + shutil.copyfileobj(src, dst, CHUNK_SIZE) + path.unlink() + return out + + +def run_import(job_dir: str, accession: str, files: list[dict], mzml_dir: str, + fasta_dir: str, keep_raw: bool = False, example_files: dict = None) -> None: + """Download ``files`` and place them in the workflow's input folders. + + mzML files go to ``mzml_dir``; Thermo RAW files are converted there with + FileConverter; FASTA files go to ``fasta_dir``; SDRF files stay in + ``job_dir`` for the page to apply. Runs as its own process. + + ``example_files`` maps an input folder to the example file names the upload + tab puts into it while it is empty; if a folder holds just those, they are + removed before the first imported file goes in. + """ + example_files = example_files or {} + checked = set() + + def target_in(directory: Path, name: str) -> Path: + """``directory/name``; replaces example data and earlier imports of the file.""" + if directory not in checked: + checked.add(directory) + examples = sorted(example_files.get(str(directory), [])) + if examples and sorted(p.name for p in directory.iterdir()) == examples: + for example in examples: + Path(directory, example).unlink() + target = Path(directory, name) + if target.exists(): + target.unlink() + return target + + job_dir, mzml_dir, fasta_dir = Path(job_dir), Path(mzml_dir), Path(fasta_dir) + downloads = Path(job_dir, "downloads", accession) + downloads.mkdir(parents=True, exist_ok=True) + mzml_dir.mkdir(parents=True, exist_ok=True) + fasta_dir.mkdir(parents=True, exist_ok=True) + + status = { + "state": "running", + "pid": os.getpid(), + "accession": accession, + "started": time.time(), + "total_files": len(files), + "done_files": 0, + "bytes_total": sum(int(f.get("size") or 0) for f in files), + "bytes_done": 0, + "current": "", + "step": "", + "imported": [], + "sdrf": [], + "errors": [], + } + child = {"process": None} + + def on_term(signum, frame): + if child["process"] is not None: + child["process"].terminate() + status["state"] = "cancelled" + status["finished"] = time.time() + write_status(job_dir, status) + _log(job_dir, "Cancelled.") + os._exit(0) + + signal.signal(signal.SIGTERM, on_term) + write_status(job_dir, status) + _log(job_dir, f"Importing {len(files)} file(s) from {accession}.") + + session = requests.Session() + bytes_before = 0 + last_write = [0.0] + + def on_progress(done): + status["bytes_done"] = bytes_before + done + if time.time() - last_write[0] > 1: + write_status(job_dir, status) + last_write[0] = time.time() + + for entry in files: + name, kind = entry["name"], entry["kind"] + status["current"], status["step"] = name, "download" + write_status(job_dir, status) + try: + path = download(entry["url"], Path(downloads, name), int(entry.get("size") or 0), + on_progress, session) + bytes_before += int(entry.get("size") or path.stat().st_size) + if path.suffix.lower() == ".gz": + status["step"] = "decompress" + write_status(job_dir, status) + path = _gunzip(path) + + if kind == KIND_MZML: + target = target_in(mzml_dir, path.name) + shutil.move(str(path), target) + elif kind == KIND_RAW: + status["step"] = "convert" + write_status(job_dir, status) + target = target_in(mzml_dir, path.stem + ".mzML") + command = ["FileConverter", "-in", str(path), "-out", str(target)] + _log(job_dir, "Running " + " ".join(command)) + child["process"] = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + output, _ = child["process"].communicate() + returncode = child["process"].returncode + child["process"] = None + _log(job_dir, output.strip()) + if returncode != 0 or not target.exists(): + target.unlink(missing_ok=True) + raise RuntimeError(f"FileConverter failed (exit code {returncode}).") + if not keep_raw: + path.unlink() + elif kind == KIND_FASTA: + target = target_in(fasta_dir, path.name) + shutil.move(str(path), target) + elif kind == KIND_SDRF: + target = Path(job_dir, "sdrf", path.name) + target.parent.mkdir(exist_ok=True) + shutil.move(str(path), target) + status["sdrf"].append(str(target)) + else: + target = path + status["imported"].append(str(target)) + _log(job_dir, f"{name}: imported as {target}") + except Exception as e: # keep going with the remaining files + status["errors"].append(f"{name}: {e}") + _log(job_dir, f"{name}: ERROR {e}") + status["done_files"] += 1 + status["bytes_done"] = bytes_before + write_status(job_dir, status) + + status["state"] = "failed" if status["errors"] and not status["imported"] else "done" + status["current"], status["step"] = "", "" + status["finished"] = time.time() + write_status(job_dir, status) + _log(job_dir, f"Finished: {len(status['imported'])} imported, {len(status['errors'])} failed.") + + +# --------------------------------------------------------------------------- +# SDRF +# --------------------------------------------------------------------------- + +@dataclass +class Sdrf: + columns: list[str] + rows: list[list[str]] + + def values(self, column: str) -> list[list[str]]: + """Per row, every non-empty value of the (possibly repeated) column.""" + idx = [i for i, c in enumerate(self.columns) if c == column] + return [[row[i].strip() for i in idx if i < len(row) and row[i].strip()] for row in self.rows] + + def column(self, column: str) -> list[str]: + """First value of ``column`` per row ('' if absent).""" + return [(v[0] if v else "") for v in self.values(column)] + + def has(self, column: str) -> bool: + return column in self.columns + + +def read_sdrf(path) -> Sdrf: + with open(path, "r", encoding="utf-8-sig", newline="") as f: + lines = [row for row in csv.reader(f, delimiter="\t") if any(c.strip() for c in row)] + if not lines: + raise ValueError("The SDRF file is empty.") + columns = [re.sub(r"\s+", " ", c.strip().lower()) for c in lines[0]] + if "comment[data file]" not in columns: + raise ValueError("The SDRF file has no 'comment[data file]' column.") + return Sdrf(columns, lines[1:]) + + +def parse_key_values(value: str) -> dict: + """'NT=Oxidation;AC=UNIMOD:35;TA=M' -> {'NT': 'Oxidation', ...}. A bare value becomes NT.""" + result = {} + for part in value.split(";"): + if "=" in part: + key, val = part.split("=", 1) + result[key.strip().upper()] = val.strip() + elif part.strip(): + result.setdefault("NT", part.strip()) + return result + + +def parse_tolerance(value: str) -> tuple[float, str] | None: + """'20 ppm' -> (20.0, 'ppm'); '0.02 Da' -> (0.02, 'Da').""" + match = re.match(r"^\s*([0-9.]+)\s*(ppm|da|th|mmu)?\s*$", value or "", re.IGNORECASE) + if not match: + return None + unit = (match.group(2) or "Da").lower() + amount = float(match.group(1)) + if unit == "mmu": + return amount / 1000.0, "Da" + return amount, "ppm" if unit == "ppm" else "Da" + + +def _openms_mods(): + """Modification and enzyme names known to OpenMS, or None without pyOpenMS.""" + try: + import pyopenms as oms + except ImportError: + return None, None, {} + mods = [] + oms.ModificationsDB().getAllSearchModifications(mods) + mods = [m.decode() if isinstance(m, bytes) else m for m in mods] + enzymes = [] + oms.ProteaseDB().getAllNames(enzymes) + enzymes = [e.decode() if isinstance(e, bytes) else e for e in enzymes] + # UniMod accession -> OpenMS modification id, to resolve SDRF names that differ + by_unimod = {} + db = oms.ModificationsDB() + for full_id in mods: + try: + mod = db.getModification(full_id) + unimod = mod.getUniModAccession() + unimod = unimod.decode() if isinstance(unimod, bytes) else unimod + mod_id = mod.getId() + by_unimod.setdefault(unimod.upper(), mod_id.decode() if isinstance(mod_id, bytes) else mod_id) + except Exception: + continue + return set(mods), enzymes, by_unimod + + +_TERMINI = { + "protein n-term": "Protein N-term", + "any n-term": "N-term", + "protein c-term": "Protein C-term", + "any c-term": "C-term", +} + + +def sdrf_modification_names(value: str, known=None, by_unimod=None) -> list[str]: + """OpenMS/Comet names for one SDRF modification entry, e.g. 'Oxidation (M)'.""" + kv = parse_key_values(value) + name = kv.get("NT", "") + accession = kv.get("AC", "").upper().replace(" ", "") + if known is not None and by_unimod and accession in by_unimod: + name = by_unimod[accession] + if not name: + return [] + position = _TERMINI.get(kv.get("PP", "").strip().lower(), "") + sites = [s.strip() for s in kv.get("TA", "").split(",") if s.strip() and s.strip().upper() != "X"] + if position: + candidates = [f"{name} ({position} {s})" for s in sites] or [f"{name} ({position})"] + else: + candidates = [f"{name} ({s})" for s in sites] + if known is None: + return candidates + resolved = [] + for candidate in candidates: + match = next((m for m in known if m.lower() == candidate.lower()), None) + if match: + resolved.append(match) + return resolved + + +def _modification_type(value: str) -> str: + return parse_key_values(value).get("MT", "variable").strip().lower() + + +def _most_common(values: list[str]) -> str: + values = [v for v in values if v] + return max(set(values), key=values.count) if values else "" + + +# Reporter channel order per plex, as IsobaricAnalyzer numbers them. +TMT_CHANNELS = { + "tmt6plex": ["126", "127", "128", "129", "130", "131"], + "tmt10plex": ["126", "127N", "127C", "128N", "128C", "129N", "129C", "130N", "130C", "131"], + "tmt11plex": ["126", "127N", "127C", "128N", "128C", "129N", "129C", "130N", "130C", "131N", "131C"], + "tmt16plex": ["126", "127N", "127C", "128N", "128C", "129N", "129C", "130N", "130C", "131N", + "131C", "132N", "132C", "133N", "133C", "134N"], + "tmt18plex": ["126", "127N", "127C", "128N", "128C", "129N", "129C", "130N", "130C", "131N", + "131C", "132N", "132C", "133N", "133C", "134N", "134C", "135N"], +} + + +def _tmt_channel(label: str) -> str: + """'TMT127N' -> '127N'; 'TMT131' -> '131'.""" + match = re.search(r"(\d{3}[NC]?)", label.upper()) + return match.group(1) if match else "" + + +def sdrf_settings(sdrf: Sdrf, mzml_names: list[str] = ()) -> dict: + """Workflow settings an SDRF file implies. + + Returns a dict with ``mode`` ('LFQ'/'TMT'), ``groups`` (mzML file name -> + group, LFQ), ``tmt_type`` and ``tmt_groups`` (channel index -> group, TMT), + ``comet`` (CometAdapter parameter -> value) and ``notes`` (what could not be + applied). + """ + known, enzymes, by_unimod = _openms_mods() + notes = [] + + # Group = factor values of the run (several factors joined with "_") + factor_columns = [c for c in dict.fromkeys(sdrf.columns) if c.startswith("factor value[")] + if not factor_columns: + notes.append("The SDRF has no factor value columns, so no sample groups are set.") + groups_per_row = [ + "_".join(v for c in factor_columns for v in sdrf.values(c)[i][:1]) + for i in range(len(sdrf.rows)) + ] + data_files = sdrf.column("comment[data file]") + labels = sdrf.column("comment[label]") + tmt_rows = [i for i, label in enumerate(labels) if "TMT" in label.upper()] + mode = "TMT" if tmt_rows else "LFQ" + + settings = {"mode": mode, "groups": {}, "tmt_type": "", "tmt_groups": {}, "comet": {}, + "notes": notes} + + if mode == "LFQ": + by_stem = {Path(n).stem.lower(): n for n in mzml_names} + unmatched = [] + for data_file, group in zip(data_files, groups_per_row): + stem = Path(data_file).name.lower() + for ext in (".gz", ".raw", ".mzml"): + stem = stem[: -len(ext)] if stem.endswith(ext) else stem + target = by_stem.get(stem, f"{Path(data_file).name.split('.')[0]}.mzML") + if mzml_names and stem not in by_stem: + unmatched.append(data_file) + continue + if group: + settings["groups"][target] = group + if unmatched: + notes.append(f"{len(unmatched)} SDRF data file(s) have no imported mzML file and were skipped.") + else: + plex = len({_tmt_channel(labels[i]) for i in tmt_rows}) + tmt_type = next((t for t, ch in TMT_CHANNELS.items() if len(ch) >= plex), "tmt18plex") + if plex in (6, 10, 11, 16, 18): + tmt_type = f"tmt{plex}plex" + settings["tmt_type"] = tmt_type + channels = TMT_CHANNELS[tmt_type] + per_channel = {} + for i in tmt_rows: + channel = _tmt_channel(labels[i]) + if channel in channels: + per_channel.setdefault(channels.index(channel), set()).add(groups_per_row[i]) + for index, values in sorted(per_channel.items()): + if len(values) == 1 and next(iter(values)): + settings["tmt_groups"][index] = next(iter(values)) + if any(len(v) > 1 for v in per_channel.values()): + notes.append("Channels carry different groups in different runs; only consistent channels were set.") + + # Search settings: the value most runs share + comet = settings["comet"] + enzyme_values = [parse_key_values(v[0]).get("NT", "") if v else "" for v in sdrf.values("comment[cleavage agent details]")] + enzyme = _most_common(enzyme_values) + if enzyme: + if enzymes is None: + comet["enzyme"] = enzyme + else: + match = next((e for e in enzymes if e.lower() == enzyme.lower()), None) + if match: + comet["enzyme"] = match + else: + notes.append(f"Enzyme '{enzyme}' is not known to OpenMS and was not set.") + + fixed, variable = [], [] + for entries in sdrf.values("comment[modification parameters]"): + for entry in entries: + names = sdrf_modification_names(entry, known, by_unimod) + if not names: + notes.append(f"Modification '{entry}' could not be matched to an OpenMS modification.") + target = fixed if _modification_type(entry) == "fixed" else variable + for name in names: + if name not in target: + target.append(name) + if fixed or variable: + comet["fixed_modifications"] = "\n".join(fixed) + comet["variable_modifications"] = "\n".join(variable) + + precursor = parse_tolerance(_most_common(sdrf.column("comment[precursor mass tolerance]"))) + if precursor: + comet["precursor_mass_tolerance"], comet["precursor_error_units"] = precursor + fragment = parse_tolerance(_most_common(sdrf.column("comment[fragment mass tolerance]"))) + if fragment: + amount, unit = fragment + # Comet bins fragments in Da; ppm tolerances mean a high-resolution MS2 + if unit == "ppm": + amount = max(round(amount * 1e-3, 3), 0.01) + high_res = unit == "ppm" or amount <= 0.1 + comet["fragment_mass_tolerance"] = amount + comet["instrument"] = "high_res" if high_res else "low_res" + comet["fragment_bin_offset"] = 0.0 if high_res else 0.4 + + # Notes repeat once per run; keep each once + settings["notes"] = list(dict.fromkeys(notes)) + return settings + + +def settings_preview(settings: dict) -> list[dict]: + """Rows describing what applying ``settings`` changes, for display.""" + rows = [{"Setting": "Analysis mode", "Value": settings["mode"]}] + for name, group in settings["groups"].items(): + rows.append({"Setting": f"Group for {name}", "Value": group}) + if settings["tmt_type"]: + rows.append({"Setting": "Isobaric type", "Value": settings["tmt_type"]}) + for index, group in settings["tmt_groups"].items(): + rows.append({"Setting": f"Group for channel {index + 1}", "Value": group}) + for key, value in settings["comet"].items(): + value = ", ".join(value.split("\n")) if isinstance(value, str) else value + rows.append({"Setting": f"Comet {key}", "Value": str(value) or "(none)"}) + return rows + + +def as_dicts(files: list[PrideFile]) -> list[dict]: + return [asdict(f) for f in files] diff --git a/src/pride_ui.py b/src/pride_ui.py new file mode 100644 index 0000000..4956bee --- /dev/null +++ b/src/pride_ui.py @@ -0,0 +1,380 @@ +"""File Upload tab that imports a PRIDE Archive dataset for reprocessing. + +The user looks up an accession, picks the MS runs (mzML, or Thermo RAW which +FileConverter turns into mzML), FASTA and SDRF files, and downloads them in a +background process into the workflow's input folders. An SDRF file then sets +the analysis mode, sample groups and search settings on the Configure page. +""" +import json +import multiprocessing +import time +from pathlib import Path + +import pandas as pd +import streamlit as st + +from src import pride +from src.common.insight_tables import show_insight_table + +LOOKUP_KEY = "pride-lookup" +STARTED_KEY = "pride-job-started" +# Downloads are capped on public servers, where the workspace volume is shared +DEFAULT_ONLINE_LIMIT_GB = 50 + + +def _dirs(wf) -> tuple[Path, Path, Path]: + inputs = Path(wf.workflow_dir, "input-files") + return Path(wf.workflow_dir, "pride-import"), Path(inputs, "mzML-files"), Path(inputs, "fasta-file") + + +def _download_limit_bytes() -> int | None: + if st.session_state.get("location") != "online": + return None + limit = st.session_state.get("settings", {}).get("pride_import", {}).get( + "max_download_gb_online", DEFAULT_ONLINE_LIMIT_GB + ) + return int(float(limit) * 1e9) if limit else None + + +def _format_size(n_bytes: float) -> str: + for unit in ("B", "KB", "MB", "GB"): + if n_bytes < 1000: + return f"{n_bytes:.1f} {unit}" if unit != "B" else f"{int(n_bytes)} B" + n_bytes /= 1000 + return f"{n_bytes:.1f} TB" + + +def _lookup(accession_text: str) -> None: + try: + accession = pride.normalize_accession(accession_text) + except ValueError as e: + st.error(str(e)) + return + try: + with st.spinner(f"Fetching {accession} from PRIDE..."): + project = pride.fetch_project(accession) + files = pride.fetch_files(accession) + except LookupError as e: + st.error(str(e)) + return + except Exception as e: + st.error(f"Could not reach the PRIDE Archive: {e}") + return + st.session_state[LOOKUP_KEY] = {"project": project, "files": pride.as_dicts(files)} + + +def _show_project(project: dict) -> None: + st.markdown(f"#### [{project['accession']}]({project['url']}): {project['title']}") + facts = { + "Organisms": project["organisms"], + "Instruments": project["instruments"], + "Modifications": project["ptms"], + "Quantification": project["quantification"], + "Experiment type": project["experiment_types"], + } + cols = st.columns(len(facts)) + for col, (label, values) in zip(cols, facts.items()): + col.markdown(f"**{label}** \n{', '.join(values) if values else 'not given'}") + with st.expander("Description and protocols"): + for label, key in (("Description", "description"), + ("Sample processing protocol", "sample_protocol"), + ("Data processing protocol", "data_protocol")): + if project[key]: + st.markdown(f"**{label}**") + st.write(project[key]) + for reference in project["references"]: + st.caption(reference) + + +def _run_stem(name: str) -> str: + return name.lower().removesuffix(".gz").rsplit(".", 1)[0] + + +def _default_ms_files(files: list[dict]) -> list[str]: + """Every run once: its mzML if the dataset has one, else its RAW file.""" + mzml = [f["name"] for f in files if f["kind"] == pride.KIND_MZML] + stems = {_run_stem(n) for n in mzml} + raw = [f["name"] for f in files if f["kind"] == pride.KIND_RAW and _run_stem(f["name"]) not in stems] + return mzml + raw + + +def _start_import(wf, accession: str, selected: list[dict], keep_raw: bool, fallback: dict) -> None: + job_dir, mzml_dir, fasta_dir = _dirs(wf) + job_dir.mkdir(parents=True, exist_ok=True) + example_files = { + str(mzml_dir): [Path(f).name for f in fallback.get("mzML-files", [])], + str(fasta_dir): [Path(f).name for f in fallback.get("fasta-file", [])], + } + pride.status_path(job_dir).unlink(missing_ok=True) + Path(job_dir, "import.log").unlink(missing_ok=True) + process = multiprocessing.Process( + target=pride.run_import, + args=(str(job_dir), accession, selected, str(mzml_dir), str(fasta_dir), keep_raw, + example_files), + daemon=False, + ) + process.start() + st.session_state[STARTED_KEY] = time.time() + + +def _file_selection(wf, lookup: dict, fallback: dict) -> None: + project, files = lookup["project"], lookup["files"] + by_name = {f["name"]: f for f in files} + + st.markdown("##### Dataset files") + table = pd.DataFrame(files)[["name", "kind", "category", "size"]] + table["size"] = table["size"].map(_format_size) + table = table.rename(columns={"name": "File", "kind": "Type", "category": "PRIDE category", + "size": "Size"}) + show_insight_table(table, name="pride-files", extra_key=project["accession"], + title=f"{project['accession']}_files", height=300, page_size=25) + + ms_options = [f["name"] for f in files if f["kind"] in (pride.KIND_MZML, pride.KIND_RAW)] + fasta_options = [f["name"] for f in files if f["kind"] == pride.KIND_FASTA] + sdrf_options = [f["name"] for f in files if f["kind"] == pride.KIND_SDRF] + if not ms_options: + st.warning("This dataset has no mzML or Thermo RAW files that can be imported.") + return + + # Widget keys carry the accession so a new lookup starts from its defaults + acc = project["accession"] + ms_files = st.multiselect( + "MS runs to import", ms_options, default=_default_ms_files(files), key=f"pride-ms-{acc}", + help="mzML files are used as they are; Thermo RAW files are converted to mzML with FileConverter.", + ) + c1, c2 = st.columns(2) + fasta_files = c1.multiselect( + "FASTA databases", fasta_options, default=fasta_options, key=f"pride-fasta-{acc}", + help="Most submissions do not include their database. Without one, add a FASTA in the " + "'FASTA database' tab.", + disabled=not fasta_options, + placeholder="None in this dataset" if not fasta_options else "Choose files", + ) + sdrf_files = c2.multiselect( + "SDRF sample metadata", sdrf_options, default=sdrf_options, key=f"pride-sdrf-{acc}", + help="An SDRF file describes each run's samples and search settings; it can set up the " + "workflow after the import.", + disabled=not sdrf_options, + placeholder="None in this dataset" if not sdrf_options else "Choose files", + ) + + selected = [by_name[n] for n in ms_files + fasta_files + sdrf_files] + has_raw = any(by_name[n]["kind"] == pride.KIND_RAW for n in ms_files) + keep_raw = False + can_convert, reason = pride.raw_conversion_available() + if has_raw: + if can_convert: + keep_raw = st.checkbox("Keep RAW files after conversion", value=False, + key=f"pride-keep-raw-{acc}") + else: + st.error(f"Thermo RAW files cannot be converted here: {reason} " + "Choose mzML files, or run the app from the Docker image.") + + total = sum(f["size"] for f in selected) + limit = _download_limit_bytes() + too_large = limit is not None and total > limit + st.caption(f"{len(selected)} file(s), {_format_size(total)} to download.") + if too_large: + st.error(f"This server imports at most {_format_size(limit)} per dataset. " + "Choose fewer runs.") + + if st.button("Download and import", type="primary", use_container_width=True, + disabled=not ms_files or too_large or (has_raw and not can_convert), + key="pride-start"): + _start_import(wf, acc, selected, keep_raw, fallback) + st.rerun() + + +def _progress(job_dir: Path) -> None: + status = pride.read_status(job_dir) + if not status: + st.info("Starting the import...") + return + done, total = status.get("bytes_done", 0), status.get("bytes_total", 0) or 1 + current = status.get("current", "") + step = {"download": f"downloading {current}", "decompress": f"decompressing {current}", + "convert": f"converting {current} to mzML"}.get(status.get("step"), current) + number = min(status.get("done_files", 0) + 1, status.get("total_files", 0)) + st.progress( + min(done / total, 1.0), + text=f"{status.get('accession')}: file {number} of {status.get('total_files')}, {step}" + f" ({_format_size(done)} of {_format_size(total)} downloaded)", + ) + if st.button("Cancel import", key="pride-cancel"): + pride.cancel(job_dir) + time.sleep(1) + st.rerun() + + +def _running(job_dir: Path) -> bool: + multiprocessing.active_children() # reap a finished import so its pid is gone + if pride.is_running(job_dir): + return True + # The process may not have written its first status yet + started = st.session_state.get(STARTED_KEY, 0) + return not pride.read_status(job_dir) and time.time() - started < 30 + + +@st.fragment(run_every=2) +def _progress_fragment(job_dir: Path) -> None: + if not _running(job_dir): + st.rerun(scope="app") + _progress(job_dir) + + +def _show_result(job_dir: Path) -> dict: + status = pride.read_status(job_dir) + if not status: + return status + state = status.get("state") + imported, errors = status.get("imported", []), status.get("errors", []) + message = f"Import of {status.get('accession')}: {len(imported)} file(s) imported" + if errors: + message += f", {len(errors)} failed" + if state == "cancelled": + st.warning(f"{message}. The import was cancelled.") + elif errors: + st.warning(f"{message}.") + else: + st.success(f"{message}.") + if errors: + with st.expander("Errors"): + for error in errors: + st.write(error) + log = Path(job_dir, "import.log") + if log.exists(): + with st.expander("Import log"): + st.code(log.read_text(encoding="utf-8", errors="replace")[-20000:], language=None) + return status + + +def apply_to_workflow(wf, mzml_files: list[str], fasta_files: list[str], settings: dict | None) -> None: + """Select the imported files and apply SDRF settings to the workflow's parameters. + + Same pattern as presets: write params.json, then drop the widgets' session + keys so the Configure page re-initializes them from the file. + """ + pm = wf.parameter_manager + params = pm.get_parameters_from_json() + drop = [] + + def set_param(key, value): + params[key] = value + drop.append(f"{pm.param_prefix}{key}") + + def set_topp(tool, key, value): + params.setdefault(tool, {})[key] = value + session_key = f"{pm.topp_param_prefix}{tool}:1:{key}" + drop.extend([session_key, f"{session_key}_display"]) + + if mzml_files: + set_param("mzML-files", mzml_files) + if fasta_files: + set_param("fasta-file", fasta_files[0]) + if settings: + set_param("analysis-mode", settings["mode"]) + for name, group in settings["groups"].items(): + set_param(f"mzML-group-{name}", group) + if settings["tmt_type"]: + set_topp("IsobaricAnalyzer-TMT", "type", settings["tmt_type"]) + for index, group in settings["tmt_groups"].items(): + set_param(f"TMT-group-sample{int(index) + 1}", group) + comet = "CometAdapter-TMT" if settings["mode"] == "TMT" else "CometAdapter" + for key, value in settings["comet"].items(): + set_topp(comet, key, value) + + with open(pm.params_file, "w", encoding="utf-8") as f: + json.dump(params, f, indent=4) + for key in drop: + st.session_state.pop(key, None) + wf.params = params + wf.ui.params = params + + +def _setup_section(wf, job_dir: Path, status: dict) -> None: + _, mzml_dir, _ = _dirs(wf) + imported = [Path(p) for p in status.get("imported", [])] + mzml_files = [str(p) for p in imported if p.suffix.lower() == ".mzml" and p.exists()] + fasta_files = [str(p) for p in imported + if pride.file_kind(p.name) == pride.KIND_FASTA and p.exists()] + if not mzml_files: + return + + st.markdown("##### Set up the workflow") + sdrf_dir = Path(job_dir, "sdrf") + uploaded = st.file_uploader( + "Add your own SDRF file", type=["tsv", "txt"], key="pride-sdrf-upload", + help="Use this when the dataset has no SDRF, or to replace it with a corrected one.", + ) + if uploaded is not None: + sdrf_dir.mkdir(parents=True, exist_ok=True) + Path(sdrf_dir, uploaded.name).write_bytes(uploaded.getbuffer()) + + sdrf_options = sorted(str(p) for p in sdrf_dir.glob("*")) if sdrf_dir.exists() else [] + sdrf_choice = st.selectbox( + "Sample metadata (SDRF)", ["Don't use an SDRF file"] + sdrf_options, + index=1 if sdrf_options else 0, key="pride-sdrf-choice", + format_func=lambda p: Path(p).name if p in sdrf_options else p, + ) + + settings = None + if sdrf_choice in sdrf_options: + try: + settings = pride.sdrf_settings(pride.read_sdrf(sdrf_choice), + [Path(p).name for p in mzml_files]) + except (OSError, ValueError) as e: + st.error(f"Could not read {Path(sdrf_choice).name}: {e}") + if settings: + preview = pd.DataFrame(pride.settings_preview(settings)) + show_insight_table(preview, name="pride-sdrf-settings", extra_key=sdrf_choice, + title="SDRF settings", height=300, page_size=25) + for note in settings["notes"]: + st.caption(note) + + if not fasta_files: + st.warning("No FASTA database was imported. Add the protein database for this dataset's " + "organism in the 'FASTA database' tab.") + summary = f"Selects the {len(mzml_files)} imported mzML file(s)" + if fasta_files: + summary += f" and {Path(fasta_files[0]).name}" + if settings: + summary += ", and applies the SDRF settings above" + st.caption(summary + ". Review them on the Configure page before running.") + if st.button("Use for the workflow", type="primary", use_container_width=True, + key="pride-apply"): + apply_to_workflow(wf, mzml_files, fasta_files, settings) + st.success("Done. Open the Configure page to review the settings.") + + +def pride_import_section(wf, fallback: dict = None) -> None: + """The PRIDE import tab. + + ``fallback`` maps an upload key ("mzML-files", "fasta-file") to the example + files its upload tab adds; they are cleared when the import replaces them. + """ + st.markdown( + "Reprocess a public dataset from the [PRIDE Archive](https://www.ebi.ac.uk/pride/archive): " + "enter its accession to download the runs into this workspace." + ) + job_dir, _, _ = _dirs(wf) + + if _running(job_dir): + _progress_fragment(job_dir) + return + + with st.form("pride-lookup-form", border=False): + c1, c2 = st.columns([3, 1]) + accession = c1.text_input("PRIDE accession", placeholder="PXD000001", + label_visibility="collapsed") + looked_up = c2.form_submit_button("Look up", use_container_width=True) + if looked_up: + _lookup(accession) + + lookup = st.session_state.get(LOOKUP_KEY) + if lookup: + _show_project(lookup["project"]) + _file_selection(wf, lookup, fallback or {}) + + status = _show_result(job_dir) + if status and status.get("state") in ("done", "cancelled", "failed"): + _setup_section(wf, job_dir, status) diff --git a/tests/test_pride.py b/tests/test_pride.py new file mode 100644 index 0000000..a08576e --- /dev/null +++ b/tests/test_pride.py @@ -0,0 +1,269 @@ +"""Tests for the PRIDE import: API parsing, the download job and SDRF settings.""" +import functools +import http.server +import os +import stat +import sys +import threading +from pathlib import Path + +import pytest + +from src import pride + +SDRF_HEADER = [ + "source name", "characteristics[organism]", "comment[data file]", "comment[label]", + "comment[cleavage agent details]", "comment[modification parameters]", + "comment[modification parameters]", "comment[modification parameters]", + "comment[precursor mass tolerance]", "comment[fragment mass tolerance]", + "factor value[disease]", +] +MODS = [ + "NT=Carbamidomethyl;AC=UNIMOD:4;TA=C;MT=Fixed", + "NT=Oxidation;MT=Variable;TA=M;AC=UNIMOD:35", + "NT=Acetyl;AC=UNIMOD:1;PP=Protein N-term;MT=variable", +] + + +def write_sdrf(path, rows): + lines = ["\t".join(SDRF_HEADER)] + ["\t".join(r) for r in rows] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def lfq_row(data_file, group, frag="20 ppm"): + return ["s", "Homo sapiens", data_file, "AC=MS:1002038;NT=label free sample", + "AC=MS:1001251;NT=Trypsin", *MODS, "10 ppm", frag, group] + + +@pytest.mark.parametrize("text, expected", [ + ("PXD000001", "PXD000001"), + (" pxd012345 ", "PXD012345"), + ("https://www.ebi.ac.uk/pride/archive/projects/PXD044981", "PXD044981"), +]) +def test_normalize_accession(text, expected): + assert pride.normalize_accession(text) == expected + + +def test_normalize_accession_rejects_other_text(): + with pytest.raises(ValueError): + pride.normalize_accession("MSV000012") + + +@pytest.mark.parametrize("name, kind", [ + ("run1.raw", pride.KIND_RAW), + ("run1.RAW", pride.KIND_RAW), + ("run1.mzML.gz", pride.KIND_MZML), + ("human.fasta", pride.KIND_FASTA), + ("PXD000001.sdrf.tsv", pride.KIND_SDRF), + ("txt.zip", pride.KIND_OTHER), +]) +def test_file_kind(name, kind): + assert pride.file_kind(name) == kind + + +@pytest.mark.parametrize("value, expected", [ + ("20 ppm", (20.0, "ppm")), + ("0.02 Da", (0.02, "Da")), + ("10ppm", (10.0, "ppm")), + ("20 mmu", (0.02, "Da")), + ("not available", None), +]) +def test_parse_tolerance(value, expected): + assert pride.parse_tolerance(value) == expected + + +def test_modification_names_without_validation(): + assert pride.sdrf_modification_names("NT=Phospho;TA=S,T,Y;MT=variable") == [ + "Phospho (S)", "Phospho (T)", "Phospho (Y)"] + assert pride.sdrf_modification_names("NT=Acetyl;PP=Protein N-term") == ["Acetyl (Protein N-term)"] + assert pride.sdrf_modification_names("NT=Gln->pyro-Glu;PP=Any N-term;TA=Q") == [ + "Gln->pyro-Glu (N-term Q)"] + + +def test_sdrf_settings_lfq(tmp_path): + pytest.importorskip("pyopenms") + sdrf = pride.read_sdrf(write_sdrf(tmp_path / "a.sdrf.tsv", [ + lfq_row("A_1.raw", "healthy"), lfq_row("A_2.raw", "tumor"), lfq_row("A_3.raw", "tumor"), + ])) + settings = pride.sdrf_settings(sdrf, ["A_1.mzML", "A_2.mzML"]) + assert settings["mode"] == "LFQ" + assert settings["groups"] == {"A_1.mzML": "healthy", "A_2.mzML": "tumor"} + comet = settings["comet"] + assert comet["enzyme"] == "Trypsin" + assert comet["fixed_modifications"] == "Carbamidomethyl (C)" + assert comet["variable_modifications"] == "Oxidation (M)\nAcetyl (Protein N-term)" + assert (comet["precursor_mass_tolerance"], comet["precursor_error_units"]) == (10.0, "ppm") + # Comet bins fragments in Da: 20 ppm becomes a high-resolution 0.02 Da search + assert (comet["fragment_mass_tolerance"], comet["instrument"], comet["fragment_bin_offset"]) == ( + 0.02, "high_res", 0.0) + assert any("no imported mzML" in n for n in settings["notes"]) + + +def test_sdrf_settings_low_res_fragments(tmp_path): + sdrf = pride.read_sdrf(write_sdrf(tmp_path / "a.sdrf.tsv", [lfq_row("A_1.raw", "x", "0.5 Da")])) + comet = pride.sdrf_settings(sdrf)["comet"] + assert (comet["fragment_mass_tolerance"], comet["instrument"], comet["fragment_bin_offset"]) == ( + 0.5, "low_res", 0.4) + + +def test_sdrf_settings_tmt(tmp_path): + rows = [] + channels = pride.TMT_CHANNELS["tmt10plex"] + for i, channel in enumerate(channels): + row = lfq_row("T_1.raw", "ctrl" if i < 5 else "treated") + row[3] = f"TMT{channel}" + rows.append(row) + settings = pride.sdrf_settings(pride.read_sdrf(write_sdrf(tmp_path / "t.sdrf.tsv", rows))) + assert settings["mode"] == "TMT" + assert settings["tmt_type"] == "tmt10plex" + assert settings["tmt_groups"] == {i: ("ctrl" if i < 5 else "treated") for i in range(10)} + + +def test_read_sdrf_needs_data_file_column(tmp_path): + path = tmp_path / "bad.tsv" + path.write_text("source name\tcharacteristics[organism]\na\tb\n") + with pytest.raises(ValueError): + pride.read_sdrf(path) + + +class FakeResponse: + def __init__(self, data, status=200): + self.data, self.status_code = data, status + + def json(self): + return self.data + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(self.status_code) + + +class FakeSession: + def __init__(self, responses): + self.responses, self.calls = responses, [] + + def get(self, url, params=None, timeout=None): + self.calls.append((url, params)) + return self.responses.pop(0) + + +def file_entry(name, size=10, category="RAW"): + return { + "fileName": name, + "fileSizeBytes": size, + "fileCategory": {"value": category}, + "publicFileLocations": [ + {"name": "Aspera Protocol", "value": f"prd_ascp@fasp.ebi.ac.uk:pride/{name}"}, + {"name": "FTP Protocol", "value": f"ftp://ftp.pride.ebi.ac.uk/pride/data/archive/{name}"}, + ], + } + + +def test_fetch_files_pages_and_uses_https(monkeypatch): + monkeypatch.setattr(pride, "FILES_PAGE_SIZE", 2) + session = FakeSession([ + FakeResponse([file_entry("a.raw"), file_entry("b.raw")]), + FakeResponse([file_entry("x.sdrf.tsv", category="EXPERIMENTAL DESIGN")]), + ]) + files = pride.fetch_files("PXD000001", session) + assert [f.name for f in files] == ["a.raw", "b.raw", "x.sdrf.tsv"] + assert files[0].url == "https://ftp.pride.ebi.ac.uk/pride/data/archive/a.raw" + assert files[2].kind == pride.KIND_SDRF + assert [c[1]["page"] for c in session.calls] == [0, 1] + + +def test_fetch_project(monkeypatch): + session = FakeSession([FakeResponse({ + "accession": "PXD000001", "title": "T", + "organisms": [{"name": "Homo sapiens (human)"}], + "instruments": [{"name": "Q Exactive"}, {"name": "Q Exactive"}], + "references": [{"referenceLine": "Paper", "doi": "10.1/x"}], + })]) + project = pride.fetch_project("PXD000001", session) + assert project["organisms"] == ["Homo sapiens (human)"] + assert project["instruments"] == ["Q Exactive"] + assert project["references"] == ["Paper doi:10.1/x"] + + +def test_fetch_project_not_found(): + with pytest.raises(LookupError): + pride.fetch_project("PXD999999", FakeSession([FakeResponse({}, 404)])) + + +@pytest.fixture +def file_server(tmp_path): + root = tmp_path / "served" + root.mkdir() + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(root)) + handler.log_message = lambda *a: None + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield root, f"http://127.0.0.1:{server.server_address[1]}" + server.shutdown() + + +@pytest.fixture +def fake_file_converter(tmp_path, monkeypatch): + """A FileConverter on PATH that writes a stub mzML.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "FileConverter" + script.write_text( + f"#!{sys.executable}\n" + "import sys\n" + "args = sys.argv[1:]\n" + "out = args[args.index('-out') + 1]\n" + "open(out, 'w').write('')\n" + ) + script.chmod(script.stat().st_mode | stat.S_IEXEC) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + + +def test_run_import(tmp_path, file_server, fake_file_converter): + root, base = file_server + (root / "run1.mzML").write_text("1") + (root / "run2.raw").write_bytes(b"raw bytes") + (root / "db.fasta").write_text(">P1\nPEPTIDE\n") + write_sdrf(root / "PXD000001.sdrf.tsv", [lfq_row("run1.raw", "a")]) + + def entry(name): + size = (root / name).stat().st_size + return {"name": name, "size": size, "url": f"{base}/{name}", "kind": pride.file_kind(name)} + + job, mzml_dir, fasta_dir = tmp_path / "job", tmp_path / "mzML-files", tmp_path / "fasta-file" + job.mkdir() + mzml_dir.mkdir() + (mzml_dir / "example.mzML").write_text("example") # what an empty upload tab shows + files = [entry(n) for n in ("run1.mzML", "run2.raw", "db.fasta", "PXD000001.sdrf.tsv")] + files.append({"name": "missing.raw", "size": 3, "url": f"{base}/missing.raw", "kind": pride.KIND_RAW}) + pride.run_import(str(job), "PXD000001", files, str(mzml_dir), str(fasta_dir), + example_files={str(mzml_dir): ["example.mzML"]}) + + status = pride.read_status(job) + assert status["state"] == "done" + assert status["done_files"] == 5 + assert sorted(p.name for p in mzml_dir.iterdir()) == ["run1.mzML", "run2.mzML"] + assert (fasta_dir / "db.fasta").exists() + assert status["sdrf"] == [str(job / "sdrf" / "PXD000001.sdrf.tsv")] + assert len(status["errors"]) == 1 and status["errors"][0].startswith("missing.raw") + # RAW files are removed after conversion unless asked to keep them + assert not (job / "downloads" / "PXD000001" / "run2.raw").exists() + + +def test_download_resumes_partial_file(tmp_path, file_server): + root, base = file_server + (root / "big.mzML").write_bytes(b"0123456789") + dest = tmp_path / "big.mzML" + (tmp_path / "big.mzML.part").write_bytes(b"01234") + # SimpleHTTPRequestHandler ignores Range and answers 200: the download restarts + pride.download(f"{base}/big.mzML", dest, 10) + assert dest.read_bytes() == b"0123456789" + assert not (tmp_path / "big.mzML.part").exists() + + +def test_download_rejects_wrong_size(tmp_path, file_server): + root, base = file_server + (root / "a.mzML").write_bytes(b"short") + with pytest.raises(IOError): + pride.download(f"{base}/a.mzML", tmp_path / "a.mzML", 999) From e55998409ab203097cac69154757c64a86ab4e86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:28:44 +0000 Subject: [PATCH 12/18] Drop unused Table import; fold in dependabot bumps (#1-#5) --- content/results_filtered.py | 2 +- requirements.txt | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/content/results_filtered.py b/content/results_filtered.py index 777d503..05e4c92 100644 --- a/content/results_filtered.py +++ b/content/results_filtered.py @@ -3,7 +3,7 @@ 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 openms_insight import Heatmap, LinePlot, SequenceView, StateManager from src.common.common import show_fig from src.common.fragment_plots import fragment_error_plot, ion_table, ion_table_html, load_psm_fragments from src.workflow.ParameterManager import ParameterManager diff --git a/requirements.txt b/requirements.txt index 0438088..2e5eea5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ cachetools==5.5.2 # via streamlit captcha==0.7.1 # via src (pyproject.toml) -certifi==2025.8.3 +certifi==2026.1.4 # via requests charset-normalizer==3.4.3 # via requests @@ -32,7 +32,7 @@ gitdb==4.0.12 # via gitpython gitpython==3.1.45 # via streamlit -idna==3.10 +idna==3.11 # via requests jinja2==3.1.6 # via @@ -42,9 +42,9 @@ jsonschema==4.23.0 # via altair jsonschema-specifications==2024.10.1 # via jsonschema -kiwisolver==1.4.8 +kiwisolver==1.4.9 # via matplotlib -markupsafe==3.0.2 +markupsafe==3.0.3 # via jinja2 matplotlib==3.10.1 # via pyopenms @@ -132,7 +132,7 @@ typing-extensions==4.13.0 # streamlit tzdata==2025.2 # via pandas -urllib3==2.3.0 +urllib3==2.6.3 # via requests watchdog==6.0.0 # via streamlit From 2e69c9ac2405b1246fb9923ad29974ff32967447 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:10:37 +0000 Subject: [PATCH 13/18] Guide biologists from upload to analysis; one-click group comparison - Navigation follows the user's path: Start here, Set up and run, Main results (Run summary, Compare groups, Volcano plot, Identified proteins), Explore further; Fine-tune and Quality control pages fold away last. - New Compare groups page runs filtering, imputation, normalization and the statistical test with the recommended settings (src/common/differential.py); volcano, PCA, heatmaps and GO enrichment compute it on demand instead of sending users to four pages first. - Every page opens with what it answers in plain words, folds background for non-specialists into an expander, and ends with where to go next (src/common/guide.py). - Configure: samples and groups first, tabs named by purpose, TMT tool tabs regrouped, plain-language help; LFQ search defaults to Orbitrap HCD; all uploaded runs are preselected and the demo files get groups, so the demo runs as shipped. - Volcano, PCA and heatmaps no longer hang on "Loading...": their insight caches are keyed on content instead of regenerated on every rerun. - GO enrichment results survive reruns and use the paginated table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FkEGemnfPye3pzon8Q9pur --- app.py | 70 +-- content/compare_groups.py | 138 ++++++ content/enrichment.py | 131 +++-- content/filtering.py | 52 +- content/imputation.py | 59 +-- content/normalization.py | 62 +-- content/quickstart.py | 200 ++++---- content/results_abundance.py | 59 ++- content/results_database_search.py | 37 +- content/results_download.py | 22 +- content/results_filtered.py | 38 +- content/results_heatmap.py | 39 +- content/results_heatmap_clustered.py | 50 +- content/results_mass_shifts.py | 36 +- content/results_overview.py | 39 +- content/results_pca.py | 66 ++- content/results_proteins.py | 32 +- content/results_qc.py | 30 +- content/results_rescoring.py | 38 +- content/results_samples.py | 28 +- content/results_volcano.py | 89 ++-- content/statistical.py | 84 ++-- content/workflow_configure.py | 24 + content/workflow_fileupload.py | 24 +- content/workflow_run.py | 8 + quantms_pca_plot/manifest.json | 50 ++ quantms_pca_plot/preprocessed/pcaData.parquet | Bin 0 -> 2816 bytes quantms_protein_heatmap/manifest.json | 51 ++ .../preprocessed/level_0.parquet | Bin 0 -> 5092 bytes .../preprocessed/level_1.parquet | Bin 0 -> 5092 bytes quantms_volcano_plot/manifest.json | 28 ++ .../preprocessed/volcanoData.parquet | Bin 0 -> 5971 bytes src/WorkflowTest.py | 448 ++++++++++-------- src/common/differential.py | 204 ++++++++ src/common/guide.py | 127 +++++ src/common/insight_tables.py | 14 + src/common/results_helpers.py | 2 + tests/test_differential.py | 81 ++++ 38 files changed, 1653 insertions(+), 807 deletions(-) create mode 100644 content/compare_groups.py create mode 100644 quantms_pca_plot/manifest.json create mode 100644 quantms_pca_plot/preprocessed/pcaData.parquet create mode 100644 quantms_protein_heatmap/manifest.json create mode 100644 quantms_protein_heatmap/preprocessed/level_0.parquet create mode 100644 quantms_protein_heatmap/preprocessed/level_1.parquet create mode 100644 quantms_volcano_plot/manifest.json create mode 100644 quantms_volcano_plot/preprocessed/volcanoData.parquet create mode 100644 src/common/differential.py create mode 100644 src/common/guide.py create mode 100644 tests/test_differential.py diff --git a/app.py b/app.py index f5dbd05..6c07765 100644 --- a/app.py +++ b/app.py @@ -16,42 +16,50 @@ st.session_state.settings = json.load(f) if __name__ == '__main__': + # Sections follow the path a user takes: set up and run, read the main + # answers, dig deeper. Expert and quality-control pages come last. pages = { - "Welcome": [ - st.Page(Path("content", "quickstart.py"), title="Quickstart", icon="๐Ÿ‘‹"), + "Start here": [ + st.Page(Path("content", "quickstart.py"), title="Home", icon="๐Ÿ ", default=True), ], - "Workflow": [ - st.Page(Path("content", "workflow_fileupload.py"), title="File Upload", icon="๐Ÿ“"), - st.Page(Path("content", "workflow_configure.py"), title="Configure", icon="โš™๏ธ"), - st.Page(Path("content", "workflow_run.py"), title="Run", icon="๐Ÿš€"), + "Step 1 ยท Set up and run": [ + st.Page(Path("content", "workflow_fileupload.py"), title="Upload data", icon="๐Ÿ“"), + st.Page(Path("content", "workflow_configure.py"), title="Describe experiment", icon="โš™๏ธ"), + st.Page(Path("content", "workflow_run.py"), title="Run analysis", icon="๐Ÿš€"), ], - "Workflow Results": [ - st.Page(Path("content", "results_overview.py"), title="Overview", icon="๐Ÿงพ"), - st.Page(Path("content", "results_database_search.py"), title="Database Search", icon="๐Ÿ”ฌ"), - st.Page(Path("content", "results_rescoring.py"), title="Rescoring", icon="๐Ÿ“ˆ"), - st.Page(Path("content", "results_filtered.py"), title="Filtered PSMs", icon="๐ŸŽฏ"), - st.Page(Path("content", "results_qc.py"), title="Identification QC", icon="๐Ÿฉบ"), - st.Page(Path("content", "results_proteins.py"), title="Proteins", icon="๐Ÿงฉ"), - st.Page(Path("content", "results_mass_shifts.py"), title="Mass Shifts", icon="๐Ÿ“"), - st.Page(Path("content", "results_abundance.py"), title="Protein Abundance", icon="๐Ÿ“‹"), - st.Page(Path("content", "results_samples.py"), title="Samples", icon="๐Ÿงช"), - st.Page(Path("content", "results_download.py"), title="Download", icon="โฌ‡๏ธ"), + "Step 2 ยท Main results": [ + st.Page(Path("content", "results_overview.py"), title="Run summary", icon="๐Ÿงพ"), + st.Page(Path("content", "compare_groups.py"), title="Compare groups", icon="โš–๏ธ"), + st.Page(Path("content", "results_volcano.py"), title="Volcano plot", icon="๐ŸŒ‹"), + st.Page(Path("content", "results_proteins.py"), title="Identified proteins", icon="๐Ÿงฉ"), ], - # Steps run in order; each one works on the previous step's output. - "Downstream Analysis": [ - st.Page(Path("content", "filtering.py"), title="1. Filtering", icon="๐Ÿงน"), - st.Page(Path("content", "imputation.py"), title="2. Imputation", icon="๐Ÿฉน"), - st.Page(Path("content", "normalization.py"), title="3. Normalization", icon="โš–๏ธ"), - st.Page(Path("content", "statistical.py"), title="4. Statistics", icon="๐Ÿ”ข"), - ], - # Every plot reads the latest Downstream Analysis output. - "Downstream Plots": [ - st.Page(Path("content", "results_volcano.py"), title="Volcano", icon="๐ŸŒ‹"), - st.Page(Path("content", "results_pca.py"), title="PCA", icon="๐Ÿ“Š"), + # The sidebar shows the first ten pages and folds the rest behind + # "View more", so everything from Clustered heatmap on is one click away. + "Step 3 ยท Explore further": [ + st.Page(Path("content", "results_pca.py"), title="Sample similarity (PCA)", icon="๐Ÿ“Š"), + st.Page(Path("content", "enrichment.py"), title="GO enrichment", icon="๐Ÿ“‰"), + st.Page(Path("content", "results_heatmap_clustered.py"), title="Clustered heatmap", icon="๐Ÿงฌ"), + st.Page(Path("content", "results_abundance.py"), title="Protein abundances", icon="๐Ÿ“‹"), + st.Page(Path("content", "results_samples.py"), title="Per-sample counts", icon="๐Ÿงช"), st.Page(Path("content", "results_heatmap.py"), title="Heatmap", icon="๐Ÿ”ฅ"), - st.Page(Path("content", "results_heatmap_clustered.py"), title="Clustered Heatmap", icon="๐Ÿงฌ"), - st.Page(Path("content", "enrichment.py"), title="Pathway Analysis", icon="๐Ÿ“‰"), - ] + ], + "Export": [ + st.Page(Path("content", "results_download.py"), title="Download results", icon="โฌ‡๏ธ"), + ], + # The four steps Compare groups runs with recommended settings, one at a time. + "Fine-tune the comparison": [ + st.Page(Path("content", "filtering.py"), title="1. Filter proteins", icon="๐Ÿงน"), + st.Page(Path("content", "imputation.py"), title="2. Missing values", icon="๐Ÿฉน"), + st.Page(Path("content", "normalization.py"), title="3. Normalize", icon="โš–๏ธ"), + st.Page(Path("content", "statistical.py"), title="4. Statistical test", icon="๐Ÿ”ข"), + ], + "Quality control and details": [ + st.Page(Path("content", "results_qc.py"), title="Identification quality", icon="๐Ÿฉบ"), + st.Page(Path("content", "results_filtered.py"), title="Peptide-spectrum matches", icon="๐ŸŽฏ"), + st.Page(Path("content", "results_mass_shifts.py"), title="Mass shifts", icon="๐Ÿ“"), + st.Page(Path("content", "results_rescoring.py"), title="Rescored hits (Percolator)", icon="๐Ÿ“ˆ"), + st.Page(Path("content", "results_database_search.py"), title="Raw search hits (Comet)", icon="๐Ÿ”ฌ"), + ], } pg = st.navigation(pages) diff --git a/content/compare_groups.py b/content/compare_groups.py new file mode 100644 index 0000000..d282e51 --- /dev/null +++ b/content/compare_groups.py @@ -0,0 +1,138 @@ +"""Compare Groups Page: which proteins change between the experimental groups.""" +import json + +import streamlit as st + +from src.common.common import page_setup, save_params +from src.common.differential import STEPS, comparison_groups, count_significant +from src.common.guide import ( + ensure_group_comparison, + needs_results, + next_steps, + page_intro, + page_link, + show_comparison_settings, +) +from src.common.insight_tables import show_insight_table +from src.common.results_helpers import get_abundance_data, get_id_column, get_sample_group_map + +params = page_setup() +st.title("Compare groups") + +page_intro( + "Which proteins are more or less abundant in one group of samples than in the other? " + "The comparison runs automatically with settings that suit most label-free experiments.", + background=""" +The comparison runs four steps on the protein abundance table: + +1. **Filter proteins.** Proteins seen in only a few samples cannot be compared + reliably, so a protein is kept only if it was measured in at least half of the + samples of at least one group. +2. **Fill in missing values.** A protein that is missing in a sample was usually + too faint to detect there. It gets that protein's lowest measured value, so a + protein switched off in one group still shows up as down-regulated. +3. **Normalize.** Intensities are log2-transformed (so a difference of 1 means + twice as much) and each sample is shifted so its median matches the others, + which corrects for unequal amounts of material loaded. +4. **Test.** A moderated t-test (similar to *limma*) compares the groups protein + by protein. Because thousands of proteins are tested, the p-values are + adjusted with the Benjamini-Hochberg method: an **adjusted p-value of 0.05** + means about 5 % of the proteins called significant are expected to be false + positives. + +**log2 fold change** is the difference of the group means on the log2 scale: ++1 means twice as abundant, โˆ’1 half as abundant. +""", + background_title="What happens here, in plain words", +) + +if "workspace" not in st.session_state: + st.warning("Please initialize your workspace first.") + st.stop() + +result = get_abundance_data(st.session_state["workspace"]) +if result is None: + needs_results() + +pivot_df, _, group_map = result +id_col = get_id_column(st.session_state["workspace"], pivot_df) +sample_group_map = get_sample_group_map(st.session_state["workspace"], pivot_df, group_map) +sample_cols = [c for c in pivot_df.columns if c in sample_group_map] + +st.subheader("Your groups") +groups: dict[str, list[str]] = {} +for s in sample_cols: + groups.setdefault(sample_group_map[s], []).append(s) +cols = st.columns(max(len(groups), 1)) +for col, (group, members) in zip(cols, sorted(groups.items())): + col.metric(group, f"{len(members)} samples") +if "Unassigned" in groups: + st.warning( + "Some samples have no group. Give every file a group on the **Describe experiment** " + "page and run the analysis again." + ) + page_link("configure") + +if st.button( + "Start over with the recommended settings", + help="Discard steps changed on the Fine-tune pages and compare the groups again with the defaults.", +): + for key in [*STEPS, "normalized_transform", "comparison_settings"]: + st.session_state.pop(key, None) + with open("default-parameters.json", "r", encoding="utf-8") as f: + defaults = json.load(f) + for key, value in defaults.items(): + if key.startswith("postproc-"): + params[key] = value + st.session_state.pop(key, None) + save_params(params) + +statistics_df = ensure_group_comparison(params) +if statistics_df is None: + st.stop() + +direction = comparison_groups(sample_group_map, sample_cols) +up, down = count_significant(statistics_df) +st.subheader("Result") +m = st.columns(3) +m[0].metric("Proteins compared", f"{statistics_df['p-adj'].notna().sum():,}") +if direction: + ref, comp = direction + m[1].metric(f"Higher in {comp}", f"{up:,}", help="Adjusted p-value โ‰ค 0.05 and at least twice as abundant.") + m[2].metric(f"Lower in {comp}", f"{down:,}", help="Adjusted p-value โ‰ค 0.05 and at most half as abundant.") + note = f"A positive log2 fold change means higher in **{comp}** than in **{ref}**." + if len(groups) > 2: + note += ( + " With more than two groups the p-value tests whether any group differs; the fold " + f"change still compares **{comp}** with **{ref}**." + ) + st.caption(note + " Counts use |log2 fold change| โ‰ฅ 1 and adjusted p-value โ‰ค 0.05; explore other cut-offs on the volcano plot.") +show_comparison_settings() + +st.subheader("All compared proteins, most significant first") +table = statistics_df.sort_values("p-adj", na_position="last")[[id_col, "log2FC", "p-adj", "p-value"]].copy() +if direction: + sig = (table["p-adj"] <= 0.05) & (table["log2FC"].abs() >= 1) + table.insert(1, "Change", "โ€“") + table.loc[sig & (table["log2FC"] > 0), "Change"] = f"higher in {direction[1]}" + table.loc[sig & (table["log2FC"] < 0), "Change"] = f"lower in {direction[1]}" +for col in ("p-adj", "p-value"): + # As text with three significant digits: the table stores floats as float32, + # which would show 0.155 as 0.1550000011920929. Rows are already in p-value order. + table[col] = table[col].map(lambda v: "" if v != v else f"{v:.3g}") +table = table.rename(columns={ + id_col: "Protein", "log2FC": "log2 fold change", "p-adj": "adjusted p-value", +}) +show_insight_table( + table, name="compare-groups", layout="fitColumns", + column_overrides={ + "log2 fold change": {"formatter": "money", "formatterParams": {"thousand": False, "precision": 2}}, + }, +) +st.caption("The table's download button saves it as CSV, in the current sort and filter.") + +next_steps( + ("volcano", "See every protein's fold change against its significance and pick cut-offs."), + ("pca", "Check that samples of the same group look alike."), + ("enrichment", "Find biological processes over-represented among the changed proteins."), +) diff --git a/content/enrichment.py b/content/enrichment.py index bac6427..3d24972 100644 --- a/content/enrichment.py +++ b/content/enrichment.py @@ -5,39 +5,45 @@ import polars as pl import streamlit as st from src.common.common import page_setup -from src.common.results_helpers import get_abundance_data, get_id_column, show_pipeline_banner +from src.common.guide import ensure_group_comparison, needs_results, next_steps, page_intro, show_comparison_settings +from src.common.insight_tables import show_insight_table +from src.common.results_helpers import get_abundance_data, get_id_column # Import GO Enrichment modules from openms_insight engine from openms_insight.analysis.enrichment import calculate_go_enrichment params = page_setup() -st.title("GO Enrichment Analysis") +st.title("GO enrichment") -st.markdown( - """ -Identify overrepresented biological themes (BP, CC, MF) within your differentially expressed protein features using MyGene.info and Fisher's Exact Test. -""" +page_intro( + "Which biological processes, cellular compartments and molecular functions are over-represented " + "among the proteins that change between your groups? This turns a list of proteins into " + "biological themes.", + background=""" +- **Gene Ontology (GO)** annotates genes with terms in three categories: **Biological Process** + (e.g. *glycolysis*), **Cellular Component** (e.g. *mitochondrion*) and **Molecular Function** + (e.g. *kinase activity*). Annotations are fetched from MyGene.info, so the page needs internet + access and protein accessions from UniProt. +- **How it works**: the changed proteins (passing both cut-offs below) are compared with all + quantified proteins. A term is enriched when it occurs more often among the changed proteins + than expected by chance (Fisher's exact test). +- **Interpretation**: enrichment is a hint, not proof. Terms backed by only one or two proteins + are fragile; look for several related terms pointing the same way. +- Using all quantified proteins as the background, rather than the whole genome, avoids + calling terms enriched just because their proteins are easy to detect. +""", + background_title="What GO enrichment is and how to read it", ) if "workspace" not in st.session_state: st.warning("Please initialize your workspace first.") st.stop() -# --- STEP 1: Upstream Statistics Checkpoint --- -if ( - "statistics_df" not in st.session_state - or st.session_state["statistics_df"] is None -): - st.warning( - "โš ๏ธ **Missing Prerequisites**: Statistical inference data not detected. Please run hypothesis testing first." - ) - st.page_link( - "content/statistical.py", label="Go to Statistical Inference", icon="๐Ÿ”ฌ" - ) +if get_abundance_data(st.session_state["workspace"]) is None: + needs_results() +final_statistics_report = ensure_group_comparison(params) +if final_statistics_report is None: st.stop() -final_statistics_report = st.session_state["statistics_df"] -show_pipeline_banner(uses="statistics") - # --- STEP 2: Preprocessing Mapping Key Configuration --- # Identify target identifier columns dynamically abundance_result = get_abundance_data(st.session_state["workspace"]) @@ -47,93 +53,86 @@ st.stop() # --- SECTION 1: Parameter Setup & Dynamic Cutoff Labels --- -st.subheader("Configure Enrichment Thresholds") +st.subheader("Which proteins count as changed?") # Check if target p-value should be adjusted or raw based on previous selections (Fallback safely to 'p-adj') target_p_col = "p-adj" if "p-adj" in final_statistics_report.columns else "p-value" p_label = ( - "Adjusted P-value (p-adj) Cutoff" + "Maximum adjusted p-value" if target_p_col == "p-adj" - else "Raw P-value (p-value) Cutoff" + else "Maximum p-value" ) ui_go_col1, ui_go_col2 = st.columns(2) with ui_go_col1: p_cutoff = st.number_input( - f"๐Ÿ”ฌ {p_label}", + p_label, min_value=0.0001, max_value=1.0, value=0.05, step=0.01, format="%.4f", - help="Proteins with significance metrics below this value are mapped to the foreground cohort.", + help="Proteins below this value (and above the fold change cut-off) are tested for enriched terms.", ) with ui_go_col2: fc_cutoff = st.number_input( - "๐Ÿ“ˆ Absolute Difference Cutoff (|log2FC|)", + "Minimum change (|log2 fold change|)", min_value=0.0, max_value=10.0, value=1.0, step=0.1, format="%.2f", - help="Proteins with absolute log2 fold change greater than or equal to this threshold will be selected.", + help="1 = at least twice or at most half as abundant. Up- and down-regulated proteins are tested together.", ) -# --- SECTION 2: Execution and Interactive View Charts --- -st.markdown("
", unsafe_allow_html=True) -if st.button("๐Ÿš€ Run GO Enrichment Analysis", type="primary", key="run_go_analysis"): - - with st.spinner("Querying MyGene.info API & executing hyper-geometric calculation loops..."): - # Convert internal pandas DataFrame to openms_insight Polars DataFrame expectation - stats_pl = pl.from_pandas(final_statistics_report) - +stats_key = int(pd.util.hash_pandas_object(final_statistics_report[["log2FC", "p-value"]], index=False).sum()) +if st.button("Find enriched GO terms", type="primary", key="run_go_analysis"): + with st.spinner("Fetching GO annotations from MyGene.info and testing every term..."): status, output = calculate_go_enrichment( - final_report=stats_pl, + final_report=pl.from_pandas(final_statistics_report), id_col=id_col, target_p_col=target_p_col, p_cutoff=p_cutoff, fc_cutoff=fc_cutoff, ) + st.session_state["go_enrichment"] = (stats_key, status, output, target_p_col, p_cutoff, fc_cutoff) - # Route response structures based on analysis output status code +# Results are rendered from session state, outside the button block: the Insight +# tables trigger a rerun the first time they are drawn. +go_result = st.session_state.get("go_enrichment") +# A result from an earlier comparison no longer matches the table above +if go_result is not None and go_result[0] == stats_key: + _, status, output, used_p_col, used_p, used_fc = go_result if status == "empty_data": - st.error("โŒ No valid statistical rows found containing standard columns to run GO alignment.") - + st.error("The comparison table has no p-values or fold changes to select proteins from.") elif status == "insufficient_proteins": st.warning( - f"โš ๏ธ Not enough significant proteins found to construct target datasets. " - f"(Criteria: {target_p_col} < {p_cutoff:.4f}, |log2FC| โ‰ฅ {fc_cutoff:.2f})." + f"Only **{output}** proteins pass the cut-offs ({used_p_col} < {used_p:.4f}, " + f"|log2 fold change| โ‰ฅ {used_fc:.2f}), too few to test. Loosen the cut-offs above." ) - st.info(f"๐Ÿ’ก Found significant proteins count: **{output}**. Try relaxing your p-value or log2FC filters.") - elif status == "success": - st.success("โญ• GO Enrichment Analysis completed successfully!") - - # Display operational matrix scale st.markdown( - f"๐Ÿ“Š **Analysis Profile Scope**: Mapped **{output['fg_count']}** significant foreground profiles out of **{output['bg_count']}** reference background items." + f"Tested **{output['fg_count']}** changed proteins against **{output['bg_count']}** " + "quantified proteins as background." ) - - # Build multi-tab interface layer for ontology subcategories - tabs = st.tabs([ - "๐Ÿงฌ Biological Process (BP)", - "๐Ÿ”ฌ Cellular Component (CC)", - "๐Ÿงช Molecular Function (MF)" - ]) - categories_data = output["categories"] - - for idx, go_type in enumerate(["BP", "CC", "MF"]): - with tabs[idx]: - fig = categories_data[go_type]["fig"] - df_go = categories_data[go_type]["df"] - + tabs = st.tabs(["Biological process", "Cellular component", "Molecular function"]) + for tab, go_type in zip(tabs, ["BP", "CC", "MF"]): + with tab: + fig = output["categories"][go_type]["fig"] + df_go = output["categories"][go_type]["df"] if fig is not None and df_go is not None: - # Render plotly bar figures generated straight from backend engine st.plotly_chart(fig, use_container_width=True) - - st.subheader(f"๐Ÿ“Š {go_type} Results Dataframe") - st.dataframe(df_go, use_container_width=True) + if isinstance(df_go, pl.DataFrame): + df_go = df_go.to_pandas() + show_insight_table(df_go, name=f"go-{go_type}", height=400) else: - st.info(f"No statistically overrepresented terms identified for Category: **{go_type}**") \ No newline at end of file + st.info("No term of this category is over-represented among the changed proteins.") + +show_comparison_settings() + +next_steps( + ("volcano", "Pick the proteins behind a term and see how strongly they change."), + ("download", "Export the tables for figures or further analysis."), +) diff --git a/content/filtering.py b/content/filtering.py index b9e3f1a..ae62394 100644 --- a/content/filtering.py +++ b/content/filtering.py @@ -5,6 +5,7 @@ import polars as pl import streamlit as st from src.common.common import page_setup, save_params, show_fig +from src.common.guide import needs_results, next_steps, page_intro from src.common.postprocessing_plots import filter_threshold_curve from src.common.insight_tables import show_insight_table from src.common.results_helpers import ( @@ -30,12 +31,21 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: return df.drop(columns=[c for c in STAT_COLUMNS if c in df.columns], errors="ignore") params = page_setup() -st.title("Step 1 of 4: Filtering") - -st.markdown( - """ -Filter out low-quality proteins from your dataset based on abundance, repeatability, or variance thresholds. -""" +st.title("Fine-tune 1 of 4: Filter proteins") + +page_intro( + 'Remove proteins that were measured in too few samples to be compared reliably.' + "\n\n" + 'You do not need this page for a standard analysis: **Compare groups** runs all four steps with the recommended settings. Use it to inspect this step or change it; the plots follow your choice.', + background=""" +- **Why filter?** A protein seen in one of six samples cannot be compared between groups: almost + all of its values would have to be invented in the next step. +- **Recommended: Low Repeatability, 50 %.** A protein is kept if at least one group has it in at + least half of its samples. This keeps proteins switched on in only one group, often the most + interesting ones. +- *Low Abundance* and *Low Variance* remove the faintest or least changing proteins instead; use + them only for specific reasons (e.g. to shorten a very long list before clustering). +""", + background_title='What filtering does', ) if "workspace" not in st.session_state: @@ -44,13 +54,7 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: result = get_abundance_data(st.session_state["workspace"]) if result is None: - st.info( - "Abundance data not available. Please run the workflow and configure sample groups first." - ) - st.page_link( - "content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹" - ) - st.stop() + needs_results() pivot_df, expr_df, group_map = result pivot_df = strip_stat_columns(pivot_df) @@ -64,17 +68,11 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: if c not in [id_col, "PeptideSequence", "log2FC", "p-value", "p-adj"] ] -# --- SECTION 1: Original Data View --- -st.subheader("Original Abundance Table") -st.markdown( - f"Currently displaying **{pivot_df.shape[0]}** proteins and **{len(sample_cols)}** samples before filtering." -) -show_insight_table(pivot_df, name="filtering-original") - -st.markdown("---") +with st.expander(f"Input: {pivot_df.shape[0]:,} proteins in {len(sample_cols)} samples"): + show_insight_table(pivot_df, name="filtering-original") # --- SECTION 2: Filter Configuration --- -st.subheader("Configure Filter Engine") +st.subheader("Settings") # Prepare Polars Metadata DataFrame required by openms_insight functions metadata_rows = [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols if s in sample_group_map] @@ -87,14 +85,13 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: # imputation would otherwise have to invent most values for. filter_options = ["Low Repeatability", "Low Abundance", "Low Variance"] filter_method = st.selectbox( - "Select Filtering Method", + "Filter by", options=filter_options, index=filter_options.index( postprocessing_param(params, "postproc-filter-method", filter_options) ), key="postproc-filter-method", - help="Choose the statistical criteria to prune unreliable protein entries. " - "Recommended: Low Repeatability with at most 50% missing values.", + help="Recommended: Low Repeatability with at most 50 % missing values per group.", ) # Render threshold sliders dynamically based on the selected filter method @@ -215,3 +212,8 @@ def run_filter(value: float) -> pl.LazyFrame: ) else: show_insight_table(filtered_df, name="filtering-filtered") + +next_steps( + ('imputation', 'Next: fill in the remaining missing values.'), + ('compare', 'Back to the overview of the comparison.'), +) diff --git a/content/imputation.py b/content/imputation.py index 89084df..ce6ab46 100644 --- a/content/imputation.py +++ b/content/imputation.py @@ -5,6 +5,7 @@ import polars as pl import streamlit as st from src.common.common import page_setup, save_params, show_fig +from src.common.guide import needs_results, next_steps, page_intro from src.common.postprocessing_plots import imputed_value_preview, missingness_vs_intensity from src.common.insight_tables import show_insight_table from src.common.results_helpers import ( @@ -26,12 +27,23 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: return df.drop(columns=[c for c in STAT_COLUMNS if c in df.columns], errors="ignore") params = page_setup() -st.title("Step 2 of 4: Imputation") - -st.markdown( - """ -Handle missing values (zeros or nulls) in your quantification matrix using biological group-aware (MAR) or absolute lowest limit (MNAR) techniques. -""" +st.title("Fine-tune 2 of 4: Missing values") + +page_intro( + 'Fill in the values of proteins that were not detected in some samples, so every protein can be tested.' + "\n\n" + 'You do not need this page for a standard analysis: **Compare groups** runs all four steps with the recommended settings. Use it to inspect this step or change it; the plots follow your choice.', + background=""" +- **Why values are missing.** In label-free data a protein is usually missing because it was too + faint to detect in that sample (*missing not at random*, MNAR), not by chance. +- **Recommended: MNAR, row.** A missing value becomes the protein's own lowest measured value, + i.e. "at most as much as the faintest time we saw it". A protein switched off in one group + then comes out as clearly lower there, as it should. +- *MAR* (missing at random) fills in the group mean or median. It suits data where dropouts are + random, but cannot fill a protein missing in a whole group. +- The plots below help decide: if proteins with missing values are generally less intense + (orange curve lower), the recommended MNAR setting is right. +""", + background_title='What imputation does', ) if "workspace" not in st.session_state: @@ -41,13 +53,7 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: # Load base dataset and clean dictionary keys result = get_abundance_data(st.session_state["workspace"]) if result is None: - st.info( - "Abundance data not available. Please run the workflow and configure sample groups first." - ) - st.page_link( - "content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹" - ) - st.stop() + needs_results() pivot_df, expr_df, group_map = result pivot_df = strip_stat_columns(pivot_df) @@ -58,31 +64,21 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: if "filtered_df" in st.session_state and st.session_state["filtered_df"] is not None: base_df = strip_stat_columns(st.session_state["filtered_df"]) st.session_state["filtered_df"] = base_df - st.info( - "๐Ÿ”„ **Upstream Pipeline Detected**: Using data processed from the **Filtering** step." - ) + st.info("Working on the output of step 1 (Filter proteins).") else: base_df = pivot_df - st.warning( - "โš ๏ธ **Raw Input Active**: No filtering history found. Operating on the original unfiltered table." - ) + st.warning("Step 1 (Filter proteins) has not been applied: working on the unfiltered table.") # 2. Identify actual sample columns dynamically based on the current active matrix sample_cols = [ c for c in base_df.columns if c not in [id_col, "PeptideSequence", "log2FC", "p-value", "p-adj"] ] -# --- SECTION 1: Input Matrix Summary --- -st.subheader("Input Matrix Overview") -st.markdown( - f"Currently analyzing **{base_df.shape[0]}** rows across **{len(sample_cols)}** samples before imputation." -) -show_insight_table(base_df, name="imputation-input") - -st.markdown("---") +with st.expander(f"Input: {base_df.shape[0]:,} proteins in {len(sample_cols)} samples"): + show_insight_table(base_df, name="imputation-input") # --- SECTION 2: Imputation Configuration --- -st.subheader("Configure Imputation Engine") +st.subheader("Settings") # Build Polars structural metadata DataFrame metadata_rows = [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols if s in sample_group_map] @@ -98,7 +94,7 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: # cannot fill a protein that is missing in a whole group. impute_options = ["MNAR (Missing Not At Random)", "MAR (Missing At Random)"] impute_category = st.selectbox( - "Select Imputation Class", + "Why are values missing?", options=impute_options, index=impute_options.index( postprocessing_param(params, "postproc-impute-class", impute_options) @@ -196,3 +192,8 @@ def run_imputation() -> pl.LazyFrame: if imputed_df is not None: st.subheader("Imputed Result Table") show_insight_table(imputed_df, name="imputation-result") + +next_steps( + ('normalization', 'Next: make the samples comparable.'), + ('compare', 'Back to the overview of the comparison.'), +) diff --git a/content/normalization.py b/content/normalization.py index 8dad683..124b528 100644 --- a/content/normalization.py +++ b/content/normalization.py @@ -5,6 +5,7 @@ import polars as pl import streamlit as st from src.common.common import page_setup, save_params, show_fig +from src.common.guide import needs_results, next_steps, page_intro from src.common.postprocessing_plots import sample_distributions from src.common.insight_tables import show_insight_table from src.common.results_helpers import ( @@ -31,12 +32,22 @@ def strip_stat_columns(df: pd.DataFrame | None) -> pd.DataFrame | None: return df.drop(columns=[c for c in STAT_COLUMNS if c in df.columns], errors="ignore") params = page_setup() -st.title("Step 3 of 4: Normalization") - -st.markdown( - """ -Standardize and transform your protein abundance profiles to correct for technical variations and optimize statistical distributions. -""" +st.title("Fine-tune 3 of 4: Normalize") + +page_intro( + 'Make samples comparable by correcting for different amounts of material loaded, and put intensities on a log scale.' + "\n\n" + 'You do not need this page for a standard analysis: **Compare groups** runs all four steps with the recommended settings. Use it to inspect this step or change it; the plots follow your choice.', + background=""" +- **log2 transformation** turns ratios into differences: a difference of 1 means twice as much. + It also makes the spread of intensities more even, as statistical tests assume. +- **Median normalization** shifts every sample so its median protein intensity matches the + others. This corrects for one sample simply containing more material. It assumes most + proteins do not change, which holds for most experiments. +- **Row scaling** (e.g. auto-scaling) puts every protein on the same scale. It is useful for + clustering but removes the fold changes, so leave it off for the group comparison. +- **Recommended: log2, median, no scaling.** +""", + background_title='What normalization does', ) if "workspace" not in st.session_state: @@ -46,13 +57,7 @@ def strip_stat_columns(df: pd.DataFrame | None) -> pd.DataFrame | None: # Load primary database assets result = get_abundance_data(st.session_state["workspace"]) if result is None: - st.info( - "Abundance data not available. Please run the workflow and configure sample groups first." - ) - st.page_link( - "content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹" - ) - st.stop() + needs_results() pivot_df, expr_df, group_map = result pivot_df = strip_stat_columns(pivot_df) @@ -75,36 +80,26 @@ def strip_stat_columns(df: pd.DataFrame | None) -> pd.DataFrame | None: and st.session_state["imputed_df"] is not None ): base_df = imputed_df - st.info( - "๐Ÿ”„ **Upstream Pipeline Detected**: Using data processed from the **Imputation** step." - ) + st.info("Working on the output of step 2 (Missing values).") elif ( "filtered_df" in st.session_state and st.session_state["filtered_df"] is not None ): base_df = filtered_df - st.warning( - "โš ๏ธ **Imputation Skipped**: Using data processed from the **Filtering** step." - ) + st.warning("Step 2 (Missing values) has not been applied: working on the output of step 1.") else: base_df = pivot_df - st.warning( - "โš ๏ธ **Raw Input Active**: No preprocessing history found. Operating on the original unfiltered table." - ) + st.warning("Steps 1 and 2 have not been applied: working on the unprocessed table.") # 2. Extract actual active sample columns dynamically sample_cols = [ c for c in base_df.columns if c not in [id_col, "PeptideSequence", "log2FC", "p-value", "p-adj"] ] -# --- SECTION 1: Active Input Table Preview --- -st.subheader("Input Table Overview") -st.markdown( - f"Currently displaying **{base_df.shape[0]}** rows and **{len(sample_cols)}** samples entering the normalization block." -) -show_insight_table(base_df, name="normalization-input") +with st.expander(f"Input: {base_df.shape[0]:,} proteins in {len(sample_cols)} samples"): + show_insight_table(base_df, name="normalization-input") -st.markdown("### Pipeline Overview") +st.markdown("### Steps so far") st.caption("Data flows in order: Filtering -> Imputation -> Normalization") step_rows = [ @@ -145,7 +140,7 @@ def strip_stat_columns(df: pd.DataFrame | None) -> pd.DataFrame | None: st.markdown("---") # --- SECTION 2: Normalization Parameter Configuration --- -st.subheader("Configure Preprocessing & Scaling Chains") +st.subheader("Settings") # Prepare structural Polars metadata DataFrame required by backend functions metadata_rows = [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols if s in sample_group_map] @@ -297,4 +292,9 @@ def run_normalization() -> pl.LazyFrame: if normalized_df is not None: # Display the finalized transformation matrix view st.subheader("Normalized Abundance Table") - show_insight_table(normalized_df, name="normalization-result") \ No newline at end of file + show_insight_table(normalized_df, name="normalization-result") + +next_steps( + ('statistics', 'Next: test every protein for a difference between the groups.'), + ('compare', 'Back to the overview of the comparison.'), +) diff --git a/content/quickstart.py b/content/quickstart.py index 48349f3..c8f8cdd 100644 --- a/content/quickstart.py +++ b/content/quickstart.py @@ -9,6 +9,7 @@ import streamlit as st from src.common.common import page_setup +from src.common.guide import page_link WINDOWS_APP_PATH = Path("/app/OpenMS-App.zip") @@ -93,7 +94,7 @@ def render_windows_download_box(app_bytes: bytes) -> None: page_setup(page="main") -st.markdown("# OpenDDA: DDA Quantitative Proteomics") +st.markdown("# OpenDDA: find out which proteins change in your samples") windows_app_bytes = load_windows_app_bytes() if windows_app_bytes is not None: @@ -101,143 +102,110 @@ def render_windows_download_box(app_bytes: bytes) -> None: st.markdown( """ -This application provides complete **Data-Dependent Acquisition (DDA)** quantification -workflows for proteomics data analysis. The pipeline identifies and quantifies proteins -from mass spectrometry data and supports two quantification strategies: - -- **Label-free quantification (LFQ)**: compares precursor intensities across separately measured samples. -- **Tandem Mass Tag (TMT) quantification**: compares reporter-ion intensities of multiplexed samples measured in one run. - -Choose the **Analysis Mode** (LFQ or TMT) on the Configure page. +OpenDDA turns raw mass spectrometry files from a **data-dependent acquisition (DDA)** +experiment into a list of proteins, how much of each protein every sample contains, +and which proteins differ between your experimental groups. You do not need to know +the search engines behind it: every setting starts at a value that suits a typical +experiment, and each page explains what you are looking at. """ ) -st.info( - "These workflows mirror the **DDA-LFQ and DDA-ISO (TMT) branches of the quantms Nextflow workflow**." -) - -st.markdown("## Workflow Overview") - -lfq_col, tmt_col = st.columns(2) - -with lfq_col: +st.markdown("## How it works") +steps = st.columns(3) +with steps[0]: st.markdown( """ -### Label-free (LFQ) - -| Stage | Tool | -|-------|------| -| **1. Identification** | Comet | -| **2. Rescoring** | Percolator | -| **3. Filtering** | IDFilter | -| **4. Quantification** | ProteomicsLFQ | -| **5. Statistical Analysis** | Built-in | +### 1 ยท Set up and run +Upload your **mzML** files and a **FASTA** file with the protein sequences of your +organism (or import a public dataset from PRIDE). Say which sample belongs to which +group, then press Run. A run takes minutes to hours depending on the data. """ ) - -with tmt_col: + page_link("upload", "Upload data") +with steps[1]: st.markdown( """ -### TMT - -| Stage | Tool | -|-------|------| -| **1. Reporter extraction** | IsobaricAnalyzer | -| **2. Identification** | Comet | -| **3. Rescoring** | Percolator | -| **4. Filtering** | IDFilter | -| **5. Protein inference** | ProteinInference | -| **6. Quantification** | ProteinQuantifier | -| **7. Statistical Analysis** | Built-in | +### 2 ยท Read the main results +The **run summary** tells you whether the run went well. **Compare groups** and the +**volcano plot** show the proteins that go up or down between your groups. +**Identified proteins** lists everything that was found. """ ) - -st.markdown("## Getting Started") - -st.markdown( - """ -Follow these steps to run your analysis: - -### 1. Upload Files -Upload your mzML mass spectrometry files and a protein FASTA database. + page_link("compare", "Compare groups") +with steps[2]: + st.markdown( + """ +### 3 ยท Explore further +Check that replicates look alike (**PCA**, **clustered heatmap**), and find biological +processes over-represented among the changed proteins (**GO enrichment**). """ -) -st.page_link("content/workflow_fileupload.py", label="Go to File Upload", icon="๐Ÿ“") + ) + page_link("enrichment", "GO enrichment") +st.markdown("## Which page answers my question?") st.markdown( """ -### 2. Configure Parameters -Choose LFQ or TMT, then set up search parameters, sample groups (or TMT channels), and analysis settings. +| Your question | Page | +|---|---| +| Did the run work, and how many proteins were found? | **Run summary** | +| Which proteins are up or down between my groups? | **Compare groups**, **Volcano plot** | +| Is my protein of interest there, and which peptides support it? | **Identified proteins** | +| How much of each protein is in each sample? | **Protein abundances** | +| Do my replicates agree? Is there an outlier sample? | **Sample similarity (PCA)**, **Clustered heatmap** | +| What biology do the changed proteins point to? | **GO enrichment** | +| I want the tables for my own analysis. | **Download results** | """ ) -st.page_link("content/workflow_configure.py", label="Go to Configure", icon="โš™๏ธ") - -st.markdown( - """ -### 3. Run Workflow -Execute the analysis pipeline and monitor progress. -""" +st.caption( + "The sections **Fine-tune the comparison** and **Quality control and details** in the sidebar " + "are for adjusting the statistics step by step and for checking the identifications in depth. " + "You can ignore them for a first analysis." ) -st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") -st.markdown( - """ -### 4. Explore Results -View identification results, quantification tables, and statistical visualizations. -- **Database Search**: View Comet PSM identification results -- **Rescoring**: Examine Percolator statistical validation output -- **Filtered PSMs**: Inspect FDR-controlled peptide identifications -- **Abundance**: Protein and PSM quantification tables with statistics -- **Volcano Plot**: Differential expression analysis visualization -- **PCA**: Principal component analysis of sample relationships -- **Heatmap**: Hierarchically clustered expression patterns -- **Spectral Library**: Download the generated spectral library for DIA/SWATH analysis +st.markdown("## Two kinds of experiments") +lfq_col, tmt_col = st.columns(2) +with lfq_col: + st.markdown( + """ +### Label-free (LFQ) +Each sample was measured in its own run. Proteins are compared by the intensity of +their peptides across runs. Choose this if you did not label your samples. """ -) - -st.markdown("### 5. Run Differential Protein Analysis") - -st.markdown( - """ -After quantification, continue through the downstream analysis pages in order: - -| Step | Available analysis | -|------|--------------------| -| **1. Filtering** | Remove proteins with low abundance, low repeatability, or low variance | -| **2. Imputation** | Handle missing values using MAR mean/median strategies or MNAR small-value replacement | -| **3. Normalization** | Apply mathematical transformation, sample normalization, and optional row scaling | -| **4. Statistics** | Test group differences and apply BH, Bonferroni, or no multiple-testing correction | -| **Volcano** | Explore effect size and adjusted-significance thresholds | -| **PCA** | Inspect sample separation using high-variance proteins | -| **Heatmap** | View abundance patterns across proteins and samples | -| **Clustered Heatmap** | Explore hierarchically clustered proteins and samples | -| **Pathway Analysis** | Run GO enrichment on statistically significant proteins | - -Each preprocessing page uses the output of the preceding step when available. The recommended -order is **Filtering โ†’ Imputation โ†’ Normalization โ†’ Statistics โ†’ Plots/Pathway Analysis**. + ) +with tmt_col: + st.markdown( + """ +### Tandem Mass Tags (TMT) +Several samples were labelled with isobaric tags, pooled and measured together. +Proteins are compared by the tag (reporter ion) intensities within each run. """ -) - -analysis_links = st.columns(4) -with analysis_links[0]: - st.page_link("content/filtering.py", label="Filtering", icon="๐Ÿงน") -with analysis_links[1]: - st.page_link("content/imputation.py", label="Imputation", icon="๐Ÿฉน") -with analysis_links[2]: - st.page_link("content/normalization.py", label="Normalization", icon="โš–๏ธ") -with analysis_links[3]: - st.page_link("content/statistical.py", label="Statistics", icon="๐Ÿ”ข") - -st.markdown("## Workspaces") + ) +st.caption("You choose the experiment type on the Describe experiment page.") -st.markdown( - """ -**Workspaces** store your inputs, parameters, and results for each analysis session. +with st.expander("๐Ÿ“– What the analysis does behind the scenes"): + st.markdown( + """ +The pipeline mirrors the DDA-LFQ and DDA-ISO (TMT) branches of the +[quantms](https://quantms.org) Nextflow workflow and uses OpenMS tools throughout: + +| Stage | What it does | Tool | +|---|---|---| +| Identification | Matches each fragmentation spectrum to a peptide from your FASTA file | Comet | +| Rescoring | Re-ranks the matches with machine learning to separate true from false ones | Percolator | +| Filtering | Keeps matches at a 1 % false discovery rate, judged against reversed "decoy" sequences | IDFilter | +| Quantification (LFQ) | Measures each peptide's signal in every run and sums it per protein | ProteomicsLFQ | +| Quantification (TMT) | Reads the reporter-ion intensities and sums them per protein | IsobaricAnalyzer, ProteinQuantifier | +| Group comparison | Filters, fills in missing values, normalizes and tests every protein | Built in | +""" + ) -- In **online mode**: The workspace ID is embedded in the URL for easy sharing -- In **local mode**: Create and manage separate workspaces for different projects +with st.expander("๐Ÿ’พ Workspaces: where your data and results live"): + st.markdown( + """ +A **workspace** keeps your files, settings and results together. -Your workspace persists between sessions, allowing you to return to a running or completed -workflow at any time. +- **Online**, the workspace ID is part of the page address. Bookmark it to come back + to a running or finished analysis, or share it with a colleague. +- **On your own computer**, create a separate workspace per project in the sidebar. """ -) + ) diff --git a/content/results_abundance.py b/content/results_abundance.py index 1a5fd8b..5a69336 100644 --- a/content/results_abundance.py +++ b/content/results_abundance.py @@ -3,19 +3,28 @@ import pandas as pd from pathlib import Path from src.common.common import page_setup +from src.common.guide import needs_results, next_steps, page_intro from src.common.results_helpers import get_workflow_dir, get_abundance_data from src.workflow.ParameterManager import ParameterManager from src.common.insight_tables import show_insight_table from src.common.contaminants import CONTAMINANT_PREFIX, contaminant_mask params = page_setup() -st.title("Abundance Quantification") - -st.markdown( - """ -View protein and PSM-level quantification from **ProteomicsLFQ**. -This page focuses on raw abundance intensity for preprocessing. -""" +st.title("Protein abundances") + +page_intro( + "How much of each protein is in each sample? One row per protein, one column per sample, " + "with the raw intensities measured by the mass spectrometer.", + background=""" +- **Intensities are relative**, not absolute amounts: they compare one protein across samples, + not different proteins with each other (some peptides simply fly better in the instrument). +- **Empty cells** mean the protein was not detected in that sample. The group comparison fills + them in; see *Fine-tune the comparison โ†’ 2. Missing values*. +- Values here are **not yet normalized**. For comparisons, use **Compare groups**, which + log-transforms and normalizes them first. +- **Contaminants** (keratins from skin, trypsin, BSA, ...) are hidden; switch them on to see them. +""", + background_title="How to read these tables", ) if "workspace" not in st.session_state: @@ -30,9 +39,7 @@ analysis_mode = workflow_params.get("analysis-mode", "LFQ") if not quant_dir.exists(): - st.info("No quantification results available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") - st.stop() + needs_results() csv_files = sorted(quant_dir.glob("*.csv")) @@ -57,11 +64,7 @@ def render_protein_table(pivot_df, is_lfq=True): """Common function to render the protein-level abundance table""" pivot_df = pivot_df.copy() - st.markdown("### Protein-Level Abundance Table") - st.info( - "This protein-level table is generated by grouping all PSMs that map to the " - "same protein and aggregating their intensities across samples." - ) + st.caption("Each protein's intensity is the sum of its peptides' intensities in that sample.") if is_lfq: # Handle LFQ mode columns (Raw Intensity) @@ -108,7 +111,7 @@ def render_protein_table(pivot_df, is_lfq=True): st.caption(f"{hidden} contaminant protein(s) hidden.") if analysis_mode == "LFQ": - protein_tab, psm_tab = st.tabs(["Protein Table", "PSM-level Quantification Table"]) + protein_tab, psm_tab = st.tabs(["Proteins", "Peptides (per spectrum match)"]) with protein_tab: if result is None: @@ -119,11 +122,9 @@ def render_protein_table(pivot_df, is_lfq=True): render_protein_table(pivot_df, is_lfq=True) with psm_tab: - st.markdown("### PSM-level Quantification Table") - st.info( - "This table shows the PSM-level quantification data, including protein IDs, " - "peptide sequences, charge states, and intensities across samples. " - "Each row represents one peptide-spectrum match detected from the MS/MS analysis." + st.caption( + "The peptide measurements behind the protein table: one row per peptide and charge " + "state, with its protein, sequence and intensity in every sample." ) psm_df = df.copy() if "ProteinName" in psm_df.columns: @@ -141,7 +142,7 @@ def render_protein_table(pivot_df, is_lfq=True): ) else: - pre_processing_tab, protein_tab = st.tabs(["Pre-processing", "Protein Table"]) + pre_processing_tab, protein_tab = st.tabs(["Intensity matrix", "Proteins"]) if result is None: st.info("๐Ÿ’ก Please run the workflow first to see results.") @@ -150,7 +151,6 @@ def render_protein_table(pivot_df, is_lfq=True): pivot_df, expr_df, group_map = result with pre_processing_tab: - st.write("### Final Results (Intensity matrix)") show_insight_table( pivot_df, name="abundance_intensity_matrix", @@ -166,12 +166,7 @@ def render_protein_table(pivot_df, is_lfq=True): except Exception as e: st.error(f"Failed to load {csv_file.name}: {e}") -st.markdown("---") -st.markdown("**Next steps:** Continue preprocessing, then run statistical inference") -col1, col2, col3 = st.columns(3) -with col1: - st.page_link("content/filtering.py", label="Filtering", icon="๐Ÿงน") -with col2: - st.page_link("content/imputation.py", label="Imputation", icon="๐Ÿงฉ") -with col3: - st.page_link("content/statistical.py", label="Statistical Inference", icon="๐Ÿ”ฌ") \ No newline at end of file +next_steps( + ("compare", "Test which proteins differ between your groups."), + ("download", "Get this table as a file."), +) diff --git a/content/results_database_search.py b/content/results_database_search.py index 397644b..42d395b 100644 --- a/content/results_database_search.py +++ b/content/results_database_search.py @@ -2,23 +2,31 @@ import streamlit as st from pathlib import Path from src.common.common import page_setup +from src.common.guide import needs_results, next_steps, page_intro from src.common.results_helpers import get_workflow_dir 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") - -st.markdown( - """ -View peptide-spectrum matches (PSMs) identified by **Comet** database search. -Click on a PSM to view the annotated spectrum and peptide sequence. -""" +st.title("Raw search hits (Comet)") + +page_intro( + 'The best peptide match for every spectrum straight from the database search, before rescoring and filtering. Many of these matches are wrong; this page is for checking the search itself.', + background=""" +- A **PSM** (peptide-spectrum match) is one fragmentation spectrum explained by one peptide. +- The **plot** shows every PSM by retention time (when the peptide came off the column) and + mass; **click a PSM** in the table to see its spectrum with the matched fragment ions + (b ions from the peptide's start, y ions from its end) and its sequence. +- The analysis identifies peptides in three steps: the database search (Comet) proposes the best + peptide per spectrum, rescoring (Percolator) re-ranks the matches, and filtering keeps those + within the false discovery rate. This page shows the result of one step. +""", + background_title='What you are looking at', ) st.info( - "**Score:** The e-value (expectation value) represents the expected number of random PSMs " - "with an equal or better score. Lower values indicate higher confidence identifications." + "**Score: e-value**, the number of random matches expected to score this well by chance. " + "Lower is better; values far below 1 indicate a confident match." ) if "workspace" not in st.session_state: @@ -30,9 +38,7 @@ cache_dir = workflow_dir / "results" / "insight_cache" if not comet_dir.exists(): - st.info("No database search results available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") - st.stop() + needs_results() comet_files = sorted(comet_dir.glob("*.idXML")) @@ -75,6 +81,7 @@ st.subheader("MS2 Spectrum") line_plot(key=f"lineplot_{cache_id_prefix}", state_manager=state_manager, height=450, sequence_view_key=f"seqview_{cache_id_prefix}") -st.markdown("---") -st.markdown("**Next step:** View rescoring results") -st.page_link("content/results_rescoring.py", label="Go to Rescoring", icon="๐Ÿ“ˆ") +next_steps( + ('rescoring', 'The same matches after rescoring.'), + ('filtered', 'The final, filtered matches.'), +) diff --git a/content/results_download.py b/content/results_download.py index 476f382..f5eee73 100644 --- a/content/results_download.py +++ b/content/results_download.py @@ -1,21 +1,29 @@ """Download Results Page.""" import streamlit as st from src.common.common import page_setup +from src.common.guide import page_intro from src.WorkflowTest import WorkflowTest params = page_setup() wf = WorkflowTest() -st.title("Download Results") +st.title("Download results") -st.markdown( +page_intro( """ -Every file the workflow produced: identifications (idXML) from each search step, -quantification tables, the spectral library if one was built, and the exports in -**exports/**: all PSMs per run (`*_psms.tsv`) and every PSM's matched fragment -ions per scan (`*_fragment_ions.tsv`). -""" +Every file the analysis produced, singly or as one ZIP. The ones most people need: + +- **quant_results/**: the protein and peptide abundance table (CSV, opens in Excel or R). +- **exports/**: all peptide-spectrum matches per run (`*_psms.tsv`) and each match's + fragment ions (`*_fragment_ions.tsv`). +- **psm_filter/**, **percolator_results/**, **comet_results/**: identifications after each + step in the open idXML format, for OpenMS and other proteomics software. +- The spectral library, if you built one. + +The group comparison (fold changes and p-values) is downloaded from the table on the +**Compare groups** page with the table's download button. +""", ) wf.show_results_download_section(exclude=["insight_cache"]) diff --git a/content/results_filtered.py b/content/results_filtered.py index 05e4c92..7002a3c 100644 --- a/content/results_filtered.py +++ b/content/results_filtered.py @@ -5,25 +5,32 @@ from src.common.results_helpers import get_workflow_dir from openms_insight import Heatmap, LinePlot, SequenceView, StateManager from src.common.common import show_fig +from src.common.guide import needs_results, next_steps, page_intro from src.common.fragment_plots import fragment_error_plot, ion_table, ion_table_html, load_psm_fragments from src.workflow.ParameterManager import ParameterManager 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") - -st.markdown( - """ -View FDR-controlled peptide identifications after **IDFilter** processing. -These are high-confidence PSMs that passed the specified FDR threshold. -Click on a PSM to view the annotated spectrum and peptide sequence. -""" +st.title("Peptide-spectrum matches") + +page_intro( + 'The final, filtered peptide identifications of each run: one row per spectrum explained by a peptide. Select a match to check its spectrum, the fragment ions that support it and their mass errors.', + background=""" +- A **PSM** (peptide-spectrum match) is one fragmentation spectrum explained by one peptide. +- The **plot** shows every PSM by retention time (when the peptide came off the column) and + mass; **click a PSM** in the table to see its spectrum with the matched fragment ions + (b ions from the peptide's start, y ions from its end) and its sequence. +- The analysis identifies peptides in three steps: the database search (Comet) proposes the best + peptide per spectrum, rescoring (Percolator) re-ranks the matches, and filtering keeps those + within the false discovery rate. This page shows the result of one step. +""", + background_title='What you are looking at', ) st.info( - "**Score:** The q-value represents the minimum false discovery rate (FDR) at which this PSM " - "would be accepted. Lower values indicate higher confidence identifications." + "**Score: q-value**, the lowest false discovery rate at which this match would still be " + "accepted. 0.01 means it passes a 1 % FDR; lower is more confident." ) if "workspace" not in st.session_state: @@ -35,9 +42,7 @@ cache_dir = workflow_dir / "results" / "insight_cache" if not filter_dir.exists(): - st.info("No filtered results available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") - st.stop() + needs_results() filter_files = sorted(filter_dir.glob("*.idXML")) @@ -109,6 +114,7 @@ st.markdown("**b/y ions** (theoretical m/z; matched ions highlighted, neutral losses not counted)") st.html(ion_table_html(values, matched)) -st.markdown("---") -st.markdown("**Next step:** View abundance quantification") -st.page_link("content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹") +next_steps( + ('qc', 'Overall quality of the identifications.'), + ('proteins', 'The proteins these peptides add up to.'), +) diff --git a/content/results_heatmap.py b/content/results_heatmap.py index caf93cd..a005f17 100644 --- a/content/results_heatmap.py +++ b/content/results_heatmap.py @@ -3,24 +3,27 @@ import numpy as np import polars as pl from src.common.common import page_setup +from src.common.insight_tables import plot_cache +from src.common.guide import ensure_group_comparison, needs_results, next_steps, page_intro, show_comparison_settings from src.common.results_helpers import ( get_abundance_data, get_active_table, get_id_column, get_sample_group_map, log2_matrix, - show_pipeline_banner, ) from openms_insight import Heatmap params = page_setup() st.title("Heatmap") -st.markdown( - """ -Interactive hierarchically clustered heatmap of protein-level abundance (Z-score normalized). -Powered by OpenMS-Insight multi-resolution engine. -""" +page_intro( + 'The most variable proteins (rows) across samples (columns), in the order of your groups. For proteins and samples sorted by similarity, use the clustered heatmap.', + background=""" +Colors show each protein relative to its own average (Z-score): red is above, blue below. +Blocks of color that follow the sample groups point to group-specific proteins. +""", + background_title='How to read this heatmap', ) if "workspace" not in st.session_state: @@ -29,9 +32,7 @@ result = get_abundance_data(st.session_state["workspace"]) if result is None: - st.info("Abundance data not available. Please run the workflow and configure sample groups first.") - st.page_link("content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹") - st.stop() + needs_results() pivot_df, expr_df, group_map = result id_col = get_id_column(st.session_state["workspace"], pivot_df) @@ -39,7 +40,8 @@ # Plot the same protein table as PCA and Statistics: the latest downstream # step's output, on a log2 scale, proteins with any missing value dropped. -show_pipeline_banner() +# Plots follow the group comparison, run with the recommended settings if needed +ensure_group_comparison(params, show_errors=False) base_df, _, is_log2 = get_active_table(pivot_df) # expr_df from the workflow lists exactly the sample columns in both LFQ and TMT mode sample_cols = [c for c in expr_df.columns if c in base_df.columns] @@ -85,9 +87,7 @@ # Initialize the OpenMS-Insight Heatmap component and map attributes heatmap_component = Heatmap( - cache_id="quantms_protein_heatmap", - # Data changes whenever an upstream step reruns; rebuild rather than reuse - regenerate_cache=True, + **plot_cache("quantms_protein_heatmap", melted_df), x_column="Sample", y_column=id_col, data=heatmap_pl_lazy, @@ -113,10 +113,9 @@ else: st.warning("Insufficient data to generate the heatmap.") -st.markdown("---") -st.markdown("**Other visualizations:**") -col1, col2 = st.columns(2) -with col1: - st.page_link("content/results_volcano.py", label="Volcano Plot", icon="๐ŸŒ‹") -with col2: - st.page_link("content/results_pca.py", label="PCA", icon="๐Ÿ“Š") +show_comparison_settings() + +next_steps( + ('clustered', 'Proteins and samples sorted by similarity.'), + ('pca', 'Sample similarity at a glance.'), +) diff --git a/content/results_heatmap_clustered.py b/content/results_heatmap_clustered.py index d97291f..3e0681b 100644 --- a/content/results_heatmap_clustered.py +++ b/content/results_heatmap_clustered.py @@ -3,25 +3,31 @@ import numpy as np import polars as pl from src.common.common import page_setup +from src.common.insight_tables import plot_cache +from src.common.guide import ensure_group_comparison, needs_results, next_steps, page_intro, show_comparison_settings from src.common.results_helpers import ( get_abundance_data, get_active_table, get_id_column, get_sample_group_map, log2_matrix, - show_pipeline_banner, ) from openms_insight import ClusteredHeatmap params = page_setup() -st.title("Clustered Heatmap") - -st.markdown( - """ -A real grid heatmap (rows = proteins, columns = samples) with hierarchical -clustering dendrograms on both axes and a sample-group color bar, powered -by OpenMS-Insight. -""" +st.title("Clustered heatmap") + +page_intro( + 'The most variable proteins (rows) across all samples (columns). Similar samples and similar proteins are placed next to each other, so patterns shared by a group stand out as blocks of color.', + background=""" +- **Colors** show each protein relative to its own average (Z-score): red is above, blue below, + white average. This makes rare and abundant proteins comparable. +- **Trees (dendrograms)** on the sides join the most similar samples or proteins first. If the + sample tree splits along the group color bar at the top, the groups differ consistently. +- **Blocks of rows** with the same color pattern are proteins that rise or fall together, + often members of one complex or pathway. +""", + background_title='How to read this heatmap', ) if "workspace" not in st.session_state: @@ -30,9 +36,7 @@ result = get_abundance_data(st.session_state["workspace"]) if result is None: - st.info("Abundance data not available. Please run the workflow and configure sample groups first.") - st.page_link("content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹") - st.stop() + needs_results() pivot_df, expr_df, group_map = result id_col = get_id_column(st.session_state["workspace"], pivot_df) @@ -40,7 +44,8 @@ # Plot the same protein table as PCA and Statistics: the latest downstream # step's output, on a log2 scale, proteins with any missing value dropped. -show_pipeline_banner() +# Plots follow the group comparison, run with the recommended settings if needed +ensure_group_comparison(params, show_errors=False) base_df, _, is_log2 = get_active_table(pivot_df) # expr_df from the workflow lists exactly the sample columns in both LFQ and TMT mode sample_cols = [c for c in expr_df.columns if c in base_df.columns] @@ -90,10 +95,7 @@ group_colors = {g: group_palette[i % len(group_palette)] for i, g in enumerate(unique_groups)} heatmap_component = ClusteredHeatmap( - cache_id="quantms_clustered_heatmap", - # Data changes whenever an upstream step reruns; rebuild rather than reuse - regenerate_cache=True, - cache_path=str(st.session_state["workspace"]), + **plot_cache("quantms_clustered_heatmap", heatmap_z), id_col=id_col, data=heatmap_lazy, metadata=metadata_pl, @@ -119,10 +121,10 @@ "(dendrograms). If the sample dendrogram splits along the group color bar, the groups differ consistently." ) -st.markdown("---") -st.markdown("**Other visualizations:**") -col1, col2 = st.columns(2) -with col1: - st.page_link("content/results_volcano.py", label="Volcano Plot", icon="๐ŸŒ‹") -with col2: - st.page_link("content/results_heatmap.py", label="Heatmap (original)", icon="๐Ÿ”ฅ") +show_comparison_settings() + +next_steps( + ('enrichment', 'Find the biology behind groups of changed proteins.'), + ('pca', 'Sample similarity at a glance.'), + ('heatmap', 'The same proteins without clustering.'), +) diff --git a/content/results_mass_shifts.py b/content/results_mass_shifts.py index 2c6513f..72e4287 100644 --- a/content/results_mass_shifts.py +++ b/content/results_mass_shifts.py @@ -8,21 +8,26 @@ from openms_insight import LinePlot, StateManager, Table from src.common.common import page_setup +from src.common.guide import needs_results, next_steps, page_intro, page_link from src.common.mass_shifts import MIN_PSMS, mass_shift_summary, psm_mass_shifts from src.common.results_helpers import get_workflow_dir from src.workflow.ParameterManager import ParameterManager params = page_setup() -st.title("Mass Shifts") - -st.markdown( - """ -The difference between each identified PSM's observed precursor mass and the -theoretical mass of its peptide. With the **Open Search** preset (ยฑ500 Da -precursor tolerance) a peptide can be identified with a modification the search -did not include, and recurring shifts reveal which ones. Each shift is matched -to Unimod. Click a peak or a row to list its PSMs. -""" +st.title("Mass shifts") + +page_intro( + 'Which unexpected modifications are in your sample? Each peak is a mass difference between measured and expected peptide masses that recurs across many peptides, matched to known modifications in Unimod. Most useful after a run with the **Open Search** preset.', + background=""" +- A normal search only finds the modifications you list on *Describe experiment*. The + **Open Search** preset widens the allowed peptide mass difference to ยฑ500 Da, so peptides + carrying any other modification are still identified, at a mass offset. +- **Recurring offsets** reveal those modifications: e.g. +16.0 Da is oxidation, +0.98 Da + deamidation, +1.0 Da often a picked 13C isotope peak rather than a modification. +- If an offset matches a modification you care about, add it as a variable modification and + run a normal (closed) search for reliable quantification. +""", + background_title='What mass shifts tell you', ) if "workspace" not in st.session_state: @@ -36,9 +41,7 @@ idxmls = sorted(filter_dir.glob("*.idXML")) if filter_dir.is_dir() else [] if not idxmls: - st.info("No results available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") - st.stop() + needs_results() mzml_names = [Path(f).name for f in workflow_params.get("mzML-files", [])] mtimes = tuple(p.stat().st_mtime for p in idxmls) @@ -63,7 +66,7 @@ def _summary(paths: tuple, names: tuple, mtimes: tuple): "(0 Da and isotope picks). Apply the **Open Search** preset on the Configure page to " "look for unexpected modifications." ) - st.page_link("content/workflow_configure.py", label="Go to Configure", icon="โš™๏ธ") + page_link("configure") if psms.empty: st.info("No target PSMs in the filtered results.") @@ -166,3 +169,8 @@ def _fixed(precision: int) -> dict: Table(cache_id=ids["psms"], cache_path=str(cache_dir))( key="mass_shift_psm_table", state_manager=state_manager, height=420 ) + +next_steps( + ('configure', 'Add a discovered modification to the search.'), + ('filtered', 'Inspect the matches behind a shift.'), +) diff --git a/content/results_overview.py b/content/results_overview.py index d634631..99114f6 100644 --- a/content/results_overview.py +++ b/content/results_overview.py @@ -4,19 +4,32 @@ import streamlit as st from src.common.common import page_setup +from src.common.guide import needs_results, next_steps, page_intro from src.common.insight_tables import show_insight_table from src.common.overview import run_overview from src.common.results_helpers import get_abundance_data, get_workflow_dir from src.workflow.ParameterManager import ParameterManager params = page_setup() -st.title("Run Overview") - -st.markdown( - """ -Key figures of the last run and how each file did: MS2 spectra, identified PSMs -at the PSM FDR, peptides, precursor mass error, rescoring and search time. -""" +st.title("Run summary") + +page_intro( + "Did the run work? The key figures below say how many proteins and peptides were found, " + "and the file table shows whether any sample stands out. Start here after every run.", + background=""" +- **PSM** (peptide-spectrum match): one fragmentation spectrum explained by one peptide. + Every protein is backed by peptides, and every peptide by one or more PSMs. +- **PSMs per MS2 spectrum** (identification rate): the share of fragmentation spectra that + were explained by a peptide. 20 to 50 % is typical for a good tryptic digest on a modern + instrument; much lower values point to the wrong database, wrong modifications or wrong + mass tolerances. +- **Precursor mass error**: how far measured peptide masses lie from the expected ones, in + parts per million (ppm). On an Orbitrap the median should be within a few ppm of 0; + a large offset means the instrument needs calibration. +- A **file far below the others** in PSMs or ID rate usually had a problem during sample + preparation or measurement, and may distort the group comparison. +""", + background_title="How to read these numbers", ) if "workspace" not in st.session_state: @@ -30,9 +43,7 @@ mzml_files = [str(f) for f in workflow_params.get("mzML-files", [])] if not filter_dir.is_dir() or not any(filter_dir.glob("*.idXML")): - st.info("No results available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") - st.stop() + needs_results() log_file = workflow_dir / "logs" / "commands-and-run-times.log" sources = sorted(filter_dir.glob("*.idXML")) + [log_file] @@ -105,6 +116,8 @@ def _show(value, unit: str = "") -> str: layout="fitColumns", ) -st.markdown("---") -st.markdown("**Next step:** Look at the identifications") -st.page_link("content/results_filtered.py", label="Go to Filtered PSMs", icon="๐ŸŽฏ") +next_steps( + ("compare", "See which proteins differ between your groups."), + ("proteins", "Look up a protein of interest and the peptides that support it."), + ("download", "Get the protein and peptide tables for your own analysis."), +) diff --git a/content/results_pca.py b/content/results_pca.py index aa2875d..97838a0 100644 --- a/content/results_pca.py +++ b/content/results_pca.py @@ -3,24 +3,32 @@ import polars as pl import streamlit as st from src.common.common import page_setup -from src.common.insight_tables import show_insight_table +from src.common.guide import ensure_group_comparison, needs_results, next_steps, page_intro, show_comparison_settings +from src.common.insight_tables import plot_cache, show_insight_table from src.common.results_helpers import ( get_abundance_data, get_active_table, get_id_column, get_sample_group_map, - show_pipeline_banner, ) from openms_insight import PCAPlot params = page_setup() -st.title("PCA Analysis") - -st.markdown( - """ -Principal Component Analysis (PCA) of protein-level abundance. -Samples are projected onto their principal components and colored by group assignment to visualize clustering. -""" +st.title("Sample similarity (PCA)") + +page_intro( + 'Do replicates of the same group look alike, and do the groups differ? Each dot is one sample, placed so that samples with similar protein profiles sit close together.', + background=""" +- **PCA** (principal component analysis) condenses thousands of protein abundances into a few + axes that capture most of the differences between samples. **PC1** (x-axis) captures the + largest share, **PC2** the next largest; the percentages below the plot say how much. +- **What you hope to see**: replicates of one group close together, and the groups apart, + ideally along PC1. +- **Warning signs**: a sample far away from its own group (possible outlier: check it on + the Run summary page), or samples grouping by measurement day instead of by condition + (a batch effect). +""", + background_title='How to read a PCA plot', ) if "workspace" not in st.session_state: @@ -30,16 +38,15 @@ # 1. Load abundance data (base wide-format table + sample -> group mapping) result = get_abundance_data(st.session_state["workspace"]) if result is None: - st.info("Abundance data not available. Please run the workflow and configure sample groups first.") - st.page_link("content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹") - st.stop() + needs_results() pivot_df, expr_df, group_map = result id_col = get_id_column(st.session_state["workspace"], pivot_df) sample_group_map = get_sample_group_map(st.session_state["workspace"], pivot_df, group_map) # --- STEP 1: Use the latest downstream step's output (same table as Statistics) --- -show_pipeline_banner() +# Plots follow the group comparison, run with the recommended settings if needed +ensure_group_comparison(params, show_errors=False) base_df, _, _ = get_active_table(pivot_df) # 2. Extract active sample columns and detect unique biological groups @@ -59,18 +66,11 @@ "but group-based coloring requires 2 or more groups." ) -# --- SECTION 1: Active Input Table Preview --- -st.subheader("Input Table Overview") -st.markdown( - f"Currently analyzing **{base_df.shape[0]}** rows across **{len(sample_cols)}** samples " - f"belonging to **{len(unique_groups)} groups** ({', '.join(unique_groups)})." -) -show_insight_table(base_df, name="pca-input") - -st.markdown("---") +with st.expander(f"Data used: {base_df.shape[0]:,} proteins in {len(sample_cols)} samples from {len(unique_groups)} groups"): + show_insight_table(base_df, name="pca-input") # --- SECTION 2: PCA Configuration --- -st.subheader("Configure PCA") + expr_df_wide = base_df.set_index(id_col)[sample_cols] max_available = expr_df_wide.shape[0] @@ -80,7 +80,7 @@ st.caption(f"Using all {top_n} proteins for PCA (dataset too small for variance filtering).") else: top_n = st.slider( - "Number of proteins (Highest Variance)", + "Proteins used (most variable first)", min_value=20, max_value=min(5000, max_available), value=min(500, max_available), @@ -109,9 +109,7 @@ # 3. Initialize the OpenMS-Insight PCAPlot component (computes PCA internally) try: pca_component = PCAPlot( - cache_id="quantms_pca_plot", - # Data changes whenever an upstream step reruns; rebuild rather than reuse - regenerate_cache=True, + **plot_cache("quantms_pca_plot", expr_df_pca), data=pca_lazy, metadata=metadata_pl, sample_id_field="sample_id", @@ -151,10 +149,10 @@ ) st.markdown(f"**Proteins used:** {expr_df_pca.shape[0]} (top {top_n} by variance)") -st.markdown("---") -st.markdown("**Other visualizations:**") -col1, col2 = st.columns(2) -with col1: - st.page_link("content/results_volcano.py", label="Volcano Plot", icon="๐ŸŒ‹") -with col2: - st.page_link("content/results_heatmap.py", label="Heatmap", icon="๐Ÿ”ฅ") +show_comparison_settings() + +next_steps( + ('clustered', 'The same question as a heatmap, with samples clustered by similarity.'), + ('volcano', 'Proteins that differ between the groups.'), + ('overview', "Check an outlying sample's identification numbers."), +) diff --git a/content/results_proteins.py b/content/results_proteins.py index 93d5532..be406aa 100644 --- a/content/results_proteins.py +++ b/content/results_proteins.py @@ -5,6 +5,7 @@ from openms_insight import StateManager from src.common.common import page_setup +from src.common.guide import needs_results, next_steps, page_intro from src.common.insight_tables import show_insight_table from src.common.protein_table import build_tables, collect_psms, coverage_html, read_fasta from src.common.results_helpers import get_workflow_dir @@ -13,13 +14,21 @@ params = page_setup() wf = WorkflowTest() -st.title("Proteins") -st.markdown( - """ -Proteins identified by the FDR-filtered PSMs of all runs, with their sequence -coverage. **Select a protein** to list its peptides and see its coverage map; -**select a peptide** to list its PSMs. -""" +st.title("Identified proteins") +page_intro( + "Every protein found in any sample, with the evidence behind it. **Click a protein** to see " + "which parts of its sequence were covered by peptides; **click a peptide** to see the spectra " + "that identified it. Use the search fields in the table header to look up your protein of interest.", + background=""" +- A protein is identified through its **peptides**, and each peptide through one or more + **PSMs** (peptide-spectrum matches: a fragmentation spectrum explained by that peptide). +- **Coverage** is the share of the protein sequence covered by identified peptides. 10 to 40 % + is normal; trypsin produces some peptides too short or too long to be measured. +- More peptides and PSMs mean more confident identification. Proteins identified by a single + peptide deserve a closer look. +- All identifications passed a 1 % false discovery rate (unless changed on *Describe experiment*). +""", + background_title="How identification evidence works", ) workflow_dir = get_workflow_dir(st.session_state["workspace"]) @@ -28,9 +37,7 @@ idxml_files = sorted(filter_dir.glob("*.idXML")) if filter_dir.exists() else [] if not idxml_files: - st.info("No filtered PSMs available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") - st.stop() + needs_results() fasta_path = None if wf.params.get("fasta-file"): @@ -116,3 +123,8 @@ def decimals(n: int) -> dict: "RT (s)": decimals(1), "m/z": decimals(4), }, ) + +next_steps( + ("compare", "See whether your protein changes between the groups."), + ("abundance", "Its intensity in every sample."), +) diff --git a/content/results_qc.py b/content/results_qc.py index ab74a88..95ab6c9 100644 --- a/content/results_qc.py +++ b/content/results_qc.py @@ -5,19 +5,26 @@ import streamlit as st from src.common.common import page_setup, show_fig +from src.common.guide import needs_results, next_steps, page_intro from src.common.qc_plots import psm_qc_table, precursor_error_histogram, psms_per_file, score_distribution from src.common.results_helpers import get_workflow_dir from src.workflow.ParameterManager import ParameterManager params = page_setup() -st.title("Identification QC") -st.markdown( - """ -Quality checks of the identifications across all runs: how well targets separate -from decoys, how well the precursor masses are calibrated, and whether one run -identified clearly fewer spectra than the others. -""" +st.title("Identification quality") + +page_intro( + "Three checks that the identifications can be trusted: whether real matches score clearly better than decoy (known false) matches, whether the instrument's mass calibration was right, and whether any run identified clearly fewer spectra than the others.", + background=""" +- **Score distribution**: real (target) matches should form a separate hump of good scores next + to the decoys. Heavy overlap means few confident identifications. +- **Precursor mass error**: should be a narrow peak near 0 ppm. A shifted peak means the + instrument was off calibration; a very wide one suggests the mass tolerance is set too wide. +- **PSMs per file**: replicates should be similar. A file far below the rest likely had a + problem in sample preparation or measurement. +""", + background_title='How to read these plots', ) if "workspace" not in st.session_state: @@ -53,9 +60,7 @@ def qc_table(directory: Path) -> pd.DataFrame | None: filtered = qc_table(results_dir / "psm_filter") if all_psms is None and filtered is None: - st.info("No identification results available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") - st.stop() + needs_results() if all_psms is not None and not all_psms.empty: st.subheader("Score distribution") @@ -82,3 +87,8 @@ def qc_table(directory: Path) -> pd.DataFrame | None: show_fig(psms_per_file(filtered), "qc-psms-per-file") elif all_psms is not None: st.info("No filtered PSMs yet: the precursor error and PSMs per file appear once filtering has run.") + +next_steps( + ('overview', 'Key figures and the per-file table.'), + ('filtered', 'Inspect individual matches and their spectra.'), +) diff --git a/content/results_rescoring.py b/content/results_rescoring.py index d5f1b94..dd72bd5 100644 --- a/content/results_rescoring.py +++ b/content/results_rescoring.py @@ -2,24 +2,31 @@ import streamlit as st from pathlib import Path from src.common.common import page_setup +from src.common.guide import needs_results, next_steps, page_intro from src.common.results_helpers import get_workflow_dir 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") - -st.markdown( - """ -View PSMs after **Percolator** statistical validation. Percolator uses machine learning -to re-score PSMs and estimate false discovery rates (FDR) for more accurate results. -Click on a PSM to view the annotated spectrum and peptide sequence. -""" +st.title("Rescored hits (Percolator)") + +page_intro( + 'The search matches after Percolator has re-scored them with machine learning, before filtering. Use it to see how rescoring changed the confidence of a match.', + background=""" +- A **PSM** (peptide-spectrum match) is one fragmentation spectrum explained by one peptide. +- The **plot** shows every PSM by retention time (when the peptide came off the column) and + mass; **click a PSM** in the table to see its spectrum with the matched fragment ions + (b ions from the peptide's start, y ions from its end) and its sequence. +- The analysis identifies peptides in three steps: the database search (Comet) proposes the best + peptide per spectrum, rescoring (Percolator) re-ranks the matches, and filtering keeps those + within the false discovery rate. This page shows the result of one step. +""", + background_title='What you are looking at', ) st.info( - "**Score:** The Posterior Error Probability (PEP) represents the probability that this PSM " - "is incorrect. Lower values indicate higher confidence identifications." + "**Score: PEP** (posterior error probability), the probability that this match is wrong. " + "0.01 means a 1 % chance; lower is more confident." ) if "workspace" not in st.session_state: @@ -31,9 +38,7 @@ cache_dir = workflow_dir / "results" / "insight_cache" if not perc_dir.exists(): - st.info("No rescoring results available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") - st.stop() + needs_results() perc_files = sorted(perc_dir.glob("*.idXML")) @@ -76,6 +81,7 @@ st.subheader("MS2 Spectrum") line_plot(key=f"lineplot_{cache_id_prefix}", state_manager=state_manager, height=450, sequence_view_key=f"seqview_{cache_id_prefix}") -st.markdown("---") -st.markdown("**Next step:** View filtered PSMs") -st.page_link("content/results_filtered.py", label="Go to Filtered PSMs", icon="๐ŸŽฏ") +next_steps( + ('filtered', 'The final, filtered matches.'), + ('qc', 'Overall quality of the identifications.'), +) diff --git a/content/results_samples.py b/content/results_samples.py index 4c0ffd4..39facdb 100644 --- a/content/results_samples.py +++ b/content/results_samples.py @@ -5,20 +5,19 @@ import streamlit as st from src.common.common import page_setup, show_fig +from src.common.guide import next_steps, page_intro, page_link from src.common.insight_tables import show_insight_table from src.common.results_helpers import get_abundance_data, get_sample_group_map, get_workflow_dir from src.common.samples import COUNT_COLUMNS, channel_samples, condition_summary, lfq_samples from src.workflow.ParameterManager import ParameterManager params = page_setup() -st.title("Samples") - -st.markdown( - """ -How many PSMs, peptides and proteins each sample contributed, grouped by the -conditions set on the Configure page. A sample far below the others in its -condition points to a failed run or a loading problem. -""" +st.title("Per-sample counts") + +page_intro( + "How many spectrum matches, peptides and proteins did each sample yield? Replicates of one " + "group should give similar numbers; a sample far below the others points to a failed run or " + "less material loaded, and may distort the group comparison." ) if "workspace" not in st.session_state: @@ -47,7 +46,7 @@ def _lfq_samples(workflow_dir: str, files: tuple, groups: tuple, mtimes: tuple): filter_dir = results_dir / "psm_filter" if not filter_dir.is_dir() or not any(filter_dir.glob("*.idXML")): st.info("No results available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") + page_link("run") st.stop() mzml_files = [str(f) for f in workflow_params.get("mzML-files", [])] group_map = { @@ -68,7 +67,7 @@ def _lfq_samples(workflow_dir: str, files: tuple, groups: tuple, mtimes: tuple): abundance = get_abundance_data(st.session_state["workspace"]) if abundance is None: st.info("No quantification result available yet. Please run the workflow first.") - st.page_link("content/workflow_run.py", label="Go to Run", icon="๐Ÿš€") + page_link("run") st.stop() pivot_df, _, group_map = abundance sample_group_map = get_sample_group_map(st.session_state["workspace"], pivot_df, group_map) @@ -138,5 +137,10 @@ def _table_height(rows: int) -> int: layout="fitColumns", ) if (samples["Condition"] == "Unassigned").any(): - st.caption("Samples without a condition are shown as Unassigned. Set conditions on the Configure page.") - st.page_link("content/workflow_configure.py", label="Go to Configure", icon="โš™๏ธ") + st.caption("Samples without a group are shown as Unassigned. Set groups on the Describe experiment page.") + page_link("configure") + +next_steps( + ("overview", "Identification rate and mass error per file, to find out what went wrong with a weak sample."), + ("pca", "Check whether a weak sample also stands apart in its protein profile."), +) diff --git a/content/results_volcano.py b/content/results_volcano.py index e9d02d3..7e6d5fc 100644 --- a/content/results_volcano.py +++ b/content/results_volcano.py @@ -2,45 +2,55 @@ import streamlit as st import polars as pl from src.common.common import page_setup -from src.common.results_helpers import get_abundance_data, get_id_column, show_pipeline_banner +from src.common.differential import comparison_groups +from src.common.guide import ensure_group_comparison, needs_results, next_steps, page_intro, show_comparison_settings +from src.common.results_helpers import get_abundance_data, get_id_column, get_sample_group_map +from src.common.insight_tables import plot_cache from openms_insight import VolcanoPlot params = page_setup() -st.title("Volcano Plot") - -st.markdown( - """ -Visualize differential expression analysis with a volcano plot. -Points represent proteins colored by significance status. -""" +st.title("Volcano plot") + +page_intro( + "Every dot is a protein. The further **right or left**, the larger the change between your " + "groups; the **higher**, the more confident the test is that the change is real. Proteins in " + "the upper corners change strongly *and* reliably: these are your candidates.", + background=""" +- **x-axis, log2 fold change**: +1 means twice as abundant in the compared group, +2 four times, + โˆ’1 half as abundant. +- **y-axis, โˆ’log10 adjusted p-value**: 1.3 corresponds to an adjusted p-value of 0.05, 2 to 0.01. + The adjustment (Benjamini-Hochberg) accounts for testing thousands of proteins at once. +- **Cut-offs**: a protein is called up (red) or down (blue) when it passes both sliders. + |log2 fold change| โ‰ฅ 1 and adjusted p โ‰ค 0.05 are common choices; loosen them to explore, + tighten them for a short, high-confidence list. +- Hover over a dot to see the protein. +""", + background_title="How to read a volcano plot", ) if "workspace" not in st.session_state: st.warning("Please initialize your workspace first.") st.stop() -# 1. Check if statistical analysis results are available in the session state -if "statistics_df" not in st.session_state or st.session_state["statistics_df"] is None: - st.info("Statistical analysis data not found. Please run the statistical engine first.") - st.page_link("content/statistical.py", label="Go to Statistical Inference", icon="๐Ÿ”ฌ") - st.stop() +result = get_abundance_data(st.session_state["workspace"]) +if result is None: + needs_results() -# Retrieve the completed statistical analysis DataFrame -show_pipeline_banner(uses="statistics") -statistics_df = st.session_state["statistics_df"] +# Computed with the recommended settings unless the Fine-tune pages already did +statistics_df = ensure_group_comparison(params) +if statistics_df is None: + st.stop() if statistics_df.empty: st.info("No data available for volcano plot.") st.stop() -result = get_abundance_data(st.session_state["workspace"]) -if result is None: - st.info("Abundance data not available. Please run the workflow and configure sample groups first.") - st.page_link("content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹") - st.stop() - pivot_df, expr_df, group_map = result id_col = get_id_column(st.session_state["workspace"], pivot_df) +sample_group_map = get_sample_group_map(st.session_state["workspace"], pivot_df, group_map) +direction = comparison_groups(sample_group_map, [c for c in pivot_df.columns if c in sample_group_map]) +if direction: + st.markdown(f"Right: higher in **{direction[1]}**. Left: higher in **{direction[0]}**.") # 2. Clean data and convert to Polars for component input volcano_df = statistics_df.dropna(subset=["log2FC", "p-adj"]).copy() @@ -48,26 +58,26 @@ # 3. Configure UI sliders (changing thresholds does not invalidate cache) fc_thresh = st.slider( - "log2 Fold Change threshold", + "Minimum change (|log2 fold change|)", min_value=0.5, max_value=3.0, value=1.0, step=0.1, + help="1 = at least twice (or half) as abundant.", ) p_thresh = st.slider( - "p-adj (FDR) threshold", + "Maximum adjusted p-value", min_value=0.001, max_value=0.1, value=0.05, step=0.001, + help="0.05 means about 5 % of the proteins called significant are expected to be false positives.", ) # 4. Initialize the OpenMS-Insight VolcanoPlot component volcano_plot_component = VolcanoPlot( - cache_id="quantms_volcano_plot", - # Data changes whenever an upstream step reruns; rebuild rather than reuse - regenerate_cache=True, + **plot_cache("quantms_volcano_plot", volcano_df), data=volcano_pl_lazy, log2fc_column="log2FC", pvalue_column="p-adj", @@ -89,20 +99,17 @@ max_labels=10, # Display labels for the top N significant proteins height=600, ) -st.caption( - "Each point is a protein: effect size (log2 fold change) against significance (-log10 adjusted p-value). " - "Proteins beyond both thresholds are called up- or down-regulated; loosen the sliders to explore, tighten them for a high-confidence list." -) -# 6. Keep the existing statistical summary and bottom links up_count = ((volcano_df["p-adj"] <= p_thresh) & (volcano_df["log2FC"] >= fc_thresh)).sum() down_count = ((volcano_df["p-adj"] <= p_thresh) & (volcano_df["log2FC"] <= -fc_thresh)).sum() -st.markdown(f"**Up-regulated:** {up_count} | **Down-regulated:** {down_count}") - -st.markdown("---") -st.markdown("**Other visualizations:**") -col1, col2 = st.columns(2) -with col1: - st.page_link("content/results_pca.py", label="PCA", icon="๐Ÿ“Š") -with col2: - st.page_link("content/results_heatmap.py", label="Heatmap", icon="๐Ÿ”ฅ") +if direction: + st.markdown(f"**Higher in {direction[1]}:** {up_count} | **Lower in {direction[1]}:** {down_count}") +else: + st.markdown(f"**Up-regulated:** {up_count} | **Down-regulated:** {down_count}") +show_comparison_settings() + +next_steps( + ("enrichment", "Find biological processes over-represented among these proteins."), + ("compare", "The full table of compared proteins, most significant first."), + ("pca", "Check that samples of the same group look alike."), +) diff --git a/content/statistical.py b/content/statistical.py index e814e53..cf079bc 100644 --- a/content/statistical.py +++ b/content/statistical.py @@ -5,6 +5,7 @@ import polars as pl import streamlit as st from src.common.common import page_setup, save_params, show_fig +from src.common.guide import needs_results, next_steps, page_intro from src.common.postprocessing_plots import pvalue_histogram from src.common.insight_tables import show_insight_table from src.common.results_helpers import ( @@ -17,12 +18,25 @@ from openms_insight.analysis.statistics import calculate_statistical_tests, adjust_fdr_lazy params = page_setup() -st.title("Step 4 of 4: Statistics") - -st.markdown( - """ -Run differential expression analysis to identify statistically significant proteins across your biological groups. -""" +st.title("Fine-tune 4 of 4: Statistical test") + +page_intro( + 'Test every protein for a difference between the groups and correct for testing thousands of proteins at once.' + "\n\n" + 'You do not need this page for a standard analysis: **Compare groups** runs all four steps with the recommended settings. Use it to inspect this step or change it; the plots follow your choice.', + background=""" +- **limma-like (recommended)**: a t-test that borrows information across all proteins to judge + each protein's variability. It is more reliable than a plain t-test with the few replicates + typical in proteomics, and works for two or more groups. +- **Welch**: a plain t-test that allows different variability per group (two groups only). +- **Paired**: for matched samples, e.g. the same patient before and after treatment (two groups, + same number and order of samples). +- **ANOVA**: tests whether any of several groups differs. +- **Multiple-testing correction**: testing 3,000 proteins at p < 0.05 would call about 150 + significant by chance alone. **Benjamini-Hochberg (BH, recommended)** controls the false + discovery rate: of the proteins with adjusted p โ‰ค 0.05, about 5 % are expected to be false. + *Bonferroni* is much stricter and misses real changes. +""", + background_title='Which test to choose', ) if "workspace" not in st.session_state: @@ -32,13 +46,7 @@ # Load primary database assets result = get_abundance_data(st.session_state["workspace"]) if result is None: - st.info( - "Abundance data not available. Please run the workflow and configure sample groups first." - ) - st.page_link( - "content/results_abundance.py", label="Go to Abundance", icon="๐Ÿ“‹" - ) - st.stop() + needs_results() pivot_df, expr_df, group_map = result id_col = get_id_column(st.session_state["workspace"], pivot_df) @@ -50,30 +58,22 @@ and st.session_state["normalized_df"] is not None ): base_df = st.session_state["normalized_df"] - st.info( - "๐Ÿ”„ **Upstream Pipeline Detected**: Using data processed from the **Normalization** step." - ) + st.info("Working on the output of step 3 (Normalize).") elif ( "imputed_df" in st.session_state and st.session_state["imputed_df"] is not None ): base_df = st.session_state["imputed_df"] - st.warning( - "โš ๏ธ **Normalization Skipped**: Using data processed from the **Imputation** step." - ) + st.warning("Step 3 (Normalize) has not been applied: working on the output of step 2.") elif ( "filtered_df" in st.session_state and st.session_state["filtered_df"] is not None ): base_df = st.session_state["filtered_df"] - st.warning( - "โš ๏ธ **Preprocessing Skipped**: Using data processed from the **Filtering** step." - ) + st.warning("Steps 2 and 3 have not been applied: working on the output of step 1.") else: base_df = pivot_df - st.warning( - "โš ๏ธ **Raw Input Active**: No preprocessing history found. Operating on the original table." - ) + st.warning("Steps 1 to 3 have not been applied: working on the unprocessed table.") # log2FC below is the difference of group means, which is only a log2 fold # change when the input is log2-scaled (the Normalization default). @@ -97,17 +97,11 @@ unique_groups = sorted(list(set([sample_group_map[s] for s in sample_cols if s in sample_group_map]))) group_count = len(unique_groups) -# --- SECTION 1: Active Input Table Preview --- -st.subheader("Input Table Overview") -st.markdown( - f"Currently analyzing **{base_df.shape[0]}** rows across **{len(sample_cols)}** samples belonging to **{group_count} groups** ({', '.join(unique_groups)})." -) -show_insight_table(base_df, name="statistical-input") - -st.markdown("---") +with st.expander(f"Input: {base_df.shape[0]:,} proteins in {len(sample_cols)} samples from {group_count} groups ({', '.join(unique_groups)})"): + show_insight_table(base_df, name="statistical-input") # --- SECTION 2: Dynamic Statistical Parameter Configuration --- -st.subheader("Configure Statistical Engine") +st.subheader("Settings") # Prepare structural Polars metadata DataFrame required by backend functions metadata_rows = [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols if s in sample_group_map] @@ -118,7 +112,7 @@ col1, col2 = st.columns(2) with col1: - st.markdown("### ๐Ÿ”ฌ 1. Hypothesis Testing Method") + st.markdown("**1. Statistical test**") if group_count < 2: st.error("โŒ Statistical testing requires at least 2 unique sample groups.") @@ -133,7 +127,7 @@ help_text = "'limma_like' supports multi-group design matrices. 'anova' computes standard row-wise One-way ANOVA." selected_method = st.selectbox( - "Select Statistical Test", + "Test", options=method_options, index=method_options.index( postprocessing_param(params, "postproc-stat-test", method_options) @@ -143,10 +137,10 @@ ) with col2: - st.markdown("### ๐Ÿ›ก๏ธ 2. Multiple Testing Correction (FDR)") + st.markdown("**2. Correction for testing many proteins**") fdr_options = ["BH", "Bonferroni", "None"] selected_fdr = st.selectbox( - "Select FDR Adjustment Strategy", + "Correction", options=fdr_options, index=fdr_options.index( postprocessing_param(params, "postproc-fdr", fdr_options) @@ -159,7 +153,7 @@ # --- SECTION 3: Statistical Query Execution --- st.markdown("
", unsafe_allow_html=True) -if st.button("Run Statistical Analysis", type="primary"): +if st.button("Run the test", type="primary"): # Convert active pandas dataframe into polars lazyframe graph stats_lazy = pl.from_pandas(base_df).lazy() @@ -183,11 +177,12 @@ # ๐Ÿ’พ Save processing checkpoint inside Session State for Downstream (e.g., Volcano plot, Volcano/Heatmap UI) st.session_state["statistics_df"] = statistics_df + st.session_state.pop("comparison_settings", None) st.success(f"Successfully calculated **{selected_method}** test with **{selected_fdr}** FDR correction!") except ValueError as val_err: - st.error(f"Engine Validation Fallure: {str(val_err)}") + st.error(f"The test could not run: {val_err}") except Exception as e: st.error(f"An unexpected pipeline error occurred: {str(e)}") @@ -197,8 +192,8 @@ statistics_df = st.session_state.get("statistics_df") if statistics_df is not None: # Display the finalized statistics table view - st.subheader("Statistical Analysis Results") - st.markdown(f"Generated framework containing columns: `{id_col}`, `log2FC`, `stat`, `p-value`, `p-adj`") + st.subheader("Result") + st.caption("log2FC: log2 fold change between the groups; p-adj: p-value adjusted for testing many proteins.") show_insight_table(statistics_df, name="statistical-result") # --- SECTION 4: Test diagnostics --- @@ -209,3 +204,8 @@ "the test fits and some proteins change; a peak near 1 or a U-shape points to non-log input or the wrong test, and no peak at all means few proteins differ." ) show_fig(pvalue_histogram(st.session_state["statistics_df"]), "statistics-pvalue-histogram") + +next_steps( + ('volcano', 'See the result as a volcano plot.'), + ('compare', 'Back to the overview of the comparison.'), +) diff --git a/content/workflow_configure.py b/content/workflow_configure.py index 6bea2b7..927cc99 100644 --- a/content/workflow_configure.py +++ b/content/workflow_configure.py @@ -1,5 +1,6 @@ import streamlit as st from src.common.common import page_setup +from src.common.guide import page_intro from src.WorkflowTest import WorkflowTest @@ -7,4 +8,27 @@ wf = WorkflowTest() +st.title("Describe your experiment") +page_intro( + """ +For most experiments only three things need your attention: + +1. **Instrument preset** (buttons below): sets the mass tolerances for your mass spectrometer. +2. **Experiment type**: label-free (LFQ) or labelled (TMT/iTRAQ). +3. **Samples and groups** (first tab): which sample belongs to which condition. + +Everything else starts at values that suit a typical experiment and is saved as you go. +Switch on **Show advanced parameters** only if you need to change a specific setting. +""", + background=""" +- **mzML** is an open file format for raw mass spectrometry data. Convert vendor files + (e.g. Thermo *.raw*) with [ProteoWizard msConvert](https://proteowizard.sourceforge.io/). +- The **FASTA** file lists the protein sequences of your organism, e.g. the reference + proteome from [UniProt](https://www.uniprot.org/proteomes). Only proteins in this file can be found. +- **Modifications** like phosphorylation are added under *Database search*. Each extra + variable modification makes the search slower, so add only those you are interested in. +""", + background_title="Input files and terms explained", +) + wf.show_parameter_section() diff --git a/content/workflow_fileupload.py b/content/workflow_fileupload.py index 02d28e5..5459e21 100644 --- a/content/workflow_fileupload.py +++ b/content/workflow_fileupload.py @@ -1,9 +1,31 @@ import streamlit as st from src.common.common import page_setup +from src.common.guide import page_intro from src.WorkflowTest import WorkflowTest params = page_setup() wf = WorkflowTest() -st.title("Upload mzML files") +st.title("Upload your data") +page_intro( + """ +You need two kinds of files: + +- **MS data (mzML)**: one file per measured run. Convert vendor raw files to mzML first. +- **Protein database (FASTA)**: the protein sequences of your organism. Decoys and common + contaminants are added automatically. + +No data at hand? The app comes with a small demo dataset (yeast with spiked-in human +proteins in two concentrations) that is used when nothing is uploaded. You can also +**import a public dataset from PRIDE** to reanalyse it. +""", + background=""" +- Convert Thermo *.raw*, Bruker *.d* and other vendor formats to mzML with + [ProteoWizard msConvert](https://proteowizard.sourceforge.io/) (keep *peak picking* on). +- Download a reference proteome as FASTA from [UniProt](https://www.uniprot.org/proteomes), + ideally *one protein sequence per gene* to keep the search fast. +""", + background_title="How to get these files", +) + wf.show_file_upload_section() diff --git a/content/workflow_run.py b/content/workflow_run.py index 6853470..34262b5 100644 --- a/content/workflow_run.py +++ b/content/workflow_run.py @@ -1,8 +1,16 @@ import streamlit as st from src.common.common import page_setup +from src.common.guide import page_intro from src.WorkflowTest import WorkflowTest params = page_setup() wf = WorkflowTest() + +st.title("Run the analysis") +page_intro( + "Start the analysis and follow its progress. It runs on the server, so you can close " + "this page and come back later with the same workspace. When it has finished, open " + "**Run summary** under *Main results* in the sidebar." +) wf.show_execution_section() diff --git a/quantms_pca_plot/manifest.json b/quantms_pca_plot/manifest.json new file mode 100644 index 0000000..eb34517 --- /dev/null +++ b/quantms_pca_plot/manifest.json @@ -0,0 +1,50 @@ +{ + "version": 3, + "component_type": "pca", + "created_at": "2026-10-29T09:59:06.125380", + "cache_generation": "2b308cad64f04afeba3f2f4f8378b895", + "config_hash": "54d914aeb5e92cb3a4196ede5d20776cfc7f68f32db27616ede8ca86fa9a1517", + "input_config_hash": "54d914aeb5e92cb3a4196ede5d20776cfc7f68f32db27616ede8ca86fa9a1517", + "config": { + "metadata_map": { + "B000272_Ap_6883_EXT-765_DIA_Yeast_UPS2_ratio01_DDA_2": "UPS2_ratio01", + "B000248_Ap_6883_EXT-765_DIA_Yeast_UPS2_ratio01_DDA": "UPS2_ratio01", + "B000296_Ap_6883_EXT-765_DIA_Yeast_UPS2_ratio01_DDA_3": "UPS2_ratio01", + "B000264_Ap_6883_EXT-765_DIA_Yeast_UPS2_ratio10_DDA": "UPS2_ratio10", + "B000288_Ap_6883_EXT-765_DIA_Yeast_UPS2_ratio10_DDA_2": "UPS2_ratio10", + "B000312_Ap_6883_EXT-765_DIA_Yeast_UPS2_ratio10_DDA_3": "UPS2_ratio10" + }, + "sample_id_field": "sample_id", + "group_field": "group", + "n_components": 5, + "standardize": true, + "title": "Sample PCA", + "x_label": null, + "y_label": null, + "group_colors": {}, + "show_ellipses": true + }, + "filters": {}, + "filter_defaults": {}, + "interactivity": {}, + "data_files": { + "pcaData": "pcaData.parquet" + }, + "data_values": { + "variance_ratio": [ + 0.3911276724027375, + 0.20997202345537408, + 0.1876928501097148, + 0.1284829491286109, + 0.08272450490356281 + ], + "n_components": 5, + "pc_columns": [ + "PC1", + "PC2", + "PC3", + "PC4", + "PC5" + ] + } +} \ No newline at end of file diff --git a/quantms_pca_plot/preprocessed/pcaData.parquet b/quantms_pca_plot/preprocessed/pcaData.parquet new file mode 100644 index 0000000000000000000000000000000000000000..ae21123ffb32733def3d19a8dfa43357031db67d GIT binary patch literal 2816 zcmd5;Z%kWN6u)huP*91qryq=2GNJ~};)lhU%s4}`xOvZ|rEj4jh7ZOE zym#*JoZtDKd+wj$BW*Iy!f$8eI~p(MFhZ#Ga^>HLy~v|OilwC|e%eceeJ_ ztw{ITh*Np^=+P(T44MdbhBS*F(=xPzCaWVvEaZ^e{UcX!W_@>U*+&0WSQr*=G_0d zO1emO*y*L8RK=#x5t`@dDBTE2mL(0UUuvQqEyVjC>nN8FlO;hllH^oILu0B38l8Q! z1KEAU(a|9``)@V;FB|QwLFaSHtcWRoUSnQ1)Y4t@Si($#qlZsW!S{uy zGYGDUR!7Gh=MyK37ZMPsoGGt^NR^8}NL}Ud&6|>s;Di%}?7a1xXug#ZwJg z!_nvn&PSLl(Wy+@L1rN3h`YHGS3^*1@7>s$${Kb_BW z=3IU?uLyRsz>&7L*3Rc+Jx6*6g2HT8B|H|ZlMv#ef5bjU$K!r5>6k%Hgv5x85c>6# z{XXiC$HCu7NMACp2flv#Lu_Dqf$ySzLJq;p4X=%4`+e0kKjSkInipIf5!C7g{W#NQ zSi7fLCN4Jx?M#GuHguhYrJ;e6p)k zj(5M<5e|0x2@+a-(f;6Y%v3rwF>)k2SY{gQ9gan!gC?ayu8>Tn-J_BIkf}OcBPrb> wOE?@-s>5Ns5|XV-wLK`+1eF?FIIM)LgT0{|rA$0VFSM<{NS}?6kN)rb59j-&cmMzZ literal 0 HcmV?d00001 diff --git a/quantms_protein_heatmap/manifest.json b/quantms_protein_heatmap/manifest.json new file mode 100644 index 0000000..ca530e7 --- /dev/null +++ b/quantms_protein_heatmap/manifest.json @@ -0,0 +1,51 @@ +{ + "version": 3, + "component_type": "heatmap", + "created_at": "2026-10-29T09:54:54.145505", + "cache_generation": "8bbc7538f62f4b49baa235fcfb98d26c", + "config_hash": "17538bbd7c8bb0b83ef303c424c0f51e7dccd7b704b8b4d6f2f9b91555db0343", + "input_config_hash": "ee9573cb203c96cb1b961e1b48c3030ca16ab86349e39043ba927697a7e61efc", + "config": { + "x_column": "Sample", + "y_column": "ProteinName", + "intensity_column": "Z_score", + "min_points": 10000, + "display_aspect_ratio": 1.7777777777777777, + "x_bins": 188, + "y_bins": 106, + "use_simple_downsample": false, + "use_streaming": true, + "categorical_filters": [], + "zoom_identifier": "heatmap_zoom", + "title": "Protein Abundance Heatmap (Z-score)", + "x_label": "Samples", + "y_label": "Proteins", + "colorscale": "RdBu", + "category_column": null, + "log_scale": false, + "low_values_on_top": false, + "intensity_label": "Z-score" + }, + "filters": {}, + "filter_defaults": {}, + "interactivity": {}, + "data_files": { + "level_0": "level_0.parquet", + "level_1": "level_1.parquet" + }, + "data_values": { + "x_range": [ + "B000248_Ap_6883_EXT-765_DIA_Yeast_UPS2_ratio01_DDA", + "B000312_Ap_6883_EXT-765_DIA_Yeast_UPS2_ratio10_DDA_3" + ], + "y_range": [ + "sp|P00925|ENO2_YEAST", + "sp|Q12522|IF6_YEAST" + ], + "total": 300, + "level_sizes": [ + 300 + ], + "num_levels": 2 + } +} \ No newline at end of file diff --git a/quantms_protein_heatmap/preprocessed/level_0.parquet b/quantms_protein_heatmap/preprocessed/level_0.parquet new file mode 100644 index 0000000000000000000000000000000000000000..719a4bf0fecf45b1df23f8e6743fb1b2a3dae2fe GIT binary patch literal 5092 zcmd5=c{r5o-+#uwXM4tst+5R`mYHD~B8Fs1_B~=4vM*x|h3rzYWeKH3mShc)V<$x_ zdqP4TrI0Ko=QksDI@f#tc;9oq*L%Is%=6s${rN8U_x|4ZGuP)kMtBo6NCS3of!Xx> zAQ1!rK%!puo5L$=kPU#oG!S&qow^?DqGROhLuFjodg#mjt9@QV4^NN)onr}eMmm5N z`NQxetmEcq)yH7-bJgXf`VoP;kM2Ht7Oq@+Z?S+(&jT*Bf5B##Nb2IH6bGW$|`cKmLHVo_rPUnsTW3i+^}nZESBRZ#k?3^u-}SV}3v)x5hkA9S260xKHysX5Z|9p{cSVNJ z4ps5xyv$OY(hRTv)OxdONnj`xiADMHk17NQ#+)r0;N+JItt?oxK+$u)uKLnoZ3zJZ zaFQB)29W~CX+U<64y5B(03^8leZ!5>7%T=8u6sIcgPP?Ydt$^J{s#h*%LTTA2+9SWihdMssG*w=3X)m0fxH@HMl_* zI2NE|hr^hSNIn71Zr%n2FK3ViI!*&^gF!DbAzSH@4FA*c$!9y87Mz9XgDgMKwmyKR z2F}N@^Z0u4C0KG3*i~b1xQ7bad8VRQ{*1T`DR;|?^?o3esf+elK9s+x^fnas(0)F9 zp~Ce*2qZxxym|XT*lz=mAskpX03x^I&E^dVo=?QF03jhB?TRdr5Qc=P>yW$FGQ)!8 z{7Zn!f6uAxiXx06g32Y*ECf_OGipgNJyi>qJEP1I&3EL2i6e~; zoGx6MYU6l(@Xoc;6z<4-q30NAl2Hr?in9vd9*W&A{8SWv#Lx`cXWJq<9yxgtXw68G z^1^=Dg-aj9t|lf#&h`NbFrGfs&1Kr_l;J4ne$)fnca1ls_N{H zpZut&b13fjEQX{81mfF(I38%4@-<8SEDgu@)5^@O2Bo@)3l8d~EWJnaB6GN(Tux}9+Vt1#9^cqVW8o#XHk|el`dsgo zbFO07EZ1>rcL{3P&dHrzp2cQ);lq1DX+4e3cOy*p5!Ue7mGGTWSw4^KnXBc}Tc2H( z`ut9Cq~oPLPd6^bA+KuSa(1EEwF$lDEt^MoQZwmdEiMYH^EG3axQ?;f7Ou=co}5`91A8ZK+{40cpWBdfDy= zu&JVeuEj0Xtrz3Fq~}}^P7U=fWldHS8>V*!9!FAH)_ix5aCZ8hIhI~3 zpO@6N=HvA_tfw$uM;RpM6PkHmUv5+C-))Y8pAQq-Np(|c zWJ{acsyg}Pjve*D3-6b`x{6i7j9fI5TEs6#e7NF!Onvvc*AKXllcIP=CJw!W$gO7@ zV{({X%)Y?=(5GXV@1{IYlhhfC2}AQ8PayD=s9E|VYN^9tcQT$kB~>@OW^U3XJ1Kiu z#^OZii&uPW#_aOwynKA?7%g+6{U398WJsQNwsB{^_B@?-xw2lqV|0HaDd*Bekf`2! z?TtLfd-@@(_hpYvlB(ubw!BqYQSnlzPT59YDm_1D$v5ge%H2wypq9X1!CAX-ElVU6DeZS|OfHwCkuK@6VJ>{soN-O-&wRN8^``J zySDAqtBgI6Qi}9U=B0adVcWS`T2-kc>TX#;DOgKdGsT#QoG>+v;Ej>ys%IUXSS%Cr zl8(8S1idte4SIXaG31zIo|^aMm|kH*yp-YTC(f6(^BS`kfVg}{iEq}?uC!DMQYuY; zsqB7{Yw1Y3!r?FY-6pzeP>j`~=_~z}%8S;rcWqF&abI0~=!IbN5smW%A7tFcgY{Az zyv2zA0l6qUyP7@&I(uD^X=G*La7*8Bf*7AxkMn?Ycjw`TY{9`Mj)OaqJ|Bmtg1Re8 z4AaaeX58`Ce1;~rXgR4t=|0FuntXNqN$QP4Q9WY!P-(u`J&_@!L#x(NMQGp9)2j`R zf3JUm)y8Pi7&AaXLLl-wRN9E(A2fej2-F`6`Px#3a>-_ zFYzim+f~y?825_U{x-A9#B&TM_NGK7yuF9RSyjRJ zXT#SK;>;Y6q8U2{k@@nzbr-LviWL^6Ncy&=>yC{@Ot70UaM-Tqr)eKC=e62KUC*+< z_()w`F25;=WYG$XFYdCneN}RPU}xZVQTU1hAX-C3qoh=v?vb-T->royOEXP4b+KtQ zkj4E^QErxgAA@0KYqPgRD zRodF0tG>$B9E6SNoI6;&U0o9sxVj{t+%Lr#(AsL;UUir8595jO7@|RCfyyUsT$cQY zk*JAX-{*69JILi+jBXb`e&td?W96<0-(zX$_{LIIb9rC&glhBLy(DB&tKu<&dB*FB z^jadyID&X?@g^~ClypW{M)!KnGubhI@B_8Ca&> zX7XWi}UyZdHSh*;p$;qb|idNw@9l_@VY=A|W32M6lqOmAsky%tKEFd979 zhI@Z)%Q?47PEku<{nd`1IEzyQ~du!svqtEfYEN^Vh)Hm*=uiYUn2&m(%zAbUVCfEg}cZZpb z&bQnf&=z+@-;aK}^>%ZSx2Eq(gZsu5q>!*wa!PGF*iNqXj6-D!%A;$5BsYBhQu6N~ zY(I&WuC6Xr1A$t_!dGyQN`*lH@HnQE)i*|5eXMy)vU+=w7g5>4fk zH7)wrr?5>^ogR^}bf~J+M7DECJEbh7uU0Z%e@bTAF zg`34gROZ4Q0|z+Due69a*vdZv4-DMy2$LJ{yEjqy0M5v1uKe!vVw;LM!3cA0s{5hF zie%S?0s3>vc?N!Z2j^?(#CgL*x>9))POxpQWIc3Z=2A#06X{qm5gqG3S>asQkk;%7 zLN1!|hQ7(2u(&F+v8W-=j!iafHGdms5SoSEEwS#Roh|BXcvclG?pWgcWLouMhxaf{Ouka2UOfy}y$WX`k{HBcVNF z$ZSp(GT-}0xY#=`x%t5<5#0cAnT0(1i>ji~XcPoER&ace^veI8^s@Y=hepAE z8KKZWTykFo0IPkT3Zd{+fM<~TCr72#Ciw*VelG_WA<%UQ^0pH4F^)n}fARKTeZx7Z z;Qw6Pz+wMX2Y~<_3IKan|AKPEVgHIR|1#-Mk_(6Z+=^eszpNF7hX0yKG-_XsAKFR} zj#H6W0l>+9R`C9hel{oABesv+Fz5*&H-4Gp|4A_e zZJ~r$sRAxR0OYhH|G#|mpu-e5*+;-505b3JUzz;3uLJZw#oy==&;Wpz>5%sb$pDR1 z;HDfE7z^IS#L!aN-^tb4ivXLYlBHb1cnv%ruSLG`xFGU4#F)}0kN2SCc)UMe6OSji z#`xciDEh$^xh@`mdS8#CueoPrLqU#G9^Z`}Df%IMdV6xRABxC)uOUD7 zLG0TfqX3layIh;nHOCwaaMBL(BnRzJb}Fk+0lx>iXOA}_hfU6o7G)kvYfM@9k0s*q zvU~Y3rik`T&2{vGh}ssujviQ7M@w@&05dT1@g$J^5fZ*<0$hE(4-yHq)$ literal 0 HcmV?d00001 diff --git a/quantms_protein_heatmap/preprocessed/level_1.parquet b/quantms_protein_heatmap/preprocessed/level_1.parquet new file mode 100644 index 0000000000000000000000000000000000000000..719a4bf0fecf45b1df23f8e6743fb1b2a3dae2fe GIT binary patch literal 5092 zcmd5=c{r5o-+#uwXM4tst+5R`mYHD~B8Fs1_B~=4vM*x|h3rzYWeKH3mShc)V<$x_ zdqP4TrI0Ko=QksDI@f#tc;9oq*L%Is%=6s${rN8U_x|4ZGuP)kMtBo6NCS3of!Xx> zAQ1!rK%!puo5L$=kPU#oG!S&qow^?DqGROhLuFjodg#mjt9@QV4^NN)onr}eMmm5N z`NQxetmEcq)yH7-bJgXf`VoP;kM2Ht7Oq@+Z?S+(&jT*Bf5B##Nb2IH6bGW$|`cKmLHVo_rPUnsTW3i+^}nZESBRZ#k?3^u-}SV}3v)x5hkA9S260xKHysX5Z|9p{cSVNJ z4ps5xyv$OY(hRTv)OxdONnj`xiADMHk17NQ#+)r0;N+JItt?oxK+$u)uKLnoZ3zJZ zaFQB)29W~CX+U<64y5B(03^8leZ!5>7%T=8u6sIcgPP?Ydt$^J{s#h*%LTTA2+9SWihdMssG*w=3X)m0fxH@HMl_* zI2NE|hr^hSNIn71Zr%n2FK3ViI!*&^gF!DbAzSH@4FA*c$!9y87Mz9XgDgMKwmyKR z2F}N@^Z0u4C0KG3*i~b1xQ7bad8VRQ{*1T`DR;|?^?o3esf+elK9s+x^fnas(0)F9 zp~Ce*2qZxxym|XT*lz=mAskpX03x^I&E^dVo=?QF03jhB?TRdr5Qc=P>yW$FGQ)!8 z{7Zn!f6uAxiXx06g32Y*ECf_OGipgNJyi>qJEP1I&3EL2i6e~; zoGx6MYU6l(@Xoc;6z<4-q30NAl2Hr?in9vd9*W&A{8SWv#Lx`cXWJq<9yxgtXw68G z^1^=Dg-aj9t|lf#&h`NbFrGfs&1Kr_l;J4ne$)fnca1ls_N{H zpZut&b13fjEQX{81mfF(I38%4@-<8SEDgu@)5^@O2Bo@)3l8d~EWJnaB6GN(Tux}9+Vt1#9^cqVW8o#XHk|el`dsgo zbFO07EZ1>rcL{3P&dHrzp2cQ);lq1DX+4e3cOy*p5!Ue7mGGTWSw4^KnXBc}Tc2H( z`ut9Cq~oPLPd6^bA+KuSa(1EEwF$lDEt^MoQZwmdEiMYH^EG3axQ?;f7Ou=co}5`91A8ZK+{40cpWBdfDy= zu&JVeuEj0Xtrz3Fq~}}^P7U=fWldHS8>V*!9!FAH)_ix5aCZ8hIhI~3 zpO@6N=HvA_tfw$uM;RpM6PkHmUv5+C-))Y8pAQq-Np(|c zWJ{acsyg}Pjve*D3-6b`x{6i7j9fI5TEs6#e7NF!Onvvc*AKXllcIP=CJw!W$gO7@ zV{({X%)Y?=(5GXV@1{IYlhhfC2}AQ8PayD=s9E|VYN^9tcQT$kB~>@OW^U3XJ1Kiu z#^OZii&uPW#_aOwynKA?7%g+6{U398WJsQNwsB{^_B@?-xw2lqV|0HaDd*Bekf`2! z?TtLfd-@@(_hpYvlB(ubw!BqYQSnlzPT59YDm_1D$v5ge%H2wypq9X1!CAX-ElVU6DeZS|OfHwCkuK@6VJ>{soN-O-&wRN8^``J zySDAqtBgI6Qi}9U=B0adVcWS`T2-kc>TX#;DOgKdGsT#QoG>+v;Ej>ys%IUXSS%Cr zl8(8S1idte4SIXaG31zIo|^aMm|kH*yp-YTC(f6(^BS`kfVg}{iEq}?uC!DMQYuY; zsqB7{Yw1Y3!r?FY-6pzeP>j`~=_~z}%8S;rcWqF&abI0~=!IbN5smW%A7tFcgY{Az zyv2zA0l6qUyP7@&I(uD^X=G*La7*8Bf*7AxkMn?Ycjw`TY{9`Mj)OaqJ|Bmtg1Re8 z4AaaeX58`Ce1;~rXgR4t=|0FuntXNqN$QP4Q9WY!P-(u`J&_@!L#x(NMQGp9)2j`R zf3JUm)y8Pi7&AaXLLl-wRN9E(A2fej2-F`6`Px#3a>-_ zFYzim+f~y?825_U{x-A9#B&TM_NGK7yuF9RSyjRJ zXT#SK;>;Y6q8U2{k@@nzbr-LviWL^6Ncy&=>yC{@Ot70UaM-Tqr)eKC=e62KUC*+< z_()w`F25;=WYG$XFYdCneN}RPU}xZVQTU1hAX-C3qoh=v?vb-T->royOEXP4b+KtQ zkj4E^QErxgAA@0KYqPgRD zRodF0tG>$B9E6SNoI6;&U0o9sxVj{t+%Lr#(AsL;UUir8595jO7@|RCfyyUsT$cQY zk*JAX-{*69JILi+jBXb`e&td?W96<0-(zX$_{LIIb9rC&glhBLy(DB&tKu<&dB*FB z^jadyID&X?@g^~ClypW{M)!KnGubhI@B_8Ca&> zX7XWi}UyZdHSh*;p$;qb|idNw@9l_@VY=A|W32M6lqOmAsky%tKEFd979 zhI@Z)%Q?47PEku<{nd`1IEzyQ~du!svqtEfYEN^Vh)Hm*=uiYUn2&m(%zAbUVCfEg}cZZpb z&bQnf&=z+@-;aK}^>%ZSx2Eq(gZsu5q>!*wa!PGF*iNqXj6-D!%A;$5BsYBhQu6N~ zY(I&WuC6Xr1A$t_!dGyQN`*lH@HnQE)i*|5eXMy)vU+=w7g5>4fk zH7)wrr?5>^ogR^}bf~J+M7DECJEbh7uU0Z%e@bTAF zg`34gROZ4Q0|z+Due69a*vdZv4-DMy2$LJ{yEjqy0M5v1uKe!vVw;LM!3cA0s{5hF zie%S?0s3>vc?N!Z2j^?(#CgL*x>9))POxpQWIc3Z=2A#06X{qm5gqG3S>asQkk;%7 zLN1!|hQ7(2u(&F+v8W-=j!iafHGdms5SoSEEwS#Roh|BXcvclG?pWgcWLouMhxaf{Ouka2UOfy}y$WX`k{HBcVNF z$ZSp(GT-}0xY#=`x%t5<5#0cAnT0(1i>ji~XcPoER&ace^veI8^s@Y=hepAE z8KKZWTykFo0IPkT3Zd{+fM<~TCr72#Ciw*VelG_WA<%UQ^0pH4F^)n}fARKTeZx7Z z;Qw6Pz+wMX2Y~<_3IKan|AKPEVgHIR|1#-Mk_(6Z+=^eszpNF7hX0yKG-_XsAKFR} zj#H6W0l>+9R`C9hel{oABesv+Fz5*&H-4Gp|4A_e zZJ~r$sRAxR0OYhH|G#|mpu-e5*+;-505b3JUzz;3uLJZw#oy==&;Wpz>5%sb$pDR1 z;HDfE7z^IS#L!aN-^tb4ivXLYlBHb1cnv%ruSLG`xFGU4#F)}0kN2SCc)UMe6OSji z#`xciDEh$^xh@`mdS8#CueoPrLqU#G9^Z`}Df%IMdV6xRABxC)uOUD7 zLG0TfqX3layIh;nHOCwaaMBL(BnRzJb}Fk+0lx>iXOA}_hfU6o7G)kvYfM@9k0s*q zvU~Y3rik`T&2{vGh}ssujviQ7M@w@&05dT1@g$J^5fZ*<0$hE(4-yHq)$ literal 0 HcmV?d00001 diff --git a/quantms_volcano_plot/manifest.json b/quantms_volcano_plot/manifest.json new file mode 100644 index 0000000..c61c2ba --- /dev/null +++ b/quantms_volcano_plot/manifest.json @@ -0,0 +1,28 @@ +{ + "version": 3, + "component_type": "volcanoplot", + "created_at": "2026-10-29T09:58:33.549640", + "cache_generation": "959e0ddcbad24571984b2761621eefbf", + "config_hash": "b06bb8d4c9dc289910868a8f1d6fdb18e1deb7feaa21962787f7d334e30f5608", + "input_config_hash": "b06bb8d4c9dc289910868a8f1d6fdb18e1deb7feaa21962787f7d334e30f5608", + "config": { + "log2fc_column": "log2FC", + "pvalue_column": "p-adj", + "label_column": "ProteinName", + "title": null, + "x_label": "log2 Fold Change", + "y_label": "-log10(p-value)", + "up_color": "#E74C3C", + "down_color": "#3498DB", + "ns_color": "#95A5A6", + "show_threshold_lines": true, + "threshold_line_style": "dash" + }, + "filters": {}, + "filter_defaults": {}, + "interactivity": {}, + "data_files": { + "volcanoData": "volcanoData.parquet" + }, + "data_values": {} +} \ No newline at end of file diff --git a/quantms_volcano_plot/preprocessed/volcanoData.parquet b/quantms_volcano_plot/preprocessed/volcanoData.parquet new file mode 100644 index 0000000000000000000000000000000000000000..de7f4b827c303b8e3a1d1657dacfb4ad05b56006 GIT binary patch literal 5971 zcmcgwcT`i$zD^(%DS|)((SY<8YN(+Iq4y4<_uhM?C{22oj)3%Dq$AQ4kd7!K2#WL~ zO%cR~=O*Ym@4R#GA9t;{*4u0EJu~00%*?m{Q9oa}vK>9x9}m_j18YG+w=vuk{TW&Vy- z1r^t=JjYMhEkAY;)#1ymYrh~W%YQ1HUq;~;ZIIH?_QBUE$I1wwO*H~gy_7!ooJ0Z znBhWr7_E_? z*>Bz;@9?|pnd>N$R+SSNC;}8SyMN8cwTyb_G}s3(S9QP0O*2K1%`HEc`Pyfig~`zC zHJ)!Zc)kGFJv*7}tDLfrr8d%1t!`)EVvB&?noys@ytd`cc#wGB2rDCpg6*nqA$ElI z9!RD>eluspF!SYYX3dYGm^$fZOLqvK5->Xspd-|ms6yB#vd0G zrFec|M{+nz%w!qG%vb1#nqS5xqQinsMqH z)iGN%g?XN(S9bVgfy$`!%>((`cf41AOw-vSveRXtKeU&?6Ib#M)g}pbzAEJGCM+2@ z%3bGioQDQg2k~h|^ujGqo3HBF&nSp`u=Ya~w|!#Uh3;d{=6cqeqK^(& z=|)XM9HqCf^Y47td7oY%QSV++EGJFEx&tNRau6P$Pld^?>ZsKJOmiI#bsdz=dAo25 zWAEw{R;upbSqaq<(^nEG^OkJ!LE021Z_oCIa##~GsPa!KIuYy()Z-nzlCV{xaXU60 zJCCb-_kFYUw&h_}ltofEMOy`wY#emGWow`&hIgY9KEt**OhEWo+AZxZ68pPrp7 zdGmpP8DVW0{L z+mG5W2?A>0tx)bxNmDj1R_#@ruJlAi!TBXYtEW_$e%^+>E;(g!D5{R=&s6Yl7!i9dm#_ql zTuNp6=+c)JuGx?ovG4TUIPCe3+SA9RS*Z(eekR2kyL~qPdPUIcC;QVA5(1_6a`M9M zMbuWiiK;(Sm*LM{;KvldgMFlujT7%cN!5?I^Glj^*QXR#84jw+Xok9SVu?v*(iVJ0>2?-dzjB~>V4a=2LQHiTT=GGl!EB9h$NV)v1Hi=mV54P|HR!&gLU9DEx0j~X;3>+JUM%MWeclTrp|Cfwh1%^KL zFVwTNDc{NGf3SH?5#4!oTc~^A`VTsHfs>E;Qx!FjdX#lHXKKJdL!Ul9f^ih&I=!9d z+72Th);89g?@(PN zLP$t3)JR8P#L7ewrEfsTg+;Xmg+v5JL-q8Ls9&fg7Ss_C5fc%?fpWjVKdH99ey#`j zmP1U(C98aOdr*J{Fhg1}|Ek_N*n9}%&Db}gG9jlBm#G&yqd)gBVo+RC6z>+$3+MwR zUYpX$8oI|ewcnmkycX0t9twNtSLHep0-qFy4GIejdKUSq5iPd+vCE&1+9wA+u+Tt@ zjuGwA3;T}-6KQhUH__J?H-ww<)~VcjmILgF1bu6?jd954v=isBr#Q|G=33ni*crDZ zyu(_Q1XVb?l*1gC53i-|{S*xI{i5g5EWDkmy)oU*)o5zi(RqUu<2V&7;8ZZ~k`~yW z`IvDzfa?jotI2STPq$CKxw3_GTNC8nqA2dcJzCk-nps1Bbr3zC$zx~Sm+Nxh^(mFu zvvfn$U89<Hju7cLgG8) zi{nWkTO?eP@{OPfih&s;zOLlIo9(TUxI?CeZ~ADBW!EVA@Dis0;3SPr4q$T>G`_4M z1tcJ&6YvHAfDi%e0a*YrzB|S)ym8@$v=MmY>x**r@FISVlnA6<18Fb(Rg&rX-9QUa zg7-7Yb}G77_UE%vQaO=vkM-rUiGmRZAp!btKQ;DrldO$S@L1tRe#$h+S)n?p*O^CI z!8dO-6wYs5)%{*O6-_zu$$(^jrNB){X@Cwq8$~2B=osA9G^BAUe?+zDq5FI=y80C` z2|Y_$P<4pNZB7OZl z5h$hP4_c!s`Y8#a;gZ?I{w1N5jRs9okWk73Kl|(g>qb^7_2cLHWwtXc^+JqNo#_;K zzLm-KUrPz|0{AG=3F#CYHA&ICvhzcvL-VTzv&<})HsQ2iRYv6}OIdUo1vw8{Uq5GEMmG&JWmYu?P!gjbzUr$kmdZ0t)Nz}0 znOH2Nl&&y+By|TMypn0TdQZgaWwf|cmfKmx<&mlOMpfcxL+zuqX0B#0DT_4bYURD>71}TH9=Z$B zKZBaHRm$6%MryQYH}e@E`b_J!uSuDFRyt61V4;v;KkcfMQh~g~+i0xqIodN>&WZG` zc%%f3-Kl?c&Uu*d=)Lq|(ypkIMGV6>a7I3xIAYquAwjcVSVSK|i5af1agJ*>)r|I$ zE#L^?>1-CpFU7=6tXa0$$u=hDE4MhKT?QhBsoxI&lQBCZ1Io^OCGK#4~<#Wg#9#nz;*PY5IE%OB?5R##3<@MY|br-qA@;INZ#o zs(WlQot9_P)%1y{Z0%-ICwCb$`nc3ZIa_$AI*!WfZDPV`d=VEd_^IgVN`sniI+Yj~ zHytfIcS?-###=Jo2y1S>YNlLDl50c?Ul)hTUw~?a@~*_~H3IkZ`pJplG?mNohACqdt z+0OWeQd#>+k^56Jsn{lo?GsXVs=K$M&@CL&-jbKxWj5Z=uRK9r&dJMiS|Au zCCkqelVPQ62fI2qQ+g%=yr02s=p1rSxR*u7;KU;pFZIgSdtv4{AL>-Vri6&WiZ%ry zMXe~TZr+Z$s+ycKV-<7Nn7VQ?s3>m6E^RC=eo10EZ2F4x-?N>2Gv7(qx;h1PMcN3C?ZicP5cJaS%nXmsLq~_gkO5S zzIAZUO~5_gMFw=Z;e$zH<{_c6U6C_NV0+fV?koaHftWD;nEYh)B&P6s{ZtmK2OmMq z1osOLp6RS=nbby*~fa$B1#{ zY2?iEXBr^&TfY<59bWdkh?)336$YK;RPU|5IaRr>$7G80kELl=VVcCT}# z0Q%8Gm(Pk)1{34NVM(gr%bSc-;#AL%f83jE>|G-L+I3_%dm|Nn)^V>BoTi0|w&)G( zP^rTJ?sT#6?5)0|SlY@zh^o?-(dd3Zub2|JX>-i*t)uX|%aq%c#A4fOTi4VZ8MDWo z*>3MT&YUBddn@Oh-TGArH3H9#hMv}s2u(S+Tw98sm2O|iWL!sH`;iX0kp}GC(Jk5C zy4>`M#9PW}n1bOzLMS_QfWCWo(e?ZEj27uLUb+gVECY52%?DE6{zC8Mb|7XpW)7K+ zUo5))(_0Gy?maT#w}qFJ-)Q%B56~bE8fZ^Gdbx(ei}7P+gqLQ#32<6y-)u%T&k)yY ze;2T@?&wh+wEOHGv_qn*8zm^MF*7&VB9=VFj9GNg>^1G9wa`V4fFFo;GrV+H0tE`qra|2I}+OzJav4+Qzw)sH(afnG-yZvvge$7 z?70eqT3o`%y!5-~4&0~?4fzMRMol>5Gfxg5n+WLhmoxI}ANMQ}-s?$a&+d-$N0%w) z4(t9z5!@zd*fIi>{SpbJI=){14lbTrHXaVYcD*N&Ku8RnJ?$!P+xvgt(*6<{Fa9Yo z+ETQ!0%JM8RPxVVFAM0L5I}?fTVV8Z#z~EmJ&{aiAfcFzxPf!WItOy_OYeS(gsF2* zlHxpYS8(-={Nl42JSa|S32UJ0 zbWgWvb^9vUT)%nCHHgZ}!vg)MNuy1)P^1OWI2#tD?a!T-JNfYSZPr~J2E7taaX1#U>cVHkuM zJVb*X0{{wxRD!{2px^bhtKb;;@A{4sIR63%<3Y_;K)zmH{$OIPK4ruEQ=I;4{1RON zFd5eH_ZN~4paH}7a1rotlmW1^77BxEuusy!{#0-OmE?tFYJnk;V|6eefV~6ySL}bK z{2;YjU^+;#1}-*R5%&M~6+xch`kmGQ=K=xjk?Ma9`rlajOS%5%7YVY6%d@449Y3V# z2@p#mJ2B)$nFjY5>BvxedfI3SKRag!4;#84c>K8IrluSUg;K;`DDgmCTNhWwwl5%c z6v_`Jk3wNfT@+jgYsX-4c2yM0>9-%wU)~MZu>Xx$tS|16hvV$nDPj*p>?McMKr6Y~;HIb@ zjJ1gWIccovV%~BnEQ0M!AIA%})ceiH?}Q4Xzw;@hP$sw`V@njuRvGE3>1K+ZzqB-f zPD{tj-Nx6Ck<&ZK-`UHPi!so_*U!bvlM%ru$S1(aX&d0;ZqF#@h!jBB+6y^4+9Sjq t9mNs$g2D(faT@`o4FW0R=!kF>vvIITBDkQqPwB4{a2LDI>R^AT{|iT) str | None: return None +# Plain-language help shown above the search settings (LFQ and TMT). +DECOY_INFO = """ +**Why decoys?** To estimate how many matches are wrong, the search also looks for +peptides from fake ("decoy") proteins: your proteins with their sequences reversed or +shuffled. Every decoy hit is a known false match, so the number of decoy hits tells how +many false matches hide among the real ones. *Reverse* is the standard choice. +""" + +COMET_INFO = """ +**How peptides are identified.** Comet compares every fragmentation (MS2) spectrum with +the spectra expected for the peptides of your FASTA database. The defaults fit a tryptic +digest with carbamidomethylated cysteines. Pick the **instrument preset** at the top of the +page to set the tolerances for your mass spectrometer; change the rest only if your sample +preparation differs: + +* **enzyme**: the protease you digested with (usually Trypsin/P). +* **missed_cleavages**: how many cut sites the enzyme may skip within one peptide (2 is typical). +* **fixed_modifications**: changes present on every copy of an amino acid, e.g. + *Carbamidomethyl (C)* after reduction and alkylation with iodoacetamide. +* **variable_modifications**: changes that may or may not be present, e.g. *Oxidation (M)* + or *Phospho (STY)*. Each one added makes the search slower and less sensitive, so add only + those you expect. +* **precursor / fragment mass tolerance**: how far a measured mass may lie from the expected + one. Set by the instrument preset. +""" + +COMET_DECOY_INFO = """* **PeptideIndexing:decoy_string**: the label that marks decoy proteins in your FASTA + file (detected automatically, e.g. *rev_* or *DECOY_*). +""" + + +PERCOLATOR_INFO = """ +**Telling right from wrong matches.** Percolator learns, from many features of each +match (score, mass error, peptide length, ...), what distinguishes real matches from the +decoy ones, and re-scores every match. This typically identifies 20 to 50 % more peptides +at the same error rate. The defaults suit every experiment. +""" + +FDR_INFO = """ +**How many wrong matches you accept.** The false discovery rate (FDR) is the share of +reported matches expected to be wrong. **1 %** is the community standard and what journals +expect; raise it only for exploration. 100 % switches the filter off. +""" + + +def demo_group(filename: str) -> str: + """Group of a demo file (UPS2 spiked in at two ratios), so the demo runs as shipped.""" + if not Path("example-data", "mzML", filename).is_file(): + return "" + for ratio in ("ratio01", "ratio10"): + if ratio in filename: + return f"UPS2_{ratio}" + return "" + + # params = page_setup() class WorkflowTest(WorkflowManager): @@ -77,10 +132,11 @@ def upload(self) -> None: @st.fragment def configure(self) -> None: + self.preselect_all_inputs("mzML-files") # reactive=True so Group Selection tab updates when selection changes - self.ui.select_input_file("mzML-files", multiple=True, reactive=True) + self.ui.select_input_file("mzML-files", name="MS data files to analyse", multiple=True, reactive=True) # reactive=True so the decoy default below follows the selected database - self.ui.select_input_file("fasta-file", multiple=False, reactive=True) + self.ui.select_input_file("fasta-file", name="Protein database (FASTA)", multiple=False, reactive=True) self.params = self.parameter_manager.get_parameters_from_json() self.apply_decoy_default() @@ -89,10 +145,11 @@ def configure(self) -> None: self.ui.input_widget( key="analysis-mode", default=saved_mode, - name="Analysis Mode", + name="Experiment type", widget_type="selectbox", options=["LFQ", "TMT"], - help="Choose between Label-Free Quantification (LFQ) or Tandem Mass Tag (TMT) analysis.", + help="LFQ (label-free): every sample was measured in its own run. " + "TMT: samples were labelled with isobaric tags (TMT or iTRAQ) and pooled into one run.", reactive=True ) @@ -104,6 +161,27 @@ def configure(self) -> None: else: self.render_tmt_tabs() + def preselect_all_inputs(self, key: str) -> None: + """Select every uploaded file of ``key`` until the user makes a choice. + + A fresh workspace then runs as it is (all uploaded runs, or the demo + data); once the selection is saved, even as empty, it is left alone. + """ + if key in self.params: + return + path = Path(self.workflow_dir, "input-files", key) + if not path.is_dir(): + return + options = sorted(str(f) for f in path.iterdir() if "external_files.txt" not in str(f)) + if not options: + return + params = self.parameter_manager.get_parameters_from_json() + params[key] = options + with open(self.parameter_manager.params_file, "w", encoding="utf-8") as f: + json.dump(params, f, indent=4) + self.params = params + self.ui.params = params + def apply_decoy_default(self) -> None: """Default decoy generation to on, unless the selected FASTA already has decoys. @@ -150,13 +228,13 @@ def render_psm_fdr_widget(self) -> None: self.ui.input_widget( key="psm-fdr-percent", default=1.0, - name="PSM FDR level (%)", + name="False discovery rate for peptide matches (%)", widget_type="number", min_value=0.001, max_value=100.0, step_size=1.0, - help="Keep PSMs with a q-value up to this level. 100% disables FDR filtering " - "and passes every PSM on unfiltered.", + help="Keep peptide-spectrum matches (PSMs) with a q-value up to this level. 1 % is the " + "standard; 100 % disables the filter and passes every PSM on.", ) def psm_fdr(self) -> float: @@ -194,10 +272,16 @@ def render_contaminant_option(self): ) def render_lfq_tabs(self): - st.subheader("LFQ Analysis Mode") - t = st.tabs(["**Identification**", "**Rescoring**", "**Filtering**", "**Library Generation**", "**Quantification**", "**Group Selection**"]) + st.subheader("Label-free experiment") + t = st.tabs([ + "**1 ยท Samples and groups**", "**2 ยท Database search**", "**3 ยท Rescoring**", + "**4 ยท False discovery rate**", "**5 ยท Quantification**", "**6 ยท Spectral library (optional)**", + ]) with t[0]: + self.render_lfq_groups() + + with t[1]: # Checkbox for decoy generation # reactive=True ensures the parent configure() fragment re-runs when checkbox changes, # so conditional UI (DecoyDatabase settings) updates immediately @@ -216,14 +300,7 @@ def render_lfq_tabs(self): # Show DecoyDatabase settings if generating decoys if self.params.get("generate-decoys", True): - st.info(""" - **Decoy Database Settings:** - * **method**: How decoy sequences are generated from target protein sequences. - *Reverse* creates decoys by reversing each sequence, while *shuffle* randomly - rearranges the amino acids. Both methods preserve the amino acid composition - of the original protein, ensuring decoys have similar properties to real sequences - for accurate false discovery rate (FDR) estimation. - """) + st.info(DECOY_INFO) self.ui.input_TOPP( "DecoyDatabase", custom_defaults={ @@ -234,20 +311,9 @@ def render_lfq_tabs(self): include_parameters=["method"], ) - comet_info = """ - **Identification (Comet):** - * **enzyme**: The enzyme used for peptide digestion. - * **missed_cleavages**: Number of possible cleavage sites missed by the enzyme. It has no effect if enzyme is unspecific cleavage. - * **fixed_modifications**: Fixed modifications, specified using Unimod (www.unimod.org) terms, e.g. 'Carbamidomethyl (C)' or 'Oxidation (M)' - * **variable_modifications**: Variable modifications, specified using Unimod (www.unimod.org) terms, e.g. 'Carbamidomethyl (C)' or 'Oxidation (M)' - * **instrument**: Type of instrument (high_res or low_res). Use 'high_res' for high-resolution MS2 (Orbitrap, TOF), 'low_res' for ion trap. - * **fragment_mass_tolerance**: Fragment mass tolerance for MS2 matching. - * **fragment_bin_offset**: Offset for binning MS2 spectra. Typically 0.0 for high-res, 0.4 for low-res instruments. - """ + comet_info = COMET_INFO if not self.params.get("generate-decoys", True): - comet_info += """* **PeptideIndexing:decoy_string**: String that was appended (or prefixed - see 'decoy_string_position' flag below) to the accessions - in the protein database to indicate decoy proteins. - """ + comet_info += COMET_DECOY_INFO st.info(comet_info) comet_include = [":enzyme", "missed_cleavages", "fixed_modifications", "variable_modifications", @@ -261,7 +327,8 @@ def render_lfq_tabs(self): "CometAdapter", custom_defaults={ "threads": 8, - "instrument": "low_res", + # Orbitrap HCD (the "Orbitrap HCD" preset), the most common DDA setup + "instrument": "high_res", "missed_cleavages": 2, "min_peptide_length": 6, "max_peptide_length": 40, @@ -270,8 +337,8 @@ def render_lfq_tabs(self): "isotope_error": "0/1", "precursor_charge": "2:4", "precursor_mass_tolerance": 20.0, - "fragment_mass_tolerance": 0.6, - "fragment_bin_offset": 0.4, + "fragment_mass_tolerance": 0.01, + "fragment_bin_offset": 0.0, "max_variable_mods_in_peptide": 3, "minimum_peaks": 1, "clip_nterm_methionine": "true", @@ -286,13 +353,8 @@ def render_lfq_tabs(self): exclude_parameters=["second_enzyme"], ) - with t[1]: - st.info(""" - **Rescoring (Percolator):** - * **post_processing_tdc**: Use target-decoy competition to assign q-values and PEPs. - * **score_type**: Type of the peptide main score - * **subset_max_train**: Only train an SVM on a subset of PSMs, and use the resulting score vector to evaluate the other PSMs. Recommended when analyzing huge numbers (>1 million) of PSMs. When set to 0, all PSMs are used for training as normal. - """) + with t[2]: + st.info(PERCOLATOR_INFO) # decoy_pattern is always derived from upstream, never shown percolator_include = ["post_processing_tdc", "score_type", "subset_max_train"] @@ -310,12 +372,8 @@ def render_lfq_tabs(self): exclude_parameters=["out_type"], ) - with t[2]: - st.info(""" - **Filtering (IDFilter):** - PSMs are filtered on their Percolator q-value at the FDR level below. - Set it to 100% to keep all PSMs. - """) + with t[3]: + st.info(FDR_INFO) self.render_psm_fdr_widget() self.ui.input_TOPP( "IDFilter", @@ -325,10 +383,12 @@ def render_lfq_tabs(self): exclude_parameters=["type_protein", "type_peptide", "score:psm"], ) - with t[3]: # Library Generation + with t[5]: st.info(""" - **Spectral Library Generation (EasyPQP):** - Generate a spectral library from filtered PSMs for targeted proteomics (DIA/SWATH). + **Optional.** Builds a spectral library from the identified peptides, for analysing + data-independent acquisition (DIA/SWATH) runs of the same samples later, e.g. in + OpenSWATH. Not needed for the protein comparison; leave it off if you do not plan a + DIA experiment. """) self.ui.input_widget( @@ -385,10 +445,14 @@ def render_lfq_tabs(self): with t[4]: st.info(""" - **Quantification (ProteomicsLFQ):** - * **intThreshold**: Peak intensity threshold applied in seed detection. - * **psmFDR**: FDR threshold for sub-protein level (e.g. 0.05=5%). Use -FDR_type to choose the level. Cutoff is applied at the highest level. If Bayesian inference was chosen, it is equivalent with a peptide FDR - * **proteinFDR**: Protein FDR threshold (0.05=5%). + **How proteins are quantified.** ProteomicsLFQ measures each identified peptide's + signal over its elution time in every run, aligns the runs so the same peptide is + matched across them, and adds up the peptides of each protein. Only peptides unique + to one protein are used, so shared peptides cannot blur the comparison. + + * **intThreshold**: the faintest signal that counts as a peptide (raise it for noisy data). + * **psmFDR** / **proteinFDR**: the false discovery rate at peptide and protein level + (0.01 = 1 %, the community standard). """) self.ui.input_TOPP( "ProteomicsLFQ", @@ -408,60 +472,65 @@ def render_lfq_tabs(self): include_parameters=["intThreshold", "psmFDR", "proteinFDR"], ) - with t[5]: - st.markdown("### ๐Ÿงช Sample Group Assignment") - st.info( - "Enter a group name for each mzML file.\n\n" - "Examples: case, control" - ) + def render_lfq_groups(self): + """Per-file group names; the group comparison needs at least two groups.""" + st.markdown( + "Give every file the name of its experimental group, for example **control** and " + "**treated**. Files with the same name are replicates of one condition, and the group " + "comparison tests every protein between the groups. Use at least two groups, ideally " + "with three or more replicates each." + ) - mzml_keys = self.params.get("mzML-files") + mzml_keys = self.params.get("mzML-files") - if not mzml_keys: - st.warning("No mzML files available. Please upload mzML files first.") - return + if not mzml_keys: + st.warning("No mzML files available. Please upload mzML files first.") + return - try: - mzml_files = self.file_manager.get_files(mzml_keys) - except ValueError: - st.warning("Selected mzML files are not available.") - return + try: + mzml_files = self.file_manager.get_files(mzml_keys) + except ValueError: + st.warning("Selected mzML files are not available.") + return - # Current mzML filenames - current_filenames = {Path(mz).name for mz in mzml_files} + # Current mzML filenames + current_filenames = {Path(mz).name for mz in mzml_files} - # Per-file group input using input_widget (auto-saves to params.json) - for mz in mzml_files: - filename = Path(mz).name - self.ui.input_widget( - key=f"mzML-group-{filename}", - default="", - name=f"Group for {filename}", - widget_type="text", - help="e.g. case, control", - ) + # Per-file group input using input_widget (auto-saves to params.json) + for mz in mzml_files: + filename = Path(mz).name + self.ui.input_widget( + key=f"mzML-group-{filename}", + default=demo_group(filename), + name=f"Group of {filename}", + widget_type="text", + help="The condition this sample belongs to, e.g. control or treated. Spelling must match exactly between replicates.", + ) - # Reload params to get current values - self.params = self.parameter_manager.get_parameters_from_json() + # Reload params to get current values + self.params = self.parameter_manager.get_parameters_from_json() - # Clean up orphaned group params from previously selected files - orphaned_keys = [ - k for k in self.params.keys() - if k.startswith("mzML-group-") and k[11:] not in current_filenames - ] - for key in orphaned_keys: - del self.params[key] - if orphaned_keys: - self.parameter_manager.save_parameters() + # Clean up orphaned group params from previously selected files + orphaned_keys = [ + k for k in self.params.keys() + if k.startswith("mzML-group-") and k[11:] not in current_filenames + ] + for key in orphaned_keys: + del self.params[key] + if orphaned_keys: + self.parameter_manager.save_parameters() def render_tmt_tabs(self): - st.subheader("TMT Analysis Mode") + st.subheader("TMT / iTRAQ experiment") # Create tabs for different analysis steps. - t = st.tabs( - ["**IsobaricAnalyzer**", "**CometAdapter**", "**PercolatorAdapter**", "**IDFilter**", "**IDMapper**", "**FileMerger**", - "**ProteinInference**", "**IDFilter**", "**IDConflictResolver**", "**ProteinQuantifier**", "**Group Selection**"] - ) + t = st.tabs([ + "**1 ยท Samples and groups**", "**2 ยท Labels and decoys**", "**3 ยท Database search**", + "**4 ยท Rescoring**", "**5 ยท False discovery rate**", "**6 ยท Proteins and quantification**", + ]) + with t[0]: + self.render_tmt_groups() + with t[1]: # Checkbox for decoy generation # reactive=True ensures the parent configure() fragment re-runs when checkbox changes, # so conditional UI (DecoyDatabase settings) updates immediately @@ -480,14 +549,7 @@ def render_tmt_tabs(self): # Show DecoyDatabase settings if generating decoys if self.params.get("generate-decoys", True): - st.info(""" - **Decoy Database Settings:** - * **method**: How decoy sequences are generated from target protein sequences. - *Reverse* creates decoys by reversing each sequence, while *shuffle* randomly - rearranges the amino acids. Both methods preserve the amino acid composition - of the original protein, ensuring decoys have similar properties to real sequences - for accurate false discovery rate (FDR) estimation. - """) + st.info(DECOY_INFO) self.ui.input_TOPP( "DecoyDatabase", custom_defaults={ @@ -498,31 +560,11 @@ def render_tmt_tabs(self): include_parameters=["method"], ) - comet_info = """ - **Identification (Comet):** - * **enzyme**: The enzyme used for peptide digestion. - * **missed_cleavages**: Number of possible cleavage sites missed by the enzyme. It has no effect if enzyme is unspecific cleavage. - * **fixed_modifications**: Fixed modifications, specified using Unimod (www.unimod.org) terms, e.g. 'Carbamidomethyl (C)' or 'Oxidation (M)' - * **variable_modifications**: Variable modifications, specified using Unimod (www.unimod.org) terms, e.g. 'Carbamidomethyl (C)' or 'Oxidation (M)' - * **instrument**: Type of instrument (high_res or low_res). Use 'high_res' for high-resolution MS2 (Orbitrap, TOF), 'low_res' for ion trap. - * **fragment_mass_tolerance**: Fragment mass tolerance for MS2 matching. - * **fragment_bin_offset**: Offset for binning MS2 spectra. Typically 0.0 for high-res, 0.4 for low-res instruments. - """ - if not self.params.get("generate-decoys", True): - comet_info += """* **PeptideIndexing:decoy_string**: String that was appended (or prefixed - see 'decoy_string_position' flag below) to the accessions - in the protein database to indicate decoy proteins. - """ - st.info(comet_info) - - st.write(Path(self.workflow_dir, "results")) - - comet_include = [":enzyme", "missed_cleavages", "fixed_modifications", "variable_modifications", - "instrument", "fragment_mass_tolerance", "fragment_error_units", "fragment_bin_offset", - "precursor_mass_tolerance", "precursor_error_units", "isotope_error"] - if not self.params.get("generate-decoys", True): - # Only show decoy_string when not generating decoys - comet_include.append("PeptideIndexing:decoy_string") - + st.info(""" + **Label type.** Choose the TMT or iTRAQ kit you used (e.g. *tmt11plex*, *tmt16plex*). + IsobaricAnalyzer reads each channel's reporter-ion intensity from the fragment spectra; + the defaults fit current Orbitrap TMT methods. + """) self.ui.input_TOPP( "IsobaricAnalyzer", custom_defaults={ @@ -538,7 +580,8 @@ def render_tmt_tabs(self): tool_instance_name="IsobaricAnalyzer-TMT", reactive=True, ) - with t[1]: + with t[2]: + st.info(COMET_INFO) comet_include = [":enzyme", "missed_cleavages", "fixed_modifications", "variable_modifications", "instrument", "fragment_mass_tolerance", "fragment_error_units", "fragment_bin_offset", "PeptideIndexing:IL_equivalent", "precursor_mass_tolerance", "precursor_error_units", "isotope_error"] @@ -573,12 +616,8 @@ def render_tmt_tabs(self): exclude_parameters=["second_enzyme"], tool_instance_name="CometAdapter-TMT", ) - with t[2]: - st.info(""" - **Filtering (IDFilter):** - * **score:type_peptide**: Score used for filtering. If empty, the main score is used. - * **score:psm**: The score which should be reached by a peptide hit to be kept. (use 'NAN' to disable this filter) - """) + with t[3]: + st.info(PERCOLATOR_INFO) self.ui.input_TOPP( "PercolatorAdapter", custom_defaults={ @@ -592,25 +631,20 @@ def render_tmt_tabs(self): tool_instance_name="PercolatorAdapter-TMT", ) - with t[3]: - st.info(""" - **Filtering (IDFilter):** - PSMs are filtered on their Percolator q-value at the FDR level below. - Set it to 100% to keep all PSMs. - """) + with t[4]: + st.info(FDR_INFO) self.render_psm_fdr_widget() self.ui.input_TOPP( "IDFilter", exclude_parameters=["type_peptide", "score:psm"], tool_instance_name="IDFilter-strict", ) - with t[4]: - st.info(""" - **Quantification (ProteomicsLFQ):** - * **intThreshold**: Peak intensity threshold applied in seed detection. - * **psmFDR**: FDR threshold for sub-protein level (e.g. 0.05=5%). Use -FDR_type to choose the level. Cutoff is applied at the highest level. If Bayesian inference was chosen, it is equivalent with a peptide FDR - * **proteinFDR**: Protein FDR threshold (0.05=5%). - """) + with t[5]: + st.info( + "These steps turn the identified peptides into one abundance per protein and channel. " + "The defaults follow the quantms TMT workflow; most experiments never change them." + ) + st.markdown("**Map identifications to the reporter intensities** (IDMapper)") self.ui.input_TOPP( "IDMapper", custom_defaults={ @@ -620,6 +654,7 @@ def render_tmt_tabs(self): tool_instance_name="IDMapper-TMT", ) with t[5]: + st.markdown("**Combine the runs** (FileMerger)") self.ui.input_TOPP( "FileMerger", custom_defaults={ @@ -631,7 +666,8 @@ def render_tmt_tabs(self): flag_parameters=["annotate_file_origin"], tool_instance_name="FileMerger-TMT", ) - with t[6]: + with t[5]: + st.markdown("**Infer proteins from peptides** (ProteinInference)") self.ui.input_TOPP( "ProteinInference", custom_defaults={ @@ -647,7 +683,8 @@ def render_tmt_tabs(self): }, tool_instance_name="ProteinInference-TMT", ) - with t[7]: + with t[5]: + st.markdown("**Protein-level false discovery rate** (IDFilter)") # A single checkbox widget for workflow logic. # self.ui.input_widget("run-python-script", False, "Run custom Python script") * # Generate input widgets for a custom Python tool, located at src/python-tools. @@ -665,7 +702,8 @@ def render_tmt_tabs(self): flag_parameters=["delete_unreferenced_peptide_hits", "remove_decoys"], tool_instance_name="IDFilter-lenient", ) - with t[8]: + with t[5]: + st.markdown("**Resolve peptides matched to several features** (IDConflictResolver)") self.ui.input_TOPP( "IDConflictResolver", custom_defaults={ @@ -674,7 +712,8 @@ def render_tmt_tabs(self): tool_instance_name="IDConflictResolver-TMT", ) - with t[9]: + with t[5]: + st.markdown("**Sum peptides into protein abundances** (ProteinQuantifier)") self.ui.input_TOPP( "ProteinQuantifier", custom_defaults={ @@ -689,64 +728,69 @@ def render_tmt_tabs(self): flag_parameters=["top:include_all", "ratios"], tool_instance_name="ProteinQuantifier-TMT", ) - with t[10]: - st.markdown("### ๐Ÿงช TMT Sample Group Assignment") + def render_tmt_groups(self): + """Per-channel group names for the label type chosen in IsobaricAnalyzer.""" + st.markdown( + "Give every label channel the name of the experimental group of the sample in it, for " + "example **control** and **treated**. Channels with the same name are replicates. " + "Type **skip** for an empty channel or a reference/pool channel you do not want compared. " + "The number of channels follows the label type chosen in tab 2." + ) - latest_params = self.parameter_manager.get_parameters_from_json() - type_key = ( - f"{self.parameter_manager.topp_param_prefix}" - "IsobaricAnalyzer-TMT:1:type" - ) - selected_type = str( - st.session_state.get(type_key) - or latest_params.get("IsobaricAnalyzer-TMT", {}).get("type") - or "tmt11plex" - ).lower() - - m = re.search(r'\d+', selected_type) - is_supported_type = any(label in selected_type for label in ["tmt", "itraq"]) - if not m or not is_supported_type: - st.warning("Please select a supported isobaric type in the IsobaricAnalyzer tab first.") - else: - num_plex = int(m.group()) - channels = [f"sample{i+1}" for i in range(num_plex)] - st.caption(f"Isobaric type: **{selected_type}** - {num_plex} channels") - st.info("Assign a group name to each channel. Use **'skip'** to exclude a channel.") + latest_params = self.parameter_manager.get_parameters_from_json() + type_key = ( + f"{self.parameter_manager.topp_param_prefix}" + "IsobaricAnalyzer-TMT:1:type" + ) + selected_type = str( + st.session_state.get(type_key) + or latest_params.get("IsobaricAnalyzer-TMT", {}).get("type") + or "tmt11plex" + ).lower() + + m = re.search(r'\d+', selected_type) + is_supported_type = any(label in selected_type for label in ["tmt", "itraq"]) + if not m or not is_supported_type: + st.warning("Choose a TMT or iTRAQ label type in tab 2 (Labels and decoys) first.") + else: + num_plex = int(m.group()) + channels = [f"sample{i+1}" for i in range(num_plex)] + st.caption(f"Isobaric type: **{selected_type}** - {num_plex} channels") - for row_start in range(0, num_plex, 2): - c1, c2 = st.columns(2) + for row_start in range(0, num_plex, 2): + c1, c2 = st.columns(2) - left_idx = row_start - left_channel = channels[left_idx] - with c1: + left_idx = row_start + left_channel = channels[left_idx] + with c1: + self.ui.input_widget( + key=f"TMT-group-{left_channel}", + default="", + name=f"Group for channel {left_idx + 1}", + widget_type="text", + help="e.g. control, case, skip", + ) + + right_idx = row_start + 1 + if right_idx < num_plex: + right_channel = channels[right_idx] + with c2: self.ui.input_widget( - key=f"TMT-group-{left_channel}", + key=f"TMT-group-{right_channel}", default="", - name=f"Group for channel {left_idx + 1}", + name=f"Group for channel {right_idx + 1}", widget_type="text", help="e.g. control, case, skip", ) - right_idx = row_start + 1 - if right_idx < num_plex: - right_channel = channels[right_idx] - with c2: - self.ui.input_widget( - key=f"TMT-group-{right_channel}", - default="", - name=f"Group for channel {right_idx + 1}", - widget_type="text", - help="e.g. control, case, skip", - ) - - # Remove orphaned params from a previously selected larger plex - self.params = self.parameter_manager.get_parameters_from_json() - valid_keys = {f"TMT-group-{ch}" for ch in channels} - orphaned = [k for k in self.params if k.startswith("TMT-group-") and k not in valid_keys] - if orphaned: - for k in orphaned: - del self.params[k] - self.parameter_manager.save_parameters() + # Remove orphaned params from a previously selected larger plex + self.params = self.parameter_manager.get_parameters_from_json() + valid_keys = {f"TMT-group-{ch}" for ch in channels} + orphaned = [k for k in self.params if k.startswith("TMT-group-") and k not in valid_keys] + if orphaned: + for k in orphaned: + del self.params[k] + self.parameter_manager.save_parameters() def _has_identifications(self, idxml_files: list, stage: str) -> bool: """Return False, and log why, when no idXML file holds a peptide hit. diff --git a/src/common/differential.py b/src/common/differential.py new file mode 100644 index 0000000..3b8cf30 --- /dev/null +++ b/src/common/differential.py @@ -0,0 +1,204 @@ +"""Group comparison in one go: filtering, imputation, normalization and statistics. + +The four Downstream Analysis pages run these steps one at a time so each can be +tuned. Most users only want the answer with sensible settings, so this module +chains the same openms_insight functions with the settings saved in the +workspace (the shipped defaults unless a step page changed them) and continues +from whatever step the user already applied by hand. +""" +import json + +import pandas as pd +import polars as pl +from openms_insight.analysis.filter import ( + filter_low_abundance, + filter_low_repeatability, + filter_low_variance, +) +from openms_insight.analysis.imputation import impute_mar, impute_smallest_value +from openms_insight.analysis.normalization import normalize_samples, scale_data, transform_data +from openms_insight.analysis.statistics import adjust_fdr_lazy, calculate_statistical_tests + +STAT_COLUMNS = ["log2FC", "p-value", "p-adj", "stat"] + +# Session-state keys of the chain, in order (same as results_helpers.POSTPROCESSING_STEPS). +STEPS = ["filtered_df", "imputed_df", "normalized_df", "statistics_df"] + +SETTING_KEYS = [ + "postproc-filter-method", + "postproc-filter-abundance-percentile", + "postproc-filter-max-missing", + "postproc-filter-variance-percentile", + "postproc-impute-class", + "postproc-impute-mar-strategy", + "postproc-impute-mnar-scope", + "postproc-transform", + "postproc-normalization", + "postproc-scaling", + "postproc-stat-test", + "postproc-fdr", +] + + +def default_settings(path: str = "default-parameters.json") -> dict: + """The shipped downstream-analysis defaults.""" + with open(path, "r", encoding="utf-8") as f: + defaults = json.load(f) + return {k: defaults[k] for k in SETTING_KEYS} + + +def resolve_settings(params: dict, defaults: dict | None = None) -> dict: + """Saved settings where present, shipped defaults for the rest.""" + defaults = default_settings() if defaults is None else defaults + return {k: params.get(k, defaults[k]) for k in SETTING_KEYS} + + +def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: + return df.drop(columns=[c for c in STAT_COLUMNS if c in df.columns], errors="ignore") + + +def valid_test(test: str, n_groups: int) -> str: + """The saved test if it fits the number of groups, else limma_like.""" + allowed = ["limma_like", "welch", "paired"] if n_groups == 2 else ["limma_like", "anova"] + return test if test in allowed else "limma_like" + + +def describe_settings(settings: dict) -> list[str]: + """One plain-language line per step, for showing what was run.""" + method = settings["postproc-filter-method"] + if method == "Low Repeatability": + filt = ( + f"kept proteins measured in at least {100 - float(settings['postproc-filter-max-missing']):g} % " + "of the samples of at least one group" + ) + elif method == "Low Abundance": + filt = f"removed the {float(settings['postproc-filter-abundance-percentile']):g} % least abundant proteins" + else: + filt = f"removed the {float(settings['postproc-filter-variance-percentile']):g} % least variable proteins" + if settings["postproc-impute-class"].startswith("MNAR"): + imp = ( + "filled missing values with the protein's lowest measured value (assumes they were too faint to detect)" + if settings["postproc-impute-mnar-scope"] == "row" + else "filled missing values with the lowest value of the whole dataset" + ) + else: + imp = f"filled missing values with the group {settings['postproc-impute-mar-strategy']}" + norm = f"{settings['postproc-transform']} transform, {settings['postproc-normalization']} normalization" + if settings["postproc-scaling"] != "None": + norm += f", {settings['postproc-scaling']} scaling" + stat = f"{settings['postproc-stat-test']} test, {settings['postproc-fdr']} correction for multiple testing" + return [f"**Filtering:** {filt}", f"**Missing values:** {imp}", f"**Normalization:** {norm}", f"**Statistics:** {stat}"] + + +def run_differential( + pivot_df: pd.DataFrame, + sample_group_map: dict, + id_col: str, + settings: dict, + done: dict | None = None, +) -> dict: + """Run the steps of the chain that are not in ``done`` and return all outputs. + + ``done`` maps step keys (see ``STEPS``) to tables the user already produced; + the chain continues from the latest of them. Returns every step's table plus + ``normalized_transform`` and the test actually used (``stat_test``). + Raises ValueError when the samples do not form at least two groups. + """ + done = {k: v for k, v in (done or {}).items() if k in STEPS and v is not None} + sample_cols = [c for c in pivot_df.columns if c in sample_group_map] + metadata = pl.DataFrame( + [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols], + schema={"sample_id": pl.String, "group": pl.String}, + ) + groups = sorted({sample_group_map[s] for s in sample_cols if sample_group_map[s] != "Unassigned"}) + if len(groups) < 2: + raise ValueError( + "Comparing groups needs at least two groups. Assign a group (e.g. control, treated) " + "to every file on the Configure page and run the workflow again." + ) + + out = dict(done) + out.setdefault("normalized_transform", settings["postproc-transform"]) + + if "filtered_df" not in out: + data = pl.from_pandas(strip_stat_columns(pivot_df)).lazy() + method = settings["postproc-filter-method"] + if method == "Low Abundance": + data = filter_low_abundance( + quantification_data=data, metadata=metadata, group_column="group", + threshold_percentile=float(settings["postproc-filter-abundance-percentile"]), + ) + elif method == "Low Variance": + data = filter_low_variance( + quantification_data=data, metadata=metadata, group_column="group", + threshold_percentile=float(settings["postproc-filter-variance-percentile"]), + ) + else: + data = filter_low_repeatability( + quantification_data=data, metadata=metadata, group_column="group", + max_missing_ratio=float(settings["postproc-filter-max-missing"]) / 100.0, + ) + out["filtered_df"] = strip_stat_columns(data.collect().to_pandas()) + if out["filtered_df"].empty: + raise ValueError( + "No protein passed the filter. Relax it on the Filtering page (e.g. allow more missing values)." + ) + + if "imputed_df" not in out: + data = pl.from_pandas(out["filtered_df"]).lazy() + if settings["postproc-impute-class"].startswith("MAR"): + data = impute_mar( + quantification_data=data, metadata=metadata, group_column="group", + strategy=settings["postproc-impute-mar-strategy"], + ) + else: + data = impute_smallest_value( + quantification_data=data, metadata=metadata, scope=settings["postproc-impute-mnar-scope"], + ) + out["imputed_df"] = strip_stat_columns(data.collect().to_pandas()) + + if "normalized_df" not in out: + normalization = settings["postproc-normalization"] + if normalization == "reference_feature": + # Needs a protein the user names on the Normalization page + normalization = "median" + data = transform_data( + quantification_data=pl.from_pandas(out["imputed_df"]).lazy(), + metadata=metadata, strategy=settings["postproc-transform"], + ) + data = normalize_samples( + quantification_data=data, metadata=metadata, strategy=normalization, id_col=id_col, + reference_feature=None, + ) + data = scale_data(quantification_data=data, metadata=metadata, strategy=settings["postproc-scaling"]) + out["normalized_df"] = strip_stat_columns(data.collect().to_pandas()) + out["normalized_transform"] = settings["postproc-transform"] + + out["stat_test"] = valid_test(settings["postproc-stat-test"], len(groups)) + if "statistics_df" not in out: + data = calculate_statistical_tests( + quantification_data=pl.from_pandas(out["normalized_df"]).lazy(), + metadata=metadata, method=out["stat_test"], + ) + data = adjust_fdr_lazy(quantification_data=data, strategy=settings["postproc-fdr"]) + out["statistics_df"] = data.collect().to_pandas() + return out + + +def count_significant(statistics_df: pd.DataFrame, fc: float = 1.0, padj: float = 0.05) -> tuple[int, int]: + """Proteins up and down at |log2FC| >= fc and adjusted p <= padj.""" + df = statistics_df.dropna(subset=["log2FC", "p-adj"]) + sig = df["p-adj"] <= padj + return int((sig & (df["log2FC"] >= fc)).sum()), int((sig & (df["log2FC"] <= -fc)).sum()) + + +def comparison_groups(sample_group_map: dict, sample_cols: list) -> tuple[str, str] | None: + """(reference, compared) groups behind the sign of log2FC. + + openms_insight subtracts the alphabetically first group's mean from the + last one's, so a positive log2FC means higher in the last group. + """ + groups = sorted({sample_group_map[s] for s in sample_cols if s in sample_group_map}) + if len(groups) < 2: + return None + return groups[0], groups[-1] diff --git a/src/common/guide.py b/src/common/guide.py new file mode 100644 index 0000000..34509c1 --- /dev/null +++ b/src/common/guide.py @@ -0,0 +1,127 @@ +"""Shared page furniture that guides users through the app. + +Every page opens with ``page_intro`` (what the page answers and, folded away, +the background a non-specialist needs to read it) and ends with ``next_steps`` +(where to go from here), so the app reads as one path from upload to biology. +""" +import streamlit as st + +from src.common.differential import STEPS, describe_settings, resolve_settings, run_differential +from src.common.results_helpers import get_abundance_data, get_id_column, get_sample_group_map + +# Page paths, so links survive a page being renamed in one place only. +PAGES = { + "home": ("content/quickstart.py", "Home", "๐Ÿ "), + "upload": ("content/workflow_fileupload.py", "Upload data", "๐Ÿ“"), + "configure": ("content/workflow_configure.py", "Describe experiment", "โš™๏ธ"), + "run": ("content/workflow_run.py", "Run analysis", "๐Ÿš€"), + "overview": ("content/results_overview.py", "Run summary", "๐Ÿงพ"), + "proteins": ("content/results_proteins.py", "Identified proteins", "๐Ÿงฉ"), + "abundance": ("content/results_abundance.py", "Protein abundances", "๐Ÿ“‹"), + "compare": ("content/compare_groups.py", "Compare groups", "โš–๏ธ"), + "volcano": ("content/results_volcano.py", "Volcano plot", "๐ŸŒ‹"), + "pca": ("content/results_pca.py", "Sample similarity (PCA)", "๐Ÿ“Š"), + "heatmap": ("content/results_heatmap.py", "Heatmap", "๐Ÿ”ฅ"), + "clustered": ("content/results_heatmap_clustered.py", "Clustered heatmap", "๐Ÿงฌ"), + "enrichment": ("content/enrichment.py", "GO enrichment", "๐Ÿ“‰"), + "samples": ("content/results_samples.py", "Per-sample counts", "๐Ÿงช"), + "download": ("content/results_download.py", "Download results", "โฌ‡๏ธ"), + "filtering": ("content/filtering.py", "1. Filter proteins", "๐Ÿงน"), + "imputation": ("content/imputation.py", "2. Missing values", "๐Ÿฉน"), + "normalization": ("content/normalization.py", "3. Normalize", "โš–๏ธ"), + "statistics": ("content/statistical.py", "4. Statistical test", "๐Ÿ”ข"), + "qc": ("content/results_qc.py", "Identification quality", "๐Ÿฉบ"), + "filtered": ("content/results_filtered.py", "Peptide-spectrum matches", "๐ŸŽฏ"), + "search": ("content/results_database_search.py", "Raw search hits (Comet)", "๐Ÿ”ฌ"), + "rescoring": ("content/results_rescoring.py", "Rescored hits (Percolator)", "๐Ÿ“ˆ"), + "mass_shifts": ("content/results_mass_shifts.py", "Mass shifts", "๐Ÿ“"), +} + + +def page_link(key: str, label: str | None = None) -> None: + path, default_label, icon = PAGES[key] + try: + st.page_link(path, label=label or default_label, icon=icon) + except KeyError: + # A page loaded on its own (AppTest.from_file) has no navigation to link into + st.markdown(f"{icon} {label or default_label}") + + +def page_intro(lead: str, background: str | None = None, background_title: str = "Background") -> None: + """The page's purpose in plain words, with optional background folded away.""" + st.markdown(lead) + if background: + with st.expander(f"๐Ÿ“– {background_title}"): + st.markdown(background) + + +def next_steps(*steps: tuple[str, str]) -> None: + """Footer linking to the pages that usually come next, each with a reason.""" + st.markdown("---") + st.markdown("**Where to go next**") + for key, why in steps: + cols = st.columns([2, 5]) + with cols[0]: + page_link(key) + cols[1].markdown(why) + + +def needs_results(message: str = "No results yet. Upload your data, describe the experiment and run the analysis first.") -> None: + """Explain that the page needs a finished run, link to the start, and stop.""" + st.info(message) + cols = st.columns(3) + with cols[0]: + page_link("upload") + with cols[1]: + page_link("configure") + with cols[2]: + page_link("run") + st.stop() + + +def ensure_group_comparison(params: dict, show_errors: bool = True): + """Make sure the group comparison exists, computing it with saved settings if not. + + Steps the user applied by hand on the Downstream Analysis pages are kept; + only the missing ones run. Returns the statistics table, or None when there + is no abundance data or the samples do not form two groups. + """ + if st.session_state.get("statistics_df") is not None: + return st.session_state["statistics_df"] + workspace = st.session_state.get("workspace") + result = get_abundance_data(workspace) if workspace else None + if result is None: + return None + pivot_df, _, group_map = result + id_col = get_id_column(workspace, pivot_df) + sample_group_map = get_sample_group_map(workspace, pivot_df, group_map) + settings = resolve_settings(params) + try: + with st.spinner("Comparing groups with the recommended settings..."): + out = run_differential( + pivot_df, sample_group_map, id_col, settings, + done={k: st.session_state.get(k) for k in STEPS}, + ) + except ValueError as err: + if show_errors: + st.warning(str(err)) + return None + for key in [*STEPS, "normalized_transform"]: + st.session_state[key] = out[key] + st.session_state["comparison_settings"] = describe_settings({**settings, "postproc-stat-test": out["stat_test"]}) + return out["statistics_df"] + + +def show_comparison_settings() -> None: + """Fold-away note of how the group comparison was computed.""" + lines = st.session_state.get("comparison_settings") + with st.expander("โš™๏ธ How the groups were compared"): + if lines: + st.markdown("\n".join(f"- {line}" for line in lines)) + else: + st.markdown("Computed step by step on the Fine-tune pages.") + st.caption("Change any step on the Fine-tune pages; the plots follow.") + cols = st.columns(4) + for col, key in zip(cols, ["filtering", "imputation", "normalization", "statistics"]): + with col: + page_link(key) diff --git a/src/common/insight_tables.py b/src/common/insight_tables.py index 776a35e..ca7e22e 100644 --- a/src/common/insight_tables.py +++ b/src/common/insight_tables.py @@ -37,6 +37,20 @@ def _fingerprint_dataframe(df: pd.DataFrame) -> str: return digest.hexdigest() +def plot_cache(name: str, df: pd.DataFrame) -> dict: + """``cache_id``/``cache_path`` for an insight plot of a table computed in the session. + + Keying the cache on the table's content rebuilds it when an upstream step + changes the data, and reuses it on every other rerun. Rebuilding on each + rerun (``regenerate_cache=True``) instead hands the browser new data every + time the component asks for it, and the plot never leaves "Loading...". + """ + return { + "cache_id": f"{name}_{_fingerprint_dataframe(df)[:16]}", + "cache_path": str(_default_cache_dir()), + } + + def _default_cache_dir() -> Path: """Cache location for tables that are not tied to a workflow result directory.""" workspace = st.session_state.get("workspace") diff --git a/src/common/results_helpers.py b/src/common/results_helpers.py index 7e67393..8fe09a4 100644 --- a/src/common/results_helpers.py +++ b/src/common/results_helpers.py @@ -541,6 +541,8 @@ def clear_downstream_steps(step: str) -> None: Re-applying a step changes the table the later steps were computed from, so their stored results no longer match and must be recomputed. """ + # The note of how the comparison was computed no longer applies + st.session_state.pop("comparison_settings", None) for key in POSTPROCESSING_STEPS[POSTPROCESSING_STEPS.index(step) + 1:]: st.session_state.pop(key, None) if key == "normalized_df": diff --git a/tests/test_differential.py b/tests/test_differential.py new file mode 100644 index 0000000..b4489ce --- /dev/null +++ b/tests/test_differential.py @@ -0,0 +1,81 @@ +"""Tests for the one-go group comparison in src/common/differential.py.""" +import numpy as np +import pandas as pd +import pytest + +from src.common.differential import ( + count_significant, + default_settings, + describe_settings, + resolve_settings, + run_differential, + valid_test, +) + + +def _table(n_groups=2, n_proteins=60, seed=0): + rng = np.random.default_rng(seed) + samples = [f"sample{i + 1}[file{i + 1}]" for i in range(3 * n_groups)] + group_map = {s: f"g{i // 3}" for i, s in enumerate(samples)} + base = rng.lognormal(mean=20, sigma=1, size=(n_proteins, 1)) + data = base * rng.lognormal(mean=0, sigma=0.15, size=(n_proteins, len(samples))) + data[:10, 3:6] *= 8 # ten proteins clearly up in the second group + data[50:, :3] = 0 # ten proteins never seen in the first group + df = pd.DataFrame(data, columns=samples) + df.insert(0, "ProteinName", [f"P{i:05d}" for i in range(n_proteins)]) + return df, group_map + + +def test_defaults_run_end_to_end_and_find_the_changed_proteins(): + df, groups = _table() + out = run_differential(df, groups, "ProteinName", default_settings()) + stats = out["statistics_df"] + assert {"log2FC", "p-value", "p-adj"} <= set(stats.columns) + assert out["normalized_transform"] == "log2" + up, _ = count_significant(stats) + assert up >= 5 + top = stats.sort_values("p-value")["ProteinName"].head(10) + assert top.isin([f"P{i:05d}" for i in range(10)]).sum() >= 5 + + +def test_continues_from_a_step_done_by_hand(): + df, groups = _table() + filtered = df.head(30) + out = run_differential(df, groups, "ProteinName", default_settings(), done={"filtered_df": filtered}) + assert out["filtered_df"] is filtered + assert len(out["statistics_df"]) == 30 + + +def test_needs_two_groups(): + df, groups = _table() + with pytest.raises(ValueError, match="two groups"): + run_differential(df, {s: "g0" for s in groups}, "ProteinName", default_settings()) + + +def test_test_falls_back_to_limma_for_more_groups(): + assert valid_test("welch", 3) == "limma_like" + assert valid_test("anova", 3) == "anova" + assert valid_test("anova", 2) == "limma_like" + df, groups = _table(n_groups=3) + settings = {**default_settings(), "postproc-stat-test": "welch"} + out = run_differential(df, groups, "ProteinName", settings) + assert out["stat_test"] == "limma_like" + + +def test_saved_settings_win_over_defaults_and_are_described(): + settings = resolve_settings({"postproc-fdr": "Bonferroni"}) + assert settings["postproc-fdr"] == "Bonferroni" + assert settings["postproc-transform"] == "log2" + lines = describe_settings(settings) + assert len(lines) == 4 and "Bonferroni" in lines[3] + + +def test_comparison_direction_matches_the_sign_of_log2fc(): + from src.common.differential import comparison_groups + + df, groups = _table() + ref, comp = comparison_groups(groups, list(groups)) + assert (ref, comp) == ("g0", "g1") + stats = run_differential(df, groups, "ProteinName", default_settings())["statistics_df"] + # The planted proteins are higher in g1, so they must come out positive + assert (stats.set_index("ProteinName").loc[[f"P{i:05d}" for i in range(10)], "log2FC"] > 0).all() From 7cc6ad02a69818f7c0289e97f454e29388632913 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:27:37 +0000 Subject: [PATCH 14/18] Home: lead with finding differentially abundant proteins Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FkEGemnfPye3pzon8Q9pur --- content/quickstart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/quickstart.py b/content/quickstart.py index c8f8cdd..3798d85 100644 --- a/content/quickstart.py +++ b/content/quickstart.py @@ -94,7 +94,7 @@ def render_windows_download_box(app_bytes: bytes) -> None: page_setup(page="main") -st.markdown("# OpenDDA: find out which proteins change in your samples") +st.markdown("# OpenDDA: Find differentially abundant proteins in your samples") windows_app_bytes = load_windows_app_bytes() if windows_app_bytes is not None: @@ -104,7 +104,7 @@ def render_windows_download_box(app_bytes: bytes) -> None: """ OpenDDA turns raw mass spectrometry files from a **data-dependent acquisition (DDA)** experiment into a list of proteins, how much of each protein every sample contains, -and which proteins differ between your experimental groups. You do not need to know +and which proteins are differentially abundant between your experimental groups. You do not need to know the search engines behind it: every setting starts at a value that suits a typical experiment, and each page explains what you are looking at. """ From 48dd730e76a5b6734e7c0aec73a80e8771b37d77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:56:43 +0000 Subject: [PATCH 15/18] Make TMT experiments work through the guided analysis - Samples or channels without a group are listed as "Unassigned" and left out of the comparison. Before, an LFQ sample without a group vanished silently, and an Unassigned group became a third group that skewed the test and the direction of log2FC. - Fine-tune pages and PCA take their sample columns from the group map, so the TMT report columns n_proteins, n_peptides and protein_score no longer count as samples. - Group changes on Describe experiment refresh the comparison; they apply without a new run. - TMT channels are named by their reporter ion (sample3[127C] instead of abundance_sample3[unknown]), in the tables and in the group form. - The TMT protein table shows one tidy table like LFQ; texts on Upload, Compare groups and Missing values cover both experiment types. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FkEGemnfPye3pzon8Q9pur --- content/compare_groups.py | 9 +++---- content/filtering.py | 13 ++++------ content/imputation.py | 18 +++++++------- content/normalization.py | 11 ++++----- content/results_abundance.py | 42 ++++++++++++++++---------------- content/results_mass_shifts.py | 2 +- content/results_overview.py | 2 +- content/results_pca.py | 6 ++--- content/results_samples.py | 2 +- content/statistical.py | 15 ++++-------- content/workflow_fileupload.py | 2 ++ src/WorkflowTest.py | 9 +++++-- src/common/differential.py | 17 +++++++++---- src/common/guide.py | 13 +++++++--- src/common/isobaric.py | 43 +++++++++++++++++++++++++++++++++ src/common/results_helpers.py | 38 +++++++++++++++++++++++++++-- src/pride_ui.py | 4 ++-- tests/test_differential.py | 44 ++++++++++++++++++++++++++++++++++ tests/test_isobaric.py | 28 ++++++++++++++++++++++ 19 files changed, 235 insertions(+), 83 deletions(-) create mode 100644 src/common/isobaric.py create mode 100644 tests/test_isobaric.py diff --git a/content/compare_groups.py b/content/compare_groups.py index d282e51..4145fd8 100644 --- a/content/compare_groups.py +++ b/content/compare_groups.py @@ -21,7 +21,7 @@ page_intro( "Which proteins are more or less abundant in one group of samples than in the other? " - "The comparison runs automatically with settings that suit most label-free experiments.", + "The comparison runs automatically with settings that suit most experiments, label-free and TMT alike.", background=""" The comparison runs four steps on the protein abundance table: @@ -65,11 +65,12 @@ groups.setdefault(sample_group_map[s], []).append(s) cols = st.columns(max(len(groups), 1)) for col, (group, members) in zip(cols, sorted(groups.items())): - col.metric(group, f"{len(members)} samples") + col.metric(group, f"{len(members)} sample{'' if len(members) == 1 else 's'}") if "Unassigned" in groups: st.warning( - "Some samples have no group. Give every file a group on the **Describe experiment** " - "page and run the analysis again." + "Some samples have no group, so they are left out of the comparison. Give each a group on " + "the **Describe experiment** page (for TMT, type **skip** for channels you do not want " + "compared). The comparison updates without running the analysis again." ) page_link("configure") diff --git a/content/filtering.py b/content/filtering.py index ae62394..dce39cf 100644 --- a/content/filtering.py +++ b/content/filtering.py @@ -13,6 +13,8 @@ get_abundance_data, get_id_column, get_sample_group_map, + group_metadata, + sample_columns, postprocessing_param, ) @@ -62,11 +64,7 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: sample_group_map = get_sample_group_map(st.session_state["workspace"], pivot_df, group_map) # 1. Identify actual sample columns dynamically -sample_cols = [ - c - for c in pivot_df.columns - if c not in [id_col, "PeptideSequence", "log2FC", "p-value", "p-adj"] -] +sample_cols = sample_columns(pivot_df, sample_group_map) with st.expander(f"Input: {pivot_df.shape[0]:,} proteins in {len(sample_cols)} samples"): show_insight_table(pivot_df, name="filtering-original") @@ -75,10 +73,7 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: st.subheader("Settings") # Prepare Polars Metadata DataFrame required by openms_insight functions -metadata_rows = [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols if s in sample_group_map] -metadata_pl = pl.DataFrame( - metadata_rows, schema={"sample_id": pl.String, "group": pl.String} -) +metadata_pl = group_metadata(sample_group_map, sample_cols) # User selection for filtering strategy. The default keeps proteins quantified # in at least half of the samples of one group, which removes rows that diff --git a/content/imputation.py b/content/imputation.py index ce6ab46..60c339e 100644 --- a/content/imputation.py +++ b/content/imputation.py @@ -13,6 +13,8 @@ get_abundance_data, get_id_column, get_sample_group_map, + group_metadata, + sample_columns, postprocessing_param, ) @@ -33,8 +35,9 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: 'Fill in the values of proteins that were not detected in some samples, so every protein can be tested.' "\n\n" + 'You do not need this page for a standard analysis: **Compare groups** runs all four steps with the recommended settings. Use it to inspect this step or change it; the plots follow your choice.', background=""" -- **Why values are missing.** In label-free data a protein is usually missing because it was too - faint to detect in that sample (*missing not at random*, MNAR), not by chance. +- **Why values are missing.** A protein is usually missing because it was too faint to detect + in that sample (*missing not at random*, MNAR), not by chance. Label-free data has many such + gaps; TMT data has fewer, because all channels of a run are measured in the same spectrum. - **Recommended: MNAR, row.** A missing value becomes the protein's own lowest measured value, i.e. "at most as much as the faintest time we saw it". A protein switched off in one group then comes out as clearly lower there, as it should. @@ -70,9 +73,7 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: st.warning("Step 1 (Filter proteins) has not been applied: working on the unfiltered table.") # 2. Identify actual sample columns dynamically based on the current active matrix -sample_cols = [ - c for c in base_df.columns if c not in [id_col, "PeptideSequence", "log2FC", "p-value", "p-adj"] -] +sample_cols = sample_columns(base_df, sample_group_map) with st.expander(f"Input: {base_df.shape[0]:,} proteins in {len(sample_cols)} samples"): show_insight_table(base_df, name="imputation-input") @@ -81,13 +82,10 @@ def strip_stat_columns(df: pd.DataFrame) -> pd.DataFrame: st.subheader("Settings") # Build Polars structural metadata DataFrame -metadata_rows = [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols if s in sample_group_map] -metadata_pl = pl.DataFrame( - metadata_rows, schema={"sample_id": pl.String, "group": pl.String} -) +metadata_pl = group_metadata(sample_group_map, sample_cols) # User selection for core missingness assumption strategy -# In DDA label-free data most missing values are proteins below the detection +# In DDA data (label-free in particular) most missing values are proteins below the detection # limit (MNAR), so the default fills them with the protein's own smallest # observed intensity. The global minimum sits far below most proteins and turns # a single random dropout into a large false fold change; MAR group imputation diff --git a/content/normalization.py b/content/normalization.py index 124b528..759b3cd 100644 --- a/content/normalization.py +++ b/content/normalization.py @@ -13,6 +13,8 @@ get_abundance_data, get_id_column, get_sample_group_map, + group_metadata, + sample_columns, postprocessing_param, ) # Import normalization engine functions from openms_insight @@ -92,9 +94,7 @@ def strip_stat_columns(df: pd.DataFrame | None) -> pd.DataFrame | None: st.warning("Steps 1 and 2 have not been applied: working on the unprocessed table.") # 2. Extract actual active sample columns dynamically -sample_cols = [ - c for c in base_df.columns if c not in [id_col, "PeptideSequence", "log2FC", "p-value", "p-adj"] -] +sample_cols = sample_columns(base_df, sample_group_map) with st.expander(f"Input: {base_df.shape[0]:,} proteins in {len(sample_cols)} samples"): show_insight_table(base_df, name="normalization-input") @@ -143,10 +143,7 @@ def strip_stat_columns(df: pd.DataFrame | None) -> pd.DataFrame | None: st.subheader("Settings") # Prepare structural Polars metadata DataFrame required by backend functions -metadata_rows = [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols if s in sample_group_map] -metadata_pl = pl.DataFrame( - metadata_rows, schema={"sample_id": pl.String, "group": pl.String} -) +metadata_pl = group_metadata(sample_group_map, sample_cols) st.caption( "Recommended: log2 transformation, median normalization and no row scaling. " diff --git a/content/results_abundance.py b/content/results_abundance.py index 5a69336..f56bcc9 100644 --- a/content/results_abundance.py +++ b/content/results_abundance.py @@ -64,7 +64,14 @@ def render_protein_table(pivot_df, is_lfq=True): """Common function to render the protein-level abundance table""" pivot_df = pivot_df.copy() - st.caption("Each protein's intensity is the sum of its peptides' intensities in that sample.") + if is_lfq: + st.caption("Each protein's intensity is the sum of its peptides' intensities in that sample.") + else: + st.caption( + "One column per label channel, named by its reporter ion. Each value is the protein's " + "reporter-ion intensity in that channel, from its most intense peptides; n_peptides " + "counts the peptides behind it." + ) if is_lfq: # Handle LFQ mode columns (Raw Intensity) @@ -76,10 +83,11 @@ def render_protein_table(pivot_df, is_lfq=True): else: # Handle non-LFQ mode columns (Log2-transformed Intensity) id_col = "protein" + id_col = pivot_df.columns[0] exclude_cols = [id_col, "n_proteins", "n_peptides", "protein_score"] sample_cols = [c for c in pivot_df.columns if c not in exclude_cols and "ratio" not in c.lower()] - display_cols = [id_col] + sample_cols + display_cols = [id_col, "n_peptides"] + sample_cols # Filter to available columns, then sort and display available_cols = [c for c in display_cols if c in pivot_df.columns] @@ -91,8 +99,15 @@ def render_protein_table(pivot_df, is_lfq=True): name="abundance_protein", cache_dir=cache_dir, source_files=table_sources, - extra_key=contaminant_key, + # The cache follows the source files, so name the columns shown as well + extra_key=f"{contaminant_key};{available_cols}", title="protein_abundance", + # Intensities as whole numbers (the table stores float32) + column_overrides={ + c: {"formatter": "money", "formatterParams": {"thousand": ",", "precision": 0}, "minWidth": 110} + for c in sample_cols + }, + **({} if is_lfq else {"layout": "fitColumns"}), ) try: @@ -142,26 +157,9 @@ def render_protein_table(pivot_df, is_lfq=True): ) else: - pre_processing_tab, protein_tab = st.tabs(["Intensity matrix", "Proteins"]) - if result is None: - st.info("๐Ÿ’ก Please run the workflow first to see results.") - st.stop() - - pivot_df, expr_df, group_map = result - - with pre_processing_tab: - show_insight_table( - pivot_df, - name="abundance_intensity_matrix", - cache_dir=cache_dir, - source_files=table_sources, - extra_key=contaminant_key, - title="intensity_matrix", - ) - - with protein_tab: - render_protein_table(pivot_df, is_lfq=False) + needs_results() + render_protein_table(result[0], is_lfq=False) except Exception as e: st.error(f"Failed to load {csv_file.name}: {e}") diff --git a/content/results_mass_shifts.py b/content/results_mass_shifts.py index 72e4287..72c3bc1 100644 --- a/content/results_mass_shifts.py +++ b/content/results_mass_shifts.py @@ -63,7 +63,7 @@ def _summary(paths: tuple, names: tuple, mtimes: tuple): else: st.caption( "This run used a closed search, so only shifts within the precursor tolerance appear " - "(0 Da and isotope picks). Apply the **Open Search** preset on the Configure page to " + "(0 Da and isotope picks). Apply the **Open Search** preset on the Describe experiment page to " "look for unexpected modifications." ) page_link("configure") diff --git a/content/results_overview.py b/content/results_overview.py index 99114f6..6384a19 100644 --- a/content/results_overview.py +++ b/content/results_overview.py @@ -92,7 +92,7 @@ def _show(value, unit: str = "") -> str: ) row2[1].metric("Files", _show(figures["Files"])) row2[2].metric("Samples", _show(samples), help="Sample columns of the quantification result.") -row2[3].metric("Conditions", _show(conditions), help="Distinct groups set on the Configure page.") +row2[3].metric("Conditions", _show(conditions), help="Distinct groups set on the Describe experiment page.") st.subheader("Files") st.caption( diff --git a/content/results_pca.py b/content/results_pca.py index 97838a0..d5beef6 100644 --- a/content/results_pca.py +++ b/content/results_pca.py @@ -10,6 +10,7 @@ get_active_table, get_id_column, get_sample_group_map, + sample_columns, ) from openms_insight import PCAPlot @@ -50,10 +51,7 @@ base_df, _, _ = get_active_table(pivot_df) # 2. Extract active sample columns and detect unique biological groups -sample_cols = [ - c for c in base_df.columns - if c not in [id_col, "PeptideSequence", "log2FC", "p-adj", "stat", "p-value"] -] +sample_cols = sample_columns(base_df, sample_group_map) unique_groups = sorted({sample_group_map[s] for s in sample_cols if s in sample_group_map}) if len(sample_cols) < 2: diff --git a/content/results_samples.py b/content/results_samples.py index 39facdb..07f42e0 100644 --- a/content/results_samples.py +++ b/content/results_samples.py @@ -77,7 +77,7 @@ def _lfq_samples(workflow_dir: str, files: tuple, groups: tuple, mtimes: tuple): sources = sorted((results_dir / "quant_results").glob("*.csv")) st.caption( "Each sample is an isobaric channel. Quantified proteins have an intensity above 0 in the " - "channel. Identifications are made per file, not per channel: see the Overview page." + "channel. Identifications are made per file, not per channel: see the Run summary page." ) if samples.empty: diff --git a/content/statistical.py b/content/statistical.py index cf079bc..2d9d53a 100644 --- a/content/statistical.py +++ b/content/statistical.py @@ -12,6 +12,8 @@ get_abundance_data, get_id_column, get_sample_group_map, + group_metadata, + sample_columns, postprocessing_param, ) # Import statistics engine functions from openms_insight @@ -91,10 +93,9 @@ ) # 2. Extract actual active sample columns and detect unique biological groups -sample_cols = [ - c for c in base_df.columns if c not in [id_col, "PeptideSequence", "log2FC", "p-value", "p-adj"] -] -unique_groups = sorted(list(set([sample_group_map[s] for s in sample_cols if s in sample_group_map]))) +sample_cols = sample_columns(base_df, sample_group_map) +metadata_pl = group_metadata(sample_group_map, sample_cols) +unique_groups = sorted(set(metadata_pl["group"].to_list())) group_count = len(unique_groups) with st.expander(f"Input: {base_df.shape[0]:,} proteins in {len(sample_cols)} samples from {group_count} groups ({', '.join(unique_groups)})"): @@ -103,12 +104,6 @@ # --- SECTION 2: Dynamic Statistical Parameter Configuration --- st.subheader("Settings") -# Prepare structural Polars metadata DataFrame required by backend functions -metadata_rows = [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols if s in sample_group_map] -metadata_pl = pl.DataFrame( - metadata_rows, schema={"sample_id": pl.String, "group": pl.String} -) - col1, col2 = st.columns(2) with col1: diff --git a/content/workflow_fileupload.py b/content/workflow_fileupload.py index 5459e21..4e0badd 100644 --- a/content/workflow_fileupload.py +++ b/content/workflow_fileupload.py @@ -12,6 +12,8 @@ You need two kinds of files: - **MS data (mzML)**: one file per measured run. Convert vendor raw files to mzML first. + For a TMT experiment, upload every run (fraction) of the labelled pool; the groups are + then set per label channel, not per file. - **Protein database (FASTA)**: the protein sequences of your organism. Decoys and common contaminants are added automatically. diff --git a/src/WorkflowTest.py b/src/WorkflowTest.py index 3c3cd9b..5778cf7 100644 --- a/src/WorkflowTest.py +++ b/src/WorkflowTest.py @@ -17,6 +17,7 @@ from src.common.results_helpers import get_abundance_data from src.common.results_helpers import parse_idxml, build_spectra_cache, load_idxml, write_psm_exports from src.common.contaminants import CONTAMINANT_PREFIX, append_fasta, write_contaminants +from src.common.isobaric import ISOBARIC_CHANNELS from src.pride_ui import pride_import_section from openms_insight import Table, Heatmap, LinePlot, SequenceView @@ -755,6 +756,10 @@ def render_tmt_groups(self): else: num_plex = int(m.group()) channels = [f"sample{i+1}" for i in range(num_plex)] + reporter = ISOBARIC_CHANNELS.get(selected_type, []) + + def tags(idx): + return f" ({reporter[idx]})" if idx < len(reporter) else "" st.caption(f"Isobaric type: **{selected_type}** - {num_plex} channels") for row_start in range(0, num_plex, 2): @@ -766,7 +771,7 @@ def render_tmt_groups(self): self.ui.input_widget( key=f"TMT-group-{left_channel}", default="", - name=f"Group for channel {left_idx + 1}", + name=f"Group for channel {left_idx + 1}{tags(left_idx)}", widget_type="text", help="e.g. control, case, skip", ) @@ -778,7 +783,7 @@ def render_tmt_groups(self): self.ui.input_widget( key=f"TMT-group-{right_channel}", default="", - name=f"Group for channel {right_idx + 1}", + name=f"Group for channel {right_idx + 1}{tags(right_idx)}", widget_type="text", help="e.g. control, case, skip", ) diff --git a/src/common/differential.py b/src/common/differential.py index 3b8cf30..5650679 100644 --- a/src/common/differential.py +++ b/src/common/differential.py @@ -105,16 +105,19 @@ def run_differential( Raises ValueError when the samples do not form at least two groups. """ done = {k: v for k, v in (done or {}).items() if k in STEPS and v is not None} - sample_cols = [c for c in pivot_df.columns if c in sample_group_map] + # Samples without a group stay in the tables but take no part in the comparison + sample_cols = [ + c for c in pivot_df.columns if sample_group_map.get(c, "Unassigned") != "Unassigned" + ] metadata = pl.DataFrame( [{"sample_id": s, "group": sample_group_map[s]} for s in sample_cols], schema={"sample_id": pl.String, "group": pl.String}, ) - groups = sorted({sample_group_map[s] for s in sample_cols if sample_group_map[s] != "Unassigned"}) + groups = sorted({sample_group_map[s] for s in sample_cols}) if len(groups) < 2: raise ValueError( "Comparing groups needs at least two groups. Assign a group (e.g. control, treated) " - "to every file on the Configure page and run the workflow again." + "to every sample on the Describe experiment page and run the workflow again." ) out = dict(done) @@ -196,9 +199,13 @@ def comparison_groups(sample_group_map: dict, sample_cols: list) -> tuple[str, s """(reference, compared) groups behind the sign of log2FC. openms_insight subtracts the alphabetically first group's mean from the - last one's, so a positive log2FC means higher in the last group. + last one's, so a positive log2FC means higher in the last group. Samples + without a group are not part of the comparison. """ - groups = sorted({sample_group_map[s] for s in sample_cols if s in sample_group_map}) + groups = sorted({ + sample_group_map[s] for s in sample_cols + if sample_group_map.get(s, "Unassigned") != "Unassigned" + }) if len(groups) < 2: return None return groups[0], groups[-1] diff --git a/src/common/guide.py b/src/common/guide.py index 34509c1..d103d1d 100644 --- a/src/common/guide.py +++ b/src/common/guide.py @@ -86,15 +86,22 @@ def ensure_group_comparison(params: dict, show_errors: bool = True): only the missing ones run. Returns the statistics table, or None when there is no abundance data or the samples do not form two groups. """ - if st.session_state.get("statistics_df") is not None: - return st.session_state["statistics_df"] workspace = st.session_state.get("workspace") result = get_abundance_data(workspace) if workspace else None if result is None: - return None + return st.session_state.get("statistics_df") pivot_df, _, group_map = result id_col = get_id_column(workspace, pivot_df) sample_group_map = get_sample_group_map(workspace, pivot_df, group_map) + # Groups edited on Describe experiment apply without a new run, so a + # comparison made with other groups is out of date + previous = st.session_state.get("comparison_group_map") + if previous is not None and previous != sample_group_map: + for key in [*STEPS, "normalized_transform", "comparison_settings"]: + st.session_state.pop(key, None) + st.session_state["comparison_group_map"] = sample_group_map + if st.session_state.get("statistics_df") is not None: + return st.session_state["statistics_df"] settings = resolve_settings(params) try: with st.spinner("Comparing groups with the recommended settings..."): diff --git a/src/common/isobaric.py b/src/common/isobaric.py new file mode 100644 index 0000000..8937a63 --- /dev/null +++ b/src/common/isobaric.py @@ -0,0 +1,43 @@ +"""Reporter channels of the isobaric label types IsobaricAnalyzer supports.""" +import re + +# Channel names in IsobaricAnalyzer's channel order, so channel n is the n-th entry +_TMT11 = ["126", "127N", "127C", "128N", "128C", "129N", "129C", "130N", "130C", "131N", "131C"] +_TMT16 = _TMT11 + ["132N", "132C", "133N", "133C", "134N"] +ISOBARIC_CHANNELS = { + "itraq4plex": ["114", "115", "116", "117"], + "itraq8plex": ["113", "114", "115", "116", "117", "118", "119", "121"], + "tmt6plex": ["126", "127", "128", "129", "130", "131"], + "tmt10plex": _TMT11[:9] + ["131"], + "tmt11plex": _TMT11, + "tmt16plex": _TMT16, + "tmt18plex": _TMT16 + ["134C", "135N"], +} + +_CHANNEL_COLUMN = re.compile(r"^(?:abundance_)?sample(\d+)\[[^\]]*\]$") + + +def label_type(params: dict) -> str: + """The label type of the run, from the saved workflow parameters.""" + for source in (params.get("IsobaricAnalyzer-TMT", {}), params.get("_defaults", {}).get("IsobaricAnalyzer-TMT", {})): + if source.get("type"): + return str(source["type"]).lower() + return "tmt11plex" + + +def channel_column_names(columns, label: str) -> dict: + """Readable names for ProteinQuantifier's channel columns. + + ProteinQuantifier writes ``abundance_sample3[unknown]`` when the channels + have no description; this becomes ``sample3[127C]``. The ``sampleN[`` part + stays, because the group assignment finds channels by it. + """ + tags = ISOBARIC_CHANNELS.get(label, []) + renamed = {} + for col in columns: + m = _CHANNEL_COLUMN.match(str(col)) + if m: + n = int(m.group(1)) + tag = tags[n - 1] if n <= len(tags) else str(col).rsplit("[", 1)[1].rstrip("]") + renamed[col] = f"sample{n}[{tag}]" + return renamed diff --git a/src/common/results_helpers.py b/src/common/results_helpers.py index 8fe09a4..0543a4e 100644 --- a/src/common/results_helpers.py +++ b/src/common/results_helpers.py @@ -14,6 +14,11 @@ from src.workflow.ParameterManager import ParameterManager from src.common.contaminants import drop_contaminants, is_contaminant from src.common.peptide_format import to_proforma +from src.common.isobaric import channel_column_names, label_type + +UNASSIGNED = "Unassigned" +# Columns the group comparison adds to a protein table +STAT_COLUMNS = ("log2FC", "p-value", "p-adj", "stat") def get_workflow_dir(workspace): """Get the workflow directory path.""" @@ -393,6 +398,8 @@ def load_abundance_data(workspace_path: str, csv_mtime: float, params_mtime: flo # ratio column removal df = df.loc[:, ~df.columns.str.contains('ratio', case=False)] + # "abundance_sample3[unknown]" -> "sample3[127C]" + df = df.rename(columns=channel_column_names(df.columns, label_type(workflow_params))) # exclude_indices = st.session_state.get("tmt_exclude_indices", []) # group_map = st.session_state.get("tmt_group_map", {}) @@ -512,10 +519,16 @@ def get_sample_group_map(workspace: Path, pivot_df: pd.DataFrame, group_map: dic analysis_mode = ParameterManager(workflow_dir, "TOPP Workflow").get_parameters_from_json().get("analysis-mode", "LFQ") if analysis_mode == "LFQ": - return { + grouped = { k[:-5] if k.endswith(".mzML") else k: v for k, v in group_map.items() } + # Every sample column is listed, those without a group as "Unassigned" + return { + c: grouped.get(c) or UNASSIGNED + for c in pivot_df.columns + if c not in ("ProteinName", "PeptideSequence", *STAT_COLUMNS) + } actual_sample_names = pivot_df.columns.tolist() norm_map = {} @@ -527,10 +540,31 @@ def get_sample_group_map(workspace: Path, pivot_df: pd.DataFrame, group_map: dic target_substring = f"sample{sample_idx}[" real_full_name = next((name for name in actual_sample_names if target_substring in name), None) if real_full_name: - norm_map[real_full_name] = v if v and v.strip() else "Unassigned" + norm_map[real_full_name] = v if v and v.strip() else UNASSIGNED + # Channels left without a group (not skipped) count as "Unassigned" + for name in actual_sample_names: + if re.search(r"sample\d+\[", str(name)) and name not in norm_map: + norm_map[name] = UNASSIGNED return norm_map +def sample_columns(df: pd.DataFrame, sample_group_map: dict) -> list: + """The sample columns of ``df``, in table order (no id or summary columns).""" + return [c for c in df.columns if c in sample_group_map] + + +def group_metadata(sample_group_map: dict, columns) -> pl.DataFrame: + """Sample-to-group table for the statistics, leaving out unassigned samples.""" + return pl.DataFrame( + [ + {"sample_id": s, "group": sample_group_map[s]} + for s in columns + if sample_group_map.get(s, UNASSIGNED) != UNASSIGNED + ], + schema={"sample_id": pl.String, "group": pl.String}, + ) + + # Session-state keys of the postprocessing chain, in pipeline order. POSTPROCESSING_STEPS = ["filtered_df", "imputed_df", "normalized_df", "statistics_df"] diff --git a/src/pride_ui.py b/src/pride_ui.py index 4956bee..2bf7e38 100644 --- a/src/pride_ui.py +++ b/src/pride_ui.py @@ -339,11 +339,11 @@ def _setup_section(wf, job_dir: Path, status: dict) -> None: summary += f" and {Path(fasta_files[0]).name}" if settings: summary += ", and applies the SDRF settings above" - st.caption(summary + ". Review them on the Configure page before running.") + st.caption(summary + ". Review them on the Describe experiment page before running.") if st.button("Use for the workflow", type="primary", use_container_width=True, key="pride-apply"): apply_to_workflow(wf, mzml_files, fasta_files, settings) - st.success("Done. Open the Configure page to review the settings.") + st.success("Done. Open the Describe experiment page to review the settings.") def pride_import_section(wf, fallback: dict = None) -> None: diff --git a/tests/test_differential.py b/tests/test_differential.py index b4489ce..fad8b55 100644 --- a/tests/test_differential.py +++ b/tests/test_differential.py @@ -79,3 +79,47 @@ def test_comparison_direction_matches_the_sign_of_log2fc(): stats = run_differential(df, groups, "ProteinName", default_settings())["statistics_df"] # The planted proteins are higher in g1, so they must come out positive assert (stats.set_index("ProteinName").loc[[f"P{i:05d}" for i in range(10)], "log2FC"] > 0).all() + + +def test_unassigned_samples_and_tmt_summary_columns_stay_out_of_the_comparison(): + df, groups = _table() + df.insert(1, "n_peptides", 3) + df.insert(2, "protein_score", 0.9) + df["sample7[file7]"] = df["sample1[file1]"] * 100 # a channel nobody grouped + groups["sample7[file7]"] = "Unassigned" + out = run_differential(df, groups, "ProteinName", default_settings()) + reference = run_differential(*_table(), "ProteinName", default_settings()) + merged = out["statistics_df"].merge(reference["statistics_df"], on="ProteinName") + np.testing.assert_allclose(merged["log2FC_x"], merged["log2FC_y"], rtol=1e-5) + assert (out["normalized_df"]["n_peptides"] == 3).all() + + +def test_comparison_groups_ignore_unassigned(): + from src.common.differential import comparison_groups + + groups = {"a": "control", "b": "treated", "c": "Unassigned"} + assert comparison_groups(groups, list(groups)) == ("control", "treated") + + +def test_tmt_group_map_marks_ungrouped_channels(tmp_path): + import json + + from src.common.results_helpers import ( + get_sample_group_map, + group_metadata, + sample_columns, + ) + + workflow_dir = tmp_path / "topp-workflow" + workflow_dir.mkdir() + (workflow_dir / "params.json").write_text(json.dumps({"analysis-mode": "TMT"})) + pivot = pd.DataFrame(columns=[ + "protein", "n_proteins", "protein_score", "n_peptides", + "sample1[126]", "sample2[127N]", "sample3[127C]", + ]) + mapping = get_sample_group_map(tmp_path, pivot, {0: "control", 1: "treated"}) + assert mapping == {"sample1[126]": "control", "sample2[127N]": "treated", "sample3[127C]": "Unassigned"} + assert sample_columns(pivot, mapping) == ["sample1[126]", "sample2[127N]", "sample3[127C]"] + assert group_metadata(mapping, sample_columns(pivot, mapping))["sample_id"].to_list() == [ + "sample1[126]", "sample2[127N]", + ] diff --git a/tests/test_isobaric.py b/tests/test_isobaric.py new file mode 100644 index 0000000..5ee544a --- /dev/null +++ b/tests/test_isobaric.py @@ -0,0 +1,28 @@ +"""Tests for the reporter-channel naming in src/common/isobaric.py.""" +from src.common.isobaric import ISOBARIC_CHANNELS, channel_column_names, label_type + + +def test_plex_sizes_match_their_names(): + for label, channels in ISOBARIC_CHANNELS.items(): + assert len(channels) == int(label.split("plex")[0].lstrip("tmitraq")) + assert len(set(channels)) == len(channels) + + +def test_protein_quantifier_columns_get_reporter_names(): + columns = ["protein", "n_proteins", "abundance_sample1[unknown]", "abundance_sample3[unknown]"] + assert channel_column_names(columns, "tmt11plex") == { + "abundance_sample1[unknown]": "sample1[126]", + "abundance_sample3[unknown]": "sample3[127C]", + } + + +def test_described_channels_keep_their_description_beyond_the_plex(): + assert channel_column_names(["abundance_sample5[pool]"], "itraq4plex") == { + "abundance_sample5[pool]": "sample5[pool]" + } + + +def test_label_type_prefers_the_saved_choice(): + assert label_type({}) == "tmt11plex" + assert label_type({"_defaults": {"IsobaricAnalyzer-TMT": {"type": "tmt16plex"}}}) == "tmt16plex" + assert label_type({"IsobaricAnalyzer-TMT": {"type": "TMT6plex"}}) == "tmt6plex" From 5ca0278fd7d2658863d6bc99a4fa46d7fd123b63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:55:04 +0000 Subject: [PATCH 16/18] Keep Docker builds from hanging at the tzdata prompt apt-get ran without DEBIAN_FRONTEND=noninteractive, so when a package pulled in tzdata the build stopped at the interactive timezone question. Set it, with TZ=Etc/UTC, in the first stage of every Dockerfile; later stages inherit it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FkEGemnfPye3pzon8Q9pur --- Dockerfile | 4 ++++ Dockerfile.arm | 4 ++++ Dockerfile_simple | 4 ++++ Dockerfile_simple.arm | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/Dockerfile b/Dockerfile index a7547d4..02eedb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,10 @@ ARG GITHUB_REPO=quantms-web USER root +# Keep apt from stopping at interactive prompts: tzdata, pulled in by +# later packages, otherwise waits for a timezone and the build hangs. +ENV DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC + # Install required Ubuntu packages. RUN apt-get -y update RUN apt-get install -y --no-install-recommends --no-install-suggests g++ autoconf automake patch libtool make git gpg wget ca-certificates curl jq libgtk2.0-dev openjdk-8-jdk cron diff --git a/Dockerfile.arm b/Dockerfile.arm index 2a2d403..88a6dd9 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -18,6 +18,10 @@ ARG GITHUB_REPO=quantms-web USER root +# Keep apt from stopping at interactive prompts: tzdata, pulled in by +# later packages, otherwise waits for a timezone and the build hangs. +ENV DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC + # Install required Ubuntu packages. RUN apt-get -y update RUN apt-get install -y --no-install-recommends --no-install-suggests g++ autoconf automake patch libtool make git gpg wget ca-certificates curl jq libgtk2.0-dev openjdk-8-jdk cron cmake diff --git a/Dockerfile_simple b/Dockerfile_simple index c77892f..ba003b5 100644 --- a/Dockerfile_simple +++ b/Dockerfile_simple @@ -20,6 +20,10 @@ ARG GITHUB_REPO=quantms-web # Step 1: set up a sane build system USER root +# Keep apt from stopping at interactive prompts: tzdata, pulled in by +# later packages, otherwise waits for a timezone and the build hangs. +ENV DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC + RUN apt-get -y update # note: streamlit in docker needs libgtk2.0-dev (see https://yugdamor.medium.com/importerror-libgthread-2-0-so-0-cannot-open-shared-object-file-no-such-file-or-directory-895b94a7827b) RUN apt-get install -y --no-install-recommends --no-install-suggests wget ca-certificates libgtk2.0-dev curl jq cron nginx diff --git a/Dockerfile_simple.arm b/Dockerfile_simple.arm index 9c03063..50fefba 100644 --- a/Dockerfile_simple.arm +++ b/Dockerfile_simple.arm @@ -20,6 +20,10 @@ ARG GITHUB_REPO=quantms-web # Step 1: set up a sane build system USER root +# Keep apt from stopping at interactive prompts: tzdata, pulled in by +# later packages, otherwise waits for a timezone and the build hangs. +ENV DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC + RUN apt-get -y update # note: streamlit in docker needs libgtk2.0-dev (see https://yugdamor.medium.com/importerror-libgthread-2-0-so-0-cannot-open-shared-object-file-no-such-file-or-directory-895b94a7827b) # build-essential is needed on arm64: pyprophet (a quantms-web requirement, not a From b204c477e7b94e101fe183af48baabbe309d2e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:08:59 +0000 Subject: [PATCH 17/18] Check out shell scripts with LF line endings on Windows too A CRLF checkout (core.autocrlf=true) gives docker/entrypoint.sh a shebang ending in a carriage return, so the container fails with "exec /app/entrypoint.sh: no such file or directory". Same rule as the streamlit-template's .gitattributes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FkEGemnfPye3pzon8Q9pur --- .gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitattributes b/.gitattributes index 045c361..0c16be9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,9 @@ +# Shell scripts are executed by /bin/sh inside Linux containers and by POSIX +# shells in tests/test_seed_demos.py. A CRLF checkout on Windows breaks both: +# sh reports "not found" on every carriage return, and a shebang with a +# trailing CR fails to exec. Pin them to LF regardless of core.autocrlf. +*.sh text eol=lf + # MS data files carry byte offsets (indexed mzML); never convert line endings. *.mzML -text *.fasta -text From 3c8d404859423d339223971bb451f14dfdf01506 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:57:28 +0000 Subject: [PATCH 18/18] Keep spike-in proteins out of the contaminants; annotated spectra on Identified proteins cRAP lists the UPS standards (and other proteins people spike in on purpose). Added as CON__ twins, they joined the targets' protein groups, which then counted as contaminants and were dropped from the comparison: the demo's UPS2 spike-ins never reached Compare groups, so it found nothing. cRAP proteins already in the user's FASTA, by sequence or UniProt entry name, now stay targets (46 of 116 for the demo database). With the unchanged defaults the demo finds 5 UPS2 proteins up (log2FC 3.6 to 5.5) and no yeast protein. The contaminant file is only rewritten when it changes, and the decoy database is rebuilt when it does. The log line says what cRAP is. Identified proteins: clicking a PSM opens its spectrum with the matched fragment ions annotated, from the run's SequenceView/LinePlot cache. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FkEGemnfPye3pzon8Q9pur --- content/results_proteins.py | 38 +++++++++++++++++++++++++--- src/WorkflowTest.py | 12 ++++++--- src/common/contaminants.py | 43 +++++++++++++++++++++++++++----- src/common/protein_table.py | 15 +++++++++++- tests/test_contaminants.py | 34 ++++++++++++++++++++++++- tests/test_protein_table.py | 49 +++++++++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 15 deletions(-) diff --git a/content/results_proteins.py b/content/results_proteins.py index be406aa..752ee79 100644 --- a/content/results_proteins.py +++ b/content/results_proteins.py @@ -2,7 +2,7 @@ from pathlib import Path import streamlit as st -from openms_insight import StateManager +from openms_insight import LinePlot, SequenceView, StateManager from src.common.common import page_setup from src.common.guide import needs_results, next_steps, page_intro @@ -17,8 +17,9 @@ st.title("Identified proteins") page_intro( "Every protein found in any sample, with the evidence behind it. **Click a protein** to see " - "which parts of its sequence were covered by peptides; **click a peptide** to see the spectra " - "that identified it. Use the search fields in the table header to look up your protein of interest.", + "which parts of its sequence were covered by peptides; **click a peptide** to list the spectra " + "that identified it, and **click one of those** to see the spectrum with its fragment ions " + "annotated. Use the search fields in the table header to look up your protein of interest.", background=""" - A protein is identified through its **peptides**, and each peptide through one or more **PSMs** (peptide-spectrum matches: a fragmentation spectrum explained by that peptide). @@ -114,16 +115,45 @@ def decimals(n: int) -> dict: ) st.subheader("PSMs of the selected peptide") +st.caption("One row per spectrum that identified the peptide. Click a row to see its annotated spectrum below.") show_insight_table( psms_long, name="protein_psms", cache_dir=cache_dir, source_files=source_files, title="psms", height=350, state_manager=state_manager, filters={"protein": "accession", "peptide": "peptide"}, + interactivity={"psm": "psm"}, layout="fitColumns", + extra_key="with-psm-id-v2", # the cache follows the source files; name the newer psm column and layout column_overrides={ - "accession": HIDDEN, "peptide": HIDDEN, + "accession": HIDDEN, "peptide": HIDDEN, "psm": HIDDEN, "RT (s)": decimals(1), "m/z": decimals(4), }, ) +# The annotated spectrum comes from the viewer cache the workflow built for the +# PSM's run (as on the Peptide-spectrum matches page), pointed at this one PSM +selected_psm = state_manager.get_selection("psm") +match = psms_long[psms_long["psm"] == selected_psm] if selected_psm else psms_long.iloc[0:0] +if not match.empty: + psm = match.iloc[0] + run, psm_id = str(selected_psm).rsplit(":", 1) + st.subheader(f"Spectrum of {psm['peptide']} (scan {psm['scan']}, {Path(psm['file']).stem})") + if not (cache_dir / f"seqview_{run}").is_dir() or psm["file"] not in mzml_names: + st.info("No spectrum viewer data for this run. Re-run the analysis to create it.") + else: + viewer_state = StateManager("proteins_page_spectrum") + viewer_state.set_selection("identification", int(psm_id)) + viewer_state.set_selection("file", mzml_names.index(psm["file"])) + viewer_state.set_selection("spectrum", int(psm["scan"])) + st.caption( + "Peaks explained by fragment ions of the peptide are coloured: b ions contain its start, " + "y ions its end. Click a peak to find it in the sequence." + ) + SequenceView(cache_id=f"seqview_{run}", cache_path=str(cache_dir))( + key="proteins_seqview", state_manager=viewer_state, height=420, + ) + LinePlot(cache_id=f"lineplot_{run}", cache_path=str(cache_dir))( + key="proteins_lineplot", state_manager=viewer_state, height=420, sequence_view_key="proteins_seqview", + ) + next_steps( ("compare", "See whether your protein changes between the groups."), ("abundance", "Its intensity in every sample."), diff --git a/src/WorkflowTest.py b/src/WorkflowTest.py index 5778cf7..ff69b2c 100644 --- a/src/WorkflowTest.py +++ b/src/WorkflowTest.py @@ -832,8 +832,12 @@ def prepare_search_database(self, fasta_path: Path): db_dir = Path(self.workflow_dir, "results", "database") con_fasta = db_dir / "contaminants.fasta" if add_contaminants: - n_con = write_contaminants(con_fasta) - self.logger.log(f"๐Ÿงซ Adding {n_con} contaminant proteins (cRAP, {CONTAMINANT_PREFIX} accessions)") + n_con, n_in_db = write_contaminants(con_fasta, targets=fasta_path) + note = f"; {n_in_db} already in your database are kept as targets" if n_in_db else "" + self.logger.log( + f"๐Ÿงซ Adding {n_con} common lab contaminants (cRAP: keratins, trypsin, ...; " + f"{CONTAMINANT_PREFIX} accessions, hidden from the results){note}" + ) if self.params.get("generate-decoys", True): # Get decoy_string from DecoyDatabase params @@ -842,7 +846,9 @@ def prepare_search_database(self, fasta_path: Path): # DecoyDatabase reads both files, so the contaminants get decoys too decoy_fasta = db_dir / f"{fasta_path.stem}.con.decoy.fasta" decoy_inputs = [str(fasta_path), str(con_fasta)] - stale = decoy_fasta.exists() and decoy_fasta.stat().st_mtime < fasta_path.stat().st_mtime + stale = decoy_fasta.exists() and decoy_fasta.stat().st_mtime < max( + fasta_path.stat().st_mtime, con_fasta.stat().st_mtime + ) else: decoy_fasta = fasta_path.with_suffix(".decoy.fasta") decoy_inputs = [str(fasta_path)] diff --git a/src/common/contaminants.py b/src/common/contaminants.py index 5b7ccc1..cbf31f0 100644 --- a/src/common/contaminants.py +++ b/src/common/contaminants.py @@ -4,6 +4,7 @@ their accessions, so every protein and PSM table can flag them, and the protein-level results and downstream analysis leave them out by default. """ +import re from pathlib import Path import pandas as pd @@ -19,19 +20,49 @@ _ACCESSION_SEPARATORS = (";", ",", "/") -def write_contaminants(out_path, crap_path=CRAP_FASTA) -> int: +def write_contaminants(out_path, crap_path=CRAP_FASTA, targets=None) -> tuple[int, int]: """Write the cRAP entries with ``CON__`` accessions to ``out_path``. - Entries that already carry the prefix keep it. Returns the number of entries. + Entries that already carry the prefix keep it. A cRAP protein that is in the + ``targets`` FASTA, by sequence or by UniProt entry name (cRAP's sequences + can differ from the current UniProt ones), is left out: the user searches for it on purpose + (e.g. the UPS spike-in standards, which cRAP lists too), and a CON__ twin + would mark its protein group as a contaminant and drop it from the results. + The file is rewritten only when its content changes, so a decoy database + built from it stays valid. Returns ``(written, already_in_targets)``. """ entries = [] FASTAFile().load(str(crap_path), entries) - for entry in entries: + target_sequences, target_names = set(), set() + if targets is not None: + target_entries = [] + FASTAFile().load(str(targets), target_entries) + target_sequences = {e.sequence for e in target_entries} + target_names = {_entry_name(e.identifier) for e in target_entries} - {""} + kept = [ + e for e in entries + if e.sequence not in target_sequences and _entry_name(e.identifier) not in target_names + ] + for entry in kept: if not entry.identifier.startswith(CONTAMINANT_PREFIX): entry.identifier = CONTAMINANT_PREFIX + entry.identifier - Path(out_path).parent.mkdir(parents=True, exist_ok=True) - FASTAFile().store(str(out_path), entries) - return len(entries) + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + tmp = out_path.with_name(out_path.name + ".tmp") + FASTAFile().store(str(tmp), kept) + if out_path.exists() and out_path.read_bytes() == tmp.read_bytes(): + tmp.unlink() + else: + tmp.replace(out_path) + return len(kept), len(entries) - len(kept) + + +def _entry_name(identifier: str) -> str: + """The UniProt entry name (``ALBU_HUMAN``) in ``sp|P02768|ALBU_HUMAN`` or cRAP's ``sp|ALBU_HUMAN|``.""" + for part in identifier.removeprefix(CONTAMINANT_PREFIX).split("|"): + if re.fullmatch(r"[A-Z0-9]+_[A-Z0-9]+", part): + return part + return "" def append_fasta(parts, out_path) -> None: diff --git a/src/common/protein_table.py b/src/common/protein_table.py index f8842bd..995b334 100644 --- a/src/common/protein_table.py +++ b/src/common/protein_table.py @@ -16,7 +16,10 @@ DECOY_PREFIXES = ("DECOY_", "decoy_", "rev_", "REV_", "XXX_", "reversed_") Q_VALUE_KEYS = ("MS:1001491", "q-value") -PSM_COLUMNS = ["file", "scan", "rt", "mz", "charge", "peptide", "sequence", "score", "q_value", "proteins"] +# run and psm_id locate the PSM in the run's spectrum viewer cache (see parse_idxml) +PSM_COLUMNS = [ + "file", "scan", "rt", "mz", "charge", "peptide", "sequence", "score", "q_value", "proteins", "run", "psm_id", +] def _is_decoy_accession(accession: str) -> bool: @@ -50,8 +53,13 @@ def collect_psms(idxml_paths, mzml_names=None) -> pd.DataFrame: records = [] for path in idxml_paths: file = extract_filename_from_idxml(Path(path), mzml_names) + # parse_idxml numbers every hit of the run in this order; the top hit's + # number is its id in the run's SequenceView cache + n_hits = 0 for pep in load_idxml(path)[1]: hits = pep.getHits() + psm_id = n_hits + n_hits += len(hits) if not hits: continue hit = hits[0] @@ -81,6 +89,8 @@ def collect_psms(idxml_paths, mzml_names=None) -> pd.DataFrame: "score": hit.getScore(), "q_value": q_value, "proteins": proteins, + "run": Path(path).stem, + "psm_id": psm_id, }) return pd.DataFrame(records, columns=PSM_COLUMNS) @@ -151,6 +161,9 @@ def build_tables(psms: pd.DataFrame, fasta: dict[str, tuple[str, str]], files: l psms_long = long[["accession", "peptide", "file", "scan", "rt", "mz", "charge", "score", "q_value"]].rename( columns={"rt": "RT (s)", "mz": "m/z", "q_value": "q-value"} ).reset_index(drop=True) + if {"run", "psm_id"} <= set(long.columns): + # One value per PSM, for selecting it: ":" + psms_long["psm"] = (long["run"].astype(str) + ":" + long["psm_id"].astype(str)).to_numpy() return proteins, peptides, psms_long, coverage diff --git a/tests/test_contaminants.py b/tests/test_contaminants.py index 12dfd3d..b653347 100644 --- a/tests/test_contaminants.py +++ b/tests/test_contaminants.py @@ -35,15 +35,47 @@ def _ids(path): def test_write_contaminants_prefixes_every_crap_entry(tmp_path): out = tmp_path / "con.fasta" - n = write_contaminants(out) + n, skipped = write_contaminants(out) ids = _ids(out) assert n == len(ids) == len(_ids(CRAP_FASTA)) == 116 + assert skipped == 0 assert all(i.startswith(CONTAMINANT_PREFIX) for i in ids) # An already prefixed entry is not prefixed twice write_contaminants(tmp_path / "again.fasta", crap_path=out) assert _ids(tmp_path / "again.fasta") == ids +def test_contaminants_already_in_the_database_stay_targets(tmp_path): + crap = [] + poms.FASTAFile().load(str(CRAP_FASTA), crap) + # One by sequence (under another name), one by UniProt entry name (another sequence) + _fasta(tmp_path / "targets.fasta", [ + ("sp|Q0|MYPROT_BOVIN", crap[0].sequence), + ("sp|P02768|ALBU_HUMAN", "PEPTIDEK"), + ]) + assert any("ALBU_HUMAN" in i for i in _ids(CRAP_FASTA)) + out = tmp_path / "con.fasta" + n, skipped = write_contaminants(out, targets=tmp_path / "targets.fasta") + assert (n, skipped) == (114, 2) + kept = _load(out) + assert crap[0].sequence not in {e.sequence for e in kept} + assert not any("ALBU_HUMAN" in e.identifier for e in kept) + + +def test_unchanged_contaminants_are_not_rewritten(tmp_path): + out = tmp_path / "con.fasta" + write_contaminants(out) + before = out.stat().st_mtime_ns + write_contaminants(out) + assert out.stat().st_mtime_ns == before + + +def _load(path): + entries = [] + poms.FASTAFile().load(str(path), entries) + return entries + + def test_append_fasta_keeps_order(tmp_path): _fasta(tmp_path / "a.fasta", [("sp|P1|A", "PEPTIDEK")]) _fasta(tmp_path / "b.fasta", [("CON__sp|K1|B", "ELVISK"), ("rev_CON__sp|K1|B", "KSIVLE")]) diff --git a/tests/test_protein_table.py b/tests/test_protein_table.py index 5df4102..6177f3e 100644 --- a/tests/test_protein_table.py +++ b/tests/test_protein_table.py @@ -69,3 +69,52 @@ def test_coverage_html_marks_covered_residues(): out = coverage_html("ACDEFGHIKLM", np.array([True, True] + [False] * 9)) assert out.count('') == 2 assert "ACDEFGHIKL M" in out.replace('A', "A").replace('C', "C") + + +def test_psm_ids_match_the_spectrum_viewer_numbering(tmp_path): + """collect_psms' psm_id is the top hit's id_idx in parse_idxml (the viewer cache).""" + import pyopenms as poms + + from src.common.protein_table import collect_psms + from src.common.results_helpers import parse_idxml + + prot = poms.ProteinIdentification() + prot.setIdentifier("run") + protein_hits = [] + for acc in ("P1", "P2", "rev_P1"): + protein_hit = poms.ProteinHit() + protein_hit.setAccession(acc) + protein_hits.append(protein_hit) + prot.setHits(protein_hits) + peptides = [] + # Two hits, then a decoy-only spectrum, then one hit: the ids skip every hit + for scan, hits in [(5, [("PEPTIDEK", "P1"), ("PEPTLDEK", "P2")]), (6, [("KEDITPEP", "rev_P1")]), (9, [("SAMPLER", "P2")])]: + pep = poms.PeptideIdentification() + pep.setIdentifier("run") + pep.setMetaValue("spectrum_reference", f"controllerType=0 controllerNumber=1 scan={scan}") + out = [] + for seq, acc in hits: + hit = poms.PeptideHit() + hit.setSequence(poms.AASequence.fromString(seq)) + hit.setCharge(2) + ev = poms.PeptideEvidence() + ev.setProteinAccession(acc) + hit.setPeptideEvidences([ev]) + out.append(hit) + pep.setHits(out) + peptides.append(pep) + if hasattr(poms, "PeptideIdentificationList"): + container = poms.PeptideIdentificationList() + for pep in peptides: + container.push_back(pep) + peptides = container + path = tmp_path / "runA_filter.idXML" + poms.IdXMLFile().store(str(path), [prot], peptides) + + psms = collect_psms([path], ["runA.mzML"]) + viewer = parse_idxml(path, {"runA.mzML": 0})[0].to_pandas().set_index("id_idx") + assert psms["psm_id"].tolist() == [0, 3] + assert psms["run"].tolist() == ["runA_filter", "runA_filter"] + for _, row in psms.iterrows(): + assert viewer.loc[row["psm_id"], "sequence"] == row["peptide"] + assert viewer.loc[row["psm_id"], "scan_id"] == row["scan"]