Skip to content

Commit cd96e18

Browse files
authored
feat(protocol-designer): filter liquid class options and show warning (#18559)
This PR wires up liquid class filtering based on form field selection on page 1 of moveLiquid and mix forms. It also wires up the warning logic for showing why some (if any) liquid classes are disabled based on those selections and for prioritizing which warning should show if there are multiple reasons for disabling. Closes AUTH-1530
1 parent 085c440 commit cd96e18

File tree

8 files changed

+335
-13
lines changed

8 files changed

+335
-13
lines changed

protocol-designer/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,5 @@ export const CHANNELS_MAPPED_TO_MAX_SPEED: Record<
257257
},
258258
},
259259
}
260+
261+
export const MINIMUM_LIQUID_CLASS_VOLUME = 1

protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
getLabwareEntities,
1313
getPipetteEntities,
1414
} from '../../../../../../step-forms/selectors'
15-
import { useAssignLiquidClass } from '../MoveLiquidTools/hooks'
15+
import {
16+
useAssignLiquidClass,
17+
useSupportedLiquidClassOptions,
18+
} from '../MoveLiquidTools/hooks'
1619
import { LiquidClassesStepTools } from '../MoveLiquidTools/LiquidClassesStepTools'
1720
import { FirstStepMixTools } from './FirstStepMixTools'
1821
import { SecondStepMixTools } from './SecondStepMixTools'
@@ -57,6 +60,11 @@ export function MixTools(
5760
propsForFields.liquidClass.updateValue
5861
)
5962

63+
const orderedSupportedLiquidClassOptions = useSupportedLiquidClassOptions(
64+
orderedLiquidClassOptions,
65+
formData
66+
)
67+
6068
const stepComponents: Record<number, () => JSX.Element> = {
6169
0: () => (
6270
<FirstStepMixTools
@@ -76,7 +84,7 @@ export function MixTools(
7684
propsForFields={propsForFields}
7785
setShowFormErrors={setShowFormErrors}
7886
formData={formData}
79-
orderedLiquidClassOptions={orderedLiquidClassOptions}
87+
orderedLiquidClassOptions={orderedSupportedLiquidClassOptions}
8088
type="mix"
8189
/>
8290
) : (

protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/hooks.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ import { useEffect, useState } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { useSelector } from 'react-redux'
44

5-
import { getAllLiquidClassDefs } from '@opentrons/shared-data'
5+
import {
6+
getAllLiquidClassDefs,
7+
getFlexNameConversion,
8+
} from '@opentrons/shared-data'
69

10+
import { MINIMUM_LIQUID_CLASS_VOLUME } from '../../../../../../constants'
711
import {
812
getCurrentFormIsPresaved,
913
getCurrentFormUnsavedChangedFields,
@@ -15,6 +19,7 @@ import { getAllWellsFromPrimaryWells } from '../../../../../../steplist/formLeve
1519
import { getAllWellContentsForActiveItem } from '../../../../../../top-selectors/well-contents'
1620
import { getShouldUpdateForLiquidClass } from '../../utils'
1721

22+
import type { PathOption } from '@opentrons/step-generation'
1823
import type { FormData } from '../../../../../../form-types'
1924

2025
export interface LiquidClassOption {
@@ -133,3 +138,44 @@ export function useAssignLiquidClass(
133138

134139
return orderedLiquidClassOptions
135140
}
141+
142+
export const useSupportedLiquidClassOptions = (
143+
liquidClassOptions: LiquidClassOption[],
144+
formData: FormData
145+
): LiquidClassOption[] => {
146+
const { pipette, tipRack, volume: rawVolume } = formData
147+
const path = 'path' in formData ? (formData.path as PathOption) : null // handle mix or move liquid forms
148+
const pipetteEntities = useSelector(getPipetteEntities)
149+
const liquidClasses = getAllLiquidClassDefs()
150+
const pipetteEntity = pipetteEntities[pipette]
151+
152+
// early exit if pipette is not found to be permissive (not practical)
153+
if (pipetteEntity == null) {
154+
console.warn('No pipette found')
155+
return liquidClassOptions
156+
}
157+
158+
const pipetteName = getFlexNameConversion(pipetteEntity.spec)
159+
const volume = Number(rawVolume)
160+
if (volume < MINIMUM_LIQUID_CLASS_VOLUME) {
161+
return []
162+
}
163+
164+
const supportedOptions = liquidClassOptions.reduce<LiquidClassOption[]>(
165+
(acc, option) => {
166+
const liquidClass = liquidClasses[option.value]
167+
const byTipLookup = liquidClass?.byPipette
168+
.find(({ pipetteModel }) => pipetteModel === pipetteName)
169+
?.byTipType.find(({ tiprack }) => tiprack === tipRack)
170+
if (
171+
byTipLookup == null ||
172+
(path === 'multiDispense' && !('multiDispense' in byTipLookup))
173+
) {
174+
return acc
175+
}
176+
return [...acc, option]
177+
},
178+
[]
179+
)
180+
return supportedOptions
181+
}

protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data'
55
import { getEnableLiquidClasses } from '../../../../../../feature-flags/selectors'
66
import { getRobotType } from '../../../../../../file-data/selectors'
77
import { FirstStepMoveLiquidTools } from './FirstStepMoveLiquidTools'
8-
import { useAssignLiquidClass } from './hooks'
8+
import { useAssignLiquidClass, useSupportedLiquidClassOptions } from './hooks'
99
import { LiquidClassesStepTools } from './LiquidClassesStepTools'
1010
import { SecondStepsMoveLiquidTools } from './SecondStepsMoveLiquidTools'
1111

@@ -27,6 +27,11 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element {
2727
'aspirate_wells',
2828
propsForFields.liquidClass.updateValue
2929
)
30+
31+
const orderedSupportedLiquidClassOptions = useSupportedLiquidClassOptions(
32+
orderedLiquidClassOptions,
33+
formData
34+
)
3035
const robotType = useSelector(getRobotType)
3136

3237
const renderStepComponent = (): JSX.Element => {
@@ -47,7 +52,7 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element {
4752
formData={formData}
4853
setShowFormErrors={setShowFormErrors}
4954
type="transfer"
50-
orderedLiquidClassOptions={orderedLiquidClassOptions}
55+
orderedLiquidClassOptions={orderedSupportedLiquidClassOptions}
5156
/>
5257
) : (
5358
<SecondStepsMoveLiquidTools

protocol-designer/src/pages/ProtocolOverview/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export function ProtocolOverview(): JSX.Element {
9494
)
9595
const { timeline } = useSelector(fileSelectors.getRobotStateTimeline)
9696
const hasCommands = timeline.length > 0
97+
9798
const dispatch: ThunkDispatch<any> = useDispatch()
9899
const [showMaterialsListModal, setShowMaterialsListModal] = useState<boolean>(
99100
false

protocol-designer/src/steplist/formLevel/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
import {
7575
belowPipetteMinimumVolume,
7676
composeWarnings,
77+
incompatibleLiquidClass,
7778
maxDispenseWellVolume,
7879
minAspirateAirGapVolume,
7980
minDispenseAirGapVolume,
@@ -179,7 +180,8 @@ const stepFormHelperMap: {
179180
),
180181
getWarnings: composeWarnings(
181182
belowPipetteMinimumVolume,
182-
mixTipPositionInTube
183+
mixTipPositionInTube,
184+
incompatibleLiquidClass
183185
),
184186
},
185187
pause: {
@@ -233,7 +235,8 @@ const stepFormHelperMap: {
233235
minDisposalVolume,
234236
minAspirateAirGapVolume,
235237
minDispenseAirGapVolume,
236-
tipPositionInTube
238+
tipPositionInTube,
239+
incompatibleLiquidClass
237240
),
238241
},
239242
magnet: {

protocol-designer/src/steplist/formLevel/test/warnings.test.ts

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
1-
import { beforeEach, describe, expect, it } from 'vitest'
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
22

3-
import { fixture24Tuberack, fixture96Plate } from '@opentrons/shared-data'
3+
import {
4+
fixture24Tuberack,
5+
fixture96Plate,
6+
getAllLiquidClassDefs,
7+
} from '@opentrons/shared-data'
48

59
import {
610
_minAirGapVolume,
711
belowPipetteMinimumVolume,
12+
incompatibleLiquidClass,
813
maxDispenseWellVolume,
914
minDisposalVolume,
1015
mixTipPositionInTube,
1116
tipPositionInTube,
1217
} from '../warnings'
1318

14-
import type { LabwareDefinition2 } from '@opentrons/shared-data'
19+
import type { LabwareDefinition2, LiquidClass } from '@opentrons/shared-data'
1520
import type { LabwareEntity } from '@opentrons/step-generation'
1621

22+
vi.mock('@opentrons/shared-data', async () => {
23+
const actual = await vi.importActual('@opentrons/shared-data')
24+
return {
25+
...actual,
26+
getAllLiquidClassDefs: vi.fn(),
27+
}
28+
})
29+
1730
type CheckboxFields = 'aspirate_airGap_checkbox' | 'dispense_airGap_checkbox'
1831
type VolumeFields = 'aspirate_airGap_volume' | 'dispense_airGap_volume'
1932
describe('Min air gap volume', () => {
@@ -338,3 +351,105 @@ describe('Max dispense well volume', () => {
338351
})
339352
})
340353
})
354+
355+
const MOCK_GLYCEROL = {
356+
liquidClassName: 'glycerol50V1',
357+
byPipette: [
358+
{
359+
pipetteModel: 'flex_1channel_1000',
360+
byTipType: [
361+
{
362+
tiprack: 'opentrons/opentrons_flex_96_tiprack_1000ul/1',
363+
aspirate: {},
364+
singleDispense: {},
365+
multiDispense: {},
366+
},
367+
],
368+
},
369+
],
370+
} as LiquidClass
371+
const MOCK_WATER = {
372+
liquidClassName: 'waterV1',
373+
byPipette: [
374+
{
375+
pipetteModel: 'flex_1channel_1000',
376+
byTipType: [
377+
{
378+
tiprack: 'opentrons/opentrons_flex_96_tiprack_1000ul/1',
379+
aspirate: {},
380+
singleDispense: {},
381+
multiDispense: {},
382+
},
383+
],
384+
},
385+
],
386+
} as LiquidClass
387+
describe('class compatibility', () => {
388+
let fields: any
389+
beforeEach(() => {
390+
fields = {
391+
pipette: {
392+
spec: { channels: 1, liquids: { default: { maxVolume: 1000 } } },
393+
},
394+
tipRack: 'opentrons/opentrons_flex_96_tiprack_1000ul/1',
395+
liquidClass: 'glycerol_50',
396+
path: 'singleDispense',
397+
}
398+
vi.mocked(getAllLiquidClassDefs).mockReturnValue({
399+
glycerol_50: MOCK_GLYCEROL,
400+
water: MOCK_WATER,
401+
})
402+
})
403+
404+
it('should return null if the liquid class is compatible with the pipette, tips, volume, and path', () => {
405+
expect(incompatibleLiquidClass(fields)).toBe(null)
406+
})
407+
it('should return liquid classes incompatible with the pipette warning if pipette incompatible with all liquid classes', () => {
408+
fields = {
409+
...fields,
410+
pipette: {
411+
spec: { channels: 2, liquids: { default: { maxVolume: 1000 } } },
412+
},
413+
}
414+
expect(incompatibleLiquidClass(fields)?.type).toBe(
415+
'INCOMPATIBLE_ALL_PIPETTE'
416+
)
417+
})
418+
it('should return liquid classes incompatible with the pipette warning if pipette incompatible with some liquid classes', () => {
419+
vi.mocked(getAllLiquidClassDefs).mockReturnValue({
420+
water: MOCK_WATER,
421+
glycerol_50: { ...MOCK_GLYCEROL, byPipette: [] },
422+
})
423+
expect(incompatibleLiquidClass(fields)?.type).toBe(
424+
'INCOMPATIBLE_SOME_PIPETTE'
425+
)
426+
})
427+
it('should return liquid classes incompatible with the pipette warning if tiprack incompatible with all liquid classes', () => {
428+
fields = {
429+
...fields,
430+
tipRack: 'badTiprack',
431+
}
432+
expect(incompatibleLiquidClass(fields)?.type).toBe(
433+
'INCOMPATIBLE_TIP_RACK_ALL'
434+
)
435+
})
436+
it('should return liquid classes incompatible with the pipette warning if tiprack incompatible with some liquid classes', () => {
437+
vi.mocked(getAllLiquidClassDefs).mockReturnValue({
438+
water: MOCK_WATER,
439+
glycerol_50: {
440+
...MOCK_GLYCEROL,
441+
byPipette: [{ pipetteModel: 'flex_1channel_1000', byTipType: [] }],
442+
},
443+
})
444+
expect(incompatibleLiquidClass(fields)?.type).toBe(
445+
'INCOMPATIBLE_TIP_RACK_SOME'
446+
)
447+
})
448+
it('should return liquid classes incompatible with the pipette warning if pipette incompatible with all liquid classes', () => {
449+
fields = {
450+
...fields,
451+
volume: 0.01,
452+
}
453+
expect(incompatibleLiquidClass(fields)?.type).toBe('LOW_VOLUME_TRANSFER')
454+
})
455+
})

0 commit comments

Comments
 (0)