diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts index 48887d4ac17..687b0d404c5 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts @@ -1,13 +1,9 @@ import { useEffect } from 'react' -import { useHost } from '@opentrons/react-api-client' import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' -import { - useDropTipWizardFlows, - useTipAttachmentStatus, -} from '/app/organisms/DropTipWizardFlows' +import { useDropTipWizardFlows } from '/app/organisms/DropTipWizardFlows' import { useProtocolDropTipModal } from '../modals' import { useCloseCurrentRun, @@ -16,13 +12,12 @@ import { } from '/app/resources/runs' import { isTerminalRunStatus } from '../../utils' import { lastRunCommandPromptedErrorRecovery } from '/app/local-resources/commands' +import { useTipAttachmentStatus } from '/app/resources/instruments' import type { RobotType } from '@opentrons/shared-data' import type { Run, RunStatus } from '@opentrons/api-client' -import type { - DropTipWizardFlowsProps, - PipetteWithTip, -} from '/app/organisms/DropTipWizardFlows' +import type { PipetteWithTip } from '/app/resources/instruments' +import type { DropTipWizardFlowsProps } from '/app/organisms/DropTipWizardFlows' import type { UseProtocolDropTipModalResult } from '../modals' import type { PipetteDetails } from '/app/resources/maintenance_runs' @@ -49,7 +44,6 @@ export function useRunHeaderDropTip({ robotType, runStatus, }: UseRunHeaderDropTipParams): UseRunHeaderDropTipResult { - const host = useHost() const isRunCurrent = useIsRunCurrent(runId) const enteredER = runRecord?.data.hasEverEnteredErrorRecovery ?? false @@ -66,7 +60,6 @@ export function useRunHeaderDropTip({ } = useTipAttachmentStatus({ runId, runRecord: runRecord ?? null, - host, }) const dropTipModalUtils = useProtocolDropTipModal({ diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx index e1f1be57d22..b9f30de446f 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx @@ -21,7 +21,7 @@ import { useHomePipettes } from '/app/local-resources/instruments' import type { PipetteData } from '@opentrons/api-client' import type { IconProps } from '@opentrons/components' import type { UseHomePipettesProps } from '/app/local-resources/instruments' -import type { TipAttachmentStatusResult } from '/app/organisms/DropTipWizardFlows' +import type { TipAttachmentStatusResult } from '/app/resources/instruments' type UseProtocolDropTipModalProps = Pick< UseHomePipettesProps, diff --git a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx index 7198d8bb5ea..86778afe97b 100644 --- a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx +++ b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx @@ -19,8 +19,8 @@ import { useHomePipettes } from '/app/local-resources/instruments' import type { HostConfig } from '@opentrons/api-client' import type { OddModalHeaderBaseProps } from '/app/molecules/OddModal/types' import type { UseHomePipettesProps } from '/app/local-resources/instruments' -import type { PipetteWithTip } from './hooks' import type { PipetteDetails } from '/app/resources/maintenance_runs' +import type { PipetteWithTip } from '/app/resources/instruments' type TipsAttachedModalProps = Pick & { aPipetteWithTip: PipetteWithTip diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx index 917c770c10e..2a71920c4fc 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx @@ -14,7 +14,7 @@ import { useDropTipWizardFlows } from '..' import type { Mock } from 'vitest' import type { PipetteModelSpecs } from '@opentrons/shared-data' import type { HostConfig } from '@opentrons/api-client' -import type { PipetteWithTip } from '../hooks' +import type { PipetteWithTip } from '/app/resources/instruments' vi.mock('/app/resources/runs/useCloseCurrentRun') vi.mock('..') diff --git a/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useTipAttachmentStatus.test.tsx b/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useTipAttachmentStatus.test.tsx deleted file mode 100644 index 6d9d25719d2..00000000000 --- a/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useTipAttachmentStatus.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, renderHook } from '@testing-library/react' - -import { getPipetteModelSpecs } from '@opentrons/shared-data' -import { useInstrumentsQuery } from '@opentrons/react-api-client' - -import { mockPipetteInfo } from '/app/redux/pipettes/__fixtures__' -import { getPipettesWithTipAttached } from '../useTipAttachmentStatus/getPipettesWithTipAttached' -import { DropTipWizard } from '../../DropTipWizard' -import { useTipAttachmentStatus } from '../useTipAttachmentStatus' - -import type { Mock } from 'vitest' -import type { PipetteModelSpecs } from '@opentrons/shared-data' -import type { PipetteWithTip } from '../useTipAttachmentStatus' - -vi.mock('@opentrons/shared-data', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - getPipetteModelSpecs: vi.fn(), - } -}) -vi.mock('@opentrons/react-api-client') -vi.mock('../useTipAttachmentStatus/getPipettesWithTipAttached') -vi.mock('../../DropTipWizard') - -const MOCK_ACTUAL_PIPETTE = { - ...mockPipetteInfo.pipetteSpecs, - model: 'model', - tipLength: { - value: 20, - }, -} as PipetteModelSpecs - -const mockPipetteWithTip: PipetteWithTip = { - mount: 'left', - specs: MOCK_ACTUAL_PIPETTE, -} - -const mockSecondPipetteWithTip: PipetteWithTip = { - mount: 'right', - specs: MOCK_ACTUAL_PIPETTE, -} - -const mockPipettesWithTip: PipetteWithTip[] = [ - mockPipetteWithTip, - mockSecondPipetteWithTip, -] - -describe('useTipAttachmentStatus', () => { - let mockGetPipettesWithTipAttached: Mock - - beforeEach(() => { - mockGetPipettesWithTipAttached = vi.mocked(getPipettesWithTipAttached) - vi.mocked(getPipetteModelSpecs).mockReturnValue(MOCK_ACTUAL_PIPETTE) - vi.mocked(DropTipWizard).mockReturnValue(
MOCK DROP TIP WIZ
) - mockGetPipettesWithTipAttached.mockResolvedValue(mockPipettesWithTip) - vi.mocked(useInstrumentsQuery).mockReturnValue({ data: {} } as any) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should return the correct initial state', () => { - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - expect(result.current.areTipsAttached).toBe(false) - expect(result.current.aPipetteWithTip).toEqual(null) - }) - - it('should determine tip status and update state accordingly', async () => { - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - await act(async () => { - await result.current.determineTipStatus() - }) - - expect(result.current.areTipsAttached).toBe(true) - expect(result.current.aPipetteWithTip).toEqual(mockPipetteWithTip) - }) - - it('should reset tip status', async () => { - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - await act(async () => { - await result.current.determineTipStatus() - result.current.resetTipStatus() - }) - - expect(result.current.areTipsAttached).toBe(false) - expect(result.current.aPipetteWithTip).toEqual(null) - }) - - it('should set tip status resolved and update state', async () => { - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - await act(async () => { - await result.current.determineTipStatus() - result.current.setTipStatusResolved() - }) - - expect(result.current.aPipetteWithTip).toEqual(mockSecondPipetteWithTip) - }) - - it('should call onEmptyCache callback when cache becomes empty', async () => { - mockGetPipettesWithTipAttached.mockResolvedValueOnce([mockPipetteWithTip]) - - const onEmptyCacheMock = vi.fn() - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - await act(async () => { - await result.current.determineTipStatus() - result.current.setTipStatusResolved(onEmptyCacheMock) - }) - - expect(onEmptyCacheMock).toHaveBeenCalled() - }) -}) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/index.ts b/app/src/organisms/DropTipWizardFlows/hooks/index.ts index 3f3f531a9d8..f3145d7d083 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/index.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/index.ts @@ -1,6 +1,5 @@ export * from './errors' export * from './useDropTipWithType' -export * from './useTipAttachmentStatus' export * from './useDropTipLocations' export { useDropTipRouting } from './useDropTipRouting' export { useDropTipWithType } from './useDropTipWithType' diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/__tests__/getPipettesWithTipAttached.test.ts b/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/__tests__/getPipettesWithTipAttached.test.ts deleted file mode 100644 index bdaeff2dee0..00000000000 --- a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/__tests__/getPipettesWithTipAttached.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, it, beforeEach, expect, vi } from 'vitest' -import { getCommands } from '@opentrons/api-client' - -import { getPipettesWithTipAttached } from '../getPipettesWithTipAttached' -import { LEFT, RIGHT } from '@opentrons/shared-data' - -import type { GetPipettesWithTipAttached } from '../getPipettesWithTipAttached' - -vi.mock('@opentrons/api-client') - -const HOST_NAME = 'localhost' -const RUN_ID = 'testRunId' -const LEFT_PIPETTE_ID = 'testId1' -const RIGHT_PIPETTE_ID = 'testId2' -const LEFT_PIPETTE_NAME = 'testLeftName' -const RIGHT_PIPETTE_NAME = 'testRightName' -const PICK_UP_TIP = 'pickUpTip' -const DROP_TIP = 'dropTip' -const DROP_TIP_IN_PLACE = 'dropTipInPlace' -const LOAD_PIPETTE = 'loadPipette' -const FIXIT_INTENT = 'fixit' - -const LEFT_PIPETTE = { - mount: LEFT, - state: { tipDetected: true }, - instrumentType: 'pipette', - ok: true, -} -const RIGHT_PIPETTE = { - mount: RIGHT, - state: { tipDetected: true }, - instrumentType: 'pipette', - ok: true, -} - -const mockAttachedInstruments = { - data: [LEFT_PIPETTE, RIGHT_PIPETTE], - meta: { cursor: 0, totalLength: 2 }, -} - -const createMockCommand = ( - type: string, - id: string, - pipetteId: string, - status = 'succeeded' -) => ({ - id, - key: `${id}-key`, - commandType: type, - status, - params: { pipetteId }, -}) - -const mockCommands = { - data: [ - createMockCommand(LOAD_PIPETTE, 'load-left', LEFT_PIPETTE_ID), - createMockCommand(LOAD_PIPETTE, 'load-right', RIGHT_PIPETTE_ID), - createMockCommand(PICK_UP_TIP, 'pickup-left', LEFT_PIPETTE_ID), - createMockCommand(DROP_TIP, 'drop-left', LEFT_PIPETTE_ID, 'succeeded'), - ], - meta: { cursor: 0, totalLength: 4 }, -} - -const mockRunRecord = { - data: { - pipettes: [ - { id: LEFT_PIPETTE_ID, pipetteName: LEFT_PIPETTE_NAME, mount: LEFT }, - { id: RIGHT_PIPETTE_ID, pipetteName: RIGHT_PIPETTE_NAME, mount: RIGHT }, - ], - }, -} - -describe('getPipettesWithTipAttached', () => { - let DEFAULT_PARAMS: GetPipettesWithTipAttached - - beforeEach(() => { - DEFAULT_PARAMS = { - host: { hostname: HOST_NAME }, - runId: RUN_ID, - attachedInstruments: mockAttachedInstruments as any, - runRecord: mockRunRecord as any, - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommands, - } as any) - }) - - it('returns an empty array if attachedInstruments is null', async () => { - const params = { ...DEFAULT_PARAMS, attachedInstruments: null } - const result = await getPipettesWithTipAttached(params) - expect(result).toEqual([]) - }) - - it('returns an empty array if runRecord is null', async () => { - const params = { ...DEFAULT_PARAMS, runRecord: null } - const result = await getPipettesWithTipAttached(params) - expect(result).toEqual([]) - }) - - it('returns an empty array when no tips are attached according to protocol', async () => { - const mockCommandsWithoutAttachedTips = { - ...mockCommands, - data: [ - createMockCommand(LOAD_PIPETTE, 'load-left', LEFT_PIPETTE_ID), - createMockCommand(LOAD_PIPETTE, 'load-right', RIGHT_PIPETTE_ID), - createMockCommand(PICK_UP_TIP, 'pickup-left', LEFT_PIPETTE_ID), - createMockCommand(DROP_TIP, 'drop-left', LEFT_PIPETTE_ID, 'succeeded'), - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithoutAttachedTips, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result).toEqual([]) - }) - - it('returns pipettes with protocol detected tip attachment', async () => { - const mockCommandsWithPickUpTip = { - ...mockCommands, - data: [ - ...mockCommands.data, - createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), - createMockCommand(PICK_UP_TIP, 'pickup-right', RIGHT_PIPETTE_ID), - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithPickUpTip, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result).toEqual(mockAttachedInstruments.data) - }) - - it('always returns the left mount before the right mount if both pipettes have tips attached', async () => { - const mockCommandsWithPickUpTip = { - ...mockCommands, - data: [ - ...mockCommands.data, - createMockCommand(PICK_UP_TIP, 'pickup-right', RIGHT_PIPETTE_ID), - createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithPickUpTip, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result.length).toBe(2) - expect(result[0].mount).toEqual(LEFT) - expect(result[1].mount).toEqual(RIGHT) - }) - - it('does not return otherwise legitimate failed tip exchange commands if fixit intent tip commands are present and successful', async () => { - const mockCommandsWithFixit = { - ...mockCommands, - data: [ - ...mockCommands.data, - { - ...createMockCommand( - DROP_TIP_IN_PLACE, - 'fixit-drop', - LEFT_PIPETTE_ID - ), - intent: FIXIT_INTENT, - }, - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithFixit, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result).toEqual([]) - }) - - it('considers a tip attached only if the last tip exchange command was pickUpTip', async () => { - const mockCommandsWithPickUpTip = { - ...mockCommands, - data: [ - ...mockCommands.data, - createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithPickUpTip, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result).toEqual([mockAttachedInstruments.data[0]]) - }) - - it('returns all valid attached pipettes when an error occurs', async () => { - vi.mocked(getCommands).mockRejectedValueOnce( - new Error('Example network error') - ) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - - expect(result).toEqual([LEFT_PIPETTE, RIGHT_PIPETTE]) - }) - - it('filters out not ok pipettes', async () => { - vi.mocked(getCommands).mockRejectedValueOnce(new Error('Network error')) - - const mockInvalidPipettes = { - data: [ - LEFT_PIPETTE, - { - ...RIGHT_PIPETTE, - ok: false, - }, - ], - meta: { cursor: 0, totalLength: 2 }, - } - - const params = { - ...DEFAULT_PARAMS, - attachedInstruments: mockInvalidPipettes as any, - } - - const result = await getPipettesWithTipAttached(params) - - expect(result).toEqual([ - { - mount: LEFT, - state: { tipDetected: true }, - instrumentType: 'pipette', - ok: true, - }, - ]) - }) -}) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts b/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts deleted file mode 100644 index b710ba5a810..00000000000 --- a/app/src/organisms/DropTipWizardFlows/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/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/index.ts b/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/index.ts deleted file mode 100644 index 99d4ea9abd8..00000000000 --- a/app/src/organisms/DropTipWizardFlows/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, - } -} diff --git a/app/src/organisms/DropTipWizardFlows/index.ts b/app/src/organisms/DropTipWizardFlows/index.ts index 1b53f36e5c8..05a16f92e49 100644 --- a/app/src/organisms/DropTipWizardFlows/index.ts +++ b/app/src/organisms/DropTipWizardFlows/index.ts @@ -1,6 +1,4 @@ export * from './DropTipWizardFlows' -export { useTipAttachmentStatus } from './hooks' export * from './TipsAttachedModal' -export type { TipAttachmentStatusResult, PipetteWithTip } from './hooks' export type { FixitCommandTypeUtils } from './types' diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx index 57eef74d2d6..1609acfa0ca 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx @@ -24,10 +24,8 @@ import { DT_ROUTES } from '/app/organisms/DropTipWizardFlows/constants' import { SelectRecoveryOption } from './SelectRecoveryOption' import type { RecoveryContentProps, RecoveryRoute, RouteStep } from '../types' -import type { - FixitCommandTypeUtils, - PipetteWithTip, -} from '/app/organisms/DropTipWizardFlows' +import type { FixitCommandTypeUtils } from '/app/organisms/DropTipWizardFlows' +import type { PipetteWithTip } from '/app/resources/instruments' // The Drop Tip flow entry point. Includes entry from SelectRecoveryOption and CancelRun. export function ManageTips(props: RecoveryContentProps): JSX.Element { diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx index c44252e2da9..59888c39c42 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -20,7 +20,7 @@ import { import { RecoverySingleColumnContentWrapper } from '../shared' import type { ErrorKind, RecoveryContentProps, RecoveryRoute } from '../types' -import type { PipetteWithTip } from '/app/organisms/DropTipWizardFlows' +import type { PipetteWithTip } from '/app/resources/instruments' // The "home" route within Error Recovery. When a user completes a non-terminal flow or presses "Go back" enough // to escape the boundaries of any route, they will be redirected here. diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts index 0a12b59d089..8db4af030ea 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts @@ -1,9 +1,9 @@ import { useState } from 'react' import head from 'lodash/head' -import { useHost, useRunCurrentState } from '@opentrons/react-api-client' +import { useRunCurrentState } from '@opentrons/react-api-client' import { getPipetteModelSpecs } from '@opentrons/shared-data' -import { useTipAttachmentStatus } from '/app/organisms/DropTipWizardFlows' +import { useTipAttachmentStatus } from '/app/resources/instruments' import { ERROR_KINDS } from '/app/organisms/ErrorRecoveryFlows/constants' import { getErrorKind } from '/app/organisms/ErrorRecoveryFlows/utils' @@ -11,7 +11,7 @@ import type { Run, Instruments, PipetteData } from '@opentrons/api-client' import type { PipetteWithTip, TipAttachmentStatusResult, -} from '/app/organisms/DropTipWizardFlows' +} from '/app/resources/instruments' import type { ERUtilsProps } from '/app/organisms/ErrorRecoveryFlows/hooks/useERUtils' interface UseRecoveryTipStatusProps { @@ -38,11 +38,9 @@ export function useRecoveryTipStatus( failedCommandPipette, setFailedCommandPipette, ] = useState(null) - const host = useHost() const tipAttachmentStatusUtils = useTipAttachmentStatus({ ...props, - host, runRecord: props.runRecord ?? null, }) diff --git a/app/src/pages/ODD/RunSummary/index.tsx b/app/src/pages/ODD/RunSummary/index.tsx index ce7fd3c0ef6..98a53f591d4 100644 --- a/app/src/pages/ODD/RunSummary/index.tsx +++ b/app/src/pages/ODD/RunSummary/index.tsx @@ -67,15 +67,13 @@ import { EMPTY_TIMESTAMP, useCurrentRunCommands, } from '/app/resources/runs' -import { - useTipAttachmentStatus, - handleTipsAttachedModal, -} from '/app/organisms/DropTipWizardFlows' +import { handleTipsAttachedModal } from '/app/organisms/DropTipWizardFlows' import { lastRunCommandPromptedErrorRecovery } from '/app/local-resources/commands' +import { useTipAttachmentStatus } from '/app/resources/instruments' import type { IconName } from '@opentrons/components' import type { OnDeviceRouteParams } from '/app/App/types' -import type { PipetteWithTip } from '/app/organisms/DropTipWizardFlows' +import type { PipetteWithTip } from '/app/resources/instruments' export function RunSummary(): JSX.Element { const { runId } = useParams< @@ -236,7 +234,6 @@ export function RunSummary(): JSX.Element { } = useTipAttachmentStatus({ runId, runRecord: runRecord ?? null, - host, }) // Determine tip status on initial render only. Error Recovery always handles tip status, so don't show it twice. diff --git a/app/src/resources/instruments/__tests__/useTipAttachmentStatus.test.ts b/app/src/resources/instruments/__tests__/useTipAttachmentStatus.test.ts new file mode 100644 index 00000000000..6d0de5c6d05 --- /dev/null +++ b/app/src/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 '../useTipAttachmentStatus' + +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/resources/instruments/index.ts b/app/src/resources/instruments/index.ts index 16fae1ecad8..d88a2c7215f 100644 --- a/app/src/resources/instruments/index.ts +++ b/app/src/resources/instruments/index.ts @@ -1,3 +1,4 @@ export * from './useAttachedPipettes' export * from './useAttachedPipetteCalibrations' export * from './useAttachedPipettesFromInstrumentsQuery' +export * from './useTipAttachmentStatus' diff --git a/app/src/resources/instruments/useTipAttachmentStatus.ts b/app/src/resources/instruments/useTipAttachmentStatus.ts new file mode 100644 index 00000000000..ee0d6449ea6 --- /dev/null +++ b/app/src/resources/instruments/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[] +}