Source code for wellmap.plot

#!/usr/bin/env python3

"""\
Visualize the plate layout described by a wellmap TOML file.

Usage:
    wellmap <toml> [<attr>...] [-o <path>] [-p] [-c <color>] [-f]

Arguments:
    <toml>
        TOML file describing the plate layout to display.  For a complete 
        description of the file format, refer to:
        
        https://wellmap.readthedocs.io/en/latest/file_format.html

    <attr>
        The name(s) of one or more attributes from the above TOML file to 
        project onto the plate.  For example, if the TOML file contains 
        something equivalent to `well.A1.conc = 1`, then "conc" would be a 
        valid attribute.

        If no attributes are specified, the default is to display any 
        attributes that have at least two different values.  For complex 
        layouts, this may result in a figure too big to fit on the screen.
        The best solution for this is just to specify a smaller number of 
        attributes to focus on.

Options:
    -o --output PATH
        Output an image of the layout to the given path.  The file type is 
        inferred from the file extension.  If the path contains a dollar sign 
        (e.g. '$.svg'), the dollar sign will be replaced with the base name of 
        the <toml> path.

    -p --print
        Print a paper copy of the layout, e.g. to reference when setting up an 
        experiment.  The default printer for the system will be used.  To see 
        the current default printer, run: `lpstat -d`.  To change the default 
        printer, run: `lpoptions -d <printer name>`.  When printing, the 
        default color scheme is changed to 'dimgray'.  This can still be 
        overridden using the '--color' flag.

    -c --color NAME
        Use the given color scheme to illustrate which wells have which 
        properties.  The given NAME must be one of the color scheme names 
        understood by either `matplotlib` or `colorcet`.  See the links below 
        for the full list of supported colors, but some common choices are 
        given below.  The default is 'rainbow':

        rainbow:  blue, green, yellow, orange, red
        viridis:  purple, green, yellow
        plasma:   purple, red, yellow
        coolwarm: blue, red
        tab10:    blue, orange, green, red, purple, ...
        dimgray:  gray, black

        Matplotlib colors:
        https://matplotlib.org/examples/color/colormaps_reference.html

        Colorcet colors:
        http://colorcet.pyviz.org/

    -f --foreground
        Don't attempt to return the terminal to the user while the GUI runs.  
        This is meant to be used on systems where the program crashes if run in 
        the background.
"""

import wellmap
import colorcet
import numpy as np
import matplotlib.pyplot as plt
import sys, os, shlex

from wellmap import LayoutError
from inform import plural
from matplotlib.colors import BoundaryNorm, Normalize
from pathlib import Path
from .util import *

CELL_SIZE = 0.25
PAD_WIDTH = 0.20
PAD_HEIGHT = 0.20
BAR_WIDTH = 0.15
BAR_PAD_WIDTH = PAD_WIDTH
TOP_MARGIN = 0.5
LEFT_MARGIN = 0.5
RIGHT_MARGIN = PAD_WIDTH
BOTTOM_MARGIN = PAD_HEIGHT

def main():
    import docopt
    from subprocess import Popen, PIPE

    try:
        args = docopt.docopt(__doc__)
        toml_path = Path(args['<toml>'])
        show_gui = not args['--output'] and not args['--print']

        if show_gui and not args['--foreground']:
            if os.fork() != 0:
                sys.exit()

        default_color = 'dimgray' if args['--print'] else 'rainbow'
        color = args['--color'] or default_color

        fig = show(toml_path, args['<attr>'], color)

        if args['--output']:
            out_path = args['--output'].replace('$', toml_path.stem)
            fig.savefig(out_path)
            print("Layout written to:", out_path)

        if args['--print']:
            lpr = [
                'lpr',
                '-o', 'ppi=600',
                '-o', 'position=top-left',
                '-o', 'page-top=36',  # 72 pt == 1 in
                '-o', 'page-left=72',
            ]
            p = Popen(lpr, stdin=PIPE)
            fig.savefig(p.stdin, format='png', dpi=600)
            print("Layout sent to printer.")

        if show_gui:
            title = str(toml_path)
            if args['<attr>']: title += f' [{", ".join(args["<attr>"])}]'
            fig.canvas.set_window_title(title)
            plt.show()

    except UsageError as err:
        print(err)
    except LayoutError as err:
        err.toml_path = toml_path
        print(err)

[docs]def show(toml_path, attrs=None, color='rainbow'): """ Visualize the given microplate layout. It's wise to visualize TOML layouts before doing any analysis, to ensure that all of the wells are correctly annotated. The :prog:`wellmap` command-line program is a useful tool for doing this, but sometimes it's more convenient to make visualizations directly from python (e.g. when working in a jupyter notebook). That's what this function is for. :param str,pathlib.Path toml_path: The path to a file describing the layout of one or more plates. See the :doc:`/file_format` page for details about this file. :param str,list attrs: One or more attributes from the above TOML file to visualize. For example, if the TOML file contains something equivalent to ``well.A1.conc = 1``, then "conc" would be a valid attribute. If no attributes are specified, the default is to display any attributes that have at least two different values. :param str color: The name of the color scheme to use. Each different value for each different attribute will be assigned a color from this scheme. Any name understood by either colorcet_ or matplotlib_ can be used. :rtype: matplotlib.figure.Figure .. _matplotlib: https://matplotlib.org/examples/color/colormaps_reference.html .. _colorcet: http://colorcet.pyviz.org/ """ df = wellmap.load(toml_path) cmap = get_colormap(color) return plot_layout(df, attrs, cmap=cmap)
def plot_layout(df, user_attrs, cmap): import matplotlib.pyplot as plt # The whole architecture of this program is dictated by a small and obscure # bug in matplotlib. (Well, I think it's a bug.) That bug is: if you are # displaying a figure in the GUI and you use `set_size_inches()`, the whole # GUI will have the given height, but the figure itself will be too short # by the height of the GUI control panel. That control panel has different # heights with different backends (and no way that I know of to query what # its height will be), so `set_size_inches()` is not reliable. # # The only way to reliably control the height of the figure is to provide a # size when constructing it. But that requires knowing the size of the # figure in advance. I would've preferred to set the size at the end, # because by then I know everything that will be in the figure. Instead, I # have to basically work out some things twice (once to figure out how big # they will be, then a second time to actually put them in the figure). # # In particular, I have to work out the colorbar labels twice. These are # the most complicated part of the figure layout, because they come from # the TOML file and could be either very narrow or very wide. So I need to # do a first pass where I plot all the labels on a dummy figure, get their # widths, then allocate enough room for them in the main figure. # # I also need to work out the dimensions of the plates twice, but that's a # simpler calculation. if 'plate' not in df: df.insert(0, 'plate', '') plates = sorted(df['plate'].unique()) attrs = pick_attrs(df, user_attrs) fig, axes, dims = setup_axes(df, plates, attrs) try: for i, attr in enumerate(attrs): colors = pick_colors(axes[i,-1], df, attr, cmap) for j, plate in enumerate(plates): plot_plate(axes[i,j], df, plate, attr, dims, colors) for i, attr in enumerate(attrs): axes[i,0].set_ylabel(attr) for j, plate in enumerate(plates): axes[0,j].set_xlabel(plate) axes[0,j].xaxis.set_label_position('top') for ax in axes[1:,:-1].flat: ax.set_xticklabels([]) for ax in axes[:,1:-1].flat: ax.set_yticklabels([]) except: fig.close() raise return fig def plot_plate(ax, df, plate, attr, dims, colors): # Fill in a matrix with integers representing each value of the given # attribute. matrix = np.full(dims.shape, np.nan) q = df.query('plate == @plate') for _, well in q.iterrows(): i = well['row_i'] - dims.i0 j = well['col_j'] - dims.j0 matrix[i, j] = colors.transform(well[attr]) # Plot a heatmap. ax.imshow( matrix, norm=colors.norm, cmap=colors.cmap, origin='upper', interpolation='nearest', ) ax.set_xticks(dims.xticks) ax.set_yticks(dims.yticks) ax.set_xticks(dims.xticksminor, minor=True) ax.set_yticks(dims.yticksminor, minor=True) ax.set_xticklabels(dims.xticklabels) ax.set_yticklabels(dims.yticklabels) ax.grid(which='minor') ax.tick_params(which='both', axis='both', length=0) ax.xaxis.tick_top() def pick_attrs(df, user_attrs): if isinstance(user_attrs, str): user_attrs = [user_attrs] wellmap_cols = ['plate', 'well', 'well0', 'row', 'col', 'row_i', 'col_j', 'path'] user_cols = [x for x in df.columns if x not in wellmap_cols] if user_attrs: # Complain if the user specified any columns that don't exist. # Using lists (slower) instead of sets (faster) to maintain the order # of the attributes in case we want to print an error message. unknown_attrs = [ x for x in user_attrs if x not in user_cols ] if unknown_attrs: raise UsageError(f"No such {plural(unknown_attrs):attribute/s}: {quoted_join(unknown_attrs)}\nDid you mean: {quoted_join(user_cols)}") return user_attrs # If the user didn't specify any columns, show any that have more than one # unique value. else: degenerate_cols = [ x for x in user_cols if df[x].nunique() == 1 ] non_degenerate_cols = [ x for x in user_cols if x not in degenerate_cols ] if not non_degenerate_cols: if degenerate_cols: raise UsageError(f"Found only degenerate attributes (i.e. with the same value in every well): {quoted_join(degenerate_cols)}") else: raise LayoutError(f"No attributes defined.") return non_degenerate_cols def pick_colors(ax, df, attr, cmap): from matplotlib.colorbar import ColorbarBase colors = Colors(cmap, df, attr) bar = ColorbarBase( ax, norm=colors.norm, cmap=colors.cmap, boundaries=colors.boundaries, ) bar.set_ticks(colors.ticks) bar.set_ticklabels(colors.ticklabels) ax.invert_yaxis() return colors def setup_axes(df, plates, attrs): from mpl_toolkits.axes_grid1 import Divider from mpl_toolkits.axes_grid1.axes_size import Fixed # These assumptions let us simplify some code, and should always be true. assert len(plates) > 0 assert len(attrs) > 0 # Determine how much data will be shown in the figure: num_plates = len(plates) num_attrs = len(attrs) dims = Dimensions(df) bar_label_width = guess_attr_label_width(df, attrs) # Define the grid on which the axes will live: h_divs = [ LEFT_MARGIN, ] for _ in plates: h_divs += [ CELL_SIZE * dims.num_cols, PAD_WIDTH, ] h_divs[-1:] = [ BAR_PAD_WIDTH, BAR_WIDTH, RIGHT_MARGIN + bar_label_width, ] v_divs = [ TOP_MARGIN, ] for attr in attrs: v_divs += [ max( CELL_SIZE * dims.num_rows, BAR_WIDTH * dims.num_values[attr], ), PAD_HEIGHT, ] v_divs[-1:] = [ BOTTOM_MARGIN, ] # Add up all the divisions to get the width and height of the figure: figsize = sum(h_divs), sum(v_divs) # Make the figure: fig, axes = plt.subplots( num_attrs, num_plates + 1, # +1 for the colorbar axes. figsize=figsize, squeeze=False, ) # Position the axes: rect = 0.0, 0.0, 1, 1 h_divs = [Fixed(x) for x in h_divs] v_divs = [Fixed(x) for x in reversed(v_divs)] divider = Divider(fig, rect, h_divs, v_divs, aspect=False) for i in range(num_attrs): for j in range(num_plates + 1): loc = divider.new_locator(nx=2*j+1, ny=2*(num_attrs - i) - 1) axes[i,j].set_axes_locator(loc) return fig, axes, dims def guess_attr_label_width(df, attrs): # I've seen some posts suggesting that this might not work on Macs. I # can't test that, but if this ends up being a problem, I probably need to # wrap this is a try/except block and fall back to guessing a width based # on the number of characters in the string representation of each label. width = 0 fig, ax = plt.subplots() for attr in attrs: labels = df[attr].unique() ax.set_yticks(range(len(labels))) ax.set_yticklabels(labels) width = max(width, get_yticklabel_width(fig, ax)) plt.close(fig) return width def get_colormap(name): try: return colorcet.cm[name] except KeyError: return plt.get_cmap(name) def get_yticklabel_width(fig, ax): # With some backends, getting the renderer like this may trigger a warning # and cause matplotlib to drop down to the Agg backend. from matplotlib import tight_layout renderer = tight_layout.get_renderer(fig) width = max( artist.get_window_extent(renderer).width for artist in ax.get_yticklabels() ) dpi = ax.get_figure().get_dpi() return width / dpi class Dimensions: def __init__(self, df): self.i0 = df['row_i'].min() self.j0 = df['col_j'].min() self.num_rows = df['row_i'].max() - self.i0 + 1 self.num_cols = df['col_j'].max() - self.j0 + 1 self.num_values = df.nunique() self.shape = self.num_rows, self.num_cols self.xticks = np.arange(self.num_cols) self.yticks = np.arange(self.num_rows) self.xticksminor = np.arange(self.num_cols + 1) - 0.5 self.yticksminor = np.arange(self.num_rows + 1) - 0.5 self.xticklabels = [ wellmap.col_from_j(j + self.j0) for j in self.xticks ] self.yticklabels = [ wellmap.row_from_i(i + self.i0) for i in self.yticks ] class Colors: def __init__(self, cmap, df, attr): cols = ['plate', 'row_i', 'col_j'] rows = df[attr].notna() labels = df[rows]\ .sort_values(cols)\ .groupby(attr, sort=False)\ .head(1) self.map = {x: i for i, x in enumerate(labels[attr])} n = len(self.map) self.cmap = cmap self.norm = Normalize(vmin=0, vmax=max(n-1, 1)) self.boundaries = np.arange(n+1) - 0.5 self.ticks = np.fromiter(self.map.values(), dtype=int, count=n) self.ticklabels = list(self.map.keys()) def transform(self, x): return self.map[x] if not self.isnan(x) else np.nan @staticmethod def isnan(x): return isinstance(x, float) and np.isnan(x) class UsageError(Exception): pass