From 604d3ea555d711e5af831df77083590166ba6f5b Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 27 Apr 2026 19:35:15 +0100 Subject: [PATCH 1/8] build iswap_rotation_drive_request_position --- .../backends/hamilton/STAR_backend.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 3d0857461dd..a897d1c6cf1 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9999,6 +9999,59 @@ 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 = request_iswap_z_position() + 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 not None: + raise ValueError("Call setup() first") + + 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_z_position() + + 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######") From da4a323aecf5a183714d769aabcd5a4c874d5551 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 27 Apr 2026 19:38:23 +0100 Subject: [PATCH 2/8] add caching during iSWAP setup --- .../liquid_handling/backends/hamilton/STAR_backend.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index a897d1c6cf1..8006fcc9e06 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1367,6 +1367,7 @@ def __init__( self._extended_conf: Optional[ExtendedConfiguration] = None self._channel_traversal_height: float = 245.0 self._iswap_traversal_height: float = 280.0 + self._iswap_rotation_drive_x_offset_mm: Optional[float] = None self.core_adjustment = Coordinate.zero() self._unsafe = UnSafe(self) @@ -1763,6 +1764,12 @@ async def set_up_iswap(): minimum_traverse_height_at_beginning_of_a_command=int(self._iswap_traversal_height * 10) ) + 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() + ) + + async def set_up_core96_head(): if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: # Initialize 96-head From e775ee78f0631ed86c649af57b08c5344eb7a5fc Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 27 Apr 2026 19:45:10 +0100 Subject: [PATCH 3/8] Self-contain `iswap_rotation_drive_request_position` --- .../liquid_handling/backends/hamilton/STAR_backend.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 8006fcc9e06..32e5bcfbec6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1769,7 +1769,6 @@ async def set_up_iswap(): await self._iswap_rotation_drive_request_x_offset() ) - async def set_up_core96_head(): if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: # Initialize 96-head @@ -10045,13 +10044,13 @@ async def iswap_rotation_drive_request_position(self) -> Coordinate: 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 not None: + + if self._iswap_rotation_drive_x_offset_mm is None: raise ValueError("Call setup() first") - + 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_z_position() + finger_plane_z = (await self.request_iswap_position()).z return Coordinate( x=x_arm_center - self._iswap_rotation_drive_x_offset_mm, From 087802000c8b53e05374bc00e70c9a6be17744c7 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 27 Apr 2026 19:59:08 +0100 Subject: [PATCH 4/8] Widen iSWAP rotation/wrist orientation tolerances (nearest-neighbour, ~5 deg) --- .../backends/hamilton/STAR_backend.py | 139 ++++++++++++------ 1 file changed, 90 insertions(+), 49 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 32e5bcfbec6..c2fb27afd66 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10064,37 +10064,58 @@ async def request_iswap_rotation_drive_position_increments(self) -> int: return cast(int, response["rw"]) async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": - """ - Request the iSWAP rotation drive orientation. - This is the orientation of the iSWAP rotation drive (relative to the machine). - - Uses empirically determined increment values: - FRONT: -25 ± 50 - RIGHT: +29068 ± 50 - LEFT: -29116 ± 50 + """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). - """ - # Map motor increments to rotation orientations (constant lookup table). - rotation_orientation_to_motor_increment_dict = { - STARBackend.RotationDriveOrientation.FRONT: range(-75, 26), - STARBackend.RotationDriveOrientation.RIGHT: range(29018, 29119), - STARBackend.RotationDriveOrientation.LEFT: range(-29166, -29065), - STARBackend.RotationDriveOrientation.PARKED_RIGHT: range(29450, 29550), - # TODO: add range for STAR(let)s with "PARKED_LEFT" setting + 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() - for orientation, increment_range in rotation_orientation_to_motor_increment_dict.items(): - if motor_position_increments in increment_range: - return orientation - - raise ValueError( - f"Unknown rotation orientation: {motor_position_increments}. " - f"Expected one of {list(rotation_orientation_to_motor_increment_dict.values())}." + 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.""" @@ -10102,41 +10123,61 @@ async def request_iswap_wrist_drive_position_increments(self) -> int: return cast(int, response["rt"]) async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": - """ - Request the iSWAP wrist drive orientation. - This is the orientation of the iSWAP wrist drive (always in relation to the iSWAP arm/rotation drive). + """Request the iSWAP wrist drive orientation (relative to the rotation drive). e.g.: - 1) iSWAP RotationDriveOrientation.FRONT (i.e. pointing to the front of the machine) + iSWAP WristDriveOrientation.STRAIGHT (i.e. wrist is also pointing to the front) - - 2) iSWAP RotationDriveOrientation.LEFT (i.e. pointing to the left of the machine) + iSWAP WristDriveOrientation.STRAIGHT (i.e. wrist is also pointing to the left) - - 3) iSWAP RotationDriveOrientation.FRONT (i.e. pointing to the front of the machine) + iSWAP WristDriveOrientation.RIGHT (i.e. wrist is pointing to the left !) - - The relative wrist orientation is reported as a motor position increment by the STAR firmware. This value is mapped to a `WristDriveOrientation` enum member. + 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 (e.g., RIGHT, STRAIGHT, LEFT, REVERSE). - """ + WristDriveOrientation: The interpreted wrist orientation + (RIGHT, STRAIGHT, LEFT, or REVERSE). - # Map motor increments to wrist orientations (constant lookup table). - wrist_orientation_to_motor_increment_dict = { - STARBackend.WristDriveOrientation.RIGHT: range(-26_627, -26_527), - STARBackend.WristDriveOrientation.STRAIGHT: range(-8_804, -8_704), - STARBackend.WristDriveOrientation.LEFT: range(9_051, 9_151), - STARBackend.WristDriveOrientation.REVERSE: range(26_802, 26_902), + 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() - for orientation, increment_range in wrist_orientation_to_motor_increment_dict.items(): - if motor_position_increments in increment_range: - return orientation - - raise ValueError( - f"Unknown wrist orientation: {motor_position_increments}. " - f"Expected one of {list(wrist_orientation_to_motor_increment_dict)}." + 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, From 81359aa2382fbf7170904b38bd391ed1ea7118a4 Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:32:08 +0100 Subject: [PATCH 5/8] 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 | 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 4f723cdd712..b1c472c5668 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10061,7 +10061,7 @@ async def iswap_rotation_drive_request_position(self) -> Coordinate: Composition: x = request_left_x_arm_position() - _iswap_rotation_drive_x_offset_mm y = iswap_rotation_drive_request_y() - z = request_iswap_z_position() + iswap_rotation_drive_z_offset_above_finger_mm + 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 From 56a8026a29eab213f4987e902397c5da5c87f858 Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:34:23 +0100 Subject: [PATCH 6/8] 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 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index b1c472c5668..3c119e4e3df 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10073,7 +10073,8 @@ async def iswap_rotation_drive_request_position(self) -> Coordinate: raise RuntimeError("iSWAP is not installed") if self._iswap_rotation_drive_x_offset_mm is None: - raise ValueError("Call setup() first") + 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() From 91aabef385f38f05f7242c8c6de5a0aaf001e535 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 27 Apr 2026 20:41:12 +0100 Subject: [PATCH 7/8] `make format` --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 3c119e4e3df..4fe5d9e5ea6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10073,8 +10073,7 @@ async def iswap_rotation_drive_request_position(self) -> Coordinate: 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() + 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() From d747ad3490c2b73b467860aab7b2f562a06b0aeb Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 27 Apr 2026 23:32:39 +0100 Subject: [PATCH 8/8] Drop redundant None guard around iSWAP x-offset cache (Rick's review) --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 4fe5d9e5ea6..f0362978c63 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1764,10 +1764,7 @@ async def set_up_iswap(): minimum_traverse_height_at_beginning_of_a_command=int(self._iswap_traversal_height * 10) ) - 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() - ) + self._iswap_rotation_drive_x_offset_mm = await self._iswap_rotation_drive_request_x_offset() async def set_up_core96_head(): if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: