From 9a5f11618ee6c477fafdcb2d60c0b60cda04f0a6 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 30 Oct 2024 09:54:06 -0400 Subject: [PATCH 01/49] fix(app): Fix nested labware UI in Error Recovery (#16634) Closes RQA-3440 This PR fixes copy and deck map issues when addressing labware nested in other labware. 5eab60c - Split the util that gets the labware location and renders appropriate copy into two separate utils. c753bc5 - Fixes copy issues. We recently refactored slot location display utils, and I just forgot to use the correct, new prop here and update relevant i18n strings. caf9340 - Fixes deck map issues. Essentially, if there are multiple labware in the slot with the failed labware, only render the topmost labware. This is consistent with deck map behavior in other places in the app. Yes, it's a pain and annoying to have to do this outside of the deck map, but after discussion with other FE devs, it sounds like the deck map redesign will fundamentally change the way we render all objects on the deck, so I don't think it's worth the effort to improve deck map internals currently. --- .../localization/en/error_recovery.json | 4 +- .../utils/getLabwareDisplayLocation.ts | 202 +++++------------- .../labware/utils/getLabwareLocation.ts | 147 +++++++++++++ .../local-resources/labware/utils/index.ts | 1 + .../hooks/__tests__/useDeckMapUtils.test.ts | 131 +++++++++--- .../hooks/useDeckMapUtils.ts | 142 ++++++++---- .../hooks/useFailedLabwareUtils.ts | 7 +- .../shared/TwoColLwInfoAndDeck.tsx | 5 +- .../__tests__/TwoColLwInfoAndDeck.test.tsx | 6 +- 9 files changed, 419 insertions(+), 226 deletions(-) create mode 100644 app/src/local-resources/labware/utils/getLabwareLocation.ts diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 746305e2578..30ff25a4968 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -66,8 +66,8 @@ "remove_any_attached_tips": "Remove any attached tips", "replace_tips_and_select_loc_partial_tip": "Replace tips and select the last location used for partial tip pickup.", "replace_tips_and_select_location": "It's best to replace tips and select the last location used for tip pickup.", - "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in Slot {{slot}}", - "replace_with_new_tip_rack": "Replace with new tip rack in Slot {{slot}}", + "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in {{slot}}", + "replace_with_new_tip_rack": "Replace with new tip rack in {{slot}}", "resume": "Resume", "retry_dropping_tip": "Retry dropping tip", "retry_now": "Retry now", diff --git a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts index d70e6d19d42..086854200ba 100644 --- a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts +++ b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts @@ -1,180 +1,88 @@ import { - getLabwareDefURI, - getLabwareDisplayName, getModuleDisplayName, getModuleType, getOccludedSlotCountForModule, } from '@opentrons/shared-data' - -import { - getModuleModel, - getModuleDisplayLocation, -} from '/app/local-resources/modules' +import { getLabwareLocation } from './getLabwareLocation' import type { TFunction } from 'i18next' import type { - LabwareDefinition2, - LabwareLocation, - RobotType, -} from '@opentrons/shared-data' -import type { LoadedLabwares } from '/app/local-resources/labware' -import type { LoadedModules } from '/app/local-resources/modules' + LocationSlotOnlyParams, + LocationFullParams, +} from './getLabwareLocation' -interface LabwareDisplayLocationBaseParams { - location: LabwareLocation | null - loadedModules: LoadedModules - loadedLabwares: LoadedLabwares - robotType: RobotType +export interface DisplayLocationSlotOnlyParams extends LocationSlotOnlyParams { t: TFunction isOnDevice?: boolean } -export interface LabwareDisplayLocationSlotOnly - extends LabwareDisplayLocationBaseParams { - detailLevel: 'slot-only' -} - -export interface LabwareDisplayLocationFull - extends LabwareDisplayLocationBaseParams { - detailLevel?: 'full' - allRunDefs: LabwareDefinition2[] +export interface DisplayLocationFullParams extends LocationFullParams { + t: TFunction + isOnDevice?: boolean } -export type LabwareDisplayLocationParams = - | LabwareDisplayLocationSlotOnly - | LabwareDisplayLocationFull +export type DisplayLocationParams = + | DisplayLocationSlotOnlyParams + | DisplayLocationFullParams // detailLevel applies to nested labware. If 'full', return copy that includes the actual peripheral that nests the // labware, ex, "in module XYZ in slot C1". // If 'slot-only', return only the slot name, ex "in slot C1". export function getLabwareDisplayLocation( - params: LabwareDisplayLocationParams + params: DisplayLocationParams ): string { - const { - loadedLabwares, - loadedModules, - location, - robotType, - t, - isOnDevice = false, - detailLevel = 'full', - } = params + const { t, isOnDevice = false } = params + const locationResult = getLabwareLocation(params) - if (location == null) { - console.error('Cannot get labware display location. No location provided.') + if (locationResult == null) { return '' - } else if (location === 'offDeck') { - return t('off_deck') - } else if ('slotName' in location) { - return isOnDevice - ? location.slotName - : t('slot', { slot_name: location.slotName }) - } else if ('addressableAreaName' in location) { - return isOnDevice - ? location.addressableAreaName - : t('slot', { slot_name: location.addressableAreaName }) - } else if ('moduleId' in location) { - const moduleModel = getModuleModel(loadedModules, location.moduleId) - if (moduleModel == null) { - console.error('labware is located on an unknown module model') - return '' - } - const slotName = getModuleDisplayLocation(loadedModules, location.moduleId) + } - if (detailLevel === 'slot-only') { - return t('slot', { slot_name: slotName }) - } + const { slotName, moduleModel, adapterName } = locationResult - return isOnDevice - ? `${getModuleDisplayName(moduleModel)}, ${slotName}` - : t('module_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - slot_name: slotName, - }) - } else if ('labwareId' in location) { - if (!Array.isArray(loadedLabwares)) { - console.error('Cannot get display location from loaded labwares object') - return '' + if (slotName === 'offDeck') { + return t('off_deck') + } + // Simple slot location + else if (moduleModel == null && adapterName == null) { + return isOnDevice ? slotName : t('slot', { slot_name: slotName }) + } + // Module location without adapter + else if (moduleModel != null && adapterName == null) { + if (params.detailLevel === 'slot-only') { + return t('slot', { slot_name: slotName }) + } else { + return isOnDevice + ? `${getModuleDisplayName(moduleModel)}, ${slotName}` + : t('module_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + params.robotType + ), + module: getModuleDisplayName(moduleModel), + slot_name: slotName, + }) } - const adapter = loadedLabwares.find(lw => lw.id === location.labwareId) - - if (adapter == null) { - console.error('labware is located on an unknown adapter') - return '' - } else if (detailLevel === 'slot-only') { - return getLabwareDisplayLocation({ - ...params, - location: adapter.location, + } + // Adapter locations + else if (adapterName != null) { + if (moduleModel == null) { + return t('adapter_in_slot', { + adapter: adapterName, + slot: slotName, }) - } else if (detailLevel === 'full') { - const { allRunDefs } = params as LabwareDisplayLocationFull - const adapterDef = allRunDefs.find( - def => getLabwareDefURI(def) === adapter?.definitionUri - ) - const adapterDisplayName = - adapterDef != null ? getLabwareDisplayName(adapterDef) : '' - - if (adapter.location === 'offDeck') { - return t('off_deck') - } else if ( - 'slotName' in adapter.location || - 'addressableAreaName' in adapter.location - ) { - const slotName = - 'slotName' in adapter.location - ? adapter.location.slotName - : adapter.location.addressableAreaName - return t('adapter_in_slot', { - adapter: adapterDisplayName, - slot: slotName, - }) - } else if ('moduleId' in adapter.location) { - const moduleIdUnderAdapter = adapter.location.moduleId - - if (!Array.isArray(loadedModules)) { - console.error( - 'Cannot get display location from loaded modules object' - ) - return '' - } - - const moduleModel = loadedModules.find( - module => module.id === moduleIdUnderAdapter - )?.model - if (moduleModel == null) { - console.error('labware is located on an adapter on an unknown module') - return '' - } - const slotName = getModuleDisplayLocation( - loadedModules, - adapter.location.moduleId - ) - - return t('adapter_in_mod_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - adapter: adapterDisplayName, - slot: slotName, - }) - } else { - console.error( - 'Unhandled adapter location for determining display location.' - ) - return '' - } } else { - console.error('Unhandled detail level for determining display location.') - return '' + return t('adapter_in_mod_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + params.robotType + ), + module: getModuleDisplayName(moduleModel), + adapter: adapterName, + slot: slotName, + }) } } else { - console.error('display location could not be established: ', location) return '' } } diff --git a/app/src/local-resources/labware/utils/getLabwareLocation.ts b/app/src/local-resources/labware/utils/getLabwareLocation.ts new file mode 100644 index 00000000000..bb8231679c5 --- /dev/null +++ b/app/src/local-resources/labware/utils/getLabwareLocation.ts @@ -0,0 +1,147 @@ +import { getLabwareDefURI, getLabwareDisplayName } from '@opentrons/shared-data' + +import { + getModuleDisplayLocation, + getModuleModel, +} from '/app/local-resources/modules' + +import type { + LabwareDefinition2, + LabwareLocation, + ModuleModel, + RobotType, +} from '@opentrons/shared-data' +import type { LoadedLabwares } from '/app/local-resources/labware' +import type { LoadedModules } from '/app/local-resources/modules' + +export interface LocationResult { + slotName: string + moduleModel?: ModuleModel + adapterName?: string +} + +interface BaseParams { + location: LabwareLocation | null + loadedModules: LoadedModules + loadedLabwares: LoadedLabwares + robotType: RobotType +} + +export interface LocationSlotOnlyParams extends BaseParams { + detailLevel: 'slot-only' +} + +export interface LocationFullParams extends BaseParams { + allRunDefs: LabwareDefinition2[] + detailLevel?: 'full' +} + +export type GetLabwareLocationParams = + | LocationSlotOnlyParams + | LocationFullParams + +// detailLevel returns additional information about the module and adapter in the same location, if applicable. +// if 'slot-only', returns the underlying slot location. +export function getLabwareLocation( + params: GetLabwareLocationParams +): LocationResult | null { + const { + loadedLabwares, + loadedModules, + location, + detailLevel = 'full', + } = params + + if (location == null) { + return null + } else if (location === 'offDeck') { + return { slotName: 'offDeck' } + } else if ('slotName' in location) { + return { slotName: location.slotName } + } else if ('addressableAreaName' in location) { + return { slotName: location.addressableAreaName } + } else if ('moduleId' in location) { + const moduleModel = getModuleModel(loadedModules, location.moduleId) + if (moduleModel == null) { + console.error('labware is located on an unknown module model') + return null + } + const slotName = getModuleDisplayLocation(loadedModules, location.moduleId) + + return { + slotName, + moduleModel, + } + } else if ('labwareId' in location) { + if (!Array.isArray(loadedLabwares)) { + console.error('Cannot get location from loaded labwares object') + return null + } + + const adapter = loadedLabwares.find(lw => lw.id === location.labwareId) + + if (adapter == null) { + console.error('labware is located on an unknown adapter') + return null + } else if (detailLevel === 'slot-only') { + return getLabwareLocation({ + ...params, + location: adapter.location, + }) + } else if (detailLevel === 'full') { + const { allRunDefs } = params as LocationFullParams + const adapterDef = allRunDefs.find( + def => getLabwareDefURI(def) === adapter?.definitionUri + ) + const adapterName = + adapterDef != null ? getLabwareDisplayName(adapterDef) : '' + + if (adapter.location === 'offDeck') { + return { slotName: 'offDeck', adapterName } + } else if ( + 'slotName' in adapter.location || + 'addressableAreaName' in adapter.location + ) { + const slotName = + 'slotName' in adapter.location + ? adapter.location.slotName + : adapter.location.addressableAreaName + return { slotName, adapterName } + } else if ('moduleId' in adapter.location) { + const moduleIdUnderAdapter = adapter.location.moduleId + + if (!Array.isArray(loadedModules)) { + console.error('Cannot get location from loaded modules object') + return null + } + + const moduleModel = loadedModules.find( + module => module.id === moduleIdUnderAdapter + )?.model + + if (moduleModel == null) { + console.error('labware is located on an adapter on an unknown module') + return null + } + + const slotName = getModuleDisplayLocation( + loadedModules, + adapter.location.moduleId + ) + + return { + slotName, + moduleModel, + adapterName, + } + } else { + return null + } + } else { + console.error('Unhandled detailLevel.') + return null + } + } else { + return null + } +} diff --git a/app/src/local-resources/labware/utils/index.ts b/app/src/local-resources/labware/utils/index.ts index 73879e0956b..290e953d50f 100644 --- a/app/src/local-resources/labware/utils/index.ts +++ b/app/src/local-resources/labware/utils/index.ts @@ -5,3 +5,4 @@ export * from './getLabwareDefinitionsFromCommands' export * from './getLabwareName' export * from './getLoadedLabware' export * from './getLabwareDisplayLocation' +export * from './getLabwareLocation' diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts index 7e51669bd9a..165154992b1 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts @@ -9,6 +9,7 @@ import { } from '@opentrons/shared-data' import { mockPickUpTipLabware } from '../../__fixtures__' +import { getLabwareLocation } from '/app/local-resources/labware' import { getIsLabwareMatch, getSlotNameAndLwLocFrom, @@ -29,6 +30,7 @@ vi.mock('@opentrons/shared-data', async importOriginal => { getModuleDef2: vi.fn(), } }) +vi.mock('/app/local-resources/labware') describe('getRunCurrentModulesOnDeck', () => { const mockLabwareDef: LabwareDefinition2 = { @@ -49,12 +51,13 @@ describe('getRunCurrentModulesOnDeck', () => { moduleDef: mockModuleDef, slotName: 'A1', nestedLabwareDef: mockLabwareDef, - nestedLabwareSlotName: 'MOCK_MODULE_ID', + nestedLabwareSlotName: 'A1', }, ] beforeEach(() => { vi.mocked(getModuleDef2).mockReturnValue({ model: 'MOCK_MODEL' } as any) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) }) it('should return an array of RunCurrentModulesOnDeck objects', () => { @@ -64,9 +67,11 @@ describe('getRunCurrentModulesOnDeck', () => { location: { moduleId: 'MOCK_MODULE_ID' }, }, } as any + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) const result = getRunCurrentModulesOnDeck({ failedLabwareUtils: mockPickUpTipLabwareSameSlot, + runRecord: {} as any, currentModulesInfo: mockCurrentModulesInfo, }) @@ -76,13 +81,14 @@ describe('getRunCurrentModulesOnDeck', () => { moduleLocation: { slotName: 'A1' }, innerProps: {}, nestedLabwareDef: mockLabwareDef, - highlight: 'MOCK_MODULE_ID', + highlight: 'A1', }, ]) }) it('should set highlight to null if getIsLabwareMatch returns false', () => { const result = getRunCurrentModulesOnDeck({ failedLabwareUtils: mockFailedLabwareUtils, + runRecord: {} as any, currentModulesInfo: [ { ...mockCurrentModulesInfo[0], @@ -95,8 +101,11 @@ describe('getRunCurrentModulesOnDeck', () => { }) it('should set highlight to null if nestedLabwareDef is null', () => { + vi.mocked(getLabwareLocation).mockReturnValue(null) + const result = getRunCurrentModulesOnDeck({ failedLabwareUtils: mockFailedLabwareUtils, + runRecord: {} as any, currentModulesInfo: [ { ...mockCurrentModulesInfo[0], nestedLabwareDef: null }, ], @@ -126,8 +135,10 @@ describe('getRunCurrentLabwareOnDeck', () => { } as any it('should return a valid RunCurrentLabwareOnDeck with a labware highlight if the labware is the pickUpTipLabware', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) const result = getRunCurrentLabwareOnDeck({ currentLabwareInfo: [mockCurrentLabwareInfo], + runRecord: {} as any, failedLabwareUtils: mockFailedLabwareUtils, }) @@ -141,6 +152,7 @@ describe('getRunCurrentLabwareOnDeck', () => { }) it('should set highlight to null if getIsLabwareMatch returns false', () => { + vi.mocked(getLabwareLocation).mockReturnValue(null) const result = getRunCurrentLabwareOnDeck({ failedLabwareUtils: { ...mockFailedLabwareUtils, @@ -149,6 +161,7 @@ describe('getRunCurrentLabwareOnDeck', () => { location: { slotName: 'B1' }, }, }, + runRecord: {} as any, currentLabwareInfo: [mockCurrentLabwareInfo], }) @@ -201,6 +214,7 @@ describe('getRunCurrentModulesInfo', () => { }) it('should return an array of RunCurrentModuleInfo objects for each module in runRecord.data.modules', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) const result = getRunCurrentModulesInfo({ runRecord: mockRunRecord, deckDef: mockDeckDef, @@ -214,7 +228,7 @@ describe('getRunCurrentModulesInfo', () => { moduleId: mockModule.id, moduleDef: 'MOCK_MODULE_DEF', nestedLabwareDef: 'MOCK_LW_DEF', - nestedLabwareSlotName: 'MOCK_MODULE_ID', + nestedLabwareSlotName: 'A1', slotName: mockModule.location.slotName, }, ]) @@ -311,46 +325,64 @@ describe('getRunCurrentLabwareInfo', () => { describe('getSlotNameAndLwLocFrom', () => { it('should return [null, null] if location is null', () => { - const result = getSlotNameAndLwLocFrom(null, false) + const result = getSlotNameAndLwLocFrom(null, {} as any, false) expect(result).toEqual([null, null]) }) it('should return [null, null] if location is "offDeck"', () => { - const result = getSlotNameAndLwLocFrom('offDeck', false) + const result = getSlotNameAndLwLocFrom('offDeck', {} as any, false) expect(result).toEqual([null, null]) }) it('should return [null, null] if location has a moduleId and excludeModules is true', () => { - const result = getSlotNameAndLwLocFrom({ moduleId: 'MOCK_MODULE_ID' }, true) + const result = getSlotNameAndLwLocFrom( + { moduleId: 'MOCK_MODULE_ID' }, + {} as any, + true + ) expect(result).toEqual([null, null]) }) - it('should return [moduleId, { moduleId }] if location has a moduleId and excludeModules is false', () => { + it('should return [baseSlot, { moduleId }] if location has a moduleId and excludeModules is false', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) const result = getSlotNameAndLwLocFrom( { moduleId: 'MOCK_MODULE_ID' }, + {} as any, false ) - expect(result).toEqual(['MOCK_MODULE_ID', { moduleId: 'MOCK_MODULE_ID' }]) + expect(result).toEqual(['A1', { moduleId: 'MOCK_MODULE_ID' }]) }) - it('should return [labwareId, { labwareId }] if location has a labwareId', () => { - const result = getSlotNameAndLwLocFrom({ labwareId: 'MOCK_LW_ID' }, false) - expect(result).toEqual(['MOCK_LW_ID', { labwareId: 'MOCK_LW_ID' }]) + it('should return [baseSlot, { labwareId }] if location has a labwareId', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) + const result = getSlotNameAndLwLocFrom( + { labwareId: 'MOCK_LW_ID' }, + {} as any, + false + ) + expect(result).toEqual(['A1', { labwareId: 'MOCK_LW_ID' }]) }) it('should return [addressableAreaName, { addressableAreaName }] if location has an addressableAreaName', () => { - const result = getSlotNameAndLwLocFrom({ addressableAreaName: 'A1' }, false) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) + const result = getSlotNameAndLwLocFrom( + { addressableAreaName: 'A1' }, + {} as any, + false + ) expect(result).toEqual(['A1', { addressableAreaName: 'A1' }]) }) it('should return [slotName, { slotName }] if location has a slotName', () => { - const result = getSlotNameAndLwLocFrom({ slotName: 'A1' }, false) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) + const result = getSlotNameAndLwLocFrom({ slotName: 'A1' }, {} as any, false) expect(result).toEqual(['A1', { slotName: 'A1' }]) }) it('should return [null, null] if location does not match any known location type', () => { const result = getSlotNameAndLwLocFrom( { unknownProperty: 'MOCK_VALUE' } as any, + {} as any, false ) expect(result).toEqual([null, null]) @@ -358,57 +390,90 @@ describe('getSlotNameAndLwLocFrom', () => { }) describe('getIsLabwareMatch', () => { + beforeEach(() => { + vi.mocked(getLabwareLocation).mockReturnValue(null) + }) + it('should return false if pickUpTipLabware is null', () => { - const result = getIsLabwareMatch('A1', null) + const result = getIsLabwareMatch('A1', {} as any, null) expect(result).toBe(false) }) it('should return false if pickUpTipLabware location is a string', () => { - const result = getIsLabwareMatch('offdeck', { location: 'offdeck' } as any) + const result = getIsLabwareMatch( + 'offdeck', + {} as any, + { location: 'offdeck' } as any + ) expect(result).toBe(false) }) it('should return false if pickUpTipLabware location has a moduleId', () => { - const result = getIsLabwareMatch('A1', { - location: { moduleId: 'MOCK_MODULE_ID' }, - } as any) + const result = getIsLabwareMatch( + 'A1', + {} as any, + { + location: { moduleId: 'MOCK_MODULE_ID' }, + } as any + ) expect(result).toBe(false) }) it('should return true if pickUpTipLabware location slotName matches the provided slotName', () => { - const result = getIsLabwareMatch('A1', { - location: { slotName: 'A1' }, - } as any) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'A1' }) + const result = getIsLabwareMatch( + 'A1', + {} as any, + { + location: { slotName: 'A1' }, + } as any + ) expect(result).toBe(true) }) it('should return false if pickUpTipLabware location slotName does not match the provided slotName', () => { - const result = getIsLabwareMatch('A1', { - location: { slotName: 'A2' }, - } as any) + const result = getIsLabwareMatch( + 'A1', + {} as any, + { + location: { slotName: 'A2' }, + } as any + ) expect(result).toBe(false) }) it('should return true if pickUpTipLabware location labwareId matches the provided slotName', () => { - const result = getIsLabwareMatch('lwId', { - location: { labwareId: 'lwId' }, - } as any) + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'C1' }) + + const result = getIsLabwareMatch( + 'C1', + {} as any, + { + location: { labwareId: 'lwId' }, + } as any + ) expect(result).toBe(true) }) it('should return false if pickUpTipLabware location labwareId does not match the provided slotName', () => { - const result = getIsLabwareMatch('lwId', { - location: { labwareId: 'lwId2' }, - } as any) + const result = getIsLabwareMatch( + 'lwId', + {} as any, + { + location: { labwareId: 'lwId2' }, + } as any + ) expect(result).toBe(false) }) it('should return true if pickUpTipLabware location addressableAreaName matches the provided slotName', () => { + vi.mocked(getLabwareLocation).mockReturnValue({ slotName: 'B1' }) + const slotName = 'B1' const pickUpTipLabware = { location: { addressableAreaName: 'B1' }, } as any - const result = getIsLabwareMatch(slotName, pickUpTipLabware) + const result = getIsLabwareMatch(slotName, {} as any, pickUpTipLabware) expect(result).toBe(true) }) @@ -417,7 +482,7 @@ describe('getIsLabwareMatch', () => { const pickUpTipLabware = { location: { addressableAreaName: 'B2' }, } as any - const result = getIsLabwareMatch(slotName, pickUpTipLabware) + const result = getIsLabwareMatch(slotName, {} as any, pickUpTipLabware) expect(result).toBe(false) }) @@ -426,7 +491,7 @@ describe('getIsLabwareMatch', () => { const pickUpTipLabware = { location: { unknownProperty: 'someValue' }, } as any - const result = getIsLabwareMatch(slotName, pickUpTipLabware) + const result = getIsLabwareMatch(slotName, {} as any, pickUpTipLabware) expect(result).toBe(false) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts index 06453d06d08..3ef8993b984 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { + FLEX_ROBOT_TYPE, getDeckDefFromRobotType, getFixedTrashLabwareDefinition, getModuleDef2, @@ -14,6 +15,7 @@ import { getRunLabwareRenderInfo, getRunModuleRenderInfo, } from '/app/organisms/InterventionModal/utils' +import { getLabwareLocation } from '/app/local-resources/labware' import type { Run } from '@opentrons/api-client' import type { @@ -41,7 +43,7 @@ interface UseDeckMapUtilsProps { protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedLabwareUtils: UseFailedLabwareUtilsResult labwareDefinitionsByUri: ERUtilsProps['labwareDefinitionsByUri'] - runRecord?: Run + runRecord: Run | undefined } export interface UseDeckMapUtilsResult { @@ -83,6 +85,7 @@ export function useDeckMapUtils({ () => getRunCurrentModulesOnDeck({ failedLabwareUtils, + runRecord, currentModulesInfo, }), [runId, protocolAnalysis, runRecord, deckDef, failedLabwareUtils] @@ -97,9 +100,10 @@ export function useDeckMapUtils({ () => getRunCurrentLabwareOnDeck({ failedLabwareUtils, + runRecord, currentLabwareInfo, }), - [runId, protocolAnalysis, runRecord, deckDef, failedLabwareUtils] + [failedLabwareUtils, currentLabwareInfo] ) const movedLabwareDef = @@ -176,9 +180,11 @@ interface RunCurrentModulesOnDeck { // Builds the necessary module object expected by BaseDeck. export function getRunCurrentModulesOnDeck({ failedLabwareUtils, + runRecord, currentModulesInfo, }: { failedLabwareUtils: UseDeckMapUtilsProps['failedLabwareUtils'] + runRecord: UseDeckMapUtilsProps['runRecord'] currentModulesInfo: RunCurrentModuleInfo[] }): Array { const { failedLabware } = failedLabwareUtils @@ -193,7 +199,11 @@ export function getRunCurrentModulesOnDeck({ : {}, nestedLabwareDef, - highlight: getIsLabwareMatch(nestedLabwareSlotName, failedLabware) + highlight: getIsLabwareMatch( + nestedLabwareSlotName, + runRecord, + failedLabware + ) ? nestedLabwareSlotName : null, }) @@ -205,11 +215,15 @@ interface RunCurrentLabwareOnDeck { definition: LabwareDefinition2 } // Builds the necessary labware object expected by BaseDeck. +// Note that while this highlights all labware in the failed labware slot, the result is later filtered to render +// only the topmost labware. export function getRunCurrentLabwareOnDeck({ currentLabwareInfo, + runRecord, failedLabwareUtils, }: { failedLabwareUtils: UseDeckMapUtilsProps['failedLabwareUtils'] + runRecord: UseDeckMapUtilsProps['runRecord'] currentLabwareInfo: RunCurrentLabwareInfo[] }): Array { const { failedLabware } = failedLabwareUtils @@ -218,7 +232,9 @@ export function getRunCurrentLabwareOnDeck({ ({ slotName, labwareDef, labwareLocation }) => ({ labwareLocation, definition: labwareDef, - highlight: getIsLabwareMatch(slotName, failedLabware) ? slotName : null, + highlight: getIsLabwareMatch(slotName, runRecord, failedLabware) + ? slotName + : null, }) ) } @@ -267,7 +283,11 @@ export const getRunCurrentModulesInfo = ({ ) const nestedLwLoc = nestedLabware?.location ?? null - const [nestedLwSlotName] = getSlotNameAndLwLocFrom(nestedLwLoc, false) + const [nestedLwSlotName] = getSlotNameAndLwLocFrom( + nestedLwLoc, + runRecord, + false + ) if (slotPosition == null) { return acc @@ -306,24 +326,60 @@ export function getRunCurrentLabwareInfo({ if (runRecord == null || labwareDefinitionsByUri == null) { return [] } else { - return runRecord.data.labware.reduce((acc: RunCurrentLabwareInfo[], lw) => { - const loc = lw.location - const [slotName, labwareLocation] = getSlotNameAndLwLocFrom(loc, true) // Exclude modules since handled separately. - const labwareDef = getLabwareDefinition(lw, labwareDefinitionsByUri) - - if (slotName == null || labwareLocation == null) { - return acc - } else { - return [ - ...acc, - { - labwareDef, - slotName, - labwareLocation: labwareLocation, - }, - ] + const allLabware = runRecord.data.labware.reduce( + (acc: RunCurrentLabwareInfo[], lw) => { + const loc = lw.location + const [slotName, labwareLocation] = getSlotNameAndLwLocFrom( + loc, + runRecord, + true + ) // Exclude modules since handled separately. + const labwareDef = getLabwareDefinition(lw, labwareDefinitionsByUri) + + if (slotName == null || labwareLocation == null) { + return acc + } else { + return [ + ...acc, + { + labwareDef, + slotName, + labwareLocation: labwareLocation, + }, + ] + } + }, + [] + ) + + // Group labware by slotName + const labwareBySlot = allLabware.reduce< + Record + >((acc, labware) => { + const slot = labware.slotName + if (!acc[slot]) { + acc[slot] = [] } - }, []) + acc[slot].push(labware) + return acc + }, {}) + + // For each slot, return either: + // 1. The first labware with 'labwareId' in its location if it exists + // 2. The first labware in the slot if no labware has 'labwareId' + return Object.values(labwareBySlot).map(slotLabware => { + const labwareWithId = slotLabware.find( + lw => + typeof lw.labwareLocation !== 'string' && + 'labwareId' in lw.labwareLocation + ) + return labwareWithId != null + ? { + ...labwareWithId, + labwareLocation: { slotName: labwareWithId.slotName }, + } + : slotLabware[0] + }) } } @@ -341,8 +397,18 @@ const getLabwareDefinition = ( // Get the slotName for on deck labware. export function getSlotNameAndLwLocFrom( location: LabwareLocation | null, + runRecord: UseDeckMapUtilsProps['runRecord'], excludeModules: boolean ): [string | null, LabwareLocation | null] { + const baseSlot = + getLabwareLocation({ + location, + detailLevel: 'slot-only', + loadedLabwares: runRecord?.data?.labware ?? [], + loadedModules: runRecord?.data?.modules ?? [], + robotType: FLEX_ROBOT_TYPE, + })?.slotName ?? null + if (location == null || location === 'offDeck') { return [null, null] } else if ('moduleId' in location) { @@ -350,17 +416,17 @@ export function getSlotNameAndLwLocFrom( return [null, null] } else { const moduleId = location.moduleId - return [moduleId, { moduleId }] + return [baseSlot, { moduleId }] } } else if ('labwareId' in location) { const labwareId = location.labwareId - return [labwareId, { labwareId }] + return [baseSlot, { labwareId }] } else if ('addressableAreaName' in location) { const addressableAreaName = location.addressableAreaName - return [addressableAreaName, { addressableAreaName }] + return [baseSlot, { addressableAreaName }] } else if ('slotName' in location) { const slotName = location.slotName - return [slotName, { slotName }] + return [baseSlot, { slotName }] } else { return [null, null] } @@ -369,9 +435,19 @@ export function getSlotNameAndLwLocFrom( // Whether the slotName labware is the same as the pickUpTipLabware. export function getIsLabwareMatch( slotName: string, + runRecord: UseDeckMapUtilsProps['runRecord'], pickUpTipLabware: LoadedLabware | null ): boolean { - const location = pickUpTipLabware?.location + const location = pickUpTipLabware?.location ?? null + + const slotLocation = + getLabwareLocation({ + location, + detailLevel: 'slot-only', + loadedLabwares: runRecord?.data?.labware ?? [], + loadedModules: runRecord?.data?.modules ?? [], + robotType: FLEX_ROBOT_TYPE, + })?.slotName ?? null if (location == null) { return false @@ -379,13 +455,7 @@ export function getIsLabwareMatch( // This is the "off deck" case, which we do not render (and therefore return false). else if (typeof location === 'string') { return false - } else if ('moduleId' in location) { - return location.moduleId === slotName - } else if ('slotName' in location) { - return location.slotName === slotName - } else if ('labwareId' in location) { - return location.labwareId === slotName - } else if ('addressableAreaName' in location) { - return location.addressableAreaName === slotName - } else return false + } else { + return slotLocation === slotName + } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 9ce04df1bdf..14372409c44 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -28,7 +28,7 @@ import type { MoveLabwareRunTimeCommand, LabwareLocation, } from '@opentrons/shared-data' -import type { LabwareDisplayLocationSlotOnly } from '/app/local-resources/labware' +import type { DisplayLocationSlotOnlyParams } from '/app/local-resources/labware' import type { ErrorRecoveryFlowsProps } from '..' import type { ERUtilsProps } from './useERUtils' @@ -356,10 +356,7 @@ export function useRelevantFailedLwLocations({ }: GetRelevantLwLocationsParams): RelevantFailedLabwareLocations { const { t } = useTranslation('protocol_command_text') - const BASE_DISPLAY_PARAMS: Omit< - LabwareDisplayLocationSlotOnly, - 'location' - > = { + const BASE_DISPLAY_PARAMS: Omit = { loadedLabwares: runRecord?.data?.labware ?? [], loadedModules: runRecord?.data?.modules ?? [], robotType: FLEX_ROBOT_TYPE, diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx index ca1f36647bd..e378b25d769 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx @@ -12,7 +12,6 @@ import { RecoverySingleColumnContentWrapper } from './RecoveryContentWrapper' import { TwoColumn, DeckMapContent } from '/app/molecules/InterventionModal' import { RecoveryFooterButtons } from './RecoveryFooterButtons' import { LeftColumnLabwareInfo } from './LeftColumnLabwareInfo' -import { getSlotNameAndLwLocFrom } from '../hooks/useDeckMapUtils' import { RECOVERY_MAP } from '../constants' import type * as React from 'react' @@ -46,7 +45,9 @@ export function TwoColLwInfoAndDeck( void proceedNextStep() } - const [slot] = getSlotNameAndLwLocFrom(failedLabware?.location ?? null, false) + const { + displayNameCurrentLoc: slot, + } = failedLabwareUtils.failedLabwareLocations const buildTitle = (): string => { switch (selectedRecoveryOption) { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx index 2f24fc0f3bb..15094e5aacb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx @@ -49,7 +49,11 @@ describe('TwoColLwInfoAndDeck', () => { failedLabwareUtils: { relevantWellName: 'A1', failedLabware: { location: 'C1' }, - failedLabwareLocations: { newLoc: {}, currentLoc: {} }, + failedLabwareLocations: { + newLoc: {}, + currentLoc: {}, + displayNameCurrentLoc: 'Slot C1', + }, }, deckMapUtils: { movedLabwareDef: {}, From 56329ccd5f9276d3059b994eb3ad10a84cc81e15 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 30 Oct 2024 12:04:37 -0400 Subject: [PATCH 02/49] fix(api): Fix Yocto check preventing OT-2s from booting (#16639) Same as https://github.com/Opentrons/opentrons/pull/16637, but into the release branch. (cherry picked from commit d140271f4e318e3f2470a4d8c446156825bc9492) --- api/src/opentrons/util/logging_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/opentrons/util/logging_config.py b/api/src/opentrons/util/logging_config.py index 0a36468f3bc..6520bb912f6 100644 --- a/api/src/opentrons/util/logging_config.py +++ b/api/src/opentrons/util/logging_config.py @@ -5,7 +5,7 @@ from opentrons.config import CONFIG, ARCHITECTURE, SystemArchitecture -if ARCHITECTURE is SystemArchitecture.BUILDROOT: +if ARCHITECTURE is SystemArchitecture.YOCTO: from opentrons_hardware.sensors import SENSOR_LOG_NAME else: # we don't use the sensor log on ot2 or host From beb298d38d3ad39241aa6decec1584b2130b89bf Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 30 Oct 2024 12:34:45 -0400 Subject: [PATCH 03/49] fix(app): fix dropping tips in predefined waste chute location during Error Recovery (#16636) Closes EXEC-794 8.2.0 adds predefined drop tip locations, and during drop tip wizard, the flow does not always have context concerning tip attachment status (ie, during maintenance runs). During a maintenance run, to automatically jog the pipette down somewhat before dropping the tip, we moveToAddressableArea with a predefined offset. While this works well for maintenance run type drop tip flows, it does not always play nicely during fixit type drop flows (ie, during Error Recovery). When attempting to move to an addressable area with the predefined z-offset, the command may fail with an error pointing to the z-offset being invalid given the attached tip. There are several potential ways to work around this including: * Never specify an offset, always dropping into the waste chute (and trash bin) at maximum height. This works, but could be unideal in a real world scenario. * Use the predefined z-offset during maintenance run type drop tip flows only (no offset during Error Recovery/fixit type flows). This works, however, the z-offset moved traversed during fixit type flows is less than during a maintenance run type flow. * During a fixit type flow, get some information about currently attached tip length and use that. To keep complexity down and reduce the bug surface, this PR implements option 2. Additionally, it's probably not a good idea to drop tips or blowout if the moveToAddressableArea command fails, so let's change that! --- .../DropTipWizardFlows/hooks/useDropTipCommands.ts | 6 +++--- .../organisms/DropTipWizardFlows/steps/ChooseLocation.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts index 63d3f264ae1..96875b4c5f3 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts @@ -102,7 +102,7 @@ export function useDropTipCommands({ const moveToAddressableArea = ( addressableArea: AddressableAreaName, - stayAtHighestPossibleZ = true // Generally false when moving to a waste chute or trash bin. + stayAtHighestPossibleZ = true // Generally false when moving to a waste chute or trash bin or during "fixit" flows. ): Promise => { return new Promise((resolve, reject) => { const addressableAreaFromConfig = getAddressableAreaFromConfig( @@ -127,7 +127,7 @@ export function useDropTipCommands({ moveToAACommand, ] : [Z_HOME, moveToAACommand], - true + false ) .then((commandData: CommandData[]) => { const error = commandData[0].data.error @@ -222,7 +222,7 @@ export function useDropTipCommands({ currentRoute === DT_ROUTES.BLOWOUT ? buildBlowoutCommands(instrumentModelSpecs, isFlex, pipetteId) : buildDropTipInPlaceCommand(isFlex, pipetteId), - true + false ) .then((commandData: CommandData[]) => { const error = commandData[0].data.error diff --git a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx index e53f0006bf0..08d9831c0b3 100644 --- a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx +++ b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx @@ -32,6 +32,7 @@ interface ChooseLocationProps extends DropTipWizardContainerProps { } export function ChooseLocation({ + issuedCommandsType, dropTipCommandLocations, dropTipCommands, goBackRunValid, @@ -97,7 +98,7 @@ export function ChooseLocation({ toggleIsRobotPipetteMoving() void moveToAddressableArea( selectedLocation?.slotName as AddressableAreaName, - false + issuedCommandsType === 'fixit' // Because PE has tip state during fixit flows, do not specify a manual offset. ).then(() => { void blowoutOrDropTip(currentRoute, () => { const successStep = From a74408df5c1e778d5e3e0a8c563a37921ea0d47e Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 30 Oct 2024 17:17:00 -0400 Subject: [PATCH 04/49] fix(app): fix errant GET /run/:runId requests (#16645) When viewing the Devices tab on the desktop app, there are a lot of GET requests to /runs/null. These errant requests are generated by two places: useRunStatuses The complex enabled logic has bit us in the past here - in this case, the custom enabled condition overrides the default condition not to fetch if there is no runId, creating the errant fetch. Prior to notifications, it was important to selectively enable this hook to prevent polling unnecessarily. However, telemetry indicates that pretty much everyone uses notifications, and this hook uses notifications, so for the sake of keeping things simple, let's just remove this logic. Worst case scenario, it's not terrible to poll here if MQTT is blocked. useNotifyRunQuery A manual refetch() does not use the underlying enabled conditional logic of useRunQuery. There are two ways to solve it, either the way in this PR, or do extend useRunQuery to return a manual refetch function that wraps the custom, lower-level enabled logic. For simplicity, I've chosen path 1, but I do think it would be worth revisiting this if we add more notification hooks with "always fetch only in specific circumstances" type logic. --- app/src/resources/runs/useNotifyRunQuery.ts | 2 +- app/src/resources/runs/useRunStatus.ts | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/src/resources/runs/useNotifyRunQuery.ts b/app/src/resources/runs/useNotifyRunQuery.ts index ba742b69e03..27b15a2d8e2 100644 --- a/app/src/resources/runs/useNotifyRunQuery.ts +++ b/app/src/resources/runs/useNotifyRunQuery.ts @@ -20,7 +20,7 @@ export function useNotifyRunQuery( const httpQueryResult = useRunQuery(runId, queryOptionsNotify, hostOverride) - if (shouldRefetch) { + if (shouldRefetch && runId != null) { void httpQueryResult.refetch() } diff --git a/app/src/resources/runs/useRunStatus.ts b/app/src/resources/runs/useRunStatus.ts index a1a1d5dc7cd..221819da76e 100644 --- a/app/src/resources/runs/useRunStatus.ts +++ b/app/src/resources/runs/useRunStatus.ts @@ -1,9 +1,7 @@ -import { useRef } from 'react' import { RUN_ACTION_TYPE_PLAY, RUN_STATUS_IDLE, RUN_STATUS_RUNNING, - RUN_STATUSES_TERMINAL, } from '@opentrons/api-client' import { useNotifyRunQuery } from './useNotifyRunQuery' import { DEFAULT_STATUS_REFETCH_INTERVAL } from './constants' @@ -15,14 +13,8 @@ export function useRunStatus( runId: string | null, options?: UseQueryOptions ): RunStatus | null { - const lastRunStatus = useRef(null) - const { data } = useNotifyRunQuery(runId ?? null, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, - enabled: - lastRunStatus.current == null || - !(RUN_STATUSES_TERMINAL as RunStatus[]).includes(lastRunStatus.current), - onSuccess: data => (lastRunStatus.current = data?.data?.status ?? null), ...options, }) From ec7641c500c1c31a71bd992155d69bf62c8d3d87 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Thu, 31 Oct 2024 09:12:39 -0400 Subject: [PATCH 05/49] fix(api, shared-data): Allow labware lids to be disposed in the trash bin (#16638) Covers PLAT-539 Allows the TC lid to be dropped in the trash bin at a slight offset. --- api/src/opentrons/legacy_commands/helpers.py | 10 ++- .../protocol_api/core/engine/labware.py | 4 + .../protocol_api/core/engine/protocol.py | 5 ++ .../opentrons/protocol_api/core/labware.py | 4 + .../core/legacy/legacy_labware_core.py | 5 ++ .../core/legacy/legacy_protocol_core.py | 1 + .../opentrons/protocol_api/core/protocol.py | 1 + .../protocol_api/protocol_context.py | 11 ++- .../protocol_engine/commands/move_labware.py | 44 ++++++++++- .../resources/fixture_validation.py | 7 +- .../protocol_engine/state/labware.py | 9 ++- .../protocol_api/test_protocol_context.py | 76 +++++++++++++++++++ .../1.json | 12 +++ 13 files changed, 177 insertions(+), 12 deletions(-) diff --git a/api/src/opentrons/legacy_commands/helpers.py b/api/src/opentrons/legacy_commands/helpers.py index b3de03de4bc..5b08bb1e436 100644 --- a/api/src/opentrons/legacy_commands/helpers.py +++ b/api/src/opentrons/legacy_commands/helpers.py @@ -49,7 +49,9 @@ def stringify_disposal_location(location: Union[TrashBin, WasteChute]) -> str: def _stringify_labware_movement_location( - location: Union[DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute] + location: Union[ + DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute, TrashBin + ] ) -> str: if isinstance(location, (int, str)): return f"slot {location}" @@ -61,11 +63,15 @@ def _stringify_labware_movement_location( return str(location) elif isinstance(location, WasteChute): return "Waste Chute" + elif isinstance(location, TrashBin): + return "Trash Bin " + location.location.name def stringify_labware_movement_command( source_labware: Labware, - destination: Union[DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute], + destination: Union[ + DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute, TrashBin + ], use_gripper: bool, ) -> str: source_labware_text = _stringify_labware_movement_location(source_labware) diff --git a/api/src/opentrons/protocol_api/core/engine/labware.py b/api/src/opentrons/protocol_api/core/engine/labware.py index f09a51ef181..9648805c563 100644 --- a/api/src/opentrons/protocol_api/core/engine/labware.py +++ b/api/src/opentrons/protocol_api/core/engine/labware.py @@ -139,6 +139,10 @@ def is_adapter(self) -> bool: """Whether the labware is an adapter.""" return LabwareRole.adapter in self._definition.allowedRoles + def is_lid(self) -> bool: + """Whether the labware is a lid.""" + return LabwareRole.lid in self._definition.allowedRoles + def is_fixed_trash(self) -> bool: """Whether the labware is a fixed trash.""" return self._engine_client.state.labware.is_fixed_trash( diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index dac8bc44a5b..e4decd7318c 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -329,6 +329,7 @@ def move_labware( NonConnectedModuleCore, OffDeckType, WasteChute, + TrashBin, ], use_gripper: bool, pause_for_manual_move: bool, @@ -807,6 +808,7 @@ def _convert_labware_location( NonConnectedModuleCore, OffDeckType, WasteChute, + TrashBin, ], ) -> LabwareLocation: if isinstance(location, LabwareCore): @@ -823,6 +825,7 @@ def _get_non_stacked_location( NonConnectedModuleCore, OffDeckType, WasteChute, + TrashBin, ] ) -> NonStackedLocation: if isinstance(location, (ModuleCore, NonConnectedModuleCore)): @@ -836,3 +839,5 @@ def _get_non_stacked_location( elif isinstance(location, WasteChute): # TODO(mm, 2023-12-06) This will need to determine the appropriate Waste Chute to return, but only move_labware uses this for now return AddressableAreaLocation(addressableAreaName="gripperWasteChute") + elif isinstance(location, TrashBin): + return AddressableAreaLocation(addressableAreaName=location.area_name) diff --git a/api/src/opentrons/protocol_api/core/labware.py b/api/src/opentrons/protocol_api/core/labware.py index 67b452cca6d..691a764e8d3 100644 --- a/api/src/opentrons/protocol_api/core/labware.py +++ b/api/src/opentrons/protocol_api/core/labware.py @@ -97,6 +97,10 @@ def is_tip_rack(self) -> bool: def is_adapter(self) -> bool: """Whether the labware is an adapter.""" + @abstractmethod + def is_lid(self) -> bool: + """Whether the labware is a lid.""" + @abstractmethod def is_fixed_trash(self) -> bool: """Whether the labware is a fixed trash.""" diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_labware_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_labware_core.py index 575fd7a8cc6..06411765d51 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_labware_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_labware_core.py @@ -138,6 +138,11 @@ def is_tip_rack(self) -> bool: def is_adapter(self) -> bool: return False # Adapters were introduced in v2.15 and not supported in legacy protocols + def is_lid(self) -> bool: + return ( + False # Lids were introduced in v2.21 and not supported in legacy protocols + ) + def is_fixed_trash(self) -> bool: """Whether the labware is fixed trash.""" return "fixedTrash" in self.get_quirks() diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py index aeef0e9d7c7..eac5a9109fa 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py @@ -277,6 +277,7 @@ def move_labware( legacy_module_core.LegacyModuleCore, OffDeckType, WasteChute, + TrashBin, ], use_gripper: bool, pause_for_manual_move: bool, diff --git a/api/src/opentrons/protocol_api/core/protocol.py b/api/src/opentrons/protocol_api/core/protocol.py index 9c3692c7e44..f79ab987157 100644 --- a/api/src/opentrons/protocol_api/core/protocol.py +++ b/api/src/opentrons/protocol_api/core/protocol.py @@ -104,6 +104,7 @@ def move_labware( ModuleCoreType, OffDeckType, WasteChute, + TrashBin, ], use_gripper: bool, pause_for_manual_move: bool, diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 43c5956afd9..ed7d24f4d3f 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -45,6 +45,7 @@ UnsupportedAPIError, ) from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated +from opentrons.protocol_engine.errors import LabwareMovementNotAllowedError from ._types import OffDeckType from .core.common import ModuleCore, LabwareCore, ProtocolCore @@ -668,7 +669,7 @@ def move_labware( self, labware: Labware, new_location: Union[ - DeckLocation, Labware, ModuleTypes, OffDeckType, WasteChute + DeckLocation, Labware, ModuleTypes, OffDeckType, WasteChute, TrashBin ], use_gripper: bool = False, pick_up_offset: Optional[Mapping[str, float]] = None, @@ -727,11 +728,19 @@ def move_labware( OffDeckType, DeckSlotName, StagingSlotName, + TrashBin, ] if isinstance(new_location, (Labware, ModuleContext)): location = new_location._core elif isinstance(new_location, (OffDeckType, WasteChute)): location = new_location + elif isinstance(new_location, TrashBin): + if labware._core.is_lid(): + location = new_location + else: + raise LabwareMovementNotAllowedError( + "Can only dispose of tips and Lid-type labware in a Trash Bin. Did you mean to use a Waste Chute?" + ) else: location = validation.ensure_and_convert_deck_slot( new_location, self._api_version, self._core.robot_type diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index 0d2967e87d5..eb4b101e76c 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -22,7 +22,11 @@ LabwareOffsetVector, LabwareMovementOffsetData, ) -from ..errors import LabwareMovementNotAllowedError, NotSupportedOnRobotType +from ..errors import ( + LabwareMovementNotAllowedError, + NotSupportedOnRobotType, + LabwareOffsetDoesNotExistError, +) from ..resources import labware_validation, fixture_validation from .command import ( AbstractCommandImpl, @@ -130,6 +134,7 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C ) definition_uri = current_labware.definitionUri post_drop_slide_offset: Optional[Point] = None + trash_lid_drop_offset: Optional[LabwareOffsetVector] = None if self._state_view.labware.is_fixed_trash(params.labwareId): raise LabwareMovementNotAllowedError( @@ -138,9 +143,11 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C if isinstance(params.newLocation, AddressableAreaLocation): area_name = params.newLocation.addressableAreaName - if not fixture_validation.is_gripper_waste_chute( - area_name - ) and not fixture_validation.is_deck_slot(area_name): + if ( + not fixture_validation.is_gripper_waste_chute(area_name) + and not fixture_validation.is_deck_slot(area_name) + and not fixture_validation.is_trash(area_name) + ): raise LabwareMovementNotAllowedError( f"Cannot move {current_labware.loadName} to addressable area {area_name}" ) @@ -162,6 +169,32 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C y=0, z=0, ) + elif fixture_validation.is_trash(area_name): + # When dropping labware in the trash bins we want to ensure they are lids + # and enforce a y-axis drop offset to ensure they fall within the trash bin + if labware_validation.validate_definition_is_lid( + self._state_view.labware.get_definition(params.labwareId) + ): + lid_disposable_offfets = ( + current_labware_definition.gripperOffsets.get( + "lidDisposalOffsets" + ) + ) + if lid_disposable_offfets is not None: + trash_lid_drop_offset = LabwareOffsetVector( + x=lid_disposable_offfets.dropOffset.x, + y=lid_disposable_offfets.dropOffset.y, + z=lid_disposable_offfets.dropOffset.z, + ) + else: + raise LabwareOffsetDoesNotExistError( + f"Labware Definition {current_labware.loadName} does not contain required field 'lidDisposalOffsets' of 'gripperOffsets'." + ) + else: + raise LabwareMovementNotAllowedError( + "Can only move labware with allowed role 'Lid' to a Trash Bin." + ) + elif isinstance(params.newLocation, DeckSlotLocation): self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( params.newLocation.slotName.id @@ -232,6 +265,9 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C dropOffset=params.dropOffset or LabwareOffsetVector(x=0, y=0, z=0), ) + if trash_lid_drop_offset: + user_offset_data.dropOffset += trash_lid_drop_offset + try: # Skips gripper moves when using virtual gripper await self._labware_movement.move_labware_with_gripper( diff --git a/api/src/opentrons/protocol_engine/resources/fixture_validation.py b/api/src/opentrons/protocol_engine/resources/fixture_validation.py index 745df22d712..a17bf147f85 100644 --- a/api/src/opentrons/protocol_engine/resources/fixture_validation.py +++ b/api/src/opentrons/protocol_engine/resources/fixture_validation.py @@ -29,7 +29,12 @@ def is_drop_tip_waste_chute(addressable_area_name: str) -> bool: def is_trash(addressable_area_name: str) -> bool: """Check if an addressable area is a trash bin.""" - return addressable_area_name in {"movableTrash", "fixedTrash", "shortFixedTrash"} + return any( + [ + s in addressable_area_name + for s in {"movableTrash", "fixedTrash", "shortFixedTrash"} + ] + ) def is_staging_slot(addressable_area_name: str) -> bool: diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 7cea4f9765b..0bbb8b3ab30 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -227,10 +227,11 @@ def _set_labware_location(self, state_update: update_types.StateUpdate) -> None: if labware_location_update.new_location: new_location = labware_location_update.new_location - if isinstance( - new_location, AddressableAreaLocation - ) and fixture_validation.is_gripper_waste_chute( - new_location.addressableAreaName + if isinstance(new_location, AddressableAreaLocation) and ( + fixture_validation.is_gripper_waste_chute( + new_location.addressableAreaName + ) + or fixture_validation.is_trash(new_location.addressableAreaName) ): # If a labware has been moved into a waste chute it's been chuted away and is now technically off deck new_location = OFF_DECK_LOCATION diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index 2bedbd5fb6f..5e516a5b274 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -49,6 +49,8 @@ from opentrons.protocols.api_support.deck_type import ( NoTrashDefinedError, ) +from opentrons.protocol_engine.errors import LabwareMovementNotAllowedError +from opentrons.protocol_engine.clients import SyncClient as EngineClient @pytest.fixture(autouse=True) @@ -101,6 +103,12 @@ def api_version() -> APIVersion: return MAX_SUPPORTED_VERSION +@pytest.fixture +def mock_engine_client(decoy: Decoy) -> EngineClient: + """Get a mock ProtocolEngine synchronous client.""" + return decoy.mock(cls=EngineClient) + + @pytest.fixture def subject( mock_core: ProtocolCore, @@ -944,6 +952,74 @@ def test_move_labware_off_deck_raises( subject.move_labware(labware=movable_labware, new_location=OFF_DECK) +def test_move_labware_to_trash_raises( + subject: ProtocolContext, + decoy: Decoy, + mock_core: ProtocolCore, + mock_core_map: LoadedCoreMap, + mock_engine_client: EngineClient, +) -> None: + """It should raise an LabwareMovementNotAllowedError if using move_labware to move something that is not a lid to a TrashBin.""" + mock_labware_core = decoy.mock(cls=LabwareCore) + trash_location = TrashBin( + location=DeckSlotName.SLOT_D3, + addressable_area_name="moveableTrashD3", + api_version=MAX_SUPPORTED_VERSION, + engine_client=mock_engine_client, + ) + + decoy.when(mock_labware_core.get_well_columns()).then_return([]) + + movable_labware = Labware( + core=mock_labware_core, + api_version=MAX_SUPPORTED_VERSION, + protocol_core=mock_core, + core_map=mock_core_map, + ) + + with pytest.raises(LabwareMovementNotAllowedError): + subject.move_labware(labware=movable_labware, new_location=trash_location) + + +def test_move_lid_to_trash_passes( + decoy: Decoy, + mock_core: ProtocolCore, + mock_core_map: LoadedCoreMap, + subject: ProtocolContext, + mock_engine_client: EngineClient, +) -> None: + """It should move a lid labware into a trashbin successfully.""" + mock_labware_core = decoy.mock(cls=LabwareCore) + trash_location = TrashBin( + location=DeckSlotName.SLOT_D3, + addressable_area_name="moveableTrashD3", + api_version=MAX_SUPPORTED_VERSION, + engine_client=mock_engine_client, + ) + + decoy.when(mock_labware_core.get_well_columns()).then_return([]) + decoy.when(mock_labware_core.is_lid()).then_return(True) + + movable_labware = Labware( + core=mock_labware_core, + api_version=MAX_SUPPORTED_VERSION, + protocol_core=mock_core, + core_map=mock_core_map, + ) + + subject.move_labware(labware=movable_labware, new_location=trash_location) + decoy.verify( + mock_core.move_labware( + labware_core=mock_labware_core, + new_location=trash_location, + use_gripper=False, + pause_for_manual_move=True, + pick_up_offset=None, + drop_offset=None, + ) + ) + + def test_load_trash_bin( decoy: Decoy, mock_core: ProtocolCore, diff --git a/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json index d5d56101e7f..f872649a027 100644 --- a/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json +++ b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json @@ -98,6 +98,18 @@ "y": 0, "z": -1 } + }, + "lidDisposalOffsets": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + }, + "dropOffset": { + "x": 0, + "y": 5.0, + "z": 50.0 + } } } } From 4945928df4358c9a8a3d12b844aef2bfe434e484 Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Thu, 31 Oct 2024 11:30:43 -0400 Subject: [PATCH 06/49] fix(api): update the plate reader serial number parser to include BYO and OPT delims. (#16650) --- .../drivers/absorbance_reader/async_byonoy.py | 6 ++-- .../drivers/absorbance_reader/test_driver.py | 33 ++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py b/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py index dc88c1b2dec..0460a016229 100644 --- a/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py +++ b/api/src/opentrons/drivers/absorbance_reader/async_byonoy.py @@ -24,7 +24,7 @@ SN_PARSER = re.compile(r'ATTRS{serial}=="(?P.+?)"') VERSION_PARSER = re.compile(r"Absorbance (?PV\d+\.\d+\.\d+)") -SERIAL_PARSER = re.compile(r"(?PBYO[A-Z]{3}[0-9]{5})") +SERIAL_PARSER = re.compile(r"(?P(OPT|BYO)[A-Z]{3}[0-9]+)") class AsyncByonoy: @@ -156,9 +156,9 @@ async def get_device_information(self) -> Dict[str, str]: func=partial(self._interface.get_device_information, handle), ) self._raise_if_error(err.name, f"Error getting device information: {err}") - serial_match = SERIAL_PARSER.match(device_info.sn) + serial_match = SERIAL_PARSER.fullmatch(device_info.sn) version_match = VERSION_PARSER.match(device_info.version) - serial = serial_match["serial"] if serial_match else "BYOMAA00000" + serial = serial_match["serial"].strip() if serial_match else "OPTMAA00000" version = version_match["version"].lower() if version_match else "v0.0.0" info = { "serial": serial, diff --git a/api/tests/opentrons/drivers/absorbance_reader/test_driver.py b/api/tests/opentrons/drivers/absorbance_reader/test_driver.py index 0284f277e2c..58552695f44 100644 --- a/api/tests/opentrons/drivers/absorbance_reader/test_driver.py +++ b/api/tests/opentrons/drivers/absorbance_reader/test_driver.py @@ -90,8 +90,39 @@ async def test_driver_get_device_info( info = await connected_driver.get_device_info() - mock_interface.get_device_information.assert_called_once() assert info == {"serial": "BYOMAA00013", "model": "ABS96", "version": "v1.0.2"} + mock_interface.get_device_information.assert_called_once() + mock_interface.reset_mock() + + # Test Device info with updated serial format + DEVICE_INFO.sn = "OPTMAA00034" + DEVICE_INFO.version = "Absorbance V1.0.2 2024-04-18" + + mock_interface.get_device_information.return_value = ( + MockErrorCode.NO_ERROR, + DEVICE_INFO, + ) + + info = await connected_driver.get_device_info() + + assert info == {"serial": "OPTMAA00034", "model": "ABS96", "version": "v1.0.2"} + mock_interface.get_device_information.assert_called_once() + mock_interface.reset_mock() + + # Test Device info with invalid serial format + DEVICE_INFO.sn = "YRFGHVMAA00034" + DEVICE_INFO.version = "Absorbance V1.0.2 2024-04-18" + + mock_interface.get_device_information.return_value = ( + MockErrorCode.NO_ERROR, + DEVICE_INFO, + ) + + info = await connected_driver.get_device_info() + + assert info == {"serial": "OPTMAA00000", "model": "ABS96", "version": "v1.0.2"} + mock_interface.get_device_information.assert_called_once() + mock_interface.reset_mock() @pytest.mark.parametrize( From 88eab97b77f9aac0c3086bd724840863ec93914a Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Thu, 31 Oct 2024 13:58:08 -0400 Subject: [PATCH 07/49] fix(app): Filter out unconfirmable setup steps on desktop (#16655) Closes RQA-3464 While the ODD was already filtering out setup steps that can't be confirmed by the app, the desktop app wasn't. This PR just filters out those unconfirmable steps in the "confirm setup" modal. --- .../hooks/useMissingStepsModal.ts | 20 ++++++++++++++++--- app/src/pages/ODD/ProtocolSetup/index.tsx | 1 + app/src/redux/protocol-runs/selectors.ts | 1 + 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts index d0506c55153..a25a201ef13 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts @@ -4,7 +4,11 @@ import { useConditionalConfirm } from '@opentrons/components' import { useIsHeaterShakerInProtocol } from '/app/organisms/ModuleCard/hooks' import { isAnyHeaterShakerShaking } from '../modals' -import { getMissingSetupSteps } from '/app/redux/protocol-runs' +import { + getMissingSetupSteps, + MODULE_SETUP_STEP_KEY, + ROBOT_CALIBRATION_STEP_KEY, +} from '/app/redux/protocol-runs' import type { UseConditionalConfirmResult } from '@opentrons/components' import type { RunStatus, AttachedModule } from '@opentrons/api-client' @@ -12,6 +16,11 @@ import type { ConfirmMissingStepsModalProps } from '../modals' import type { State } from '/app/redux/types' import type { StepKey } from '/app/redux/protocol-runs' +const UNCONFIRMABLE_MISSING_STEPS = new Set([ + ROBOT_CALIBRATION_STEP_KEY, + MODULE_SETUP_STEP_KEY, +]) + interface UseMissingStepsModalProps { runStatus: RunStatus | null attachedModules: AttachedModule[] @@ -47,9 +56,14 @@ export function useMissingStepsModal({ !isHeaterShakerShaking && (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + // Certain steps are not confirmed by the app, so don't include these in the modal. + const reportableMissingSetupSteps = missingSetupSteps.filter( + step => !UNCONFIRMABLE_MISSING_STEPS.has(step) + ) + const conditionalConfirmUtils = useConditionalConfirm( handleProceedToRunClick, - missingSetupSteps.length !== 0 + reportableMissingSetupSteps.length !== 0 ) const modalProps: ConfirmMissingStepsModalProps = { @@ -59,7 +73,7 @@ export function useMissingStepsModal({ ? conditionalConfirmUtils.confirm() : handleProceedToRunClick() }, - missingSteps: missingSetupSteps, + missingSteps: reportableMissingSetupSteps, } return conditionalConfirmUtils.showConfirmation diff --git a/app/src/pages/ODD/ProtocolSetup/index.tsx b/app/src/pages/ODD/ProtocolSetup/index.tsx index a9c45be592f..8a9193cbeda 100644 --- a/app/src/pages/ODD/ProtocolSetup/index.tsx +++ b/app/src/pages/ODD/ProtocolSetup/index.tsx @@ -764,6 +764,7 @@ export function ProtocolSetup(): JSX.Element { const [providedFixtureOptions, setProvidedFixtureOptions] = React.useState< CutoutFixtureId[] >([]) + // TODO(jh 10-31-24): Refactor the below to utilize useMissingStepsModal. const [labwareConfirmed, setLabwareConfirmed] = React.useState(false) const [liquidsConfirmed, setLiquidsConfirmed] = React.useState(false) const [offsetsConfirmed, setOffsetsConfirmed] = React.useState(false) diff --git a/app/src/redux/protocol-runs/selectors.ts b/app/src/redux/protocol-runs/selectors.ts index ca91c7a71ab..14149b603bb 100644 --- a/app/src/redux/protocol-runs/selectors.ts +++ b/app/src/redux/protocol-runs/selectors.ts @@ -76,6 +76,7 @@ export const getSetupStepsMissing: ( ) as Types.StepMap } +// Reports all missing setup steps, including those validated on the robot. export const getMissingSetupSteps: ( state: State, runId: string From c4a1f35177c3a0718bfea87161437fc0cb373bff Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Thu, 31 Oct 2024 15:59:20 -0400 Subject: [PATCH 08/49] fix(app): Fix tip drop height during Error Recovery (#16656) Closes RQA-3474 See #16636 for some background context (as well as what caused the regression). The conditional logic for utilizing the manual tip offset during Error Recovery was not quite right, as we were supplying stayAtHighestPossibleZ to the moveToAddressableArea command in all instances. This commit corrects it. --- .../hooks/useDropTipCommands.ts | 22 ++++++++++++++----- .../steps/ChooseDeckLocation.tsx | 2 +- .../steps/ChooseLocation.tsx | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts index 96875b4c5f3..f84c7044ec8 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts @@ -13,7 +13,11 @@ import type { import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import type { CommandData, PipetteData } from '@opentrons/api-client' import type { Axis, Sign, StepSize } from '/app/molecules/JogControls/types' -import type { DropTipFlowsRoute, FixitCommandTypeUtils } from '../types' +import type { + DropTipFlowsRoute, + FixitCommandTypeUtils, + IssuedCommandsType, +} from '../types' import type { SetRobotErrorDetailsParams, UseDTWithTypeParams } from '.' import type { RunCommandByCommandTypeParams } from './useDropTipCreateCommands' @@ -37,7 +41,7 @@ export interface UseDropTipCommandsResult { handleCleanUpAndClose: (homeOnExit?: boolean) => Promise moveToAddressableArea: ( addressableArea: AddressableAreaName, - stayAtHighestPossibleZ?: boolean + isPredefinedLocation: boolean // Is a predefined location in "choose location." ) => Promise handleJog: (axis: Axis, dir: Sign, step: StepSize) => void blowoutOrDropTip: ( @@ -102,7 +106,7 @@ export function useDropTipCommands({ const moveToAddressableArea = ( addressableArea: AddressableAreaName, - stayAtHighestPossibleZ = true // Generally false when moving to a waste chute or trash bin or during "fixit" flows. + isPredefinedLocation: boolean ): Promise => { return new Promise((resolve, reject) => { const addressableAreaFromConfig = getAddressableAreaFromConfig( @@ -116,7 +120,8 @@ export function useDropTipCommands({ const moveToAACommand = buildMoveToAACommand( addressableAreaFromConfig, pipetteId, - stayAtHighestPossibleZ + isPredefinedLocation, + issuedCommandsType ) return chainRunCommands( isFlex @@ -386,11 +391,16 @@ const buildBlowoutCommands = ( const buildMoveToAACommand = ( addressableAreaFromConfig: AddressableAreaName, pipetteId: string | null, - stayAtHighestPossibleZ: boolean + isPredefinedLocation: boolean, + commandType: IssuedCommandsType ): CreateCommand => { + // Always ensure the user does all the jogging if choosing a custom location on the deck. + const stayAtHighestPossibleZ = !isPredefinedLocation + // Because we can never be certain about which tip is attached outside a protocol run, always assume the most // conservative estimate, a 1000ul tip. - const zOffset = stayAtHighestPossibleZ ? 0 : 88 + const zOffset = commandType === 'setup' && !stayAtHighestPossibleZ ? 88 : 0 + return { commandType: 'moveToAddressableArea', params: { diff --git a/app/src/organisms/DropTipWizardFlows/steps/ChooseDeckLocation.tsx b/app/src/organisms/DropTipWizardFlows/steps/ChooseDeckLocation.tsx index 2519e1a6e0b..0bc5e6eaa67 100644 --- a/app/src/organisms/DropTipWizardFlows/steps/ChooseDeckLocation.tsx +++ b/app/src/organisms/DropTipWizardFlows/steps/ChooseDeckLocation.tsx @@ -36,7 +36,7 @@ export function ChooseDeckLocation({ )?.id if (deckSlot != null) { - void moveToAddressableArea(deckSlot).then(() => { + void moveToAddressableArea(deckSlot, false).then(() => { proceedWithConditionalClose() }) } diff --git a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx index 08d9831c0b3..577c4d0c11c 100644 --- a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx +++ b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx @@ -98,7 +98,7 @@ export function ChooseLocation({ toggleIsRobotPipetteMoving() void moveToAddressableArea( selectedLocation?.slotName as AddressableAreaName, - issuedCommandsType === 'fixit' // Because PE has tip state during fixit flows, do not specify a manual offset. + true ).then(() => { void blowoutOrDropTip(currentRoute, () => { const successStep = From a6bd20af9d341f983e4c72722aa7a0ab328b5203 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 1 Nov 2024 09:22:14 -0400 Subject: [PATCH 09/49] refactor(app): adjust ODD simple style predefined location option margin (#16661) Closes EXEC-798 Fixes a continue/go back margin issue with simple style drop tip flows on the ODD. --- .../steps/ChooseLocation.tsx | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx index 577c4d0c11c..1e5aadcafdf 100644 --- a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx +++ b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx @@ -16,8 +16,10 @@ import { import { BLOWOUT_SUCCESS, DROP_TIP_SUCCESS, DT_ROUTES } from '../constants' import { DropTipFooterButtons } from '../shared' +import type { FlattenSimpleInterpolation } from 'styled-components' import type { AddressableAreaName } from '@opentrons/shared-data' import type { + DropTipModalStyle, DropTipWizardContainerProps, ValidDropTipBlowoutLocation, } from '../types' @@ -129,13 +131,7 @@ export function ChooseLocation({ } return ( - + { + return modalStyle === 'simple' + ? containerStyleSimple(numLocations) + : CONTAINER_STYLE_INTERVENTION +} + const CONTAINER_STYLE_BASE = ` overflow: ${OVERFLOW_AUTO}; flex-direction: ${DIRECTION_COLUMN}; @@ -182,12 +189,14 @@ const CONTAINER_STYLE_INTERVENTION = css` ${CONTAINER_STYLE_BASE} ` -const CONTAINER_STYLE_SIMPLE = css` +const containerStyleSimple = ( + numLocations: number +): FlattenSimpleInterpolation => css` ${CONTAINER_STYLE_BASE} justify-content: ${JUSTIFY_SPACE_BETWEEN}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - height: 80%; + height: ${numLocations >= 4 ? '80%' : '100%'}; flex-grow: 0; } ` From 3f022b3f01d64f2a2ef7620a5044ce953f5ebd9f Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Fri, 1 Nov 2024 10:38:26 -0400 Subject: [PATCH 10/49] fix(hardware): Fix a bug from the sensor log refactor that broke gripper calibration (#16657) # Overview Since we treated pipettes as a special case for LLD some things got skipped for the gripper but this fixes it. ## Test Plan and Hands on Testing Grippers/pipettes should calibrate succesfully ## Changelog ## Review requests ## Risk assessment --- .../hardware_control/tool_sensors.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 95076f01c1c..f6c70f087bf 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -118,7 +118,7 @@ def _fix_pass_step_for_buffer( # will be the same duration=float64(abs(distance[movers[0]] / speed[movers[0]])), present_nodes=tool_nodes, - stop_condition=MoveStopCondition.sensor_report, + stop_condition=MoveStopCondition.sync_line, sensor_type_pass=sensor_type, sensor_id_pass=sensor_id, sensor_binding_flags=binding_flags, @@ -456,6 +456,14 @@ async def capacitive_probe( async with AsyncExitStack() as binding_stack: for listener in listeners.values(): await binding_stack.enter_async_context(listener) + for sensor in capacitive_sensors.values(): + await binding_stack.enter_async_context( + sensor_driver.bind_output( + can_messenger=messenger, + sensor=sensor, + binding=[SensorOutputBinding.sync], + ) + ) positions = await runner.run(can_messenger=messenger) await finalize_logs(messenger, tool, listeners, capacitive_sensors) From 1a7b61d23854f1dc135b26542d140248238b784d Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 1 Nov 2024 16:40:51 -0400 Subject: [PATCH 11/49] fix(app): Fix flickering screen during gripper Error Recovery (#16667) Closes RQA-3486 --- .../{useHomeGripperZAxis.test.ts => useHomeGripper.test.ts} | 6 +++++- .../organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) rename app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/{useHomeGripperZAxis.test.ts => useHomeGripper.test.ts} (94%) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts similarity index 94% rename from app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts rename to app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts index 32f5d939eb8..aefd00e5db2 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts @@ -94,7 +94,7 @@ describe('useHomeGripper', () => { ).toHaveBeenCalledTimes(1) }) - it('should reset hasHomedOnce when step changes to non-manual gripper step and back', async () => { + it('should only reset hasHomedOnce when step changes to non-manual gripper step', async () => { const { rerender } = renderHook( ({ recoveryMap }) => { useHomeGripper({ @@ -123,6 +123,10 @@ describe('useHomeGripper', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) + expect( + mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper + ).toHaveBeenCalledTimes(1) + rerender({ recoveryMap: mockRecoveryMap }) await act(async () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts index b165e59ebd4..38299761cfd 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts @@ -29,14 +29,14 @@ export function useHomeGripper({ } else { void handleMotionRouting(true) .then(() => updatePositionEstimatorsAndHomeGripper()) - .then(() => { + .finally(() => { + handleMotionRouting(false) setHasHomedOnce(true) }) - .finally(() => handleMotionRouting(false)) } } } else { - if (!isManualGripperStep) { + if (!isManualGripperStep && hasHomedOnce) { setHasHomedOnce(false) } } From e63686ba40df9eadb0c2dba44178b60f6396d58d Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 1 Nov 2024 16:41:11 -0400 Subject: [PATCH 12/49] fix(app): Fix tip selection during Error Recovery (#16659) Closes RQA-3472 --- .../__tests__/useFailedLabwareUtils.test.tsx | 20 -------- .../hooks/useFailedLabwareUtils.ts | 51 ++++++++----------- 2 files changed, 20 insertions(+), 51 deletions(-) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx index a24afb09b29..b0716af5c8a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx @@ -7,7 +7,6 @@ import { getRelevantWellName, getRelevantFailedLabwareCmdFrom, useRelevantFailedLwLocations, - useInitialSelectedLocationsFrom, } from '../useFailedLabwareUtils' import { DEFINED_ERROR_TYPES } from '../../constants' @@ -242,22 +241,3 @@ describe('useRelevantFailedLwLocations', () => { expect(result.current.newLoc).toStrictEqual({ slotName: 'C2' }) }) }) - -describe('useInitialSelectedLocationsFrom', () => { - it('updates result if the relevant command changes', () => { - const cmd = { commandType: 'pickUpTip', params: { wellName: 'A1' } } as any - const cmd2 = { commandType: 'pickUpTip', params: { wellName: 'A2' } } as any - - const { result, rerender } = renderHook((cmd: any) => - useInitialSelectedLocationsFrom(cmd) - ) - - rerender(cmd) - - expect(result.current).toStrictEqual({ A1: null }) - - rerender(cmd2) - - expect(result.current).toStrictEqual({ A2: null }) - }) -}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 14372409c44..72969d884d4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import without from 'lodash/without' import { useTranslation } from 'react-i18next' @@ -211,14 +211,25 @@ function useTipSelectionUtils( ): UseTipSelectionUtilsResult { const [selectedLocs, setSelectedLocs] = useState(null) - const initialLocs = useInitialSelectedLocationsFrom( - recentRelevantFailedLabwareCmd - ) - - // Set the initial locs when they first become available or update. - if (selectedLocs !== initialLocs) { - setSelectedLocs(initialLocs) - } + // Note that while other commands may have a wellName associated with them, + // we are only interested in wells for the purposes of tip picking up. + // Support state updates if the underlying well data changes, since this data is lazily retrieved and may change shortly + // after Error Recovery launches. + const initialWellName = + recentRelevantFailedLabwareCmd != null && + recentRelevantFailedLabwareCmd.commandType === 'pickUpTip' + ? recentRelevantFailedLabwareCmd.params.wellName + : null + useEffect(() => { + if ( + recentRelevantFailedLabwareCmd != null && + recentRelevantFailedLabwareCmd.commandType === 'pickUpTip' + ) { + setSelectedLocs({ + [recentRelevantFailedLabwareCmd.params.wellName]: null, + }) + } + }, [initialWellName]) const deselectTips = (locations: string[]): void => { setSelectedLocs(prevLocs => @@ -253,28 +264,6 @@ function useTipSelectionUtils( } } -// Set the initial well selection to be the last pickup tip location for the pipette used in the failed command. -export function useInitialSelectedLocationsFrom( - recentRelevantFailedLabwareCmd: FailedCommandRelevantLabware -): WellGroup | null { - const [initialWells, setInitialWells] = useState(null) - - // Note that while other commands may have a wellName associated with them, - // we are only interested in wells for the purposes of tip picking up. - // Support state updates if the underlying data changes, since this data is lazily loaded and may change shortly - // after Error Recovery launches. - if ( - recentRelevantFailedLabwareCmd != null && - recentRelevantFailedLabwareCmd.commandType === 'pickUpTip' && - (initialWells == null || - !(recentRelevantFailedLabwareCmd.params.wellName in initialWells)) - ) { - setInitialWells({ [recentRelevantFailedLabwareCmd.params.wellName]: null }) - } - - return initialWells -} - // Get the name of the relevant labware relevant to the failed command, if any. export function getFailedCmdRelevantLabware( protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'], From 7ce9d4b12eb13b63f20649766684367efc27e2b2 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 4 Nov 2024 17:42:35 -0500 Subject: [PATCH 13/49] refactor(app): Update diverged protocol behavior (#16674) Works toward RQA-3469 and closes EXEC-802 The way the app handles divergent runs, those runs that are associated with a non-determinisitic protocol, currently show some confusing copy in the desktop app/ODD, and at times, exhibit unexpected behavior. This commit works toward tying up those loose ends to make divergent protocol handling seem more like a feature and not an unhandled bug. --- .../localization/en/error_recovery.json | 5 +- .../assets/localization/en/run_details.json | 2 + .../RunHeaderModalContainer.tsx | 2 +- .../__tests__/RunProgressMeter.test.tsx | 2 +- .../hooks/useRunProgressCopy.tsx | 4 +- .../ErrorRecoveryWizard.tsx | 2 +- .../__tests__/ManageTips.test.tsx | 2 +- .../ErrorRecoveryFlows/RecoverySplash.tsx | 2 +- .../ErrorRecoveryFlows/__fixtures__/index.ts | 2 +- .../__tests__/ErrorRecoveryFlows.test.tsx | 2 +- .../__tests__/useFailedLabwareUtils.test.tsx | 38 +++++++----- .../__tests__/useRecoveryCommands.test.ts | 51 +++++++++------- .../__tests__/useRecoveryToasts.test.tsx | 18 +++--- .../ErrorRecoveryFlows/hooks/useERUtils.ts | 9 +-- .../hooks/useFailedLabwareUtils.ts | 21 ++++--- .../hooks/useFailedPipetteUtils.ts | 4 +- .../hooks/useRecoveryCommands.ts | 59 ++++++++++--------- .../hooks/useRecoveryToasts.ts | 49 ++++++++------- .../hooks/useRetainedFailedCommandBySource.ts | 2 +- .../organisms/ErrorRecoveryFlows/index.tsx | 10 +++- .../shared/ErrorDetailsModal.tsx | 4 +- .../ErrorRecoveryFlows/shared/StepInfo.tsx | 9 ++- .../TwoColTextAndFailedStepNextStep.tsx | 6 +- .../shared/__tests__/RetryStepInfo.test.tsx | 1 + .../shared/__tests__/SkipStepInfo.test.tsx | 1 + .../utils/__tests__/getErrorKind.test.ts | 8 ++- .../ErrorRecoveryFlows/utils/getErrorKind.ts | 18 ++++-- .../CurrentRunningProtocolCommand.tsx | 15 ++--- .../CurrentRunningProtocolCommand.test.tsx | 2 +- app/src/pages/ODD/RunningProtocol/index.tsx | 2 +- .../runs/useMostRecentCompletedAnalysis.ts | 1 - 31 files changed, 210 insertions(+), 143 deletions(-) diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 30ff25a4968..99589c63216 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -18,6 +18,7 @@ "continue": "Continue", "continue_run_now": "Continue run now", "continue_to_drop_tip": "Continue to drop tip", + "do_you_need_to_blowout": "First, do you need to blow out aspirated liquid?", "door_open_gripper_home": "The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.", "ensure_lw_is_accurately_placed": "Ensure labware is accurately placed in the slot to prevent further errors.", "error": "Error", @@ -49,13 +50,13 @@ "manually_move_lw_on_deck": "Manually move labware on deck", "manually_replace_lw_and_retry": "Manually replace labware on deck and retry step", "manually_replace_lw_on_deck": "Manually replace labware on deck", + "na": "N/A", "next_step": "Next step", "next_try_another_action": "Next, you can try another recovery action or cancel the run.", "no_liquid_detected": "No liquid detected", "overpressure_is_usually_caused": "Overpressure is usually caused by a tip contacting labware, a clog, or moving viscous liquid too quickly", "pick_up_tips": "Pick up tips", "pipette_overpressure": "Pipette overpressure", - "do_you_need_to_blowout": "First, do you need to blowout aspirated liquid?", "proceed_to_cancel": "Proceed to cancel", "proceed_to_tip_selection": "Proceed to tip selection", "recovery_action_failed": "{{action}} failed", @@ -76,6 +77,7 @@ "retry_with_new_tips": "Retry with new tips", "retry_with_same_tips": "Retry with same tips", "retrying_step_succeeded": "Retrying step {{step}} succeeded.", + "retrying_step_succeeded_na": "Retrying current step succeeded.", "return_to_menu": "Return to menu", "robot_door_is_open": "Robot door is open", "robot_is_canceling_run": "Robot is canceling the run", @@ -93,6 +95,7 @@ "skip_to_next_step_new_tips": "Skip to next step with new tips", "skip_to_next_step_same_tips": "Skip to next step with same tips", "skipping_to_step_succeeded": "Skipping to step {{step}} succeeded.", + "skipping_to_step_succeeded_na": "Skipping to next step succeeded.", "stand_back": "Stand back, robot is in motion", "stand_back_picking_up_tips": "Stand back, picking up tips", "stand_back_resuming": "Stand back, resuming current step", diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index 28df0734619..98443901364 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -62,6 +62,7 @@ "module_controls": "Module Controls", "module_slot_number": "Slot {{slot_number}}", "move_labware": "Move Labware", + "na": "N/A", "name": "Name", "no_files_included": "No protocol files included", "no_of_error": "{{count}} error", @@ -144,6 +145,7 @@ "status_succeeded": "Completed", "step": "Step", "step_failed": "Step failed", + "step_na": "Step: N/A", "step_number": "Step {{step_number}}:", "steps_total": "{{count}} steps total", "stored_labware_offset_data": "Stored Labware Offset data that applies to this protocol", diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/RunHeaderModalContainer.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/RunHeaderModalContainer.tsx index 3be4f607208..0c306339f69 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/RunHeaderModalContainer.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/RunHeaderModalContainer.tsx @@ -51,7 +51,7 @@ export function RunHeaderModalContainer( ) : null} diff --git a/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx b/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx index 4217ce8618a..3618b0ce586 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx @@ -114,7 +114,7 @@ describe('RunProgressMeter', () => { it('should show only the total count of commands in run and not show the meter when protocol is non-deterministic', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) render(props) - expect(screen.getByText('Current Step ?/?:')).toBeTruthy() + expect(screen.getByText('Current Step N/A:')).toBeTruthy() expect(screen.queryByText('MOCK PROGRESS BAR')).toBeFalsy() }) it('should give the correct info when run status is idle', () => { diff --git a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx index 8c522b4ff22..65e2f27d6b3 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx @@ -109,7 +109,9 @@ export function useRunProgressCopy({ if (runStatus === RUN_STATUS_IDLE) { return `${stepType}:` } else if (isTerminalStatus && currentStepNumber == null) { - return `${stepType}: N/A` + return `${stepType}: ${t('na')}` + } else if (hasRunDiverged) { + return `${stepType} ${t('na')}:` } else { const getCountString = (): string => { const current = currentStepNumber ?? '?' diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index e1dd7c5add2..1c62471380d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -82,7 +82,7 @@ export function ErrorRecoveryWizard( recoveryCommands, routeUpdateActions, } = props - const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) + const errorKind = getErrorKind(failedCommand) useInitialPipetteHome({ hasLaunchedRecovery, diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx index 39b3ab57256..941b19081c7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx @@ -282,7 +282,7 @@ describe('useDropTipFlowUtils', () => { testingRender(result.current.copyOverrides.beforeBeginningTopText as any) - screen.getByText('First, do you need to blowout aspirated liquid?') + screen.getByText('First, do you need to blow out aspirated liquid?') testingRender(result.current.copyOverrides.tipDropCompleteBtnCopy as any) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx index c9006f5d552..7a17b443508 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx @@ -85,7 +85,7 @@ export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { resumePausedRecovery, } = props const { t } = useTranslation('error_recovery') - const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) + const errorKind = getErrorKind(failedCommand) const title = useErrorName(errorKind) const { makeToast } = useToaster() diff --git a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts index c79e270bbed..1a815b99c1e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts @@ -53,7 +53,7 @@ export const mockPickUpTipLabware: LoadedLabware = { // TODO: jh(08-07-24): update the "byAnalysis" mockFailedCommand. export const mockRecoveryContentProps: RecoveryContentProps = { - failedCommandByRunRecord: mockFailedCommand, + unvalidatedFailedCommand: mockFailedCommand, failedCommand: { byRunRecord: mockFailedCommand, byAnalysis: mockFailedCommand, diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index d73d402585d..04719afca56 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -146,7 +146,7 @@ describe('ErrorRecoveryFlows', () => { beforeEach(() => { props = { runStatus: RUN_STATUS_AWAITING_RECOVERY, - failedCommandByRunRecord: mockFailedCommand, + unvalidatedFailedCommand: mockFailedCommand, runId: 'MOCK_RUN_ID', protocolAnalysis: null, } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx index b0716af5c8a..f8559163adb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx @@ -92,7 +92,7 @@ describe('getRelevantFailedLabwareCmdFrom', () => { }, } const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: failedLiquidProbeCommand, + failedCommand: { byRunRecord: failedLiquidProbeCommand } as any, }) expect(result).toEqual(failedLiquidProbeCommand) }) @@ -117,11 +117,13 @@ describe('getRelevantFailedLabwareCmdFrom', () => { overpressureErrorKinds.forEach(([commandType, errorType]) => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: { - ...failedCommand, - commandType, - error: { isDefined: true, errorType }, - }, + failedCommand: { + byRunRecord: { + ...failedCommand, + commandType, + error: { isDefined: true, errorType }, + }, + } as any, runCommands, }) expect(result).toBe(pickUpTipCommand) @@ -138,27 +140,33 @@ describe('getRelevantFailedLabwareCmdFrom', () => { }, } const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: failedGripperCommand, + failedCommand: { byRunRecord: failedGripperCommand } as any, }) expect(result).toEqual(failedGripperCommand) }) it('should return null for GENERAL_ERROR error kind', () => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: { - ...failedCommand, - error: { errorType: 'literally anything else' }, - }, + failedCommand: { + byRunRecord: { + ...failedCommand, + error: { + errorType: 'literally anything else', + }, + }, + } as any, }) expect(result).toBeNull() }) it('should return null for unhandled error kinds', () => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord: { - ...failedCommand, - error: { errorType: 'SOME_UNHANDLED_ERROR' }, - }, + failedCommand: { + byRunRecord: { + ...failedCommand, + error: { errorType: 'SOME_UNHANDLED_ERROR' }, + }, + } as any, }) expect(result).toBeNull() }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index 4079e8a8f1e..565ef49c951 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -56,7 +56,11 @@ describe('useRecoveryCommands', () => { const props = { runId: mockRunId, - failedCommandByRunRecord: mockFailedCommand, + failedCommand: { + byRunRecord: mockFailedCommand, + byAnalysis: mockFailedCommand, + }, + unvalidatedFailedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, recoveryToastUtils: { makeSuccessToast: mockMakeSuccessToast } as any, @@ -144,24 +148,29 @@ describe('useRecoveryCommands', () => { 'prepareToAspirate', ] as const).forEach(inPlaceCommandType => { it(`Should move to retryLocation if failed command is ${inPlaceCommandType} and error is appropriate when retrying`, async () => { - const { result } = renderHook(() => - useRecoveryCommands({ - runId: mockRunId, - failedCommandByRunRecord: { - ...mockFailedCommand, - commandType: inPlaceCommandType, - params: { - pipetteId: 'mock-pipette-id', - }, - error: { - errorType: 'overpressure', - errorCode: '3006', - isDefined: true, - errorInfo: { - retryLocation: [1, 2, 3], - }, + const { result } = renderHook(() => { + const failedCommand = { + ...mockFailedCommand, + commandType: inPlaceCommandType, + params: { + pipetteId: 'mock-pipette-id', + }, + error: { + errorType: 'overpressure', + errorCode: '3006', + isDefined: true, + errorInfo: { + retryLocation: [1, 2, 3], }, }, + } + return useRecoveryCommands({ + runId: mockRunId, + failedCommand: { + byRunRecord: failedCommand, + byAnalysis: failedCommand, + }, + unvalidatedFailedCommand: failedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, recoveryToastUtils: {} as any, @@ -171,7 +180,7 @@ describe('useRecoveryCommands', () => { } as any, selectedRecoveryOption: RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE, }) - ) + }) await act(async () => { await result.current.retryFailedCommand() }) @@ -245,7 +254,7 @@ describe('useRecoveryCommands', () => { const testProps = { ...props, - failedCommandByRunRecord: mockFailedCmdWithPipetteId, + unvalidatedFailedCommand: mockFailedCmdWithPipetteId, failedLabwareUtils: { ...mockFailedLabwareUtils, failedLabware: mockFailedLabware, @@ -312,7 +321,7 @@ describe('useRecoveryCommands', () => { const testProps = { ...props, - failedCommandByRunRecord: mockFailedCommandWithError, + unvalidatedFailedCommand: mockFailedCommandWithError, } const { result, rerender } = renderHook(() => @@ -349,7 +358,7 @@ describe('useRecoveryCommands', () => { const testProps = { ...props, - failedCommandByRunRecord: mockFailedCommandWithError, + unvalidatedFailedCommand: mockFailedCommandWithError, } mockUpdateErrorRecoveryPolicy.mockRejectedValueOnce( diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx index c572618bbcc..c9874eb5532 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx @@ -30,7 +30,11 @@ let mockMakeToast: Mock const DEFAULT_PROPS: BuildToast = { isOnDevice: false, - currentStepCount: 1, + stepCounts: { + currentStepNumber: 1, + hasRunDiverged: false, + totalStepCount: 1, + }, selectedRecoveryOption: RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, commandTextData: { commands: [] } as any, robotType: FLEX_ROBOT_TYPE, @@ -187,13 +191,11 @@ describe('getStepNumber', () => { }) it('should handle a falsy currentStepCount', () => { - expect(getStepNumber(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, null)).toBe('?') + expect(getStepNumber(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, null)).toBe(null) }) it('should handle unknown recovery option', () => { - expect(getStepNumber('UNKNOWN_OPTION' as any, 3)).toBe( - 'HANDLE RECOVERY TOAST OPTION EXPLICITLY.' - ) + expect(getStepNumber('UNKNOWN_OPTION' as any, 3)).toBeNull() }) }) @@ -234,17 +236,17 @@ describe('useRecoveryFullCommandText', () => { expect(result.current).toBeNull() }) - it('should return stepNumber if it is a string', () => { + it('should return null if there is no current step count', () => { const { result } = renderHook(() => useRecoveryFullCommandText({ robotType: FLEX_ROBOT_TYPE, - stepNumber: '?', + stepNumber: null, commandTextData: { commands: [] } as any, allRunDefs: [], }) ) - expect(result.current).toBe('?') + expect(result.current).toBeNull() }) it('should truncate TC command', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 57691a30e55..b1a55ea12b8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -87,6 +87,7 @@ export function useERUtils({ runStatus, showTakeover, allRunDefs, + unvalidatedFailedCommand, labwareDefinitionsByUri, }: ERUtilsProps): ERUtilsResults { const { data: attachedInstruments } = useInstrumentsQuery() @@ -100,7 +101,6 @@ export function useERUtils({ cursor: 0, pageLength: 999, }) - const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null const stepCounts = useRunningStepCounts(runId, runCommands) @@ -120,7 +120,7 @@ export function useERUtils({ ) const recoveryToastUtils = useRecoveryToasts({ - currentStepCount: stepCounts.currentStepNumber, + stepCounts, selectedRecoveryOption: currentRecoveryOptionUtils.selectedRecoveryOption, isOnDevice, commandTextData: protocolAnalysis, @@ -152,7 +152,7 @@ export function useERUtils({ }) const failedLabwareUtils = useFailedLabwareUtils({ - failedCommandByRunRecord, + failedCommand, protocolAnalysis, failedPipetteInfo, runRecord, @@ -161,7 +161,8 @@ export function useERUtils({ const recoveryCommands = useRecoveryCommands({ runId, - failedCommandByRunRecord, + failedCommand, + unvalidatedFailedCommand, failedLabwareUtils, routeUpdateActions, recoveryToastUtils, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 72969d884d4..bc077d4c624 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -30,10 +30,10 @@ import type { } from '@opentrons/shared-data' import type { DisplayLocationSlotOnlyParams } from '/app/local-resources/labware' import type { ErrorRecoveryFlowsProps } from '..' -import type { ERUtilsProps } from './useERUtils' +import type { FailedCommandBySource } from './useRetainedFailedCommandBySource' interface UseFailedLabwareUtilsProps { - failedCommandByRunRecord: ERUtilsProps['failedCommandByRunRecord'] + failedCommand: FailedCommandBySource | null protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedPipetteInfo: PipetteData | null runCommands?: CommandsData @@ -67,16 +67,17 @@ export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { * For no liquid detected errors, the relevant labware is the well in which no liquid was detected. */ export function useFailedLabwareUtils({ - failedCommandByRunRecord, + failedCommand, protocolAnalysis, failedPipetteInfo, runCommands, runRecord, }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { + const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null const recentRelevantFailedLabwareCmd = useMemo( () => getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord, + failedCommand, runCommands, }), [failedCommandByRunRecord?.key, runCommands?.meta.totalLength] @@ -129,16 +130,17 @@ type FailedCommandRelevantLabware = | null interface RelevantFailedLabwareCmd { - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] + failedCommand: FailedCommandBySource | null runCommands?: CommandsData } // Return the actual command that contains the info relating to the relevant labware. export function getRelevantFailedLabwareCmdFrom({ - failedCommandByRunRecord, + failedCommand, runCommands, }: RelevantFailedLabwareCmd): FailedCommandRelevantLabware { - const errorKind = getErrorKind(failedCommandByRunRecord) + const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null + const errorKind = getErrorKind(failedCommand) switch (errorKind) { case ERROR_KINDS.NO_LIQUID_DETECTED: @@ -161,7 +163,7 @@ export function getRelevantFailedLabwareCmdFrom({ // Returns the most recent pickUpTip command for the pipette used in the failed command, if any. function getRelevantPickUpTipCommand( - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'], + failedCommandByRunRecord: FailedCommandBySource['byRunRecord'] | null, runCommands?: CommandsData ): Omit | null { if ( @@ -333,9 +335,10 @@ export function getRelevantWellName( export type GetRelevantLwLocationsParams = Pick< UseFailedLabwareUtilsProps, - 'runRecord' | 'failedCommandByRunRecord' + 'runRecord' > & { failedLabware: UseFailedLabwareUtilsResult['failedLabware'] + failedCommandByRunRecord: FailedCommandBySource['byRunRecord'] | null } export function useRelevantFailedLwLocations({ diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts index f997592f8cd..5535cd46799 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts @@ -8,7 +8,7 @@ import type { Run, PipetteData, } from '@opentrons/api-client' -import type { ErrorRecoveryFlowsProps } from '/app/organisms/ErrorRecoveryFlows' +import type { FailedCommandBySource } from './useRetainedFailedCommandBySource' export interface UseFailedPipetteUtilsParams extends UseFailedCommandPipetteInfoProps { @@ -61,7 +61,7 @@ export function useFailedPipetteUtils( interface UseFailedCommandPipetteInfoProps { runRecord: Run | undefined attachedInstruments: Instruments | undefined - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] + failedCommandByRunRecord: FailedCommandBySource['byRunRecord'] | null } // /instruments data for the pipette used in the failedCommand, if any. diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index 7614dec4be3..d6190eaa16d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -36,11 +36,13 @@ import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' import type { RecoveryToasts } from './useRecoveryToasts' import type { UseRecoveryAnalyticsResult } from '/app/redux-resources/analytics' import type { CurrentRecoveryOptionUtils } from './useRecoveryRouting' -import type { ErrorRecoveryFlowsProps } from '../index' +import type { ErrorRecoveryFlowsProps } from '..' +import type { FailedCommandBySource } from './useRetainedFailedCommandBySource' interface UseRecoveryCommandsParams { runId: string - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] + failedCommand: FailedCommandBySource | null + unvalidatedFailedCommand: ErrorRecoveryFlowsProps['unvalidatedFailedCommand'] failedLabwareUtils: UseFailedLabwareUtilsResult routeUpdateActions: UseRouteUpdateActionsResult recoveryToastUtils: RecoveryToasts @@ -76,7 +78,8 @@ export interface UseRecoveryCommandsResult { // Returns commands with a "fixit" intent. Commands may or may not terminate Error Recovery. See each command docstring for details. export function useRecoveryCommands({ runId, - failedCommandByRunRecord, + failedCommand, + unvalidatedFailedCommand, failedLabwareUtils, routeUpdateActions, recoveryToastUtils, @@ -88,7 +91,7 @@ export function useRecoveryCommands({ const { proceedToRouteAndStep } = routeUpdateActions const { chainRunCommands } = useChainRunCommands( runId, - failedCommandByRunRecord?.id + unvalidatedFailedCommand?.id ) const { mutateAsync: resumeRunFromRecovery, @@ -137,23 +140,23 @@ export function useRecoveryCommands({ IN_PLACE_COMMAND_TYPES.includes( (failedCommand as InPlaceCommand).commandType ) - return failedCommandByRunRecord != null - ? isInPlace(failedCommandByRunRecord) - ? failedCommandByRunRecord.error?.isDefined && - failedCommandByRunRecord.error?.errorType === 'overpressure' && + return unvalidatedFailedCommand != null + ? isInPlace(unvalidatedFailedCommand) + ? unvalidatedFailedCommand.error?.isDefined && + unvalidatedFailedCommand.error?.errorType === 'overpressure' && // Paranoia: this value comes from the wire and may be unevenly implemented - typeof failedCommandByRunRecord.error?.errorInfo?.retryLocation?.at( + typeof unvalidatedFailedCommand.error?.errorInfo?.retryLocation?.at( 0 ) === 'number' ? { commandType: 'moveToCoordinates', intent: 'fixit', params: { - pipetteId: failedCommandByRunRecord.params?.pipetteId, + pipetteId: unvalidatedFailedCommand.params?.pipetteId, coordinates: { - x: failedCommandByRunRecord.error.errorInfo.retryLocation[0], - y: failedCommandByRunRecord.error.errorInfo.retryLocation[1], - z: failedCommandByRunRecord.error.errorInfo.retryLocation[2], + x: unvalidatedFailedCommand.error.errorInfo.retryLocation[0], + y: unvalidatedFailedCommand.error.errorInfo.retryLocation[1], + z: unvalidatedFailedCommand.error.errorInfo.retryLocation[2], }, }, } @@ -163,7 +166,7 @@ export function useRecoveryCommands({ } const retryFailedCommand = useCallback((): Promise => { - const { commandType, params } = failedCommandByRunRecord as FailedCommand // Null case is handled before command could be issued. + const { commandType, params } = unvalidatedFailedCommand as FailedCommand // Null case is handled before command could be issued. return chainRunRecoveryCommands( [ // move back to the location of the command if it is an in-place command @@ -171,7 +174,7 @@ export function useRecoveryCommands({ { commandType, params }, // retry the command that failed ].filter(c => c != null) as CreateCommand[] ) // the created command is the same command that failed - }, [chainRunRecoveryCommands, failedCommandByRunRecord?.key]) + }, [chainRunRecoveryCommands, unvalidatedFailedCommand?.key]) // Homes the Z-axis of all attached pipettes. const homePipetteZAxes = useCallback((): Promise => { @@ -184,7 +187,7 @@ export function useRecoveryCommands({ const pickUpTipCmd = buildPickUpTips( selectedTipLocations, - failedCommandByRunRecord, + unvalidatedFailedCommand, failedLabware ) @@ -193,7 +196,7 @@ export function useRecoveryCommands({ } else { return chainRunRecoveryCommands([pickUpTipCmd]) } - }, [chainRunRecoveryCommands, failedCommandByRunRecord, failedLabwareUtils]) + }, [chainRunRecoveryCommands, unvalidatedFailedCommand, failedLabwareUtils]) const ignoreErrorKindThisRun = (ignoreErrors: boolean): Promise => { setIgnoreErrors(ignoreErrors) @@ -204,16 +207,16 @@ export function useRecoveryCommands({ // If the request to update the policy fails, route to the error modal. const handleIgnoringErrorKind = useCallback((): Promise => { if (ignoreErrors) { - if (failedCommandByRunRecord?.error != null) { + if (unvalidatedFailedCommand?.error != null) { const ifMatch: IfMatchType = isAssumeFalsePositiveResumeKind( - failedCommandByRunRecord + failedCommand ) ? 'assumeFalsePositiveAndContinue' : 'ignoreAndContinue' const ignorePolicyRules = buildIgnorePolicyRules( - failedCommandByRunRecord.commandType, - failedCommandByRunRecord.error.errorType, + unvalidatedFailedCommand.commandType, + unvalidatedFailedCommand.error.errorType, ifMatch ) @@ -232,8 +235,8 @@ export function useRecoveryCommands({ return Promise.resolve() } }, [ - failedCommandByRunRecord?.error?.errorType, - failedCommandByRunRecord?.commandType, + unvalidatedFailedCommand?.error?.errorType, + unvalidatedFailedCommand?.commandType, ignoreErrors, ]) @@ -262,7 +265,7 @@ export function useRecoveryCommands({ }, [runId]) const handleResumeAction = (): Promise => { - if (isAssumeFalsePositiveResumeKind(failedCommandByRunRecord)) { + if (isAssumeFalsePositiveResumeKind(failedCommand)) { return resumeRunFromRecoveryAssumingFalsePositive(runId) } else { return resumeRunFromRecovery(runId) @@ -302,14 +305,14 @@ export function useRecoveryCommands({ const moveLabwareWithoutPause = useCallback((): Promise => { const moveLabwareCmd = buildMoveLabwareWithoutPause( - failedCommandByRunRecord + unvalidatedFailedCommand ) if (moveLabwareCmd == null) { return Promise.reject(new Error('Invalid use of MoveLabware command')) } else { return chainRunRecoveryCommands([moveLabwareCmd]) } - }, [chainRunRecoveryCommands, failedCommandByRunRecord]) + }, [chainRunRecoveryCommands, unvalidatedFailedCommand]) return { resumeRun, @@ -326,9 +329,9 @@ export function useRecoveryCommands({ } export function isAssumeFalsePositiveResumeKind( - failedCommandByRunRecord: UseRecoveryCommandsParams['failedCommandByRunRecord'] + failedCommand: UseRecoveryCommandsParams['failedCommand'] ): boolean { - const errorKind = getErrorKind(failedCommandByRunRecord) + const errorKind = getErrorKind(failedCommand) switch (errorKind) { case ERROR_KINDS.TIP_NOT_DETECTED: diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts index 2edf732bfdd..6daf6998ae5 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts @@ -10,7 +10,7 @@ import type { UseCommandTextStringParams } from '/app/local-resources/commands' export type BuildToast = Omit & { isOnDevice: boolean - currentStepCount: StepCounts['currentStepNumber'] + stepCounts: StepCounts selectedRecoveryOption: CurrentRecoveryOptionUtils['selectedRecoveryOption'] } @@ -21,15 +21,16 @@ export interface RecoveryToasts { // Provides methods for rendering success/failure toasts after performing a terminal recovery command. export function useRecoveryToasts({ - currentStepCount, + stepCounts, isOnDevice, selectedRecoveryOption, ...rest }: BuildToast): RecoveryToasts { + const { currentStepNumber, hasRunDiverged } = stepCounts const { makeToast } = useToaster() const displayType = isOnDevice ? 'odd' : 'desktop' - const stepNumber = getStepNumber(selectedRecoveryOption, currentStepCount) + const stepNumber = getStepNumber(selectedRecoveryOption, currentStepNumber) const desktopFullCommandText = useRecoveryFullCommandText({ ...rest, @@ -46,7 +47,8 @@ export function useRecoveryToasts({ ? desktopFullCommandText : recoveryToastText // The "heading" of the toast message. Currently, this text is only present on the desktop toasts. - const headingText = displayType === 'desktop' ? recoveryToastText : undefined + const headingText = + displayType === 'desktop' && !hasRunDiverged ? recoveryToastText : undefined const makeSuccessToast = (): void => { if (selectedRecoveryOption !== RECOVERY_MAP.CANCEL_RUN.ROUTE) { @@ -73,12 +75,18 @@ export function useRecoveryToastText({ }): string { const { t } = useTranslation('error_recovery') - const currentStepReturnVal = t('retrying_step_succeeded', { - step: stepNumber, - }) as string - const nextStepReturnVal = t('skipping_to_step_succeeded', { - step: stepNumber, - }) as string + const currentStepReturnVal = + stepNumber != null + ? t('retrying_step_succeeded', { + step: stepNumber, + }) + : t('retrying_step_succeeded_na') + const nextStepReturnVal = + stepNumber != null + ? t('skipping_to_step_succeeded', { + step: stepNumber, + }) + : t('skipping_to_step_succeeded_na') const toastText = handleRecoveryOptionAction( selectedRecoveryOption, @@ -102,7 +110,7 @@ export function useRecoveryFullCommandText( ): string | null { const { commandTextData, stepNumber } = props - const relevantCmdIdx = typeof stepNumber === 'number' ? stepNumber : -1 + const relevantCmdIdx = stepNumber ?? -1 const relevantCmd = commandTextData?.commands[relevantCmdIdx] ?? null const { commandText, kind } = useCommandTextString({ @@ -110,8 +118,8 @@ export function useRecoveryFullCommandText( command: relevantCmd, }) - if (typeof stepNumber === 'string') { - return stepNumber + if (stepNumber == null) { + return null } // Occurs when the relevantCmd doesn't exist, ex, we "skip" the last command of a run. else if (relevantCmd === null) { @@ -129,12 +137,12 @@ export function useRecoveryFullCommandText( // Return the user-facing step number, 0 indexed. If the step number cannot be determined, return '?'. export function getStepNumber( selectedRecoveryOption: BuildToast['selectedRecoveryOption'], - currentStepCount: BuildToast['currentStepCount'] -): number | string { - const currentStepReturnVal = currentStepCount ?? '?' + currentStepCount: BuildToast['stepCounts']['currentStepNumber'] +): number | null { + const currentStepReturnVal = currentStepCount ?? null // There is always a next protocol step after a command that can error, therefore, we don't need to handle that. const nextStepReturnVal = - typeof currentStepCount === 'number' ? currentStepCount + 1 : '?' + typeof currentStepCount === 'number' ? currentStepCount + 1 : null return handleRecoveryOptionAction( selectedRecoveryOption, @@ -149,7 +157,7 @@ function handleRecoveryOptionAction( selectedRecoveryOption: CurrentRecoveryOptionUtils['selectedRecoveryOption'], currentStepReturnVal: T, nextStepReturnVal: T -): T | string { +): T | null { switch (selectedRecoveryOption) { case RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE: case RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE: @@ -163,8 +171,9 @@ function handleRecoveryOptionAction( case RECOVERY_MAP.RETRY_STEP.ROUTE: case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: return currentStepReturnVal - default: - return 'HANDLE RECOVERY TOAST OPTION EXPLICITLY.' + default: { + return null + } } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts index c967d4968b1..90afa5851da 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts @@ -16,7 +16,7 @@ export interface FailedCommandBySource { * In order to reduce misuse, bundle the failedCommand into "run" and "analysis" versions. */ export function useRetainedFailedCommandBySource( - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'], + failedCommandByRunRecord: ErrorRecoveryFlowsProps['unvalidatedFailedCommand'], protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] ): FailedCommandBySource | null { // In some cases, Error Recovery (by the app definition) persists when Error Recovery (by the server definition) does diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index 124c4fea65f..3ec1afed3d8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -109,17 +109,21 @@ export function useErrorRecoveryFlows( export interface ErrorRecoveryFlowsProps { runId: string runStatus: RunStatus | null - failedCommandByRunRecord: FailedCommand | null + /* In some parts of Error Recovery, such as "retry failed command" during a generic error flow, we want to utilize + * information derived from the failed command from the run record even if there is no matching command in protocol analysis. + * Using a failed command that is not matched to a protocol analysis command is unsafe in most circumstances (ie, in + * non-generic recovery flows. Prefer using failedCommandBySource in most circumstances. */ + unvalidatedFailedCommand: FailedCommand | null protocolAnalysis: CompletedProtocolAnalysis | null } export function ErrorRecoveryFlows( props: ErrorRecoveryFlowsProps ): JSX.Element | null { - const { protocolAnalysis, runStatus, failedCommandByRunRecord } = props + const { protocolAnalysis, runStatus, unvalidatedFailedCommand } = props const failedCommandBySource = useRetainedFailedCommandBySource( - failedCommandByRunRecord, + unvalidatedFailedCommand, protocolAnalysis ) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx index b988c83971b..7eb207a9fe7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx @@ -44,7 +44,7 @@ export function useErrorDetailsModal(): { type ErrorDetailsModalProps = Omit< ErrorRecoveryFlowsProps, - 'failedCommandByRunRecord' + 'unvalidatedFailedCommand' > & ERUtilsResults & { toggleModal: () => void @@ -57,7 +57,7 @@ type ErrorDetailsModalProps = Omit< export function ErrorDetailsModal(props: ErrorDetailsModalProps): JSX.Element { const { failedCommand, toggleModal, isOnDevice } = props - const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) + const errorKind = getErrorKind(failedCommand) const errorName = useErrorName(errorKind) const isNotificationErrorKind = (): boolean => { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/StepInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/StepInfo.tsx index 8381865e3c3..bbc12ce0429 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/StepInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/StepInfo.tsx @@ -30,7 +30,7 @@ export function StepInfo({ ...styleProps }: StepInfoProps): JSX.Element { const { t } = useTranslation('error_recovery') - const { currentStepNumber, totalStepCount } = stepCounts + const { currentStepNumber, totalStepCount, hasRunDiverged } = stepCounts const currentCopy = currentStepNumber ?? '?' const totalCopy = totalStepCount ?? '?' @@ -38,6 +38,11 @@ export function StepInfo({ const desktopStyleDefaulted = desktopStyle ?? 'bodyDefaultRegular' const oddStyleDefaulted = oddStyle ?? 'bodyTextRegular' + const buildAtStepCopy = (): string => + hasRunDiverged + ? `${t('at_step')}: N/A` + : `${t('at_step')} ${currentCopy}/${totalCopy}: ` + return ( - {`${t('at_step')} ${currentCopy}/${totalCopy}: `} + {buildAtStepCopy()} {failedCommand?.byAnalysis != null && protocolAnalysis != null ? ( - + {!props.stepCounts.hasRunDiverged ? ( + + ) : ( + + )} { resumeRun: mockResumeRun, } as any, errorKind: ERROR_KINDS.GENERAL_ERROR, + stepCounts: { hasRunDiverged: false }, } as any }) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx index e1ac4cf1adc..28ef4177648 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SkipStepInfo.test.tsx @@ -28,6 +28,7 @@ describe('SkipStepInfo', () => { currentRecoveryOptionUtils: { selectedRecoveryOption: RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, } as any, + stepCounts: { hasRunDiverged: false }, } as any }) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts index fb9eea82c63..e9b5722ffa8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts @@ -71,13 +71,17 @@ describe('getErrorKind', () => { ])( 'returns $expectedError for $commandType with errorType $errorType', ({ commandType, errorType, expectedError, isDefined = true }) => { - const result = getErrorKind({ + const runRecordFailedCommand = { commandType, error: { isDefined, errorType, } as RunCommandError, - } as RunTimeCommand) + } as RunTimeCommand + + const result = getErrorKind({ + byRunRecord: runRecordFailedCommand, + } as any) expect(result).toEqual(expectedError) } ) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts index 30fc4783473..1dc5e023a6c 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts @@ -1,18 +1,24 @@ import { ERROR_KINDS, DEFINED_ERROR_TYPES } from '../constants' -import type { RunTimeCommand } from '@opentrons/shared-data' import type { ErrorKind } from '../types' +import type { FailedCommandBySource } from '/app/organisms/ErrorRecoveryFlows/hooks' /** * Given server-side information about a failed command, * decide which UI flow to present to recover from it. + * + * NOTE IMPORTANT: Any failed command by run record must have an equivalent protocol analysis command or default + * to the fallback general error. Prefer using FailedCommandBySource for this reason. */ -export function getErrorKind(failedCommand: RunTimeCommand | null): ErrorKind { - const commandType = failedCommand?.commandType - const errorIsDefined = failedCommand?.error?.isDefined ?? false - const errorType = failedCommand?.error?.errorType +export function getErrorKind( + failedCommand: FailedCommandBySource | null +): ErrorKind { + const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null + const commandType = failedCommandByRunRecord?.commandType + const errorIsDefined = failedCommandByRunRecord?.error?.isDefined ?? false + const errorType = failedCommandByRunRecord?.error?.errorType - if (errorIsDefined) { + if (Boolean(errorIsDefined)) { if ( commandType === 'prepareToAspirate' && errorType === DEFINED_ERROR_TYPES.OVERPRESSURE diff --git a/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx b/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx index be9e5e25cbb..2866c3f95dd 100644 --- a/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx +++ b/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx @@ -172,13 +172,14 @@ export function CurrentRunningProtocolCommand({ } const currentRunStatus = t(`status_${runStatus}`) - const { currentStepNumber, totalStepCount } = useRunningStepCounts( - runId, - mostRecentCommandData - ) - const stepCounterCopy = `${t('step')} ${currentStepNumber ?? '?'}/${ - totalStepCount ?? '?' - }` + const { + currentStepNumber, + totalStepCount, + hasRunDiverged, + } = useRunningStepCounts(runId, mostRecentCommandData) + const stepCounterCopy = hasRunDiverged + ? `${t('step_na')}` + : `${t('step')} ${currentStepNumber ?? '?'}/${totalStepCount ?? '?'}` const onStop = (): void => { if (runStatus === RUN_STATUS_RUNNING) pauseRun() diff --git a/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx b/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx index 54d241ff9af..581df7c013c 100644 --- a/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx +++ b/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx @@ -125,7 +125,7 @@ describe('CurrentRunningProtocolCommand', () => { }) render(props) - screen.getByText('Step ?/?') + screen.getByText('Step: N/A') }) // ToDo (kj:04/10/2023) once we fix the track event stuff, we can implement tests diff --git a/app/src/pages/ODD/RunningProtocol/index.tsx b/app/src/pages/ODD/RunningProtocol/index.tsx index 4c63302564e..33d9d515930 100644 --- a/app/src/pages/ODD/RunningProtocol/index.tsx +++ b/app/src/pages/ODD/RunningProtocol/index.tsx @@ -168,7 +168,7 @@ export function RunningProtocol(): JSX.Element { ) : null} diff --git a/app/src/resources/runs/useMostRecentCompletedAnalysis.ts b/app/src/resources/runs/useMostRecentCompletedAnalysis.ts index e5188af8d38..7a3f9eefb7e 100644 --- a/app/src/resources/runs/useMostRecentCompletedAnalysis.ts +++ b/app/src/resources/runs/useMostRecentCompletedAnalysis.ts @@ -8,7 +8,6 @@ import { useNotifyRunQuery } from '/app/resources/runs' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' -// TODO(jh, 06-17-24): This is used elsewhere in the app and should probably live in something like resources. export function useMostRecentCompletedAnalysis( runId: string | null ): CompletedProtocolAnalysis | null { From c5b323dea7c16bfb85916ef8a4fb72ed70219af8 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Tue, 5 Nov 2024 12:33:09 -0500 Subject: [PATCH 14/49] fix(app): copy and styling fixes (#16683) fix RQA-3483, RQA-3443, RQA-3465, RQA-3449 --- .../localization/en/quick_transfer.json | 4 +- .../localization/zh/quick_transfer.json | 2 - .../SetupLiquids/SetupLiquidsMap.tsx | 111 +++++++++--------- .../ProtocolParameters/index.tsx | 6 +- .../ProtocolSetupLabware/LabwareMapView.tsx | 1 - .../QuickTransferAdvancedSettings/Delay.tsx | 2 +- .../TouchTip.tsx | 2 +- .../Delay.test.tsx | 2 +- .../TouchTip.test.tsx | 2 +- 9 files changed, 67 insertions(+), 65 deletions(-) diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index 32efac281bc..c986da098c1 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -39,7 +39,7 @@ "create_new_transfer": "Create new quick transfer", "create_transfer": "Create transfer", "delay": "Delay", - "delay_before_aspirating": "Delay before aspirating", + "delay_after_aspirating": "Delay after aspirating", "delay_before_dispensing": "Delay before dispensing", "delay_duration_s": "Delay duration (seconds)", "delay_position_mm": "Delay position from bottom of well (mm)", @@ -130,7 +130,7 @@ "tip_position_value": "{{position}} mm from the bottom", "tip_rack": "Tip rack", "touch_tip": "Touch tip", - "touch_tip_before_aspirating": "Touch tip before aspirating", + "touch_tip_after_aspirating": "Touch tip after aspirating", "touch_tip_before_dispensing": "Touch tip before dispensing", "touch_tip_position_mm": "Touch tip position from bottom of well (mm)", "touch_tip_value": "{{position}} mm from bottom", diff --git a/app/src/assets/localization/zh/quick_transfer.json b/app/src/assets/localization/zh/quick_transfer.json index 4a1e2779d52..f57d7315651 100644 --- a/app/src/assets/localization/zh/quick_transfer.json +++ b/app/src/assets/localization/zh/quick_transfer.json @@ -39,7 +39,6 @@ "create_new_transfer": "创建新的快速移液命令", "create_to_get_started": "创建新的快速移液以开始操作。", "create_transfer": "创建移液命令", - "delay_before_aspirating": "吸取前的延迟", "delay_before_dispensing": "分液前的延迟", "delay_duration_s": "延迟时长(秒)", "delay_position_mm": "距孔底延迟时的位置(mm)", @@ -132,7 +131,6 @@ "tip_rack": "吸头盒", "too_many_pins_body": "删除一个快速移液,以便向您的固定列表中添加更多传输。", "too_many_pins_header": "您已达到上限!", - "touch_tip_before_aspirating": "在吸液前做碰壁动作", "touch_tip_before_dispensing": "在分液前做碰壁动作", "touch_tip_position_mm": "在孔底部做碰壁动作的高度(mm)", "touch_tip_value": "距底部 {{position}} mm", diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx index 1b556692f8d..68ce1bdfc22 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx @@ -9,6 +9,7 @@ import { Flex, JUSTIFY_CENTER, LabwareRender, + Box, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, @@ -130,60 +131,62 @@ export function SetupLiquidsMap( alignItems={ALIGN_CENTER} justifyContent={JUSTIFY_CENTER} > - - {map(labwareRenderInfo, ({ x, y }, labwareId) => { - const { - topLabwareId, - topLabwareDefinition, - topLabwareDisplayName, - } = getTopLabwareInfo(labwareId, loadLabwareCommands) - const wellFill = getWellFillFromLabwareId( - topLabwareId ?? '', - liquids, - labwareByLiquidId - ) - const labwareHasLiquid = !isEmpty(wellFill) - return topLabwareDefinition != null ? ( - - { - setHoverLabwareId(topLabwareId) - }} - onMouseLeave={() => { - setHoverLabwareId('') - }} - onClick={() => { - if (labwareHasLiquid) { - setLiquidDetailsLabwareId(topLabwareId) - } - }} - cursor={labwareHasLiquid ? 'pointer' : ''} - > - - - - - ) : null - })} - + + + {map(labwareRenderInfo, ({ x, y }, labwareId) => { + const { + topLabwareId, + topLabwareDefinition, + topLabwareDisplayName, + } = getTopLabwareInfo(labwareId, loadLabwareCommands) + const wellFill = getWellFillFromLabwareId( + topLabwareId ?? '', + liquids, + labwareByLiquidId + ) + const labwareHasLiquid = !isEmpty(wellFill) + return topLabwareDefinition != null ? ( + + { + setHoverLabwareId(topLabwareId) + }} + onMouseLeave={() => { + setHoverLabwareId('') + }} + onClick={() => { + if (labwareHasLiquid) { + setLiquidDetailsLabwareId(topLabwareId) + } + }} + cursor={labwareHasLiquid ? 'pointer' : ''} + > + + + + + ) : null + })} + + {liquidDetailsLabwareId != null && ( @@ -46,7 +46,9 @@ export function ProtocolParameters({ ) : ( - + )} ) diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx index 339ad981daa..d311a6aab5a 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx @@ -73,7 +73,6 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { } : undefined, highlightLabware: true, - highlightShadowLabware: isLabwareStacked, moduleChildren: null, stacked: isLabwareStacked, } diff --git a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx index 56aad4d7820..0692cc904ac 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx @@ -176,7 +176,7 @@ export function Delay(props: DelayProps): JSX.Element { { it('renders the first delay screen, continue, and back buttons', () => { render(props) - screen.getByText('Delay before aspirating') + screen.getByText('Delay after aspirating') screen.getByTestId('ChildNavigation_Primary_Button') screen.getByText('Enabled') screen.getByText('Disabled') diff --git a/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/TouchTip.test.tsx b/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/TouchTip.test.tsx index cc2b463c9a7..cc30db0a54f 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/TouchTip.test.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/TouchTip.test.tsx @@ -83,7 +83,7 @@ describe('TouchTip', () => { it('renders the first touch tip screen, continue, and back buttons', () => { render(props) - screen.getByText('Touch tip before aspirating') + screen.getByText('Touch tip after aspirating') screen.getByTestId('ChildNavigation_Primary_Button') screen.getByText('Enabled') screen.getByText('Disabled') From 3cf6f34ed011eb2e4a5a3c6d07cad888b5326216 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 5 Nov 2024 13:23:43 -0500 Subject: [PATCH 15/49] fix(app): Update gripper homing behavior during gripper Error Recovery (#16691) Closes RQA-3489 and RQA-3487 Although Opentrons/ot3-firmware#813 fixes the current behavior and unblocks gripper recovery, updating the position estimators isn't sufficiently accurate for post-recovery, protocol run commands. Instead, we should home everything minus the pipette plungers. There is some minor copy update on one view only per discussion w/ design. --- .../localization/en/error_recovery.json | 2 +- .../hooks/__tests__/useHomeGripper.test.ts | 32 +++++-------------- .../__tests__/useRecoveryCommands.test.ts | 7 ++-- .../hooks/useHomeGripper.ts | 12 +++---- .../hooks/useRecoveryCommands.ts | 19 +++++------ .../shared/RecoveryDoorOpenSpecial.tsx | 2 +- .../RecoveryDoorOpenSpecial.test.tsx | 2 +- 7 files changed, 26 insertions(+), 50 deletions(-) diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 99589c63216..392c9c694c3 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -19,7 +19,7 @@ "continue_run_now": "Continue run now", "continue_to_drop_tip": "Continue to drop tip", "do_you_need_to_blowout": "First, do you need to blow out aspirated liquid?", - "door_open_gripper_home": "The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.", + "door_open_robot_home": "The robot needs to safely move to its home location before you manually move the labware.", "ensure_lw_is_accurately_placed": "Ensure labware is accurately placed in the slot to prevent further errors.", "error": "Error", "error_details": "Error details", diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts index aefd00e5db2..32de0f0096d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripper.test.ts @@ -6,9 +6,7 @@ import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' describe('useHomeGripper', () => { const mockRecoveryCommands = { - updatePositionEstimatorsAndHomeGripper: vi - .fn() - .mockResolvedValue(undefined), + homeExceptPlungers: vi.fn().mockResolvedValue(undefined), } const mockRouteUpdateActions = { @@ -45,9 +43,7 @@ describe('useHomeGripper', () => { expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( true ) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalled() + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalled() expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( false ) @@ -64,9 +60,7 @@ describe('useHomeGripper', () => { }) expect(mockRouteUpdateActions.goBackPrevStep).toHaveBeenCalled() - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).not.toHaveBeenCalled() + expect(mockRecoveryCommands.homeExceptPlungers).not.toHaveBeenCalled() }) it('should not home again if already homed once', async () => { @@ -83,15 +77,11 @@ describe('useHomeGripper', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(1) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) rerender() - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(1) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) }) it('should only reset hasHomedOnce when step changes to non-manual gripper step', async () => { @@ -113,9 +103,7 @@ describe('useHomeGripper', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(1) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) rerender({ recoveryMap: { step: 'SOME_OTHER_STEP' } as any }) @@ -123,9 +111,7 @@ describe('useHomeGripper', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(1) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) rerender({ recoveryMap: mockRecoveryMap }) @@ -133,8 +119,6 @@ describe('useHomeGripper', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - expect( - mockRecoveryCommands.updatePositionEstimatorsAndHomeGripper - ).toHaveBeenCalledTimes(2) + expect(mockRecoveryCommands.homeExceptPlungers).toHaveBeenCalledTimes(1) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index 565ef49c951..ca2e086d9fd 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -16,8 +16,7 @@ import { buildPickUpTips, buildIgnorePolicyRules, isAssumeFalsePositiveResumeKind, - UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, - HOME_GRIPPER_Z, + HOME_EXCEPT_PLUNGERS, } from '../useRecoveryCommands' import { RECOVERY_MAP, ERROR_KINDS } from '../../constants' import { getErrorKind } from '/app/organisms/ErrorRecoveryFlows/utils' @@ -290,11 +289,11 @@ describe('useRecoveryCommands', () => { const { result } = renderHook(() => useRecoveryCommands(props)) await act(async () => { - await result.current.updatePositionEstimatorsAndHomeGripper() + await result.current.homeExceptPlungers() }) expect(mockChainRunCommands).toHaveBeenCalledWith( - [UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, HOME_GRIPPER_Z], + [HOME_EXCEPT_PLUNGERS], false ) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts index 38299761cfd..55fe64fdcc4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripper.ts @@ -20,7 +20,7 @@ export function useHomeGripper({ useLayoutEffect(() => { const { handleMotionRouting, goBackPrevStep } = routeUpdateActions - const { updatePositionEstimatorsAndHomeGripper } = recoveryCommands + const { homeExceptPlungers } = recoveryCommands if (!hasHomedOnce) { if (isManualGripperStep) { @@ -28,17 +28,13 @@ export function useHomeGripper({ void goBackPrevStep() } else { void handleMotionRouting(true) - .then(() => updatePositionEstimatorsAndHomeGripper()) - .finally(() => { - handleMotionRouting(false) + .then(() => homeExceptPlungers()) + .then(() => handleMotionRouting(false)) + .then(() => { setHasHomedOnce(true) }) } } - } else { - if (!isManualGripperStep && hasHomedOnce) { - setHasHomedOnce(false) - } } }, [step, hasHomedOnce, isDoorOpen, isManualGripperStep]) } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index d6190eaa16d..4ce5194aca4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -69,7 +69,7 @@ export interface UseRecoveryCommandsResult { /* A non-terminal recovery command */ releaseGripperJaws: () => Promise /* A non-terminal recovery command */ - updatePositionEstimatorsAndHomeGripper: () => Promise + homeExceptPlungers: () => Promise /* A non-terminal recovery command */ moveLabwareWithoutPause: () => Promise } @@ -294,13 +294,8 @@ export function useRecoveryCommands({ return chainRunRecoveryCommands([RELEASE_GRIPPER_JAW]) }, [chainRunRecoveryCommands]) - const updatePositionEstimatorsAndHomeGripper = useCallback((): Promise< - CommandData[] - > => { - return chainRunRecoveryCommands([ - UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, - HOME_GRIPPER_Z, - ]) + const homeExceptPlungers = useCallback((): Promise => { + return chainRunRecoveryCommands([HOME_EXCEPT_PLUNGERS]) }, [chainRunRecoveryCommands]) const moveLabwareWithoutPause = useCallback((): Promise => { @@ -321,7 +316,7 @@ export function useRecoveryCommands({ homePipetteZAxes, pickUpTips, releaseGripperJaws, - updatePositionEstimatorsAndHomeGripper, + homeExceptPlungers, moveLabwareWithoutPause, skipFailedCommand, ignoreErrorKindThisRun, @@ -360,9 +355,11 @@ export const UPDATE_ESTIMATORS_EXCEPT_PLUNGERS: CreateCommand = { params: { axes: ['x', 'y', 'extensionZ'] }, } -export const HOME_GRIPPER_Z: CreateCommand = { +export const HOME_EXCEPT_PLUNGERS: CreateCommand = { commandType: 'home', - params: { axes: ['extensionZ'] }, + params: { + axes: ['extensionJaw', 'extensionZ', 'leftZ', 'rightZ', 'x', 'y'], + }, } const buildMoveLabwareWithoutPause = ( diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx index d05db0a0b60..4331a976d5e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx @@ -46,7 +46,7 @@ export function RecoveryDoorOpenSpecial({ switch (selectedRecoveryOption) { case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: case RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE: - return t('door_open_gripper_home') + return t('door_open_robot_home') default: { console.error( `Unhandled special-cased door open subtext on route ${selectedRecoveryOption}.` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx index 423f75396c0..76e42a04c6d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx @@ -66,7 +66,7 @@ describe('RecoveryDoorOpenSpecial', () => { render(props) screen.getByText('Close the robot door') screen.getByText( - 'The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.' + 'The robot needs to safely move to its home location before you manually move the labware.' ) }) From 01c06d5bf6b9dc87a2b530d5725d64fa042a4944 Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Tue, 5 Nov 2024 13:25:50 -0500 Subject: [PATCH 16/49] fix(api): restrict the labware that can be moved to the plate reader + validate wavelengths. (#16649) --- .../protocol_api/core/engine/module_core.py | 39 ++++++++++++++++++- .../protocol_engine/commands/load_labware.py | 9 +++++ .../protocol_engine/commands/move_labware.py | 9 +++++ .../protocol_engine/state/labware.py | 27 +++++++++++++ .../engine/test_absorbance_reader_core.py | 31 +++++++++------ 5 files changed, 103 insertions(+), 12 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index 1e6d4e26b2f..d3cf8dca725 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -41,6 +41,11 @@ from .exceptions import InvalidMagnetEngageHeightError +# Valid wavelength range for absorbance reader +ABS_WAVELENGTH_MIN = 350 +ABS_WAVELENGTH_MAX = 1000 + + class ModuleCore(AbstractModuleCore): """Module core logic implementation for Python protocols. Args: @@ -581,7 +586,39 @@ def initialize( "Cannot perform Initialize action on Absorbance Reader without calling `.close_lid()` first." ) - # TODO: check that the wavelengths are within the supported wavelengths + wavelength_len = len(wavelengths) + if mode == "single" and wavelength_len != 1: + raise ValueError( + f"Single mode can only be initialized with 1 wavelength" + f" {wavelength_len} wavelengths provided instead." + ) + + if mode == "multi" and (wavelength_len < 1 or wavelength_len > 6): + raise ValueError( + f"Multi mode can only be initialized with 1 - 6 wavelengths." + f" {wavelength_len} wavelengths provided instead." + ) + + if reference_wavelength is not None and ( + reference_wavelength < ABS_WAVELENGTH_MIN + or reference_wavelength > ABS_WAVELENGTH_MAX + ): + raise ValueError( + f"Unsupported reference wavelength: ({reference_wavelength}) needs" + f" to between {ABS_WAVELENGTH_MIN} and {ABS_WAVELENGTH_MAX} nm." + ) + + for wavelength in wavelengths: + if ( + not isinstance(wavelength, int) + or wavelength < ABS_WAVELENGTH_MIN + or wavelength > ABS_WAVELENGTH_MAX + ): + raise ValueError( + f"Unsupported sample wavelength: ({wavelength}) needs" + f" to between {ABS_WAVELENGTH_MIN} and {ABS_WAVELENGTH_MAX} nm." + ) + self._engine_client.execute_command( cmd.absorbance_reader.InitializeParams( moduleId=self.module_id, diff --git a/api/src/opentrons/protocol_engine/commands/load_labware.py b/api/src/opentrons/protocol_engine/commands/load_labware.py index 05eccb95a7a..fb97f5d2c87 100644 --- a/api/src/opentrons/protocol_engine/commands/load_labware.py +++ b/api/src/opentrons/protocol_engine/commands/load_labware.py @@ -10,6 +10,8 @@ from ..resources import labware_validation, fixture_validation from ..types import ( LabwareLocation, + ModuleLocation, + ModuleModel, OnLabwareLocation, DeckSlotLocation, AddressableAreaLocation, @@ -160,6 +162,13 @@ async def execute( top_labware_definition=loaded_labware.definition, bottom_labware_id=verified_location.labwareId, ) + # Validate labware for the absorbance reader + elif isinstance(params.location, ModuleLocation): + module = self._state_view.modules.get(params.location.moduleId) + if module is not None and module.model == ModuleModel.ABSORBANCE_READER_V1: + self._state_view.labware.raise_if_labware_incompatible_with_plate_reader( + loaded_labware.definition + ) return SuccessData( public=LoadLabwareResult( diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index eb4b101e76c..09cdc08561c 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -13,9 +13,11 @@ from opentrons.protocol_engine.resources.model_utils import ModelUtils from opentrons.types import Point from ..types import ( + ModuleModel, CurrentWell, LabwareLocation, DeckSlotLocation, + ModuleLocation, OnLabwareLocation, AddressableAreaLocation, LabwareMovementStrategy, @@ -221,6 +223,13 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C raise LabwareMovementNotAllowedError( "Cannot move a labware onto itself." ) + # Validate labware for the absorbance reader + elif isinstance(available_new_location, ModuleLocation): + module = self._state_view.modules.get(available_new_location.moduleId) + if module is not None and module.model == ModuleModel.ABSORBANCE_READER_V1: + self._state_view.labware.raise_if_labware_incompatible_with_plate_reader( + current_labware_definition + ) # Allow propagation of ModuleNotLoadedError. new_offset_id = self._equipment.find_applicable_labware_offset_id( diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 0bbb8b3ab30..052ca1666ed 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -81,6 +81,10 @@ } +# The max height of the labware that can fit in a plate reader +_PLATE_READER_MAX_LABWARE_Z_MM = 16 + + class LabwareLoadParams(NamedTuple): """Parameters required to load a labware in Protocol Engine.""" @@ -818,6 +822,29 @@ def raise_if_labware_in_location( f"Labware {labware.loadName} is already present at {location}." ) + def raise_if_labware_incompatible_with_plate_reader( + self, + labware_definition: LabwareDefinition, + ) -> None: + """Raise an error if the labware is not compatible with the plate reader.""" + # TODO: (ba, 2024-11-1): the plate reader lid should not be a labware. + load_name = labware_definition.parameters.loadName + if load_name != "opentrons_flex_lid_absorbance_plate_reader_module": + number_of_wells = len(labware_definition.wells) + if number_of_wells != 96: + raise errors.LabwareMovementNotAllowedError( + f"Cannot move '{load_name}' into plate reader because the" + f" labware contains {number_of_wells} wells where 96 wells is expected." + ) + elif ( + labware_definition.dimensions.zDimension + > _PLATE_READER_MAX_LABWARE_Z_MM + ): + raise errors.LabwareMovementNotAllowedError( + f"Cannot move '{load_name}' into plate reader because the" + f" maximum allowed labware height is {_PLATE_READER_MAX_LABWARE_Z_MM}mm." + ) + def raise_if_labware_cannot_be_stacked( # noqa: C901 self, top_labware_definition: LabwareDefinition, bottom_labware_id: str ) -> None: diff --git a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py index fd537d4cad9..22b734a6024 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py @@ -69,59 +69,67 @@ def test_initialize( ) -> None: """It should set the sample wavelength with the engine client.""" subject._ready_to_initialize = True - subject.initialize("single", [123]) + subject.initialize("single", [350]) decoy.verify( mock_engine_client.execute_command( cmd.absorbance_reader.InitializeParams( moduleId="1234", measureMode="single", - sampleWavelengths=[123], + sampleWavelengths=[350], referenceWavelength=None, ), ), times=1, ) - assert subject._initialized_value == [123] + assert subject._initialized_value == [350] # Test reference wavelength - subject.initialize("single", [124], 450) + subject.initialize("single", [350], 450) decoy.verify( mock_engine_client.execute_command( cmd.absorbance_reader.InitializeParams( moduleId="1234", measureMode="single", - sampleWavelengths=[124], + sampleWavelengths=[350], referenceWavelength=450, ), ), times=1, ) - assert subject._initialized_value == [124] + assert subject._initialized_value == [350] # Test initialize multi - subject.initialize("multi", [124, 125, 126]) + subject.initialize("multi", [350, 400, 450]) decoy.verify( mock_engine_client.execute_command( cmd.absorbance_reader.InitializeParams( moduleId="1234", measureMode="multi", - sampleWavelengths=[124, 125, 126], + sampleWavelengths=[350, 400, 450], referenceWavelength=None, ), ), times=1, ) - assert subject._initialized_value == [124, 125, 126] + assert subject._initialized_value == [350, 400, 450] def test_initialize_not_ready(subject: AbsorbanceReaderCore) -> None: """It should raise CannotPerformModuleAction if you dont call .close_lid() command.""" subject._ready_to_initialize = False with pytest.raises(CannotPerformModuleAction): - subject.initialize("single", [123]) + subject.initialize("single", [350]) + + +@pytest.mark.parametrize("wavelength", [-350, 0, 1200, "wda"]) +def test_invalid_wavelengths(wavelength: int, subject: AbsorbanceReaderCore) -> None: + """It should raise ValueError if you provide an invalid wavelengthi.""" + subject._ready_to_initialize = True + with pytest.raises(ValueError): + subject.initialize("single", [wavelength]) def test_read( @@ -129,7 +137,7 @@ def test_read( ) -> None: """It should call absorbance reader to read with the engine client.""" subject._ready_to_initialize = True - subject._initialized_value = [123] + subject._initialized_value = [350] substate = AbsorbanceReaderSubState( module_id=AbsorbanceReaderId(subject.module_id), configured=True, @@ -152,6 +160,7 @@ def test_read( mock_engine_client.execute_command( cmd.absorbance_reader.ReadAbsorbanceParams( moduleId="1234", + fileName=None, ), ), times=1, From 7669fc20001fe0ffd580db7574bf86968821ca7b Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Tue, 5 Nov 2024 13:58:58 -0500 Subject: [PATCH 17/49] fix(api): Ensure stack of labware on Staging Area Slot properly resolves ancestor slot (#16681) Covers PLAT-538 Ensures labware stacked in staging area slots can resolve their lowest ancestor slot. --- .../protocol_api/core/engine/labware.py | 8 +++- .../core/engine/pipette_movement_conflict.py | 11 +++-- .../protocol_engine/execution/movement.py | 13 ++++-- .../protocol_engine/state/geometry.py | 43 +++++++++++++------ .../protocol_engine/state/modules.py | 6 +-- .../opentrons/protocol_engine/state/motion.py | 22 +++++++--- .../state/test_geometry_view.py | 29 ++++++++++++- 7 files changed, 100 insertions(+), 32 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/labware.py b/api/src/opentrons/protocol_api/core/engine/labware.py index 9648805c563..f7f4b4cdca6 100644 --- a/api/src/opentrons/protocol_api/core/engine/labware.py +++ b/api/src/opentrons/protocol_api/core/engine/labware.py @@ -19,7 +19,7 @@ LabwareOffsetCreate, LabwareOffsetVector, ) -from opentrons.types import DeckSlotName, Point +from opentrons.types import DeckSlotName, Point, StagingSlotName from opentrons.hardware_control.nozzle_manager import NozzleMap @@ -190,9 +190,13 @@ def get_well_core(self, well_name: str) -> WellCore: def get_deck_slot(self) -> Optional[DeckSlotName]: """Get the deck slot the labware is in, if on deck.""" try: - return self._engine_client.state.geometry.get_ancestor_slot_name( + ancestor = self._engine_client.state.geometry.get_ancestor_slot_name( self.labware_id ) + if isinstance(ancestor, StagingSlotName): + # The only use case for get_deck_slot is with a legacy OT-2 function which resolves to a numerical deck slot, so we can ignore staging area slots for now + return None + return ancestor except ( LabwareNotOnDeckError, ModuleNotOnDeckError, diff --git a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py index 46968c486d7..d0091768be8 100644 --- a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py @@ -9,6 +9,7 @@ ) from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError +from opentrons.protocol_engine.errors import LocationIsStagingSlotError from opentrons_shared_data.module import FLEX_TC_LID_COLLISION_ZONE from opentrons.hardware_control import CriticalPoint @@ -63,7 +64,7 @@ def __init__(self, message: str) -> None: ) -def check_safe_for_pipette_movement( +def check_safe_for_pipette_movement( # noqa: C901 engine_state: StateView, pipette_id: str, labware_id: str, @@ -121,8 +122,12 @@ def check_safe_for_pipette_movement( f"Requested motion with the {primary_nozzle} nozzle partial configuration" f" is outside of robot bounds for the pipette." ) - - labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id) + ancestor = engine_state.geometry.get_ancestor_slot_name(labware_id) + if isinstance(ancestor, StagingSlotName): + raise LocationIsStagingSlotError( + "Cannot perform pipette actions on labware in Staging Area Slot." + ) + labware_slot = ancestor surrounding_slots = adjacent_slots_getters.get_surrounding_slots( slot=labware_slot.as_int(), robot_type=engine_state.config.robot_type diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index 7eb35e5f911..7681a1ce07a 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -4,9 +4,10 @@ import logging from typing import Optional, List, Union -from opentrons.types import Point, MountType +from opentrons.types import Point, MountType, StagingSlotName from opentrons.hardware_control import HardwareControlAPI from opentrons_shared_data.errors.exceptions import PositionUnknownError +from opentrons.protocol_engine.errors import LocationIsStagingSlotError from ..types import ( WellLocation, @@ -93,9 +94,13 @@ async def move_to_well( self._state_store.modules.get_heater_shaker_movement_restrictors() ) - dest_slot_int = self._state_store.geometry.get_ancestor_slot_name( - labware_id - ).as_int() + ancestor = self._state_store.geometry.get_ancestor_slot_name(labware_id) + if isinstance(ancestor, StagingSlotName): + raise LocationIsStagingSlotError( + "Cannot move to well on labware in Staging Area Slot." + ) + + dest_slot_int = ancestor.as_int() self._hs_movement_flagger.raise_if_movement_restricted( hs_movement_restrictors=hs_movement_restrictors, diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 471065adcc2..57027fdf9f7 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -709,10 +709,12 @@ def _get_lid_dock_slot_name(self, labware_id: str) -> str: assert isinstance(labware_location, AddressableAreaLocation) return labware_location.addressableAreaName - def get_ancestor_slot_name(self, labware_id: str) -> DeckSlotName: + def get_ancestor_slot_name( + self, labware_id: str + ) -> Union[DeckSlotName, StagingSlotName]: """Get the slot name of the labware or the module that the labware is on.""" labware = self._labware.get(labware_id) - slot_name: DeckSlotName + slot_name: Union[DeckSlotName, StagingSlotName] if isinstance(labware.location, DeckSlotLocation): slot_name = labware.location.slotName @@ -724,18 +726,14 @@ def get_ancestor_slot_name(self, labware_id: str) -> DeckSlotName: slot_name = self.get_ancestor_slot_name(below_labware_id) elif isinstance(labware.location, AddressableAreaLocation): area_name = labware.location.addressableAreaName - # TODO we might want to eventually return some sort of staging slot name when we're ready to work through - # the linting nightmare it will create if self._labware.is_absorbance_reader_lid(labware_id): raise errors.LocationIsLidDockSlotError( "Cannot get ancestor slot name for labware on lid dock slot." ) - if fixture_validation.is_staging_slot(area_name): - raise errors.LocationIsStagingSlotError( - "Cannot get ancestor slot name for labware on staging slot." - ) - raise errors.LocationIs - slot_name = DeckSlotName.from_primitive(area_name) + elif fixture_validation.is_staging_slot(area_name): + slot_name = StagingSlotName.from_primitive(area_name) + else: + slot_name = DeckSlotName.from_primitive(area_name) elif labware.location == OFF_DECK_LOCATION: raise errors.LabwareNotOnDeckError( f"Labware {labware_id} does not have a slot associated with it" @@ -829,7 +827,9 @@ def get_labware_grip_point( ) def get_extra_waypoints( - self, location: Optional[CurrentPipetteLocation], to_slot: DeckSlotName + self, + location: Optional[CurrentPipetteLocation], + to_slot: Union[DeckSlotName, StagingSlotName], ) -> List[Tuple[float, float]]: """Get extra waypoints for movement if thermocycler needs to be dodged.""" if location is not None: @@ -888,8 +888,10 @@ def get_slot_item( return maybe_labware or maybe_module or maybe_fixture or None @staticmethod - def get_slot_column(slot_name: DeckSlotName) -> int: + def get_slot_column(slot_name: Union[DeckSlotName, StagingSlotName]) -> int: """Get the column number for the specified slot.""" + if isinstance(slot_name, StagingSlotName): + return 4 row_col_name = slot_name.to_ot3_equivalent() slot_name_match = WELL_NAME_PATTERN.match(row_col_name.value) assert ( @@ -1170,7 +1172,13 @@ def get_total_nominal_gripper_offset_for_move_type( ) assert isinstance( - ancestor, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) + ancestor, + ( + DeckSlotLocation, + ModuleLocation, + OnLabwareLocation, + AddressableAreaLocation, + ), ), "No gripper offsets for off-deck labware" return ( direct_parent_offset.pickUpOffset @@ -1217,7 +1225,13 @@ def get_total_nominal_gripper_offset_for_move_type( ) assert isinstance( - ancestor, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) + ancestor, + ( + DeckSlotLocation, + ModuleLocation, + OnLabwareLocation, + AddressableAreaLocation, + ), ), "No gripper offsets for off-deck labware" return ( direct_parent_offset.dropOffset @@ -1293,6 +1307,7 @@ def _labware_gripper_offsets( DeckSlotLocation, ModuleLocation, AddressableAreaLocation, + OnLabwareLocation, ), ), "No gripper offsets for off-deck labware" diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index ca8973b405c..82c32d9f003 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -32,7 +32,7 @@ from opentrons.protocol_engine.state.module_substates.absorbance_reader_substate import ( AbsorbanceReaderMeasureMode, ) -from opentrons.types import DeckSlotName, MountType +from opentrons.types import DeckSlotName, MountType, StagingSlotName from ..errors import ModuleNotConnectedError from ..types import ( @@ -1124,8 +1124,8 @@ def calculate_magnet_height( def should_dodge_thermocycler( self, - from_slot: DeckSlotName, - to_slot: DeckSlotName, + from_slot: Union[DeckSlotName, StagingSlotName], + to_slot: Union[DeckSlotName, StagingSlotName], ) -> bool: """Decide if the requested path would cross the thermocycler, if installed. diff --git a/api/src/opentrons/protocol_engine/state/motion.py b/api/src/opentrons/protocol_engine/state/motion.py index c9aa146715b..0863c42a0c1 100644 --- a/api/src/opentrons/protocol_engine/state/motion.py +++ b/api/src/opentrons/protocol_engine/state/motion.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import List, Optional, Union -from opentrons.types import MountType, Point +from opentrons.types import MountType, Point, StagingSlotName from opentrons.hardware_control.types import CriticalPoint from opentrons.motion_planning.adjacent_slots_getters import ( get_east_west_slots, @@ -277,9 +277,13 @@ def check_pipette_blocking_hs_latch( current_location = self._pipettes.get_current_location() if current_location is not None: if isinstance(current_location, CurrentWell): - pipette_deck_slot = self._geometry.get_ancestor_slot_name( + ancestor = self._geometry.get_ancestor_slot_name( current_location.labware_id - ).as_int() + ) + if isinstance(ancestor, StagingSlotName): + # Staging Area Slots cannot intersect with the h/s + return False + pipette_deck_slot = ancestor.as_int() else: pipette_deck_slot = ( self._addressable_areas.get_addressable_area_base_slot( @@ -299,9 +303,13 @@ def check_pipette_blocking_hs_shaker( current_location = self._pipettes.get_current_location() if current_location is not None: if isinstance(current_location, CurrentWell): - pipette_deck_slot = self._geometry.get_ancestor_slot_name( + ancestor = self._geometry.get_ancestor_slot_name( current_location.labware_id - ).as_int() + ) + if isinstance(ancestor, StagingSlotName): + # Staging Area Slots cannot intersect with the h/s + return False + pipette_deck_slot = ancestor.as_int() else: pipette_deck_slot = ( self._addressable_areas.get_addressable_area_base_slot( @@ -324,6 +332,10 @@ def get_touch_tip_waypoints( """Get a list of touch points for a touch tip operation.""" mount = self._pipettes.get_mount(pipette_id) labware_slot = self._geometry.get_ancestor_slot_name(labware_id) + if isinstance(labware_slot, StagingSlotName): + raise errors.LocationIsStagingSlotError( + "Cannot perform Touch Tip on labware in Staging Area Slot." + ) next_to_module = self._modules.is_edge_move_unsafe(mount, labware_slot) edge_path_type = self._labware.get_edge_path_type( labware_id, well_name, mount, labware_slot, next_to_module diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 3f7ad59bda2..aae8c460b17 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -17,7 +17,7 @@ from opentrons_shared_data.pipette import pipette_definition from opentrons.calibration_storage.helpers import uri_from_details from opentrons.protocols.models import LabwareDefinition -from opentrons.types import Point, DeckSlotName, MountType +from opentrons.types import Point, DeckSlotName, MountType, StagingSlotName from opentrons_shared_data.pipette.types import PipetteNameType from opentrons_shared_data.labware.labware_definition import ( Dimensions as LabwareDimensions, @@ -2189,6 +2189,33 @@ def test_get_ancestor_slot_name( assert subject.get_ancestor_slot_name("labware-2") == DeckSlotName.SLOT_1 +def test_get_ancestor_slot_for_labware_stack_in_staging_area_slot( + decoy: Decoy, + mock_labware_view: LabwareView, + subject: GeometryView, +) -> None: + """It should get name of ancestor slot of a stack of labware in a staging area slot.""" + decoy.when(mock_labware_view.get("labware-1")).then_return( + LoadedLabware( + id="labware-1", + loadName="load-name", + definitionUri="1234", + location=AddressableAreaLocation( + addressableAreaName=StagingSlotName.SLOT_D4.id + ), + ) + ) + decoy.when(mock_labware_view.get("labware-2")).then_return( + LoadedLabware( + id="labware-2", + loadName="load-name", + definitionUri="1234", + location=OnLabwareLocation(labwareId="labware-1"), + ) + ) + assert subject.get_ancestor_slot_name("labware-2") == StagingSlotName.SLOT_D4 + + def test_ensure_location_not_occupied_raises( decoy: Decoy, mock_labware_view: LabwareView, From de01cf64124a7427f674344d4280420b9511910a Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 5 Nov 2024 15:03:40 -0500 Subject: [PATCH 18/49] fix(app, components): Fix TC lid rendering in `runRecord` deck maps (#16692) Closes EXEC-804 --- .../utils/getLabwareDisplayLocation.ts | 7 +- .../hooks/__tests__/useDeckMapUtils.test.ts | 159 ++++++++++++++++++ .../hooks/useDeckMapUtils.ts | 50 +++++- .../hardware-sim/Deck/MoveLabwareOnDeck.tsx | 60 +------ 4 files changed, 217 insertions(+), 59 deletions(-) diff --git a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts index 086854200ba..2e02199e667 100644 --- a/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts +++ b/app/src/local-resources/labware/utils/getLabwareDisplayLocation.ts @@ -2,6 +2,8 @@ import { getModuleDisplayName, getModuleType, getOccludedSlotCountForModule, + THERMOCYCLER_MODULE_V1, + THERMOCYCLER_MODULE_V2, } from '@opentrons/shared-data' import { getLabwareLocation } from './getLabwareLocation' @@ -50,7 +52,10 @@ export function getLabwareDisplayLocation( // Module location without adapter else if (moduleModel != null && adapterName == null) { if (params.detailLevel === 'slot-only') { - return t('slot', { slot_name: slotName }) + return moduleModel === THERMOCYCLER_MODULE_V1 || + moduleModel === THERMOCYCLER_MODULE_V2 + ? t('slot', { slot_name: 'A1+B1' }) + : t('slot', { slot_name: slotName }) } else { return isOnDevice ? `${getModuleDisplayName(moduleModel)}, ${slotName}` diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts index 165154992b1..1a6d07ba634 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts @@ -17,6 +17,7 @@ import { getRunCurrentModulesInfo, getRunCurrentLabwareOnDeck, getRunCurrentModulesOnDeck, + updateLabwareInModules, } from '../useDeckMapUtils' import type { LabwareDefinition2 } from '@opentrons/shared-data' @@ -495,3 +496,161 @@ describe('getIsLabwareMatch', () => { expect(result).toBe(false) }) }) + +describe('updateLabwareInModules', () => { + const mockLabwareDef: LabwareDefinition2 = { + ...(fixture96Plate as LabwareDefinition2), + metadata: { + displayName: 'Mock Labware Definition', + displayCategory: 'wellPlate', + displayVolumeUnits: 'mL', + }, + } + + const mockModule = { + moduleModel: 'temperatureModuleV2', + moduleLocation: { slotName: 'A1' }, + innerProps: {}, + nestedLabwareDef: null, + highlight: null, + } as any + + const mockLabware = { + labwareDef: mockLabwareDef, + labwareLocation: { slotName: 'A1' }, + slotName: 'A1', + } + + it('should update module with nested labware when they share the same slot', () => { + const result = updateLabwareInModules({ + runCurrentModules: [mockModule], + currentLabwareInfo: [mockLabware], + }) + + expect(result.updatedModules).toEqual([ + { + ...mockModule, + nestedLabwareDef: mockLabwareDef, + }, + ]) + expect(result.remainingLabware).toEqual([]) + }) + + it('should keep labware separate when slots do not match', () => { + const labwareInDifferentSlot = { + ...mockLabware, + labwareLocation: { slotName: 'B1' }, + slotName: 'B1', + } + + const result = updateLabwareInModules({ + runCurrentModules: [mockModule], + currentLabwareInfo: [labwareInDifferentSlot], + }) + + expect(result.updatedModules).toEqual([mockModule]) + expect(result.remainingLabware).toEqual([labwareInDifferentSlot]) + }) + + it('should handle multiple modules and labware', () => { + const mockModuleB1 = { + ...mockModule, + moduleLocation: { slotName: 'B1' }, + } + + const labwareB1 = { + ...mockLabware, + labwareLocation: { slotName: 'B1' }, + slotName: 'B1', + } + + const labwareC1 = { + ...mockLabware, + labwareLocation: { slotName: 'C1' }, + slotName: 'C1', + } + + const result = updateLabwareInModules({ + runCurrentModules: [mockModule, mockModuleB1], + currentLabwareInfo: [mockLabware, labwareB1, labwareC1], + }) + + expect(result.updatedModules).toEqual([ + { + ...mockModule, + nestedLabwareDef: mockLabwareDef, + }, + { + ...mockModuleB1, + nestedLabwareDef: mockLabwareDef, + }, + ]) + expect(result.remainingLabware).toEqual([labwareC1]) + }) + + it('should handle empty modules array', () => { + const result = updateLabwareInModules({ + runCurrentModules: [], + currentLabwareInfo: [mockLabware], + }) + + expect(result.updatedModules).toEqual([]) + expect(result.remainingLabware).toEqual([mockLabware]) + }) + + it('should handle empty labware array', () => { + const result = updateLabwareInModules({ + runCurrentModules: [mockModule], + currentLabwareInfo: [], + }) + + expect(result.updatedModules).toEqual([mockModule]) + expect(result.remainingLabware).toEqual([]) + }) + + it('should handle multiple labware in same slot, nesting only one with module', () => { + const labwareA1Second = { + ...mockLabware, + labwareDef: { + ...mockLabwareDef, + metadata: { + ...mockLabwareDef.metadata, + displayName: 'Second Labware', + }, + }, + } + + const result = updateLabwareInModules({ + runCurrentModules: [mockModule], + currentLabwareInfo: [mockLabware, labwareA1Second], + }) + + expect(result.updatedModules).toEqual([ + { + ...mockModule, + nestedLabwareDef: mockLabwareDef, + }, + ]) + expect(result.remainingLabware).toEqual([]) + }) + + it('should preserve module properties when updating with nested labware', () => { + const moduleWithProperties = { + ...mockModule, + innerProps: { lidMotorState: 'open' }, + highlight: 'someHighlight', + } + + const result = updateLabwareInModules({ + runCurrentModules: [moduleWithProperties], + currentLabwareInfo: [mockLabware], + }) + + expect(result.updatedModules).toEqual([ + { + ...moduleWithProperties, + nestedLabwareDef: mockLabwareDef, + }, + ]) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts index 3ef8993b984..458747f5b07 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts @@ -71,6 +71,8 @@ export function useDeckMapUtils({ const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) const deckDef = getDeckDefFromRobotType(robotType) + // TODO(jh, 11-05-24): Revisit this logic along with deckmap interfaces after deck map redesign. + const currentModulesInfo = useMemo( () => getRunCurrentModulesInfo({ @@ -96,12 +98,17 @@ export function useDeckMapUtils({ [runRecord, labwareDefinitionsByUri] ) + const { updatedModules, remainingLabware } = useMemo( + () => updateLabwareInModules({ runCurrentModules, currentLabwareInfo }), + [runCurrentModules, currentLabwareInfo] + ) + const runCurrentLabware = useMemo( () => getRunCurrentLabwareOnDeck({ failedLabwareUtils, runRecord, - currentLabwareInfo, + currentLabwareInfo: remainingLabware, }), [failedLabwareUtils, currentLabwareInfo] ) @@ -137,7 +144,7 @@ export function useDeckMapUtils({ return { deckConfig, - modulesOnDeck: runCurrentModules.map( + modulesOnDeck: updatedModules.map( ({ moduleModel, moduleLocation, innerProps, nestedLabwareDef }) => ({ moduleModel, moduleLocation, @@ -149,7 +156,7 @@ export function useDeckMapUtils({ labwareLocation, definition, })), - highlightLabwareEventuallyIn: [...runCurrentModules, ...runCurrentLabware] + highlightLabwareEventuallyIn: [...updatedModules, ...runCurrentLabware] .map(el => el.highlight) .filter(maybeSlot => maybeSlot != null) as string[], kind: 'intervention', @@ -459,3 +466,40 @@ export function getIsLabwareMatch( return slotLocation === slotName } } + +// If any labware share a slot with a module, the labware should be nested within the module for rendering purposes. +// This prevents issues such as TC nested labware rendering in "B1" instead of the special-cased location. +export function updateLabwareInModules({ + runCurrentModules, + currentLabwareInfo, +}: { + runCurrentModules: ReturnType + currentLabwareInfo: ReturnType +}): { + updatedModules: ReturnType + remainingLabware: ReturnType +} { + const usedSlots = new Set() + + const updatedModules = runCurrentModules.map(moduleInfo => { + const labwareInSameLoc = currentLabwareInfo.find( + lw => moduleInfo.moduleLocation.slotName === lw.slotName + ) + + if (labwareInSameLoc != null) { + usedSlots.add(labwareInSameLoc.slotName) + return { + ...moduleInfo, + nestedLabwareDef: labwareInSameLoc.labwareDef, + } + } else { + return moduleInfo + } + }) + + const remainingLabware = currentLabwareInfo.filter( + lw => !usedSlots.has(lw.slotName) + ) + + return { updatedModules, remainingLabware } +} diff --git a/components/src/hardware-sim/Deck/MoveLabwareOnDeck.tsx b/components/src/hardware-sim/Deck/MoveLabwareOnDeck.tsx index 3dec72c574f..171beb0e597 100644 --- a/components/src/hardware-sim/Deck/MoveLabwareOnDeck.tsx +++ b/components/src/hardware-sim/Deck/MoveLabwareOnDeck.tsx @@ -1,12 +1,12 @@ import * as React from 'react' import styled from 'styled-components' -import flatMap from 'lodash/flatMap' import { animated, useSpring, easings } from '@react-spring/web' import { getDeckDefFromRobotType, getModuleDef2, getPositionFromSlotId, } from '@opentrons/shared-data' +import { LabwareRender } from '../Labware' import { COLORS } from '../../helix-design-system' import { IDENTITY_AFFINE_TRANSFORM, multiplyMatrices } from '../utils' @@ -14,7 +14,6 @@ import { BaseDeck } from '../BaseDeck' import type { LoadedLabware, - LabwareWell, LoadedModule, Coordinates, LabwareDefinition2, @@ -127,7 +126,6 @@ function getLabwareCoordinates({ } } -const OUTLINE_THICKNESS_MM = 3 const SPLASH_Y_BUFFER_MM = 10 interface MoveLabwareOnDeckProps extends StyleProps { @@ -212,7 +210,9 @@ export function MoveLabwareOnDeck( loop: true, }) - if (deckDef == null) return null + if (deckDef == null) { + return null + } return ( - - {flatMap( - movedLabwareDef.ordering, - (row: string[], i: number, c: string[][]) => - row.map(wellName => ( - - )) - )} + - ) : ( - - ) -} From 4ecb49c7bc134626ee5089ceaae9bd942593a940 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 5 Nov 2024 17:43:38 -0500 Subject: [PATCH 19/49] fix(app): fix recovery takeover state cleanup (#16694) Closes RQA-3488 When a different app, app B, cancels the active recovery session for app A, some state cleanup should occur for app A. The exact condition for triggering this state clean up was a bit off: currently we are cleaning up the state for app A when app B enters Error Recovery. Instead, we should clean up app A's state when app B cancels app A's recovery session. --- .../__tests__/useCleanupRecoveryState.test.ts | 63 ++++++++++++++++--- .../hooks/useCleanupRecoveryState.ts | 29 +++++---- .../ErrorRecoveryFlows/hooks/useERUtils.ts | 6 +- .../organisms/ErrorRecoveryFlows/index.tsx | 2 +- 4 files changed, 73 insertions(+), 27 deletions(-) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useCleanupRecoveryState.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useCleanupRecoveryState.test.ts index f7ba3682799..9f9628546cc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useCleanupRecoveryState.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useCleanupRecoveryState.test.ts @@ -11,7 +11,7 @@ describe('useCleanupRecoveryState', () => { beforeEach(() => { mockSetRM = vi.fn() props = { - isTakeover: false, + isActiveUser: false, stashedMapRef: { current: { route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, @@ -22,7 +22,7 @@ describe('useCleanupRecoveryState', () => { } }) - it('does not modify state when isTakeover is false', () => { + it('does not modify state when user was never active', () => { renderHook(() => useCleanupRecoveryState(props)) expect(props.stashedMapRef.current).toEqual({ @@ -32,10 +32,26 @@ describe('useCleanupRecoveryState', () => { expect(mockSetRM).not.toHaveBeenCalled() }) - it('resets state when isTakeover is true', () => { - props.isTakeover = true + it('does not modify state when user becomes active', () => { + props.isActiveUser = true + renderHook(() => useCleanupRecoveryState(props)) + expect(props.stashedMapRef.current).toEqual({ + route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, + step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.SKIP, + }) + expect(mockSetRM).not.toHaveBeenCalled() + }) + + it('resets state when user becomes inactive after being active', () => { + const { rerender } = renderHook( + ({ isActiveUser }) => useCleanupRecoveryState({ ...props, isActiveUser }), + { initialProps: { isActiveUser: true } } + ) + + rerender({ isActiveUser: false }) + expect(props.stashedMapRef.current).toBeNull() expect(mockSetRM).toHaveBeenCalledWith({ route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, @@ -44,9 +60,13 @@ describe('useCleanupRecoveryState', () => { }) it('handles case when stashedMapRef.current is already null', () => { - props.isTakeover = true + const { rerender } = renderHook( + ({ isActiveUser }) => useCleanupRecoveryState({ ...props, isActiveUser }), + { initialProps: { isActiveUser: true } } + ) + props.stashedMapRef.current = null - renderHook(() => useCleanupRecoveryState(props)) + rerender({ isActiveUser: false }) expect(props.stashedMapRef.current).toBeNull() expect(mockSetRM).toHaveBeenCalledWith({ @@ -55,19 +75,21 @@ describe('useCleanupRecoveryState', () => { }) }) - it('does not reset state when isTakeover changes from true to false', () => { + it('does not reset state on subsequent inactive states', () => { const { rerender } = renderHook( - ({ isTakeover }) => useCleanupRecoveryState({ ...props, isTakeover }), - { initialProps: { isTakeover: true } } + ({ isActiveUser }) => useCleanupRecoveryState({ ...props, isActiveUser }), + { initialProps: { isActiveUser: true } } ) + rerender({ isActiveUser: false }) mockSetRM.mockClear() + props.stashedMapRef.current = { route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.SKIP, } - rerender({ isTakeover: false }) + rerender({ isActiveUser: false }) expect(props.stashedMapRef.current).toEqual({ route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, @@ -75,4 +97,25 @@ describe('useCleanupRecoveryState', () => { }) expect(mockSetRM).not.toHaveBeenCalled() }) + + it('resets state only after a full active->inactive cycle', () => { + const { rerender } = renderHook( + ({ isActiveUser }) => useCleanupRecoveryState({ ...props, isActiveUser }), + { initialProps: { isActiveUser: false } } + ) + + rerender({ isActiveUser: true }) + expect(mockSetRM).not.toHaveBeenCalled() + expect(props.stashedMapRef.current).toEqual({ + route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, + step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.SKIP, + }) + + rerender({ isActiveUser: false }) + expect(props.stashedMapRef.current).toBeNull() + expect(mockSetRM).toHaveBeenCalledWith({ + route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, + step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, + }) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useCleanupRecoveryState.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useCleanupRecoveryState.ts index 3d01e2356c5..f6fd0eb15db 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useCleanupRecoveryState.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useCleanupRecoveryState.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useState } from 'react' import { RECOVERY_MAP } from '../constants' @@ -9,25 +9,28 @@ import type { } from '../hooks' export interface UseCleanupProps { - isTakeover: ERUtilsProps['showTakeover'] + isActiveUser: ERUtilsProps['isActiveUser'] stashedMapRef: UseRouteUpdateActionsResult['stashedMapRef'] setRM: UseRecoveryRoutingResult['setRM'] } -// When certain events (ex, a takeover) occur, reset state that needs to be reset. +// When certain events (ex, someone terminates this app's recovery session) occur, reset state that needs to be reset. export function useCleanupRecoveryState({ - isTakeover, + isActiveUser, stashedMapRef, setRM, }: UseCleanupProps): void { - useEffect(() => { - if (isTakeover) { - stashedMapRef.current = null + const [wasActiveUser, setWasActiveUser] = useState(false) - setRM({ - route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, - step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, - }) - } - }, [isTakeover]) + if (isActiveUser && !wasActiveUser) { + setWasActiveUser(true) + } else if (!isActiveUser && wasActiveUser) { + setWasActiveUser(false) + + stashedMapRef.current = null + setRM({ + route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, + step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, + }) + } } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index b1a55ea12b8..533b30aa6c4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -50,7 +50,7 @@ export type ERUtilsProps = Omit & { isOnDevice: boolean robotType: RobotType failedCommand: ReturnType - showTakeover: boolean + isActiveUser: UseRecoveryTakeoverResult['isActiveUser'] allRunDefs: LabwareDefinition2[] labwareDefinitionsByUri: LabwareDefinitionsByUri | null } @@ -85,7 +85,7 @@ export function useERUtils({ isOnDevice, robotType, runStatus, - showTakeover, + isActiveUser, allRunDefs, unvalidatedFailedCommand, labwareDefinitionsByUri, @@ -193,7 +193,7 @@ export function useERUtils({ ) useCleanupRecoveryState({ - isTakeover: showTakeover, + isActiveUser, setRM, stashedMapRef: routeUpdateActions.stashedMapRef, }) diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index 3ec1afed3d8..6461ae773fc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -160,7 +160,7 @@ export function ErrorRecoveryFlows( toggleERWizAsActiveUser, isOnDevice, robotType, - showTakeover, + isActiveUser, failedCommand: failedCommandBySource, allRunDefs, labwareDefinitionsByUri, From 0ad9ef83a4b175f78f5add776f91abdbc682c736 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 6 Nov 2024 13:36:31 -0500 Subject: [PATCH 20/49] fix(app-shell): fix clearing robot update cache after robot cache update (#16706) Closes EXEC-806 --- app-shell/src/robot-update/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-shell/src/robot-update/index.ts b/app-shell/src/robot-update/index.ts index 6e6d9b03363..01afaa15960 100644 --- a/app-shell/src/robot-update/index.ts +++ b/app-shell/src/robot-update/index.ts @@ -254,7 +254,7 @@ export function checkForRobotUpdate( }) }) .then(() => - cleanupReleaseFiles(cacheDirForMachineFiles(target), CURRENT_VERSION) + cleanupReleaseFiles(cacheDirForMachine(target), CURRENT_VERSION) ) } From 78f6791fbc801099f581c4e1ca69133c1268edad Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 7 Nov 2024 08:36:30 -0500 Subject: [PATCH 21/49] fix(app): do not crash when you get a "C" locale (#16716) Some linux systems are configured with one of the POSIX default locales, like "C" or "POSIX", and for some reason this will be returned by electron's getSystemPreferredLanguages() api directly. Unfortunately, passing this to the browser js standard Intl.Locale() will make it throw, and that would whitescreen the app. Instead, catch the error and treat it as an unmatched system language. Note that this crashes even if the feature flag isn't on. --- .../SystemLanguagePreferenceModal/index.tsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx index 59ae8640f92..1a3a0d7d9ba 100644 --- a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx +++ b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx @@ -27,6 +27,10 @@ import { getSystemLanguage } from '/app/redux/shell' import type { DropdownOption } from '@opentrons/components' import type { Dispatch } from '/app/redux/types' +type ArrayElement< + ArrayType extends readonly unknown[] +> = ArrayType extends ReadonlyArray ? ElementType : never + export function SystemLanguagePreferenceModal(): JSX.Element | null { const { i18n, t } = useTranslation(['app_settings', 'shared', 'branded']) const enableLocalization = useFeatureFlag('enableLocalization') @@ -83,13 +87,31 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null { useEffect(() => { if (systemLanguage != null) { // prefer match entire locale, then match just language e.g. zh-Hant and zh-CN - const matchedSystemLanguageOption = - LANGUAGES.find(lng => lng.value === systemLanguage) ?? - LANGUAGES.find( - lng => - new Intl.Locale(lng.value).language === - new Intl.Locale(systemLanguage).language - ) + const matchSystemLanguage: () => ArrayElement< + typeof LANGUAGES + > | null = () => { + try { + return ( + LANGUAGES.find(lng => lng.value === systemLanguage) ?? + LANGUAGES.find( + lng => + new Intl.Locale(lng.value).language === + new Intl.Locale(systemLanguage).language + ) ?? + null + ) + } catch (error: unknown) { + // Sometimes the language that we get from the shell will not be something + // js i18n can understand. Specifically, some linux systems will have their + // locale set to "C" (https://www.gnu.org/software/libc/manual/html_node/Standard-Locales.html) + // and that will cause Intl.Locale to throw. In this case, we'll treat it as + // unset and fall back to our default. + console.log(`Failed to search languages: ${error}`) + return null + } + } + const matchedSystemLanguageOption = matchSystemLanguage() + if (matchedSystemLanguageOption != null) { // initial current option: set to detected system language setCurrentOption(matchedSystemLanguageOption) From 8203e3a63dbac947f355474ee0ac1eaf22b79d97 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Thu, 7 Nov 2024 10:59:05 -0500 Subject: [PATCH 22/49] refactor(api,shared-data): Clarify gripper offsets (#16711) --- .../commands/absorbance_reader/close_lid.py | 4 +- .../commands/absorbance_reader/open_lid.py | 4 +- .../commands/unsafe/unsafe_place_labware.py | 20 +++++++-- .../protocol_engine/state/geometry.py | 18 +++++++- .../protocol_engine/state/labware.py | 13 ++++-- .../state/test_geometry_view.py | 6 +-- .../state/test_labware_view.py | 9 ++-- shared-data/labware/schemas/2.json | 42 +++++++++++++------ shared-data/labware/schemas/3.json | 42 +++++++++++++------ shared-data/module/schemas/3.json | 2 +- 10 files changed, 113 insertions(+), 47 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py index 2f7f96d9523..b608f6cf5f9 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py @@ -107,7 +107,9 @@ async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: ) ) - lid_gripper_offsets = self._state_view.labware.get_labware_gripper_offsets( + # The lid's labware definition stores gripper offsets for itself in the + # space normally meant for offsets for labware stacked atop it. + lid_gripper_offsets = self._state_view.labware.get_child_gripper_offsets( loaded_lid.id, None ) if lid_gripper_offsets is None: diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py index 5f3eed57199..8e59fcd3ee0 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py @@ -106,7 +106,9 @@ async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: mod_substate.module_id ) - lid_gripper_offsets = self._state_view.labware.get_labware_gripper_offsets( + # The lid's labware definition stores gripper offsets for itself in the + # space normally meant for offsets for labware stacked atop it. + lid_gripper_offsets = self._state_view.labware.get_child_gripper_offsets( loaded_lid.id, None ) if lid_gripper_offsets is None: diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py index 547b8416637..181c49470f7 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py @@ -2,7 +2,7 @@ from __future__ import annotations from pydantic import BaseModel, Field -from typing import TYPE_CHECKING, Optional, Type, cast +from typing import TYPE_CHECKING, Optional, Type from typing_extensions import Literal from opentrons.hardware_control.types import Axis, OT3Mount @@ -84,13 +84,25 @@ async def execute( "Cannot place labware when gripper is not gripping." ) - # Allow propagation of LabwareNotLoadedError. labware_id = params.labwareId + # Allow propagation of LabwareNotLoadedError. definition_uri = self._state_view.labware.get(labware_id).definitionUri - final_offsets = self._state_view.labware.get_labware_gripper_offsets( + + # todo(mm, 2024-11-06): This is only correct in the special case of an + # absorbance reader lid. Its definition currently puts the offsets for *itself* + # in the property that's normally meant for offsets for its *children.* + final_offsets = self._state_view.labware.get_child_gripper_offsets( labware_id, None ) - drop_offset = cast(Point, final_offsets.dropOffset) if final_offsets else None + drop_offset = ( + Point( + final_offsets.dropOffset.x, + final_offsets.dropOffset.y, + final_offsets.dropOffset.z, + ) + if final_offsets + else None + ) if isinstance(params.location, DeckSlotLocation): self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 57027fdf9f7..c352b94320e 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -1203,6 +1203,7 @@ def get_total_nominal_gripper_offset_for_move_type( extra_offset = LabwareOffsetVector(x=0, y=0, z=0) if ( isinstance(ancestor, ModuleLocation) + # todo(mm, 2024-11-06): Do not access private module state; only use public ModuleView methods. and self._modules._state.requested_model_by_id[ancestor.moduleId] == ModuleModel.THERMOCYCLER_MODULE_V2 and labware_validation.validate_definition_is_lid(current_labware) @@ -1241,6 +1242,19 @@ def get_total_nominal_gripper_offset_for_move_type( + extra_offset ) + # todo(mm, 2024-11-05): This may be incorrect because it does not take the following + # offsets into account: + # + # * The pickup offset in the definition of the parent of the gripped labware. + # * The "additional offset" or "user offset", e.g. the `pickUpOffset` and `dropOffset` + # params in the `moveLabware` command. + # + # For robustness, we should combine this with `get_gripper_labware_movement_waypoints()`. + # + # We should also be more explicit about which offsets act to move the gripper paddles + # relative to the gripped labware, and which offsets act to change how the gripped + # labware sits atop its parent. Those have different effects on how far the gripped + # labware juts beyond the paddles while it's in transit. def check_gripper_labware_tip_collision( self, gripper_homed_position_z: float, @@ -1321,11 +1335,11 @@ def _labware_gripper_offsets( module_loc = self._modules.get_location(parent_location.moduleId) slot_name = module_loc.slotName - slot_based_offset = self._labware.get_labware_gripper_offsets( + slot_based_offset = self._labware.get_child_gripper_offsets( labware_id=labware_id, slot_name=slot_name.to_ot3_equivalent() ) - return slot_based_offset or self._labware.get_labware_gripper_offsets( + return slot_based_offset or self._labware.get_child_gripper_offsets( labware_id=labware_id, slot_name=None ) diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 052ca1666ed..f964480e5f1 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -928,19 +928,24 @@ def get_deck_default_gripper_offsets(self) -> Optional[LabwareMovementOffsetData else None ) - def get_labware_gripper_offsets( + def get_child_gripper_offsets( self, labware_id: str, slot_name: Optional[DeckSlotName], ) -> Optional[LabwareMovementOffsetData]: - """Get the labware's gripper offsets of the specified type. + """Get the offsets that a labware says should be applied to children stacked atop it. + + Params: + labware_id: The ID of a parent labware (atop which another labware, the child, will be stacked). + slot_name: The ancestor slot that the parent labware is ultimately loaded into, + perhaps after going through a module in the middle. Returns: - If `slot_name` is provided, returns the gripper offsets that the labware definition + If `slot_name` is provided, returns the gripper offsets that the parent labware definition specifies just for that slot, or `None` if the labware definition doesn't have an exact match. - If `slot_name` is `None`, returns the gripper offsets that the labware + If `slot_name` is `None`, returns the gripper offsets that the parent labware definition designates as "default," or `None` if it doesn't designate any as such. """ parsed_offsets = self.get_definition(labware_id).gripperOffsets diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index aae8c460b17..889408d6da6 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -2750,7 +2750,7 @@ def test_get_stacked_labware_total_nominal_offset_slot_specific( DeckSlotLocation(slotName=DeckSlotName.SLOT_C1) ) decoy.when( - mock_labware_view.get_labware_gripper_offsets( + mock_labware_view.get_child_gripper_offsets( labware_id="adapter-id", slot_name=DeckSlotName.SLOT_C1 ) ).then_return( @@ -2802,12 +2802,12 @@ def test_get_stacked_labware_total_nominal_offset_default( DeckSlotLocation(slotName=DeckSlotName.SLOT_4) ) decoy.when( - mock_labware_view.get_labware_gripper_offsets( + mock_labware_view.get_child_gripper_offsets( labware_id="adapter-id", slot_name=DeckSlotName.SLOT_C1 ) ).then_return(None) decoy.when( - mock_labware_view.get_labware_gripper_offsets( + mock_labware_view.get_child_gripper_offsets( labware_id="adapter-id", slot_name=None ) ).then_return( diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index d6b05b7b027..47dde33ce67 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -1530,10 +1530,9 @@ def test_get_labware_gripper_offsets( ) assert ( - subject.get_labware_gripper_offsets(labware_id="plate-id", slot_name=None) - is None + subject.get_child_gripper_offsets(labware_id="plate-id", slot_name=None) is None ) - assert subject.get_labware_gripper_offsets( + assert subject.get_child_gripper_offsets( labware_id="adapter-plate-id", slot_name=DeckSlotName.SLOT_D1 ) == LabwareMovementOffsetData( pickUpOffset=LabwareOffsetVector(x=0, y=0, z=0), @@ -1570,13 +1569,13 @@ def test_get_labware_gripper_offsets_default_no_slots( ) assert ( - subject.get_labware_gripper_offsets( + subject.get_child_gripper_offsets( labware_id="labware-id", slot_name=DeckSlotName.SLOT_D1 ) is None ) - assert subject.get_labware_gripper_offsets( + assert subject.get_child_gripper_offsets( labware_id="labware-id", slot_name=None ) == LabwareMovementOffsetData( pickUpOffset=LabwareOffsetVector(x=1, y=2, z=3), diff --git a/shared-data/labware/schemas/2.json b/shared-data/labware/schemas/2.json index 6f39c6af175..b60a731c242 100644 --- a/shared-data/labware/schemas/2.json +++ b/shared-data/labware/schemas/2.json @@ -65,6 +65,20 @@ "type": "number" } } + }, + "pickUpAndDropOffsets": { + "type": "object", + "required": ["pickUpOffset", "dropOffset"], + "properties": { + "pickUpOffset": { + "$ref": "#/definitions/coordinates", + "description": "Offset added to calculate pick-up coordinates." + }, + "dropOffset": { + "$ref": "#/definitions/coordinates", + "description": "Offset added to calculate drop coordinates." + } + } } }, "type": "object", @@ -343,21 +357,23 @@ }, "gripperOffsets": { "type": "object", - "description": "Offsets to be added when calculating the coordinates a gripper should go to when picking up or dropping a labware on this labware.", + "description": "Offsets to add when picking up or dropping another labware stacked atop this one. Do not use this to adjust the position of the gripper paddles relative to this labware or the child labware; use `gripHeightFromLabwareBottom` on this definition or the child's definition for that.", + "additionalProperties": { + "$ref": "#/definitions/pickUpAndDropOffsets", + "description": "Properties here are named for, and matched based on, the deck slot that this labware is atop--or, if this labware is atop a module, the deck slot that that module is atop." + }, "properties": { "default": { - "type": "object", - "properties": { - "pickUpOffset": { - "$ref": "#/definitions/coordinates", - "description": "Offset added to calculate pick-up coordinates of a labware placed on this labware." - }, - "dropOffset": { - "$ref": "#/definitions/coordinates", - "description": "Offset added to calculate drop coordinates of a labware placed on this labware." - } - }, - "required": ["pickUpOffset", "dropOffset"] + "$ref": "#/definitions/pickUpAndDropOffsets", + "description": "The offsets to use if there's no slot-specific match in `additionalProperties`." + }, + "lidOffsets": { + "$ref": "#/definitions/pickUpAndDropOffsets", + "description": "Additional offsets for gripping this labware, if this labware is a lid. Beware this property's placement: instead of affecting the labware stacked atop this labware, like the rest of the `gripperOffsets` properties, it affects this labware." + }, + "lidDisposalOffsets": { + "$ref": "#/definitions/pickUpAndDropOffsets", + "description": "Additional offsets for gripping this labware, if this labware is a lid and it's being moved to a trash bin. Beware this property's placement: instead of affecting the labware stacked atop this labware, like the rest of the `gripperOffsets` properties, it affects this labware." } } }, diff --git a/shared-data/labware/schemas/3.json b/shared-data/labware/schemas/3.json index e38c070919a..9948833fb18 100644 --- a/shared-data/labware/schemas/3.json +++ b/shared-data/labware/schemas/3.json @@ -65,6 +65,20 @@ } } }, + "pickUpAndDropOffsets": { + "type": "object", + "required": ["pickUpOffset", "dropOffset"], + "properties": { + "pickUpOffset": { + "$ref": "#/definitions/coordinates", + "description": "Offset added to calculate pick-up coordinates." + }, + "dropOffset": { + "$ref": "#/definitions/coordinates", + "description": "Offset added to calculate drop coordinates." + } + } + }, "SphericalSegment": { "type": "object", "description": "A partial sphere shaped section at the bottom of the well.", @@ -538,21 +552,23 @@ }, "gripperOffsets": { "type": "object", - "description": "Offsets to be added when calculating the coordinates a gripper should go to when picking up or dropping a labware on this labware.", + "description": "Offsets to add when picking up or dropping another labware stacked atop this one. Do not use this to adjust the position of the gripper paddles relative to this labware or the child labware; use `gripHeightFromLabwareBottom` on this definition or the child's definition for that.", + "additionalProperties": { + "$ref": "#/definitions/pickUpAndDropOffsets", + "description": "Properties here are named for, and matched based on, the deck slot that this labware is atop--or, if this labware is atop a module, the deck slot that that module is atop." + }, "properties": { "default": { - "type": "object", - "properties": { - "pickUpOffset": { - "$ref": "#/definitions/coordinates", - "description": "Offset added to calculate pick-up coordinates of a labware placed on this labware." - }, - "dropOffset": { - "$ref": "#/definitions/coordinates", - "description": "Offset added to calculate drop coordinates of a labware placed on this labware." - } - }, - "required": ["pickUpOffset", "dropOffset"] + "$ref": "#/definitions/pickUpAndDropOffsets", + "description": "The offsets to use if there's no slot-specific match in `additionalProperties`." + }, + "lidOffsets": { + "$ref": "#/definitions/pickUpAndDropOffsets", + "description": "Additional offsets for gripping this labware, if this labware is a lid. Beware this property's placement: instead of affecting the labware stacked atop this labware, like the rest of the `gripperOffsets` properties, it affects this labware." + }, + "lidDisposalOffsets": { + "$ref": "#/definitions/pickUpAndDropOffsets", + "description": "Additional offsets for gripping this labware, if this labware is a lid and it's being moved to a trash bin. Beware this property's placement: instead of affecting the labware stacked atop this labware, like the rest of the `gripperOffsets` properties, it affects this labware." } } }, diff --git a/shared-data/module/schemas/3.json b/shared-data/module/schemas/3.json index c422645a67a..9bc24d9adef 100644 --- a/shared-data/module/schemas/3.json +++ b/shared-data/module/schemas/3.json @@ -141,7 +141,7 @@ }, "gripperOffsets": { "type": "object", - "description": "Offsets to be added when calculating the coordinates a gripper should go to when picking up or dropping a labware on a module.", + "description": "Offsets to be added when calculating the coordinates a gripper should go to when picking up or dropping a labware on this module.", "properties": { "default": { "type": "object", From 347a23ed2a96345d70897e1164930e4a11af47c7 Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Thu, 7 Nov 2024 13:08:00 -0500 Subject: [PATCH 23/49] refactor(api,robot-server,app): use labwareURI instead of labwareID for placeLabwareState and unsafe/placeLabware command. (#16719) The `placeLabwareState` and `unsafe/placeLabware` commands rely on the `labwareID` to place labware held by the gripper back down to a deck slot. However, as correctly pointed out, this is wrong because the labwareID changes across runs. This was working for the plate reader lid because the lid is loaded with a fixed labwareID, however, that would not be the case for other labware across different runs. This pull request replaces the labwareID with labwareURI used by the `placeLabwareState` and `unsafe/placeLabware` commands so the behavior is consistent across runs. --- api-client/src/runs/types.ts | 2 +- .../commands/unsafe/unsafe_place_labware.py | 51 ++++++++++++++----- .../modules/hooks/usePlacePlateReaderLid.ts | 6 +-- .../robot_server/runs/router/base_router.py | 21 +++++--- robot-server/robot_server/runs/run_models.py | 2 +- shared-data/command/schemas/10.json | 8 +-- shared-data/command/types/unsafe.ts | 2 +- 7 files changed, 63 insertions(+), 29 deletions(-) diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 621443dce03..a6279d12145 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -214,7 +214,7 @@ export interface NozzleLayoutValues { } export interface PlaceLabwareState { - labwareId: string + labwareURI: string location: OnDeckLabwareLocation shouldPlaceDown: boolean } diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py index 181c49470f7..e6cc7217ba1 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Optional, Type from typing_extensions import Literal +from opentrons.calibration_storage.helpers import details_from_uri from opentrons.hardware_control.types import Axis, OT3Mount from opentrons.motion_planning.waypoints import get_gripper_labware_placement_waypoints from opentrons.protocol_engine.errors.exceptions import ( @@ -13,7 +14,12 @@ ) from opentrons.types import Point -from ...types import DeckSlotLocation, ModuleModel, OnDeckLabwareLocation +from ...types import ( + DeckSlotLocation, + LoadedLabware, + ModuleModel, + OnDeckLabwareLocation, +) from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ...errors.error_occurrence import ErrorOccurrence from ...resources import ensure_ot3_hardware @@ -32,7 +38,7 @@ class UnsafePlaceLabwareParams(BaseModel): """Payload required for an UnsafePlaceLabware command.""" - labwareId: str = Field(..., description="The id of the labware to place.") + labwareURI: str = Field(..., description="Labware URI for labware.") location: OnDeckLabwareLocation = Field( ..., description="Where to place the labware." ) @@ -71,8 +77,8 @@ async def execute( is pressed, get into error recovery, etc). Unlike the `moveLabware` command, where you pick a source and destination - location, this command takes the labwareId to be moved and location to - move it to. + location, this command takes the labwareURI of the labware to be moved + and location to move it to. """ ot3api = ensure_ot3_hardware(self._hardware_api) @@ -84,10 +90,35 @@ async def execute( "Cannot place labware when gripper is not gripping." ) - labware_id = params.labwareId - # Allow propagation of LabwareNotLoadedError. - definition_uri = self._state_view.labware.get(labware_id).definitionUri + location = self._state_view.geometry.ensure_valid_gripper_location( + params.location, + ) + + # TODO: We need a way to create temporary labware for moving around, + # the labware should get deleted once its used. + details = details_from_uri(params.labwareURI) + labware = await self._equipment.load_labware( + load_name=details.load_name, + namespace=details.namespace, + version=details.version, + location=location, + labware_id=None, + ) + + self._state_view.labware._state.definitions_by_uri[ + params.labwareURI + ] = labware.definition + self._state_view.labware._state.labware_by_id[ + labware.labware_id + ] = LoadedLabware.construct( + id=labware.labware_id, + location=location, + loadName=labware.definition.parameters.loadName, + definitionUri=params.labwareURI, + offsetId=labware.offsetId, + ) + labware_id = labware.labware_id # todo(mm, 2024-11-06): This is only correct in the special case of an # absorbance reader lid. Its definition currently puts the offsets for *itself* # in the property that's normally meant for offsets for its *children.* @@ -109,10 +140,6 @@ async def execute( params.location.slotName.id ) - location = self._state_view.geometry.ensure_valid_gripper_location( - params.location, - ) - # This is an absorbance reader, move the lid to its dock (staging area). if isinstance(location, DeckSlotLocation): module = self._state_view.modules.get_by_slot(location.slotName) @@ -122,7 +149,7 @@ async def execute( ) new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=definition_uri, + labware_definition_uri=params.labwareURI, labware_location=location, ) diff --git a/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts index 0e4dabcb660..6b9ce1bca0b 100644 --- a/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts +++ b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts @@ -36,7 +36,7 @@ export function usePlacePlateReaderLid( const location = placeLabware.location const loadModuleCommand = buildLoadModuleCommand(location as ModuleLocation) const placeLabwareCommand = buildPlaceLabwareCommand( - placeLabware.labwareId as string, + placeLabware.labwareURI as string, location ) commandsToExecute = [loadModuleCommand, placeLabwareCommand] @@ -72,11 +72,11 @@ const buildLoadModuleCommand = (location: ModuleLocation): CreateCommand => { } const buildPlaceLabwareCommand = ( - labwareId: string, + labwareURI: string, location: OnDeckLabwareLocation ): CreateCommand => { return { commandType: 'unsafe/placeLabware' as const, - params: { labwareId, location }, + params: { labwareURI, location }, } } diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index b7df09f8992..7e20e62881a 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -602,6 +602,7 @@ async def get_current_state( # noqa: C901 for pipetteId, nozzle_map in active_nozzle_maps.items() } + run = run_data_manager.get(run_id=runId) current_command = run_data_manager.get_current_command(run_id=runId) last_completed_command = run_data_manager.get_last_completed_command( run_id=runId @@ -636,11 +637,14 @@ async def get_current_state( # noqa: C901 if isinstance(command, MoveLabware): location = command.params.newLocation if isinstance(location, DeckSlotLocation): - place_labware = PlaceLabwareState( - location=location, - labwareId=command.params.labwareId, - shouldPlaceDown=False, - ) + for labware in run.labware: + if labware.id == command.params.labwareId: + place_labware = PlaceLabwareState( + location=location, + labwareURI=labware.definitionUri, + shouldPlaceDown=False, + ) + break # Handle absorbance reader lid elif isinstance(command, (OpenLid, CloseLid)): for mod in run.modules: @@ -655,10 +659,13 @@ async def get_current_state( # noqa: C901 and hw_mod.serial_number == mod.serialNumber ): location = mod.location - labware_id = f"{mod.model}Lid{location.slotName}" + # TODO: Not the best location for this, we should + # remove this once we are no longer defining the plate reader lid + # as a labware. + labware_uri = "opentrons/opentrons_flex_lid_absorbance_plate_reader_module/1" place_labware = PlaceLabwareState( location=location, - labwareId=labware_id, + labwareURI=labware_uri, shouldPlaceDown=estop_engaged, ) break diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 8baedb97a3b..2ed77b0d0bc 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -319,7 +319,7 @@ class ActiveNozzleLayout(BaseModel): class PlaceLabwareState(BaseModel): """Details the labware being placed by the gripper.""" - labwareId: str = Field(..., description="The ID of the labware to place.") + labwareURI: str = Field(..., description="The URI of the labware to place.") location: OnDeckLabwareLocation = Field( ..., description="The location the labware should be in." ) diff --git a/shared-data/command/schemas/10.json b/shared-data/command/schemas/10.json index 93bc2387d63..7e8f87a6c22 100644 --- a/shared-data/command/schemas/10.json +++ b/shared-data/command/schemas/10.json @@ -4749,9 +4749,9 @@ "description": "Payload required for an UnsafePlaceLabware command.", "type": "object", "properties": { - "labwareId": { - "title": "Labwareid", - "description": "The id of the labware to place.", + "labwareURI": { + "title": "Labwareuri", + "description": "Labware URI for labware.", "type": "string" }, "location": { @@ -4773,7 +4773,7 @@ ] } }, - "required": ["labwareId", "location"] + "required": ["labwareURI", "location"] }, "UnsafePlaceLabwareCreate": { "title": "UnsafePlaceLabwareCreate", diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index 3875aaa3036..f16988e0e8d 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -92,7 +92,7 @@ export interface UnsafeUngripLabwareRunTimeCommand result?: any } export interface UnsafePlaceLabwareParams { - labwareId: string + labwareURI: string location: OnDeckLabwareLocation } export interface UnsafePlaceLabwareCreateCommand From e813161f90a8dd02c0c064b8cc9adea3f40f5bf1 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Thu, 7 Nov 2024 14:55:48 -0500 Subject: [PATCH 24/49] fix(app): fix run log TC lid stacking issues and module icon formatting (#16705) fix RQA-3485, RQA-3468, RQA-3463 --- .../en/protocol_command_text.json | 13 +- .../zh/protocol_command_text.json | 6 - .../assets/localization/zh/run_details.json | 6 - .../utils/commandText/getLoadCommandText.ts | 124 ++++-------------- .../getLabwareDisplayLocation.test.tsx | 4 +- .../labware/utils/getLabwareLocation.ts | 5 + .../__fixtures__/mockRobotSideAnalysis.json | 9 ++ .../Command/__tests__/CommandText.test.tsx | 10 +- .../LabwarePositionCheck/ResultsSummary.tsx | 23 ++-- 9 files changed, 65 insertions(+), 135 deletions(-) diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index 6dbee9af16f..a472c55432c 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -3,8 +3,8 @@ "absorbance_reader_initialize": "Initializing Absorbance Reader to perform {{mode}} measurement at {{wavelengths}}", "absorbance_reader_open_lid": "Opening Absorbance Reader lid", "absorbance_reader_read": "Reading plate in Absorbance Reader", - "adapter_in_mod_in_slot": "{{adapter}} on {{module}} in {{slot}}", - "adapter_in_slot": "{{adapter}} in {{slot}}", + "adapter_in_mod_in_slot": "{{adapter}} on {{module}} in Slot {{slot}}", + "adapter_in_slot": "{{adapter}} in Slot {{slot}}", "aspirate": "Aspirating {{volume}} µL from well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", "aspirate_in_place": "Aspirating {{volume}} µL in place at {{flow_rate}} µL/sec ", "blowout": "Blowing out at well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", @@ -31,14 +31,10 @@ "engaging_magnetic_module": "Engaging Magnetic Module", "fixed_trash": "Fixed Trash", "home_gantry": "Homing all gantry, pipette, and plunger axes", + "in_location": "in {{location}}", "latching_hs_latch": "Latching labware on Heater-Shaker", "left": "Left", - "load_labware_info_protocol_setup": "Load {{labware}} in {{module_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter": "Load {{labware}} in {{adapter_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter_module": "Load {{labware}} in {{adapter_name}} in {{module_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter_off_deck": "Load {{labware}} in {{adapter_name}} off deck", - "load_labware_info_protocol_setup_no_module": "Load {{labware}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_off_deck": "Load {{labware}} off deck", + "load_labware_to_display_location": "Load {{labware}} {{display_location}}", "load_liquids_info_protocol_setup": "Load {{liquid}} into {{labware}}", "load_module_protocol_setup": "Load {{module}} in Slot {{slot_name}}", "load_pipette_protocol_setup": "Load {{pipette_name}} in {{mount_name}} Mount", @@ -58,6 +54,7 @@ "notes": "notes", "off_deck": "off deck", "offdeck": "offdeck", + "on_location": "on {{location}}", "opening_tc_lid": "Opening Thermocycler lid", "pause": "Pause", "pause_on": "Pause on {{robot_name}}", diff --git a/app/src/assets/localization/zh/protocol_command_text.json b/app/src/assets/localization/zh/protocol_command_text.json index 74ab15b69b7..9d976c2bc88 100644 --- a/app/src/assets/localization/zh/protocol_command_text.json +++ b/app/src/assets/localization/zh/protocol_command_text.json @@ -28,12 +28,6 @@ "home_gantry": "复位所有龙门架、移液器和柱塞轴", "latching_hs_latch": "在热震荡模块上锁定实验耗材", "left": "左", - "load_labware_info_protocol_setup_adapter_module": "在{{module_name}}的甲板槽{{slot_name}}上加载适配器{{adapter_name}}中的{{labware}}", - "load_labware_info_protocol_setup_adapter_off_deck": "在板外加载适配器{{adapter_name}}中的{{labware}}", - "load_labware_info_protocol_setup_adapter": "在甲板槽{{slot_name}}上加载适配器{{adapter_name}}中的{{labware}}", - "load_labware_info_protocol_setup_no_module": "在甲板槽{{slot_name}}中加载{{labware}}", - "load_labware_info_protocol_setup_off_deck": "在板外加载{{labware}}", - "load_labware_info_protocol_setup": "在{{module_name}}的甲板槽{{slot_name}}中加载{{labware}}", "load_liquids_info_protocol_setup": "将{{liquid}}加载到{{labware}}中", "load_module_protocol_setup": "在甲板槽{{slot_name}}中加载模块{{module}}", "load_pipette_protocol_setup": "在{{mount_name}}支架上加载{{pipette_name}}", diff --git a/app/src/assets/localization/zh/run_details.json b/app/src/assets/localization/zh/run_details.json index 00d584bb4ba..2bfa9c1a5e1 100644 --- a/app/src/assets/localization/zh/run_details.json +++ b/app/src/assets/localization/zh/run_details.json @@ -52,12 +52,6 @@ "labware": "耗材", "left": "左", "listed_values": "列出的值仅供查看", - "load_labware_info_protocol_setup_adapter_off_deck": "在甲板外的{{adapter_name}}上加载{{labware}}", - "load_labware_info_protocol_setup_adapter": "在{{slot_name}}号板位中的{{adapter_name}}中加载{{labware}}", - "load_labware_info_protocol_setup_no_module": "在{{slot_name}}号板位中加载{{labware}}", - "load_labware_info_protocol_setup_off_deck": "在甲板外加载{{labware}}", - "load_labware_info_protocol_setup_plural": "在{{module_name}}中加载{{labware}}", - "load_labware_info_protocol_setup": "在{{slot_name}}号板位中的{{module_name}}中加载{{labware}}", "load_liquids_info_protocol_setup": "将{{liquid}}加载到{{labware}}中", "load_module_protocol_setup_plural": "加载{{module}}", "load_module_protocol_setup": "在{{slot_name}}号板位中加载{{module}}", diff --git a/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts index cba135218c8..52663e94305 100644 --- a/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts +++ b/app/src/local-resources/commands/hooks/useCommandTextString/utils/commandText/getLoadCommandText.ts @@ -8,13 +8,11 @@ import { import { getPipetteNameOnMount } from '../getPipetteNameOnMount' import { getLiquidDisplayName } from '../getLiquidDisplayName' -import { getLabwareName } from '/app/local-resources/labware' import { - getModuleModel, - getModuleDisplayLocation, -} from '/app/local-resources/modules' + getLabwareName, + getLabwareDisplayLocation, +} from '/app/local-resources/labware' -import type { LoadLabwareRunTimeCommand } from '@opentrons/shared-data' import type { GetCommandText } from '../..' export const getLoadCommandText = ({ @@ -53,103 +51,27 @@ export const getLoadCommandText = ({ }) } case 'loadLabware': { - if ( - command.params.location !== 'offDeck' && - 'moduleId' in command.params.location - ) { - const moduleModel = - commandTextData != null - ? getModuleModel( - commandTextData.modules ?? [], - command.params.location.moduleId - ) - : null - const moduleName = - moduleModel != null ? getModuleDisplayName(moduleModel) : '' - - return t('load_labware_info_protocol_setup', { - count: - moduleModel != null - ? getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ) - : 1, - labware: command.result?.definition.metadata.displayName, - slot_name: - commandTextData != null - ? getModuleDisplayLocation( - commandTextData.modules ?? [], - command.params.location.moduleId - ) - : null, - module_name: moduleName, - }) - } else if ( - command.params.location !== 'offDeck' && - 'labwareId' in command.params.location - ) { - const labwareId = command.params.location.labwareId - const labwareName = command.result?.definition.metadata.displayName - const matchingAdapter = commandTextData?.commands.find( - (command): command is LoadLabwareRunTimeCommand => - command.commandType === 'loadLabware' && - command.result?.labwareId === labwareId - ) - const adapterName = - matchingAdapter?.result?.definition.metadata.displayName - const adapterLoc = matchingAdapter?.params.location - if (adapterLoc === 'offDeck') { - return t('load_labware_info_protocol_setup_adapter_off_deck', { - labware: labwareName, - adapter_name: adapterName, - }) - } else if (adapterLoc != null && 'slotName' in adapterLoc) { - return t('load_labware_info_protocol_setup_adapter', { - labware: labwareName, - adapter_name: adapterName, - slot_name: adapterLoc?.slotName, - }) - } else if (adapterLoc != null && 'moduleId' in adapterLoc) { - const moduleModel = - commandTextData != null - ? getModuleModel( - commandTextData.modules ?? [], - adapterLoc?.moduleId ?? '' - ) - : null - const moduleName = - moduleModel != null ? getModuleDisplayName(moduleModel) : '' - return t('load_labware_info_protocol_setup_adapter_module', { - labware: labwareName, - adapter_name: adapterName, - module_name: moduleName, - slot_name: - commandTextData != null - ? getModuleDisplayLocation( - commandTextData.modules ?? [], - adapterLoc?.moduleId ?? '' - ) - : null, - }) - } else { - // shouldn't reach here, adapter shouldn't have location type labwareId - return '' - } - } else { - const labware = - command.result?.definition.metadata.displayName ?? - command.params.displayName - return command.params.location === 'offDeck' - ? t('load_labware_info_protocol_setup_off_deck', { labware }) - : t('load_labware_info_protocol_setup_no_module', { - labware, - slot_name: - 'addressableAreaName' in command.params.location - ? command.params.location.addressableAreaName - : command.params.location.slotName, - }) + const location = getLabwareDisplayLocation({ + location: command.params.location, + robotType, + allRunDefs, + loadedLabwares: commandTextData?.labware ?? [], + loadedModules: commandTextData?.modules ?? [], + t, + }) + const labwareName = command.result?.definition.metadata.displayName + // use in preposition for modules and slots, on for labware and adapters + let displayLocation = t('in_location', { location }) + if (command.params.location === 'offDeck') { + displayLocation = location + } else if ('labwareId' in command.params.location) { + displayLocation = t('on_location', { location }) } + + return t('load_labware_to_display_location', { + labware: labwareName, + display_location: displayLocation, + }) } case 'reloadLabware': { const { labwareId } = command.params diff --git a/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx b/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx index 22e02478ded..ca4b095f00e 100644 --- a/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx +++ b/app/src/local-resources/labware/utils/__tests__/getLabwareDisplayLocation.test.tsx @@ -125,7 +125,7 @@ describe('getLabwareDisplayLocation with translations', () => { }, }) - screen.getByText('Mock Adapter in D1') + screen.getByText('Mock Adapter in Slot D1') }) it('should return a slot-only location when detailLevel is "slot-only"', () => { @@ -168,6 +168,6 @@ describe('getLabwareDisplayLocation with translations', () => { }, }) - screen.getByText('Mock Adapter on Temperature Module in 2') + screen.getByText('Mock Adapter on Temperature Module in Slot 2') }) }) diff --git a/app/src/local-resources/labware/utils/getLabwareLocation.ts b/app/src/local-resources/labware/utils/getLabwareLocation.ts index bb8231679c5..aec9e30a186 100644 --- a/app/src/local-resources/labware/utils/getLabwareLocation.ts +++ b/app/src/local-resources/labware/utils/getLabwareLocation.ts @@ -134,6 +134,11 @@ export function getLabwareLocation( moduleModel, adapterName, } + } else if ('labwareId' in adapter.location) { + return getLabwareLocation({ + ...params, + location: adapter.location, + }) } else { return null } diff --git a/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json b/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json index cd2bd35c802..848be62365c 100644 --- a/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json +++ b/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json @@ -71,6 +71,15 @@ "slotName": "5" }, "displayName": "NEST 1 Well Reservoir 195 mL" + }, + { + "id": "29444782-bdc8-4ad8-92fe-5e28872e85e5:opentrons/opentrons_96_flat_bottom_adapter/1", + "loadName": "opentrons_96_flat_bottom_adapter", + "definitionUri": "opentrons/opentrons_96_flat_bottom_adapter/1", + "location": { + "slotName": "2" + }, + "displayName": "Opentrons 96 Flat Bottom Adapter" } ], "modules": [ diff --git a/app/src/molecules/Command/__tests__/CommandText.test.tsx b/app/src/molecules/Command/__tests__/CommandText.test.tsx index f2762b622d7..483e739bbcb 100644 --- a/app/src/molecules/Command/__tests__/CommandText.test.tsx +++ b/app/src/molecules/Command/__tests__/CommandText.test.tsx @@ -553,9 +553,15 @@ describe('CommandText', () => { ) }) it('renders correct text for loadLabware in adapter', () => { + const flatBottomAdapterCommand = mockCommandTextData.commands.find( + c => + c.commandType === 'loadLabware' && + c.params.loadName === 'opentrons_96_flat_bottom_adapter' + ) + renderWithProviders( { } ) screen.getByText( - 'Load mock displayName in Opentrons 96 Flat Bottom Adapter in Slot 2' + 'Load mock displayName on Opentrons 96 Flat Bottom Adapter in Slot 2' ) }) it('renders correct text for loadLabware off deck', () => { diff --git a/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx b/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx index 98f88fac2bd..eafda1a2c8a 100644 --- a/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx +++ b/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx @@ -29,6 +29,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + DIRECTION_ROW, } from '@opentrons/components' import { PythonLabwareOffsetSnippet } from '/app/molecules/PythonLabwareOffsetSnippet' import { @@ -373,16 +374,18 @@ export const TerseOffsetTable = (props: OffsetTableProps): JSX.Element => { return ( - - {location.moduleModel != null ? ( - - ) : null} + + + {location.moduleModel != null ? ( + + ) : null} + Date: Thu, 7 Nov 2024 17:25:07 -0500 Subject: [PATCH 25/49] fix(app): add an exit button for failed moveToAddressable area commands during Error Recovery (#16729) Closes RQA-3542 --- .../DropTipWizardFlows/DropTipWizardFlows.tsx | 6 +- .../DropTipWizardFlows/hooks/errors.tsx | 6 +- .../hooks/useDropTipCommands.ts | 101 +++++++++--------- 3 files changed, 60 insertions(+), 53 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx b/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx index b6b60315936..921e0fc04c3 100644 --- a/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx +++ b/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx @@ -68,9 +68,11 @@ export function DropTipWizardFlows( // after it closes. useEffect(() => { return () => { - dropTipWithTypeUtils.dropTipCommands.handleCleanUpAndClose() + if (issuedCommandsType === 'setup') { + void dropTipWithTypeUtils.dropTipCommands.handleCleanUpAndClose() + } } - }, []) + }, [issuedCommandsType]) return ( => { return new Promise((resolve, reject) => { - const addressableAreaFromConfig = getAddressableAreaFromConfig( - addressableArea, - deckConfig, - instrumentModelSpecs.channels, - robotType - ) - - if (addressableAreaFromConfig != null) { - const moveToAACommand = buildMoveToAACommand( - addressableAreaFromConfig, - pipetteId, - isPredefinedLocation, - issuedCommandsType - ) - return chainRunCommands( - isFlex - ? [ - ENGAGE_AXES, - UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, - Z_HOME, - moveToAACommand, - ] - : [Z_HOME, moveToAACommand], - false - ) - .then((commandData: CommandData[]) => { - const error = commandData[0].data.error - if (error != null) { - setErrorDetails({ - runCommandError: error, - message: `Error moving to position: ${error.detail}`, - }) - } - }) - .then(resolve) - .catch(error => { - if ( - fixitCommandTypeUtils != null && - issuedCommandsType === 'fixit' - ) { - fixitCommandTypeUtils.errorOverrides.generalFailure() - } + Promise.resolve() + .then(() => { + const addressableAreaFromConfig = getAddressableAreaFromConfig( + addressableArea, + deckConfig, + instrumentModelSpecs.channels, + robotType + ) + + if (addressableAreaFromConfig == null) { + throw new Error('invalid addressable area.') + } - reject( - new Error(`Error issuing move to addressable area: ${error}`) - ) - }) - } else { - setErrorDetails({ - message: `Error moving to position: invalid addressable area.`, + const moveToAACommand = buildMoveToAACommand( + addressableAreaFromConfig, + pipetteId, + isPredefinedLocation, + issuedCommandsType + ) + + return chainRunCommands( + isFlex + ? [ + ENGAGE_AXES, + UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, + Z_HOME, + moveToAACommand, + ] + : [Z_HOME, moveToAACommand], + false + ) + }) + .then((commandData: CommandData[]) => { + const error = commandData[0].data.error + if (error != null) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw error + } + resolve() + }) + .catch(error => { + if (fixitCommandTypeUtils != null && issuedCommandsType === 'fixit') { + fixitCommandTypeUtils.errorOverrides.generalFailure() + } else { + setErrorDetails({ + runCommandError: error, + message: error.detail + ? `Error moving to position: ${error.detail}` + : 'Error moving to position: invalid addressable area.', + }) + } + reject(error) }) - } }) } From 4133e0704d321eca013a1a7a497a5800093850bf Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Thu, 7 Nov 2024 15:24:41 -0800 Subject: [PATCH 26/49] App hide unused env variables app build 8.2.0 (#16736) --- .github/workflows/app-test-build-deploy.yaml | 35 +++++++++++--------- app-shell-odd/Makefile | 6 ++-- app-shell/Makefile | 14 ++++---- app/vite.config.mts | 6 +++- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index f0bfe7d8946..d443fae35a0 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -318,12 +318,12 @@ jobs: if: startsWith(matrix.os, 'windows') && contains(needs.determine-build-type.outputs.type, 'release') shell: cmd env: - SM_HOST: ${{ secrets.SM_HOST }} + SM_HOST: ${{ secrets.SM_HOST_V2 }} SM_CLIENT_CERT_FILE: "D:\\Certificate_pkcs12.p12" - SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD}} - SM_API_KEY: ${{secrets.SM_API_KEY}} + SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD_V2}} + SM_API_KEY: ${{secrets.SM_API_KEY_V2}} run: | - curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:${{secrets.SM_API_KEY}}" -o Keylockertools-windows-x64.msi + curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:${{secrets.SM_API_KEY_V2}}" -o Keylockertools-windows-x64.msi msiexec /i Keylockertools-windows-x64.msi /quiet /qn smksp_registrar.exe list smctl.exe keypair ls @@ -331,6 +331,15 @@ jobs: smksp_cert_sync.exe smctl.exe healthcheck --all + # Do the frontend dist bundle + - name: 'bundle ${{matrix.variant}} frontend' + env: + OT_APP_MIXPANEL_ID: ${{ secrets.OT_APP_MIXPANEL_ID }} + OT_APP_INTERCOM_ID: ${{ secrets.OT_APP_INTERCOM_ID }} + OPENTRONS_PROJECT: ${{ steps.project.outputs.project }} + run: | + make -C app dist + # build the desktop app and deploy it - name: 'build ${{matrix.variant}} app for ${{ matrix.os }}' if: matrix.target == 'desktop' @@ -339,18 +348,14 @@ jobs: OT_APP_MIXPANEL_ID: ${{ secrets.OT_APP_MIXPANEL_ID }} OT_APP_INTERCOM_ID: ${{ secrets.OT_APP_INTERCOM_ID }} WINDOWS_SIGN: ${{ format('{0}', contains(needs.determine-build-type.outputs.type, 'release')) }} - SM_HOST: ${{secrets.SM_HOST}} - SM_CLIENT_CERT_FILE: "D:\\Certificate_pkcs12.p12" - SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD}} - SM_API_KEY: ${{secrets.SM_API_KEY}} - SM_CODE_SIGNING_CERT_SHA1_HASH: ${{secrets.SM_CODE_SIGNING_CERT_SHA1_HASH}} - SM_KEYPAIR_ALIAS: ${{secrets.SM_KEYPAIR_ALIAS}} + SM_CODE_SIGNING_CERT_SHA1_HASH: ${{secrets.SM_CODE_SIGNING_CERT_SHA1_HASH_V2}} + SM_KEYPAIR_ALIAS: ${{secrets.SM_KEYPAIR_ALIAS_V2}} WINDOWS_CSC_FILEPATH: "D:\\opentrons_labworks_inc.crt" - CSC_LINK: ${{ secrets.OT_APP_CSC_MACOS }} - CSC_KEY_PASSWORD: ${{ secrets.OT_APP_CSC_KEY_MACOS }} - APPLE_ID: ${{ secrets.OT_APP_APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.OT_APP_APPLE_ID_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.OT_APP_APPLE_TEAM_ID }} + CSC_LINK: ${{ secrets.OT_APP_CSC_MACOS_V2 }} + CSC_KEY_PASSWORD: ${{ secrets.OT_APP_CSC_KEY_MACOS_V2 }} + APPLE_ID: ${{ secrets.OT_APP_APPLE_ID_V2 }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.OT_APP_APPLE_ID_PASSWORD_V2 }} + APPLE_TEAM_ID: ${{ secrets.OT_APP_APPLE_TEAM_ID_V2 }} HOST_PYTHON: python OPENTRONS_PROJECT: ${{ steps.project.outputs.project }} OT_APP_DEPLOY_BUCKET: ${{ steps.project.outputs.bucket }} diff --git a/app-shell-odd/Makefile b/app-shell-odd/Makefile index 543ed2de95f..5d2d7ac37bd 100644 --- a/app-shell-odd/Makefile +++ b/app-shell-odd/Makefile @@ -65,12 +65,14 @@ deps: .PHONY: package-deps package-deps: clean lib deps +# Note: keep the push dep separate from the dist target so it doesn't accidentally +# do a js dist when we want to only build electron .PHONY: dist-ot3 -dist-ot3: package-deps +dist-ot3: clean lib NO_USB_DETECTION=true OT_APP_DEPLOY_BUCKET=opentrons-app OT_APP_DEPLOY_FOLDER=builds OPENTRONS_PROJECT=$(OPENTRONS_PROJECT) $(builder) --linux --arm64 .PHONY: push-ot3 -push-ot3: dist-ot3 +push-ot3: dist-ot3 deps tar -zcvf opentrons-robot-app.tar.gz -C ./dist/linux-arm64-unpacked/ ./ scp $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) -r ./opentrons-robot-app.tar.gz root@$(host): ssh $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) root@$(host) "mount -o remount,rw / && systemctl stop opentrons-robot-app && rm -rf /opt/opentrons-app && mkdir -p /opt/opentrons-app" diff --git a/app-shell/Makefile b/app-shell/Makefile index 5daafd82f44..74e4e4b1912 100644 --- a/app-shell/Makefile +++ b/app-shell/Makefile @@ -121,32 +121,34 @@ package dist-posix dist-osx dist-linux dist-win: export BUILD_ID := $(build_id) package dist-posix dist-osx dist-linux dist-win: export NO_PYTHON := $(if $(no_python_bundle),true,false) package dist-posix dist-osx dist-linux dist-win: export USE_HARD_LINKS := false +# Note: these depend on make -C app dist having been run; do not do this automatically because we separate these +# tasks in CI and even if you have a file dep it's easy to accidentally make the dist run. .PHONY: package -package: package-deps +package: $(builder) --dir .PHONY: dist-posix -dist-posix: package-deps +dist-posix: clean lib $(builder) --linux --mac $(MAKE) _dist-collect-artifacts .PHONY: dist-osx -dist-osx: package-deps +dist-osx: clean lib $(builder) --mac --x64 $(MAKE) _dist-collect-artifacts .PHONY: dist-linux -dist-linux: package-deps +dist-linux: clean lib $(builder) --linux $(MAKE) _dist-collect-artifacts .PHONY: dist-win -dist-win: package-deps +dist-win: clean lib $(builder) --win --x64 $(MAKE) _dist-collect-artifacts .PHONY: dist-ot3 -dist-ot3: package-deps +dist-ot3: clean lib NO_PYTHON=true $(builder) --linux --arm64 --dir cd dist/linux-arm64-unpacked diff --git a/app/vite.config.mts b/app/vite.config.mts index 0d1ccadcc19..f10fedf4f7e 100644 --- a/app/vite.config.mts +++ b/app/vite.config.mts @@ -46,7 +46,11 @@ export default defineConfig( }, }, define: { - 'process.env': process.env, + 'process.env': { + NODE_ENV: process.env.NODE_ENV, + OT_APP_MIXPANEL_ID: process.env.OT_APP_MIXPANEL_ID, + OPENTRONS_PROJECT: process.env.OPENTRONS_PROJECT, + }, global: 'globalThis', _PKG_VERSION_: JSON.stringify(version), _OPENTRONS_PROJECT_: JSON.stringify(project), From e6d13c6f4581103ce376f65685c8d2472a834264 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Fri, 8 Nov 2024 09:48:26 -0500 Subject: [PATCH 27/49] fix(ci): app signing secret rotation (#16738) (#16739) CherryPick #16738 --- .github/workflows/app-test-build-deploy.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index d443fae35a0..0b5c1cbe35f 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -308,7 +308,7 @@ jobs: if: startsWith(matrix.os, 'windows') && contains(needs.determine-build-type.outputs.type, 'release') shell: bash run: | - echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 + echo "${{ secrets.SM_CLIENT_CERT_FILE_B64_V2 }}" | base64 --decode > /d/Certificate_pkcs12.p12 echo "${{ secrets.WINDOWS_CSC_B64}}" | base64 --decode > /d/opentrons_labworks_inc.crt echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH @@ -350,6 +350,10 @@ jobs: WINDOWS_SIGN: ${{ format('{0}', contains(needs.determine-build-type.outputs.type, 'release')) }} SM_CODE_SIGNING_CERT_SHA1_HASH: ${{secrets.SM_CODE_SIGNING_CERT_SHA1_HASH_V2}} SM_KEYPAIR_ALIAS: ${{secrets.SM_KEYPAIR_ALIAS_V2}} + SM_HOST: ${{ secrets.SM_HOST_V2 }} + SM_CLIENT_CERT_FILE: "D:\\Certificate_pkcs12.p12" + SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD_V2}} + SM_API_KEY: ${{secrets.SM_API_KEY_V2}} WINDOWS_CSC_FILEPATH: "D:\\opentrons_labworks_inc.crt" CSC_LINK: ${{ secrets.OT_APP_CSC_MACOS_V2 }} CSC_KEY_PASSWORD: ${{ secrets.OT_APP_CSC_KEY_MACOS_V2 }} From 2fda991321512704cbe27a03255fb78e6a3566c6 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Fri, 8 Nov 2024 10:57:46 -0500 Subject: [PATCH 28/49] test(api): Add some absorbance reader integration tests (#16740) --- .../protocol_api_integration/test_modules.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 api/tests/opentrons/protocol_api_integration/test_modules.py diff --git a/api/tests/opentrons/protocol_api_integration/test_modules.py b/api/tests/opentrons/protocol_api_integration/test_modules.py new file mode 100644 index 00000000000..a287974296d --- /dev/null +++ b/api/tests/opentrons/protocol_api_integration/test_modules.py @@ -0,0 +1,68 @@ +"""Tests for modules.""" + +import typing +import pytest + +from opentrons import simulate, protocol_api + + +def test_absorbance_reader_labware_load_conflict() -> None: + """It should prevent loading a labware onto a closed absorbance reader.""" + protocol = simulate.get_protocol_api(version="2.21", robot_type="Flex") + module = protocol.load_module("absorbanceReaderV1", "A3") + + # The lid should be treated as initially closed. + with pytest.raises(Exception): + module.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt") + + module.open_lid() # type: ignore[union-attr] + # Should not raise after opening the lid. + labware_1 = module.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt") + + protocol.move_labware(labware_1, protocol_api.OFF_DECK) + + # Should raise after closing the lid again. + module.close_lid() # type: ignore[union-attr] + module.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt") + + +def test_absorbance_reader_labware_move_conflict() -> None: + """It should prevent moving a labware onto a closed absorbance reader.""" + protocol = simulate.get_protocol_api(version="2.21", robot_type="Flex") + module = protocol.load_module("absorbanceReaderV1", "A3") + labware = protocol.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt", "A1") + + with pytest.raises(Exception): + # The lid should be treated as initially closed. + protocol.move_labware(labware, module, use_gripper=True) + + module.open_lid() # type: ignore[union-attr] + # Should not raise after opening the lid. + protocol.move_labware(labware, module, use_gripper=True) + + protocol.move_labware(labware, "A1", use_gripper=True) + + # Should raise after closing the lid again. + module.close_lid() # type: ignore[union-attr] + with pytest.raises(Exception): + protocol.move_labware(labware, module, use_gripper=True) + + +def test_absorbance_reader_read_preconditions() -> None: + """Test the preconditions for triggering an absorbance reader read.""" + protocol = simulate.get_protocol_api(version="2.21", robot_type="Flex") + module = typing.cast( + protocol_api.AbsorbanceReaderContext, + protocol.load_module("absorbanceReaderV1", "A3"), + ) + + with pytest.raises(Exception, match="initialize"): + module.read() # .initialize() must be called first. + + with pytest.raises(Exception, match="close"): + module.initialize("single", [500]) # .close_lid() must be called first. + + module.close_lid() + module.initialize("single", [500]) + + module.read() # Should not raise now. From 839da567baecb37161a591c922e1f483f7172d43 Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Fri, 8 Nov 2024 10:03:05 -0800 Subject: [PATCH 29/49] feat(app-shell, app-shell-odd): filter out unused env vars (#16741) (#16742) --- app-shell-odd/vite.config.mts | 5 ++++- app-shell/vite.config.mts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app-shell-odd/vite.config.mts b/app-shell-odd/vite.config.mts index 7848c92bd8d..ef20c33b0ed 100644 --- a/app-shell-odd/vite.config.mts +++ b/app-shell-odd/vite.config.mts @@ -59,7 +59,10 @@ export default defineConfig( }, }, define: { - 'process.env': process.env, + 'process.env': { + NODE_ENV: process.env.NODE_ENV, + OPENTRONS_PROJECT: process.env.OPENTRONS_PROJECT, + }, global: 'globalThis', _PKG_VERSION_: JSON.stringify(version), _PKG_PRODUCT_NAME_: JSON.stringify(pkg.productName), diff --git a/app-shell/vite.config.mts b/app-shell/vite.config.mts index 63862287fc1..8e2883cca0d 100644 --- a/app-shell/vite.config.mts +++ b/app-shell/vite.config.mts @@ -37,7 +37,10 @@ export default defineConfig( exclude: ['node_modules'], }, define: { - 'process.env': process.env, + 'process.env': { + NODE_ENV: process.env.NODE_ENV, + OPENTRONS_PROJECT: process.env.OPENTRONS_PROJECT, + }, global: 'globalThis', _PKG_VERSION_: JSON.stringify(version), _PKG_PRODUCT_NAME_: JSON.stringify(pkg.productName), From e87fe8c80ded947b6c494d4883233283d29f037b Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 8 Nov 2024 14:14:46 -0500 Subject: [PATCH 30/49] fix(app): fix ODD IntersectionObserver reference cycle (#16743) Works toward EXEC-807 We have this code in the ODD that runs every render cycle on a lot of the "idle" views that manages the scrollbar. We instantiate a new observer on every render, but the old observer is never cleaned up. This commit ensures that we only ever instantiate one observer, and we properly remove it on component derender. --- .../hooks/__tests__/useScrollPosition.test.ts | 76 +++++++++++++++++++ .../local-resources/dom-utils/hooks/index.ts | 1 + .../dom-utils/hooks/useScrollPosition.ts | 27 +++++++ app/src/local-resources/dom-utils/index.ts | 1 + .../Navigation/__tests__/Navigation.test.tsx | 24 ++---- app/src/organisms/ODD/Navigation/index.tsx | 11 +-- .../__tests__/ProtocolDetails.test.tsx | 18 ++--- app/src/pages/ODD/ProtocolDetails/index.tsx | 12 +-- .../__tests__/ProtocolSetup.test.tsx | 18 ++--- app/src/pages/ODD/ProtocolSetup/index.tsx | 10 +-- .../__tests__/QuickTransferDetails.test.tsx | 18 ++--- .../pages/ODD/QuickTransferDetails/index.tsx | 12 +-- 12 files changed, 139 insertions(+), 89 deletions(-) create mode 100644 app/src/local-resources/dom-utils/hooks/__tests__/useScrollPosition.test.ts create mode 100644 app/src/local-resources/dom-utils/hooks/index.ts create mode 100644 app/src/local-resources/dom-utils/hooks/useScrollPosition.ts create mode 100644 app/src/local-resources/dom-utils/index.ts diff --git a/app/src/local-resources/dom-utils/hooks/__tests__/useScrollPosition.test.ts b/app/src/local-resources/dom-utils/hooks/__tests__/useScrollPosition.test.ts new file mode 100644 index 00000000000..f5a47b2518c --- /dev/null +++ b/app/src/local-resources/dom-utils/hooks/__tests__/useScrollPosition.test.ts @@ -0,0 +1,76 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { useScrollPosition } from '../useScrollPosition' + +describe('useScrollPosition', () => { + const mockObserve = vi.fn() + const mockDisconnect = vi.fn() + let intersectionCallback: (entries: IntersectionObserverEntry[]) => void + + beforeEach(() => { + vi.stubGlobal( + 'IntersectionObserver', + vi.fn(callback => { + intersectionCallback = callback + return { + observe: mockObserve, + disconnect: mockDisconnect, + unobserve: vi.fn(), + } + }) + ) + }) + + it('should return initial state and ref', () => { + const { result } = renderHook(() => useScrollPosition()) + + expect(result.current.isScrolled).toBe(false) + expect(result.current.scrollRef).toBeDefined() + expect(result.current.scrollRef.current).toBe(null) + }) + + it('should observe when ref is set', async () => { + const { result } = renderHook(() => useScrollPosition()) + + const div = document.createElement('div') + + await act(async () => { + // @ts-expect-error we're forcibly setting readonly ref + result.current.scrollRef.current = div + + const observer = new IntersectionObserver(intersectionCallback) + observer.observe(div) + }) + + expect(mockObserve).toHaveBeenCalledWith(div) + }) + + it('should update isScrolled when intersection changes for both scrolled and unscrolled cases', () => { + const { result } = renderHook(() => useScrollPosition()) + + act(() => { + intersectionCallback([ + { isIntersecting: false } as IntersectionObserverEntry, + ]) + }) + + expect(result.current.isScrolled).toBe(true) + + act(() => { + intersectionCallback([ + { isIntersecting: true } as IntersectionObserverEntry, + ]) + }) + + expect(result.current.isScrolled).toBe(false) + }) + + it('should disconnect observer on unmount', () => { + const { unmount } = renderHook(() => useScrollPosition()) + + unmount() + + expect(mockDisconnect).toHaveBeenCalled() + }) +}) diff --git a/app/src/local-resources/dom-utils/hooks/index.ts b/app/src/local-resources/dom-utils/hooks/index.ts new file mode 100644 index 00000000000..2098c90e0c3 --- /dev/null +++ b/app/src/local-resources/dom-utils/hooks/index.ts @@ -0,0 +1 @@ +export * from './useScrollPosition' diff --git a/app/src/local-resources/dom-utils/hooks/useScrollPosition.ts b/app/src/local-resources/dom-utils/hooks/useScrollPosition.ts new file mode 100644 index 00000000000..8b3aa945947 --- /dev/null +++ b/app/src/local-resources/dom-utils/hooks/useScrollPosition.ts @@ -0,0 +1,27 @@ +import { useRef, useState, useEffect } from 'react' + +import type { RefObject } from 'react' + +export function useScrollPosition(): { + scrollRef: RefObject + isScrolled: boolean +} { + const scrollRef = useRef(null) + const [isScrolled, setIsScrolled] = useState(false) + + useEffect(() => { + const observer = new IntersectionObserver(([entry]) => { + setIsScrolled(!entry.isIntersecting) + }) + + if (scrollRef.current != null) { + observer.observe(scrollRef.current) + } + + return () => { + observer.disconnect() + } + }, []) + + return { scrollRef, isScrolled } +} diff --git a/app/src/local-resources/dom-utils/index.ts b/app/src/local-resources/dom-utils/index.ts new file mode 100644 index 00000000000..fc78d35129c --- /dev/null +++ b/app/src/local-resources/dom-utils/index.ts @@ -0,0 +1 @@ +export * from './hooks' diff --git a/app/src/organisms/ODD/Navigation/__tests__/Navigation.test.tsx b/app/src/organisms/ODD/Navigation/__tests__/Navigation.test.tsx index 5a18c86f7dc..c86ba363d5c 100644 --- a/app/src/organisms/ODD/Navigation/__tests__/Navigation.test.tsx +++ b/app/src/organisms/ODD/Navigation/__tests__/Navigation.test.tsx @@ -10,31 +10,15 @@ import { mockConnectedRobot } from '/app/redux/discovery/__fixtures__' import { useNetworkConnection } from '/app/resources/networking/hooks/useNetworkConnection' import { NavigationMenu } from '../NavigationMenu' import { Navigation } from '..' +import { useScrollPosition } from '/app/local-resources/dom-utils' vi.mock('/app/resources/networking/hooks/useNetworkConnection') vi.mock('/app/redux/discovery') vi.mock('../NavigationMenu') +vi.mock('/app/local-resources/dom-utils') mockConnectedRobot.name = '12345678901234567' -class MockIntersectionObserver { - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: MockIntersectionObserver, -}) - -Object.defineProperty(global, 'IntersectionObserver', { - writable: true, - configurable: true, - value: MockIntersectionObserver, -}) - const render = (props: React.ComponentProps) => { return renderWithProviders( @@ -56,6 +40,10 @@ describe('Navigation', () => { isUsbConnected: false, connectionStatus: 'Not connected', }) + vi.mocked(useScrollPosition).mockReturnValue({ + isScrolled: false, + scrollRef: {} as any, + }) }) it('should render text and they have attribute', () => { render(props) diff --git a/app/src/organisms/ODD/Navigation/index.tsx b/app/src/organisms/ODD/Navigation/index.tsx index a49d88a8ca9..8b60946f929 100644 --- a/app/src/organisms/ODD/Navigation/index.tsx +++ b/app/src/organisms/ODD/Navigation/index.tsx @@ -27,6 +27,7 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { ODD_FOCUS_VISIBLE } from '/app/atoms/buttons/constants' +import { useScrollPosition } from '/app/local-resources/dom-utils' import { useNetworkConnection } from '/app/resources/networking/hooks/useNetworkConnection' import { getLocalRobot } from '/app/redux/discovery' @@ -92,15 +93,7 @@ export function Navigation(props: NavigationProps): JSX.Element { setShowNavMenu(openMenu) } - const scrollRef = React.useRef(null) - const [isScrolled, setIsScrolled] = React.useState(false) - - const observer = new IntersectionObserver(([entry]) => { - setIsScrolled(!entry.isIntersecting) - }) - if (scrollRef.current != null) { - observer.observe(scrollRef.current) - } + const { scrollRef, isScrolled } = useScrollPosition() const navBarScrollRef = React.useRef(null) React.useEffect(() => { diff --git a/app/src/pages/ODD/ProtocolDetails/__tests__/ProtocolDetails.test.tsx b/app/src/pages/ODD/ProtocolDetails/__tests__/ProtocolDetails.test.tsx index dfe517c58aa..675d8b038a8 100644 --- a/app/src/pages/ODD/ProtocolDetails/__tests__/ProtocolDetails.test.tsx +++ b/app/src/pages/ODD/ProtocolDetails/__tests__/ProtocolDetails.test.tsx @@ -24,21 +24,10 @@ import { Deck } from '../Deck' import { Hardware } from '../Hardware' import { Labware } from '../Labware' import { Parameters } from '../Parameters' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { HostConfig } from '@opentrons/api-client' -// Mock IntersectionObserver -class IntersectionObserver { - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, -}) vi.mock( '/app/organisms/ODD/ProtocolSetup/ProtocolSetupParameters/ProtocolSetupParameters' ) @@ -55,6 +44,7 @@ vi.mock('../Hardware') vi.mock('../Labware') vi.mock('../Parameters') vi.mock('/app/redux/config') +vi.mock('/app/local-resources/dom-utils') const MOCK_HOST_CONFIG = {} as HostConfig const mockCreateRun = vi.fn((id: string) => {}) @@ -119,6 +109,10 @@ describe('ODDProtocolDetails', () => { vi.mocked(getProtocol).mockResolvedValue({ data: { links: { referencingRuns: [{ id: '1' }, { id: '2' }] } }, } as any) + vi.mocked(useScrollPosition).mockReturnValue({ + isScrolled: false, + scrollRef: {} as any, + }) }) afterEach(() => { vi.resetAllMocks() diff --git a/app/src/pages/ODD/ProtocolDetails/index.tsx b/app/src/pages/ODD/ProtocolDetails/index.tsx index 0088c0c76d4..133210ff218 100644 --- a/app/src/pages/ODD/ProtocolDetails/index.tsx +++ b/app/src/pages/ODD/ProtocolDetails/index.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState } from 'react' import last from 'lodash/last' import { useTranslation } from 'react-i18next' import { useQueryClient } from 'react-query' @@ -56,6 +56,7 @@ import { Hardware } from './Hardware' import { Labware } from './Labware' import { Liquids } from './Liquids' import { formatTimeWithUtcLabel } from '/app/resources/runs' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { Protocol } from '@opentrons/api-client' import type { OddModalHeaderBaseProps } from '/app/molecules/OddModal/types' @@ -335,14 +336,7 @@ export function ProtocolDetails(): JSX.Element | null { }) // Watch for scrolling to toggle dropshadow - const scrollRef = useRef(null) - const [isScrolled, setIsScrolled] = useState(false) - const observer = new IntersectionObserver(([entry]) => { - setIsScrolled(!entry.isIntersecting) - }) - if (scrollRef.current != null) { - observer.observe(scrollRef.current) - } + const { scrollRef, isScrolled } = useScrollPosition() let pinnedProtocolIds = useSelector(getPinnedProtocolIds) ?? [] const pinned = pinnedProtocolIds.includes(protocolId) diff --git a/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 96f39a280f9..438f856b41d 100644 --- a/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -64,22 +64,11 @@ import { } from '/app/resources/runs' import { mockConnectableRobot } from '/app/redux/discovery/__fixtures__' import { mockRunTimeParameterData } from '/app/organisms/ODD/ProtocolSetup/__fixtures__' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { UseQueryResult } from 'react-query' import type * as SharedData from '@opentrons/shared-data' import type { NavigateFunction } from 'react-router-dom' -// Mock IntersectionObserver -class IntersectionObserver { - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, -}) let mockNavigate = vi.fn() @@ -125,6 +114,7 @@ vi.mock('../ConfirmSetupStepsCompleteModal') vi.mock('/app/redux-resources/analytics') vi.mock('/app/redux-resources/robots') vi.mock('/app/resources/modules') +vi.mock('/app/local-resources/dom-utils') const render = (path = '/') => { return renderWithProviders( @@ -334,6 +324,10 @@ describe('ProtocolSetup', () => { when(vi.mocked(useTrackProtocolRunEvent)) .calledWith(RUN_ID, ROBOT_NAME) .thenReturn({ trackProtocolRunEvent: mockTrackProtocolRunEvent }) + vi.mocked(useScrollPosition).mockReturnValue({ + isScrolled: false, + scrollRef: {} as any, + }) }) it('should render text, image, and buttons', () => { diff --git a/app/src/pages/ODD/ProtocolSetup/index.tsx b/app/src/pages/ODD/ProtocolSetup/index.tsx index 8a9193cbeda..03a03626a55 100644 --- a/app/src/pages/ODD/ProtocolSetup/index.tsx +++ b/app/src/pages/ODD/ProtocolSetup/index.tsx @@ -81,6 +81,7 @@ import { useModuleCalibrationStatus, useProtocolAnalysisErrors, } from '/app/resources/runs' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { Run } from '@opentrons/api-client' import type { CutoutFixtureId, CutoutId } from '@opentrons/shared-data' @@ -129,14 +130,7 @@ function PrepareToRun({ const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const navigate = useNavigate() const { makeSnackbar } = useToaster() - const scrollRef = React.useRef(null) - const [isScrolled, setIsScrolled] = React.useState(false) - const observer = new IntersectionObserver(([entry]) => { - setIsScrolled(!entry.isIntersecting) - }) - if (scrollRef.current != null) { - observer.observe(scrollRef.current) - } + const { scrollRef, isScrolled } = useScrollPosition() const protocolId = runRecord?.data?.protocolId ?? null const { data: protocolRecord } = useProtocolQuery(protocolId, { diff --git a/app/src/pages/ODD/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx b/app/src/pages/ODD/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx index 9d1848ee31e..33203e4dc4f 100644 --- a/app/src/pages/ODD/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx +++ b/app/src/pages/ODD/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx @@ -26,21 +26,10 @@ import { QuickTransferDetails } from '..' import { Deck } from '../Deck' import { Hardware } from '../Hardware' import { Labware } from '../Labware' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { HostConfig } from '@opentrons/api-client' -// Mock IntersectionObserver -class IntersectionObserver { - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, -}) vi.mock('/app/organisms/ODD/ProtocolSetup/ProtocolSetupParameters') vi.mock('@opentrons/api-client') vi.mock('@opentrons/react-api-client') @@ -55,6 +44,7 @@ vi.mock('../Deck') vi.mock('../Hardware') vi.mock('../Labware') vi.mock('/app/redux/config') +vi.mock('/app/local-resources/dom-utils') const MOCK_HOST_CONFIG = {} as HostConfig const mockCreateRun = vi.fn((id: string) => {}) @@ -125,6 +115,10 @@ describe('ODDQuickTransferDetails', () => { }, } as any) when(vi.mocked(useHost)).calledWith().thenReturn(MOCK_HOST_CONFIG) + vi.mocked(useScrollPosition).mockReturnValue({ + isScrolled: false, + scrollRef: {} as any, + }) }) afterEach(() => { vi.resetAllMocks() diff --git a/app/src/pages/ODD/QuickTransferDetails/index.tsx b/app/src/pages/ODD/QuickTransferDetails/index.tsx index 7095fd47ddb..5989ec1f29a 100644 --- a/app/src/pages/ODD/QuickTransferDetails/index.tsx +++ b/app/src/pages/ODD/QuickTransferDetails/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect } from 'react' import last from 'lodash/last' import { useTranslation } from 'react-i18next' import { useQueryClient } from 'react-query' @@ -57,6 +57,7 @@ import { Deck } from './Deck' import { Hardware } from './Hardware' import { Labware } from './Labware' import { formatTimeWithUtcLabel } from '/app/resources/runs' +import { useScrollPosition } from '/app/local-resources/dom-utils' import type { Protocol } from '@opentrons/api-client' import type { Dispatch } from '/app/redux/types' @@ -321,14 +322,7 @@ export function QuickTransferDetails(): JSX.Element | null { }) // Watch for scrolling to toggle dropshadow - const scrollRef = useRef(null) - const [isScrolled, setIsScrolled] = useState(false) - const observer = new IntersectionObserver(([entry]) => { - setIsScrolled(!entry.isIntersecting) - }) - if (scrollRef.current != null) { - observer.observe(scrollRef.current) - } + const { scrollRef, isScrolled } = useScrollPosition() let pinnedTransferIds = useSelector(getPinnedQuickTransferIds) ?? [] const pinned = pinnedTransferIds.includes(transferId) From 74312f2190d662bafd51cb6b73f95687da444c70 Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Tue, 12 Nov 2024 11:13:45 -0500 Subject: [PATCH 31/49] feat(shared-data): add lv96 shared data definitions (#16749) # Overview Add 200ul pipette definitions to shared data ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment This will not work out of the box until we get aspirate/dispense pipette functions from the factory. --- .../js/__tests__/pipetteSchemaV2.test.ts | 6 +- .../general/ninety_six_channel/p200/3_0.json | 660 ++++++++++++++++++ .../geometry/ninety_six_channel/p200/3_0.json | 305 ++++++++ .../ninety_six_channel/p200/placeholder.gltf | 0 .../ninety_six_channel/p200/default/3_0.json | 121 ++++ 5 files changed, 1089 insertions(+), 3 deletions(-) create mode 100644 shared-data/pipette/definitions/2/general/ninety_six_channel/p200/3_0.json create mode 100644 shared-data/pipette/definitions/2/geometry/ninety_six_channel/p200/3_0.json create mode 100644 shared-data/pipette/definitions/2/geometry/ninety_six_channel/p200/placeholder.gltf create mode 100644 shared-data/pipette/definitions/2/liquid/ninety_six_channel/p200/default/3_0.json diff --git a/shared-data/js/__tests__/pipetteSchemaV2.test.ts b/shared-data/js/__tests__/pipetteSchemaV2.test.ts index d5007cd276c..1fc694978d9 100644 --- a/shared-data/js/__tests__/pipetteSchemaV2.test.ts +++ b/shared-data/js/__tests__/pipetteSchemaV2.test.ts @@ -50,7 +50,7 @@ describe('test schema against all liquid specs definitions', () => { }) it(`second parent dir matches pipette model: ${liquidPath}`, () => { - expect(['p10', 'p20', 'p50', 'p300', 'p1000']).toContain( + expect(['p10', 'p20', 'p50', 'p200', 'p300', 'p1000']).toContain( path.basename(path.dirname(path.dirname(liquidPath))) ) }) @@ -73,7 +73,7 @@ describe('test schema against all geometry specs definitions', () => { }) it(`parent dir matches pipette model: ${geometryPath}`, () => { - expect(['p10', 'p20', 'p50', 'p300', 'p1000']).toContain( + expect(['p10', 'p20', 'p50', 'p200', 'p300', 'p1000']).toContain( path.basename(path.dirname(geometryPath)) ) }) @@ -100,7 +100,7 @@ describe('test schema against all general specs definitions', () => { }) it(`parent dir matches pipette model: ${generalPath}`, () => { - expect(['p10', 'p20', 'p50', 'p300', 'p1000']).toContain( + expect(['p10', 'p20', 'p50', 'p200', 'p300', 'p1000']).toContain( path.basename(path.dirname(generalPath)) ) }) diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p200/3_0.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p200/3_0.json new file mode 100644 index 00000000000..5719bb3437c --- /dev/null +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p200/3_0.json @@ -0,0 +1,660 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipettePropertiesSchema.json", + "displayName": "Flex 96-Channel 200 μL", + "model": "p200", + "displayCategory": "FLEX", + "validNozzleMaps": { + "maps": { + "SingleA1": ["A1"], + "SingleH1": ["H1"], + "SingleA12": ["A12"], + "SingleH12": ["H12"], + "Column1": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + "Column12": ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"], + "RowA": [ + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "A10", + "A11", + "A12" + ], + "RowH": [ + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9", + "H10", + "H11", + "H12" + ], + "Full": [ + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "A10", + "A11", + "A12", + "B1", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "B10", + "B11", + "B12", + "C1", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "C10", + "C11", + "C12", + "D1", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "D10", + "D11", + "D12", + "E1", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "E10", + "E11", + "E12", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "G10", + "G11", + "G12", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9", + "H10", + "H11", + "H12" + ] + } + }, + "pickUpTipConfigurations": { + "pressFit": { + "presses": 1, + "increment": 0.0, + "configurationsByNozzleMap": { + "SingleA1": { + "default": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.884, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.884, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.884 + } + } + }, + "t200": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5 + }, + "v1": { + "default": 9.981, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.981 + } + } + }, + "t50": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.884, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.884, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.884 + } + } + } + }, + "SingleH1": { + "default": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.884, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.884, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.884 + } + } + }, + "t200": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5 + }, + "v1": { + "default": 9.981, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.981 + } + } + }, + "t50": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.884, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.884, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.884 + } + } + } + }, + "SingleA12": { + "default": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.884, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.884, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.884 + } + } + }, + "t200": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5 + }, + "v1": { + "default": 9.981, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.981 + } + } + }, + "t50": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.884, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.884, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.884 + } + } + } + }, + "SingleH12": { + "default": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.884, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.884, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.884 + } + } + }, + "t200": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5 + }, + "v1": { + "default": 9.981, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.981, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.981 + } + } + }, + "t50": { + "speed": 10.0, + "distance": 10.5, + "current": 0.4, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.884, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.884, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.884 + } + } + } + }, + "Column1": { + "default": { + "speed": 10.0, + "distance": 13.0, + "current": 0.55, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.16, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.74 + }, + "v3": { + "default": 9.49, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.52, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.49, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.49, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.52 + } + } + }, + "t200": { + "speed": 10.0, + "distance": 13.0, + "current": 0.55, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.74 + }, + "v3": { + "default": 9.49, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.49, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.49 + } + } + }, + "t50": { + "speed": 10.0, + "distance": 13.0, + "current": 0.55, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.16 + }, + "v3": { + "default": 9.52, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.52, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.52 + } + } + } + }, + "Column12": { + "default": { + "speed": 10.0, + "distance": 13.0, + "current": 0.55, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.16, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.74 + }, + "v3": { + "default": 9.49, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.52, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.49, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.49, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.52 + } + } + }, + "t200": { + "speed": 10.0, + "distance": 13.0, + "current": 0.55, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.74 + }, + "v3": { + "default": 9.49, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.49, + "opentrons/opentrons_flex_96_filtertiprack_200ul/1": 9.49 + } + } + }, + "t50": { + "speed": 10.0, + "distance": 13.0, + "current": 0.55, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.16 + }, + "v3": { + "default": 9.52, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.52, + "opentrons/opentrons_flex_96_filtertiprack_50ul/1": 9.52 + } + } + } + }, + "RowA": { + "default": { + "speed": 10.0, + "distance": 13.0, + "current": 0.55, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 + }, + "v1": { + "default": 9.379, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.379, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.6 + } + } + } + }, + "RowH": { + "default": { + "speed": 10.0, + "distance": 13.0, + "current": 0.55, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5 + }, + "v1": { + "default": 9.401, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 9.401, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.415 + } + } + } + } + } + }, + "camAction": { + "prep_move_distance": 8.25, + "prep_move_speed": 10.0, + "connectTiprackDistanceMM": 7.0, + "configurationsByNozzleMap": { + "Full": { + "default": { + "speed": 5.5, + "distance": 10.0, + "current": 1.5, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.16, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.74 + } + } + }, + "t200": { + "speed": 5.5, + "distance": 10.0, + "current": 1.5, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.74 + } + } + }, + "t50": { + "speed": 5.5, + "distance": 10.0, + "current": 1.5, + "tipOverlaps": { + "v0": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.5 + }, + "v1": { + "default": 10.5, + "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.16 + } + } + } + } + } + } + }, + "dropTipConfigurations": { + "camAction": { + "current": 1.5, + "speed": 5.5, + "distance": 10.8, + "prep_move_distance": 19.0, + "prep_move_speed": 10.0 + } + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 0.8 + }, + "plungerPositionsConfigurations": { + "default": { + "top": 0.5, + "bottom": 68.5, + "blowout": 73.5, + "drop": 80 + } + }, + "availableSensors": { + "sensors": ["pressure", "capacitive", "environment"], + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": true, + "availableConfigurations": [1, 8, 12, 16, 24, 48, 96] + }, + "backCompatNames": [], + "channels": 96, + "shaftDiameter": 2, + "shaftULperMM": 3.14159, + "backlashDistance": 3.0, + "quirks": [], + "plungerHomingConfigurations": { + "current": 0.8, + "speed": 5 + }, + "tipPresenceCheckDistanceMM": 8.0, + "endTipActionRetractDistanceMM": 2.0 +} diff --git a/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p200/3_0.json b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p200/3_0.json new file mode 100644 index 00000000000..437830a58c5 --- /dev/null +++ b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p200/3_0.json @@ -0,0 +1,305 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteGeometrySchema.json", + "pathTo3D": "pipette/definitions/2/geometry/ninety_six_channel/p200/placeholder.gltf", + "nozzleOffset": [-36.0, -25.5, -259.15], + "pipetteBoundingBoxOffsets": { + "backLeftCorner": [-67.0, -3.5, -259.15], + "frontRightCorner": [94.0, -113.0, -259.15] + }, + "orderedRows": [ + { + "key": "A", + "orderedNozzles": [ + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "A10", + "A11", + "A12" + ] + }, + { + "key": "B", + "orderedNozzles": [ + "B1", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "B10", + "B11", + "B12" + ] + }, + { + "key": "C", + "orderedNozzles": [ + "C1", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "C10", + "C11", + "C12" + ] + }, + { + "key": "D", + "orderedNozzles": [ + "D1", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "D10", + "D11", + "D12" + ] + }, + { + "key": "E", + "orderedNozzles": [ + "E1", + "E2", + "E3", + "E4", + "E5", + "E6", + "E7", + "E8", + "E9", + "E10", + "E11", + "E12" + ] + }, + { + "key": "F", + "orderedNozzles": [ + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12" + ] + }, + { + "key": "G", + "orderedNozzles": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "G8", + "G9", + "G10", + "G11", + "G12" + ] + }, + { + "key": "H", + "orderedNozzles": [ + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "H7", + "H8", + "H9", + "H10", + "H11", + "H12" + ] + } + ], + "orderedColumns": [ + { + "key": "1", + "orderedNozzles": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"] + }, + { + "key": "2", + "orderedNozzles": ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"] + }, + { + "key": "3", + "orderedNozzles": ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"] + }, + { + "key": "4", + "orderedNozzles": ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"] + }, + { + "key": "5", + "orderedNozzles": ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"] + }, + { + "key": "6", + "orderedNozzles": ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"] + }, + { + "key": "7", + "orderedNozzles": ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"] + }, + { + "key": "8", + "orderedNozzles": ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"] + }, + { + "key": "9", + "orderedNozzles": ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"] + }, + { + "key": "10", + "orderedNozzles": ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"] + }, + { + "key": "11", + "orderedNozzles": ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"] + }, + { + "key": "12", + "orderedNozzles": ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + } + ], + "nozzleMap": { + "A1": [-36.0, -25.5, -259.15], + "A2": [-27.0, -25.5, -259.15], + "A3": [-18.0, -25.5, -259.15], + "A4": [-9.0, -25.5, -259.15], + "A5": [0.0, -25.5, -259.15], + "A6": [9.0, -25.5, -259.15], + "A7": [18.0, -25.5, -259.15], + "A8": [27.0, -25.5, -259.15], + "A9": [36.0, -25.5, -259.15], + "A10": [45.0, -25.5, -259.15], + "A11": [54.0, -25.5, -259.15], + "A12": [63.0, -25.5, -259.15], + "B1": [-36.0, -34.5, -259.15], + "B2": [-27.0, -34.5, -259.15], + "B3": [-18.0, -34.5, -259.15], + "B4": [-9.0, -34.5, -259.15], + "B5": [0.0, -34.5, -259.15], + "B6": [9.0, -34.5, -259.15], + "B7": [18.0, -34.5, -259.15], + "B8": [27.0, -34.5, -259.15], + "B9": [36.0, -34.5, -259.15], + "B10": [45.0, -34.5, -259.15], + "B11": [54.0, -34.5, -259.15], + "B12": [63.0, -34.5, -259.15], + "C1": [-36.0, -43.5, -259.15], + "C2": [-27.0, -43.5, -259.15], + "C3": [-18.0, -43.5, -259.15], + "C4": [-9.0, -43.5, -259.15], + "C5": [0.0, -43.5, -259.15], + "C6": [9.0, -43.5, -259.15], + "C7": [18.0, -43.5, -259.15], + "C8": [27.0, -43.5, -259.15], + "C9": [36.0, -43.5, -259.15], + "C10": [45.0, -43.5, -259.15], + "C11": [54.0, -43.5, -259.15], + "C12": [63.0, -43.5, -259.15], + "D1": [-36.0, -52.5, -259.15], + "D2": [-27.0, -52.5, -259.15], + "D3": [-18.0, -52.5, -259.15], + "D4": [-9.0, -52.5, -259.15], + "D5": [0.0, -52.5, -259.15], + "D6": [9.0, -52.5, -259.15], + "D7": [18.0, -52.5, -259.15], + "D8": [27.0, -52.5, -259.15], + "D9": [36.0, -52.5, -259.15], + "D10": [45.0, -52.5, -259.15], + "D11": [54.0, -52.5, -259.15], + "D12": [63.0, -52.5, -259.15], + "E1": [-36.0, -61.5, -259.15], + "E2": [-27.0, -61.5, -259.15], + "E3": [-18.0, -61.5, -259.15], + "E4": [-9.0, -61.5, -259.15], + "E5": [0.0, -61.5, -259.15], + "E6": [9.0, -61.5, -259.15], + "E7": [18.0, -61.5, -259.15], + "E8": [27.0, -61.5, -259.15], + "E9": [36.0, -61.5, -259.15], + "E10": [45.0, -61.5, -259.15], + "E11": [54.0, -61.5, -259.15], + "E12": [63.0, -61.5, -259.15], + "F1": [-36.0, -70.5, -259.15], + "F2": [-27.0, -70.5, -259.15], + "F3": [-18.0, -70.5, -259.15], + "F4": [-9.0, -70.5, -259.15], + "F5": [0.0, -70.5, -259.15], + "F6": [9.0, -70.5, -259.15], + "F7": [18.0, -70.5, -259.15], + "F8": [27.0, -70.5, -259.15], + "F9": [36.0, -70.5, -259.15], + "F10": [45.0, -70.5, -259.15], + "F11": [54.0, -70.5, -259.15], + "F12": [63.0, -70.5, -259.15], + "G1": [-36.0, -79.5, -259.15], + "G2": [-27.0, -79.5, -259.15], + "G3": [-18.0, -79.5, -259.15], + "G4": [-9.0, -79.5, -259.15], + "G5": [0.0, -79.5, -259.15], + "G6": [9.0, -79.5, -259.15], + "G7": [18.0, -79.5, -259.15], + "G8": [27.0, -79.5, -259.15], + "G9": [36.0, -79.5, -259.15], + "G10": [45.0, -79.5, -259.15], + "G11": [54.0, -79.5, -259.15], + "G12": [63.0, -79.5, -259.15], + "H1": [-36.0, -88.5, -259.15], + "H2": [-27.0, -88.5, -259.15], + "H3": [-18.0, -88.5, -259.15], + "H4": [-9.0, -88.5, -259.15], + "H5": [0.0, -88.5, -259.15], + "H6": [9.0, -88.5, -259.15], + "H7": [18.0, -88.5, -259.15], + "H8": [27.0, -88.5, -259.15], + "H9": [36.0, -88.5, -259.15], + "H10": [45.0, -88.5, -259.15], + "H11": [54.0, -88.5, -259.15], + "H12": [63.0, -88.5, -259.15] + }, + "lldSettings": { + "t50": { + "minHeight": 1.5, + "minVolume": 0 + }, + "t200": { + "minHeight": 1.5, + "minVolume": 0 + } + } +} diff --git a/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p200/placeholder.gltf b/shared-data/pipette/definitions/2/geometry/ninety_six_channel/p200/placeholder.gltf new file mode 100644 index 00000000000..e69de29bb2d diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p200/default/3_0.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p200/default/3_0.json new file mode 100644 index 00000000000..ef067186a90 --- /dev/null +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p200/default/3_0.json @@ -0,0 +1,121 @@ +{ + "$otSharedSchema": "#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json", + "supportedTips": { + "t50": { + "uiMaxFlowRate": 194, + "defaultAspirateFlowRate": { + "default": 6, + "valuesByApiLevel": { "2.14": 6 } + }, + "defaultDispenseFlowRate": { + "default": 6, + "valuesByApiLevel": { "2.14": 6 } + }, + "defaultBlowOutFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultFlowAcceleration": 16000.0, + "defaultTipLength": 57.9, + "defaultReturnTipHeight": 0.2, + "aspirate": { + "default": { + "1": [ + [1.9733, 2.7039, 5.1258], + [2.88, 1.0915, 8.3077], + [3.7642, 0.5906, 9.7502], + [4.9783, 1.0072, 8.1822], + [5.9342, 0.2998, 11.7038], + [6.8708, 0.1887, 12.3626], + [7.8092, 0.1497, 12.631], + [8.7525, 0.1275, 12.804], + [13.4575, 0.0741, 13.2718], + [22.8675, 0.0296, 13.87], + [37.0442, 0.0128, 14.2551], + [55.4792, -0.0013, 14.7754] + ] + } + }, + "dispense": { + "default": { + "1": [ + [1.9733, 2.7039, 5.1258], + [2.88, 1.0915, 8.3077], + [3.7642, 0.5906, 9.7502], + [4.9783, 1.0072, 8.1822], + [5.9342, 0.2998, 11.7038], + [6.8708, 0.1887, 12.3626], + [7.8092, 0.1497, 12.631], + [8.7525, 0.1275, 12.804], + [13.4575, 0.0741, 13.2718], + [22.8675, 0.0296, 13.87], + [37.0442, 0.0128, 14.2551], + [55.4792, -0.0013, 14.7754] + ] + } + }, + "defaultPushOutVolume": 7 + }, + "t200": { + "uiMaxFlowRate": 194, + "defaultAspirateFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultDispenseFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultBlowOutFlowRate": { + "default": 80, + "valuesByApiLevel": { "2.14": 80 } + }, + "defaultFlowAcceleration": 16000.0, + "defaultTipLength": 58.35, + "defaultReturnTipHeight": 0.2, + "aspirate": { + "default": { + "1": [ + [1.9331, 3.4604, 3.5588], + [2.9808, 1.5307, 7.2892], + [3.9869, 0.825, 9.3926], + [4.9762, 0.5141, 10.6323], + [5.9431, 0.3232, 11.5819], + [6.9223, 0.2644, 11.9317], + [7.8877, 0.1832, 12.4935], + [8.8562, 0.1512, 12.7463], + [47.7169, 0.0281, 13.836], + [95.63, 0.0007, 15.147], + [211.1169, 0.0005, 15.1655] + ] + } + }, + "dispense": { + "default": { + "1": [ + [1.9331, 3.4604, 3.5588], + [2.9808, 1.5307, 7.2892], + [3.9869, 0.825, 9.3926], + [4.9762, 0.5141, 10.6323], + [5.9431, 0.3232, 11.5819], + [6.9223, 0.2644, 11.9317], + [7.8877, 0.1832, 12.4935], + [8.8562, 0.1512, 12.7463], + [47.7169, 0.0281, 13.836], + [95.63, 0.0007, 15.147], + [211.1169, 0.0005, 15.1655] + ] + } + }, + "defaultPushOutVolume": 5 + } + }, + "maxVolume": 200, + "minVolume": 1, + "defaultTipracks": [ + "opentrons/opentrons_flex_96_tiprack_200ul/1", + "opentrons/opentrons_flex_96_tiprack_50ul/1", + "opentrons/opentrons_flex_96_filtertiprack_200ul/1", + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" + ] +} From 5b0c7f4ba8bc74f72f0da2b9021787ae09ce7ef8 Mon Sep 17 00:00:00 2001 From: connected-znaim <60662281+connected-znaim@users.noreply.github.com> Date: Tue, 12 Nov 2024 11:25:21 -0500 Subject: [PATCH 32/49] feat(opentrons-ai-client & opentrons-ai-server): Feedback api (#16761) # Overview Added an API to take in feedback from the AI Client. Also added a spinner that changes between values every 5 seconds. ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment --------- Co-authored-by: FELIPE BELGINE --- .../localization/en/protocol_generator.json | 4 ++ .../src/atoms/SendButton/index.tsx | 47 ++++++++++++++-- .../src/molecules/FeedbackModal/index.tsx | 53 +++++++++++++++++-- .../src/resources/constants.ts | 5 ++ opentrons-ai-server/api/handler/fast.py | 32 +++++++++++ .../api/models/feedback_response.py | 6 +++ opentrons-ai-server/tests/helpers/client.py | 5 ++ opentrons-ai-server/tests/test_live.py | 9 ++++ 8 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 opentrons-ai-server/api/models/feedback_response.py diff --git a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json index e321b939ade..94b1afae702 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -43,6 +43,10 @@ "pcr": "PCR", "pipettes": "Pipettes: Specify your pipettes, including the volume, number of channels, and whether they’re mounted on the left or right.", "privacy_policy": "By continuing, you agree to the Opentrons Privacy Policy and End user license agreement", + "progressFinalizing": "Finalizing...", + "progressGenerating": "Generating...", + "progressInitializing": "Initializing...", + "progressProcessing": "Processing...", "protocol_file": "Protocol file", "provide_details_of_changes": "Provide details of changes you want to make", "python_file_type_error": "Python file type required", diff --git a/opentrons-ai-client/src/atoms/SendButton/index.tsx b/opentrons-ai-client/src/atoms/SendButton/index.tsx index 2a0079d21d6..ed4128e56ca 100644 --- a/opentrons-ai-client/src/atoms/SendButton/index.tsx +++ b/opentrons-ai-client/src/atoms/SendButton/index.tsx @@ -7,8 +7,11 @@ import { COLORS, DISPLAY_FLEX, Icon, - JUSTIFY_CENTER, + JUSTIFY_SPACE_AROUND, + StyledText, } from '@opentrons/components' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' interface SendButtonProps { handleClick: () => void @@ -21,6 +24,15 @@ export function SendButton({ disabled = false, isLoading = false, }: SendButtonProps): JSX.Element { + const { t } = useTranslation('protocol_generator') + + const progressTexts = [ + t('progressInitializing'), + t('progressProcessing'), + t('progressGenerating'), + t('progressFinalizing'), + ] + const playButtonStyle = css` -webkit-tap-highlight-color: transparent; &:focus { @@ -47,20 +59,47 @@ export function SendButton({ color: ${COLORS.grey50}; } ` + + const [buttonText, setButtonText] = useState(progressTexts[0]) + const [, setProgressIndex] = useState(0) + + useEffect(() => { + if (isLoading) { + const interval = setInterval(() => { + setProgressIndex(prevIndex => { + const newIndex = (prevIndex + 1) % progressTexts.length + setButtonText(progressTexts[newIndex]) + return newIndex + }) + }, 5000) + + return () => { + setProgressIndex(0) + clearInterval(interval) + } + } + }, [isLoading]) + return ( + {isLoading ? ( + + {buttonText} + + ) : null} ('') const [, setShowFeedbackModal] = useAtom(feedbackModalAtom) + const [token] = useAtom(tokenAtom) + const { callApi } = useApiCall() + + const handleSendFeedback = async (): Promise => { + try { + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + } + + const getEndpoint = (): string => { + switch (process.env.NODE_ENV) { + case 'production': + return PROD_FEEDBACK_END_POINT + case 'development': + return LOCAL_FEEDBACK_END_POINT + default: + return STAGING_FEEDBACK_END_POINT + } + } + + const url = getEndpoint() + + const config = { + url, + method: 'POST', + headers, + data: { + feedbackText: feedbackValue, + fake: false, + }, + } + await callApi(config as AxiosRequestConfig) + setShowFeedbackModal(false) + } catch (err: any) { + console.error(`error: ${err.message}`) + throw err + } + } return ( { - setShowFeedbackModal(false) + disabled={feedbackValue === ''} + onClick={async () => { + await handleSendFeedback() }} > diff --git a/opentrons-ai-client/src/resources/constants.ts b/opentrons-ai-client/src/resources/constants.ts index c5e2f8826c6..83263524760 100644 --- a/opentrons-ai-client/src/resources/constants.ts +++ b/opentrons-ai-client/src/resources/constants.ts @@ -1,7 +1,10 @@ // ToDo (kk:05/29/2024) this should be switched by env var export const STAGING_END_POINT = 'https://staging.opentrons.ai/api/chat/completion' +export const STAGING_FEEDBACK_END_POINT = + 'https://staging.opentrons.ai/api/chat/feedback' export const PROD_END_POINT = 'https://opentrons.ai/api/chat/completion' +export const PROD_FEEDBACK_END_POINT = 'https://opentrons.ai/api/chat/feedback' // auth0 domain export const AUTH0_DOMAIN = 'identity.auth.opentrons.com' @@ -19,5 +22,7 @@ export const LOCAL_AUTH0_CLIENT_ID = 'PcuD1wEutfijyglNeRBi41oxsKJ1HtKw' export const LOCAL_AUTH0_AUDIENCE = 'sandbox-ai-api' export const LOCAL_AUTH0_DOMAIN = 'identity.auth-dev.opentrons.com' export const LOCAL_END_POINT = 'http://localhost:8000/api/chat/completion' +export const LOCAL_FEEDBACK_END_POINT = + 'http://localhost:8000/api/chat/feedback' export const CLIENT_MAX_WIDTH = '1440px' diff --git a/opentrons-ai-server/api/handler/fast.py b/opentrons-ai-server/api/handler/fast.py index 9f7c2f966b7..ad25441bc06 100644 --- a/opentrons-ai-server/api/handler/fast.py +++ b/opentrons-ai-server/api/handler/fast.py @@ -24,6 +24,7 @@ from api.models.chat_request import ChatRequest from api.models.chat_response import ChatResponse from api.models.empty_request_error import EmptyRequestError +from api.models.feedback_response import FeedbackResponse from api.models.internal_server_error import InternalServerError from api.settings import Settings @@ -249,6 +250,37 @@ async def redoc_html() -> HTMLResponse: return get_redoc_html(openapi_url="/api/openapi.json", title="Opentrons API Documentation") +@app.post( + "/api/chat/feedback", + response_model=Union[FeedbackResponse, ErrorResponse], + summary="Feedback", + description="Send feedback to the team.", +) +async def feedback(request: Request, auth_result: Any = Security(auth.verify)) -> FeedbackResponse: # noqa: B008 + """ + Send feedback to the team. + + - **request**: The HTTP request containing the feedback message. + - **returns**: A feedback response or an error message. + """ + logger.info("POST /api/feedback") + try: + body = await request.json() + if "feedbackText" not in body.keys() or body["feedbackText"] == "": + logger.info("Feedback empty") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=EmptyRequestError(message="Request body is empty")) + logger.info(f"Feedback received: {body}") + feedbackText = body["feedbackText"] + # todo: Store feedback text in a database + return FeedbackResponse(reply=f"Feedback Received: {feedbackText}", fake=False) + + except Exception as e: + logger.exception("Error processing feedback") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=InternalServerError(exception_object=e).model_dump() + ) from e + + @app.get("/api/doc", include_in_schema=False) async def swagger_html() -> HTMLResponse: return get_swagger_ui_html(openapi_url="/api/openapi.json", title="Opentrons API Documentation") diff --git a/opentrons-ai-server/api/models/feedback_response.py b/opentrons-ai-server/api/models/feedback_response.py new file mode 100644 index 00000000000..80e335871c3 --- /dev/null +++ b/opentrons-ai-server/api/models/feedback_response.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class FeedbackResponse(BaseModel): + reply: str + fake: bool diff --git a/opentrons-ai-server/tests/helpers/client.py b/opentrons-ai-server/tests/helpers/client.py index e238ccad705..7c0d2383ffd 100644 --- a/opentrons-ai-server/tests/helpers/client.py +++ b/opentrons-ai-server/tests/helpers/client.py @@ -68,6 +68,11 @@ def get_chat_completion(self, message: str, fake: bool = True, fake_key: Optiona headers = self.standard_headers if not bad_auth else self.invalid_auth_headers return self.httpx.post("/chat/completion", headers=headers, json=request.model_dump()) + def get_feedback(self, message: str, fake: bool = True) -> Response: + """Call the /chat/feedback endpoint and return the response.""" + request = f'{"feedbackText": "{message}"}' + return self.httpx.post("/chat/feedback", headers=self.standard_headers, json=request) + def get_bad_endpoint(self, bad_auth: bool = False) -> Response: """Call nonexistent endpoint and return the response.""" headers = self.standard_headers if not bad_auth else self.invalid_auth_headers diff --git a/opentrons-ai-server/tests/test_live.py b/opentrons-ai-server/tests/test_live.py index beb3c1b483c..ce22f4ff405 100644 --- a/opentrons-ai-server/tests/test_live.py +++ b/opentrons-ai-server/tests/test_live.py @@ -1,5 +1,6 @@ import pytest from api.models.chat_response import ChatResponse +from api.models.feedback_response import FeedbackResponse from tests.helpers.client import Client @@ -26,6 +27,14 @@ def test_get_chat_completion_bad_auth(client: Client) -> None: assert response.status_code == 401, "Chat completion with bad auth should return HTTP 401" +@pytest.mark.live +def test_get_feedback_good_auth(client: Client) -> None: + """Test the feedback endpoint with good authentication.""" + response = client.get_feedback("How do I load tipracks for my 8 channel pipette on an OT2?", fake=True) + assert response.status_code == 200, "Feedback with good auth should return HTTP 200" + FeedbackResponse.model_validate(response.json()) + + @pytest.mark.live def test_get_bad_endpoint_with_good_auth(client: Client) -> None: """Test a nonexistent endpoint with good authentication.""" From 7bc46d384578104baece757e8eb77c22a638cf82 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Tue, 12 Nov 2024 11:30:14 -0500 Subject: [PATCH 33/49] fix(api): Update Plate Reader CSV output to match OEM file output (#16751) Covers RQA-3462 Update plate reader CSV output to closely match OEM output file naming and data structuring --- .../protocol_engine/resources/file_provider.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/api/src/opentrons/protocol_engine/resources/file_provider.py b/api/src/opentrons/protocol_engine/resources/file_provider.py index e1299605e76..a224e15a1b7 100644 --- a/api/src/opentrons/protocol_engine/resources/file_provider.py +++ b/api/src/opentrons/protocol_engine/resources/file_provider.py @@ -66,7 +66,7 @@ def build_generic_csv( # noqa: C901 row.append(str(measurement.data[f"{plate_alpharows[i]}{j+1}"])) rows.append(row) for i in range(3): - rows.append([""]) + rows.append([]) rows.append(["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]) for i in range(8): row = [plate_alpharows[i]] @@ -74,7 +74,7 @@ def build_generic_csv( # noqa: C901 row.append("") rows.append(row) for i in range(3): - rows.append([""]) + rows.append([]) rows.append( [ "", @@ -86,7 +86,7 @@ def build_generic_csv( # noqa: C901 ] ) for i in range(3): - rows.append([""]) + rows.append([]) rows.append( [ "", @@ -100,7 +100,7 @@ def build_generic_csv( # noqa: C901 ) rows.append(["1", "Sample 1", "", "", "", "1", "", "", "", "", "", ""]) for i in range(3): - rows.append([""]) + rows.append([]) # end of file metadata rows.append(["Protocol"]) @@ -109,13 +109,17 @@ def build_generic_csv( # noqa: C901 if self.reference_wavelength is not None: rows.append(["Reference Wavelength (nm)", str(self.reference_wavelength)]) rows.append(["Serial No.", self.serial_number]) - rows.append(["Measurement started at", str(self.start_time)]) - rows.append(["Measurement finished at", str(self.finish_time)]) + rows.append( + ["Measurement started at", self.start_time.strftime("%m %d %H:%M:%S %Y")] + ) + rows.append( + ["Measurement finished at", self.finish_time.strftime("%m %d %H:%M:%S %Y")] + ) # Ensure the filename adheres to ruleset contains the wavelength for a given measurement if filename.endswith(".csv"): filename = filename[:-4] - filename = filename + "_" + str(measurement.wavelength) + ".csv" + filename = filename + str(measurement.wavelength) + "nm.csv" return GenericCsvTransform.build( filename=filename, From 60dca5409a9313d952557f8e995828581d1a844d Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Tue, 12 Nov 2024 11:52:02 -0500 Subject: [PATCH 34/49] fix(api): Do not load the absorbance reader lid with `loadLabware` (#16734) --- .../core/engine/pipette_movement_conflict.py | 6 +- .../protocol_api/core/engine/protocol.py | 30 ---- .../opentrons/protocol_api/module_contexts.py | 13 ++ .../protocol_api/protocol_context.py | 3 +- .../protocol_engine/actions/__init__.py | 2 - .../protocol_engine/actions/actions.py | 12 -- .../protocol_engine/clients/sync_client.py | 6 - .../commands/absorbance_reader/close_lid.py | 45 ++---- .../commands/absorbance_reader/open_lid.py | 42 ++---- .../protocol_engine/commands/load_module.py | 39 ------ .../commands/unsafe/unsafe_place_labware.py | 56 ++------ .../protocol_engine/create_protocol_engine.py | 19 ++- .../execution/labware_movement.py | 90 +++++++++--- .../protocol_engine/protocol_engine.py | 7 - .../resources/deck_data_provider.py | 39 ------ .../protocol_engine/state/geometry.py | 81 ++++++----- .../protocol_engine/state/labware.py | 112 ++++++++++----- .../absorbance_reader_substate.py | 4 +- .../protocol_engine/state/modules.py | 122 +++++++---------- .../protocol_engine/state/update_types.py | 16 +++ .../engine/test_absorbance_reader_core.py | 1 - .../core/engine/test_deck_conflict.py | 12 +- .../protocol_api_integration/test_modules.py | 3 +- .../test_labware_movement_handler.py | 41 ++++-- .../resources/test_deck_data_provider.py | 55 -------- .../state/test_geometry_view.py | 128 ++++++++++-------- .../state/test_labware_view.py | 76 +++-------- .../protocol_engine/state/test_module_view.py | 4 +- .../deck/definitions/5/ot3_standard.json | 24 ---- 29 files changed, 473 insertions(+), 615 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py index d0091768be8..6433c638190 100644 --- a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py @@ -287,8 +287,10 @@ def check_safe_for_tip_pickup_and_return( is_96_ch_tiprack_adapter = engine_state.labware.get_has_quirk( labware_id=tiprack_parent.labwareId, quirk="tiprackAdapterFor96Channel" ) - tiprack_height = engine_state.labware.get_dimensions(labware_id).z - adapter_height = engine_state.labware.get_dimensions(tiprack_parent.labwareId).z + tiprack_height = engine_state.labware.get_dimensions(labware_id=labware_id).z + adapter_height = engine_state.labware.get_dimensions( + labware_id=tiprack_parent.labwareId + ).z if is_partial_config and tiprack_height < adapter_height: raise PartialTipMovementNotAllowedError( f"{tiprack_name} cannot be on an adapter taller than the tip rack" diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index e4decd7318c..7c681b232a5 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -448,40 +448,10 @@ def load_module( existing_module_ids=list(self._module_cores_by_id.keys()), ) - # When the protocol engine is created, we add Module Lids as part of the deck fixed labware - # If a valid module exists in the deck config. For analysis, we add the labware here since - # deck fixed labware is not created under the same conditions. We also need to inject the Module - # lids when the module isnt already on the deck config, like when adding a new - # module during a protocol setup. - self._load_virtual_module_lid(module_core) - self._module_cores_by_id[module_core.module_id] = module_core return module_core - def _load_virtual_module_lid( - self, module_core: Union[ModuleCore, NonConnectedModuleCore] - ) -> None: - if isinstance(module_core, AbsorbanceReaderCore): - substate = self._engine_client.state.modules.get_absorbance_reader_substate( - module_core.module_id - ) - if substate.lid_id is None: - lid = self._engine_client.execute_command_without_recovery( - cmd.LoadLabwareParams( - loadName="opentrons_flex_lid_absorbance_plate_reader_module", - location=ModuleLocation(moduleId=module_core.module_id), - namespace="opentrons", - version=1, - displayName="Absorbance Reader Lid", - ) - ) - - self._engine_client.add_absorbance_reader_lid( - module_id=module_core.module_id, - lid_id=lid.labwareId, - ) - def _create_non_connected_module_core( self, load_module_result: LoadModuleResult ) -> NonConnectedModuleCore: diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index 9ae550f8d3f..7beab69c53f 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -3,6 +3,8 @@ import logging from typing import List, Dict, Optional, Union, cast +from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated + from opentrons.protocol_engine.types import ABSMeasureMode from opentrons_shared_data.labware.types import LabwareDefinition from opentrons_shared_data.module.types import ModuleModel, ModuleType @@ -159,7 +161,18 @@ def load_labware( load_location = loaded_adapter._core else: load_location = self._core + name = validation.ensure_lowercase_name(name) + + # todo(mm, 2024-11-08): This check belongs in opentrons.protocol_api.core.engine.deck_conflict. + # We're currently doing it here, at the ModuleContext level, for consistency with what + # ProtocolContext.load_labware() does. (It should also be moved to the deck_conflict module.) + if isinstance(self._core, AbsorbanceReaderCore): + if self._core.is_lid_on(): + raise CommandPreconditionViolated( + f"Cannot load {name} onto the Absorbance Reader Module when its lid is closed." + ) + labware_core = self._protocol_core.load_labware( load_name=name, label=label, diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index ed7d24f4d3f..840edba5081 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -714,7 +714,8 @@ def move_labware( f"Expected labware of type 'Labware' but got {type(labware)}." ) - # Ensure that when moving to an absorbance reader than the lid is open + # Ensure that when moving to an absorbance reader that the lid is open + # todo(mm, 2024-11-08): Unify this with opentrons.protocol_api.core.engine.deck_conflict. if isinstance(new_location, AbsorbanceReaderContext): if new_location.is_lid_on(): raise CommandPreconditionViolated( diff --git a/api/src/opentrons/protocol_engine/actions/__init__.py b/api/src/opentrons/protocol_engine/actions/__init__.py index 26dfb0df8e0..6d7125cc83e 100644 --- a/api/src/opentrons/protocol_engine/actions/__init__.py +++ b/api/src/opentrons/protocol_engine/actions/__init__.py @@ -28,7 +28,6 @@ DoorChangeAction, ResetTipsAction, SetPipetteMovementSpeedAction, - AddAbsorbanceReaderLidAction, ) from .get_state_update import get_state_updates @@ -58,7 +57,6 @@ "DoorChangeAction", "ResetTipsAction", "SetPipetteMovementSpeedAction", - "AddAbsorbanceReaderLidAction", # action payload values "PauseSource", "FinishErrorDetails", diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index 6260a6d4614..15b04048699 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -271,17 +271,6 @@ class SetPipetteMovementSpeedAction: speed: Optional[float] -@dataclasses.dataclass(frozen=True) -class AddAbsorbanceReaderLidAction: - """Add the absorbance reader lid id to the absorbance reader module substate. - - This action is dispatched the absorbance reader module is first loaded. - """ - - module_id: str - lid_id: str - - @dataclasses.dataclass(frozen=True) class SetErrorRecoveryPolicyAction: """See `ProtocolEngine.set_error_recovery_policy()`.""" @@ -309,6 +298,5 @@ class SetErrorRecoveryPolicyAction: AddLiquidAction, ResetTipsAction, SetPipetteMovementSpeedAction, - AddAbsorbanceReaderLidAction, SetErrorRecoveryPolicyAction, ] diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index d0c21846d19..3460c13d463 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -119,12 +119,6 @@ def add_addressable_area(self, addressable_area_name: str) -> None: "add_addressable_area", addressable_area_name=addressable_area_name ) - def add_absorbance_reader_lid(self, module_id: str, lid_id: str) -> None: - """Add an absorbance reader lid to the module state.""" - self._transport.call_method( - "add_absorbance_reader_lid", module_id=module_id, lid_id=lid_id - ) - def add_liquid( self, name: str, color: Optional[str], description: Optional[str] ) -> Liquid: diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py index b608f6cf5f9..069c2803b22 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/close_lid.py @@ -10,7 +10,6 @@ from ...errors import CannotPerformModuleAction from opentrons.protocol_engine.types import AddressableAreaLocation -from opentrons.protocol_engine.resources import labware_validation from ...state.update_types import StateUpdate @@ -53,41 +52,35 @@ def __init__( async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: """Execute the close lid command.""" + state_update = StateUpdate() mod_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId ) - # lid should currently be on the module - assert mod_substate.lid_id is not None - loaded_lid = self._state_view.labware.get(mod_substate.lid_id) - assert labware_validation.is_absorbance_reader_lid(loaded_lid.loadName) - hardware_lid_status = AbsorbanceReaderLidStatus.OFF - # If the lid is closed, if the lid is open No-op out if not self._state_view.config.use_virtual_modules: abs_reader = self._equipment.get_module_hardware_api(mod_substate.module_id) if abs_reader is not None: - result = await abs_reader.get_current_lid_status() - hardware_lid_status = result + hardware_lid_status = await abs_reader.get_current_lid_status() else: raise CannotPerformModuleAction( "Could not reach the Hardware API for Opentrons Plate Reader Module." ) - # If the lid is already ON, no-op losing lid if hardware_lid_status is AbsorbanceReaderLidStatus.ON: - # The lid is already On, so we can no-op and return the lids current location data - assert isinstance(loaded_lid.location, AddressableAreaLocation) - new_location = loaded_lid.location - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=loaded_lid.definitionUri, - labware_location=loaded_lid.location, + # The lid is already physically ON, so we can no-op physically closing it + state_update.set_absorbance_reader_lid( + module_id=mod_substate.module_id, is_lid_on=True ) else: # Allow propagation of ModuleNotAttachedError. _ = self._equipment.get_module_hardware_api(mod_substate.module_id) + lid_definition = ( + self._state_view.labware.get_absorbance_reader_lid_definition() + ) + current_location = self._state_view.modules.absorbance_reader_dock_location( params.moduleId ) @@ -110,34 +103,26 @@ async def execute(self, params: CloseLidParams) -> SuccessData[CloseLidResult]: # The lid's labware definition stores gripper offsets for itself in the # space normally meant for offsets for labware stacked atop it. lid_gripper_offsets = self._state_view.labware.get_child_gripper_offsets( - loaded_lid.id, None + labware_definition=lid_definition, + slot_name=None, ) if lid_gripper_offsets is None: raise ValueError( "Gripper Offset values for Absorbance Reader Lid labware must not be None." ) - # Skips gripper moves when using virtual gripper await self._labware_movement.move_labware_with_gripper( - labware_id=loaded_lid.id, + labware_definition=lid_definition, current_location=current_location, new_location=new_location, user_offset_data=lid_gripper_offsets, post_drop_slide_offset=None, ) - - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=loaded_lid.definitionUri, - labware_location=new_location, + state_update.set_absorbance_reader_lid( + module_id=mod_substate.module_id, + is_lid_on=True, ) - state_update = StateUpdate() - state_update.set_labware_location( - labware_id=loaded_lid.id, - new_location=new_location, - new_offset_id=new_offset_id, - ) - return SuccessData( public=CloseLidResult(), state_update=state_update, diff --git a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py index 8e59fcd3ee0..1ad56413f9a 100644 --- a/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +++ b/api/src/opentrons/protocol_engine/commands/absorbance_reader/open_lid.py @@ -9,7 +9,6 @@ from ...errors.error_occurrence import ErrorOccurrence from ...errors import CannotPerformModuleAction -from opentrons.protocol_engine.resources import labware_validation from opentrons.protocol_engine.types import AddressableAreaLocation from opentrons.drivers.types import AbsorbanceReaderLidStatus @@ -54,39 +53,35 @@ def __init__( async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: """Move the absorbance reader lid from the module to the lid dock.""" + state_update = StateUpdate() mod_substate = self._state_view.modules.get_absorbance_reader_substate( module_id=params.moduleId ) - # lid should currently be on the module - assert mod_substate.lid_id is not None - loaded_lid = self._state_view.labware.get(mod_substate.lid_id) - assert labware_validation.is_absorbance_reader_lid(loaded_lid.loadName) hardware_lid_status = AbsorbanceReaderLidStatus.ON - # If the lid is closed, if the lid is open No-op out if not self._state_view.config.use_virtual_modules: abs_reader = self._equipment.get_module_hardware_api(mod_substate.module_id) if abs_reader is not None: - result = await abs_reader.get_current_lid_status() - hardware_lid_status = result + hardware_lid_status = await abs_reader.get_current_lid_status() else: raise CannotPerformModuleAction( "Could not reach the Hardware API for Opentrons Plate Reader Module." ) - # If the lid is already OFF, no-op the lid removal if hardware_lid_status is AbsorbanceReaderLidStatus.OFF: - assert isinstance(loaded_lid.location, AddressableAreaLocation) - new_location = loaded_lid.location - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=loaded_lid.definitionUri, - labware_location=loaded_lid.location, + # The lid is already physically OFF, so we can no-op physically closing it + state_update.set_absorbance_reader_lid( + module_id=mod_substate.module_id, is_lid_on=False ) else: # Allow propagation of ModuleNotAttachedError. _ = self._equipment.get_module_hardware_api(mod_substate.module_id) + lid_definition = ( + self._state_view.labware.get_absorbance_reader_lid_definition() + ) + absorbance_model = self._state_view.modules.get_requested_model( params.moduleId ) @@ -109,34 +104,25 @@ async def execute(self, params: OpenLidParams) -> SuccessData[OpenLidResult]: # The lid's labware definition stores gripper offsets for itself in the # space normally meant for offsets for labware stacked atop it. lid_gripper_offsets = self._state_view.labware.get_child_gripper_offsets( - loaded_lid.id, None + labware_definition=lid_definition, + slot_name=None, ) if lid_gripper_offsets is None: raise ValueError( "Gripper Offset values for Absorbance Reader Lid labware must not be None." ) - # Skips gripper moves when using virtual gripper await self._labware_movement.move_labware_with_gripper( - labware_id=loaded_lid.id, + labware_definition=lid_definition, current_location=current_location, new_location=new_location, user_offset_data=lid_gripper_offsets, post_drop_slide_offset=None, ) - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=loaded_lid.definitionUri, - labware_location=new_location, + state_update.set_absorbance_reader_lid( + module_id=mod_substate.module_id, is_lid_on=False ) - state_update = StateUpdate() - - state_update.set_labware_location( - labware_id=loaded_lid.id, - new_location=new_location, - new_offset_id=new_offset_id, - ) - return SuccessData( public=OpenLidResult(), state_update=state_update, diff --git a/api/src/opentrons/protocol_engine/commands/load_module.py b/api/src/opentrons/protocol_engine/commands/load_module.py index 9560f4931c3..a44212f9bf5 100644 --- a/api/src/opentrons/protocol_engine/commands/load_module.py +++ b/api/src/opentrons/protocol_engine/commands/load_module.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, Field from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData -from ..errors import ModuleNotLoadedError from ..errors.error_occurrence import ErrorOccurrence from ..types import ( DeckSlotLocation, @@ -17,7 +16,6 @@ from opentrons.protocol_engine.resources import deck_configuration_provider -from opentrons.drivers.types import AbsorbanceReaderLidStatus if TYPE_CHECKING: from ..state.state import StateView @@ -152,43 +150,6 @@ async def execute(self, params: LoadModuleParams) -> SuccessData[LoadModuleResul module_id=params.moduleId, ) - # Handle lid position update for loaded Plate Reader module on deck - if ( - not self._state_view.config.use_virtual_modules - and params.model == ModuleModel.ABSORBANCE_READER_V1 - and params.moduleId is not None - ): - try: - abs_reader = self._equipment.get_module_hardware_api( - self._state_view.modules.get_absorbance_reader_substate( - params.moduleId - ).module_id - ) - except ModuleNotLoadedError: - abs_reader = None - - if abs_reader is not None: - result = await abs_reader.get_current_lid_status() - if ( - isinstance(result, AbsorbanceReaderLidStatus) - and result is not AbsorbanceReaderLidStatus.ON - ): - reader_area = self._state_view.modules.ensure_and_convert_module_fixture_location( - params.location.slotName, - self._state_view.config.deck_type, - params.model, - ) - lid_labware = self._state_view.labware.get_by_addressable_area( - reader_area - ) - - if lid_labware is not None: - self._state_view.labware._state.labware_by_id[ - lid_labware.id - ].location = self._state_view.modules.absorbance_reader_dock_location( - params.moduleId - ) - return SuccessData( public=LoadModuleResult( moduleId=loaded_module.module_id, diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py index e6cc7217ba1..aa11555954d 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py @@ -1,11 +1,13 @@ """Place labware payload, result, and implementaiton.""" from __future__ import annotations -from pydantic import BaseModel, Field from typing import TYPE_CHECKING, Optional, Type from typing_extensions import Literal -from opentrons.calibration_storage.helpers import details_from_uri +from opentrons_shared_data.labware.types import LabwareUri +from opentrons_shared_data.labware.labware_definition import LabwareDefinition +from pydantic import BaseModel, Field + from opentrons.hardware_control.types import Axis, OT3Mount from opentrons.motion_planning.waypoints import get_gripper_labware_placement_waypoints from opentrons.protocol_engine.errors.exceptions import ( @@ -16,14 +18,12 @@ from ...types import ( DeckSlotLocation, - LoadedLabware, ModuleModel, OnDeckLabwareLocation, ) from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ...errors.error_occurrence import ErrorOccurrence from ...resources import ensure_ot3_hardware -from ...state.update_types import StateUpdate from opentrons.hardware_control import HardwareControlAPI, OT3HardwareControlAPI @@ -94,36 +94,17 @@ async def execute( params.location, ) - # TODO: We need a way to create temporary labware for moving around, - # the labware should get deleted once its used. - details = details_from_uri(params.labwareURI) - labware = await self._equipment.load_labware( - load_name=details.load_name, - namespace=details.namespace, - version=details.version, - location=location, - labware_id=None, - ) - - self._state_view.labware._state.definitions_by_uri[ - params.labwareURI - ] = labware.definition - self._state_view.labware._state.labware_by_id[ - labware.labware_id - ] = LoadedLabware.construct( - id=labware.labware_id, - location=location, - loadName=labware.definition.parameters.loadName, - definitionUri=params.labwareURI, - offsetId=labware.offsetId, + definition = self._state_view.labware.get_definition_by_uri( + # todo(mm, 2024-11-07): This is an unsafe cast from untrusted input. + # We need a str -> LabwareUri parse/validate function. + LabwareUri(params.labwareURI) ) - labware_id = labware.labware_id # todo(mm, 2024-11-06): This is only correct in the special case of an # absorbance reader lid. Its definition currently puts the offsets for *itself* # in the property that's normally meant for offsets for its *children.* final_offsets = self._state_view.labware.get_child_gripper_offsets( - labware_id, None + labware_definition=definition, slot_name=None ) drop_offset = ( Point( @@ -148,30 +129,19 @@ async def execute( module.id ) - new_offset_id = self._equipment.find_applicable_labware_offset_id( - labware_definition_uri=params.labwareURI, - labware_location=location, - ) - # NOTE: When the estop is pressed, the gantry loses position, # so the robot needs to home x, y to sync. await ot3api.home(axes=[Axis.Z_L, Axis.Z_R, Axis.Z_G, Axis.X, Axis.Y]) - state_update = StateUpdate() # Place the labware down - await self._start_movement(ot3api, labware_id, location, drop_offset) + await self._start_movement(ot3api, definition, location, drop_offset) - state_update.set_labware_location( - labware_id=labware_id, - new_location=location, - new_offset_id=new_offset_id, - ) - return SuccessData(public=UnsafePlaceLabwareResult(), state_update=state_update) + return SuccessData(public=UnsafePlaceLabwareResult()) async def _start_movement( self, ot3api: OT3HardwareControlAPI, - labware_id: str, + labware_definition: LabwareDefinition, location: OnDeckLabwareLocation, drop_offset: Optional[Point], ) -> None: @@ -181,7 +151,7 @@ async def _start_movement( ) to_labware_center = self._state_view.geometry.get_labware_grip_point( - labware_id=labware_id, location=location + labware_definition=labware_definition, location=location ) movement_waypoints = get_gripper_labware_placement_waypoints( diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index 372972c1f50..5c21c70efef 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -8,6 +8,9 @@ from opentrons.protocol_engine.execution.error_recovery_hardware_state_synchronizer import ( ErrorRecoveryHardwareStateSynchronizer, ) +from opentrons.protocol_engine.resources.labware_data_provider import ( + LabwareDataProvider, +) from opentrons.util.async_helpers import async_context_manager_in_thread from opentrons_shared_data.robot import load as load_robot @@ -81,7 +84,7 @@ async def create_protocol_engine( module_data_provider = ModuleDataProvider() file_provider = file_provider or FileProvider() - return ProtocolEngine( + pe = ProtocolEngine( hardware_api=hardware_api, state_store=state_store, action_dispatcher=action_dispatcher, @@ -93,6 +96,20 @@ async def create_protocol_engine( file_provider=file_provider, ) + # todo(mm, 2024-11-08): This is a quick hack to support the absorbance reader, which + # expects the engine to have this special labware definition available. It would be + # cleaner for the `loadModule` command to do this I/O and insert the definition + # into state. That gets easier after https://opentrons.atlassian.net/browse/EXEC-756. + # + # NOTE: This needs to stay in sync with LabwareView.get_absorbance_reader_lid_definition(). + pe.add_labware_definition( + await LabwareDataProvider().get_labware_definition( + "opentrons_flex_lid_absorbance_plate_reader_module", "opentrons", 1 + ) + ) + + return pe + @contextlib.contextmanager def create_protocol_engine_in_thread( diff --git a/api/src/opentrons/protocol_engine/execution/labware_movement.py b/api/src/opentrons/protocol_engine/execution/labware_movement.py index 8ede6f6085b..77de449c058 100644 --- a/api/src/opentrons/protocol_engine/execution/labware_movement.py +++ b/api/src/opentrons/protocol_engine/execution/labware_movement.py @@ -1,7 +1,9 @@ """Labware movement command handling.""" from __future__ import annotations -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, overload + +from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons.types import Point @@ -79,24 +81,64 @@ def __init__( ) ) + @overload async def move_labware_with_gripper( self, + *, labware_id: str, current_location: OnDeckLabwareLocation, new_location: OnDeckLabwareLocation, user_offset_data: LabwareMovementOffsetData, post_drop_slide_offset: Optional[Point], ) -> None: - """Move a loaded labware from one location to another using gripper.""" + ... + + @overload + async def move_labware_with_gripper( + self, + *, + labware_definition: LabwareDefinition, + current_location: OnDeckLabwareLocation, + new_location: OnDeckLabwareLocation, + user_offset_data: LabwareMovementOffsetData, + post_drop_slide_offset: Optional[Point], + ) -> None: + ... + + async def move_labware_with_gripper( # noqa: C901 + self, + *, + labware_id: str | None = None, + labware_definition: LabwareDefinition | None = None, + current_location: OnDeckLabwareLocation, + new_location: OnDeckLabwareLocation, + user_offset_data: LabwareMovementOffsetData, + post_drop_slide_offset: Optional[Point], + ) -> None: + """Physically move a labware from one location to another using the gripper. + + Generally, provide the `labware_id` of a loaded labware, and this method will + automatically look up its labware definition. If you're physically moving + something that has not been loaded as a labware (this is not common), + provide the `labware_definition` yourself instead. + """ use_virtual_gripper = self._state_store.config.use_virtual_gripper + if labware_definition is None: + assert labware_id is not None # From this method's @typing.overloads. + labware_definition = self._state_store.labware.get_definition(labware_id) + if use_virtual_gripper: - # During Analysis we will pass in hard coded estimates for certain positions only accessible during execution - self._state_store.geometry.check_gripper_labware_tip_collision( - gripper_homed_position_z=_GRIPPER_HOMED_POSITION_Z, - labware_id=labware_id, - current_location=current_location, - ) + # todo(mm, 2024-11-07): We should do this collision checking even when we + # only have a `labware_definition`, not a `labware_id`. Resolve when + # `check_gripper_labware_tip_collision()` can be made independent of `labware_id`. + if labware_id is not None: + self._state_store.geometry.check_gripper_labware_tip_collision( + # During Analysis we will pass in hard coded estimates for certain positions only accessible during execution + gripper_homed_position_z=_GRIPPER_HOMED_POSITION_Z, + labware_id=labware_id, + current_location=current_location, + ) return ot3api = ensure_ot3_hardware( @@ -119,14 +161,16 @@ async def move_labware_with_gripper( await ot3api.home(axes=[Axis.Z_L, Axis.Z_R, Axis.Z_G]) gripper_homed_position = await ot3api.gantry_position(mount=gripper_mount) - # Verify that no tip collisions will occur during the move - self._state_store.geometry.check_gripper_labware_tip_collision( - gripper_homed_position_z=gripper_homed_position.z, - labware_id=labware_id, - current_location=current_location, - ) + # todo(mm, 2024-11-07): We should do this collision checking even when we + # only have a `labware_definition`, not a `labware_id`. Resolve when + # `check_gripper_labware_tip_collision()` can be made independent of `labware_id`. + if labware_id is not None: + self._state_store.geometry.check_gripper_labware_tip_collision( + gripper_homed_position_z=gripper_homed_position.z, + labware_id=labware_id, + current_location=current_location, + ) - current_labware = self._state_store.labware.get_definition(labware_id) async with self._thermocycler_plate_lifter.lift_plate_for_labware_movement( labware_location=current_location ): @@ -135,14 +179,14 @@ async def move_labware_with_gripper( from_location=current_location, to_location=new_location, additional_offset_vector=user_offset_data, - current_labware=current_labware, + current_labware=labware_definition, ) ) from_labware_center = self._state_store.geometry.get_labware_grip_point( - labware_id=labware_id, location=current_location + labware_definition=labware_definition, location=current_location ) to_labware_center = self._state_store.geometry.get_labware_grip_point( - labware_id=labware_id, location=new_location + labware_definition=labware_definition, location=new_location ) movement_waypoints = get_gripper_labware_movement_waypoints( from_labware_center=from_labware_center, @@ -151,7 +195,9 @@ async def move_labware_with_gripper( offset_data=final_offsets, post_drop_slide_offset=post_drop_slide_offset, ) - labware_grip_force = self._state_store.labware.get_grip_force(labware_id) + labware_grip_force = self._state_store.labware.get_grip_force( + labware_definition + ) holding_labware = False for waypoint_data in movement_waypoints: if waypoint_data.jaw_open: @@ -174,9 +220,11 @@ async def move_labware_with_gripper( # should be holding labware if holding_labware: labware_bbox = self._state_store.labware.get_dimensions( - labware_id + labware_definition=labware_definition + ) + well_bbox = self._state_store.labware.get_well_bbox( + labware_definition=labware_definition ) - well_bbox = self._state_store.labware.get_well_bbox(labware_id) # todo(mm, 2024-09-26): This currently raises a lower-level 2015 FailedGripperPickupError. # Convert this to a higher-level 3001 LabwareDroppedError or 3002 LabwareNotPickedUpError, # depending on what waypoint we're at, to propagate a more specific error code to users. diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index ced32b20cc3..df9a00fe131 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -59,7 +59,6 @@ HardwareStoppedAction, ResetTipsAction, SetPipetteMovementSpeedAction, - AddAbsorbanceReaderLidAction, ) @@ -577,12 +576,6 @@ def add_addressable_area(self, addressable_area_name: str) -> None: AddAddressableAreaAction(addressable_area=area) ) - def add_absorbance_reader_lid(self, module_id: str, lid_id: str) -> None: - """Add an absorbance reader lid to the module state.""" - self._action_dispatcher.dispatch( - AddAbsorbanceReaderLidAction(module_id=module_id, lid_id=lid_id) - ) - def reset_tips(self, labware_id: str) -> None: """Reset the tip state of a given labware.""" # TODO(mm, 2023-03-10): Safely raise an error if the given labware isn't a diff --git a/api/src/opentrons/protocol_engine/resources/deck_data_provider.py b/api/src/opentrons/protocol_engine/resources/deck_data_provider.py index c67260a8001..72117c23075 100644 --- a/api/src/opentrons/protocol_engine/resources/deck_data_provider.py +++ b/api/src/opentrons/protocol_engine/resources/deck_data_provider.py @@ -17,11 +17,9 @@ DeckSlotLocation, DeckType, LabwareLocation, - AddressableAreaLocation, DeckConfigurationType, ) from .labware_data_provider import LabwareDataProvider -from ..resources import deck_configuration_provider @final @@ -71,43 +69,6 @@ async def get_deck_fixed_labware( slot = cast(Optional[str], fixture.get("slot")) if ( - deck_configuration is not None - and load_name is not None - and slot is not None - and slot not in DeckSlotName._value2member_map_ - ): - # The provided slot is likely to be an addressable area for Module-required labware Eg: Plate Reader Lid - for ( - cutout_id, - cutout_fixture_id, - opentrons_module_serial_number, - ) in deck_configuration: - provided_addressable_areas = ( - deck_configuration_provider.get_provided_addressable_area_names( - cutout_fixture_id=cutout_fixture_id, - cutout_id=cutout_id, - deck_definition=deck_definition, - ) - ) - if slot in provided_addressable_areas: - addressable_area_location = AddressableAreaLocation( - addressableAreaName=slot - ) - definition = await self._labware_data.get_labware_definition( - load_name=load_name, - namespace="opentrons", - version=1, - ) - - labware.append( - DeckFixedLabware( - labware_id=labware_id, - definition=definition, - location=addressable_area_location, - ) - ) - - elif ( load_fixed_trash and load_name is not None and slot is not None diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index c352b94320e..8120dc7a0ad 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -262,32 +262,33 @@ def get_min_travel_z( return min_travel_z def get_labware_parent_nominal_position(self, labware_id: str) -> Point: - """Get the position of the labware's uncalibrated parent slot (deck, module, or another labware).""" + """Get the position of the labware's uncalibrated parent (deck slot, module, or another labware).""" try: addressable_area_name = self.get_ancestor_slot_name(labware_id).id except errors.LocationIsStagingSlotError: addressable_area_name = self._get_staging_slot_name(labware_id) except errors.LocationIsLidDockSlotError: addressable_area_name = self._get_lid_dock_slot_name(labware_id) - slot_pos = self._addressable_areas.get_addressable_area_position( + parent_pos = self._addressable_areas.get_addressable_area_position( addressable_area_name ) - labware_data = self._labware.get(labware_id) - offset = self._get_labware_position_offset(labware_id, labware_data.location) + offset_from_parent = self._get_offset_from_parent( + child_definition=self._labware.get_definition(labware_id), + parent=self._labware.get(labware_id).location, + ) return Point( - slot_pos.x + offset.x, - slot_pos.y + offset.y, - slot_pos.z + offset.z, + parent_pos.x + offset_from_parent.x, + parent_pos.y + offset_from_parent.y, + parent_pos.z + offset_from_parent.z, ) - def _get_labware_position_offset( - self, labware_id: str, labware_location: LabwareLocation + def _get_offset_from_parent( + self, child_definition: LabwareDefinition, parent: LabwareLocation ) -> LabwareOffsetVector: - """Gets the offset vector of a labware on the given location. + """Gets the offset vector of a labware placed on the given location. - NOTE: Not to be confused with LPC offset. - For labware on Deck Slot: returns an offset of (0, 0, 0) - For labware on a Module: returns the nominal offset for the labware's position when placed on the specified module (using slot-transformed labwareOffset @@ -298,40 +299,42 @@ def _get_labware_position_offset( on modules as well as stacking overlaps. Does not include module calibration offset or LPC offset. """ - if isinstance(labware_location, (AddressableAreaLocation, DeckSlotLocation)): + if isinstance(parent, (AddressableAreaLocation, DeckSlotLocation)): return LabwareOffsetVector(x=0, y=0, z=0) - elif isinstance(labware_location, ModuleLocation): - module_id = labware_location.moduleId - module_offset = self._modules.get_nominal_module_offset( + elif isinstance(parent, ModuleLocation): + module_id = parent.moduleId + module_to_child = self._modules.get_nominal_offset_to_child( module_id=module_id, addressable_areas=self._addressable_areas ) module_model = self._modules.get_connected_model(module_id) stacking_overlap = self._labware.get_module_overlap_offsets( - labware_id, module_model + child_definition, module_model ) return LabwareOffsetVector( - x=module_offset.x - stacking_overlap.x, - y=module_offset.y - stacking_overlap.y, - z=module_offset.z - stacking_overlap.z, + x=module_to_child.x - stacking_overlap.x, + y=module_to_child.y - stacking_overlap.y, + z=module_to_child.z - stacking_overlap.z, + ) + elif isinstance(parent, OnLabwareLocation): + on_labware = self._labware.get(parent.labwareId) + on_labware_dimensions = self._labware.get_dimensions( + labware_id=on_labware.id ) - elif isinstance(labware_location, OnLabwareLocation): - on_labware = self._labware.get(labware_location.labwareId) - on_labware_dimensions = self._labware.get_dimensions(on_labware.id) stacking_overlap = self._labware.get_labware_overlap_offsets( - labware_id=labware_id, below_labware_name=on_labware.loadName + definition=child_definition, below_labware_name=on_labware.loadName ) labware_offset = LabwareOffsetVector( x=stacking_overlap.x, y=stacking_overlap.y, z=on_labware_dimensions.z - stacking_overlap.z, ) - return labware_offset + self._get_labware_position_offset( - on_labware.id, on_labware.location + return labware_offset + self._get_offset_from_parent( + self._labware.get_definition(on_labware.id), on_labware.location ) else: raise errors.LabwareNotOnDeckError( - f"Cannot access labware {labware_id} since it is not on the deck. " - f"Either it has been loaded off-deck or its been moved off-deck." + "Cannot access labware since it is not on the deck. " + "Either it has been loaded off-deck or its been moved off-deck." ) def _normalize_module_calibration_offset( @@ -766,7 +769,7 @@ def ensure_location_not_occupied( def get_labware_grip_point( self, - labware_id: str, + labware_definition: LabwareDefinition, location: Union[ DeckSlotLocation, ModuleLocation, OnLabwareLocation, AddressableAreaLocation ], @@ -782,7 +785,7 @@ def get_labware_grip_point( z-position of labware bottom + grip height from labware bottom. """ grip_height_from_labware_bottom = ( - self._labware.get_grip_height_from_labware_bottom(labware_id) + self._labware.get_grip_height_from_labware_bottom(labware_definition) ) location_name: str @@ -808,7 +811,9 @@ def get_labware_grip_point( ).slotName.id else: # OnLabwareLocation location_name = self.get_ancestor_slot_name(location.labwareId).id - labware_offset = self._get_labware_position_offset(labware_id, location) + labware_offset = self._get_offset_from_parent( + child_definition=labware_definition, parent=location + ) # Get the calibrated offset if the on labware location is on top of a module, otherwise return empty one cal_offset = self._get_calibrated_module_offset(location) offset = LabwareOffsetVector( @@ -1243,12 +1248,16 @@ def get_total_nominal_gripper_offset_for_move_type( ) # todo(mm, 2024-11-05): This may be incorrect because it does not take the following - # offsets into account: + # offsets into account, which *are* taken into account for the actual gripper movement: # # * The pickup offset in the definition of the parent of the gripped labware. # * The "additional offset" or "user offset", e.g. the `pickUpOffset` and `dropOffset` # params in the `moveLabware` command. # + # And this *does* take these extra offsets into account: + # + # * The labware's Labware Position Check offset + # # For robustness, we should combine this with `get_gripper_labware_movement_waypoints()`. # # We should also be more explicit about which offsets act to move the gripper paddles @@ -1262,18 +1271,22 @@ def check_gripper_labware_tip_collision( current_location: OnDeckLabwareLocation, ) -> None: """Check for potential collision of tips against labware to be lifted.""" - # TODO(cb, 2024-01-22): Remove the 1 and 8 channel special case once we are doing X axis validation + labware_definition = self._labware.get_definition(labware_id) pipettes = self._pipettes.get_all() for pipette in pipettes: + # TODO(cb, 2024-01-22): Remove the 1 and 8 channel special case once we are doing X axis validation if self._pipettes.get_channels(pipette.id) in [1, 8]: return tip = self._pipettes.get_attached_tip(pipette.id) if tip: + # NOTE: This call to get_labware_highest_z() uses the labware's LPC offset, + # which is an inconsistency between this and the actual gripper movement. + # See the todo comment above this function. labware_top_z_when_gripped = gripper_homed_position_z + ( self.get_labware_highest_z(labware_id=labware_id) - self.get_labware_grip_point( - labware_id=labware_id, location=current_location + labware_definition=labware_definition, location=current_location ).z ) # TODO(cb, 2024-01-18): Utilizing the nozzle map and labware X coordinates verify if collisions will occur on the X axis (analysis will use hard coded data to measure from the gripper critical point to the pipette mount) @@ -1281,7 +1294,7 @@ def check_gripper_labware_tip_collision( _PIPETTE_HOMED_POSITION_Z - tip.length ) < labware_top_z_when_gripped: raise LabwareMovementNotAllowedError( - f"Cannot move labware '{self._labware.get(labware_id).loadName}' when {int(tip.volume)} µL tips are attached." + f"Cannot move labware '{labware_definition.parameters.loadName}' when {int(tip.volume)} µL tips are attached." ) return diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index f964480e5f1..419e9974d5c 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -13,6 +13,7 @@ NamedTuple, cast, Union, + overload, ) from opentrons.protocol_engine.state import update_types @@ -630,10 +631,26 @@ def get_load_name(self, labware_id: str) -> str: definition = self.get_definition(labware_id) return definition.parameters.loadName - def get_dimensions(self, labware_id: str) -> Dimensions: + @overload + def get_dimensions(self, *, labware_definition: LabwareDefinition) -> Dimensions: + pass + + @overload + def get_dimensions(self, *, labware_id: str) -> Dimensions: + pass + + def get_dimensions( + self, + *, + labware_definition: LabwareDefinition | None = None, + labware_id: str | None = None, + ) -> Dimensions: """Get the labware's dimensions.""" - definition = self.get_definition(labware_id) - dims = definition.dimensions + if labware_definition is None: + assert labware_id is not None # From our @overloads. + labware_definition = self.get_definition(labware_id) + + dims = labware_definition.dimensions return Dimensions( x=dims.xDimension, @@ -642,10 +659,9 @@ def get_dimensions(self, labware_id: str) -> Dimensions: ) def get_labware_overlap_offsets( - self, labware_id: str, below_labware_name: str + self, definition: LabwareDefinition, below_labware_name: str ) -> OverlapOffset: """Get the labware's overlap with requested labware's load name.""" - definition = self.get_definition(labware_id) if below_labware_name in definition.stackingOffsetWithLabware.keys(): stacking_overlap = definition.stackingOffsetWithLabware.get( below_labware_name, OverlapOffset(x=0, y=0, z=0) @@ -659,10 +675,9 @@ def get_labware_overlap_offsets( ) def get_module_overlap_offsets( - self, labware_id: str, module_model: ModuleModel + self, definition: LabwareDefinition, module_model: ModuleModel ) -> OverlapOffset: """Get the labware's overlap with requested module model.""" - definition = self.get_definition(labware_id) stacking_overlap = definition.stackingOffsetWithModule.get( str(module_model.value) ) @@ -827,23 +842,18 @@ def raise_if_labware_incompatible_with_plate_reader( labware_definition: LabwareDefinition, ) -> None: """Raise an error if the labware is not compatible with the plate reader.""" - # TODO: (ba, 2024-11-1): the plate reader lid should not be a labware. load_name = labware_definition.parameters.loadName - if load_name != "opentrons_flex_lid_absorbance_plate_reader_module": - number_of_wells = len(labware_definition.wells) - if number_of_wells != 96: - raise errors.LabwareMovementNotAllowedError( - f"Cannot move '{load_name}' into plate reader because the" - f" labware contains {number_of_wells} wells where 96 wells is expected." - ) - elif ( - labware_definition.dimensions.zDimension - > _PLATE_READER_MAX_LABWARE_Z_MM - ): - raise errors.LabwareMovementNotAllowedError( - f"Cannot move '{load_name}' into plate reader because the" - f" maximum allowed labware height is {_PLATE_READER_MAX_LABWARE_Z_MM}mm." - ) + number_of_wells = len(labware_definition.wells) + if number_of_wells != 96: + raise errors.LabwareMovementNotAllowedError( + f"Cannot move '{load_name}' into plate reader because the" + f" labware contains {number_of_wells} wells where 96 wells is expected." + ) + elif labware_definition.dimensions.zDimension > _PLATE_READER_MAX_LABWARE_Z_MM: + raise errors.LabwareMovementNotAllowedError( + f"Cannot move '{load_name}' into plate reader because the" + f" maximum allowed labware height is {_PLATE_READER_MAX_LABWARE_Z_MM}mm." + ) def raise_if_labware_cannot_be_stacked( # noqa: C901 self, top_labware_definition: LabwareDefinition, bottom_labware_id: str @@ -928,12 +938,39 @@ def get_deck_default_gripper_offsets(self) -> Optional[LabwareMovementOffsetData else None ) + def get_absorbance_reader_lid_definition(self) -> LabwareDefinition: + """Return the special labware definition for the plate reader lid. + + See todo comments in `create_protocol_engine(). + """ + # NOTE: This needs to stay in sync with create_protocol_engine(). + return self._state.definitions_by_uri[ + "opentrons/opentrons_flex_lid_absorbance_plate_reader_module/1" + ] + + @overload def get_child_gripper_offsets( self, - labware_id: str, + *, + labware_definition: LabwareDefinition, + slot_name: Optional[DeckSlotName], + ) -> Optional[LabwareMovementOffsetData]: + pass + + @overload + def get_child_gripper_offsets( + self, *, labware_id: str, slot_name: Optional[DeckSlotName] + ) -> Optional[LabwareMovementOffsetData]: + pass + + def get_child_gripper_offsets( + self, + *, + labware_definition: Optional[LabwareDefinition] = None, + labware_id: Optional[str] = None, slot_name: Optional[DeckSlotName], ) -> Optional[LabwareMovementOffsetData]: - """Get the offsets that a labware says should be applied to children stacked atop it. + """Get the grip offsets that a labware says should be applied to children stacked atop it. Params: labware_id: The ID of a parent labware (atop which another labware, the child, will be stacked). @@ -948,7 +985,13 @@ def get_child_gripper_offsets( If `slot_name` is `None`, returns the gripper offsets that the parent labware definition designates as "default," or `None` if it doesn't designate any as such. """ - parsed_offsets = self.get_definition(labware_id).gripperOffsets + if labware_id is not None: + labware_definition = self.get_definition(labware_id) + else: + # Should be ensured by our @overloads. + assert labware_definition is not None + + parsed_offsets = labware_definition.gripperOffsets offset_key = slot_name.id if slot_name else "default" if parsed_offsets is None or offset_key not in parsed_offsets: @@ -963,20 +1006,22 @@ def get_child_gripper_offsets( ), ) - def get_grip_force(self, labware_id: str) -> float: + def get_grip_force(self, labware_definition: LabwareDefinition) -> float: """Get the recommended grip force for gripping labware using gripper.""" - recommended_force = self.get_definition(labware_id).gripForce + recommended_force = labware_definition.gripForce return ( recommended_force if recommended_force is not None else LABWARE_GRIP_FORCE ) - def get_grip_height_from_labware_bottom(self, labware_id: str) -> float: + def get_grip_height_from_labware_bottom( + self, labware_definition: LabwareDefinition + ) -> float: """Get the recommended grip height from labware bottom, if present.""" - recommended_height = self.get_definition(labware_id).gripHeightFromLabwareBottom + recommended_height = labware_definition.gripHeightFromLabwareBottom return ( recommended_height if recommended_height is not None - else self.get_dimensions(labware_id).z / 2 + else self.get_dimensions(labware_definition=labware_definition).z / 2 ) @staticmethod @@ -1019,7 +1064,7 @@ def _min_y_of_well(well_defn: WellDefinition) -> float: def _max_z_of_well(well_defn: WellDefinition) -> float: return well_defn.z + well_defn.depth - def get_well_bbox(self, labware_id: str) -> Dimensions: + def get_well_bbox(self, labware_definition: LabwareDefinition) -> Dimensions: """Get the bounding box implied by the wells. The bounding box of the labware that is implied by the wells is that required @@ -1030,14 +1075,13 @@ def get_well_bbox(self, labware_id: str) -> Dimensions: This is used for the specific purpose of finding the reasonable uncertainty bounds of where and how a gripper will interact with a labware. """ - defn = self.get_definition(labware_id) max_x: Optional[float] = None min_x: Optional[float] = None max_y: Optional[float] = None min_y: Optional[float] = None max_z: Optional[float] = None - for well in defn.wells.values(): + for well in labware_definition.wells.values(): well_max_x = self._max_x_of_well(well) well_min_x = self._min_x_of_well(well) well_max_y = self._max_y_of_well(well) diff --git a/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py b/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py index 33b96aa0881..79bdbc50b60 100644 --- a/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py +++ b/api/src/opentrons/protocol_engine/state/module_substates/absorbance_reader_substate.py @@ -9,6 +9,9 @@ AbsorbanceReaderMeasureMode = NewType("AbsorbanceReaderMeasureMode", str) +# todo(mm, 2024-11-08): frozen=True is getting pretty painful because ModuleStore has +# no type-safe way to modify just a single attribute. Consider unfreezing this +# (taking care to ensure that consumers of ModuleView still only get a read-only view). @dataclass(frozen=True) class AbsorbanceReaderSubState: """Absorbance-Plate-Reader-specific state.""" @@ -21,7 +24,6 @@ class AbsorbanceReaderSubState: configured_wavelengths: Optional[List[int]] measure_mode: Optional[AbsorbanceReaderMeasureMode] reference_wavelength: Optional[int] - lid_id: Optional[str] def raise_if_lid_status_not_expected(self, lid_on_expected: bool) -> None: """Raise if the lid status is not correct.""" diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 82c32d9f003..c61d4173ff1 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -26,9 +26,11 @@ get_west_slot, get_adjacent_staging_slot, ) +from opentrons.protocol_engine.actions.get_state_update import get_state_updates from opentrons.protocol_engine.commands.calibration.calibrate_module import ( CalibrateModuleResult, ) +from opentrons.protocol_engine.state import update_types from opentrons.protocol_engine.state.module_substates.absorbance_reader_substate import ( AbsorbanceReaderMeasureMode, ) @@ -67,7 +69,6 @@ Action, SucceedCommandAction, AddModuleAction, - AddAbsorbanceReaderLidAction, ) from ._abstract_store import HasState, HandlesActions from .module_substates import ( @@ -234,13 +235,14 @@ def handle_action(self, action: Action) -> None: requested_model=None, module_live_data=action.module_live_data, ) - elif isinstance(action, AddAbsorbanceReaderLidAction): - self._update_absorbance_reader_lid_id( - module_id=action.module_id, - lid_id=action.lid_id, - ) + + for state_update in get_state_updates(action): + self._handle_state_update(state_update) def _handle_command(self, command: Command) -> None: + # todo(mm, 2024-11-04): Delete this function. Port these isinstance() + # checks to the update_types.StateUpdate mechanism. + if isinstance(command.result, LoadModuleResult): slot_name = command.params.location.slotName self._add_module_substate( @@ -297,38 +299,40 @@ def _handle_command(self, command: Command) -> None: if isinstance( command.result, ( - absorbance_reader.CloseLidResult, - absorbance_reader.OpenLidResult, absorbance_reader.InitializeResult, absorbance_reader.ReadAbsorbanceResult, ), ): self._handle_absorbance_reader_commands(command) - def _update_absorbance_reader_lid_id( - self, - module_id: str, - lid_id: str, - ) -> None: - abs_substate = self._state.substate_by_module_id.get(module_id) - assert isinstance( - abs_substate, AbsorbanceReaderSubState - ), f"{module_id} is not an absorbance plate reader." + def _handle_state_update(self, state_update: update_types.StateUpdate) -> None: + if state_update.absorbance_reader_lid != update_types.NO_CHANGE: + module_id = state_update.absorbance_reader_lid.module_id + is_lid_on = state_update.absorbance_reader_lid.is_lid_on + + # Get current values: + absorbance_reader_substate = self._state.substate_by_module_id[module_id] + assert isinstance( + absorbance_reader_substate, AbsorbanceReaderSubState + ), f"{module_id} is not an absorbance plate reader." + configured = absorbance_reader_substate.configured + measure_mode = absorbance_reader_substate.measure_mode + configured_wavelengths = absorbance_reader_substate.configured_wavelengths + reference_wavelength = absorbance_reader_substate.reference_wavelength + data = absorbance_reader_substate.data - prev_state: AbsorbanceReaderSubState = abs_substate - self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( - module_id=AbsorbanceReaderId(module_id), - configured=prev_state.configured, - measured=prev_state.measured, - is_lid_on=prev_state.is_lid_on, - data=prev_state.data, - measure_mode=prev_state.measure_mode, - configured_wavelengths=prev_state.configured_wavelengths, - reference_wavelength=prev_state.reference_wavelength, - lid_id=lid_id, - ) + self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( + module_id=AbsorbanceReaderId(module_id), + configured=configured, + measured=True, + is_lid_on=is_lid_on, + measure_mode=measure_mode, + configured_wavelengths=configured_wavelengths, + reference_wavelength=reference_wavelength, + data=data, + ) - def _add_module_substate( # noqa: C901 + def _add_module_substate( self, module_id: str, serial_number: Optional[str], @@ -387,16 +391,6 @@ def _add_module_substate( # noqa: C901 module_id=MagneticBlockId(module_id) ) elif ModuleModel.is_absorbance_reader(actual_model): - lid_labware_id = None - slot = self._state.slot_by_module_id[module_id] - if slot is not None: - reader_addressable_area = f"absorbanceReaderV1{slot.value}" - for labware in self._state.deck_fixed_labware: - if labware.location == AddressableAreaLocation( - addressableAreaName=reader_addressable_area - ): - lid_labware_id = labware.labware_id - break self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( module_id=AbsorbanceReaderId(module_id), configured=False, @@ -406,7 +400,6 @@ def _add_module_substate( # noqa: C901 measure_mode=None, configured_wavelengths=None, reference_wavelength=None, - lid_id=lid_labware_id, ) def _update_additional_slots_occupied_by_thermocycler( @@ -600,8 +593,6 @@ def _handle_absorbance_reader_commands( command: Union[ absorbance_reader.Initialize, absorbance_reader.ReadAbsorbance, - absorbance_reader.CloseLid, - absorbance_reader.OpenLid, ], ) -> None: module_id = command.params.moduleId @@ -616,8 +607,6 @@ def _handle_absorbance_reader_commands( configured_wavelengths = absorbance_reader_substate.configured_wavelengths reference_wavelength = absorbance_reader_substate.reference_wavelength is_lid_on = absorbance_reader_substate.is_lid_on - lid_id = absorbance_reader_substate.lid_id - data = absorbance_reader_substate.data if isinstance(command.result, absorbance_reader.InitializeResult): self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( @@ -625,7 +614,6 @@ def _handle_absorbance_reader_commands( configured=True, measured=False, is_lid_on=is_lid_on, - lid_id=lid_id, measure_mode=AbsorbanceReaderMeasureMode(command.params.measureMode), configured_wavelengths=command.params.sampleWavelengths, reference_wavelength=command.params.referenceWavelength, @@ -637,39 +625,12 @@ def _handle_absorbance_reader_commands( configured=configured, measured=True, is_lid_on=is_lid_on, - lid_id=lid_id, measure_mode=measure_mode, configured_wavelengths=configured_wavelengths, reference_wavelength=reference_wavelength, data=command.result.data, ) - elif isinstance(command.result, absorbance_reader.OpenLidResult): - self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( - module_id=AbsorbanceReaderId(module_id), - configured=configured, - measured=True, - is_lid_on=False, - lid_id=lid_id, - measure_mode=measure_mode, - configured_wavelengths=configured_wavelengths, - reference_wavelength=reference_wavelength, - data=data, - ) - - elif isinstance(command.result, absorbance_reader.CloseLidResult): - self._state.substate_by_module_id[module_id] = AbsorbanceReaderSubState( - module_id=AbsorbanceReaderId(module_id), - configured=configured, - measured=True, - is_lid_on=True, - lid_id=lid_id, - measure_mode=measure_mode, - configured_wavelengths=configured_wavelengths, - reference_wavelength=reference_wavelength, - data=data, - ) - class ModuleView(HasState[ModuleState]): """Read-only view of computed module state.""" @@ -883,12 +844,21 @@ def get_dimensions(self, module_id: str) -> ModuleDimensions: """Get the specified module's dimensions.""" return self.get_definition(module_id).dimensions - def get_nominal_module_offset( + def get_nominal_offset_to_child( self, module_id: str, + # todo(mm, 2024-11-07): A method of one view taking a sibling view as an argument + # is unusual, and may be bug-prone if the order in which the views are updated + # matters. If we need to compute something that depends on module info and + # addressable area info, can we do that computation in GeometryView instead of + # here? addressable_areas: AddressableAreaView, ) -> LabwareOffsetVector: - """Get the module's nominal offset vector computed with slot transform.""" + """Get the nominal offset from a module's location to its child labware's location. + + Includes the slot-specific transform. Does not include the child's + Labware Position Check offset. + """ if ( self.state.deck_type == DeckType.OT2_STANDARD or self.state.deck_type == DeckType.OT2_SHORT_TRASH @@ -996,7 +966,7 @@ def get_module_highest_z( default_lw_offset_point = self.get_definition(module_id).labwareOffset.z z_difference = module_height - default_lw_offset_point - nominal_transformed_lw_offset_z = self.get_nominal_module_offset( + nominal_transformed_lw_offset_z = self.get_nominal_offset_to_child( module_id=module_id, addressable_areas=addressable_areas ).z calibration_offset = self.get_module_calibration_offset(module_id) diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index 181d8820723..3d062e00265 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -205,6 +205,14 @@ class LiquidOperatedUpdate: volume_added: float | ClearType +@dataclasses.dataclass +class AbsorbanceReaderLidUpdate: + """An update to an absorbance reader's lid location.""" + + module_id: str + is_lid_on: bool + + @dataclasses.dataclass class StateUpdate: """Represents an update to perform on engine state.""" @@ -231,6 +239,8 @@ class StateUpdate: liquid_operated: LiquidOperatedUpdate | NoChangeType = NO_CHANGE + absorbance_reader_lid: AbsorbanceReaderLidUpdate | NoChangeType = NO_CHANGE + # These convenience functions let the caller avoid the boilerplate of constructing a # complicated dataclass tree. @@ -406,3 +416,9 @@ def set_liquid_operated( well_name=well_name, volume_added=volume_added, ) + + def set_absorbance_reader_lid(self, module_id: str, is_lid_on: bool) -> None: + """Update an absorbance reader's lid location. See `AbsorbanceReaderLidUpdate`.""" + self.absorbance_reader_lid = AbsorbanceReaderLidUpdate( + module_id=module_id, is_lid_on=is_lid_on + ) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py index 22b734a6024..9bc195296a2 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_absorbance_reader_core.py @@ -147,7 +147,6 @@ def test_read( configured_wavelengths=subject._initialized_value, measure_mode=AbsorbanceReaderMeasureMode("single"), reference_wavelength=None, - lid_id="pr_lid_labware", ) decoy.when( mock_engine_client.state.modules.get_absorbance_reader_substate( diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index 42e17983018..05a09ac452c 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -896,9 +896,9 @@ def test_valid_96_pipette_movement_for_tiprack_and_adapter( ) -> None: """It should raise appropriate error for unsuitable tiprack parent when moving 96 channel to it.""" decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(96) - decoy.when(mock_state_view.labware.get_dimensions("adapter-id")).then_return( - Dimensions(x=0, y=0, z=100) - ) + decoy.when( + mock_state_view.labware.get_dimensions(labware_id="adapter-id") + ).then_return(Dimensions(x=0, y=0, z=100)) decoy.when(mock_state_view.labware.get_display_name("labware-id")).then_return( "A cool tiprack" ) @@ -908,9 +908,9 @@ def test_valid_96_pipette_movement_for_tiprack_and_adapter( decoy.when(mock_state_view.labware.get_location("labware-id")).then_return( tiprack_parent ) - decoy.when(mock_state_view.labware.get_dimensions("labware-id")).then_return( - tiprack_dim - ) + decoy.when( + mock_state_view.labware.get_dimensions(labware_id="labware-id") + ).then_return(tiprack_dim) decoy.when( mock_state_view.labware.get_has_quirk( labware_id="adapter-id", quirk="tiprackAdapterFor96Channel" diff --git a/api/tests/opentrons/protocol_api_integration/test_modules.py b/api/tests/opentrons/protocol_api_integration/test_modules.py index a287974296d..e8a26112d88 100644 --- a/api/tests/opentrons/protocol_api_integration/test_modules.py +++ b/api/tests/opentrons/protocol_api_integration/test_modules.py @@ -23,7 +23,8 @@ def test_absorbance_reader_labware_load_conflict() -> None: # Should raise after closing the lid again. module.close_lid() # type: ignore[union-attr] - module.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt") + with pytest.raises(Exception): + module.load_labware("opentrons_96_wellplate_200ul_pcr_full_skirt") def test_absorbance_reader_labware_move_conflict() -> None: diff --git a/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py b/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py index 6032bad81b8..3377e39b666 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py @@ -2,10 +2,11 @@ from __future__ import annotations from datetime import datetime +from typing import TYPE_CHECKING, Union, Optional, Tuple +from unittest.mock import sentinel -import pytest from decoy import Decoy, matchers -from typing import TYPE_CHECKING, Union, Optional, Tuple +import pytest from opentrons.protocol_engine.execution import EquipmentHandler, MovementHandler from opentrons.hardware_control import HardwareControlAPI @@ -133,7 +134,7 @@ async def set_up_decoy_hardware_gripper( decoy.when(ot3_hardware_api.hardware_gripper.jaw_width).then_return(89) decoy.when( - state_store.labware.get_grip_force("my-teleporting-labware") + state_store.labware.get_grip_force(sentinel.my_teleporting_labware_def) ).then_return(100) decoy.when(state_store.labware.get_labware_offset("new-offset-id")).then_return( @@ -195,6 +196,10 @@ async def test_raise_error_if_gripper_pickup_failed( starting_location = DeckSlotLocation(slotName=DeckSlotName.SLOT_1) to_location = DeckSlotLocation(slotName=DeckSlotName.SLOT_2) + decoy.when( + state_store.labware.get_definition("my-teleporting-labware") + ).then_return(sentinel.my_teleporting_labware_def) + mock_tc_context_manager = decoy.mock(name="mock_tc_context_manager") decoy.when( thermocycler_plate_lifter.lift_plate_for_labware_movement( @@ -217,22 +222,27 @@ async def test_raise_error_if_gripper_pickup_failed( decoy.when( state_store.geometry.get_labware_grip_point( - labware_id="my-teleporting-labware", location=starting_location + labware_definition=sentinel.my_teleporting_labware_def, + location=starting_location, ) ).then_return(Point(101, 102, 119.5)) decoy.when( state_store.geometry.get_labware_grip_point( - labware_id="my-teleporting-labware", location=to_location + labware_definition=sentinel.my_teleporting_labware_def, location=to_location ) ).then_return(Point(201, 202, 219.5)) decoy.when( - state_store.labware.get_dimensions(labware_id="my-teleporting-labware") + state_store.labware.get_dimensions( + labware_definition=sentinel.my_teleporting_labware_def + ) ).then_return(Dimensions(x=100, y=85, z=0)) decoy.when( - state_store.labware.get_well_bbox(labware_id="my-teleporting-labware") + state_store.labware.get_well_bbox( + labware_definition=sentinel.my_teleporting_labware_def + ) ).then_return(Dimensions(x=99, y=80, z=1)) await subject.move_labware_with_gripper( @@ -320,6 +330,10 @@ async def test_move_labware_with_gripper( # smoke test for gripper labware movement with actual labware and make this a unit test. await set_up_decoy_hardware_gripper(decoy, ot3_hardware_api, state_store) + decoy.when( + state_store.labware.get_definition("my-teleporting-labware") + ).then_return(sentinel.my_teleporting_labware_def) + user_offset_data, final_offset_data = hardware_gripper_offset_data current_labware = state_store.labware.get_definition( labware_id="my-teleporting-labware" @@ -334,21 +348,26 @@ async def test_move_labware_with_gripper( ).then_return(final_offset_data) decoy.when( - state_store.labware.get_dimensions(labware_id="my-teleporting-labware") + state_store.labware.get_dimensions( + labware_definition=sentinel.my_teleporting_labware_def + ) ).then_return(Dimensions(x=100, y=85, z=0)) decoy.when( - state_store.labware.get_well_bbox(labware_id="my-teleporting-labware") + state_store.labware.get_well_bbox( + labware_definition=sentinel.my_teleporting_labware_def + ) ).then_return(Dimensions(x=99, y=80, z=1)) decoy.when( state_store.geometry.get_labware_grip_point( - labware_id="my-teleporting-labware", location=from_location + labware_definition=sentinel.my_teleporting_labware_def, + location=from_location, ) ).then_return(Point(101, 102, 119.5)) decoy.when( state_store.geometry.get_labware_grip_point( - labware_id="my-teleporting-labware", location=to_location + labware_definition=sentinel.my_teleporting_labware_def, location=to_location ) ).then_return(Point(201, 202, 219.5)) mock_tc_context_manager = decoy.mock(name="mock_tc_context_manager") diff --git a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py index 3c8552cdd6f..e051f155113 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_deck_data_provider.py @@ -10,8 +10,6 @@ from opentrons.protocol_engine.types import ( DeckSlotLocation, DeckType, - DeckConfigurationType, - AddressableAreaLocation, ) from opentrons.protocol_engine.resources import ( LabwareDataProvider, @@ -135,56 +133,3 @@ async def test_get_deck_labware_fixtures_ot3_standard( definition=ot3_fixed_trash_def, ) ] - - -def _make_deck_config_with_plate_reader() -> DeckConfigurationType: - return [ - ("cutoutA1", "singleLeftSlot", None), - ("cutoutB1", "singleLeftSlot", None), - ("cutoutC1", "singleLeftSlot", None), - ("cutoutD1", "singleLeftSlot", None), - ("cutoutA2", "singleCenterSlot", None), - ("cutoutB2", "singleCenterSlot", None), - ("cutoutC2", "singleCenterSlot", None), - ("cutoutD2", "singleCenterSlot", None), - ("cutoutA3", "singleRightSlot", None), - ("cutoutB3", "singleRightSlot", None), - ("cutoutC3", "singleRightSlot", None), - ("cutoutD3", "absorbanceReaderV1", "abc123"), - ] - - -async def test_get_deck_labware_fixtures_ot3_standard_for_plate_reader( - decoy: Decoy, - ot3_standard_deck_def: DeckDefinitionV5, - ot3_absorbance_reader_lid: LabwareDefinition, - mock_labware_data_provider: LabwareDataProvider, -) -> None: - """It should get a lis including the Plate Reader Lid for our deck fixed labware.""" - subject = DeckDataProvider( - deck_type=DeckType.OT3_STANDARD, labware_data=mock_labware_data_provider - ) - - decoy.when( - await mock_labware_data_provider.get_labware_definition( - load_name="opentrons_flex_lid_absorbance_plate_reader_module", - namespace="opentrons", - version=1, - ) - ).then_return(ot3_absorbance_reader_lid) - - deck_config = _make_deck_config_with_plate_reader() - - result = await subject.get_deck_fixed_labware( - False, ot3_standard_deck_def, deck_config - ) - - assert result == [ - DeckFixedLabware( - labware_id="absorbanceReaderV1LidD3", - location=AddressableAreaLocation( - addressableAreaName="absorbanceReaderV1D3" - ), - definition=ot3_absorbance_reader_lid, - ) - ] diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 889408d6da6..42ee037c1ce 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -1,15 +1,18 @@ """Test state getters for retrieving geometry views of state.""" import inspect import json +from datetime import datetime +from math import isclose +from typing import cast, List, Tuple, Optional, NamedTuple, Dict +from unittest.mock import sentinel + +import pytest +from decoy import Decoy + from opentrons.protocol_engine.state.update_types import ( LoadedLabwareUpdate, StateUpdate, ) -import pytest -from math import isclose -from decoy import Decoy -from typing import cast, List, Tuple, Optional, NamedTuple, Dict -from datetime import datetime from opentrons_shared_data.deck.types import DeckDefinitionV5 from opentrons_shared_data.deck import load as load_deck @@ -366,6 +369,9 @@ def test_get_labware_parent_position_on_module( ) decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + sentinel.labware_def + ) decoy.when(mock_module_view.get_location("module-id")).then_return( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) @@ -378,7 +384,7 @@ def test_get_labware_parent_position_on_module( ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -389,7 +395,7 @@ def test_get_labware_parent_position_on_module( ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "labware-id", ModuleModel.THERMOCYCLER_MODULE_V2 + sentinel.labware_def, ModuleModel.THERMOCYCLER_MODULE_V2 ) ).then_return(OverlapOffset(x=1, y=2, z=3)) decoy.when(mock_module_view.get_module_calibration_offset("module-id")).then_return( @@ -420,6 +426,11 @@ def test_get_labware_parent_position_on_labware( location=OnLabwareLocation(labwareId="adapter-id"), offsetId=None, ) + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition(labware_data.id)).then_return( + sentinel.labware_def + ) + adapter_data = LoadedLabware( id="adapter-id", loadName="xyz", @@ -427,37 +438,41 @@ def test_get_labware_parent_position_on_labware( location=ModuleLocation(moduleId="module-id"), offsetId=None, ) - decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get("adapter-id")).then_return(adapter_data) + decoy.when(mock_labware_view.get_definition(adapter_data.id)).then_return( + sentinel.adapter_def + ) + decoy.when(mock_module_view.get_location("module-id")).then_return( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) decoy.when( mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) ).then_return(Point(1, 2, 3)) - decoy.when(mock_labware_view.get("adapter-id")).then_return(adapter_data) - decoy.when(mock_labware_view.get_dimensions("adapter-id")).then_return( + + decoy.when(mock_labware_view.get_dimensions(labware_id="adapter-id")).then_return( Dimensions(x=123, y=456, z=5) ) decoy.when( - mock_labware_view.get_labware_overlap_offsets("labware-id", "xyz") + mock_labware_view.get_labware_overlap_offsets(sentinel.labware_def, "xyz") ).then_return(OverlapOffset(x=1, y=2, z=2)) decoy.when(mock_labware_view.get_deck_definition()).then_return( ot2_standard_deck_def ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) ).then_return(LabwareOffsetVector(x=1, y=2, z=3)) decoy.when(mock_module_view.get_connected_model("module-id")).then_return( - ModuleModel.MAGNETIC_MODULE_V2 + sentinel.connected_model ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "adapter-id", ModuleModel.MAGNETIC_MODULE_V2 + sentinel.adapter_def, sentinel.connected_model ) ).then_return(OverlapOffset(x=-3, y=-2, z=-1)) @@ -637,7 +652,7 @@ def test_get_module_labware_highest_z( ot2_standard_deck_def ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -654,7 +669,7 @@ def test_get_module_labware_highest_z( ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "labware-id", ModuleModel.MAGNETIC_MODULE_V2 + well_plate_def, ModuleModel.MAGNETIC_MODULE_V2 ) ).then_return(OverlapOffset(x=0, y=0, z=0)) @@ -1003,24 +1018,28 @@ def test_get_highest_z_in_slot_with_stacked_labware_on_slot( decoy.when(mock_labware_view.get_definition("top-labware-id")).then_return( well_plate_def ) + decoy.when(mock_labware_view.get_definition("middle-labware-id")).then_return( + sentinel.middle_labware_def + ) + decoy.when( mock_labware_view.get_labware_offset_vector("top-labware-id") ).then_return(top_lw_lpc_offset) - decoy.when(mock_labware_view.get_dimensions("middle-labware-id")).then_return( - Dimensions(x=10, y=20, z=30) - ) - decoy.when(mock_labware_view.get_dimensions("bottom-labware-id")).then_return( - Dimensions(x=11, y=12, z=13) - ) + decoy.when( + mock_labware_view.get_dimensions(labware_id="middle-labware-id") + ).then_return(Dimensions(x=10, y=20, z=30)) + decoy.when( + mock_labware_view.get_dimensions(labware_id="bottom-labware-id") + ).then_return(Dimensions(x=11, y=12, z=13)) decoy.when( mock_labware_view.get_labware_overlap_offsets( - "top-labware-id", below_labware_name="middle-labware-name" + well_plate_def, below_labware_name="middle-labware-name" ) ).then_return(OverlapOffset(x=4, y=5, z=6)) decoy.when( mock_labware_view.get_labware_overlap_offsets( - "middle-labware-id", below_labware_name="bottom-labware-name" + sentinel.middle_labware_def, below_labware_name="bottom-labware-name" ) ).then_return(OverlapOffset(x=7, y=8, z=9)) @@ -1099,16 +1118,20 @@ def test_get_highest_z_in_slot_with_labware_stack_on_module( ) decoy.when(mock_labware_view.get("adapter-id")).then_return(adapter) + decoy.when(mock_labware_view.get_definition("adapter-id")).then_return( + sentinel.adapter_def + ) decoy.when(mock_labware_view.get("top-labware-id")).then_return(top_labware) + decoy.when( mock_labware_view.get_labware_offset_vector("top-labware-id") ).then_return(top_lw_lpc_offset) - decoy.when(mock_labware_view.get_dimensions("adapter-id")).then_return( + decoy.when(mock_labware_view.get_dimensions(labware_id="adapter-id")).then_return( Dimensions(x=10, y=20, z=30) ) decoy.when( mock_labware_view.get_labware_overlap_offsets( - labware_id="top-labware-id", below_labware_name="adapter-name" + definition=well_plate_def, below_labware_name="adapter-name" ) ).then_return(OverlapOffset(x=4, y=5, z=6)) @@ -1116,7 +1139,7 @@ def test_get_highest_z_in_slot_with_labware_stack_on_module( DeckSlotLocation(slotName=DeckSlotName.SLOT_3) ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -1127,7 +1150,7 @@ def test_get_highest_z_in_slot_with_labware_stack_on_module( decoy.when( mock_labware_view.get_module_overlap_offsets( - "adapter-id", ModuleModel.TEMPERATURE_MODULE_V2 + sentinel.adapter_def, ModuleModel.TEMPERATURE_MODULE_V2 ) ).then_return(OverlapOffset(x=1.1, y=2.2, z=3.3)) @@ -1333,7 +1356,7 @@ def test_get_module_labware_well_position( ot2_standard_deck_def ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -1349,7 +1372,7 @@ def test_get_module_labware_well_position( ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "labware-id", ModuleModel.MAGNETIC_MODULE_V2 + well_plate_def, ModuleModel.MAGNETIC_MODULE_V2 ) ).then_return(OverlapOffset(x=0, y=0, z=0)) @@ -2255,21 +2278,22 @@ def test_ensure_location_not_occupied_raises( def test_get_labware_grip_point( decoy: Decoy, mock_labware_view: LabwareView, - mock_module_view: ModuleView, mock_addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should get the grip point of the labware at the specified location.""" decoy.when( - mock_labware_view.get_grip_height_from_labware_bottom("labware-id") + mock_labware_view.get_grip_height_from_labware_bottom( + sentinel.labware_definition + ) ).then_return(100) decoy.when( mock_addressable_area_view.get_addressable_area_center(DeckSlotName.SLOT_1.id) ).then_return(Point(x=101, y=102, z=103)) labware_center = subject.get_labware_grip_point( - labware_id="labware-id", location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1) + labware_definition=sentinel.labware_definition, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), ) assert labware_center == Point(101.0, 102.0, 203) @@ -2278,20 +2302,10 @@ def test_get_labware_grip_point( def test_get_labware_grip_point_on_labware( decoy: Decoy, mock_labware_view: LabwareView, - mock_module_view: ModuleView, mock_addressable_area_view: AddressableAreaView, - ot2_standard_deck_def: DeckDefinitionV5, subject: GeometryView, ) -> None: """It should get the grip point of a labware on another labware.""" - decoy.when(mock_labware_view.get(labware_id="labware-id")).then_return( - LoadedLabware( - id="labware-id", - loadName="above-name", - definitionUri="1234", - location=OnLabwareLocation(labwareId="below-id"), - ) - ) decoy.when(mock_labware_view.get(labware_id="below-id")).then_return( LoadedLabware( id="below-id", @@ -2301,14 +2315,16 @@ def test_get_labware_grip_point_on_labware( ) ) - decoy.when(mock_labware_view.get_dimensions("below-id")).then_return( + decoy.when(mock_labware_view.get_dimensions(labware_id="below-id")).then_return( Dimensions(x=1000, y=1001, z=11) ) decoy.when( - mock_labware_view.get_grip_height_from_labware_bottom("labware-id") + mock_labware_view.get_grip_height_from_labware_bottom( + labware_definition=sentinel.definition + ) ).then_return(100) decoy.when( - mock_labware_view.get_labware_overlap_offsets("labware-id", "below-name") + mock_labware_view.get_labware_overlap_offsets(sentinel.definition, "below-name") ).then_return(OverlapOffset(x=0, y=1, z=6)) decoy.when( @@ -2316,7 +2332,8 @@ def test_get_labware_grip_point_on_labware( ).then_return(Point(x=5, y=9, z=10)) grip_point = subject.get_labware_grip_point( - labware_id="labware-id", location=OnLabwareLocation(labwareId="below-id") + labware_definition=sentinel.definition, + location=OnLabwareLocation(labwareId="below-id"), ) assert grip_point == Point(5, 10, 115.0) @@ -2332,7 +2349,9 @@ def test_get_labware_grip_point_for_labware_on_module( ) -> None: """It should return the grip point for labware directly on a module.""" decoy.when( - mock_labware_view.get_grip_height_from_labware_bottom("labware-id") + mock_labware_view.get_grip_height_from_labware_bottom( + sentinel.labware_definition + ) ).then_return(500) decoy.when(mock_module_view.get_location("module-id")).then_return( DeckSlotLocation(slotName=DeckSlotName.SLOT_4) @@ -2341,7 +2360,7 @@ def test_get_labware_grip_point_for_labware_on_module( ot2_standard_deck_def ) decoy.when( - mock_module_view.get_nominal_module_offset( + mock_module_view.get_nominal_offset_to_child( module_id="module-id", addressable_areas=mock_addressable_area_view, ) @@ -2351,7 +2370,7 @@ def test_get_labware_grip_point_for_labware_on_module( ) decoy.when( mock_labware_view.get_module_overlap_offsets( - "labware-id", ModuleModel.MAGNETIC_MODULE_V2 + sentinel.labware_definition, ModuleModel.MAGNETIC_MODULE_V2 ) ).then_return(OverlapOffset(x=10, y=20, z=30)) decoy.when(mock_module_view.get_module_calibration_offset("module-id")).then_return( @@ -2364,7 +2383,8 @@ def test_get_labware_grip_point_for_labware_on_module( mock_addressable_area_view.get_addressable_area_center(DeckSlotName.SLOT_4.id) ).then_return(Point(100, 200, 300)) result_grip_point = subject.get_labware_grip_point( - labware_id="labware-id", location=ModuleLocation(moduleId="module-id") + labware_definition=sentinel.labware_definition, + location=ModuleLocation(moduleId="module-id"), ) assert result_grip_point == Point(x=191, y=382, z=1073) @@ -2915,12 +2935,12 @@ def test_check_gripper_labware_tip_collision( ) ).then_return(Point(x=11, y=22, z=33)) decoy.when( - mock_labware_view.get_grip_height_from_labware_bottom("labware-id") + mock_labware_view.get_grip_height_from_labware_bottom(definition) ).then_return(1.0) decoy.when(mock_labware_view.get_definition("labware-id")).then_return(definition) decoy.when( subject.get_labware_grip_point( - labware_id="labware-id", + labware_definition=definition, location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), ) ).then_return(Point(x=100.0, y=100.0, z=0.0)) diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index 47dde33ce67..56113aff419 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -686,19 +686,14 @@ def test_get_dimensions(well_plate_def: LabwareDefinition) -> None: def test_get_labware_overlap_offsets() -> None: """It should get the labware overlap offsets.""" - subject = get_labware_view( - labware_by_id={"plate-id": plate}, - definitions_by_uri={ - "some-plate-uri": LabwareDefinition.construct( # type: ignore[call-arg] - stackingOffsetWithLabware={ - "bottom-labware-name": SharedDataOverlapOffset(x=1, y=2, z=3) - } - ) - }, - ) - + subject = get_labware_view() result = subject.get_labware_overlap_offsets( - labware_id="plate-id", below_labware_name="bottom-labware-name" + definition=LabwareDefinition.construct( # type: ignore[call-arg] + stackingOffsetWithLabware={ + "bottom-labware-name": SharedDataOverlapOffset(x=1, y=2, z=3) + } + ), + below_labware_name="bottom-labware-name", ) assert result == OverlapOffset(x=1, y=2, z=3) @@ -777,15 +772,12 @@ def test_get_module_overlap_offsets( """It should get the labware overlap offsets.""" subject = get_labware_view( deck_definition=spec_deck_definition, - labware_by_id={"plate-id": plate}, - definitions_by_uri={ - "some-plate-uri": LabwareDefinition.construct( # type: ignore[call-arg] - stackingOffsetWithModule=stacking_offset_with_module - ) - }, ) result = subject.get_module_overlap_offsets( - labware_id="plate-id", module_model=module_model + definition=LabwareDefinition.construct( # type: ignore[call-arg] + stackingOffsetWithModule=stacking_offset_with_module + ), + module_model=module_model, ) assert result == expected_offset @@ -1588,16 +1580,10 @@ def test_get_grip_force( reservoir_def: LabwareDefinition, ) -> None: """It should get the grip force, if present, from labware definition or return default.""" - subject = get_labware_view( - labware_by_id={"flex-tiprack-id": flex_tiprack, "reservoir-id": reservoir}, - definitions_by_uri={ - "some-flex-tiprack-uri": flex_50uL_tiprack, - "some-reservoir-uri": reservoir_def, - }, - ) + subject = get_labware_view() - assert subject.get_grip_force("flex-tiprack-id") == 16 # from definition - assert subject.get_grip_force("reservoir-id") == 15 # default + assert subject.get_grip_force(flex_50uL_tiprack) == 16 # from definition + assert subject.get_grip_force(reservoir_def) == 15 # default def test_get_grip_height_from_labware_bottom( @@ -1605,20 +1591,11 @@ def test_get_grip_height_from_labware_bottom( reservoir_def: LabwareDefinition, ) -> None: """It should get the grip height, if present, from labware definition or return default.""" - subject = get_labware_view( - labware_by_id={"plate-id": plate, "reservoir-id": reservoir}, - definitions_by_uri={ - "some-plate-uri": well_plate_def, - "some-reservoir-uri": reservoir_def, - }, - ) - + subject = get_labware_view() assert ( - subject.get_grip_height_from_labware_bottom("plate-id") == 12.2 + subject.get_grip_height_from_labware_bottom(well_plate_def) == 12.2 ) # from definition - assert ( - subject.get_grip_height_from_labware_bottom("reservoir-id") == 15.7 - ) # default + assert subject.get_grip_height_from_labware_bottom(reservoir_def) == 15.7 # default @pytest.mark.parametrize( @@ -1637,18 +1614,7 @@ def test_calculates_well_bounding_box( ) -> None: """It should be able to calculate well bounding boxes.""" definition = LabwareDefinition.parse_obj(load_definition(labware_to_check, 1)) - labware = LoadedLabware( - id="test-labware-id", - loadName=labware_to_check, - location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), - definitionUri="test-labware-uri", - offsetId=None, - displayName="Fancy Plate Name", - ) - subject = get_labware_view( - labware_by_id={"test-labware-id": labware}, - definitions_by_uri={"test-labware-uri": definition}, - ) - assert subject.get_well_bbox("test-labware-id").x == pytest.approx(well_bbox.x) - assert subject.get_well_bbox("test-labware-id").y == pytest.approx(well_bbox.y) - assert subject.get_well_bbox("test-labware-id").z == pytest.approx(well_bbox.z) + subject = get_labware_view() + assert subject.get_well_bbox(definition).x == pytest.approx(well_bbox.x) + assert subject.get_well_bbox(definition).y == pytest.approx(well_bbox.y) + assert subject.get_well_bbox(definition).z == pytest.approx(well_bbox.z) diff --git a/api/tests/opentrons/protocol_engine/state/test_module_view.py b/api/tests/opentrons/protocol_engine/state/test_module_view.py index 3a5f14f1516..66152a57240 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -406,7 +406,7 @@ def test_get_module_offset_for_ot2_standard( }, ) assert ( - subject.get_nominal_module_offset("module-id", get_addressable_area_view()) + subject.get_nominal_offset_to_child("module-id", get_addressable_area_view()) == expected_offset ) @@ -470,7 +470,7 @@ def test_get_module_offset_for_ot3_standard( }, ) - result_offset = subject.get_nominal_module_offset( + result_offset = subject.get_nominal_offset_to_child( "module-id", get_addressable_area_view( deck_configuration=None, diff --git a/shared-data/deck/definitions/5/ot3_standard.json b/shared-data/deck/definitions/5/ot3_standard.json index c6f50c049c8..fe47adc0a3c 100644 --- a/shared-data/deck/definitions/5/ot3_standard.json +++ b/shared-data/deck/definitions/5/ot3_standard.json @@ -831,30 +831,6 @@ "slot": "A3", "labware": "opentrons_1_trash_3200ml_fixed", "displayName": "Fixed Trash" - }, - { - "id": "absorbanceReaderV1LidA3", - "slot": "absorbanceReaderV1A3", - "labware": "opentrons_flex_lid_absorbance_plate_reader_module", - "displayName": "Plate Reader Lid" - }, - { - "id": "absorbanceReaderV1LidB3", - "slot": "absorbanceReaderV1B3", - "labware": "opentrons_flex_lid_absorbance_plate_reader_module", - "displayName": "Plate Reader Lid" - }, - { - "id": "absorbanceReaderV1LidC3", - "slot": "absorbanceReaderV1C3", - "labware": "opentrons_flex_lid_absorbance_plate_reader_module", - "displayName": "Plate Reader Lid" - }, - { - "id": "absorbanceReaderV1LidD3", - "slot": "absorbanceReaderV1D3", - "labware": "opentrons_flex_lid_absorbance_plate_reader_module", - "displayName": "Plate Reader Lid" } ] }, From 2e25a45969148a82589d13c0d5f016ff11004484 Mon Sep 17 00:00:00 2001 From: fbelginetw <167361860+fbelginetw@users.noreply.github.com> Date: Tue, 12 Nov 2024 13:18:57 -0500 Subject: [PATCH 35/49] feat: Opentrons AI Client - Steps section (#16748) # Overview This PR adds the Steps section to the create protocol flow. ![image](https://github.com/user-attachments/assets/57c91ae7-de64-44b7-88fe-37668d326672) ## Test Plan and Hands on Testing - On the landing page click Create a new protocol button, you will be redirected to the new page - fill up the required information in the Application, Instruments, Modules and Labwares & Liquids sections and click Confirm - You now can add individual Steps or paste from document - The Prompt Preview component is updated with the data entered. ## Changelog - Add Steps section ## Review requests - Verify new section. ## Risk assessment - low --- .../localization/en/create_protocol.json | 20 +- .../atoms/ControlledTextAreaField/index.tsx | 39 ++ .../src/atoms/TextAreaField/index.tsx | 360 ++++++++++++++++++ .../ControlledAddTextAreaFields.test.tsx | 95 +++++ .../ControlledAddTextAreaFields/index.tsx | 89 +++++ .../ControlledLabwareListItems.test.tsx | 2 +- .../src/molecules/PromptPreview/index.tsx | 1 + .../molecules/PromptPreviewSection/index.tsx | 14 +- .../__tests__/ApplicationSection.test.tsx | 5 +- .../__tests__/InstrumentsSection.test.tsx | 48 ++- .../organisms/InstrumentsSection/index.tsx | 15 +- .../organisms/LabwareLiquidsSection/index.tsx | 15 +- .../src/organisms/LabwareModal/index.tsx | 5 +- .../__tests__/ModulesSection.test.tsx | 16 +- .../ProtocolSectionsContainer/index.tsx | 3 +- .../__tests__/StepsSection.test.tsx | 129 +++++++ .../src/organisms/StepsSection/index.tsx | 163 ++++++++ .../__tests__/CreateProtocol.test.tsx | 40 ++ .../src/pages/CreateProtocol/index.tsx | 4 + .../resources/utils/createProtocolUtils.tsx | 20 + 20 files changed, 1049 insertions(+), 34 deletions(-) create mode 100644 opentrons-ai-client/src/atoms/ControlledTextAreaField/index.tsx create mode 100644 opentrons-ai-client/src/atoms/TextAreaField/index.tsx create mode 100644 opentrons-ai-client/src/molecules/ControlledAddTextAreaFields/__tests__/ControlledAddTextAreaFields.test.tsx create mode 100644 opentrons-ai-client/src/molecules/ControlledAddTextAreaFields/index.tsx create mode 100644 opentrons-ai-client/src/organisms/StepsSection/__tests__/StepsSection.test.tsx create mode 100644 opentrons-ai-client/src/organisms/StepsSection/index.tsx diff --git a/opentrons-ai-client/src/assets/localization/en/create_protocol.json b/opentrons-ai-client/src/assets/localization/en/create_protocol.json index 3d6e6735660..b3abd8522ba 100644 --- a/opentrons-ai-client/src/assets/localization/en/create_protocol.json +++ b/opentrons-ai-client/src/assets/localization/en/create_protocol.json @@ -46,8 +46,8 @@ "tubeRack": "Tube rack", "wellPlate": "Well plate", "no_labwares_added_yet": "No labware added yet", - "labwares_quantity_label": "quantity", - "labwares_remove_label": "remove", + "labwares_quantity_label": "Quantity", + "labwares_remove_label": "Remove", "labwares_save_label": "Save", "labwares_cancel_label": "Cancel", "search_for_labware_placeholder": "Search for labware...", @@ -59,5 +59,19 @@ "add_opentrons_liquid": "Add Liquid", "add_liquid_caption": "Example: \"Add 1.5mL of master mix to all the wells in the first column of the deep well plate.\"", "liquid": "Liquid", - "remove_liquid": "Remove" + "remove_liquid": "Remove", + "steps_section_title": "Steps", + "steps_section_textbody": "Give step-by-step instructions on how to handle liquids, with quantities in microliters (µL) and exact source and destination locations within labware. Always err on the side of providing extra information!", + "add_individual_step": "Add individual steps", + "paste_from_document": "Paste from document", + "paste_from_document_title": "Paste the steps from your document. Make sure your steps are clearly numbered.", + "paste_from_document_input_title": "Steps", + "paste_from_document_input_caption_1": "Example:", + "paste_from_document_input_caption_2": "Use right pipette to transfer 15 uL of mastermix from source well to destination well. Use the same pipette tip for all transfers.", + "paste_from_document_input_caption_3": "Use left pipette to transfer 10 ul of sample from the source to destination well. Mix the sample and mastermix of 25 ul total volume 9 times. Blow out to `destination well`. Use a new tip for each transfer.", + "add_step": "Add step", + "remove_step": "Remove", + "step": "Step", + "add_step_caption": "Example: \"Transfer 10 μL from each of the wells in the source labware to the same wells in the destination labware. Use a new tip for each transfer.", + "none": "None" } diff --git a/opentrons-ai-client/src/atoms/ControlledTextAreaField/index.tsx b/opentrons-ai-client/src/atoms/ControlledTextAreaField/index.tsx new file mode 100644 index 00000000000..b7bc92d30a0 --- /dev/null +++ b/opentrons-ai-client/src/atoms/ControlledTextAreaField/index.tsx @@ -0,0 +1,39 @@ +import { Controller } from 'react-hook-form' +import { TextAreaField } from '../TextAreaField' + +interface ControlledTextAreaFieldProps { + id?: string + name: string + rules?: any + title?: string + caption?: string + height?: string +} + +export function ControlledTextAreaField({ + id, + name, + rules, + title, + caption, + height, +}: ControlledTextAreaFieldProps): JSX.Element { + return ( + ( + + )} + /> + ) +} diff --git a/opentrons-ai-client/src/atoms/TextAreaField/index.tsx b/opentrons-ai-client/src/atoms/TextAreaField/index.tsx new file mode 100644 index 00000000000..d1c1f0d8c17 --- /dev/null +++ b/opentrons-ai-client/src/atoms/TextAreaField/index.tsx @@ -0,0 +1,360 @@ +import { + TYPOGRAPHY, + useHoverTooltip, + RESPONSIVENESS, + SPACING, + COLORS, + BORDERS, + Flex, + ALIGN_CENTER, + DIRECTION_COLUMN, + DIRECTION_ROW, + StyledText, + Icon, + Tooltip, + TEXT_ALIGN_RIGHT, +} from '@opentrons/components' +import type { IconName } from '@opentrons/components' +import * as React from 'react' +import styled, { css } from 'styled-components' + +export const INPUT_TYPE_NUMBER = 'number' as const +export const LEGACY_INPUT_TYPE_TEXT = 'text' as const +export const LEGACY_INPUT_TYPE_PASSWORD = 'password' as const +const COLOR_WARNING_DARK = '#9e5e00' // ToDo (kk:08/13/2024) replace this with COLORS + +export interface TextAreaFieldProps { + /** field is disabled if value is true */ + disabled?: boolean + /** change handler */ + onChange?: React.ChangeEventHandler + /** name of field in form */ + name?: string + /** optional ID of