Source code for spacr.figures.sheet

"""Lay out the regression results as one multi-panel figure.

The sheet is laid out like a journal figure: 6-12 panels, bold upper-case
letters top-left, reading order matching the argument, related panels adjacent
and sharing scales, and more white space between groups than within them.

The panel order is fixed and follows the reading order:

    A  volcano                what the screen found
    B  strongest effects      which genes, and how sure
    C  effect distribution    what the effects look like as a whole
    D  control separation     whether the assay worked at all
    E  guide agreement        whether the calls are corroborated
    F  p-value distribution   whether the correction means anything
    G  q-q                    whether the model was entitled to say it

Panels A and B give the result; panels C to G show whether it can be trusted.
"""

from __future__ import annotations

import string
from dataclasses import dataclass
from typing import List, Optional, Sequence

from .panels import REGISTRY, SHEET_ORDER, Panel
from .style import figure_style, hide_unused, panel_letter, theme_target

#: Single column, double column and full page, in inches. The published
#: figures are built to these and nothing else.
WIDTHS = {"single": 3.4, "double": 7.0, "full": 9.5}

#: A panel's height when it is one grid cell. Keeps the aspect near the
#: square the dense panels want without squashing the wide ones.
CELL_HEIGHT = 2.05


@dataclass
[docs] class Sheet: """A rendered sheet and everything needed to write its legend. :param figure: Matplotlib figure containing the rendered multi-panel grid. :param panels: successfully rendered panel records in figure-reading order; their sequence supplies legend letters and captions. :param skipped: panel records omitted from the grid, retaining their titles and failure reasons for the legend. """ figure: object panels: List[Panel] skipped: List[Panel]
[docs] def legend(self) -> str: """The figure legend, panel by panel. Generated from the panels themselves, so it matches the drawn figure. """ lines = [] for letter, panel in zip(string.ascii_uppercase, self.panels): if panel.caption: lines.append(f"({letter}) {panel.caption}") if self.skipped: lines.append( "Not shown: " + "; ".join(f"{p.title} ({p.reason})" for p in self.skipped) + ".") return " ".join(lines)
def _grid(count: int, width: str) -> tuple: """Rows and columns for ``count`` panels at this width. Two columns at single width, three at double, four at full -- the proportions used by the published figures. The column cap keeps each panel large enough to read. """ columns = {"single": 2, "double": 3, "full": 4}[width] columns = min(columns, max(count, 1)) rows = -(-count // columns) return rows, columns
[docs] def build_sheet(frame, *, width: str = "double", target: Optional[str] = None, order: Sequence[str] = SHEET_ORDER, alpha: float = 0.05, effect_threshold: Optional[float] = None, highlight: Optional[str] = None) -> Sheet: """Draw every panel this table supports, as one figure. :param frame: the coefficient table. :param width: ``'single'``, ``'double'`` or ``'full'``. :param target: ``'screen'`` or ``'print'``; defaults to the user's own figure preference. :param highlight: a gene to ring on the volcano, so the sheet can follow the selection in the GUI. :returns: a :class:`Sheet`. Panels whose data is absent are skipped and listed with the reason, never drawn as an empty frame. """ import matplotlib.pyplot as plt target = target or theme_target() with figure_style(target): scratch = plt.figure() supported = [] try: for key in order: ax = scratch.add_subplot(111) try: result = REGISTRY[key](ax, frame) except Exception as error: # noqa: BLE001 result = Panel(key, key, drawn=False, reason=str(error)) if result.drawn: supported.append(key) scratch.clear() finally: plt.close(scratch) rows, columns = _grid(len(supported), width) figure = plt.figure(figsize=(WIDTHS[width], max(rows, 1) * CELL_HEIGHT)) axes = figure.subplots(max(rows, 1), columns, squeeze=False).ravel() from .bundle import _register_figure_data drawn: List[Panel] = [] skipped: List[Panel] = [] for index, key in enumerate(order): if key not in supported: ax = plt.figure().add_subplot(111) try: skipped.append(REGISTRY[key](ax, frame)) except Exception as error: # noqa: BLE001 skipped.append(Panel(key, key, drawn=False, reason=str(error))) plt.close(ax.figure) continue ax = axes[len(drawn)] kwargs = {} if key == "volcano": kwargs = dict(alpha=alpha, effect_threshold=effect_threshold, highlight=highlight) panel = REGISTRY[key](ax, frame, **kwargs) panel_letter(ax, string.ascii_uppercase[len(drawn)]) drawn.append(panel) hide_unused(axes[len(drawn):]) figure.subplots_adjust(left=.09, right=.98, top=.93, bottom=.09, wspace=.42, hspace=.52) recipes = [dict(slot=index, regression=panel.key, regression_options=dict(alpha=alpha, effect_threshold=effect_threshold, highlight=highlight) if panel.key == "volcano" else {}, measurement=panel.title, letter=string.ascii_uppercase[index], rect=list(axes[index].get_position().bounds)) for index, panel in enumerate(drawn)] if recipes: _register_figure_data(figure, frame, kind="regression_panel", grid=[rows, columns], panels=recipes) else: _register_figure_data(figure, frame, kind="sheet") return Sheet(figure=figure, panels=drawn, skipped=skipped)
[docs] def build_panel(key: str, frame, *, target: Optional[str] = None, figsize=(3.4, 2.6), **kwargs): """One panel on its own figure, for the grid view and for saving. :param key: panel name from :data:`spacr.figures.panels.REGISTRY`. :param frame: coefficient table consumed by the selected panel. :returns: ``(figure, Panel)``. """ import matplotlib.pyplot as plt with figure_style(target or theme_target(), kind=key): figure = plt.figure(figsize=figsize) ax = figure.add_subplot(111) panel = REGISTRY[key](ax, frame, **kwargs) from .bundle import _register_figure_data figure.subplots_adjust(left=.16, right=.97, top=.92, bottom=.16) attach(figure, panel) recipe = dict(regression=key, regression_options=dict(kwargs), rect=list(ax.get_position().bounds)) if kwargs.get("compartment"): from ..localisation import table recipe["localisations"] = dict(table()) _register_figure_data(figure, frame, kind="regression_panel", **recipe) figure._spacr_drawn_data = panel.data return figure, panel
[docs] def attach(figure, panel) -> None: """Attach a panel's name, data and groups to its figure. The figure carries them because the export sees only the figure. A user right-clicks a picture in the queue and asks to save it; nothing at that point knows which frame it came from or what was compared, unless the figure itself does. Private attributes on a matplotlib Figure rather than a wrapper object, because the figure is handed through the Qt bridge, the queue, a spill file and back, and a wrapper would be lost at the first of those. :param figure: Matplotlib figure that will carry the export metadata. :param panel: panel metadata to attach; ``None`` leaves the figure unchanged. """ if panel is None: return figure.set_label(panel.title or panel.key) figure._spacr_title = panel.title or panel.key figure._spacr_caption = panel.caption if getattr(panel, "data", None) is not None: from .bundle import _register_figure_data _register_figure_data(figure, panel.data, kind=panel.key, title=panel.title or panel.key) if getattr(panel, "groups", None): figure._spacr_groups = panel.groups
__all__ = ["CELL_HEIGHT", "Sheet", "WIDTHS", "attach", "build_panel", "build_sheet"]