Skip to content

Commit e22fdd6

Browse files
rickwierengaclaude
andcommitted
fix: handle firmware slave timeout for slow CoRe 96 head operations
The Hamilton firmware master has an internal ~5 minute timeout for slave commands. For slow liquid handling operations (e.g. large volumes at low flow rates), the master reports a slave timeout error (H0 HardwareError, trace 11) even though the CoRe 96 head is still working and will finish. This adds a workaround: when aspirate96/dispense96 receive this specific error, we poll the master by sending C0 EV (move Z to safety) which goes through the 96-head task queue. If the head is busy, the master responds with trace 46 ("CoRe 96 head task busy"). When it finishes, EV succeeds harmlessly. Also increases read_timeout on aspirate_core_96/dispense_core_96 to 300s so our Python timeout doesn't fire before the firmware's. Closes #944 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ca065b4 commit e22fdd6

1 file changed

Lines changed: 117 additions & 75 deletions

File tree

pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py

Lines changed: 117 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -3487,6 +3487,28 @@ async def drop_tips96(
34873487
),
34883488
)
34893489

3490+
async def _core96_wait_for_idle(self, timeout: float = 600, poll_interval: float = 5):
3491+
"""Poll the CoRe 96 head until it finishes its current operation.
3492+
3493+
Sends the "move to Z safety" command (C0 EV), which goes through the master's 96-head
3494+
task queue. If the head is busy, the master responds with trace 46. When the head finishes,
3495+
EV succeeds and harmlessly ensures the Z axis is at the safe position.
3496+
"""
3497+
start = asyncio.get_event_loop().time()
3498+
while asyncio.get_event_loop().time() - start < timeout:
3499+
await asyncio.sleep(poll_interval)
3500+
try:
3501+
await self.send_command(module="C0", command="EV", read_timeout=10)
3502+
logger.info("CoRe 96 head finished (EV succeeded)")
3503+
return
3504+
except STARFirmwareError as e:
3505+
master_error = e.errors.get("Master")
3506+
if master_error is not None and master_error.trace_information == 46:
3507+
logger.debug("CoRe 96 head still busy, waiting...")
3508+
continue
3509+
raise
3510+
raise TimeoutError("CoRe 96 head did not become idle within timeout")
3511+
34903512
@_requires_head96
34913513
async def aspirate96(
34923514
self,
@@ -3707,43 +3729,50 @@ async def aspirate96(
37073729
settling_time = settling_time or (hlc.aspiration_settling_time if hlc is not None else 0.5)
37083730

37093731
x_direction = 0 if position.x >= 0 else 1
3710-
return await self.aspirate_core_96(
3711-
x_position=abs(round(position.x * 10)),
3712-
x_direction=x_direction,
3713-
y_positions=round(position.y * 10),
3714-
aspiration_type=aspiration_type,
3715-
minimum_traverse_height_at_beginning_of_a_command=round(
3716-
(minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10
3717-
),
3718-
min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10),
3719-
lld_search_height=round(lld_search_height * 10),
3720-
liquid_surface_no_lld=round(liquid_height * 10),
3721-
pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10),
3722-
minimum_height=round((minimum_height or position.z) * 10),
3723-
second_section_height=round(second_section_height * 10),
3724-
second_section_ratio=round(second_section_ratio * 10),
3725-
immersion_depth=round(immersion_depth * 10),
3726-
immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1),
3727-
surface_following_distance=round(surface_following_distance * 10),
3728-
aspiration_volumes=round(volume * 10),
3729-
aspiration_speed=round(flow_rate * 10),
3730-
transport_air_volume=round(transport_air_volume * 10),
3731-
blow_out_air_volume=round(blow_out_air_volume * 10),
3732-
pre_wetting_volume=round(pre_wetting_volume * 10),
3733-
lld_mode=int(use_lld),
3734-
gamma_lld_sensitivity=gamma_lld_sensitivity,
3735-
swap_speed=round(swap_speed * 10),
3736-
settling_time=round(settling_time * 10),
3737-
mix_volume=round(aspiration.mix.volume * 10) if aspiration.mix is not None else 0,
3738-
mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0,
3739-
mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10),
3740-
mix_surface_following_distance=round(mix_surface_following_distance * 10),
3741-
speed_of_mix=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 1200,
3742-
channel_pattern=[True] * 12 * 8,
3743-
limit_curve_index=limit_curve_index,
3744-
tadm_algorithm=False,
3745-
recording_mode=0,
3746-
)
3732+
try:
3733+
return await self.aspirate_core_96(
3734+
x_position=abs(round(position.x * 10)),
3735+
x_direction=x_direction,
3736+
y_positions=round(position.y * 10),
3737+
aspiration_type=aspiration_type,
3738+
minimum_traverse_height_at_beginning_of_a_command=round(
3739+
(minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10
3740+
),
3741+
min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10),
3742+
lld_search_height=round(lld_search_height * 10),
3743+
liquid_surface_no_lld=round(liquid_height * 10),
3744+
pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10),
3745+
minimum_height=round((minimum_height or position.z) * 10),
3746+
second_section_height=round(second_section_height * 10),
3747+
second_section_ratio=round(second_section_ratio * 10),
3748+
immersion_depth=round(immersion_depth * 10),
3749+
immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1),
3750+
surface_following_distance=round(surface_following_distance * 10),
3751+
aspiration_volumes=round(volume * 10),
3752+
aspiration_speed=round(flow_rate * 10),
3753+
transport_air_volume=round(transport_air_volume * 10),
3754+
blow_out_air_volume=round(blow_out_air_volume * 10),
3755+
pre_wetting_volume=round(pre_wetting_volume * 10),
3756+
lld_mode=int(use_lld),
3757+
gamma_lld_sensitivity=gamma_lld_sensitivity,
3758+
swap_speed=round(swap_speed * 10),
3759+
settling_time=round(settling_time * 10),
3760+
mix_volume=round(aspiration.mix.volume * 10) if aspiration.mix is not None else 0,
3761+
mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0,
3762+
mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10),
3763+
mix_surface_following_distance=round(mix_surface_following_distance * 10),
3764+
speed_of_mix=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 1200,
3765+
channel_pattern=[True] * 12 * 8,
3766+
limit_curve_index=limit_curve_index,
3767+
tadm_algorithm=False,
3768+
recording_mode=0,
3769+
)
3770+
except STARFirmwareError as e:
3771+
if self._is_core96_slave_timeout(e):
3772+
logger.warning("Firmware slave timeout during aspirate96, polling for completion")
3773+
await self._core96_wait_for_idle()
3774+
else:
3775+
raise
37473776

37483777
@_requires_head96
37493778
async def dispense96(
@@ -3981,44 +4010,51 @@ async def dispense96(
39814010
swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100)
39824011
settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5)
39834012

3984-
return await self.dispense_core_96(
3985-
dispensing_mode=dispense_mode,
3986-
x_position=abs(round(position.x * 10)),
3987-
x_direction=0 if position.x >= 0 else 1,
3988-
y_position=round(position.y * 10),
3989-
minimum_traverse_height_at_beginning_of_a_command=round(
3990-
(minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10
3991-
),
3992-
min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10),
3993-
lld_search_height=round(lld_search_height * 10),
3994-
liquid_surface_no_lld=round(liquid_height * 10),
3995-
pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10),
3996-
minimum_height=round((minimum_height or position.z) * 10),
3997-
second_section_height=round(second_section_height * 10),
3998-
second_section_ratio=round(second_section_ratio * 10),
3999-
immersion_depth=round(immersion_depth * 10),
4000-
immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1),
4001-
surface_following_distance=round(surface_following_distance * 10),
4002-
dispense_volume=round(volume * 10),
4003-
dispense_speed=round(flow_rate * 10),
4004-
transport_air_volume=round(transport_air_volume * 10),
4005-
blow_out_air_volume=round(blow_out_air_volume * 10),
4006-
lld_mode=int(use_lld),
4007-
gamma_lld_sensitivity=gamma_lld_sensitivity,
4008-
swap_speed=round(swap_speed * 10),
4009-
settling_time=round(settling_time * 10),
4010-
mixing_volume=round(dispense.mix.volume * 10) if dispense.mix is not None else 0,
4011-
mixing_cycles=dispense.mix.repetitions if dispense.mix is not None else 0,
4012-
mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10),
4013-
mix_surface_following_distance=round(mix_surface_following_distance * 10),
4014-
speed_of_mixing=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 1200,
4015-
channel_pattern=[True] * 12 * 8,
4016-
limit_curve_index=limit_curve_index,
4017-
tadm_algorithm=False,
4018-
recording_mode=0,
4019-
cut_off_speed=round(cut_off_speed * 10),
4020-
stop_back_volume=round(stop_back_volume * 10),
4021-
)
4013+
try:
4014+
return await self.dispense_core_96(
4015+
dispensing_mode=dispense_mode,
4016+
x_position=abs(round(position.x * 10)),
4017+
x_direction=0 if position.x >= 0 else 1,
4018+
y_position=round(position.y * 10),
4019+
minimum_traverse_height_at_beginning_of_a_command=round(
4020+
(minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10
4021+
),
4022+
min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10),
4023+
lld_search_height=round(lld_search_height * 10),
4024+
liquid_surface_no_lld=round(liquid_height * 10),
4025+
pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10),
4026+
minimum_height=round((minimum_height or position.z) * 10),
4027+
second_section_height=round(second_section_height * 10),
4028+
second_section_ratio=round(second_section_ratio * 10),
4029+
immersion_depth=round(immersion_depth * 10),
4030+
immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1),
4031+
surface_following_distance=round(surface_following_distance * 10),
4032+
dispense_volume=round(volume * 10),
4033+
dispense_speed=round(flow_rate * 10),
4034+
transport_air_volume=round(transport_air_volume * 10),
4035+
blow_out_air_volume=round(blow_out_air_volume * 10),
4036+
lld_mode=int(use_lld),
4037+
gamma_lld_sensitivity=gamma_lld_sensitivity,
4038+
swap_speed=round(swap_speed * 10),
4039+
settling_time=round(settling_time * 10),
4040+
mixing_volume=round(dispense.mix.volume * 10) if dispense.mix is not None else 0,
4041+
mixing_cycles=dispense.mix.repetitions if dispense.mix is not None else 0,
4042+
mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10),
4043+
mix_surface_following_distance=round(mix_surface_following_distance * 10),
4044+
speed_of_mixing=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 1200,
4045+
channel_pattern=[True] * 12 * 8,
4046+
limit_curve_index=limit_curve_index,
4047+
tadm_algorithm=False,
4048+
recording_mode=0,
4049+
cut_off_speed=round(cut_off_speed * 10),
4050+
stop_back_volume=round(stop_back_volume * 10),
4051+
)
4052+
except STARFirmwareError as e:
4053+
if self._is_core96_slave_timeout(e):
4054+
logger.warning("Firmware slave command timeout during dispense96, waiting for head to finish")
4055+
await self._core96_wait_for_idle()
4056+
else:
4057+
raise
40224058

40234059
async def iswap_move_picked_up_resource(
40244060
self,
@@ -7771,6 +7807,7 @@ async def aspirate_core_96(
77717807
surface_following_distance_during_mix: int = 0,
77727808
tube_2nd_section_ratio: int = 3425,
77737809
tube_2nd_section_height_measured_from_zm: int = 0,
7810+
_bypass_fw_wait: bool = False,
77747811
):
77757812
"""aspirate CoRe 96
77767813
@@ -7953,6 +7990,8 @@ async def aspirate_core_96(
79537990
return await self.send_command(
79547991
module="C0",
79557992
command="EA",
7993+
read_timeout=max(300, self.read_timeout),
7994+
wait=not _bypass_fw_wait,
79567995
aa=aspiration_type,
79577996
xs=f"{x_position:05}",
79587997
xd=x_direction,
@@ -8038,6 +8077,7 @@ async def dispense_core_96(
80388077
surface_following_distance_during_mixing: int = 0,
80398078
pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50,
80408079
tube_2nd_section_height_measured_from_zm: int = 0,
8080+
_bypass_fw_wait: bool = False,
80418081
):
80428082
"""Dispensing of liquid using CoRe 96
80438083
@@ -8227,6 +8267,8 @@ async def dispense_core_96(
82278267
return await self.send_command(
82288268
module="C0",
82298269
command="ED",
8270+
read_timeout=max(300, self.read_timeout),
8271+
wait=not _bypass_fw_wait,
82308272
da=dispensing_mode,
82318273
xs=f"{x_position:05}",
82328274
xd=x_direction,

0 commit comments

Comments
 (0)