diff --git a/.codecov.yml b/.codecov.yml index 4eb13688110..0ec55aa6462 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,27 +1,55 @@ +version: 2.0 + +coverage: + status: + project: + default: + target: auto + threshold: '10' + patch: + default: + target: auto + threshold: 0 + +comment: + layout: 'reach, diff, flags, files' + ignore: - - '**/node_modules' - - 'webpack-config' - - 'hardware-testing' - - '**/*.md' - - '**/*.yaml' + - 'webpack-config/**' + - 'hardware-testing/**' + - 'abr-testing/**' + - 'test-data-generation/**' + - 'performance-metrics/**' + - 'package-testing/**' + - 'opentrons-ai-server/**' + - 'opentrons-ai-client/**' + - 'g-code-testing/**' + - 'api-client/**' + - 'analyses-snapshot-testing/**' + - '.storybook/**' + - 'react-api-client/**' + - 'scripts/**' + - '.github/**' + - '**/build/**' + - '**/dist/**' + - '**/node_modules/**' + - '**/{test,tests,__tests__,__mocks__,mocks}/**' + - '**/*.{md,yaml,yml,json,rst}' - '**/Makefile' - - '**/*.in' - - '**/*.json' - - '**/*.config.js' + - '**/*.{in,ini,lock,toml,cfg}' + - '**/setup.py' + - '**/requirements.txt' + - '**/.flake8' + - '**/.coveragerc' + - '**/.prettierrc.js' + - '**/.eslintrc.js' + - '**/Pipfile' + - '**/Dockerfile*' + - '**/*.{config.js,config.ts}' - '**/*.mk' - '**/*.stories.tsx' - -comment: - # add flags to `layout` configuration to show up in the PR comment - layout: 'reach, diff, flags, files' + - '**/*.{mjs,css,cjs,mts}' flag_management: default_rules: carryforward: true - -coverage: - status: - project: - default: - target: auto - threshold: 10 diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index 873bfe65c07..13a2051c767 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -52,7 +52,7 @@ env: jobs: js-unit-test: # unit tests for the app's view layer (not the node layer) - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' name: 'opentrons app frontend unit tests' timeout-minutes: 60 steps: @@ -386,7 +386,7 @@ jobs: deploy-release-app: name: 'Deploy built app artifacts to S3' - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' needs: ['js-unit-test', 'backend-unit-test', 'build-app', 'determine-build-type'] if: contains(fromJSON(needs.determine-build-type.outputs.variants), 'release') || contains(fromJSON(needs.determine-build-type.outputs.variants), 'internal-release') diff --git a/.github/workflows/components-test-build-deploy.yaml b/.github/workflows/components-test-build-deploy.yaml index 2b10617c283..aa9bfb7d342 100644 --- a/.github/workflows/components-test-build-deploy.yaml +++ b/.github/workflows/components-test-build-deploy.yaml @@ -41,7 +41,7 @@ jobs: js-unit-test: name: 'components unit tests' timeout-minutes: 30 - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' steps: - uses: 'actions/checkout@v4' - uses: 'actions/setup-node@v4' @@ -75,7 +75,7 @@ jobs: build-components-storybook: name: 'build components artifact' - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' if: github.event_name != 'pull_request' needs: ['js-unit-test'] steps: @@ -135,7 +135,7 @@ jobs: deploy-components: name: 'deploy components storybook artifact to S3' - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' needs: ['js-unit-test', 'build-components-storybook', 'determine-build-type'] if: needs.determine-build-type.outputs.type != 'none' diff --git a/.github/workflows/js-check.yaml b/.github/workflows/js-check.yaml index 807d4a2570c..fa117227fdc 100644 --- a/.github/workflows/js-check.yaml +++ b/.github/workflows/js-check.yaml @@ -39,7 +39,7 @@ env: jobs: checks: name: 'js checks' - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' timeout-minutes: 20 steps: - uses: 'actions/checkout@v4' diff --git a/.github/workflows/ll-test-build-deploy.yaml b/.github/workflows/ll-test-build-deploy.yaml index 35cbc96eced..8027399ffbd 100644 --- a/.github/workflows/ll-test-build-deploy.yaml +++ b/.github/workflows/ll-test-build-deploy.yaml @@ -40,7 +40,7 @@ jobs: js-unit-test: name: 'labware library unit tests' timeout-minutes: 20 - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' steps: - uses: 'actions/checkout@v4' - uses: 'actions/setup-node@v4' @@ -83,7 +83,7 @@ jobs: name: 'labware library e2e tests' needs: ['js-unit-test'] timeout-minutes: 30 - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' steps: - uses: 'actions/checkout@v4' # https://github.com/actions/checkout/issues/290 @@ -123,7 +123,7 @@ jobs: name: 'build labware library artifact' needs: ['js-unit-test'] timeout-minutes: 30 - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' if: github.event_name != 'pull_request' steps: - uses: 'actions/checkout@v4' @@ -170,7 +170,7 @@ jobs: path: labware-library/dist deploy-ll: name: 'deploy LL artifact to S3' - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' needs: ['js-unit-test', 'e2e-test', 'build-ll'] if: github.event_name != 'pull_request' steps: diff --git a/.github/workflows/opentrons-ai-client-test.yaml b/.github/workflows/opentrons-ai-client-test.yaml index 0a78cf73da3..9187667d504 100644 --- a/.github/workflows/opentrons-ai-client-test.yaml +++ b/.github/workflows/opentrons-ai-client-test.yaml @@ -35,7 +35,7 @@ env: jobs: js-unit-test: - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' name: 'opentrons ai frontend unit tests' timeout-minutes: 60 steps: diff --git a/.github/workflows/react-api-client-test.yaml b/.github/workflows/react-api-client-test.yaml index 8a8759f12e1..c9bb0fcc6dd 100644 --- a/.github/workflows/react-api-client-test.yaml +++ b/.github/workflows/react-api-client-test.yaml @@ -34,7 +34,7 @@ jobs: js-unit-test: name: 'api-client and react-api-client unit tests' timeout-minutes: 30 - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' steps: - uses: 'actions/checkout@v4' - uses: 'actions/setup-node@v4' diff --git a/.github/workflows/step-generation-test.yaml b/.github/workflows/step-generation-test.yaml index ac435cf999d..c589223e452 100644 --- a/.github/workflows/step-generation-test.yaml +++ b/.github/workflows/step-generation-test.yaml @@ -9,12 +9,18 @@ on: - 'shared-data/**' - 'package.json' - '.github/workflows/step-generation-test.yaml' + - '.github/actions/js/setup/action.yml' + - '.github/actions/git/resolve-tag/action.yml' + - '.github/actions/environment/complex-variables/action.yml' push: paths: - 'step-generation/**' - 'shared-data/**' - 'package.json' - '.github/workflows/step-generation-test.yaml' + - '.github/actions/js/setup/action.yml' + - '.github/actions/git/resolve-tag/action.yml' + - '.github/actions/environment/complex-variables/action.yml' branches: - '*' @@ -32,36 +38,16 @@ env: jobs: js-unit-test: name: 'step generation unit tests' - runs-on: 'ubuntu-22.04' - timeout-minutes: 30 + runs-on: 'ubuntu-24.04' + timeout-minutes: 20 steps: - - uses: 'actions/checkout@v4' - - uses: 'actions/setup-node@v4' - with: - node-version: '22.11.0' - - name: 'install udev for usb-detection' - run: | - # WORKAROUND: Remove microsoft debian repo due to https://github.com/microsoft/linux-package-repositories/issues/130. Remove line below after it is resolved - sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update && sudo apt-get install libudev-dev - - name: 'cache yarn cache' - uses: actions/cache@v3 - with: - path: | - ${{ github.workspace }}/.yarn-cache - ${{ github.workspace }}/.npm-cache - key: js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - restore-keys: | - js-${{ secrets.GH_CACHE_VERSION }}-${{ runner.os }}-yarn- - - name: 'setup-js' - run: | - npm config set cache ./.npm-cache - yarn config set cache-folder ./.yarn-cache - make setup-js + - name: 'Checkout Repository' + uses: actions/checkout@v4 + - uses: ./.github/actions/js/setup - name: 'run step generation unit tests' run: make -C step-generation test-cov - name: 'Upload coverage report' - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: - files: ./coverage/lcov.info flags: step-generation + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/usb-bridge-lint-test.yaml b/.github/workflows/usb-bridge-lint-test.yaml index bfe11aed61b..7957e898d3c 100644 --- a/.github/workflows/usb-bridge-lint-test.yaml +++ b/.github/workflows/usb-bridge-lint-test.yaml @@ -39,7 +39,7 @@ jobs: lint: name: 'usb-bridge linting' timeout-minutes: 10 - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' steps: - uses: 'actions/checkout@v4' with: @@ -60,7 +60,7 @@ jobs: name: 'usb-bridge package tests' timeout-minutes: 10 needs: [lint] - runs-on: 'ubuntu-22.04' + runs-on: 'ubuntu-24.04' steps: - uses: 'actions/checkout@v4' with: diff --git a/NOTICE b/NOTICE index 3d2de27433a..88a30e2a338 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,5 @@ Opentrons Platform -Copyright 2015-2020 Opentrons Labworks, Inc. +Copyright 2015-2025 Opentrons Labworks, Inc. The source code in this repository is licensed under the Apache License, Version 2.0 (the "License"); you may not use this code except in diff --git a/api-client/src/runs/createLabwareOffset.ts b/api-client/src/runs/createLabwareOffset.ts index 0b91566cf46..e1c125f8254 100644 --- a/api-client/src/runs/createLabwareOffset.ts +++ b/api-client/src/runs/createLabwareOffset.ts @@ -2,17 +2,25 @@ import { POST, request } from '../request' import type { ResponsePromise } from '../request' import type { HostConfig } from '../types' -import type { LegacyLabwareOffsetCreateData, Run } from './types' +import type { LabwareOffset, LegacyLabwareOffsetCreateData } from './types' export function createLabwareOffset( config: HostConfig, runId: string, data: LegacyLabwareOffsetCreateData -): ResponsePromise { - return request( - POST, - `/runs/${runId}/labware_offsets`, - { data }, - config - ) +): ResponsePromise +export function createLabwareOffset( + config: HostConfig, + runId: string, + data: LegacyLabwareOffsetCreateData[] +): ResponsePromise +export function createLabwareOffset( + config: HostConfig, + runId: string, + data: LegacyLabwareOffsetCreateData | LegacyLabwareOffsetCreateData[] +): ResponsePromise { + return request< + LabwareOffset | LabwareOffset[], + { data: LegacyLabwareOffsetCreateData | LegacyLabwareOffsetCreateData[] } + >(POST, `/runs/${runId}/labware_offsets`, { data }, config) } diff --git a/api/docs/v2/versioning.rst b/api/docs/v2/versioning.rst index 935011f61dd..9f615ed04ff 100644 --- a/api/docs/v2/versioning.rst +++ b/api/docs/v2/versioning.rst @@ -84,6 +84,8 @@ This table lists the correspondence between Protocol API versions and robot soft +-------------+------------------------------+ | API Version | Introduced in Robot Software | +=============+==============================+ +| 2.22 | 8.3.0 | ++-------------+------------------------------+ | 2.21 | 8.2.0 | +-------------+------------------------------+ | 2.20 | 8.0.0 | @@ -136,6 +138,10 @@ This table lists the correspondence between Protocol API versions and robot soft Changes in API Versions ======================= +Version 2.22 +------------- +This version includes beta features for our commercial partners. + Version 2.21 ------------ - Adds :py:class:`.AbsorbanceReaderContext` to support the :ref:`Absorbance Plate Reader Module `. Use the load name ``absorbanceReaderV1`` with :py:meth:`.ProtocolContext.load_module` to add an Absorbance Plate Reader to a protocol. diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index c0bca3c428a..d240b1fafea 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -48,7 +48,6 @@ class AspirateVolumeMixin(BaseModel): correctionVolume: Optional[float] = Field( None, description="The correction volume in uL.", - ge=0, ) @@ -65,7 +64,6 @@ class DispenseVolumeMixin(BaseModel): correctionVolume: Optional[float] = Field( None, description="The correction volume in uL.", - ge=0, ) diff --git a/protocol-designer/src/assets/localization/en/tooltip.json b/protocol-designer/src/assets/localization/en/tooltip.json index 849844a0c34..1445cedf0bd 100644 --- a/protocol-designer/src/assets/localization/en/tooltip.json +++ b/protocol-designer/src/assets/localization/en/tooltip.json @@ -65,7 +65,7 @@ "newLocation": "New location to move the selected labware", "nozzles": "Partial pickup requires a tip rack directly on the deck. Full rack pickup requires the Flex 96 Tip Rack Adapter.", "pipette": "Select the pipette you want to use", - "preWetTip": "Pre-wet by aspirating and dispensing 2/3 of the tip’s max volume", + "preWetTip": "Pre-wet by aspirating and dispensing the total aspiration volume", "setTemperature": "Select the temperature to set your module to", "wells": "Select wells", "volume": "Volume to dispense in each well" diff --git a/protocol-designer/src/molecules/ToggleStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleStepFormField/index.tsx index ad609acf0b9..3068f4e0e69 100644 --- a/protocol-designer/src/molecules/ToggleStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleStepFormField/index.tsx @@ -1,5 +1,6 @@ import { ALIGN_CENTER, + Check, COLORS, DIRECTION_COLUMN, Flex, @@ -16,12 +17,13 @@ import { ToggleButton } from '../../atoms/ToggleButton' interface ToggleStepFormFieldProps { title: string isSelected: boolean - onLabel: string - offLabel: string toggleUpdateValue: (value: unknown) => void toggleValue: unknown tooltipContent: string | null - isDisabled: boolean + isDisabled?: boolean + onLabel?: string + offLabel?: string + toggleElement?: 'toggle' | 'checkbox' } export function ToggleStepFormField( props: ToggleStepFormFieldProps @@ -34,7 +36,8 @@ export function ToggleStepFormField( toggleUpdateValue, toggleValue, tooltipContent, - isDisabled, + isDisabled = false, + toggleElement = 'toggle', } = props const [targetProps, tooltipProps] = useHoverTooltip() @@ -42,7 +45,7 @@ export function ToggleStepFormField( <> { if (!isDisabled) { toggleUpdateValue(!toggleValue) @@ -50,11 +53,7 @@ export function ToggleStepFormField( }} disabled={isDisabled} > - {tooltipContent != null ? ( - {tooltipContent} - ) : null} - - + {title} @@ -72,15 +72,21 @@ export function ToggleStepFormField( > {isSelected ? onLabel : offLabel} - + {toggleElement === 'toggle' ? ( + + ) : ( + + )} + {tooltipContent != null ? ( + {tooltipContent} + ) : null} ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/LabwareField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/LabwareField.tsx index 07e08b8a299..00f68dcd46c 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/LabwareField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/LabwareField.tsx @@ -31,6 +31,7 @@ export function LabwareField(props: FieldProps): JSX.Element { onExit={() => { dispatch(hoverSelection({ id: null, text: null })) }} + width="100%" /> ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index 253889423ca..45eee9219f8 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -360,6 +360,7 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { } + width="21.875rem" >
{tab === 'aspirate' ? ( - ) : null} & { createLabwareOffset: UseMutateAsyncFunction< - Run, + LabwareOffset, unknown, CreateLabwareOffsetParams > @@ -29,19 +29,22 @@ export function useCreateLabwareOffsetMutation(): UseCreateLabwareOffsetMutation const host = useHost() const queryClient = useQueryClient() - const mutation = useMutation( - ({ runId, data }) => - createLabwareOffset(host as HostConfig, runId, data) - .then(response => { - queryClient.invalidateQueries([host, 'runs']).catch((e: Error) => { - console.error(`error invalidating runs query: ${e.message}`) - }) - return response.data - }) - .catch((e: Error) => { - console.error(`error creating labware offsets: ${e.message}`) - throw e + const mutation = useMutation< + LabwareOffset, + unknown, + CreateLabwareOffsetParams + >(({ runId, data }) => + createLabwareOffset(host as HostConfig, runId, data) + .then(response => { + queryClient.invalidateQueries([host, 'runs']).catch((e: Error) => { + console.error(`error invalidating runs query: ${e.message}`) }) + return response.data + }) + .catch((e: Error) => { + console.error(`error creating labware offsets: ${e.message}`) + throw e + }) ) return { diff --git a/robot-server/robot_server/labware_offsets/router.py b/robot-server/robot_server/labware_offsets/router.py index 4ebf532d657..7d00fbc68d6 100644 --- a/robot-server/robot_server/labware_offsets/router.py +++ b/robot-server/robot_server/labware_offsets/router.py @@ -12,7 +12,10 @@ from opentrons.protocol_engine import ModuleModel from robot_server.labware_offsets.models import LabwareOffsetNotFound -from robot_server.service.dependencies import get_current_time, get_unique_id +from robot_server.service.dependencies import ( + UniqueIDFactory, + get_current_time, +) from robot_server.service.json_api.request import RequestModel from robot_server.service.json_api.response import ( MultiBodyMeta, @@ -42,42 +45,67 @@ @PydanticResponse.wrap_route( router.post, path="/labwareOffsets", - summary="Store a labware offset", + summary="Store labware offsets", description=textwrap.dedent( """\ - Store a labware offset for later retrieval through `GET /labwareOffsets`. + Store labware offsets for later retrieval through `GET /labwareOffsets`. On its own, this does not affect robot motion. - To do that, you must add the offset to a run, through the `/runs` endpoints. + To do that, you must add the offsets to a run, through the `/runs` endpoints. + + The response body's `data` will either be a single offset or a list of offsets, + depending on whether you provided a single offset or a list in the request body's `data`. """ ), status_code=201, include_in_schema=False, # todo(mm, 2025-01-08): Include for v8.4.0. ) -async def post_labware_offset( # noqa: D103 +async def post_labware_offsets( # noqa: D103 store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)], - new_offset_id: Annotated[str, fastapi.Depends(get_unique_id)], + new_offset_id_factory: Annotated[UniqueIDFactory, fastapi.Depends(UniqueIDFactory)], new_offset_created_at: Annotated[datetime, fastapi.Depends(get_current_time)], - request_body: Annotated[RequestModel[StoredLabwareOffsetCreate], fastapi.Body()], -) -> PydanticResponse[SimpleBody[StoredLabwareOffset]]: - new_offset = IncomingStoredLabwareOffset( - id=new_offset_id, - createdAt=new_offset_created_at, - definitionUri=request_body.data.definitionUri, - locationSequence=request_body.data.locationSequence, - vector=request_body.data.vector, + request_body: Annotated[ + RequestModel[StoredLabwareOffsetCreate | list[StoredLabwareOffsetCreate]], + fastapi.Body(), + ], +) -> PydanticResponse[SimpleBody[StoredLabwareOffset | list[StoredLabwareOffset]]]: + new_offsets = [ + IncomingStoredLabwareOffset( + id=new_offset_id_factory.get(), + createdAt=new_offset_created_at, + definitionUri=request_body_element.definitionUri, + locationSequence=request_body_element.locationSequence, + vector=request_body_element.vector, + ) + for request_body_element in ( + request_body.data + if isinstance(request_body.data, list) + else [request_body.data] + ) + ] + + for new_offset in new_offsets: + store.add(new_offset) + + stored_offsets = [ + StoredLabwareOffset.model_construct( + id=incoming.id, + createdAt=incoming.createdAt, + definitionUri=incoming.definitionUri, + locationSequence=incoming.locationSequence, + vector=incoming.vector, + ) + for incoming in new_offsets + ] + + # Return a list if the client POSTed a list, or an object if the client POSTed an object. + # For some reason, mypy needs to be given the type annotation explicitly. + response_data: StoredLabwareOffset | list[StoredLabwareOffset] = ( + stored_offsets if isinstance(request_body.data, list) else stored_offsets[0] ) - store.add(new_offset) + return await PydanticResponse.create( - content=SimpleBody.model_construct( - data=StoredLabwareOffset( - id=new_offset_id, - createdAt=new_offset_created_at, - definitionUri=request_body.data.definitionUri, - locationSequence=request_body.data.locationSequence, - vector=request_body.data.vector, - ) - ), + content=SimpleBody.model_construct(data=response_data), status_code=201, ) diff --git a/robot-server/robot_server/maintenance_runs/router/labware_router.py b/robot-server/robot_server/maintenance_runs/router/labware_router.py index c64e8b7db97..1271710de1e 100644 --- a/robot-server/robot_server/maintenance_runs/router/labware_router.py +++ b/robot-server/robot_server/maintenance_runs/router/labware_router.py @@ -36,33 +36,57 @@ "There is no matching `GET /maintenance_runs/{runId}/labware_offsets` endpoint." " To read the list of labware offsets currently on the run," " see the run's `labwareOffsets` field." + "\n\n" + "The response body's `data` will either be a single offset or a list of offsets," + " depending on whether you provided a single offset or a list in the request body's `data`." ), status_code=status.HTTP_201_CREATED, responses={ - status.HTTP_201_CREATED: {"model": SimpleBody[LabwareOffset]}, + status.HTTP_201_CREATED: { + "model": SimpleBody[LabwareOffset | list[LabwareOffset]] + }, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[RunNotFound]}, status.HTTP_409_CONFLICT: {"model": ErrorBody[RunNotIdle]}, }, ) async def add_labware_offset( - request_body: RequestModel[LabwareOffsetCreate | LegacyLabwareOffsetCreate], + request_body: RequestModel[ + LabwareOffsetCreate + | LegacyLabwareOffsetCreate + | list[LabwareOffsetCreate | LegacyLabwareOffsetCreate] + ], run_orchestrator_store: Annotated[ MaintenanceRunOrchestratorStore, Depends(get_maintenance_run_orchestrator_store) ], run: Annotated[MaintenanceRun, Depends(get_run_data_from_url)], -) -> PydanticResponse[SimpleBody[LabwareOffset]]: - """Add a labware offset to a maintenance run. +) -> PydanticResponse[SimpleBody[LabwareOffset | list[LabwareOffset]]]: + """Add labware offsets to a maintenance run. Args: request_body: New labware offset request data from request body. run_orchestrator_store: Engine storage interface. run: Run response data by ID from URL; ensures 404 if run not found. """ - added_offset = run_orchestrator_store.add_labware_offset(request_body.data) - log.info(f'Added labware offset "{added_offset.id}"' f' to run "{run.id}".') + offsets_to_add = ( + request_body.data + if isinstance(request_body.data, list) + else [request_body.data] + ) + + added_offsets: list[LabwareOffset] = [] + for offset_to_add in offsets_to_add: + added_offset = run_orchestrator_store.add_labware_offset(offset_to_add) + added_offsets.append(added_offset) + log.info(f'Added labware offset "{added_offset.id}" to run "{run.id}".') + + # Return a list if the client POSTed a list, or an object if the client POSTed an object. + # For some reason, mypy needs to be given the type annotation explicitly. + response_data: LabwareOffset | list[LabwareOffset] = ( + added_offsets if isinstance(request_body.data, list) else added_offsets[0] + ) return await PydanticResponse.create( - content=SimpleBody.model_construct(data=added_offset), + content=SimpleBody.model_construct(data=response_data), status_code=status.HTTP_201_CREATED, ) diff --git a/robot-server/robot_server/runs/router/labware_router.py b/robot-server/robot_server/runs/router/labware_router.py index 78c880a2df5..970af7415c3 100644 --- a/robot-server/robot_server/runs/router/labware_router.py +++ b/robot-server/robot_server/runs/router/labware_router.py @@ -35,29 +35,38 @@ @PydanticResponse.wrap_route( labware_router.post, path="/runs/{runId}/labware_offsets", - summary="Add a labware offset to a run", + summary="Add labware offsets to a run", description=( - "Add a labware offset to an existing run, returning the created offset." + "Add labware offsets to an existing run, returning the created offsets." "\n\n" "There is no matching `GET /runs/{runId}/labware_offsets` endpoint." " To read the list of labware offsets currently on the run," " see the run's `labwareOffsets` field." + "\n\n" + "The response body's `data` will either be a single offset or a list of offsets," + " depending on whether you provided a single offset or a list in the request body's `data`." ), status_code=status.HTTP_201_CREATED, responses={ - status.HTTP_201_CREATED: {"model": SimpleBody[LabwareOffset]}, + status.HTTP_201_CREATED: { + "model": SimpleBody[LabwareOffset | list[LabwareOffset]] + }, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[RunNotFound]}, status.HTTP_409_CONFLICT: {"model": ErrorBody[Union[RunStopped, RunNotIdle]]}, }, ) async def add_labware_offset( - request_body: RequestModel[LegacyLabwareOffsetCreate | LabwareOffsetCreate], + request_body: RequestModel[ + LegacyLabwareOffsetCreate + | LabwareOffsetCreate + | list[LegacyLabwareOffsetCreate | LabwareOffsetCreate] + ], run_orchestrator_store: Annotated[ RunOrchestratorStore, Depends(get_run_orchestrator_store) ], run: Annotated[Run, Depends(get_run_data_from_url)], -) -> PydanticResponse[SimpleBody[LabwareOffset]]: - """Add a labware offset to a run. +) -> PydanticResponse[SimpleBody[LabwareOffset | list[LabwareOffset]]]: + """Add labware offsets to a run. Args: request_body: New labware offset request data from request body. @@ -69,11 +78,26 @@ async def add_labware_offset( status.HTTP_409_CONFLICT ) - added_offset = run_orchestrator_store.add_labware_offset(request_body.data) - log.info(f'Added labware offset "{added_offset.id}"' f' to run "{run.id}".') + offsets_to_add = ( + request_body.data + if isinstance(request_body.data, list) + else [request_body.data] + ) + + added_offsets: list[LabwareOffset] = [] + for offset_to_add in offsets_to_add: + added_offset = run_orchestrator_store.add_labware_offset(offset_to_add) + added_offsets.append(added_offset) + log.info(f'Added labware offset "{added_offset.id}" to run "{run.id}".') + + # Return a list if the client POSTed a list, or an object if the client POSTed an object. + # For some reason, mypy needs to be given the type annotation explicitly. + response_data: LabwareOffset | list[LabwareOffset] = ( + added_offsets if isinstance(request_body.data, list) else added_offsets[0] + ) return await PydanticResponse.create( - content=SimpleBody.model_construct(data=added_offset), + content=SimpleBody.model_construct(data=response_data), status_code=status.HTTP_201_CREATED, ) diff --git a/robot-server/robot_server/service/dependencies.py b/robot-server/robot_server/service/dependencies.py index b27e014a3e9..80d811616c9 100644 --- a/robot-server/robot_server/service/dependencies.py +++ b/robot-server/robot_server/service/dependencies.py @@ -35,7 +35,27 @@ async def get_session_manager( async def get_unique_id() -> str: """Get a unique ID string to use as a resource identifier.""" - return str(uuid4()) + return UniqueIDFactory().get() + + +class UniqueIDFactory: + """ + This is equivalent to the `get_unique_id()` free function. Wrapping it in a factory + class makes things easier for FastAPI endpoint functions that need multiple unique + IDs. They can do: + + unique_id_factory: UniqueIDFactory = fastapi.Depends(UniqueIDFactory) + + And then: + + unique_id_1 = await unique_id_factory.get() + unique_id_2 = await unique_id_factory.get() + """ + + @staticmethod + def get() -> str: + """Get a unique ID to use as a resource identifier.""" + return str(uuid4()) async def get_current_time() -> datetime: diff --git a/robot-server/tests/integration/http_api/test_labware_offsets.tavern.yaml b/robot-server/tests/integration/http_api/test_labware_offsets.tavern.yaml index 0745f10a2ae..754a20df132 100644 --- a/robot-server/tests/integration/http_api/test_labware_offsets.tavern.yaml +++ b/robot-server/tests/integration/http_api/test_labware_offsets.tavern.yaml @@ -150,14 +150,98 @@ stages: cursor: 0 totalLength: 0 +--- +test_name: Test POSTing multiple offsets in a single request + +marks: + - usefixtures: + - ot3_server_base_url + +stages: + - name: POST multiple offsets and check the response + request: + method: POST + url: '{ot3_server_base_url}/labwareOffsets' + json: + data: + - definitionUri: testNamespace/loadName1/1 + locationSequence: + - kind: onAddressableArea + addressableAreaName: A1 + vector: + x: 1 + y: 1 + z: 1 + - definitionUri: testNamespace/loadName2/1 + locationSequence: + - kind: onAddressableArea + addressableAreaName: A2 + vector: + x: 2 + y: 2 + z: 2 + response: + status_code: 201 + json: + data: + - id: !anystr + createdAt: !anystr + definitionUri: testNamespace/loadName1/1 + locationSequence: + - kind: onAddressableArea + addressableAreaName: A1 + vector: + x: 1 + y: 1 + z: 1 + - id: !anystr + createdAt: !anystr + definitionUri: testNamespace/loadName2/1 + locationSequence: + - kind: onAddressableArea + addressableAreaName: A2 + vector: + x: 2 + y: 2 + z: 2 + save: + json: + offset_1_data: data[0] + offset_2_data: data[1] + + - name: POST an empty list of offsets and check the response + request: + method: POST + url: '{ot3_server_base_url}/labwareOffsets' + json: + data: [] + response: + status_code: 201 + json: + data: [] + + - name: Make sure all offsets got stored + request: + url: '{ot3_server_base_url}/labwareOffsets' + response: + json: + data: + - !force_format_include '{offset_1_data}' + - !force_format_include '{offset_2_data}' + meta: + cursor: 0 + totalLength: 2 + --- # Some of the filter query parameters can have `null` values or be omitted, # with different semantics between the two. That distinction takes a bit of care to # preserve across our code, so here we test it specifically. test_name: Test null vs. omitted filter query parameters + marks: - usefixtures: - ot3_server_base_url + stages: - name: POST test offset 1 request: @@ -180,6 +264,7 @@ stages: save: json: offset_1_data: data + - name: POST test offset 2 request: method: POST @@ -202,6 +287,7 @@ stages: save: json: offset_2_data: data + - name: POST test offset 3 request: method: POST @@ -224,6 +310,7 @@ stages: save: json: offset_3_data: data + - name: POST test offset 4 request: method: POST @@ -247,6 +334,7 @@ stages: save: json: offset_4_data: data + - name: Test no filters request: url: '{ot3_server_base_url}/labwareOffsets' @@ -258,6 +346,7 @@ stages: - !force_format_include '{offset_3_data}' - !force_format_include '{offset_4_data}' meta: !anydict + - name: Test filtering on locationModuleModel=null request: url: '{ot3_server_base_url}/labwareOffsets?locationModuleModel=null' @@ -267,6 +356,7 @@ stages: - !force_format_include '{offset_1_data}' - !force_format_include '{offset_3_data}' meta: !anydict + - name: Test filtering on locationDefinitionUri=null request: url: '{ot3_server_base_url}/labwareOffsets?locationDefinitionUri=null' diff --git a/robot-server/tests/maintenance_runs/router/test_labware_router.py b/robot-server/tests/maintenance_runs/router/test_labware_router.py index 72b1e95e2f7..e1691c1b26b 100644 --- a/robot-server/tests/maintenance_runs/router/test_labware_router.py +++ b/robot-server/tests/maintenance_runs/router/test_labware_router.py @@ -50,20 +50,32 @@ def labware_definition(minimal_labware_def: LabwareDefDict) -> LabwareDefinition return LabwareDefinition.model_validate(minimal_labware_def) -async def test_add_labware_offset( +async def test_add_labware_offsets( decoy: Decoy, mock_maintenance_run_orchestrator_store: MaintenanceRunOrchestratorStore, run: MaintenanceRun, ) -> None: - """It should add the labware offset to the engine, assuming the run is current.""" - labware_offset_request = pe_types.LegacyLabwareOffsetCreate( + """It should add the labware offsets to the engine, assuming the run is current.""" + labware_offset_request_1 = pe_types.LegacyLabwareOffsetCreate( definitionUri="namespace_1/load_name_1/123", location=pe_types.LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), vector=pe_types.LabwareOffsetVector(x=1, y=2, z=3), ) + labware_offset_request_2 = pe_types.LegacyLabwareOffsetCreate( + definitionUri="namespace_1/load_name_2/123", + location=pe_types.LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + vector=pe_types.LabwareOffsetVector(x=1, y=2, z=3), + ) - labware_offset = pe_types.LabwareOffset( - id="labware-offset-id", + labware_offset_1 = pe_types.LabwareOffset( + id="labware-offset-id-1", + createdAt=datetime(year=2022, month=2, day=2), + definitionUri="labware-definition-uri", + location=pe_types.LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + vector=pe_types.LabwareOffsetVector(x=0, y=0, z=0), + ) + labware_offset_2 = pe_types.LabwareOffset( + id="labware-offset-id-2", createdAt=datetime(year=2022, month=2, day=2), definitionUri="labware-definition-uri", location=pe_types.LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), @@ -72,17 +84,39 @@ async def test_add_labware_offset( decoy.when( mock_maintenance_run_orchestrator_store.add_labware_offset( - labware_offset_request + labware_offset_request_1 + ) + ).then_return(labware_offset_1) + decoy.when( + mock_maintenance_run_orchestrator_store.add_labware_offset( + labware_offset_request_2 ) - ).then_return(labware_offset) + ).then_return(labware_offset_2) result = await add_labware_offset( - request_body=RequestModel(data=labware_offset_request), + request_body=RequestModel(data=labware_offset_request_1), run_orchestrator_store=mock_maintenance_run_orchestrator_store, run=run, ) + assert result.content == SimpleBody(data=labware_offset_1) + assert result.status_code == 201 - assert result.content == SimpleBody(data=labware_offset) + result = await add_labware_offset( + request_body=RequestModel( + data=[labware_offset_request_1, labware_offset_request_2] + ), + run_orchestrator_store=mock_maintenance_run_orchestrator_store, + run=run, + ) + assert result.content == SimpleBody(data=[labware_offset_1, labware_offset_2]) + assert result.status_code == 201 + + result = await add_labware_offset( + request_body=RequestModel(data=[]), + run_orchestrator_store=mock_maintenance_run_orchestrator_store, + run=run, + ) + assert result.content == SimpleBody(data=[]) assert result.status_code == 201 diff --git a/robot-server/tests/runs/router/test_labware_router.py b/robot-server/tests/runs/router/test_labware_router.py index 2b55b4097f6..e8304784fbf 100644 --- a/robot-server/tests/runs/router/test_labware_router.py +++ b/robot-server/tests/runs/router/test_labware_router.py @@ -53,20 +53,32 @@ def labware_definition(minimal_labware_def: LabwareDefDict) -> LabwareDefinition return LabwareDefinition.model_validate(minimal_labware_def) -async def test_add_labware_offset( +async def test_add_labware_offsets( decoy: Decoy, mock_run_orchestrator_store: RunOrchestratorStore, run: Run, ) -> None: - """It should add the labware offset to the engine, assuming the run is current.""" - labware_offset_request = pe_types.LegacyLabwareOffsetCreate( + """It should add the labware offsets to the engine, assuming the run is current.""" + labware_offset_request_1 = pe_types.LegacyLabwareOffsetCreate( definitionUri="namespace_1/load_name_1/123", location=pe_types.LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), vector=pe_types.LabwareOffsetVector(x=1, y=2, z=3), ) + labware_offset_request_2 = pe_types.LegacyLabwareOffsetCreate( + definitionUri="namespace_1/load_name_2/123", + location=pe_types.LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + vector=pe_types.LabwareOffsetVector(x=1, y=2, z=3), + ) - labware_offset = pe_types.LabwareOffset( - id="labware-offset-id", + labware_offset_1 = pe_types.LabwareOffset( + id="labware-offset-id-1", + createdAt=datetime(year=2022, month=2, day=2), + definitionUri="labware-definition-uri", + location=pe_types.LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), + vector=pe_types.LabwareOffsetVector(x=0, y=0, z=0), + ) + labware_offset_2 = pe_types.LabwareOffset( + id="labware-offset-id-2", createdAt=datetime(year=2022, month=2, day=2), definitionUri="labware-definition-uri", location=pe_types.LegacyLabwareOffsetLocation(slotName=DeckSlotName.SLOT_1), @@ -74,16 +86,36 @@ async def test_add_labware_offset( ) decoy.when( - mock_run_orchestrator_store.add_labware_offset(labware_offset_request) - ).then_return(labware_offset) + mock_run_orchestrator_store.add_labware_offset(labware_offset_request_1) + ).then_return(labware_offset_1) + decoy.when( + mock_run_orchestrator_store.add_labware_offset(labware_offset_request_2) + ).then_return(labware_offset_2) result = await add_labware_offset( - request_body=RequestModel(data=labware_offset_request), + request_body=RequestModel(data=labware_offset_request_1), run_orchestrator_store=mock_run_orchestrator_store, run=run, ) + assert result.content == SimpleBody(data=labware_offset_1) + assert result.status_code == 201 - assert result.content == SimpleBody(data=labware_offset) + result = await add_labware_offset( + request_body=RequestModel( + data=[labware_offset_request_1, labware_offset_request_2] + ), + run_orchestrator_store=mock_run_orchestrator_store, + run=run, + ) + assert result.content == SimpleBody(data=[labware_offset_1, labware_offset_2]) + assert result.status_code == 201 + + result = await add_labware_offset( + request_body=RequestModel(data=[]), + run_orchestrator_store=mock_run_orchestrator_store, + run=run, + ) + assert result.content == SimpleBody(data=[]) assert result.status_code == 201 diff --git a/shared-data/command/schemas/12.json b/shared-data/command/schemas/12.json index 2e40c357c7d..08c86764882 100644 --- a/shared-data/command/schemas/12.json +++ b/shared-data/command/schemas/12.json @@ -67,7 +67,6 @@ "correctionVolume": { "anyOf": [ { - "minimum": 0.0, "type": "number" }, { @@ -176,7 +175,6 @@ "correctionVolume": { "anyOf": [ { - "minimum": 0.0, "type": "number" }, { @@ -215,7 +213,6 @@ "correctionVolume": { "anyOf": [ { - "minimum": 0.0, "type": "number" }, { @@ -413,7 +410,6 @@ "correctionVolume": { "anyOf": [ { - "minimum": 0.0, "type": "number" }, { @@ -1521,7 +1517,6 @@ "correctionVolume": { "anyOf": [ { - "minimum": 0.0, "type": "number" }, { @@ -1565,7 +1560,6 @@ "correctionVolume": { "anyOf": [ { - "minimum": 0.0, "type": "number" }, { @@ -1651,7 +1645,6 @@ "correctionVolume": { "anyOf": [ { - "minimum": 0.0, "type": "number" }, { @@ -1914,7 +1907,6 @@ "correctionVolume": { "anyOf": [ { - "minimum": 0.0, "type": "number" }, { diff --git a/step-generation/Makefile b/step-generation/Makefile index fb32b39b756..9d3d98b40bf 100644 --- a/step-generation/Makefile +++ b/step-generation/Makefile @@ -3,10 +3,10 @@ # using bash instead of /bin/bash in SHELL prevents macOS optimizing away our PATH update SHELL := bash -# These variables can be overriden when make is invoked to customize the +# These variables can be overridden when make is invoked to customize the # behavior of jest tests ?= -cov_opts ?= --coverage=true +cov_opts ?= --coverage --pool=threads test_opts ?= .PHONY: test diff --git a/step-generation/src/__tests__/distribute.test.ts b/step-generation/src/__tests__/distribute.test.ts index 2264f4b44b3..638cc95e7a2 100644 --- a/step-generation/src/__tests__/distribute.test.ts +++ b/step-generation/src/__tests__/distribute.test.ts @@ -896,6 +896,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', const result = distribute(args, invariantContext, robotStateWithTip) const res = getSuccessResult(result) expect(res.commands).toEqual([ + // prewet + aspirateHelper('A1', 150), + delayCommand(11), + dispenseHelper('A1', 150, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) aspirateHelper('A1', 35), delayCommand(11), @@ -957,6 +972,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', const res = getSuccessResult(result) expect(res.commands).toEqual([ // no need to pickup tip/drop tip since change tip is never + // prewet + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate @@ -1040,6 +1070,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', // replace tip since change tip is always ...dropTipHelper(), pickUpTipHelper('A1'), + // prewet + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate @@ -1079,6 +1124,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', ...dropTipHelper(), // next chunk from A1: remaining volume pickUpTipHelper('B1'), + // prewet + aspirateHelper('A1', 160), + delayCommand(11), + dispenseHelper('A1', 160, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate 100 liquid + 60 for disposal vol @@ -1130,6 +1190,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', // replace tip at the beginning of the step ...dropTipHelper(), pickUpTipHelper('A1'), + // prewet + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate @@ -1210,6 +1285,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', const res = getSuccessResult(result) expect(res.commands).toEqual([ // no need to replace tip since change tip is never + // prewet + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate @@ -1295,6 +1385,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', // replace tip ...dropTipHelper(), pickUpTipHelper('A1'), + // prewet + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate @@ -1336,6 +1441,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', ...dropTipHelper(), // next chunk from A1: remaining volume pickUpTipHelper('B1'), + // prewet + aspirateHelper('A1', 160), + delayCommand(11), + dispenseHelper('A1', 160, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate 100 liquid + 60 for disposal vol @@ -1387,6 +1507,22 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', // replace tip ...dropTipHelper(), pickUpTipHelper('A1'), + // prewet + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), + // mix (asp) ...mixCommandsWithDelay, // aspirate @@ -1469,6 +1605,22 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', const res = getSuccessResult(result) expect(res.commands).toEqual([ // no need to replace tip since changeTip is never + // prewet + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), + // mix (asp) ...mixCommandsWithDelay, // aspirate @@ -1554,6 +1706,21 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', // replace tip ...dropTipHelper(), pickUpTipHelper('A1'), + // prewet + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate @@ -1595,6 +1762,20 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', ...dropTipHelper(), // next chunk from A1: remaining volume pickUpTipHelper('B1'), + aspirateHelper('A1', 160), + delayCommand(11), + dispenseHelper('A1', 160, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate 100 liquid + 60 for disposal vol @@ -1639,13 +1820,30 @@ describe('advanced settings: volume, mix, pre-wet tip, tip touch, tip position', changeTip: 'once', blowoutLocation: DEST_WELL_BLOWOUT_DESTINATION, } as DistributeArgs + console.log(args.aspirateDelay, args.dispenseDelay) const result = distribute(args, invariantContext, robotStateWithTip) const res = getSuccessResult(result) expect(res.commands).toEqual([ // replace tip ...dropTipHelper(), + + // prewet pickUpTipHelper('A1'), + aspirateHelper('A1', 260), + delayCommand(11), + dispenseHelper('A1', 260, { + labwareId: SOURCE_LABWARE, + wellLocation: { + origin: 'bottom', + offset: { + x: 0, + y: 0, + z: ASPIRATE_OFFSET_FROM_BOTTOM_MM, + }, + }, + }), + delayCommand(12), // mix (asp) ...mixCommandsWithDelay, // aspirate diff --git a/step-generation/src/commandCreators/compound/distribute.ts b/step-generation/src/commandCreators/compound/distribute.ts index 111626c79ce..8269250b79d 100644 --- a/step-generation/src/commandCreators/compound/distribute.ts +++ b/step-generation/src/commandCreators/compound/distribute.ts @@ -496,8 +496,35 @@ export const distribute: CommandCreator = ( ] : [] const aspirateDisposalVolumeOnce = chunkIndex === 0 ? disposalVolume : 0 + // if changeTip is 'once' or 'never', only prewet for first aspiration + // if changeTip is 'always', pre-wet for each chunk + const preWetTipCommands = + args.preWetTip && + (args.changeTip === 'always' ? true : chunkIndex === 0) + ? mixUtil({ + pipette: args.pipette, + labware: args.sourceLabware, + well: args.sourceWell, + volume: + args.volume * destWellChunk.length + + (args.changeTip === 'always' + ? disposalVolume + : aspirateDisposalVolumeOnce), + times: 1, + offsetFromBottomMm: aspirateOffsetFromBottomMm, + aspirateFlowRateUlSec, + dispenseFlowRateUlSec, + aspirateDelaySeconds: aspirateDelay?.seconds, + dispenseDelaySeconds: dispenseDelay?.seconds, + tipRack: args.tipRack, + xOffset: aspirateXOffset, + yOffset: aspirateYOffset, + nozzles, + }) + : [] return [ ...tipCommands, + ...preWetTipCommands, ...configureForVolumeCommand, ...mixBeforeAspirateCommands, curryCommandCreator(aspirate, { diff --git a/step-generation/src/commandCreators/compound/transfer.ts b/step-generation/src/commandCreators/compound/transfer.ts index c4b3d183ea2..282b92ad3e1 100644 --- a/step-generation/src/commandCreators/compound/transfer.ts +++ b/step-generation/src/commandCreators/compound/transfer.ts @@ -286,7 +286,7 @@ export const transfer: CommandCreator = ( pipette: args.pipette, labware: args.sourceLabware, well: sourceWell, - volume: Math.max(subTransferVol), + volume: subTransferVol, times: 1, offsetFromBottomMm: aspirateOffsetFromBottomMm, aspirateFlowRateUlSec, diff --git a/step-generation/src/fixtures/commandFixtures.ts b/step-generation/src/fixtures/commandFixtures.ts index 37e4ed07a44..056ad5114cd 100644 --- a/step-generation/src/fixtures/commandFixtures.ts +++ b/step-generation/src/fixtures/commandFixtures.ts @@ -138,7 +138,12 @@ export const makeAspirateHelper: MakeAspDispHelper = bakedP ...params, }, }) -export const makeMoveToWellHelper = (wellName: string, labwareId?: string) => ({ +export const makeMoveToWellHelper = ( + wellName: string, + labwareId?: string, + forceDirect?: boolean, + minimumZHeight?: number +) => ({ commandType: 'moveToWell', key: expect.any(String), params: { @@ -153,6 +158,8 @@ export const makeMoveToWellHelper = (wellName: string, labwareId?: string) => ({ z: 11.54, }, }, + forceDirect, + minimumZHeight, }, }) export const makeAirGapHelper = (volume: number) => ({ @@ -268,18 +275,25 @@ export const makeTouchTipHelper: MakeTouchTipHelper = bakedParams => ( key: expect.any(String), params: { ..._defaultTouchTipParams, ...bakedParams, wellName, ...params }, }) -export const delayCommand = (seconds: number): CreateCommand => ({ +export const delayCommand = ( + seconds: number, + message?: string +): CreateCommand => ({ commandType: 'waitForDuration', key: expect.any(String), params: { seconds: seconds, + message, }, }) export const delayWithOffset = ( wellName: string, labwareId: string, seconds?: number, - zOffset?: number + zOffset?: number, + forceDirect?: boolean, + minimumZHeight?: number, + message?: string ): CreateCommand[] => [ { commandType: 'moveToWell', @@ -296,6 +310,8 @@ export const delayWithOffset = ( z: zOffset || 14, }, }, + forceDirect, + minimumZHeight, }, }, { @@ -303,6 +319,7 @@ export const delayWithOffset = ( key: expect.any(String), params: { seconds: seconds ?? 12, + message, }, }, ]