This chapter shows you how to instantiate instrument interfaces in PLR. You can follow along with all of the code here by running it yourself in JupyterLab.
Install PyLabRobot and JupyterLab together before starting:
python-m pip install pylabrobot==0.2.2 jupyterlab
Then start JupyterLab from the folder where you want to keep your notebooks:
jupyter lab
JupyterLab will open in your browser. Create a new Python notebook, paste the recipe into a cell, and run it with Shift+Enter. Notebook cells support the await calls used throughout this book directly.
3.1 Simulating a liquid-handling robot with no hardware
Start a simulated liquid handler, look at what is on it, and shut it down.
LiquidHandler is the frontend. It lives in pylabrobot.liquid_handling.
2
LiquidHandlerChatterboxBackend is the backend that needs no hardware. It narrates every operation instead of performing it.
3
STARLetDeck is a ready-made Hamilton STARlet deck: the standard eight-channel layout with tip racks, a waste, and a trash in the right places. There are a few to choose from; more in chapter 3.
4
Nothing happens yet — a LiquidHandler is constructed, not connected. Construction is cheap and synchronous.
5
setup() connects to the backend. Everything before it was bookkeeping; nothing works after it until it is called.
6
stop() closes the connection. Your protocol should always end with this.
Setting up the liquid handler.
Stopping the liquid handler.
WarningGotcha: volume tracking is off by default
does_volume_tracking() returns False until you call set_volume_tracking(True). Until then, aspirating from a well PLR believes is empty succeeds silently — no TooLittleLiquidError, no warning. Turn it on when you want the frontend to catch volume mistakes:
from pylabrobot.resources import set_volume_trackingset_volume_tracking(True)
Recipes in this book that seed a well with set_volume(...) are establishing what should be there, so the narration reads correctly and so the recipe still holds once tracking is on.
The frontend produces the same plan whichever backend is attached; the backend is the only difference. The frontend owns tracking and validation, the backend owns motion, and the chatterbox implements the first and skips the second.
Chatterbox backends also exist for thermocyclers, incubators, centrifuges, sealers, plate readers, pumps, and scales — about 15 machine types. See the workcell recipe.
3.2 From simulation to hardware
The whole point of the frontend/backend split is that only one line changes when you move to a real machine — the backend.
TipSwap the backend, keep everything else
To run the recipe above on a physical Hamilton STAR(let), change line 2 of the imports and line 4 of the construction; nothing else moves:
from pylabrobot.liquid_handling import LiquidHandlerfrom pylabrobot.liquid_handling.backends import STARBackend # <- was ChatterboxBackendfrom pylabrobot.resources import STARLetDecklh = LiquidHandler(backend=STARBackend(), deck=STARLetDeck()) # <- was ChatterboxBackend()await lh.setup()await lh.stop()
Same frontend, same deck, same setup/stop discipline, same protocol calls. Every pattern you learn against the chatterbox transfers unchanged.
Two things do have to happen before STARBackend() will connect:
Install the hardware dependencies. PLR keeps drivers out of the default install: pip install "pylabrobot[usb]" for USB machines like the Hamilton (other machines use [serial], [hid], [ftdi], and friends).
Set up the driver once per computer. On Windows, use Zadig to bind the device (“ML Star”) to libusbK; on macOS/Linux, install libusb. This is operating-system plumbing, not Python — do it once and forget it.
The same swap works across the catalogue: every machine type that ships a chatterbox has a real counterpart with the same interface — ThermocyclerChatterboxBackend → your thermocycler’s driver backend, IncubatorChatterboxBackend → its real backend, and so on. The frontend never knows the difference (chapter 13 exploits this to record runs identically in both modes).
Why everything is await
Every PLR operation that touches a machine is async def — setup, aspirate, move_plate, stop, all of them — so every call needs await.
PLR is built on asyncio. An async def function returns a coroutine, which does not run until the event loop gets to it; await hands control to that coroutine and lets it do its work. The practical upshot for you:
In a Jupyter notebook, await works directly in a cell — notebooks run inside the event loop, so await lh.setup() is all you write.
In a plain Python script, you must start the loop yourself. There is no top-level await:
asyncio.run(main()) creates the loop, runs main() to completion, and tears the loop down.
async with lh: is shorthand for try: await lh.setup() ... finally: await lh.stop(). It calls setup() on entry and stop() on exit — see the setup/stop recipe.
The recipes in this book use notebook-style await directly, because the cookbook renders through Jupyter. When you take a recipe into a script, wrap it in asyncio.run(main()) and you are done.
3.3 Inspect what is on the deck
Read what is on the deck as a flat list.
_ = lh.summary()names = [r.name for r in lh.deck.get_all_children()]print(len(names), "resources:", names[:8], "...")
1
lh.summary() prints a rail map of the deck — every resource and where it sits. (It prints rather than returns, which is why the result is discarded.)
2
deck.get_all_children() walks the whole resource tree and returns every node, flat. This is the one to program against: count plates, find free positions, locate a named resource.
The rail map uses box-drawing characters (├──). On Windows, a terminal using the legacy cp1252 code page raises UnicodeEncodeError when printing it. In a notebook or with UTF-8 output it is fine; if you hit it in a script, set PYTHONIOENCODING=utf-8 (or chcp 65001) first.
See also:deck.get_all_children() in chapter 3; the occupancy-map variant with a custom predicate in chapter 5.
3.4 See the deck
Open a live, interactive view of the deck in your browser.
from pylabrobot.visualizer import Visualizervis = Visualizer(resource=lh, open_browser=False)await vis.setup()# ... run your protocol; the deck redraws live ...await vis.stop()
1
resource=lh — the visualizer watches the liquid handler, so the deck updates as you pipette. Set open_browser=True (the default) when running this yourself; it is suppressed here so the book does not hijack your browser.
2
vis.setup() starts two servers: a websocket on 2121 that streams deck state, and a file server on 1337 that serves the page. Open the file-server URL it prints.
3
vis.stop() tears the servers down. Same setup/stop discipline as everything else.
Websocket server started at http://127.0.0.1:2121
File server started at http://127.0.0.1:1337 . Open this URL in your browser.
See also:ws_port (2121) and fs_port (1337) are constructor arguments to Visualizer, not settings — pylabrobot.config configures logging only (chapter 13).
3.5 Simulate a whole workcell
This is the standout capability of 0.2.2: ~15 machine types ship chatterbox backends, so you can simulate an entire lab — liquid handler, thermocycler, incubator, centrifuge, sealer — before any of it exists on a bench.
Stand up a thermocycler and an incubator alongside the liquid handler, and run a PCR profile against the simulated thermocycler.
A Thermocycler is constructed like a LiquidHandler — frontend object plus a backend. The chatterbox backend is the simulated half.
2
Machines share the setup/stop discipline, and like the liquid handler they enforce it with @need_setup_finished on their operations.
3
The chatterbox prints the profile it was handed, step by step.
4
The Incubator takes racks (its plate-carrier shelves) and a loading_tray_location. An empty rack list is fine for a first look.
5
Real machine questions have real answers: how many free sites, which plate is where, what temperature is it at.
Setting up thermocycler.
Setting lid temperature(s) to 99.0°C.
Running protocol:
- Stage 1/1: 3 step(s) x 3 repeat(s)
- Repeat 1/3:
- Step 1/3 (repeat 1/3): temperature(s) = 95.0°C, hold = 30.0s
- Step 2/3 (repeat 1/3): temperature(s) = 58.0°C, hold = 30.0s
- Step 3/3 (repeat 1/3): temperature(s) = 72.0°C, hold = 60.0s
- Repeat 2/3:
- Step 1/3 (repeat 2/3): temperature(s) = 95.0°C, hold = 30.0s
- Step 2/3 (repeat 2/3): temperature(s) = 58.0°C, hold = 30.0s
- Step 3/3 (repeat 2/3): temperature(s) = 72.0°C, hold = 60.0s
- Repeat 3/3:
- Step 1/3 (repeat 3/3): temperature(s) = 95.0°C, hold = 30.0s
- Step 2/3 (repeat 3/3): temperature(s) = 58.0°C, hold = 30.0s
- Step 3/3 (repeat 3/3): temperature(s) = 72.0°C, hold = 60.0s
Stopping thermocycler.
Setting up incubator backend
free sites: 0
Stopping incubator backend
What simulation cannot tell you.
Simulations provide a model of the expected outcome of a process while making certain idealized assumptions about the nature of the hardware and physical processes. The most egregious assumption that PLR makes is that all of the liquid-handling steps are perfectly precise. This is unlikely to be true, and you must calibrate and verify your liquid-handling steps in order to get good results.
See also: moving a plate to a thermocycler and recording it is chapter 9.
3.6 Start independent machines at the same time
The workcell recipe above starts each machine in turn, so the run waits for the liquid handler to home, then waits for the thermocycler to answer, then waits for the incubator. Those waits have nothing to do with each other. asyncio.gather issues them together and returns when the last one finishes:
Three coroutines, one wait. gather schedules them on the same event loop and resumes each one as its machine answers, so the total is roughly the slowest setup rather than the sum of all three.
2
Both machines are up here — gather does not return until every coroutine it was given has finished.
3
Shutdown parallelizes the same way. gather propagates the first exception it sees, so a machine that fails to stop still surfaces.
Setting up the liquid handler.
Setting up thermocycler.
Setting up incubator backend
lid open: True
free sites: 0
Stopping the liquid handler.
Stopping thermocycler.
Stopping incubator backend
[None, None, None]
This is the reason PLR is async at all. A protocol that only ever awaits one machine could have been written with blocking calls; the concurrency is what async buys you.
WarningGotcha: concurrency is per machine, not per channel
gather is safe across machines that are genuinely independent — separate devices on separate connections. It is not a way to parallelize one machine:
One LiquidHandler has one set of channels and one connection to the backend. Overlapping two operations on it races them against each other — interleaved firmware commands, and tracking state updated from both at once. Await those in sequence, and reach for gather only at the boundary between machines.
With chatterbox backends nothing measurable overlaps here: the simulated machines answer instantly, so there is no I/O to wait through. The shape is what matters — on hardware, the three setups proceed at once.
See also:chapter 16 puts this on a simulated clock, where overlapping a long instrument step with other work has a visible effect on the total.
3.7 Shut down cleanly
Make sure stop() always runs, even when the protocol throws halfway through.
asyncwith LiquidHandler( backend=LiquidHandlerChatterboxBackend(), deck=STARLetDeck()) as lh:# ... protocol ...pass
WarningGotcha: setup() is not optional
PLR enforces it itself. aspirate, dispense, pick_up_tips, and drop_tips are wrapped in @need_setup_finished, which raises if you call them before setup():
RuntimeError: The setup has not finished. See `setup`.
Chapter 15 composes recovery policy out of the same idea.
See also: against the chatterbox, omitting stop() has no effect. Against hardware it can leave the machine connected and in an undefined state.
3.8 What to remember
A LiquidHandler = a Deck (layout) + a Backend (motion). Construction is cheap; setup() is the real start.
LiquidHandlerChatterboxBackend is a complete backend that narrates instead of moving. Read the narration — it is what the machine would have done.
Every machine call is await. Notebooks handle it directly; scripts wrap everything in asyncio.run(main()).
~15 machine types ship chatterbox backends — simulate a whole workcell before the bench exists.
Always stop(): use async with or try/finally. It is not optional machinery, it is hygiene.