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): implement the liquid transfer function #17179

Merged
merged 22 commits into from
Jan 15, 2025

Conversation

sanni-t
Copy link
Member

@sanni-t sanni-t commented Jan 2, 2025

Closes AUTH-866

Overview

Final PR of the 3-PR series.
Implements InstrumentCore.transfer_liquid().

The transfer_liquid() method does the following:

  1. Loads the relevant liquid class record into protocol engine, gets the liquid class ID back. This will be used in command annotations in the future
  2. Breaks down the total transfer volume into piecewise transfers, if necessary.
  3. Does auto-tip handling, unless the user has specified to 'never' use a new tip.
  4. Calls aspirate_liquid_class() and dispense_liquid_class() for every source-> destination transfer.

This PR also updates the tiprack 'names' to URIs in the liquid class definition in shared data for 'water' only. We will have a separate PR for updating rest of the definitions.

Test Plan and Hands on Testing

  • Added some unit tests for things that are unit-testable
  • Added integration tests for verifying that transfer_liquid() execution succeeds for various supported configurations
  • TODO: on-robot testing

Additionally, we will be adding analysis snapshot tests with protocols that use various configurations for transferring with liquid classes.

Review requests

  • The implementation of transfer_liquid() turned out to be not easily unit-testable, mainly owing to the location of implementation (inside InstrumentCore). I plan on moving this function out of the InstrumentCore so that it can be unit-tested without the constraints of rest of the Instrument core. But this still serves as a robust implementation for doing end-to-end, integration testing so that's what I am focusing on in this PR.
  • This PR also makes liquid-classes-based transfer available for hardware testing and verification.

Risk assessment

Low. Doesn't change existing API

@sanni-t sanni-t changed the base branch from edge to AUTH-866-add-transfer-flow-builder-2 January 2, 2025 16:14
@sanni-t sanni-t marked this pull request as ready for review January 7, 2025 14:43
@sanni-t sanni-t requested review from a team as code owners January 7, 2025 14:43
@sanni-t sanni-t requested review from jbleon95 and ddcc4 and removed request for a team January 7, 2025 14:52
pip_api_name
for pip_api_name, pip_name in PIPETTE_API_NAMES_MAP.items()
if pip_name == pipette.pipetteName
][0]
Copy link
Contributor

Choose a reason for hiding this comment

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

Small nitpick: This can be more efficiently implemented as:

load_name = next(
  (pip_api_name
   for pip_api_name, pip_name in PIPETTE_API_NAMES_MAP.items()
   if pip_name == pipette.pipetteName),
  None
)
assert load_name
return load_name

@@ -883,7 +904,7 @@ def load_liquid_class(

liquid_class_record = LiquidClassRecord(
liquidClassName=liquid_class.name,
pipetteModel=self.get_model(), # TODO: verify this is the correct 'model' to use
pipetteModel=pipette_load_name, # TODO: verify this is the correct 'model' to use
Copy link
Contributor

Choose a reason for hiding this comment

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

Does comment still apply?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nope. I'll remove it.

pipette_load_name=self.get_pipette_name(), # TODO: update this to use load name instead
tiprack_uri=tiprack_uri,
pipette_load_name=self.get_pipette_load_name(),
tiprack_uri=tiprack_uri_for_transfer_props,
)
transfer_props = liquid_class.get_for(
Copy link
Contributor

Choose a reason for hiding this comment

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

self.load_liquid_class() above also calls liquid_class.get_for(), right?

Can you refactor self.load_liquid_class() to just take in the transfer_props directly as an argument?

@@ -1034,7 +1153,9 @@ def aspirate_liquid_class(
)
components_executor.aspirate_and_wait(volume=volume)
components_executor.retract_after_aspiration(volume=volume)
return components_executor.tip_state.last_liquid_and_air_gap_in_tip
last_contents = components_executor.tip_state.last_liquid_and_air_gap_in_tip
tip_contents[-1] = last_contents
Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, I'm a little confused what this is trying to do: it sometimes modifies the list that's passed in, and it sometimes modifies a list that was created local to this function?

Copy link
Member Author

Choose a reason for hiding this comment

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

Talked about it in person. The list that's passed in is not re-used by the caller function so it doesn't matter if the list gets modified here or not, since the list in the caller function gets replaced by ones returned by the aspirate_liquid_class and dispense_liquid_class. But I can see how it can cause confusion so I'll update this to use a copy of the list for any modifications.

Comment on lines 637 to 642
mock_instrument_core.air_gap_in_place(
volume=air_gap_volume, flow_rate=air_gap_volume
),
mock_instrument_core.delay(0.2),
)
if add_final_air_gap is True
else None,
mock_instrument_core.delay(0.2) if add_final_air_gap is True else None,

This comment was marked as resolved.

Copy link
Contributor

@jbleon95 jbleon95 left a comment

Choose a reason for hiding this comment

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

It all looks good to me, and tested this on the robot so we're ready to hand this off for hardware testing

]
for step_volume, src_dest_combo in source_dest_per_volume_step:
step_source, step_destination = src_dest_combo
is_last_step = len([source_dest_per_volume_step]) > 0
Copy link
Contributor

Choose a reason for hiding this comment

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

How does this work? Why does len([source_dest_per_volume_step]) > 0 mean it's the last step? And wouldn't the length always be > 0?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh shoot! I meant to fix this! Thanks for catching it!

# Neither aspirate nor a dispense process should be adding liquid
# when there is an air gap present.
assert (
self.last_liquid_and_air_gap_in_tip.air_gap == 0
), "Air gap present in the tip."
self.last_liquid_and_air_gap_in_tip.liquid += volume

def remove_liquid(self, volume: float) -> None:
def delete_liquid(self, volume: float) -> None:
# Neither aspirate nor a dispense process should be removing liquid
# when there is an air gap present.
assert (
self.last_liquid_and_air_gap_in_tip.air_gap == 0
), "Air gap present in the tip."
self.last_liquid_and_air_gap_in_tip.liquid -= volume
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it allowed to make last_liquid_and_air_gap_in_tip.liquid go below zero here?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nope. I can add an assertion there to make sure we never remove more liquid than is present

# Mapping of public Python Protocol API pipette load names
# to names used by the internal Opentrons system
# TODO (spp, 2025-01-02): make the API load name a part of the pipette's definition
PIPETTE_API_NAMES_MAP = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this same dict in api/src/opentrons/protocol_api/validation.py as well? (I also needed it for Python generation from PD -- see here.)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep. I've moved it to shared data for precisely this reason that PD will need to use it too

source_dest_per_volume_step
)
except StopIteration:
is_last_step = True
Copy link
Contributor

Choose a reason for hiding this comment

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

Is source_dest_per_volume_step an iterator or a list?
Can you turn it into a list?
If so, could you just enumerate over the list and check if you're at the index of the last item?

Like,

for idx, (next_step_volume, next_src_dest_combo) in enumerate(source_dest_per_volume_step):
  is_last_step = (idx == len(source_dest_per_volume_step) - 1)
  do stuff ..
  

Copy link
Member Author

Choose a reason for hiding this comment

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

It's an iterator.
Yes I can turn it into a list but that defeats the purpose of having expand_for_volume_constraints as a generator function that uses memory more efficiently. The iterator can have quite a number of elements on some occasions. So unless there's a compelling reason to not do it the way I have over here, I think we should avoid converting it to list.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I see. In that case, I wouldn't do this inline in the code here -- it's too much logic to pack in here. Instead, I would define a helper function that implements another generator to wrap expand_for_volume_constraints() that includes whether we're at the last step.

But you also control expand_for_volume_constraints(), don't you? Could you just make it include is_last_step in the values that it yields?

@sanni-t sanni-t force-pushed the AUTH-866-add-transfer-flow-builder-2 branch from bdfe17b to f1c2919 Compare January 15, 2025 16:27
@sanni-t sanni-t force-pushed the AUTH-866-add-transfer-flow-builder-final branch from 7287185 to 3a1b24b Compare January 15, 2025 17:37
@sanni-t sanni-t changed the base branch from AUTH-866-add-transfer-flow-builder-2 to edge January 15, 2025 17:37
@sanni-t sanni-t merged commit eca564a into edge Jan 15, 2025
18 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.

3 participants