Controlling Z speed of LLD

I’m trying to implement liquid level detection on a STAR. When I run my aspiration cycle (code below), the following happens:

  1. Tip moves to above correct well
  2. Plunger moves to completely empty state
  3. Very rapidly (takes ~1 second), the tip moves down while the plunger moves up.
  4. (a) If a tall water column is present, this movement stops after a few mm, and the liquid level is detected quite accurately. Aspiration occurs at the liquid level.
  5. (b) If only a thin layer of water was present, instead the tip rams into the plate. The z-definition of my labware is correct, I verified that by moving the tip to plate[5][0].get_absolute_location("center", "center", "bottom"). The ram is quite hard, when I run the aspiration without carrier present, the tip dips below the carrier level.

Is there an argument that slows down step 3 so that small amounts of liquid can be detected? We’re working with 6 well plates that contain only 3 mL of liquid, which equates to a water column of only around 3 mm. The robot is not able to detect this liquid level. In my observations, the argument lld_search_height doesn’t seem to do much. Step 3 always starts very high above the liquid level no matter which value I enter here.

Code:

# some function definitions
def _serialize(single_arg, channels_to_use: list[int]) -> list:
    return [single_arg] * len(channels_to_use)


def _mm2dmm(distance_in_mm: float) -> int:
    return int(round(distance_in_mm * 10, 0))


def _serialize_and_convert(single_arg: float, channels_to_use: list[int]) -> list[int]:
    serialized_args = _serialize(single_arg, channels_to_use)
    return [_mm2dmm(arg) for arg in serialized_args]


# parameters
channels_to_use = [9]

well_source = plate[1]
vol_transfer = 500 # volume to transfer
vol_source = 500 # volume in source well
vol_dest = 0 # volume in destination well

well_source_liquid_level = well_source[0].compute_height_from_volume(liquid_volume=vol_source)
well_source_following_distance = well_source[0].compute_height_from_volume(vol_source) - well_source[
    0
].compute_height_from_volume(vol_source - vol_transfer)

await lh.aspirate(
    resources=well_source,
    vols=_serialize(vol_transfer, channels_to_use),
    use_channels=channels_to_use,
    lld_mode=_serialize(STAR.LLDMode.PRESSURE, channels_to_use),
    lld_search_height=_serialize_and_convert(well_source_liquid_level, channels_to_use),
    liquid_height=_serialize(well_source_liquid_level, channels_to_use),
    surface_following_distance=_serialize_and_convert(well_source_following_distance, channels_to_use),
    swap_speed=100,
)

I must be doing something wrong!

2 Likes

Detecting 3ml in a 6wp seems like a tough task for pLLD on STARs. Have you tried playing around with the pLLD sensitivity via dp_lld_sensitivity? according to the docs, a value of 1 is the most sensitive setting, but I have not tried this myself.

Why is it a tough task for pLLD?

I have played around with it, but did not observe any differences. On either 4 or 1 the liquid level is detected accurately on a large column of water.

where do you pass the lld_search_heights parameter? This parameter is always computed by STAR.aspirate (l. 1554) and cannot be overriden (happy to change that).

I don’t really see a parameter for lld search speed in the aspirate/dispense commands on the master module C0. However, there is the following command on the PX modules. It should be possible to find the liquid level using this command and then just pass that through the liquid surface parameter on STAR.

it appears to me there is an issue with parameter zx “Minimum height (maximum immersion depth) [0.1 mm]”.

It is currently defined as

minimum_height = \
      _fill_in_defaults(minimum_height, [int((ls-5) * 10) for ls in liquid_surfaces_no_lld])

STAR.py:1566. This is to match what VENUS sends.

Could you try replacing that line with this:

minimum_height = _fill_in_defaults(minimum_height, [int(wb * 10) for wb in well_bottoms])

(well_bottoms is defined on line 1550)

(if not sufficient, could you artificially increase this value (int(wb * 10) + <some int value>) to see what a good number is? then we can see how to dynamically derive that value from known information and hopefully generalize.)

2 Likes

Lots of insight here, thanks a lot Rick. I’m testing things out right now.

Quick question: when liquid_height is passed to lh.aspirate() (assuming backend is STAR): are there any other parameters that override the exact aspiration height (such as immersion for example)?

Edit: After digging around, I now understand what you mean in the first half of your post. I think the PX/C0 modules are still a bit over my head. It appears zl##### is exactly what we need. The standard value of 450 mm/s really way too high for low liquid levels. If you could give me some pointers as tow here I can implement this parameter, I’d be happy to give it a try.

I also opened a PR for adding lld_search_heights functionality.

Edit 2: Ok, I’ve gotten this far:

tip_pattern = [False, False, False, False, False, False, False, False, False, True]
await lh.backend.send_command(
    module="PX",
    command="ZE",
    tip_pattern=tip_pattern,
    # zh="09320",
    # zc="31200",
    # zi="0000",
    # zj="0",
    # gf="1",
    # gt="0010",
    # gl="0010",
    # gu="0030",
    # gn="0030",
    # gm="0",
    # gz="9999",
    # cj="0",
    # co="0030",
    # cp="0030",
    # cq="0030",
    # cl="02000",
    # cc="0",
    # cd="0",
    # zv="12000",
    zl="00450",
    # zr="075",
    # zw="3",
    # dv="05333",
    # dl="02000",
    # dr="100",
    # dw="5",
)

Tip 10 does exactly what I want it to! It slowly lowers to the liquid level and stops there. However, tip 1 and tip 9 are also doing some funky things… Tip 1 lowers about 3 cm and then stops, tip 9 slowly drops all the way until it hits something. Tips 2 and 8 are stationary. I feel like I’m close, but could use some help :slight_smile:

Edit #3: I made a mistake, re-edited. After restarting the STAR, now all 10 heads do the action! It’s apparent to me that somehow my tip_pattern is incorrect or not being parsed well.

2 Likes

Hey @fderop,

Please try out the following, with a full container first to be safe (I don’t know how to tell it that there is a “minimum height” with this command, so I do expect it to search until it crashes):

await lh.backend.send_command(
    module="PA",
    command="ZE",
    zl="00450",
)

If I understand this correctly, the problem you are seeing is a bit multifaceted:

  1. You are using command “ZE” → that command doesn’t have a “tm” / tip_pattern parameter. Instead this command is part of the “P …” series of modules which you have to tell the channel that you want to use directly (i.e. replace " … " with the channel you want - N.B.: Hamilton naming convention uses 1-based indexing and, for some reason, starts with 1 at the back)
  2. You are using module “PX”:
    • I believe this module is to activate all channels → this could explain why you see all channels perform the command (your previous observations are frankly odd but tbh it’s futile to chase the reasoning for it, and you already did the right thing by just rebooting the machine)
    • What you actually want to use in this case is module “PA” → the “A” stands for the number “10” … in hexadecimal encoding :sweat_smile: (which is what Hamilton machines use most of the time but not all the time)

Note of caution: module=“PA”-command-“ZE” is what I’d call a very “specific” command, ideally you would just use module=“C0”-command-“AS” / aspirate_pip handed to lh.aspirate directly. Then you could use tip_patterns.
But as Rick mentioned above, module=“C0”-command-“AS” does not appear to have a firmware command attribute to modify its “LLD search speed Z-drive [increment/second]”… which is a bit odd and I don’t know why.

To actually use module=“PA”-command-“ZE” you have to move the correct pipette into the correct position (in your case channel_10 above the targeted well of your 6-well plate) before you call this command because this command does not take positional information of the well. This means you have to first deal with corrdinated pipette movement, i.e. ensuring channels don’t crash into one another when you tell one single channel to move to a certain location. If you go down that route I’d recommend calling a lh.aspirate() command to the location first, give it a massive z-offset and volume=[0] to let lh.aspirate() deal with the channel coordination.

To make things more confusing the attribute “zl” has completely different meanings in these two commands:

  • module=“C0”-command-“AS” → “zl” → Liquid surface at function without LLD [0.1mm] (default = 3600)
  • module=“C0”-command-“AS” → “zl” → LLD search speed Z-drive [increment/second] (default = 04500)
2 Likes

Aaaaa - that makes a lot of sense. I’ll try that out first thing in the morning. Thanks a ton!!! I believe it’ll be relatively trivial to write a wrapper for the PX command.

1 Like

Thanks Camillo for the detailed guide as always!

There is a second option for this:

  • STAR.prepare_for_manual_channel_operation(channel=0) for spreading the channels at allow maximum space for channel (0-indexed from the back)
  • STAR.move_channel_x(channel, x): x in mm from the right
  • STAR.move_channel_y(channel, y): y in mm from the front
  • STAR.move_channel_z(channel, z): z in mm from the bottom

Highlighting it says INCREMENT here. Conversion table:

(only the C0 module uses SI units, the “specialized” modules (for channels, x-arm, iswap, etc.) use these Hamilton-specific step-units.)

Super, thank you both. I’m assuming the increment is the smallest step the stepper motors can make.

Ohhh I haven’t come across…

  • `STAR.prepare_for_manual_channel_operation(channel=0) i.e.
  • STAR.position_max_free_y_for_n(channel=0) i.e.
  • module=“C0”-command=“JP” before.

Thank you, I will try this out and see which positions it moves the non-selected channels to.
Though I believe this command set will always be slower than a lh.aspirate() implementation:

  • it requires 4 commands to be executed sequentially rather than one which smartly optimises the “coordinated channel movement”,
  • quite a lot of time will be lost in the non-selected channels moving into their “idle” positions when executing STAR.prepare_for_manual_channel_operation(channel=0)
  • this implementation avoids rather than utilises “coordinated channel movement”, i.e. channel movements are sequential and linear → lh.aspirate() can synchronise the targeted channel’s movement in the x- and y-dimensions, i.e. the channel moves in diagonals across the deck … this is actually how I found out that the OT-2 can only deal with a single firmware command at a time, it never moves in diagonals :sweat_smile:

This is why I believe there is a lot of value in the creation of a STAR.move_channel_to_position(channel=0, position=Coordinate(x,y,z)) which would basically just be a wrapper for a STAR.aspirate_pip() command with vols=[0] and a “Minimum z-Position at end of a command [0.1 mm] / te” of z.

1 Like

I would say it is a tough task for pLLD because 3 mm of liquid to go through (+ the search speed you encountered) is closing in on what liquid height is needed to allow pLLD to work.

This comes down do pLLD using continuous channel-plunger movement to create and measure pressure inside the plunger.
If you have a small liquid - small not in volume but in z height - then the tip might already crash into the bottom of the well before the pressure sensor has detected a noticeable drop in pressure.

May I ask: Can you not use cLLD for your use case?
cLLD has issues with small liquids too, but this time because the volume is low and therefore there is little to no conductive material to which the channel can dissipate its charge to.
That is not the case for you due to the geometry of your wells. You have got 3 mL of liquid which is almost too easy for Hamilton’s cLLD.
Being nosy, I assume you are working on some cell culture application. Are you worried that the charge dissipation of cLLD could have a negative impact on your cells?

2 Likes

I tried it out yesterday and it works really well with the slow z-speed! Super accurate, down to 0.1 mm. Basically stops instantly as it touches down, with a tight convex concave meniscus between the water and the tip.

One main practical advantage of pLLD I see over cLLD is that it doesn’t require conductive tips (thus easier to debug/calibrate with the transparent ones). Though, I haven’t tested too much yet, maybe cLLD is more practical in the end. Yes, for now we’re trying to implement cell culture media exchanges, but with some nasty twists that make it a bit more difficult.

2 Likes