Source code for spacr.figures.plates

"""Small-multiple heatmaps for plate-layout measurements.

All plates for one measurement share a color scale and are arranged in a
compact grid with square wells. Missing wells remain distinct from measured
zeros and are excluded from color-limit estimation. Styling is scoped through
:func:`spacr.figures.style.figure_style` so drawing a plate does not change
global Matplotlib settings.
"""

from __future__ import annotations

import math
from typing import List, Optional, Sequence, Tuple

import numpy as np

from .panels import Panel
from .style import (TYPE_SCALE, WEIGHTS, Palette, figure_style, resolve_ink,
                    theme_target)

#: The composite the small multiple aims at. Slightly wider than square
#: because the colour bar takes a strip along the bottom; a tile in the
#: figure grid is square-ish, so the closer the composite is to 1 the more
#: of the tile the plates get.
TARGET_ASPECT = 1.2

#: Figure width in inches. The double-column measure the sheet uses, so a
#: plate panel and a regression sheet are the same width on a page.
WIDTH = 7.0

#: Margins, in inches, measured from what the type actually needs: the row
#: letters on the left, the column numbers and the colour bar below, the
#: plate name above.
#: The gaps are small because the labels are SHARED: only the left column
#: carries row letters and only the bottom row carries column numbers, so
#: nothing has to fit between two plates except the lower one's name. A
#: small multiple should read as one block, not as four pictures that
#: happen to be near each other.
MARGIN = {"left": 0.28, "right": 0.10, "top": 0.20, "bottom": 0.58,
          "wspace": 0.18, "hspace": 0.22}

#: Where the shared colour bar sits, in inches from the bottom of the
#: figure, and how thick it is. Below it go its own tick labels and the
#: measurement's name, which is why it is not at the very bottom.
BAR = {"bottom": 0.24, "height": 0.075, "width": 2.4}

#: How dark the "no measurement here" wash is, as a fraction of the ink.
#: Neutral, so an empty well differs from a measured one in HUE and not
#: only in lightness -- a difference that survives a bad monitor and a
#: greyscale print, which a lightness-only difference does not.
EMPTY_WASH_ALPHA = 0.09



[docs] def plate_ramp(target: str = "screen"): """Create the sequential blue color map used for plate measurements. Parameters ---------- target : str, default="screen" ``"print"`` selects the print ramp. Every other value selects the screen ramp, which avoids the darkest print color so high values remain distinct from a dark interface background. Returns ------- matplotlib.colors.Colormap Color map with a transparent bad-value color for unmeasured wells. """ from matplotlib.colors import LinearSegmentedColormap if target == "print": stops = ["#F7FAFD", Palette.BLUE_LIGHT, Palette.BLUE, Palette.NAVY] else: stops = ["#E8EDEE", Palette.BLUE_LIGHT, Palette.BLUE] ramp = LinearSegmentedColormap.from_list(f"spacr_plate_{target}", stops) ramp.set_bad("none") return ramp
[docs] def score_ramp(target: str = "screen"): """Create the diverging color map used for signed plate scores. A hit score (SSMD, robust z, B-score) is signed about the negative control, which is what a diverging map is for. The ends are the house ``down`` and ``up`` colours (:data:`spacr.figures.style.ROLES`), so a well scored below the control reads rust and one above it green, the same as on a volcano. :param target: ``"print"`` centres on a near-white; every other value on the light grey of the screen plate ramp. :returns: A color map with a transparent bad-value color. """ from matplotlib.colors import LinearSegmentedColormap middle = "#F7F7F7" if target == "print" else "#E8EDEE" ramp = LinearSegmentedColormap.from_list( f"spacr_score_{target}", [Palette.RUST, Palette.CORAL, middle, "#8FC2A2", Palette.GREEN]) ramp.set_bad("none") return ramp
[docs] def plate_names(frame) -> List[str]: """Return distinct nonempty plate identifiers in first-occurrence order. ``prc`` keys are parsed from the right: the final tokens are row and column, while every preceding token belongs to the plate identifier. This preserves plate identifiers that contain underscores. Screens whose keys are the plain three-token form -- which is every one this module has been run on -- are unaffected: the plate is still the first token because there is nothing in front of it. :param frame: long-format table whose ``prc`` values identify wells. :returns: Plate identifiers parsed from the right. Keys with fewer than three underscore-delimited tokens are retained verbatim. """ if "prc" not in getattr(frame, "columns", ()): return [] seen, order = set(), [] for key in frame["prc"].astype(str): parts = key.split("_") if len(parts) < 3: name = key else: name = "_".join(parts[:-2]) if name and name not in seen: seen.add(name) order.append(name) return order
[docs] def full_plate_grid(rows: Sequence[int], columns: Sequence[int]) -> Tuple[int, int]: """The plate the measured wells sit on, not the box that bounds them. THE EDGE HAS TO BE THE EDGE. Pivoting only the wells that carry data drops an entirely unused column, so a screen that never used columns 1-3 -- which the tsg101 screen does not -- drew its first measured column hard against the left spine. Every edge effect then reads one plate position out, and an edge effect is the artefact a plate heatmap exists to show. :param rows: measured 1-based plate row indices. :param columns: measured 1-based plate column indices. :returns: ``(n_rows, n_columns)`` of the smallest standard format that contains every measured well, or the bounding box when the wells fit no standard plate (a partial or non-standard layout). """ from .. import schema as _schema if not len(rows) or not len(columns): return 0, 0 top, right = int(max(rows)), int(max(columns)) fmt = _schema.plate_format_for(top, right) if fmt is None: return top, right n_rows, n_columns = _schema.PLATE_FORMATS[fmt] return int(n_rows), int(n_columns)
[docs] def well_matrices(frame, variable: str, *, grouping: str = "mean", min_count=0, plates: Optional[Sequence[str]] = None): """One matrix per plate, on a shared grid, with absent wells as ``nan``. Wraps :func:`spacr.plot.generate_plate_heatmap` -- which is where the prc parsing, the letter walk past row P and the min_count filter live -- and undoes the one thing it does that a picture must not inherit: its ``.fillna(0)``. A well with no rows and a well that measured zero are the same cell afterwards, so the count map is fetched alongside the value map and a well with a count of zero is masked back out. A well with rows but nothing numeric in any of them is masked too. The count map counts ROWS, and the aggregation coerces the variable with ``errors='coerce'``, so such a well aggregates to NaN and is filled with the same invented zero one step further in. :param frame: long-format frame with a ``prc`` column. :param variable: the measurement column to aggregate. :param grouping: ``'mean'``, ``'sum'`` or ``'count'``. :param min_count: wells with fewer rows than this are dropped, and then read as absent rather than as zero. :param plates: return only these plates, in this order. :returns: ``(names, matrices, (n_rows, n_columns))`` with equally many names and matrices on the smallest fitting standard plate grid. Absent or unreadable wells and wells below ``min_count`` are ``nan``. """ import pandas as pd from ..plot import generate_plate_heatmap from .. import schema as _schema names = list(plates) if plates is not None else plate_names(frame) if not names: return [], [], (0, 0) wanted = ["prc"] + ([variable] if variable in frame.columns else []) work = frame.loc[:, wanted].copy() text = work["prc"].astype(str) head = None if text.str.count(_schema.KEY_SEPARATOR).ge(2).all(): head = text.str.rsplit(_schema.KEY_SEPARATOR, n=2).str[0].to_numpy() readable = None if grouping != "count" and variable in work.columns: numeric = pd.to_numeric(work[variable], errors="coerce").notna().to_numpy() if not numeric.all(): readable = numeric maps, counts = [], [] for name in names: on_plate = None if head is None else head == str(name) subset = work if on_plate is None else work[on_plate].copy() values, _limits = generate_plate_heatmap( subset, name, variable, grouping, "all", min_count) if grouping == "count": present = values else: present = generate_plate_heatmap( subset, name, variable, "count", "all", min_count)[0] if readable is not None: present = _drop_unreadable_wells( generate_plate_heatmap, present, subset, readable if on_plate is None else readable[on_plate], name, variable) maps.append(values) counts.append(present) rows = sorted({index for m in maps for index in (_schema.row_index(label) for label in m.index) if index is not None}) columns = sorted({index for m in maps for index in (_schema.column_index(label) for label in m.columns) if index is not None}) n_rows, n_columns = full_plate_grid(rows, columns) if not n_rows or not n_columns: return names, [], (0, 0) row_ids = [_schema.row_id(i) for i in range(1, n_rows + 1)] column_ids = [_schema.column_id(i) for i in range(1, n_columns + 1)] matrices = [] for values, present in zip(maps, counts): grid = values.reindex(index=row_ids, columns=column_ids) seen = present.reindex(index=row_ids, columns=column_ids) block = grid.to_numpy(dtype="float64", copy=True) empty = ~(seen.to_numpy(dtype="float64") > 0) block[empty] = np.nan matrices.append(block) return names, matrices, (n_rows, n_columns)
def _drop_unreadable_wells(heatmap, present, subset, readable, name, variable): """Zero the presence of a well that has rows but no number in any of them. ``min_count`` KEEPS ITS MEANING -- it counts rows, as it always has, so the readable-row map is taken at ``min_count`` 0 and used only to knock out the wells that have nothing numeric at all. A well that is half unreadable is still the mean of the half that is readable, which is what ``generate_plate_heatmap`` computes. :param heatmap: plate-aggregation callable used to count readable rows. :param present: per-well row-count frame produced before numeric filtering. :param subset: rows belonging to the plate being processed. :param readable: boolean row mask, aligned with ``subset``, identifying values that can be interpreted as numbers. :param name: plate identifier passed to ``heatmap``. :param variable: measurement column passed to ``heatmap``. :returns: A presence frame aligned with ``present``, with zero only where no readable numeric row exists. """ measured = subset[readable].copy() if not len(measured): return present * 0 numbers = heatmap(measured, name, variable, "count", "all", 0)[0] numbers = numbers.reindex(index=present.index, columns=present.columns, fill_value=0) return present.where(numbers.to_numpy() > 0, 0)
[docs] def shared_limits(matrices: Sequence[np.ndarray], min_max="allq" ) -> Tuple[float, float]: """One colour scale for every plate, over the wells that exist. SHARED IS THE POINT. A plate heatmap is read by comparing plates; four independent scales make the same colour mean four different numbers and turn a batch effect into an invisible one. :param matrices: per-plate value matrices; non-finite wells are excluded from the shared scale. :param min_max: A two-float sequence selects those quantiles; a two-item sequence containing a non-float supplies absolute endpoints. ``'allq'`` selects the 2nd and 98th percentiles, while every other value selects the finite extrema. :returns: ``(low, high)`` shared by every matrix. An empty finite pool returns ``(0.0, 1.0)``; equal endpoints are widened by ``1e-6``. """ pool = np.concatenate([m.ravel() for m in matrices]) if matrices \ else np.array([], dtype="float64") pool = pool[np.isfinite(pool)] if not pool.size: return 0.0, 1.0 if isinstance(min_max, (list, tuple)) and len(min_max) == 2: if all(isinstance(value, float) for value in min_max): low, high = np.quantile(pool, [min_max[0], min_max[1]]) else: low, high = float(min_max[0]), float(min_max[1]) elif min_max == "allq": low, high = np.quantile(pool, [0.02, 0.98]) else: low, high = float(np.nanmin(pool)), float(np.nanmax(pool)) low, high = float(low), float(high) if low == high: high = low + 1e-6 return low, high
[docs] def small_multiple_layout(count: int, plate_aspect: float, target: float = TARGET_ASPECT) -> Tuple[int, int]: """Rows and columns of plates that put the composite nearest square. Four plates in a row is 4 x 1.5 = a 6:1 composite; four in a 2 x 2 is 1.5:1. Both hold the same picture; only one of them fills a tile. :param count: number of plates to place. A non-positive count has no grid and returns ``(0, 0)``. :param plate_aspect: one plate's width over its height, in wells. For a positive ``count``, this and ``target`` must be positive; unsupported nonpositive ratios may propagate arithmetic domain or division errors. :param target: the positive composite width-over-height to aim at. :returns: ``(rows, columns)``. """ if count <= 0: return 0, 0 best, choice = None, (1, count) for columns in range(1, count + 1): rows = -(-count // columns) aspect = (columns * plate_aspect) / rows penalty = abs(math.log(aspect / target)) if best is None or penalty < best - 1e-12: best, choice = penalty, (rows, columns) return choice
[docs] def plate_figure_name(variable: str, prefix: str = "plate_heatmap", suffix: str = ".pdf") -> str: """The file a plate panel is written to: named for what it draws. NOT :func:`spacr.schema.escape_filename_component`, which escapes the key separator -- ``log_pred`` would be written as ``log%5Fpred``. This is a file name and not a key: an underscore is exactly what belongs in it. Unicode alphanumerics and ``-_.`` in ``variable`` are retained; every other character is replaced with an underscore. :param variable: measurement name to include in the file name. :param prefix: filename prefix, used verbatim. :param suffix: filename suffix, including any extension, used verbatim. :returns: ``prefix``, a sanitized nonempty variable component, and ``suffix`` joined into one filename. """ text = "".join(character if character.isalnum() or character in "-_." else "_" for character in str(variable).strip()) return f"{prefix}_{text or 'value'}{suffix}"
def _tick_step(n: int) -> int: """Label every well, every other one, or every fourth. Twenty-four column numbers under a plate that is two inches wide at 6.2 pt is a solid line of digits. The step is chosen from the count rather than the width because the width is derived from the count. :param n: number of row or column labels available. :returns: ``1`` through 12 labels, ``2`` through 26, otherwise ``4``. """ if n <= 12: return 1 if n <= 26: return 2 return 4
[docs] def draw_plate(ax, matrix: np.ndarray, *, vmin: float, vmax: float, cmap, ink: str, name: str = "", row_labels=None, column_labels=None) -> None: """One plate into one axes, with square wells and no gridlines. :param ax: matplotlib axes that receives the wash, image, ticks and title. :param matrix: ``(n_rows, n_columns)``, ``nan`` where no well was measured. :param vmin: lower endpoint shared by the plate's colour normalisation. :param vmax: upper endpoint shared by the plate's colour normalisation. :param cmap: matplotlib colormap (or registered colormap name) used for measured wells. :param ink: colour used for ticks, labels, spines and the plate wash. :param name: optional plate title; false values leave the title unset. :param row_labels: ``True`` to draw the row letters, ``False`` to leave the axis bare (an inner plate of the small multiple shares the outer one's). :param column_labels: truthy to draw column numbers; false values leave the axis bare when an outer plate already supplies them. :returns: ``None``; artists are added to ``ax`` in place. """ from matplotlib.patches import Rectangle n_rows, n_columns = matrix.shape ax.add_patch(Rectangle((0, 0), n_columns, n_rows, facecolor=_wash(ink), edgecolor="none", zorder=0)) ax.imshow(np.ma.masked_invalid(matrix), cmap=cmap, vmin=vmin, vmax=vmax, origin="upper", extent=(0, n_columns, n_rows, 0), interpolation="nearest", aspect="equal", zorder=1) from .. import schema as _schema row_step, column_step = _tick_step(n_rows), _tick_step(n_columns) ax.set_yticks([i + 0.5 for i in range(0, n_rows, row_step)]) ax.set_xticks([i + 0.5 for i in range(0, n_columns, column_step)]) ax.set_yticklabels( [_schema.letters_from_row_index(i + 1) for i in range(0, n_rows, row_step)] if row_labels else []) ax.set_xticklabels( [str(i + 1) for i in range(0, n_columns, column_step)] if column_labels else []) ax.tick_params(length=1.6, width=WEIGHTS["spine"], pad=1.4, colors=ink, labelsize=TYPE_SCALE["tick"]) for spine in ax.spines.values(): spine.set_linewidth(WEIGHTS["spine"]) spine.set_color(ink) if name: ax.set_title(name, fontsize=TYPE_SCALE["annotation"], pad=2.0, color=ink)
def _wash(ink: str) -> tuple: """Return the translucent wash for a well that was never measured. :param ink: colour to convert to RGBA. :returns: RGBA tuple whose alpha is :data:`EMPTY_WASH_ALPHA`. """ from matplotlib.colors import to_rgba return to_rgba(ink, EMPTY_WASH_ALPHA)
[docs] def build_plates(frame, variable: str, *, grouping: str = "mean", min_max="allq", min_count=0, cmap=None, target: Optional[str] = None, width: float = WIDTH, plates: Optional[Sequence[str]] = None, limits: Optional[Tuple[float, float]] = None, outline: Optional[str] = None): """Every plate of a screen as one figure, on one colour scale. :param frame: long-format frame with a ``prc`` column and ``variable``. :param variable: the measurement to aggregate per well. :param grouping: ``'mean'``, ``'sum'`` or ``'count'``. :param min_count: minimum number of rows required for a well to be drawn. :param min_max: colour-scale spec, as :func:`spacr.plot.generate_plate_heatmap` defines it -- but applied ONCE, over every plate at the same time. :param cmap: a colormap to override the house ramp. ``None`` uses :func:`plate_ramp`, which is what the style asks for. :param target: ``'screen'`` or ``'print'``; defaults to the user's own figure preference. :param width: figure width in inches. The HEIGHT is derived from it, so that the wells come out square. :param plates: draw only these plates, in this order. :param limits: an explicit ``(vmin, vmax)``, overriding ``min_max``. THIS IS WHAT MAKES ONE-PLATE-PER-FIGURE SAFE: a caller that wants a plate to a tile computes :func:`shared_limits` over every plate once and passes the same pair to each figure, so splitting the small multiple up does not silently give each plate its own scale again. :param outline: a column of ``frame``; every well whose mean of it is above zero gets a square outline in the ink colour. A hit call drawn on the score it was called from, without a second figure. :returns: ``(figure, Panel)``. With no matrix the panel has ``drawn=False`` plus its missing requirements and reason. Otherwise its caption records the actual aggregation, well counts, and shared limits. """ import matplotlib.pyplot as plt target = target or theme_target() ink = resolve_ink(target) with figure_style(target, frame="box"): names, matrices, (n_rows, n_columns) = well_matrices( frame, variable, grouping=grouping, min_count=min_count, plates=plates) if not matrices: figure = plt.figure(figsize=(width, width / TARGET_ASPECT)) from .bundle import _register_figure_data _register_figure_data(figure, frame, y=str(variable), kind="heatmap") return figure, Panel( "plates", "plate heatmaps", drawn=False, reason=(f"no plate in this table carries a well grid for " f"{variable!r}"), needs=("prc", variable)) measured = [int(np.isfinite(m).sum()) for m in matrices] vmin, vmax = (float(limits[0]), float(limits[1])) if limits \ else shared_limits(matrices, min_max) marks = [None] * len(matrices) if outline and outline in frame.columns: _marked, outlined, _shape = well_matrices( frame, outline, grouping="mean", min_count=min_count, plates=names) marks = [m if m.shape == matrix.shape else None for m, matrix in zip(outlined, matrices)] rows, columns = small_multiple_layout( len(matrices), n_columns / max(n_rows, 1)) cell_w = (width - MARGIN["left"] - MARGIN["right"] - (columns - 1) * MARGIN["wspace"]) / columns cell_h = cell_w * n_rows / n_columns height = (MARGIN["top"] + MARGIN["bottom"] + rows * cell_h + (rows - 1) * MARGIN["hspace"]) figure = plt.figure(figsize=(width, height)) from .bundle import _register_figure_data ramp = plate_ramp(target) if cmap is None else _named(cmap) image = None for index, (name, matrix) in enumerate(zip(names, matrices)): row, column = divmod(index, columns) left = (MARGIN["left"] + column * (cell_w + MARGIN["wspace"])) / width bottom = (MARGIN["bottom"] + (rows - row - 1) * (cell_h + MARGIN["hspace"])) / height ax = figure.add_axes([left, bottom, cell_w / width, cell_h / height]) draw_plate(ax, matrix, vmin=vmin, vmax=vmax, cmap=ramp, ink=ink, name=str(name), row_labels=column == 0, column_labels=row == rows - 1 or index + columns >= len(matrices)) if marks[index] is not None: _outline_wells(ax, marks[index], ink) if image is None: image = ax.images[0] _colour_bar(figure, image, variable, ink, width, height) from .. import schema as _schema recipes = [] for index, (name, ax) in enumerate(zip(names, figure.axes[:-1])): coordinates = {} for key in frame["prc"].astype(str).unique(): parts = key.rsplit("_", 2) if len(parts) == 3 and parts[0] == str(name): row, column = _schema.row_index(parts[1]), _schema.column_index(parts[2]) if row is not None and column is not None: coordinates[key] = [row, column] recipes.append(dict(slot=index, rect=list(ax.get_position().bounds), y=str(variable), measurement=f"{variable} ({name})", plate=dict( name=str(name), variable=str(variable), grouping=str(grouping), min_count=min_count if isinstance(min_count, (int, float)) else 0, coordinates=coordinates, shape=[n_rows, n_columns], limits=[vmin, vmax], colors=ramp(np.linspace(0, 1, ramp.N)).tolist(), bad_color=ramp.get_bad().tolist(), under_color=ramp.get_under().tolist(), over_color=ramp.get_over().tolist(), ink=ink, wash_alpha=EMPTY_WASH_ALPHA, linewidth=WEIGHTS["spine"], tick_fontsize=TYPE_SCALE["tick"], title_fontsize=TYPE_SCALE["annotation"], xticks=ax.get_xticks().tolist(), yticks=ax.get_yticks().tolist(), xticklabels=[tick.get_text() for tick in ax.get_xticklabels()], yticklabels=[tick.get_text() for tick in ax.get_yticklabels()], outline=str(outline) if outline and outline in frame.columns else "", outline_linewidth=WEIGHTS["data"] * 0.6))) _register_figure_data(figure, frame, y=str(variable), kind="plate_heatmap", grid=[rows, columns], panels=recipes, plates=[str(name) for name in names], plate_colorbar=dict(rect=list(figure.axes[-1].get_position().bounds), ticks=[vmin, vmax], ink=ink, linewidth=WEIGHTS["spine"], fontsize=TYPE_SCALE["annotation"], text_position=[0.5, (BAR["bottom"] - 0.022) / height], text=str(variable).replace("_", " ").lower())) blank = sum(m.size for m in matrices) - sum(measured) subject = ("objects per well, counted" if grouping == "count" else f"{variable} per well, " + ("summed" if grouping == "sum" else "averaged") + " over the objects in it") plural = "" if len(matrices) == 1 else "s" return figure, Panel( "plates", "plate heatmaps", caption=( f"{subject}, for {len(matrices)} plate{plural} of " f"{n_rows}x{n_columns} wells. All plates share one colour " f"scale ({vmin:.3g} to {vmax:.3g}) so the same colour is the " f"same number on every plate. {sum(measured)} wells were " f"measured; the {blank} that were not are left as a neutral " f"wash and are excluded from the scale." + (f" {sum(int(np.nansum(m > 0)) for m in marks if m is not None)}" f" well(s) marked by {outline} are outlined." if outline else "")), needs=("prc", variable))
def _outline_wells(ax, marks: np.ndarray, ink: str) -> None: """Draw a square outline round every well whose mark is above zero. :param ax: the plate's axes, in well units as :func:`draw_plate` sets it. :param marks: per-well matrix; NaN and non-positive wells are unmarked. :param ink: outline colour. :returns: ``None``; patches are added to ``ax``. """ from matplotlib.patches import Rectangle inset = 0.1 for r, c in zip(*np.nonzero(np.nan_to_num(marks) > 0)): ax.add_patch(Rectangle((c + inset, r + inset), 1 - 2 * inset, 1 - 2 * inset, fill=False, edgecolor=ink, linewidth=WEIGHTS["data"] * 0.6, zorder=3)) def _named(cmap): """Return a detached colormap with transparent missing values. :param cmap: registered colormap name or colormap object. :returns: A copy whose bad-value colour is transparent. The caller's colormap object is not mutated. """ if isinstance(cmap, str): from matplotlib import colormaps cmap = colormaps[cmap] cmap = cmap.copy() cmap.set_bad("none") return cmap def _colour_bar(figure, image, variable: str, ink: str, width: float, height: float) -> None: """One thin horizontal bar under the whole small multiple. One, because there is one scale. Horizontal and low, because the composite is wider than it is tall and a bar down the right side would steal a plate's width. :param figure: figure receiving the colour-bar axes and variable label. :param image: plotted image carrying the shared colour normalization. :param variable: measurement name rendered below the bar. :param ink: colour used for the outline, ticks, and label. :param width: figure width in inches. :param height: figure height in inches. :returns: ``None``; one axes and one text artist are added to ``figure``. """ bar_w = min(BAR["width"], width * 0.42) cax = figure.add_axes([(width - bar_w) / 2 / width, BAR["bottom"] / height, bar_w / width, BAR["height"] / height]) cax.set_label("<colorbar>") bar = figure.colorbar(image, cax=cax, orientation="horizontal") bar.outline.set_linewidth(WEIGHTS["spine"]) bar.outline.set_edgecolor(ink) cax.tick_params(length=1.6, width=WEIGHTS["spine"], pad=1.2, labelsize=TYPE_SCALE["annotation"], colors=ink) bar.set_ticks([image.norm.vmin, image.norm.vmax]) cax.set_xticklabels([f"{image.norm.vmin:.3g}", f"{image.norm.vmax:.3g}"]) figure.text(0.5, (BAR["bottom"] - 0.022) / height, str(variable).replace("_", " ").lower(), ha="center", va="top", color=ink, fontsize=TYPE_SCALE["annotation"]) __all__ = ["BAR", "EMPTY_WASH_ALPHA", "MARGIN", "TARGET_ASPECT", "WIDTH", "build_plates", "draw_plate", "full_plate_grid", "plate_figure_name", "plate_names", "plate_ramp", "score_ramp", "shared_limits", "small_multiple_layout", "well_matrices"]