diff --git a/components/src/atoms/buttons/AltPrimaryButton.tsx b/components/src/atoms/buttons/AltPrimaryButton.tsx index baab333ac45..89272a10bd8 100644 --- a/components/src/atoms/buttons/AltPrimaryButton.tsx +++ b/components/src/atoms/buttons/AltPrimaryButton.tsx @@ -16,18 +16,17 @@ export const AltPrimaryButton = styled(Btn)` ${styleProps} - &:focus { + &:hover { + box-shadow: 0 0 0; background-color: ${COLORS.grey35}; - box-shadow: none; } &:active { background-color: ${COLORS.grey40}; } - &:hover { - box-shadow: 0 0 0; - background-color: ${COLORS.grey35}; + &:active:hover { + background-color: ${COLORS.grey40}; } &:disabled { diff --git a/protocol-designer/src/assets/localization/en/alert.json b/protocol-designer/src/assets/localization/en/alert.json index c4919fb762a..2eb62e96deb 100644 --- a/protocol-designer/src/assets/localization/en/alert.json +++ b/protocol-designer/src/assets/localization/en/alert.json @@ -178,6 +178,10 @@ "NEXT_TIPRACK_HAS_LID": { "title": "Unable to pick up tips", "body": "The next available tip rack has a lid. Remove the lid from the tip rack to pick up tips." + }, + "INCOMPLETE_PICKUP": { + "title": "Incomplete tip pickup", + "body": "At least one of the selected tips is empty" } }, "warning": { diff --git a/protocol-designer/src/components/organisms/SelectLabwareModal/SelectCustomLabware.tsx b/protocol-designer/src/components/organisms/SelectLabwareModal/SelectCustomLabware.tsx index a7dafdd2a5b..cf09e33cdc5 100644 --- a/protocol-designer/src/components/organisms/SelectLabwareModal/SelectCustomLabware.tsx +++ b/protocol-designer/src/components/organisms/SelectLabwareModal/SelectCustomLabware.tsx @@ -6,6 +6,7 @@ import { ListButton, ListButtonAccordion, ListButtonAccordionContainer, + SPACING, } from '@opentrons/components' import { getCustomLabwareDefsByURI } from '../../../labware-defs/selectors' @@ -58,6 +59,7 @@ export function SelectCustomLabware( { handleCategoryClick(CUSTOM_CATEGORY) }} diff --git a/protocol-designer/src/components/organisms/SelectLabwareModal/SelectLabwareOnAdapter.tsx b/protocol-designer/src/components/organisms/SelectLabwareModal/SelectLabwareOnAdapter.tsx index 8a84b464b18..58f0f5066bc 100644 --- a/protocol-designer/src/components/organisms/SelectLabwareModal/SelectLabwareOnAdapter.tsx +++ b/protocol-designer/src/components/organisms/SelectLabwareModal/SelectLabwareOnAdapter.tsx @@ -98,7 +98,8 @@ export function SelectLabwareOnAdapter( > {has96Channel && loadName === ADAPTER_96_CHANNEL ? permittedTipracks.map((tiprackDefUri, index) => { - const nestedDef = defs[tiprackDefUri] + const nestedDef = + defs[tiprackDefUri] ?? customLabwareDefs[tiprackDefUri] const stackingLabwareDefUris = getStackerDefinitions( { ...defs, diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipSelectionWizard/SelectTips.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipSelectionWizard/SelectTips.tsx index 108accb8a66..8378dd57d69 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipSelectionWizard/SelectTips.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipSelectionWizard/SelectTips.tsx @@ -160,16 +160,6 @@ export function SelectTips( } const handleClickWell = (wellName: string): void => { - if ( - tipState?.[wellName] === 'EMPTY' || - !tipAccessibileStatusByWellName[wellName].isAccessible || - (allWellsAffectedByHover.includes(wellName) && - !areAllHoveredWellsAccessibleAndOccupied) - ) { - return - } - setShowPickupsRequiredBanner(false) - const prevSelectedTipsByIndex = selectedTips.reduce>( (acc, tipList, index) => { const innerAcc = tipList.reduce((acc, tip) => { @@ -180,6 +170,18 @@ export function SelectTips( {} ) + if ( + // always allow tip unselection + !(wellName in prevSelectedTipsByIndex) && + (tipState?.[wellName] === 'EMPTY' || + !tipAccessibileStatusByWellName[wellName].isAccessible || + (allWellsAffectedByHover.includes(wellName) && + !areAllHoveredWellsAccessibleAndOccupied)) + ) { + return + } + setShowPickupsRequiredBanner(false) + if (channels === 1 || nozzles === SINGLE) { if (wellName in prevSelectedTipsByIndex) { const indexToUnselect = prevSelectedTipsByIndex[wellName] @@ -274,7 +276,13 @@ export function SelectTips( status = rawState === NO ? NO : INACCESSIBLE } if (selectedTips.flat().some(tip => tip === wellName)) { - status = rawState === USED ? SELECTED_USED : SELECTED + const isAccessible = + tipAccessibileStatusByWellName[wellName].isAccessible + if (!isAccessible) { + status = SELECTED_ERROR + } else { + status = rawState === USED ? SELECTED_USED : SELECTED + } } else if (allWellsAffectedByHover.includes(wellName)) { if ( areAllHoveredWellsAccessibleAndOccupied && diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipTrackingField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipTrackingField.tsx index fa959fdee1b..88ad99500ef 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipTrackingField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipTrackingField.tsx @@ -11,12 +11,7 @@ import { SPACING, StyledText, } from '@opentrons/components' -import { - getAllLiquidClassDefs, - getFlexNameConversion, - OT2_ROBOT_TYPE, - WATER_LIQUID_CLASS_NAME, -} from '@opentrons/shared-data' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { AUTOMATIC, getDefaultPrimaryNozzle, @@ -72,28 +67,6 @@ export function TipTrackingField(props: TipTrackingFieldProps): JSX.Element { tiprackEntity => tiprackEntity.labwareDefURI === formData.tipRack )?.def - const allLiquidClassDefs = getAllLiquidClassDefs() - const liquidClassDef = - allLiquidClassDefs[formData.liquidClass ?? ''] ?? - allLiquidClassDefs[WATER_LIQUID_CLASS_NAME] - const convertedPipetteName = - pipette != null ? getFlexNameConversion(pipette.spec) : null - const liquidClassValuesForPipette = liquidClassDef.byPipette.find( - ({ pipetteModel }) => convertedPipetteName === pipetteModel - ) - const liquidClassValuesForTip = liquidClassValuesForPipette?.byTipType.find( - tipObject => tipObject.tiprack === formData.tipRack - ) - - let airGapByVolume: Array<[number, number]> = [] - // no air gap included for mix step - if (formData.stepType === 'moveLiquid') { - airGapByVolume = - (liquidClassValuesForTip?.aspirate.retract.airGapByVolume as Array< - [number, number] - >) ?? [] - } - const transferPlanAndReferenceVolumes = pipette != null && tiprackDefinition != null && formData != null ? getTransferPlanAndReferenceVolumes({ @@ -113,14 +86,14 @@ export function TipTrackingField(props: TipTrackingFieldProps): JSX.Element { conditioningByVolume: robotType === OT2_ROBOT_TYPE ? [] - : ((liquidClassValuesForTip?.multiDispense - ?.conditioningByVolume as Array<[number, number]>) ?? null), + : [[0, Number(formData.conditioning_volume ?? 0)]], disposalByVolume: robotType === OT2_ROBOT_TYPE ? [] - : ((liquidClassValuesForTip?.multiDispense - ?.disposalByVolume as Array<[number, number]>) ?? null), - aspirateAirGapByVolume: airGapByVolume, + : [[0, Number(formData.disposalVolume_volume ?? 0)]], + aspirateAirGapByVolume: [ + [0, Number(formData.aspirate_airGap_volume ?? 0)], + ], }) : null diff --git a/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdateMix.ts b/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdateMix.ts index 563acdaad20..5a29ac482c6 100644 --- a/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdateMix.ts +++ b/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdateMix.ts @@ -132,7 +132,11 @@ const updatePatchOnPipetteChange = ( return { ...patch, - ...getDefaultFields('aspirate_flowRate', 'dispense_flowRate'), + ...getDefaultFields( + 'aspirate_flowRate', + 'dispense_flowRate', + 'tips_selected' + ), nozzles, tipRack: firstDefaultTiprackURIOnDeck, } @@ -148,7 +152,12 @@ const updatePatchOnTiprackChange = ( if (fieldHasChanged(rawForm, patch, 'tipRack')) { return { ...patch, - ...getDefaultFields('aspirate_flowRate', 'dispense_flowRate'), + ...getDefaultFields( + 'aspirate_flowRate', + 'dispense_flowRate', + 'tiprack_selected', + 'tips_selected' + ), } } @@ -181,6 +190,32 @@ const updatePatchOnChangeTipChange = ( return patch } +const updatePatchOnWellsSelectedChange = ( + patch: FormPatch, + rawForm: FormData +): FormPatch => { + if (fieldHasChanged(rawForm, patch, 'wells')) { + return { + ...patch, + ...getDefaultFields('tips_selected'), + } + } + return patch +} + +const updatePatchOnVolumeChange = ( + patch: FormPatch, + rawForm: FormData +): FormPatch => { + if (fieldHasChanged(rawForm, patch, 'volume')) { + return { + ...patch, + ...getDefaultFields('tips_selected'), + } + } + return patch +} + export function dependentFieldsUpdateMix( originalPatch: FormPatch, rawForm: FormData, // raw = NOT hydrated @@ -213,5 +248,7 @@ export function dependentFieldsUpdateMix( chainPatch => updatePatchOnTiprackChange(chainPatch, rawForm), chainPatch => updatePatchOnNozzlesChange(chainPatch, rawForm), chainPatch => updatePatchOnChangeTipChange(chainPatch, rawForm), + chainPatch => updatePatchOnWellsSelectedChange(chainPatch, rawForm), + chainPatch => updatePatchOnVolumeChange(chainPatch, rawForm), ]) } diff --git a/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdateMoveLiquid.ts b/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdateMoveLiquid.ts index 7a7584ff217..008d4b2a45c 100644 --- a/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdateMoveLiquid.ts +++ b/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdateMoveLiquid.ts @@ -223,7 +223,8 @@ const updatePatchOnPipetteChange = ( 'dispense_mix_volume', 'disposalVolume_volume', 'aspirate_mmFromBottom', - 'dispense_mmFromBottom' + 'dispense_mmFromBottom', + 'tips_selected' ), nozzles, aspirate_airGap_volume: airGapVolume, @@ -256,7 +257,9 @@ const updatePatchOnTiprackChange = ( 'dispense_flowRate', 'aspirate_mix_volume', 'dispense_mix_volume', - 'disposalVolume_volume' + 'disposalVolume_volume', + 'tips_selected', + 'tiprack_selected' ), aspirate_airGap_volume: airGapVolume, dispense_airGap_volume: airGapVolume, @@ -646,8 +649,8 @@ export function updatePatchBlowoutFields( if (shouldResetBlowoutLocation) { return { ...patch, ...getDefaultFields('blowout_location') } } + return { ...patch, ...getDefaultFields('tips_selected') } } - return patch } @@ -662,7 +665,7 @@ const updatePatchOnNozzleChange = ( ) { return { ...patch, - ...getDefaultFields('aspirate_wells', 'dispense_wells'), + ...getDefaultFields('aspirate_wells', 'dispense_wells', 'tips_selected'), } } return patch @@ -732,6 +735,44 @@ const updatePatchOnChangeTipChange = ( return patch } +const updatePatchOnWellsSelectedChange = ( + patch: FormPatch, + rawForm: FormData +): FormPatch => { + if ( + fieldHasChanged(rawForm, patch, 'aspirate_wells') || + fieldHasChanged(rawForm, patch, 'dispense_wells') + ) { + return { + ...patch, + ...getDefaultFields('tips_selected'), + } + } + return patch +} + +const updatePatchOnVolumeChange = ( + patch: FormPatch, + rawForm: FormData +): FormPatch => { + const relevantFields = [ + 'volume', + 'conditioning_volume', + 'disposalVolume_volume', + 'aspirate_airGap_volume', + 'dispense_airGap_volume,', + ] + for (const field of relevantFields) { + if (fieldHasChanged(rawForm, patch, field)) { + return { + ...patch, + ...getDefaultFields('tips_selected'), + } + } + } + return patch +} + export function dependentFieldsUpdateMoveLiquid( originalPatch: FormPatch, rawForm: FormData, // raw = NOT hydrated @@ -779,5 +820,7 @@ export function dependentFieldsUpdateMoveLiquid( chainPatch => updatePatchOnPathChange(chainPatch, rawForm, pipetteEntities), chainPatch => updatePatchOnNozzlesChange(chainPatch, rawForm), chainPatch => updatePatchOnChangeTipChange(chainPatch, rawForm), + chainPatch => updatePatchOnWellsSelectedChange(chainPatch, rawForm), + chainPatch => updatePatchOnVolumeChange(chainPatch, rawForm), ]) } diff --git a/protocol-designer/src/steplist/formLevel/handleFormChange/test/mix.test.ts b/protocol-designer/src/steplist/formLevel/handleFormChange/test/mix.test.ts index fb879f520e0..87298a79959 100644 --- a/protocol-designer/src/steplist/formLevel/handleFormChange/test/mix.test.ts +++ b/protocol-designer/src/steplist/formLevel/handleFormChange/test/mix.test.ts @@ -150,6 +150,7 @@ describe('well selection should update', () => { mix_mmFromBottom: DEFAULT_MM_OFFSET_FROM_BOTTOM, mix_touchTip_mmFromTop: null, mix_touchTip_checkbox: false, + tips_selected: [], }) }) it('select labware with multiple wells', () => { @@ -163,6 +164,7 @@ describe('well selection should update', () => { mix_mmFromBottom: DEFAULT_MM_OFFSET_FROM_BOTTOM, mix_touchTip_mmFromTop: null, mix_touchTip_checkbox: false, + tips_selected: [], }) }) }) diff --git a/protocol-designer/src/steplist/formLevel/handleFormChange/test/moveLiquid.test.ts b/protocol-designer/src/steplist/formLevel/handleFormChange/test/moveLiquid.test.ts index 24398752927..75955387bc2 100644 --- a/protocol-designer/src/steplist/formLevel/handleFormChange/test/moveLiquid.test.ts +++ b/protocol-designer/src/steplist/formLevel/handleFormChange/test/moveLiquid.test.ts @@ -92,6 +92,7 @@ describe('path should update...', () => { const patch = {} expect(handleFormHelper(patch, { blah: 'blaaah' })).toEqual({ path: 'single', + tips_selected: [], }) }) describe('if path is multi and volume*2 + air gap volume exceeds pipette/tip capacity', () => { @@ -255,13 +256,14 @@ describe('disposal volume should update...', () => { disposalVolume_volume: null, conditioning_volume: null, conditioning_checkbox: false, + tips_selected: [], }) }) it('when volume is raised but disposal vol is still in capacity, do not change (noop case)', () => { const patch = { volume: '2.5' } const result = handleFormHelper(patch, form) - expect(result).toEqual(patch) + expect(result).toEqual({ ...patch, tips_selected: [] }) }) it('when the aspirate > air gap volume is large', () => { @@ -272,7 +274,7 @@ describe('disposal volume should update...', () => { aspirate_airGap_volume: '3', volume: '1', }) - expect(result).toEqual({ disposalVolume_volume: '5' }) + expect(result).toEqual({ disposalVolume_volume: '5', tips_selected: [] }) }) it('when the aspirate > air gap volume is increased', () => { const patch = { aspirate_airGap_volume: '3' } @@ -286,6 +288,7 @@ describe('disposal volume should update...', () => { expect(result).toEqual({ aspirate_airGap_volume: '3', disposalVolume_volume: '5', + tips_selected: [], }) }) it('skipped when the aspirate > air gap checkbox not checked', () => { @@ -296,7 +299,7 @@ describe('disposal volume should update...', () => { aspirate_airGap_volume: '3', volume: '1', }) - expect(result).toEqual({ disposalVolume_volume: '6' }) + expect(result).toEqual({ disposalVolume_volume: '6', tips_selected: [] }) }) describe('when volume is raised so that disposal vol must be exactly zero, clear/zero disposal volume fields', () => { @@ -316,6 +319,7 @@ describe('disposal volume should update...', () => { dispense_mix_times: null, dispense_mix_volume: null, blowout_checkbox: false, + tips_selected: [], }) }) @@ -325,6 +329,7 @@ describe('disposal volume should update...', () => { expect(result).toEqual({ ...patch, disposalVolume_volume: '0', + tips_selected: [], }) }) }) @@ -334,17 +339,18 @@ describe('disposal volume should update...', () => { expect(result).toEqual({ volume: '4.6', disposalVolume_volume: '0.8', + tips_selected: [], }) }) it('clamp excessive disposal volume to max', () => { const result = handleFormHelper({ disposalVolume_volume: '9999' }, form) - expect(result).toEqual({ disposalVolume_volume: '6' }) + expect(result).toEqual({ disposalVolume_volume: '6', tips_selected: [] }) }) it('when disposal volume is a negative number, set to zero', () => { const result = handleFormHelper({ disposalVolume_volume: '-2' }, form) - expect(result).toEqual({ disposalVolume_volume: '0' }) + expect(result).toEqual({ disposalVolume_volume: '0', tips_selected: [] }) }) describe('mix fields should clear...', () => { @@ -368,6 +374,7 @@ describe('disposal volume should update...', () => { aspirate_mix_times: null, aspirate_mix_volume: null, preWetTip: false, + tips_selected: [], }) }) }) @@ -391,7 +398,7 @@ describe('disposal volume should update...', () => { ] testCases.forEach(({ prevPath, nextPath, incompatible }) => { - const patch = { path: nextPath } + const patch = { path: nextPath, tips_selected: [] } it(`when changing path ${prevPath} → ${nextPath}, arbitrary labware still allowed`, () => { // @ts-expect-error(sa, 2021-6-15): missing id and stepType to be valid formData type const result = updatePatchBlowoutFields(patch, { diff --git a/shared-data/js/helpers/__tests__/getOt2SurroundingSlots.test.ts b/shared-data/js/helpers/__tests__/getOt2SurroundingSlots.test.ts new file mode 100644 index 00000000000..31189f14948 --- /dev/null +++ b/shared-data/js/helpers/__tests__/getOt2SurroundingSlots.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { getOt2SurroundingSlots } from '../getOt2SurroundingSlots' + +import type { OT2AddressableAreaName } from '../../../deck' + +describe('getOt2SurroundingSlots', () => { + it('returns the correct adjacent slots for a center slot (slot 5)', () => { + const result = getOt2SurroundingSlots('5') + expect(result).toEqual(['1', '2', '3', '4', '6', '7', '8', '9']) + }) + + it('returns correct adjacent slots for a corner slot (slot 1)', () => { + const result = getOt2SurroundingSlots('1') + expect(result).toEqual(['2', '4', '5']) + }) + + it('returns correct adjacent slots for an edge slot (slot 2)', () => { + const result = getOt2SurroundingSlots('2') + expect(result).toEqual(['1', '3', '4', '5', '6']) + }) + + it('returns empty array for slot not on the OT-2 deck', () => { + const result = getOt2SurroundingSlots('99' as OT2AddressableAreaName) + expect(result).toEqual([]) + }) + + it('returns correct adjacent slots for slot in right-most column (slot 3)', () => { + const result = getOt2SurroundingSlots('3') + expect(result).toEqual(['2', '5', '6']) + }) + + it('returns correct adjacent slots for slot 11', () => { + const result = getOt2SurroundingSlots('11') + expect(result).toEqual(['7', '8', '9', '10']) + }) +}) diff --git a/shared-data/js/helpers/getOt2SurroundingSlots.ts b/shared-data/js/helpers/getOt2SurroundingSlots.ts new file mode 100644 index 00000000000..31d02b824c5 --- /dev/null +++ b/shared-data/js/helpers/getOt2SurroundingSlots.ts @@ -0,0 +1,40 @@ +import chunk from 'lodash/chunk' + +import { OT2_SINGLE_SLOT_ADDRESSABLE_AREAS } from '../constants' + +import type { OT2AddressableAreaName } from '../../deck' +import type { DeckSlotId } from '../types' + +// OT-2 slot rows in ascending order (front-to-back of robot deck) +const OT2_SLOT_ROWS = chunk(OT2_SINGLE_SLOT_ADDRESSABLE_AREAS, 3) + +export const getOt2SurroundingSlots = ( + slot: OT2AddressableAreaName +): DeckSlotId[] => { + const rowIndex = OT2_SLOT_ROWS.findIndex(row => row.includes(slot)) + const surroundingSlots: DeckSlotId[] = [] + if (rowIndex >= 0) { + const columnIndex = OT2_SLOT_ROWS[rowIndex].indexOf(slot) + const minRowIndexToSearch = Math.max(0, rowIndex - 1) + const maxRowIndexToSearch = Math.min(OT2_SLOT_ROWS.length - 1, rowIndex + 1) + const minColumnIndexToSearch = Math.max(0, columnIndex - 1) + const maxColumnIndexToSearch = Math.min( + OT2_SLOT_ROWS[0].length - 1, + columnIndex + 1 + ) + for (const row of OT2_SLOT_ROWS.slice( + minRowIndexToSearch, + maxRowIndexToSearch + 1 + )) { + for (const surroundingSlot of row.slice( + minColumnIndexToSearch, + maxColumnIndexToSearch + 1 + )) { + if (surroundingSlot !== slot) { + surroundingSlots.push(surroundingSlot) + } + } + } + } + return surroundingSlots +} diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index 60b6a4525de..ab1f294c9b6 100644 --- a/shared-data/js/helpers/index.ts +++ b/shared-data/js/helpers/index.ts @@ -42,6 +42,7 @@ export * from './linearInterpolate' export * from './liquidClasses' export * from './getAddressableAreasInProtocol' export * from './getFlexSurroundingSlots' +export * from './getOt2SurroundingSlots' export * from './getSimplestFlexDeckConfig' export * from './formatRunTimeParameterDefaultValue' export * from './formatRunTimeParameterValue' diff --git a/step-generation/src/__tests__/getIsSafePipetteMovement.test.ts b/step-generation/src/__tests__/getIsSafePipetteMovement.test.ts index b11887bc385..df4b12cc4e2 100644 --- a/step-generation/src/__tests__/getIsSafePipetteMovement.test.ts +++ b/step-generation/src/__tests__/getIsSafePipetteMovement.test.ts @@ -103,23 +103,7 @@ describe('getIsSafePipetteMovement', () => { tipState: {}, liquidState: {}, } as any, - invariantContext: { - labwareEntities: {}, - pipetteEntities: {}, - moduleEntities: {}, - liquidEntities: {}, - trashBinEntities: { - trashBin: { - pythonName: 'trash_bin_1', - location: 'A3', - id: 'trashBin', - }, - }, - wasteChuteEntities: {}, - stagingAreaEntities: {}, - gripperEntities: {}, - config: {} as any, - }, + invariantContext: mockInvariantProperties, labwareId: 'mockId', wellLocationOffset: { x: 0, y: 0, z: 0 }, wellTargetName: mockWellName, diff --git a/step-generation/src/__tests__/replaceTip.test.ts b/step-generation/src/__tests__/replaceTip.test.ts index 63941411a76..1dedf4dbd79 100644 --- a/step-generation/src/__tests__/replaceTip.test.ts +++ b/step-generation/src/__tests__/replaceTip.test.ts @@ -189,6 +189,13 @@ describe('replaceTip', () => { tipracks: { [tiprack1Id]: { A1: EMPTY, + B1: EMPTY, + C1: EMPTY, + D1: EMPTY, + E1: EMPTY, + F1: EMPTY, + G1: EMPTY, + H1: EMPTY, }, }, pipettes: { diff --git a/step-generation/src/commandCreators/atomic/pickUpTip.ts b/step-generation/src/commandCreators/atomic/pickUpTip.ts index 7f6f78fbcb1..cfc885ca32c 100644 --- a/step-generation/src/commandCreators/atomic/pickUpTip.ts +++ b/step-generation/src/commandCreators/atomic/pickUpTip.ts @@ -1,10 +1,15 @@ +import { ALL } from '@opentrons/shared-data' + import { AUTOMATIC, COLUMN_4_SLOTS, MANUAL } from '../../constants' import { + incompletePickup, pipettingIntoColumn4, possiblePipetteCollision, } from '../../errorCreators' import { formatPyStr, + getDefaultPrimaryNozzle, + getIsSafePickupWithinTiprack, getIsSafePipetteMovement, getSlotInLocationStack, uuid, @@ -30,27 +35,47 @@ export const pickUpTip: CommandCreator = ( invariantContext, prevRobotState ) => { - const { pipetteId, labwareId, wellName, tipTrackingOption = AUTOMATIC } = args + const { + pipetteId, + labwareId, + wellName, + tipTrackingOption = AUTOMATIC, + nozzles, + } = args const errors: CommandCreatorError[] = [] - const isMultiChannelPipette = - invariantContext.pipetteEntities[pipetteId]?.spec.channels !== 1 + const channels = invariantContext.pipetteEntities[pipetteId].spec.channels + + const isSafePipetteMovement = getIsSafePipetteMovement({ + robotState: prevRobotState, + invariantContext, + pipetteId, + labwareId, + // we don't adjust the offset when moving to the tiprack + wellLocationOffset: { x: 0, y: 0 }, + wellTargetName: wellName, + }) + const primaryNozzle = getDefaultPrimaryNozzle({ + nozzles: nozzles ?? ALL, + channels, + }) + const isSafeWithinTiprack = getIsSafePickupWithinTiprack({ + tipState: prevRobotState.tipState.tipracks[labwareId], + primaryNozzle, + channels, + nozzleConfiguration: nozzles ?? ALL, + wellName, + tiprackDef: invariantContext.labwareEntities[labwareId].def, + }) - if ( - isMultiChannelPipette && - !getIsSafePipetteMovement({ - robotState: prevRobotState, - invariantContext, - pipetteId, - labwareId, - // we don't adjust the offset when moving to the tiprack - wellLocationOffset: { x: 0, y: 0 }, - wellTargetName: wellName, - }) - ) { + if (!isSafePipetteMovement || !isSafeWithinTiprack.isSafe) { errors.push(possiblePipetteCollision()) } + if (isSafeWithinTiprack.isComplete !== true) { + errors.push(incompletePickup()) + } + const tiprackSlot = getSlotInLocationStack( prevRobotState.labware[labwareId].stack ) diff --git a/step-generation/src/constants.ts b/step-generation/src/constants.ts index 907de79da59..0ad155a9b4e 100644 --- a/step-generation/src/constants.ts +++ b/step-generation/src/constants.ts @@ -13,6 +13,7 @@ import type { AddressableOffsetVector, ModuleModel, ModuleType, + OT2AddressableAreaName, } from '@opentrons/shared-data' import type { AbsorbanceReaderState, @@ -96,4 +97,4 @@ export const MANUAL: 'manual' = 'manual' export const STAGING_AREA_SLOTS = ['A4', 'B4', 'C4', 'D4'] -export const HOPPER_STACKER_LOCATION = 'hopper' +export const OT2_TC_SLOTS: OT2AddressableAreaName[] = ['7', '8', '10', '11'] diff --git a/step-generation/src/errorCreators.ts b/step-generation/src/errorCreators.ts index d287c925c07..79397b7e7c2 100644 --- a/step-generation/src/errorCreators.ts +++ b/step-generation/src/errorCreators.ts @@ -394,3 +394,10 @@ export const stackTooHigh = (args: { slot: string }): CommandCreatorError => { message: `The stack on slot ${args.slot} is too high`, } } + +export const incompletePickup = (): CommandCreatorError => { + return { + type: 'INCOMPLETE_PICKUP', + message: 'At least one of the selected tips is empty', + } +} diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts index b5be196d33e..e813d01fc30 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -752,6 +752,7 @@ export type ErrorType = | 'HEATER_SHAKER_LATCH_OPEN' | 'HEATER_SHAKER_NORTH_SOUTH__OF_NON_TIPRACK_WITH_MULTI_CHANNEL' | 'HEATER_SHAKER_NORTH_SOUTH_EAST_WEST_SHAKING' + | 'INCOMPLETE_PICKUP' | 'INSUFFICIENT_TIPS' | 'INVALID_SLOT' | 'LABWARE_DISCARDED_IN_TRASH' diff --git a/step-generation/src/utils/__tests__/misc.test.ts b/step-generation/src/utils/__tests__/misc.test.ts index bbae3b2e5b8..125e7e670a0 100644 --- a/step-generation/src/utils/__tests__/misc.test.ts +++ b/step-generation/src/utils/__tests__/misc.test.ts @@ -329,7 +329,7 @@ describe('getTransferPlanAndReferenceVolumes', () => { it('should return isSupported false for multiDispense if not enough volume', () => { const result = getTransferPlanAndReferenceVolumes({ pipetteSpecs: MOCK_P10_SPECS, - tiprackDefinition: fixtureTiprack10ul, + tiprackDefinition: { ...fixtureTiprack10ul, namespace: 'opentrons' }, volume: 6, path: 'multiDispense', numAspirateWells: 1, diff --git a/step-generation/src/utils/misc.ts b/step-generation/src/utils/misc.ts index 8dc1cd20029..f33e5524985 100644 --- a/step-generation/src/utils/misc.ts +++ b/step-generation/src/utils/misc.ts @@ -1102,23 +1102,26 @@ export const getTransferPlanAndReferenceVolumes = (args: { conditioningByVolume ) ?? 0) : 0 + + const isCustomTiprack = tiprackDefinition?.namespace !== 'opentrons' const isMultiDispenseAvailable = - conditioningByVolume != null && - disposalByVolume != null && - maxWorkingVolume >= - minVolumeForMultiAspirateDispense + - conditioningVolumeForMultiAspirateDispense + - (linearInterpolate( - minVolumeForMultiAspirateDispense, - disposalByVolume - ) ?? 0) + - // don't take air gap into account if conditioning volume is present - (conditioningVolumeForMultiAspirateDispense === 0 - ? (linearInterpolate( - minVolumeForMultiAspirateDispense, - aspirateAirGapByVolume - ) ?? 0) - : 0) + isCustomTiprack || + (conditioningByVolume != null && + disposalByVolume != null && + maxWorkingVolume >= + minVolumeForMultiAspirateDispense + + conditioningVolumeForMultiAspirateDispense + + (linearInterpolate( + minVolumeForMultiAspirateDispense, + disposalByVolume + ) ?? 0) + + // don't take air gap into account if conditioning volume is present + (conditioningVolumeForMultiAspirateDispense === 0 + ? (linearInterpolate( + minVolumeForMultiAspirateDispense, + aspirateAirGapByVolume + ) ?? 0) + : 0)) const isMultiAspirateAvailable = maxWorkingVolume >= minVolumeForMultiAspirateDispense diff --git a/step-generation/src/utils/safePipetteMovements.ts b/step-generation/src/utils/safePipetteMovements.ts index 22b4ed2f9d3..50c0cc01cda 100644 --- a/step-generation/src/utils/safePipetteMovements.ts +++ b/step-generation/src/utils/safePipetteMovements.ts @@ -5,12 +5,14 @@ import { getAddressableAreaFromSlotId, getDeckDefFromRobotType, getFlexSurroundingSlots, + getOt2SurroundingSlots, getPositionFromSlotId, + OT2_ROBOT_TYPE, SINGLE, THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' -import { EMPTY } from '../constants' +import { EMPTY, OT2_TC_SLOTS } from '../constants' import { getFullStackFromLabwares, getSlotInLocationStack } from './misc' import type { @@ -19,7 +21,9 @@ import type { LabwareDefinition, ModuleModel, NozzleConfigurationStyle, + OT2AddressableAreaName, PipetteChannels, + RobotType, } from '@opentrons/shared-data' import type { InvariantContext, @@ -188,12 +192,27 @@ const getSlotHasPotentialCollidingObject = ( pipetteBounds: Point[], slotInfo: SlotInfo[], robotState: RobotState, - invariantContext: InvariantContext + invariantContext: InvariantContext, + robotType: RobotType ): boolean => { + const isThermocyclerOnDeck = Object.values( + invariantContext.moduleEntities + ).some(({ type }) => type === THERMOCYCLER_MODULE_TYPE) for (const slot of slotInfo) { const slotBounds = slot.addressableArea?.boundingBox const slotPosition = slot.position + // explicit OT-2 check for if the pipette will enter the space above a thermocycler-occupied slot + const willCollideWithThermocycler = + isThermocyclerOnDeck && + robotType === OT2_ROBOT_TYPE && + slot.addressableArea?.id != null && + OT2_TC_SLOTS.includes(slot.addressableArea.id as OT2AddressableAreaName) + + if (willCollideWithThermocycler) { + return true + } + // If slotPosition or slotBounds is null, continue to the next iteration if (slotPosition == null || slotBounds == null) { continue @@ -296,7 +315,6 @@ export const getIsSafePipetteMovement = (args: { primaryNozzle: primaryNozzleOverride, nozzleConfiguration: nozzleConfigurationOverride, } = args - const deckDefinition = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) const { pipetteEntities, labwareEntities, @@ -308,6 +326,13 @@ export const getIsSafePipetteMovement = (args: { const pipetteEntity = pipetteEntities[pipetteId] const nozzleConfiguration = nozzleConfigurationOverride ?? robotState.pipettes[pipetteId]?.nozzles + const { spec: pipetteSpecs } = pipetteEntity ?? {} + + // NOTE: I don't like this, but step-generation is currently blind to robot type, so we'll infer from the pipette specs + const displayCategory = pipetteSpecs?.displayCategory + const isFlexPipette = displayCategory === 'FLEX' + const robotType = isFlexPipette ? FLEX_ROBOT_TYPE : OT2_ROBOT_TYPE + const deckDefinition = getDeckDefFromRobotType(robotType) // early exit if labwareId is a trashBin or wasteChute or if no nozzle is provided if ( @@ -369,10 +394,10 @@ export const getIsSafePipetteMovement = (args: { wellTargetPoint, primaryNozzle ) - const surroundingSlots = getFlexSurroundingSlots( - labwareSlot, - stagingAreaSlots - ) + const surroundingSlots = + robotType === OT2_ROBOT_TYPE + ? getOt2SurroundingSlots(labwareSlot as OT2AddressableAreaName) + : getFlexSurroundingSlots(labwareSlot, stagingAreaSlots) const slotInfos: SlotInfo[] = surroundingSlots.map(slot => { const addressableArea = getAddressableAreaFromSlotId(slot, deckDefinition) const position = getPositionFromSlotId(slot, deckDefinition) @@ -390,7 +415,8 @@ export const getIsSafePipetteMovement = (args: { pipetteBoundsAtWellLocation, slotInfos, robotState, - invariantContext + invariantContext, + robotType ) ) }