Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): track volumes from multichannel configs #16698

Merged
merged 5 commits into from
Nov 8, 2024

Conversation

sfoster1
Copy link
Member

@sfoster1 sfoster1 commented Nov 5, 2024

This PR adds the capability to properly track volume changes made by multichannel pipettes (and partial tip loadings of multichannel pipettes) to the engine. It also adds a quick refactor of the visibility of nozzle map types. This is well separated by commit.

multichannels

This is in commit d49e90b

There are two ways in which we need to handle multichannel nozzle configurations specially compared to single-channel configurations.

First, and what EXEC-795 is about, is that pipettes with multiple active nozzles will aspirate out of or dispense into multiple wells in an aspirate/dispense/in_place command. Which wells the pipette touches is a matter of projecting the pipette nozzle map out over the layout of the labware and predicting which wells are interacted with.

This is itself non-trivial because labware can have many formats. What we can do is make the math work correctly when possible - when the labware is laid out normally enough that we can do projections of this type - and fall back to pretending to be a single channel if we fail. Since we're computing the logical equivalent of actual physical state, and if the labware is irregular it's unlikely that a multiple nozzle layout will physically work with the labware, I think this is safe.

Specifically the thing we need to do is generalize the logic used in the tip store to project which tips are picked up by a multichannel to labware of different formats. Our multichannel pipette nozzles are laid out to match SBS 96-well plates, and so that's our "default" labware. On labware that follows SBS patterns but is more dense - a 384 plate, for instance - then we have to subsample, picking a single well in each group of (well_count / 96) that occupies the same space as a 96-well well to interact with. On labware that follows SBS patterns but is less dense - a 12-column reservoir, for instance - then we have to supersample, letting a labware well be touched by multiple nozzles.

The second thing we have to deal with is that if the labware is a reservoir or reservoir-like - it has fewer wells than we have nozzles - then the common case is that multiple nozzles are in a well, and in that case if we're keeping track of the volume taken out of or added into a well we have to multiply the operation volume by the number of nozzles per well, which we can get by just dividing sizes without taking into account pattern overlap.

nozzle maps

This is in commit d64b929

This came up as I was poking around with needing the nozzle map to be visible in new places; I found it pretty awful that it was just implicitly exposed including internals, so make a new interface protocol that is explicitly exposed in opentrons.types and hold all of the internals, well, internal, at least to the engine.

Closes EXEC-795

to come out of draft

  • tests. lots of tests
  • general approval that this is the way forward

testing

opentrons_cli analyze is probably enough for these because it's all logical state manipulation.

  • this works for a single pipette the way it has always works, ditto 1-channel configurations on 8 or 96 channel pipettes
  • 8-channel pipettes properly handle 96-standard full-column operations
  • 8-channel pipettes properly handle 96-standard offset operations
  • 8-channel pipettes handle 384 plates, including A1 and B1 operations
  • 8-channel pipettes handle 12-column reservoirs
  • 96-channel pipettes handle 96-standard full operations
  • 96-channel pipettes handle 384 plates, including A1, B1, A2, and B2 operations
  • 96-channel pipettes handle 12-column and 1 column reservoirs

@sfoster1 sfoster1 force-pushed the exec-795-multichannel-liquid-updates branch 2 times, most recently from 143aa25 to 4aec1fc Compare November 6, 2024 17:54
This PR adds the capability to properly track volume changes made by
multichannel pipettes (and partial tip loadings of multichannel
pipettes) to the engine.

There are two ways in which we need to handle multichannel nozzle
configurations specially compared to single-channel configurations.

First, and what EXEC-795 is about, is that pipettes with multiple active
nozzles will aspirate out of or dispense into multiple wells in an
aspirate/dispense/in_place command. Which wells the pipette touches is a
matter of projecting the pipette nozzle map out over the layout of the
labware and predicting which wells are interacted with.

This is itself non-trivial because labware can have many formats. What
we can do is make the math work correctly when possible - when the
labware is laid out normally enough that we can do projections of this
type - and fall back to pretending to be a single channel if we fail.
Since we're computing the logical equivalent of actual physical state,
and if the labware is irregular it's unlikely that a multiple nozzle
layout will physically work with the labware, I think this is safe.

Specifically the thing we need to do is generalize the logic used in the
tip store to project which tips are picked up by a multichannel to
labware of different formats. Our multichannel pipette nozzles are laid
out to match SBS 96-well plates, and so that's our "default" labware. On
labware that follows SBS patterns but is more dense - a 384 plate, for
instance - then we have to subsample, picking a single well in each
group of (well_count / 96) that occupies the same space as a 96-well
well to interact with. On labware that follows SBS patterns but is less
dense - a 12-column reservoir, for instance - then we have to
supersample, letting a labware well be touched by multiple nozzles.

The second thing we have to deal with is that if the labware is a
reservoir or reservoir-like - it has fewer wells than we have nozzles -
then the common case is that multiple nozzles are in a well, and in that
case if we're keeping track of the volume taken out of or added into a
well we have to multiply the operation volume by the number of nozzles
per well, which we can get by just dividing sizes without taking into
account pattern overlap.

Closes EXEC-795
There's this hardware controller NozzleMap type that is mostly internal
but actually exposed upstream in a couple weird uncontrolled ways.
Refactor this so that
- There's a controlled interface that is in opentrons.types and that is
the only thing that is exposed above the engine
- The engine and lower are allowed to see the actual type

This had some knock-on consequences because some functionality had to
move to lower layers. Specifically, "can this layout support LLD" is a
question that now only the engine can answer because the physical
configuration type is not in the interface, so push it down (which feels
better anyway because now you get this checked if you use the commands api).
@sfoster1 sfoster1 force-pushed the exec-795-multichannel-liquid-updates branch from 4aec1fc to d64b929 Compare November 6, 2024 18:59
Copy link
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.

Makes sense so far, thanks!

Comment on lines 172 to 178
well_names=self._state_view.geometry.get_wells_covered_by_pipette_focused_on_well(
labware_id, well_name, pipette_id
),
volume_added=-volume_aspirated
* self._state_view.geometry.get_nozzles_per_well(
labware_id, well_name, pipette_id
),
Copy link
Contributor

@SyntaxColoring SyntaxColoring Nov 7, 2024

Choose a reason for hiding this comment

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

Since this well_names/volume_added pattern happens across several commands, it might make sense to centralize it in GeometryView. Like:

class WellAndVol(NamedTuple):
    well_name: str
    volume: float

class GeometryView:
    ...
    def project_liquid_operation(
        labware_id: str, focused_well: str, pipette_id: str, volume_per_tip: float
    ) -> list[WellAndVol]:
        ...

And then StateUpdate.set_liquid_operated() would take a list[WellAndVol], instead of taking a list of well_names and a separate total volume_added.

This would probably help with command implementation testability, too.

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess... I do kind of want to enforce the "all the wells gain/lose the same volume" though, and I think it's good to be explicit.

Copy link
Contributor

Choose a reason for hiding this comment

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

edge case scenario with liquid level detection:

  • say you have 100 µL in A1 and H1 and 50 µL in B1–G1.
  • your pipette is in COLUMN nozzle configuration.
  • command a meniscus-relative aspirate of 25 µL from the column (A1–H1).
  • we (i.e., humans) know that the A1 and H1 tips will aspirate 25 µL of liquid and the other tips will aspirate 0.

do we want to account for that in software? or are we content to say that is Not a Thing You Should Do in documentation?

Copy link
Member Author

Choose a reason for hiding this comment

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

That Is Not A Thing You Should Do

"""Get a flat list of wells that are covered by a pipette when moved to a specified well.

When you move a pipette in a multichannel configuration to a specific well - here called
"focused on" the well, for lack of a better option - the pipette will operate on other wells as well.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think I've seen this called the "primary well" or "active well" elsewhere.

@sfoster1 sfoster1 marked this pull request as ready for review November 7, 2024 23:02
@sfoster1 sfoster1 requested a review from a team as a code owner November 7, 2024 23:02
Copy link
Contributor

@ryanthecoder ryanthecoder 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.

Copy link
Contributor

@TamarZanzouri TamarZanzouri left a comment

Choose a reason for hiding this comment

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

WOW! nice work!

Copy link
Contributor

@CaseyBatten CaseyBatten left a comment

Choose a reason for hiding this comment

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

Looks really good overall, thanks for the cleanup on some of the tip state tracking logic!

Comment on lines +118 to +119
for well in wells_covered_dense(nozzle_map, well_name, columns):
wells[well] = TipRackWellState.USED
Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you for addressing this, its been something I've been meaning to swing back on eventually that was going to give us issues down the line. The new well math looks awesome.

) -> Optional[str]:
"""Get the next available clean tip. Does not support use of a starting tip if the pipette used is in a partial configuration."""
wells = self._state.tips_by_labware_id.get(labware_id, {})
columns = self._state.column_by_labware_id.get(labware_id, [])

# TODO(sf): I'm pretty sure this can be replaced with wells_covered_96 but I'm not quite sure how
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you're correct, but we'd need to keep in mind the tip cluster logic has a lot of rules for what constitutes a valid cluster and when/where you can index in and over a row/column when picking your next cluster. Of note it works differently for 8ch pipettes vs 96ch pipettes, on 8ch pipettes the starting nozzles of A1 and H1 being searching through the tiprack from the left side rather than the right side like on a 96ch search. I think we'd need to account for those things to determine the initial point of reference on a tiprack map to compare against before deferring to wells_covered_96.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah that's fair.

@sfoster1 sfoster1 merged commit 1ae4b63 into edge Nov 8, 2024
49 checks passed
@sfoster1 sfoster1 deleted the exec-795-multichannel-liquid-updates branch November 8, 2024 17:19
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