"""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 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]:
"""Return the smallest standard plate shape containing every measured well.
Pivoting only the wells that carry data drops entirely unused columns, so a
screen that never used columns 1-3 (such as the tsg101 screen) would draw
its first measured column against the left spine and shift every edge
effect by the missing positions.
: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
reverses 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]:
"""Return one ``(low, high)`` colour range for all plates' measured wells.
Plate heatmaps are read by comparing plates; independent scales would give
one colour a different value on each plate and hide batch effects.
: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
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 together.
: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``. To
draw one plate per figure on a common scale, compute
:func:`shared_limits` over every plate once and pass the same pair to
each figure.
: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"]