Skip to content

Commit ed7a504

Browse files
committed
Round-2 viewer fixes: raw formatters, selectedAA, Scatter3D guard, Precursor
Re-audit follow-ups: - Tables: drop precision from the dashNegativeOne columns (ProteoformMass, ProteoformLevelQvalue, Nmass, Cmass) so they show the RAW value like legacy (precision-4 was destroying tiny Q-values); keep the -1 -> "-" sentinel. - TnT tag walk: publish selectedAA (= selectedAApos - StartPos when the selected residue is within the tag) so the selected-residue gold highlight renders; drop zero-valued tag masses (legacy filters != 0); silently skip absent components instead of st.warning (match Deconv). - Deconv Scatter3D: pass the precursor-lookup columns only when all four are present in the frame, so stale 4-column threedim_SN_plot caches no longer crash the panel (ValueError) and degrade to the legacy per-scan view. - Deconv SequenceView: stop emitting computed_mass (it forced the TnT branch, mislabeled the header "Proteoform", and disabled the variable-mod menu); emit the observed precursor_mass from the selected scan so the "Precursor" header renders and variable mods stay enabled. - Remove dead code (_data_path, unused imports) and drop the inert internal_fragment_map from the Deconv selectable layout (parity with TnT).
1 parent f500362 commit ed7a504

3 files changed

Lines changed: 149 additions & 62 deletions

File tree

content/FLASHDeconv/FLASHDeconvLayoutManager.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,13 @@ def handleSettingButtons():
219219

220220
def setSequenceView():
221221
if get_sequence() is not None:
222+
# Parity with the TnT layout: `internal_fragment_map` was dropped because
223+
# neither the legacy grid nor the OI viewer renders it (it produces
224+
# nothing). Only the sequence view is added on sequence submission.
222225
global COMPONENT_OPTIONS
223-
COMPONENT_OPTIONS = COMPONENT_OPTIONS + ['Sequence view (Mass table needed)',
224-
'Internal fragment map (Mass table needed)']
226+
COMPONENT_OPTIONS = COMPONENT_OPTIONS + ['Sequence view (Mass table needed)']
225227
global COMPONENT_NAMES
226-
COMPONENT_NAMES = COMPONENT_NAMES + ['sequence_view', 'internal_fragment_map']
228+
COMPONENT_NAMES = COMPONENT_NAMES + ['sequence_view']
227229

228230

229231
# page initialization

content/FLASHDeconv/FLASHDeconvViewerOI.py

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from __future__ import annotations
3131

3232
from pathlib import Path
33-
from typing import Any, Dict, List, Optional
33+
from typing import List, Optional
3434

3535
import polars as pl
3636
import streamlit as st
@@ -45,10 +45,7 @@
4545
Table,
4646
)
4747

48-
from content.FLASHDeconv.deconv_sequence import (
49-
bake_fixed_modifications,
50-
theoretical_mass,
51-
)
48+
from content.FLASHDeconv.deconv_sequence import bake_fixed_modifications
5249

5350
# Map the layout COMPONENT_NAMES (FLASHDeconvLayoutManager) to a builder. Every
5451
# builder returns a *callable* OpenMS-Insight component already wired with the
@@ -109,15 +106,6 @@ def _component_cache_dir(file_manager, experiment_id: str) -> str:
109106
return str(cache_root)
110107

111108

112-
def _data_path(file_manager, experiment_id: str, name_tag: str) -> Optional[str]:
113-
"""Resolve the on-disk parquet path for a stored frame, or None if absent."""
114-
if not file_manager.result_exists(experiment_id, name_tag):
115-
return None
116-
res = file_manager.get_results(experiment_id, [name_tag], partial=True)
117-
path = res.get(name_tag)
118-
return str(path) if path is not None else None
119-
120-
121109
def _lazy(file_manager, experiment_id: str, name_tag: str) -> Optional[pl.LazyFrame]:
122110
"""Load a stored frame as a polars LazyFrame, or None if absent."""
123111
if not file_manager.result_exists(experiment_id, name_tag):
@@ -281,6 +269,25 @@ def _build_scatter3d(file_manager, experiment_id: str, cache_dir: str):
281269
data = _lazy(file_manager, experiment_id, "threedim_SN_plot")
282270
if data is None:
283271
return None
272+
# MS2 precursor-signal lookup: locate the precursor scan's row
273+
# (Scan == PrecursorScan) and the index into its MonoMass array whose value
274+
# matches PrecursorMass. Fresh parses (src/parse/deconv.py) emit all four
275+
# columns (7-col frame), but STALE/OLD ``threedim_SN_plot.pq`` caches only
276+
# carry the 4 legacy columns (index, PrecursorScan, SignalPeaks, NoisyPeaks).
277+
# Scatter3D._validate_mappings raises ValueError if any precursor column is
278+
# configured-but-missing, and this builder runs OUTSIDE the page try/except,
279+
# so we MUST schema-gate: pass the precursor params ONLY when ALL FOUR
280+
# columns are present; otherwise fall back to the legacy per-scan behavior.
281+
schema_names = data.collect_schema().names()
282+
precursor_cols = ("Scan", "PrecursorScan", "PrecursorMass", "MonoMass")
283+
precursor_kwargs = {}
284+
if all(col in schema_names for col in precursor_cols):
285+
precursor_kwargs = {
286+
"scan_column": "Scan",
287+
"precursor_scan_column": "PrecursorScan",
288+
"precursor_mass_column": "PrecursorMass",
289+
"mono_mass_column": "MonoMass",
290+
}
284291
# 3D S/N plot: scanIndex value-filters on `index`; massIndex handled internally
285292
# as an array subscript (NOT a value filter).
286293
return Scatter3D(
@@ -289,16 +296,9 @@ def _build_scatter3d(file_manager, experiment_id: str, cache_dir: str):
289296
scan_filter="index",
290297
signal_column="SignalPeaks",
291298
noisy_column="NoisyPeaks",
292-
# MS2 precursor-signal lookup: locate the precursor scan's row
293-
# (Scan == PrecursorScan) and the index into its MonoMass array whose
294-
# value matches PrecursorMass. All four columns are emitted on
295-
# threedim_SN_plot by src/parse/deconv.py.
296-
scan_column="Scan",
297-
precursor_scan_column="PrecursorScan",
298-
precursor_mass_column="PrecursorMass",
299-
mono_mass_column="MonoMass",
300299
title="Precursor Signals",
301300
cache_path=cache_dir,
301+
**precursor_kwargs,
302302
)
303303

304304

@@ -318,6 +318,49 @@ def _build_fdr_plot(file_manager, experiment_id: str, cache_dir: str):
318318
)
319319

320320

321+
def _selected_precursor_mass(file_manager, experiment_id: str, state_manager):
322+
"""Observed PrecursorMass of the currently-selected scan, or None.
323+
324+
Mirrors the legacy "Precursor" mass header (src/render/update.py get_sequence
325+
/ per-scan data), which reads ``PrecursorMass`` from the selected scan. The
326+
selected scan is the ``scanIndex`` selection (== the scan_table ``index``);
327+
we look its ``PrecursorMass`` up in the scan_table frame. Returns None when no
328+
scan is selected, the table/column is absent, or the value is 0.0 (the legacy
329+
sentinel for "scan not eligible for this view", which renders an empty header).
330+
"""
331+
if state_manager is None:
332+
return None
333+
selected_index = state_manager.get_selection(SCAN_KEY)
334+
if selected_index is None:
335+
return None
336+
scan_table = _lazy(file_manager, experiment_id, "scan_table")
337+
if scan_table is None:
338+
return None
339+
names = scan_table.collect_schema().names()
340+
if "index" not in names or "PrecursorMass" not in names:
341+
return None
342+
try:
343+
row = (
344+
scan_table.filter(pl.col("index") == selected_index)
345+
.select("PrecursorMass")
346+
.collect()
347+
)
348+
except Exception:
349+
return None
350+
if row.height == 0:
351+
return None
352+
value = row["PrecursorMass"][0]
353+
if value is None:
354+
return None
355+
try:
356+
value = float(value)
357+
except (TypeError, ValueError):
358+
return None
359+
if value == 0.0:
360+
return None
361+
return value
362+
363+
321364
def _get_sequence(file_manager):
322365
"""Return the submitted (sequence, fix_C, fix_M) tuple, or None."""
323366
if not file_manager.result_exists("sequence", "sequence"):
@@ -374,17 +417,29 @@ def _build_sequence_view(
374417
pl.col("SumIntensity").alias("intensity"),
375418
)
376419

377-
# Pass the sequence as a single-row frame so we can attach the optional
378-
# `computed_mass` column (the baked sequence's monoisotopic mass) for the
379-
# SequenceView mass header. Falls back to a plain string when pyOpenMS is
380-
# unavailable (theoretical_mass returns None) so the column is simply omitted.
381-
seq_mass = theoretical_mass(sequence_string)
382-
if seq_mass is not None:
420+
# Mass header parity (legacy Deconv shows the "Precursor" header:
421+
# Theoretical / Observed / Δ). We MUST NOT emit `computed_mass` here: in the
422+
# OI SequenceView Vue, `displayTnT = (computedMass !== undefined)`, which would
423+
# (a) mislabel the header "Proteoform" instead of "Precursor", and (b) force
424+
# `disableVariableModifications = true`, silently disabling the variable/custom
425+
# modification context menu that this path explicitly enables via
426+
# `disable_variable_modifications=False`. So `computed_mass` stays dropped.
427+
#
428+
# Instead emit `precursor_mass` = the OBSERVED precursor mass of the selected
429+
# scan (legacy reads `PrecursorMass` from the selected scan; see
430+
# src/render/update.py get_sequence). When a scan is selected and its
431+
# PrecursorMass is reachable in the scan_table, wire it so the "Precursor"
432+
# header renders; otherwise omit it (header observed/Δ rows render empty) --
433+
# either way the variable-mod menu stays enabled.
434+
precursor_mass = _selected_precursor_mass(
435+
file_manager, experiment_id, state_manager
436+
)
437+
if precursor_mass is not None:
383438
sequence_data = pl.LazyFrame(
384439
{
385440
"sequence": [sequence_string],
386441
"precursor_charge": [1],
387-
"computed_mass": [seq_mass],
442+
"precursor_mass": [precursor_mass],
388443
}
389444
)
390445
else:

content/FLASHTnT/FLASHTnTViewerOI.py

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -170,20 +170,22 @@ def _stamp_proteoform_index(
170170
{"title": "Accession", "field": "accession", "sorter": "string"},
171171
{"title": "Description", "field": "description", "sorter": "string"},
172172
{"title": "Length", "field": "length", "sorter": "number"},
173-
# Legacy TabulatorProteinTable.vue renders the `-1` sentinel as "-" (the raw
174-
# value otherwise). The OI `dashNegativeOne` formatter reproduces the sentinel
175-
# rule; precision 4 matches the app-wide `toFixedFormatter()` default (4 dp,
176-
# used for every other numeric mass column, e.g. MonoMass in the mass table).
173+
# Legacy TabulatorProteinTable.vue renders the `-1` sentinel as "-" and the
174+
# RAW unrounded value otherwise (TabulatorProteinTable.vue:78-81). The OI
175+
# `dashNegativeOne` formatter reproduces the sentinel rule and renders the raw
176+
# value when `precision` is omitted -- so we drop `formatterParams` to avoid
177+
# rounding (critical for tiny Q-values: 0.00012 must NOT become 0.0001).
177178
{"title": "Mass", "field": "ProteoformMass", "sorter": "number",
178-
"formatter": "dashNegativeOne", "formatterParams": {"precision": 4}},
179+
"formatter": "dashNegativeOne"},
179180
{"title": "No. of Matched Fragments", "field": "MatchingFragments", "sorter": "number"},
180181
{"title": "No. of Modifications", "field": "ModCount", "sorter": "number"},
181182
{"title": "No. of Tags", "field": "TagCount", "sorter": "number"},
182183
{"title": "Score", "field": "Score", "sorter": "number"},
183-
# Q-Value also uses the `-1 -> "-"` sentinel rule (legacy formatter); 4 dp
184-
# matches the app-wide default decimal precision.
184+
# Q-Value also uses the `-1 -> "-"` sentinel rule with the RAW unrounded value
185+
# otherwise (TabulatorProteinTable.vue:105-108). No `formatterParams` so the
186+
# raw value is shown -- rounding would corrupt tiny Q-values (e.g. 0.00012).
185187
{"title": "Q-Value (Proteoform Level)", "field": "ProteoformLevelQvalue", "sorter": "number",
186-
"formatter": "dashNegativeOne", "formatterParams": {"precision": 4}},
188+
"formatter": "dashNegativeOne"},
187189
]
188190

189191
# TabulatorTagTable.vue columns -> tag_dfs fields.
@@ -194,12 +196,13 @@ def _stamp_proteoform_index(
194196
{"title": "Sequence", "field": "TagSequence", "sorter": "string"},
195197
{"title": "Length", "field": "Length", "sorter": "number"},
196198
{"title": "Tag Score", "field": "Score", "sorter": "number"},
197-
# N/C mass use the legacy `-1 -> "-"` sentinel rule (TabulatorTagTable.vue
198-
# ~72-83); precision 4 matches the app-wide mass decimal default.
199+
# N/C mass use the legacy `-1 -> "-"` sentinel rule and render the RAW
200+
# unrounded value otherwise (TabulatorTagTable.vue:72-83). No `formatterParams`
201+
# so the raw value is shown (rounding would lose precision on the mass offset).
199202
{"title": "N mass", "field": "Nmass", "sorter": "number",
200-
"formatter": "dashNegativeOne", "formatterParams": {"precision": 4}},
203+
"formatter": "dashNegativeOne"},
201204
{"title": "C mass", "field": "Cmass", "sorter": "number",
202-
"formatter": "dashNegativeOne", "formatterParams": {"precision": 4}},
205+
"formatter": "dashNegativeOne"},
203206
{"title": "Δ mass", "field": "DeltaMass", "sorter": "number"},
204207
]
205208

@@ -310,15 +313,18 @@ def _resolve_tag_masses(file_manager, experiment_id: str, state_manager) -> None
310313
311314
Only the selected tag's row is collected (filtered by ``TagIndex``). The tag
312315
``mzs`` are a comma-joined string (trailing comma); parse and drop non-numeric
313-
entries, keeping the STORED order (ascending for C-term tags, descending for
314-
N-term tags). ``TagSequence`` gives the residue letters; the legacy walks
315-
consecutive stored masses labelling gap ``i`` with ``sequence[len-1-i]`` —
316-
i.e. the REVERSED sequence aligns to the stored-order gaps regardless of
317-
anchoring (verified against both an ascending C-term and a descending N-term
318-
tag). Do NOT sort the masses: sorting breaks the alignment for descending
319-
(N-term) tags. The published value is a dict
320-
``{"masses": [...], "residues": [...]}`` consumed by the OI LinePlot tag walk;
321-
when no residues are available it carries only masses (highlight-only)."""
316+
AND zero entries (legacy ``number !== 0``), keeping the STORED order (ascending
317+
for C-term tags, descending for N-term tags). ``TagSequence`` gives the residue
318+
letters; the legacy walks consecutive stored masses labelling gap ``i`` with
319+
``sequence[len-1-i]`` — i.e. the REVERSED sequence aligns to the stored-order
320+
gaps regardless of anchoring (verified against both an ascending C-term and a
321+
descending N-term tag). Do NOT sort the masses: sorting breaks the alignment
322+
for descending (N-term) tags. The published value is a dict
323+
``{"masses": [...], "residues": [...], "nTerminal": bool}`` consumed by the OI
324+
LinePlot tag walk; when no residues are available it carries only masses
325+
(highlight-only). When a residue within the selected tag's span is also
326+
selected (``selectedAApos``), a tag-relative ``selectedAA`` index is added so
327+
the walk gold-highlights that residue (legacy ``selectedAApos - StartPos``)."""
322328
def _clear_all() -> None:
323329
state_manager.clear_selection(TAG_MASSES_KEY)
324330
state_manager.clear_selection(TAG_SPAN_KEY)
@@ -355,8 +361,12 @@ def _clear_all() -> None:
355361

356362
raw = selected["tag_masses"][0]
357363
# Keep STORED order (do not sort) so the reversed-sequence walk aligns for
358-
# both ascending (C-term) and descending (N-term) tags.
359-
masses = [m for m in raw if m is not None] if raw is not None else []
364+
# both ascending (C-term) and descending (N-term) tags. Drop null AND zero
365+
# masses (legacy `number !== 0`, TabulatorTagTable.vue:140): a literal 0 mass
366+
# would misalign the reversed-residue walk.
367+
masses = (
368+
[m for m in raw if m is not None and m != 0] if raw is not None else []
369+
)
360370
if not masses:
361371
_clear_all()
362372
return
@@ -373,15 +383,33 @@ def _clear_all() -> None:
373383
n_mass = selected["n_mass"][0]
374384
n_terminal = (n_mass is not None) and (float(n_mass) == -1.0)
375385

376-
state_manager.set_selection(
377-
TAG_MASSES_KEY,
378-
{"masses": list(masses), "residues": residues, "nTerminal": n_terminal},
379-
)
386+
tag_masses = {
387+
"masses": list(masses),
388+
"residues": residues,
389+
"nTerminal": n_terminal,
390+
}
380391

381-
# Tag-span highlight on the SequenceView. StartPos/EndPos are protein-absolute
382-
# (matching the full-protein residue grid), so they bracket the tag directly.
392+
# Selected-residue gold (#F3A712) highlight (legacy
393+
# `selectedTag.selectedAA = selectedAApos - StartPos`, TabulatorTagTable.vue:
394+
# 151,169). When a residue is selected (AA_KEY holds its protein-absolute
395+
# position) AND it falls within the selected tag's [StartPos, EndPos] span,
396+
# publish the tag-relative residue index so the LinePlot tag walk highlights
397+
# that residue; omit otherwise (no highlight).
383398
start_pos = selected["start_pos"][0]
384399
end_pos = selected["end_pos"][0]
400+
selected_aa_pos = state_manager.get_selection(AA_KEY)
401+
if (
402+
selected_aa_pos is not None
403+
and start_pos is not None
404+
and end_pos is not None
405+
and int(start_pos) <= int(selected_aa_pos) <= int(end_pos)
406+
):
407+
tag_masses["selectedAA"] = int(int(selected_aa_pos) - int(start_pos))
408+
409+
state_manager.set_selection(TAG_MASSES_KEY, tag_masses)
410+
411+
# Tag-span highlight on the SequenceView. StartPos/EndPos are protein-absolute
412+
# (matching the full-protein residue grid), so they bracket the tag directly.
385413
if start_pos is not None and end_pos is not None:
386414
state_manager.set_selection(
387415
TAG_SPAN_KEY,
@@ -799,7 +827,9 @@ def render_experiment_panel(
799827
best_per_spectrum=best_per_spectrum,
800828
)
801829
if component is None:
802-
st.warning(f"No data for '{comp_name}'.")
830+
# Silently skip an absent component (data frame missing),
831+
# matching the Deconv viewer's documented intent and avoiding
832+
# noisy warnings on stale / partial caches.
803833
continue
804834
key = f"tnt_oi_{panel_index}_{row_index}_{col_index}_{comp_name}"
805835
component(key=key, state_manager=state_manager)

0 commit comments

Comments
 (0)