Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ async def _assign_tip_rack(self, tip_rack: TipRack, tip: Tip):
labware_uuid = self.get_ot_name(tip_rack.name)

deck = tip_rack.parent
while deck is not None and not isinstance(deck, OTDeck):
deck = deck.parent # labware sits in a slot holder, whose parent is the deck
assert isinstance(deck, OTDeck)
slot = deck.get_slot(tip_rack)
assert slot is not None, "tip rack must be on deck"
Expand Down Expand Up @@ -437,6 +439,15 @@ def _get_default_aspiration_flow_rate(self, pipette_name: str) -> float:
"p20_multi_gen2": 7.6,
}[pipette_name]

def _deck_to_robot_frame(self, location: Coordinate) -> Coordinate:
"""Convert a deck-frame coordinate to the OT-2 robot frame.

pylabrobot positions OT deck slots from the deck plate corner, whereas the OT-2 motion API
expects coordinates in the robot frame whose origin is slot 1's corner. The two frames differ by
slot 1's position in the deck frame, so subtract it.
"""
return location - cast(OTDeck, self.deck).slot_locations[0]

async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int]):
"""Aspirate liquid from the specified resource using pip."""

Expand All @@ -447,7 +458,7 @@ async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[
pipette_name = self.get_pipette_name(pipette_id)
flow_rate = op.flow_rate or self._get_default_aspiration_flow_rate(pipette_name)

location = (
location = self._deck_to_robot_frame(
op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom")
+ op.offset
+ Coordinate(z=op.liquid_height or 0)
Expand Down Expand Up @@ -478,7 +489,7 @@ async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[
pipette_id=pipette_id,
)

traversal_location = (
traversal_location = self._deck_to_robot_frame(
op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + op.offset
)
traversal_location.z = self.traversal_height
Expand Down Expand Up @@ -522,7 +533,7 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in
pipette_name = self.get_pipette_name(pipette_id)
flow_rate = op.flow_rate or self._get_default_dispense_flow_rate(pipette_name)

location = (
location = self._deck_to_robot_frame(
op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom")
+ op.offset
+ Coordinate(z=op.liquid_height or 0)
Expand Down Expand Up @@ -552,7 +563,7 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in
pipette_id=pipette_id,
)

traversal_location = (
traversal_location = self._deck_to_robot_frame(
op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + op.offset
)
traversal_location.z = self.traversal_height
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,19 @@ def test_get_pickup_pipette_raises_when_tip_already_mounted(self):
with self.assertRaises(NoChannelError):
self.backend._get_pickup_pipette(ops)

# -- _deck_to_robot_frame --

def test_deck_to_robot_frame_maps_slot1_corner_to_robot_origin(self):
"""The deck->robot transform subtracts slot 1's corner, so a deck-frame point at slot 1's
corner becomes the robot origin and a point offset from it keeps that offset."""
self.backend.set_deck(self.deck)
corner = self.deck.slot_locations[0]
self.assertEqual(self.backend._deck_to_robot_frame(corner), Coordinate(0, 0, 0))
self.assertEqual(
self.backend._deck_to_robot_frame(corner + Coordinate(10, 20, 3)),
Coordinate(10, 20, 3),
)

# -- _get_drop_pipette --

def test_get_drop_pipette_selects_right_for_20ul(self):
Expand Down
1 change: 1 addition & 0 deletions pylabrobot/resources/opentrons/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .deck import OTDeck
from .load import load_ot_tip_rack
from .module import OTModule
from .ot2_geometry import OT2RobotGeometry
from .tip_racks import *
158 changes: 104 additions & 54 deletions pylabrobot/resources/opentrons/deck.py
Original file line number Diff line number Diff line change
@@ -1,67 +1,107 @@
import textwrap
from typing import List, Optional
from typing import List, Optional, cast

from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.deck import Deck
from pylabrobot.resources.resource import Resource
from pylabrobot.resources.resource_holder import ResourceHolder
from pylabrobot.resources.trash import Trash

_SLOT_SIZE_X = 128.0
_SLOT_SIZE_Y = 86.0

# The fixed trash (opentrons_1_trash_1100ml_fixed) is larger than a standard slot and overhangs it,
# so slot 12's holder takes the trash footprint instead of the standard slot size.
_TRASH_SIZE_X = 172.86
_TRASH_SIZE_Y = 165.86
_TRASH_SIZE_Z = 82.0

# Base OT-2 slot grid in the robot frame, where slot 1's corner is the origin.
_BASE_SLOT_LOCATIONS = [
Coordinate(x=0.0, y=0.0, z=0.0),
Coordinate(x=132.5, y=0.0, z=0.0),
Coordinate(x=265.0, y=0.0, z=0.0),
Coordinate(x=0.0, y=90.5, z=0.0),
Coordinate(x=132.5, y=90.5, z=0.0),
Coordinate(x=265.0, y=90.5, z=0.0),
Coordinate(x=0.0, y=181.0, z=0.0),
Coordinate(x=132.5, y=181.0, z=0.0),
Coordinate(x=265.0, y=181.0, z=0.0),
Coordinate(x=0.0, y=271.5, z=0.0),
Coordinate(x=132.5, y=271.5, z=0.0),
Coordinate(x=265.0, y=271.5, z=0.0),
]
# The deck plate corner sits at (-115.65, -68.03) in the robot frame, which is this resource's
# local origin, so each slot is re-based onto the plate corner by adding the offset.
_SLOT_CORNER_OFFSET = Coordinate(x=115.65, y=68.03, z=0.0)


class OTDeck(Deck):
"""The OpenTron deck."""
"""The Opentrons OT-2 deck.

The 12 slots are modeled as :class:`ResourceHolder` children, one per slot, so the deck geometry
has a single source of truth and renders directly from the serialized resource tree. Labware is
placed into a slot's holder with :meth:`assign_child_at_slot`.
"""

def __init__(
self,
size_x: float = 624.3,
size_y: float = 565.2,
size_z: float = 900,
size_z: float = 0,
origin: Coordinate = Coordinate(0, 0, 0),
with_trash: bool = True,
name: str = "deck",
name: str = "ot2_deck",
category: str = "deck",
):
# size_z is probably wrong

super().__init__(size_x=size_x, size_y=size_y, size_z=size_z, origin=origin)

self.slots: List[Optional[Resource]] = [None] * 12

self.slot_locations = [
Coordinate(x=0.0, y=0.0, z=0.0),
Coordinate(x=132.5, y=0.0, z=0.0),
Coordinate(x=265.0, y=0.0, z=0.0),
Coordinate(x=0.0, y=90.5, z=0.0),
Coordinate(x=132.5, y=90.5, z=0.0),
Coordinate(x=265.0, y=90.5, z=0.0),
Coordinate(x=0.0, y=181.0, z=0.0),
Coordinate(x=132.5, y=181.0, z=0.0),
Coordinate(x=265.0, y=181.0, z=0.0),
Coordinate(x=0.0, y=271.5, z=0.0),
Coordinate(x=132.5, y=271.5, z=0.0),
Coordinate(x=265.0, y=271.5, z=0.0),
]
super().__init__(
size_x=size_x, size_y=size_y, size_z=size_z, name=name, origin=origin, category=category
)

self._slot_holders: List[ResourceHolder] = []
for i, base in enumerate(_BASE_SLOT_LOCATIONS):
is_trash_slot = i == 11 and with_trash
holder = ResourceHolder(
name=f"{self.name}_slot_{i + 1}",
size_x=_TRASH_SIZE_X if is_trash_slot else _SLOT_SIZE_X,
size_y=_TRASH_SIZE_Y if is_trash_slot else _SLOT_SIZE_Y,
size_z=_TRASH_SIZE_Z if is_trash_slot else 0,
)
self._slot_holders.append(holder)
super().assign_child_resource(holder, location=base + _SLOT_CORNER_OFFSET)

if with_trash:
self._assign_trash()

@property
def slots(self) -> List[Optional[Resource]]:
"""The labware in each slot, or ``None`` for empty slots (slot 1 first)."""
return [holder.resource for holder in self._slot_holders]

@property
def slot_locations(self) -> List[Coordinate]:
"""The location of each slot, re-based onto the deck plate corner (slot 1 first)."""
return [cast(Coordinate, holder.location) for holder in self._slot_holders]

def _assign_trash(self):
"""Assign the trash area to the deck.
"""Assign the trash area to slot 12.

Because all opentrons operations require that the resource passed references a parent, we need
to create a dummy resource to represent the container of the actual trash area.
"""

trash_container = Trash(
name="trash_container",
size_x=172.86,
size_y=165.86,
size_z=82,
size_x=_TRASH_SIZE_X,
size_y=_TRASH_SIZE_Y,
size_z=_TRASH_SIZE_Z,
)

actual_trash = Trash(
name="trash",
size_x=172.86,
size_y=165.86,
size_z=82,
size_x=_TRASH_SIZE_X,
size_y=_TRASH_SIZE_Y,
size_z=_TRASH_SIZE_Z,
)

# Trash location used to be Coordinate(x=86.43, y=82.93, z=0),
Expand All @@ -75,47 +115,57 @@ def _assign_trash(self):
def assign_child_resource(
self,
resource: Resource,
location: Optional[Coordinate],
location: Optional[Coordinate] = None,
reassign: bool = True,
):
"""Assign a resource to a slot.
"""Assign a slot holder to the deck.

..warning:: This method exists only for deserialization. You should use
:meth:`assign_child_at_slot` instead.
The deck's direct children are the 12 slot holders created in ``__init__``. Deserialization
re-assigns those holders by name, replacing the placeholder with the loaded one (which carries
its labware). Labware itself is placed with :meth:`assign_child_at_slot`, not here.
"""

if location is None:
raise ValueError("location must be provided for resources on the deck")

if location not in self.slot_locations:
super().assign_child_resource(resource, location=location)
else:
slot = self.slot_locations.index(location) + 1
self.assign_child_at_slot(resource, slot)
existing = next((child for child in self.children if child.name == resource.name), None)
if existing is not None:
if not reassign:
raise ValueError(f"Resource '{resource.name}' already assigned to deck")
super().unassign_child_resource(existing)
for i, holder in enumerate(self._slot_holders):
if holder is existing:
self._slot_holders[i] = cast(ResourceHolder, resource)
break
elif not isinstance(resource, ResourceHolder):
raise ValueError(
f"Cannot assign '{resource.name}' directly to the deck. Use assign_child_at_slot to place "
"labware into a slot. A deck serialized before slots became resource holders stores labware "
"as direct children and will not load."
)

super().assign_child_resource(resource, location=location, reassign=reassign)

def assign_child_at_slot(self, resource: Resource, slot: int):
if slot not in range(1, 13):
raise ValueError("slot must be between 1 and 12")

if self.slots[slot - 1] is not None:
holder = self._slot_holders[slot - 1]
if holder.resource is not None:
raise ValueError(f"Spot {slot} is already occupied")

self.slots[slot - 1] = resource
super().assign_child_resource(resource, location=self.slot_locations[slot - 1])
holder.assign_child_resource(resource)

def unassign_child_resource(self, resource: Resource):
if resource not in self.slots:
raise ValueError(f"Resource {resource.name} is not assigned to this deck")

slot = self.slots.index(resource)
self.slots[slot] = None
for holder in self._slot_holders:
if holder.resource is resource:
holder.unassign_child_resource(resource)
return
super().unassign_child_resource(resource)

def get_slot(self, resource: Resource) -> Optional[int]:
"""Get the slot number of a resource."""
if resource not in self.slots:
return None
return self.slots.index(resource) + 1
for i, holder in enumerate(self._slot_holders):
if holder.resource is resource:
return i + 1
return None

def summary(self) -> str:
"""Get a summary of the deck.
Expand Down
38 changes: 38 additions & 0 deletions pylabrobot/resources/opentrons/deck_tests.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import textwrap
import unittest

from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.corning.plates import (
Cor_96_wellplate_360ul_Fb,
)
from pylabrobot.resources.opentrons.deck import OTDeck
from pylabrobot.resources.opentrons.tip_racks import (
opentrons_96_tiprack_300ul,
)
from pylabrobot.resources.resource import Resource
from pylabrobot.resources.resource_holder import ResourceHolder


class TestOTDeck(unittest.TestCase):
Expand All @@ -23,6 +26,41 @@ def setUp(self) -> None:
self.deck.assign_child_at_slot(Cor_96_wellplate_360ul_Fb("my_plate"), 4)
self.deck.assign_child_at_slot(Cor_96_wellplate_360ul_Fb("my_other_plate"), 5)

def test_slot_locations_inset_from_plate_corner(self):
"""Slots are re-based onto the deck plate corner, so slot 1 sits at the corner offset
(115.65, 68.03) rather than the deck origin, and every other slot shifts by the same amount."""
self.assertEqual(self.deck.slot_locations[0], Coordinate(115.65, 68.03, 0))
self.assertEqual(self.deck.slot_locations[11], Coordinate(380.65, 339.53, 0))

def test_slots_are_resource_holders_at_inset_positions(self):
"""The 12 slots are ResourceHolder children at the inset slot locations, and a slot's labware
is that holder's child."""
holders = self.deck._slot_holders
self.assertEqual(len(holders), 12)
self.assertTrue(all(isinstance(h, ResourceHolder) for h in holders))
self.assertEqual(holders[0].location, Coordinate(115.65, 68.03, 0))
self.assertIs(holders[6].resource, self.deck.slots[6]) # tip_rack_1 lives in slot 7's holder

def test_assign_child_resource_rejects_direct_labware(self):
"""Labware must enter a slot via assign_child_at_slot; assigning it directly to the deck (as a
deck serialized before slots became holders would) is rejected rather than misplaced."""
deck = OTDeck()
plate = Cor_96_wellplate_360ul_Fb("p")
with self.assertRaises(ValueError):
deck.assign_child_resource(plate, location=Coordinate(115.65, 68.03, 0))

def test_serialize_deserialize_round_trip(self):
"""A deck with labware survives serialize -> deserialize, with slots resolving to the same
labware (validates replacing the placeholder holders with the loaded ones)."""
loaded = Resource.deserialize(self.deck.serialize())
assert isinstance(loaded, OTDeck)
slot_6, slot_3 = loaded.slots[6], loaded.slots[3]
assert slot_6 is not None and slot_3 is not None
self.assertEqual(slot_6.name, "tip_rack_1")
self.assertEqual(loaded.get_slot(slot_6), 7)
self.assertEqual(slot_3.name, "my_plate")
self.assertEqual(loaded.slot_locations[0], Coordinate(115.65, 68.03, 0))

def test_summary(self):
self.assertEqual(
self.deck.summary(),
Expand Down
Loading
Loading