Single Dispense Undefined Offset Issue

Hi everyone,

I found that when I dispense into multiple wells in the same column using multiple channels simultaneously the pipetting accuracy is exactly as desired:



for x in tqdm(range(88, 93,8)):
    dest_wells = prep_plate_0.children[x:x+5]

    await lh.dispense(
                      dest_wells, 
                      vols=[8]*len(dest_wells),
                      swap_speed = [20]*len(dest_wells),
                      homogenization_speed = [20]*len(dest_wells),
                      offsets = [Coordinate(x=0,y=0,z=1.66)]*len(dest_wells),
                      mix_speed = 20
    )

dest_wells
> [Well(name=prep_plate_0_well_11_0, location=(108.500, 070.000, 000.750), size_x=9.0, size_y=9.0, size_z=11.3, category=well),
 Well(name=prep_plate_0_well_11_1, location=(108.500, 061.000, 000.750), size_x=9.0, size_y=9.0, size_z=11.3, category=well),
 Well(name=prep_plate_0_well_11_2, location=(108.500, 052.000, 000.750), size_x=9.0, size_y=9.0, size_z=11.3, category=well),
 Well(name=prep_plate_0_well_11_3, location=(108.500, 043.000, 000.750), size_x=9.0, size_y=9.0, size_z=11.3, category=well),
 Well(name=prep_plate_0_well_11_4, location=(108.500, 034.000, 000.750), size_x=9.0, size_y=9.0, size_z=11.3, category=well)]

However, as soon as I use a single dispensation the arm moves about 5mm to the right and the tip then crashes into the plate.

await lh.dispense(dest_wells[channel_idx],
                          vols = [8],
                          swap_speed = 40,
                          homogenization_speed = 20,
                          flow_rates = 120,
                          blow_out_air_volume = 2,
                          offsets = [Coordinate(x=0,y=0,z=1.66)],
                          mix_speed = 30,
                          use_channels = [channel_idx]
                          )


channel_idx
> 0
dest_wells[channel_idx]
> Well(name=prep_plate_0_well_11_0, location=(108.500, 070.000, 000.750), size_x=9.0, size_y=9.0, size_z=11.3, category=well)

I have checked the specified destination wells and they are exactly the same (shown above with the associated code).

I observe the exact same behaviour at aspirations.

Here is one example that I filmed:
Working coordinates for multi-aspirate

source_wells = reagent_plate_1.children[:4]

for x in range(1):
    await lh.aspirate(
        source_wells,
        vols=[2]*len(source_wells),
        swap_speed = [50]*len(source_wells),
        homogenization_speed = [20]*len(source_wells),
        lld_mode = [lh.backend.LLDMode(1)]*len(source_wells),
        use_channels = [channel+4*x for channel in [0,1,2,3]]
    )

Disfunctional coordinates for single-aspirate

await lh.aspirate(
    source_wells[0],
    vols=[2],
    swap_speed = [50],
    homogenization_speed = [20],
    lld_mode = [lh.backend.LLDMode(1)],
    use_channels = [0]
    )

these two cells executed right after one another:

This is a very consistent across different locations and different plates.

Does anyone have an idea why single dispensations would suddenly change their x offset without any commands to do so (as far as I can see)?

Sorry for the late reply.

This is because for single-resource dispenses, the 2d center of the resource is automatically added as an offset in LiquidHandler - useful when aspirating from a tube or beaker. This is not done when a list of resources is passed, even if the list contains one item.

In your case, the well is interpreted as a single-resource dispense which I had initially intended for tubes/beakers. STAR also adds this offset when aspirating from/dispensing to a Well → you see double the offset. If you had passed the well in a [] you would see correct behavior.

I did the single-resource aspirate/dispense after the list-dispense, and was not thinking clearly on how to disambiguate the API. Let’s do that now. I think we should either

  1. always add the 2d center in LiquidHandler so we never have to do this in STAR. This offset would be added to any user-defined offset passed to the offsets parameter of LH. The sum would be sent to the backend. On the Opentrons, we would need to subtract the ‘default’ 2d center offset because their API already automatically centers an aspiration in a well.
  2. always have STAR add the offset, remove from LiquidHandler. I think there is an argument to be made for having backends specify the center offset; this is effectively what already happens on Opentrons.

Both are equally ‘dangerous’ by symmetry. I think I like the first option better because it is super explicit. In the standard form, you will just see a resource and the offset to its left front bottom. The opentrons backend will have to adjust for that.

Thank you for your explanation, @rickwierenga.

Just to make sure I understand:

  1. the backend, in this case STAR.py, always calculates the 2D center and adds it to each aspiration/dispensation position, no matter whether the position is given inside a list or directly:
await lh.aspirate(pos1, vols=[5]) # position given directly
await lh.aspirate([pos1], vols=[5]) # position given inside a list
  1. but, importantly, the liquid_handler calculates the 2D center and adds it to each aspiration/dispensation position, only if the position is given directly:

Which means…

await lh.aspirate(pos1, vols=[5]) # position given directly

…actually receives the 2D offset instructions twice, moving the aspiration/dispensation into the back, right corner of the container/well (which, of course, doesn’t even exist in the case of circular cross-section containers, and the tip is destined to crash into the plate… )


?

That makes everything I have observed make sense now.

As you said, the simple, direct solution is that all aspiration/dispensation positions must be given to the corresponding method (.aspirate(), .dispense()) inside a list and this should probably be placed into the docs with a big warning sign.

In regards to your two proposed solutions:
I also think solution 2 - have all 2D center offsets added only by the backend - is preferrable.
If the Opentrons API already performs this action it is likely that other machines that might get integrated in the future will do the same. Keeping aspiration positions consistent and predictable is very important. In this situation one should be able to look up the backend definitions and doesn’t have to worry about anything else to figure out what is happening.

Exactly right.

People don’t read the docs. I will make the API sensible.

Probably option 1 is more predictable IMO. It is more intuitive to always just aspirate from the center (on the xy plane). Imagine calling aspirate on a beaker/bucket and it aspirates from the corner…

Passing a single well should be treated the same as if that well were passed in a list. This cannot be cleanly reconciled with aspirating from the corner (not-center) if not a well.