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
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 *
86 changes: 86 additions & 0 deletions pylabrobot/resources/opentrons/ot2_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import dataclasses
from typing import List, Tuple

from pylabrobot.resources.coordinate import Coordinate


@dataclasses.dataclass(frozen=True)
class OT2RobotGeometry:
"""Static geometry of an Opentrons OT-2, expressed in the robot frame.

Every OT-2 Standard shares this geometry, so it is a constant rather than something probed from
the device. The only OT-2 data worth discovering at runtime is which pipettes are mounted, not
where the deck is.

The robot frame has its origin at the front-left corner of slot 1, with +x to the right, +y to
the back, and +z up. All distances are in mm.
"""

# Gantry working area reachable by the reference (right) mount.
extents: Coordinate = dataclasses.field(default_factory=lambda: Coordinate(446.75, 347.5, 0.0))

# Partial-tip body clearance: how far a pipette's bounding box may sit beyond the deck extents at
# each edge. These bound the pipette body in partial nozzle configurations only; they do not
# constrain a fully-configured single or multi-channel pipette and are not used by the
# single-channel reach check below.
padding_front: float = 31.89
padding_rear: float = -35.91
padding_left: float = 0.0
padding_right: float = 0.0

# Nozzle offset of each mount from the gantry carriage; the right mount is the reference.
left_mount_offset: Coordinate = dataclasses.field(
default_factory=lambda: Coordinate(-34.0, 0.0, 0.0)
)
right_mount_offset: Coordinate = dataclasses.field(
default_factory=lambda: Coordinate(0.0, 0.0, 0.0)
)

def mount_offset(self, mount: str) -> Coordinate:
"""Nozzle offset of ``mount`` ("left" or "right") from the gantry carriage."""
if mount == "left":
return self.left_mount_offset
if mount == "right":
return self.right_mount_offset
raise ValueError(f"mount must be 'left' or 'right', got {mount!r}")

def single_channel_reach(self, mount: str) -> Tuple[float, float, float, float]:
"""Reachable nozzle region for a fully-configured pipette on ``mount``.

Returns ``(x_min, x_max, y_min, y_max)`` in the robot frame: the gantry working area
(``extents``) translated by the mount offset. This mirrors how the OT-2 motion system bounds a
fully-configured pipette - the deck extents per mount, running from the front-left corner to
``extents`` plus the mount offset. It is independent of ``padding_*``, which govern partial-tip
nozzle configurations only. The mount offset has no y component, so both mounts share the same
front/back reach and differ only in x.
"""
off = self.mount_offset(mount)
return (off.x, self.extents.x + off.x, 0.0, self.extents.y)

@staticmethod
def channel_y_offsets() -> List[float]:
"""Y offset of each head8 channel from the head center, in mm.

Indexed back-to-front to match the Opentrons nozzle map: index 0 is the back-most nozzle (row
A, the primary/critical point) at the largest +y, descending to the front-most nozzle at -y.
The head8's 8 channels at 9 mm pitch span the head center by +-31.5 mm.
"""
half = (8 - 1) / 2
return [(half - i) * 9.0 for i in range(8)]

def can_reach_position(
self, mount: str, position: Coordinate, channel_offset: float = 0.0
) -> bool:
"""Whether a nozzle displaced ``channel_offset`` mm in y from the head center can reach
``position`` in x and y on ``mount``.

A pure predicate over the reachable region, here the single-channel deck extents for the
mount. For a single channel use the default
``channel_offset=0``. For a head8, pass each channel's offset from :meth:`channel_y_offsets`; the
center must land within the extents, so near the front/back limits only a subset of channels can
reach, which is why an edge column is picked up partially rather than all 8. Z is not bounded by
deck extents and is checked separately by the motion layer.
"""
x_min, x_max, y_min, y_max = self.single_channel_reach(mount)
center_y = position.y - channel_offset
return x_min <= position.x <= x_max and y_min <= center_y <= y_max
54 changes: 54 additions & 0 deletions pylabrobot/resources/opentrons/ot2_geometry_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import unittest

from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.opentrons.ot2_geometry import OT2RobotGeometry


class TestOT2RobotGeometry(unittest.TestCase):
"""Tests for the static OT-2 robot geometry."""

def assert_bounds_almost_equal(self, actual, expected):
for a, e in zip(actual, expected):
self.assertAlmostEqual(a, e, places=4)

def test_single_channel_reach_right_mount_is_full_extents(self):
"""Right mount (reference): reach is the full gantry extents anchored at the origin."""
self.assert_bounds_almost_equal(
OT2RobotGeometry().single_channel_reach("right"), (0.0, 446.75, 0.0, 347.5)
)

def test_single_channel_reach_left_mount_shifted_by_mount_offset(self):
"""Left mount sits 34 mm left of the reference, so its x window shifts by -34 while y is
unchanged (the mount offset has no y component)."""
self.assert_bounds_almost_equal(
OT2RobotGeometry().single_channel_reach("left"), (-34.0, 412.75, 0.0, 347.5)
)

def test_can_reach_position_inside_and_outside(self):
"""A point inside the mount's extents reaches; one past the front edge (y<0) does not."""
geo = OT2RobotGeometry()
self.assertTrue(geo.can_reach_position("right", Coordinate(265.0, 271.5, 0))) # slot 12 corner
self.assertFalse(
geo.can_reach_position("right", Coordinate(265.0, -5.0, 0))
) # in front of deck

def test_channel_y_offsets_head8_matches_opentrons_nozzle_map(self):
"""8 channels at 9 mm pitch, indexed back-to-front: index 0 is A1 at +31.5 (the back-most,
primary nozzle), descending to the front-most at -31.5, matching the Opentrons nozzle map."""
offsets = OT2RobotGeometry().channel_y_offsets()
self.assertEqual(len(offsets), 8)
self.assertAlmostEqual(offsets[0], 31.5)
self.assertAlmostEqual(offsets[-1], -31.5)

def test_can_reach_position_channel_offset_partial_at_back_edge(self):
"""Near the back limit a back-displaced channel reaches but a front-displaced one does not, so
a head8 engages only a subset of channels there."""
geo = OT2RobotGeometry()
target = Coordinate(265.0, 340.0, 0)
self.assertTrue(geo.can_reach_position("right", target, channel_offset=31.5))
self.assertFalse(geo.can_reach_position("right", target, channel_offset=-31.5))

def test_mount_offset_rejects_unknown_mount(self):
"""Only 'left' and 'right' are valid mounts."""
with self.assertRaises(ValueError):
OT2RobotGeometry().mount_offset("middle")
Loading