Skip to content

Conversation

@jbleon95
Copy link
Contributor

@jbleon95 jbleon95 commented Nov 3, 2025

Overview

This PR fixes an inefficiency in the liquid class transfer code related to its built-in configure_for_volume calls. Previously we would unconditionally configure_for_volume any time the volume_for_pipette_mode_configuration argument was provided in the aspirate_liquid_class function call. This was provided for every aspirate in transfer_with_liquid_class and distribute_with_liquid_class, and for the first step of every consolidate for consolidate_with_liquid_class. In addition to this configuring the volume, this would also move the pipette to a hardcoded location 2mm above the well top, and if this was different then the submerge start position, an extraneous movement would occur.

What this meant in practice was that in the worst case we were doing an extra movement and a no-op configure_for_volume and prepare_to_aspirate regardless of whether we were going to change volume mode or if the pipette even had different volume modes in the first place.

To fix this, a new method was added to check what volume mode a specific pipette will enter given a volume. Using this, in addition to an existing method to query the current volume mode, we can skip any unneeded volume mode configurations.

When testing with existing protocols, occasionally some would fail with a prepare to aspirate error, which this inefficiency was having happen all the time. To prevent this while keeping in the optimization, we will still prepare to aspirate if we need to by querying the protocol engine.

This change has been put behind a version gate and the max supported version was increased to 2.28.

Test Plan and Hands on Testing

Tested a number of existing liquid class protocols, in addition integration and snapshot tests should cover any changes.

Changelog

  • Added tracking of volume modes by storing relevant information in PipetteDict and protocol engine objects, along with methods to get the current volume mode or if the volume mode will change
  • Put in a check to see if volume mode will change before moving and configuring for volume
  • Bumped max supported version to 2.28

Review requests

Does putting the new non-configure prepare_to_aspirate make sense where I put it? The pipette should not be in liquid when that is called, but t might be better to put it in after we move to the submerge start position in case for some reason it could be in a bad location for prepare to aspirate.

Risk assessment

Medium, the version gate should prevent any changes to existing protocols but this will slightly change the exectuion steps of any new protocol that uses liquid class transfers

@jbleon95 jbleon95 marked this pull request as ready for review November 6, 2025 15:55
@codecov
Copy link

codecov bot commented Nov 6, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 25.10%. Comparing base (fedc8ad) to head (08c3aea).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             edge   #20009      +/-   ##
==========================================
- Coverage   25.31%   25.10%   -0.22%     
==========================================
  Files        3549     3549              
  Lines      296827   296826       -1     
  Branches    42170    42171       +1     
==========================================
- Hits        75145    74505     -640     
- Misses     221664   222298     +634     
- Partials       18       23       +5     
Flag Coverage Δ
app 1.16% <ø> (-0.94%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 102 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

elif not self._engine_client.state.pipettes.get_ready_to_aspirate(
self._pipette_id
):
self.prepare_to_aspirate()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Sorry, still trying to wrap my head around what this is doing.

BEFORE,
if volume_for_pipette_mode_configuration is None, we would NOT have prepare_to_aspirate() here regardless of whether the pipette is ready to aspirate, right?

Can that (volume_for_pipette_mode_configuration=None and not being ready to aspirate here) ever happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right in that this could cause a difference in behavior in existing protocols and I can make sure this doesn't trigger in those instances.

The fact that we were always unconditionally going down this configure_for_volume path (which also includes the movement and prepare for aspirate) was hiding the fact that there were scenarios where we don't need to configure the volume but do need to prepare to aspirate, which would be if the pipette is starting the transfer not prepared to aspirate, i.e. it's last action was a blow-out or full dispense with push out

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, my question was more: was there ever a case when volume_for_pipette_mode_configuration was unset and we were not ready to aspirate?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think so, because the only time this would be None is in non-first aspirates in consolidate, where we would have already called prepare_to_aspirate beforehand

Copy link
Collaborator

Choose a reason for hiding this comment

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

Got it, thanks!

self.configure_for_volume(volume_for_pipette_mode_configuration)
self.prepare_to_aspirate()

elif not self._engine_client.state.pipettes.get_ready_to_aspirate(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hm, my philosophical objection to adding more queries to the engine is that it makes life harder for future-us when we want to be able to say what steps a transfer will do without actually performing the transfer.

Does @ncdiehl still want PD step generation to match Python command-for-command? This would make that a bit harder since PD doesn't have an engine to query.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think this necessarily precludes us from doing that in the future, because honestly all we need to know is the pipette's initial state, since we'll prepare to aspirate within the transfer component executor after this. But perhaps I should clean this up a bit to make that more clear or make it more functional. But I think on its own this is a correct code, especially if an overpressure error happens during the course of a protocol which we can't predict.

As for PD, I don't think we have the equivalent to this, but this is checking a variable that is updated in response to pick_up_tip, any form of blow_out, a full dispense and that I imagine we could track

or pipette_definition.AvailableSensorDefinition(sensors=[]),
volume_mode=liquid_class,
available_volume_modes_min_vol={
volume_mode: props.min_volume
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hey, is PipetteLiquidPropertiesDefinition.min_volume a lie? It's defined as:

min_volume: float = Field(
    ...,
    description="The minimum supported volume of the pipette.",
    alias="minVolume",
)

Do you mean the minimum supported volume in this particular mode? If it's the minimum volume for the pipette in general, what's the point of introducing your available_volume_modes with different entries for default and lowVolumeDefault?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is in practice the minimum supported volume of each mode, and we determine if we switch to low volume mode if both we have a low volume mode AND we are providing a volume below the default minimum.

Copy link
Contributor

Choose a reason for hiding this comment

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

the min volume should reference the min volume in each of default or lowVolumeDefault

Copy link
Collaborator

Choose a reason for hiding this comment

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

@ryanthecoder or @jbleon95: For the lowVolumeDefault mode, is PipetteLiquidPropertiesDefinition.min_volume expected to just be 0?

I.e., for a low-volume-capable pipette, we would have something like:

  • lowVolumeDefault: min_volume=0, max_volume=x
  • default: min_volume=x, max_volume=1000

?

Copy link
Member

Choose a reason for hiding this comment

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

No, a min_volume is non-zero. in the low volume modes it's usually something like 0.5

if not has_low_volume_mode:
return VolumeModes.default
if volume >= available_volume_modes_min_vol[VolumeModes.default]:
return VolumeModes.default
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hm, this PR seems to do a lot of plumbing (adding PipetteDict.available_volume_modes, and then the available_volume_modes_min_vol dict) just to see if volume >= available_volume_modes_min_vol[VolumeModes.default] here.

I thought you were going to use available_volume_modes_min_vol[VolumeModes.lowVolumeDefault] somehow.

If you just need the "high-volume mode transition point," would it be easier to just add that one number to the PipetteDict?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I originally was passing the whole dictionary to the engine before I realized it added a lot of cruft to unit tests and decided it was easier to just pass what we need. I can go back and refactor PipetteDict to be more straightforward if we can't think of another reason to keep the full object for any future work

Copy link
Collaborator

@ddcc4 ddcc4 left a comment

Choose a reason for hiding this comment

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

OK, let me know if you want to simplify the available_volume_modes/available_volume_modes_min_vol logic to keep the classes simpler.

But I'm otherwise OK in principle with this change.

Copy link
Member

@sfoster1 sfoster1 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 to me!

or pipette_definition.AvailableSensorDefinition(sensors=[]),
volume_mode=liquid_class,
available_volume_modes_min_vol={
volume_mode: props.min_volume
Copy link
Member

Choose a reason for hiding this comment

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

No, a min_volume is non-zero. in the low volume modes it's usually something like 0.5

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.

5 participants