From 380f9e2599c96f38be49a7c3e07af1b642a8a3bd Mon Sep 17 00:00:00 2001 From: jbleon95 Date: Wed, 4 Dec 2024 10:57:42 -0500 Subject: [PATCH 1/7] getNextTip implementation --- .../protocol_engine/commands/__init__.py | 14 +++ .../commands/command_unions.py | 13 ++ .../protocol_engine/commands/get_next_tip.py | 111 ++++++++++++++++++ shared-data/command/schemas/11.json | 61 ++++++++++ 4 files changed, 199 insertions(+) create mode 100644 api/src/opentrons/protocol_engine/commands/get_next_tip.py diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index 8e1e91bec50..6dfd382b319 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -332,6 +332,14 @@ VerifyTipPresenceCommandType, ) +from .get_next_tip import ( + GetNextTip, + GetNextTipCreate, + GetNextTipParams, + GetNextTipResult, + GetNextTipCommandType, +) + from .liquid_probe import ( LiquidProbe, LiquidProbeParams, @@ -595,6 +603,12 @@ "VerifyTipPresenceParams", "VerifyTipPresenceResult", "VerifyTipPresenceCommandType", + # get next tip command bundle + "GetNextTip", + "GetNextTipCreate", + "GetNextTipParams", + "GetNextTipResult", + "GetNextTipCommandType", # liquid probe command bundle "LiquidProbe", "LiquidProbeParams", diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 9c548fa8045..16663bf6df6 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -323,6 +323,14 @@ GetTipPresenceCommandType, ) +from .get_next_tip import ( + GetNextTip, + GetNextTipCreate, + GetNextTipParams, + GetNextTipResult, + GetNextTipCommandType, +) + from .liquid_probe import ( LiquidProbe, LiquidProbeParams, @@ -375,6 +383,7 @@ SetStatusBar, VerifyTipPresence, GetTipPresence, + GetNextTip, LiquidProbe, TryLiquidProbe, heater_shaker.WaitForTemperature, @@ -460,6 +469,7 @@ SetStatusBarParams, VerifyTipPresenceParams, GetTipPresenceParams, + GetNextTipParams, LiquidProbeParams, TryLiquidProbeParams, heater_shaker.WaitForTemperatureParams, @@ -543,6 +553,7 @@ SetStatusBarCommandType, VerifyTipPresenceCommandType, GetTipPresenceCommandType, + GetNextTipCommandType, LiquidProbeCommandType, TryLiquidProbeCommandType, heater_shaker.WaitForTemperatureCommandType, @@ -627,6 +638,7 @@ SetStatusBarCreate, VerifyTipPresenceCreate, GetTipPresenceCreate, + GetNextTipCreate, LiquidProbeCreate, TryLiquidProbeCreate, heater_shaker.WaitForTemperatureCreate, @@ -712,6 +724,7 @@ SetStatusBarResult, VerifyTipPresenceResult, GetTipPresenceResult, + GetNextTipResult, LiquidProbeResult, TryLiquidProbeResult, heater_shaker.WaitForTemperatureResult, diff --git a/api/src/opentrons/protocol_engine/commands/get_next_tip.py b/api/src/opentrons/protocol_engine/commands/get_next_tip.py new file mode 100644 index 00000000000..9669a064cfe --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/get_next_tip.py @@ -0,0 +1,111 @@ +"""Get next tip command request, result, and implementation models.""" + +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import TYPE_CHECKING, Optional, Type, List, Literal + + +from ..errors import ErrorOccurrence +from .pipetting_common import PipetteIdMixin + +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + SuccessData, +) + +if TYPE_CHECKING: + from ..state.state import StateView + + +GetNextTipCommandType = Literal["getNextTip"] + + +class GetNextTipParams(PipetteIdMixin): + """Payload needed to resolve the next available tip.""" + + labwareIds: List[str] = Field( + ..., # TODO order matters + description="Labware ID(s) of tip racks to resolve next available tip(s) from.", + ) + startingWellName: Optional[str] = Field( + "A1", description="Name of starting tip rack 'well'." + ) + + +class GetNextTipResult(BaseModel): + """Result data from the execution of a GetNextTip.""" + + labwareId: Optional[str] = Field( + ..., description="Labware ID where next available tip is, if any." + ) + wellName: Optional[str] = Field( + ..., description="Well name of next available tip, if any." + ) + + +class GetNextTipImplementation( + AbstractCommandImpl[GetNextTipParams, SuccessData[GetNextTipResult]] +): + """Get next tip command implementation.""" + + def __init__( + self, + state_view: StateView, + **kwargs: object, + ) -> None: + self._state_view = state_view + + async def execute(self, params: GetNextTipParams) -> SuccessData[GetNextTipResult]: + """Get the next available tip for the requested pipette.""" + pipette_id = params.pipetteId + starting_tip_name = params.startingWellName + + num_tips = self._state_view.tips.get_pipette_active_channels(pipette_id) + active_tips = self._state_view.tips.get_pipette_active_channels(pipette_id) + nozzle_map = ( + self._state_view.tips.get_pipette_nozzle_map(pipette_id) + if num_tips != active_tips + else None + ) + + labware_id: Optional[str] + for labware_id in params.labwareIds: + well_name = self._state_view.tips.get_next_tip( + labware_id=labware_id, + num_tips=num_tips, + starting_tip_name=starting_tip_name, + nozzle_map=nozzle_map, + ) + if well_name is not None: + break + else: + labware_id = None + well_name = None + + return SuccessData( + public=GetNextTipResult( + labwareId=labware_id, + wellName=well_name, + ) + ) + + +class GetNextTip(BaseCommand[GetNextTipParams, GetNextTipResult, ErrorOccurrence]): + """Get next tip command model.""" + + commandType: GetNextTipCommandType = "getNextTip" + params: GetNextTipParams + result: Optional[GetNextTipResult] + + _ImplementationCls: Type[GetNextTipImplementation] = GetNextTipImplementation + + +class GetNextTipCreate(BaseCommandCreate[GetNextTipParams]): + """Get next tip command creation request model.""" + + commandType: GetNextTipCommandType = "getNextTip" + params: GetNextTipParams + + _CommandCls: Type[GetNextTip] = GetNextTip diff --git a/shared-data/command/schemas/11.json b/shared-data/command/schemas/11.json index 38a39ea7902..41321b238be 100644 --- a/shared-data/command/schemas/11.json +++ b/shared-data/command/schemas/11.json @@ -42,6 +42,7 @@ "setStatusBar": "#/definitions/SetStatusBarCreate", "verifyTipPresence": "#/definitions/VerifyTipPresenceCreate", "getTipPresence": "#/definitions/GetTipPresenceCreate", + "getNextTip": "#/definitions/GetNextTipCreate", "liquidProbe": "#/definitions/LiquidProbeCreate", "tryLiquidProbe": "#/definitions/TryLiquidProbeCreate", "heaterShaker/waitForTemperature": "#/definitions/opentrons__protocol_engine__commands__heater_shaker__wait_for_temperature__WaitForTemperatureCreate", @@ -199,6 +200,9 @@ { "$ref": "#/definitions/GetTipPresenceCreate" }, + { + "$ref": "#/definitions/GetNextTipCreate" + }, { "$ref": "#/definitions/LiquidProbeCreate" }, @@ -4018,6 +4022,63 @@ }, "required": ["params"] }, + "GetNextTipParams": { + "title": "GetNextTipParams", + "description": "Payload needed to resolve the next available tip.", + "type": "object", + "properties": { + "pipetteId": { + "title": "Pipetteid", + "description": "Identifier of pipette to use for liquid handling.", + "type": "string" + }, + "labwareIds": { + "title": "Labwareids", + "description": "Labware ID(s) of tip racks to resolve next available tip(s) from.", + "type": "array", + "items": { + "type": "string" + } + }, + "startingWellName": { + "title": "Startingwellname", + "description": "Name of starting tip rack 'well'.", + "default": "A1", + "type": "string" + } + }, + "required": ["pipetteId", "labwareIds"] + }, + "GetNextTipCreate": { + "title": "GetNextTipCreate", + "description": "Get next tip command creation request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "getNextTip", + "enum": ["getNextTip"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/GetNextTipParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] + }, "LiquidProbeParams": { "title": "LiquidProbeParams", "description": "Parameters required for a `liquidProbe` command.", From 7ce4aeaff657a69e6fb4f6536d0c7bf1caf3dbea Mon Sep 17 00:00:00 2001 From: jbleon95 Date: Wed, 4 Dec 2024 12:16:52 -0500 Subject: [PATCH 2/7] unit test for getNextTip impl --- .../protocol_engine/commands/get_next_tip.py | 4 +- .../commands/test_get_next_tip.py | 91 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py diff --git a/api/src/opentrons/protocol_engine/commands/get_next_tip.py b/api/src/opentrons/protocol_engine/commands/get_next_tip.py index 9669a064cfe..a5193ee9e9a 100644 --- a/api/src/opentrons/protocol_engine/commands/get_next_tip.py +++ b/api/src/opentrons/protocol_engine/commands/get_next_tip.py @@ -63,10 +63,10 @@ async def execute(self, params: GetNextTipParams) -> SuccessData[GetNextTipResul starting_tip_name = params.startingWellName num_tips = self._state_view.tips.get_pipette_active_channels(pipette_id) - active_tips = self._state_view.tips.get_pipette_active_channels(pipette_id) + total_tips = self._state_view.tips.get_pipette_channels(pipette_id) nozzle_map = ( self._state_view.tips.get_pipette_nozzle_map(pipette_id) - if num_tips != active_tips + if num_tips != total_tips else None ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py b/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py new file mode 100644 index 00000000000..5a5c7489535 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py @@ -0,0 +1,91 @@ +"""Test get next tip in place commands.""" +from decoy import Decoy + +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.commands.get_next_tip import ( + GetNextTipParams, + GetNextTipResult, + GetNextTipImplementation, +) + +from opentrons.hardware_control.nozzle_manager import NozzleMap +from opentrons.protocol_engine import StateView + + +async def test_get_next_tip_implementation_full( + decoy: Decoy, + state_view: StateView, +) -> None: + """A GetNextTip command should have an execution implementation.""" + subject = GetNextTipImplementation(state_view=state_view) + params = GetNextTipParams( + pipetteId="abc", labwareIds=["123", "456"], startingWellName="xyz" + ) + + decoy.when(state_view.tips.get_pipette_active_channels("abc")).then_return(42) + decoy.when(state_view.tips.get_pipette_channels("abc")).then_return(42) + + decoy.when( + state_view.tips.get_next_tip( + labware_id="456", num_tips=42, starting_tip_name="xyz", nozzle_map=None + ) + ).then_return("foo") + + result = await subject.execute(params) + + assert result == SuccessData( + public=GetNextTipResult(labwareId="456", wellName="foo"), + ) + + +async def test_get_next_tip_implementation_partial( + decoy: Decoy, + state_view: StateView, +) -> None: + """A GetNextTip command should have an execution implementation.""" + subject = GetNextTipImplementation(state_view=state_view) + params = GetNextTipParams( + pipetteId="abc", labwareIds=["123", "456"], startingWellName="xyz" + ) + mock_nozzle_map = decoy.mock(cls=NozzleMap) + + decoy.when(state_view.tips.get_pipette_active_channels("abc")).then_return(24) + decoy.when(state_view.tips.get_pipette_channels("abc")).then_return(42) + decoy.when(state_view.tips.get_pipette_nozzle_map("abc")).then_return( + mock_nozzle_map + ) + + decoy.when( + state_view.tips.get_next_tip( + labware_id="456", + num_tips=24, + starting_tip_name="xyz", + nozzle_map=mock_nozzle_map, + ) + ).then_return("foo") + + result = await subject.execute(params) + + assert result == SuccessData( + public=GetNextTipResult(labwareId="456", wellName="foo"), + ) + + +async def test_get_next_tip_implementation_no_tips( + decoy: Decoy, + state_view: StateView, +) -> None: + """A GetNextTip command should have an execution implementation.""" + subject = GetNextTipImplementation(state_view=state_view) + params = GetNextTipParams( + pipetteId="abc", labwareIds=["123", "456"], startingWellName="xyz" + ) + + decoy.when(state_view.tips.get_pipette_active_channels("abc")).then_return(42) + decoy.when(state_view.tips.get_pipette_channels("abc")).then_return(42) + + result = await subject.execute(params) + + assert result == SuccessData( + public=GetNextTipResult(labwareId=None, wellName=None), + ) From ebcf65f817319dfe8e7136ed1ff6917f0a79a40e Mon Sep 17 00:00:00 2001 From: jbleon95 Date: Wed, 4 Dec 2024 14:02:49 -0500 Subject: [PATCH 3/7] use new class to hold next tip info for result --- .../protocol_engine/commands/get_next_tip.py | 20 +++++++------------ api/src/opentrons/protocol_engine/types.py | 12 +++++++++++ .../commands/test_get_next_tip.py | 13 ++++++++---- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/get_next_tip.py b/api/src/opentrons/protocol_engine/commands/get_next_tip.py index a5193ee9e9a..eb8618b66f8 100644 --- a/api/src/opentrons/protocol_engine/commands/get_next_tip.py +++ b/api/src/opentrons/protocol_engine/commands/get_next_tip.py @@ -6,6 +6,7 @@ from ..errors import ErrorOccurrence +from ..types import NextTipInfo from .pipetting_common import PipetteIdMixin from .command import ( @@ -37,11 +38,9 @@ class GetNextTipParams(PipetteIdMixin): class GetNextTipResult(BaseModel): """Result data from the execution of a GetNextTip.""" - labwareId: Optional[str] = Field( - ..., description="Labware ID where next available tip is, if any." - ) - wellName: Optional[str] = Field( - ..., description="Well name of next available tip, if any." + nextTipInfo: Optional[NextTipInfo] = Field( + ..., + description="Labware ID and well name of next available tip for a pipette, if any.", ) @@ -79,17 +78,12 @@ async def execute(self, params: GetNextTipParams) -> SuccessData[GetNextTipResul nozzle_map=nozzle_map, ) if well_name is not None: + next_tip = NextTipInfo(labwareId=labware_id, wellName=well_name) break else: - labware_id = None - well_name = None + next_tip = None - return SuccessData( - public=GetNextTipResult( - labwareId=labware_id, - wellName=well_name, - ) - ) + return SuccessData(public=GetNextTipResult(nextTipInfo=next_tip)) class GetNextTip(BaseCommand[GetNextTipParams, GetNextTipResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 2a0bbf78c28..949146f1e1a 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -1115,6 +1115,18 @@ def from_hw_state(cls, state: HwTipStateType) -> "TipPresenceStatus": }[state] +class NextTipInfo(BaseModel): + """Next available tip labware and well name data.""" + + labwareId: str = Field( + ..., + description="The labware ID of the tip rack where the next available tip(s) are located.", + ) + wellName: str = Field( + ..., description="The (starting) well name of the next available tip(s)." + ) + + # TODO (spp, 2024-04-02): move all RTP types to runner class RTPBase(BaseModel): """Parameters defined in a protocol.""" diff --git a/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py b/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py index 5a5c7489535..68dc5924996 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py @@ -1,6 +1,8 @@ """Test get next tip in place commands.""" from decoy import Decoy +from opentrons.protocol_engine import StateView +from opentrons.protocol_engine.types import NextTipInfo from opentrons.protocol_engine.commands.command import SuccessData from opentrons.protocol_engine.commands.get_next_tip import ( GetNextTipParams, @@ -9,7 +11,6 @@ ) from opentrons.hardware_control.nozzle_manager import NozzleMap -from opentrons.protocol_engine import StateView async def test_get_next_tip_implementation_full( @@ -34,7 +35,9 @@ async def test_get_next_tip_implementation_full( result = await subject.execute(params) assert result == SuccessData( - public=GetNextTipResult(labwareId="456", wellName="foo"), + public=GetNextTipResult( + nextTipInfo=NextTipInfo(labwareId="456", wellName="foo") + ), ) @@ -67,7 +70,9 @@ async def test_get_next_tip_implementation_partial( result = await subject.execute(params) assert result == SuccessData( - public=GetNextTipResult(labwareId="456", wellName="foo"), + public=GetNextTipResult( + nextTipInfo=NextTipInfo(labwareId="456", wellName="foo") + ), ) @@ -87,5 +92,5 @@ async def test_get_next_tip_implementation_no_tips( result = await subject.execute(params) assert result == SuccessData( - public=GetNextTipResult(labwareId=None, wellName=None), + public=GetNextTipResult(nextTipInfo=None), ) From 7fe084daab625ceae4a38e271676640ac62b172e Mon Sep 17 00:00:00 2001 From: jbleon95 Date: Wed, 4 Dec 2024 14:16:02 -0500 Subject: [PATCH 4/7] address todo comment --- api/src/opentrons/protocol_engine/commands/get_next_tip.py | 5 +++-- shared-data/command/schemas/11.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/get_next_tip.py b/api/src/opentrons/protocol_engine/commands/get_next_tip.py index eb8618b66f8..1df2acde77e 100644 --- a/api/src/opentrons/protocol_engine/commands/get_next_tip.py +++ b/api/src/opentrons/protocol_engine/commands/get_next_tip.py @@ -27,8 +27,9 @@ class GetNextTipParams(PipetteIdMixin): """Payload needed to resolve the next available tip.""" labwareIds: List[str] = Field( - ..., # TODO order matters - description="Labware ID(s) of tip racks to resolve next available tip(s) from.", + ..., + description="Labware ID(s) of tip racks to resolve next available tip(s) from." + " Labware IDs will be resolved sequentially", ) startingWellName: Optional[str] = Field( "A1", description="Name of starting tip rack 'well'." diff --git a/shared-data/command/schemas/11.json b/shared-data/command/schemas/11.json index 41321b238be..eca73a0fca8 100644 --- a/shared-data/command/schemas/11.json +++ b/shared-data/command/schemas/11.json @@ -4034,7 +4034,7 @@ }, "labwareIds": { "title": "Labwareids", - "description": "Labware ID(s) of tip racks to resolve next available tip(s) from.", + "description": "Labware ID(s) of tip racks to resolve next available tip(s) from. Labware IDs will be resolved sequentially", "type": "array", "items": { "type": "string" From 9f790ced975237bac47aba0d77a7bd0916cf6c40 Mon Sep 17 00:00:00 2001 From: jbleon95 Date: Fri, 6 Dec 2024 15:56:18 -0500 Subject: [PATCH 5/7] implementation fix and return reason if no tip could be found --- .../protocol_engine/commands/get_next_tip.py | 53 ++++++++---- api/src/opentrons/protocol_engine/types.py | 21 ++++- .../commands/test_get_next_tip.py | 82 +++++++++++++++---- 3 files changed, 119 insertions(+), 37 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/get_next_tip.py b/api/src/opentrons/protocol_engine/commands/get_next_tip.py index 1df2acde77e..d25999de56e 100644 --- a/api/src/opentrons/protocol_engine/commands/get_next_tip.py +++ b/api/src/opentrons/protocol_engine/commands/get_next_tip.py @@ -2,11 +2,12 @@ from __future__ import annotations from pydantic import BaseModel, Field -from typing import TYPE_CHECKING, Optional, Type, List, Literal +from typing import TYPE_CHECKING, Optional, Type, List, Literal, Union +from opentrons.types import NozzleConfigurationType from ..errors import ErrorOccurrence -from ..types import NextTipInfo +from ..types import NextTipInfo, NoTipAvailable, NoTipReason from .pipetting_common import PipetteIdMixin from .command import ( @@ -28,20 +29,23 @@ class GetNextTipParams(PipetteIdMixin): labwareIds: List[str] = Field( ..., - description="Labware ID(s) of tip racks to resolve next available tip(s) from." + description="Labware ID(s) of tip racks to resolve next available tip(s) from" " Labware IDs will be resolved sequentially", ) - startingWellName: Optional[str] = Field( - "A1", description="Name of starting tip rack 'well'." + startingTipWell: Optional[str] = Field( + None, + description="Name of starting tip rack 'well'." + " This only applies to the first tip rack in the list provided in labwareIDs", ) class GetNextTipResult(BaseModel): """Result data from the execution of a GetNextTip.""" - nextTipInfo: Optional[NextTipInfo] = Field( + nextTipInfo: Union[NextTipInfo, NoTipAvailable] = Field( ..., - description="Labware ID and well name of next available tip for a pipette, if any.", + description="Labware ID and well name of next available tip for a pipette," + " or information why no tip could be resolved.", ) @@ -60,17 +64,25 @@ def __init__( async def execute(self, params: GetNextTipParams) -> SuccessData[GetNextTipResult]: """Get the next available tip for the requested pipette.""" pipette_id = params.pipetteId - starting_tip_name = params.startingWellName + starting_tip_name = params.startingTipWell num_tips = self._state_view.tips.get_pipette_active_channels(pipette_id) - total_tips = self._state_view.tips.get_pipette_channels(pipette_id) - nozzle_map = ( - self._state_view.tips.get_pipette_nozzle_map(pipette_id) - if num_tips != total_tips - else None - ) - - labware_id: Optional[str] + nozzle_map = self._state_view.tips.get_pipette_nozzle_map(pipette_id) + + if ( + starting_tip_name is not None + and nozzle_map.configuration != NozzleConfigurationType.FULL + ): + return SuccessData( + public=GetNextTipResult( + nextTipInfo=NoTipAvailable( + noTipReason=NoTipReason.STARTING_TIP_WITH_PARTIAL, + message="Cannot automatically resolve next tip with starting tip and partial tip configuration.", + ) + ) + ) + + next_tip: Union[NextTipInfo, NoTipAvailable] for labware_id in params.labwareIds: well_name = self._state_view.tips.get_next_tip( labware_id=labware_id, @@ -79,10 +91,15 @@ async def execute(self, params: GetNextTipParams) -> SuccessData[GetNextTipResul nozzle_map=nozzle_map, ) if well_name is not None: - next_tip = NextTipInfo(labwareId=labware_id, wellName=well_name) + next_tip = NextTipInfo(labwareId=labware_id, tipOriginWell=well_name) break + # After the first tip rack is exhausted, starting tip no longer applies + starting_tip_name = None else: - next_tip = None + next_tip = NoTipAvailable( + noTipReason=NoTipReason.NO_AVAILABLE_TIPS, + message="No available tips for given pipette, nozzle configuration and provided tip racks.", + ) return SuccessData(public=GetNextTipResult(nextTipInfo=next_tip)) diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 949146f1e1a..699ce632169 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -1122,11 +1122,30 @@ class NextTipInfo(BaseModel): ..., description="The labware ID of the tip rack where the next available tip(s) are located.", ) - wellName: str = Field( + tipOriginWell: str = Field( ..., description="The (starting) well name of the next available tip(s)." ) +class NoTipReason(Enum): + """The cause of no tip being available for a pipette and tip rack(s).""" + + NO_AVAILABLE_TIPS = "noAvailableTips" + STARTING_TIP_WITH_PARTIAL = "startingTipWithPartial" + INCOMPATIBLE_CONFIGURATION = "incompatibleConfiguration" + + +class NoTipAvailable(BaseModel): + """No available next tip data.""" + + noTipReason: NoTipReason = Field( + ..., description="The reason why no next available tip could be provided." + ) + message: Optional[str] = Field( + None, description="Optional message explaining why a tip wasn't available." + ) + + # TODO (spp, 2024-04-02): move all RTP types to runner class RTPBase(BaseModel): """Parameters defined in a protocol.""" diff --git a/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py b/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py index 68dc5924996..b64b38064ed 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py @@ -1,8 +1,9 @@ """Test get next tip in place commands.""" from decoy import Decoy +from opentrons.types import NozzleConfigurationType from opentrons.protocol_engine import StateView -from opentrons.protocol_engine.types import NextTipInfo +from opentrons.protocol_engine.types import NextTipInfo, NoTipAvailable, NoTipReason from opentrons.protocol_engine.commands.command import SuccessData from opentrons.protocol_engine.commands.get_next_tip import ( GetNextTipParams, @@ -13,22 +14,29 @@ from opentrons.hardware_control.nozzle_manager import NozzleMap -async def test_get_next_tip_implementation_full( +async def test_get_next_tip_implementation( decoy: Decoy, state_view: StateView, ) -> None: """A GetNextTip command should have an execution implementation.""" subject = GetNextTipImplementation(state_view=state_view) params = GetNextTipParams( - pipetteId="abc", labwareIds=["123", "456"], startingWellName="xyz" + pipetteId="abc", labwareIds=["123"], startingTipWell="xyz" ) + mock_nozzle_map = decoy.mock(cls=NozzleMap) decoy.when(state_view.tips.get_pipette_active_channels("abc")).then_return(42) - decoy.when(state_view.tips.get_pipette_channels("abc")).then_return(42) + decoy.when(state_view.tips.get_pipette_nozzle_map("abc")).then_return( + mock_nozzle_map + ) + decoy.when(mock_nozzle_map.configuration).then_return(NozzleConfigurationType.FULL) decoy.when( state_view.tips.get_next_tip( - labware_id="456", num_tips=42, starting_tip_name="xyz", nozzle_map=None + labware_id="123", + num_tips=42, + starting_tip_name="xyz", + nozzle_map=mock_nozzle_map, ) ).then_return("foo") @@ -36,33 +44,33 @@ async def test_get_next_tip_implementation_full( assert result == SuccessData( public=GetNextTipResult( - nextTipInfo=NextTipInfo(labwareId="456", wellName="foo") + nextTipInfo=NextTipInfo(labwareId="123", tipOriginWell="foo") ), ) -async def test_get_next_tip_implementation_partial( +async def test_get_next_tip_implementation_multiple_tip_racks( decoy: Decoy, state_view: StateView, ) -> None: - """A GetNextTip command should have an execution implementation.""" + """A GetNextTip command with multiple tip racks should not apply starting tip to the following ones.""" subject = GetNextTipImplementation(state_view=state_view) params = GetNextTipParams( - pipetteId="abc", labwareIds=["123", "456"], startingWellName="xyz" + pipetteId="abc", labwareIds=["123", "456"], startingTipWell="xyz" ) mock_nozzle_map = decoy.mock(cls=NozzleMap) - decoy.when(state_view.tips.get_pipette_active_channels("abc")).then_return(24) - decoy.when(state_view.tips.get_pipette_channels("abc")).then_return(42) + decoy.when(state_view.tips.get_pipette_active_channels("abc")).then_return(42) decoy.when(state_view.tips.get_pipette_nozzle_map("abc")).then_return( mock_nozzle_map ) + decoy.when(mock_nozzle_map.configuration).then_return(NozzleConfigurationType.FULL) decoy.when( state_view.tips.get_next_tip( labware_id="456", - num_tips=24, - starting_tip_name="xyz", + num_tips=42, + starting_tip_name=None, nozzle_map=mock_nozzle_map, ) ).then_return("foo") @@ -71,7 +79,7 @@ async def test_get_next_tip_implementation_partial( assert result == SuccessData( public=GetNextTipResult( - nextTipInfo=NextTipInfo(labwareId="456", wellName="foo") + nextTipInfo=NextTipInfo(labwareId="456", tipOriginWell="foo") ), ) @@ -80,17 +88,55 @@ async def test_get_next_tip_implementation_no_tips( decoy: Decoy, state_view: StateView, ) -> None: - """A GetNextTip command should have an execution implementation.""" + """A GetNextTip command should return with NoTipAvailable if there are no available tips.""" subject = GetNextTipImplementation(state_view=state_view) params = GetNextTipParams( - pipetteId="abc", labwareIds=["123", "456"], startingWellName="xyz" + pipetteId="abc", labwareIds=["123", "456"], startingTipWell="xyz" ) + mock_nozzle_map = decoy.mock(cls=NozzleMap) decoy.when(state_view.tips.get_pipette_active_channels("abc")).then_return(42) - decoy.when(state_view.tips.get_pipette_channels("abc")).then_return(42) + decoy.when(state_view.tips.get_pipette_nozzle_map("abc")).then_return( + mock_nozzle_map + ) + decoy.when(mock_nozzle_map.configuration).then_return(NozzleConfigurationType.FULL) result = await subject.execute(params) assert result == SuccessData( - public=GetNextTipResult(nextTipInfo=None), + public=GetNextTipResult( + nextTipInfo=NoTipAvailable( + noTipReason=NoTipReason.NO_AVAILABLE_TIPS, + message="No available tips for given pipette, nozzle configuration and provided tip racks.", + ) + ), + ) + + +async def test_get_next_tip_implementation_partial_with_starting_tip( + decoy: Decoy, + state_view: StateView, +) -> None: + """A GetNextTip command should return with NoTipAvailable if there's a starting tip and a partial config.""" + subject = GetNextTipImplementation(state_view=state_view) + params = GetNextTipParams( + pipetteId="abc", labwareIds=["123", "456"], startingTipWell="xyz" + ) + mock_nozzle_map = decoy.mock(cls=NozzleMap) + + decoy.when(state_view.tips.get_pipette_active_channels("abc")).then_return(42) + decoy.when(state_view.tips.get_pipette_nozzle_map("abc")).then_return( + mock_nozzle_map + ) + decoy.when(mock_nozzle_map.configuration).then_return(NozzleConfigurationType.ROW) + + result = await subject.execute(params) + + assert result == SuccessData( + public=GetNextTipResult( + nextTipInfo=NoTipAvailable( + noTipReason=NoTipReason.STARTING_TIP_WITH_PARTIAL, + message="Cannot automatically resolve next tip with starting tip and partial tip configuration.", + ) + ), ) From 2cac65213016bf43b9e32fb7b462b58f04bcc78e Mon Sep 17 00:00:00 2001 From: jbleon95 Date: Fri, 6 Dec 2024 16:18:47 -0500 Subject: [PATCH 6/7] schema fix --- shared-data/command/schemas/11.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/shared-data/command/schemas/11.json b/shared-data/command/schemas/11.json index eca73a0fca8..70dfa0217fa 100644 --- a/shared-data/command/schemas/11.json +++ b/shared-data/command/schemas/11.json @@ -4034,16 +4034,15 @@ }, "labwareIds": { "title": "Labwareids", - "description": "Labware ID(s) of tip racks to resolve next available tip(s) from. Labware IDs will be resolved sequentially", + "description": "Labware ID(s) of tip racks to resolve next available tip(s) from Labware IDs will be resolved sequentially", "type": "array", "items": { "type": "string" } }, - "startingWellName": { - "title": "Startingwellname", - "description": "Name of starting tip rack 'well'.", - "default": "A1", + "startingTipWell": { + "title": "Startingtipwell", + "description": "Name of starting tip rack 'well'. This only applies to the first tip rack in the list provided in labwareIDs", "type": "string" } }, From 544c62856ae572b5abcd5607a3690cec7c5abacc Mon Sep 17 00:00:00 2001 From: jbleon95 Date: Mon, 9 Dec 2024 10:51:19 -0500 Subject: [PATCH 7/7] rename property and comment per review --- api/src/opentrons/protocol_engine/commands/get_next_tip.py | 6 +++++- api/src/opentrons/protocol_engine/types.py | 2 +- .../opentrons/protocol_engine/commands/test_get_next_tip.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/get_next_tip.py b/api/src/opentrons/protocol_engine/commands/get_next_tip.py index d25999de56e..7ff10681bfb 100644 --- a/api/src/opentrons/protocol_engine/commands/get_next_tip.py +++ b/api/src/opentrons/protocol_engine/commands/get_next_tip.py @@ -73,6 +73,10 @@ async def execute(self, params: GetNextTipParams) -> SuccessData[GetNextTipResul starting_tip_name is not None and nozzle_map.configuration != NozzleConfigurationType.FULL ): + # This is to match the behavior found in PAPI, but also because we don't have logic to automatically find + # the next tip with partial configuration and a starting tip. This will never work for a 96-channel due to + # x-axis overlap, but could eventually work with 8-channel if we better define starting tip USED or CLEAN + # state when starting a protocol to prevent accidental tip pick-up with starting non-full tip racks. return SuccessData( public=GetNextTipResult( nextTipInfo=NoTipAvailable( @@ -91,7 +95,7 @@ async def execute(self, params: GetNextTipParams) -> SuccessData[GetNextTipResul nozzle_map=nozzle_map, ) if well_name is not None: - next_tip = NextTipInfo(labwareId=labware_id, tipOriginWell=well_name) + next_tip = NextTipInfo(labwareId=labware_id, tipStartingWell=well_name) break # After the first tip rack is exhausted, starting tip no longer applies starting_tip_name = None diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 699ce632169..f5cfb485611 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -1122,7 +1122,7 @@ class NextTipInfo(BaseModel): ..., description="The labware ID of the tip rack where the next available tip(s) are located.", ) - tipOriginWell: str = Field( + tipStartingWell: str = Field( ..., description="The (starting) well name of the next available tip(s)." ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py b/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py index b64b38064ed..4221cae864d 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_get_next_tip.py @@ -44,7 +44,7 @@ async def test_get_next_tip_implementation( assert result == SuccessData( public=GetNextTipResult( - nextTipInfo=NextTipInfo(labwareId="123", tipOriginWell="foo") + nextTipInfo=NextTipInfo(labwareId="123", tipStartingWell="foo") ), ) @@ -79,7 +79,7 @@ async def test_get_next_tip_implementation_multiple_tip_racks( assert result == SuccessData( public=GetNextTipResult( - nextTipInfo=NextTipInfo(labwareId="456", tipOriginWell="foo") + nextTipInfo=NextTipInfo(labwareId="456", tipStartingWell="foo") ), )