Source code for spacr.qt.screens.volcano

"""Volcano Explorer — open a finished regression and interrogate its plot.

A regression run leaves a volcano PDF. That file answers "what was
significant" and nothing else: which guide is that dot, what would it look
like coloured by compartment, does the axis label match the correction that
was actually applied, and can I have it as a vector figure at the size the
journal asked for.

This screen opens a results folder (or a single results CSV) and hands it to
:class:`spacr.qt.widgets.volcano_explorer.VolcanoExplorer`, which draws the
plot through the same renderer the pipeline used -- so what is on screen is
the plot, not a preview of it.

It is deliberately a *reader*. It runs no analysis and writes nothing back
into the results folder; exports go wherever the user chooses. That means it
can be pointed at a finished run, including one produced months ago on another
machine, without any risk to it.
"""
from __future__ import annotations

import os

APP_KEY = "volcano_explorer"
APP_NAME = "Volcano Explorer"
APP_DESCRIPTION = (
    "Open a regression result, click any point for its full record, restyle "
    "the plot and export it as vector PDF or PNG")
APP_INTRO = (
    "Choose a regression results folder or CSV. Every point carries its whole "
    "row, so clicking one tells you the guide, gene, effect, P value, "
    "adjusted value and how many wells it was seen in. Colour and shape can "
    "be driven by any column, including columns merged in from your own "
    "annotation file. Axis scales, thresholds, a broken axis, colormap, "
    "marker, fonts, line weights and titles are all editable, and the export "
    "re-renders the figure rather than screenshotting it, so a PDF stays "
    "vector at publication size.")
APP_TRANSLATIONS = (
    "Vulkanutforskare", "Vulkan-Explorer", "Explorador de volcán",
    "火山图浏览器", "Explorador de vulcão", "वोल्केनो एक्सप्लोरर",
    "볼케이노 탐색기", "Eldfjallakönnuður", "Explorateur de volcan")
#: Why there is no ``spacr-run volcano_explorer``. This screen is a reader
#: with no analysis behind it, so the honest headless answer is the renderer
#: it draws through -- the same one the pipeline uses, which is why the
#: exported figure is identical.
#:
#: WRITTEN OUT AGAIN in :data:`spacr.cli.INTERACTIVE_ONLY` rather than reached
#: from there. It used to travel as the row's ``cli_note=``; the row is gone,
#: and ``spacr.cli`` answers ``--list`` on clusters with no PySide6 at all, so
#: it cannot import this module to read the sentence. A test asserts the two
#: copies are the same string.
APP_CLI_NOTE = (
    "Volcano Explorer is an interactive reader for a finished regression — "
    "clicking a point is the feature, so there is nothing to batch; run it "
    "in the GUI (spacr-qt), where it is “Publication figure…” on the "
    "Regression volcano and a button on that masthead. Headless, call "
    "spacr.volcano_style.render_volcano(results, VolcanoStyle(...), "
    "save_path='volcano.pdf'); that is the renderer this screen draws "
    "through, so the figure is the same one, vector at publication size.")

__all__ = ["APP_KEY", "APP_NAME", "APP_DESCRIPTION", "APP_INTRO",
           "APP_CLI_NOTE", "find_results_table"]

#: Result CSVs a regression folder may hold, best first. ``results_grna.csv``
#: leads because the volcano is a guide-level plot: ``results.csv`` is the
#: same rows for a permutation run but the gene-level table for a simultaneous
#: fit, and plotting genes where the user expects guides is a silent switch of
#: what a point means.
_RESULT_FILENAMES = (
    "guide_permutation_results_long.csv",
    "results_grna.csv",
    "results.csv",
)


[docs] def find_results_table(path): """Return the results CSV to plot for ``path``, or None. Accepts the CSV itself, a regression output folder, or a parent holding one -- the three things a user actually has to hand when they want to look at a volcano again. :param path: a results CSV, a regression output folder, or a folder searched recursively for one; ``~`` is expanded. """ path = os.path.abspath(os.path.expanduser(os.fspath(path))) if os.path.isfile(path): return path if path.lower().endswith(".csv") else None if not os.path.isdir(path): return None for name in _RESULT_FILENAMES: candidate = os.path.join(path, name) if os.path.exists(candidate): return candidate for entry in sorted(os.listdir(path)): child = os.path.join(path, entry) if os.path.isdir(child): found = find_results_table(child) if found: return found return None
[docs] def load_results(path): """Read a results CSV and keep only the primary support family. A long permutation table holds every minimum-support family stacked, so plotting it unfiltered draws each guide once per family -- the same point two to four times, at different heights, which reads as extra hits. """ import pandas as pd frame = pd.read_csv(path) if "minimum_wells_threshold" in frame.columns: primary = frame["minimum_wells_threshold"].min() frame = frame.loc[frame["minimum_wells_threshold"] == primary] if "outcome" in frame.columns and frame["outcome"].nunique() > 1: first = frame["outcome"].iloc[0] frame = frame.loc[frame["outcome"] == first] return frame.reset_index(drop=True)
def _make_screen(app_key=None, host=None): """Build the screen lazily; matplotlib and Qt widgets are not cheap.""" from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget, ) from ..widgets.volcano_explorer import VolcanoExplorer class VolcanoScreen(QWidget): """The volcano plot, as the registry builds it. :param host: the main window, passed by the registry for navigation. NOT a Qt parent: handing it to ``QWidget.__init__`` raises, because the registry's host is not always a QWidget. """ def __init__(self, host=None): """Build the screen. ``host`` is the window, NOT a Qt parent. Handing it to ``QWidget.__init__`` raises, because the registry's host is not always a QWidget. """ super().__init__() self.host = host layout = QVBoxLayout(self) bar = QHBoxLayout() self._path_label = QLabel("No results loaded", self) self._path_label.setWordWrap(True) open_button = QPushButton("Open results…", self) open_button.setToolTip( "Choose a regression results folder or a results CSV") open_button.clicked.connect(self._open) bar.addWidget(open_button) bar.addWidget(self._path_label, 1) layout.addLayout(bar) self.explorer = VolcanoExplorer(parent=self) layout.addWidget(self.explorer, 1) self.setAcceptDrops(True) def load(self, path) -> bool: """Load a results folder. Returns whether one was found.""" table = find_results_table(path) if table is None: self._path_label.setText( f"No results CSV found under {path}") return False self.explorer.set_results(load_results(table)) self._path_label.setText(table) return True def _open(self) -> None: """Ask for a results folder and load it.""" folder = QFileDialog.getExistingDirectory( self, "Choose a regression results folder") if folder: self.load(folder) def dragEnterEvent(self, event): # noqa: N802 - Qt name """Accept a drag that carries paths.""" if event.mimeData().hasUrls(): event.acceptProposedAction() def dropEvent(self, event): # noqa: N802 - Qt name """Load the first dropped folder that holds results.""" for url in event.mimeData().urls(): if url.isLocalFile() and self.load(url.toLocalFile()): break event.acceptProposedAction() return VolcanoScreen(host=host)