diff --git a/api/src/opentrons/protocol_engine/commands/air_gap_in_place.py b/api/src/opentrons/protocol_engine/commands/air_gap_in_place.py index ca40da39ed0..d4f058a0a1f 100644 --- a/api/src/opentrons/protocol_engine/commands/air_gap_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/air_gap_in_place.py @@ -25,6 +25,7 @@ from ..errors.error_occurrence import ErrorOccurrence from ..errors.exceptions import PipetteNotReadyToAspirateError from ..state.update_types import StateUpdate +from ..types import AspiratedFluid, FluidKind if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover @@ -48,7 +49,7 @@ class AirGapInPlaceResult(BaseLiquidHandlingResult): _ExecuteReturn = Union[ - SuccessData[AirGapInPlaceResult, None], + SuccessData[AirGapInPlaceResult], DefinedErrorData[OverpressureError], ] @@ -128,9 +129,12 @@ async def execute(self, params: AirGapInPlaceParams) -> _ExecuteReturn: state_update=state_update, ) else: + state_update.set_fluid_aspirated( + pipette_id=params.pipetteId, + fluid=AspiratedFluid(kind=FluidKind.AIR, volume=volume), + ) return SuccessData( public=AirGapInPlaceResult(volume=volume), - private=None, state_update=state_update, ) diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index 00d57a93e9a..b5541c79792 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -25,7 +25,14 @@ from opentrons.hardware_control import HardwareControlAPI from ..state.update_types import StateUpdate, CLEAR -from ..types import WellLocation, WellOrigin, CurrentWell, DeckPoint +from ..types import ( + WellLocation, + WellOrigin, + CurrentWell, + DeckPoint, + AspiratedFluid, + FluidKind, +) if TYPE_CHECKING: from ..execution import MovementHandler, PipettingHandler @@ -141,6 +148,7 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: well_name=well_name, volume_added=CLEAR, ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -162,6 +170,10 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: well_name=well_name, volume_added=-volume_aspirated, ) + state_update.set_fluid_aspirated( + pipette_id=params.pipetteId, + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=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 4c7ab2cc01c..f25b6c24bbb 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py @@ -25,7 +25,7 @@ from ..errors.error_occurrence import ErrorOccurrence from ..errors.exceptions import PipetteNotReadyToAspirateError from ..state.update_types import StateUpdate, CLEAR -from ..types import CurrentWell +from ..types import CurrentWell, AspiratedFluid, FluidKind if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover @@ -115,6 +115,7 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: well_name=current_location.well_name, volume_added=CLEAR, ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -139,6 +140,10 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: state_update=state_update, ) else: + state_update.set_fluid_aspirated( + pipette_id=params.pipetteId, + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=volume), + ) if ( isinstance(current_location, CurrentWell) and current_location.pipette_id == params.pipetteId @@ -148,6 +153,7 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: well_name=current_location.well_name, volume_added=-volume, ) + return SuccessData( public=AspirateInPlaceResult(volume=volume), state_update=state_update, diff --git a/api/src/opentrons/protocol_engine/commands/blow_out.py b/api/src/opentrons/protocol_engine/commands/blow_out.py index e13378b5541..c450fa894ed 100644 --- a/api/src/opentrons/protocol_engine/commands/blow_out.py +++ b/api/src/opentrons/protocol_engine/commands/blow_out.py @@ -93,6 +93,7 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn: pipette_id=params.pipetteId, flow_rate=params.flowRate ) except PipetteOverpressureError as e: + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -112,8 +113,10 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn: ) }, ), + state_update=state_update, ) else: + state_update.set_fluid_empty(pipette_id=params.pipetteId) return SuccessData( public=BlowOutResult(position=deck_point), state_update=state_update, diff --git a/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py b/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py index 0b9aaec77b2..07895537d6b 100644 --- a/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/blow_out_in_place.py @@ -19,6 +19,7 @@ SuccessData, ) from ..errors.error_occurrence import ErrorOccurrence +from ..state import update_types from opentrons.hardware_control import HardwareControlAPI @@ -72,12 +73,14 @@ def __init__( async def execute(self, params: BlowOutInPlaceParams) -> _ExecuteReturn: """Blow-out without moving the pipette.""" + state_update = update_types.StateUpdate() try: current_position = await self._gantry_mover.get_position(params.pipetteId) await self._pipetting.blow_out_in_place( pipette_id=params.pipetteId, flow_rate=params.flowRate ) except PipetteOverpressureError as e: + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -97,8 +100,10 @@ async def execute(self, params: BlowOutInPlaceParams) -> _ExecuteReturn: ) }, ), + state_update=state_update, ) else: + state_update.set_fluid_empty(pipette_id=params.pipetteId) return SuccessData( public=BlowOutInPlaceResult(), ) diff --git a/api/src/opentrons/protocol_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index a7fee20c762..603fa7396a7 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -112,6 +112,7 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: well_name=well_name, volume_added=CLEAR, ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -128,11 +129,17 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: state_update=state_update, ) else: + volume_added = ( + self._state_view.pipettes.get_liquid_dispensed_by_ejecting_volume( + pipette_id=params.pipetteId, volume=volume + ) + ) state_update.set_liquid_operated( labware_id=labware_id, well_name=well_name, - volume_added=volume, + volume_added=volume_added if volume_added is not None else CLEAR, ) + state_update.set_fluid_ejected(pipette_id=params.pipetteId, volume=volume) return SuccessData( public=DispenseResult(volume=volume, position=deck_point), state_update=state_update, 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 7df9471b038..ee7cae42dc1 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py @@ -94,6 +94,7 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: well_name=current_location.well_name, volume_added=CLEAR, ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -118,14 +119,20 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: state_update=state_update, ) else: + state_update.set_fluid_ejected(pipette_id=params.pipetteId, volume=volume) if ( isinstance(current_location, CurrentWell) and current_location.pipette_id == params.pipetteId ): + volume_added = ( + self._state_view.pipettes.get_liquid_dispensed_by_ejecting_volume( + pipette_id=params.pipetteId, volume=volume + ) + ) state_update.set_liquid_operated( labware_id=current_location.labware_id, well_name=current_location.well_name, - volume_added=volume, + volume_added=volume_added if volume_added is not None else CLEAR, ) return SuccessData( public=DispenseInPlaceResult(volume=volume), diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip.py b/api/src/opentrons/protocol_engine/commands/drop_tip.py index 81a34a5ad39..9901ff587ac 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip.py @@ -150,12 +150,15 @@ async def execute(self, params: DropTipParams) -> _ExecuteReturn: state_update_if_false_positive.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) + state_update.set_fluid_unknown(pipette_id=pipette_id) + state_update_if_false_positive.set_fluid_unknown(pipette_id=pipette_id) return DefinedErrorData( public=error, state_update=state_update, state_update_if_false_positive=state_update_if_false_positive, ) else: + state_update.set_fluid_unknown(pipette_id=pipette_id) state_update.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py index aa40384ac6a..c5b708c5502 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py @@ -65,7 +65,6 @@ def __init__( async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn: """Drop a tip using the requested pipette.""" state_update = update_types.StateUpdate() - try: await self._tip_handler.drop_tip( pipette_id=params.pipetteId, home_after=params.homeAfter @@ -75,6 +74,10 @@ async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn: state_update_if_false_positive.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) + state_update.set_fluid_unknown(pipette_id=params.pipetteId) + state_update_if_false_positive.set_fluid_unknown( + pipette_id=params.pipetteId + ) error = TipPhysicallyAttachedError( id=self._model_utils.generate_id(), createdAt=self._model_utils.get_timestamp(), @@ -92,6 +95,7 @@ async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn: state_update_if_false_positive=state_update_if_false_positive, ) else: + state_update.set_fluid_unknown(pipette_id=params.pipetteId) state_update.update_pipette_tip_state( pipette_id=params.pipetteId, tip_geometry=None ) diff --git a/api/src/opentrons/protocol_engine/commands/load_pipette.py b/api/src/opentrons/protocol_engine/commands/load_pipette.py index 7d09bf33028..6d8d74b4fa2 100644 --- a/api/src/opentrons/protocol_engine/commands/load_pipette.py +++ b/api/src/opentrons/protocol_engine/commands/load_pipette.py @@ -127,6 +127,7 @@ async def execute( serial_number=loaded_pipette.serial_number, config=loaded_pipette.static_config, ) + state_update.set_fluid_unknown(pipette_id=loaded_pipette.pipette_id) return SuccessData( public=LoadPipetteResult(pipetteId=loaded_pipette.pipette_id), diff --git a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py index 898929566fe..e6846c2f4a1 100644 --- a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py +++ b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py @@ -172,6 +172,7 @@ async def execute( state_update.mark_tips_as_used( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) + state_update.set_fluid_empty(pipette_id=pipette_id) return SuccessData( public=PickUpTipResult( tipVolume=tip_geometry.volume, diff --git a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py index 01012be1d7f..f5525b3c90e 100644 --- a/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/prepare_to_aspirate.py @@ -18,6 +18,7 @@ SuccessData, ) from ..errors.error_occurrence import ErrorOccurrence +from ..state import update_types if TYPE_CHECKING: from ..execution import PipettingHandler, GantryMover @@ -64,11 +65,13 @@ def __init__( async def execute(self, params: PrepareToAspirateParams) -> _ExecuteReturn: """Prepare the pipette to aspirate.""" current_position = await self._gantry_mover.get_position(params.pipetteId) + state_update = update_types.StateUpdate() try: await self._pipetting_handler.prepare_for_aspirate( pipette_id=params.pipetteId, ) except PipetteOverpressureError as e: + state_update.set_fluid_unknown(pipette_id=params.pipetteId) return DefinedErrorData( public=OverpressureError( id=self._model_utils.generate_id(), @@ -90,10 +93,12 @@ async def execute(self, params: PrepareToAspirateParams) -> _ExecuteReturn: } ), ), + state_update=state_update, ) else: + state_update.set_fluid_empty(pipette_id=params.pipetteId) return SuccessData( - public=PrepareToAspirateResult(), + public=PrepareToAspirateResult(), state_update=state_update ) diff --git a/api/src/opentrons/protocol_engine/state/fluid_stack.py b/api/src/opentrons/protocol_engine/state/fluid_stack.py new file mode 100644 index 00000000000..f32230d03d2 --- /dev/null +++ b/api/src/opentrons/protocol_engine/state/fluid_stack.py @@ -0,0 +1,126 @@ +"""Implements fluid stack tracking for pipettes. + +Inside a pipette's tip, there can be a mix of kinds of fluids - here, "fluid" means "liquid" (i.e. a protocol-relevant +working liquid that is aspirated or dispensed from wells) or "air" (i.e. because there was an air gap). Since sometimes +you want air gaps in different places - below liquid to prevent dripping, above liquid to provide extra room to push the +plunger - we need to support some notion of at least ordinal position of air and liquid, and we do so as a stack because +that's physically relevant. +""" +from logging import getLogger +from numpy import isclose +from ..types import AspiratedFluid, FluidKind + +_LOG = getLogger(__name__) + + +class FluidStack: + """A FluidStack data structure is a list of AspiratedFluids, with stack-style (last-in-first-out) ordering. + + The front of the list is the top of the liquid stack and the back of the list is the bottom of the liquid stack. The + state is internal and the interaction surface is the methods. This is a mutating API. + """ + + _FluidStack = list[AspiratedFluid] + + _fluid_stack: _FluidStack + + def __init__(self, _fluid_stack: _FluidStack | None = None) -> None: + """Build a FluidStack. + + The argument is provided for testing and shouldn't be generally used. + """ + self._fluid_stack = _fluid_stack or [] + + def add_fluid(self, new: AspiratedFluid) -> None: + """Add fluid to a stack. + + If the new fluid is of a different kind than what's on the bottom of the stack. add a new record; if the same + kind, add a new record to the end. + """ + if len(self._fluid_stack) == 0 or self._fluid_stack[-1].kind != new.kind: + # this is a new kind of fluid, append the record + self._fluid_stack.append(new) + else: + # this is more of the same kind of fluid, add the volumes + old_fluid = self._fluid_stack.pop(-1) + self._fluid_stack.append( + AspiratedFluid(kind=new.kind, volume=old_fluid.volume + new.volume) + ) + + def _alter_fluid_records( + self, remove: int, new_last: AspiratedFluid | None + ) -> None: + if remove == len(self._fluid_stack) or len(self._fluid_stack) == 0: + self._fluid_stack = [] + return + if remove != 0: + removed = self._fluid_stack[:-remove] + else: + removed = self._fluid_stack + if new_last: + self._fluid_stack.pop(-1) + removed.append(new_last) + self._fluid_stack = removed + + def remove_fluid(self, volume: float) -> None: + """Remove a specific amount of fluid from the stack. + + This will consume records that are wholly included in the provided volume and alter the remaining + final records (if any) to decrement the amount of volume removed from it. + + This function is designed to be used inside pipette store action handlers, which are generally not + exception-safe, and therefore swallows and logs errors. + """ + self._fluid_stack_iterator = reversed(self._fluid_stack) + removed_elements: list[AspiratedFluid] = [] + while volume > 0: + try: + last_stack_element = next(self._fluid_stack_iterator) + except StopIteration: + _LOG.error( + f"Attempting to remove more fluid than present, {volume}uL left over" + ) + self._alter_fluid_records(len(removed_elements), None) + return + if last_stack_element.volume < volume: + self._alter_fluid_records( + len(removed_elements), + AspiratedFluid( + kind=last_stack_element.kind, + volume=last_stack_element.volume - volume, + ), + ) + return + elif isclose(last_stack_element.volume, volume): + self._alter_fluid_records(len(removed_elements) + 1, None) + return + else: + removed_elements.append(last_stack_element) + volume -= last_stack_element.volume + _LOG.error(f"Failed to handle removing {volume}uL from {self._fluid_stack}") + + def aspirated_volume(self, kind: FluidKind | None = None) -> float: + """Measure the total amount of fluid (optionally filtered by kind) in the stack.""" + volume = 0.0 + for el in self._fluid_stack: + if kind is not None and el.kind != kind: + continue + volume += el.volume + return volume + + def liquid_part_of_dispense_volume(self, volume: float) -> float: + """Get the amount of liquid in the specified volume starting at the bottom of the stack.""" + liquid_volume = 0.0 + for el in reversed(self._fluid_stack): + volume -= min(el.volume, volume) + if el.kind == FluidKind.LIQUID: + liquid_volume += max(volume, el.volume) + if isclose(volume, 0.0): + return liquid_volume + return liquid_volume + + def __eq__(self, other: object) -> bool: + """Equality.""" + if isinstance(other, type(self)): + return other._fluid_stack == self._fluid_stack + return False diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index d7746b326bc..b3820d23724 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -2,13 +2,13 @@ from __future__ import annotations import dataclasses +from logging import getLogger from typing import ( Dict, List, Mapping, Optional, Tuple, - Union, ) from opentrons_shared_data.pipette import pipette_definition @@ -21,8 +21,7 @@ ) from opentrons.types import MountType, Mount as HwMount, Point -from . import update_types -from .. import commands +from . import update_types, fluid_stack from .. import errors from ..types import ( LoadedPipette, @@ -36,13 +35,13 @@ ) from ..actions import ( Action, - FailCommandAction, SetPipetteMovementSpeedAction, - SucceedCommandAction, get_state_updates, ) from ._abstract_store import HasState, HandlesActions +LOG = getLogger(__name__) + @dataclasses.dataclass(frozen=True) class HardwarePipette: @@ -108,8 +107,7 @@ class PipetteState: # attributes are populated at the appropriate times. Refactor to a # single dict-of-many-things instead of many dicts-of-single-things. pipettes_by_id: Dict[str, LoadedPipette] - aspirated_volume_by_id: Dict[str, Optional[float]] - aspirated_liquid_by_id: Dict[str, Optional[float]] + pipette_contents_by_id: Dict[str, Optional[fluid_stack.FluidStack]] current_location: Optional[CurrentPipetteLocation] current_deck_point: CurrentDeckPoint attached_tip_by_id: Dict[str, Optional[TipGeometry]] @@ -129,8 +127,7 @@ def __init__(self) -> None: """Initialize a PipetteStore and its state.""" self._state = PipetteState( pipettes_by_id={}, - aspirated_volume_by_id={}, - aspirated_liquid_by_id={}, + pipette_contents_by_id={}, attached_tip_by_id={}, current_location=None, current_deck_point=CurrentDeckPoint(mount=None, deck_point=None), @@ -149,11 +146,9 @@ def handle_action(self, action: Action) -> None: self._update_pipette_config(state_update) self._update_pipette_nozzle_map(state_update) self._update_tip_state(state_update) + self._update_volumes(state_update) - if isinstance(action, (SucceedCommandAction, FailCommandAction)): - self._update_volumes(action) - - elif isinstance(action, SetPipetteMovementSpeedAction): + if isinstance(action, SetPipetteMovementSpeedAction): self._state.movement_speed_by_id[action.pipette_id] = action.speed def _set_load_pipette(self, state_update: update_types.StateUpdate) -> None: @@ -168,8 +163,7 @@ def _set_load_pipette(self, state_update: update_types.StateUpdate) -> None: self._state.liquid_presence_detection_by_id[pipette_id] = ( state_update.loaded_pipette.liquid_presence_detection or False ) - self._state.aspirated_volume_by_id[pipette_id] = None - self._state.aspirated_liquid_by_id[pipette_id] = None + self._state.pipette_contents_by_id[pipette_id] = fluid_stack.FluidStack() self._state.movement_speed_by_id[pipette_id] = None self._state.attached_tip_by_id[pipette_id] = None @@ -180,8 +174,9 @@ def _update_tip_state(self, state_update: update_types.StateUpdate) -> None: attached_tip = state_update.pipette_tip_state.tip_geometry self._state.attached_tip_by_id[pipette_id] = attached_tip - self._state.aspirated_volume_by_id[pipette_id] = 0 - self._state.aspirated_liquid_by_id[pipette_id] = 0 + self._state.pipette_contents_by_id[ + pipette_id + ] = fluid_stack.FluidStack() static_config = self._state.static_config_by_id.get(pipette_id) if static_config: @@ -208,8 +203,9 @@ def _update_tip_state(self, state_update: update_types.StateUpdate) -> None: else: pipette_id = state_update.pipette_tip_state.pipette_id - self._state.aspirated_volume_by_id[pipette_id] = None - self._state.aspirated_liquid_by_id[pipette_id] = None + self._state.pipette_contents_by_id[ + pipette_id + ] = fluid_stack.FluidStack() self._state.attached_tip_by_id[pipette_id] = None static_config = self._state.static_config_by_id.get(pipette_id) @@ -313,70 +309,42 @@ def _update_pipette_nozzle_map( state_update.pipette_nozzle_map.pipette_id ] = state_update.pipette_nozzle_map.nozzle_map - def _update_volumes( - self, action: Union[SucceedCommandAction, FailCommandAction] + def _update_volumes(self, state_update: update_types.StateUpdate) -> None: + if state_update.pipette_aspirated_fluid == update_types.NO_CHANGE: + return + if state_update.pipette_aspirated_fluid.type == "aspirated": + self._update_aspirated(state_update.pipette_aspirated_fluid) + elif state_update.pipette_aspirated_fluid.type == "ejected": + self._update_ejected(state_update.pipette_aspirated_fluid) + elif state_update.pipette_aspirated_fluid.type == "empty": + self._update_empty(state_update.pipette_aspirated_fluid) + elif state_update.pipette_aspirated_fluid.type == "unknown": + self._update_unknown(state_update.pipette_aspirated_fluid) + else: + LOG.error( + f"Unknown aspirated fluid update type {state_update.pipette_aspirated_fluid.type}" + ) + + def _update_aspirated( + self, update: update_types.PipetteAspiratedFluidUpdate ) -> None: - # todo(mm, 2024-10-10): Port these isinstance checks to StateUpdate. - # https://opentrons.atlassian.net/browse/EXEC-754 + self._fluid_stack_log_if_empty(update.pipette_id).add_fluid(update.fluid) - if isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, - (commands.AspirateResult, commands.AspirateInPlaceResult), - ): - pipette_id = action.command.params.pipetteId - previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 - previous_liquid = self._state.aspirated_liquid_by_id[pipette_id] or 0 - # PipetteHandler will have clamped action.command.result.volume for us, so - # next_volume should always be in bounds. - next_volume = previous_volume + action.command.result.volume - next_liquid = previous_liquid + action.command.result.volume - - self._state.aspirated_volume_by_id[pipette_id] = next_volume - self._state.aspirated_liquid_by_id[pipette_id] = next_liquid - - elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, - (commands.DispenseResult, commands.DispenseInPlaceResult), - ): - pipette_id = action.command.params.pipetteId - previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 - previous_liquid = self._state.aspirated_liquid_by_id[pipette_id] or 0 - # PipetteHandler will have clamped action.command.result.volume for us, so - # next_volume should always be in bounds. - next_volume = previous_volume - action.command.result.volume - next_liquid = previous_liquid - action.command.result.volume - self._state.aspirated_volume_by_id[pipette_id] = next_volume - self._state.aspirated_liquid_by_id[pipette_id] = next_liquid - - elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, - ( - commands.BlowOutResult, - commands.BlowOutInPlaceResult, - commands.unsafe.UnsafeBlowOutInPlaceResult, - ), - ): - pipette_id = action.command.params.pipetteId - self._state.aspirated_volume_by_id[pipette_id] = None - self._state.aspirated_liquid_by_id[pipette_id] = None + def _update_ejected(self, update: update_types.PipetteEjectedFluidUpdate) -> None: + self._fluid_stack_log_if_empty(update.pipette_id).remove_fluid(update.volume) - elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, commands.PrepareToAspirateResult - ): - pipette_id = action.command.params.pipetteId - self._state.aspirated_volume_by_id[pipette_id] = 0 - self._state.aspirated_liquid_by_id[pipette_id] = 0 - elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, commands.AirGapInPlaceResult - ): - # The point of the air_gap command as a separate thing from the aspirate command - # is that air gap updates the aspirated volume but _not_ the aspirated liquid, since - # it is an air gap. - pipette_id = action.command.params.pipetteId - previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 - self._state.aspirated_volume_by_id[pipette_id] = ( - previous_volume + action.command.result.volume - ) + def _update_empty(self, update: update_types.PipetteEmptyFluidUpdate) -> None: + self._state.pipette_contents_by_id[update.pipette_id] = fluid_stack.FluidStack() + + def _update_unknown(self, update: update_types.PipetteUnknownFluidUpdate) -> None: + self._state.pipette_contents_by_id[update.pipette_id] = None + + def _fluid_stack_log_if_empty(self, pipette_id: str) -> fluid_stack.FluidStack: + stack = self._state.pipette_contents_by_id[pipette_id] + if stack is None: + LOG.error("Pipette state tried to alter an unknown-contents pipette") + return fluid_stack.FluidStack() + return stack class PipetteView(HasState[PipetteState]): @@ -496,32 +464,45 @@ def get_aspirated_volume(self, pipette_id: str) -> Optional[float]: self.validate_tip_state(pipette_id, True) try: - return self._state.aspirated_volume_by_id[pipette_id] + stack = self._state.pipette_contents_by_id[pipette_id] + if stack is None: + return None + return stack.aspirated_volume() except KeyError as e: raise errors.PipetteNotLoadedError( f"Pipette {pipette_id} not found; unable to get current volume." ) from e - def get_aspirated_liquid_volume(self, pipette_id: str) -> Optional[float]: - """Get the amount of liquid inside a pipette by ID. + def get_liquid_dispensed_by_ejecting_volume( + self, pipette_id: str, volume: float + ) -> Optional[float]: + """Get the amount of liquid (not air) that will be dispensed if the pipette ejects a specified volume. + + For instance, if the pipette contains, in vertical order, + 10 ul air + 80 ul liquid + 5 ul air - Unlike get_aspirated_volume, this function will not take air volume (i.e., that - set by an airGap command) into account. This makes it the right function to call - to know how much liquid a dispense command will place into a well. + then dispensing 10ul would result in 5ul of liquid; dispensing 85 ul would result in 80ul liquid; dispensing + 95ul would result in 80ul liquid. Returns: - The volume of liquid the pipette has aspirated. + The volume of liquid that would be dispensed by the requested volume. None, after blow-out or when the plunger is in an unsafe position. Raises: - PipetteNOtLoadedError: pipette ID does not exist. + PipetteNotLoadedError: pipette ID does not exist. TipnotAttachedError: No tip is attached to the pipette. """ self.validate_tip_state(pipette_id, True) try: - return self._state.aspirated_liquid_by_id[pipette_id] + stack = self._state.pipette_contents_by_id[pipette_id] + if stack is None: + return None + return stack.liquid_part_of_dispense_volume(volume) + except KeyError as e: raise errors.PipetteNotLoadedError( f"Pipette {pipette_id} not found; unable to get current liquid volume." diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index 181d8820723..cf436e848f8 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -8,7 +8,12 @@ from opentrons.hardware_control.nozzle_manager import NozzleMap from opentrons.protocol_engine.resources import pipette_data_provider -from opentrons.protocol_engine.types import DeckPoint, LabwareLocation, TipGeometry +from opentrons.protocol_engine.types import ( + DeckPoint, + LabwareLocation, + TipGeometry, + AspiratedFluid, +) from opentrons.types import MountType from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons_shared_data.pipette.types import PipetteNameType @@ -205,6 +210,40 @@ class LiquidOperatedUpdate: volume_added: float | ClearType +@dataclasses.dataclass +class PipetteAspiratedFluidUpdate: + """Represents the pipette aspirating something. Might be air or liquid from a well.""" + + pipette_id: str + fluid: AspiratedFluid + type: typing.Literal["aspirated"] = "aspirated" + + +@dataclasses.dataclass +class PipetteEjectedFluidUpdate: + """Represents the pipette pushing something out. Might be air or liquid.""" + + pipette_id: str + volume: float + type: typing.Literal["ejected"] = "ejected" + + +@dataclasses.dataclass +class PipetteUnknownFluidUpdate: + """Represents the amount of fluid in the pipette becoming unknown.""" + + pipette_id: str + type: typing.Literal["unknown"] = "unknown" + + +@dataclasses.dataclass +class PipetteEmptyFluidUpdate: + """Sets the pipette to be valid and empty.""" + + pipette_id: str + type: typing.Literal["empty"] = "empty" + + @dataclasses.dataclass class StateUpdate: """Represents an update to perform on engine state.""" @@ -219,6 +258,10 @@ class StateUpdate: pipette_tip_state: PipetteTipStateUpdate | NoChangeType = NO_CHANGE + pipette_aspirated_fluid: PipetteAspiratedFluidUpdate | PipetteEjectedFluidUpdate | PipetteUnknownFluidUpdate | PipetteEmptyFluidUpdate | NoChangeType = ( + NO_CHANGE + ) + labware_location: LabwareLocationUpdate | NoChangeType = NO_CHANGE loaded_labware: LoadedLabwareUpdate | NoChangeType = NO_CHANGE @@ -406,3 +449,27 @@ def set_liquid_operated( well_name=well_name, volume_added=volume_added, ) + + def set_fluid_aspirated(self, pipette_id: str, fluid: AspiratedFluid) -> None: + """Update record of fluid held inside a pipette. See `PipetteAspiratedFluidUpdate`.""" + self.pipette_aspirated_fluid = PipetteAspiratedFluidUpdate( + type="aspirated", pipette_id=pipette_id, fluid=fluid + ) + + def set_fluid_ejected(self, pipette_id: str, volume: float) -> None: + """Update record of fluid held inside a pipette. See `PipetteEjectedFluidUpdate`.""" + self.pipette_aspirated_fluid = PipetteEjectedFluidUpdate( + type="ejected", pipette_id=pipette_id, volume=volume + ) + + def set_fluid_unknown(self, pipette_id: str) -> None: + """Update record fo fluid held inside a pipette. See `PipetteUnknownFluidUpdate`.""" + self.pipette_aspirated_fluid = PipetteUnknownFluidUpdate( + type="unknown", pipette_id=pipette_id + ) + + def set_fluid_empty(self, pipette_id: str) -> None: + """Update record fo fluid held inside a pipette. See `PipetteEmptyFluidUpdate`.""" + self.pipette_aspirated_fluid = PipetteEmptyFluidUpdate( + type="empty", pipette_id=pipette_id + ) diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index ea3a57945b2..780b02d2129 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -423,6 +423,21 @@ class TipGeometry: volume: float +class FluidKind(str, Enum): + """A kind of fluid that can be inside a pipette.""" + + LIQUID = "LIQUID" + AIR = "AIR" + + +@dataclass(frozen=True) +class AspiratedFluid: + """Fluid inside a pipette.""" + + kind: FluidKind + volume: float + + class MovementAxis(str, Enum): """Axis on which to issue a relative movement.""" diff --git a/api/tests/opentrons/protocol_engine/commands/test_air_gap_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_air_gap_in_place.py index a743355baf3..5d66a845dcc 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_air_gap_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_air_gap_in_place.py @@ -25,6 +25,8 @@ CurrentWell, CurrentPipetteLocation, CurrentAddressableArea, + AspiratedFluid, + FluidKind, ) from opentrons.protocol_engine.state import update_types @@ -123,13 +125,22 @@ async def test_air_gap_in_place_implementation( if isinstance(location, CurrentWell): assert result == SuccessData( public=AirGapInPlaceResult(volume=123), - private=None, - state_update=update_types.StateUpdate(), + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id-abc", + fluid=AspiratedFluid(kind=FluidKind.AIR, volume=123), + ) + ), ) else: assert result == SuccessData( public=AirGapInPlaceResult(volume=123), - private=None, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id-abc", + fluid=AspiratedFluid(kind=FluidKind.AIR, volume=123), + ) + ), ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index 026af05ead3..90845d2f64b 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -14,6 +14,8 @@ FlowRates, CurrentWell, TipGeometry, + AspiratedFluid, + FluidKind, ) from opentrons.protocol_engine.actions import ( SetPipetteMovementSpeedAction, @@ -30,6 +32,7 @@ from opentrons.protocol_engine.resources.pipette_data_provider import ( LoadedStaticPipetteData, ) +from opentrons.protocol_engine.state.fluid_stack import FluidStack from .command_fixtures import ( create_load_pipette_command, @@ -62,8 +65,7 @@ def test_sets_initial_state(subject: PipetteStore) -> None: assert result == PipetteState( pipettes_by_id={}, - aspirated_volume_by_id={}, - aspirated_liquid_by_id={}, + pipette_contents_by_id={}, current_location=None, current_deck_point=CurrentDeckPoint(mount=None, deck_point=None), attached_tip_by_id={}, @@ -241,8 +243,7 @@ def test_handles_load_pipette( pipetteName=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) - assert result.aspirated_volume_by_id["pipette-id"] is None - assert result.aspirated_liquid_by_id["pipette-id"] is None + assert result.pipette_contents_by_id["pipette-id"] is None assert result.movement_speed_by_id["pipette-id"] is None assert result.attached_tip_by_id["pipette-id"] is None @@ -272,7 +273,10 @@ def test_handles_pick_up_and_drop_tip(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="abc" + ), ), ) ) @@ -284,15 +288,17 @@ def test_handles_pick_up_and_drop_tip(subject: PipetteStore) -> None: pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="abc", tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="abc" + ), ), ) ) assert subject.state.attached_tip_by_id["abc"] == TipGeometry( volume=42, length=101, diameter=8.0 ) - assert subject.state.aspirated_volume_by_id["abc"] == 0 - assert subject.state.aspirated_liquid_by_id["abc"] == 0 + assert subject.state.pipette_contents_by_id["abc"] == FluidStack() subject.handle_action( SucceedCommandAction( @@ -300,13 +306,15 @@ def test_handles_pick_up_and_drop_tip(subject: PipetteStore) -> None: state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="abc", tip_geometry=None - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="abc" + ), ), ) ) assert subject.state.attached_tip_by_id["abc"] is None - assert subject.state.aspirated_volume_by_id["abc"] is None - assert subject.state.aspirated_liquid_by_id["abc"] is None + assert subject.state.pipette_contents_by_id["abc"] is None def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: @@ -334,7 +342,10 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), ), ) ) @@ -345,15 +356,17 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="xyz", tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="xyz" + ), ), ) ) assert subject.state.attached_tip_by_id["xyz"] == TipGeometry( volume=42, length=101, diameter=8.0 ) - assert subject.state.aspirated_volume_by_id["xyz"] == 0 - assert subject.state.aspirated_liquid_by_id["xyz"] == 0 + assert subject.state.pipette_contents_by_id["xyz"] == FluidStack() subject.handle_action( SucceedCommandAction( @@ -361,13 +374,15 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="xyz", tip_geometry=None - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), ), ) ) assert subject.state.attached_tip_by_id["xyz"] is None - assert subject.state.aspirated_volume_by_id["xyz"] is None - assert subject.state.aspirated_liquid_by_id["xyz"] is None + assert subject.state.pipette_contents_by_id["xyz"] is None def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: @@ -395,7 +410,10 @@ def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), ), ) ) @@ -406,15 +424,17 @@ def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="xyz", tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="xyz" + ), ), ) ) assert subject.state.attached_tip_by_id["xyz"] == TipGeometry( volume=42, length=101, diameter=8.0 ) - assert subject.state.aspirated_volume_by_id["xyz"] == 0 - assert subject.state.aspirated_liquid_by_id["xyz"] == 0 + assert subject.state.pipette_contents_by_id["xyz"] == FluidStack() subject.handle_action( SucceedCommandAction( @@ -422,13 +442,15 @@ def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: state_update=update_types.StateUpdate( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="xyz", tip_geometry=None - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), ), ) ) assert subject.state.attached_tip_by_id["xyz"] is None - assert subject.state.aspirated_volume_by_id["xyz"] is None - assert subject.state.aspirated_liquid_by_id["xyz"] is None + assert subject.state.pipette_contents_by_id["xyz"] is None @pytest.mark.parametrize( @@ -459,19 +481,34 @@ def test_aspirate_adds_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate_command, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="xyz", + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=42), ) ), ) ) - subject.handle_action(SucceedCommandAction(command=aspirate_command)) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 42 - assert subject.state.aspirated_liquid_by_id["pipette-id"] == 42 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack( + _fluid_stack=[AspiratedFluid(kind=FluidKind.LIQUID, volume=42)] + ) subject.handle_action(SucceedCommandAction(command=aspirate_command)) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 84 - assert subject.state.aspirated_liquid_by_id["pipette-id"] == 84 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack( + _fluid_stack=[AspiratedFluid(kind=FluidKind.LIQUID, volume=84)] + ) @pytest.mark.parametrize( @@ -494,6 +531,10 @@ def test_dispense_subtracts_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) + pick_up_tip_command = create_pick_up_tip_command( + pipette_id="pipette-id", tip_volume=47, tip_length=101, tip_diameter=8.0 + ) + aspirate_command = create_aspirate_command( pipette_id="pipette-id", volume=42, @@ -509,20 +550,37 @@ def test_dispense_subtracts_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="xyz" + ), + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=pick_up_tip_command, + state_update=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="pipette-id", + tip_geometry=TipGeometry(volume=47, length=101, diameter=8.0), + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="xyz" + ), ), ) ) subject.handle_action(SucceedCommandAction(command=aspirate_command)) subject.handle_action(SucceedCommandAction(command=dispense_command)) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 21 - assert subject.state.aspirated_liquid_by_id["pipette-id"] == 21 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack( + _fluid_stack=[AspiratedFluid(kind=FluidKind.LIQUID, volume=21)] + ) subject.handle_action(SucceedCommandAction(command=dispense_command)) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 0 - assert subject.state.aspirated_liquid_by_id["pipette-id"] == 0 + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack() @pytest.mark.parametrize( @@ -542,6 +600,10 @@ def test_blow_out_clears_volume( pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, ) + pick_up_tip_command = create_pick_up_tip_command( + pipette_id="pipette-id", tip_volume=47, tip_length=101, tip_diameter=8.0 + ) + aspirate_command = create_aspirate_command( pipette_id="pipette-id", volume=42, @@ -561,11 +623,43 @@ def test_blow_out_clears_volume( ), ) ) - subject.handle_action(SucceedCommandAction(command=aspirate_command)) - subject.handle_action(SucceedCommandAction(command=blow_out_command)) + subject.handle_action( + SucceedCommandAction( + command=pick_up_tip_command, + state_update=update_types.StateUpdate( + pipette_tip_state=update_types.PipetteTipStateUpdate( + pipette_id="pipette-id", + tip_geometry=TipGeometry(volume=47, length=101, diameter=8.0), + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ), + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=aspirate_command, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteAspiratedFluidUpdate( + pipette_id="pipette-id", + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=42), + ) + ), + ) + ) + subject.handle_action( + SucceedCommandAction( + command=blow_out_command, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ) + ), + ) + ) - assert subject.state.aspirated_volume_by_id["pipette-id"] is None - assert subject.state.aspirated_liquid_by_id["pipette-id"] is None + assert subject.state.pipette_contents_by_id["pipette-id"] is None def test_set_movement_speed(subject: PipetteStore) -> None: @@ -658,14 +752,30 @@ def test_add_pipette_config( @pytest.mark.parametrize( - "previous", + "previous_cmd,previous_state", [ - create_blow_out_command(pipette_id="pipette-id", flow_rate=1.0), - create_dispense_command(pipette_id="pipette-id", volume=10, flow_rate=1.0), + ( + create_blow_out_command(pipette_id="pipette-id", flow_rate=1.0), + update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ) + ), + ), + ( + create_dispense_command(pipette_id="pipette-id", volume=10, flow_rate=1.0), + update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEjectedFluidUpdate( + pipette_id="pipette-id", volume=10 + ) + ), + ), ], ) def test_prepare_to_aspirate_marks_pipette_ready( - subject: PipetteStore, previous: cmd.Command + subject: PipetteStore, + previous_cmd: cmd.Command, + previous_state: update_types.StateUpdate, ) -> None: """It should mark a pipette as ready to aspirate.""" load_pipette_command = create_load_pipette_command( @@ -685,7 +795,10 @@ def test_prepare_to_aspirate_marks_pipette_ready( pipette_name=PipetteNameType.P50_MULTI_FLEX, mount=MountType.LEFT, liquid_presence_detection=None, - ) + ), + pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( + pipette_id="pipette-id" + ), ), ) ) @@ -696,20 +809,29 @@ def test_prepare_to_aspirate_marks_pipette_ready( pipette_tip_state=update_types.PipetteTipStateUpdate( pipette_id="pipette-id", tip_geometry=TipGeometry(volume=42, length=101, diameter=8.0), - ) + ), + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="xyz" + ), ), ) ) subject.handle_action( - SucceedCommandAction( - command=previous, - ) + SucceedCommandAction(command=previous_cmd, state_update=previous_state) ) prepare_to_aspirate_command = create_prepare_to_aspirate_command( pipette_id="pipette-id" ) - subject.handle_action(SucceedCommandAction(command=prepare_to_aspirate_command)) - assert subject.state.aspirated_volume_by_id["pipette-id"] == 0.0 - assert subject.state.aspirated_liquid_by_id["pipette-id"] == 0.0 + subject.handle_action( + SucceedCommandAction( + command=prepare_to_aspirate_command, + state_update=update_types.StateUpdate( + pipette_aspirated_fluid=update_types.PipetteEmptyFluidUpdate( + pipette_id="pipette-id" + ) + ), + ) + ) + assert subject.state.pipette_contents_by_id["pipette-id"] == FluidStack() diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py index 7d530c37e36..455c17b8a91 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py @@ -1,8 +1,10 @@ """Tests for pipette state accessors in the protocol_engine state store.""" from collections import OrderedDict +from typing import cast, Dict, List, Optional, Tuple, NamedTuple import pytest -from typing import cast, Dict, List, Optional, Tuple, NamedTuple +from decoy import Decoy + from opentrons_shared_data.pipette.types import PipetteNameType from opentrons_shared_data.pipette import pipette_definition @@ -30,6 +32,7 @@ BoundingNozzlesOffsets, PipetteBoundingBoxOffsets, ) +from opentrons.protocol_engine.state import fluid_stack from opentrons.hardware_control.nozzle_manager import NozzleMap, NozzleConfigurationType from opentrons.protocol_engine.errors import TipNotAttachedError, PipetteNotLoadedError @@ -56,7 +59,6 @@ def get_pipette_view( pipettes_by_id: Optional[Dict[str, LoadedPipette]] = None, - aspirated_volume_by_id: Optional[Dict[str, Optional[float]]] = None, current_well: Optional[CurrentPipetteLocation] = None, current_deck_point: CurrentDeckPoint = CurrentDeckPoint( mount=None, deck_point=None @@ -67,13 +69,14 @@ def get_pipette_view( flow_rates_by_id: Optional[Dict[str, FlowRates]] = None, nozzle_layout_by_id: Optional[Dict[str, NozzleMap]] = None, liquid_presence_detection_by_id: Optional[Dict[str, bool]] = None, - aspirated_liquid_by_id: Optional[Dict[str, Optional[float]]] = None, + pipette_contents_by_id: Optional[ + Dict[str, Optional[fluid_stack.FluidStack]] + ] = None, ) -> PipetteView: """Get a pipette view test subject with the specified state.""" state = PipetteState( pipettes_by_id=pipettes_by_id or {}, - aspirated_volume_by_id=aspirated_volume_by_id or {}, - aspirated_liquid_by_id=aspirated_liquid_by_id or {}, + pipette_contents_by_id=pipette_contents_by_id or {}, current_location=current_well, current_deck_point=current_deck_point, attached_tip_by_id=attached_tip_by_id or {}, @@ -236,11 +239,12 @@ def test_get_hardware_pipette_raises_with_name_mismatch() -> None: ) -def test_get_aspirated_volume() -> None: +def test_get_aspirated_volume(decoy: Decoy) -> None: """It should get the aspirate volume for a pipette.""" + stack = decoy.mock(cls=fluid_stack.FluidStack) subject = get_pipette_view( - aspirated_volume_by_id={ - "pipette-id": 42, + pipette_contents_by_id={ + "pipette-id": stack, "pipette-id-none": None, "pipette-id-no-tip": None, }, @@ -250,6 +254,7 @@ def test_get_aspirated_volume() -> None: "pipette-id-no-tip": None, }, ) + decoy.when(stack.aspirated_volume()).then_return(42) assert subject.get_aspirated_volume("pipette-id") == 42 assert subject.get_aspirated_volume("pipette-id-none") is None @@ -328,9 +333,10 @@ def test_get_pipette_working_volume_raises_if_tip_volume_is_none( def test_get_pipette_available_volume( - supported_tip_fixture: pipette_definition.SupportedTipsDefinition, + supported_tip_fixture: pipette_definition.SupportedTipsDefinition, decoy: Decoy ) -> None: """It should get the available volume for a pipette.""" + stack = decoy.mock(cls=fluid_stack.FluidStack) subject = get_pipette_view( attached_tip_by_id={ "pipette-id": TipGeometry( @@ -339,7 +345,7 @@ def test_get_pipette_available_volume( volume=100, ), }, - aspirated_volume_by_id={"pipette-id": 58}, + pipette_contents_by_id={"pipette-id": stack}, static_config_by_id={ "pipette-id": StaticPipetteConfig( min_volume=1,