PyLabRobot / pylabrobot

An interactive & hardware agnostic interface for lab automation
https://docs.pylabrobot.org
MIT License
153 stars 54 forks source link

Create a distribute feature #176

Open rickwierenga opened 2 months ago

rickwierenga commented 2 months ago

like https://docs.opentrons.com/v2/new_protocol_api.html#opentrons.protocol_api.InstrumentContext.distribute

Can lift from https://github.com/rickwierenga/pylabrobot-art-studio, where this is already implemented. Just need to make it nice and general

rickwierenga commented 1 month ago

@BioCam is the existing transfer sufficient for your needs?

BioCam commented 1 month ago

I am just reading through the liquid_handler.transfer() function. It is quite a neat, small function:

  async def transfer(
    self,
    source: Well,
    targets: List[Well],
    source_vol: Optional[float] = None,
    ratios: Optional[List[float]] = None,
    target_vols: Optional[List[float]] = None,
    aspiration_flow_rate: Optional[float] = None,
    dispense_flow_rates: Optional[Union[float, List[Optional[float]]]] = None,
    **backend_kwargs
  ):
    """Transfer liquid from one well to another.

    Examples:

      Transfer 50 uL of liquid from the first well to the second well:

      >>> lh.transfer(plate["A1"], plate["B1"], source_vol=50)

      Transfer 80 uL of liquid from the first well equally to the first column:

      >>> lh.transfer(plate["A1"], plate["A1:H1"], source_vol=80)

      Transfer 60 uL of liquid from the first well in a 1:2 ratio to 2 other wells:

      >>> lh.transfer(plate["A1"], plate["B1:C1"], source_vol=60, ratios=[2, 1])

      Transfer arbitrary volumes to the first column:

      >>> lh.transfer(plate["A1"], plate["A1:H1"], target_vols=[3, 1, 4, 1, 5, 9, 6, 2])

    Args:
      source: The source well.
      targets: The target wells.
      source_vol: The volume to transfer from the source well.
      ratios: The ratios to use when transferring liquid to the target wells. If not specified, then
        the volumes will be distributed equally.
      target_vols: The volumes to transfer to the target wells. If specified, `source_vols` and
        `ratios` must be `None`.
      aspiration_flow_rate: The flow rate to use when aspirating, in ul/s. If `None`, the backend
        default will be used.
      dispense_flow_rates: The flow rates to use when dispensing, in ul/s. If `None`, the backend
        default will be used. Either a single flow rate for all channels, or a list of flow rates,
        one for each target well.

    Raises:
      RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`.
    """

    # Deprecation check for single values
    if isinstance(targets, Well):
      raise NotImplementedError("Single target is deprecated, use a list of targets.")
    if isinstance(dispense_flow_rates, numbers.Rational):
      raise NotImplementedError("Single dispense flow rate is deprecated, use a list of flow rates")

    if target_vols is not None:
      if ratios is not None:
        raise TypeError("Cannot specify ratios and target_vols at the same time")
      if source_vol is not None:
        raise TypeError("Cannot specify source_vol and target_vols at the same time")
    else:
      if source_vol is None:
        raise TypeError("Must specify either source_vol or target_vols")

      if ratios is None:
        ratios = [1] * len(targets)

      target_vols = [source_vol * r / sum(ratios) for r in ratios]

    await self.aspirate(
      resources=[source],
      vols=[sum(target_vols)],
      flow_rates=aspiration_flow_rate,
      **backend_kwargs)
    for target, vol in zip(targets, target_vols):
      await self.dispense(
        resources=[target],
        vols=[vol],
        flow_rates=dispense_flow_rates,
        use_channels=[0],
        **backend_kwargs)

But for complex functionality I think we should be inspired by Opentrons approach and build some Hamilton wisdom into it and top it up with a bit genuine innovation by us:

Opentrons cleanly distinguishes between their transfer() (simple liquid transfers from 1 well to another 1 well) and distribute() functions (smart function that distributes liquid from one source well into multiple destination wells, autonomously identifying the best way to do so, i.e. it goes back to the source well when it has reached its current tip's max volume). If I understand the above correctly it kind of merges these two concepts into one function with the name liquid_handler.transfer().

I believe Opentrons is right to have transfer() be simple transfers of liquid from 1 source well to 1 destination well, and in analogy for multi-channel systems like the STAR would be from 1 list of source wells to 1 list of destination wells, something like this:

lh.transfer(source_plate["A1", "B1", "E2", "H2", "D4", "F4", "A10", "G12"], destination_plate["A1:H1"],vols=[80, 40, 50, 100, 30, 50, 80, 75])

This by itself is already adding functionality to Hamilton machines that the hardware and firmware would have no problems with, but that I could not find in GUI-based proprietary software, no matter how much I searched.

But, this is very different from a distribute() function, we might also want to discuss whether PLR should call this feature "distribute" because some people might be more familiar with the term "aliquot":

lh.distribute(water_trough, dest_matrix = [destination_plate["A1:H1"]], vols_matrix=[[80, 40, 50, 100, 30, 50, 80, 75]], use_channels=[0])

I am proposing a couple of things simultaneously in this little line here:

  1. Adapt the "smartness" we have seen from Opentrons, i.e. the function has to identify what tip it carries on the targeted channel, what that tip's max volume is, how to chunk the channel's distribute_list into aspiration_cycles that ensure there is never too much liquid in the tip, ensures a certain retention_volume in the tip (important for accurate aspiration_cycles repeats, and performs the dispense and aspirations autonomously.
  2. Super-power far beyond the capability's of any system I have encountered: make full use of the multi-channel system using a destination_matrix and volume_matrix argument: Instead of giving the function a transfer volume list a transfer volume matrix is, as the name implies, a 2-dimensional array: 0-7 rows representing each channel (in an 8-channel system, more if more channels exist), but it has no real limit in the number columns.

A simple showcase to highlight the power I imagine this to have:

We all perform normalizations, no secret here. Let's say you have a 96-well plate of protein extractions one needs to normalise. We calculate that we need to add the following buffer volumes to a new well, and then add x volume from the extraction plate to it:

image

There is no reason to transfer all these volumes of the same buffer to an empty well in separate transfers, distributing / aliquotting is way faster, particularly if when one scales to multiple plates. But at the same time using a single tip (like with the OT-2) is painfully slow.

This is where PLR's new distribute function can come in: use all channels available on the robot one has; the function is simply given the matrix of destination resources, and a matrix of volumes, and then identifies how to chunk the matrix - column by column - into aspiration cycles to execute.

It would be vastly faster than anything currently available and totally in the realm of y-independent channel systems ... the only thing that's missing is a flexible-enough, detailed-enough, computational design-empowered software = PLR :)

Normalisation is just one application, I can see use cases like inoculations, experimental setup construction and more.

This function should also have arguments like pre_aspirate_dispense=[5,5,5,None] (i.e. the volume to dispense straight back to the source well to avoid the known issue with the first aliquot), and retention_vols=[5,5,5,None] (i.e. the volume that should stay back in the tip to ensure the last aliquot is identical to all the previous ones).

This is a big vision for a PLR method imo but I am proposing it because I believe it to be incredibly powerful, and a way to set PLR instantly apart and showcase what programmationally-designed liquid handling can do.


To be clear, I don't believe the information regarding channel parameter settings to make such a function accurate and reliable is given with the current clinging to "liquid classes", but I'm sure we can figure out a better way using the full set of aspiration and dispensation arguments ;)

rickwierenga commented 1 month ago

I will make this

BioCam commented 1 month ago

hahaha 🤣

I thought you might like it

BioCam commented 1 month ago

I will make this

I know how much fun it will be to actually build this function, so I won't take this from you @rickwierenga (plus, I've got a ton of work in my day job and other PLR dev tasks) but how about we have a meeting solely discussing how to implement this function in the next couple of day?

For example, we must absolutely polish STAR.aspirate() and STAR.dispense() before heading into this (unit standardisation in particular) and we must ensure we have access to all arguments. I see these tasks as urgent precedence constraints to the implementation of lh.distribute() / lh.aliquot().

PS. I did warn you that once PLR is more open to complex, more "user-focused" features I will let a lot of ideas loose :P

rickwierenga commented 1 month ago

a meeting

Sure, what time?

PS. I did warn you that once PLR is more open to complex, more "user-focused" features I will let a lot of ideas loose :P

This has always been the goal: front ends exist to compose complex functions out of easy-to-implement atomic commands. (obviously while maintaining granular control.) From the paper: "Composition of the unit operations, such as the discard operation (drop tip in trash) and the transfer operations (combined aspirate and dispense), are performed at the LiquidHandler level and above". So excited we are nearing a stage where we can productively implement composite commands more than simple transfer and discard 🎉

BioCam commented 1 month ago

@rickwierenga, I'm available today until 15:00 UCT+1 or on Wednesday? :)

So excited we are nearing a stage where we can productively implement composite commands more than simple transfer and discard 🎉

I completely agree 🚀

rickwierenga commented 1 month ago

sorry to delay, but Wednesday is a lot better for me

BioCam commented 1 month ago

I know, let's do Wednesday during the day at 11:00 UCT+1? This meeting shouldn't be longer than 45 min because we just want to create a requirements list for the distribute function to begin the initial design; there are quite a lot of design considerations tbh.

rickwierenga commented 1 month ago

Perfect!