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

Open
wants to merge 21 commits into
base: edge
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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 api/src/opentrons/protocol_api/instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ def air_gap(
raise RuntimeError("No previous Well cached to perform air gap")
target = loc.labware.as_well().top(height)
self.move_to(target, publish=False)
# add (is_liquid: bool = True) parameter to aspirate? Or deduce location is not in well in Aspirate command?
self.aspirate(volume)
return self

Expand Down
6 changes: 6 additions & 0 deletions api/src/opentrons/protocol_engine/commands/aspirate.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
command_note_adder=self._command_note_adder,
)
except PipetteOverpressureError as e:
# can we get aspirated_amount_prior_to_error? If not, can we assume no liquid was removed from well? Ask Ryan
return DefinedErrorData(
public=OverpressureError(
id=self._model_utils.generate_id(),
Expand All @@ -156,6 +157,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
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/commands/blow_out.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn:
pipette_id=params.pipetteId, flow_rate=params.flowRate
)
except PipetteOverpressureError as e:
# can we get blown_out_amount_prior_to_error? If not, can we assume no liquid was added to well? Ask Ryan
return DefinedErrorData(
public=OverpressureError(
id=self._model_utils.generate_id(),
Expand All @@ -114,6 +115,7 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn:
),
)
else:
# if blow out over a well, can we get pipette tip liquid volume and add that to well liquid volume?
return SuccessData(
public=BlowOutResult(position=deck_point),
private=None,
Expand Down
6 changes: 6 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,7 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn:
push_out=params.pushOut,
)
except PipetteOverpressureError as e:
# can we get dispensed_amount_prior_to_error? If not, can we assume no liquid was added to well? Ask Ryan
return DefinedErrorData(
public=OverpressureError(
id=self._model_utils.generate_id(),
Expand All @@ -123,6 +124,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
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(
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
58 changes: 47 additions & 11 deletions api/src/opentrons/protocol_engine/state/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from opentrons.types import Point, DeckSlotName, StagingSlotName, MountType

from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN
from opentrons_shared_data.labware.labware_definition import InnerWellGeometry
from opentrons_shared_data.deck.types import CutoutFixture
from opentrons_shared_data.pipette import PIPETTE_X_SPAN
from opentrons_shared_data.pipette.types import ChannelCount
Expand Down Expand Up @@ -1372,6 +1371,7 @@ def get_well_offset_adjustment(

Distance is with reference to the well bottom.
"""
# TODO(pbm, 10-23-24): refactor to smartly reduce height/volume conversions
initial_handling_height = self.get_well_handling_height(
labware_id=labware_id,
well_name=well_name,
Expand All @@ -1386,9 +1386,9 @@ def get_well_offset_adjustment(
volume = operation_volume or 0.0

if volume:
well_geometry = self._labware.get_well_geometry(labware_id, well_name)
return self.get_well_height_after_volume(
well_geometry=well_geometry,
labware_id=labware_id,
well_name=well_name,
initial_height=initial_handling_height,
volume=volume,
)
Expand All @@ -1401,15 +1401,29 @@ def get_meniscus_height(
well_name: str,
) -> float:
"""Returns stored meniscus height in specified well."""
meniscus_height = self._wells.get_last_measured_liquid_height(
labware_id=labware_id, well_name=well_name
)
if meniscus_height is None:
raise errors.LiquidHeightUnknownError(
"Must liquid probe before specifying WellOrigin.MENISCUS."
(
loaded_volume_info,
probed_height_info,
probed_volume_info,
) = self._wells.get_well_liquid_info(labware_id=labware_id, well_name=well_name)
if probed_height_info is not None and probed_height_info.height is not None:
return probed_height_info.height
elif loaded_volume_info is not None and loaded_volume_info.volume is not None:
return self.get_well_height_at_volume(
labware_id=labware_id,
well_name=well_name,
volume=loaded_volume_info.volume,
)
elif probed_volume_info is not None and probed_volume_info.volume is not None:
return self.get_well_height_at_volume(
labware_id=labware_id,
well_name=well_name,
volume=probed_volume_info.volume,
)
else:
return meniscus_height
raise errors.LiquidHeightUnknownError(
"Must LiquidProbe or LoadLiquid before specifying WellOrigin.MENISCUS."
)

def get_well_handling_height(
self,
Expand All @@ -1431,12 +1445,15 @@ def get_well_handling_height(
return float(handling_height)

def get_well_height_after_volume(
self, well_geometry: InnerWellGeometry, initial_height: float, volume: float
self, labware_id: str, well_name: str, initial_height: float, volume: float
) -> float:
"""Return the height of liquid in a labware well after a given volume has been handled.

This is given an initial handling height, with reference to the well bottom.
"""
well_geometry = self._labware.get_well_geometry(
labware_id=labware_id, well_name=well_name
)
initial_volume = find_volume_at_well_height(
target_height=initial_height, well_geometry=well_geometry
)
Expand All @@ -1445,6 +1462,24 @@ def get_well_height_after_volume(
target_volume=final_volume, well_geometry=well_geometry
)

def get_well_height_at_volume(
self, labware_id: str, well_name: str, volume: float
) -> float:
"""Convert well volume to height."""
well_geometry = self._labware.get_well_geometry(labware_id, well_name)
return find_height_at_well_volume(
target_volume=volume, well_geometry=well_geometry
)

def get_well_volume_at_height(
self, labware_id: str, well_name: str, height: float
) -> float:
"""Convert well height to volume."""
well_geometry = self._labware.get_well_geometry(labware_id, well_name)
return find_volume_at_well_height(
target_height=height, well_geometry=well_geometry
)

def validate_dispense_volume_into_well(
self,
labware_id: str,
Expand All @@ -1456,6 +1491,7 @@ def validate_dispense_volume_into_well(
well_def = self._labware.get_well_definition(labware_id, well_name)
well_volumetric_capacity = well_def.totalLiquidVolume
if well_location.origin == WellOrigin.MENISCUS:
# TODO(pbm, 10-23-24): refactor to smartly reduce height/volume conversions
well_geometry = self._labware.get_well_geometry(labware_id, well_name)
meniscus_height = self.get_meniscus_height(
labware_id=labware_id, well_name=well_name
Expand Down
10 changes: 7 additions & 3 deletions api/src/opentrons/protocol_engine/state/state_summary.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
"""Public protocol run data models."""
from pydantic import BaseModel, Field
from typing import List, Optional
from typing import List, Optional, Union
from datetime import datetime

from ..errors import ErrorOccurrence
from ..types import (
EngineStatus,
LiquidHeightSummary,
LoadedLabware,
LabwareOffset,
LoadedModule,
LoadedPipette,
Liquid,
LoadedVolumeSummary,
ProbedHeightSummary,
ProbedVolumeSummary,
)


Expand All @@ -30,5 +32,7 @@ class StateSummary(BaseModel):
startedAt: Optional[datetime]
completedAt: Optional[datetime]
liquids: List[Liquid] = Field(default_factory=list)
wells: List[LiquidHeightSummary] = Field(default_factory=list)
wells: List[
Union[LoadedVolumeSummary, ProbedHeightSummary, ProbedVolumeSummary]
] = Field(default_factory=list)
files: List[str] = Field(default_factory=list)
Loading
Loading