Skip to content

Commit 4749546

Browse files
authored
Add FK-based STARBackend.iswap_request_pose() + iswap_request_joint_state() (#1041)
* Add iSWAP FK pose + joint-state accessors Written with Claude Code assistance
1 parent 71837a8 commit 4749546

2 files changed

Lines changed: 302 additions & 0 deletions

File tree

pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from typing import Concatenate, ParamSpec
3535

3636
from pylabrobot import audio
37+
from pylabrobot.arms.standard import CartesianCoords
3738
from pylabrobot.heating_shaking.hamilton_backend import HamiltonHeaterShakerInterface
3839
from pylabrobot.liquid_handling.backends.hamilton.base import (
3940
HamiltonLiquidHandler,
@@ -1322,6 +1323,36 @@ class Head96Information:
13221323
class STARBackend(HamiltonLiquidHandler, HamiltonHeaterShakerInterface):
13231324
"""Interface for the Hamilton STARBackend."""
13241325

1326+
class iSWAPAxis(enum.IntEnum):
1327+
"""Axis index for `iswap_request_joint_state` dicts.
1328+
1329+
Units are axis-implicit (matches PF400's `Dict[int, float]` keyed by `PFAxis`):
1330+
prismatic axes (X/Y/Z, GRIPPER) are in mm; revolute axes (ROTATION, WRIST)
1331+
are in degrees. Z is the rotation-drive-bottom Z (sits 13 mm above the
1332+
gripper finger plane).
1333+
"""
1334+
1335+
X = 1 # X-arm carriage (rotation-drive X in deck coords)
1336+
Y = 2 # Y carriage at rotation drive
1337+
Z = 3 # Z carriage at rotation drive (rotation-drive-bottom Z, deck coords).
1338+
# NOT the grip-center Z - that sits ~13 mm below this plane and only
1339+
# appears in iswap_request_pose().location.z.
1340+
ROTATION = 4 # W joint 1, signed from calibrated FRONT (deg)
1341+
WRIST = 5 # T joint 2, signed from motor zero (deg)
1342+
GRIPPER = 6 # gripper jaw opening width
1343+
1344+
@property
1345+
def is_in_kinematic_chain(self) -> bool:
1346+
"""Whether this axis enters forward kinematics. The gripper is an
1347+
addressable actuator but not a chain member - it changes what is held,
1348+
not where the gripper frame is."""
1349+
return self is not STARBackend.iSWAPAxis.GRIPPER
1350+
1351+
@property
1352+
def is_revolute(self) -> bool:
1353+
"""True for revolute (rotary, deg) axes; False for prismatic (linear, mm)."""
1354+
return self in (STARBackend.iSWAPAxis.ROTATION, STARBackend.iSWAPAxis.WRIST)
1355+
13251356
PIP_X_MIN_WITH_LEFT_SIDE_PANEL: float = 320.0
13261357
HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL: float = 0.0
13271358

@@ -1375,6 +1406,8 @@ def __init__(
13751406
self._iswap_wrist_drive_predefined_increments: Optional[
13761407
Dict[STARBackend.WristDriveOrientation, int]
13771408
] = None
1409+
self._iswap_link_1_length: Optional[float] = None
1410+
self._iswap_link_2_length: Optional[float] = None
13781411
self.core_adjustment = Coordinate.zero()
13791412
self._unsafe = UnSafe(self)
13801413

@@ -1790,6 +1823,9 @@ async def set_up_iswap():
17901823
STARBackend.WristDriveOrientation.REVERSE: wrist_predefined["reverse"],
17911824
}
17921825

1826+
self._iswap_link_1_length = await self.iswap_request_link_1_length()
1827+
self._iswap_link_2_length = await self.iswap_request_link_2_length()
1828+
17931829
async def set_up_core96_head():
17941830
if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head:
17951831
# Initialize 96-head
@@ -10735,6 +10771,120 @@ async def iswap_wrist_drive_rotate_to_angle(
1073510771
wrist_current_limit=current_limit,
1073610772
)
1073710773

10774+
# -----------------------------------------------------------------------
10775+
# iSWAP: Forward Kinematics
10776+
# -----------------------------------------------------------------------
10777+
10778+
@staticmethod
10779+
def _iswap_fk(
10780+
joints: Dict["STARBackend.iSWAPAxis", float],
10781+
link_1_length: float,
10782+
link_2_length: float,
10783+
wrist_straight_angle: float,
10784+
) -> CartesianCoords:
10785+
"""Pure forward-kinematics map: joint state -> gripper pose (no I/O).
10786+
10787+
Takes an `iSWAPAxis`-keyed joint dict and the calibrated link lengths /
10788+
STRAIGHT angle, returns just the gripper-frame pose (grip-center location
10789+
+ yaw). No intermediate frames in the return; callers that need the wrist
10790+
XY can recompute trivially from joints + L1.
10791+
10792+
Sign convention follows right-hand rule about +Z (CCW positive looking
10793+
down). Yaw is the deck-frame direction of link 2:
10794+
`alpha_2 = (W - 90) + (T - T_STRAIGHT)`, with 0 deg = +x deck-right.
10795+
Z: rotation-drive-bottom Z minus `iswap_rotation_drive_z_offset_above_finger_mm`
10796+
(13 mm).
10797+
"""
10798+
rotation_drive_angle = joints[STARBackend.iSWAPAxis.ROTATION]
10799+
wrist_drive_angle = joints[STARBackend.iSWAPAxis.WRIST]
10800+
base_x = joints[STARBackend.iSWAPAxis.X]
10801+
base_y = joints[STARBackend.iSWAPAxis.Y]
10802+
base_z = joints[STARBackend.iSWAPAxis.Z]
10803+
10804+
link_1_deck_angle = rotation_drive_angle - 90.0
10805+
link_2_deck_angle = link_1_deck_angle + (wrist_drive_angle - wrist_straight_angle)
10806+
10807+
alpha_1_rad = math.radians(link_1_deck_angle)
10808+
alpha_2_rad = math.radians(link_2_deck_angle)
10809+
10810+
grip_x = base_x + link_1_length * math.cos(alpha_1_rad) + link_2_length * math.cos(alpha_2_rad)
10811+
grip_y = base_y + link_1_length * math.sin(alpha_1_rad) + link_2_length * math.sin(alpha_2_rad)
10812+
grip_z = base_z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm
10813+
10814+
return CartesianCoords(
10815+
location=Coordinate(x=grip_x, y=grip_y, z=grip_z),
10816+
rotation=Rotation(z=link_2_deck_angle),
10817+
)
10818+
10819+
async def iswap_request_joint_state(self) -> Dict[int, float]:
10820+
"""Snapshot of the iSWAP's current joint state, keyed by `iSWAPAxis`.
10821+
10822+
Composes the per-axis request methods into a single read-through dict.
10823+
Units are axis-implicit (see `iSWAPAxis`): X/Y/Z/GRIPPER in mm, ROTATION
10824+
and WRIST in degrees.
10825+
10826+
Raises:
10827+
RuntimeError: if iSWAP is not installed or if `setup()` has not run
10828+
(the rotation-drive angle reader needs the predefined-stop table).
10829+
"""
10830+
if not self.extended_conf.left_x_drive.iswap_installed:
10831+
raise RuntimeError("iSWAP is not installed")
10832+
10833+
return {
10834+
STARBackend.iSWAPAxis.X: await self.iswap_rotation_drive_request_x(),
10835+
STARBackend.iSWAPAxis.Y: await self.iswap_rotation_drive_request_y(),
10836+
STARBackend.iSWAPAxis.Z: await self.iswap_rotation_drive_request_z(),
10837+
STARBackend.iSWAPAxis.ROTATION: await self.iswap_rotation_drive_request_angle(),
10838+
STARBackend.iSWAPAxis.WRIST: await self.iswap_wrist_drive_request_angle(),
10839+
STARBackend.iSWAPAxis.GRIPPER: await self.iswap_gripper_request_width(),
10840+
}
10841+
10842+
async def iswap_request_pose(self) -> CartesianCoords:
10843+
"""Compute the iSWAP gripper pose via forward kinematics.
10844+
10845+
FK-based alternative to `request_iswap_position` (C0 QG), which is
10846+
firmware-state-dependent and only returns correct values after certain
10847+
preceding commands. Reads the joint state directly via
10848+
`iswap_request_joint_state` and runs `_iswap_fk` against the link lengths
10849+
cached during `setup()`.
10850+
10851+
Returns:
10852+
`CartesianCoords` with `location` = grip-center deck coordinates (mm) and
10853+
`rotation.z` = gripper yaw (deg, deck-frame, 0 = +x; `rotation.x`/`.y` = 0
10854+
since the gripper plane stays parallel to the deck).
10855+
10856+
Raises:
10857+
RuntimeError: if iSWAP is not installed or if `setup()` has not populated
10858+
the wrist STRAIGHT calibration / cached link lengths.
10859+
"""
10860+
if (
10861+
self._iswap_wrist_drive_predefined_increments is None
10862+
or self._iswap_link_1_length is None
10863+
or self._iswap_link_2_length is None
10864+
):
10865+
raise RuntimeError(
10866+
"iSWAP setup state not loaded (wrist predefined positions / link lengths); "
10867+
"ensure the iSWAP is installed and `setup()` has run."
10868+
)
10869+
10870+
wrist_straight_increments = self._iswap_wrist_drive_predefined_increments[
10871+
STARBackend.WristDriveOrientation.STRAIGHT
10872+
]
10873+
wrist_straight_angle = STARBackend._iswap_wrist_drive_increments_to_angle(
10874+
wrist_straight_increments
10875+
)
10876+
10877+
joints = {
10878+
STARBackend.iSWAPAxis(k): v for k, v in (await self.iswap_request_joint_state()).items()
10879+
}
10880+
10881+
return STARBackend._iswap_fk(
10882+
joints=joints,
10883+
link_1_length=self._iswap_link_1_length,
10884+
link_2_length=self._iswap_link_2_length,
10885+
wrist_straight_angle=wrist_straight_angle,
10886+
)
10887+
1073810888
# -----------------------------------------------------------------------
1073910889
# iSWAP: Combined Rotation-Wrist Moves
1074010890
# -----------------------------------------------------------------------

pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import unittest.mock
66
from typing import Literal, cast
77

8+
from pylabrobot.arms.standard import CartesianCoords
89
from pylabrobot.liquid_handling import LiquidHandler
910
from pylabrobot.liquid_handling.standard import GripDirection, Pickup
1011
from pylabrobot.plate_reading import PlateReader
@@ -147,6 +148,157 @@ def _any_write_and_read_command_call(cmd):
147148
)
148149

149150

151+
class TestiSWAPForwardKinematics(unittest.TestCase):
152+
"""Geometry of `STARBackend._iswap_fk` (pure FK, no I/O).
153+
154+
Verifies the canonical (W, T) configurations against the docstring examples
155+
in `request_iswap_wrist_drive_orientation`:
156+
- W=FRONT (0) + T=STRAIGHT -> arm extends in -y (front of deck)
157+
- W=LEFT (-90) + T=STRAIGHT -> arm extends in -x (left of deck)
158+
- W=RIGHT(+90) + T=STRAIGHT -> arm extends in +x (right of deck)
159+
- W=FRONT (0) + T=RIGHT -> arm tip ends up to the left (-x) of rot. drive
160+
Uses factory-default link lengths (138 mm each) and the factory-default
161+
STRAIGHT angle (~-45 deg). Asserts only on the public pose contract
162+
(`location` + `rotation.z`).
163+
"""
164+
165+
L1 = 138.0
166+
L2 = 138.0
167+
T_STRAIGHT = -45.0 # factory-default STRAIGHT calibration (EEPROM-dependent in practice)
168+
Z_OFFSET = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm
169+
BASE_X, BASE_Y, BASE_Z = 100.0, 500.0, 200.0
170+
# Gripper jaw width: hardware range ~71-134 mm (drive min/max increments).
171+
# FK doesn't read this axis, but the joint dict represents full state, so
172+
# use a realistic mid-range plate-grip value.
173+
GRIPPER_WIDTH = 90.0
174+
175+
def _fk(self, w: float, t: float) -> CartesianCoords:
176+
joints = {
177+
STARBackend.iSWAPAxis.X: self.BASE_X,
178+
STARBackend.iSWAPAxis.Y: self.BASE_Y,
179+
STARBackend.iSWAPAxis.Z: self.BASE_Z,
180+
STARBackend.iSWAPAxis.ROTATION: w,
181+
STARBackend.iSWAPAxis.WRIST: t,
182+
STARBackend.iSWAPAxis.GRIPPER: self.GRIPPER_WIDTH,
183+
}
184+
return STARBackend._iswap_fk(
185+
joints=joints,
186+
link_1_length=self.L1,
187+
link_2_length=self.L2,
188+
wrist_straight_angle=self.T_STRAIGHT,
189+
)
190+
191+
def test_front_straight_extends_in_minus_y(self):
192+
pose = self._fk(w=0.0, t=self.T_STRAIGHT)
193+
self.assertAlmostEqual(pose.location.x, self.BASE_X, places=6)
194+
self.assertAlmostEqual(pose.location.y, self.BASE_Y - (self.L1 + self.L2), places=6)
195+
self.assertAlmostEqual(pose.location.z, self.BASE_Z - self.Z_OFFSET, places=6)
196+
self.assertAlmostEqual(pose.rotation.z, -90.0, places=6)
197+
198+
def test_left_straight_extends_in_minus_x(self):
199+
pose = self._fk(w=-90.0, t=self.T_STRAIGHT)
200+
self.assertAlmostEqual(pose.location.x, self.BASE_X - (self.L1 + self.L2), places=6)
201+
self.assertAlmostEqual(pose.location.y, self.BASE_Y, places=6)
202+
self.assertAlmostEqual(pose.rotation.z, -180.0, places=6)
203+
204+
def test_front_right_extends_in_minus_x(self):
205+
"""W=FRONT + T=RIGHT (-135 deg motor) -> gripper points to deck-left."""
206+
pose = self._fk(w=0.0, t=-135.0)
207+
# Link 1 points -y from base; link 2 is bent right (CW by 90 deg) -> points -x.
208+
self.assertAlmostEqual(pose.location.x, self.BASE_X - self.L2, places=6)
209+
self.assertAlmostEqual(pose.location.y, self.BASE_Y - self.L1, places=6)
210+
self.assertAlmostEqual(pose.rotation.z, -180.0, places=6)
211+
212+
def test_reverse_folds_arm_back_onto_base_xy(self):
213+
"""T=REVERSE (+135) folds link 2 back 180 deg from link 1 -> tip XY = base XY."""
214+
pose = self._fk(w=0.0, t=+135.0)
215+
self.assertAlmostEqual(pose.location.x, self.BASE_X, places=6)
216+
self.assertAlmostEqual(pose.location.y, self.BASE_Y, places=6)
217+
218+
219+
class TestiSWAPAxisPredicates(unittest.TestCase):
220+
"""Predicates on `STARBackend.iSWAPAxis` classify axes by kinematic role / unit."""
221+
222+
def test_is_in_kinematic_chain(self):
223+
Axis = STARBackend.iSWAPAxis
224+
for a in (Axis.X, Axis.Y, Axis.Z, Axis.ROTATION, Axis.WRIST):
225+
self.assertTrue(a.is_in_kinematic_chain, f"{a.name} should be in the chain")
226+
self.assertFalse(Axis.GRIPPER.is_in_kinematic_chain, "GRIPPER should NOT be in the chain")
227+
228+
229+
class TestiSWAPRequestJointState(unittest.IsolatedAsyncioTestCase):
230+
"""`iswap_request_joint_state` composes the per-axis request methods into one dict."""
231+
232+
def _make_backend(self) -> STARBackend:
233+
b = STARBackend()
234+
b._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION
235+
b.iswap_rotation_drive_request_x = unittest.mock.AsyncMock(return_value=100.0)
236+
b.iswap_rotation_drive_request_y = unittest.mock.AsyncMock(return_value=500.0)
237+
b.iswap_rotation_drive_request_z = unittest.mock.AsyncMock(return_value=200.0)
238+
b.iswap_rotation_drive_request_angle = unittest.mock.AsyncMock(return_value=0.0)
239+
b.iswap_wrist_drive_request_angle = unittest.mock.AsyncMock(return_value=-45.0)
240+
b.iswap_gripper_request_width = unittest.mock.AsyncMock(return_value=90.0)
241+
return b
242+
243+
async def test_returns_full_axis_dict(self):
244+
b = self._make_backend()
245+
joints = await b.iswap_request_joint_state()
246+
Axis = STARBackend.iSWAPAxis
247+
self.assertEqual(
248+
joints,
249+
{
250+
Axis.X: 100.0,
251+
Axis.Y: 500.0,
252+
Axis.Z: 200.0,
253+
Axis.ROTATION: 0.0,
254+
Axis.WRIST: -45.0,
255+
Axis.GRIPPER: 90.0,
256+
},
257+
)
258+
259+
260+
class TestiSWAPRequestPose(unittest.IsolatedAsyncioTestCase):
261+
"""`iswap_request_pose` reads joints + runs FK against the cached link lengths."""
262+
263+
def _make_backend(self) -> STARBackend:
264+
b = STARBackend()
265+
b._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION
266+
b._iswap_link_1_length = 138.0
267+
b._iswap_link_2_length = 138.0
268+
b._iswap_wrist_drive_predefined_increments = {
269+
STARBackend.WristDriveOrientation.RIGHT: -26577,
270+
STARBackend.WristDriveOrientation.STRAIGHT: -8859,
271+
STARBackend.WristDriveOrientation.LEFT: 8859,
272+
STARBackend.WristDriveOrientation.REVERSE: 26577,
273+
}
274+
b.iswap_rotation_drive_request_x = unittest.mock.AsyncMock(return_value=100.0)
275+
b.iswap_rotation_drive_request_y = unittest.mock.AsyncMock(return_value=500.0)
276+
b.iswap_rotation_drive_request_z = unittest.mock.AsyncMock(return_value=200.0)
277+
b.iswap_rotation_drive_request_angle = unittest.mock.AsyncMock(return_value=0.0)
278+
b.iswap_wrist_drive_request_angle = unittest.mock.AsyncMock(return_value=-45.0)
279+
b.iswap_gripper_request_width = unittest.mock.AsyncMock(return_value=90.0)
280+
return b
281+
282+
async def test_front_straight_pose(self):
283+
"""Canonical W=0 / T=-45 / base=(100, 500, 200) -> grip ~ (100, 224, 187), yaw ~ -90°.
284+
285+
Verifies the full I/O path (per-axis reads -> joint state -> FK -> pose). EEPROM
286+
STRAIGHT (-8859 incr) maps to ~-45.0007 deg, so the canonical -90° yaw lands at
287+
-89.999° and grip x picks up a ~0.002 mm offset - this is intentional per-machine
288+
calibration drift, not an FK bug.
289+
"""
290+
b = self._make_backend()
291+
pose = await b.iswap_request_pose()
292+
self.assertIsInstance(pose, CartesianCoords)
293+
self.assertAlmostEqual(pose.location.x, 100.0, places=2)
294+
self.assertAlmostEqual(pose.location.y, 224.0, places=3)
295+
self.assertAlmostEqual(pose.location.z, 187.0, places=6)
296+
self.assertAlmostEqual(pose.rotation.z, -90.0, places=2)
297+
# rotation.x / y are always 0 - the gripper plane stays parallel to the deck.
298+
self.assertEqual(pose.rotation.x, 0.0)
299+
self.assertEqual(pose.rotation.y, 0.0)
300+
301+
150302
class TestSTARUSBComms(unittest.IsolatedAsyncioTestCase):
151303
"""Test that USB data is parsed correctly."""
152304

0 commit comments

Comments
 (0)