Skip to content

feat(api): group together multiple wells for multi-channel transfers#17900

Merged
jbleon95 merged 15 commits into
chore_release-8.4.0from
multi_channel_well_grouping_support
Mar 28, 2025
Merged

feat(api): group together multiple wells for multi-channel transfers#17900
jbleon95 merged 15 commits into
chore_release-8.4.0from
multi_channel_well_grouping_support

Conversation

@jbleon95
Copy link
Copy Markdown
Contributor

@jbleon95 jbleon95 commented Mar 27, 2025

Overview

Closes AUTH-1412.

This PR adds multi-channel pipette support to the liquid class transfer methods (transfer_liquid, distribute_liquid, consolidate_liquid) by means of adding well grouping logic for multi-tip configurations addressing 96 and 384 well plates.

The existing aspirate and dispense commands take a single well target, but the transfer functions are able to take multiple well targets. This introduces an interesting model of behavior. When a single tip pipette/configuration is used, each well given as an argument is addressed by that instrument's tip. But in the existing implementation, and in the existing aspirate/dispense, multi-tip configurations instead are only telling the pipette where to move the pipette. To illustrate this difference, when pipetting with a single tip to a column (say A1 through H1 of a 96 well plate), those eight wells are given and those eight wells are each visited once by the single tip of the pipette. But to make an 8-channel pipette (with a primary nozzle of A1) visit all eight wells only once, the argument would instead by only A1. It's up to the user to understand the primary nozzle and where the other tips will end up.

This PR intends to introduce a new paradigm, which is tentatively called well grouping. The idea is to hold to the logic that each well given as an argument is visited exactly once by the pipette's tip (assuming the well is given once as an argument). This means that giving a column for an 8-channel pipette would have that pipette visit the entire column once in one go, rather than visiting each well with it's primary tip. Similarly, giving a whole row for a row configuration would have that row picked up in one go, and giving a whole labware's worth of wells for a full 96-tip configuration would have the picked up in one go.

While this logic can be used for partial columns and any future partial row or sub-rectangular configurations, this PR only allows this to be used for full columns, full rows and full 96-tip configurations.

The implementation of this logic is strict in that it requires the wells to be given in order, where the first well in any sequence of a column/row/whole well plate is the top-most/left-most/top-left-most well in the plate. Then every well after that that would be covered by the pipette's tip configuration needs to be given, and an error will be raised if a different well is provided in that list or if not all wells are given.

The old transfer and related methods had a similar outcome in behavior, but was implemented in a much simpler and less robust way (It would literally reduce the list of wells to only wells in the first row, or first two rows for a 384 well plate for a pipette with more than one tip, no additional logic or checking if the whole column was provided).

If this behavior is not desired by a user, a new flag has been added, visit_every_well, that if set to be True, will revert to the previous behavior of unconditionally visiting every well given with the primary nozzle.

Test Plan and Hands on Testing

A lot of the different combinations of configurations, 96 and 384 well plates, and scenarios have been tested in unit tests, and the following protocol has been tested on a robot to confirm it works as expected.

requirements = {
    "robotType": "Flex",
    "apiLevel": "2.23"
}

metadata = {
    "protocolName":'Liquid Class Multi Tip Well Grouping',
    'author':'Jeremy'
}

def run(protocol_context):
    tiprack = protocol_context.load_labware("opentrons_flex_96_tiprack_200ul", "C2")
    trash = protocol_context.load_trash_bin('A3')
    pipette_1k = protocol_context.load_instrument("flex_8channel_1000", "left", tip_racks=[tiprack])
    nest_plate = protocol_context.load_labware("nest_96_wellplate_2ml_deep", "D1")
    arma_plate = protocol_context.load_labware("armadillo_96_wellplate_200ul_pcr_full_skirt", "D3")

    water_class = protocol_context.define_liquid_class("water")

    # Transfer 1 column to another
    pipette_1k.transfer_liquid(
        liquid_class=water_class,
        volume=100,
        source=nest_plate.columns()[0],
        dest=arma_plate.columns()[0],
        new_tip="once",
        trash_location=trash,
    )

    # Consolidate two columns to one
    pipette_1k.consolidate_liquid(
        liquid_class=water_class,
        volume=100,
        source=nest_plate.columns()[:2],
        dest=arma_plate.columns()[0],
        new_tip="once",
        trash_location=trash,
    )

    # Distribute one column to two
    pipette_1k.distribute_liquid(
        liquid_class=water_class,
        volume=100,
        source=nest_plate.columns()[0],
        dest=arma_plate.columns()[:2],
        new_tip="once",
        trash_location=trash,
    )

    # Use visit every well for 1 column to another transfer
    pipette_1k.transfer_liquid(
        liquid_class=water_class,
        volume=100,
        source=nest_plate.columns()[0][0],
        dest=arma_plate.columns()[0][0],
        new_tip="once",
        trash_location=trash,
        visit_every_well=True,
    )

Changelog

  • Added well grouping logic to transfer_liquid, distribute_liquid, consolidate_liquid for 8-channel columns, 12-channel rows and 96-channel full configurations, for 96 and 384 well plates
  • Added a visit_every_well argument to revert to previous behavior where every well would be visited by the primary nozzle
  • Update distribute and consolidate function signatures to allow a list of wells for source/destination, so that well grouping can be allowed to try and group them into a single target. If it is unable to do so, the method will still raise an error.

Review requests

Naming of things, arguments and error messages specifically, as always. There's also the question of if this behavior should be the default (I believe it should be but there could be an argument to reverse it).

There's also a couple of interesting quirks to the way I've implemented this logic. The primary one being that although wells are expected to be in order, really only the first well in a sequence matters, since that's the one we will choose to target and then check the next N-1 wells (where N is the number of tips in the configuration) to see if they match (384 well plates are a little more complicated but hold mostly true). This means for an 8-channel column, an ordered list of wells A1 through H1 would work the same as A1, and then an arbitrary order of B1 through H1, but having anything other than A1 be first would cause the function to fail (assuming A1 is in that list, as it would not be covered if you targeted anything below it). There would be ways of either making this behavior smarting or enforcing stricter rules to make this more clear, but it was worth mentioning here these corner cases.

Risk assessment

Medium, this does change the default behavior for the new liquid class transfer methods and a significant way, but it is limited to these new functions that have yet to been released and it does function similarly to the old transfer with an 8-channel pipette.

@y3rsh
Copy link
Copy Markdown
Member

y3rsh commented Mar 27, 2025

This is a very clever solution. That said, I’m a little sad to see us moving away from what feels like a simpler API—one that only ever requires thinking in terms of primary tip.

Are there examples where this additional way of targeting shines or simplifies use cases? I’d love to see them to help better understand adding this method of targeting.

@jbleon95
Copy link
Copy Markdown
Contributor Author

jbleon95 commented Mar 27, 2025

This is a very clever solution. That said, I’m a little sad to see us moving away from what feels like a simpler API—one that only ever requires thinking in terms of primary tip.

Are there examples where this additional way of targeting shines or simplifies use cases? I’d love to see them to help better understand adding this method of targeting.

I do think that it is more "readable" (as in expressing the intent of the function and having a lay person read it) having a transfer between two columns be expressed like

eight_channel_pipette.transfer_liquid(
    liquid_class=water_class,
    volume=100,
    source=nest_plate.columns()[0],
    dest=arma_plate.columns()[0],
    new_tip="once",
)

instead of

eight_channel_pipette.transfer_liquid(
    liquid_class=water_class,
    volume=100,
    source=nest_plate.wells_by_name()['A1'],
    dest=arma_plate.wells_by_name()['A1'],
    new_tip="once",
)

And to go even further with this example, if you wanted to say transfer an entire plate's worth of columns, I think its much much more readable having this

eight_channel_pipette.transfer_liquid(
    liquid_class=water_class,
    volume=100,
    source=nest_plate.columns(),
    dest=arma_plate.columns(),
    new_tip="once",
)

rather then

eight_channel_pipette.transfer_liquid(
    liquid_class=water_class,
    volume=100,
    source=nest_plate.rows()[0],
    dest=arma_plate.rows()[0],
    new_tip="once",
)

That said, the primary nozzle targeting is still available if with the visit_every_well boolean (or whatever we end up naming it), and if desired we could always make that the default behavior

@y3rsh
Copy link
Copy Markdown
Member

y3rsh commented Mar 27, 2025

Thank you for the examples and I agree, that is more readable. Did not think of it that way.
This will be nice for full tip config.
With partial tip config, I don't need the whole column so I would need to make an edited list to pass.
I know we tend to like flexibility. I do like the readability of the examples. Yet, I still think it is a close call on the tradeoff for simplicity, consistency of the mental model, and documentation. I'm good either way, not a hill I will die on but wanted to discuss.

@jbleon95 jbleon95 marked this pull request as ready for review March 28, 2025 14:27
@jbleon95 jbleon95 requested a review from a team as a code owner March 28, 2025 14:27
@jbleon95 jbleon95 requested a review from sanni-t March 28, 2025 14:27
@sfoster1
Copy link
Copy Markdown
Member

With partial tip config, I don't need the whole column so I would need to make an edited list to pass.

I agree with everything you said, yeah. I think this bit in particular is going to be a sticking point no matter what solution we end up with - either it's not clear from the protocol what will occur, or you have to edit the list. I think a good way to solve this might be to have some extra utilities that would be like pipette.wells_from_top() or something that just take a well reference and return a list of well references that this pipette will touch if the active well is the one you passed in

Copy link
Copy Markdown
Contributor

@SyntaxColoring SyntaxColoring left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still looking through this (feel free to not wait for me if you have other approvals), but it all looks sensible so far. One question though.

Comment thread api/src/opentrons/protocol_api/instrument_context.py
Union[types.Location, labware.Well, TrashBin, WasteChute]
] = None,
return_tip: bool = False,
visit_every_well: bool = False,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I find the name visit_every_well a little confusing because even when this is False, the pipette does still visit every well, in the sense that some tip will descend into every well. In other words, it's not as if visit_every_well=False skips wells.

Copy link
Copy Markdown
Contributor

@SyntaxColoring SyntaxColoring Mar 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about, like, locate_with_primary_nozzle=False? Assuming "primary nozzle" is already a public-facing PAPI concept. I always forget the exact terminology.

Comment on lines +112 to +126
@pytest.fixture
def mock_96_well_labware(decoy: Decoy) -> Labware:
"""Get a mock 96 well labware."""
mock_96_well_labware = decoy.mock(cls=Labware)
decoy.when(mock_96_well_labware.parameters).then_return({"format": "96Standard"}) # type: ignore[typeddict-item]
labware_wells_by_column = []
for column in WELLS_BY_COLUMN_96:
wells_by_column = []
for well_name in column:
mock_well = decoy.mock(cls=Well)
decoy.when(mock_well.well_name).then_return(well_name)
wells_by_column.append(mock_well)
labware_wells_by_column.append(wells_by_column)
decoy.when(mock_96_well_labware.columns()).then_return(labware_wells_by_column)
return mock_96_well_labware
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

I think these tests would get a lot cleaner if transfer_liquid_utils.py took just the data that it actually needed, like well names and quirks, instead of full Labware objects. Then you could do all of this without mocks.

@ecormany
Copy link
Copy Markdown
Contributor

This seems good to me. While this isn't the exact behavior of transfer(), that's largely because it was written before partial tip pickup was supported. Broadly though, this gives protocol authors a path to type _liquid to update their existing transfer calls and not have to change the source and dest arguments in most cases. I think that's a usability and documentation win — and if we find some edge cases later on, we can address them with bug fixes, docs, or both.

Copy link
Copy Markdown
Contributor

@SyntaxColoring SyntaxColoring left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't had a chance to really scrutinize the logic because of my own time constraints, but this all looks reasonable and I agree that we can keep polishing it (e.g. bikeshed the name visit_every_well) after feature freeze.

Comment on lines -1682 to +1702
dest=(types.Location(types.Point(), labware=dest), dest._core),
dest=(
types.Location(types.Point(), labware=verified_dest),
verified_dest._core,
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not introduced in this PR, but is types.Point() correct here? I thought Location.point was always in absolute deck coordinates, so wouldn't this try to pipette at deck coordinates (0, 0, 0)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discard this Point in the implementation, or rather, we re-calculate it based on the positions specified in the liquid class. We need to pass this Location type in order to have access to the labware (or LabwareLike) object when updating the location cache.

Copy link
Copy Markdown
Member

@sanni-t sanni-t left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! I like the consolidated well grouping logic!
Just have some code organizational nitpicks but they don't need to be addressed in this PR.

Comment thread api/src/opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py Outdated
@jbleon95 jbleon95 merged commit ecda6ca into chore_release-8.4.0 Mar 28, 2025
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants