diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 6db6691bb75..4a1d1bc78d4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4530,11 +4530,135 @@ async def move_channel_y(self, channel: int, y: float): ) async def move_channel_z(self, channel: int, z: float): - """Move a channel in the z direction.""" + """Move a channel in the Z direction. + + .. deprecated:: + Use :meth:`move_channel_stop_disk_z` for moves without a tip attached (stop disk) + or :meth:`move_channel_tool_z` when a tip or tool is attached (tip/tool end). + + The Hamilton firmware interprets this Z position based on its internal + "tip mounted" state for the specified channel. When the firmware state + indicates that no tip is mounted, the absolute Z position refers to the + bottom of the stop disk. In that case, this command is effectively + equivalent to :meth:`move_channel_stop_disk_z` for the same numeric Z value. + + When the firmware state indicates that a tip is mounted on the channel, + the same Z position instead refers to the physical end of the tip. In + this case, the numeric Z value used with this method may differ from the + stop disk Z position used with :meth:`move_channel_stop_disk_z` for the same + physical height above the deck. + """ + warnings.warn( + "move_channel_z is deprecated. Use move_channel_stop_disk_z() for moves without a tip attached " + "or move_channel_tool_z() when a tip/tool is attached.", + DeprecationWarning, + stacklevel=2, + ) await self.position_single_pipetting_channel_in_z_direction( pipetting_channel_index=channel + 1, z_position=round(z * 10) ) + async def move_channel_stop_disk_z( + self, + channel_idx: int, + z: float, + speed: float = 125.0, + acceleration: float = 800.0, + current_limit: int = 3, + ): + """Move a channel's Z-drive to an absolute stop disk position. + + Communicates directly with the individual channel rather than through the + master module. + + Args: + channel_idx: Channel index (0-based, backmost = 0). + z: Target Z position in mm (stop disk). + speed: Max Z-drive speed in mm/sec. Default 125.0 mm/s. + acceleration: Acceleration in mm/sec². Default 800.0. Valid range: ~53.6 to 1609. + current_limit: Current limit (0-7). Default 3. + """ + + z_increment = STARBackend.mm_to_z_drive_increment(z) + speed_increment = STARBackend.mm_to_z_drive_increment(speed) + acceleration_increment = STARBackend.mm_to_z_drive_increment(acceleration / 1000) + + if not isinstance(channel_idx, int): + raise ValueError(f"channel_idx must be an int, got {type(channel_idx).__name__}") + if not (0 <= channel_idx < self.num_channels): + raise ValueError( + f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" + ) + assert 9320 <= z_increment <= 31200, ( + f"z must be between {STARBackend.z_drive_increment_to_mm(9320)} and " + f"{STARBackend.z_drive_increment_to_mm(31200)} mm, got {z} mm" + ) + assert 20 <= speed_increment <= 15000, ( + f"speed must be between {STARBackend.z_drive_increment_to_mm(20)} and " + f"{STARBackend.z_drive_increment_to_mm(15000)} mm/s, got {speed} mm/s" + ) + assert 5 <= acceleration_increment <= 150, ( + f"acceleration must be between ~53.6 and ~1609 mm/s², got {acceleration} mm/s²" + ) + assert 0 <= current_limit <= 7, f"current_limit must be between 0 and 7, got {current_limit}" + + return await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="ZA", + za=f"{z_increment:05}", + zv=f"{speed_increment:05}", + zr=f"{acceleration_increment:03}", + zw=f"{current_limit:01}", + ) + + async def move_channel_tool_z(self, channel_idx: int, z: float): + """Move a channel in the Z direction (tip/tool end reference). + + Requires a tip or tool to be attached. Use :meth:`move_channel_stop_disk_z` + for moves without a tip. + + Args: + channel_idx: Channel index (0-based, backmost = 0). + z: Target Z position in mm (tip/tool end). + """ + + if not isinstance(channel_idx, int): + raise ValueError(f"channel_idx must be an int, got {type(channel_idx).__name__}") + if not (0 <= channel_idx < self.num_channels): + raise ValueError( + f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" + ) + + tip_presence = await self.request_tip_presence() + + if not tip_presence[channel_idx]: + raise ValueError( + f"Channel {channel_idx} does not have a tip or tool attached. " + "Use move_channel_stop_disk_z() for Z moves without a tip attached." + ) + + tip_len = await self.request_tip_len_on_channel(channel_idx) + + # The firmware command operates in "tip space" (Z refers to the tip/tool end). + # Convert the head-space limits to tip-space limits: + # tip_space = head_space - tip_len + fitting_depth + max_tip_z = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + min_tip_z = ( + STARBackend.MINIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + + if not (min_tip_z <= z <= max_tip_z): + raise ValueError( + f"z={z} mm out of safe range [{min_tip_z}, {max_tip_z}] mm " + f"for tip length {tip_len} mm on channel {channel_idx}" + ) + + await self.position_single_pipetting_channel_in_z_direction( + pipetting_channel_index=channel_idx + 1, z_position=round(z * 10) + ) + async def move_channel_x_relative(self, channel: int, distance: float): """Move a channel in the x direction by a relative amount.""" current_x = await self.request_x_pos_channel_n(channel) @@ -4547,6 +4671,7 @@ async def move_channel_y_relative(self, channel: int, distance: float): async def move_channel_z_relative(self, channel: int, distance: float): """Move a channel in the z direction by a relative amount.""" + # TODO: determine whether this refers to stop disk or tip bottom current_z = await self.request_z_pos_channel_n(channel) await self.move_channel_z(channel, current_z + distance)