Skip to content
127 changes: 126 additions & 1 deletion pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)

Comment on lines +4551 to 4560
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not use the method you tell people to use?

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)
Expand All @@ -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)

Expand Down
Loading