Skip to content

Commit 10b8761

Browse files
BioCamclaude
andauthored
OpentronsOT2ChatterboxBackend: a dry-run OT-2 backend with I/O logging, via a transport seam (#1132)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f3eeee commit 10b8761

5 files changed

Lines changed: 437 additions & 30 deletions

File tree

pylabrobot/liquid_handling/backends/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .hamilton.STAR_backend import STAR, STARBackend
55
from .hamilton.vantage_backend import Vantage, VantageBackend
66
from .opentrons_backend import OpentronsOT2Backend
7+
from .opentrons_chatterbox import OpentronsOT2ChatterboxBackend
78
from .opentrons_simulator import OpentronsOT2Simulator
89
from .serializing_backend import SerializingBackend
910
from .tecan.EVO_backend import EVO, EVOBackend

pylabrobot/liquid_handling/backends/opentrons_backend.py

Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import inspect
2+
import logging
13
import uuid
2-
from typing import Dict, List, Optional, Tuple, Union, cast
4+
from typing import Any, Dict, List, Optional, Tuple, Union, cast
35

46
from pylabrobot import utils
7+
from pylabrobot.io import LOG_LEVEL_IO
58
from pylabrobot.liquid_handling.backends.backend import (
69
LiquidHandlerBackend,
710
)
@@ -31,9 +34,6 @@
3134
try:
3235
import ot_api
3336

34-
# for run cancellation
35-
import ot_api.requestor as _req
36-
3737
USE_OT = True
3838
except ImportError as e:
3939
USE_OT = False
@@ -44,6 +44,38 @@
4444
# https://labautomation.io/t/connect-pylabrobot-to-ot2/2862/18
4545
_OT_DECK_IS_ADDRESSABLE_AREA_VERSION = "7.1.0"
4646

47+
logger = logging.getLogger(__name__)
48+
49+
50+
class _IOLogger:
51+
"""Transparent proxy over the ``ot_api`` module that logs every call at
52+
``LOG_LEVEL_IO``.
53+
54+
The OT-2 talks HTTP through ``ot_api`` rather than a pylabrobot.io transport, so
55+
this wrapper gives it the same wire-level logging every other backend gets from
56+
its io object. Submodules (``lh``, ``health``, ...) are wrapped recursively;
57+
plain attributes (e.g. ``run_id``) pass through untouched.
58+
"""
59+
60+
def __init__(self, target: Any, prefix: str = ""):
61+
self._target = target
62+
self._prefix = prefix
63+
64+
def __getattr__(self, name: str) -> Any:
65+
attr = getattr(self._target, name)
66+
qualified = f"{self._prefix}.{name}" if self._prefix else name
67+
if inspect.ismodule(attr):
68+
return _IOLogger(attr, qualified)
69+
if callable(attr):
70+
71+
def _logged(*args, **kwargs):
72+
parts = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()]
73+
logger.log(LOG_LEVEL_IO, "%s(%s)", qualified, ", ".join(parts))
74+
return attr(*args, **kwargs)
75+
76+
return _logged
77+
return attr
78+
4779

4880
class OpentronsOT2Backend(LiquidHandlerBackend):
4981
"""Backends for the Opentrons OT2 liquid handling robots."""
@@ -77,8 +109,13 @@ def __init__(self, host: str, port: int = 31950):
77109
self.host = host
78110
self.port = port
79111

80-
ot_api.set_host(host)
81-
ot_api.set_port(port)
112+
# All hardware I/O goes through this handle so a subclass (e.g. the chatterbox)
113+
# can dry-run the backend by swapping it for a recording stand-in. The real handle
114+
# wraps ot_api to log every HTTP call at LOG_LEVEL_IO, like other backends' io.
115+
self._ot: Any = _IOLogger(ot_api)
116+
117+
self._ot.set_host(host)
118+
self._ot.set_port(port)
82119

83120
self.ot_api_version: Optional[str] = None
84121
self.left_pipette: Optional[Dict[str, str]] = None
@@ -97,16 +134,16 @@ def serialize(self) -> dict:
97134

98135
async def setup(self, skip_home: bool = False):
99136
# create run
100-
run_id = ot_api.runs.create()
101-
ot_api.set_run(run_id)
137+
run_id = self._ot.runs.create()
138+
self._ot.set_run(run_id)
102139

103140
# get pipettes, then assign them
104-
self.left_pipette, self.right_pipette = ot_api.lh.add_mounted_pipettes()
141+
self.left_pipette, self.right_pipette = self._ot.lh.add_mounted_pipettes()
105142

106143
self.left_pipette_has_tip = self.right_pipette_has_tip = False
107144

108145
# get api version
109-
health = ot_api.health.get()
146+
health = self._ot.health.get()
110147
self.ot_api_version = health["api_version"]
111148

112149
if not skip_home:
@@ -124,16 +161,16 @@ async def stop(self):
124161
self.right_pipette = None
125162

126163
# cancel the HTTP-API run if it exists (helpful to make device available again in official Opentrons app)
127-
run_id = getattr(ot_api, "run_id", None)
164+
run_id = getattr(self._ot, "run_id", None)
128165
if run_id:
129166
try:
130-
_req.post(f"/runs/{run_id}/cancel")
167+
self._ot.requestor.post(f"/runs/{run_id}/cancel")
131168
except Exception:
132169
try:
133-
_req.post(f"/runs/{run_id}/actions/cancel")
170+
self._ot.requestor.post(f"/runs/{run_id}/actions/cancel")
134171
except Exception:
135172
try:
136-
_req.delete(f"/runs/{run_id}")
173+
self._ot.requestor.delete(f"/runs/{run_id}")
137174
except Exception:
138175
pass
139176

@@ -231,7 +268,7 @@ async def _assign_tip_rack(self, tip_rack: TipRack, tip: Tip):
231268
],
232269
}
233270

234-
data = ot_api.labware.define(lw)
271+
data = self._ot.labware.define(lw)
235272
namespace, definition, version = data["data"]["definitionUri"].split("/")
236273

237274
# assign labware to robot
@@ -244,7 +281,7 @@ async def _assign_tip_rack(self, tip_rack: TipRack, tip: Tip):
244281
slot = deck.get_slot(tip_rack)
245282
assert slot is not None, "tip rack must be on deck"
246283

247-
ot_api.labware.add(
284+
self._ot.labware.add(
248285
load_name=definition,
249286
namespace=namespace,
250287
ot_location=slot,
@@ -323,7 +360,7 @@ async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]):
323360

324361
offset_z += op.tip.total_tip_length
325362

326-
ot_api.lh.pick_up_tip(
363+
self._ot.lh.pick_up_tip(
327364
labware_id=self.get_ot_name(tip_rack.name),
328365
well_name=self.get_ot_name(op.resource.name),
329366
pipette_id=pipette_id,
@@ -363,15 +400,15 @@ async def drop_tips(self, ops: List[Drop], use_channels: List[int]):
363400
offset_z += 10
364401

365402
if use_fixed_trash:
366-
ot_api.lh.move_to_addressable_area_for_drop_tip(
403+
self._ot.lh.move_to_addressable_area_for_drop_tip(
367404
pipette_id=pipette_id,
368405
offset_x=offset_x,
369406
offset_y=offset_y,
370407
offset_z=offset_z,
371408
)
372-
ot_api.lh.drop_tip_in_place(pipette_id=pipette_id)
409+
self._ot.lh.drop_tip_in_place(pipette_id=pipette_id)
373410
else:
374-
ot_api.lh.drop_tip(
411+
self._ot.lh.drop_tip(
375412
labware_id,
376413
well_name=self.get_ot_name(op.resource.name),
377414
pipette_id=pipette_id,
@@ -472,18 +509,18 @@ async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[
472509

473510
if op.mix is not None:
474511
for _ in range(op.mix.repetitions):
475-
ot_api.lh.aspirate_in_place(
512+
self._ot.lh.aspirate_in_place(
476513
volume=op.mix.volume,
477514
flow_rate=op.mix.flow_rate,
478515
pipette_id=pipette_id,
479516
)
480-
ot_api.lh.dispense_in_place(
517+
self._ot.lh.dispense_in_place(
481518
volume=op.mix.volume,
482519
flow_rate=op.mix.flow_rate,
483520
pipette_id=pipette_id,
484521
)
485522

486-
ot_api.lh.aspirate_in_place(
523+
self._ot.lh.aspirate_in_place(
487524
volume=volume,
488525
flow_rate=flow_rate,
489526
pipette_id=pipette_id,
@@ -544,20 +581,20 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in
544581
pipette_id=pipette_id,
545582
)
546583

547-
ot_api.lh.dispense_in_place(
584+
self._ot.lh.dispense_in_place(
548585
volume=volume,
549586
flow_rate=flow_rate,
550587
pipette_id=pipette_id,
551588
)
552589

553590
if op.mix is not None:
554591
for _ in range(op.mix.repetitions):
555-
ot_api.lh.aspirate_in_place(
592+
self._ot.lh.aspirate_in_place(
556593
volume=op.mix.volume,
557594
flow_rate=op.mix.flow_rate,
558595
pipette_id=pipette_id,
559596
)
560-
ot_api.lh.dispense_in_place(
597+
self._ot.lh.dispense_in_place(
561598
volume=op.mix.volume,
562599
flow_rate=op.mix.flow_rate,
563600
pipette_id=pipette_id,
@@ -574,7 +611,7 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in
574611
)
575612

576613
async def home(self):
577-
ot_api.health.home()
614+
self._ot.health.home()
578615

579616
async def pick_up_tips96(self, pickup: PickupTipRack):
580617
raise NotImplementedError("The Opentrons backend does not support the 96 head.")
@@ -601,7 +638,7 @@ async def drop_resource(self, drop: ResourceDrop):
601638

602639
async def list_connected_modules(self) -> List[dict]:
603640
"""List all connected temperature modules."""
604-
return cast(List[dict], ot_api.modules.list_connected_modules())
641+
return cast(List[dict], self._ot.modules.list_connected_modules())
605642

606643
def _pipette_id_for_channel(self, channel: int) -> str:
607644
pipettes = []
@@ -618,7 +655,7 @@ def _current_channel_position(self, channel: int) -> Tuple[str, Coordinate]:
618655

619656
pipette_id = self._pipette_id_for_channel(channel)
620657
try:
621-
res = ot_api.lh.save_position(pipette_id=pipette_id)
658+
res = self._ot.lh.save_position(pipette_id=pipette_id)
622659
pos = res["data"]["result"]["position"]
623660
current = Coordinate(pos["x"], pos["y"], pos["z"])
624661
except Exception as exc: # noqa: BLE001
@@ -687,7 +724,7 @@ async def move_pipette_head(
687724
if pipette_id is None:
688725
raise ValueError("No pipette id given or left/right pipette not available.")
689726

690-
ot_api.lh.move_arm(
727+
self._ot.lh.move_arm(
691728
pipette_id=pipette_id,
692729
location_x=location.x,
693730
location_y=location.y,

pylabrobot/liquid_handling/backends/opentrons_backend_tests.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from pylabrobot.liquid_handling import LiquidHandler
99
from pylabrobot.liquid_handling.backends.opentrons_backend import (
10+
_OT_DECK_IS_ADDRESSABLE_AREA_VERSION,
1011
OpentronsOT2Backend,
1112
)
1213
from pylabrobot.liquid_handling.errors import NoChannelError
@@ -188,6 +189,59 @@ def assert_parameters(
188189
with no_volume_tracking():
189190
await self.lh.dispense(self.plate["A1"], vols=[10])
190191

192+
# -- characterization of the remaining ot_api call sites (Phase 0 safety net) --
193+
194+
@patch("ot_api.health.home")
195+
async def test_home_calls_health_home(self, mock_home):
196+
"""home() issues exactly one ot_api.health.home() call."""
197+
await self.backend.home()
198+
mock_home.assert_called_once_with()
199+
200+
@patch("ot_api.modules.list_connected_modules")
201+
async def test_list_connected_modules_passthrough(self, mock_modules):
202+
"""list_connected_modules() returns ot_api.modules.list_connected_modules() verbatim."""
203+
mock_modules.return_value = [{"id": "tempdeck"}]
204+
result = await self.backend.list_connected_modules()
205+
mock_modules.assert_called_once_with()
206+
self.assertEqual(result, [{"id": "tempdeck"}])
207+
208+
@patch("ot_api.run_id", "run-id", create=True)
209+
@patch("ot_api.requestor.post")
210+
async def test_stop_cancels_active_run_and_clears_pipettes(self, mock_post):
211+
"""stop() cancels the active run through the requestor and clears mounted pipettes."""
212+
await self.backend.stop()
213+
mock_post.assert_called_once_with("/runs/run-id/cancel")
214+
self.assertIsNone(self.backend.left_pipette)
215+
self.assertIsNone(self.backend.right_pipette)
216+
217+
@patch("ot_api.lh.drop_tip_in_place")
218+
@patch("ot_api.lh.move_to_addressable_area_for_drop_tip")
219+
@patch("ot_api.lh.drop_tip")
220+
@patch("ot_api.lh.pick_up_tip")
221+
@patch("ot_api.labware.define")
222+
@patch("ot_api.labware.add")
223+
async def test_tip_drop_to_trash_uses_addressable_area(
224+
self,
225+
mock_add,
226+
mock_define,
227+
mock_pick_up_tip,
228+
mock_drop_tip,
229+
mock_to_trash,
230+
mock_drop_in_place,
231+
):
232+
"""At api_version >= 7.1.0 a discard to the deck trash routes via the addressable
233+
area (move_to_addressable_area_for_drop_tip + drop_tip_in_place), not drop_tip."""
234+
mock_define.side_effect = _mock_define
235+
mock_add.side_effect = _mock_add
236+
self.backend.ot_api_version = _OT_DECK_IS_ADDRESSABLE_AREA_VERSION
237+
238+
await self.lh.pick_up_tips(self.tip_rack["A1"])
239+
await self.lh.discard_tips()
240+
241+
mock_to_trash.assert_called_once()
242+
mock_drop_in_place.assert_called_once()
243+
mock_drop_tip.assert_not_called()
244+
191245

192246
def _make_backend_with_pipettes(left_name="p300_single_gen2", right_name="p20_single_gen2"):
193247
"""Create a backend with pipette state set directly (no ot_api needed)."""

0 commit comments

Comments
 (0)