-
Notifications
You must be signed in to change notification settings - Fork 178
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): add execution of aspirate steps in a liquid class based transfer #17092
base: edge
Are you sure you want to change the base?
Conversation
…mponentsExecutor executes individual components delegated by aspirate/ dispense
) | ||
if new_tip == TransferTipPolicyV2.ONCE: | ||
# TODO: update this once getNextTip is implemented | ||
self.get_next_tip() |
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.
Hey, does the upcoming get_next_tip()
return the tip to use (stateless), or does it alter some internal state to indicate the tip it chose?
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.
It returns the tip to use in a stateless way
|
||
|
||
@pytest.fixture | ||
def maximal_liquid_class_def() -> LiquidClassSchemaV1: |
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.
lol
# TODO: update this once getNextTip is implemented | ||
self.get_next_tip() | ||
|
||
# TODO: add aspirate and dispense |
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.
Does this just call the aspirate_liquid_class()
function below?
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.
aspirate_liquid_class
and the upcoming dispense_liquid_class
functions
assert delay_props.duration is not None | ||
self._instrument.delay(delay_props.duration) | ||
|
||
def dispense_and_wait(self, volume: float, push_out: Optional[float]) -> None: |
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.
So this PR primarily implements aspirate, which would not use functions like dispense_and_wait()
, right? This is just here for illustration?
Edit: Oh wait, maybe you do use it because mix()
is part of aspirate? Can you move this function below mix()
so that the functions are arranged in roughly the order that they would be called during a transfer, so that a reader can read this file top-down and follow the program flow?
@@ -38,6 +42,7 @@ | |||
) | |||
from opentrons.protocol_api._nozzle_layout import NozzleLayout | |||
from . import overlap_versions, pipette_movement_conflict | |||
from . import transfer_components_executor as tx_comps_executor |
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.
Hehe, this name is really too long. Maybe just transfer_executor
?
But it doesn't even behave like the traditional meaning of an executor, which is a thing where you submit high-level tasks to and it decides when and how to run the tasks -- here, you the caller are the one choosing when and how to invoke the functions in the executor.
I hate the name helper
, but this file really is just a transfer_helper
. Hm ...
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.
Ya I struggled with the name..
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.
As someone who dislikes the name helper
almost as much as manager
for classes, I agree that transfer_helper
does appear to be the most straightforward, simplest, while still descriptive name for what this class is using
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.
Cool, helper it is then
) | ||
aspirate_location = Location(aspirate_point, labware=source_loc.labware) | ||
|
||
components_executer = tx_comps_executor.get_transfer_components_executor( |
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.
Hm, I'm not a fan of this get_...()
function. It makes it look like you're calling a method on an object named tx_comps_executor
. (I was confused because you had renamed the file in your import, and I didn't realize that you had done that, so I assumed tx_comps_executor
was a variable name.)
If get_transfer_components_executor()
doesn't do anything besides call the TransferComponentsExecutor
constructor, can you just invoke the constructor directly here, to make it more obvious what's going on?
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 had to add the get..()
function in order to create a mock of the TransferComponentsExecutor
in the tests.
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.
Oh, also, another thought was to lru_cache
to cache the TransferComponentsExecutor
instance so only one instance would be created when the same liquid class and (source well -> dest-well) combo is being used. The getter would allow us to do that.
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 had to add the
get..()
function in order to create a mock of theTransferComponentsExecutor
in the tests.
Hm, is there a way to mock out the class itself? (In my past projects, we used unittest.mock
instead of PyTest, which let you mock the class constructor itself, so that you wouldn't need to create get_
function just for testing.)
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.
We can mock out the class but because the class is not injected into the core and rather just instantiated in the aspirate/ dispense methods, unittest.mock
will be insufficient. But correct me if I'm wrong.
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 believe we should be able to monkeypatch this out of the unit tests using the existing pattern we have, especially if we instantiate it like tx_comps_executor. TransferComponentsExecutor
(or whatever we rename it to)
) | ||
aspirate_location = Location(aspirate_point, labware=source_loc.labware) | ||
|
||
components_executer = tx_comps_executor.get_transfer_components_executor( |
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.
But secondly, I think the only reason that TransferComponentsExecutor
is a class at all is to save you the trouble of passing in arguments like aspirate_location
and source_well
to the method calls every time, right?
TransferComponentsExecutor
doesn't really have any state of its own and it doesn't manage any state, right? In that case, I'm not sure it deserves to be a class. How bad would it be to make transfer_components_executor.py
just a flat file of stateless helper functions, and to pass in the necessary arguments on every call?
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.
Ya, that's another decision I struggled with. This class originally contained the aspirate_liquid_class
and dispense_liquid_class
implementations too and was built to manage state between aspirates and dispenses since we do have to keep track of air gaps between the aspirates and dispenses (and some more stuff when implementing consolidate and distribute).
But then we needed to have aspirate_liquid_class
and dispense_liquid_class
available in the core (or some way to call the aspirate process and dispense process separately instead of in a big transfer) because of two reasons-
- It's necessary for hardware testing since they need to run test scripts after each aspirate/ dispense process. With these in the instrument core, they can access them using private methods in their scripts.
- We anticipate needing a public API for these liquid-class-based aspirate and dispense processes in the near future. At which point we will face this question again.
So I had to move a lot of things around to facilitate this and wasn't fully satisfied with the final architecture.
Splitting the TransferComponentsExecutor
into individual functions will make the arguments list for each of these functions quite long but it's not a bad option.
Hey, so I don't know if you're planning to give I think one typical way to do that is to define a mixin. So you would have something like:
Depending on the type checker we're using, you may need to do a bit more work to tell the type checker that |
Hmm, so some internal state does get added for dispense and the overarching transfer implementation. But it might not have to be internal to the |
assert ( | ||
mix_properties.repetitions is not None and mix_properties.volume is not None | ||
) | ||
for n in range(mix_properties.repetitions): |
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.
for n in range(mix_properties.repetitions): | |
for _ in range(mix_properties.repetitions): |
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.
The n
gets used in the dispense implementation so going to leave this as is
self._instrument.touch_tip( | ||
location=retract_location, | ||
well_core=self._target_well, | ||
radius=0, |
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.
radius=0, | |
radius=1.0, |
|
||
def _add_air_gap(self, air_gap_volume: float) -> None: | ||
"""Add an air gap.""" | ||
aspirate_props = self._transfer_properties.aspirate |
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.
It seems a little inconsistent to be referring to self._transfer_properties
here while also taking in a value that is retrieved from the self-same transfer properties. I'd almost prefer the arguments to this (and this pattern could be extended for these sub steps) to be air_gap_by_volume
, flow_rate_by_volume
, and delay
properties, so the only self
access is _instrument
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.
Ya, I've renamed them .._overrides
in the next part. But I'll try out your suggestion too
if new_tip == TransferTipPolicyV2.ONCE: | ||
# TODO: update this once getNextTip is implemented | ||
self.get_next_tip() | ||
for step_volume, (src, dest) in source_dest_per_volume_step: # type: ignore[assignment] |
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.
This is more of a personal style thing, but this statement seems messy, aesthetically, with the tuple being unpacked from a tuple and the type ignore. I don't know if there's a better way to represent this, but maybe it could look cleaner by doing the second tuple unpacking elsewhere (and naming them source
and destination
, I sort of dislike the shortening of these variables)
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.
Ya the transfer
method is a work in progress. It's mostly there for showing how the aspirate and dispense methods will be used in the transfer. I'll address this in the last PR.
@@ -38,6 +42,7 @@ | |||
) | |||
from opentrons.protocol_api._nozzle_layout import NozzleLayout | |||
from . import overlap_versions, pipette_movement_conflict | |||
from . import transfer_components_executor as tx_comps_executor |
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.
As someone who dislikes the name helper
almost as much as manager
for classes, I agree that transfer_helper
does appear to be the most straightforward, simplest, while still descriptive name for what this class is using
) | ||
aspirate_location = Location(aspirate_point, labware=source_loc.labware) | ||
|
||
components_executer = tx_comps_executor.get_transfer_components_executor( |
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 believe we should be able to monkeypatch this out of the unit tests using the existing pattern we have, especially if we instantiate it like tx_comps_executor. TransferComponentsExecutor
(or whatever we rename it to)
Addresses AUTH-866
Overview
Part 1 of three-part series of implementing transfer function.
This PR adds a
TransferComponentsExecutor
class that executes individual parts of the overarching 'aspiration' process. These components are-The
InstrumentCore
gets a new methodaspirate_liquid_class()
which utilizes the above executor to execute the aspiration steps in a specific order.aspirate_liquid_class()
will then be utilized by theInstrumentCore.transfer_liquid()
method to perform aspiration during each transfer step.This method can also be accessed in the protocol by using private API accessors for testing purposes.
Test Plan and Hands on Testing
Review requests
I landed on this architecture as it allows us to unit test the transfer using different configurations of the liquid class easily.
It also allows us to build the transfer step by step, and very importantly, allows hardware testing using individual aspirate and dispense steps (instead of testing one big transfer all at once).
Still, open to improvement suggestions.
There are a few TODOs in the code so far. Most of them will be addressed by the last PR of this 3-part series.
Risk assessment
Low. Makes no changes to the existing code.