From 48a66324df2ab1c945bab8c07d45ec5fa35e19fd Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 30 Apr 2026 19:37:19 +0100 Subject: [PATCH 01/21] expose iswap_rotation_drive_move_y --- .../backends/hamilton/STAR_backend.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 7a2dd59ed88..8731015ce56 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9775,6 +9775,8 @@ async def iswap_request_link_2_length(self) -> float: iswap_rotation_drive_min_increment = -30032 # ~ -93 deg iswap_rotation_drive_max_increment = 30032 # ~ +93 deg iswap_rotation_drive_deg_per_increment = 0.00309619077 + iswap_rotation_drive_y_speed_increment_range = (50, 8_000) + iswap_rotation_drive_diameter = 30.5 class RotationDriveOrientation(enum.Enum): LEFT = 1 @@ -9822,6 +9824,85 @@ async def iswap_rotation_drive_request_y(self) -> float: 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 iswap_rotation_drive_move_y( + self, + y: float, + speed: float = 220.0, + acceleration_level: int = 2, + current_protection_limiter: int = 7, + make_space: bool = False, + ): + """Move the iSWAP rotation drive to an absolute Y position (R0 YA). + + Args: + y: Target Y coordinate in mm. + speed: Max velocity in mm/sec. Default 220.0. + acceleration_level: Acceleration index, 1 or 2. Default 2. + current_protection_limiter: Motor current limit, 0-7. Default 7. + make_space: If True, reposition pipetting channels in a single + synchronous JY move when channel 0 is in the way and can be cleared. + If False, raise so the caller decides. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + + iswap_radius = STARBackend.iswap_rotation_drive_diameter / 2 + channel_0_radius = self._channels_minimum_y_spacing[0] / 2 + channel_0_y = await self.request_y_pos_channel_n(0) + + compressed_channel_0_y = self.extended_conf.left_arm_min_y_position + sum( + self._channels_minimum_y_spacing[1:] + ) + + max_y = self.extended_conf.pip_maximal_y_position + absolute_min_y = self.extended_conf.left_arm_min_y_position + if not (absolute_min_y <= y <= max_y): + raise ValueError(f"y must be between {absolute_min_y} and {max_y} mm, got {y} mm") + + target_channel_0_y = y - channel_0_radius - iswap_radius + if channel_0_y > target_channel_0_y: + if target_channel_0_y < compressed_channel_0_y: + raise ValueError( + f"y={y} mm is unreachable: would require channel 0 at " + f"{target_channel_0_y} mm, below the compressed floor " + f"{compressed_channel_0_y} mm" + ) + if not make_space: + raise ValueError( + f"y={y} mm requires channel 0 at <= {target_channel_0_y} mm " + f"(currently {channel_0_y} mm); pass make_space=True to " + f"reposition channels" + ) + await self.move_all_channels_in_z_safety() + await self.position_channels_in_y_direction({0: target_channel_0_y}, make_space=True) + + speed_increments = STARBackend.mm_to_y_drive_increment(speed) + speed_min, speed_max = STARBackend.iswap_rotation_drive_y_speed_increment_range + if not (speed_min <= speed_increments <= speed_max): + raise ValueError( + f"speed must be between " + f"{STARBackend.y_drive_increment_to_mm(speed_min)} and " + f"{STARBackend.y_drive_increment_to_mm(speed_max)} mm/sec, " + f"got {speed} mm/sec" + ) + + if not (1 <= acceleration_level <= 2): + raise ValueError(f"acceleration_level must be between 1 and 2, got {acceleration_level}") + + if not (0 <= current_protection_limiter <= 7): + raise ValueError( + f"current_protection_limiter must be between 0 and 7, got {current_protection_limiter}" + ) + + await self.send_command( + module="R0", + command="YA", + ya=f"{round(STARBackend.mm_to_y_drive_increment(y)):05}", + yv=f"{round(speed_increments):04}", + yr=f"{int(acceleration_level)}", + yw=f"{int(current_protection_limiter)}", + ) + # 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. From 4d121881a8f9139724fffa0060a4d92fc087d171 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 30 Apr 2026 19:47:21 +0100 Subject: [PATCH 02/21] shuffle iswap move y next below move x --- .../backends/hamilton/STAR_backend.py | 118 +++++++++--------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index a59bb89cad4..70928ec4f9b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9871,6 +9871,64 @@ async def iswap_rotation_drive_request_y(self) -> float: 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 experimental_iswap_rotation_drive_move_x( + self, + x: float, + acceleration_level: int = 3, + current_protection_limiter: int = 7, + ): + """Move the iSWAP rotation drive to an absolute X position (deck coordinates). + + Thin wrapper around `x_arm_move` that translates rotation-drive X into + X-arm carriage X using the cached `kg` offset. + + Args: + x: Target rotation-drive X coordinate in mm. + acceleration_level: Acceleration index (hardware units), 1-5. Default 3. + current_protection_limiter: Motor current limit (hardware units), 0-7. Default 7. + """ + # TODO: remove "experimental_" prefix once x_arm_move has been optimised + + 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() + kg = self._iswap_rotation_drive_x_offset_mm + + x_min = 90.0 - kg + x_max = 1350.0 - kg + if not (x_min <= x <= x_max): + raise ValueError(f"x must be between {x_min} and {x_max} mm, is {x}") + + return await self.experimental_x_arm_move( + x=x + kg, + acceleration_level=acceleration_level, + current_protection_limiter=current_protection_limiter, + ) + async def iswap_rotation_drive_move_y( self, y: float, @@ -9879,7 +9937,7 @@ async def iswap_rotation_drive_move_y( current_protection_limiter: int = 7, make_space: bool = False, ): - """Move the iSWAP rotation drive to an absolute Y position (R0 YA). + """Move the iSWAP rotation drive to an absolute Y position. Args: y: Target Y coordinate in mm. @@ -9950,64 +10008,6 @@ async def iswap_rotation_drive_move_y( yw=f"{int(current_protection_limiter)}", ) - # 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 experimental_iswap_rotation_drive_move_x( - self, - x: float, - acceleration_level: int = 3, - current_protection_limiter: int = 7, - ): - """Move the iSWAP rotation drive to an absolute X position (deck coordinates). - - Thin wrapper around `x_arm_move` that translates rotation-drive X into - X-arm carriage X using the cached `kg` offset. - - Args: - x: Target rotation-drive X coordinate in mm. - acceleration_level: Acceleration index (hardware units), 1-5. Default 3. - current_protection_limiter: Motor current limit (hardware units), 0-7. Default 7. - """ - # TODO: remove "experimental_" prefix once x_arm_move has been optimised - - 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() - kg = self._iswap_rotation_drive_x_offset_mm - - x_min = 90.0 - kg - x_max = 1350.0 - kg - if not (x_min <= x <= x_max): - raise ValueError(f"x must be between {x_min} and {x_max} mm, is {x}") - - return await self.experimental_x_arm_move( - x=x + kg, - acceleration_level=acceleration_level, - current_protection_limiter=current_protection_limiter, - ) - async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. From 561bca4206e6a4dfad4cb29df1b9a7d56eb40a60 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 30 Apr 2026 13:02:39 -0700 Subject: [PATCH 03/21] Expose `STARBackend.iswap_rotation_drive_move_z()` Adds the absolute Z-axis sibling to `iswap_rotation_drive_move_x` (#1018) and `iswap_rotation_drive_move_y` (#1020). Wraps `R0 ZA` and accepts the rotation-drive plane Z (matching `iswap_rotation_drive_request_z`); the 13 mm offset to the gripper finger plane is applied internally. `acceleration` is exposed in mm/sec^2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backends/hamilton/STAR_backend.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 70928ec4f9b..9b774aea921 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9875,6 +9875,14 @@ async def iswap_rotation_drive_request_y(self) -> float: # 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 + # R0 ZA firmware spec (SFGT.3112): za=-187..+26661 incr (default 24600), + # zv=50..15000 incr/sec (default 11000), zr=5..999 in 1000 incr/sec^2 units + # (default 60), zw=0..7 (default 6). Position is in finger-plane coords; + # `iswap_rotation_drive_move_z` adds the 13 mm offset for the user. + iswap_rotation_drive_z_min_increment = -187 + iswap_rotation_drive_z_max_increment = 26_661 + iswap_rotation_drive_z_speed_increment_range = (50, 15_000) + iswap_rotation_drive_z_acceleration_increment_range = (5, 999) async def iswap_rotation_drive_request_z(self) -> float: """Request iSWAP rotation drive Z position (deck coordinates), in mm. @@ -10008,6 +10016,80 @@ async def iswap_rotation_drive_move_y( yw=f"{int(current_protection_limiter)}", ) + async def iswap_rotation_drive_move_z( + self, + z: float, + speed: float = 118.0, + acceleration: float = 643.66, + current_protection_limiter: int = 6, + ): + """Move the iSWAP rotation drive to an absolute Z position (deck coordinates). + + `z` is the rotation-drive plane Z, matching what + `iswap_rotation_drive_request_z` returns. The 13 mm offset to the gripper + finger plane (where R0 ZA is calibrated) is applied internally. + + Args: + z: Target rotation-drive Z coordinate in mm. + speed: Max velocity in mm/sec. Default 118.0 (firmware default). + acceleration: Acceleration in mm/sec^2. Default 643.66 + (firmware default 60 in 1000 incr/sec^2 units). + current_protection_limiter: Motor current limit, 0-7. Default 6 + (firmware default). + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + + z_min_incr = STARBackend.iswap_rotation_drive_z_min_increment + z_max_incr = STARBackend.iswap_rotation_drive_z_max_increment + absolute_min_z = ( + STARBackend.z_drive_increment_to_mm(z_min_incr) + + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + ) + absolute_max_z = ( + STARBackend.z_drive_increment_to_mm(z_max_incr) + + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + ) + if not (absolute_min_z <= z <= absolute_max_z): + raise ValueError(f"z must be between {absolute_min_z} and {absolute_max_z} mm, got {z} mm") + + finger_plane_z = z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + z_increments = STARBackend.mm_to_z_drive_increment(finger_plane_z) + + speed_increments = STARBackend.mm_to_z_drive_increment(speed) + speed_min, speed_max = STARBackend.iswap_rotation_drive_z_speed_increment_range + if not (speed_min <= speed_increments <= speed_max): + raise ValueError( + f"speed must be between " + f"{STARBackend.z_drive_increment_to_mm(speed_min)} and " + f"{STARBackend.z_drive_increment_to_mm(speed_max)} mm/sec, " + f"got {speed} mm/sec" + ) + + acceleration_increments = STARBackend.mm_to_z_drive_increment(acceleration / 1000) + accel_min, accel_max = STARBackend.iswap_rotation_drive_z_acceleration_increment_range + if not (accel_min <= acceleration_increments <= accel_max): + raise ValueError( + f"acceleration must be between " + f"{STARBackend.z_drive_increment_to_mm(accel_min * 1000)} and " + f"{STARBackend.z_drive_increment_to_mm(accel_max * 1000)} mm/sec^2, " + f"got {acceleration} mm/sec^2" + ) + + if not (0 <= current_protection_limiter <= 7): + raise ValueError( + f"current_protection_limiter must be between 0 and 7, got {current_protection_limiter}" + ) + + await self.send_command( + module="R0", + command="ZA", + za=f"{round(z_increments):+06}", + zv=f"{round(speed_increments):05}", + zr=f"{round(acceleration_increments):03}", + zw=f"{int(current_protection_limiter)}", + ) + async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. From f6f9bda38adccbec404c895cecae9d3f51cb4354 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 13:39:01 +0100 Subject: [PATCH 04/21] clarify `bottom` return (per LFB PLR expectation) PLR users expect left-front-bottom referencing; for channels this changes to center-center in x-y but in z people still expect bottom; since we are referencing the actual rotation drive here and its bottom is 13 mm above the finger_z_plane I have added a short comment to make this clearer --- .../liquid_handling/backends/hamilton/STAR_backend.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 6f3b1e60833..5d2519756b4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9876,10 +9876,9 @@ async def iswap_rotation_drive_request_y(self) -> float: # 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 - # R0 ZA firmware spec (SFGT.3112): za=-187..+26661 incr (default 24600), - # zv=50..15000 incr/sec (default 11000), zr=5..999 in 1000 incr/sec^2 units - # (default 60), zw=0..7 (default 6). Position is in finger-plane coords; - # `iswap_rotation_drive_move_z` adds the 13 mm offset for the user. + + # Position is in finger-plane coords; `iswap_rotation_drive_move_z` adds + # the 13 mm offset to return the bottom position of the rotation drive. iswap_rotation_drive_z_min_increment = -187 iswap_rotation_drive_z_max_increment = 26_661 iswap_rotation_drive_z_speed_increment_range = (50, 15_000) From a1b6e381c1356a63aeaddf19cc9f4e5309394094 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 13:40:26 +0100 Subject: [PATCH 05/21] `make format` --- 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 5d2519756b4..fca96c26d34 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9876,7 +9876,7 @@ async def iswap_rotation_drive_request_y(self) -> float: # 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 - + # Position is in finger-plane coords; `iswap_rotation_drive_move_z` adds # the 13 mm offset to return the bottom position of the rotation drive. iswap_rotation_drive_z_min_increment = -187 From c91052476b8edbabea50d205e4e99bdf846c0957 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 13:47:58 +0100 Subject: [PATCH 06/21] create `iswap_rotation_drive_request_predefined_z_positions` --- .../backends/hamilton/STAR_backend.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index fca96c26d34..f94ab8d7e8a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10098,6 +10098,45 @@ async def iswap_rotation_drive_move_z( zw=f"{int(current_protection_limiter)}", ) + async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, int]: + """Read the iSWAP rotation-drive Z-axis predefined-position table from EEPROM. + + Sends R0 RA ra=pz. Firmware returns 10 signed-integer slots; all 10 are + positions (no length slot, unlike pw/pt). Slots beyond home/parking are + extra slots addressable via R0 ZP zp2..zp9. + + Keys (motor increments; Z-drive resolution 0.01072765 mm/incr): + "home" pz[0] - home position + "parking" pz[1] - parking pose (firmware requires pz[1] >= iz + 100) + "extra_1" pz[2] - extra slot, address via R0 ZP zp2 + "extra_2" pz[3] - extra slot, address via R0 ZP zp3 + "extra_3" pz[4] - extra slot, address via R0 ZP zp4 + "extra_4" pz[5] - extra slot, address via R0 ZP zp5 + "extra_5" pz[6] - extra slot, address via R0 ZP zp6 + "extra_6" pz[7] - extra slot, address via R0 ZP zp7 + "extra_7" pz[8] - extra slot, address via R0 ZP zp8 + "extra_8" pz[9] - extra slot, address via R0 ZP zp9 + + 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="pz", fmt="pz##### (n)") + pz = cast(List[int], resp["pz"]) + return { + "home": pz[0], + "parking": pz[1], + "extra_1": pz[2], + "extra_2": pz[3], + "extra_3": pz[4], + "extra_4": pz[5], + "extra_5": pz[6], + "extra_6": pz[7], + "extra_7": pz[8], + "extra_8": pz[9], + } + async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. From b9cab7d8f19f2011ad5adba3d5315910c3e3084f Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 13:55:20 +0100 Subject: [PATCH 07/21] create `iswap_rotation_drive_request_predefined_y_positions` --- .../backends/hamilton/STAR_backend.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index f94ab8d7e8a..ef13644079e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10024,6 +10024,45 @@ async def iswap_rotation_drive_move_y( yw=f"{int(current_protection_limiter)}", ) + async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, int]: + """Read the iSWAP rotation-drive Y-axis predefined-position table from EEPROM. + + Sends R0 RA ra=py. Firmware returns 10 signed-integer slots; all 10 are + positions (no length slot, unlike pw/pt). Slots beyond the documented + semantic roles are extra slots addressable via R0 YP yp5..yp9. + + Keys (motor increments; Y-drive resolution 0.046302 mm/incr): + "home" py[0] - home position + "lower_limit" py[1] - lower travel limit + "upper_limit" py[2] - upper travel limit + "parking" py[3] - parking pose (firmware requires py[3] > iy + 100) + "pre_parking" py[4] - pre-parking pose (firmware requires py[4] < py[3] - 430) + "extra_1" py[5] - extra slot, address via R0 YP yp5 + "extra_2" py[6] - extra slot, address via R0 YP yp6 + "extra_3" py[7] - extra slot, address via R0 YP yp7 + "extra_4" py[8] - extra slot, address via R0 YP yp8 + "extra_5" py[9] - extra slot, address via R0 YP yp9 + + 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="py", fmt="py##### (n)") + py = cast(List[int], resp["py"]) + return { + "home": py[0], + "lower_limit": py[1], + "upper_limit": py[2], + "parking": py[3], + "pre_parking": py[4], + "extra_1": py[5], + "extra_2": py[6], + "extra_3": py[7], + "extra_4": py[8], + "extra_5": py[9], + } + async def iswap_rotation_drive_move_z( self, z: float, From 861044024da71f643f8eaf0df19d16256a340c7d Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 14:48:50 +0100 Subject: [PATCH 08/21] add iSWAP Y/Z drive conversions and correct pip Y resolution --- .../backends/hamilton/STAR_backend.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index ef13644079e..d11fa10d5f4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9826,6 +9826,25 @@ async def iswap_request_link_2_length(self) -> float: iswap_rotation_drive_diameter = 30.5 iswap_rotation_drive_safety_radius = 90.0 + iswap_y_drive_mm_per_increment = 0.046302083 + iswap_z_drive_mm_per_increment = 0.01072765 + + @staticmethod + def iswap_y_drive_mm_to_increment(value_mm: float) -> int: + return round(value_mm / STARBackend.iswap_y_drive_mm_per_increment) + + @staticmethod + def iswap_y_drive_increment_to_mm(value_increments: int) -> float: + return round(value_increments * STARBackend.iswap_y_drive_mm_per_increment, 2) + + @staticmethod + def iswap_z_drive_mm_to_increment(value_mm: float) -> int: + return round(value_mm / STARBackend.iswap_z_drive_mm_per_increment) + + @staticmethod + def iswap_z_drive_increment_to_mm(value_increments: int) -> float: + return round(value_increments * STARBackend.iswap_z_drive_mm_per_increment, 2) + class RotationDriveOrientation(enum.Enum): LEFT = 1 FRONT = 2 @@ -11235,7 +11254,7 @@ async def request_cover_open(self) -> bool: # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- - y_drive_mm_per_increment = 0.046302082 + y_drive_mm_per_increment = 0.046302083 z_drive_mm_per_increment = 0.01072765 dispensing_drive_vol_per_increment = 0.046876 # uL / increment From de21aa6f2cee5c771699775d30d7a00dd60a2d41 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 15:20:16 +0100 Subject: [PATCH 09/21] tighten iswap_rotation_drive_move_z error messages and document Raises --- .../backends/hamilton/STAR_backend.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index d11fa10d5f4..4ad95819f8a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10050,7 +10050,7 @@ async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, positions (no length slot, unlike pw/pt). Slots beyond the documented semantic roles are extra slots addressable via R0 YP yp5..yp9. - Keys (motor increments; Y-drive resolution 0.046302 mm/incr): + Keys (motor increments; see `iswap_y_drive_mm_per_increment`): "home" py[0] - home position "lower_limit" py[1] - lower travel limit "upper_limit" py[2] - upper travel limit @@ -10102,6 +10102,9 @@ async def iswap_rotation_drive_move_z( (firmware default 60 in 1000 incr/sec^2 units). current_protection_limiter: Motor current limit, 0-7. Default 6 (firmware default). + + 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") @@ -10117,7 +10120,7 @@ async def iswap_rotation_drive_move_z( + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm ) if not (absolute_min_z <= z <= absolute_max_z): - raise ValueError(f"z must be between {absolute_min_z} and {absolute_max_z} mm, got {z} mm") + raise ValueError(f"z must be between {absolute_min_z} and {absolute_max_z} mm, is {z}") finger_plane_z = z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm z_increments = STARBackend.mm_to_z_drive_increment(finger_plane_z) @@ -10129,7 +10132,7 @@ async def iswap_rotation_drive_move_z( f"speed must be between " f"{STARBackend.z_drive_increment_to_mm(speed_min)} and " f"{STARBackend.z_drive_increment_to_mm(speed_max)} mm/sec, " - f"got {speed} mm/sec" + f"is {speed}" ) acceleration_increments = STARBackend.mm_to_z_drive_increment(acceleration / 1000) @@ -10139,12 +10142,12 @@ async def iswap_rotation_drive_move_z( f"acceleration must be between " f"{STARBackend.z_drive_increment_to_mm(accel_min * 1000)} and " f"{STARBackend.z_drive_increment_to_mm(accel_max * 1000)} mm/sec^2, " - f"got {acceleration} mm/sec^2" + f"is {acceleration}" ) if not (0 <= current_protection_limiter <= 7): raise ValueError( - f"current_protection_limiter must be between 0 and 7, got {current_protection_limiter}" + f"current_protection_limiter must be between 0 and 7, is {current_protection_limiter}" ) await self.send_command( @@ -10163,7 +10166,7 @@ async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, positions (no length slot, unlike pw/pt). Slots beyond home/parking are extra slots addressable via R0 ZP zp2..zp9. - Keys (motor increments; Z-drive resolution 0.01072765 mm/incr): + Keys (motor increments; see `iswap_z_drive_mm_per_increment`): "home" pz[0] - home position "parking" pz[1] - parking pose (firmware requires pz[1] >= iz + 100) "extra_1" pz[2] - extra slot, address via R0 ZP zp2 From 414aec5aa28b0c21cf94dcfb003ca26ed03a9d7a Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 13:47:58 +0100 Subject: [PATCH 10/21] create `iswap_rotation_drive_request_predefined_z_positions` --- .../backends/hamilton/STAR_backend.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index fca96c26d34..f94ab8d7e8a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10098,6 +10098,45 @@ async def iswap_rotation_drive_move_z( zw=f"{int(current_protection_limiter)}", ) + async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, int]: + """Read the iSWAP rotation-drive Z-axis predefined-position table from EEPROM. + + Sends R0 RA ra=pz. Firmware returns 10 signed-integer slots; all 10 are + positions (no length slot, unlike pw/pt). Slots beyond home/parking are + extra slots addressable via R0 ZP zp2..zp9. + + Keys (motor increments; Z-drive resolution 0.01072765 mm/incr): + "home" pz[0] - home position + "parking" pz[1] - parking pose (firmware requires pz[1] >= iz + 100) + "extra_1" pz[2] - extra slot, address via R0 ZP zp2 + "extra_2" pz[3] - extra slot, address via R0 ZP zp3 + "extra_3" pz[4] - extra slot, address via R0 ZP zp4 + "extra_4" pz[5] - extra slot, address via R0 ZP zp5 + "extra_5" pz[6] - extra slot, address via R0 ZP zp6 + "extra_6" pz[7] - extra slot, address via R0 ZP zp7 + "extra_7" pz[8] - extra slot, address via R0 ZP zp8 + "extra_8" pz[9] - extra slot, address via R0 ZP zp9 + + 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="pz", fmt="pz##### (n)") + pz = cast(List[int], resp["pz"]) + return { + "home": pz[0], + "parking": pz[1], + "extra_1": pz[2], + "extra_2": pz[3], + "extra_3": pz[4], + "extra_4": pz[5], + "extra_5": pz[6], + "extra_6": pz[7], + "extra_7": pz[8], + "extra_8": pz[9], + } + async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. From be4ceb4efba1abc39c5480619484ca8c5adc252e Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 13:55:20 +0100 Subject: [PATCH 11/21] create `iswap_rotation_drive_request_predefined_y_positions` --- .../backends/hamilton/STAR_backend.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index f94ab8d7e8a..ef13644079e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10024,6 +10024,45 @@ async def iswap_rotation_drive_move_y( yw=f"{int(current_protection_limiter)}", ) + async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, int]: + """Read the iSWAP rotation-drive Y-axis predefined-position table from EEPROM. + + Sends R0 RA ra=py. Firmware returns 10 signed-integer slots; all 10 are + positions (no length slot, unlike pw/pt). Slots beyond the documented + semantic roles are extra slots addressable via R0 YP yp5..yp9. + + Keys (motor increments; Y-drive resolution 0.046302 mm/incr): + "home" py[0] - home position + "lower_limit" py[1] - lower travel limit + "upper_limit" py[2] - upper travel limit + "parking" py[3] - parking pose (firmware requires py[3] > iy + 100) + "pre_parking" py[4] - pre-parking pose (firmware requires py[4] < py[3] - 430) + "extra_1" py[5] - extra slot, address via R0 YP yp5 + "extra_2" py[6] - extra slot, address via R0 YP yp6 + "extra_3" py[7] - extra slot, address via R0 YP yp7 + "extra_4" py[8] - extra slot, address via R0 YP yp8 + "extra_5" py[9] - extra slot, address via R0 YP yp9 + + 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="py", fmt="py##### (n)") + py = cast(List[int], resp["py"]) + return { + "home": py[0], + "lower_limit": py[1], + "upper_limit": py[2], + "parking": py[3], + "pre_parking": py[4], + "extra_1": py[5], + "extra_2": py[6], + "extra_3": py[7], + "extra_4": py[8], + "extra_5": py[9], + } + async def iswap_rotation_drive_move_z( self, z: float, From 70de9fb67830e1524c08e2522808e581af633c87 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 14:48:50 +0100 Subject: [PATCH 12/21] add iSWAP Y/Z drive conversions and correct pip Y resolution --- .../backends/hamilton/STAR_backend.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index ef13644079e..d11fa10d5f4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9826,6 +9826,25 @@ async def iswap_request_link_2_length(self) -> float: iswap_rotation_drive_diameter = 30.5 iswap_rotation_drive_safety_radius = 90.0 + iswap_y_drive_mm_per_increment = 0.046302083 + iswap_z_drive_mm_per_increment = 0.01072765 + + @staticmethod + def iswap_y_drive_mm_to_increment(value_mm: float) -> int: + return round(value_mm / STARBackend.iswap_y_drive_mm_per_increment) + + @staticmethod + def iswap_y_drive_increment_to_mm(value_increments: int) -> float: + return round(value_increments * STARBackend.iswap_y_drive_mm_per_increment, 2) + + @staticmethod + def iswap_z_drive_mm_to_increment(value_mm: float) -> int: + return round(value_mm / STARBackend.iswap_z_drive_mm_per_increment) + + @staticmethod + def iswap_z_drive_increment_to_mm(value_increments: int) -> float: + return round(value_increments * STARBackend.iswap_z_drive_mm_per_increment, 2) + class RotationDriveOrientation(enum.Enum): LEFT = 1 FRONT = 2 @@ -11235,7 +11254,7 @@ async def request_cover_open(self) -> bool: # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- - y_drive_mm_per_increment = 0.046302082 + y_drive_mm_per_increment = 0.046302083 z_drive_mm_per_increment = 0.01072765 dispensing_drive_vol_per_increment = 0.046876 # uL / increment From 3a90a19df77dd13badc634dbc2e9f297d39b1a77 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 15:20:16 +0100 Subject: [PATCH 13/21] tighten iswap_rotation_drive_move_z error messages and document Raises --- .../backends/hamilton/STAR_backend.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index d11fa10d5f4..4ad95819f8a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10050,7 +10050,7 @@ async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, positions (no length slot, unlike pw/pt). Slots beyond the documented semantic roles are extra slots addressable via R0 YP yp5..yp9. - Keys (motor increments; Y-drive resolution 0.046302 mm/incr): + Keys (motor increments; see `iswap_y_drive_mm_per_increment`): "home" py[0] - home position "lower_limit" py[1] - lower travel limit "upper_limit" py[2] - upper travel limit @@ -10102,6 +10102,9 @@ async def iswap_rotation_drive_move_z( (firmware default 60 in 1000 incr/sec^2 units). current_protection_limiter: Motor current limit, 0-7. Default 6 (firmware default). + + 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") @@ -10117,7 +10120,7 @@ async def iswap_rotation_drive_move_z( + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm ) if not (absolute_min_z <= z <= absolute_max_z): - raise ValueError(f"z must be between {absolute_min_z} and {absolute_max_z} mm, got {z} mm") + raise ValueError(f"z must be between {absolute_min_z} and {absolute_max_z} mm, is {z}") finger_plane_z = z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm z_increments = STARBackend.mm_to_z_drive_increment(finger_plane_z) @@ -10129,7 +10132,7 @@ async def iswap_rotation_drive_move_z( f"speed must be between " f"{STARBackend.z_drive_increment_to_mm(speed_min)} and " f"{STARBackend.z_drive_increment_to_mm(speed_max)} mm/sec, " - f"got {speed} mm/sec" + f"is {speed}" ) acceleration_increments = STARBackend.mm_to_z_drive_increment(acceleration / 1000) @@ -10139,12 +10142,12 @@ async def iswap_rotation_drive_move_z( f"acceleration must be between " f"{STARBackend.z_drive_increment_to_mm(accel_min * 1000)} and " f"{STARBackend.z_drive_increment_to_mm(accel_max * 1000)} mm/sec^2, " - f"got {acceleration} mm/sec^2" + f"is {acceleration}" ) if not (0 <= current_protection_limiter <= 7): raise ValueError( - f"current_protection_limiter must be between 0 and 7, got {current_protection_limiter}" + f"current_protection_limiter must be between 0 and 7, is {current_protection_limiter}" ) await self.send_command( @@ -10163,7 +10166,7 @@ async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, positions (no length slot, unlike pw/pt). Slots beyond home/parking are extra slots addressable via R0 ZP zp2..zp9. - Keys (motor increments; Z-drive resolution 0.01072765 mm/incr): + Keys (motor increments; see `iswap_z_drive_mm_per_increment`): "home" pz[0] - home position "parking" pz[1] - parking pose (firmware requires pz[1] >= iz + 100) "extra_1" pz[2] - extra slot, address via R0 ZP zp2 From da985da4fcd202653ecad88128c967f974be9d05 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 15:48:21 +0100 Subject: [PATCH 14/21] drop firmware-internal constraints from predefined Y/Z parking-pose docstrings --- 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 4ad95819f8a..c0b57f3e567 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10054,7 +10054,7 @@ async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, "home" py[0] - home position "lower_limit" py[1] - lower travel limit "upper_limit" py[2] - upper travel limit - "parking" py[3] - parking pose (firmware requires py[3] > iy + 100) + "parking" py[3] - parking pose (back of travel) "pre_parking" py[4] - pre-parking pose (firmware requires py[4] < py[3] - 430) "extra_1" py[5] - extra slot, address via R0 YP yp5 "extra_2" py[6] - extra slot, address via R0 YP yp6 @@ -10168,7 +10168,7 @@ async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, Keys (motor increments; see `iswap_z_drive_mm_per_increment`): "home" pz[0] - home position - "parking" pz[1] - parking pose (firmware requires pz[1] >= iz + 100) + "parking" pz[1] - parking pose (top of travel) "extra_1" pz[2] - extra slot, address via R0 ZP zp2 "extra_2" pz[3] - extra slot, address via R0 ZP zp3 "extra_3" pz[4] - extra slot, address via R0 ZP zp4 From 5250402bdd252d5411b6a96da1206702d561ba2e Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 15:56:30 +0100 Subject: [PATCH 15/21] reword Z increment-range comment to drop misleading return claim --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index c0b57f3e567..dd76f376d30 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9896,8 +9896,9 @@ async def iswap_rotation_drive_request_y(self) -> float: # 13 mm above it. iswap_rotation_drive_z_offset_above_finger_mm = 13.0 - # Position is in finger-plane coords; `iswap_rotation_drive_move_z` adds - # the 13 mm offset to return the bottom position of the rotation drive. + # Z increment ranges below are in finger-plane coords (the R0 ZA reference). + # `iswap_rotation_drive_move_z` and `iswap_rotation_drive_request_z` apply + # the 13 mm offset internally to translate between deck and finger-plane Z. iswap_rotation_drive_z_min_increment = -187 iswap_rotation_drive_z_max_increment = 26_661 iswap_rotation_drive_z_speed_increment_range = (50, 15_000) From e648668bfa2690bf08502a64dbdbc717a742be59 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 16:14:08 +0100 Subject: [PATCH 16/21] decouple iSWAP rotation-drive Y/Z from PIP-channel conversions --- .../backends/hamilton/STAR_backend.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index dd76f376d30..56987a54418 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9889,7 +9889,7 @@ async def iswap_rotation_drive_request_y(self) -> float: 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) + return round(STARBackend.iswap_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 @@ -10017,13 +10017,13 @@ async def iswap_rotation_drive_move_y( await self.move_all_channels_in_z_safety() await self.position_channels_in_y_direction({0: target_channel_0_y}, make_space=True) - speed_increments = STARBackend.mm_to_y_drive_increment(speed) + speed_increments = STARBackend.iswap_y_drive_mm_to_increment(speed) speed_min, speed_max = STARBackend.iswap_rotation_drive_y_speed_increment_range if not (speed_min <= speed_increments <= speed_max): raise ValueError( f"speed must be between " - f"{STARBackend.y_drive_increment_to_mm(speed_min)} and " - f"{STARBackend.y_drive_increment_to_mm(speed_max)} mm/sec, " + f"{STARBackend.iswap_y_drive_increment_to_mm(speed_min)} and " + f"{STARBackend.iswap_y_drive_increment_to_mm(speed_max)} mm/sec, " f"got {speed} mm/sec" ) @@ -10038,7 +10038,7 @@ async def iswap_rotation_drive_move_y( await self.send_command( module="R0", command="YA", - ya=f"{round(STARBackend.mm_to_y_drive_increment(y)):05}", + ya=f"{round(STARBackend.iswap_y_drive_mm_to_increment(y)):05}", yv=f"{round(speed_increments):04}", yr=f"{int(acceleration_level)}", yw=f"{int(current_protection_limiter)}", @@ -10113,36 +10113,36 @@ async def iswap_rotation_drive_move_z( z_min_incr = STARBackend.iswap_rotation_drive_z_min_increment z_max_incr = STARBackend.iswap_rotation_drive_z_max_increment absolute_min_z = ( - STARBackend.z_drive_increment_to_mm(z_min_incr) + STARBackend.iswap_z_drive_increment_to_mm(z_min_incr) + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm ) absolute_max_z = ( - STARBackend.z_drive_increment_to_mm(z_max_incr) + STARBackend.iswap_z_drive_increment_to_mm(z_max_incr) + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm ) if not (absolute_min_z <= z <= absolute_max_z): raise ValueError(f"z must be between {absolute_min_z} and {absolute_max_z} mm, is {z}") finger_plane_z = z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - z_increments = STARBackend.mm_to_z_drive_increment(finger_plane_z) + z_increments = STARBackend.iswap_z_drive_mm_to_increment(finger_plane_z) - speed_increments = STARBackend.mm_to_z_drive_increment(speed) + speed_increments = STARBackend.iswap_z_drive_mm_to_increment(speed) speed_min, speed_max = STARBackend.iswap_rotation_drive_z_speed_increment_range if not (speed_min <= speed_increments <= speed_max): raise ValueError( f"speed must be between " - f"{STARBackend.z_drive_increment_to_mm(speed_min)} and " - f"{STARBackend.z_drive_increment_to_mm(speed_max)} mm/sec, " + f"{STARBackend.iswap_z_drive_increment_to_mm(speed_min)} and " + f"{STARBackend.iswap_z_drive_increment_to_mm(speed_max)} mm/sec, " f"is {speed}" ) - acceleration_increments = STARBackend.mm_to_z_drive_increment(acceleration / 1000) + acceleration_increments = STARBackend.iswap_z_drive_mm_to_increment(acceleration / 1000) accel_min, accel_max = STARBackend.iswap_rotation_drive_z_acceleration_increment_range if not (accel_min <= acceleration_increments <= accel_max): raise ValueError( f"acceleration must be between " - f"{STARBackend.z_drive_increment_to_mm(accel_min * 1000)} and " - f"{STARBackend.z_drive_increment_to_mm(accel_max * 1000)} mm/sec^2, " + f"{STARBackend.iswap_z_drive_increment_to_mm(accel_min * 1000)} and " + f"{STARBackend.iswap_z_drive_increment_to_mm(accel_max * 1000)} mm/sec^2, " f"is {acceleration}" ) From e8c2d7d28e2cac675ce15ba8cee050a8cb8e42d4 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 18:01:05 +0100 Subject: [PATCH 17/21] rename increment version to `_` and provide public API in expected mm --- .../backends/hamilton/STAR_backend.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 56987a54418..99700a6eee0 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10044,7 +10044,7 @@ async def iswap_rotation_drive_move_y( yw=f"{int(current_protection_limiter)}", ) - async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, int]: + async def _iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, int]: """Read the iSWAP rotation-drive Y-axis predefined-position table from EEPROM. Sends R0 RA ra=py. Firmware returns 10 signed-integer slots; all 10 are @@ -10083,6 +10083,18 @@ async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, "extra_5": py[9], } + async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, float]: + """Read iSWAP rotation-drive Y predefined-position table in mm. + + Wraps `_iswap_rotation_drive_request_predefined_y_positions`, converting + each value via `iswap_y_drive_increment_to_mm`. + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + table = await self._iswap_rotation_drive_request_predefined_y_positions() + return {k: STARBackend.iswap_y_drive_increment_to_mm(v) for k, v in table.items()} + async def iswap_rotation_drive_move_z( self, z: float, @@ -10160,7 +10172,7 @@ async def iswap_rotation_drive_move_z( zw=f"{int(current_protection_limiter)}", ) - async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, int]: + async def _iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, int]: """Read the iSWAP rotation-drive Z-axis predefined-position table from EEPROM. Sends R0 RA ra=pz. Firmware returns 10 signed-integer slots; all 10 are @@ -10199,6 +10211,18 @@ async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, "extra_8": pz[9], } + async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, float]: + """Read iSWAP rotation-drive Z predefined-position table in mm. + + Wraps `_iswap_rotation_drive_request_predefined_z_positions`, converting + each value via `iswap_z_drive_increment_to_mm`. + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + table = await self._iswap_rotation_drive_request_predefined_z_positions() + return {k: STARBackend.iswap_z_drive_increment_to_mm(v) for k, v in table.items()} + async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. From f9a322cef78d2e3e512ea6d46f5fc6790986a8ca Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 18:18:26 +0100 Subject: [PATCH 18/21] make everything that references rotation drive return rotation drive data --- .../backends/hamilton/STAR_backend.py | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 99700a6eee0..c211fb2d17e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -9891,9 +9891,9 @@ async def iswap_rotation_drive_request_y(self) -> float: iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter return round(STARBackend.iswap_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. + # Vertical offset between the rotation drive's bottom (its lowest physical + # point) and the gripper finger plane. R0 RZ is calibrated to the finger + # plane; the rotation drive's bottom sits 13 mm above it. iswap_rotation_drive_z_offset_above_finger_mm = 13.0 # Z increment ranges below are in finger-plane coords (the R0 ZA reference). @@ -9905,9 +9905,10 @@ async def iswap_rotation_drive_request_y(self) -> float: iswap_rotation_drive_z_acceleration_increment_range = (5, 999) async def iswap_rotation_drive_request_z(self) -> float: - """Request iSWAP rotation drive Z position (deck coordinates), in mm. + """Request iSWAP rotation-drive-bottom Z (deck coordinates), in mm. - Adds the 13 mm structural offset to the gripper finger plane (C0 QG). + Returns the Z of the rotation drive's lowest physical point, which sits + 13 mm above the gripper finger plane that C0 QG reports. """ if not self.extended_conf.left_x_drive.iswap_installed: raise RuntimeError("iSWAP is not installed") @@ -10102,14 +10103,14 @@ async def iswap_rotation_drive_move_z( acceleration: float = 643.66, current_protection_limiter: int = 6, ): - """Move the iSWAP rotation drive to an absolute Z position (deck coordinates). + """Move the iSWAP rotation-drive bottom to an absolute Z (deck coordinates). - `z` is the rotation-drive plane Z, matching what - `iswap_rotation_drive_request_z` returns. The 13 mm offset to the gripper - finger plane (where R0 ZA is calibrated) is applied internally. + `z` is the rotation-drive bottom Z (lowest physical point of the drive), + matching what `iswap_rotation_drive_request_z` returns. The 13 mm offset + to the finger plane (R0 ZA reference) is applied internally. Args: - z: Target rotation-drive Z coordinate in mm. + z: Target rotation-drive-bottom Z coordinate in mm. speed: Max velocity in mm/sec. Default 118.0 (firmware default). acceleration: Acceleration in mm/sec^2. Default 643.66 (firmware default 60 in 1000 incr/sec^2 units). @@ -10214,14 +10215,20 @@ async def _iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, float]: """Read iSWAP rotation-drive Z predefined-position table in mm. - Wraps `_iswap_rotation_drive_request_predefined_z_positions`, converting - each value via `iswap_z_drive_increment_to_mm`. + Returns rotation-drive-bottom Z (matching `iswap_rotation_drive_request_z` + and `iswap_rotation_drive_move_z`). Wraps the increment private method, + converts via `iswap_z_drive_increment_to_mm`, then adds + `iswap_rotation_drive_z_offset_above_finger_mm` so EEPROM finger-plane + increments map to deck-coordinate mm at the rotation drive's bottom. Raises: RuntimeError: if the iSWAP module is not installed. """ table = await self._iswap_rotation_drive_request_predefined_z_positions() - return {k: STARBackend.iswap_z_drive_increment_to_mm(v) for k, v in table.items()} + offset = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + return { + k: STARBackend.iswap_z_drive_increment_to_mm(v) + offset for k, v in table.items() + } async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. From dbfcb0b2de6473abc6f6fb43adaad4926c259958 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 18:28:38 +0100 Subject: [PATCH 19/21] `make format` --- .../liquid_handling/backends/hamilton/STAR_backend.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index c211fb2d17e..4e4ac9529bd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10182,7 +10182,7 @@ async def _iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str Keys (motor increments; see `iswap_z_drive_mm_per_increment`): "home" pz[0] - home position - "parking" pz[1] - parking pose (top of travel) + "parking" pz[1] - parking pose "extra_1" pz[2] - extra slot, address via R0 ZP zp2 "extra_2" pz[3] - extra slot, address via R0 ZP zp3 "extra_3" pz[4] - extra slot, address via R0 ZP zp4 @@ -10226,9 +10226,7 @@ async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, """ table = await self._iswap_rotation_drive_request_predefined_z_positions() offset = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - return { - k: STARBackend.iswap_z_drive_increment_to_mm(v) + offset for k, v in table.items() - } + return {k: STARBackend.iswap_z_drive_increment_to_mm(v) + offset for k, v in table.items()} async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. From c8566d51085d74bdb44644ca3beb8fd99b10b011 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 18:34:46 +0100 Subject: [PATCH 20/21] simplify: compress increment and mm returns into one method --- .../backends/hamilton/STAR_backend.py | 88 +++++++------------ 1 file changed, 34 insertions(+), 54 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 4e4ac9529bd..a8cc016e4a8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10045,14 +10045,14 @@ async def iswap_rotation_drive_move_y( yw=f"{int(current_protection_limiter)}", ) - async def _iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, int]: - """Read the iSWAP rotation-drive Y-axis predefined-position table from EEPROM. + async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, float]: + """Read iSWAP rotation-drive Y predefined-position table from EEPROM, in mm. Sends R0 RA ra=py. Firmware returns 10 signed-integer slots; all 10 are positions (no length slot, unlike pw/pt). Slots beyond the documented semantic roles are extra slots addressable via R0 YP yp5..yp9. - Keys (motor increments; see `iswap_y_drive_mm_per_increment`): + Keys (mm): "home" py[0] - home position "lower_limit" py[1] - lower travel limit "upper_limit" py[2] - upper travel limit @@ -10071,31 +10071,20 @@ async def _iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str raise RuntimeError("iSWAP is not installed") resp = await self.send_command(module="R0", command="RA", ra="py", fmt="py##### (n)") py = cast(List[int], resp["py"]) + to_mm = STARBackend.iswap_y_drive_increment_to_mm return { - "home": py[0], - "lower_limit": py[1], - "upper_limit": py[2], - "parking": py[3], - "pre_parking": py[4], - "extra_1": py[5], - "extra_2": py[6], - "extra_3": py[7], - "extra_4": py[8], - "extra_5": py[9], + "home": to_mm(py[0]), + "lower_limit": to_mm(py[1]), + "upper_limit": to_mm(py[2]), + "parking": to_mm(py[3]), + "pre_parking": to_mm(py[4]), + "extra_1": to_mm(py[5]), + "extra_2": to_mm(py[6]), + "extra_3": to_mm(py[7]), + "extra_4": to_mm(py[8]), + "extra_5": to_mm(py[9]), } - async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, float]: - """Read iSWAP rotation-drive Y predefined-position table in mm. - - Wraps `_iswap_rotation_drive_request_predefined_y_positions`, converting - each value via `iswap_y_drive_increment_to_mm`. - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - table = await self._iswap_rotation_drive_request_predefined_y_positions() - return {k: STARBackend.iswap_y_drive_increment_to_mm(v) for k, v in table.items()} - async def iswap_rotation_drive_move_z( self, z: float, @@ -10173,14 +10162,19 @@ async def iswap_rotation_drive_move_z( zw=f"{int(current_protection_limiter)}", ) - async def _iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, int]: - """Read the iSWAP rotation-drive Z-axis predefined-position table from EEPROM. + async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, float]: + """Read iSWAP rotation-drive Z predefined-position table from EEPROM, in mm. Sends R0 RA ra=pz. Firmware returns 10 signed-integer slots; all 10 are positions (no length slot, unlike pw/pt). Slots beyond home/parking are extra slots addressable via R0 ZP zp2..zp9. - Keys (motor increments; see `iswap_z_drive_mm_per_increment`): + Returns rotation-drive-bottom Z (matching `iswap_rotation_drive_request_z` + and `iswap_rotation_drive_move_z`): each EEPROM finger-plane increment is + converted via `iswap_z_drive_increment_to_mm` then offset by + `iswap_rotation_drive_z_offset_above_finger_mm`. + + Keys (mm): "home" pz[0] - home position "parking" pz[1] - parking pose "extra_1" pz[2] - extra slot, address via R0 ZP zp2 @@ -10199,35 +10193,21 @@ async def _iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str raise RuntimeError("iSWAP is not installed") resp = await self.send_command(module="R0", command="RA", ra="pz", fmt="pz##### (n)") pz = cast(List[int], resp["pz"]) + offset = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + to_mm = STARBackend.iswap_z_drive_increment_to_mm return { - "home": pz[0], - "parking": pz[1], - "extra_1": pz[2], - "extra_2": pz[3], - "extra_3": pz[4], - "extra_4": pz[5], - "extra_5": pz[6], - "extra_6": pz[7], - "extra_7": pz[8], - "extra_8": pz[9], + "home": to_mm(pz[0]) + offset, + "parking": to_mm(pz[1]) + offset, + "extra_1": to_mm(pz[2]) + offset, + "extra_2": to_mm(pz[3]) + offset, + "extra_3": to_mm(pz[4]) + offset, + "extra_4": to_mm(pz[5]) + offset, + "extra_5": to_mm(pz[6]) + offset, + "extra_6": to_mm(pz[7]) + offset, + "extra_7": to_mm(pz[8]) + offset, + "extra_8": to_mm(pz[9]) + offset, } - async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, float]: - """Read iSWAP rotation-drive Z predefined-position table in mm. - - Returns rotation-drive-bottom Z (matching `iswap_rotation_drive_request_z` - and `iswap_rotation_drive_move_z`). Wraps the increment private method, - converts via `iswap_z_drive_increment_to_mm`, then adds - `iswap_rotation_drive_z_offset_above_finger_mm` so EEPROM finger-plane - increments map to deck-coordinate mm at the rotation drive's bottom. - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - table = await self._iswap_rotation_drive_request_predefined_z_positions() - offset = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - return {k: STARBackend.iswap_z_drive_increment_to_mm(v) + offset for k, v in table.items()} - async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. From f747bf5956a10c175e93996a5d90ed51e942451e Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 1 May 2026 21:24:44 +0100 Subject: [PATCH 21/21] simplify z conversions --- .../backends/hamilton/STAR_backend.py | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index a8cc016e4a8..10cfc65de36 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -10071,19 +10071,19 @@ async def iswap_rotation_drive_request_predefined_y_positions(self) -> Dict[str, raise RuntimeError("iSWAP is not installed") resp = await self.send_command(module="R0", command="RA", ra="py", fmt="py##### (n)") py = cast(List[int], resp["py"]) - to_mm = STARBackend.iswap_y_drive_increment_to_mm - return { - "home": to_mm(py[0]), - "lower_limit": to_mm(py[1]), - "upper_limit": to_mm(py[2]), - "parking": to_mm(py[3]), - "pre_parking": to_mm(py[4]), - "extra_1": to_mm(py[5]), - "extra_2": to_mm(py[6]), - "extra_3": to_mm(py[7]), - "extra_4": to_mm(py[8]), - "extra_5": to_mm(py[9]), - } + keys = ( + "home", + "lower_limit", + "upper_limit", + "parking", + "pre_parking", + "extra_1", + "extra_2", + "extra_3", + "extra_4", + "extra_5", + ) + return {k: STARBackend.iswap_y_drive_increment_to_mm(py[i]) for i, k in enumerate(keys)} async def iswap_rotation_drive_move_z( self, @@ -10194,18 +10194,20 @@ async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, resp = await self.send_command(module="R0", command="RA", ra="pz", fmt="pz##### (n)") pz = cast(List[int], resp["pz"]) offset = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - to_mm = STARBackend.iswap_z_drive_increment_to_mm + keys = ( + "home", + "parking", + "extra_1", + "extra_2", + "extra_3", + "extra_4", + "extra_5", + "extra_6", + "extra_7", + "extra_8", + ) return { - "home": to_mm(pz[0]) + offset, - "parking": to_mm(pz[1]) + offset, - "extra_1": to_mm(pz[2]) + offset, - "extra_2": to_mm(pz[3]) + offset, - "extra_3": to_mm(pz[4]) + offset, - "extra_4": to_mm(pz[5]) + offset, - "extra_5": to_mm(pz[6]) + offset, - "extra_6": to_mm(pz[7]) + offset, - "extra_7": to_mm(pz[8]) + offset, - "extra_8": to_mm(pz[9]) + offset, + k: STARBackend.iswap_z_drive_increment_to_mm(pz[i]) + offset for i, k in enumerate(keys) } async def iswap_rotation_drive_request_predefined_positions(self) -> Dict[str, int]: