Serial Dilution Workflow
Today I spent around three hours in the Media Lab space and managed to set up a workflow for performing serial dilutions on the Opentrons robot. I chose to work on serial dilutions because they’re pretty straightforward from an automation perspective and seem like a good “easy” candidate for an LLM to attempt. In this entry, I’ll walk through the PyLabRobot implementation and comment on a few takeaways from today’s session.Serial dilutions are a fundamental technique used to systematically dilute a concentrated solution by creating a series of successive dilutions, each being a fixed ratio of the previous one. The two most common dilution factors are ten-fold (1:10) and two-fold (1:2). In a ten-fold serial dilution, each subsequent dilution is 10 times less concentrated than the previous one, while in a two-fold serial dilution, each subsequent dilution is 2 times less concentrated.
Serial Dilution Background
Ten-fold serial dilutions are typically used when a large range of concentrations needs to be covered, such as in bacterial enumeration or viral titer determination. Two-fold serial dilutions are often used when a more precise concentration range is required, such as in ELISA assays for antibody titer determination.
I won’t go into much more detail here about serial dilutions because there are many great resources like this one where you can learn more.
Lessons 1: Claude Understands Serial Dilutions
Very early in the process, I asked Claude (Opus) to explain how the serial dilution would work given my lab setup. Claude helpfully outlined the steps I would need to take, and I based my implementation on those instructions. Later, when trying to recreate the behavior, Claude would sometimes accidentally provide me instructions for a 4-step dilution instead of a 5-step dilution (see example below), and once suggested adding more volume to a single well than was actually possible.
Lenni
I want perform a simple 5-step 10-fold serial dilution on an Opentrons liquid handling robot. This example is more to
help me understand the math and logic behind the process as opposed to achieving some experimental goal.
Given the following resources, walk me through the steps for performing the serial dilution. 1. A 360 uL 96-well plate
2. A beaker of water (dilutent)
3. A beaker of water + food coloring (my concentrated solution)
Claude
Great! Let's walk through the steps for performing a simple 5-step 10-fold serial dilution using an Opentrons liquid
handling robot. We'll use water as the diluent and water with food coloring as the concentrated solution.
Resources:1. A 360 µL 96-well plate
2. A beaker of water (diluent)
3. A beaker of water + food coloring (concentrated solution)
Steps:1. Begin by adding 180 µL of water (diluent) to wells A2, A3, A4, and A5 of the 96-well plate using the Opentrons robot.
2. Add 200 µL of the water + food coloring solution (concentrated solution) to well A1 of the 96-well plate.
3. Transfer 20 µL of the solution from well A1 to well A2 using the Opentrons robot. Mix the contents of well A2 by
pipetting up and down a few times. This step creates a 1:10 dilution (10^-1) of the original solution.4. Transfer 20 µL of the diluted solution from well A2 to well A3. Mix the contents of well A3 by pipetting up and down
a few times. This step creates a 1:100 dilution (10^-2) of the original solution.5. Transfer 20 µL of the diluted solution from well A3 to well A4. Mix the contents of well A4 by pipetting up and down
a few times. This step creates a 1:1,000 dilution (10^-3) of the original solution.6. Transfer 20 µL of the diluted solution from well A4 to well A5. Mix the contents of well A5 by pipetting up and down
a few times. This step creates a 1:10,000 dilution (10^-4) of the original solution.
At the end of this process, you will have the following dilutions in your 96-well plate:* Well A1: Original concentrated solution
* Well A2: 1:10 dilution (10^-1)
* Well A3: 1:100 dilution (10^-2)
* Well A4: 1:1,000 dilution (10^-3)
* Well A5: 1:10,000 dilution (10^-4)
The math behind each dilution step is as follows:* Step 3: (20 µL of solution A1 + 180 µL of diluent) / 200 µL total volume = 1:10 dilution
* Step 4: (20 µL of solution A2 + 180 µL of diluent) / 200 µL total volume = 1:100 dilution
* Step 5: (20 µL of solution A3 + 180 µL of diluent) / 200 µL total volume = 1:1,000 dilution
* Step 6: (20 µL of solution A4 + 180 µL of diluent) / 200 µL total volume = 1:10,000 dilution
The Opentrons robot can be programmed to perform these steps accurately and consistently, making the serial dilution process more efficient and reproducible.
Thanks Claude!
Implementing the Serial Dilution on PyLabRobot
For this part, I didn’t use any LLMs outside of a few basic syntax questions where the LLM replaced Stack Overflow. Doing this “manually” was helpful to get more comfortable with PyLabRobot and also go end-to-end on a protocol implementation. I think that was actually the most important part, because I learned how protocol development occurs, and what it takes to “debug” your protocol in real-time. Using Jupyter notebooks is incredibly powerful for this because you can perform single commands and verify that each piece of your protocol works before moving onto the next part. Let’s look at the implementation and then I’ll discuss a few takeaways.
Basic setup
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import OpentronsBackend
from pylabrobot.resources import OTDeck, Coordinate
= OpentronsBackend(host="xxx.xxx.xxx.xxx", port=31950)
backend = LiquidHandler(backend=backend, deck=OTDeck())
lh await lh.setup()
Define tip rack and plate resources
from pylabrobot.resources.opentrons import *
= opentrons_96_tiprack_300ul("tip_rack_300")
tips300 =10)
lh.deck.assign_child_at_slot(tips300, slot
= opentrons_96_tiprack_20ul("tip_rack_20")
tips20 =8)
lh.deck.assign_child_at_slot(tips20, slot
= corning_96_wellplate_360ul_flat("96_wellplate_360ul_flat") # well volume is 400 uL MAX. Probably use 200-300 uL.
well_plate =7) lh.deck.assign_child_at_slot(well_plate, slot
Define custom beaker resources with dye solution and dilutent
from pylabrobot.resources import Container, Coordinate
# Define custom beaker resource with diluent
= Container(name="dilutent_deck_parent", size_x=127,size_y=86,size_z=0) # Dimensions of a single slot on the Opentrons deck
dilutent_deck_slot = Container(name='dilutent_beaker', size_x=40,size_y=40,size_z=10) # Dimensions of the beaker represented as a 3-dimensional box
dilutent_beaker =43.5, y=23, z=0)) # Coordinates of the beaker with respect to the beaker_parent coordinate system
dilutent_deck_slot.assign_child_resource(dilutent_beaker, Coordinate(x=1)
lh.deck.assign_child_at_slot(dilutent_deck_slot, slot
# Define custom beaker resource with dye solution
= Container(name="dye_deck_parent", size_x=127,size_y=86,size_z=0)
dye_deck_slot = Container(name='dye_beaker', size_x=25,size_y=24,size_z=10)
dye_beaker =43.5, y=23, z=0))
dye_deck_slot.assign_child_resource(dye_beaker, Coordinate(x=2)
lh.deck.assign_child_at_slot(dye_deck_slot, slot
lh.summary()
Deck: 624.3mm x 565.2mm
+-----------------+-----------------+-----------------+
| | | |
| 10: tip_rack... | 11: Empty | 12: trash_co... |
| | | |
+-----------------+-----------------+-----------------+
| | | |
| 7: 96_wellp... | 8: tip_rack... | 9: Empty |
| | | |
+-----------------+-----------------+-----------------+
| | | |
| 4: Empty | 5: Empty | 6: Empty |
| | | |
+-----------------+-----------------+-----------------+
| | | |
| 1: dilutent... | 2: dye_beak... | 3: Empty |
| | | | +-----------------+-----------------+-----------------
Labeling wells
Let’s label wells with their respective dilution factors for clarity.
= well_plate['A1'] # initial concentration
initial = well_plate['A2'] # 1:10 dilution
dilution1 = well_plate['A3'] # 1:100 dilution
dilution2 = well_plate['A4'] # 1:1000 dilution
dilution3 = well_plate['A5'] # 1:10000 dilution
dilution4 = well_plate['A6'] # 1:100000 dilution
dilution5 = [dilution1, dilution2, dilution3, dilution4, dilution5] dilution_wells
Transferring liquids
Let’s transfer 200 uL of the original dye-containing solution into the initial well
.
await lh.pick_up_tips(tips300[f'A1'], offsets=[Coordinate(y=0.18, z=-6)]) # pick up a new tip
await lh.aspirate(dye_beaker,200, offsets=[Coordinate(z=8)]) # grab dye solution from beaker resource
await lh.dispense(initial, vols=200, offsets=[Coordinate(y=0.2)])
await lh.discard_tips()
Next, we’ll transfer 180 uL of diluent into each of the dilution wells. We will reuse the pipette tip since we’re just transferring water and discard it at the end.
await lh.pick_up_tips(tips300[f'A2'], offsets=[Coordinate(y=0.18, z=-6)]) # pick up a new tip
for index, well in enumerate(dilution_wells):
await lh.aspirate(dilutent_beaker,180, offsets=[Coordinate(z=20)]) # grab diluent from beaker resource
await lh.dispense(well, vols=180, offsets=[Coordinate(y=0.2)])
await lh.discard_tips()
Next, we’ll transfer 20 uL from the dye solution beaker into the dilution1 well, creating our 1:10 dilution. We’ll then take 20 uL from dilution1
and transfer it to the dilution2
well and so on, until we’ve performed all of our dilutions.
await lh.pick_up_tips(tips20[f'A1'], offsets=[Coordinate(z=-6)]) # pick up a new tip
await lh.aspirate(dye_beaker,20, offsets=[Coordinate(z=8)]) # get liquid from bacteria beaker
await lh.dispense(dilution1,20, offsets=[Coordinate(z=2)]) # aspirate to make 1:10 dilution
for i in range(len(dilution_wells) - 1):
= dilution_wells[i]
source_well = dilution_wells[i+1]
destination_well
await lh.aspirate(source_well, 20, offsets=[Coordinate(z=-6.5)])
await lh.dispense(destination_well, 20, offsets=[Coordinate(z=2)])
await lh.discard_tips()
Voilà. I tested the protocol and it (mostly) works (video available here). The main issue I saw with the current implementation is that when dispensing with the 20 uL pipette tip, a drop tends to form at the bottom of the tip that likes to hang on… unfortunately that drop represents about half the volume inside the pipette. This is probably an issue addressed somewhere, so I’ll need to read up on that. On a related note, I didn’t really do any mixing of the dilutions. I could have aspirated/dispensed a few times with the tip in the well. This would have both mixed the solution and ensured the drop made it into the well.
Other improvements to consider:
- Use a multi-channel adapter for the OT to save time when transferring diluent (Thanks for making me aware of this, Alex K!)
- If I was doing this for real, I should have discarded tips between each dilution step to avoid tracking in liquid from a different well. I avoided this to save time and tips.
Lesson 2: Iterative Protocol Development Is Your Friend
When I was initially thinking about this project, I was somewhat naive in believing that LLMs would be able to one-shot large portions of protocols or entire protocols. That seems hard.
Developing the protocol was an iterative process. Are the custom resources in the right spot on the deck? Is the tip going far enough into the beaker to actually pick up liquid? How deep does the 20 uL tip need to go into the 96-well plate to aspirate liquid? All of these questions that pop up are difficult to know, even when you have pretty good knowledge of the deck setup. Some trial and error is inherent in the process. The fact that you can execute single commands in a Jupyter notebook is really useful as it lets you iterate over a step.
The scenario below is hopefully illustrative of how a lot of the development happened.
Alright… I need to get the robot to aspirate liquid from this beaker. I’ll define it on the deck and then actually put water in the beaker and put it on the deck. Now let’s try the aspirate step with a z coordinate z=40. Oops. Too high. Didn’t get any liquid but the API thinks I have liquid in the top. Okay, send a command to dispense the ghost liquid. Alright, how about z=20… okay, that worked. Dispense. Okay pick up a new tip. Shoot, I asked it to pick up a tip from an empty tip slot. Okay, put back the ghost tip and move out of the way so I can put a tip in there… So on and so forth.
In this kind of workflow where code meets the real world, iteration is your friend and it currently seems unrealistic to me for LLMs to do any kind of one-shot-and-run protocol development here. That said, if you can figure out and communicate to LLMs where their capabilities will be helpful, then I’m sure they can still be great automation assistants. For example, they could come up with the main skeleton script, helpfully divided into testable parts and flag places where testing needs to be done. Today was helpful to get me thinking more about how LLMs can fit into the lab automation workflow grounded in a more realistic understanding of protocol development.