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): fully connected volume tracking #16532

Merged
merged 45 commits into from
Oct 29, 2024
Merged

Conversation

pmoegenburg
Copy link
Contributor

@pmoegenburg pmoegenburg commented Oct 18, 2024

Overview

This PR aims to consolidate well volume data from our existing relevant commands into a single source of truth in WellStore. These commands are LiquidProbe, LoadLiquid, Aspirate, and Dispense.

volume estimation and height

Well volume and height are tracked in three parts:

  • the probed height is a height gathered from a liquid probe since the last volume-affecting operation occurred; it is nulled out if you carry out a liquid operation on a well
  • the tracked volume is a volume that started with a LoadLiquid and was then modified by liquid volume tracking from aspirate and dispense volumes
  • the estimated volume is the same, but has been updated or started with a liquid height measurement that was converted into an estimated volume

The reason for having all three is that they have different accuracy characteristics, and we therefore use them for meniscus height estimation in an order of precedence:

  • The most accurate is a probed height
  • If a probed height isn't available, use a tracked volume that we turn into a height
  • If that isn't available, use an estimated volume that we turn into a height

The latter two values are only accessible if the labware the well is in has internal geometry data. Even when we do, that geometry data is noisy and can be inaccurate, making those values suitable for problems like "I don't want to have to accurately measure how much is in this tube" and not for problems like "I want to minimize external droplet formation on my 1uL aspiration".

This PR implements all that, as well as setting the movement data for actually going to a well meniscus.

volume offsets

A pretty important thing to consider for static meniscus-relative movements is that when you're aspirating you don't want to move to where the meniscus is but rather where it will be when the operation ends, so you don't leave your pipette high and dry. We signify this by allowing a "volume offset" in a location. This is like a height offset but is specified in uL or the special value "operationVolume". It will add an extra Z-offset to the positioning to account for a volume change in a well. This is calculated using the well's internal geometry and thus only available if the well has internal geometry. If the value is the string "operationVolume", which should be thought of as a sentinel value, we even calculate the volume offset from the volume you passed to aspirate; but specifying the value as a float is available for things like a moveToWell preceding an aspirateInPlace.

There's some gross internals because of history. One particularly gross one is the Location data type; this is an old friend indeed, and its friendship continues to pay dividends since we want to add something to it that is somewhat internal, and we have to do so in a really gross way.

testing

We really need to test this in actually used protocols and this shouldn't be relied upon yet, but extensive test coverage should help.

Copy link

codecov bot commented Oct 22, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 92.43%. Comparing base (9797d74) to head (c1c1fcf).
Report is 75 commits behind head on edge.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             edge   #16532       +/-   ##
===========================================
+ Coverage   79.88%   92.43%   +12.55%     
===========================================
  Files         120       77       -43     
  Lines        4410     1283     -3127     
===========================================
- Hits         3523     1186     -2337     
+ Misses        887       97      -790     
Flag Coverage Δ
g-code-testing 92.43% <ø> (ø)
shared-data ?

Flags with carried forward coverage won't be shown. Click here to find out more.

see 43 files with indirect coverage changes

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.

Couple small things to change and some answers to the questions. Also I think we're missing handling for the in place liquid handling commands? Structure looks great!

api/src/opentrons/protocol_api/instrument_context.py Outdated Show resolved Hide resolved
api/src/opentrons/protocol_engine/commands/aspirate.py Outdated Show resolved Hide resolved
api/src/opentrons/protocol_engine/commands/blow_out.py Outdated Show resolved Hide resolved
api/src/opentrons/protocol_engine/commands/blow_out.py Outdated Show resolved Hide resolved
api/src/opentrons/protocol_engine/commands/dispense.py Outdated Show resolved Hide resolved
api/src/opentrons/protocol_engine/state/state_summary.py Outdated Show resolved Hide resolved
api/src/opentrons/protocol_engine/state/wells.py Outdated Show resolved Hide resolved
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.

clearing previous review

This is not the right thing to be doing, we want callers to be able to
say what they want.
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.

LGTM

well_name=action.error.private.well_name,
height=None,
time=action.failed_at,
state_update = get_state_update(action)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
state_update = get_state_update(action)
state_update = get_state_update.get_state_update(action)

await self._command_executor.execute(command_id=command_id)
try:
await self._command_executor.execute(command_id=command_id)
except BaseException:
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 intentional?

Copy link
Member

Choose a reason for hiding this comment

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

yes. it could also be a separate change probably but not having this is why tests would mysteriously hang sometimes

)
except KeyError:
return False
) -> Tuple[
Copy link
Contributor

Choose a reason for hiding this comment

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

a bit worried about returning a tuple with optional values?

Comment on lines 184 to 185
summaries = self._append_summaries_with_probed_heights(summaries=summaries)
summaries = self._append_summaries_with_probed_volumes(summaries=summaries)
Copy link
Contributor

Choose a reason for hiding this comment

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

this is a bit confusing to me. can we not return anything? the list is mutable

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.

a few requests. thank you!

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.

This all looks reasonable to me so far. I'm catching up and working my way through the wells.py stuff now. Meanwhile, here are some thoughts for the other parts.

Comment on lines 381 to 392
class WellInfoSummary(BaseModel):
"""Payload for a well's liquid info in StateSummary."""

labware_id: str
well_name: str
height: float
last_measured: datetime
loaded_volume: Optional[float] = None
last_loaded: Optional[datetime] = None
operations_since_load: Optional[int] = None
probed_height: Optional[float] = None
probed_volume: Optional[float] = None
last_probed: Optional[datetime] = None
operations_since_probe: Optional[int] = None
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really want to expose all of these WellInfoSummary fields over HTTP and store them in the database? Is there a client need for all of these fields?

Copy link
Member

Choose a reason for hiding this comment

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

I eagerly want to expose these fields over HTTP because I think this system is better when it exposes more information, though storing it in the database is a bit icky. There isn't a currently planned in the app use for them but there is a use for me wanting to see what the information is.

api/src/opentrons/protocol_engine/types.py Outdated Show resolved Hide resolved
api/src/opentrons/protocol_engine/state/update_types.py Outdated Show resolved Hide resolved
api/src/opentrons/protocol_engine/state/update_types.py Outdated Show resolved Hide resolved
bit more readable, bit less datetime
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.

Looks good to merge if all comments are resolved to your satisfaction. I agree that we should get this in, get some geometry definitions in, and play around with it to see what breaks.

Comment on lines +1421 to +1428
elif (
well_liquid.probed_volume is not None
and well_liquid.probed_volume.volume is not None
):
return self.get_well_height_at_volume(
labware_id=labware_id,
well_name=well_name,
volume=well_liquid.probed_volume.volume,
Copy link
Contributor

@SyntaxColoring SyntaxColoring Oct 28, 2024

Choose a reason for hiding this comment

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

This ladder looks like:

  1. Return the last probed height, directly.
  2. Or return the height computed from the loaded volume, where the "loaded volume" is either from a load_liquid(), or from adding up aspirate()s and dispense()s.
  3. (these highlighted lines) Or return the height computed from the last probed volume.

But in part 3, wasn't the "last probed volume" computed from the last probed height? So isn't part 3 exactly equivalent to part 1? Is part 3 doing anything?

Copy link
Member

Choose a reason for hiding this comment

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

no, because the key with (1) is that the last probed height is available if and only if you haven't since touched the volume, whereas (3) is always available

Copy link
Member

Choose a reason for hiding this comment

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

the use cases for (1) look like, "do a probe immediately before every aspirate for maximum accuracy". the use case for (3) looks like "i have this one tube of master mix I fill up when it's getting empty and I don't care how much is in it beyond 'enough', so I'll probe it once at the beginning of the protocol and let the robot track volume from there"

Copy link
Contributor

Choose a reason for hiding this comment

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

no, because the key with (1) is that the last probed height is available if and only if you haven't since touched the volume, whereas (3) is always available

Ohh I see, thanks. So when we touch the volume, the element in probed_heights is deleted, but the element in probed_volumes is not.


This certainly doesn't have to change in this PR, but what do you think about simplifying this by expressing it in WellState as attributes like:

  • current_baseline, which stores, per-well, either:
    • The most recent volume from loadLiquid (even if there were a bunch of liquid handling ops since then)
    • Or the most recent height from probeLiquid (even if there were a bunch of liquid handling ops since then)
  • volume_change_since_baseline, which stores, per-well, the aspirate+dispense+etc. volume delta since then.

So then, going through the three modes, using your descriptions from Slack:

  1. mode 1: meniscus relative aspiration from a recent LLD height estimation (ie. probe then aspirate). this is the "gold standard"...

    This is when current_baseline is a liquidProbe height and volume_change_since_baseline == 0.

  2. mode 2: meniscus relative aspiration from volume-based height estimation, from a volume entered in the protocol with load_liquid and tracked through pipette actions. less accurate than immediate LLD...

    This is when current_baseline is a loadLiquid volume.

  3. mode 3: meniscus relative aspriation from volume-based height estimation from height-based volume estimation, i.e. you got your initial volume from an LLD pass. this is the worst because you suffer those errors twice...

    This is when current_baseline is a liquidProbe height and volume_change_since_baseline != 0.

I guess I'm shooting for WellState attributes to represent exactly what we know, in plain terms, without any editorializing. Then the GeometryView methods can implement the entirety of our business logic, with the cascading accuracy thing, based on those ground truths. Right now, I feel like the business logic is leaking into WellState and muddying what those attributes are. (probed_heights and probed_volumes differ in more ways than just "one is in height units and one is in volume units.")

Copy link
Member

Choose a reason for hiding this comment

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

I see what you're getting at. My bias is towards doing that but also storing separate data separately. What I think that version would be is

  • loaded_volume_baseline: the last load_volume
  • probed_height_baseline: the last probe_liquid, whenever it was
  • volume_change_since_loaded_volume_baseline
  • volume_change_since_probed_volume_baseline (we need both because you might have probed after some liquid operations on a loaded baseline)
  • something like immediate_probed_height. This could also just be probed_height_baseline if we're tracking how many operations it's been since then, but I think it's much nicer and inspectable to be able to look at the data and pick between the entries, rather than follow a chain of logic.

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.

Thank you for addressing the changes! Lgtm!

@sfoster1 sfoster1 merged commit 6812452 into edge Oct 29, 2024
22 checks passed
@sfoster1 sfoster1 deleted the EXEC-281-volume-tracking branch October 29, 2024 13:33
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