Source code for spacr.figures.sheet

"""The whole regression as one publication-ready figure.

Not a gallery of separate pictures at separate sizes. One sheet, laid out the
way a journal figure is: 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 -- which is the
only hierarchy cue the published figures use.

READING ORDER IS THE ARGUMENT, and it is why the panel order is fixed rather
than alphabetical or whatever the dict happened to hold:

    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

A reader who stops after B has the result. One who reads to G knows whether
to believe it. A sheet ordered any other way asks them to take the result on
trust and audit it afterwards, which is not how anyone reads a figure.
"""

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 rather than written twice: a legend maintained by hand beside the code that draws the figure is a legend that describes last month's 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 NAMED, never drawn as an empty frame. A blank box in a figure sheet reads as a panel that failed, which is worse than a gap and much worse than a sentence saying why. """ 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: """Hang a panel's name, data and groups on its figure. THE FIGURE HAS TO CARRY THEM because that is all the export sees. 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"]