-
Notifications
You must be signed in to change notification settings - Fork 179
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
Conversation
143aa25
to
4aec1fc
Compare
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).
4aec1fc
to
d64b929
Compare
There was a problem hiding this 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!
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 | ||
), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
Co-authored-by: Ryan Howard <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WOW! nice work!
There was a problem hiding this 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!
for well in wells_covered_dense(nozzle_map, well_name, columns): | ||
wells[well] = TipRackWellState.USED |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah that's fair.
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
testing
opentrons_cli analyze
is probably enough for these because it's all logical state manipulation.