Source code for spacr.figures.fast_render

"""Render generated regression figures from their interactive scenes.

For the plots listed in :data:`FAST_PANELS`, :func:`render_panel` exports a
provided ``FastPlot`` directly so the saved figure matches the visible tab.
In ``auto`` mode, a call without a live widget uses the corresponding
matplotlib panel. ``SPACR_FIGURE_RENDERER=pyqtgraph`` explicitly requests a
new scene built from the coefficient table; ``matplotlib`` forces that
renderer.

Output paths pass through :func:`spacr.plot.figure_path`, so file extensions
follow the configured format. Matplotlib figures are published through
``spacr.figure_sink.publish`` and scene exports through
``spacr.figure_sink.publish_file``; both routes add saved files to the gallery
when a listener is present.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Optional, Sequence

from .panels import SHEET_ORDER

#: The seven duplicated plots: house-style panel key -> the class in
#: ``spacr.qt.widgets.fast_plots`` that draws the same picture on screen.
#:
#: This mapping IS the claim that the two are the same plot. A key here whose
#: two renderers show different things is the bug this module exists to
#: remove, so a test asserts the keys are exactly ``SHEET_ORDER``.
FAST_PANELS = {
    "volcano": "VolcanoPlot",
    "effect_rank": "EffectRankPlot",
    "effect_distribution": "EffectDistribution",
    "controls": "ControlSeparation",
    "agreement": "GuideAgreementPlot",
    "p_histogram": "PValueHistogram",
    "qq": "QQPlot",
}

#: What a renderer choice can be. ``auto`` is the rule the module docstring
#: states; the other two are a person overruling it.
RENDERERS = ("auto", "pyqtgraph", "matplotlib")

#: A QApplication this module had to start, kept alive. Qt destroys the
#: application the moment its last Python reference goes, and an export that
#: happens after that is a segfault rather than an exception.
_APPLICATION = None


@dataclass
[docs] class RenderedPanel: """One generated figure: what drew it, where it went, and why. ``renderer`` is RECORDED rather than assumed, because the answer varies per run and per machine and a user comparing a figure on screen against a figure in a folder has to be able to find out which one they are holding. :param key: generated-panel key identifying the requested figure. :param path: exported file path, or ``None`` when no file was written. :param renderer: renderer selected for this result, whether it completed or reported a refusal. :param drawn: whether the selected renderer successfully produced the panel; this does not by itself imply that a file was written. :param reason: explanation for fallback or refusal, empty when unnecessary. """ key: str path: Optional[str] = None renderer: str = "" drawn: bool = False reason: str = ""
[docs] def __bool__(self) -> bool: """Return whether the panel was drawn and has an output path. :returns: ``True`` only for a drawn panel with a non-empty path. """ return bool(self.drawn and self.path)
[docs] def requested_renderer() -> str: """The renderer the environment asks for, or ``'auto'``. An unrecognised value is ``'auto'`` rather than an error: a run must not lose its figures over a misspelt environment variable, which is the rule :func:`spacr.figure_style.figure_save_mode` already follows for the save mode. """ asked = os.environ.get("SPACR_FIGURE_RENDERER", "").strip().lower() return asked if asked in RENDERERS else "auto"
[docs] def qt_application(): """The running ``QApplication``, or None. NEVER creates one. Deliberately does not import PySide6 unless it is already imported. Importing Qt pulls a GUI toolkit into a notebook that asked for a regression, so the question "is there a GUI?" has to be answerable without answering it in the affirmative by accident. """ import sys module = sys.modules.get("PySide6.QtWidgets") if module is None: return None try: return module.QApplication.instance() except Exception: # noqa: BLE001 return None
[docs] def renderer_for(key: str, force: Optional[str] = None) -> tuple: """``(renderer, reason)`` for one panel. The decision, in one place. :param key: generated-panel key. Only keys in :data:`FAST_PANELS` have an interactive twin; every other key is assigned to matplotlib. :param force: one of :data:`RENDERERS`, overriding the environment and the auto rule. :returns: ``('pyqtgraph'|'matplotlib', reason)``. The reason is never empty for matplotlib, because "why is this not the screen's renderer" is exactly the question a user asks of a figure that does not match a tab. """ choice = str(force).strip().lower() if force else requested_renderer() if choice not in RENDERERS: choice = "auto" if key not in FAST_PANELS: return "matplotlib", f"{key!r} has no interactive twin" if choice == "matplotlib": return "matplotlib", "matplotlib was asked for" if choice == "auto": return "matplotlib", ("no live plot was handed in, so there is no " "scene to render and no tab to disagree with") available, why = _pyqtgraph_ready(create=True) if not available: return "matplotlib", why return "pyqtgraph", ""
def _pyqtgraph_ready(create: bool = True) -> tuple: """``(ok, reason)``: can a scene be BUILT here and now? Reached only when pyqtgraph was explicitly asked for, which is what licenses starting a ``QApplication``: importing Qt costs ~1 s and pulls a GUI toolkit into a notebook, so it happens on request and never on a guess. """ global _APPLICATION application = qt_application() os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6") try: from ..qt.widgets.fast_plots import HAVE_PYQTGRAPH except Exception as error: # noqa: BLE001 return False, f"pyqtgraph plots are unavailable: {error}" if not HAVE_PYQTGRAPH: return False, "pyqtgraph is not installed" if application is None: try: from PySide6.QtWidgets import QApplication if not os.environ.get("DISPLAY") and not os.environ.get( "WAYLAND_DISPLAY"): os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") _APPLICATION = QApplication.instance() or QApplication([]) except Exception as error: # noqa: BLE001 return False, f"no QApplication could be started: {error}" return True, ""
[docs] def build_fast_plot(key: str, frame, *, alpha: float = 0.05): """A live ``FastPlot`` for ``key``, fed from ``frame``. THE COLUMNS ARE RESOLVED BY :mod:`spacr.figures.panels`, not here. A second opinion about which column is the effect and which rows are hypotheses is precisely the disagreement this module exists to end, and ``effect_column`` / ``p_column`` / ``q_column`` / ``tested`` are the generated side's single statement of it. :param key: generated-panel key with an entry in :data:`FAST_PANELS`. :param frame: coefficient table used to populate the interactive plot. :returns: the widget, or None when this table cannot support the panel. :raises KeyError: on a key with no interactive twin. """ from ..qt.widgets import fast_plots from .panels import effect_column, p_column, q_column, tested if key not in FAST_PANELS: raise KeyError(f"{key!r} has no pyqtgraph twin; known: " f"{', '.join(sorted(FAST_PANELS))}") if frame is None or not len(frame): return None effect = effect_column(frame) raw_p = p_column(frame) keys = (frame["feature"].astype(str).tolist() if "feature" in frame.columns else None) plot = getattr(fast_plots, FAST_PANELS[key])() if key == "volcano": if effect is None or raw_p is None: return None plot.set_results(frame, effect=effect, p_column=raw_p, alpha=alpha, q_column=q_column(frame), key_column="feature" if keys else None) elif key == "effect_rank": if effect is None: return None plot.set_results(frame, effect=effect, alpha=alpha, key_column="feature" if keys else None) elif key == "effect_distribution": if effect is None: return None rows = tested(frame) plot.set_effects(frame.loc[rows, effect], keys=_subset(keys, rows), untested=int(len(frame) - int(rows.sum()))) elif key in ("p_histogram", "qq"): if raw_p is None: return None rows = tested(frame) plot.set_p_values(frame.loc[rows, raw_p], keys=_subset(keys, rows)) elif key == "controls": groups, group_keys = _control_groups(frame, effect) if len(groups) < 2: return None plot.set_groups(groups, keys=group_keys or None) else: from ..guide_concordance import guide_support if effect is None or "feature" not in frame.columns: return None support = guide_support(frame, alpha=alpha) if support is None or not len(support): return None plot.set_support(support, keys=(support["feature"] if "feature" in support.columns else None)) return plot
def _subset(keys, rows): """``keys`` restricted to ``rows``, or None when there were none.""" if keys is None: return None return [key for key, keep in zip(keys, rows) if keep] def _control_groups(frame, effect) -> tuple: """``({label: values}, {label: keys})`` split by the fit's own labels. The label names match :func:`spacr.figures.panels.control_separation` exactly -- ``nc`` is "negative", ``pc`` is "positive" -- because a control called "negative" on screen and "nc" in the paper figure is the same disagreement in a smaller place. """ from .panels import _column condition = _column(frame, "condition", "control", "class") if effect is None or condition is None: return {}, {} names = {"nc": "negative", "pc": "positive", "control": "control", "other": "screen"} groups, keys = {}, {} for value, label in names.items(): rows = frame[frame[condition].astype(str) == value] if len(rows): groups[label] = rows[effect].to_numpy() if "feature" in rows.columns: keys[label] = rows["feature"].astype(str).tolist() return groups, keys
[docs] def render_panel(key: str, frame=None, path=None, *, plot=None, fmt: Optional[str] = None, renderer: Optional[str] = None, alpha: float = 0.05, announce: bool = True) -> RenderedPanel: """Write one generated panel, from the scene where there is one. :param key: a key of :data:`FAST_PANELS` (equivalently of :data:`spacr.figures.panels.SHEET_ORDER`). :param frame: the coefficient table. Needed only when no ``plot`` is given and for the matplotlib fallback. :param path: destination. Its extension is REPLACED by the figure-format preference unless ``fmt`` forces one, so the name always names what was actually written. :param plot: a live ``FastPlot`` to render. THIS IS THE POINT OF THE MODULE: given the widget the user is looking at, the file IS that widget rather than a second drawing of its data. :param renderer: force one of :data:`RENDERERS`. :param announce: put the file in the gallery as well as on disk . :returns: a :class:`RenderedPanel`, always. A panel that could not be drawn comes back with ``drawn=False`` and a reason rather than raising -- losing a fit over a picture is the worst trade here. """ from ..plot import figure_path chosen, why = renderer_for(key, renderer) if plot is not None: chosen, why = "pyqtgraph", "" destination = figure_path(path, fmt) if path else None if chosen == "pyqtgraph": rendered = _render_with_pyqtgraph(key, frame, destination, plot=plot, alpha=alpha, announce=announce) if rendered.drawn or plot is not None: return rendered why = rendered.reason or "the scene could not be built" return _render_with_matplotlib(key, frame, path, fmt=fmt, reason=why, announce=announce)
def _render_with_pyqtgraph(key, frame, destination, *, plot=None, alpha=0.05, announce=True) -> RenderedPanel: """Render the scene. Never raises; a failure comes back as a reason.""" owned = plot is None try: if owned: plot = build_fast_plot(key, frame, alpha=alpha) if plot is None: return RenderedPanel(key, renderer="pyqtgraph", drawn=False, reason="this table cannot support the panel") if destination is None: return RenderedPanel(key, renderer="pyqtgraph", drawn=False, reason="no destination was given") folder = os.path.dirname(str(destination)) if folder: os.makedirs(folder, exist_ok=True) written = plot.export(destination) or destination except Exception as error: # noqa: BLE001 return RenderedPanel(key, renderer="pyqtgraph", drawn=False, reason=f"{type(error).__name__}: {error}") finally: if owned and plot is not None: try: plot.deleteLater() except Exception: # noqa: BLE001 pass if announce: from ..figure_sink import publish_file publish_file(written, title=key) return RenderedPanel(key, path=str(written), renderer="pyqtgraph", drawn=True) def _render_with_matplotlib(key, frame, path, *, fmt=None, reason="", announce=True) -> RenderedPanel: """The house-style panel, published exactly as it always was.""" import matplotlib.pyplot as plt from ..figure_sink import publish from .sheet import build_panel if frame is None or not len(frame): return RenderedPanel(key, renderer="matplotlib", drawn=False, reason=reason or "no coefficients") try: figure, panel = build_panel(key, frame) except Exception as error: # noqa: BLE001 return RenderedPanel(key, renderer="matplotlib", drawn=False, reason=f"{type(error).__name__}: {error}") if not panel.drawn: plt.close(figure) return RenderedPanel(key, renderer="matplotlib", drawn=False, reason=panel.reason or reason) try: if announce: written = publish(figure, path, fmt=fmt, bbox_inches="tight") else: from ..plot import save_figure written = (save_figure(figure, path, fmt=fmt, bbox_inches="tight") if path is not None else None) finally: plt.close(figure) return RenderedPanel(key, path=(str(written) if written else None), renderer="matplotlib", drawn=True, reason=reason)
[docs] def write_panels(frame, dst, *, keys: Sequence[str] = SHEET_ORDER, plots=None, fmt: Optional[str] = None, renderer: Optional[str] = None, alpha: float = 0.05, verbose: bool = True) -> list: """Write every house-style panel into ``dst``. Returns the records. :param frame: coefficient/results table used to build panels that have no live plot and by any matplotlib fallback. :param dst: output directory, created when absent; each panel key becomes the destination file stem within it. :param plots: ``{key: live FastPlot}`` for the panels that are on screen. Anything absent is built from the frame. :param verbose: print one line naming the renderer that drew them, so a user who finds a figure that does not match a tab can see why in the run's log rather than by inspecting the file. """ plots = dict(plots or {}) folder = str(dst) os.makedirs(folder, exist_ok=True) chosen = renderer if chosen is None: chosen = ("pyqtgraph" if plots else renderer_for(keys[0] if keys else "volcano")[0]) records = [render_panel(key, frame, os.path.join(folder, key), plot=plots.get(key), fmt=fmt, renderer=chosen, alpha=alpha) for key in keys] if verbose: counts: dict = {} for record in records: if record.drawn: counts[record.renderer] = counts.get(record.renderer, 0) + 1 drawn = sum(counts.values()) summary = ", ".join(f"{count} by {name}" for name, count in sorted(counts.items())) print(f"[figures] {drawn}/{len(records)} regression panel(s) written " f"to {folder}" + (f" ({summary})" if summary else "")) for record in records: if not record.drawn and record.reason: print(f"[figures] {record.key} not drawn: {record.reason}") return records
__all__ = ["FAST_PANELS", "RENDERERS", "RenderedPanel", "build_fast_plot", "qt_application", "render_panel", "renderer_for", "requested_renderer", "write_panels"]