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
118 changes: 117 additions & 1 deletion pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -8663,6 +8663,122 @@ def mm_to_z_drive_increment(value_mm: float) -> int:
def z_drive_increment_to_mm(value_increments: int) -> float:
return round(value_increments * STARBackend.z_drive_mm_per_increment, 2)

async def clld_probe_x_position_using_channel(
self,
channel_idx: int, # 0-based indexing of channels!
probing_direction: Literal["right", "left"],
end_pos_search: Optional[float] = None, # mm
post_detection_dist: float = 2.0, # mm,
tip_bottom_diameter: float = 1.2, # mm
read_timeout=240.0, # seconds
) -> float:
"""
Probe the x-position of a conductive material using a channel’s capacitive liquid
level detection (cLLD) via a lateral X scan.

Starting from the channel’s current X position, the channel is moved laterally in
the specified direction using the XL command until cLLD triggers or the configured
end position is reached. After the scan, the channel is retracted inward by
`post_detection_dist`.

The returned value is a first-order geometric estimate of the material boundary,
corrected by half the tip bottom diameter assuming cylindrical tip contact.

Notes:
- The XL command does not report whether cLLD triggered; reaching the end position
is indistinguishable from a successful detection.
- This function assumes cLLD triggers before `end_pos_search`.

Preconditions:
- The channel must already be at a Z height safe for lateral X motion.
- The current X position must be consistent with `probing_direction`.

Side effects:
- Moves the specified channel in X.
- Leaves the channel retracted from the detected object.

Returns:
float: Estimated x-position of the detected material boundary in millimeters.
"""

assert channel_idx in range(
self.num_channels
), f"Channel index must be between 0 and {self.num_channels - 1}, is {channel_idx}."
assert probing_direction in [
"right",
"left",
], f"Probing direction must be either 'right' or 'left', is {probing_direction}."
assert post_detection_dist >= 0.0, (
f"Post-detection distance must be non-negative, is {post_detection_dist} mm."
"(always marks a movement away from the detected material)."
)

# TODO: Anti-channel-crash feature -> use self.deck with recursive logic
current_x_position = await self.request_x_pos_channel_n(channel_idx)
# y_position = await self.request_y_pos_channel_n(channel_idx)
# current_z_position = await self.request_z_pos_channel_n(channel_idx)

# Use identified rail number to calculate possible upper limit:
# STAR = 95 - 1415 mm, STARlet = 95 - 800mm
num_rails = self.extended_conf["xt"]
track_width = 22.5 # mm
reachable_dist_to_last_rail = 125.0

max_safe_upper_x_pos = num_rails * track_width + reachable_dist_to_last_rail
max_safe_lower_x_pos = 95.0 # unit: mm

if end_pos_search is None:
if probing_direction == "right":
end_pos_search = max_safe_upper_x_pos
else: # probing_direction == "left"
end_pos_search = max_safe_lower_x_pos
else:
assert max_safe_lower_x_pos <= end_pos_search <= max_safe_upper_x_pos, (
f"End position for x search must be between "
f"{max_safe_lower_x_pos} and {max_safe_upper_x_pos} mm, "
f"is {end_pos_search} mm."
)

# Assert probing direction matches start and end positions
if probing_direction == "right":
assert current_x_position < end_pos_search, (
f"Current position ({current_x_position} mm) must be less than "
+ f"end position ({end_pos_search} mm) when probing right."
)
else: # probing_direction == "left"
assert current_x_position > end_pos_search, (
f"Current position ({current_x_position} mm) must be greater than "
+ f"end position ({end_pos_search} mm) when probing left."
)

# Move channel in x until cLLD (Note: does not return detected x-position!)
await self.send_command(
module="C0",
command="XL",
xs=f"{int(round(end_pos_search * 10)):05}",
read_timeout=read_timeout,
)

sensor_triggered_x_pos = await self.request_x_pos_channel_n(channel_idx)

# Move channel post-detection
if probing_direction == "left":
final_x_pos = sensor_triggered_x_pos + post_detection_dist

# tip_bottom_diameter geometric correction assuming cylindrical tip contact
material_x_pos = sensor_triggered_x_pos - tip_bottom_diameter / 2

else: # probing_direction == "right"
final_x_pos = sensor_triggered_x_pos - post_detection_dist

material_x_pos = sensor_triggered_x_pos + tip_bottom_diameter / 2

# Move away from detected object to avoid mechanical interference
# e.g. touch carrier, then carrier moves -> friction on channel!
await self.move_channel_x(x=final_x_pos, channel=channel_idx)

return round(material_x_pos, 1)

async def clld_probe_y_position_using_channel(
self,
channel_idx: int, # 0-based indexing of channels!
Expand Down Expand Up @@ -8848,7 +8964,7 @@ async def clld_probe_y_position_using_channel(
else: # probing_direction == "forward"
material_y_pos = detected_material_y_pos - tip_bottom_diameter / 2

return material_y_pos
return round(material_y_pos, 1)

async def move_z_drive_to_liquid_surface_using_clld(
self,
Expand Down