From 523e523c28677ab655c75589720128795d9764af Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 11:10:17 +0100 Subject: [PATCH 01/13] Add iSWAP per-drive predefined-position readers; reorganize into rotation/wrist/gripper sections --- .../backends/hamilton/STAR_backend.py | 599 +++++++++++------- 1 file changed, 371 insertions(+), 228 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index f0362978c63..eb54c09ce55 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9742,6 +9742,325 @@ async def move_iswap_z(self, z_position: float): allow_splitting=True, ) + # ----------------------------------------------------------------------- + # iSWAP: Rotation Drive (Joint 1) + # ----------------------------------------------------------------------- + + class RotationDriveOrientation(enum.Enum): + LEFT = 1 + FRONT = 2 + RIGHT = 3 + PARKED_RIGHT = None + + async def _iswap_rotation_drive_request_x_offset(self) -> float: + """Read the X-offset i.e. X-axis center <-> iSWAP rotation drive, in mm. + + Stored in the master EEPROM as parameter `kg`. + Default: 34.0 mm, but typically tuned per machine during service calibration. + Required for deriving the iSWAP rotation drive's deck X coordinate from + the X-arm carriage center. + Cached on the backend as `_iswap_rotation_drive_x_offset_mm` during setup. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="C0", command="RA", ra="kg", fmt="kg###") + return cast(int, resp["kg"]) / 10.0 + + async def iswap_rotation_drive_request_x(self) -> float: + """Request iSWAP rotation drive X position (deck coordinates), in mm. + + Computed as `request_left_x_arm_position() - kg` (cached at setup). + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + if self._iswap_rotation_drive_x_offset_mm is None: + self._iswap_rotation_drive_x_offset_mm = await self._iswap_rotation_drive_request_x_offset() + x_arm_center = await self.request_left_x_arm_position() + return x_arm_center - self._iswap_rotation_drive_x_offset_mm + + async def iswap_rotation_drive_request_y(self) -> float: + """Request iSWAP rotation drive Y position (deck coordinates), in mm. + + Reads the linear Y carriage that the rotation joint is mounted on. This is + NOT the gripper finger's Y - the finger position depends on the rotation + drive (W) and wrist (T) angles. Use `iswap_rotation_drive_request_position` + for the rotation drive's full XYZ. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RY", fmt="ry##### (n)") + iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter + return round(STARBackend.y_drive_increment_to_mm(iswap_y_pos), 1) + + # Vertical drop from the iSWAP rotation drive plane to the gripper finger + # plane. R0 RZ is calibrated to the finger plane; the rotation drive sits + # 13 mm above it. + iswap_rotation_drive_z_offset_above_finger_mm = 13.0 + + async def iswap_rotation_drive_request_z(self) -> float: + """Request iSWAP rotation drive Z position (deck coordinates), in mm. + + Adds the 13 mm structural offset to the gripper finger plane (C0 QG). + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + finger_plane_z = (await self.request_iswap_position()).z + return finger_plane_z + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + + async def iswap_rotation_drive_request_position(self) -> Coordinate: + """Position of the iSWAP rotation drive (joint 1) in deck coordinates, mm.""" + return Coordinate( + x=await self.iswap_rotation_drive_request_x(), + y=await self.iswap_rotation_drive_request_y(), + z=await self.iswap_rotation_drive_request_z(), + ) + + async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, int]: + """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. + + Sends R0 RA ra=pw. Firmware returns 10 signed-integer slots; documented + slots get semantic names, user-programmable slots are exposed under + firmware-mnemonic names (`wp5..wp8`) so they can be addressed via + R0 WP wp# without translation. + + Keys (all values are signed motor increments; W-drive resolution + 0.00310 deg/incr): + "home" pw[0] - home position + "w1" pw[1] - LEFT deck position (~ -90 deg) + "w2" pw[2] - FRONT deck position (~ 0 deg) + "w3" pw[3] - RIGHT deck position (~ +90 deg) + "parking" pw[4] - past-W3 parking pose (firmware requires > iw + 50) + "extra_1" pw[5] - extra slot, address via R0 WP wp5 + "extra_2" pw[6] - extra slot, address via R0 WP wp6 + "extra_3" pw[7] - extra slot, address via R0 WP wp7 + "extra_4" pw[8] - extra slot, address via R0 WP wp8 + "arm_length" pw[9] - W arm-length offset + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pw", fmt="pw##### (n)") + pw = cast(List[int], resp["pw"]) + return { + "home": pw[0], + "w1": pw[1], + "w2": pw[2], + "w3": pw[3], + "parking": pw[4], + "extra_1": pw[5], + "extra_2": pw[6], + "extra_3": pw[7], + "extra_4": pw[8], + "arm_length": pw[9], + } + + async def request_iswap_rotation_drive_position_increments(self) -> int: + """Query the iSWAP rotation drive position (units: increments) from the firmware.""" + response = await self.send_command(module="R0", command="RW", fmt="rw######") + return cast(int, response["rw"]) + + async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": + """Request the iSWAP rotation drive orientation. + + Uses nearest-neighbour classification against firmware default `pw` + values. Each machine's EEPROM stores its own `pw` adjustment so the + actual stop position can drift by up to a few hundred increments per + machine; an earlier implementation used +/-50 windows and faulted on + machines calibrated outside that band. We now pick whichever predefined + stop is closest and only raise if the drive is more than ~5 deg + (~1700 incr) from any of them, which catches "drive is mid-transit / + undefined" cases without being brittle to per-machine calibration. + + Defaults (W-drive resolution = 0.00310 deg/incr): + LEFT W1 -29068 incr (~ -90 deg) + FRONT W2 +0 incr (~ 0 deg) + RIGHT W3 +29068 incr (~ +90 deg) + PARKED_RIGHT park +29500 incr (~ +91 deg, beyond W3 at the stop) + + Returns: + RotationDriveOrientation: The interpreted rotation orientation + (LEFT, FRONT, RIGHT, or PARKED_RIGHT). + + Raises: + ValueError: if the measured position is more than 1700 incr (~5 deg) + from any predefined stop (drive is in transit or drifted). + """ + # Nearest-neighbour reference positions (firmware `pw` defaults). + # PARKED_RIGHT is kept as a distinct neighbour so we can report "parked" + # explicitly when the drive sits at the parking stop rather than the W3 + # work stop. + # TODO: add PARKED_LEFT reference for STAR(let)s that park on the left. + rotation_reference_positions = { + STARBackend.RotationDriveOrientation.LEFT: -29068, + STARBackend.RotationDriveOrientation.FRONT: 0, + STARBackend.RotationDriveOrientation.RIGHT: 29068, + STARBackend.RotationDriveOrientation.PARKED_RIGHT: 29500, + } + tolerance_incr = 1700 # ~5 deg at 0.00310 deg/incr (iSWAP W-drive resolution) + + motor_position_increments = await self.request_iswap_rotation_drive_position_increments() + + orientation, offset = min( + ((o, abs(p - motor_position_increments)) for o, p in rotation_reference_positions.items()), + key=lambda pair: pair[1], + ) + if offset > tolerance_incr: + raise ValueError( + f"Unknown rotation orientation: {motor_position_increments} incr is " + f"{offset} incr (~{offset * 0.00310:.2f} deg) from the nearest predefined " + f"stop ({orientation.name} at {rotation_reference_positions[orientation]}). " + "Is the rotation drive in transit or mis-calibrated?" + ) + return orientation + + async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientation): + """Rotate the iSWAP rotation drive to a predefined working stop (R0 WP). + + Args: + orientation: must be LEFT, FRONT, or RIGHT. PARKED_RIGHT is not + accepted; use `park_iswap()` for parking. + + Raises: + ValueError: if orientation is not LEFT, FRONT, or RIGHT. + """ + if orientation in { + STARBackend.RotationDriveOrientation.RIGHT, + STARBackend.RotationDriveOrientation.FRONT, + STARBackend.RotationDriveOrientation.LEFT, + }: + return await self.send_command( + module="R0", + command="WP", + auto_id=False, + wp=orientation.value, + ) + else: + raise ValueError(f"Invalid rotation drive orientation: {orientation}") + + # ----------------------------------------------------------------------- + # iSWAP: Wrist Drive (Joint 2) + # ----------------------------------------------------------------------- + + class WristDriveOrientation(enum.Enum): + RIGHT = 1 + STRAIGHT = 2 + LEFT = 3 + REVERSE = 4 + + async def request_iswap_wrist_drive_position_increments(self) -> int: + """Query the iSWAP wrist drive position (units: increments) from the firmware.""" + response = await self.send_command(module="R0", command="RT", fmt="rt######") + return cast(int, response["rt"]) + + async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, int]: + """Read the iSWAP wrist twist drive (T) predefined-position table from EEPROM. + + Sends R0 RA ra=pt. Firmware returns 10 signed-integer slots; documented + slots get semantic names, user-programmable slots are exposed under + firmware-mnemonic names (`tp6..tp8`) so they can be addressed via + R0 TP tp# without translation. + + Keys (all values are signed motor increments; T-drive resolution + 0.00508 deg/incr): + "home" pt[0] - home position + "t1" pt[1] - FRONT plate grip direction (~ -135 deg) + "t2" pt[2] - RIGHT plate grip direction (~ -45 deg) + "t3" pt[3] - BACK plate grip direction (~ +45 deg) + "t4" pt[4] - LEFT plate grip direction (~ +135 deg) + "parking" pt[5] - free pip channel + parking pose (firmware requires < it - 50) + "extra_1" pt[6] - extra slot, address via R0 TP tp6 + "extra_2" pt[7] - extra slot, address via R0 TP tp7 + "extra_3" pt[8] - extra slot, address via R0 TP tp8 + "gripper_length" pt[9] - gripper-length offset + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pt", fmt="pt##### (n)") + pt = cast(List[int], resp["pt"]) + return { + "home": pt[0], + "t1": pt[1], + "t2": pt[2], + "t3": pt[3], + "t4": pt[4], + "parking": pt[5], + "extra_1": pt[6], + "extra_2": pt[7], + "extra_3": pt[8], + "gripper_length": pt[9], + } + + async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": + """Request the iSWAP wrist drive orientation (relative to the rotation drive). + + e.g.: + 1) RotationDriveOrientation.FRONT + WristDriveOrientation.STRAIGHT + => wrist also points to the front of the machine. + 2) RotationDriveOrientation.LEFT + WristDriveOrientation.STRAIGHT + => wrist also points to the left of the machine. + 3) RotationDriveOrientation.FRONT + WristDriveOrientation.RIGHT + => wrist points to the left of the machine. + + Uses nearest-neighbour classification against firmware default `pt` + values. Each machine's EEPROM stores its own `pt` adjustment so the + actual stop position can drift by up to a few hundred increments per + machine; an earlier implementation used +/-50 windows and faulted on + machines calibrated outside that band. We now pick whichever predefined + stop is closest and only raise if the wrist is more than ~5 deg + (~1000 incr) from any of them. + + Defaults (T-drive resolution = 0.00508 deg/incr): + RIGHT T1 -26577 incr (~ -135 deg) + STRAIGHT T2 -8859 incr (~ -45 deg) + LEFT T3 +8859 incr (~ +45 deg) + REVERSE T4 +26577 incr (~ +135 deg) + + Returns: + WristDriveOrientation: The interpreted wrist orientation + (RIGHT, STRAIGHT, LEFT, or REVERSE). + + Raises: + ValueError: if the measured position is more than 1000 incr (~5 deg) + from any predefined stop (drive is in transit or drifted). + """ + # Nearest-neighbour reference positions (firmware `pt` defaults). + wrist_reference_positions = { + STARBackend.WristDriveOrientation.RIGHT: -26577, + STARBackend.WristDriveOrientation.STRAIGHT: -8859, + STARBackend.WristDriveOrientation.LEFT: 8859, + STARBackend.WristDriveOrientation.REVERSE: 26577, + } + tolerance_incr = 1000 # ~5 deg at 0.00508 deg/incr (iSWAP T-drive resolution) + + motor_position_increments = await self.request_iswap_wrist_drive_position_increments() + + orientation, offset = min( + ((o, abs(p - motor_position_increments)) for o, p in wrist_reference_positions.items()), + key=lambda pair: pair[1], + ) + if offset > tolerance_incr: + raise ValueError( + f"Unknown wrist orientation: {motor_position_increments} incr is " + f"{offset} incr (~{offset * 0.00508:.2f} deg) from the nearest predefined " + f"stop ({orientation.name} at {wrist_reference_positions[orientation]}). " + "Is the wrist drive in transit or mis-calibrated?" + ) + return orientation + + async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): + """Rotate the iSWAP wrist drive to a predefined orientation.""" + return await self.send_command( + module="R0", + command="TP", + auto_id=False, + tp=orientation.value, + ) + # ----------------------------------------------------------------------- # iSWAP: Gripper # ----------------------------------------------------------------------- @@ -9750,7 +10069,7 @@ async def move_iswap_z(self, z_position: float): @staticmethod def iswap_gripper_drive_increment_to_mm(value_increments: int) -> float: - return round(value_increments * STARBackend.iswap_gripper_drive_mm_per_increment, 2) + return round(value_increments * STARBackend.iswap_gripper_drive_mm_per_increment, 1) @staticmethod def iswap_gripper_drive_mm_to_increment(value_mm: float) -> int: @@ -9769,7 +10088,58 @@ async def iswap_gripper_request_width(self) -> float: return STARBackend.iswap_gripper_drive_increment_to_mm(actual_increments) + async def request_iswap_gripper_predefined_positions(self) -> Dict[str, int]: + """Read the iSWAP gripper drive (G) predefined-position table. + + Keys (motor increments; G-drive resolution 0.00554 mm/incr): + "home" pg[0] - home & parking + "fully_open" pg[1] - default 24120 = max jaw width + "closed" pg[2] - gripper closed + "plate_type_1" pg[3] - grip plate type 1 + "plate_type_2" pg[4] - grip plate type 2 + "plate_type_3" pg[5] - grip plate type 3 + "plate_type_4" pg[6] - grip plate type 4 + "plate_type_5" pg[7] - grip plate type 5 + "plate_type_6" pg[8] - grip plate type 6 + "plate_type_7" pg[9] - grip plate type 7 + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pg", fmt="pg##### (n)") + pg = cast(List[int], resp["pg"]) + return { + "home": pg[0], + "fully_open": pg[1], + "closed": pg[2], + "plate_type_1": pg[3], + "plate_type_2": pg[4], + "plate_type_3": pg[5], + "plate_type_4": pg[6], + "plate_type_5": pg[7], + "plate_type_6": pg[8], + "plate_type_7": pg[9], + } + + async def request_plate_in_iswap(self) -> bool: + """Request plate in iSWAP + + Returns: + True if holding a plate, False otherwise. + """ + + resp = await self.send_command(module="C0", command="QP", fmt="ph#") + return resp is not None and resp["ph"] == 1 + async def open_not_initialized_gripper(self): + """Initialize the iSWAP gripper drive (C0 GI). + + Required if the gripper drive hasn't been initialized yet. After init, + the drive sits in a known position from which subsequent open/close + commands can operate. + """ return await self.send_command(module="C0", command="GI") async def iswap_open_gripper(self, open_position: Optional[float] = None): @@ -10029,180 +10399,6 @@ async def iswap_put_plate( self._iswap_parked = False return command_output - # ----------------------------------------------------------------------- - # iSWAP: Rotation Drive (Joint 1) - # ----------------------------------------------------------------------- - - async def _iswap_rotation_drive_request_x_offset(self) -> float: - """Read the X-offset i.e. X-axis center <-> iSWAP rotation drive, in mm. - - Stored in the master EEPROM as parameter `kg`. - Default: 34.0 mm, but typically tuned per machine during service calibration. - Required for deriving the iSWAP rotation drive's deck X coordinate from - the X-arm carriage center. - Cached on the backend as `_iswap_rotation_drive_x_offset_mm` during setup. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="C0", command="RA", ra="kg", fmt="kg###") - return cast(int, resp["kg"]) / 10.0 - - # Vertical drop from the iSWAP rotation drive plane to the gripper finger - # plane. R0 RZ is calibrated to the finger plane; the rotation drive sits - # 13 mm above it. - iswap_rotation_drive_z_offset_above_finger_mm = 13.0 - - async def iswap_rotation_drive_request_position(self) -> Coordinate: - """Position of the iSWAP rotation drive (joint 1) in deck coordinates, mm. - - Composition: - x = request_left_x_arm_position() - _iswap_rotation_drive_x_offset_mm - y = iswap_rotation_drive_request_y() - z = (await request_iswap_position()).z + iswap_rotation_drive_z_offset_above_finger_mm - - The Z offset (13 mm) is the structural drop from the rotation drive - plane to the gripper finger plane. R0 RZ is Hamilton-calibrated to - the finger plane, so we add 13 mm to recover the rotation drive's - true Z. - """ - - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - - if self._iswap_rotation_drive_x_offset_mm is None: - self._iswap_rotation_drive_x_offset_mm = await self._iswap_rotation_drive_request_x_offset() - - x_arm_center = await self.request_left_x_arm_position() - rotation_drive_y = await self.iswap_rotation_drive_request_y() - finger_plane_z = (await self.request_iswap_position()).z - - return Coordinate( - x=x_arm_center - self._iswap_rotation_drive_x_offset_mm, - y=rotation_drive_y, - z=finger_plane_z + self.iswap_rotation_drive_z_offset_above_finger_mm, - ) - - async def request_iswap_rotation_drive_position_increments(self) -> int: - """Query the iSWAP rotation drive position (units: increments) from the firmware.""" - response = await self.send_command(module="R0", command="RW", fmt="rw######") - return cast(int, response["rw"]) - - async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": - """Request the iSWAP rotation drive orientation. - - Uses nearest-neighbour classification against firmware default `pw` - values. Each machine's EEPROM stores its own `pw` adjustment so the - actual stop position can drift by up to a few hundred increments per - machine; an earlier implementation used +/-50 windows and faulted on - machines calibrated outside that band. We now pick whichever predefined - stop is closest and only raise if the drive is more than ~5 deg - (~1700 incr) from any of them, which catches "drive is mid-transit / - undefined" cases without being brittle to per-machine calibration. - - Defaults (W-drive resolution = 0.00310 deg/incr): - LEFT W1 -29068 incr (~ -90 deg) - FRONT W2 +0 incr (~ 0 deg) - RIGHT W3 +29068 incr (~ +90 deg) - PARKED_RIGHT park +29500 incr (~ +91 deg, beyond W3 at the stop) - - Returns: - RotationDriveOrientation: The interpreted rotation orientation - (LEFT, FRONT, RIGHT, or PARKED_RIGHT). - - Raises: - ValueError: if the measured position is more than 1700 incr (~5 deg) - from any predefined stop (drive is in transit or drifted). - """ - # Nearest-neighbour reference positions (firmware `pw` defaults). - # PARKED_RIGHT is kept as a distinct neighbour so we can report "parked" - # explicitly when the drive sits at the parking stop rather than the W3 - # work stop. - # TODO: add PARKED_LEFT reference for STAR(let)s that park on the left. - rotation_reference_positions = { - STARBackend.RotationDriveOrientation.LEFT: -29068, - STARBackend.RotationDriveOrientation.FRONT: 0, - STARBackend.RotationDriveOrientation.RIGHT: 29068, - STARBackend.RotationDriveOrientation.PARKED_RIGHT: 29500, - } - tolerance_incr = 1700 # ~5 deg at 0.00310 deg/incr (iSWAP W-drive resolution) - - motor_position_increments = await self.request_iswap_rotation_drive_position_increments() - - orientation, offset = min( - ((o, abs(p - motor_position_increments)) for o, p in rotation_reference_positions.items()), - key=lambda pair: pair[1], - ) - if offset > tolerance_incr: - raise ValueError( - f"Unknown rotation orientation: {motor_position_increments} incr is " - f"{offset} incr (~{offset * 0.00310:.2f} deg) from the nearest predefined " - f"stop ({orientation.name} at {rotation_reference_positions[orientation]}). " - "Is the rotation drive in transit or mis-calibrated?" - ) - return orientation - - async def request_iswap_wrist_drive_position_increments(self) -> int: - """Query the iSWAP wrist drive position (units: increments) from the firmware.""" - response = await self.send_command(module="R0", command="RT", fmt="rt######") - return cast(int, response["rt"]) - - async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": - """Request the iSWAP wrist drive orientation (relative to the rotation drive). - - e.g.: - 1) RotationDriveOrientation.FRONT + WristDriveOrientation.STRAIGHT - => wrist also points to the front of the machine. - 2) RotationDriveOrientation.LEFT + WristDriveOrientation.STRAIGHT - => wrist also points to the left of the machine. - 3) RotationDriveOrientation.FRONT + WristDriveOrientation.RIGHT - => wrist points to the left of the machine. - - Uses nearest-neighbour classification against firmware default `pt` - values. Each machine's EEPROM stores its own `pt` adjustment so the - actual stop position can drift by up to a few hundred increments per - machine; an earlier implementation used +/-50 windows and faulted on - machines calibrated outside that band. We now pick whichever predefined - stop is closest and only raise if the wrist is more than ~5 deg - (~1000 incr) from any of them. - - Defaults (T-drive resolution = 0.00508 deg/incr): - RIGHT T1 -26577 incr (~ -135 deg) - STRAIGHT T2 -8859 incr (~ -45 deg) - LEFT T3 +8859 incr (~ +45 deg) - REVERSE T4 +26577 incr (~ +135 deg) - - Returns: - WristDriveOrientation: The interpreted wrist orientation - (RIGHT, STRAIGHT, LEFT, or REVERSE). - - Raises: - ValueError: if the measured position is more than 1000 incr (~5 deg) - from any predefined stop (drive is in transit or drifted). - """ - # Nearest-neighbour reference positions (firmware `pt` defaults). - wrist_reference_positions = { - STARBackend.WristDriveOrientation.RIGHT: -26577, - STARBackend.WristDriveOrientation.STRAIGHT: -8859, - STARBackend.WristDriveOrientation.LEFT: 8859, - STARBackend.WristDriveOrientation.REVERSE: 26577, - } - tolerance_incr = 1000 # ~5 deg at 0.00508 deg/incr (iSWAP T-drive resolution) - - motor_position_increments = await self.request_iswap_wrist_drive_position_increments() - - orientation, offset = min( - ((o, abs(p - motor_position_increments)) for o, p in wrist_reference_positions.items()), - key=lambda pair: pair[1], - ) - if offset > tolerance_incr: - raise ValueError( - f"Unknown wrist orientation: {motor_position_increments} incr is " - f"{offset} incr (~{offset * 0.00508:.2f} deg) from the nearest predefined " - f"stop ({orientation.name} at {wrist_reference_positions[orientation]}). " - "Is the wrist drive in transit or mis-calibrated?" - ) - return orientation - async def iswap_rotate( self, rotation_drive: "RotationDriveOrientation", @@ -10513,16 +10709,6 @@ async def request_iswap_in_parking_position(self): return await self.send_command(module="C0", command="RG", fmt="rg#") - async def request_plate_in_iswap(self) -> bool: - """Request plate in iSWAP - - Returns: - True if holding a plate, False otherwise. - """ - - resp = await self.send_command(module="C0", command="QP", fmt="ph#") - return resp is not None and resp["ph"] == 1 - async def request_iswap_position(self) -> Coordinate: """Request iSWAP position ( grip center ) @@ -10542,14 +10728,6 @@ async def request_iswap_position(self) -> Coordinate: z=(resp["zj"] / 10) * (1 if resp["zd"] == 0 else -1), ) - async def iswap_rotation_drive_request_y(self) -> float: - """Request iSWAP rotation drive Y position (center) in mm. This is equivalent to the y location of the iSWAP module.""" - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RY", fmt="ry##### (n)") - iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter - return round(STARBackend.y_drive_increment_to_mm(iswap_y_pos), 1) - async def request_iswap_initialization_status(self) -> bool: """Request iSWAP initialization status @@ -11771,41 +11949,6 @@ async def ztouch_probe_z_height_using_channel( return float(result_in_mm) - class RotationDriveOrientation(enum.Enum): - LEFT = 1 - FRONT = 2 - RIGHT = 3 - PARKED_RIGHT = None - - async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientation): - if orientation in { - STARBackend.RotationDriveOrientation.RIGHT, - STARBackend.RotationDriveOrientation.FRONT, - STARBackend.RotationDriveOrientation.LEFT, - }: - return await self.send_command( - module="R0", - command="WP", - auto_id=False, - wp=orientation.value, - ) - else: - raise ValueError(f"Invalid rotation drive orientation: {orientation}") - - class WristDriveOrientation(enum.Enum): - RIGHT = 1 - STRAIGHT = 2 - LEFT = 3 - REVERSE = 4 - - async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): - return await self.send_command( - module="R0", - command="TP", - auto_id=False, - tp=orientation.value, - ) - @staticmethod def channel_id(channel_idx: int) -> str: """channel_idx: plr style, 0-indexed from the back""" From 7020466b75ec9cf9e7453c43a45e1ff5a92c8bdf Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 13:33:40 +0100 Subject: [PATCH 02/13] Expose iSWAP per-drive bound + resolution constants; reference from orientation classifiers --- .../backends/hamilton/STAR_backend.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index eb54c09ce55..dec3975218c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9746,6 +9746,10 @@ async def move_iswap_z(self, z_position: float): # iSWAP: Rotation Drive (Joint 1) # ----------------------------------------------------------------------- + iswap_rotation_drive_min_increment = -30032 # ~ -93 deg + iswap_rotation_drive_max_increment = 30032 # ~ +93 deg + iswap_rotation_drive_deg_per_increment = 0.00309619077 # ~1% per-stop spread on real machines + class RotationDriveOrientation(enum.Enum): LEFT = 1 FRONT = 2 @@ -9898,7 +9902,7 @@ async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrient STARBackend.RotationDriveOrientation.RIGHT: 29068, STARBackend.RotationDriveOrientation.PARKED_RIGHT: 29500, } - tolerance_incr = 1700 # ~5 deg at 0.00310 deg/incr (iSWAP W-drive resolution) + tolerance_incr = 1700 # ~5 deg at iswap_rotation_drive_deg_per_increment motor_position_increments = await self.request_iswap_rotation_drive_position_increments() @@ -9909,7 +9913,8 @@ async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrient if offset > tolerance_incr: raise ValueError( f"Unknown rotation orientation: {motor_position_increments} incr is " - f"{offset} incr (~{offset * 0.00310:.2f} deg) from the nearest predefined " + f"{offset} incr (~{offset * STARBackend.iswap_rotation_drive_deg_per_increment:.2f} deg) " + f"from the nearest predefined " f"stop ({orientation.name} at {rotation_reference_positions[orientation]}). " "Is the rotation drive in transit or mis-calibrated?" ) @@ -9943,6 +9948,10 @@ async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientatio # iSWAP: Wrist Drive (Joint 2) # ----------------------------------------------------------------------- + iswap_wrist_drive_min_increment = -30000 # ~ -152 deg + iswap_wrist_drive_max_increment = 30000 # ~ +152 deg + iswap_wrist_drive_deg_per_increment = 0.00507968798 # ~1% per-stop spread on real machines + class WristDriveOrientation(enum.Enum): RIGHT = 1 STRAIGHT = 2 @@ -10035,7 +10044,7 @@ async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation" STARBackend.WristDriveOrientation.LEFT: 8859, STARBackend.WristDriveOrientation.REVERSE: 26577, } - tolerance_incr = 1000 # ~5 deg at 0.00508 deg/incr (iSWAP T-drive resolution) + tolerance_incr = 1000 # ~5 deg at iswap_wrist_drive_deg_per_increment motor_position_increments = await self.request_iswap_wrist_drive_position_increments() @@ -10046,7 +10055,8 @@ async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation" if offset > tolerance_incr: raise ValueError( f"Unknown wrist orientation: {motor_position_increments} incr is " - f"{offset} incr (~{offset * 0.00508:.2f} deg) from the nearest predefined " + f"{offset} incr (~{offset * STARBackend.iswap_wrist_drive_deg_per_increment:.2f} deg) " + f"from the nearest predefined " f"stop ({orientation.name} at {wrist_reference_positions[orientation]}). " "Is the wrist drive in transit or mis-calibrated?" ) @@ -10065,6 +10075,9 @@ async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): # iSWAP: Gripper # ----------------------------------------------------------------------- + iswap_gripper_drive_min_increment = 12780 # ~ 71 mm + iswap_gripper_drive_max_increment = 24120 # ~ 134 mm + iswap_gripper_drive_mm_per_increment = 0.00554337 @staticmethod From 8942b3f1387d61aca5599bee44592ff632f27926 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 14:28:38 +0100 Subject: [PATCH 03/13] Use enum-aligned keys + mm link lengths in iSWAP predefined-position dicts - Keys now match the RotationDriveOrientation / WristDriveOrientation enum names (left/front/right, right/straight/left/reverse) instead of opaque firmware indices (w1/w2/w3, t1..t4) - Link-length values now in mm (link_1_len, link_2_len), not raw increments --- .../backends/hamilton/STAR_backend.py | 68 +++++++++---------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index dec3975218c..e7985e5a2d8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9819,7 +9819,7 @@ async def iswap_rotation_drive_request_position(self) -> Coordinate: z=await self.iswap_rotation_drive_request_z(), ) - async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, int]: + async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, Union[int, float]]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. Sends R0 RA ra=pw. Firmware returns 10 signed-integer slots; documented @@ -9827,18 +9827,17 @@ async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, i firmware-mnemonic names (`wp5..wp8`) so they can be addressed via R0 WP wp# without translation. - Keys (all values are signed motor increments; W-drive resolution - 0.00310 deg/incr): - "home" pw[0] - home position - "w1" pw[1] - LEFT deck position (~ -90 deg) - "w2" pw[2] - FRONT deck position (~ 0 deg) - "w3" pw[3] - RIGHT deck position (~ +90 deg) - "parking" pw[4] - past-W3 parking pose (firmware requires > iw + 50) - "extra_1" pw[5] - extra slot, address via R0 WP wp5 - "extra_2" pw[6] - extra slot, address via R0 WP wp6 - "extra_3" pw[7] - extra slot, address via R0 WP wp7 - "extra_4" pw[8] - extra slot, address via R0 WP wp8 - "arm_length" pw[9] - W arm-length offset + Keys (motor increments unless noted; W-drive resolution 0.00310 deg/incr): + "home" pw[0] - home position + "left" pw[1] - LEFT deck position (~ -90 deg) + "front" pw[2] - FRONT deck position (~ 0 deg) + "right" pw[3] - RIGHT deck position (~ +90 deg) + "parking" pw[4] - past-W3 parking pose (firmware requires > iw + 50) + "extra_1" pw[5] - extra slot, address via R0 WP wp5 + "extra_2" pw[6] - extra slot, address via R0 WP wp6 + "extra_3" pw[7] - extra slot, address via R0 WP wp7 + "extra_4" pw[8] - extra slot, address via R0 WP wp8 + "link_1_len" pw[9]/10 - link 1 (rotation drive -> wrist) length in mm Raises: RuntimeError: if the iSWAP module is not installed. @@ -9849,15 +9848,15 @@ async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, i pw = cast(List[int], resp["pw"]) return { "home": pw[0], - "w1": pw[1], - "w2": pw[2], - "w3": pw[3], + "left": pw[1], + "front": pw[2], + "right": pw[3], "parking": pw[4], "extra_1": pw[5], "extra_2": pw[6], "extra_3": pw[7], "extra_4": pw[8], - "arm_length": pw[9], + "link_1_len": round(pw[9] / 10, 1), } async def request_iswap_rotation_drive_position_increments(self) -> int: @@ -9963,7 +9962,7 @@ async def request_iswap_wrist_drive_position_increments(self) -> int: response = await self.send_command(module="R0", command="RT", fmt="rt######") return cast(int, response["rt"]) - async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, int]: + async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, Union[int, float]]: """Read the iSWAP wrist twist drive (T) predefined-position table from EEPROM. Sends R0 RA ra=pt. Firmware returns 10 signed-integer slots; documented @@ -9971,18 +9970,17 @@ async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, int] firmware-mnemonic names (`tp6..tp8`) so they can be addressed via R0 TP tp# without translation. - Keys (all values are signed motor increments; T-drive resolution - 0.00508 deg/incr): - "home" pt[0] - home position - "t1" pt[1] - FRONT plate grip direction (~ -135 deg) - "t2" pt[2] - RIGHT plate grip direction (~ -45 deg) - "t3" pt[3] - BACK plate grip direction (~ +45 deg) - "t4" pt[4] - LEFT plate grip direction (~ +135 deg) - "parking" pt[5] - free pip channel + parking pose (firmware requires < it - 50) - "extra_1" pt[6] - extra slot, address via R0 TP tp6 - "extra_2" pt[7] - extra slot, address via R0 TP tp7 - "extra_3" pt[8] - extra slot, address via R0 TP tp8 - "gripper_length" pt[9] - gripper-length offset + Keys (motor increments unless noted; T-drive resolution 0.00508 deg/incr): + "home" pt[0] - home position + "right" pt[1] - wrist twisted right relative to arm (~ -135 deg) + "straight" pt[2] - wrist aligned with arm (~ -45 deg) + "left" pt[3] - wrist twisted left relative to arm (~ +45 deg) + "reverse" pt[4] - wrist twisted 180 deg from straight (~ +135 deg) + "parking" pt[5] - free pip channel + parking pose (firmware requires < it - 50) + "extra_1" pt[6] - extra slot, address via R0 TP tp6 + "extra_2" pt[7] - extra slot, address via R0 TP tp7 + "extra_3" pt[8] - extra slot, address via R0 TP tp8 + "link_2_len" pt[9]/10 - link 2 (wrist -> gripper finger) length in mm Raises: RuntimeError: if the iSWAP module is not installed. @@ -9993,15 +9991,15 @@ async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, int] pt = cast(List[int], resp["pt"]) return { "home": pt[0], - "t1": pt[1], - "t2": pt[2], - "t3": pt[3], - "t4": pt[4], + "right": pt[1], + "straight": pt[2], + "left": pt[3], + "reverse": pt[4], "parking": pt[5], "extra_1": pt[6], "extra_2": pt[7], "extra_3": pt[8], - "gripper_length": pt[9], + "link_2_len": round(pt[9] / 10, 1), } async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": From 813d95498ba48689f157150b6f18aff7477e22a4 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 14:45:39 +0100 Subject: [PATCH 04/13] polish --- .../liquid_handling/backends/hamilton/STAR_backend.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index e7985e5a2d8..e77aaae7657 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9743,12 +9743,12 @@ async def move_iswap_z(self, z_position: float): ) # ----------------------------------------------------------------------- - # iSWAP: Rotation Drive (Joint 1) + # iSWAP: "Rotation Drive" (Joint 1) # ----------------------------------------------------------------------- iswap_rotation_drive_min_increment = -30032 # ~ -93 deg iswap_rotation_drive_max_increment = 30032 # ~ +93 deg - iswap_rotation_drive_deg_per_increment = 0.00309619077 # ~1% per-stop spread on real machines + iswap_rotation_drive_deg_per_increment = 0.00309619077 class RotationDriveOrientation(enum.Enum): LEFT = 1 @@ -9944,12 +9944,12 @@ async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientatio raise ValueError(f"Invalid rotation drive orientation: {orientation}") # ----------------------------------------------------------------------- - # iSWAP: Wrist Drive (Joint 2) + # iSWAP: "Wrist Drive" (Joint 2) # ----------------------------------------------------------------------- iswap_wrist_drive_min_increment = -30000 # ~ -152 deg iswap_wrist_drive_max_increment = 30000 # ~ +152 deg - iswap_wrist_drive_deg_per_increment = 0.00507968798 # ~1% per-stop spread on real machines + iswap_wrist_drive_deg_per_increment = 0.00507968798 class WristDriveOrientation(enum.Enum): RIGHT = 1 From 8cf3c381280b436b2ece489204e72d4a82ed9239 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 15:42:16 +0100 Subject: [PATCH 05/13] address Copilot review --- .../backends/hamilton/STAR_backend.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index e77aaae7657..8a33bc9cc28 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9823,9 +9823,8 @@ async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, U """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. Sends R0 RA ra=pw. Firmware returns 10 signed-integer slots; documented - slots get semantic names, user-programmable slots are exposed under - firmware-mnemonic names (`wp5..wp8`) so they can be addressed via - R0 WP wp# without translation. + slots get semantic names, undocumented slots are returned as + "extra_1".."extra_4" and addressable via R0 WP wp5..wp8. Keys (motor increments unless noted; W-drive resolution 0.00310 deg/incr): "home" pw[0] - home position @@ -9966,9 +9965,8 @@ async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, Unio """Read the iSWAP wrist twist drive (T) predefined-position table from EEPROM. Sends R0 RA ra=pt. Firmware returns 10 signed-integer slots; documented - slots get semantic names, user-programmable slots are exposed under - firmware-mnemonic names (`tp6..tp8`) so they can be addressed via - R0 TP tp# without translation. + slots get semantic names, undocumented slots are returned as + "extra_1".."extra_3" and addressable via R0 TP tp6..tp8. Keys (motor increments unless noted; T-drive resolution 0.00508 deg/incr): "home" pt[0] - home position @@ -10721,14 +10719,14 @@ async def request_iswap_in_parking_position(self): return await self.send_command(module="C0", command="RG", fmt="rg#") async def request_iswap_position(self) -> Coordinate: - """Request iSWAP position ( grip center ) + """Request iSWAP gripper finger center position. Returns: - xs: Hotel center in X direction [1mm] + xs: Gripper finger center in X direction [1mm] xd: X direction 0 = positive 1 = negative - yj: Gripper center in Y direction [1mm] + yj: Gripper finger center in Y direction [1mm] yd: Y direction 0 = positive 1 = negative - zj: Gripper Z height (gripping height) [1mm] + zj: Gripper finger center Z height [1mm] zd: Z direction 0 = positive 1 = negative """ From 0f884c2ba68dadad57033fe8f87aba1e7697415b Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 15:45:39 +0100 Subject: [PATCH 06/13] separate iswap link-length accessors from predefined-position readers --- .../backends/hamilton/STAR_backend.py | 86 ++++++++++++------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 8a33bc9cc28..128ff5268c9 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9819,24 +9819,24 @@ async def iswap_rotation_drive_request_position(self) -> Coordinate: z=await self.iswap_rotation_drive_request_z(), ) - async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, Union[int, float]]: + async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. - Sends R0 RA ra=pw. Firmware returns 10 signed-integer slots; documented - slots get semantic names, undocumented slots are returned as + Sends R0 RA ra=pw. Firmware returns 10 signed-integer slots; the 9 position + slots are returned here. Slot pw[9] (arm length) is exposed separately via + `request_iswap_link_1_length_mm`. Undocumented slots are returned as "extra_1".."extra_4" and addressable via R0 WP wp5..wp8. - Keys (motor increments unless noted; W-drive resolution 0.00310 deg/incr): - "home" pw[0] - home position - "left" pw[1] - LEFT deck position (~ -90 deg) - "front" pw[2] - FRONT deck position (~ 0 deg) - "right" pw[3] - RIGHT deck position (~ +90 deg) - "parking" pw[4] - past-W3 parking pose (firmware requires > iw + 50) - "extra_1" pw[5] - extra slot, address via R0 WP wp5 - "extra_2" pw[6] - extra slot, address via R0 WP wp6 - "extra_3" pw[7] - extra slot, address via R0 WP wp7 - "extra_4" pw[8] - extra slot, address via R0 WP wp8 - "link_1_len" pw[9]/10 - link 1 (rotation drive -> wrist) length in mm + Keys (motor increments; W-drive resolution 0.00310 deg/incr): + "home" pw[0] - home position + "left" pw[1] - LEFT deck position (~ -90 deg) + "front" pw[2] - FRONT deck position (~ 0 deg) + "right" pw[3] - RIGHT deck position (~ +90 deg) + "parking" pw[4] - past-W3 parking pose (firmware requires > iw + 50) + "extra_1" pw[5] - extra slot, address via R0 WP wp5 + "extra_2" pw[6] - extra slot, address via R0 WP wp6 + "extra_3" pw[7] - extra slot, address via R0 WP wp7 + "extra_4" pw[8] - extra slot, address via R0 WP wp8 Raises: RuntimeError: if the iSWAP module is not installed. @@ -9855,9 +9855,22 @@ async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, U "extra_2": pw[6], "extra_3": pw[7], "extra_4": pw[8], - "link_1_len": round(pw[9] / 10, 1), } + async def request_iswap_link_1_length_mm(self) -> float: + """Read iSWAP link 1 length (rotation joint -> wrist joint) in mm. + + Sends R0 RA ra=pw and returns pw[9]/10. Default factory value 138.0 mm. + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pw", fmt="pw##### (n)") + pw = cast(List[int], resp["pw"]) + return round(pw[9] / 10, 1) + async def request_iswap_rotation_drive_position_increments(self) -> int: """Query the iSWAP rotation drive position (units: increments) from the firmware.""" response = await self.send_command(module="R0", command="RW", fmt="rw######") @@ -9961,24 +9974,24 @@ async def request_iswap_wrist_drive_position_increments(self) -> int: response = await self.send_command(module="R0", command="RT", fmt="rt######") return cast(int, response["rt"]) - async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, Union[int, float]]: + async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP wrist twist drive (T) predefined-position table from EEPROM. - Sends R0 RA ra=pt. Firmware returns 10 signed-integer slots; documented - slots get semantic names, undocumented slots are returned as + Sends R0 RA ra=pt. Firmware returns 10 signed-integer slots; the 9 position + slots are returned here. Slot pt[9] (arm length) is exposed separately via + `request_iswap_link_2_length_mm`. Undocumented slots are returned as "extra_1".."extra_3" and addressable via R0 TP tp6..tp8. - Keys (motor increments unless noted; T-drive resolution 0.00508 deg/incr): - "home" pt[0] - home position - "right" pt[1] - wrist twisted right relative to arm (~ -135 deg) - "straight" pt[2] - wrist aligned with arm (~ -45 deg) - "left" pt[3] - wrist twisted left relative to arm (~ +45 deg) - "reverse" pt[4] - wrist twisted 180 deg from straight (~ +135 deg) - "parking" pt[5] - free pip channel + parking pose (firmware requires < it - 50) - "extra_1" pt[6] - extra slot, address via R0 TP tp6 - "extra_2" pt[7] - extra slot, address via R0 TP tp7 - "extra_3" pt[8] - extra slot, address via R0 TP tp8 - "link_2_len" pt[9]/10 - link 2 (wrist -> gripper finger) length in mm + Keys (motor increments; T-drive resolution 0.00508 deg/incr): + "home" pt[0] - home position + "right" pt[1] - wrist twisted right relative to arm (~ -135 deg) + "straight" pt[2] - wrist aligned with arm (~ -45 deg) + "left" pt[3] - wrist twisted left relative to arm (~ +45 deg) + "reverse" pt[4] - wrist twisted 180 deg from straight (~ +135 deg) + "parking" pt[5] - free pip channel + parking pose (firmware requires < it - 50) + "extra_1" pt[6] - extra slot, address via R0 TP tp6 + "extra_2" pt[7] - extra slot, address via R0 TP tp7 + "extra_3" pt[8] - extra slot, address via R0 TP tp8 Raises: RuntimeError: if the iSWAP module is not installed. @@ -9997,9 +10010,22 @@ async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, Unio "extra_1": pt[6], "extra_2": pt[7], "extra_3": pt[8], - "link_2_len": round(pt[9] / 10, 1), } + async def request_iswap_link_2_length_mm(self) -> float: + """Read iSWAP link 2 length (wrist joint -> gripper finger center) in mm. + + Sends R0 RA ra=pt and returns pt[9]/10. Default factory value 138.0 mm. + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pt", fmt="pt##### (n)") + pt = cast(List[int], resp["pt"]) + return round(pt[9] / 10, 1) + async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": """Request the iSWAP wrist drive orientation (relative to the rotation drive). From eeda0abba3e16e811a1330b7cc213a82747be02f Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 15:54:03 +0100 Subject: [PATCH 07/13] organise linkage request section --- .../backends/hamilton/STAR_backend.py | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 128ff5268c9..7a6da799a4a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9742,6 +9742,38 @@ async def move_iswap_z(self, z_position: float): allow_splitting=True, ) + # ----------------------------------------------------------------------- + # iSWAP: SCARA Geometry + # ----------------------------------------------------------------------- + + async def request_iswap_link_1_length_mm(self) -> float: + """Read iSWAP link 1 length (rotation joint -> wrist joint) in mm. + + Sends R0 RA ra=pw and returns pw[9]/10. Default factory value 138.0 mm. + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pw", fmt="pw##### (n)") + pw = cast(List[int], resp["pw"]) + return round(pw[9] / 10, 1) + + async def request_iswap_link_2_length_mm(self) -> float: + """Read iSWAP link 2 length (wrist joint -> gripper finger center) in mm. + + Sends R0 RA ra=pt and returns pt[9]/10. Default factory value 138.0 mm. + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pt", fmt="pt##### (n)") + pt = cast(List[int], resp["pt"]) + return round(pt[9] / 10, 1) + # ----------------------------------------------------------------------- # iSWAP: "Rotation Drive" (Joint 1) # ----------------------------------------------------------------------- @@ -9857,20 +9889,6 @@ async def request_iswap_rotation_drive_predefined_positions(self) -> Dict[str, i "extra_4": pw[8], } - async def request_iswap_link_1_length_mm(self) -> float: - """Read iSWAP link 1 length (rotation joint -> wrist joint) in mm. - - Sends R0 RA ra=pw and returns pw[9]/10. Default factory value 138.0 mm. - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="pw", fmt="pw##### (n)") - pw = cast(List[int], resp["pw"]) - return round(pw[9] / 10, 1) - async def request_iswap_rotation_drive_position_increments(self) -> int: """Query the iSWAP rotation drive position (units: increments) from the firmware.""" response = await self.send_command(module="R0", command="RW", fmt="rw######") @@ -10012,20 +10030,6 @@ async def request_iswap_wrist_drive_predefined_positions(self) -> Dict[str, int] "extra_3": pt[8], } - async def request_iswap_link_2_length_mm(self) -> float: - """Read iSWAP link 2 length (wrist joint -> gripper finger center) in mm. - - Sends R0 RA ra=pt and returns pt[9]/10. Default factory value 138.0 mm. - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="pt", fmt="pt##### (n)") - pt = cast(List[int], resp["pt"]) - return round(pt[9] / 10, 1) - async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": """Request the iSWAP wrist drive orientation (relative to the rotation drive). From 2ee5de3a36a0c1a57cffd7a775995af3ea930511 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 15:55:47 +0100 Subject: [PATCH 08/13] simplify linkage retrieval methods --- .../backends/hamilton/STAR_backend.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 7a6da799a4a..67b84a53cfb 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9746,13 +9746,10 @@ async def move_iswap_z(self, z_position: float): # iSWAP: SCARA Geometry # ----------------------------------------------------------------------- - async def request_iswap_link_1_length_mm(self) -> float: + async def iswap_request_link_1_length(self) -> float: """Read iSWAP link 1 length (rotation joint -> wrist joint) in mm. - Sends R0 RA ra=pw and returns pw[9]/10. Default factory value 138.0 mm. - - Raises: - RuntimeError: if the iSWAP module is not installed. + Default factory value 138.0 mm. """ if not self.extended_conf.left_x_drive.iswap_installed: raise RuntimeError("iSWAP is not installed") @@ -9760,13 +9757,10 @@ async def request_iswap_link_1_length_mm(self) -> float: pw = cast(List[int], resp["pw"]) return round(pw[9] / 10, 1) - async def request_iswap_link_2_length_mm(self) -> float: + async def iswap_request_link_2_length(self) -> float: """Read iSWAP link 2 length (wrist joint -> gripper finger center) in mm. - Sends R0 RA ra=pt and returns pt[9]/10. Default factory value 138.0 mm. - - Raises: - RuntimeError: if the iSWAP module is not installed. + Default factory value 138.0 mm. """ if not self.extended_conf.left_x_drive.iswap_installed: raise RuntimeError("iSWAP is not installed") From 8a661bd7e1c0bf4f8cdfb967b4711b51227886c7 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 17:02:36 +0100 Subject: [PATCH 09/13] expose iswap_rotate_both_joints for parallel joint absolute moves --- .../backends/hamilton/STAR_backend.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 67b84a53cfb..81570d15c63 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10091,6 +10091,79 @@ async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): tp=orientation.value, ) + # ----------------------------------------------------------------------- + # iSWAP: Combined Rotation-Wrist Moves + # ----------------------------------------------------------------------- + + async def iswap_rotate_both_joints( + self, + rotation_position_increments: int, + wrist_position_increments: int, + rotation_velocity_incr_per_sec: int = 55000, + wrist_velocity_incr_per_sec: int = 48000, + rotation_acceleration_kincr_per_sec2: int = 170, + wrist_acceleration_kincr_per_sec2: int = 145, + rotation_current_limit: int = 5, + wrist_current_limit: int = 5, + ) -> None: + """Absolute parallel move of rotation (j01) + wrist (j02) drives. + + Args: + rotation_position_increments: signed rotation-drive destination position, + bounded by the rotation drive envelope. + wrist_position_increments: signed wrist-drive destination position, bounded + by the wrist drive envelope. + rotation_velocity_incr_per_sec: rotation max velocity, range 20..75000. + wrist_velocity_incr_per_sec: wrist max velocity, range 20..65000. + rotation_acceleration_kincr_per_sec2: rotation acceleration in 1000 incr/sec^2, + range 5..200. + wrist_acceleration_kincr_per_sec2: wrist acceleration in 1000 incr/sec^2, + range 5..200. + rotation_current_limit: rotation current protection limiter, range 0..7. + wrist_current_limit: wrist current protection limiter, range 0..7. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + + if abs(rotation_position_increments) > STARBackend.iswap_rotation_drive_max_increment: + raise ValueError( + f"rotation_position_increments must be between " + f"{-STARBackend.iswap_rotation_drive_max_increment} and " + f"{STARBackend.iswap_rotation_drive_max_increment}; got {rotation_position_increments}" + ) + if abs(wrist_position_increments) > STARBackend.iswap_wrist_drive_max_increment: + raise ValueError( + f"wrist_position_increments must be between " + f"{-STARBackend.iswap_wrist_drive_max_increment} and " + f"{STARBackend.iswap_wrist_drive_max_increment}; got {wrist_position_increments}" + ) + + if not 20 <= rotation_velocity_incr_per_sec <= 75000: + raise ValueError("rotation_velocity_incr_per_sec must be in 20..75000") + if not 20 <= wrist_velocity_incr_per_sec <= 65000: + raise ValueError("wrist_velocity_incr_per_sec must be in 20..65000") + if not 5 <= rotation_acceleration_kincr_per_sec2 <= 200: + raise ValueError("rotation_acceleration_kincr_per_sec2 must be in 5..200") + if not 5 <= wrist_acceleration_kincr_per_sec2 <= 200: + raise ValueError("wrist_acceleration_kincr_per_sec2 must be in 5..200") + if not 0 <= rotation_current_limit <= 7: + raise ValueError("rotation_current_limit must be in 0..7") + if not 0 <= wrist_current_limit <= 7: + raise ValueError("wrist_current_limit must be in 0..7") + + await self.send_command( + module="R0", + command="PA", + wa=f"{rotation_position_increments:+06}", + wv=f"{rotation_velocity_incr_per_sec:05}", + wr=f"{rotation_acceleration_kincr_per_sec2:03}", + ww=f"{rotation_current_limit}", + ta=f"{wrist_position_increments:+06}", + tv=f"{wrist_velocity_incr_per_sec:05}", + tr=f"{wrist_acceleration_kincr_per_sec2:03}", + tw=f"{wrist_current_limit}", + ) + # ----------------------------------------------------------------------- # iSWAP: Gripper # ----------------------------------------------------------------------- From 4ee13c09feca58a5591ce90502e98b30097c4f3b Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 18:33:33 +0100 Subject: [PATCH 10/13] simplify arguments --- .../backends/hamilton/STAR_backend.py | 74 ++++++++++--------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 81570d15c63..820633630d7 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10095,29 +10095,29 @@ async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): # iSWAP: Combined Rotation-Wrist Moves # ----------------------------------------------------------------------- - async def iswap_rotate_both_joints( + async def _iswap_rotate_both_joints_in_increments( self, - rotation_position_increments: int, - wrist_position_increments: int, - rotation_velocity_incr_per_sec: int = 55000, - wrist_velocity_incr_per_sec: int = 48000, - rotation_acceleration_kincr_per_sec2: int = 170, - wrist_acceleration_kincr_per_sec2: int = 145, + rotation_position: int, # units: increments + wrist_position: int, # units: increments + rotation_speed: int = 25_000, # units: increments/sec + wrist_speed: int = 20_000, # units: increments/sec + rotation_acceleration: int = 170, # units: increments/sec**2 + wrist_acceleration: int = 145, # units: increments/sec**2 rotation_current_limit: int = 5, wrist_current_limit: int = 5, ) -> None: """Absolute parallel move of rotation (j01) + wrist (j02) drives. Args: - rotation_position_increments: signed rotation-drive destination position, + rotation_position: signed rotation-drive destination position, bounded by the rotation drive envelope. - wrist_position_increments: signed wrist-drive destination position, bounded + wrist_position: signed wrist-drive destination position, bounded by the wrist drive envelope. - rotation_velocity_incr_per_sec: rotation max velocity, range 20..75000. - wrist_velocity_incr_per_sec: wrist max velocity, range 20..65000. - rotation_acceleration_kincr_per_sec2: rotation acceleration in 1000 incr/sec^2, + rotation_speed: rotation max velocity, range 20..75000. + wrist_speed: wrist max velocity, range 20..65000. + rotation_acceleration: rotation acceleration in 1000 incr/sec^2, range 5..200. - wrist_acceleration_kincr_per_sec2: wrist acceleration in 1000 incr/sec^2, + wrist_acceleration: wrist acceleration in 1000 incr/sec^2, range 5..200. rotation_current_limit: rotation current protection limiter, range 0..7. wrist_current_limit: wrist current protection limiter, range 0..7. @@ -10125,42 +10125,46 @@ async def iswap_rotate_both_joints( if not self.extended_conf.left_x_drive.iswap_installed: raise RuntimeError("iSWAP is not installed") - if abs(rotation_position_increments) > STARBackend.iswap_rotation_drive_max_increment: + if abs(rotation_position) > STARBackend.iswap_rotation_drive_max_increment: raise ValueError( - f"rotation_position_increments must be between " + f"rotation_position must be between " f"{-STARBackend.iswap_rotation_drive_max_increment} and " - f"{STARBackend.iswap_rotation_drive_max_increment}; got {rotation_position_increments}" + f"{STARBackend.iswap_rotation_drive_max_increment}; got {rotation_position}" ) - if abs(wrist_position_increments) > STARBackend.iswap_wrist_drive_max_increment: + if abs(wrist_position) > STARBackend.iswap_wrist_drive_max_increment: raise ValueError( - f"wrist_position_increments must be between " + f"wrist_position must be between " f"{-STARBackend.iswap_wrist_drive_max_increment} and " - f"{STARBackend.iswap_wrist_drive_max_increment}; got {wrist_position_increments}" + f"{STARBackend.iswap_wrist_drive_max_increment}; got {wrist_position}" ) - if not 20 <= rotation_velocity_incr_per_sec <= 75000: - raise ValueError("rotation_velocity_incr_per_sec must be in 20..75000") - if not 20 <= wrist_velocity_incr_per_sec <= 65000: - raise ValueError("wrist_velocity_incr_per_sec must be in 20..65000") - if not 5 <= rotation_acceleration_kincr_per_sec2 <= 200: - raise ValueError("rotation_acceleration_kincr_per_sec2 must be in 5..200") - if not 5 <= wrist_acceleration_kincr_per_sec2 <= 200: - raise ValueError("wrist_acceleration_kincr_per_sec2 must be in 5..200") + if not 20 <= rotation_speed <= 75000: + raise ValueError(f"rotation_speed must be between 20 and 75000; got {rotation_speed}") + if not 20 <= wrist_speed <= 65000: + raise ValueError(f"wrist_speed must be between 20 and 65000; got {wrist_speed}") + if not 5 <= rotation_acceleration <= 200: + raise ValueError( + f"rotation_acceleration must be between 5 and 200; got {rotation_acceleration}" + ) + if not 5 <= wrist_acceleration <= 200: + raise ValueError(f"wrist_acceleration must be between 5 and 200; got {wrist_acceleration}") if not 0 <= rotation_current_limit <= 7: - raise ValueError("rotation_current_limit must be in 0..7") + raise ValueError( + f"rotation_current_limit must be between 0 and 7; got {rotation_current_limit}" + ) if not 0 <= wrist_current_limit <= 7: - raise ValueError("wrist_current_limit must be in 0..7") + raise ValueError(f"wrist_current_limit must be between 0 and 7; got {wrist_current_limit}") await self.send_command( module="R0", command="PA", - wa=f"{rotation_position_increments:+06}", - wv=f"{rotation_velocity_incr_per_sec:05}", - wr=f"{rotation_acceleration_kincr_per_sec2:03}", + wa=f"{rotation_position:+06}", + wv=f"{rotation_speed:05}", + wr=f"{rotation_acceleration:03}", ww=f"{rotation_current_limit}", - ta=f"{wrist_position_increments:+06}", - tv=f"{wrist_velocity_incr_per_sec:05}", - tr=f"{wrist_acceleration_kincr_per_sec2:03}", + ta=f"{wrist_position:+06}", + tv=f"{wrist_speed:05}", + tr=f"{wrist_acceleration:03}", tw=f"{wrist_current_limit}", ) From d8400c5a24343fed2c6e1b07d55c8db327944459 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 28 Apr 2026 19:06:31 +0100 Subject: [PATCH 11/13] align error messages and joint notation on rotate_both_joints --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 75f3036181d..c1155c3a150 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10106,7 +10106,7 @@ async def _iswap_rotate_both_joints_in_increments( rotation_current_limit: int = 5, wrist_current_limit: int = 5, ) -> None: - """Absolute parallel move of rotation (j01) + wrist (j02) drives. + """Absolute parallel move of rotation (Joint 1) + wrist (Joint 2) drives. Args: rotation_position [increments]: signed destination, range -30032..+30032. From 5edd53a5e765b73be1cc3dc83da4ef6478f88bd9 Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:19:53 +0100 Subject: [PATCH 12/13] Update pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index c1155c3a150..3562fc2f717 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10101,8 +10101,8 @@ async def _iswap_rotate_both_joints_in_increments( wrist_position: int, # units: increments rotation_speed: int = 25_000, # units: increments/sec wrist_speed: int = 20_000, # units: increments/sec - rotation_acceleration: int = 170, # units: increments/sec**2 - wrist_acceleration: int = 145, # units: increments/sec**2 + rotation_acceleration: int = 170, # units: 1000 increments/sec^2 + wrist_acceleration: int = 145, # units: 1000 increments/sec^2 rotation_current_limit: int = 5, wrist_current_limit: int = 5, ) -> None: From 910f5507577eb9ba3d7c35356b3bbc3eb077aef4 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 28 Apr 2026 12:45:41 -0700 Subject: [PATCH 13/13] =?UTF-8?q?rename=20=5Fiswap=5Frotate=5Fboth=5Fjoint?= =?UTF-8?q?s=5Fin=5Fincrements=20=E2=86=92=20=5Fiswap=5Frotate=5Fincrement?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 4cae5dc9a0d..c572acc2a0e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10095,7 +10095,7 @@ async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): # iSWAP: Combined Rotation-Wrist Moves # ----------------------------------------------------------------------- - async def _iswap_rotate_both_joints_in_increments( + async def _iswap_rotate_increments( self, rotation_position: int, # units: increments wrist_position: int, # units: increments