• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kalekundert / wellmap / 7532133631

01 Nov 2023 05:34PM UTC coverage: 97.967%. Remained the same
7532133631

push

github

kalekundert
chore: update ruff command

964 of 984 relevant lines covered (97.97%)

16.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.87
/wellmap/plot.py
1
#!/usr/bin/env python3
2

3
"""\
17✔
4
Visualize the plate layout described by a wellmap TOML file.
5

6
Usage:
7
    wellmap <toml> [<param>...] [-o <path>] [-p] [-c <color>] [-s] [-f]
8

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

16
    <param>
17
        The name(s) of one or more experimental parameters from the above TOML 
18
        file to project onto the plate.  For example, if the TOML file contains 
19
        something equivalent to `well.A1.conc = 1`, then "conc" would be a 
20
        valid parameter name.
21

22
        If no names are given, the default is to display any parameters that 
23
        have at least two different values.  For complex layouts, this may 
24
        result in a figure too big to fit on the screen.  The best solution for 
25
        this (at the moment) is just to specify some parameters to focus on.
26

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

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

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

49
        rainbow:  blue, green, yellow, orange, red
50
        viridis:  purple, green, yellow
51
        plasma:   purple, red, yellow
52
        coolwarm: blue, red
53
        tab10:    blue, orange, green, red, purple, ...
54
        dimgray:  gray, black
55

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

59
        Colorcet colors:
60
        http://colorcet.pyviz.org/
61

62
    -s --superimpose
63
        Superimpose the exact value for each well (i.e. as a word/number) above 
64
        the layout.  Note that if the values are too big, they might overlap 
65
        neighboring wells, which could make things hard to read.  If you want 
66
        more control over the exact formatting of these superimposed values, 
67
        use the python/R API.
68

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

75
import wellmap
17✔
76
import colorcet
17✔
77
import numpy as np
17✔
78
import matplotlib.pyplot as plt
17✔
79
import sys, os
17✔
80

81
from inform import plural
17✔
82
from matplotlib.colors import Normalize
17✔
83
from collections.abc import Mapping
17✔
84
from pathlib import Path
17✔
85
from .util import *
17✔
86

87
try:
17✔
88
    from typing import Dict, Annotated, get_origin
17✔
89
except ImportError:
5✔
90
    from typing_extensions import Dict, Annotated, get_origin
5✔
91

92
def main():
17✔
93
    import docopt
17✔
94
    from subprocess import Popen, PIPE
17✔
95

96
    try:
17✔
97
        args = docopt.docopt(__doc__)
17✔
98
        toml_path = Path(args['<toml>'])
17✔
99
        show_gui = not args['--output'] and not args['--print']
17✔
100

101
        if show_gui and not args['--foreground']:
17✔
102
            if os.fork() != 0:
×
103
                sys.exit()
×
104

105
        style = Style()
17✔
106
        if args['--print']: style.color_scheme = 'dimgray'
17✔
107
        if args['--color']: style.color_scheme = args['--color']
17✔
108
        if args['--superimpose']: style.superimpose_values = True
17✔
109

110
        fig = show(toml_path, args['<param>'], style=style)
17✔
111

112
        if args['--output']:
17✔
113
            out_path = args['--output'].replace('$', toml_path.stem)
17✔
114
            fig.savefig(out_path)
17✔
115
            print("Layout written to:", out_path)
17✔
116

117
        if args['--print']:
17✔
118
            lpr = [
×
119
                'lpr',
120
                '-o', 'ppi=600',
121
                '-o', 'position=top-left',
122
                '-o', 'page-top=36',  # 72 pt == 1 in
123
                '-o', 'page-left=72',
124
            ]
125
            p = Popen(lpr, stdin=PIPE)
×
126
            fig.savefig(p.stdin, format='png', dpi=600)
×
127
            print("Layout sent to printer.")
×
128

129
        if show_gui:
17✔
130
            title = str(toml_path)
×
131
            if args['<param>']: title += f' [{", ".join(args["<param>"])}]'
×
132
            fig.canvas.setWindowTitle(title)
×
133
            plt.show()
×
134

135
    except UsageError as err:
17✔
136
        print(err)
17✔
137
    except LayoutError as err:
17✔
138
        err.toml_path = toml_path
17✔
139
        print(err)
17✔
140

141
def show(toml_path, params=None, *, style=None):
17✔
142
    """
143
    Visualize the given microplate layout.
144

145
    It's wise to visualize TOML layouts before doing any analysis, to ensure 
146
    that all of the wells are correctly annotated.  The :prog:`wellmap` 
147
    command-line program is a useful tool for doing this, but sometimes it's 
148
    more convenient to make visualizations directly from python (e.g. when 
149
    working in a jupyter notebook).  That's what this function is for.
150

151
    .. note::
152

153
        Jupyter will display the layout twice when you use this function, 
154
        unless you put a semicolon after it.  See :issue:`19` for more 
155
        information.
156

157
    :param str,pathlib.Path toml_path:
158
        The path to a file describing the layout of one or more plates.  See 
159
        the :doc:`/file_format` page for details about this file.
160

161
    :param str,list params:
162
        The names of one or more experimental parameters from the above TOML 
163
        file to visualize.  For example, if the TOML file contains something 
164
        equivalent to ``well.A1.conc = 1``, then "conc" would be a valid 
165
        parameter name.  If not specified, the default is to display any 
166
        parameters that have at least two different values. 
167

168
    :param Style style:
169
        Settings that control miscellaneous aspects of the plot, e.g. colors, 
170
        dimensions, etc.
171

172
    :rtype: matplotlib.figure.Figure
173
    """
174
    df, meta = wellmap.load(toml_path, meta=True)
17✔
175
    style = Style.from_merge(style or Style(), meta.style)
17✔
176
    return show_df(df, params, style=style)
17✔
177

178
def show_df(df, cols=None, *, style=None):
17✔
179
    """
180
    Visualize the microplate layout described by the given data frame.
181

182
    Unlike the `show()` function and the :prog:`wellmap` command-line program, 
183
    this function is not limited to displaying layouts parsed directly from 
184
    TOML files.  Any data frame that specifies a well for each row can be 
185
    plotted.  This provides the means to:
186

187
    - Project experimental data onto a layout.
188
    - Visualize layouts that weren't generated by wellmap in the first place.
189

190
    For example, you could load experimental data into a data frame and use 
191
    this function to visualize it directly, without ever having to specify a 
192
    layout.  This might be a useful way to get a quick sense for the data.
193

194
    :param pandas.DataFrame df:
195
        The data frame describing the layout to plot.  The data frame must be 
196
        tidy_: each row must describe a single well, and each column must 
197
        describe a single aspect of each well.  The location of each well must 
198
        be specified using one or more of the same columns that wellmap uses 
199
        for that purpose, namely:
200

201
        - *plate*
202
        - *well*
203
        - *well0*
204
        - *row*
205
        - *col*
206
        - *row_i*
207
        - *col_j*
208

209
        See `load()` for the exact meanings of these columns.  It's not 
210
        necessary to specify all of these columns, there just needs to be 
211
        enough information to locate each well.  If the *plate* column is 
212
        missing, it is assumed that all of the wells are on the same plate.  It 
213
        is also assumed that any redundant columns (e.g. *row* and *row_i*) 
214
        will be consistent with each other.
215

216
        Any scalar-valued columns other than these can be plotted.
217

218
    :param str,list cols:
219
        Which columns to plot onto the layout.  The columns used to locate the 
220
        wells (listed above) cannot be plotted.  The default is to include any 
221
        columns that have at least two different values.
222

223
    :param Style style:
224
        Settings than control miscellaneous aspects of the plot, e.g. colors, 
225
        dimensions, etc.
226

227
    :rtype: matplotlib.figure.Figure
228
    """
229

230
    # The whole architecture of this function is dictated by (what I consider 
231
    # to be) a small and obscure bug in matplotlib.  That bug is: if you are 
232
    # displaying a figure in the GUI and you use `set_size_inches()`, the whole 
233
    # GUI will have the given height, but the figure itself will be too short 
234
    # by the height of the GUI control panel.  That control panel has different 
235
    # heights with different backends (and no way that I know of to query what 
236
    # its height will be), so `set_size_inches()` is not reliable.
237
    #
238
    # The only way to reliably control the height of the figure is to provide a 
239
    # size when constructing it.  But that requires knowing the size of the 
240
    # figure in advance.  I would've preferred to set the size at the end, 
241
    # because by then I know everything that will be in the figure.  Instead, I 
242
    # have to basically work out some things twice (once to figure out how big 
243
    # they will be, then a second time to actually put them in the figure).
244
    #
245
    # In particular, I have to work out the colorbar labels twice.  These are 
246
    # the most complicated part of the figure layout, because they come from 
247
    # the TOML file and could be either very narrow or very wide.  So I need to 
248
    # do a first pass where I plot all the labels on a dummy figure, get their 
249
    # widths, then allocate enough room for them in the main figure.  
250
    # 
251
    # I also need to work out the dimensions of the plates twice, but that's a 
252
    # simpler calculation.
253

254
    style = style or Style()
17✔
255

256
    df = require_well_locations(df)
17✔
257
    plates = sorted(df['plate'].unique())
17✔
258
    params = pick_params(df, cols)
17✔
259

260
    fig, axes, dims = setup_axes(df, plates, params, style)
17✔
261

262
    try:
17✔
263
        for i, param in enumerate(params):
17✔
264
            cmap = get_colormap(style[param].color_scheme)
17✔
265
            colors = setup_color_bar(axes[i,-1], df, param, cmap)
17✔
266

267
            for j, plate in enumerate(plates):
17✔
268
                plot_plate(axes[i,j], df, plate, param, style, dims, colors)
17✔
269

270
        for i, param in enumerate(params):
17✔
271
            axes[i,0].set_ylabel(param)
17✔
272
        for j, plate in enumerate(plates):
17✔
273
            axes[0,j].set_xlabel(plate)
17✔
274
            axes[0,j].xaxis.set_label_position('top')
17✔
275

276
        for ax in axes[1:,:-1].flat:
17✔
277
            ax.set_xticklabels([])
17✔
278
        for ax in axes[:,1:-1].flat:
17✔
279
            ax.set_yticklabels([])
17✔
280

281
    except:
×
282
        plt.close(fig)
×
283
        raise
×
284

285
    return fig
17✔
286

287
def plot_plate(ax, df, plate, param, style, dims, colors):
17✔
288
    # Fill in a matrix with integers representing each value of the given 
289
    # experimental parameter.
290
    matrix = np.full(dims.shape, np.nan)
17✔
291
    q = df.query('plate == @plate')
17✔
292

293
    for _, well in q.iterrows():
17✔
294
        i = well['row_i'] - dims.i0
17✔
295
        j = well['col_j'] - dims.j0
17✔
296
        matrix[i, j] = x = colors.transform(well[param])
17✔
297

298
        if style[param].superimpose_values:
17✔
299
            bg = colors.cmap(colors.norm(x))
17✔
300
            fg = choose_foreground_color(bg)
17✔
301

302
            text = format(well[param], style[param].superimpose_format)
17✔
303
            kwargs = {
17✔
304
                    'color': fg,
305
                    'horizontalalignment': 'center',
306
                    'verticalalignment': 'center_baseline',
307
                    **style[param].superimpose_kwargs,
308
            }
309
            ax.text(j, i, text, **kwargs)
17✔
310

311
    ax.imshow(
17✔
312
            matrix,
313
            norm=colors.norm,
314
            cmap=colors.cmap,
315
            origin='upper',
316
            interpolation='nearest',
317
    )
318

319
    ax.set_xticks(dims.xticks)
17✔
320
    ax.set_yticks(dims.yticks)
17✔
321
    ax.set_xticks(dims.xticksminor, minor=True)
17✔
322
    ax.set_yticks(dims.yticksminor, minor=True)
17✔
323
    ax.set_xticklabels(dims.xticklabels)
17✔
324
    ax.set_yticklabels(dims.yticklabels)
17✔
325
    ax.grid(which='minor')
17✔
326
    ax.tick_params(which='both', axis='both', length=0)
17✔
327
    ax.xaxis.tick_top()
17✔
328

329
def pick_params(df, user_params):
17✔
330
    if isinstance(user_params, str):
17✔
331
        user_params = [user_params]
17✔
332

333
    wellmap_cols = ['plate', 'well', 'well0', 'row', 'col', 'row_i', 'col_j', 'path']
17✔
334
    user_cols = [x for x in df.columns if x not in wellmap_cols]
17✔
335

336
    if user_params:
17✔
337
        # Complain if the user specified any columns that don't exist.
338

339
        # Using lists (slower) instead of sets (faster) to maintain the order 
340
        # of the columns in case we want to print an error message.
341
        unknown_params = [
17✔
342
                x for x in user_params
343
                if x not in user_cols
344
        ]
345
        if unknown_params:
17✔
346
            raise UsageError(f"No such {plural(unknown_params):parameter/s}: {quoted_join(unknown_params)}\nDid you mean: {quoted_join(user_cols)}")
17✔
347

348
        return user_params
17✔
349

350
    # If the user didn't specify any columns, show any that have more than one 
351
    # unique value.
352
    else:
353
        degenerate_cols = [
17✔
354
                x for x in user_cols
355
                if df[x].nunique() == 1
356
        ]
357
        non_degenerate_cols = [
17✔
358
                x for x in user_cols
359
                if x not in degenerate_cols
360
        ]
361
        if not non_degenerate_cols:
17✔
362
            if degenerate_cols:
17✔
363
                raise UsageError(f"Found only degenerate parameters (i.e. with the same value in every well): {quoted_join(degenerate_cols)}")
17✔
364
            else:
365
                raise LayoutError("No experimental parameters found.")
17✔
366

367
        return non_degenerate_cols
17✔
368

369
def setup_axes(df, plates, params, style):
17✔
370
    from mpl_toolkits.axes_grid1 import Divider
17✔
371
    from mpl_toolkits.axes_grid1.axes_size import Fixed
17✔
372

373
    # These assumptions let us simplify some code, and should always be true.
374
    assert len(plates) > 0
17✔
375
    assert len(params) > 0
17✔
376

377
    # Determine how much data will be shown in the figure:
378
    num_plates = len(plates)
17✔
379
    num_params = len(params)
17✔
380
    dims = Dimensions(df)
17✔
381

382
    bar_label_width = guess_param_label_width(df, params)
17✔
383

384
    # Define the grid on which the axes will live:
385
    h_divs  = [
17✔
386
            style.left_margin,
387
    ]
388
    for _ in plates:
17✔
389
        h_divs += [
17✔
390
                style.cell_size * dims.num_cols,
391
                style.pad_width,
392
        ]
393
    h_divs[-1:] = [
17✔
394
            style.bar_pad_width,
395
            style.bar_width,
396
            style.right_margin + bar_label_width,
397
    ]
398

399
    v_divs = [
17✔
400
            style.top_margin,
401
    ]
402
    for param in params:
17✔
403
        v_divs += [
17✔
404
                max(
405
                    style.cell_size * dims.num_rows,
406
                    style.bar_width * dims.num_values[param],
407
                ),
408
                style.pad_height,
409
        ]
410
    v_divs[-1:] = [
17✔
411
            style.bottom_margin,
412
    ]
413

414
    # Add up all the divisions to get the width and height of the figure:
415
    figsize = sum(h_divs), sum(v_divs)
17✔
416

417
    # Make the figure:
418
    fig, axes = plt.subplots(
17✔
419
            num_params,
420
            num_plates + 1,  # +1 for the colorbar axes.
421
            figsize=figsize,
422
            squeeze=False,
423
    )
424

425
    # Position the axes:
426
    rect = 0.0, 0.0, 1, 1
17✔
427
    h_divs = [Fixed(x) for x in h_divs]
17✔
428
    v_divs = [Fixed(x) for x in reversed(v_divs)]
17✔
429
    divider = Divider(fig, rect, h_divs, v_divs, aspect=False)
17✔
430

431
    for i in range(num_params):
17✔
432
        for j in range(num_plates + 1):
17✔
433
            loc = divider.new_locator(nx=2*j+1, ny=2*(num_params - i) - 1)
17✔
434
            axes[i,j].set_axes_locator(loc)
17✔
435

436
    return fig, axes, dims
17✔
437

438
def setup_color_bar(ax, df, param, cmap):
17✔
439
    from matplotlib.colorbar import ColorbarBase
17✔
440

441
    colors = Colors(cmap, df, param)
17✔
442

443
    bar = ColorbarBase(
17✔
444
            ax,
445
            norm=colors.norm,
446
            cmap=colors.cmap,
447
            boundaries=colors.boundaries,
448
    )
449
    bar.set_ticks(colors.ticks)
17✔
450
    bar.set_ticklabels(colors.ticklabels)
17✔
451

452
    ax.invert_yaxis()
17✔
453

454
    return colors
17✔
455

456
def guess_param_label_width(df, params):
17✔
457
    # I've seen some posts suggesting that this might not work on Macs.  I 
458
    # can't test that, but if this ends up being a problem, I probably need to 
459
    # wrap this is a try/except block and fall back to guessing a width based 
460
    # on the number of characters in the string representation of each label.
461
    #
462
    # https://stackoverflow.com/questions/22667224/get-text-bounding-box-independent-of-backend
463

464
    width = 0
17✔
465
    fig, ax = plt.subplots()
17✔
466

467
    for param in params:
17✔
468
        labels = df[param].unique()
17✔
469
        ax.set_yticks(range(len(labels)))
17✔
470
        ax.set_yticklabels(labels)
17✔
471

472
        width = max(width, get_yticklabel_width(ax))
17✔
473

474
    plt.close(fig)
17✔
475
    return width
17✔
476

477
def choose_foreground_color(bg_color):
17✔
478
    # Decide whether to use white or black text, based on the background color.  
479
    # The basic algorithm is to scale the primaries by their perceived 
480
    # brightness, then to compare to a threshold value that generally works 
481
    # well for monitors.
482
    #
483
    # https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
484

485
    r, g, b = bg_color[:3]
17✔
486
    grey = r * 0.299 + g * 0.587 + b * 0.114
17✔
487
    return 'black' if grey > 0.588 else 'white'
17✔
488

489
def get_colormap(name):
17✔
490
    try:
17✔
491
        return colorcet.cm[name]
17✔
492
    except KeyError:
17✔
493
        return plt.get_cmap(name)
17✔
494

495
def get_yticklabel_width(ax):
17✔
496
    # With some backends, getting the renderer like this may trigger a warning 
497
    # and cause matplotlib to drop down to the Agg backend.
498
    fig = ax.get_figure()
17✔
499
    renderer = fig.canvas.get_renderer()
17✔
500
    width = max(
17✔
501
            artist.get_window_extent(renderer).width
502
            for artist in ax.get_yticklabels()
503
    )
504
    dpi = fig.get_dpi()
17✔
505
    return width / dpi
17✔
506

507
_by_param = object()
17✔
508

509
def _find_param_level_attrs(cls):
17✔
510
    cls._param_level_attrs = []
17✔
511

512
    for name, annot in cls.__annotations__.items():
17✔
513
        if get_origin(annot) is Annotated and _by_param in annot.__metadata__:
17✔
514
            cls._param_level_attrs.append(name)
17✔
515

516
    return cls
17✔
517

518
def _find_mutable_defaults(cls):
17✔
519
    cls._default_factories = {}
17✔
520

521
    for name, annotation in cls.__annotations__.items():
17✔
522
        if get_origin(annotation) is Annotated:
17✔
523
            annotation = annotation.__origin__
17✔
524

525
        if annotation in [list, dict]:
17✔
526
            cls._default_factories[name] = annotation
17✔
527

528
    return cls
17✔
529

530
def _fix_init_signature(cls):
17✔
531
    from inspect import Signature, Parameter
17✔
532

533
    # This is just for the benefit of code editing/documentation tools.  
534
    # Changing the signature like this doesn't affect how the constructor 
535
    # behaves.
536

537
    init_params = [
17✔
538
            Parameter('self', kind=Parameter.POSITIONAL_OR_KEYWORD),
539
    ]
540

541
    for name, annotation in cls.__annotations__.items():
17✔
542
        try:
17✔
543
            default_factory = cls._default_factories[name]
17✔
544
        except KeyError:
17✔
545
            default = getattr(cls, name)
17✔
546
        else:
547
            default = default_factory()
17✔
548

549
        if get_origin(annotation) is Annotated:
17✔
550
            annotation = annotation.__origin__
17✔
551

552
        param = Parameter(
17✔
553
                name=name,
554
                kind=Parameter.KEYWORD_ONLY,
555
                default=default,
556
                annotation=annotation,
557
        )
558
        init_params.append(param)
17✔
559

560
    param = Parameter(
17✔
561
            name='by_param',
562
            kind=Parameter.KEYWORD_ONLY,
563
            default={},
564
            annotation=Dict[str, dict],
565
    )
566
    init_params.append(param)
17✔
567

568
    cls.__init__.__signature__ = Signature(init_params)
17✔
569
    return cls
17✔
570

571
@_fix_init_signature
17✔
572
@_find_param_level_attrs
17✔
573
@_find_mutable_defaults
17✔
574
class Style:
14✔
575
    """
576
    Describe how to plot well layouts.
577

578
    Style objects exist to be passed to `show()` or `show_df()`, where they 
579
    determine various aspects of the plots' appearances.  You can create/modify 
580
    style objects yourself (see examples below), or you can load them from TOML 
581
    files via the ``meta=True`` argument to `load`.
582

583
    Examples:
584
    
585
        Specify a color scheme (via constructor)::
586

587
            >>> Style(color_scheme='coolwarm')
588

589
        Specify a color scheme (via attribute)::
590
            
591
            >>> style = Style()
592
            >>> style.color_scheme = 'coolwarm'
593

594
        Specify a color scheme for only a specific parameter (via constructor)::
595

596
            >>> Style(by_param={'param': {'color_scheme': 'coolwarm'}})
597

598
        Specify a color scheme for only a specific parameter (via attribute)::
599

600
            >>> style = Style()
601
            >>> style['param'].color_scheme = 'coolwarm'
602
    """
603

604
    # This class is pretty complicated, because it has to achieve all of the 
605
    # following:
606
    #
607
    # - User-facing API: This class is meant to be used directly by end users, 
608
    #   so it needs a nice API.  My goal was to make it feel like a data class, 
609
    #   e.g. a constructor with a correct signature, a meaningful `repr()`, 
610
    #   attribute-style accessors, etc.
611
    #
612
    # - Merging: There are multiple places where we need to merge one style 
613
    #   into another, e.g. when one layout with a style includes another layout 
614
    #   with a style.  Doing this requires keeping track of what values were 
615
    #   actually specified by the user, as opposed to having default values.  
616
    #
617
    # - Error checking: We don't want to silently do nothing if the user 
618
    #   specifies a style option that doesn't exist (e.g. due to a 
619
    #   misspelling).  So this class needs to be aware of what options actually 
620
    #   exist, and raise an error whenever an unknown option is encountered.
621
    #
622
    # - Parameter-level options: Some style options can have different values 
623
    #   for each parameter in a layout, e.g. `color_scheme` and 
624
    #   `superimpose_values`.
625

626
    cell_size: float = 0.25
17✔
627
    """
9✔
628
    The size of the boxes representing each well, in inches.
629
    """
630

631
    pad_width: float = 0.20
17✔
632
    """
9✔
633
    The vertical padding between layouts, in inches.
634
    """
635

636
    pad_height: float = 0.20
17✔
637
    """
9✔
638
    The horizontal padding between layouts, in inches.
639
    """
640

641
    bar_width: float = 0.15
17✔
642
    """
9✔
643
    The width of the color bar, in inches.
644
    """
645

646
    bar_pad_width: float = pad_width
17✔
647
    """
9✔
648
    The horizontal padding between the color bar and the nearest layout, in 
649
    inches.
650
    """
651

652
    top_margin: float = 0.5
17✔
653
    """
9✔
654
    The space between the layouts and the top edge of the figure, in inches.
655
    """
656

657
    left_margin: float = 0.5
17✔
658
    """
9✔
659
    The space between the layouts and the left edge of the figure, in inches.
660
    """
661

662
    right_margin: float = pad_width
17✔
663
    """
9✔
664
    The space between the layouts and the right edge of the figure, in inches.
665
    """
666

667
    bottom_margin: float = pad_height
17✔
668
    """
9✔
669
    The space between the layouts and the bottom edge of the figure, in inches.
670
    """
671

672
    color_scheme: Annotated[str, _by_param] = 'rainbow'
17✔
673
    """
9✔
674
    The name of the color scheme to use.  Each different value for each 
675
    different parameter will be assigned a color from this scheme.  Any 
676
    name understood by either colorcet_ or matplotlib_ can be used.
677

678
    .. _matplotlib: https://matplotlib.org/examples/color/colormaps_reference.html
679
    .. _colorcet: http://colorcet.pyviz.org/
680
    """
681

682
    superimpose_values: Annotated[bool, _by_param] = False
17✔
683
    """
9✔
684
    Whether or not to write exact values (i.e. as words/numbers) above each 
685
    well in the layout.  Use True/False to enable/disable this behavior for all 
686
    parameters, or use a container (e.g. list, set) to enable it only for 
687
    specific parameter names.
688
    """
689

690
    superimpose_format: Annotated[str, _by_param] = ''
17✔
691
    """
9✔
692
    The `format string 
693
    <https://docs.python.org/3/library/string.html#formatspec>`_ to use when 
694
    superimposing values over each well, e.g. 
695
    """
696

697
    superimpose_kwargs: Annotated[dict, _by_param] = None
17✔
698
    """
9✔
699
    Any keyword arguments to pass on to `matplotlib.text.Text` when rendering 
700
    the superimposed values over each well.
701
    """
702

703
    def __init__(self, **kwargs):
17✔
704
        self._style = {}
17✔
705
        self._param_styles = {}
17✔
706
        self._mutable_defaults = {}
17✔
707

708
        # Assign each attribute one at a time, to reuse the same error-checking 
709
        # logic that gets applied normally.
710

711
        by_param = kwargs.pop('by_param', {})
17✔
712

713
        for name, value in kwargs.items():
17✔
714
            setattr(self, name, value)
17✔
715

716
        for param, style in by_param.items():
17✔
717
            if not isinstance(style, Mapping):
17✔
718
                raise ValueError(f"expected *by_param* to be a dict of dicts, got: {by_param!r}")
×
719
            for name, value in style.items():
17✔
720
                setattr(self[param], name, value)
17✔
721

722
    def __eq__(self, other):
17✔
723

724
        # Make sure that default values are handled correctly by actually
725
        # looking up each attribute through the normal channels.
726

727
        def get_all_attrs(style, params):
17✔
728
            base = {
17✔
729
                    k: getattr(style, k)
730
                    for k in style.__class__.__annotations__
731
            }
732
            by_param = {
17✔
733
                    k1: {
734
                        k2: getattr(style[k1], k2)
735
                        for k2 in self._param_level_attrs
736
                    }
737
                    for k1 in params
738
            }
739
            return base, by_param
17✔
740

741
        params = set(self._param_styles) | set(other._param_styles)
17✔
742

743
        return (
17✔
744
                self.__class__ is other.__class__ and
745
                get_all_attrs(self, params) == get_all_attrs(other, params)
746
        )
747

748
    def __repr__(self):
17✔
749
        kwargs = []
17✔
750
        order = {
17✔
751
                name: i
752
                for i, name in enumerate(self.__class__.__annotations__)
753
        }
754

755
        mutable_defaults = {
17✔
756
                k: v
757
                for k, v in self._mutable_defaults.items()
758
                if v
759
        }
760
        non_default_styles = {**mutable_defaults, **self._style}
17✔
761

762
        for name in sorted(non_default_styles, key=lambda x: order[x]):
17✔
763
            kwargs.append(f'{name}={non_default_styles[name]!r}')
17✔
764

765
        if self._param_styles:
17✔
766
            kwargs.append(f'by_param={self._param_styles!r}')
17✔
767

768
        return f'{self.__class__.__name__}({", ".join(kwargs)})'
17✔
769

770
    def __getattribute__(self, name):
17✔
771
        cls = super().__getattribute__('__class__')
17✔
772

773
        # We need `__getattribute__()` instead of `__getattr__()` because there 
774
        # are class-level attributes with the same names as the instance-level 
775
        # attributes that we want to generate dynamically.
776

777
        if name in cls.__annotations__:
17✔
778
            try:
17✔
779
                return self._style[name]
17✔
780
            except KeyError:
17✔
781
                pass
17✔
782

783
            if name in cls._default_factories:
17✔
784
                try:
17✔
785
                    return self._mutable_defaults[name]
17✔
786
                except KeyError:
17✔
787
                    default = cls._default_factories[name]()
17✔
788
                    self._mutable_defaults[name] = default
17✔
789
                    return default
17✔
790

791
            return getattr(self.__class__, name)
17✔
792

793
        else:
794
            return super().__getattribute__(name)
17✔
795

796
    def __getattr__(self, name):
17✔
797
        known_names = self.__class__.__annotations__
17✔
798
        raise StyleAttributeError(name, known_names)
17✔
799

800
    def __setattr__(self, name, value):
17✔
801
        known_names = self.__class__.__annotations__
17✔
802

803
        if name.startswith('_'):
17✔
804
            super().__setattr__(name, value)
17✔
805
        elif name in known_names:
17✔
806
            self._style[name] = value
17✔
807
            self._mutable_defaults.pop(name, None)
17✔
808
        else:
809
            raise StyleAttributeError(name, known_names)
17✔
810

811
    def __getitem__(self, param):
17✔
812
        """
813
        Get the style for a specific parameter.
814

815
        The following style options can be specified on a per-parameter basis:
816

817
        - `color_scheme`
818
        - `superimpose_values`
819
        - `superimpose_format`
820
        - `superimpose_kwargs`
821

822
        This method will return an object that allows getting an setting 
823
        options specific to the given parameter.  Specifically, the object will 
824
        have an attribute corresponding to each of the above options.  If no 
825
        parameter-specific value has been specified for an option, its value 
826
        will default to that in the parent `Style` object.
827
        """
828
        return Style._ParamStyle(self, param)
17✔
829

830
    def merge(self, other):
17✔
831
        """
832
        Merge the options from another style object into this one.
833

834
        If both styles specify the same option, for value from this style will 
835
        be used (i.e. the value won't change).  The `superimpose_kwargs` option 
836
        (which is a dictionary) is merged recursively in this same manner.
837
        """
838
        recursive_merge(self._style, other._style)
17✔
839
        recursive_merge(self._param_styles, other._param_styles)
17✔
840
        recursive_merge(self._mutable_defaults, other._mutable_defaults)
17✔
841

842
        for key in set(self._style) & set(self._mutable_defaults):
17✔
843
            recursive_merge(self._style[key], self._mutable_defaults[key])
17✔
844
            del self._mutable_defaults[key]
17✔
845

846
    @classmethod
17✔
847
    def from_merge(cls, *others):
14✔
848
        """
849
        Combine all of the given styles into a single object.
850

851
        If multiple styles specify the same option, the earlier value will be 
852
        used.  The `merge` for more details.
853
        """
854
        style = cls()
17✔
855
        for other in others:
17✔
856
            style.merge(other)
17✔
857
        return style
17✔
858

859
    class _ParamStyle:
17✔
860

861
        def __init__(self, style, param):
17✔
862
            self._style = style
17✔
863
            self._param = param
17✔
864

865
        def __repr__(self):
17✔
866
            return f'{self._style}[{self._param!r}]'
×
867

868
        def __getattr__(self, name):
17✔
869
            if name in self._style._param_level_attrs:
17✔
870
                try:
17✔
871
                    return self._style._param_styles[self._param][name]
17✔
872
                except KeyError:
17✔
873
                    return getattr(self._style, name)
17✔
874

875
            else:
876
                raise StyleAttributeError(
17✔
877
                        name,
878
                        self._style._param_level_attrs,
879
                        is_param_level=True,
880
                )
881

882
        def __setattr__(self, name, value):
17✔
883
            if name.startswith('_'):
17✔
884
                super().__setattr__(name, value)
17✔
885
            elif name in self._style._param_level_attrs:
17✔
886
                self._style._param_styles.setdefault(self._param, {})[name] = value
17✔
887
            else:
888
                raise StyleAttributeError(
17✔
889
                        name,
890
                        self._style._param_level_attrs,
891
                        is_param_level=True,
892
                )
893

894

895
class Dimensions:
17✔
896

897
    def __init__(self, df):
17✔
898
        self.i0 = df['row_i'].min()
17✔
899
        self.j0 = df['col_j'].min() 
17✔
900
        self.num_rows = df['row_i'].max() - self.i0 + 1
17✔
901
        self.num_cols = df['col_j'].max() - self.j0 + 1
17✔
902
        self.num_values = df.nunique()
17✔
903
        self.shape = self.num_rows, self.num_cols
17✔
904

905
        self.xticks = np.arange(self.num_cols)
17✔
906
        self.yticks = np.arange(self.num_rows)
17✔
907

908
        self.xticksminor = np.arange(self.num_cols + 1) - 0.5
17✔
909
        self.yticksminor = np.arange(self.num_rows + 1) - 0.5
17✔
910

911
        self.xticklabels = [
17✔
912
                wellmap.col_from_j(j + self.j0)
913
                for j in self.xticks
914
        ]
915
        self.yticklabels = [
17✔
916
                wellmap.row_from_i(i + self.i0)
917
                for i in self.yticks
918
        ]
919

920
class Colors:
17✔
921

922
    def __init__(self, cmap, df, param):
17✔
923
        cols = ['plate', 'row_i', 'col_j']
17✔
924
        rows = df[param].notna()
17✔
925
        labels = df[rows]\
17✔
926
                .sort_values(cols)\
927
                .groupby(param, sort=False)\
928
                .head(1)
929

930
        self.map = {x: i for i, x in enumerate(labels[param])}
17✔
931

932
        n = len(self.map)
17✔
933
        self.cmap = cmap
17✔
934
        self.norm = Normalize(vmin=0, vmax=max(n-1, 1))
17✔
935
        self.boundaries = np.arange(n+1) - 0.5
17✔
936
        self.ticks = np.fromiter(self.map.values(), dtype=int, count=n)
17✔
937
        self.ticklabels = list(self.map.keys())
17✔
938

939
    def transform(self, x):
17✔
940
        def is_nan(x):
17✔
941
            return isinstance(x, float) and np.isnan(x)
17✔
942
        return self.map[x] if not is_nan(x) else np.nan
17✔
943

944

945
class UsageError(Exception):
17✔
946
    pass
17✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc