Source code for spacr.qt.widgets.volcano_explorer

"""The volcano you can click, restyle and export.

The pipeline writes a volcano PDF, and until now that was the end of it: to
change an axis label, recolour by a covariate or find out which guide a dot
was, you re-ran the analysis or opened the CSV. This widget makes the plot the
thing you interrogate.

Three ideas hold it together:

* **One renderer.** Every pixel comes from
  :func:`spacr.volcano_style.render_volcano`, the same function the headless
  pipeline calls. Exporting re-renders from the same :class:`VolcanoStyle` at
  print size rather than screenshotting the widget, so what is saved is what
  was on screen, at full vector quality.
* **The style is a value.** Controls write into a
  :class:`~spacr.volcano_style.VolcanoStyle` and ask for a redraw. That makes
  the whole appearance saveable to JSON, reloadable, and reproducible.
* **Clicking is a lookup, not a hit test on pixels.** The nearest point is
  found in data space normalised by the axis ranges, so a click is as accurate
  on a squashed axis as on a square one.

Annotation files can be dropped in to colour or shape the points by anything:
the file is merged onto the results by a join column, and every column it
brings becomes available in the colour-by and shape-by menus.
"""

from __future__ import annotations

import dataclasses
import os
from typing import Any

import numpy as np
import pandas as pd
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDoubleSpinBox,
    QFileDialog,
    QFormLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMessageBox,
    QPushButton,
    QScrollArea,
    QSpinBox,
    QTableWidget,
    QTableWidgetItem,
    QVBoxLayout,
    QWidget,
)

from ...volcano_style import (
    COLORMAPS,
    FONT_FAMILIES,
    LINE_STYLES,
    MARKER_SHAPES,
    SCALES,
    VolcanoStyle,
    localizations_present,
    point_details,
    render_volcano,
    validate_style,
)
from .collapsible_splitter import CollapsibleSplitter
from .sortable_table import install_sorting, table_item

#: What an optional column menu calls "leave this unset". Spelled once,
#: because the menu has to tell it apart from a column actually named that.
_NONE_ROW = "— none —"

#: The red a broken setting's name turns, when the theme cannot be asked.
#: spaCR's own error hue; a literal only for a bare widget with no palette
#: behind it, which is a unit run rather than the application.
_PROBLEM_INK = "#C4441C"


def _setting_named_in(message: str) -> str:
    """The style setting a renderer error blames, or ``""`` for none.

    The renderer says ``x_column='foo' is not a column of the results``, so
    the setting is usually spelled out in the sentence -- and where it is,
    the reader can be pointed at the control that caused it rather than left
    to hunt. Longest name first, or ``color_by`` would answer for
    ``color_by`` and ``colormap`` alike; the ``name=`` form is preferred over
    a bare mention, because a message may name a second setting only to
    suggest it.

    ``""`` when nothing in the sentence is a setting: the explanation is
    still shown, it just has no control to turn red.
    """
    text = str(message)
    names = sorted((f.name for f in dataclasses.fields(VolcanoStyle)),
                   key=len, reverse=True)
    for name in names:
        if f"{name}=" in text:
            return name
    for name in names:
        if name in text:
            return name
    return ""


#: Columns never offered as a colour/shape source: they are the plot's own
#: axes or bookkeeping, and mapping colour to the y axis says nothing.
_NON_MAPPING_COLUMNS = frozenset({
    "standardized_marginal_effect", "adjusted_p_value", "permutation_p_value",
    "p_value", "q_value", "coefficient", "significant", "alpha",
})


class _OptionalNumbers(QWidget):
    """One number, or a pair of them, that can also be "automatic".

    An axis limit and the ends of a colour scale are ``None`` for "let the
    data decide" and a number otherwise, and a spin box has no way to say
    ``None``: a panel built from spin boxes alone can set a limit and never
    take it back off. The tick is that third state.
    """

    changed = Signal()

    def __init__(self, count: int, low: float, high: float, decimals: int,
                 caption: str, parent=None):
        """Build the tick and its spin boxes.

        :param count: how many numbers this control holds. One for a single
            limit, two for the ends of a range -- which is what decides
            whether the spin boxes are captioned "from" and "to".
        :param low: the smallest value a spin box accepts.
        :param high: the largest.
        :param decimals: places shown, and therefore the finest step a
            reader can express.
        :param caption: the tooltip text, used alone for a single number and
            suffixed with "(from)" / "(to)" for a pair.
        :param parent: parent widget.
        """
        super().__init__(parent)
        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(4)
        self._auto = QCheckBox("auto", self)
        self._auto.setToolTip("Let the plot choose. Untick to type a value.")
        self._auto.setChecked(True)
        layout.addWidget(self._auto)
        self._spins: list[QDoubleSpinBox] = []
        for index in range(int(count)):
            spin = QDoubleSpinBox(self)
            spin.setRange(low, high)
            spin.setDecimals(decimals)
            spin.setToolTip(f"{caption} ({'from' if index == 0 else 'to'})"
                            if count > 1 else caption)
            layout.addWidget(spin)
            self._spins.append(spin)
        self._sync_enabled()
        self._auto.toggled.connect(self._auto_toggled)
        for spin in self._spins:
            spin.valueChanged.connect(lambda _value: self.changed.emit())

    def _auto_toggled(self, _on: bool) -> None:
        """Enable or grey the spinboxes, then announce the change."""
        self._sync_enabled()
        self.changed.emit()

    def _sync_enabled(self) -> None:
        """Grey the spinboxes while automatic is ticked.

        Greyed rather than hidden: the numbers stay visible so a reader can see
        what automatic is going to override.
        """
        for spin in self._spins:
            spin.setEnabled(not self._auto.isChecked())

    def value(self):
        """``None`` when automatic, else the number or the pair."""
        if self._auto.isChecked():
            return None
        numbers = tuple(float(spin.value()) for spin in self._spins)
        return numbers[0] if len(numbers) == 1 else numbers

    def setValue(self, value) -> None:  # noqa: N802 - matches QSpinBox
        """Set the numbers, or fall back to automatic.

        A value this control cannot express -- a pair where one number was
        wanted -- reads as AUTOMATIC rather than as a number nobody typed.
        Signals are blocked throughout, so seeding the control does not announce
        a change the user did not make.

        :param value: the number, the sequence of numbers, or ``None``.
        """
        widgets = [self._auto, *self._spins]
        blocked = [widget.blockSignals(True) for widget in widgets]
        try:
            numbers = []
            if value is not None:
                given = ([value] if len(self._spins) == 1 else list(value))
                try:
                    numbers = [float(item) for item in given]
                except (TypeError, ValueError):
                    numbers = []
            if len(numbers) == len(self._spins):
                self._auto.setChecked(False)
                for spin, number in zip(self._spins, numbers):
                    spin.setValue(number)
            else:
                self._auto.setChecked(True)
        finally:
            for widget, was in zip(widgets, blocked):
                widget.blockSignals(was)
        self._sync_enabled()


class _MultiSelect(QListWidget):
    """A closed list any number of whose entries can be ticked at once.

    Several at once because "dense granules and rhoptries 1" is ONE question:
    a combo box would make the reader choose which half of their comparison
    to look at.

    :param caption: what the list is for. Shown as the TOOLTIP, not as a
        heading -- the widget draws no label of its own, so this is the only
        place the question is written.
    :param parent: parent widget; ownership only.
    """

    changed = Signal()

    def __init__(self, caption: str, parent=None):
        """Build the tick list, with the caption as its only prompt."""
        super().__init__(parent)
        self.setToolTip(caption)
        self.setSelectionMode(QListWidget.NoSelection)
        self.setMaximumHeight(108)
        self._filling = False
        self.itemChanged.connect(self._item_changed)

    def _item_changed(self, _item) -> None:
        """Announce a tick, unless the list is being filled.

        The guard is what stops a repopulate from emitting once per item and
        making every listener redo the work N times for one change.
        """
        if not self._filling:
            self.changed.emit()

    def options(self) -> list:
        """Every option offered, in list order.

        :returns: the stored values rather than the captions, so a caller reads
            what the option means and not what it says.
        """
        return [self.item(row).data(Qt.UserRole) for row in range(self.count())]

    def values(self) -> tuple:
        """What is ticked, in the offered order."""
        return tuple(self.item(row).data(Qt.UserRole)
                     for row in range(self.count())
                     if self.item(row).checkState() == Qt.Checked)

    def setOptions(self, options) -> None:  # noqa: N802 - Qt naming
        """Offer these, keeping whatever of them was already ticked."""
        self.setValues(self.values(), options)

    def setValues(self, values, options=None) -> None:  # noqa: N802
        """Tick exactly ``values``.

        A value that is not on the offered list is ADDED to it rather than
        dropped: a style file naming a compartment this screen has none of
        would otherwise lose it the moment the panel was refilled.
        """
        wanted = list(dict.fromkeys(values or ()))
        offered = list(self.options() if options is None else options)
        offered += [value for value in wanted if value not in offered]
        self._filling = True
        try:
            self.clear()
            for option in offered:
                item = QListWidgetItem(str(option), self)
                item.setData(Qt.UserRole, option)
                item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
                item.setCheckState(Qt.Checked if option in wanted
                                   else Qt.Unchecked)
        finally:
            self._filling = False


class _ReadOnlyValue(QLabel):
    """A setting the panel shows and does not edit.

    The two the volcano has -- the split axis's pair of pairs and the
    per-point annotation map -- are set from the plot itself. Showing them
    greyed is the same rule the right-click menu follows: a setting silently
    absent from the panel is one the user has been told exists and cannot
    find.
    """

    def show_value(self, value) -> None:
        """Display a value, or the word "none".

        :param value: the value; ``None`` and the empty containers all render as
            "none", because an empty box reads as a control that failed to
            load rather than as an answer.
        """
        text = "none" if value in (None, (), {}, "") else str(value)
        self.setText(text)


[docs] class VolcanoExplorer(QWidget): """An interactive volcano: click a point, restyle it, export it. :param results: the fitted table to plot. Copied with the index reset, so the row numbers a clicked point reports are positions in the plot rather than whatever index the caller happened to carry. :param style: the styling to open with. A fresh :class:`VolcanoStyle` is used when none is given. :param parent: parent widget. """ point_selected = Signal(dict) style_changed = Signal() def __init__(self, results: pd.DataFrame | None = None, style: VolcanoStyle | None = None, parent=None): """Build the volcano, its style controls and its detail panel. The plot ("Volcano plot") and the selected-point table ("Selected point") are sections of a vertical :class:`~spacr.qt.widgets.collapsible_splitter.CollapsibleSplitter` -- each folds by its heading, and the edge between them drags -- and that column shares a draggable edge with the style controls, whose categories already fold. :param results: the fitted table to plot. :param style: how to draw it. :param parent: parent widget. """ super().__init__(parent) self._results = pd.DataFrame() if results is None else results.reset_index(drop=True) self._style = style or VolcanoStyle() self._controls: dict[str, QWidget] = {} #: The name beside each control, so a broken setting can be pointed #: at. Held rather than looked up off the form every time: a red #: label has to be cleared again when the value is corrected, and #: that means knowing every label, not only the offending one. self._labels: dict[str, QLabel] = {} #: Which folding section holds each setting, so one that goes red #: can open itself. A red label inside a closed section is a red #: label nobody sees. self._sections: dict[str, QWidget] = {} #: The last value of each setting that actually drew, and the whole #: style it drew from. The first is what a broken setting falls back #: to; the second is the safety net for a failure no single setting #: can be blamed for. self._last_good: dict[str, Any] = {} self._last_good_style: VolcanoStyle | None = None self._problems: dict[str, str] = {} self._problem_ink = self._error_ink() self._updating = False self._selected_index: int | None = None outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) splitter = CollapsibleSplitter(Qt.Horizontal, self, persist_key="volcano_explorer::body") self._splitter = splitter outer.addWidget(splitter) from .graph_builder import _canvas_class from matplotlib.figure import Figure self._figure = Figure(figsize=(self._style.figure_width, self._style.figure_height)) self._canvas = _canvas_class()(self._figure) self._canvas.setMinimumSize(420, 320) self._canvas.mpl_connect("button_press_event", self._on_click) self._canvas.setContextMenuPolicy(Qt.CustomContextMenu) self._canvas.customContextMenuRequested.connect(self._style_menu) self._problem_line = QLabel("", self) self._problem_line.setObjectName("VolcanoProblems") self._problem_line.setWordWrap(True) self._problem_line.setVisible(False) plot = QWidget(self) plot_layout = QVBoxLayout(plot) plot_layout.setContentsMargins(0, 0, 0, 0) plot_layout.addWidget(self._canvas, 1) plot_layout.addWidget(self._problem_line) left = CollapsibleSplitter(Qt.Vertical, persist_key="volcano_explorer::plot") left.add_section(plot, "Volcano plot", persist_key="volcano_explorer/Volcano plot", stretch=1) left.add_section(self._build_detail_panel(), "Selected point", persist_key="volcano_explorer/Selected point", stretch=0) self._plot_splitter = left splitter.add_pane(left, "Plot", stretch=3) scroll = QScrollArea(self) scroll.setWidgetResizable(True) scroll.setMinimumWidth(300) scroll.setWidget(self._build_controls()) splitter.add_pane(scroll, "Style", stretch=1) self.setAcceptDrops(True) if not self._results.empty: self.refresh() from ..screens.settings_model import retarget_field_tooltips retarget_field_tooltips(self)
[docs] def set_results(self, results: pd.DataFrame) -> None: """Plot a new fitted table, clearing any selection. THE INDEX IS RESET. Point picking is by POSITION in the plotted frame, so a table carrying its old index would have every click resolve to the wrong row. :param results: the fitted table to plot. """ self._results = results.reset_index(drop=True) self._selected_index = None self._repopulate_column_menus() self.refresh()
[docs] def results(self) -> pd.DataFrame: """The table being plotted. A COPY, so a caller cannot change what is on screen by editing what it was handed. :returns: the results table. """ return self._results.copy()
[docs] def style(self) -> VolcanoStyle: """How the volcano is currently drawn. :returns: the style. """ return self._style
[docs] def set_style(self, style: VolcanoStyle) -> None: """Restyle the plot and push the new values into the controls. BOTH, so a style set from code leaves the controls agreeing with the plot rather than showing what they were last set to by hand. :param style: the style to apply. """ self._style = style self._push_style_to_controls() self.refresh()
[docs] def build_style_menu(self): """Build the context menu for styling the current volcano plot. Returns ------- PySide6.QtWidgets.QMenu A menu containing live style controls and style-file actions. Changes update both the plot and its side-panel controls. """ from PySide6.QtWidgets import QMenu from .fast_plots import add_style_entries, add_style_file_entries menu = QMenu(self) menu.setToolTipsVisible(True) def changed(_name=None, _value=None): """Re-apply the style after any menu entry changes it.""" self.set_style(self._style) add_style_entries(menu, self._style, changed, choices=self._style_choices(), labels=self._style_labels()) menu.addSeparator() add_style_file_entries(menu, self._style, changed, parent=self) menu.addSeparator() from ...figures.bundle import _register_figure_data from .figure_settings import _add_figure_tools _register_figure_data( self._figure, self._results, x=self._style.x_column, y=self._style.y_column, kind="scatter", title=self._figure.axes[0].get_title() if self._figure.axes else "") _add_figure_tools(menu, self._figure, self, lambda **_k: self.set_style(self._style)) return menu
def _style_choices(self) -> dict: """``{field: values}`` for every setting the side panel closes. READ OFF THE PANEL ITSELF rather than from a list of names. The list was written as ``colour_by`` and ``label_by`` while the controls -- and :class:`VolcanoStyle` -- spell them ``color_by`` and ``label_column``, so those two never matched: the menu offered a text box where the panel offered a picker, and one of the two routes let a user type a column that does not exist. A panel control that IS a closed list is now the definition of one, so the two cannot disagree again and the marker, the colormap, the line style and the fonts get their pickers on the menu as well. """ choices: dict = {} for name, widget in self._controls.items(): if isinstance(widget, _MultiSelect): if widget.count(): choices[name] = list(widget.options()) continue if not isinstance(widget, QComboBox): continue values = [] for index in range(widget.count()): data = widget.itemData(index) if data is None and widget.itemText(index) != _NONE_ROW: data = widget.itemText(index) values.append(data) if values: choices[name] = values return choices def _style_labels(self) -> dict: """``{field: {value: what the panel calls it}}``. The panel says "Circle" and the style stores ``"o"``; the menu has to say "Circle" too, or the two routes offer the same setting in two vocabularies and only one of them is the one a reader recognises. Only rows whose words differ from their value are listed, so the common case costs nothing. """ named: dict = {} for name, widget in self._controls.items(): if not isinstance(widget, QComboBox): continue rows = {} for index in range(widget.count()): data = widget.itemData(index) text = widget.itemText(index) if text != ("" if data is None else str(data)): rows[data] = text if rows: named[name] = rows return named
[docs] def menu_settings(self) -> set: """The style fields the right-click menu offers, by name.""" from .fast_plots import style_menu_fields return style_menu_fields(self.build_style_menu())
[docs] def panel_settings(self) -> set: """The style fields the side panel offers, by name.""" return set(self._controls)
def _style_menu(self, position) -> None: """Right-click on the canvas: build the menu and show it.""" self.build_style_menu().exec(self._canvas.mapToGlobal(position))
[docs] def merge_annotation_file(self, path, *, on: str | None = None) -> int: """Merge a CSV/Excel of annotations onto the results. Every column it brings becomes selectable for colour and shape. The join column is inferred from whichever shared column matches most rows, so a file keyed on ``gene`` and one keyed on ``guide`` both work without the user being asked which is which. :param path: annotation file; ``.xlsx``/``.xls`` is read as Excel, anything else as CSV. It must share a column with the results (unless ``on`` names one) or :class:`ValueError` is raised. :returns: the number of columns added. """ path = os.fspath(path) frame = (pd.read_excel(path) if path.lower().endswith((".xlsx", ".xls")) else pd.read_csv(path)) if frame.empty: return 0 shared = [c for c in frame.columns if c in self._results.columns] if on is not None: key = on elif shared: key = max(shared, key=lambda c: self._results[c].astype(str).isin( frame[c].astype(str)).sum()) else: raise ValueError( f"{os.path.basename(path)} shares no column with the results " f"({sorted(self._results.columns)[:10]}), so there is nothing " f"to join on.") incoming = frame.drop_duplicates(subset=[key]) new_columns = [c for c in incoming.columns if c not in self._results.columns] if not new_columns: return 0 merged = self._results.copy() lookup = incoming.set_index(incoming[key].astype(str)) keys = self._results[key].astype(str) for column in new_columns: merged[column] = keys.map(lookup[column]) self._results = merged self._repopulate_column_menus() self.refresh() return len(new_columns)
def _build_controls(self) -> QWidget: """Build the volcano controls in four initially collapsed sections. The significance level, threshold method, threshold multiplier, and colour-by field remain visible because they define the primary statistical filtering and visual encoding choices. Plot, test, appearance, and label options are grouped in collapsible sections. """ from .section import Section container = QWidget(self) layout = QVBoxLayout(container) layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(8) constant = QWidget(container) constant_form = QFormLayout(constant) constant_form.setContentsMargins(8, 8, 8, 8) constant_form.setSpacing(4) for key, widget in ( ("alpha", self._spin(1e-6, 0.5, 0.01, 6, "Significance level")), ("threshold_method", self._combo( ["value", "std", "mad", "quantile", "control"], "How the effect-size cut is derived")), ("threshold_multiplier", self._spin( 0.0, 100.0, 0.5, 4, "Multiplier applied to the rule above (the quantile itself " "when the method is 'quantile')")), ("color_by", self._combo([], "Column that chooses each colour")), ): constant_form.addRow(self._register(key, widget), widget) layout.addWidget(constant) for title, rows in ( ("What is plotted", [ ("x_column", self._combo([], "Column plotted on the x axis")), ("y_column", self._combo([], "Column plotted on the y axis")), ("y_neg_log10", self._check("\u2212log\u2081\u2080 the y column")), ("label_column", self._combo([], "Column holding point names")), ("x_scale", self._combo(SCALES, "X axis scale")), ("y_scale", self._combo(SCALES, "Y axis scale")), ("x_lim", self._optional(2, -1e9, 1e9, 4, "X axis limits")), ("y_lim", self._optional(2, -1e9, 1e9, 4, "Y axis limits")), ("invert_x", self._check("Invert x axis")), ("invert_y", self._check("Invert y axis")), ("split_axis", self._check("Split the y axis (broken axis)")), ("split_height_ratio", self._spin( 0.1, 0.9, 0.05, 2, "Height of the upper panel")), ("split_y_lims", self._readonly( "Where the y axis is split (set by ticking the split " "above)")), ]), ("How it is tested", [ ("effect_threshold", self._optional( 1, -1e6, 1e6, 4, "Effect-size cut used when the method is 'value'; " "automatic draws no effect-size line at all")), ("control_column", self._combo( [], "Boolean column marking the non-targeting controls, " "for the 'control' method")), ("show_alpha_line", self._check("Draw the significance line")), ("show_effect_lines", self._check( "Draw the effect-size lines")), ("show_zero_line", self._check("Draw the zero line")), ]), ("How it looks", [ ("marker", self._combo( [code for code, _ in MARKER_SHAPES], "Marker shape", labels=[label for _, label in MARKER_SHAPES])), ("marker_size", self._spin(1, 400, 2, 1, "Marker size")), ("significant_marker_size", self._spin( 1, 400, 2, 1, "Marker size for significant points")), ("marker_alpha", self._spin(0.05, 1.0, 0.05, 2, "Opacity")), ("marker_edge_width", self._spin(0, 5, 0.05, 2, "Edge width")), ("marker_edge_color", self._line("Edge colour")), ("base_color", self._line("Colour of non-significant points")), ("significant_color", self._line( "Colour of significant points")), ("colormap", self._combo( [name for group in COLORMAPS.values() for name in group], "Colormap")), ("color_vmin", self._optional(1, -1e9, 1e9, 4, "Low end of the colour scale")), ("color_vmax", self._optional(1, -1e9, 1e9, 4, "High end of the colour scale")), ("shape_by", self._combo([], "Column that chooses each shape")), ("localizations", self._multi( "Colour by localization \u2014 tick any combination")), ("localization_column", self._combo( [], "Column naming each row's gene, for the compartment " "lookup")), ("show_colorbar", self._check("Show the colour bar")), ("line_width", self._spin(0, 10, 0.1, 2, "Threshold line width")), ("line_color", self._line("Threshold line colour")), ("line_style", self._combo( [code for code, _ in LINE_STYLES], "Threshold line style", labels=[label for _, label in LINE_STYLES])), ("zero_line_width", self._spin(0, 10, 0.1, 2, "Zero line width")), ("zero_line_color", self._line("Zero line colour")), ("grid", self._check("Show grid")), ("grid_axis", self._combo(["x", "y", "both", "none"], "Grid axis")), ("grid_color", self._line("Grid colour")), ("grid_width", self._spin(0, 5, 0.1, 2, "Grid line width")), ("hide_top_right_spines", self._check( "Hide top/right spines")), ("axis_color", self._line( "Colour of the axis lines, the ticks and the text")), ("screen_background", self._line( "Background on screen ('none' shows the page through)")), ("background_color", self._line( "Background of an exported figure ('none' leaves the " "page showing through)")), ("figure_width", self._spin(1, 40, 0.2, 2, "Figure width (in)")), ("figure_height", self._spin(1, 40, 0.2, 2, "Figure height (in)")), ("dpi", self._int_spin(50, 1200, "Raster export dpi")), ("legend", self._check("Show legend")), ("legend_location", self._combo( ["best", "upper right", "upper left", "lower left", "lower right", "right", "center left", "center right", "lower center", "upper center", "center"], "Legend position")), ("transparent", self._check( "Transparent background on export")), ]), ("What is labelled", [ ("title", self._line("Plot title")), ("x_label", self._line("X axis title")), ("y_label", self._line("Y axis title (blank = automatic)")), ("font_family", self._combo(FONT_FAMILIES, "Font family")), ("font_size", self._spin(4, 48, 0.5, 1, "Base font size")), ("title_font_size", self._spin(4, 48, 0.5, 1, "Title size")), ("label_font_size", self._spin(4, 48, 0.5, 1, "Annotation size")), ("tick_font_size", self._spin(4, 48, 0.5, 1, "Tick label size")), ("font_weight", self._combo( ["normal", "bold", "light", "medium", "semibold", "heavy"], "Font weight")), ("annotate_significant", self._check("Label every hit")), ("annotations", self._readonly( "Points labelled by name (set by clicking a point)")), ]), ): section = Section(title, container) for key, widget in rows: section.add_row(self._register(key, widget, section), widget) layout.addWidget(section) buttons = QWidget(self) row = QVBoxLayout(buttons) row.setContentsMargins(0, 0, 0, 0) top = QHBoxLayout() for text, slot, tip in ( ("Export PDF\u2026", lambda: self.export("pdf"), "Write a vector PDF of exactly this plot"), ("Export PNG\u2026", lambda: self.export("png"), "Write a raster PNG at the dpi set under Frame"), ): button = QPushButton(text, self) button.setToolTip(tip) button.clicked.connect(slot) top.addWidget(button) row.addLayout(top) bottom = QHBoxLayout() for text, slot, tip in ( ("Load annotations\u2026", self._pick_annotation_file, "Merge a CSV/Excel of annotations, then colour or shape by any " "of its columns"), ("Save style", self._save_style, "Save this appearance as JSON"), ("Load style", self._load_style, "Restore a saved appearance"), ): button = QPushButton(text, self) button.setToolTip(tip) button.clicked.connect(slot) bottom.addWidget(button) row.addLayout(bottom) layout.addWidget(buttons) layout.addStretch(1) return container def _register(self, key: str, widget: QWidget, section=None) -> QLabel: """Record a control, and build the name that sits beside it. THE LABEL IS BUILT HERE RATHER THAN BY THE FORM. ``addRow`` with a string makes a QLabel nobody holds a reference to, and every one of them is needed back -- to turn red when its setting breaks, and, just as much, to turn black again when it is corrected. """ self._controls[key] = widget label = QLabel(str(widget.property("caption") or key), self) label.setWordWrap(True) label.setMaximumWidth(190) self._labels[key] = label if section is not None: self._sections[key] = section return label def _combo(self, options, caption: str, *, labels=None) -> QComboBox: """One labelled combo box in the style column. :param options: the values it offers. :param caption: its label. :param labels: display names for the options, when they differ. :returns: the combo box. """ widget = QComboBox(self) widget.setProperty("caption", caption) widget.setToolTip(caption) for index, option in enumerate(options): widget.addItem(labels[index] if labels else str(option), option) widget.currentIndexChanged.connect(self._on_control_changed) return widget def _check(self, caption: str) -> QCheckBox: """One labelled tick box in the style column. :param caption: its label. :returns: the tick box. """ widget = QCheckBox(self) widget.setProperty("caption", caption) widget.setToolTip(caption) widget.toggled.connect(self._on_control_changed) return widget def _spin(self, low, high, step, decimals, caption) -> QDoubleSpinBox: """One labelled float spin box in the style column. :param low: the smallest value it accepts. :param high: the largest. :param step: how much one click moves it. :param decimals: how many decimal places it shows. :param caption: its label. :returns: the spin box. """ widget = QDoubleSpinBox(self) widget.setProperty("caption", caption) widget.setToolTip(caption) widget.setRange(low, high) widget.setSingleStep(step) widget.setDecimals(decimals) widget.valueChanged.connect(self._on_control_changed) return widget def _int_spin(self, low, high, caption) -> QSpinBox: """One labelled integer spin box in the style column. :param low: the smallest value it accepts. :param high: the largest. :param caption: its label. :returns: the spin box. """ widget = QSpinBox(self) widget.setProperty("caption", caption) widget.setToolTip(caption) widget.setRange(low, high) widget.valueChanged.connect(self._on_control_changed) return widget def _line(self, caption: str) -> QLineEdit: """One labelled text field in the style column. :param caption: its label. :returns: the field. """ widget = QLineEdit(self) widget.setProperty("caption", caption) widget.setToolTip(caption) widget.editingFinished.connect(self._on_control_changed) return widget def _optional(self, count, low, high, decimals, caption): """A spin box that can also mean "not set". A SEPARATE CONTROL BECAUSE ZERO IS A VALUE. Several of these settings have a meaningful zero, so the absent state cannot be spelled as one and needs a box of its own to say so. :param count: how many spin boxes the row carries. :param low: the smallest value each accepts. :param high: the largest. :param decimals: how many decimal places they show. :param caption: the row's label. :returns: the row's widgets. """ widget = _OptionalNumbers(count, low, high, decimals, caption, self) widget.setProperty("caption", caption) widget.setToolTip(caption) widget.changed.connect(self._on_control_changed) return widget def _multi(self, caption: str) -> "_MultiSelect": """One labelled multi-select list in the style column. :param caption: its label. :returns: the list. """ widget = _MultiSelect(caption, self) widget.setProperty("caption", caption) widget.changed.connect(self._on_control_changed) return widget def _readonly(self, caption: str) -> "_ReadOnlyValue": """One labelled read-only field, for values the plot computes. :param caption: its label. :returns: the field. """ widget = _ReadOnlyValue("none", self) widget.setProperty("caption", caption) widget.setToolTip(caption) widget.setEnabled(False) return widget def _build_detail_panel(self) -> QWidget: """Build the panel that describes the selected point.""" box = QGroupBox("Selected point", self) layout = QVBoxLayout(box) layout.setContentsMargins(8, 8, 8, 8) self._detail_hint = QLabel("Click any point to see everything known " "about it.", self) self._detail_hint.setWordWrap(True) layout.addWidget(self._detail_hint) self._detail_table = QTableWidget(0, 2, self) install_sorting(self._detail_table) self._detail_table.setHorizontalHeaderLabels(["Field", "Value"]) self._detail_table.verticalHeader().setVisible(False) self._detail_table.horizontalHeader().setStretchLastSection(True) self._detail_table.setMaximumHeight(190) layout.addWidget(self._detail_table) box.setMaximumHeight(260) return box def _repopulate_column_menus(self) -> None: """Refill the column dropdowns from whatever columns now exist.""" columns = list(self._results.columns) numeric = [c for c in columns if pd.api.types.is_numeric_dtype(self._results[c])] mappable = [c for c in columns if c not in _NON_MAPPING_COLUMNS] self._updating = True try: for key, options, allow_none in ( ("x_column", numeric, False), ("y_column", numeric, False), ("label_column", columns, False), ("color_by", mappable, True), ("shape_by", mappable, True), ("control_column", columns, True), ("localization_column", columns, True), ): widget = self._controls.get(key) if widget is None: continue current = widget.currentData() widget.clear() if allow_none: widget.addItem(_NONE_ROW, None) for option in options: try: missing = pd.isna(option) except (TypeError, ValueError): missing = False if isinstance(missing, (bool, np.bool_)) and missing: widget.addItem("None", None) else: widget.addItem(str(option), option) index = widget.findData(current) if index < 0: index = widget.findData(getattr(self._style, key, None)) widget.setCurrentIndex(max(index, 0)) compartments = self._controls.get("localizations") if compartments is not None: compartments.setOptions(self.compartments()) finally: self._updating = False
[docs] def compartments(self) -> list: """The LOPIT compartments this screen actually has, commonest first. NOT ALL 27 IN THE REFERENCE TABLE: a tick box that would colour nothing is indistinguishable from a broken one. Empty when no column of the results names a gene, which is a volcano without compartment colouring rather than an error. """ if self._results.empty: return [] try: return localizations_present(self._results, self._style) except Exception: # noqa: BLE001 - no reference table, no colouring return []
def _push_style_to_controls(self) -> None: """Write the style into every control without triggering a redraw.""" self._updating = True try: for key, widget in self._controls.items(): value = getattr(self._style, key, None) if isinstance(widget, _MultiSelect): widget.setValues(value or ()) elif isinstance(widget, _OptionalNumbers): widget.setValue(value) elif isinstance(widget, _ReadOnlyValue): widget.show_value(value) elif isinstance(widget, QCheckBox): widget.setChecked(bool(value)) elif isinstance(widget, QComboBox): index = widget.findData(value) if index < 0: index = widget.findText(str(value)) widget.setCurrentIndex(max(index, 0)) elif isinstance(widget, (QDoubleSpinBox, QSpinBox)): if value is not None: widget.setValue(value) elif isinstance(widget, QLineEdit): widget.setText("" if value is None else str(value)) finally: self._updating = False def _pull_style_from_controls(self) -> None: """Read every control back into a style object.""" for key, widget in self._controls.items(): if isinstance(widget, _ReadOnlyValue): continue if isinstance(widget, _MultiSelect): setattr(self._style, key, tuple(widget.values())) elif isinstance(widget, _OptionalNumbers): setattr(self._style, key, widget.value()) elif isinstance(widget, QCheckBox): setattr(self._style, key, bool(widget.isChecked())) elif isinstance(widget, QComboBox): index = widget.currentIndex() setattr(self._style, key, widget.itemData(index) if index >= 0 else None) elif isinstance(widget, (QDoubleSpinBox, QSpinBox)): setattr(self._style, key, widget.value()) elif isinstance(widget, QLineEdit): text = widget.text().strip() setattr(self._style, key, text) def _on_control_changed(self, *_args) -> None: """Restyle and redraw after any control moves.""" if self._updating: return self._pull_style_from_controls() self.refresh() self.style_changed.emit()
[docs] def refresh(self) -> None: """Redraw the canvas, and name the settings that could not be used. A BAD SETTING DOES NOT COST THE READER THE PICTURE. One mistyped colour used to replace the whole volcano with the words "cannot draw this plot", which takes away the only thing on screen over a single field and does not say which field. Instead the offending settings fall back to the last value that drew, the figure stays up, the name of each offending setting goes red and the reasons are printed under the plot. ``screen=True``: this render is being read, so it takes :attr:`VolcanoStyle.screen_background`. The export path does not pass it and therefore keeps the transparent figure default. """ if self._results.empty: return if not self._controls.get("x_column", QComboBox()).count(): self._repopulate_column_menus() self._push_style_to_controls() self._derive_split_limits() problems = validate_style(self._results, self._style) for candidate in self._drawable_styles(problems): try: _figure, self._panels = render_volcano( self._results, candidate, figure=self._figure, screen=True) except Exception as error: # noqa: BLE001 - never crash on style problems.setdefault(_setting_named_in(str(error)), str(error)) continue self._remember(candidate) break else: self._figure.clear() axis = self._figure.add_subplot(111) axis.set_axis_off() self._panels = [axis] self._show_problems(problems) self._canvas.draw_idle()
def _derive_split_limits(self) -> None: """A split axis needs limits; derive them rather than refuse to draw. Wrapped, because the suggestion is read off the y column and the y column is one of the settings that can be broken. A split that cannot be sized is left unset, and the y column's own complaint is the one the reader is shown. """ if not self._style.split_axis or self._style.split_y_lims: return try: self._style.split_y_lims = self._suggest_split() except Exception: # noqa: BLE001 - the y column answers for this self._style.split_y_lims = None def _drawable_styles(self, problems: dict): """Styles to try, best first, so something stays on the canvas. 1. the current style with each broken setting replaced by the last value of it that drew -- which is what "keep the figure, minus that setting's contribution" means in practice; 2. failing that, the whole of the last style that drew; 3. failing that, the plain defaults. Yielded rather than returned so the common case -- nothing is wrong -- builds exactly one style and copies nothing. """ fallback = VolcanoStyle() if problems: remembered = { name: self._last_good.get(name, getattr(fallback, name)) for name in problems if hasattr(fallback, name)} yield dataclasses.replace(self._style, **remembered) else: yield self._style if self._last_good_style is not None: yield self._last_good_style yield fallback def _remember(self, style: VolcanoStyle) -> None: """Record the style that just drew, field by field and whole.""" self._last_good = {field.name: getattr(style, field.name) for field in dataclasses.fields(style)} self._last_good_style = dataclasses.replace(style) @staticmethod def _error_ink() -> str: """The application's own error red, or the house one without a theme.""" try: from ..theme import active_palette return str(active_palette().get("error") or _PROBLEM_INK) except Exception: # noqa: BLE001 - a bare widget has no palette return _PROBLEM_INK
[docs] def problems(self) -> dict: """Return validation problems recorded during the latest redraw.""" return dict(self._problems)
[docs] def label_for(self, setting: str) -> QLabel | None: """Return the label associated with a style setting. :param setting: the style setting's key, as its control was registered; an unknown key gives ``None``. """ return self._labels.get(setting)
[docs] def section_for(self, setting: str): """Return the collapsible section containing a style setting. :param setting: the style setting's key; an unknown key, or one registered outside a section, gives ``None``. """ return self._sections.get(setting)
[docs] def sections(self) -> list: """Return unique settings sections in panel order.""" seen: list = [] for section in self._sections.values(): if section not in seen: seen.append(section) return seen
def _caption_of(self, setting: str) -> str: """The label shown for one style setting. :param setting: the setting's name. :returns: its caption. """ widget = self._controls.get(setting) caption = widget.property("caption") if widget is not None else None return str(caption or setting or "") def _show_problems(self, problems: dict) -> None: """Redden the offending names, open their sections, print the reasons. EVERY LABEL IS VISITED, not only the broken ones: clearing the red when a value is corrected is half the job, and a pass that touched only the offenders could never do it. """ self._problems = dict(problems) ink = self._problem_ink for name, label in self._labels.items(): broken = name in problems label.setProperty("volcanoProblem", broken) label.setStyleSheet(f"color: {ink};" if broken else "") if not broken: continue section = self._sections.get(name) if section is not None and not section.is_expanded(): section.set_expanded(True) lines = [] for name, message in problems.items(): caption = self._caption_of(name) if name else "" lines.append(f"{caption}: {message}" if caption else str(message)) self._problem_line.setStyleSheet(f"color: {ink};") self._problem_line.setText("\n".join(lines)) self._problem_line.setVisible(bool(lines)) def _suggest_split(self): """Split just above the null cloud, at the 99th percentile of y.""" y = self._plotted_y() if y.size == 0: return None cut = float(np.nanquantile(y, 0.99)) top = float(np.nanmax(y)) if not np.isfinite(cut) or not np.isfinite(top) or top <= cut: return None pad = (top - cut) * 0.1 or 0.1 return ((0.0, cut), (cut + pad * 0.5, top + pad)) def _plotted_y(self) -> np.ndarray: """The y values actually drawn, after any transform. DISTINCT FROM THE COLUMN. A volcano usually plots -log10(p), so the value under the cursor is not the value in the table, and a readout that confused them would misreport every point. :returns: the plotted y array. """ raw = pd.to_numeric(self._results[self._style.y_column], errors="coerce").to_numpy(float) if self._style.y_neg_log10: return -np.log10(np.clip(raw, np.finfo(float).tiny, None)) return raw def _on_click(self, event) -> None: """Select the point under the click and describe it. :param event: the matplotlib click event. """ if event.inaxes is None or self._results.empty: return index = self.nearest_point(event.xdata, event.ydata, event.inaxes) if index is None: return self.select_point(index)
[docs] def nearest_point(self, x: float, y: float, axes=None) -> int | None: """Index of the point closest to ``(x, y)`` in axis-normalised space. Normalising by each axis's visible range is what makes a click land on the point that LOOKS closest. Raw Euclidean distance in data units picks the wrong point whenever the axes have different scales, which on a volcano they always do. :param x: the click's x position in data units of the x column. :param y: the click's y position in plotted units (after the ``-log10`` transform when the style applies one). :returns: the row position, or ``None`` when no point lies within 0.05 of the normalised visible range. """ if self._results.empty: return None xs = pd.to_numeric(self._results[self._style.x_column], errors="coerce").to_numpy(float) ys = self._plotted_y() x_span = y_span = 1.0 if axes is not None: low, high = axes.get_xlim() x_span = abs(high - low) or 1.0 low, high = axes.get_ylim() y_span = abs(high - low) or 1.0 distance = np.hypot((xs - x) / x_span, (ys - y) / y_span) if not np.isfinite(distance).any(): return None index = int(np.nanargmin(distance)) return index if distance[index] <= 0.05 else None
[docs] def select_point(self, index: int) -> dict: """Select a point by row index and show everything known about it. :param index: positional row index into the results frame (not a label). """ detail = point_details(self._results, index, self._style) self._selected_index = int(index) rows = [(k, v) for k, v in detail.items() if not k.startswith("_")] rows += [(k.lstrip("_") + " (plotted)", v) for k, v in detail.items() if k.startswith("_")] self._detail_table.setRowCount(len(rows)) for row, (key, value) in enumerate(rows): if isinstance(value, float): text = f"{value:.6g}" else: text = str(value) self._detail_table.setItem(row, 0, table_item(str(key))) item = table_item(text) item.setToolTip(text) self._detail_table.setItem(row, 1, item) name = detail.get(self._style.label_column, index) self._detail_hint.setText(f"Selected {name}") self.point_selected.emit(detail) return detail
[docs] def selected_index(self) -> int | None: """Which row the user has clicked, if any. A POSITION into the plotted frame, not a label -- see :meth:`set_results`. :returns: the row position, or None when nothing is selected. """ return self._selected_index
[docs] def export(self, fmt: str = "pdf", path: str | None = None) -> str | None: """Re-render at print size and write the file. Returns the path written, or ``None``. Not a screenshot: the figure is drawn again from the same style, so a PDF stays vector and a PNG honours the dpi under Frame regardless of how large the widget happens to be on screen. """ if self._results.empty: return None if path is None: filters = {"pdf": "PDF (*.pdf)", "png": "PNG (*.png)", "svg": "SVG (*.svg)"} path, _selected = QFileDialog.getSaveFileName( self, f"Export volcano as {fmt.upper()}", f"volcano.{fmt}", filters.get(fmt, "All files (*)")) if not path: return None if not path.lower().endswith(f".{fmt}"): path = f"{path}.{fmt}" from matplotlib.figure import Figure from ...plot import FIGURE_FORMATS figure = Figure(figsize=(self._style.figure_width, self._style.figure_height), dpi=self._style.dpi) try: if str(fmt).lower() in FIGURE_FORMATS: render_volcano(self._results, self._style, figure=figure, save_path=path) return path from .figure_settings import save_figure_as render_volcano(self._results, self._style, figure=figure) written = save_figure_as(self, figure, path) except Exception as error: # noqa: BLE001 QMessageBox.warning(self, "Export failed", str(error)) return None if not written: QMessageBox.warning( self, "Export failed", f"Nothing could be written to {path}.") return None return written
def _save_style(self) -> str | None: """Write the current style to a file.""" path, _selected = QFileDialog.getSaveFileName( self, "Save plot style", "volcano_style.json", "JSON (*.json)") if not path: return None return self._style.save(path) def _load_style(self) -> str | None: """Read a style from a file and apply it.""" path, _selected = QFileDialog.getOpenFileName( self, "Load plot style", "", "JSON (*.json)") if not path: return None self.set_style(VolcanoStyle.load(path)) return path def _pick_annotation_file(self) -> None: """Ask for a file of points to label on the plot.""" path, _selected = QFileDialog.getOpenFileName( self, "Load an annotation table", "", "Tables (*.csv *.tsv *.txt *.xlsx);;All files (*)") if not path: return try: added = self.merge_annotation_file(path) except Exception as error: # noqa: BLE001 QMessageBox.warning(self, "Could not merge annotations", str(error)) return QMessageBox.information( self, "Annotations merged", f"Added {added} column{'s' if added != 1 else ''}. They are now " f"available under Colour & shape mapping.") @staticmethod def _dropped_paths(event) -> list[str]: """The usable paths out of a drop. :param event: the Qt drop event. :returns: the paths, empty when the drop carries none. """ mime = event.mimeData() if not mime.hasUrls(): return [] return [url.toLocalFile() for url in mime.urls() if url.isLocalFile()]
[docs] def dragEnterEvent(self, event): # noqa: N802 - Qt name """Accept a drag carrying a table this explorer can plot. :param event: the Qt drag event. """ if self._dropped_paths(event): event.acceptProposedAction() else: event.ignore()
[docs] def dropEvent(self, event): # noqa: N802 - Qt name """Plot the dropped table. :param event: the Qt drop event. """ paths = self._dropped_paths(event) if not paths: event.ignore() return for path in paths: if path.lower().endswith(".json"): self.set_style(VolcanoStyle.load(path)) else: try: self.merge_annotation_file(path) except Exception: # noqa: BLE001 - drop is best-effort continue event.acceptProposedAction()
__all__ = ["VolcanoExplorer"]