diff --git a/app/src/local-resources/instruments/__tests__/useTipAttachmentStatus.test.ts b/app/src/local-resources/instruments/__tests__/useTipAttachmentStatus.test.ts new file mode 100644 index 00000000000..a8e66aba7de --- /dev/null +++ b/app/src/local-resources/instruments/__tests__/useTipAttachmentStatus.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' + +import { + getCommands, + getInstruments, + getRunCurrentState, +} from '@opentrons/api-client' +import { getPipetteModelSpecs } from '@opentrons/shared-data' +import { useHost } from '@opentrons/react-api-client' + +import { mockPipetteInfo } from '/app/redux/pipettes/__fixtures__' +import { useTipAttachmentStatus } from '../hooks' + +import type { PipetteModelSpecs } from '@opentrons/shared-data' +import type { PipetteData } from '@opentrons/api-client' + +vi.mock('@opentrons/shared-data', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getPipetteModelSpecs: vi.fn(), + } +}) +vi.mock('@opentrons/api-client') +vi.mock('@opentrons/react-api-client') + +const MOCK_HOST = { ip: '1.2.3.4', port: 31950 } as any +const MOCK_RUN_ID = 'run-123' + +const MOCK_ACTUAL_PIPETTE = { + ...mockPipetteInfo.pipetteSpecs, + model: 'model', + tipLength: { + value: 20, + }, +} as PipetteModelSpecs + +const mockPipetteData: PipetteData = { + mount: 'left', + instrumentType: 'pipette', + instrumentModel: 'p1000_single_v3.6', + ok: true, +} as any + +const mockSecondPipetteData: PipetteData = { + ...mockPipetteData, + mount: 'right', +} + +const mockRunRecord = { + data: { + pipettes: [ + { id: 'pipette-1', mount: 'left' }, + { id: 'pipette-2', mount: 'right' }, + ], + }, +} as any + +const mockTipStates = { + 'pipette-1': { hasTip: true }, + 'pipette-2': { hasTip: true }, +} + +describe('useTipAttachmentStatus', () => { + beforeEach(() => { + vi.mocked(useHost).mockReturnValue(MOCK_HOST) + vi.mocked(getPipetteModelSpecs).mockReturnValue(MOCK_ACTUAL_PIPETTE) + + vi.mocked(getInstruments).mockResolvedValue({ + data: { data: [mockPipetteData, mockSecondPipetteData] }, + } as any) + + vi.mocked(getRunCurrentState).mockResolvedValue({ + data: { data: { tipStates: mockTipStates } }, + } as any) + + vi.mocked(getCommands).mockResolvedValue({ + data: { data: [{ commandType: 'mockType' }] }, + } as any) + }) + + const renderTipAttachmentStatus = () => { + return renderHook(() => + useTipAttachmentStatus({ + runId: MOCK_RUN_ID, + runRecord: mockRunRecord, + }) + ) + } + + it('should return the correct initial state', async () => { + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + expect(result.current.areTipsAttached).toBe(false) + expect(result.current.aPipetteWithTip).toEqual(null) + expect(result.current.initialPipettesWithTipsCount).toEqual(null) + }) + + it('should determine tip status and update state accordingly', async () => { + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.areTipsAttached).toBe(true) + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, + }) + expect(result.current.initialPipettesWithTipsCount).toBe(2) + }) + + it('should handle network errors', async () => { + vi.mocked(getInstruments).mockRejectedValueOnce(new Error('Error')) + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.areTipsAttached).toBe(false) + expect(result.current.aPipetteWithTip).toBeNull() + }) + + it('should reset tip status', async () => { + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + act(() => { + result.current.resetTipStatus() + }) + + expect(result.current.areTipsAttached).toBe(false) + expect(result.current.aPipetteWithTip).toEqual(null) + expect(result.current.initialPipettesWithTipsCount).toEqual(null) + }) + + it('should set tip status resolved and a state', async () => { + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, + }) + + act(() => { + result.current.setTipStatusResolved() + }) + + await waitFor(() => + expect(result.current.aPipetteWithTip?.mount).toBe('right') + ) + }) + + it('should call onEmptyCache callback when cache becomes empty', async () => { + vi.mocked(getRunCurrentState).mockResolvedValueOnce({ + data: { + data: { + tipStates: { + 'pipette-1': { hasTip: true }, + 'pipette-2': { hasTip: false }, + }, + }, + }, + } as any) + + const onEmptyCacheMock = vi.fn() + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, + }) + + act(() => { + result.current.setTipStatusResolved(onEmptyCacheMock) + }) + + await waitFor(() => { + expect(onEmptyCacheMock).toHaveBeenCalled() + }) + }) + + it('should handle tipPhysicallyMissing error by assuming tip is attached', async () => { + vi.mocked(getCommands).mockResolvedValueOnce({ + data: { + data: [ + { + error: { + errorType: 'tipPhysicallyMissing', + }, + }, + ], + }, + } as any) + + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.areTipsAttached).toBe(true) + }) + + it('should call onTipsDetected callback when tips remain after resolution', async () => { + const onTipsDetectedMock = vi.fn() + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, + }) + + act(() => { + result.current.setTipStatusResolved(undefined, onTipsDetectedMock) + }) + + await waitFor(() => { + expect(onTipsDetectedMock).toHaveBeenCalled() + }) + + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'right', + specs: MOCK_ACTUAL_PIPETTE, + }) + }) +}) diff --git a/app/src/local-resources/instruments/hooks/useTipAttachmentStatus.ts b/app/src/local-resources/instruments/hooks/useTipAttachmentStatus.ts new file mode 100644 index 00000000000..ee0d6449ea6 --- /dev/null +++ b/app/src/local-resources/instruments/hooks/useTipAttachmentStatus.ts @@ -0,0 +1,235 @@ +import { useState, useCallback } from 'react' +import head from 'lodash/head' + +import { useHost } from '@opentrons/react-api-client' +import { + getCommands, + getInstruments, + getRunCurrentState, +} from '@opentrons/api-client' +import { getPipetteModelSpecs } from '@opentrons/shared-data' + +import type { + HostConfig, + Mount, + PipetteData, + Run, + RunCommandSummary, +} from '@opentrons/api-client' +import type { PipetteModelSpecs } from '@opentrons/shared-data' + +export interface PipetteWithTip { + mount: Mount + specs: PipetteModelSpecs +} + +export interface PipetteTipState { + specs: PipetteModelSpecs | null + mount: Mount + hasTip: boolean +} + +export interface TipAttachmentStatusParams { + runId: string + runRecord: Run | null +} + +export interface TipAttachmentStatusResult { + /** Updates the pipettes with tip cache. Determine whether tips are likely attached on one or more pipettes, assuming + * tips are attached when there's uncertainty. + * + * NOTE: This function makes a few network requests on each invocation! + * */ + determineTipStatus: () => Promise + /* Whether tips are likely attached on *any* pipette. Typically called after determineTipStatus() */ + areTipsAttached: boolean + /* Resets the cached pipettes with tip statuses to null. */ + resetTipStatus: () => void + /** Removes the first element from the tip attached cache if present. + * @param {Function} onEmptyCache After removing the pipette from the cache, if the attached tip cache is empty, invoke this callback. + * @param {Function} onTipsDetected After removing the pipette from the cache, if the attached tip cache is not empty, invoke this callback. + * */ + setTipStatusResolved: ( + onEmptyCache?: () => void, + onTipsDetected?: () => void + ) => Promise + /* Relevant pipette information for a pipette with a tip attached. If both pipettes have tips attached, return the left pipette. */ + aPipetteWithTip: PipetteWithTip | null + /* The initial number of pipettes with tips. Null if there has been no tip check yet. */ + initialPipettesWithTipsCount: number | null +} + +// Returns various utilities for interacting with the cache of pipettes with tips attached. +export function useTipAttachmentStatus( + params: TipAttachmentStatusParams +): TipAttachmentStatusResult { + const { runId, runRecord } = params + const host = useHost() + const [pipettesWithTip, setPipettesWithTip] = useState([]) + const [initialPipettesCount, setInitialPipettesCount] = useState< + number | null + >(null) + + const aPipetteWithTip = head(pipettesWithTip) ?? null + const areTipsAttached = + pipettesWithTip.length > 0 && head(pipettesWithTip)?.specs != null + + const determineTipStatus = useCallback((): Promise => { + return Promise.all([ + getInstruments(host as HostConfig), + getRunCurrentState(host as HostConfig, runId), + getCommands(host as HostConfig, runId, { + includeFixitCommands: false, + pageLength: 1, + cursor: null, + }), + ]) + .then(([attachedInstruments, currentState, commandsData]) => { + const { tipStates } = currentState.data.data + + const pipetteInfo = validatePipetteInfo( + attachedInstruments?.data.data as PipetteData[] + ) + + const pipetteInfoById = createPipetteInfoById(runRecord, pipetteInfo) + const pipettesWithTipsData = getPipettesWithTipsData( + // eslint-disable-next-line + tipStates, + pipetteInfoById, + commandsData.data.data as RunCommandSummary[] + ) + const pipettesWithTipAndSpecs = filterPipettesWithTips( + pipettesWithTipsData + ) + + setPipettesWithTip(pipettesWithTipAndSpecs) + + if (initialPipettesCount === null) { + setInitialPipettesCount(pipettesWithTipAndSpecs.length) + } + + return Promise.resolve(pipettesWithTipAndSpecs) + }) + .catch(e => { + console.error(`Error during tip status check: ${e.message}`) + return Promise.resolve([]) + }) + }, [host, initialPipettesCount, runId, runRecord]) + + const resetTipStatus = (): void => { + setPipettesWithTip([]) + setInitialPipettesCount(null) + } + + const setTipStatusResolved = ( + onEmptyCache?: () => void, + onTipsDetected?: () => void + ): Promise => { + return new Promise(resolve => { + setPipettesWithTip(prevPipettesWithTip => { + const newState = [...prevPipettesWithTip.slice(1)] + if (newState.length === 0) { + onEmptyCache?.() + } else { + onTipsDetected?.() + } + + resolve(newState[0]) + return newState + }) + }) + } + + return { + areTipsAttached, + determineTipStatus, + resetTipStatus, + aPipetteWithTip, + setTipStatusResolved, + initialPipettesWithTipsCount: initialPipettesCount, + } +} + +// Return good pipettes from instrument data. +const validatePipetteInfo = ( + attachedInstruments: PipetteData[] | null +): PipetteData[] => { + const goodPipetteInfo = + attachedInstruments?.filter( + instr => instr.instrumentType === 'pipette' && instr.ok + ) ?? null + + if (goodPipetteInfo == null) { + throw new Error( + 'Attached instrument pipettes differ from current state pipettes.' + ) + } + + return goodPipetteInfo +} + +// Associate pipette info with a pipette id. +const createPipetteInfoById = ( + runRecord: Run | null, + pipetteInfo: PipetteData[] +): Record => { + const pipetteInfoById: Record = {} + + runRecord?.data.pipettes.forEach(p => { + const pipetteInfoForThisPipette = pipetteInfo.find( + goodPipette => p.mount === goodPipette.mount + ) + if (pipetteInfoForThisPipette != null) { + pipetteInfoById[p.id] = pipetteInfoForThisPipette + } + }) + + return pipetteInfoById +} + +const getPipettesWithTipsData = ( + tipStates: Record, + pipetteInfoById: Record, + commands: RunCommandSummary[] +): PipetteTipState[] => { + return Object.entries(tipStates).map(([pipetteId, tipInfo]) => { + const pipetteInfo = pipetteInfoById[pipetteId] + const specs = getPipetteModelSpecs(pipetteInfo.instrumentModel) + return { + specs, + mount: pipetteInfo.mount, + hasTip: getMightHaveTipGivenCommands(Boolean(tipInfo.hasTip), commands), + } + }) +} + +const PICK_UP_TIP_COMMAND_TYPES: Array = [ + 'pickUpTip', +] as const + +// Sometimes, the robot and the tip status util have different ideas of when tips are attached. +// For example, if a pickUpTip command fails, the robot does not think a tip is attached. However, we want to be +// conservative and prompt drop tip wizard in case there are tips attached unexpectedly. +const getMightHaveTipGivenCommands = ( + hasTip: boolean, + commands: RunCommandSummary[] +): boolean => { + const lastRunProtocolCommand = commands[commands.length - 1] + + if ( + PICK_UP_TIP_COMMAND_TYPES.includes(lastRunProtocolCommand.commandType) || + lastRunProtocolCommand?.error?.errorType === 'tipPhysicallyMissing' + ) { + return true + } else { + return hasTip + } +} + +const filterPipettesWithTips = ( + pipettesWithTipsData: PipetteTipState[] +): PipetteWithTip[] => { + return pipettesWithTipsData.filter( + pipette => pipette.specs != null && pipette.hasTip + ) as PipetteWithTip[] +} diff --git a/app/src/local-resources/instruments/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts b/app/src/local-resources/instruments/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts deleted file mode 100644 index b710ba5a810..00000000000 --- a/app/src/local-resources/instruments/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { getCommands } from '@opentrons/api-client' -import { LEFT } from '@opentrons/shared-data' - -import type { - HostConfig, - PipetteData, - Run, - CommandsData, - RunCommandSummary, - Instruments, -} from '@opentrons/api-client' -import type { - LoadedPipette, - PipettingRunTimeCommand, -} from '@opentrons/shared-data' - -export interface GetPipettesWithTipAttached { - host: HostConfig | null - runId: string - attachedInstruments: Instruments | null - runRecord: Run | null -} - -export function getPipettesWithTipAttached({ - host, - runId, - attachedInstruments, - runRecord, -}: GetPipettesWithTipAttached): Promise { - if (attachedInstruments == null || runRecord == null) { - return Promise.resolve([]) - } - - return ( - getCommandsExecutedDuringRun(host as HostConfig, runId) - .then(executedCmdData => - checkPipettesForAttachedTips( - executedCmdData.data, - runRecord.data.pipettes, - attachedInstruments.data as PipetteData[] - ) - ) - // If any network error occurs, return all attached pipettes as having tips attached for safety reasons. - .catch(() => Promise.resolve(getPipettesDataFrom(attachedInstruments))) - ) -} - -function getCommandsExecutedDuringRun( - host: HostConfig, - runId: string -): Promise { - return getCommands(host, runId, { - cursor: null, - pageLength: 0, - includeFixitCommands: true, - }).then(response => { - const { totalLength } = response.data.meta - return getCommands(host, runId, { - cursor: 0, - pageLength: totalLength, - includeFixitCommands: null, - }).then(response => response.data) - }) -} - -const TIP_EXCHANGE_COMMAND_TYPES = [ - 'dropTip', - 'dropTipInPlace', - 'pickUpTip', - 'moveToAddressableAreaForDropTip', -] - -function checkPipettesForAttachedTips( - commands: RunCommandSummary[], - pipettesUsedInRun: LoadedPipette[], - attachedPipettes: PipetteData[] -): PipetteData[] { - let pipettesWithUnknownTipStatus = pipettesUsedInRun - const mountsWithTipAttached: Array = [] - - // Iterate backwards through commands, finding first tip exchange command for each pipette. - // If there's a chance the tip is still attached, flag the pipette. - for (let i = commands.length - 1; i >= 0; i--) { - if (pipettesWithUnknownTipStatus.length === 0) { - break - } - - const commandType = commands[i].commandType - const pipetteUsedInCommand = (commands[i] as PipettingRunTimeCommand).params - .pipetteId - const isTipExchangeCommand = TIP_EXCHANGE_COMMAND_TYPES.includes( - commandType - ) - const pipetteUsedInCommandWithUnknownTipStatus = - pipettesWithUnknownTipStatus.find( - pipette => pipette.id === pipetteUsedInCommand - ) ?? null - - // If the currently iterated command is a fixit command, we can safely assume the user - // had the option to fix pipettes with tips in this command and all commands - // earlier in the run, during Error Recovery flows. - if ( - commands[i].intent === 'fixit' && - isTipExchangeCommand && - commands[i].status === 'succeeded' - ) { - break - } - - if ( - isTipExchangeCommand && - pipetteUsedInCommandWithUnknownTipStatus != null - ) { - const tipPossiblyAttached = - commands[i].status !== 'succeeded' || commandType === 'pickUpTip' - - if (tipPossiblyAttached) { - mountsWithTipAttached.push( - pipetteUsedInCommandWithUnknownTipStatus.mount - ) - } - pipettesWithUnknownTipStatus = pipettesWithUnknownTipStatus.filter( - pipette => pipette.id !== pipetteUsedInCommand - ) - } - } - - // Convert the array of mounts with attached tips to PipetteData with attached tips. - const pipettesWithTipAttached = attachedPipettes.filter( - attachedPipette => - mountsWithTipAttached.includes(attachedPipette.mount) && - attachedPipette.ok - ) - - // Preferentially assign the left mount as the first element. - if ( - pipettesWithTipAttached.length === 2 && - pipettesWithTipAttached[1].mount === LEFT - ) { - ;[pipettesWithTipAttached[0], pipettesWithTipAttached[1]] = [ - pipettesWithTipAttached[1], - pipettesWithTipAttached[0], - ] - } - - return pipettesWithTipAttached -} - -function getPipettesDataFrom( - attachedInstruments: Instruments | null -): PipetteData[] { - return attachedInstruments != null - ? (attachedInstruments.data.filter( - instrument => instrument.instrumentType === 'pipette' && instrument.ok - ) as PipetteData[]) - : [] -} diff --git a/app/src/local-resources/instruments/hooks/useTipAttachmentStatus/index.ts b/app/src/local-resources/instruments/hooks/useTipAttachmentStatus/index.ts deleted file mode 100644 index 99d4ea9abd8..00000000000 --- a/app/src/local-resources/instruments/hooks/useTipAttachmentStatus/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useState, useCallback } from 'react' -import head from 'lodash/head' - -import { useInstrumentsQuery } from '@opentrons/react-api-client' -import { getPipetteModelSpecs } from '@opentrons/shared-data' - -import { getPipettesWithTipAttached } from './getPipettesWithTipAttached' - -import type { Mount } from '@opentrons/api-client' -import type { PipetteModelSpecs } from '@opentrons/shared-data' -import type { GetPipettesWithTipAttached } from './getPipettesWithTipAttached' - -const INSTRUMENTS_POLL_MS = 5000 - -export interface PipetteWithTip { - mount: Mount - specs: PipetteModelSpecs -} - -export interface TipAttachmentStatusResult { - /** Updates the pipettes with tip cache. Determine whether tips are likely attached on one or more pipettes. - * - * NOTE: Use responsibly! This function can potentially (but not likely) iterate over the entire length of a protocol run. - * */ - determineTipStatus: () => Promise - /* Whether tips are likely attached on *any* pipette. Typically called after determineTipStatus() */ - areTipsAttached: boolean - /* Resets the cached pipettes with tip statuses to null. */ - resetTipStatus: () => void - /** Removes the first element from the tip attached cache if present. - * @param {Function} onEmptyCache After removing the pipette from the cache, if the attached tip cache is empty, invoke this callback. - * @param {Function} onTipsDetected After removing the pipette from the cache, if the attached tip cache is not empty, invoke this callback. - * */ - setTipStatusResolved: ( - onEmptyCache?: () => void, - onTipsDetected?: () => void - ) => Promise - /* Relevant pipette information for a pipette with a tip attached. If both pipettes have tips attached, return the left pipette. */ - aPipetteWithTip: PipetteWithTip | null - /* The initial number of pipettes with tips. Null if there has been no tip check yet. */ - initialPipettesWithTipsCount: number | null -} - -// Returns various utilities for interacting with the cache of pipettes with tips attached. -export function useTipAttachmentStatus( - params: Omit -): TipAttachmentStatusResult { - const [pipettesWithTip, setPipettesWithTip] = useState([]) - const [initialPipettesCount, setInitialPipettesCount] = useState< - number | null - >(null) - const { data: attachedInstruments } = useInstrumentsQuery({ - refetchInterval: INSTRUMENTS_POLL_MS, - }) - - const aPipetteWithTip = head(pipettesWithTip) ?? null - const areTipsAttached = - pipettesWithTip.length > 0 && head(pipettesWithTip)?.specs != null - - const determineTipStatus = useCallback((): Promise => { - return getPipettesWithTipAttached({ - ...params, - attachedInstruments: attachedInstruments ?? null, - }).then(pipettesWithTip => { - const pipettesWithTipsData = pipettesWithTip.map(pipette => { - const specs = getPipetteModelSpecs(pipette.instrumentModel) - return { - specs, - mount: pipette.mount, - } - }) - const pipettesWithTipAndSpecs = pipettesWithTipsData.filter( - pipette => pipette.specs != null - ) as PipetteWithTip[] - - setPipettesWithTip(pipettesWithTipAndSpecs) - // Set only once. - if (initialPipettesCount === null) { - setInitialPipettesCount(pipettesWithTipAndSpecs.length) - } - - return Promise.resolve(pipettesWithTipAndSpecs) - }) - }, [params]) - - const resetTipStatus = (): void => { - setPipettesWithTip([]) - setInitialPipettesCount(null) - } - - const setTipStatusResolved = ( - onEmptyCache?: () => void, - onTipsDetected?: () => void - ): Promise => { - return new Promise(resolve => { - setPipettesWithTip(prevPipettesWithTip => { - const newState = [...prevPipettesWithTip.slice(1)] - if (newState.length === 0) { - onEmptyCache?.() - } else { - onTipsDetected?.() - } - - resolve(newState[0]) - return newState - }) - }) - } - - return { - areTipsAttached, - determineTipStatus, - resetTipStatus, - aPipetteWithTip, - setTipStatusResolved, - initialPipettesWithTipsCount: initialPipettesCount, - } -}