diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/fill.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/fill.py index 8dfbfc8890a..fca7380cc9b 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/fill.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/fill.py @@ -1,4 +1,4 @@ -"""Command models to engage a user to empty a Flex Stacker.""" +"""Command models to engage a user to fill a Flex Stacker.""" from __future__ import annotations from typing import Optional, Literal, TYPE_CHECKING, Annotated diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py index b8b2bfa874f..5f7afe6d980 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py @@ -1,4 +1,4 @@ -"""Command models to retrieve a labware from a Flex Stacker.""" +"""Command models to store a labware in a Flex Stacker.""" from __future__ import annotations from typing import Optional, Literal, TYPE_CHECKING, Type, Union, cast diff --git a/protocol-designer/src/step-forms/selectors/index.ts b/protocol-designer/src/step-forms/selectors/index.ts index d31b6cf0759..c3bf64cda8d 100644 --- a/protocol-designer/src/step-forms/selectors/index.ts +++ b/protocol-designer/src/step-forms/selectors/index.ts @@ -230,6 +230,14 @@ const ABSORBANCE_READER_INITIAL_STATE: AbsorbanceReaderState = { } const FLEX_STACKER_INITIAL_STATE: FlexStackerModuleState = { type: FLEX_STACKER_MODULE_TYPE, + latchOpen: null, + maxPoolCount: 0, + storedLabwareDetails: null, + shuttlePosition: 'home', + labwareInStacker: null, + labwareInShuttle: null, + labwareRetrieved: null, + labwareStored: null, } const MODULE_INITIAL_STATES_MAP: Record< diff --git a/protocol-designer/src/step-forms/types.ts b/protocol-designer/src/step-forms/types.ts index 2e7bd754846..1aaa92cb27d 100644 --- a/protocol-designer/src/step-forms/types.ts +++ b/protocol-designer/src/step-forms/types.ts @@ -4,6 +4,7 @@ import type { CutoutId, FLEX_STACKER_MODULE_TYPE, FlexModuleCutoutFixtureId, + FlexStackerSetStoredLabwareParams, HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE, MAGNETIC_MODULE_TYPE, @@ -71,7 +72,14 @@ export interface MagneticBlockState { export interface FlexStackerModuleState { type: typeof FLEX_STACKER_MODULE_TYPE - // TODO: extend this state + latchOpen: boolean | null + maxPoolCount: number + storedLabwareDetails: FlexStackerSetStoredLabwareParams | null + shuttlePosition: 'home' | 'retrieved' | 'stored' + labwareInStacker: string[] | null + labwareInShuttle: number | null + labwareRetrieved: number | null + labwareStored: number | null } export type InitializationMode = 'single' | 'multi' export interface Initialization { diff --git a/shared-data/command/types/module.ts b/shared-data/command/types/module.ts index e2879bba875..ab54d16585e 100644 --- a/shared-data/command/types/module.ts +++ b/shared-data/command/types/module.ts @@ -461,17 +461,19 @@ interface StackerStoredLabwareDefinitionURIs { lidLabwareURI?: string | null } +export interface FlexStackerSetStoredLabwareParams { + moduleId: string + initialCount?: number | null + initialStoredLabware?: FlexStackerStoredLabwareGroup[] | null + primaryLabware: FlexStackerStoredLabwareDetails + lidLabware: FlexStackerStoredLabwareDetails | null + adapterLabware: FlexStackerStoredLabwareDetails | null +} + export interface FlexStackerSetStoredLabwareCreateCommand extends CommonCommandCreateInfo { commandType: 'flexStacker/setStoredLabware' - params: { - moduleId: string - initialCount?: number | null - initialStoredLabware?: FlexStackerStoredLabwareGroup[] | null - primaryLabware: FlexStackerStoredLabwareDetails - lidLabware: FlexStackerStoredLabwareDetails | null - adapterLabware: FlexStackerStoredLabwareDetails | null - } + params: FlexStackerSetStoredLabwareParams } export interface FlexStackerSetStoredLabwareRunTimeCommand @@ -502,25 +504,29 @@ export interface FlexStackerStoreCreateCommand extends CommonCommandCreateInfo { } } +export interface FlexStackerFillParams { + moduleId: string + strategy: 'manualWithPause' | 'logical' + message?: string + count?: number + labwareToStore?: FlexStackerStoredLabwareGroup[] +} + export interface FlexStackerFillCreateCommand extends CommonCommandCreateInfo { commandType: 'flexStacker/fill' - params: { - moduleId: string - strategy: 'manualWithPause' | 'logical' - message?: string - count?: number - labwareToStore?: FlexStackerStoredLabwareGroup[] - } + params: FlexStackerFillParams +} + +export interface FlexStackerEmptyParams { + moduleId: string + strategy: 'manualWithPause' | 'logical' + message?: string + count?: number } export interface FlexStackerEmptyCreateCommand extends CommonCommandCreateInfo { commandType: 'flexStacker/empty' - params: { - moduleId: string - strategy: 'manualWithPause' | 'logical' - message?: string - count?: number - } + params: FlexStackerEmptyParams } export interface FlexStackerPrepareShuttleCreateCommand diff --git a/shared-data/js/fixtures.ts b/shared-data/js/fixtures.ts index b6b19cb252c..39941423e0f 100644 --- a/shared-data/js/fixtures.ts +++ b/shared-data/js/fixtures.ts @@ -868,6 +868,7 @@ export function getFixtureDisplayName( } } +// TODO: Move to helpers/deckDeclarationHelpers.ts export const STANDARD_OT2_SLOTS: AddressableAreaName[] = [ ADDRESSABLE_AREA_1, ADDRESSABLE_AREA_2, @@ -882,6 +883,7 @@ export const STANDARD_OT2_SLOTS: AddressableAreaName[] = [ ADDRESSABLE_AREA_11, ] +// TODO: Move to helpers export const STANDARD_FLEX_SLOTS: AddressableAreaName[] = [ A1_ADDRESSABLE_AREA, A2_ADDRESSABLE_AREA, diff --git a/shared-data/js/helpers/__tests__/getFlexStackerHardwareProps.test.ts b/shared-data/js/helpers/__tests__/getFlexStackerHardwareProps.test.ts new file mode 100644 index 00000000000..19ee02005fc --- /dev/null +++ b/shared-data/js/helpers/__tests__/getFlexStackerHardwareProps.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import { + fixtureTiprack1000ul, + FLEX_STACKER_MODULE_V1, + getSchema2Dimensions, + MAGNETIC_MODULE_V1, +} from '../..' +import { + getHeightOfLabwareStackFromDefinitions, + getLabwareOverlapOffset, + getModuleMaxFillHeight, + getStackerMaxPoolCountByHeight, +} from '../getFlexStackerHardwareProps' + +import type { LabwareDefinition2 } from '../..' + +describe('getModuleMaxFillHeight()', () => { + it('should return the max fill height for a given module model', () => { + expect(getModuleMaxFillHeight(FLEX_STACKER_MODULE_V1)).toBe(612.75) + }) +}) + +describe('getStackerMaxPoolCountByHeight()', () => { + it('should return the max pool count by height for a given module model', () => { + expect(getStackerMaxPoolCountByHeight(FLEX_STACKER_MODULE_V1, 100, 0)).toBe( + 6 + ) + }) + + it('should throw an error if the module model is invalid', () => { + expect(() => + getStackerMaxPoolCountByHeight(MAGNETIC_MODULE_V1, 100, 0) + ).toThrow( + 'Invalid module model for max pool count by height: magneticModuleV1' + ) + }) +}) + +describe('getLabwareOverlapOffset()', () => { + const mockLabwareDefinition = fixtureTiprack1000ul as LabwareDefinition2 + + it('should return the labware overlap offset for a given module model', () => { + const result = getLabwareOverlapOffset( + FLEX_STACKER_MODULE_V1, + mockLabwareDefinition, + 'labware-name' + ) + expect(result).toStrictEqual({ x: 0, y: 0, z: 0 }) + }) + + it('should throw an error if the module model is invalid', () => { + expect(() => + getLabwareOverlapOffset( + MAGNETIC_MODULE_V1, + mockLabwareDefinition, + 'labware-name' + ) + ).toThrow( + 'Invalid module model for labware overlap offset: magneticModuleV1' + ) + }) +}) + +describe('getHeightOfLabwareStackFromDefinitions()', () => { + it('should return the height of a stack of labware from definitions', () => { + const mockLabwareDefinition = fixtureTiprack1000ul as LabwareDefinition2 + const result = getHeightOfLabwareStackFromDefinitions([ + mockLabwareDefinition, + ]) + expect(result).toBe(getSchema2Dimensions(mockLabwareDefinition).zDimension) + }) + + it('should return 0 if the definitions are empty', () => { + const result = getHeightOfLabwareStackFromDefinitions([]) + expect(result).toBe(0) + }) + + it('should return the height of a stack of labware from definitions', () => { + const mockLabwareDefinition = fixtureTiprack1000ul as LabwareDefinition2 + const result = getHeightOfLabwareStackFromDefinitions([ + mockLabwareDefinition, + mockLabwareDefinition, + ]) + expect(result).toBe( + getSchema2Dimensions(mockLabwareDefinition).zDimension * 2 + ) + }) +}) diff --git a/shared-data/js/helpers/__tests__/symbolicPositionHelpers.test.ts b/shared-data/js/helpers/__tests__/symbolicPositionHelpers.test.ts new file mode 100644 index 00000000000..e24d461bac1 --- /dev/null +++ b/shared-data/js/helpers/__tests__/symbolicPositionHelpers.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { STANDARD_FLEX_SLOTS, STANDARD_OT2_SLOTS } from '../../fixtures' +import { getIsValidSlotName } from '../symbolicPositionHelpers' + +describe('getIsSlotValid', () => { + beforeEach(() => {}) + + it('returns true for a valid slot', () => { + STANDARD_FLEX_SLOTS.forEach(slot => { + expect(getIsValidSlotName(slot)).toBe(true) + }) + STANDARD_OT2_SLOTS.forEach(slot => { + expect(getIsValidSlotName(slot)).toBe(true) + }) + }) + it('returns false for an invalid slot', () => { + expect(getIsValidSlotName('13')).toBe(false) + }) + it('returns false for an invalid slot', () => { + expect(getIsValidSlotName('A5')).toBe(false) + }) +}) diff --git a/shared-data/js/helpers/getFlexStackerHardwareProps.ts b/shared-data/js/helpers/getFlexStackerHardwareProps.ts new file mode 100644 index 00000000000..f1b51ba307f --- /dev/null +++ b/shared-data/js/helpers/getFlexStackerHardwareProps.ts @@ -0,0 +1,77 @@ +import { FLEX_STACKER_MODULE_V1 } from '../constants' +import { getModuleDef } from '../modules' +import { getSchema2Dimensions } from './positionMath' + +import type { LabwareDefinition, ModuleModel, Vector3D } from '../types' + +export const getModuleMaxFillHeight = (model: ModuleModel): number => { + if (model === FLEX_STACKER_MODULE_V1) { + return ( + getModuleDef(FLEX_STACKER_MODULE_V1).dimensions.maxStackerFillHeight ?? 0 + ) + } + throw new Error(`Invalid module model for max fill height: ${model}`) +} + +export const getStackerMaxPoolCountByHeight = ( + model: ModuleModel, + poolHeight: number, + poolOverlap: number +): number => { + if (model === FLEX_STACKER_MODULE_V1) { + const maxFillHeight = getModuleMaxFillHeight(model) + if (maxFillHeight <= 0) { + throw new Error( + `Invalid max fill height for ${model}: ${maxFillHeight} must be greater than 0` + ) + } + return Math.floor( + (maxFillHeight - poolOverlap) / (poolHeight - poolOverlap) + ) + } + throw new Error(`Invalid module model for max pool count by height: ${model}`) +} + +export const getLabwareOverlapOffset = ( + model: ModuleModel, + definition: LabwareDefinition, + belowLabwareName: string +): Vector3D => { + if (model !== FLEX_STACKER_MODULE_V1) { + throw new Error(`Invalid module model for labware overlap offset: ${model}`) + } + if ( + belowLabwareName in Object.keys(definition.stackingOffsetWithLabware ?? {}) + ) { + return ( + definition.stackingOffsetWithLabware?.[belowLabwareName] ?? { + x: 0, + y: 0, + z: 0, + } + ) + } + return definition.stackingOffsetWithLabware?.default ?? { x: 0, y: 0, z: 0 } +} + +export const getHeightOfLabwareStackFromDefinitions = ( + definitions: LabwareDefinition[] +): number => { + if (definitions.length === 0) { + return 0 + } + let total_height = 0.0 + let upper_def: LabwareDefinition = definitions[0] + for (const lower_def of definitions.slice(1)) { + const overlap = getLabwareOverlapOffset( + FLEX_STACKER_MODULE_V1, + upper_def, + lower_def.parameters.loadName + ).z + total_height += getSchema2Dimensions(upper_def).zDimension - overlap + upper_def = lower_def + } + return total_height + getSchema2Dimensions(upper_def).zDimension +} +// export const getModuleMaxRetrievableHeight = (model: ModuleModel): number => +// getModuleDef(model).dimensions.maxStackerRetrievableHeight ?? 0 diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index da0fd82c797..00b65c2f69e 100644 --- a/shared-data/js/helpers/index.ts +++ b/shared-data/js/helpers/index.ts @@ -36,6 +36,7 @@ export * from './matrixMath' export * from './getLoadedLabwareDefinitionsByUri' export * from './getFixedTrashLabwareDefinition' export * from './getOccludedSlotCountForModule' +export * from './getFlexStackerHardwareProps' export * from './labwareInference' export * from './linearInterpolate' export * from './liquidClasses' diff --git a/shared-data/js/helpers/symbolicPositionHelpers.ts b/shared-data/js/helpers/symbolicPositionHelpers.ts index c0c32e0e3af..4f2b9622131 100644 --- a/shared-data/js/helpers/symbolicPositionHelpers.ts +++ b/shared-data/js/helpers/symbolicPositionHelpers.ts @@ -1,8 +1,10 @@ +import { STANDARD_FLEX_SLOTS, STANDARD_OT2_SLOTS } from '../fixtures' + import type { LabwareLocation, OnDeckLabwareLocation, } from '../../command/types/setup' -import type { AddressableAreaName } from '../../js' +import type { AddressableAreaName } from '../../deck' export const changeAnyUseOfMeToPreserveStructure_thisIsAnOffDeckLocationInASlotName = (quoteUnquoteSlotName: string): boolean => @@ -41,3 +43,14 @@ export const locationIsOnAddressableArea = ( labwareLocation: LabwareLocation ): labwareLocation is { addressableAreaName: AddressableAreaName } => locationIsOnDeck(labwareLocation) && 'addressableAreaName' in labwareLocation + +export const getIsValidSlotName = (slot: string): boolean => { + return ( + STANDARD_OT2_SLOTS.includes(slot as AddressableAreaName) || + STANDARD_FLEX_SLOTS.includes(slot as AddressableAreaName) || + slot === 'A4' || + slot === 'B4' || + slot === 'C4' || + slot === 'D4' + ) +} diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 1e9ea7d7bf6..ea24e65ce88 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -566,6 +566,8 @@ export interface ModuleDimensions { labwareInterfaceXDimension?: number labwareInterfaceYDimension?: number lidHeight?: number + maxStackerFillHeight?: number + maxStackerRetrievableHeight?: number } export interface ModuleCalibrationPoint { diff --git a/step-generation/src/__tests__/flexStackerEmpty.test.ts b/step-generation/src/__tests__/flexStackerEmpty.test.ts new file mode 100644 index 00000000000..141cf362382 --- /dev/null +++ b/step-generation/src/__tests__/flexStackerEmpty.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + FLEX_STACKER_MODULE_TYPE, + FLEX_STACKER_MODULE_V1, +} from '@opentrons/shared-data' + +import { flexStackerEmpty } from '../commandCreators/atomic/flexStackerEmpty' +import { + getErrorResult, + getInitialRobotStateStandard, + makeContext, +} from '../fixtures' +import { flexStackerStateGetter } from '../robotStateSelectors' + +import type { + FlexStackerModuleState, + InvariantContext, + RobotState, +} from '../types' + +const moduleId = 'flexStackerId' +const gripperId = 'gripperId' +vi.mock('../robotStateSelectors') + +describe('flexStackerEmpty', () => { + let invariantContext: InvariantContext + let robotState: RobotState + beforeEach(() => { + invariantContext = makeContext() + invariantContext.moduleEntities[moduleId] = { + id: moduleId, + type: FLEX_STACKER_MODULE_TYPE, + model: FLEX_STACKER_MODULE_V1, + pythonName: 'mock_flex_stacker_1', + } + invariantContext.gripperEntities[gripperId] = { + id: gripperId, + } + + robotState = getInitialRobotStateStandard(invariantContext) + robotState.modules[moduleId] = { + slot: 'D3', + moduleState: { + type: FLEX_STACKER_MODULE_TYPE, + maxPoolCount: 6, + latchOpen: false, + storedLabwareDetails: null, + shuttlePosition: 'home', + labwareIdsInStacker: null, + }, + } + vi.mocked(flexStackerStateGetter).mockReturnValue( + {} as FlexStackerModuleState + ) + }) + it('creates flex stacker empty command', () => { + const result = flexStackerEmpty( + { + moduleId, + strategy: 'logical', + }, + invariantContext, + robotState + ) + expect(result).toEqual({ + commands: [ + { + commandType: 'flexStacker/empty', + key: expect.any(String), + params: { + moduleId, + strategy: 'logical', + message: undefined, + count: undefined, + }, + }, + ], + python: 'mock_flex_stacker_1.empty()', + }) + }) + it('creates returns error if bad module state', () => { + vi.mocked(flexStackerStateGetter).mockReturnValue(null) + const result = flexStackerEmpty( + { + moduleId, + strategy: 'logical', + }, + invariantContext, + robotState + ) + expect(getErrorResult(result).errors).toHaveLength(1) + expect(getErrorResult(result).errors[0]).toMatchObject({ + type: 'MISSING_MODULE', + }) + }) + it('creates returns error if no gripper', () => { + invariantContext.gripperEntities = {} + const result = flexStackerEmpty( + { + moduleId, + strategy: 'logical', + }, + invariantContext, + robotState + ) + expect(getErrorResult(result).errors).toHaveLength(1) + expect(getErrorResult(result).errors[0]).toMatchObject({ + type: 'FLEX_STACKER_NO_GRIPPER', + }) + }) +}) diff --git a/step-generation/src/__tests__/pythonFileUtils.test.ts b/step-generation/src/__tests__/pythonFileUtils.test.ts index 4cd9bed5cd0..c93a567e20e 100644 --- a/step-generation/src/__tests__/pythonFileUtils.test.ts +++ b/step-generation/src/__tests__/pythonFileUtils.test.ts @@ -8,6 +8,8 @@ import { fixtureTiprack1000ul, fixtureTiprackAdapter, FLEX_ROBOT_TYPE, + FLEX_STACKER_MODULE_TYPE, + FLEX_STACKER_MODULE_V1, GLYCEROL_LIQUID_CLASS_NAME, HEATERSHAKER_MODULE_TYPE, HEATERSHAKER_MODULE_V1, @@ -108,6 +110,7 @@ describe('pythonRequirements', () => { const moduleId = '1' const moduleId2 = '2' const moduleId3 = '3' +const moduleId4 = '4' const mockModuleEntities: ModuleEntities = { [moduleId]: { id: moduleId, @@ -137,6 +140,7 @@ const labwareId6 = 'labwareId6' const labwareId7 = 'labwareId7' const labwareId8 = 'labwareId8' const deckRiserId = 'deckRiserId' +const flexStackerLabwareId = 'flexStackerLabwareId' const mockLabwareEntities: LabwareEntities = { [labwareId1]: { id: labwareId1, @@ -420,6 +424,72 @@ well_plate_3 = protocol.load_labware_from_definition( )`.trimStart() ) }) + it('should generate loadLabware for a flex stacker', () => { + const mockModuleEntitiesWithFlexStackerModule = { + ...mockModuleEntities, + [moduleId4]: { + ...mockModuleEntities[moduleId4], + id: moduleId4, + model: FLEX_STACKER_MODULE_V1, + type: FLEX_STACKER_MODULE_TYPE, + pythonName: 'flex_stacker_1', + }, + } + const mockLabwareEntitiesWithFlexStackerLabware = { + ...mockLabwareEntities, + [flexStackerLabwareId]: { + id: flexStackerLabwareId, + labwareDefURI: 'opentrons/fixture_96_plate/1', + def: opentrons96Plate as LabwareDefinition2, + pythonName: 'well_plate_4', + }, + } + + const mockLabwareRobotStateWithFlexStackerLabware = { + ...labwareRobotState, + [flexStackerLabwareId]: { + ...labwareRobotState[labwareId6], + stack: [flexStackerLabwareId, moduleId4, 'A4'], + }, + } + + const loadLabware = getLoadLabware( + mockModuleEntitiesWithFlexStackerModule, + mockLabwareEntitiesWithFlexStackerLabware, + mockLabwareRobotStateWithFlexStackerLabware, + mockLabwareNicknames + ) + + expect(loadLabware).toBe( + ` + # Load Labware: +well_plate_1 = adapter_2.load_labware( + "fixture_96_plate", + label="reagent plate", + namespace="opentrons", + version=1, +) +well_plate_2 = magnetic_block_2.load_labware( + "fixture_96_plate", + namespace="opentrons", + version=1, + lid="mock_lid", + lid_namespace="opentrons", + lid_version=1, +) +well_plate_3 = protocol.load_labware_from_definition( + CUSTOM_LABWARE["fixture/fixture_96_plate/1"], + location="C2", + label="sample plate", +) +well_plate_4 = flex_stacker_1.load_labware( + "fixture_96_plate", + namespace="opentrons", + version=1, +) +well_plate_4 = flex_stacker_1.set_stored_labware(fixture_96_plate, opentrons, 1, count=0)`.trimStart() + ) + }) }) describe('getLoadPipettes', () => { diff --git a/step-generation/src/__tests__/stackerUpdates.test.ts b/step-generation/src/__tests__/stackerUpdates.test.ts new file mode 100644 index 00000000000..20c101fb4bb --- /dev/null +++ b/step-generation/src/__tests__/stackerUpdates.test.ts @@ -0,0 +1,298 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + FLEX_STACKER_MODULE_TYPE, + getHeightOfLabwareStackFromDefinitions, + getLabwareOverlapOffset, + getStackerMaxPoolCountByHeight, +} from '@opentrons/shared-data' + +import { getInitialRobotStateStandard, makeContext } from '../fixtures' +import { + forFlexStackerEmpty, + forFlexStackerFill, + forFlexStackerRetrieve, + forFlexStackerStore, +} from '../getNextRobotStateAndWarnings/stackerUpdates' +import { getModuleState } from '../robotStateSelectors' + +import type { FlexStackerModuleState } from '../types' + +vi.mock('../robotStateSelectors') +vi.mock('@opentrons/shared-data', async importOriginal => ({ + ...(await importOriginal()), + getHeightOfLabwareStackFromDefinitions: vi.fn(), + getStackerMaxPoolCountByHeight: vi.fn(), + getLabwareOverlapOffset: vi.fn(), +})) + +const LABWARE_ID = 'sourcePlateId' +const FLEX_STACKER_ID = 'flexStackerId' + +describe('flex stacker state updates forFlexStackerEmpty', () => { + const FLEX_STACKER_ID = 'flexStackerId' + const invariantContext = makeContext() + const robotState = getInitialRobotStateStandard(invariantContext) + beforeEach(() => { + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareIdsInStacker: ['labware1', 'labware2', 'labware3'], + maxPoolCount: 6, + labwareStored: LABWARE_ID, + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should remove the last item from the stored stacker list', () => { + const props = { + count: 1, + moduleId: FLEX_STACKER_ID, + strategy: 'logical' as const, + } + forFlexStackerEmpty(props, invariantContext, { + robotState: robotState, + warnings: [], + }) + + const moduleState = getModuleState( + robotState, + FLEX_STACKER_ID + ) as FlexStackerModuleState + expect(moduleState?.labwareIdsInStacker).toEqual(['labware2', 'labware3']) + }) + + it('should remove all items from the stored stacker list if count is null', () => { + const props = { + moduleId: FLEX_STACKER_ID, + strategy: 'logical' as const, + } + forFlexStackerEmpty(props, invariantContext, { + robotState: robotState, + warnings: [], + }) + + const moduleState = getModuleState( + robotState, + FLEX_STACKER_ID + ) as FlexStackerModuleState + expect(moduleState?.labwareIdsInStacker).toBeNull() + }) + + it('should remove all items from the stored stacker list if count is null', () => { + const props = { + moduleId: FLEX_STACKER_ID, + strategy: 'logical' as const, + } + forFlexStackerEmpty(props, invariantContext, { + robotState: robotState, + warnings: [], + }) + + const moduleState = getModuleState( + robotState, + FLEX_STACKER_ID + ) as FlexStackerModuleState + expect(moduleState?.labwareIdsInStacker).toBeNull() + }) +}) + +describe('flex stacker state updates forFlexStackerFill', () => { + const invariantContext = makeContext() + const robotState = getInitialRobotStateStandard(invariantContext) + beforeEach(() => { + vi.mocked(getLabwareOverlapOffset).mockReturnValue({ x: 0, y: 0, z: 10 }) + vi.mocked(getHeightOfLabwareStackFromDefinitions).mockReturnValue(10) + vi.mocked(getStackerMaxPoolCountByHeight).mockReturnValue(10) + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareIdsInStacker: ['labware1', 'labware2', 'labware3'], + max_pool_count: 6, + labwareStored: LABWARE_ID, + } as any) + }) + + it('should add the specified number of items to the stored stacker list', () => { + const props = { + count: 1, + moduleId: FLEX_STACKER_ID, + strategy: 'logical' as const, + } + forFlexStackerFill(props, invariantContext, { + robotState: robotState, + warnings: [], + }) + + const moduleState = getModuleState( + robotState, + FLEX_STACKER_ID + ) as FlexStackerModuleState + expect(moduleState?.labwareIdsInStacker).toHaveLength(4) + }) + + it('should not add labware to the list if count is null', () => { + const props = { + moduleId: FLEX_STACKER_ID, + strategy: 'logical' as const, + } + forFlexStackerFill(props, invariantContext, { + robotState: robotState, + warnings: [], + }) + + const moduleState = getModuleState( + robotState, + FLEX_STACKER_ID + ) as FlexStackerModuleState + expect(moduleState?.labwareIdsInStacker).toHaveLength(3) + }) + + it('should not add labware to the list if count is greater than maxPoolCount', () => { + const props = { + count: 15, + moduleId: FLEX_STACKER_ID, + strategy: 'logical' as const, + } + forFlexStackerFill(props, invariantContext, { robotState, warnings: [] }) + + const moduleState = getModuleState( + robotState, + FLEX_STACKER_ID + ) as FlexStackerModuleState + expect(moduleState?.labwareIdsInStacker).toHaveLength(3) + }) +}) + +describe('flex stacker state updates forFlexStackerRetrieve', () => { + const invariantContext = makeContext() + const robotState = getInitialRobotStateStandard(invariantContext) + beforeEach(() => { + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareIdsInStacker: ['tiprack1Id', 'tiprack2Id', 'tiprack4AdapterId'], + max_pool_count: 6, + labwareStored: LABWARE_ID, + } as any) + }) + + it('should raise an error if there is no labware in the stacker', () => { + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareIdsInStacker: [], + max_pool_count: 6, + labwareStored: LABWARE_ID, + } as any) + expect(() => { + forFlexStackerRetrieve({ moduleId: FLEX_STACKER_ID }, invariantContext, { + robotState, + warnings: [], + }) + }).toThrow('Cannot retrieve labware bc there is no labware in the stacker') + }) + + it('should raise an error if there is labware on the shuttle', () => { + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareIdsInStacker: ['tiprack1Id', 'tiprack2Id', 'tiprack4AdapterId'], + max_pool_count: 6, + labwareStored: LABWARE_ID, + shuttlePosition: 'retrieved', + } as any) + expect(() => { + forFlexStackerRetrieve({ moduleId: FLEX_STACKER_ID }, invariantContext, { + robotState, + warnings: [], + }) + }).toThrow('Cannot retrieve labware bc there is labware on the shuttle') + }) + + it('should raise an error if there is no stored labware details or primary labware', () => { + expect(() => { + forFlexStackerRetrieve({ moduleId: FLEX_STACKER_ID }, invariantContext, { + robotState, + warnings: [], + }) + }).toThrow( + 'Cannot retrieve labware bc there is no stored labware details or primary labware' + ) + }) + + it('should retrieve the labware from the stacker', () => { + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareIdsInStacker: ['tiprack1Id', 'tiprack2Id', 'tiprack4AdapterId'], + max_pool_count: 6, + labwareStored: LABWARE_ID, + shuttlePosition: 'home', + storedLabwareDetails: { + primaryLabware: LABWARE_ID, + }, + } as any) + + forFlexStackerRetrieve({ moduleId: FLEX_STACKER_ID }, invariantContext, { + robotState, + warnings: [], + }) + + const moduleState = getModuleState( + robotState, + FLEX_STACKER_ID + ) as FlexStackerModuleState + expect(moduleState?.shuttlePosition).toBe('retrieved') + expect(moduleState?.labwareIdsInStacker).toHaveLength(2) + expect(robotState.labware.tiprack1Id?.stack).toHaveLength(1) + }) +}) + +describe('flex stacker state updates forFlexStackerStore', () => { + const invariantContext = makeContext() + const robotState = getInitialRobotStateStandard(invariantContext) + robotState.modules[FLEX_STACKER_ID] = { + slot: '1', + moduleState: { + type: FLEX_STACKER_MODULE_TYPE, + labwareIdsInStacker: ['tiprack1Id', 'tiprack2Id', 'tiprack4AdapterId'], + max_pool_count: 6, + labwareStored: LABWARE_ID, + storedLabwareDetails: { + primaryLabware: { + id: LABWARE_ID, + def: invariantContext.labwareEntities[LABWARE_ID]?.def, + }, + }, + } as any, + } + beforeEach(() => { + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareIdsInStacker: ['tiprack1Id', 'tiprack2Id', 'tiprack4AdapterId'], + max_pool_count: 6, + labwareStored: LABWARE_ID, + storedLabwareDetails: { + primaryLabware: { + id: LABWARE_ID, + def: invariantContext.labwareEntities[LABWARE_ID]?.def, + }, + }, + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should store the labware in the stacker', () => { + forFlexStackerStore({ moduleId: FLEX_STACKER_ID }, invariantContext, { + robotState, + warnings: [], + }) + const moduleState = getModuleState( + robotState, + FLEX_STACKER_ID + ) as FlexStackerModuleState + expect(moduleState?.shuttlePosition).toBe('stored') + expect(moduleState?.labwareIdsInStacker).toHaveLength(4) + }) +}) diff --git a/step-generation/src/commandCreators/atomic/flexStackerEmpty.ts b/step-generation/src/commandCreators/atomic/flexStackerEmpty.ts new file mode 100644 index 00000000000..211419b13bb --- /dev/null +++ b/step-generation/src/commandCreators/atomic/flexStackerEmpty.ts @@ -0,0 +1,43 @@ +import * as errorCreators from '../../errorCreators' +import { flexStackerStateGetter } from '../../robotStateSelectors' +import { uuid } from '../../utils' + +import type { FlexStackerEmptyCreateCommand } from '@opentrons/shared-data' +import type { CommandCreator, CommandCreatorError } from '../../types' + +export const flexStackerEmpty: CommandCreator< + FlexStackerEmptyCreateCommand['params'] +> = (args, invariantContext, prevRobotState) => { + const { gripperEntities, moduleEntities } = invariantContext + const flexStackerState = flexStackerStateGetter(prevRobotState, args.moduleId) + const hasGripperEntity = Object.keys(gripperEntities).length > 0 + + const errors: CommandCreatorError[] = [] + if (args.moduleId == null || flexStackerState == null) { + errors.push(errorCreators.missingModuleError()) + } + + if (!hasGripperEntity) { + errors.push(errorCreators.flexStackerNoGripper()) + } + if (errors.length > 0) { + return { errors } + } + const pythonName = moduleEntities[args.moduleId].pythonName + + return { + commands: [ + { + commandType: 'flexStacker/empty', + key: uuid(), + params: { + moduleId: args.moduleId, + strategy: args.strategy, + message: args.message, + count: args.count, + }, + }, + ], + python: `${pythonName}.empty()`, + } +} diff --git a/step-generation/src/errorCreators.ts b/step-generation/src/errorCreators.ts index 20055173512..68a6f42a882 100644 --- a/step-generation/src/errorCreators.ts +++ b/step-generation/src/errorCreators.ts @@ -184,6 +184,14 @@ export const absorbanceReaderNoGripper = (): CommandCreatorError => { } } +export const flexStackerNoGripper = (): CommandCreatorError => { + return { + type: 'FLEX_STACKER_NO_GRIPPER', + message: + 'This step involves a gripper. Add a gripper or remove step to proceed.', + } +} + export const heaterShakerIsShaking = (): CommandCreatorError => { return { type: 'HEATER_SHAKER_IS_SHAKING', diff --git a/step-generation/src/getNextRobotStateAndWarnings/index.ts b/step-generation/src/getNextRobotStateAndWarnings/index.ts index 7a3151a973e..fb3cb7b08c8 100644 --- a/step-generation/src/getNextRobotStateAndWarnings/index.ts +++ b/step-generation/src/getNextRobotStateAndWarnings/index.ts @@ -27,6 +27,12 @@ import { } from './heaterShakerUpdates' import { forBlowOutInPlace, forDropTipInPlace } from './inPlaceCommandUpdates' import { forDisengageMagnet, forEngageMagnet } from './magnetUpdates' +import { + forFlexStackerEmpty, + forFlexStackerFill, + forFlexStackerRetrieve, + forFlexStackerStore, +} from './stackerUpdates' import { forAwaitTemperature, forDeactivateTemperature, @@ -114,18 +120,41 @@ function _getNextRobotStateAndWarningsSingleCommand( case 'createTimer': case 'waitForTasks': break - - // for flex stacker - // TODO: wire these up if they change state - // for flex stacker support + // setStoredLabware is handled in the python file while lading a labware on the stacker. no need to update state + case 'flexStacker/setStoredLabware': + break + // unsafe commands, no need to update state + case 'flexStacker/prepareShuttle': case 'flexStacker/closeLatch': + case 'flexStacker/openLatch': + break case 'flexStacker/empty': + forFlexStackerEmpty( + command.params, + invariantContext, + robotStateAndWarnings + ) + break case 'flexStacker/fill': - case 'flexStacker/openLatch': - case 'flexStacker/prepareShuttle': + forFlexStackerFill( + command.params, + invariantContext, + robotStateAndWarnings + ) + break case 'flexStacker/retrieve': - case 'flexStacker/setStoredLabware': + forFlexStackerRetrieve( + command.params, + invariantContext, + robotStateAndWarnings + ) + break case 'flexStacker/store': + forFlexStackerStore( + command.params, + invariantContext, + robotStateAndWarnings + ) break // the following commands currently don't effect tracked robot state diff --git a/step-generation/src/getNextRobotStateAndWarnings/stackerUpdates.ts b/step-generation/src/getNextRobotStateAndWarnings/stackerUpdates.ts new file mode 100644 index 00000000000..1b7cdb35229 --- /dev/null +++ b/step-generation/src/getNextRobotStateAndWarnings/stackerUpdates.ts @@ -0,0 +1,196 @@ +import { + FLEX_STACKER_MODULE_TYPE, + FLEX_STACKER_MODULE_V1, + getHeightOfLabwareStackFromDefinitions, + getLabwareOverlapOffset, + getStackerMaxPoolCountByHeight, +} from '@opentrons/shared-data' + +import { getModuleState } from '../robotStateSelectors' +import { uuid } from '../utils' + +import type { + FlexStackerEmptyParams, + FlexStackerFillParams, + ModuleOnlyParams, +} from '@opentrons/shared-data' +import type { + FlexStackerModuleState, + InvariantContext, + RobotState, + RobotStateAndWarnings, +} from '../types' + +const _getStackerModuleState = ( + robotState: RobotState, + module: string +): FlexStackerModuleState | null => { + const moduleState = getModuleState(robotState, module) + + if (moduleState.type === FLEX_STACKER_MODULE_TYPE) { + return moduleState + } else { + console.error( + `Flex stacker state updater expected ${module} moduleState to be flexStacker, but it was ${moduleState.type}` + ) + return null + } +} + +export const forFlexStackerEmpty = ( + params: FlexStackerEmptyParams, + invariantContext: InvariantContext, + robotStateAndWarnings: RobotStateAndWarnings +): void => { + const { robotState } = robotStateAndWarnings + const { moduleId, count } = params + const moduleState = _getStackerModuleState(robotState, moduleId) + + if (moduleState != null) { + if (count != null && count > 0) { + moduleState.labwareIdsInStacker = + moduleState?.labwareIdsInStacker?.splice( + moduleState?.labwareIdsInStacker?.length - 1 - count + ) ?? null + } else { + moduleState.labwareIdsInStacker = null + } + } +} + +export const forFlexStackerFill = ( + params: FlexStackerFillParams, + invariantContext: InvariantContext, + robotStateAndWarnings: RobotStateAndWarnings +): void => { + const { robotState } = robotStateAndWarnings + const { moduleId, count } = params + const moduleState = _getStackerModuleState(robotState, moduleId) + const labwareDefinition = + invariantContext.labwareEntities[ + moduleState?.labwareIdsInStacker?.[0] ?? '' + ]?.def + const listOfLabwareDefinitions = Array.from( + { length: moduleState?.labwareIdsInStacker?.length ?? 0 }, + _ => labwareDefinition + ) + const poolHeight = getHeightOfLabwareStackFromDefinitions( + listOfLabwareDefinitions + ) + const poolOverlap = getLabwareOverlapOffset( + FLEX_STACKER_MODULE_V1, + labwareDefinition, + 'default' + ) + const maxStorableLabware = getStackerMaxPoolCountByHeight( + FLEX_STACKER_MODULE_V1, + poolHeight, + poolOverlap.z + ) + + if (moduleState != null) { + if ( + count != null && + count > 0 && + maxStorableLabware > + count + (moduleState.labwareIdsInStacker?.length ?? 0) + ) { + // create labware entities for the new labware + const newLabwareIdList = Array.from({ length: count }, () => uuid()) + moduleState.labwareIdsInStacker = [ + ...(moduleState.labwareIdsInStacker ?? []), + ...newLabwareIdList, + ] + } + } +} + +export const forFlexStackerRetrieve = ( + params: ModuleOnlyParams, + invariantContext: InvariantContext, + robotStateAndWarnings: RobotStateAndWarnings +): void => { + const { robotState } = robotStateAndWarnings + const { moduleId } = params + const moduleState = _getStackerModuleState(robotState, moduleId) + if (moduleState != null) { + if (moduleState.shuttlePosition === 'retrieved') { + throw new Error( + 'Cannot retrieve labware bc there is labware on the shuttle' + ) + } + if (moduleState.labwareIdsInStacker?.length === 0) { + throw new Error( + 'Cannot retrieve labware bc there is no labware in the stacker' + ) + } + if (moduleState.storedLabwareDetails?.primaryLabware == null) + throw new Error( + 'Cannot retrieve labware bc there is no stored labware details or primary labware' + ) + moduleState.shuttlePosition = 'retrieved' + } + + const retrievedLabware = moduleState?.labwareIdsInStacker?.shift() + if (retrievedLabware == null) { + throw new Error( + 'Cannot retrieve labware bc there is no labware in the stacker' + ) + } + // make sure this is shuttle slot + // create labware entity for retrieved labware + robotState.labware[retrievedLabware] = { + ...robotState.labware[retrievedLabware], + stack: robotState.labware[retrievedLabware]?.stack?.slice(0, -1) ?? [], + } +} + +export const forFlexStackerStore = ( + params: ModuleOnlyParams, + invariantContext: InvariantContext, + robotStateAndWarnings: RobotStateAndWarnings +): void => { + const { robotState } = robotStateAndWarnings + const { moduleId } = params + const moduleState = _getStackerModuleState(robotState, moduleId) + if (moduleState != null) { + if (moduleState.shuttlePosition === 'stored') { + throw new Error('Cannot store labware bc there is labware on the shuttle') + } + // get module location + const moduleLocation = robotState.modules[moduleId]?.slot + if (moduleLocation == null) { + throw new Error('Cannot store labware bc there is no module location') + } + if (moduleState.storedLabwareDetails?.primaryLabware == null) { + throw new Error('Cannot store labware bc there is no labware stored') + } + if ( + (moduleState.labwareIdsInStacker?.length ?? 0) + 1 > + moduleState.maxPoolCount + ) { + throw new Error( + 'Cannot store labware bc there is no space in the stacker' + ) + } + // get labware id on module from the move labware command + const newLabwareId = uuid() + const moduleOnSlot = robotState.modules[moduleId].slot + // move labware should update the labware id on the shuttle + const labwareToStore = Object.entries(robotState.labware).find( + ([_, labware]) => labware.stack.includes(moduleOnSlot) + )?.[0] + if (labwareToStore == null) { + throw new Error( + 'Cannot store labware bc there is no labware on the module' + ) + } + moduleState.shuttlePosition = 'stored' + moduleState.labwareIdsInStacker = [ + newLabwareId, + ...(moduleState.labwareIdsInStacker ?? []), + ] + // remove labware from entities and from shuttle + // update stack of labware on the module + } +} diff --git a/step-generation/src/robotStateSelectors.ts b/step-generation/src/robotStateSelectors.ts index 6449be4a35b..8e8f052b4f3 100644 --- a/step-generation/src/robotStateSelectors.ts +++ b/step-generation/src/robotStateSelectors.ts @@ -5,6 +5,7 @@ import { ABSORBANCE_READER_TYPE, ALL, COLUMN, + FLEX_STACKER_MODULE_TYPE, getIsLid, getLabwareDefIsStandard, getLabwareDefURI, @@ -20,6 +21,7 @@ import { getSlotInLocationStack } from './utils' import type { NozzleConfigurationStyle } from '@opentrons/shared-data' import type { AbsorbanceReaderState, + FlexStackerModuleState, InvariantContext, ModuleTemporalProperties, RobotState, @@ -256,15 +258,15 @@ export function getPipetteWithTipMaxVol( } export function getModuleState( robotState: RobotState, - module: string + moduleId: string ): ModuleTemporalProperties['moduleState'] { - if (!(module in robotState.modules)) { + if (!(moduleId in robotState.modules)) { console.warn( - `getModuleState expected module id "${module}" to be in robot state` + `getModuleState expected module id "${moduleId}" to be in robot state` ) } - return robotState.modules[module]?.moduleState + return robotState.modules[moduleId]?.moduleState } export const thermocyclerStateGetter = ( robotState: RobotState, @@ -286,3 +288,13 @@ export const absorbanceReaderStateGetter = ( ? hardwareModule : null } + +export const flexStackerStateGetter = ( + robotState: RobotState, + moduleId: string +): FlexStackerModuleState | null => { + const hardwareModule = robotState.modules[moduleId]?.moduleState + return hardwareModule && hardwareModule.type === FLEX_STACKER_MODULE_TYPE + ? hardwareModule + : null +} diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts index 69ed50ed4c8..9188293c2d3 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -2,6 +2,7 @@ import type { ABSORBANCE_READER_TYPE, CreateCommand, FLEX_STACKER_MODULE_TYPE, + FlexStackerSetStoredLabwareParams, HEATERSHAKER_MODULE_TYPE, LabwareDefinition2, LabwareLocation, @@ -104,9 +105,13 @@ export interface AbsorbanceReaderState { initialization: Initialization | null } -export interface FlexStackerState { +export interface FlexStackerModuleState { type: typeof FLEX_STACKER_MODULE_TYPE - // TODO: extend this state + maxPoolCount: number + latchOpen: boolean | null + storedLabwareDetails: FlexStackerSetStoredLabwareParams | null + shuttlePosition: 'home' | 'retrieved' | 'stored' + labwareIdsInStacker: string[] | null } export type ModuleState = @@ -116,7 +121,7 @@ export type ModuleState = | HeaterShakerModuleState | MagneticBlockState | AbsorbanceReaderState - | FlexStackerState + | FlexStackerModuleState export interface ModuleTemporalProperties { slot: DeckSlot moduleState: ModuleState @@ -752,6 +757,7 @@ export type ErrorType = | 'THERMOCYCLER_LID_CLOSED' | 'TIP_VOLUME_EXCEEDED' | 'TIPRACK_LID_NOT_ALLOWED_ON_DECK' + | 'FLEX_STACKER_NO_GRIPPER' export interface CommandCreatorError { message: string diff --git a/step-generation/src/utils/pythonFileUtils.ts b/step-generation/src/utils/pythonFileUtils.ts index a427372e6cf..ee7f33603e9 100644 --- a/step-generation/src/utils/pythonFileUtils.ts +++ b/step-generation/src/utils/pythonFileUtils.ts @@ -2,6 +2,7 @@ import max from 'lodash/max' import { FLEX_ROBOT_TYPE, + FLEX_STACKER_MODULE_TYPE, getAllLiquidClassDefs, getCutoutDisplayName, getFlexNameConversion, @@ -12,7 +13,7 @@ import { } from '@opentrons/shared-data' import { getLiquidClassName } from './liquidClassUtils' -import { getSlotInLocationStack } from './misc' +import { getLargestStackInSlot, getSlotInLocationStack } from './misc' import { CUSTOM_LABWARE_DICT_NAME, formatPyDict, @@ -293,10 +294,28 @@ export function getLoadLabware( let parentName: string let locationArg: string | undefined + let pythonFlexStackerSetStoredLabware: string | undefined if (onAdapter) { parentName = allLabwareEntities[labwareSlot].pythonName } else if (onModule) { parentName = moduleEntities[labwareSlot].pythonName + if (moduleEntities[labwareSlot].type === FLEX_STACKER_MODULE_TYPE) { + const largestStack = getLargestStackInSlot( + labwareRobotState, + labwareSlot + ) + // Count only labware items (excluding slot, module, and adapters) + const labwareCount = largestStack.filter( + itemId => + itemId in allLabwareEntities && + !allLabwareEntities[itemId].def.allowedRoles?.includes( + 'adapter' + ) && + !allLabwareEntities[itemId].def.allowedRoles?.includes('lid') + ).length + // should I extract this to a function? all the logic will be the same + pythonFlexStackerSetStoredLabware = `${pythonName} = ${parentName}.set_stored_labware(${labware.def.parameters.loadName}, ${labware.def.namespace}, ${labware.def.version}, count=${labwareCount})` + } } else { parentName = PROTOCOL_CONTEXT_NAME locationArg = `location=${ @@ -322,6 +341,9 @@ export function getLoadLabware( `${pythonName} = ${parentName}.load_labware(\n` + `${indentPyLines(loadLabwareArgs)},\n` + `)`, + ...(pythonFlexStackerSetStoredLabware != null + ? [pythonFlexStackerSetStoredLabware] + : []), ] } else { // custom labware