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
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
544d695
initial implementation for load_liquid volume-tracking
pmoegenburg Oct 18, 2024
cb77025
Merge branch 'edge' into EXEC-281-volume-tracking
pmoegenburg Oct 18, 2024
aa5ff64
updated naming
pmoegenburg Oct 18, 2024
e7ee66e
updated implementation to include Aspirate and Dispense results
pmoegenburg Oct 18, 2024
5959370
eliminated previously used import
pmoegenburg Oct 18, 2024
661e96a
added operations_since_measurement to LiquidHeightInfo
pmoegenburg Oct 18, 2024
77f28d3
refactor to use StateUpdate
pmoegenburg Oct 21, 2024
52bc91e
updated setting and getting liquid info and linted
pmoegenburg Oct 22, 2024
61c29d8
Merge branch 'edge' into EXEC-281-volume-tracking
pmoegenburg Oct 22, 2024
d5f021d
for less diff
pmoegenburg Oct 22, 2024
067b490
reverted typing changes
pmoegenburg Oct 22, 2024
e8b8d6a
1 more revert
pmoegenburg Oct 22, 2024
15dd2e0
updated naming
pmoegenburg Oct 22, 2024
f5b5302
fixed WellStore dict handling
pmoegenburg Oct 22, 2024
e2a6c3d
renamed state update stuff and fixed tests
pmoegenburg Oct 22, 2024
a6e23b0
refactored getting liquid values, fixed and added tests
pmoegenburg Oct 23, 2024
2332aac
updated WellView get methods, fixed LiquidProbe to prevent errors, ad…
pmoegenburg Oct 23, 2024
5bafa09
removed comments
pmoegenburg Oct 23, 2024
4a36e9a
added comments
pmoegenburg Oct 23, 2024
53e0e04
fixed lint error
pmoegenburg Oct 23, 2024
64873fb
Merge branch 'edge' into EXEC-281-volume-tracking
pmoegenburg Oct 24, 2024
c1c1fcf
updates from Seth's PR review
pmoegenburg Oct 24, 2024
517fa1a
Merge branch 'edge' into EXEC-281-volume-tracking
pmoegenburg Oct 24, 2024
ed6ffff
added LiquidOperatedUpdate to aspirate/dispense_in_place
pmoegenburg Oct 25, 2024
15094da
removed/addressed comment
pmoegenburg Oct 25, 2024
93d431c
in place liquid failure handling
sfoster1 Oct 25, 2024
dea1321
yes, but which one
sfoster1 Oct 25, 2024
ffd67c6
whoops
sfoster1 Oct 25, 2024
a2d2cf8
this can definitely be none it means we didn't have schema 3 labware
sfoster1 Oct 25, 2024
e3043b9
let's at least do some logging
sfoster1 Oct 25, 2024
8180c59
format
sfoster1 Oct 25, 2024
4186202
format again
sfoster1 Oct 28, 2024
7df4113
fix: do not force operationvolume offset
sfoster1 Oct 28, 2024
e0b9daa
fix: set operationVolume in the core
sfoster1 Oct 28, 2024
e6259ee
imports r hard
sfoster1 Oct 28, 2024
d60e85d
really hard, i swear
sfoster1 Oct 28, 2024
29f68be
Merge branch 'edge' into EXEC-281-volume-tracking
sfoster1 Oct 28, 2024
d9b8147
merge fixups
sfoster1 Oct 28, 2024
b0be1f5
use a dataclass instead of atuple
sfoster1 Oct 28, 2024
8a74d85
oops
sfoster1 Oct 28, 2024
ab45875
lint
sfoster1 Oct 28, 2024
095c6ac
review: better summarization
sfoster1 Oct 28, 2024
c69b4f4
clear instead of None
sfoster1 Oct 28, 2024
1f972d7
remove some todos
sfoster1 Oct 28, 2024
b844901
volume_added
sfoster1 Oct 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions analyses-snapshot-testing/citools/generate_analyses.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ def analyze(protocol: TargetProtocol, container: docker.models.containers.Contai
start_time = time.time()
result = None
exit_code = None
console.print(f"Beginning analysis of {protocol.host_protocol_file.name}")
try:
command_result = container.exec_run(cmd=command)
exit_code = command_result.exit_code
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_api/core/engine/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ def aspirate(
absolute_point=location.point,
is_meniscus=is_meniscus,
)
if well_location.origin == WellOrigin.MENISCUS:
well_location.volumeOffset = "operationVolume"
pipette_movement_conflict.check_safe_for_pipette_movement(
engine_state=self._engine_client.state,
pipette_id=self._pipette_id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# noqa: D100

from __future__ import annotations
from typing import TYPE_CHECKING

from .actions import (
Action,
Expand All @@ -9,7 +10,9 @@
)
from ..commands.command import DefinedErrorData
from ..error_recovery_policy import ErrorRecoveryType
from ..state.update_types import StateUpdate

if TYPE_CHECKING:
from ..state.update_types import StateUpdate


def get_state_updates(action: Action) -> list[StateUpdate]:
Expand Down
17 changes: 12 additions & 5 deletions api/src/opentrons/protocol_engine/commands/aspirate.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,11 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
well_name=well_name,
)

well_location = params.wellLocation
if well_location.origin == WellOrigin.MENISCUS:
well_location.volumeOffset = "operationVolume"

position = await self._movement.move_to_well(
pipette_id=pipette_id,
labware_id=labware_id,
well_name=well_name,
well_location=well_location,
well_location=params.wellLocation,
current_well=current_well,
operation_volume=-params.volume,
)
Expand All @@ -140,6 +136,12 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
command_note_adder=self._command_note_adder,
)
except PipetteOverpressureError as e:
# TODO(pbm, 10-24-24): get new tip and LiquidProbe in error recovery to reestablish well liquid level
state_update.set_liquid_operated(
labware_id=labware_id,
well_name=well_name,
volume=None,
)
return DefinedErrorData(
public=OverpressureError(
id=self._model_utils.generate_id(),
Expand All @@ -156,6 +158,11 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
state_update=state_update,
)
else:
state_update.set_liquid_operated(
labware_id=labware_id,
well_name=well_name,
volume=-volume_aspirated,
)
return SuccessData(
public=AspirateResult(
volume=volume_aspirated,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
)
from ..errors.error_occurrence import ErrorOccurrence
from ..errors.exceptions import PipetteNotReadyToAspirateError
from ..state.update_types import StateUpdate
from ..types import CurrentWell

if TYPE_CHECKING:
from ..execution import PipettingHandler, GantryMover
Expand Down Expand Up @@ -91,6 +93,10 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn:
" The first aspirate following a blow-out must be from a specific well"
" so the plunger can be reset in a known safe position."
)

state_update = StateUpdate()
current_location = self._state_view.pipettes.get_current_location()

try:
current_position = await self._gantry_mover.get_position(params.pipetteId)
volume = await self._pipetting.aspirate_in_place(
Expand All @@ -100,6 +106,16 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn:
command_note_adder=self._command_note_adder,
)
except PipetteOverpressureError as e:
# TODO(pbm, 10-24-24): if location is a well, get new tip and LiquidProbe in error recovery to reestablish well liquid level
SyntaxColoring marked this conversation as resolved.
Show resolved Hide resolved
if (
isinstance(current_location, CurrentWell)
and current_location.pipette_id == params.pipetteId
):
state_update.set_liquid_operated(
labware_id=current_location.labware_id,
well_name=current_location.well_name,
volume=None,
)
return DefinedErrorData(
public=OverpressureError(
id=self._model_utils.generate_id(),
Expand All @@ -121,10 +137,22 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn:
}
),
),
state_update=state_update,
)
else:
if (
isinstance(current_location, CurrentWell)
and current_location.pipette_id == params.pipetteId
):
state_update.set_liquid_operated(
labware_id=current_location.labware_id,
well_name=current_location.well_name,
volume=-volume,
)
return SuccessData(
public=AspirateInPlaceResult(volume=volume), private=None
public=AspirateInPlaceResult(volume=volume),
private=None,
state_update=state_update,
)


Expand Down
11 changes: 11 additions & 0 deletions api/src/opentrons/protocol_engine/commands/dispense.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn:
push_out=params.pushOut,
)
except PipetteOverpressureError as e:
# TODO(pbm, 10-24-24): get new tip and LiquidProbe in error recovery to reestablish well liquid level
state_update.set_liquid_operated(
labware_id=labware_id,
well_name=well_name,
volume=None,
)
return DefinedErrorData(
public=OverpressureError(
id=self._model_utils.generate_id(),
Expand All @@ -123,6 +129,11 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn:
state_update=state_update,
)
else:
state_update.set_liquid_operated(
labware_id=labware_id,
well_name=well_name,
volume=volume,
)
return SuccessData(
public=DispenseResult(volume=volume, position=deck_point),
private=None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
DefinedErrorData,
)
from ..errors.error_occurrence import ErrorOccurrence
from ..state.update_types import StateUpdate
from ..types import CurrentWell

if TYPE_CHECKING:
from ..execution import PipettingHandler, GantryMover
from ..resources import ModelUtils
from ..state.state import StateView


DispenseInPlaceCommandType = Literal["dispenseInPlace"]
Expand Down Expand Up @@ -59,16 +62,20 @@ class DispenseInPlaceImplementation(
def __init__(
self,
pipetting: PipettingHandler,
state_view: StateView,
gantry_mover: GantryMover,
model_utils: ModelUtils,
**kwargs: object,
) -> None:
self._pipetting = pipetting
self._state_view = state_view
self._gantry_mover = gantry_mover
self._model_utils = model_utils

async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn:
"""Dispense without moving the pipette."""
state_update = StateUpdate()
current_location = self._state_view.pipettes.get_current_location()
try:
current_position = await self._gantry_mover.get_position(params.pipetteId)
volume = await self._pipetting.dispense_in_place(
Expand All @@ -78,6 +85,16 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn:
push_out=params.pushOut,
)
except PipetteOverpressureError as e:
# TODO(pbm, 10-24-24): if location is a well, get new tip and LiquidProbe in error recovery to reestablish well liquid level
if (
isinstance(current_location, CurrentWell)
and current_location.pipette_id == params.pipetteId
):
state_update.set_liquid_operated(
labware_id=current_location.labware_id,
well_name=current_location.well_name,
volume=None,
)
return DefinedErrorData(
public=OverpressureError(
id=self._model_utils.generate_id(),
Expand All @@ -99,10 +116,22 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn:
}
),
),
state_update=state_update,
)
else:
if (
isinstance(current_location, CurrentWell)
and current_location.pipette_id == params.pipetteId
):
state_update.set_liquid_operated(
labware_id=current_location.labware_id,
well_name=current_location.well_name,
volume=volume,
)
return SuccessData(
public=DispenseInPlaceResult(volume=volume), private=None
public=DispenseInPlaceResult(volume=volume),
private=None,
state_update=state_update,
)


Expand Down
48 changes: 44 additions & 4 deletions api/src/opentrons/protocol_engine/commands/liquid_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
MustHomeError,
PipetteNotReadyToAspirateError,
TipNotEmptyError,
IncompleteLabwareDefinitionError,
)
from opentrons.types import MountType
from opentrons_shared_data.errors.exceptions import (
Expand Down Expand Up @@ -205,6 +206,13 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn:
self._state_view, self._movement, self._pipetting, params
)
if isinstance(z_pos_or_error, PipetteLiquidNotFoundError):
state_update.set_liquid_probed(
labware_id=params.labwareId,
well_name=params.wellName,
height=None,
volume=None,
last_probed=self._model_utils.get_timestamp(),
)
return DefinedErrorData(
public=LiquidNotFoundError(
id=self._model_utils.generate_id(),
Expand All @@ -220,6 +228,21 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn:
state_update=state_update,
)
else:
try:
well_volume = self._state_view.geometry.get_well_volume_at_height(
pmoegenburg marked this conversation as resolved.
Show resolved Hide resolved
labware_id=params.labwareId,
well_name=params.wellName,
height=z_pos_or_error,
)
except IncompleteLabwareDefinitionError:
well_volume = None
state_update.set_liquid_probed(
labware_id=params.labwareId,
well_name=params.wellName,
height=z_pos_or_error,
volume=well_volume,
last_probed=self._model_utils.get_timestamp(),
)
return SuccessData(
public=LiquidProbeResult(
z_position=z_pos_or_error, position=deck_point
Expand All @@ -239,11 +262,13 @@ def __init__(
state_view: StateView,
movement: MovementHandler,
pipetting: PipettingHandler,
model_utils: ModelUtils,
**kwargs: object,
) -> None:
self._state_view = state_view
self._movement = movement
self._pipetting = pipetting
self._model_utils = model_utils

async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn:
"""Execute a `tryLiquidProbe` command.
Expand All @@ -256,11 +281,26 @@ async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn:
self._state_view, self._movement, self._pipetting, params
)

z_pos = (
None
if isinstance(z_pos_or_error, PipetteLiquidNotFoundError)
else z_pos_or_error
if isinstance(z_pos_or_error, PipetteLiquidNotFoundError):
z_pos = None
well_volume = None
else:
z_pos = z_pos_or_error
try:
well_volume = self._state_view.geometry.get_well_volume_at_height(
labware_id=params.labwareId, well_name=params.wellName, height=z_pos
)
except IncompleteLabwareDefinitionError:
well_volume = None

state_update.set_liquid_probed(
labware_id=params.labwareId,
well_name=params.wellName,
height=z_pos,
volume=well_volume,
last_probed=self._model_utils.get_timestamp(),
)

return SuccessData(
public=TryLiquidProbeResult(
z_position=z_pos,
Expand Down
19 changes: 17 additions & 2 deletions api/src/opentrons/protocol_engine/commands/load_liquid.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
from typing import Optional, Type, Dict, TYPE_CHECKING
from typing_extensions import Literal

from opentrons.protocol_engine.state.update_types import StateUpdate

from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
from ..errors.error_occurrence import ErrorOccurrence

if TYPE_CHECKING:
from ..state.state import StateView
from ..resources import ModelUtils

LoadLiquidCommandType = Literal["loadLiquid"]

Expand Down Expand Up @@ -41,8 +44,11 @@ class LoadLiquidImplementation(
):
"""Load liquid command implementation."""

def __init__(self, state_view: StateView, **kwargs: object) -> None:
def __init__(
self, state_view: StateView, model_utils: ModelUtils, **kwargs: object
) -> None:
self._state_view = state_view
self._model_utils = model_utils

async def execute(
self, params: LoadLiquidParams
Expand All @@ -54,7 +60,16 @@ async def execute(
labware_id=params.labwareId, wells=params.volumeByWell
)

return SuccessData(public=LoadLiquidResult(), private=None)
state_update = StateUpdate()
state_update.set_liquid_loaded(
labware_id=params.labwareId,
volumes=params.volumeByWell,
last_loaded=self._model_utils.get_timestamp(),
)

return SuccessData(
public=LoadLiquidResult(), private=None, state_update=state_update
)


class LoadLiquid(BaseCommand[LoadLiquidParams, LoadLiquidResult, ErrorOccurrence]):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ async def join(self) -> None:

async def _run_commands(self) -> None:
async for command_id in self._command_generator():
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

log.exception("Unhandled failure in command executor")
raise
# Yield to the event loop in case we're executing a long sequence of commands
# that never yields internally. For example, a long sequence of comment commands.
await asyncio.sleep(0)
Loading
Loading