Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 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,137 @@ 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.
"""
# TODO: remove after 2026-09
warnings.warn(
"move_channel_z is deprecated and will be removed after 2026-09 in legacy PLR. "
"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)
Expand All @@ -4547,6 +4673,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