Coverage for /home/kale/research/software/projects/bio96/bio96/verify.py: 98%

# Coverage for /home/kale/research/software/projects/bio96/bio96/verify.py : 98%

## 167 statements 163 run 4 missing 0 excluded

Hot-keys on this page

r m x p toggle line displays

j k next/prev highlighted chunk

0 (zero) top of page

1 (one) first highlighted chunk

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 | #!/usr/bin/env python3 """\ Usage: bio96 <toml> [<attr>...] [options] Arguments: <toml> TOML file describing the plate layout to display. For a complete description of the file format, refer to: https://github.com/kalekundert/bio96/ <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. Options: -o --output PATH Output an image of the layout to the given path. The file type is inferred from the file extension. -c --color NAME [default: rainbow] 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: rainbow: blue, green, yellow, red viridis: purple, green, yellow plasma: purple, red, yellow coolwarm: blue, red 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 bio96 import colorcet import numpy as np import matplotlib.pyplot as plt import sys, os from bio96 import ConfigError from nonstdlib import plural from matplotlib.colors import BoundaryNorm, Normalize from pathlib import Path 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 # TODO: # - General renaming/refactoring # - Object for num plates/attrs/rows/cols # - Tests? def main(): import docopt try: args = docopt.docopt(__doc__) toml_path = Path(args['<toml>']) df = bio96.load(toml_path) cmap = colorcet.cm.get(args['--color'], plt.get_cmap(args['--color'])) if not args['--foreground'] and not args['--output']: if os.fork() != 0: sys.exit() fig = plot_attrs(df, args['<attr>'], cmap=cmap) if args['--output']: out_path = args['--output'].replace('$', toml_path.stem) fig.savefig(out_path) print("Layout written to:", out_path) else: plt.show() except CliError as err: print(err) except ConfigError as err: err.toml_path = toml_path print(err) def plot_attrs(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 # it's 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 = setup_axes(df, plates, attrs) for i, attr in enumerate(attrs): norm = pick_norm(axes[i,-1], df, attr, cmap) for j, plate in enumerate(plates): plot_plate(axes[i,j], df, plate, attr, norm, cmap) 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([]) return fig def pick_attrs(df, user_attrs): bio96_cols = ['plate', 'well', 'well0', 'row', 'col', 'row_i', 'col_j', 'path'] user_cols = [x for x in df.columns if x not in bio96_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 ConfigError(f"No such {plural(unknown_attrs):attribute/s}: {', '.join(unknown_attrs)}.\n\nDid you mean: {', '.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 CliError(f"Found only degenerate attributes (i.e. with the same value in every well): {', '.join(degenerate_cols)}") else: raise ConfigError(f"No attributes defined.") return non_degenerate_cols def pick_norm(ax, df, attr, cmap): from matplotlib.colorbar import ColorbarBase norm = SortNorm(df[attr].unique()) bar = ColorbarBase( ax, norm=norm.norm, cmap=cmap, boundaries=norm.boundaries, ) bar.set_ticks(list(norm.map.values())) bar.set_ticklabels(list(norm.map.keys())) ax.invert_yaxis() return norm def plot_plate(ax, df, plate, attr, norm, cmap): i0 = df['row_i'].min() j0 = df['col_j'].min() num_rows = df['row_i'].max() - i0 + 1 num_cols = df['col_j'].max() - j0 + 1 q = df.query('plate == @plate') matrix = np.full((num_rows, num_cols), np.nan) for _, well in q.iterrows(): i = well['row_i'] - i0 j = well['col_j'] - j0 matrix[i, j] = norm(well[attr]) ax.matshow(matrix, norm=norm.norm, cmap=cmap) xticks = np.arange(num_cols) xticklabels = [bio96.col_from_j(j + j0) for j in xticks] yticks = np.arange(num_rows) yticklabels = [bio96.row_from_i(i + i0) for i in yticks] ax.set_xticks(xticks) ax.set_yticks(yticks) ax.set_xticks(xticks - 0.5, minor=True) ax.set_yticks(yticks - 0.5, minor=True) ax.set_xticklabels(xticklabels) ax.set_yticklabels(yticklabels) ax.grid(which='minor') ax.tick_params(which='both', axis='both', length=0) 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) num_rows = df['row_i'].max() - df['row_i'].min() + 1 num_cols = df['col_j'].max() - df['col_j'].min() + 1 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 * num_cols, PAD_WIDTH, ] h_divs[-1:] = [ BAR_PAD_WIDTH, BAR_WIDTH, RIGHT_MARGIN + bar_label_width, ] v_divs = [ TOP_MARGIN, ] for _ in attrs: v_divs += [ CELL_SIZE * num_rows, 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 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_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 # Bad name... class SortNorm: def __init__(self, values): self.map = {x: i for i, x in enumerate(values) if not self.isnan(x)} n = len(self.map) self.boundaries = np.arange(n+1) - 0.5 self.norm = Normalize(vmin=0, vmax=n-1) def __call__(self, x, clip=None): if self.isnan(x): return np.nan return self.map[x] #from matplotlib.cbook import iterable #values = value if iterable(value) else [value] #return np.array([self.map[x] for x in values]) @staticmethod def isnan(x): return isinstance(x, float) and np.isnan(x) class CliError(Exception): pass