diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index 4d3a2f61f20..b11fe64f709 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -24,6 +24,7 @@ ABSMeasureMode, StackerFillEmptyStrategy, StackerStoredLabwareGroup, + StackerLabwareMovementStrategy, ) from opentrons.types import DeckSlotName from opentrons.protocol_engine.clients import SyncClient as ProtocolEngineClient @@ -747,6 +748,7 @@ def store(self) -> None: self._engine_client.execute_command( cmd.flex_stacker.StoreParams( moduleId=self.module_id, + strategy=StackerLabwareMovementStrategy.AUTOMATIC, ) ) diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py index 4df9046da0e..5072809dde1 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py @@ -41,6 +41,7 @@ InStackerHopperLocation, StackerStoredLabwareGroup, ModuleLocation, + StackerLabwareMovementStrategy, ) if TYPE_CHECKING: @@ -59,6 +60,13 @@ class StoreParams(BaseModel): ..., description="Unique ID of the flex stacker.", ) + strategy: StackerLabwareMovementStrategy = Field( + ..., + description=( + "If manual, indicates that labware has been moved to the hopper " + "manually by the user, as required in error recovery." + ), + ) class StoreResult(BaseModel): @@ -201,42 +209,47 @@ async def execute(self, params: StoreParams) -> _ExecuteReturn: stacker_hw = self._equipment.get_module_hardware_api(stacker_state.module_id) state_update = update_types.StateUpdate() - try: - if stacker_hw is not None: - stacker_hw.set_stacker_identify(True) + if stacker_hw is not None: + stacker_hw.set_stacker_identify(True) + + if ( + params.strategy is StackerLabwareMovementStrategy.AUTOMATIC + and stacker_hw is not None + ): + try: await stacker_hw.store_labware( labware_height=stacker_state.get_pool_height_minus_overlap() ) - except FlexStackerStallError as e: - return DefinedErrorData( - public=FlexStackerStallOrCollisionError( - id=self._model_utils.generate_id(), - createdAt=self._model_utils.get_timestamp(), - wrappedErrors=[ - ErrorOccurrence.from_failed( - id=self._model_utils.generate_id(), - createdAt=self._model_utils.get_timestamp(), - error=e, - ) - ], - errorInfo={"labwareId": primary_id}, - ), - ) - except FlexStackerShuttleMissingError as e: - return DefinedErrorData( - public=FlexStackerShuttleError( - id=self._model_utils.generate_id(), - createdAt=self._model_utils.get_timestamp(), - wrappedErrors=[ - ErrorOccurrence.from_failed( - id=self._model_utils.generate_id(), - createdAt=self._model_utils.get_timestamp(), - error=e, - ) - ], - errorInfo={"labwareId": primary_id}, - ), - ) + except FlexStackerStallError as e: + return DefinedErrorData( + public=FlexStackerStallOrCollisionError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo={"labwareId": primary_id}, + ), + ) + except FlexStackerShuttleMissingError as e: + return DefinedErrorData( + public=FlexStackerShuttleError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo={"labwareId": primary_id}, + ), + ) id_list = [ id for id in (primary_id, maybe_adapter_id, maybe_lid_id) if id is not None diff --git a/api/src/opentrons/protocol_engine/types/__init__.py b/api/src/opentrons/protocol_engine/types/__init__.py index c817ff283eb..a33b0ecd02e 100644 --- a/api/src/opentrons/protocol_engine/types/__init__.py +++ b/api/src/opentrons/protocol_engine/types/__init__.py @@ -68,6 +68,7 @@ ModuleOffsetData, StackerFillEmptyStrategy, StackerStoredLabwareGroup, + StackerLabwareMovementStrategy, ) from .location import ( DeckSlotLocation, @@ -212,6 +213,7 @@ "ModuleOffsetData", "StackerFillEmptyStrategy", "StackerStoredLabwareGroup", + "StackerLabwareMovementStrategy", # Locations of things on deck "DeckSlotLocation", "StagingSlotLocation", diff --git a/api/src/opentrons/protocol_engine/types/module.py b/api/src/opentrons/protocol_engine/types/module.py index 5c78a60e256..e1193b7ae61 100644 --- a/api/src/opentrons/protocol_engine/types/module.py +++ b/api/src/opentrons/protocol_engine/types/module.py @@ -276,6 +276,13 @@ class StackerFillEmptyStrategy(str, Enum): LOGICAL = "logical" +class StackerLabwareMovementStrategy(str, Enum): + """Strategy to retrieve or store labware.""" + + AUTOMATIC = "automatic" + MANUAL = "manual" + + class StackerStoredLabwareGroup(BaseModel): """Represents one group of labware stored in a stacker hopper.""" diff --git a/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_store.py b/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_store.py index a098b85a4f0..d3a77bb71b3 100644 --- a/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_store.py +++ b/api/tests/opentrons/protocol_engine/commands/flex_stacker/test_store.py @@ -37,6 +37,7 @@ InStackerHopperLocation, OnCutoutFixtureLocationSequenceComponent, StackerStoredLabwareGroup, + StackerLabwareMovementStrategy, ) from opentrons.protocol_engine.errors import ( CannotPerformModuleAction, @@ -83,7 +84,9 @@ async def test_store_raises_if_full( flex_50uL_tiprack: LabwareDefinition, ) -> None: """It should raise if called when the stacker is full.""" - data = flex_stacker.StoreParams(moduleId=stacker_id) + data = flex_stacker.StoreParams( + moduleId=stacker_id, strategy=StackerLabwareMovementStrategy.AUTOMATIC + ) fs_module_substate = FlexStackerSubState( module_id=stacker_id, @@ -114,7 +117,9 @@ async def test_store_raises_if_carriage_logically_empty( flex_50uL_tiprack: LabwareDefinition, ) -> None: """It should raise if called with a known-empty carriage.""" - data = flex_stacker.StoreParams(moduleId=stacker_id) + data = flex_stacker.StoreParams( + moduleId=stacker_id, strategy=StackerLabwareMovementStrategy.AUTOMATIC + ) fs_module_substate = FlexStackerSubState( module_id=stacker_id, @@ -157,7 +162,9 @@ async def test_store_raises_if_not_configured( stacker_id: FlexStackerId, ) -> None: """It should raise if called before the stacker is configured.""" - data = flex_stacker.StoreParams(moduleId=stacker_id) + data = flex_stacker.StoreParams( + moduleId=stacker_id, strategy=StackerLabwareMovementStrategy.AUTOMATIC + ) fs_module_substate = FlexStackerSubState( module_id=stacker_id, pool_primary_definition=None, @@ -210,7 +217,9 @@ async def test_store_raises_if_stall( ], ) -> None: """It should raise a stall error.""" - data = flex_stacker.StoreParams(moduleId=stacker_id) + data = flex_stacker.StoreParams( + moduleId=stacker_id, strategy=StackerLabwareMovementStrategy.AUTOMATIC + ) error_id = "error-id" error_timestamp = datetime(year=2020, month=1, day=2) @@ -385,7 +394,9 @@ async def test_store_raises_if_labware_does_not_match( param_lid: LabwareDefinition | None, ) -> None: """It should raise if the labware to be stored does not match the labware pool parameters.""" - data = flex_stacker.StoreParams(moduleId=stacker_id) + data = flex_stacker.StoreParams( + moduleId=stacker_id, strategy=StackerLabwareMovementStrategy.AUTOMATIC + ) fs_module_substate = FlexStackerSubState( module_id=stacker_id, @@ -444,6 +455,10 @@ async def test_store_raises_if_labware_does_not_match( await subject.execute(data) +@pytest.mark.parametrize( + "move_strategy", + [StackerLabwareMovementStrategy.AUTOMATIC, StackerLabwareMovementStrategy.MANUAL], +) async def test_store( decoy: Decoy, state_view: StateView, @@ -452,9 +467,10 @@ async def test_store( subject: StoreImpl, stacker_hardware: FlexStacker, flex_50uL_tiprack: LabwareDefinition, + move_strategy: StackerLabwareMovementStrategy, ) -> None: """It should store the labware on the stack.""" - data = flex_stacker.StoreParams(moduleId=stacker_id) + data = flex_stacker.StoreParams(moduleId=stacker_id, strategy=move_strategy) fs_module_substate = FlexStackerSubState( module_id=stacker_id, @@ -513,7 +529,11 @@ async def test_store( result = await subject.execute(data) - decoy.verify(await stacker_hardware.store_labware(labware_height=4), times=1) + decoy.verify( + await stacker_hardware.store_labware(labware_height=4), + times=1 if move_strategy == StackerLabwareMovementStrategy.AUTOMATIC else 0, + ) + assert result == SuccessData( public=flex_stacker.StoreResult( primaryOriginLocationSequence=[ diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index 5d6b46ea228..b4c53993477 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -24,6 +24,8 @@ import type { CreateCommand, DispenseInPlaceRunTimeCommand, DropTipInPlaceRunTimeCommand, + FlexStackerRetrieveRunTimeCommand, + FlexStackerStoreRunTimeCommand, LoadedLabware, MoveLabwareParams, MoveToCoordinatesCreateCommand, @@ -85,6 +87,8 @@ export interface UseRecoveryCommandsResult { homeShuttle: () => Promise /* A non-terminal recovery-command */ manualRetrieve: () => Promise + /* A non-terminal recovery-command */ + manualStore: () => Promise } // TODO(jh, 07-24-24): Create tighter abstractions for terminal vs. non-terminal commands. @@ -416,6 +420,17 @@ export function useRecoveryCommands({ } }, [chainRunRecoveryCommands, unvalidatedFailedCommand]) + const manualStore = useCallback((): Promise => { + const manualStoreCommand = buildManualStore(unvalidatedFailedCommand) + if (manualStoreCommand == null) { + return reportAndRouteFailedCmd( + new Error('Invalid use of manual store command') + ) + } else { + return chainRunRecoveryCommands([manualStoreCommand]) + } + }, [chainRunRecoveryCommands, unvalidatedFailedCommand]) + const moveLabwareWithoutPause = useCallback((): Promise => { const moveLabwareCmd = buildMoveLabwareWithoutPause( unvalidatedFailedCommand @@ -443,6 +458,7 @@ export function useRecoveryCommands({ homeAll, homeShuttle, manualRetrieve, + manualStore, closeLabwareLatch, releaseLabwareLatch, } @@ -521,15 +537,28 @@ const buildManualRetrieve = ( if (failedCommand == null) { return null } - const storeOrRetriveFailedCommandParams = failedCommand.params - const moduleId = - 'moduleId' in storeOrRetriveFailedCommandParams - ? storeOrRetriveFailedCommandParams.moduleId - : '' + const retrieveCommand = failedCommand as FlexStackerRetrieveRunTimeCommand return { commandType: 'unsafe/flexStacker/manualRetrieve', params: { - moduleId: moduleId, + moduleId: retrieveCommand.params.moduleId, + }, + intent: 'fixit', + } +} + +const buildManualStore = ( + failedCommand: FailedCommand | null +): CreateCommand | null => { + if (failedCommand == null) { + return null + } + const storeCommand = failedCommand as FlexStackerStoreRunTimeCommand + return { + commandType: 'flexStacker/store', + params: { + moduleId: storeCommand.params.moduleId, + strategy: 'manual', }, intent: 'fixit', } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/SkipStepInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/SkipStepInfo.tsx index d9d95e34531..f7cbfaa7856 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/SkipStepInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/SkipStepInfo.tsx @@ -9,6 +9,7 @@ import type { RecoveryContentProps } from '../types' export function SkipStepInfo(props: RecoveryContentProps): JSX.Element { const { + failedCommand, recoveryCommands, routeUpdateActions, currentRecoveryOptionUtils, @@ -24,7 +25,11 @@ export function SkipStepInfo(props: RecoveryContentProps): JSX.Element { } = RECOVERY_MAP const { selectedRecoveryOption } = currentRecoveryOptionUtils const { skipFailedCommand } = recoveryCommands - const { moveLabwareWithoutPause, manualRetrieve } = recoveryCommands + const { + moveLabwareWithoutPause, + manualRetrieve, + manualStore, + } = recoveryCommands const { handleMotionRouting } = routeUpdateActions const { ROBOT_SKIPPING_STEP } = RECOVERY_MAP const { t } = useTranslation('error_recovery') @@ -40,9 +45,19 @@ export function SkipStepInfo(props: RecoveryContentProps): JSX.Element { case STACKER_HOPPER_EMPTY_SKIP.ROUTE: case STACKER_SHUTTLE_EMPTY_SKIP.ROUTE: case STACKER_STALLED_SKIP.ROUTE: - void manualRetrieve().then(() => { - skipFailedCommand() - }) + if ( + failedCommand?.byRunRecord.commandType === 'flexStacker/retrieve' + ) { + void manualRetrieve().then(() => { + skipFailedCommand() + }) + } else if ( + failedCommand?.byRunRecord.commandType === 'flexStacker/store' + ) { + void manualStore().then(() => { + skipFailedCommand() + }) + } break default: skipFailedCommand() diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx index 0059962fd44..e3af4075e02 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx @@ -16,11 +16,13 @@ describe('SkipStepInfo', () => { let mockHandleMotionRouting: Mock let mockSkipFailedCommand: Mock let mockManualRetrieve: Mock + let mockManualStore: Mock beforeEach(() => { mockHandleMotionRouting = vi.fn(() => Promise.resolve()) mockSkipFailedCommand = vi.fn(() => Promise.resolve()) mockManualRetrieve = vi.fn(() => Promise.resolve()) + mockManualStore = vi.fn(() => Promise.resolve()) props = { routeUpdateActions: { @@ -29,6 +31,7 @@ describe('SkipStepInfo', () => { recoveryCommands: { skipFailedCommand: mockSkipFailedCommand, manualRetrieve: mockManualRetrieve, + manualStore: mockManualStore, } as any, currentRecoveryOptionUtils: { selectedRecoveryOption: RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, @@ -125,6 +128,9 @@ describe('SkipStepInfo', () => { RECOVERY_MAP.STACKER_STALLED_SKIP.ROUTE, ])('calls manualRetreive when the route is %s', async route => { props.currentRecoveryOptionUtils.selectedRecoveryOption = route + props.failedCommand = { + byRunRecord: { commandType: 'flexStacker/retrieve' as any }, + } as any render(props) clickButtonLabeled('Continue run now') @@ -144,4 +150,33 @@ describe('SkipStepInfo', () => { ) }) }) + + it.each([ + RECOVERY_MAP.STACKER_HOPPER_EMPTY_SKIP.ROUTE, + RECOVERY_MAP.STACKER_SHUTTLE_EMPTY_SKIP.ROUTE, + RECOVERY_MAP.STACKER_STALLED_SKIP.ROUTE, + ])('calls manualStore when the route is %s', async route => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = route + props.failedCommand = { + byRunRecord: { commandType: 'flexStacker/store' as any }, + } as any + render(props) + + clickButtonLabeled('Continue run now') + + await waitFor(() => { + expect(mockHandleMotionRouting).toHaveBeenCalledWith( + true, + RECOVERY_MAP.ROBOT_SKIPPING_STEP.ROUTE + ) + }) + await waitFor(() => { + expect(mockManualStore).toHaveBeenCalled() + }) + await waitFor(() => { + expect(mockHandleMotionRouting.mock.invocationCallOrder[0]).toBeLessThan( + mockManualStore.mock.invocationCallOrder[0] + ) + }) + }) }) diff --git a/shared-data/command/schemas/14.json b/shared-data/command/schemas/14.json index 996a82684f5..42c94c3ecc0 100644 --- a/shared-data/command/schemas/14.json +++ b/shared-data/command/schemas/14.json @@ -5303,6 +5303,12 @@ "title": "StackerFillEmptyStrategy", "type": "string" }, + "StackerLabwareMovementStrategy": { + "description": "Strategy to retrieve or store labware.", + "enum": ["automatic", "manual"], + "title": "StackerLabwareMovementStrategy", + "type": "string" + }, "StackerStoredLabwareDetails": { "description": "The parameters defining a labware to be stored in the stacker.", "properties": { @@ -5388,9 +5394,13 @@ "description": "Unique ID of the flex stacker.", "title": "Moduleid", "type": "string" + }, + "strategy": { + "$ref": "#/$defs/StackerLabwareMovementStrategy", + "description": "If manual, indicates that labware has been moved to the hopper manually by the user, as required in error recovery." } }, - "required": ["moduleId"], + "required": ["moduleId", "strategy"], "title": "StoreParams", "type": "object" }, diff --git a/shared-data/command/types/module.ts b/shared-data/command/types/module.ts index 25e30ad50a0..13e1474a734 100644 --- a/shared-data/command/types/module.ts +++ b/shared-data/command/types/module.ts @@ -464,7 +464,10 @@ export interface FlexStackerRetrieveCreateCommand export interface FlexStackerStoreCreateCommand extends CommonCommandCreateInfo { commandType: 'flexStacker/store' - params: { moduleId: string } + params: { + moduleId: string + strategy: 'automatic' | 'manual' + } } export interface FlexStackerFillCreateCommand extends CommonCommandCreateInfo {