Biotech 🦠, startups 🚀, tech 👨‍💻

Generating drug dose ranges with Autoprotocol for screening

I was thinking that when writing a protocol with Autoprotocol it would be really useful to say “Give me a plate of a drug that covers a dose range from 10nM to 100nM”. It turned out that this was a little bit more complex than I had originally thought.

The Autoprotocol spec currently only covers singly dimensioned quantities so Unit(100, 'second') or Unit(50,'microliter'). There’s definitely a debate there to talk about adding compound units, however technically the only point when one would need to specify a concentration is if the Ref section of Autoprotocol was extended to support more explicit definition.

In the case of generating a series of transfers to populate a plate the output required is just the volume that the liquid handler needs to move. Therefore, the concentration calculations can happen at the autoprotocol-python level. I threw together an implementation of this with the units package for Python, Pint, rather than extending the Unit() class.

import autoprotocol
from autoprotocol import Unit
from autoprotocol.container import Container
from autoprotocol.protocol import Protocol
from autoprotocol.protocol import Ref
import numpy as np
from pint import UnitRegistry
import json

# load the default unit registry with Pint
ureg = UnitRegistry()

p = Protocol()

# Function calculates volume required for a final concentration from the stock concentration of the drug.
def conc_to_vol(start_conc, final_conc, final_vol):
    start_vol = final_conc/start_conc * final_vol
    return Unit(, 'microliter')

# Function generates the transfers to the destination plate.
def gen_plate(source, source_conc, dest, concs, total_well_volume):
    dest_wells = dest.wells_from(0, len(concs))
    for i, conc in enumerate(concs):
        p.transfer(source, dest_wells[i], conc_to_vol(source_conc, conc, total_well_volume))

# Create the drug source and the destination dose plate
tube = p.ref("drug", id=None, cont_type="micro-1.5", storage="cold_4", discard=None)
plate = p.ref("assay_plate", id=None, cont_type="384-flat", storage=None, discard=True)

# generate a concentration range, and convert to a Pint Quantity
# From 0 nM -> 500nM every 1.5 nM
conc_range = [x * ureg['nanomol/L'] for x in np.arange(0,500,1.5)]

#Generate the plate, 90µL max total volume
gen_plate(tube.well(0), 1 * ureg['micromol/L'], plate, conc_range, 90 * ureg['microliter'])

# Dump the Autoprotocol JSON.
jprotocol = json.dumps(p.as_dict(), indent=2)

So from here you would probably want to add a standard quantity of cells, then add growth media to reach the total volume, 90:microliter here.

This is an OK proof of concept and I really like the user interface of working concentration ranges rather than just volumes as its more relevant to assay. The failure so far is that if you are generating huge ranges, you can hit the well limit of the plate quite quickly. It would be good to add some logic that generates enough plates to support the number of concentrations you want to observe.

In addition there is no logic to check the volumes being transferred, at the low end around 1 nM, transfer instructions are being generated for sub-microliter volumes, for this it would be better to use the acoustic_transfer instruction to avoid pipetting errors.

What’s cool about this is you can generate a series of concentrations you want to assess. Here I just generated a linear series but you can come up with different functions to generate a series that samples the parametric space. This is going to be a huge theme going forward, using computation and automation to efficiently traverse wide, experimental parametric spaces.