Default aspirate & dispense commands trigger assertion errors

Hi everyone,

When using the aspirate & dispense commands I am receiving errors that appear to arise from the commands’ default values.
Starting with…

Aspiration

await lh.aspirate(
        [source_well],
        vols=[10],
        lld_mode = [lh.backend.LLDMode(1)],
    )

…triggers error:

File ~/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/STAR.py:3769, in STAR.aspirate_pip(self, aspiration_type, tip_pattern, x_positions, y_positions, minimum_traverse_height_at_beginning_of_a_command, min_z_endpos, lld_search_height, clot_detection_height, liquid_surface_no_lld, pull_out_distance_transport_air, second_section_height, second_section_ratio, minimum_height, immersion_depth, immersion_depth_direction, surface_following_distance, aspiration_volumes, aspiration_speed, transport_air_volume, blow_out_air_volume, pre_wetting_volume, lld_mode, gamma_lld_sensitivity, dp_lld_sensitivity, aspirate_position_above_z_touch_off, detection_height_difference_for_dual_lld, swap_speed, settling_time, homogenization_volume, homogenization_cycles, homogenization_position_from_liquid_surface, homogenization_speed, homogenization_surface_following_distance, limit_curve_index, tadm_algorithm, recording_mode, use_2nd_section_aspiration, retract_height_over_2nd_section_to_empty_tip, dispensation_speed_during_emptying_tip, dosing_drive_speed_during_2nd_section_search, z_drive_speed_during_2nd_section_search, cup_upper_edge, ratio_liquid_rise_to_tip_deep_in, immersion_depth_2nd_section)
   3767 print(f"swap_speed = {swap_speed}")
   3768 print(f"homogenization_speed = {homogenization_speed}")
-> 3769 assert all(3 <= x <= 1600 for x in swap_speed), "swap_speed must be between 3 and 1600"
   3770 assert all(0 <= x <= 99 for x in settling_time), "settling_time must be between 0 and 99"
   3771 assert all(0 <= x <= 12500 for x in homogenization_volume), \
   3772   "homogenization_volume must be between 0 and 12500"

AssertionError: swap_speed must be between 3 and 1600

And when giving a specific swap_speed then another error arises:

File ~/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/STAR.py:3777, in STAR.aspirate_pip(self, aspiration_type, tip_pattern, x_positions, y_positions, minimum_traverse_height_at_beginning_of_a_command, min_z_endpos, lld_search_height, clot_detection_height, liquid_surface_no_lld, pull_out_distance_transport_air, second_section_height, second_section_ratio, minimum_height, immersion_depth, immersion_depth_direction, surface_following_distance, aspiration_volumes, aspiration_speed, transport_air_volume, blow_out_air_volume, pre_wetting_volume, lld_mode, gamma_lld_sensitivity, dp_lld_sensitivity, aspirate_position_above_z_touch_off, detection_height_difference_for_dual_lld, swap_speed, settling_time, homogenization_volume, homogenization_cycles, homogenization_position_from_liquid_surface, homogenization_speed, homogenization_surface_following_distance, limit_curve_index, tadm_algorithm, recording_mode, use_2nd_section_aspiration, retract_height_over_2nd_section_to_empty_tip, dispensation_speed_during_emptying_tip, dosing_drive_speed_during_2nd_section_search, z_drive_speed_during_2nd_section_search, cup_upper_edge, ratio_liquid_rise_to_tip_deep_in, immersion_depth_2nd_section)
   3773 assert all(0 <= x <= 99 for x in homogenization_cycles), \
   3774   "homogenization_cycles must be between 0 and 99"
   3775 assert all(0 <= x <= 900 for x in homogenization_position_from_liquid_surface), \
   3776   "homogenization_position_from_liquid_surface must be between 0 and 900"
-> 3777 assert all(4 <= x <= 5000 for x in homogenization_speed), \
   3778   "homogenization_speed must be between 4 and 5000"
   3779 assert all(0 <= x <= 3600 for x in homogenization_surface_following_distance), \
   3780   "homogenization_surface_following_distance must be between 0 and 3600"
   3781 assert all(0 <= x <= 999 for x in limit_curve_index), \
   3782   "limit_curve_index must be between 0 and 999"

AssertionError: homogenization_speed must be between 4 and 5000

Investigation of STAR.aspirate_pip().

To troubleshoot I added the lines

   3767 print(f"swap_speed = {swap_speed}")
   3768 print(f"homogenization_speed = {homogenization_speed}")

which return

swap_speed = [0]
homogenization_speed = [0]

Now if I understand this correctly there are a number of aspirate commands that all link together to send the final one to the machine, in this case STAR, in what I am just going to call “command generation chain” (for a lack of a better phrase):

STAR.aspirate_pip() (where the actual firmware command is generated) → STAR.aspirate() (a convenient wrapper?) → liquid_handler.aspirate() (the atomic command generator to use for any machine?) → machine / the physical Hamilton STAR

Bugs could lurk anywhere along the way but the error message indicates it is inside the STAR.py file and the line number shows it is triggered by the first of these methods, i.e. STAR.aspirate_pip().

The definition of this method is:

async def aspirate_pip(
    self,
    aspiration_type: List[int] = [0],
    tip_pattern: List[bool] = [True],
    x_positions: List[int] = [0],
    y_positions: List[int] = [0],
    minimum_traverse_height_at_beginning_of_a_command: int = 3600,
    min_z_endpos: int = 3600,
    lld_search_height: List[int] = [0],
    clot_detection_height: List[int] = [60],
    liquid_surface_no_lld: List[int] = [3600],
    pull_out_distance_transport_air: List[int] = [50],
    second_section_height: List[int] = [0],
    second_section_ratio: List[int] = [0],
    minimum_height: List[int] = [3600],
    immersion_depth: List[int] = [0],
    immersion_depth_direction: List[int] = [0],
    surface_following_distance: List[int] = [0],
    aspiration_volumes: List[int] = [0],
    aspiration_speed: List[int] = [500],
    transport_air_volume: List[int] = [0],
    blow_out_air_volume: List[int] = [200],
    pre_wetting_volume: List[int] = [0],
    lld_mode: List[int] = [1],
    gamma_lld_sensitivity: List[int] = [1],
    dp_lld_sensitivity: List[int] = [1],
    aspirate_position_above_z_touch_off: List[int] = [5],
    detection_height_difference_for_dual_lld: List[int] = [0],
    swap_speed: List[int] = [100],
    settling_time: List[int] = [5],
    homogenization_volume: List[int] = [0],
    homogenization_cycles: List[int] = [0],
    homogenization_position_from_liquid_surface: List[int] = [250],
    homogenization_speed: List[int] = [500],
    homogenization_surface_following_distance: List[int] = [0],
    limit_curve_index: List[int] = [0],
    tadm_algorithm: bool = False,
    recording_mode: int = 0,

    # For second section aspiration only
    use_2nd_section_aspiration: List[bool] = [False],
    retract_height_over_2nd_section_to_empty_tip: List[int] = [60],
    dispensation_speed_during_emptying_tip: List[int] = [468],
    dosing_drive_speed_during_2nd_section_search: List[int] = [468],
    z_drive_speed_during_2nd_section_search: List[int] = [215],
    cup_upper_edge: List[int] = [3600],
    ratio_liquid_rise_to_tip_deep_in: List[int] = [16246],
    immersion_depth_2nd_section: List[int] = [30]
  ):

…clearly indicating the default value for swap_speed: List[int] = [100], and homogenization_speed: List[int] = [500], - indicating to me that the error new values are set to these arguments at a later timepoint.


Investigation of STAR.aspirate().

So I was searching for the culprit in the next method in line, i.e. STAR.aspirate():
Which just takes the defaults from STAR.aspirate_pip() - except it changes some based on whether there is an hlc aka HamiltonLiquidClass defined.
Indeed, the only possible re-assignment of swap_speed and homogenization_speed to set them to 0 across the entire “command generation chain” is given by STAR.aspirate() via…

swap_speed = _fill_in_defaults(swap_speed,
      default=[int(hlc.aspiration_swap_speed*10) if hlc is not None else 0
               for hlc in hamilton_liquid_classes])
...
    homogenization_speed = _fill_in_defaults(homogenization_speed,
        default=[int(hlc.aspiration_mix_flow_rate*10) if hlc is not None else 0
               for hlc in hamilton_liquid_classes])

This makes sense → when not being given a liquid_classes=[HamiltonLiquidClass] argument it overrides the swap_speed, homogenization_speed and a bunch of other arguments to 0 - but the only ones who the assertion statement flags as an error are swap_speed and homogenization_speed :slight_smile:

I believe this should be prevented by the STAR._fill_in_defaults() method, and I don’t know why this doesn’t appear to be happening?

But if the problem is that no HamiltonLiquidClass was used then using one should also fix this issue.
But it doesn’t:

await lh.aspirate(
        [source_well],
        vols=[10],
        lld_mode = [lh.backend.LLDMode(1)],
        liquid_classes = [StandardVolume_Water_DispenseSurface_Empty],
    )

…because the liquid class is somehow not seen as reflected by a print(f"hamilton_liquid_classes = {hamilton_liquid_classes}") statement placed inside the STAR.aspirate() method:

hamilton_liquid_classes = None
swap_speed = [0]
homogenization_speed = [0]

Investigation of liquid_handler.aspirate().

Leaving me to investigate the next command in the “command generation chain”, i.e. liquid_handler.aspirate().
This method, and indeed the entire liquid_handler.py file never mentions liquid_classes because they are neatly stored away in the backend_kwargs.

But clearly they are never actually given forward from liquid_handler.aspirate() to STAR.aspirate().
To figure out where we lose them I added print(f"liquid_handler.py - aspirate - backend_kwargs:extras: {extras}") to liquid_handler.py:717, and this is what the same run command as above then returns:

liquid_handler.py - aspirate - backend_kwargs:extras: {'liquid_classes'}
hamilton_liquid_classes = None
swap_speed = [0]
homogenization_speed = [0]

…meaning, the liquid_classes variable is actually deleted before being handed to STAR.aspirate()


Actionable results

  1. Making default lh.aspirate(), used in automation scripts, work → maybe the default values are not accurately assigned if hlc is None?
  2. Making liquid_classes work by not deleting them before handover from liquid_handler.aspirate() to STAR.aspirate()

I tried implementing no. 2 by deleting lines liquid_handler.py:718 and liquid_handler.py:719:

for extra in extras:
    del backend_kwargs[extra]

…which means STAR.aspirate() is actually handed the liquid_classes argument.

But this threw up error:

File ~/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/STAR.py:61, in need_iswap_parked.<locals>.wrapper(self, *args, **kwargs)
     58 if self.iswap_installed and not self.iswap_parked:
     59   await self.park_iswap()
---> 61 result = await method(self, *args, **kwargs) # pylint: disable=not-callable
     63 return result

TypeError: STAR.aspirate() got an unexpected keyword argument 'liquid_classes'

Leading me to think that liquid_classes are not yet implemented in PLR for STAR machines.


Please correct anything that you see that doesn’t look right. Chasing these errors is difficult and I am still learning to navigate the software architecture.

For lh.dispense() the same issues arise when not explicitly declaring the following arguments:

  • swap_speed
  • mix_speed

Looking at the assertion statements for STAR.dispense_pip() I believe the same logic discussed in the aspiration investigation above applies.

Update on issue no. 2:
I used…

await lh.aspirate(
        [source_well],
        vols=[10],
        lld_mode = [lh.backend.LLDMode(1)],
        liquid_classes = [StandardVolume_Water_DispenseSurface_Empty],
    )

…based on the forum thread How does PyLabRobot handle liquid classes?.

That was a mistake. The liquid_handling.aspirate() method will call STAR.aspirate() which does not take the argument liquid_classes but takes the argument hamilton_liquid_classes.

Using…

await lh.aspirate(
        [source_well],
        vols=[10],
        lld_mode = [lh.backend.LLDMode(1)],
        hamilton_liquid_classes = [StandardVolume_Water_DispenseSurface_Empty],
    )

liquid_handling.aspirate() now correctly identifies hamilton_liquid_classes as a “non-extra” backend_kwargs argument, does not delete it and hands it forward to STAR.aspirate().

Meaning…

…is correct: Using a HamiltonLiquidClass does prevent the default overriding of swap_speed and homogenization_speed because in …

hlc is not None.


Now only the overriding of…

  • swap_speed and homogenization_speed → aspiration
  • swap_speed and mix_speed → dispensation
    …require fixing.

But for now, if one assigns a hamilton_liquid_classes = [HamiltonLiquidClass<instance>] then this is no issue.

Thank you for the detailed reporting. TLDR is fixed with default values STAR · PyLabRobot/pylabrobot@f535f45 · GitHub.


The call stack for generating commands on the star is (ignoring some helpers):

  • lh.aspirate: machine-agnostic command for executing an atomic aspiration
  • STAR.aspirate: the STAR-implementation of the LiquidHandlerBackend.aspirate method. LiquidHandlerBackend specifies what commands each liquid handler backend must implement, and what parameters they should take at a minimum. Beyond that, backends can take additional arguments with backend_kwargs. (note: the LiquidHandler methods specify a format and are not executed). Here, PLR-values are converted to STAR firmware values. I decided realistic default values (as they are used in our 10gb log files) should also go here.
  • STAR.aspirate_pip: the low level command implementing the STAR firmware operation. I added a Python implementation for most commands in the firmware doc and the goal is to mirror this doc as closely as possible for easy debugging/development. Here, values are checked to be in a valid range, and the firmware-doc specific values are provided.
  • HamiltonLiquidHandler.send_command: assemble commands in the Hamilton format for sending them to a machine. Shared between STAR and Vantage. (I imagine that at some point we may remove this class and create a hamilton fw utilities collection instead.)
  • USBBackend.write: write over the usb cable.

The issue with swap_speed was that I did not provide a good value for the swap_speed in STAR.aspirate, in _fill_in_defaults in particular. _fill_in_defaults just creates a list of the correct type and does some minor type checking, otherwise it’s a ‘dumb’ method. The error was raised by the STAR.aspirate_pip because the value provided by STAR.aspirate was out of range. If a Hamilton liquid class was provided, a better default value was used which did not raise an error (in this case better meant not-crash, but (hopefully) that is not generally true). The commit above fixed that.

As you said in the third post here: yes, the parameter on the backend (STAR.aspirate) is named hamilton_liquid_classes. If you use hamilton_liquid_classes=[StandardVolume_Water_DispenseSurface_Empty] it will work. For reason laid out in the post you also linked above, this is a better name.

As you also mentioned, this parameter is stored in backend_kwargs. LiquidHandler does some checking to make sure the backend receives only the parameters it should. The STAR-unrecognized liquid_classes parameter was automatically removed. I wrote some more here: Writing robot agnostic methods — PyLabRobot documentation.

Thank you for linking the source of this wrong info. I have edited it for future ease.

1 Like