diff --git a/analyses-snapshot-testing/citools/generate_analyses.py b/analyses-snapshot-testing/citools/generate_analyses.py index f67d0394429..52aba70363b 100644 --- a/analyses-snapshot-testing/citools/generate_analyses.py +++ b/analyses-snapshot-testing/citools/generate_analyses.py @@ -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 diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 4474a174a85..825d45bfded 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -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, diff --git a/api/src/opentrons/protocol_engine/actions/get_state_update.py b/api/src/opentrons/protocol_engine/actions/get_state_update.py index 3ab984ab850..ec29a6e38ef 100644 --- a/api/src/opentrons/protocol_engine/actions/get_state_update.py +++ b/api/src/opentrons/protocol_engine/actions/get_state_update.py @@ -1,5 +1,6 @@ # noqa: D100 - +from __future__ import annotations +from typing import TYPE_CHECKING from .actions import ( Action, @@ -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]: diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index 14b59248216..bbeb089182d 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -24,7 +24,7 @@ from opentrons.hardware_control import HardwareControlAPI -from ..state.update_types import StateUpdate +from ..state.update_types import StateUpdate, CLEAR from ..types import WellLocation, WellOrigin, CurrentWell, DeckPoint if TYPE_CHECKING: @@ -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, ) @@ -140,6 +136,11 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: command_note_adder=self._command_note_adder, ) except PipetteOverpressureError as e: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=CLEAR, + ) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -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_added=-volume_aspirated, + ) return SuccessData( public=AspirateResult( volume=volume_aspirated, diff --git a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py index 59879e7ca63..23d331cd19b 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py @@ -24,6 +24,8 @@ ) from ..errors.error_occurrence import ErrorOccurrence from ..errors.exceptions import PipetteNotReadyToAspirateError +from ..state.update_types import StateUpdate, CLEAR +from ..types import CurrentWell if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover @@ -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( @@ -100,6 +106,15 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: command_note_adder=self._command_note_adder, ) except PipetteOverpressureError as e: + 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_added=CLEAR, + ) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -121,10 +136,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_added=-volume, + ) return SuccessData( - public=AspirateInPlaceResult(volume=volume), private=None + public=AspirateInPlaceResult(volume=volume), + private=None, + state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index 7e18cc6560b..ad57cb1882f 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -8,7 +8,7 @@ from pydantic import Field from ..types import DeckPoint -from ..state.update_types import StateUpdate +from ..state.update_types import StateUpdate, CLEAR from .pipetting_common import ( PipetteIdMixin, DispenseVolumeMixin, @@ -107,6 +107,11 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: push_out=params.pushOut, ) except PipetteOverpressureError as e: + state_update.set_liquid_operated( + labware_id=labware_id, + well_name=well_name, + volume_added=CLEAR, + ) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -123,6 +128,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_added=volume, + ) return SuccessData( public=DispenseResult(volume=volume, position=deck_point), private=None, diff --git a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py index 36f15e8e528..b6eac561559 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py @@ -21,10 +21,13 @@ DefinedErrorData, ) from ..errors.error_occurrence import ErrorOccurrence +from ..state.update_types import StateUpdate, CLEAR +from ..types import CurrentWell if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover from ..resources import ModelUtils + from ..state.state import StateView DispenseInPlaceCommandType = Literal["dispenseInPlace"] @@ -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( @@ -78,6 +85,15 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: push_out=params.pushOut, ) except PipetteOverpressureError as e: + 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_added=CLEAR, + ) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -99,10 +115,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_added=volume, + ) return SuccessData( - public=DispenseInPlaceResult(volume=volume), private=None + public=DispenseInPlaceResult(volume=volume), + private=None, + state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/liquid_probe.py b/api/src/opentrons/protocol_engine/commands/liquid_probe.py index 1a8597f9c03..677d62731f9 100644 --- a/api/src/opentrons/protocol_engine/commands/liquid_probe.py +++ b/api/src/opentrons/protocol_engine/commands/liquid_probe.py @@ -11,6 +11,7 @@ MustHomeError, PipetteNotReadyToAspirateError, TipNotEmptyError, + IncompleteLabwareDefinitionError, ) from opentrons.types import MountType from opentrons_shared_data.errors.exceptions import ( @@ -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=update_types.CLEAR, + volume=update_types.CLEAR, + last_probed=self._model_utils.get_timestamp(), + ) return DefinedErrorData( public=LiquidNotFoundError( id=self._model_utils.generate_id(), @@ -220,6 +228,23 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn: state_update=state_update, ) else: + try: + well_volume: float | update_types.ClearType = ( + 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 = update_types.CLEAR + 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 @@ -239,11 +264,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. @@ -256,11 +283,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: float | update_types.ClearType = update_types.CLEAR + 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 = update_types.CLEAR + + state_update.set_liquid_probed( + labware_id=params.labwareId, + well_name=params.wellName, + height=z_pos if z_pos is not None else update_types.CLEAR, + volume=well_volume, + last_probed=self._model_utils.get_timestamp(), ) + return SuccessData( public=TryLiquidProbeResult( z_position=z_pos, diff --git a/api/src/opentrons/protocol_engine/commands/load_liquid.py b/api/src/opentrons/protocol_engine/commands/load_liquid.py index 856cf3ee127..d37bc537b2d 100644 --- a/api/src/opentrons/protocol_engine/commands/load_liquid.py +++ b/api/src/opentrons/protocol_engine/commands/load_liquid.py @@ -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"] @@ -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 @@ -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]): diff --git a/api/src/opentrons/protocol_engine/execution/queue_worker.py b/api/src/opentrons/protocol_engine/execution/queue_worker.py index 67f8f17b42c..015adf085c9 100644 --- a/api/src/opentrons/protocol_engine/execution/queue_worker.py +++ b/api/src/opentrons/protocol_engine/execution/queue_worker.py @@ -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: + 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) diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 125be3339a9..471065adcc2 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -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 @@ -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, @@ -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, ) @@ -1401,15 +1401,36 @@ def get_meniscus_height( well_name: str, ) -> float: """Returns stored meniscus height in specified well.""" - meniscus_height = self._wells.get_last_measured_liquid_height( + well_liquid = self._wells.get_well_liquid_info( labware_id=labware_id, well_name=well_name ) - if meniscus_height is None: - raise errors.LiquidHeightUnknownError( - "Must liquid probe before specifying WellOrigin.MENISCUS." + if ( + well_liquid.probed_height is not None + and well_liquid.probed_height.height is not None + ): + return well_liquid.probed_height.height + elif ( + well_liquid.loaded_volume is not None + and well_liquid.loaded_volume.volume is not None + ): + return self.get_well_height_at_volume( + labware_id=labware_id, + well_name=well_name, + volume=well_liquid.loaded_volume.volume, + ) + 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, ) else: - return meniscus_height + raise errors.LiquidHeightUnknownError( + "Must LiquidProbe or LoadLiquid before specifying WellOrigin.MENISCUS." + ) def get_well_handling_height( self, @@ -1431,12 +1452,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 ) @@ -1445,6 +1469,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, @@ -1456,6 +1498,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 diff --git a/api/src/opentrons/protocol_engine/state/state_summary.py b/api/src/opentrons/protocol_engine/state/state_summary.py index b1c4dd8f766..7e47ccbbb37 100644 --- a/api/src/opentrons/protocol_engine/state/state_summary.py +++ b/api/src/opentrons/protocol_engine/state/state_summary.py @@ -6,12 +6,12 @@ from ..errors import ErrorOccurrence from ..types import ( EngineStatus, - LiquidHeightSummary, LoadedLabware, LabwareOffset, LoadedModule, LoadedPipette, Liquid, + WellInfoSummary, ) @@ -30,5 +30,5 @@ 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[WellInfoSummary] = Field(default_factory=list) files: List[str] = Field(default_factory=list) diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index 5d941d33933..181d8820723 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -4,6 +4,7 @@ import dataclasses import enum import typing +from datetime import datetime from opentrons.hardware_control.nozzle_manager import NozzleMap from opentrons.protocol_engine.resources import pipette_data_provider @@ -175,6 +176,35 @@ class TipsUsedUpdate: """ +@dataclasses.dataclass +class LiquidLoadedUpdate: + """An update from loading a liquid.""" + + labware_id: str + volumes: typing.Dict[str, float] + last_loaded: datetime + + +@dataclasses.dataclass +class LiquidProbedUpdate: + """An update from probing a liquid.""" + + labware_id: str + well_name: str + last_probed: datetime + height: float | ClearType + volume: float | ClearType + + +@dataclasses.dataclass +class LiquidOperatedUpdate: + """An update from operating a liquid.""" + + labware_id: str + well_name: str + volume_added: float | ClearType + + @dataclasses.dataclass class StateUpdate: """Represents an update to perform on engine state.""" @@ -195,6 +225,12 @@ class StateUpdate: tips_used: TipsUsedUpdate | NoChangeType = NO_CHANGE + liquid_loaded: LiquidLoadedUpdate | NoChangeType = NO_CHANGE + + liquid_probed: LiquidProbedUpdate | NoChangeType = NO_CHANGE + + liquid_operated: LiquidOperatedUpdate | NoChangeType = NO_CHANGE + # These convenience functions let the caller avoid the boilerplate of constructing a # complicated dataclass tree. @@ -330,3 +366,43 @@ def mark_tips_as_used( self.tips_used = TipsUsedUpdate( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) + + def set_liquid_loaded( + self, + labware_id: str, + volumes: typing.Dict[str, float], + last_loaded: datetime, + ) -> None: + """Add liquid volumes to well state. See `LoadLiquidUpdate`.""" + self.liquid_loaded = LiquidLoadedUpdate( + labware_id=labware_id, + volumes=volumes, + last_loaded=last_loaded, + ) + + def set_liquid_probed( + self, + labware_id: str, + well_name: str, + last_probed: datetime, + height: float | ClearType, + volume: float | ClearType, + ) -> None: + """Add a liquid height and volume to well state. See `ProbeLiquidUpdate`.""" + self.liquid_probed = LiquidProbedUpdate( + labware_id=labware_id, + well_name=well_name, + height=height, + volume=volume, + last_probed=last_probed, + ) + + def set_liquid_operated( + self, labware_id: str, well_name: str, volume_added: float | ClearType + ) -> None: + """Update liquid volumes in well state. See `OperateLiquidUpdate`.""" + self.liquid_operated = LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name, + volume_added=volume_added, + ) diff --git a/api/src/opentrons/protocol_engine/state/wells.py b/api/src/opentrons/protocol_engine/state/wells.py index d74d94a1be0..5b4d3bb8d77 100644 --- a/api/src/opentrons/protocol_engine/state/wells.py +++ b/api/src/opentrons/protocol_engine/state/wells.py @@ -1,25 +1,32 @@ """Basic well data state and store.""" from dataclasses import dataclass -from datetime import datetime -from typing import Dict, List, Optional -from opentrons.protocol_engine.actions.actions import ( - FailCommandAction, - SucceedCommandAction, +from typing import Dict, List, Union, Iterator, Optional, Tuple, overload, TypeVar + +from opentrons.protocol_engine.types import ( + ProbedHeightInfo, + ProbedVolumeInfo, + LoadedVolumeInfo, + WellInfoSummary, + WellLiquidInfo, ) -from opentrons.protocol_engine.commands.liquid_probe import LiquidProbeResult -from opentrons.protocol_engine.commands.pipetting_common import LiquidNotFoundError -from opentrons.protocol_engine.types import LiquidHeightInfo, LiquidHeightSummary +from . import update_types from ._abstract_store import HasState, HandlesActions from ..actions import Action -from ..commands import Command +from ..actions.get_state_update import get_state_updates + + +LabwareId = str +WellName = str @dataclass class WellState: """State of all wells.""" - measured_liquid_heights: Dict[str, Dict[str, LiquidHeightInfo]] + loaded_volumes: Dict[LabwareId, Dict[WellName, LoadedVolumeInfo]] + probed_heights: Dict[LabwareId, Dict[WellName, ProbedHeightInfo]] + probed_volumes: Dict[LabwareId, Dict[WellName, ProbedVolumeInfo]] class WellStore(HasState[WellState], HandlesActions): @@ -29,41 +36,95 @@ class WellStore(HasState[WellState], HandlesActions): def __init__(self) -> None: """Initialize a well store and its state.""" - self._state = WellState(measured_liquid_heights={}) + self._state = WellState(loaded_volumes={}, probed_heights={}, probed_volumes={}) def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - if isinstance(action, SucceedCommandAction): - self._handle_succeeded_command(action.command) - if isinstance(action, FailCommandAction): - self._handle_failed_command(action) - - def _handle_succeeded_command(self, command: Command) -> None: - if isinstance(command.result, LiquidProbeResult): - self._set_liquid_height( - labware_id=command.params.labwareId, - well_name=command.params.wellName, - height=command.result.z_position, - time=command.createdAt, - ) - - def _handle_failed_command(self, action: FailCommandAction) -> None: - if isinstance(action.error, LiquidNotFoundError): - self._set_liquid_height( - labware_id=action.error.private.labware_id, - well_name=action.error.private.well_name, - height=None, - time=action.failed_at, + for state_update in get_state_updates(action): + if state_update.liquid_loaded != update_types.NO_CHANGE: + self._handle_liquid_loaded_update(state_update.liquid_loaded) + if state_update.liquid_probed != update_types.NO_CHANGE: + self._handle_liquid_probed_update(state_update.liquid_probed) + if state_update.liquid_operated != update_types.NO_CHANGE: + self._handle_liquid_operated_update(state_update.liquid_operated) + + def _handle_liquid_loaded_update( + self, state_update: update_types.LiquidLoadedUpdate + ) -> None: + labware_id = state_update.labware_id + if labware_id not in self._state.loaded_volumes: + self._state.loaded_volumes[labware_id] = {} + for (well, volume) in state_update.volumes.items(): + self._state.loaded_volumes[labware_id][well] = LoadedVolumeInfo( + volume=_none_from_clear(volume), + last_loaded=state_update.last_loaded, + operations_since_load=0, ) - def _set_liquid_height( - self, labware_id: str, well_name: str, height: float, time: datetime + def _handle_liquid_probed_update( + self, state_update: update_types.LiquidProbedUpdate + ) -> None: + labware_id = state_update.labware_id + well_name = state_update.well_name + if labware_id not in self._state.probed_heights: + self._state.probed_heights[labware_id] = {} + if labware_id not in self._state.probed_volumes: + self._state.probed_volumes[labware_id] = {} + self._state.probed_heights[labware_id][well_name] = ProbedHeightInfo( + height=_none_from_clear(state_update.height), + last_probed=state_update.last_probed, + ) + self._state.probed_volumes[labware_id][well_name] = ProbedVolumeInfo( + volume=_none_from_clear(state_update.volume), + last_probed=state_update.last_probed, + operations_since_probe=0, + ) + + def _handle_liquid_operated_update( + self, state_update: update_types.LiquidOperatedUpdate ) -> None: - """Set the liquid height of the well.""" - lhi = LiquidHeightInfo(height=height, last_measured=time) - if labware_id not in self._state.measured_liquid_heights: - self._state.measured_liquid_heights[labware_id] = {} - self._state.measured_liquid_heights[labware_id][well_name] = lhi + labware_id = state_update.labware_id + well_name = state_update.well_name + if ( + labware_id in self._state.loaded_volumes + and well_name in self._state.loaded_volumes[labware_id] + ): + if state_update.volume_added is update_types.CLEAR: + del self._state.loaded_volumes[labware_id][well_name] + else: + prev_loaded_vol_info = self._state.loaded_volumes[labware_id][well_name] + assert prev_loaded_vol_info.volume is not None + self._state.loaded_volumes[labware_id][well_name] = LoadedVolumeInfo( + volume=prev_loaded_vol_info.volume + state_update.volume_added, + last_loaded=prev_loaded_vol_info.last_loaded, + operations_since_load=prev_loaded_vol_info.operations_since_load + + 1, + ) + if ( + labware_id in self._state.probed_heights + and well_name in self._state.probed_heights[labware_id] + ): + del self._state.probed_heights[labware_id][well_name] + if ( + labware_id in self._state.probed_volumes + and well_name in self._state.probed_volumes[labware_id] + ): + if state_update.volume_added is update_types.CLEAR: + del self._state.probed_volumes[labware_id][well_name] + else: + prev_probed_vol_info = self._state.probed_volumes[labware_id][well_name] + if prev_probed_vol_info.volume is None: + new_vol_info: float | None = None + else: + new_vol_info = ( + prev_probed_vol_info.volume + state_update.volume_added + ) + self._state.probed_volumes[labware_id][well_name] = ProbedVolumeInfo( + volume=new_vol_info, + last_probed=prev_probed_vol_info.last_probed, + operations_since_probe=prev_probed_vol_info.operations_since_probe + + 1, + ) class WellView(HasState[WellState]): @@ -79,51 +140,97 @@ def __init__(self, state: WellState) -> None: """ self._state = state - def get_all(self) -> List[LiquidHeightSummary]: - """Get all well liquid heights.""" - all_heights: List[LiquidHeightSummary] = [] - for labware, wells in self._state.measured_liquid_heights.items(): - for well, lhi in wells.items(): - lhs = LiquidHeightSummary( - labware_id=labware, - well_name=well, - height=lhi.height, - last_measured=lhi.last_measured, - ) - all_heights.append(lhs) - return all_heights - - def get_all_in_labware(self, labware_id: str) -> List[LiquidHeightSummary]: - """Get all well liquid heights for a particular labware.""" - all_heights: List[LiquidHeightSummary] = [] - for well, lhi in self._state.measured_liquid_heights[labware_id].items(): - lhs = LiquidHeightSummary( - labware_id=labware_id, - well_name=well, - height=lhi.height, - last_measured=lhi.last_measured, - ) - all_heights.append(lhs) - return all_heights - - def get_last_measured_liquid_height( - self, labware_id: str, well_name: str - ) -> Optional[float]: - """Returns the height of the liquid according to the most recent liquid level probe to this well. - - Returns None if no liquid probe has been done. - """ - try: - height = self._state.measured_liquid_heights[labware_id][well_name].height - return height - except KeyError: - return None - - def has_measured_liquid_height(self, labware_id: str, well_name: str) -> bool: - """Returns True if the well has been liquid level probed previously.""" - try: - return bool( - self._state.measured_liquid_heights[labware_id][well_name].height - ) - except KeyError: - return False + def get_well_liquid_info(self, labware_id: str, well_name: str) -> WellLiquidInfo: + """Return all the liquid info for a well.""" + if ( + labware_id not in self._state.loaded_volumes + or well_name not in self._state.loaded_volumes[labware_id] + ): + loaded_volume_info = None + else: + loaded_volume_info = self._state.loaded_volumes[labware_id][well_name] + if ( + labware_id not in self._state.probed_heights + or well_name not in self._state.probed_heights[labware_id] + ): + probed_height_info = None + else: + probed_height_info = self._state.probed_heights[labware_id][well_name] + if ( + labware_id not in self._state.probed_volumes + or well_name not in self._state.probed_volumes[labware_id] + ): + probed_volume_info = None + else: + probed_volume_info = self._state.probed_volumes[labware_id][well_name] + return WellLiquidInfo( + loaded_volume=loaded_volume_info, + probed_height=probed_height_info, + probed_volume=probed_volume_info, + ) + + def get_all(self) -> List[WellInfoSummary]: + """Get all well liquid info summaries.""" + + def _all_well_combos() -> Iterator[Tuple[str, str, str]]: + for labware, lv_wells in self._state.loaded_volumes.items(): + for well_name in lv_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + for labware, ph_wells in self._state.probed_heights.items(): + for well_name in ph_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + for labware, pv_wells in self._state.probed_volumes.items(): + for well_name in pv_wells.keys(): + yield f"{labware}{well_name}", labware, well_name + + wells = { + key: (labware_id, well_name) + for key, labware_id, well_name in _all_well_combos() + } + return [ + self._summarize_well(labware_id, well_name) + for labware_id, well_name in wells.values() + ] + + def _summarize_well(self, labware_id: str, well_name: str) -> WellInfoSummary: + well_liquid_info = self.get_well_liquid_info(labware_id, well_name) + return WellInfoSummary( + labware_id=labware_id, + well_name=well_name, + loaded_volume=_volume_from_info(well_liquid_info.loaded_volume), + probed_volume=_volume_from_info(well_liquid_info.probed_volume), + probed_height=_height_from_info(well_liquid_info.probed_height), + ) + + +@overload +def _volume_from_info(info: Optional[ProbedVolumeInfo]) -> Optional[float]: + ... + + +@overload +def _volume_from_info(info: Optional[LoadedVolumeInfo]) -> Optional[float]: + ... + + +def _volume_from_info( + info: Union[ProbedVolumeInfo, LoadedVolumeInfo, None] +) -> Optional[float]: + if info is None: + return None + return info.volume + + +def _height_from_info(info: Optional[ProbedHeightInfo]) -> Optional[float]: + if info is None: + return None + return info.height + + +MaybeClear = TypeVar("MaybeClear") + + +def _none_from_clear(inval: MaybeClear | update_types.ClearType) -> MaybeClear | None: + if inval == update_types.CLEAR: + return None + return inval diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 72daafd3a52..ea3a57945b2 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -355,20 +355,46 @@ class CurrentWell: well_name: str -class LiquidHeightInfo(BaseModel): - """Payload required to store recent measured liquid heights.""" +class LoadedVolumeInfo(BaseModel): + """A well's liquid volume, initialized by a LoadLiquid, updated by Aspirate and Dispense.""" - height: float - last_measured: datetime + volume: Optional[float] = None + last_loaded: datetime + operations_since_load: int -class LiquidHeightSummary(BaseModel): - """Payload for liquid state height in StateSummary.""" +class ProbedHeightInfo(BaseModel): + """A well's liquid height, initialized by a LiquidProbe, cleared by Aspirate and Dispense.""" + + height: Optional[float] = None + last_probed: datetime + + +class ProbedVolumeInfo(BaseModel): + """A well's liquid volume, initialized by a LiquidProbe, updated by Aspirate and Dispense.""" + + volume: Optional[float] = None + last_probed: datetime + operations_since_probe: int + + +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 + probed_height: Optional[float] = None + probed_volume: Optional[float] = None + + +@dataclass +class WellLiquidInfo: + """Tracked and sensed information about liquid in a well.""" + + probed_height: Optional[ProbedHeightInfo] + loaded_volume: Optional[LoadedVolumeInfo] + probed_volume: Optional[ProbedVolumeInfo] @dataclass(frozen=True) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index 3d07bfe07d8..0ab9ac9da73 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -545,7 +545,7 @@ def test_aspirate_from_well( ) -def test_aspirate_from_location( +def test_aspirate_from_coordinates( decoy: Decoy, mock_engine_client: EngineClient, mock_protocol_core: ProtocolCore, @@ -583,6 +583,72 @@ def test_aspirate_from_location( ) +def test_aspirate_from_meniscus( + decoy: Decoy, + mock_engine_client: EngineClient, + mock_protocol_core: ProtocolCore, + subject: InstrumentCore, +) -> None: + """It should aspirate from a well.""" + location = Location(point=Point(1, 2, 3), labware=None) + + well_core = WellCore( + name="my cool well", labware_id="123abc", engine_client=mock_engine_client + ) + + decoy.when( + mock_engine_client.state.geometry.get_relative_liquid_handling_well_location( + labware_id="123abc", + well_name="my cool well", + absolute_point=Point(1, 2, 3), + is_meniscus=True, + ) + ).then_return( + LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, offset=WellOffset(x=3, y=2, z=1), volumeOffset=0 + ) + ) + + subject.aspirate( + location=location, + well_core=well_core, + volume=12.34, + rate=5.6, + flow_rate=7.8, + in_place=False, + is_meniscus=True, + ) + + decoy.verify( + pipette_movement_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="123abc", + well_name="my cool well", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=3, y=2, z=1), + volumeOffset="operationVolume", + ), + ), + mock_engine_client.execute_command( + cmd.AspirateParams( + pipetteId="abc123", + labwareId="123abc", + wellName="my cool well", + wellLocation=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=3, y=2, z=1), + volumeOffset="operationVolume", + ), + volume=12.34, + flowRate=7.8, + ) + ), + mock_protocol_core.set_last_location(location=location, mount=Mount.LEFT), + ) + + def test_aspirate_in_place( decoy: Decoy, mock_engine_client: EngineClient, diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py index 8d6f6d92179..ccaac0b6748 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py @@ -109,7 +109,12 @@ async def test_aspirate_implementation_no_prep( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), ), ) @@ -178,7 +183,12 @@ async def test_aspirate_implementation_with_prep( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), ), ) @@ -313,7 +323,12 @@ async def test_overpressure_error( labware_id=labware_id, well_name=well_name ), new_deck_point=DeckPoint(x=position.x, y=position.y, z=position.z), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name, + volume_added=update_types.CLEAR, + ), ), ) @@ -329,14 +344,10 @@ async def test_aspirate_implementation_meniscus( ) -> None: """Aspirate should update WellVolumeOffset when called with WellOrigin.MENISCUS.""" location = LiquidHandlingWellLocation( - origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=-1) - ) - updated_location = LiquidHandlingWellLocation( origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=-1), volumeOffset="operationVolume", ) - data = AspirateParams( pipetteId="abc", labwareId="123", @@ -353,7 +364,7 @@ async def test_aspirate_implementation_meniscus( pipette_id="abc", labware_id="123", well_name="A3", - well_location=updated_location, + well_location=location, current_well=None, operation_volume=-50, ), @@ -378,6 +389,11 @@ async def test_aspirate_implementation_meniscus( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="123", + well_name="A3", + volume_added=-50, + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py index 3891dd90294..f639033d157 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py @@ -21,6 +21,12 @@ from opentrons.protocol_engine.resources import ModelUtils from opentrons.protocol_engine.state.state import StateStore from opentrons.protocol_engine.commands.pipetting_common import OverpressureError +from opentrons.protocol_engine.types import ( + CurrentWell, + CurrentPipetteLocation, + CurrentAddressableArea, +) +from opentrons.protocol_engine.state import update_types @pytest.fixture @@ -61,6 +67,22 @@ def subject( ) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id-abc", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id-abc", "addressable-area-1"), None, None), + ], +) async def test_aspirate_in_place_implementation( decoy: Decoy, pipetting: PipettingHandler, @@ -68,6 +90,9 @@ async def test_aspirate_in_place_implementation( hardware_api: HardwareAPI, mock_command_note_adder: CommandNoteAdder, subject: AspirateInPlaceImplementation, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should aspirate in place.""" data = AspirateInPlaceParams( @@ -91,9 +116,27 @@ async def test_aspirate_in_place_implementation( ) ).then_return(123) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) + result = await subject.execute(params=data) - assert result == SuccessData(public=AspirateInPlaceResult(volume=123), private=None) + if isinstance(location, CurrentWell): + assert result == SuccessData( + public=AspirateInPlaceResult(volume=123), + private=None, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=-123, + ) + ), + ) + else: + assert result == SuccessData( + public=AspirateInPlaceResult(volume=123), + private=None, + ) async def test_handle_aspirate_in_place_request_not_ready_to_aspirate( @@ -153,6 +196,22 @@ async def test_aspirate_raises_volume_error( await subject.execute(data) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id", "addressable-area-1"), None, None), + ], +) async def test_overpressure_error( decoy: Decoy, gantry_mover: GantryMover, @@ -160,6 +219,10 @@ async def test_overpressure_error( subject: AspirateInPlaceImplementation, model_utils: ModelUtils, mock_command_note_adder: CommandNoteAdder, + state_store: StateStore, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should return an overpressure error if the hardware API indicates that.""" pipette_id = "pipette-id" @@ -191,14 +254,32 @@ async def test_overpressure_error( decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) result = await subject.execute(data) - assert result == DefinedErrorData( - public=OverpressureError.construct( - id=error_id, - createdAt=error_timestamp, - wrappedErrors=[matchers.Anything()], - errorInfo={"retryLocation": (position.x, position.y, position.z)}, - ), - ) + if isinstance(location, CurrentWell): + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=update_types.CLEAR, + ) + ), + ) + else: + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense.py b/api/tests/opentrons/protocol_engine/commands/test_dispense.py index 167223e6d9d..106bf197e11 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense.py @@ -92,6 +92,11 @@ async def test_dispense_implementation( ), new_deck_point=DeckPoint.construct(x=1, y=2, z=3), ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id-abc123", + well_name="A3", + volume_added=42, + ), ), ) @@ -161,5 +166,10 @@ async def test_overpressure_error( ), new_deck_point=DeckPoint.construct(x=1, y=2, z=3), ), + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id", + well_name="well-name", + volume_added=update_types.CLEAR, + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py index 53a491ad211..56090ec63a7 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py @@ -1,6 +1,7 @@ """Test dispense-in-place commands.""" from datetime import datetime +import pytest from decoy import Decoy, matchers from opentrons_shared_data.errors.exceptions import PipetteOverpressureError @@ -16,17 +17,53 @@ ) from opentrons.protocol_engine.commands.pipetting_common import OverpressureError from opentrons.protocol_engine.resources import ModelUtils - - +from opentrons.protocol_engine.state.state import StateStore +from opentrons.protocol_engine.types import ( + CurrentWell, + CurrentPipetteLocation, + CurrentAddressableArea, +) +from opentrons.protocol_engine.state import update_types + + +@pytest.fixture +def state_store(decoy: Decoy) -> StateStore: + """Get a mock in the shape of a StateStore.""" + return decoy.mock(cls=StateStore) + + +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id-abc", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id-abc", "addressable-area-1"), None, None), + ], +) async def test_dispense_in_place_implementation( decoy: Decoy, pipetting: PipettingHandler, + state_store: StateStore, gantry_mover: GantryMover, model_utils: ModelUtils, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should dispense in place.""" subject = DispenseInPlaceImplementation( - pipetting=pipetting, gantry_mover=gantry_mover, model_utils=model_utils + pipetting=pipetting, + state_view=state_store, + gantry_mover=gantry_mover, + model_utils=model_utils, ) data = DispenseInPlaceParams( @@ -41,20 +78,61 @@ async def test_dispense_in_place_implementation( ) ).then_return(42) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) + result = await subject.execute(data) - assert result == SuccessData(public=DispenseInPlaceResult(volume=42), private=None) + if isinstance(location, CurrentWell): + assert result == SuccessData( + public=DispenseInPlaceResult(volume=42), + private=None, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=42, + ) + ), + ) + else: + assert result == SuccessData( + public=DispenseInPlaceResult(volume=42), + private=None, + ) +@pytest.mark.parametrize( + "location,stateupdateLabware,stateupdateWell", + [ + ( + CurrentWell( + pipette_id="pipette-id", + labware_id="labware-id-1", + well_name="well-name-1", + ), + "labware-id-1", + "well-name-1", + ), + (None, None, None), + (CurrentAddressableArea("pipette-id", "addressable-area-1"), None, None), + ], +) async def test_overpressure_error( decoy: Decoy, gantry_mover: GantryMover, pipetting: PipettingHandler, + state_store: StateStore, model_utils: ModelUtils, + location: CurrentPipetteLocation | None, + stateupdateLabware: str, + stateupdateWell: str, ) -> None: """It should return an overpressure error if the hardware API indicates that.""" subject = DispenseInPlaceImplementation( - pipetting=pipetting, gantry_mover=gantry_mover, model_utils=model_utils + pipetting=pipetting, + state_view=state_store, + gantry_mover=gantry_mover, + model_utils=model_utils, ) pipette_id = "pipette-id" @@ -83,14 +161,32 @@ async def test_overpressure_error( decoy.when(model_utils.generate_id()).then_return(error_id) decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + decoy.when(state_store.pipettes.get_current_location()).then_return(location) result = await subject.execute(data) - assert result == DefinedErrorData( - public=OverpressureError.construct( - id=error_id, - createdAt=error_timestamp, - wrappedErrors=[matchers.Anything()], - errorInfo={"retryLocation": (position.x, position.y, position.z)}, - ), - ) + if isinstance(location, CurrentWell): + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=stateupdateLabware, + well_name=stateupdateWell, + volume_added=update_types.CLEAR, + ) + ), + ) + else: + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ) + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py index 6fb6ebc6935..5593ac9ca66 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py +++ b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py @@ -104,6 +104,7 @@ async def test_liquid_probe_implementation( subject: EitherImplementation, params_type: EitherParamsType, result_type: EitherResultType, + model_utils: ModelUtils, ) -> None: """It should move to the destination and do a liquid probe there.""" location = WellLocation(origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)) @@ -137,6 +138,17 @@ async def test_liquid_probe_implementation( ), ).then_return(15.0) + decoy.when( + state_view.geometry.get_well_volume_at_height( + labware_id="123", + well_name="A3", + height=15.0, + ), + ).then_return(30.0) + + timestamp = datetime(year=2020, month=1, day=2) + decoy.when(model_utils.get_timestamp()).then_return(timestamp) + result = await subject.execute(data) assert type(result.public) is result_type # Pydantic v1 only compares the fields. @@ -148,7 +160,14 @@ async def test_liquid_probe_implementation( pipette_id="abc", new_location=update_types.Well(labware_id="123", well_name="A3"), new_deck_point=DeckPoint(x=1, y=2, z=3), - ) + ), + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="123", + well_name="A3", + height=15.0, + volume=30.0, + last_probed=timestamp, + ), ), ) @@ -212,7 +231,14 @@ async def test_liquid_not_found_error( pipette_id=pipette_id, new_location=update_types.Well(labware_id=labware_id, well_name=well_name), new_deck_point=DeckPoint(x=position.x, y=position.y, z=position.z), - ) + ), + liquid_probed=update_types.LiquidProbedUpdate( + labware_id=labware_id, + well_name=well_name, + height=update_types.CLEAR, + volume=update_types.CLEAR, + last_probed=error_timestamp, + ), ) if isinstance(subject, LiquidProbeImplementation): assert result == DefinedErrorData( diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py b/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py index 3ccaaea15d0..d8641b1eb4b 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_liquid.py @@ -1,6 +1,7 @@ """Test load-liquid command.""" import pytest from decoy import Decoy +from datetime import datetime from opentrons.protocol_engine.commands.command import SuccessData from opentrons.protocol_engine.commands import ( @@ -9,6 +10,8 @@ LoadLiquidParams, ) from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.resources.model_utils import ModelUtils +from opentrons.protocol_engine.state import update_types @pytest.fixture @@ -18,15 +21,18 @@ def mock_state_view(decoy: Decoy) -> StateView: @pytest.fixture -def subject(mock_state_view: StateView) -> LoadLiquidImplementation: +def subject( + mock_state_view: StateView, model_utils: ModelUtils +) -> LoadLiquidImplementation: """Load liquid implementation test subject.""" - return LoadLiquidImplementation(state_view=mock_state_view) + return LoadLiquidImplementation(state_view=mock_state_view, model_utils=model_utils) async def test_load_liquid_implementation( decoy: Decoy, subject: LoadLiquidImplementation, mock_state_view: StateView, + model_utils: ModelUtils, ) -> None: """Test LoadLiquid command execution.""" data = LoadLiquidParams( @@ -34,9 +40,23 @@ async def test_load_liquid_implementation( liquidId="liquid-id", volumeByWell={"A1": 30, "B2": 100}, ) + + timestamp = datetime(year=2020, month=1, day=2) + decoy.when(model_utils.get_timestamp()).then_return(timestamp) + result = await subject.execute(data) - assert result == SuccessData(public=LoadLiquidResult(), private=None) + assert result == SuccessData( + public=LoadLiquidResult(), + private=None, + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id="labware-id", + volumes={"A1": 30, "B2": 100}, + last_loaded=timestamp, + ) + ), + ) decoy.verify(mock_state_view.liquid.validate_liquid_id("liquid-id")) diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index 9c4665d31a2..5ac522095f2 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -1,7 +1,7 @@ """Command factories to use in tests as data fixtures.""" from datetime import datetime from pydantic import BaseModel -from typing import Optional, cast +from typing import Optional, cast, Dict from opentrons_shared_data.pipette.types import PipetteNameType from opentrons.types import MountType @@ -338,6 +338,29 @@ def create_liquid_probe_command( ) +def create_load_liquid_command( + liquid_id: str = "liquid-id", + labware_id: str = "labware-id", + volume_by_well: Dict[str, float] = {"A1": 30, "B2": 100}, +) -> cmd.LoadLiquid: + """Get a completed Load Liquid command.""" + params = cmd.LoadLiquidParams( + liquidId=liquid_id, + labwareId=labware_id, + volumeByWell=volume_by_well, + ) + result = cmd.LoadLiquidResult() + + return cmd.LoadLiquid( + id="command-id", + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + createdAt=datetime.now(), + params=params, + result=result, + ) + + def create_pick_up_tip_command( pipette_id: str, labware_id: str = "labware-id", diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 7a94f06ca09..1a0e27d2bb5 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -55,6 +55,9 @@ LoadedPipette, TipGeometry, ModuleDefinition, + ProbedHeightInfo, + LoadedVolumeInfo, + WellLiquidInfo, ) from opentrons.protocol_engine.commands import ( CommandStatus, @@ -1539,9 +1542,13 @@ def test_get_well_position_with_meniscus_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(70.5) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + probed_volume=None, + probed_height=ProbedHeightInfo(height=70.5, last_probed=datetime.now()), + loaded_volume=None, + ) + ) decoy.when( mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") ).then_return(0.5) @@ -1563,6 +1570,68 @@ def test_get_well_position_with_meniscus_offset( ) +def test_get_well_position_with_volume_offset_raises_error( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """Calling get_well_position with any volume offset should raise an error when there's no innerLabwareGeometry.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_raise( + errors.IncompleteLabwareDefinitionError("Woops!") + ) + + with pytest.raises(errors.IncompleteLabwareDefinitionError): + subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + volumeOffset="operationVolume", + ), + operation_volume=-1245.833, + pipette_id="pipette-id", + ) + + def test_get_well_position_with_meniscus_and_literal_volume_offset( decoy: Decoy, well_plate_def: LabwareDefinition, @@ -1597,9 +1666,13 @@ def test_get_well_position_with_meniscus_and_literal_volume_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(45.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1663,9 +1736,13 @@ def test_get_well_position_with_meniscus_and_float_volume_offset( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(45.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=45.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1728,9 +1805,13 @@ def test_get_well_position_raises_validation_error( decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "B2") - ).then_return(40.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=40.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) labware_def = _load_labware_definition_data() assert labware_def.innerLabwareGeometry is not None inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] @@ -1755,6 +1836,76 @@ def test_get_well_position_raises_validation_error( ) +def test_get_meniscus_height( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """It should be able to get the position of a well meniscus in a labware.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "B2")).then_return( + WellLiquidInfo( + loaded_volume=LoadedVolumeInfo( + volume=2000.0, last_loaded=datetime.now(), operations_since_load=0 + ), + probed_height=None, + probed_volume=None, + ) + ) + labware_def = _load_labware_definition_data() + assert labware_def.innerLabwareGeometry is not None + inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_return( + inner_well_def + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + + result = subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=WellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + ), + pipette_id="pipette-id", + ) + + assert result == Point( + x=slot_pos[0] + 1 + well_def.x + 2, + y=slot_pos[1] - 2 + well_def.y + 3, + z=slot_pos[2] + 3 + well_def.z + 4 + 39.2423, + ) + + def test_get_relative_well_location( decoy: Decoy, well_plate_def: LabwareDefinition, @@ -3133,9 +3284,13 @@ def test_validate_dispense_volume_into_well_meniscus( decoy.when(mock_labware_view.get_well_geometry("labware-id", "A1")).then_return( inner_well_def ) - decoy.when( - mock_well_view.get_last_measured_liquid_height("labware-id", "A1") - ).then_return(40.0) + decoy.when(mock_well_view.get_well_liquid_info("labware-id", "A1")).then_return( + WellLiquidInfo( + loaded_volume=None, + probed_height=ProbedHeightInfo(height=40.0, last_probed=datetime.now()), + probed_volume=None, + ) + ) with pytest.raises(errors.InvalidDispenseVolumeError): subject.validate_dispense_volume_into_well( diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index d461ddda4e6..d6b05b7b027 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -460,6 +460,17 @@ def test_get_well_definition_get_first(well_plate_def: LabwareDefinition) -> Non assert result == expected_well_def +def test_get_well_geometry_raises_error(well_plate_def: LabwareDefinition) -> None: + """It should raise an IncompleteLabwareDefinitionError when there's no innerLabwareGeometry.""" + subject = get_labware_view( + labware_by_id={"plate-id": plate}, + definitions_by_uri={"some-plate-uri": well_plate_def}, + ) + + with pytest.raises(errors.IncompleteLabwareDefinitionError): + subject.get_well_geometry(labware_id="plate-id") + + def test_get_well_size_circular(well_plate_def: LabwareDefinition) -> None: """It should return the well dimensions of a circular well.""" subject = get_labware_view( diff --git a/api/tests/opentrons/protocol_engine/state/test_well_store.py b/api/tests/opentrons/protocol_engine/state/test_well_store.py index 325021a9942..822329aa874 100644 --- a/api/tests/opentrons/protocol_engine/state/test_well_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_well_store.py @@ -1,9 +1,15 @@ """Well state store tests.""" import pytest +from datetime import datetime from opentrons.protocol_engine.state.wells import WellStore from opentrons.protocol_engine.actions.actions import SucceedCommandAction +from opentrons.protocol_engine.state import update_types -from .command_fixtures import create_liquid_probe_command +from .command_fixtures import ( + create_liquid_probe_command, + create_load_liquid_command, + create_aspirate_command, +) @pytest.fixture @@ -16,13 +22,215 @@ def test_handles_liquid_probe_success(subject: WellStore) -> None: """It should add the well to the state after a successful liquid probe.""" labware_id = "labware-id" well_name = "well-name" + liquid_probe = create_liquid_probe_command() + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + private_result=None, + command=liquid_probe, + state_update=update_types.StateUpdate( + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="labware-id", + well_name="well-name", + height=15.0, + volume=30.0, + last_probed=timestamp, + ) + ), + ) + ) + + assert len(subject.state.probed_heights) == 1 + assert len(subject.state.probed_volumes) == 1 + + assert subject.state.probed_heights[labware_id][well_name].height == 15.0 + assert subject.state.probed_heights[labware_id][well_name].last_probed == timestamp + assert subject.state.probed_volumes[labware_id][well_name].volume == 30.0 + assert subject.state.probed_volumes[labware_id][well_name].last_probed == timestamp + assert ( + subject.state.probed_volumes[labware_id][well_name].operations_since_probe == 0 + ) + + +def test_handles_load_liquid_success(subject: WellStore) -> None: + """It should add the well to the state after a successful load liquid.""" + labware_id = "labware-id" + well_name_1 = "well-name-1" + well_name_2 = "well-name-2" + load_liquid = create_load_liquid_command( + labware_id=labware_id, volume_by_well={well_name_1: 30, well_name_2: 100} + ) + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + private_result=None, + command=load_liquid, + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id=labware_id, + volumes={well_name_1: 30, well_name_2: 100}, + last_loaded=timestamp, + ) + ), + ) + ) + + assert len(subject.state.loaded_volumes) == 1 + assert len(subject.state.loaded_volumes[labware_id]) == 2 + + assert subject.state.loaded_volumes[labware_id][well_name_1].volume == 30.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].operations_since_load == 0 + ) + assert subject.state.loaded_volumes[labware_id][well_name_2].volume == 100.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].operations_since_load == 0 + ) + + +def test_handles_load_liquid_and_aspirate(subject: WellStore) -> None: + """It should populate the well state after load liquid and update the well state after aspirate.""" + pipette_id = "pipette-id" + labware_id = "labware-id" + well_name_1 = "well-name-1" + well_name_2 = "well-name-2" + aspirated_volume = 10.0 + load_liquid = create_load_liquid_command( + labware_id=labware_id, volume_by_well={well_name_1: 30, well_name_2: 100} + ) + aspirate_1 = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name_1, + ) + aspirate_2 = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name_2, + ) + timestamp = datetime(year=2020, month=1, day=2) + + subject.handle_action( + SucceedCommandAction( + private_result=None, + command=load_liquid, + state_update=update_types.StateUpdate( + liquid_loaded=update_types.LiquidLoadedUpdate( + labware_id=labware_id, + volumes={well_name_1: 30, well_name_2: 100}, + last_loaded=timestamp, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + private_result=None, + command=aspirate_1, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name_1, + volume_added=-aspirated_volume, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + private_result=None, + command=aspirate_2, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id=labware_id, + well_name=well_name_2, + volume_added=-aspirated_volume, + ) + ), + ) + ) + + assert len(subject.state.loaded_volumes) == 1 + assert len(subject.state.loaded_volumes[labware_id]) == 2 + assert subject.state.loaded_volumes[labware_id][well_name_1].volume == 20.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_1].operations_since_load == 1 + ) + assert subject.state.loaded_volumes[labware_id][well_name_2].volume == 90.0 + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].last_loaded == timestamp + ) + assert ( + subject.state.loaded_volumes[labware_id][well_name_2].operations_since_load == 1 + ) + + +def test_handles_liquid_probe_and_aspirate(subject: WellStore) -> None: + """It should populate the well state after liquid probe and update the well state after aspirate.""" + pipette_id = "pipette-id" + labware_id = "labware-id" + well_name = "well-name" + aspirated_volume = 10.0 liquid_probe = create_liquid_probe_command() + aspirate = create_aspirate_command( + pipette_id=pipette_id, + volume=aspirated_volume, + flow_rate=1.0, + labware_id=labware_id, + well_name=well_name, + ) + timestamp = datetime(year=2020, month=1, day=2) subject.handle_action( - SucceedCommandAction(private_result=None, command=liquid_probe) + SucceedCommandAction( + private_result=None, + command=liquid_probe, + state_update=update_types.StateUpdate( + liquid_probed=update_types.LiquidProbedUpdate( + labware_id="labware-id", + well_name="well-name", + height=15.0, + volume=30.0, + last_probed=timestamp, + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + private_result=None, + command=aspirate, + state_update=update_types.StateUpdate( + liquid_operated=update_types.LiquidOperatedUpdate( + labware_id="labware-id", + well_name="well-name", + volume_added=-aspirated_volume, + ) + ), + ) ) - assert len(subject.state.measured_liquid_heights) == 1 + assert len(subject.state.probed_heights[labware_id]) == 0 + assert len(subject.state.probed_volumes) == 1 - assert subject.state.measured_liquid_heights[labware_id][well_name].height == 0.5 + assert subject.state.probed_volumes[labware_id][well_name].volume == 20.0 + assert subject.state.probed_volumes[labware_id][well_name].last_probed == timestamp + assert ( + subject.state.probed_volumes[labware_id][well_name].operations_since_probe == 1 + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_well_view.py b/api/tests/opentrons/protocol_engine/state/test_well_view.py index 3bd86e9dcb9..5025e4ee93e 100644 --- a/api/tests/opentrons/protocol_engine/state/test_well_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_well_view.py @@ -1,6 +1,10 @@ """Well view tests.""" from datetime import datetime -from opentrons.protocol_engine.types import LiquidHeightInfo +from opentrons.protocol_engine.types import ( + LoadedVolumeInfo, + ProbedHeightInfo, + ProbedVolumeInfo, +) import pytest from opentrons.protocol_engine.state.wells import WellState, WellView @@ -8,44 +12,47 @@ @pytest.fixture def subject() -> WellView: """Get a well view test subject.""" - labware_id = "labware-id" - well_name = "well-name" - height_info = LiquidHeightInfo(height=0.5, last_measured=datetime.now()) - state = WellState(measured_liquid_heights={labware_id: {well_name: height_info}}) + loaded_volume_info = LoadedVolumeInfo( + volume=30.0, last_loaded=datetime.now(), operations_since_load=0 + ) + probed_height_info = ProbedHeightInfo(height=5.5, last_probed=datetime.now()) + probed_volume_info = ProbedVolumeInfo( + volume=25.0, last_probed=datetime.now(), operations_since_probe=0 + ) + state = WellState( + loaded_volumes={"labware_id_1": {"well_name": loaded_volume_info}}, + probed_heights={"labware_id_2": {"well_name": probed_height_info}}, + probed_volumes={"labware_id_2": {"well_name": probed_volume_info}}, + ) return WellView(state) -def test_get_all(subject: WellView) -> None: - """Should return a list of well heights.""" - assert subject.get_all()[0].height == 0.5 - - -def test_get_last_measured_liquid_height(subject: WellView) -> None: - """Should return the height of a single well correctly for valid wells.""" - labware_id = "labware-id" - well_name = "well-name" - - invalid_labware_id = "invalid-labware-id" - invalid_well_name = "invalid-well-name" - - assert ( - subject.get_last_measured_liquid_height(invalid_labware_id, invalid_well_name) - is None +def test_get_well_liquid_info(subject: WellView) -> None: + """Should return a tuple of well infos.""" + volume_info = subject.get_well_liquid_info( + labware_id="labware_id_1", well_name="well_name" ) - assert subject.get_last_measured_liquid_height(labware_id, well_name) == 0.5 + assert volume_info.loaded_volume is not None + assert volume_info.probed_height is None + assert volume_info.probed_volume is None + assert volume_info.loaded_volume.volume == 30.0 + volume_info = subject.get_well_liquid_info( + labware_id="labware_id_2", well_name="well_name" + ) + assert volume_info.loaded_volume is None + assert volume_info.probed_height is not None + assert volume_info.probed_volume is not None + assert volume_info.probed_height.height == 5.5 + assert volume_info.probed_volume.volume == 25.0 -def test_has_measured_liquid_height(subject: WellView) -> None: - """Should return True for measured wells and False for ones that have no measurements.""" - labware_id = "labware-id" - well_name = "well-name" - invalid_labware_id = "invalid-labware-id" - invalid_well_name = "invalid-well-name" +def test_get_all(subject: WellView) -> None: + """Should return a list of well summaries.""" + summaries = subject.get_all() - assert ( - subject.has_measured_liquid_height(invalid_labware_id, invalid_well_name) - is False - ) - assert subject.has_measured_liquid_height(labware_id, well_name) is True + assert len(summaries) == 2, f"{summaries}" + assert summaries[0].loaded_volume == 30.0 + assert summaries[1].probed_height == 5.5 + assert summaries[1].probed_volume == 25.0