diff --git a/.eslintignore b/.eslintignore index 698dfd264bf..1817bfb34aa 100644 --- a/.eslintignore +++ b/.eslintignore @@ -13,7 +13,6 @@ !api/release-notes.md !app-shell/build/release-notes.md **/.yarn-cache/** -protocol-designer/cypress/downloads/** # components library storybook-static diff --git a/.github/workflows/abr-testing-lint-test.yaml b/.github/workflows/abr-testing-lint-test.yaml index cad18a980d1..a7c45f2a16e 100644 --- a/.github/workflows/abr-testing-lint-test.yaml +++ b/.github/workflows/abr-testing-lint-test.yaml @@ -40,7 +40,7 @@ jobs: with: fetch-depth: 0 - name: Setup Node - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@v6' with: node-version: '12' - name: Setup Python diff --git a/.github/workflows/analyses-snapshot-test.yaml b/.github/workflows/analyses-snapshot-test.yaml index 44cb32d3c4a..6a632287ed6 100644 --- a/.github/workflows/analyses-snapshot-test.yaml +++ b/.github/workflows/analyses-snapshot-test.yaml @@ -40,7 +40,7 @@ jobs: matrix_json: ${{ steps.set-matrix.outputs.json }} steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup UV uses: astral-sh/setup-uv@v6 with: @@ -67,7 +67,7 @@ jobs: if: github.event_name == 'pull_request' steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Are the analyses snapshots in my PR branch in sync with the target branch? if: github.event_name == 'pull_request' run: | @@ -95,7 +95,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup UV uses: astral-sh/setup-uv@v6 with: @@ -132,7 +132,7 @@ jobs: PR_TARGET_BRANCH: ${{ github.event.pull_request.base.ref || 'edge'}} steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup UV uses: astral-sh/setup-uv@v6 with: diff --git a/.github/workflows/api-test-lint-deploy.yaml b/.github/workflows/api-test-lint-deploy.yaml index f5f1a39020f..8ab5aea6295 100644 --- a/.github/workflows/api-test-lint-deploy.yaml +++ b/.github/workflows/api-test-lint-deploy.yaml @@ -55,7 +55,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' @@ -92,7 +92,7 @@ jobs: run: | git fetch -f origin ${{ github.ref }}:${{ github.ref }} git checkout ${{ github.ref }} - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' @@ -187,8 +187,8 @@ jobs: runs-on: 'ubuntu-24.04' if: github.event_name == 'push' permissions: - id-token: write # Required for OIDC - contents: read # Required for checkout + id-token: write # Required for OIDC + contents: read # Required for checkout steps: - uses: 'actions/checkout@v4' with: @@ -199,7 +199,7 @@ jobs: run: | git fetch -f origin ${{ github.ref }}:${{ github.ref }} git checkout ${{ github.ref }} - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index 552a2d5256b..2d8b1a47bbd 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -18,6 +18,7 @@ on: - 'yarn.lock' - '.github/workflows/app-test-build-deploy.yaml' - '.github/workflows/utils.js' + - 'vitest.config.*' branches: - '**' tags: @@ -36,6 +37,7 @@ on: - '*.json' - 'yarn.lock' - 'scripts/**' + - '.vitest.config.*' workflow_dispatch: {} concurrency: @@ -450,7 +452,7 @@ jobs: if: always() && github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/ot3')) && needs.js-unit-test.result == 'success' && needs.backend-unit-test.result == 'success' && needs.build-app.result == 'success' && needs.deploy-release-app.result == 'success' steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send success alert' uses: ./.github/actions/simple-build-alert continue-on-error: true @@ -473,7 +475,7 @@ jobs: if: always() && github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/ot3')) && (needs.js-unit-test.result == 'failure' || needs.backend-unit-test.result == 'failure' || needs.build-app.result == 'failure' || needs.deploy-release-app.result == 'failure') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Determine failed jobs' id: failed-jobs shell: bash @@ -518,7 +520,7 @@ jobs: if: always() && github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/ot3')) && (needs.js-unit-test.result == 'cancelled' || needs.backend-unit-test.result == 'cancelled' || needs.build-app.result == 'cancelled' || needs.deploy-release-app.result == 'cancelled') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send cancelled alert' uses: ./.github/actions/simple-build-alert continue-on-error: true diff --git a/.github/workflows/components-test-build-deploy.yaml b/.github/workflows/components-test-build-deploy.yaml index fd8b06e91eb..a4e5733f52a 100644 --- a/.github/workflows/components-test-build-deploy.yaml +++ b/.github/workflows/components-test-build-deploy.yaml @@ -14,6 +14,7 @@ on: - 'app/src/molecules/**' - 'protocol-designer/src/components/**' - 'scripts/static-deploy/**' + - 'vitest.config.*' push: branches: - 'edge' @@ -46,7 +47,7 @@ jobs: relative_artifact_dir: ${{ steps.deploy-config.outputs.RELATIVE_ARTIFACT_DIR }} steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: ./.github/actions/git/resolve-tag - name: Setup UV uses: astral-sh/setup-uv@v6 @@ -90,11 +91,11 @@ jobs: name: 'components-artifact' path: storybook-static - deploy-components: name: 'deploy components storybook artifact to S3' runs-on: 'ubuntu-24.04' - needs: ['determine-deploy-config', 'js-unit-test', 'build-components-storybook'] + needs: + ['determine-deploy-config', 'js-unit-test', 'build-components-storybook'] if: always() && needs.build-components-storybook.result == 'success' && needs.determine-deploy-config.result == 'success' permissions: id-token: write @@ -106,7 +107,7 @@ jobs: role-to-assume: ${{ secrets.STATIC_DEPLOYMENT_ROLE }} aws-region: us-east-2 - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - id: resolve-tag @@ -156,7 +157,7 @@ jobs: json -I -f ./components/package.json -e "this.version=\"$VERSION_STRING\"" json -I -f ./components/package.json -e "this.dependencies['@opentrons/shared-data']=\"$VERSION_STRING\"" json -I -f ./components/package.json -e "delete this.dependencies['@opentrons/step-generation']" - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' registry-url: 'https://registry.npmjs.org' @@ -173,11 +174,17 @@ jobs: notify-success: name: 'Notify Build Success' runs-on: 'ubuntu-latest' - needs: [js-unit-test, build-components-storybook, deploy-components, publish-components] + needs: + [ + js-unit-test, + build-components-storybook, + deploy-components, + publish-components, + ] if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && needs.js-unit-test.result == 'success' && needs.build-components-storybook.result == 'success' && needs.deploy-components.result == 'success' && needs.publish-components.result == 'success' steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send success alert' uses: ./.github/actions/simple-build-alert with: @@ -188,11 +195,17 @@ jobs: notify-failure: name: 'Notify Build Failure' runs-on: 'ubuntu-latest' - needs: [js-unit-test, build-components-storybook, deploy-components, publish-components] + needs: + [ + js-unit-test, + build-components-storybook, + deploy-components, + publish-components, + ] if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.js-unit-test.result == 'failure' || needs.build-components-storybook.result == 'failure' || needs.deploy-components.result == 'failure' || needs.publish-components.result == 'failure') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Determine failed jobs' id: failed-jobs shell: bash @@ -210,7 +223,7 @@ jobs: if [[ "${{ needs.publish-components.result }}" == "failure" ]]; then failed_jobs+=("publish-components") fi - + IFS=',' echo "failed_jobs=${failed_jobs[*]}" >> $GITHUB_OUTPUT @@ -225,11 +238,17 @@ jobs: notify-cancelled: name: 'Notify Build Cancelled' runs-on: 'ubuntu-latest' - needs: [js-unit-test, build-components-storybook, deploy-components, publish-components] + needs: + [ + js-unit-test, + build-components-storybook, + deploy-components, + publish-components, + ] if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.js-unit-test.result == 'cancelled' || needs.build-components-storybook.result == 'cancelled' || needs.deploy-components.result == 'cancelled' || needs.publish-components.result == 'cancelled') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send cancelled alert' uses: ./.github/actions/simple-build-alert with: diff --git a/.github/workflows/docs-build-deploy.yaml b/.github/workflows/docs-build-deploy.yaml index f8fb4a28e60..0c0eb8c9b0b 100644 --- a/.github/workflows/docs-build-deploy.yaml +++ b/.github/workflows/docs-build-deploy.yaml @@ -29,7 +29,7 @@ env: # to the working-directory of our tools: scripts/static-deploy # our script deploy_ci_config.py expects this ENV variable is set RELATIVE_ARTIFACT_DIR: '../../dist' - + jobs: build-docs: timeout-minutes: 5 @@ -43,7 +43,7 @@ jobs: artifacts-path: ${{ steps.upload-artifacts.outputs.path }} steps: - name: 'Checkout Repository' - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup UV uses: astral-sh/setup-uv@v6 with: @@ -66,35 +66,35 @@ jobs: retention-days: 1 determine-deploy-config: - name: Determine Deployment Configuration - runs-on: ubuntu-24.04 - outputs: - application: ${{ steps.deploy-config.outputs.APPLICATION }} - environment: ${{ steps.deploy-config.outputs.ENVIRONMENT }} - sandbox_prefix: ${{ steps.deploy-config.outputs.SANDBOX_PREFIX }} - relative_artifact_dir: ${{ steps.deploy-config.outputs.RELATIVE_ARTIFACT_DIR }} - branch: ${{ steps.config.outputs.branch }} - bucket: ${{ steps.config.outputs.bucket }} - url: ${{ steps.config.outputs.url }} - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - uses: ./.github/actions/git/resolve-tag - - name: Setup UV - uses: astral-sh/setup-uv@v6 - with: - python-version: '3.10' - enable-cache: true - - name: Setup Deploy Dependencies - working-directory: scripts/static-deploy - run: make setup - - name: Determine Deployment Configuration - id: deploy-config - working-directory: scripts/static-deploy - run: make resolve-ci + name: Determine Deployment Configuration + runs-on: ubuntu-24.04 + outputs: + application: ${{ steps.deploy-config.outputs.APPLICATION }} + environment: ${{ steps.deploy-config.outputs.ENVIRONMENT }} + sandbox_prefix: ${{ steps.deploy-config.outputs.SANDBOX_PREFIX }} + relative_artifact_dir: ${{ steps.deploy-config.outputs.RELATIVE_ARTIFACT_DIR }} + branch: ${{ steps.config.outputs.branch }} + bucket: ${{ steps.config.outputs.bucket }} + url: ${{ steps.config.outputs.url }} + steps: + - name: Checkout Repository + uses: actions/checkout@v5 + - uses: ./.github/actions/git/resolve-tag + - name: Setup UV + uses: astral-sh/setup-uv@v6 + with: + python-version: '3.10' + enable-cache: true + - name: Setup Deploy Dependencies + working-directory: scripts/static-deploy + run: make setup + - name: Determine Deployment Configuration + id: deploy-config + working-directory: scripts/static-deploy + run: make resolve-ci deploy-docs: - needs: + needs: - build-docs - determine-deploy-config timeout-minutes: 5 @@ -109,7 +109,7 @@ jobs: URL: ${{ needs.determine-deploy-config.outputs.url }} steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - id: resolve-tag uses: ./.github/actions/git/resolve-tag - name: Setup UV @@ -119,8 +119,7 @@ jobs: enable-cache: true - name: Setup Deploy Dependencies working-directory: scripts/static-deploy - run: - make setup + run: make setup - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: @@ -141,8 +140,8 @@ jobs: ENVIRONMENT=${{ needs.determine-deploy-config.outputs.environment }} \ SANDBOX_PREFIX=${{ needs.determine-deploy-config.outputs.sandbox_prefix }} \ RELATIVE_ARTIFACT_DIR=${{ needs.determine-deploy-config.outputs.relative_artifact_dir }} - + - name: Output Deployment URL run: | echo "## 🚀 Docs site deployed to ${{ env.ENVIRONMENT }}" >> $GITHUB_STEP_SUMMARY - echo "<${{ env.URL }}>" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "<${{ env.URL }}>" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/docs-build.yaml b/.github/workflows/docs-build.yaml index 3bb9962afa2..e931bc5a6a0 100644 --- a/.github/workflows/docs-build.yaml +++ b/.github/workflows/docs-build.yaml @@ -56,7 +56,7 @@ jobs: relative_artifact_dir: ${{ steps.deploy-config.outputs.RELATIVE_ARTIFACT_DIR }} steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: ./.github/actions/git/resolve-tag - name: Setup UV uses: astral-sh/setup-uv@v6 @@ -80,7 +80,7 @@ jobs: fetch-depth: 0 - uses: ./.github/actions/git/resolve-tag - uses: ./.github/actions/environment/complex-variables - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v3' @@ -111,7 +111,7 @@ jobs: contents: read steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - uses: ./.github/actions/git/resolve-tag diff --git a/.github/workflows/g-code-confirm-tests.yaml b/.github/workflows/g-code-confirm-tests.yaml index 0bfb165c68a..26ce989fb4d 100644 --- a/.github/workflows/g-code-confirm-tests.yaml +++ b/.github/workflows/g-code-confirm-tests.yaml @@ -39,7 +39,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '12' - uses: 'actions/setup-python@v3' diff --git a/.github/workflows/g-code-testing-lint-test.yaml b/.github/workflows/g-code-testing-lint-test.yaml index e65fc3e4c4c..20ea13cfbd9 100644 --- a/.github/workflows/g-code-testing-lint-test.yaml +++ b/.github/workflows/g-code-testing-lint-test.yaml @@ -50,7 +50,7 @@ jobs: # 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 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'set complex environment variables' diff --git a/.github/workflows/hardware-lint-test.yaml b/.github/workflows/hardware-lint-test.yaml index ab0a41cd494..12ba48f1102 100644 --- a/.github/workflows/hardware-lint-test.yaml +++ b/.github/workflows/hardware-lint-test.yaml @@ -48,7 +48,7 @@ jobs: with: fetch-depth: 0 - name: Setup Node - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@v6' with: node-version: '12' diff --git a/.github/workflows/hardware-testing-protocols.yaml b/.github/workflows/hardware-testing-protocols.yaml index f22fd0d3847..6990d0b55bd 100644 --- a/.github/workflows/hardware-testing-protocols.yaml +++ b/.github/workflows/hardware-testing-protocols.yaml @@ -43,7 +43,7 @@ jobs: fetch-depth: 0 - name: Setup Node - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@v6' with: node-version: '12' diff --git a/.github/workflows/hardware-testing.yaml b/.github/workflows/hardware-testing.yaml index 91162150374..c6b24f0b68a 100644 --- a/.github/workflows/hardware-testing.yaml +++ b/.github/workflows/hardware-testing.yaml @@ -51,7 +51,7 @@ jobs: fetch-depth: 0 - name: Setup Node - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@v6' with: node-version: '12' diff --git a/.github/workflows/http-docs-build.yaml b/.github/workflows/http-docs-build.yaml index ab1a6e8f0a7..7654d9bbc5c 100644 --- a/.github/workflows/http-docs-build.yaml +++ b/.github/workflows/http-docs-build.yaml @@ -52,7 +52,7 @@ jobs: - uses: 'actions/setup-python@v3' with: python-version: '3.10' - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: './.github/actions/python/setup' diff --git a/.github/workflows/js-check.yaml b/.github/workflows/js-check.yaml index e7a43b98b02..cefda4e2d9d 100644 --- a/.github/workflows/js-check.yaml +++ b/.github/workflows/js-check.yaml @@ -6,23 +6,29 @@ name: 'JS checks' on: pull_request: paths: - - '**/*.js' - - './.*.js' - - '**/*.ts' - - '**/*.tsx' - - '**/*.json' - - '**/*.css' - - '**/*.md' + - '**.js' + - '**.cjs' + - '**.mjs' + - '**.ts' + - '**.cts' + - '**.mts' + - '**.tsx' + - '**.json' + - '**.css' + - '**.md' push: paths: - - '**/*.js' - - './.*.js' - - '**/*.ts' - - '**/*.tsx' - - '**/*.json' - - '**/*.md' + - '**.js' + - '**.cjs' + - '**.mjs' + - '**.ts' + - '**.cts' + - '**.mts' + - '**.tsx' + - '**.json' + - '**.css' + - '**.md' - '.github/workflows/js-check.yaml' - - '**/*.css' workflow_dispatch: concurrency: @@ -43,7 +49,7 @@ jobs: timeout-minutes: 20 steps: - uses: 'actions/checkout@v4' - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'set complex environment variables' diff --git a/.github/workflows/ll-test-build-deploy.yaml b/.github/workflows/ll-test-build-deploy.yaml index 529c8760efa..b7219ebb694 100644 --- a/.github/workflows/ll-test-build-deploy.yaml +++ b/.github/workflows/ll-test-build-deploy.yaml @@ -12,6 +12,8 @@ on: - '.github/workflows/ll-test-build-deploy.yaml' - '.github/workflows/utils.js' - 'scripts/static-deploy/**' + - 'scripts/git-version-v2.mjs' + - 'vitest.config.*' push: branches: - 'edge' @@ -47,10 +49,10 @@ jobs: relative_artifact_dir: ${{ steps.deploy-config.outputs.RELATIVE_ARTIFACT_DIR }} steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: ./.github/actions/git/resolve-tag - name: Setup UV - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: python-version: '3.10' - name: Setup Deploy Dependencies @@ -68,7 +70,7 @@ jobs: runs-on: 'ubuntu-24.04' if: ${{ !startsWith(github.ref, 'refs/tags/') }} steps: - - uses: 'actions/checkout@v4' + - uses: 'actions/checkout@v5' - uses: ./.github/actions/js/setup - name: 'run labware library unit tests' run: make -C labware-library test-cov @@ -84,7 +86,7 @@ jobs: runs-on: 'ubuntu-24.04' if: ${{ !startsWith(github.ref, 'refs/tags/') }} steps: - - uses: 'actions/checkout@v4' + - uses: 'actions/checkout@v5' - uses: ./.github/actions/js/setup - name: 'test-e2e' env: @@ -93,12 +95,10 @@ jobs: run: make -C labware-library test-e2e build-ll: name: 'build labware library artifact' - needs: ['js-unit-test', 'e2e-test'] timeout-minutes: 10 runs-on: 'ubuntu-24.04' - if: always() && (needs.js-unit-test.result == 'success' || needs.js-unit-test.result == 'skipped') && (needs.e2e-test.result == 'success' || needs.e2e-test.result == 'skipped') steps: - - uses: 'actions/checkout@v4' + - uses: 'actions/checkout@v5' with: fetch-depth: 0 - uses: ./.github/actions/js/setup @@ -125,12 +125,12 @@ jobs: contents: read steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - uses: ./.github/actions/git/resolve-tag - name: Setup UV - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: python-version: '3.10' - name: Setup Deploy Dependencies @@ -167,7 +167,7 @@ jobs: if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.js-unit-test.result == 'success' || needs.js-unit-test.result == 'skipped') && needs.build-ll.result == 'success' && needs.deploy-ll.result == 'success' steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send success alert' uses: ./.github/actions/simple-build-alert with: @@ -182,7 +182,7 @@ jobs: if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.js-unit-test.result == 'failure' || needs.build-ll.result == 'failure' || needs.deploy-ll.result == 'failure') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Determine failed jobs' id: failed-jobs shell: bash @@ -197,7 +197,7 @@ jobs: if [[ "${{ needs.deploy-ll.result }}" == "failure" ]]; then failed_jobs+=("deploy-ll") fi - + IFS=',' echo "failed_jobs=${failed_jobs[*]}" >> $GITHUB_OUTPUT @@ -216,7 +216,7 @@ jobs: if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.js-unit-test.result == 'cancelled' || needs.build-ll.result == 'cancelled' || needs.deploy-ll.result == 'cancelled') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send cancelled alert' uses: ./.github/actions/simple-build-alert with: diff --git a/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml b/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml index 1010d6232e5..40e730b0396 100644 --- a/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml +++ b/.github/workflows/opentrons-ai-client-staging-continuous-deploy.yaml @@ -25,7 +25,7 @@ jobs: timeout-minutes: 10 steps: - uses: 'actions/checkout@v4' - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'install udev' diff --git a/.github/workflows/opentrons-ai-client-test.yaml b/.github/workflows/opentrons-ai-client-test.yaml index e050bf2ab5a..a7b6a2af7e5 100644 --- a/.github/workflows/opentrons-ai-client-test.yaml +++ b/.github/workflows/opentrons-ai-client-test.yaml @@ -12,6 +12,7 @@ on: - 'components/**' - 'shared-data/**' - '.github/workflows/opentrons-ai-client-test.yml' + - 'vitest.config.*' branches: - '**' tags: @@ -24,6 +25,7 @@ on: - 'components/**' - 'shared-data/**' - '.github/workflows/opentrons-ai-client-test.yml' + - 'vitest.config.*' workflow_dispatch: concurrency: @@ -40,7 +42,7 @@ jobs: timeout-minutes: 60 steps: - uses: 'actions/checkout@v4' - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'install udev' diff --git a/.github/workflows/opentrons-ai-production-deploy.yaml b/.github/workflows/opentrons-ai-production-deploy.yaml index 870df40dd88..4e39554df4d 100644 --- a/.github/workflows/opentrons-ai-production-deploy.yaml +++ b/.github/workflows/opentrons-ai-production-deploy.yaml @@ -24,7 +24,7 @@ jobs: timeout-minutes: 10 steps: - uses: 'actions/checkout@v4' - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'install udev' @@ -114,7 +114,7 @@ jobs: if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.deploy-client.result == 'success' || needs.deploy-server.result == 'success') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send success alert' uses: ./.github/actions/simple-build-alert with: @@ -129,7 +129,7 @@ jobs: if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.deploy-client.result == 'failure' || needs.deploy-server.result == 'failure') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Determine failed jobs' id: failed-jobs shell: bash @@ -141,7 +141,7 @@ jobs: if [[ "${{ needs.deploy-server.result }}" == "failure" ]]; then failed_jobs+=("deploy-server") fi - + IFS=',' echo "failed_jobs=${failed_jobs[*]}" >> $GITHUB_OUTPUT @@ -160,7 +160,7 @@ jobs: if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.deploy-client.result == 'cancelled' || needs.deploy-server.result == 'cancelled') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send cancelled alert' uses: ./.github/actions/simple-build-alert with: diff --git a/.github/workflows/pd-test-build-deploy.yaml b/.github/workflows/pd-test-build-deploy.yaml index 7780d0c6626..408d229844e 100644 --- a/.github/workflows/pd-test-build-deploy.yaml +++ b/.github/workflows/pd-test-build-deploy.yaml @@ -16,6 +16,8 @@ on: - '.github/actions/environment/complex-variables/action.yml' - 'scripts/static-deploy/**' - 'scripts/git-version-protocol-designer.mjs' + - 'scripts/git-version-v2.mjs' + - 'vitest.config.*' push: branches: - 'edge' @@ -51,7 +53,7 @@ jobs: relative_artifact_dir: ${{ steps.deploy-config.outputs.RELATIVE_ARTIFACT_DIR }} steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: ./.github/actions/git/resolve-tag - name: Setup UV uses: astral-sh/setup-uv@v6 @@ -73,7 +75,7 @@ jobs: if: ${{ !startsWith(github.ref, 'refs/tags/') }} steps: - name: 'Checkout Repository' - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: ./.github/actions/js/setup - name: 'run unit tests' run: make -C protocol-designer test-cov @@ -83,31 +85,14 @@ jobs: flags: protocol-designer token: ${{ secrets.CODECOV_TOKEN }} - e2e-test: - name: 'protocol designer e2e tests' - runs-on: 'ubuntu-24.04' - timeout-minutes: 20 - if: ${{ !startsWith(github.ref, 'refs/tags/') }} - steps: - - name: 'Checkout Repository' - uses: actions/checkout@v4 - with: - fetch-depth: 0 # PD needs to see labware in other release branches - - uses: ./.github/actions/js/setup - - name: 'run test-e2e' - run: make -C protocol-designer test-e2e - build-pd: timeout-minutes: 20 name: 'build protocol designer' needs: - determine-deploy-config - - unit-test - - e2e-test runs-on: 'ubuntu-24.04' - if: always() && (needs.unit-test.result == 'success' || needs.unit-test.result == 'skipped') && (needs.e2e-test.result == 'success' || needs.e2e-test.result == 'skipped') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 # Unlimited fetch depth. Required for getsentry/action-release. - id: resolve-tag @@ -127,8 +112,8 @@ jobs: run: | make -C protocol-designer NODE_ENV=${{ needs.determine-deploy-config.outputs.environment == 'production' && 'production' || 'development' }} OT_PD_PRERELEASE_MODE=${{ needs.determine-deploy-config.outputs.environment == 'sandbox' && '1' || '0' }} - name: 'upload sourcemaps to Sentry' - # Only on production releases (tagged with protocol-designer*) - if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/protocol-designer') + # Only on production or staging releases (protocol-designer* or staging-protocol-designer*) + if: github.ref_type == 'tag' && (startsWith(github.ref, 'refs/tags/protocol-designer') || startsWith(github.ref, 'refs/tags/staging-protocol-designer')) uses: getsentry/action-release@v3 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -140,7 +125,7 @@ jobs: set_commits: auto ignore_missing: true finalize: false - environment: production + environment: ${{ needs.determine-deploy-config.outputs.environment == 'production' && 'production' || 'staging' }} - name: 'upload github artifact' uses: actions/upload-artifact@v4 with: @@ -165,7 +150,7 @@ jobs: role-to-assume: ${{ secrets.STATIC_DEPLOYMENT_ROLE }} aws-region: us-east-2 - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - id: resolve-tag @@ -198,8 +183,8 @@ jobs: SANDBOX_PREFIX=${{ needs.determine-deploy-config.outputs.sandbox_prefix }} \ RELATIVE_ARTIFACT_DIR=${{ needs.determine-deploy-config.outputs.relative_artifact_dir }} - name: 'finalize the production release in Sentry' - # Only on production releases (tagged with protocol-designer*) - if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/protocol-designer') + # Only on production or staging releases (protocol-designer* or staging-protocol-designer*) + if: github.ref_type == 'tag' && (startsWith(github.ref, 'refs/tags/protocol-designer') || startsWith(github.ref, 'refs/tags/staging-protocol-designer')) uses: getsentry/action-release@v3 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -213,11 +198,11 @@ jobs: notify-success: name: 'Notify Build Success' runs-on: 'ubuntu-latest' - needs: [unit-test, e2e-test, build-pd, deploy-pd] - if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.unit-test.result == 'success' || needs.unit-test.result == 'skipped') && (needs.e2e-test.result == 'success' || needs.e2e-test.result == 'skipped') && needs.build-pd.result == 'success' && needs.deploy-pd.result == 'success' + needs: [unit-test, build-pd, deploy-pd] + if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.unit-test.result == 'success' || needs.unit-test.result == 'skipped') && needs.build-pd.result == 'success' && needs.deploy-pd.result == 'success' steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send success alert' uses: ./.github/actions/simple-build-alert with: @@ -228,11 +213,11 @@ jobs: notify-failure: name: 'Notify Build Failure' runs-on: 'ubuntu-latest' - needs: [unit-test, e2e-test, build-pd, deploy-pd] - if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.unit-test.result == 'failure' || needs.e2e-test.result == 'failure' || needs.build-pd.result == 'failure' || needs.deploy-pd.result == 'failure') + needs: [unit-test, build-pd, deploy-pd] + if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.unit-test.result == 'failure' || needs.build-pd.result == 'failure' || needs.deploy-pd.result == 'failure') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Determine failed jobs' id: failed-jobs shell: bash @@ -241,9 +226,6 @@ jobs: if [[ "${{ needs.unit-test.result }}" == "failure" ]]; then failed_jobs+=("unit-test") fi - if [[ "${{ needs.e2e-test.result }}" == "failure" ]]; then - failed_jobs+=("e2e-test") - fi if [[ "${{ needs.build-pd.result }}" == "failure" ]]; then failed_jobs+=("build-pd") fi @@ -265,11 +247,11 @@ jobs: notify-cancelled: name: 'Notify Build Cancelled' runs-on: 'ubuntu-latest' - needs: [unit-test, e2e-test, build-pd, deploy-pd] - if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.unit-test.result == 'cancelled' || needs.e2e-test.result == 'cancelled' || needs.build-pd.result == 'cancelled' || needs.deploy-pd.result == 'cancelled') + needs: [unit-test, build-pd, deploy-pd] + if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.unit-test.result == 'cancelled' || needs.build-pd.result == 'cancelled' || needs.deploy-pd.result == 'cancelled') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send cancelled alert' uses: ./.github/actions/simple-build-alert with: diff --git a/.github/workflows/react-api-client-test.yaml b/.github/workflows/react-api-client-test.yaml index 73cabadb61b..72c24b0e4d5 100644 --- a/.github/workflows/react-api-client-test.yaml +++ b/.github/workflows/react-api-client-test.yaml @@ -9,12 +9,14 @@ on: - 'api-client/**' - 'package.json' - '.github/workflows/react-api-client-test.yaml' + - 'vitest.config.*' push: paths: - 'react-api-client/**' - 'api-client/**' - 'package.json' - '.github/workflows/react-api-client-test.yaml' + - 'vitest.config.*' branches: - '*' workflow_dispatch: @@ -37,7 +39,7 @@ jobs: runs-on: 'ubuntu-24.04' steps: - uses: 'actions/checkout@v4' - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'install libudev for usb-detection' diff --git a/.github/workflows/robot-server-lint-test.yaml b/.github/workflows/robot-server-lint-test.yaml index ead5704203f..54c63835551 100644 --- a/.github/workflows/robot-server-lint-test.yaml +++ b/.github/workflows/robot-server-lint-test.yaml @@ -59,7 +59,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' diff --git a/.github/workflows/server-utils-lint-test.yaml b/.github/workflows/server-utils-lint-test.yaml index d84c54a6690..dcd41aea214 100644 --- a/.github/workflows/server-utils-lint-test.yaml +++ b/.github/workflows/server-utils-lint-test.yaml @@ -44,7 +44,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' @@ -65,7 +65,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' diff --git a/.github/workflows/shared-data-test-lint-deploy.yaml b/.github/workflows/shared-data-test-lint-deploy.yaml index 1e0b29ad7fa..0cfa1a47820 100644 --- a/.github/workflows/shared-data-test-lint-deploy.yaml +++ b/.github/workflows/shared-data-test-lint-deploy.yaml @@ -13,6 +13,7 @@ on: - '.github/workflows/shared-data-test-lint-deploy.yaml' - '.github/actions/python/**/*' - '.github/workflows/utils.js' + - 'vitest.config.*' branches: - 'edge' - 'release' @@ -30,6 +31,7 @@ on: - '.github/workflows/shared-data-test-lint-deploy.yaml' - '.github/actions/python/**/*' - '.github/workflows/utils.js' + - 'vitest.config.*' workflow_dispatch: concurrency: @@ -49,7 +51,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v3' @@ -116,7 +118,7 @@ jobs: timeout-minutes: 30 steps: - uses: 'actions/checkout@v4' - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'install udev' @@ -152,8 +154,8 @@ jobs: runs-on: 'ubuntu-24.04' if: github.event_name == 'push' permissions: - id-token: write # Required for OIDC - contents: read # Required for checkout + id-token: write # Required for OIDC + contents: read # Required for checkout steps: - uses: 'actions/checkout@v4' with: @@ -164,7 +166,7 @@ jobs: run: | git fetch -f origin ${{ github.ref }}:${{ github.ref }} git checkout ${{ github.ref }} - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'install udev for usb-detection' @@ -236,7 +238,7 @@ jobs: run: | git fetch -f origin ${{ github.ref }}:${{ github.ref }} git checkout ${{ github.ref }} - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' registry-url: 'https://registry.npmjs.org' @@ -271,7 +273,7 @@ jobs: VERSION_STRING=$(echo ${{ github.ref }} | sed -E 's/refs\/tags\/(components|shared-data)@//') json -I -f ./shared-data/package.json -e "this.version=\"$VERSION_STRING\"" cd ./shared-data - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' registry-url: 'https://registry.npmjs.org' @@ -285,11 +287,19 @@ jobs: notify-success: name: 'Notify Build Success' runs-on: 'ubuntu-latest' - needs: [python-lint, python-test, js-test, python-deploy, publish-switch, publish-to-npm] + needs: + [ + python-lint, + python-test, + js-test, + python-deploy, + publish-switch, + publish-to-npm, + ] if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && needs.python-lint.result == 'success' && needs.python-test.result == 'success' && needs.js-test.result == 'success' && needs.python-deploy.result == 'success' && needs.publish-switch.result == 'success' && needs.publish-to-npm.result == 'success' steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send success alert' uses: ./.github/actions/simple-build-alert with: @@ -300,11 +310,19 @@ jobs: notify-failure: name: 'Notify Build Failure' runs-on: 'ubuntu-latest' - needs: [python-lint, python-test, js-test, python-deploy, publish-switch, publish-to-npm] + needs: + [ + python-lint, + python-test, + js-test, + python-deploy, + publish-switch, + publish-to-npm, + ] if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.python-lint.result == 'failure' || needs.python-test.result == 'failure' || needs.js-test.result == 'failure' || needs.python-deploy.result == 'failure' || needs.publish-switch.result == 'failure' || needs.publish-to-npm.result == 'failure') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Determine failed jobs' id: failed-jobs shell: bash @@ -328,7 +346,7 @@ jobs: if [[ "${{ needs.publish-to-npm.result }}" == "failure" ]]; then failed_jobs+=("publish-to-npm") fi - + IFS=',' echo "failed_jobs=${failed_jobs[*]}" >> $GITHUB_OUTPUT @@ -343,11 +361,19 @@ jobs: notify-cancelled: name: 'Notify Build Cancelled' runs-on: 'ubuntu-latest' - needs: [python-lint, python-test, js-test, python-deploy, publish-switch, publish-to-npm] + needs: + [ + python-lint, + python-test, + js-test, + python-deploy, + publish-switch, + publish-to-npm, + ] if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (needs.python-lint.result == 'cancelled' || needs.python-test.result == 'cancelled' || needs.js-test.result == 'cancelled' || needs.python-deploy.result == 'cancelled' || needs.publish-switch.result == 'cancelled' || needs.publish-to-npm.result == 'cancelled') steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Send cancelled alert' uses: ./.github/actions/simple-build-alert with: diff --git a/.github/workflows/static-deploy-lint-test.yaml b/.github/workflows/static-deploy-lint-test.yaml index 8e54be317f6..50fcea3d2ee 100644 --- a/.github/workflows/static-deploy-lint-test.yaml +++ b/.github/workflows/static-deploy-lint-test.yaml @@ -16,7 +16,7 @@ jobs: steps: - name: 'Checkout Repository' - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup UV uses: astral-sh/setup-uv@v6 with: diff --git a/.github/workflows/step-generation-test.yaml b/.github/workflows/step-generation-test.yaml index c589223e452..4cd9b45015a 100644 --- a/.github/workflows/step-generation-test.yaml +++ b/.github/workflows/step-generation-test.yaml @@ -12,6 +12,7 @@ on: - '.github/actions/js/setup/action.yml' - '.github/actions/git/resolve-tag/action.yml' - '.github/actions/environment/complex-variables/action.yml' + - 'vitest.config.*' push: paths: - 'step-generation/**' @@ -21,6 +22,7 @@ on: - '.github/actions/js/setup/action.yml' - '.github/actions/git/resolve-tag/action.yml' - '.github/actions/environment/complex-variables/action.yml' + - 'vitest.config.*' branches: - '*' @@ -42,7 +44,7 @@ jobs: timeout-minutes: 20 steps: - name: 'Checkout Repository' - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: ./.github/actions/js/setup - name: 'run step generation unit tests' run: make -C step-generation test-cov diff --git a/.github/workflows/system-server-lint-test.yaml b/.github/workflows/system-server-lint-test.yaml index 0cad92fa3be..a5eea8df161 100644 --- a/.github/workflows/system-server-lint-test.yaml +++ b/.github/workflows/system-server-lint-test.yaml @@ -46,7 +46,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' @@ -67,7 +67,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' diff --git a/.github/workflows/tag-releases.yaml b/.github/workflows/tag-releases.yaml index 2d82d0aed24..66756a63e3e 100644 --- a/.github/workflows/tag-releases.yaml +++ b/.github/workflows/tag-releases.yaml @@ -22,7 +22,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - name: 'cache yarn cache' diff --git a/.github/workflows/update-server-lint-test.yaml b/.github/workflows/update-server-lint-test.yaml index d81b88b9694..874b8eb98bf 100644 --- a/.github/workflows/update-server-lint-test.yaml +++ b/.github/workflows/update-server-lint-test.yaml @@ -44,7 +44,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' @@ -65,7 +65,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' diff --git a/.github/workflows/usb-bridge-lint-test.yaml b/.github/workflows/usb-bridge-lint-test.yaml index 435ec72a01b..9172efdc9db 100644 --- a/.github/workflows/usb-bridge-lint-test.yaml +++ b/.github/workflows/usb-bridge-lint-test.yaml @@ -44,7 +44,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' @@ -65,7 +65,7 @@ jobs: - uses: 'actions/checkout@v4' with: fetch-depth: 0 - - uses: 'actions/setup-node@v4' + - uses: 'actions/setup-node@v6' with: node-version: '22.12.0' - uses: 'actions/setup-python@v4' diff --git a/.storybook/main.ts b/.storybook/main.ts index 7970bfb233e..ca4fc685883 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,13 +1,16 @@ -module.exports = { +import type { StorybookConfig } from '@storybook/react-vite' + +const config: StorybookConfig = { stories: [ '../components/**/*.stories.@(js|jsx|ts|tsx)', '../app/**/*.stories.@(js|jsx|ts|tsx)', '../protocol-designer/**/*.stories.@(js|jsx|ts|tsx)', '../opentrons-ai-client/**/*.stories.@(js|jsx|ts|tsx)', '../components/**/*.mdx', - '../app/**/*.mdx', - '../protocol-designer/**/*.mdx', - '../opentrons-ai-client/**/*.mdx', + // ToDo activate when needed + // '../app/**/*.mdx', + // '../protocol-designer/**/*.mdx', + // '../opentrons-ai-client/**/*.mdx', ], addons: [ @@ -18,10 +21,20 @@ module.exports = { framework: { name: '@storybook/react-vite', - options: {}, + options: { + builder: { + // Storybook would normally find the Vite config automatically. + // That doesn't work for us because we have one monorepo-wide Storybook + // installation, while each project has its own local Vite config. + // So we treat Storybook as its own Vite project with its own config. + viteConfigPath: '.storybook/vite.config.mjs', + }, + }, }, docs: { autodocs: true, }, } + +export default config diff --git a/vite.config.mts b/.storybook/vite.config.mts similarity index 75% rename from vite.config.mts rename to .storybook/vite.config.mts index ee8a29df8eb..a487380e600 100644 --- a/vite.config.mts +++ b/.storybook/vite.config.mts @@ -1,23 +1,6 @@ /// /// -// todo(mm, 2025-09-15): This file is used under confusing circumstances. -// -// For normal production bundling and dev-serving, each project has its own -// vite.config.mts that gets favored. This one is never used. -// -// For vitest invocations, vitest would normally default to those same project-specific -// vite.config.mts files. However, because we have a single global vitest.config.mts, it -// uses that instead, completely ignoring the project-specific files. From there, -// vitest.config.mts explicitly includes this file. -// -// So, that leaves us with: -// - An arbitrary split between this global vite.config.mts the global vitest.config.mts -// - Global vite.config.mts and global vitest.config.mts comprising, together, an -// amalgamation of all projects' needs -- all projects' aliases, all projects' defines, etc. -// - Which is probably largely duplicating the existing project-local configs, -// which we'd get for free if we didn't override them with our vitest.config.mts - import path from 'path' import react from '@vitejs/plugin-react' import lostCss from 'lost' @@ -63,7 +46,9 @@ export default defineConfig({ // NOTE: For security, only include environment variables here if they're explicitly allowlisted. _FF_ENV_VARS_: {}, _NODE_ENV_: JSON.stringify(process.env.NODE_ENV), - _OT_AI_CLIENT_MIXPANEL_ID_: JSON.stringify(process.env.OT_AI_CLIENT_MIXPANEL_ID), + _OT_AI_CLIENT_MIXPANEL_ID_: JSON.stringify( + process.env.OT_AI_CLIENT_MIXPANEL_ID + ), _OT_APP_MIXPANEL_ID_: JSON.stringify(process.env.OT_APP_MIXPANEL_ID), _OT_LL_MIXPANEL_DEV_ID_: JSON.stringify(process.env.OT_LL_MIXPANEL_DEV_ID), _OT_LL_MIXPANEL_ID_: JSON.stringify(process.env.OT_LL_MIXPANEL_ID), diff --git a/Makefile b/Makefile index f297819972d..1c155a041cb 100755 --- a/Makefile +++ b/Makefile @@ -196,7 +196,6 @@ test-windows: test-js test-py-windows .PHONY: test-e2e test-e2e: $(MAKE) -C $(LABWARE_LIBRARY_DIR) test-e2e - $(MAKE) -C $(PROTOCOL_DESIGNER_DIR) test-e2e PYTHON_TEST_TARGETS := $(addsuffix -py-test, $(PYTHON_DIRS)) WINDOWS_PYTHON_TEST_TARGETS := $(addsuffix -py-test, $(HARDWARE_DIR) $(API_DIR) $(SHARED_DATA_DIR)/python) diff --git a/abr-testing/abr_testing/protocols/active_protocols/10_ZymoBIOMICS_Magbead_DNA_Cells_Flex.py b/abr-testing/abr_testing/protocols/active_protocols/10_ZymoBIOMICS_Magbead_DNA_Cells_Flex.py index 320ff01b0c0..01442e22dd3 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/10_ZymoBIOMICS_Magbead_DNA_Cells_Flex.py +++ b/abr-testing/abr_testing/protocols/active_protocols/10_ZymoBIOMICS_Magbead_DNA_Cells_Flex.py @@ -19,7 +19,7 @@ } -requirements = {"robotType": "Flex", "apiLevel": "2.26"} +requirements = {"robotType": "Flex", "apiLevel": "2.27"} """ Slot A1: Tips 1000 Slot A2: Tips 1000 @@ -71,12 +71,13 @@ def add_parameters(parameters: protocol_api.ParameterContext) -> None: def run(protocol: protocol_api.ProtocolContext) -> None: """Protocol Set Up.""" + protocol.capture_image(filename="start_of_run") heater_shaker_speed = protocol.params.heater_shaker_speed # type: ignore[attr-defined] mount = protocol.params.pipette_mount # type: ignore[attr-defined] deactivate_modules_bool = protocol.params.deactivate_modules # type: ignore[attr-defined] probe_height_bool = protocol.params.probe_liquid_height # type: ignore[attr-defined] meniscus_z = protocol.params.meniscus_z # type: ignore[attr-defined] - helpers.comment_protocol_version(protocol, "03") + helpers.comment_protocol_version(protocol, "04") if not protocol.is_simulating(): slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) @@ -265,6 +266,7 @@ def mixing(well: Well, pip: InstrumentContext, mvol: float, reps: int = 8) -> No dispensing at the top and 2 cycles of aspirating from middle, dispensing at the bottom """ + protocol.capture_image(filename="mixing") center = well.top(5) asp = well.bottom(z=1) disp = well.top(-8) @@ -295,6 +297,7 @@ def mixing(well: Well, pip: InstrumentContext, mvol: float, reps: int = 8) -> No def lysis(vol: float, source: Well) -> None: """Lysis.""" protocol.comment("-----Beginning Lysis Steps-----") + protocol.capture_image(filename="lysis") num_transfers = math.ceil(vol / 980) tipcheck(m1000) total_lysis_aspirated = 0.0 @@ -337,6 +340,8 @@ def bind(vol1: float, vol2: float) -> None: plate. """ protocol.comment("-----Beginning Binding Steps-----") + protocol.capture_image(filename="binding_steps") + for i, well in enumerate(samples_m): tipcheck(m1000) num_trans = math.ceil(vol1 / 980) @@ -439,6 +444,8 @@ def wash(vol: float, source: List[Well]) -> None: """Wash Steps.""" global whichwash # Defines which wash the protocol is on to log on the app protocol.comment("-----Now starting Wash #" + str(whichwash) + "-----") + protocol.capture_image(filename="wash_step") + global wash_volume_tracker num_trans = math.ceil(vol / 980.0) @@ -487,6 +494,7 @@ def wash(vol: float, source: List[Well]) -> None: remove_supernatant(vol) def elute(vol: float) -> None: + protocol.capture_image(filename="elute_step") tipcheck(m1000) total_elution_vol = 0.0 for i, m in enumerate(samples_m): @@ -564,7 +572,7 @@ def elute(vol: float) -> None: wash(wash1_vol, all_washes) wash(wash2_vol, all_washes) wash(wash3_vol, all_washes) - h_s.set_and_wait_for_temperature(55) + h_s.set_target_temperature(55) for beaddry in np.arange(drybeads, 0, -0.5): protocol.delay( minutes=0.5, @@ -583,6 +591,7 @@ def elute(vol: float) -> None: ) if deactivate_modules_bool: helpers.deactivate_modules(protocol) + protocol.capture_image(filename="end_of_run") if not protocol.is_simulating(): slack_bot.send_run_completed_message(metadata["protocolName"]) except Exception as e: diff --git a/abr-testing/abr_testing/protocols/active_protocols/11_IDT xGen 1000 ul 96ch.py b/abr-testing/abr_testing/protocols/active_protocols/11_IDT xGen 1000 ul 96ch.py index cdec6f9cef5..a3ca3a89837 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/11_IDT xGen 1000 ul 96ch.py +++ b/abr-testing/abr_testing/protocols/active_protocols/11_IDT xGen 1000 ul 96ch.py @@ -18,7 +18,7 @@ } requirements = { "robotType": "Flex", - "apiLevel": "2.26", + "apiLevel": "2.27", } @@ -57,6 +57,8 @@ def add_parameters(parameters: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Protocol.""" + protocol.capture_image(filename="start_of_run") + # ======================== DOWNLOADED PARAMETERS ======================== global COLUMNS # Number of Columns of Samples # =================== LOADING THE RUNTIME PARAMETERS ==================== @@ -94,8 +96,7 @@ def run(protocol: ProtocolContext) -> None: TIP_MIX = True # Default False | Use Tip Mixing instead of Heatershaker ONDECK_THERMO = True # Default True | On Deck Thermocycler ONDECK_TEMP = True - NOLABEL = False # Default False | True = Do not include Liquid Labeling, - helpers.comment_protocol_version(protocol, "02") + helpers.comment_protocol_version(protocol, "03") # =============================== PIPETTE =============================== p1000 = protocol.load_instrument("flex_96channel_1000", "left") @@ -187,15 +188,198 @@ def run(protocol: ProtocolContext) -> None: CleanupBead = reagent_plate_2["A1"] Adapter = reagent_plate_2["A2"] Barcodes = reagent_plate_2["B1"] + # ====== CALCULATING LIQUIDS ====== + Sample_Volume = 19.5 + Reagent_Vol_CleanupBead_Volume = 80.5 + Reagent_Vol_RSB = 52 + Reagent_Vol_PCR = 25 + Reagent_Vol_FRERAT = 10.5 + Reagent_Vol_ERAT = 10.5 + Reagent_Vol_Adapter = 5 + Reagent_Vol_LIG = 25 + + Row_Quadrant12 = ["A", "C", "E", "G", "I", "K", "M", "O"] + Row_Quadrant34 = ["B", "D", "F", "H", "J", "L", "N", "P"] + Row_96 = ["A", "B", "C", "D", "E", "F", "G", "H"] + + Column_Quadrant13 = [ + "1", + "3", + "5", + "7", + "9", + "11", + "13", + "15", + "17", + "19", + "21", + "23", + ] + Column_Quadrant24 = [ + "2", + "4", + "6", + "8", + "10", + "12", + "14", + "16", + "18", + "20", + "22", + "24", + ] + Column_96 = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] + + # ======== DEFINING LIQUIDS ======= + Sample = protocol.define_liquid( + name="Sample", description="Sample", display_color="#52AAFF" + ) # 52AAFF = 'Sample Blue' + Reagent_CleanupBead = protocol.define_liquid( + name="EtOH", description="CleanupBead Beads", display_color="#704848" + ) # 704848 = 'CleanupBead Brown' + protocol.define_liquid( + name="EtOH", description="80% Ethanol", display_color="#9ACECB" + ) # 9ACECB = 'Ethanol Blue' + Reagent_RSB = protocol.define_liquid( + name="RSB", description="Resuspension Buffer", display_color="#00FFF2" + ) # 00FFF2 = 'Base Light Blue' + Reagent_PCR = protocol.define_liquid( + name="PCR", description="PCR Mix", display_color="#FF0000" + ) # FF0000 = 'Base Red' + Reagent_FRERAT = protocol.define_liquid( + name="FRERAT", + description="Fragmentation Enzymatic Prep", + display_color="#FFA000", + ) # FFA000 = 'Base Orange' + Reagent_ERAT = protocol.define_liquid( + name="ERAT", + description="End Repair Enzymatic Prep", + display_color="#FFA000", + ) # FFA000 = 'Base Orange' + Reagent_LIG = protocol.define_liquid( + name="LIG", description="Ligation Mix", display_color="#0EFF00" + ) # 0EFF00 = 'Base Green' + Reagent_Adapter = protocol.define_liquid( + name="Adapter", description="Adapter", display_color="#0EFF00" + ) # 0EFF00 = 'Base Green' + protocol.define_liquid( + name="PRIMER", description="PRIMER", display_color="#0EFF00" + ) # 0EFF00 = 'Base Green' + Reagent_Barcodes = protocol.define_liquid( + name="Barcodes", description="Barcodes", display_color="#7DFFC4" + ) # 7DFFC4 = 'Barcode Green' + protocol.define_liquid( + name="H20", description="H20", display_color="#AABFBF" + ) # AABFBF = 'H20' + Placeholder_Sample = protocol.define_liquid( + name="Placeholder_Sample", + description="Excess Sample", + display_color="#82A9CF", + ) # 82A9CF = 'Placeholder Sample Blue' + protocol.define_liquid( + name="Final_Sample", description="Final Sample", display_color="#82A9CF" + ) # 82A9CF = 'Placeholder Blue' + Liquid_trash_well = protocol.define_liquid( + name="Liquid_trash_well", + description="Liquid Trash", + display_color="#9B9B9B", + ) # 9B9B9B = 'Liquid Trash Grey' + + # ======== LOADING LIQUIDS ======= + # ========================== REAGENT PLATE_1 ============================ + if FRAG_MODE == "EZ": + FRERAT = reagent_plate_1["A1"] + if FRAG_MODE == "MC": + ERAT = reagent_plate_1["A1"] + LIG = reagent_plate_1["A2"] + PCR = reagent_plate_1["B1"] + RSB = reagent_plate_1["B2"] + + # ========================== REAGENT PLATE_2 ============================ + CleanupBead = reagent_plate_2["A1"] + Adapter = reagent_plate_2["A2"] + Barcodes = reagent_plate_2["B1"] + + # Reagent Plate 1 + for row in Row_Quadrant12: + if FRAG_MODE == "EZ": + for col in Column_Quadrant13: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_FRERAT, volume=Reagent_Vol_FRERAT + ) + for col in Column_Quadrant13: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_ERAT, volume=Reagent_Vol_ERAT + ) + for col in Column_Quadrant24: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_LIG, volume=Reagent_Vol_LIG + ) + for row in Row_Quadrant34: + for col in Column_Quadrant13: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_PCR, volume=Reagent_Vol_PCR * (1 / 12) + ) + for col in Column_Quadrant24: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_RSB, volume=Reagent_Vol_RSB * (1 / 12) + ) + # Reagent Plate 1 + for row in Row_Quadrant12: + for col in Column_Quadrant13: + reagent_plate_2.wells_by_name()[row + col].load_liquid( + liquid=Reagent_CleanupBead, + volume=Reagent_Vol_CleanupBead_Volume, + ) + for col in Column_Quadrant24: + reagent_plate_2.wells_by_name()[row + col].load_liquid( + liquid=Reagent_Adapter, volume=Reagent_Vol_Adapter + ) + for row in Row_Quadrant34: + for col in Column_Quadrant13: + reagent_plate_2.wells_by_name()[row + col].load_liquid( + liquid=Reagent_Barcodes, volume=5 + ) + + # Liquid Trash + for row in Row_96: + for col in Column_96: + Liquid_trash.wells_by_name()[row + col].load_liquid( + liquid=Liquid_trash_well, volume=0 + ) + + # ETOH Reservoir + for row in Row_96: + for col in Column_96: + ETOH_Reservoir.wells_by_name()[row + col].load_liquid( + liquid=Reagent_CleanupBead, volume=0 + ) + + # Sample Plate 1 + for row in Row_96: + for col in Column_96: + sample_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Sample, volume=Sample_Volume + ) + + # Sample Plate 2 + for row in Row_96: + for col in Column_96: + sample_plate_2.wells_by_name()[row + col].load_liquid( + liquid=Placeholder_Sample, volume=0 + ) # ========================================= PROTOCOL START try: thermocycler.open_lid() if DRYRUN is False: protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") - thermocycler.set_block_temperature(4) - thermocycler.set_lid_temperature(100) - temp_block.set_temperature(4) + tc_block_task = thermocycler.start_set_block_temperature(4) + tc_lid_task = thermocycler.start_set_lid_temperature(100) + temp_task = temp_block.start_set_temperature(4) + protocol.wait_for_tasks([tc_block_task, tc_lid_task, temp_task]) if STEP_EZ_FRERAT: protocol.comment("==============================================") protocol.comment("--> Enzymatic Prep") @@ -217,11 +401,20 @@ def run(protocol: ProtocolContext) -> None: FRERATMixVol + 1, FRERAT.bottom(z=dot_bottom), ) - p1000.aspirate(FRERATVol + 1, FRERAT.bottom(z=dot_bottom)) + p1000.aspirate( + FRERATVol + 1, + location=FRERAT.meniscus(z=-1, target="start"), + end_location=FRERAT.meniscus(z=-1, target="end"), + ) p1000.dispense(1, FRERAT.bottom(z=dot_bottom)) p1000.dispense( FRERATVol, - sample_plate_1.wells_by_name()["A1"].bottom(z=dot_bottom), + location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="start" + ), + end_location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="end" + ), ) p1000.mix(FRERATMixRep, FRERATMixVol) p1000.move_to(sample_plate_1["A1"].top(z=-3)) @@ -245,7 +438,7 @@ def run(protocol: ProtocolContext) -> None: thermocycler.execute_profile( steps=profile_FRERAT, repetitions=1, block_max_volume=50 ) - thermocycler.set_block_temperature(4) + thermocycler.start_set_block_temperature(4) thermocycler.open_lid() else: if DRYRUN is False: @@ -281,7 +474,12 @@ def run(protocol: ProtocolContext) -> None: p1000.aspirate(ERATVol, ERAT.bottom(z=0.5)) p1000.dispense( ERATVol, - sample_plate_1.wells_by_name()["A1"].bottom(z=dot_bottom), + location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="start" + ), + end_location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="end" + ), ) p1000.mix(ERATMixRep, ERATMixVol, rate=0.5) p1000.move_to(sample_plate_1["A1"].top(z=-3)) @@ -296,11 +494,6 @@ def run(protocol: ProtocolContext) -> None: source_location=lids, new_location=sample_plate_1, use_gripper=True ) - # protocol.move_labware( - # labware=lids[-1], - # new_location=sample_plate_1, - # use_gripper=True, - # ) if ONDECK_THERMO: thermocycler.close_lid() if DRYRUN is False: @@ -311,7 +504,7 @@ def run(protocol: ProtocolContext) -> None: thermocycler.execute_profile( steps=profile_ERAT, repetitions=1, block_max_volume=50 ) - thermocycler.set_block_temperature(4) + thermocycler.start_set_block_temperature(4) thermocycler.open_lid() else: if DRYRUN is False: @@ -346,10 +539,19 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== p1000.pick_up_tip(tiprack_50_2["A1"]) - p1000.aspirate(AdapterVol + 1, Adapter.bottom(z=0.5)) + p1000.aspirate( + AdapterVol + 1, + location=Adapter.meniscus(z=-1, target="start"), + end_location=Adapter.meniscus(z=-1, target="end"), + ) p1000.dispense( AdapterVol, - sample_plate_1.wells_by_name()["A1"].bottom(z=1), + location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="start" + ), + end_location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="end" + ), ) p1000.move_to(sample_plate_1["A1"].bottom(z=dot_bottom)) p1000.move_to(sample_plate_1["A1"].top(z=-3)) @@ -363,12 +565,20 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.dispense = p96x_50_flow_rate_dispense_default * 0.5 p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== - p1000.aspirate(LIGVol, LIG.bottom(z=dot_bottom)) + p1000.aspirate( + LIGVol, + location=LIG.meniscus(z=-1, target="start"), + end_location=LIG.meniscus(z=-1, target="end"), + ) p1000.default_speed = 100 p1000.move_to(LIG.top(z=3)) protocol.delay(seconds=1) p1000.default_speed = 400 - p1000.dispense(LIGVol, sample_plate_1["A1"].bottom(z=1)) + p1000.dispense( + LIGVol, + location=sample_plate_1["A1"].meniscus(z=-1, target="start"), + end_location=sample_plate_1["A1"].meniscus(z=-1, target="end"), + ) p1000.move_to(sample_plate_1["A1"].bottom(z=dot_bottom)) p1000.mix(LIGMixRep, LIGMixVol, rate=0.5) p1000.default_speed = 100 @@ -386,11 +596,6 @@ def run(protocol: ProtocolContext) -> None: source_location=lids, new_location=sample_plate_1, use_gripper=True ) - # protocol.move_labware( - # labware=lids[1], - # new_location=sample_plate_1, - # use_gripper=True, - # ) if ONDECK_THERMO: thermocycler.close_lid() if DRYRUN is False: @@ -400,7 +605,7 @@ def run(protocol: ProtocolContext) -> None: thermocycler.execute_profile( steps=profile_LIG, repetitions=1, block_max_volume=50 ) - thermocycler.set_block_temperature(4) + thermocycler.start_set_block_temperature(4) thermocycler.open_lid() else: if DRYRUN is False: @@ -416,11 +621,6 @@ def run(protocol: ProtocolContext) -> None: source_location=sample_plate_1, new_location=TRASH, use_gripper=True ) - # protocol.move_labware( - # labware=lids[1], - # new_location=TRASH, - # use_gripper=True, - # ) ######################################################################## if STEP_CLEANUP_1: @@ -442,7 +642,6 @@ def run(protocol: ProtocolContext) -> None: protocol.move_lid(tiprack_200_1, TRASH, use_gripper=True) protocol.move_labware(tiprack_200_1, tiprack_A3_adapter, use_gripper=True) # GRIPPER MOVE CleanupPlate_1 FROM: MAG PLATE --> D1 - # protocol.move_labware(labware=sample_plate_3, new_location = "", use_gripper = True) protocol.move_labware( labware=CleanupPlate_1, new_location="D1", @@ -562,7 +761,11 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_200_flow_rate_blow_out_default # =============================================== p1000.pick_up_tip(tiprack_200_X["A1"]) - p1000.aspirate(ETOHMaxVol + 10, ETOH_Reservoir["A1"].bottom(z=dot_bottom)) + p1000.aspirate( + ETOHMaxVol + 10, + location=ETOH_Reservoir["A1"].meniscus(z=-1, target="start"), + end_location=ETOH_Reservoir["A1"].meniscus(z=-1, target="end"), + ) p1000.move_to(ETOH_Reservoir["A1"].top(z=0)) p1000.move_to(ETOH_Reservoir["A1"].top(z=-5)) p1000.move_to(CleanupPlate_1["A1"].top(z=2)) @@ -626,7 +829,11 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_200_flow_rate_blow_out_default # =============================================== p1000.pick_up_tip(tiprack_200_X["A1"]) - p1000.aspirate(ETOHMaxVol + 10, ETOH_Reservoir["A1"].bottom(z=dot_bottom)) + p1000.aspirate( + ETOHMaxVol + 10, + location=ETOH_Reservoir["A1"].meniscus(z=-1, target="start"), + end_location=ETOH_Reservoir["A1"].meniscus(z=-1, target="end"), + ) p1000.move_to(ETOH_Reservoir["A1"].top(z=0)) p1000.move_to(ETOH_Reservoir["A1"].top(z=-5)) p1000.move_to(CleanupPlate_1["A1"].top(z=2)) @@ -716,12 +923,6 @@ def run(protocol: ProtocolContext) -> None: new_location=TRASH, use_gripper=True, ) - # GRIPPER MOVE sample_plate_2 FROM: B4 --> A4 - # protocol.move_labware( - # labware=sample_plate_2, - # new_location="A4", - # use_gripper=True, - # ) # TOWER DISPENSES NEW PLATE tiprack_50_3 = stacker_50_ul_tips.retrieve() protocol.move_lid(tiprack_50_3, TRASH, use_gripper=True) @@ -751,10 +952,15 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== p1000.pick_up_tip(tiprack_50_3["A1"]) - p1000.aspirate(RSBVol, RSB.bottom(z=dot_bottom)) + p1000.aspirate( + RSBVol, + location=RSB.meniscus(z=-1, target="start"), + end_location=RSB.meniscus(z=-1, target="end"), + ) p1000.move_to(CleanupPlate_1.wells_by_name()["A1"].bottom(z=dot_bottom)) p1000.dispense( - RSBVol, CleanupPlate_1.wells_by_name()["A1"].bottom(z=dot_bottom) + RSBVol, + location=CleanupPlate_1.wells_by_name()["A1"].bottom(z=dot_bottom), ) p1000.mix(RSBMix, RSBMixVol, rate=0.5) p1000.blow_out(CleanupPlate_1.wells_by_name()["A1"].top(z=-3)) @@ -825,8 +1031,17 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.dispense = p96x_50_flow_rate_dispense_default * 0.2 p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== - p1000.aspirate(PCRVol, PCR.bottom(z=dot_bottom)) - p1000.dispense(PCRVol, sample_plate_2["A1"].bottom(z=dot_bottom)) + p1000.prepare_to_aspirate() + p1000.aspirate( + PCRVol, + location=PCR.meniscus(z=-1, target="start"), + end_location=PCR.meniscus(z=-1, target="end"), + ) + p1000.dispense( + PCRVol, + location=sample_plate_2["A1"].meniscus(z=-1, target="start"), + end_location=sample_plate_2["A1"].meniscus(z=-1, target="end"), + ) p1000.mix(PCRMixRep, PCRMixVol) p1000.move_to(sample_plate_2["A1"].top(z=-3)) protocol.delay(seconds=3) @@ -838,8 +1053,17 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.dispense = p96x_50_flow_rate_dispense_default * 0.5 p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== - p1000.aspirate(BarcodeVol, Barcodes.bottom(z=dot_bottom)) - p1000.dispense(BarcodeVol, sample_plate_2["A1"].bottom(z=dot_bottom)) + p1000.prepare_to_aspirate() + p1000.aspirate( + BarcodeVol, + location=Barcodes.meniscus(z=-1, target="start"), + end_location=Barcodes.meniscus(z=-1, target="end"), + ) + p1000.dispense( + BarcodeVol, + location=sample_plate_2["A1"].meniscus(z=-1, target="start"), + end_location=sample_plate_2["A1"].meniscus(z=-1, target="end"), + ) p1000.mix(BarcodeMixRep, BarcodeMixVol) p1000.move_to(sample_plate_2["A1"].top(z=-3)) protocol.delay(seconds=3) @@ -876,7 +1100,7 @@ def run(protocol: ProtocolContext) -> None: thermocycler.execute_profile( steps=profile_PCR_3, repetitions=1, block_max_volume=50 ) - thermocycler.set_block_temperature(4) + thermocycler.start_set_block_temperature(4) thermocycler.open_lid() else: if DRYRUN is False: @@ -945,7 +1169,12 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 p1000.move_to(CleanupBead.bottom(z=1)) p1000.mix(CleanupBeadPremix, 30, rate=0.5) - p1000.aspirate(CleanupBeadVol, CleanupBead.bottom(z=1)) + p1000.prepare_to_aspirate() + p1000.aspirate( + CleanupBeadVol, + location=CleanupBead.meniscus(z=-1, target="start"), + end_location=CleanupBead.meniscus(z=-1, target="end"), + ) p1000.move_to(CleanupBead.top(z=-3)) p1000.dispense(CleanupBeadVol, CleanupPlate_2["A1"].bottom(z=0.5)) p1000.move_to(CleanupPlate_2["A1"].bottom(z=2.5)) @@ -1038,17 +1267,6 @@ def run(protocol: ProtocolContext) -> None: protocol.delay(minutes=0.5) # ================================================================ - # GRIPPER MOVE tiprack_200_5 FROM: tiprack_A2_adapter --> TRASH - # protocol.move_labware( - # labware=tiprack_200_5, - # new_location=TRASH, - # use_gripper=True, - # ) - # # TOWER DISPENSES NEW PLATE - # tiprack_200_6 = stacker_200_ul_tips.retrieve() - # protocol.move_labware(tiprack_200_6, tiprack_A2_adapter, use_gripper=True) - # protocol.comment("MOVING: tiprack_200_6 = A4 --> tiprack_A2_adapter") - # =================================================================== protocol.comment("--> Remove ETOH Wash") RemoveSup = 200 @@ -1187,7 +1405,11 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== p1000.pick_up_tip(tiprack_50_6["A1"].top(z=2)) - p1000.aspirate(RSBVol, RSB.bottom(z=dot_bottom)) + p1000.aspirate( + RSBVol, + location=RSB.meniscus(z=-1, target="start"), + end_location=RSB.meniscus(z=-1, target="end"), + ) p1000.move_to(CleanupPlate_2.wells_by_name()["A1"].bottom(z=dot_bottom)) p1000.dispense( RSBVol, @@ -1219,19 +1441,6 @@ def run(protocol: ProtocolContext) -> None: new_location=TRASH, use_gripper=True, ) - # # GRIPPER MOVE sample_plate_2 FROM: D4 --> THERMOCYCLER - # if ONDECK_THERMO: - # protocol.move_labware( - # labware=sample_plate_3, - # new_location=thermocycler, - # use_gripper=True, - # ) - # else: - # protocol.move_labware( - # labware=sample_plate_3, - # new_location="B1", - # use_gripper=True, - # ) # ============================================================== if DRYRUN is False: @@ -1267,200 +1476,7 @@ def run(protocol: ProtocolContext) -> None: protocol.comment("==============================================") protocol.comment("--> Report") protocol.comment("==============================================") - # ===== DEFINE LIQUIDS - if NOLABEL is False: - # PROTOCOL SETUP - LABELING - - # ====== CALCULATING LIQUIDS ====== - Sample_Volume = 19.5 - Reagent_Vol_CleanupBead_Volume = 80.5 - Reagent_Vol_RSB = 52 - Reagent_Vol_PCR = 25 - Reagent_Vol_FRERAT = 10.5 - Reagent_Vol_ERAT = 10.5 - Reagent_Vol_Adapter = 5 - Reagent_Vol_LIG = 25 - - Row_Quadrant12 = ["A", "C", "E", "G", "I", "K", "M", "O"] - Row_Quadrant34 = ["B", "D", "F", "H", "J", "L", "N", "P"] - Row_96 = ["A", "B", "C", "D", "E", "F", "G", "H"] - - Column_Quadrant13 = [ - "1", - "3", - "5", - "7", - "9", - "11", - "13", - "15", - "17", - "19", - "21", - "23", - ] - Column_Quadrant24 = [ - "2", - "4", - "6", - "8", - "10", - "12", - "14", - "16", - "18", - "20", - "22", - "24", - ] - Column_96 = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] - - # ======== DEFINING LIQUIDS ======= - Sample = protocol.define_liquid( - name="Sample", description="Sample", display_color="#52AAFF" - ) # 52AAFF = 'Sample Blue' - Reagent_CleanupBead = protocol.define_liquid( - name="EtOH", description="CleanupBead Beads", display_color="#704848" - ) # 704848 = 'CleanupBead Brown' - protocol.define_liquid( - name="EtOH", description="80% Ethanol", display_color="#9ACECB" - ) # 9ACECB = 'Ethanol Blue' - Reagent_RSB = protocol.define_liquid( - name="RSB", description="Resuspension Buffer", display_color="#00FFF2" - ) # 00FFF2 = 'Base Light Blue' - Reagent_PCR = protocol.define_liquid( - name="PCR", description="PCR Mix", display_color="#FF0000" - ) # FF0000 = 'Base Red' - Reagent_FRERAT = protocol.define_liquid( - name="FRERAT", - description="Fragmentation Enzymatic Prep", - display_color="#FFA000", - ) # FFA000 = 'Base Orange' - Reagent_ERAT = protocol.define_liquid( - name="ERAT", - description="End Repair Enzymatic Prep", - display_color="#FFA000", - ) # FFA000 = 'Base Orange' - Reagent_LIG = protocol.define_liquid( - name="LIG", description="Ligation Mix", display_color="#0EFF00" - ) # 0EFF00 = 'Base Green' - Reagent_Adapter = protocol.define_liquid( - name="Adapter", description="Adapter", display_color="#0EFF00" - ) # 0EFF00 = 'Base Green' - protocol.define_liquid( - name="PRIMER", description="PRIMER", display_color="#0EFF00" - ) # 0EFF00 = 'Base Green' - Reagent_Barcodes = protocol.define_liquid( - name="Barcodes", description="Barcodes", display_color="#7DFFC4" - ) # 7DFFC4 = 'Barcode Green' - protocol.define_liquid( - name="H20", description="H20", display_color="#AABFBF" - ) # AABFBF = 'H20' - Placeholder_Sample = protocol.define_liquid( - name="Placeholder_Sample", - description="Excess Sample", - display_color="#82A9CF", - ) # 82A9CF = 'Placeholder Sample Blue' - protocol.define_liquid( - name="Final_Sample", description="Final Sample", display_color="#82A9CF" - ) # 82A9CF = 'Placeholder Blue' - Liquid_trash_well = protocol.define_liquid( - name="Liquid_trash_well", - description="Liquid Trash", - display_color="#9B9B9B", - ) # 9B9B9B = 'Liquid Trash Grey' - - # ======== LOADING LIQUIDS ======= - # ========================== REAGENT PLATE_1 ============================ - if FRAG_MODE == "EZ": - FRERAT = reagent_plate_1["A1"] - if FRAG_MODE == "MC": - ERAT = reagent_plate_1["A1"] - LIG = reagent_plate_1["A2"] - PCR = reagent_plate_1["B1"] - RSB = reagent_plate_1["B2"] - - # ========================== REAGENT PLATE_2 ============================ - CleanupBead = reagent_plate_2["A1"] - Adapter = reagent_plate_2["A2"] - Barcodes = reagent_plate_2["B1"] - - # Reagent Plate 1 - for row in Row_Quadrant12: - if FRAG_MODE == "EZ": - for col in Column_Quadrant13: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_FRERAT, volume=Reagent_Vol_FRERAT - ) - for col in Column_Quadrant13: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_ERAT, volume=Reagent_Vol_ERAT - ) - for col in Column_Quadrant24: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_LIG, volume=Reagent_Vol_LIG - ) - for row in Row_Quadrant34: - for col in Column_Quadrant13: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_PCR, volume=Reagent_Vol_PCR * (1 / 12) - ) - for col in Column_Quadrant24: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_RSB, volume=Reagent_Vol_RSB * (1 / 12) - ) - - # Reagent Plate 1 - for row in Row_Quadrant12: - for col in Column_Quadrant13: - reagent_plate_2.wells_by_name()[row + col].load_liquid( - liquid=Reagent_CleanupBead, - volume=Reagent_Vol_CleanupBead_Volume, - ) - for col in Column_Quadrant24: - reagent_plate_2.wells_by_name()[row + col].load_liquid( - liquid=Reagent_Adapter, volume=Reagent_Vol_Adapter - ) - for row in Row_Quadrant34: - for col in Column_Quadrant13: - reagent_plate_2.wells_by_name()[row + col].load_liquid( - liquid=Reagent_Barcodes, volume=5 - ) - - # Liquid Trash - for row in Row_96: - for col in Column_96: - Liquid_trash.wells_by_name()[row + col].load_liquid( - liquid=Liquid_trash_well, volume=0 - ) - - # ETOH Reservoir - for row in Row_96: - for col in Column_96: - ETOH_Reservoir.wells_by_name()[row + col].load_liquid( - liquid=Reagent_CleanupBead, volume=0 - ) - - # Sample Plate 1 - for row in Row_96: - for col in Column_96: - sample_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Sample, volume=Sample_Volume - ) - - # Sample Plate 2 - for row in Row_96: - for col in Column_96: - sample_plate_2.wells_by_name()[row + col].load_liquid( - liquid=Placeholder_Sample, volume=0 - ) - # # Sample Plate 3 - # for row in Row_96: - # for col in Column_96: - # sample_plate_3.wells_by_name()[row + col].load_liquid( - # liquid=Final_Sample, volume=0 - # ) if not protocol.is_simulating(): slack_bot.send_run_completed_message(metadata["protocolName"]) except Exception as e: diff --git a/abr-testing/abr_testing/protocols/active_protocols/12_Illumina RNA all parts.py b/abr-testing/abr_testing/protocols/active_protocols/12_Illumina RNA all parts.py index b2c55a8bf9a..a5aacb61cab 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/12_Illumina RNA all parts.py +++ b/abr-testing/abr_testing/protocols/active_protocols/12_Illumina RNA all parts.py @@ -21,7 +21,7 @@ } requirements = { "robotType": "Flex", - "apiLevel": "2.26", + "apiLevel": "2.27", } @@ -70,7 +70,9 @@ def add_parameters(parameters: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Protocol.""" - helpers.comment_protocol_version(protocol, "02") + protocol.capture_image(filename="start_of_run") + + helpers.comment_protocol_version(protocol, "03") # ======================== DOWNLOADED PARAMETERS ======================== global REUSE_ANY_50_TIPS # T/F Whether or not Reusing any p50 @@ -2053,10 +2055,11 @@ def nozzlecheck(nozzletype: str, tip_rack: Labware) -> None: thermocycler.execute_profile( steps=profile_TAGSTOP, repetitions=1, block_max_volume=100 ) - thermocycler.set_block_temperature(62) + block_task = thermocycler.start_set_block_temperature(62) if HYBRID_PAUSE: protocol.comment("HYBRIDIZATION PAUSED") - thermocycler.set_block_temperature(10) + protocol.wait_for_tasks([block_task]) + thermocycler.start_set_block_temperature(10) thermocycler.open_lid() else: protocol.comment( @@ -2071,8 +2074,9 @@ def nozzlecheck(nozzletype: str, tip_rack: Labware) -> None: if DRYRUN is False: protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") if ONDECK_THERMO: - thermocycler.set_block_temperature(58) - thermocycler.set_lid_temperature(58) + tc_block_task = thermocycler.start_set_block_temperature(58) + tc_lid_task = thermocycler.start_set_lid_temperature(58) + protocol.wait_for_tasks([tc_block_task, tc_lid_task]) # ============================================================================================ protocol.comment("MOVING: tiprack_50_X = SCP_Position --> D4") protocol.move_labware( @@ -2543,8 +2547,9 @@ def nozzlecheck(nozzletype: str, tip_rack: Labware) -> None: if ONDECK_THERMO: if DRYRUN is False: protocol.comment("SETTING THERMO to Room Temp") - thermocycler.set_block_temperature(4) - thermocycler.set_lid_temperature(100) + tc_block_task = thermocycler.start_set_block_temperature(4) + tc_lid_task = thermocycler.start_set_lid_temperature(100) + protocol.wait_for_tasks([tc_block_task, tc_lid_task]) thermocycler.close_lid() if DRYRUN is False: profile_PCR_1: List[ThermocyclerStep] = [ diff --git a/abr-testing/abr_testing/protocols/active_protocols/13_Stacker_Labware_Stamping_Test.py b/abr-testing/abr_testing/protocols/active_protocols/13_Stacker_Labware_Stamping_Test.py index b551c2887b8..a39ea4bb01c 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/13_Stacker_Labware_Stamping_Test.py +++ b/abr-testing/abr_testing/protocols/active_protocols/13_Stacker_Labware_Stamping_Test.py @@ -18,7 +18,7 @@ "author": "Rhyann Clarke None: """Unload tipracks and assign to pipette.""" + ctx.capture_image(filename="unload_tipracks") + p96.tip_racks.clear() for i in range(2): tip_rack = stacker.retrieve() @@ -124,12 +128,13 @@ def unload_tipracks_from_stacker( def run(ctx: ProtocolContext) -> None: """Run the protocol.""" + ctx.capture_image(filename="start_of_run") + use_temp_mod = ctx.params.use_temp_mod # type: ignore[attr-defined] if not ctx.is_simulating(): from abr_testing.protocols import helpers - helpers.comment_protocol_version(ctx, "02") - + helpers.comment_protocol_version(ctx, "03") slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) tiprack_adapters = [ diff --git a/abr-testing/abr_testing/protocols/active_protocols/14_IDT xGen 200 ul 96ch.py b/abr-testing/abr_testing/protocols/active_protocols/14_IDT xGen 200 ul 96ch.py index b1551e04b7c..4a072c9dbe2 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/14_IDT xGen 200 ul 96ch.py +++ b/abr-testing/abr_testing/protocols/active_protocols/14_IDT xGen 200 ul 96ch.py @@ -18,7 +18,7 @@ } requirements = { "robotType": "Flex", - "apiLevel": "2.25", + "apiLevel": "2.27", } @@ -57,6 +57,8 @@ def add_parameters(parameters: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Protocol.""" + protocol.capture_image(filename="start_of_run") + # ======================== DOWNLOADED PARAMETERS ======================== global COLUMNS # Number of Columns of Samples # =================== LOADING THE RUNTIME PARAMETERS ==================== @@ -67,6 +69,8 @@ def run(protocol: ProtocolContext) -> None: PCRCYCLES = protocol.params.PCRCYCLES # type: ignore[attr-defined] DEACTIVATE_TEMP = protocol.params.deactivate_modules # type: ignore[attr-defined] dot_bottom = protocol.params.dot_bottom # type: ignore[attr-defined] + helpers.comment_protocol_version(protocol, "02") + if not protocol.is_simulating(): slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) @@ -94,7 +98,6 @@ def run(protocol: ProtocolContext) -> None: TIP_MIX = True # Default False | Use Tip Mixing instead of Heatershaker ONDECK_THERMO = True # Default True | On Deck Thermocycler ONDECK_TEMP = True - NOLABEL = False # Default False | True = Do not include Liquid Labeling, # =============================== PIPETTE =============================== p1000 = protocol.load_instrument("flex_96channel_200", "left") @@ -186,15 +189,197 @@ def run(protocol: ProtocolContext) -> None: CleanupBead = reagent_plate_2["A1"] Adapter = reagent_plate_2["A2"] Barcodes = reagent_plate_2["B1"] + Sample_Volume = 19.5 + Reagent_Vol_CleanupBead_Volume = 80.5 + Reagent_Vol_RSB = 52 + Reagent_Vol_PCR = 25 + Reagent_Vol_FRERAT = 10.5 + Reagent_Vol_ERAT = 10.5 + Reagent_Vol_Adapter = 5 + Reagent_Vol_LIG = 25 + + Row_Quadrant12 = ["A", "C", "E", "G", "I", "K", "M", "O"] + Row_Quadrant34 = ["B", "D", "F", "H", "J", "L", "N", "P"] + Row_96 = ["A", "B", "C", "D", "E", "F", "G", "H"] + + Column_Quadrant13 = [ + "1", + "3", + "5", + "7", + "9", + "11", + "13", + "15", + "17", + "19", + "21", + "23", + ] + Column_Quadrant24 = [ + "2", + "4", + "6", + "8", + "10", + "12", + "14", + "16", + "18", + "20", + "22", + "24", + ] + Column_96 = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] + + # ======== DEFINING LIQUIDS ======= + Sample = protocol.define_liquid( + name="Sample", description="Sample", display_color="#52AAFF" + ) # 52AAFF = 'Sample Blue' + Reagent_CleanupBead = protocol.define_liquid( + name="EtOH", description="CleanupBead Beads", display_color="#704848" + ) # 704848 = 'CleanupBead Brown' + protocol.define_liquid( + name="EtOH", description="80% Ethanol", display_color="#9ACECB" + ) # 9ACECB = 'Ethanol Blue' + Reagent_RSB = protocol.define_liquid( + name="RSB", description="Resuspension Buffer", display_color="#00FFF2" + ) # 00FFF2 = 'Base Light Blue' + Reagent_PCR = protocol.define_liquid( + name="PCR", description="PCR Mix", display_color="#FF0000" + ) # FF0000 = 'Base Red' + Reagent_FRERAT = protocol.define_liquid( + name="FRERAT", + description="Fragmentation Enzymatic Prep", + display_color="#FFA000", + ) # FFA000 = 'Base Orange' + Reagent_ERAT = protocol.define_liquid( + name="ERAT", + description="End Repair Enzymatic Prep", + display_color="#FFA000", + ) # FFA000 = 'Base Orange' + Reagent_LIG = protocol.define_liquid( + name="LIG", description="Ligation Mix", display_color="#0EFF00" + ) # 0EFF00 = 'Base Green' + Reagent_Adapter = protocol.define_liquid( + name="Adapter", description="Adapter", display_color="#0EFF00" + ) # 0EFF00 = 'Base Green' + protocol.define_liquid( + name="PRIMER", description="PRIMER", display_color="#0EFF00" + ) # 0EFF00 = 'Base Green' + Reagent_Barcodes = protocol.define_liquid( + name="Barcodes", description="Barcodes", display_color="#7DFFC4" + ) # 7DFFC4 = 'Barcode Green' + protocol.define_liquid( + name="H20", description="H20", display_color="#AABFBF" + ) # AABFBF = 'H20' + Placeholder_Sample = protocol.define_liquid( + name="Placeholder_Sample", + description="Excess Sample", + display_color="#82A9CF", + ) # 82A9CF = 'Placeholder Sample Blue' + protocol.define_liquid( + name="Final_Sample", description="Final Sample", display_color="#82A9CF" + ) # 82A9CF = 'Placeholder Blue' + Liquid_trash_well = protocol.define_liquid( + name="Liquid_trash_well", + description="Liquid Trash", + display_color="#9B9B9B", + ) # 9B9B9B = 'Liquid Trash Grey' + + # ======== LOADING LIQUIDS ======= + # ========================== REAGENT PLATE_1 ============================ + if FRAG_MODE == "EZ": + FRERAT = reagent_plate_1["A1"] + if FRAG_MODE == "MC": + ERAT = reagent_plate_1["A1"] + LIG = reagent_plate_1["A2"] + PCR = reagent_plate_1["B1"] + RSB = reagent_plate_1["B2"] + # ========================== REAGENT PLATE_2 ============================ + CleanupBead = reagent_plate_2["A1"] + Adapter = reagent_plate_2["A2"] + Barcodes = reagent_plate_2["B1"] + + # Reagent Plate 1 + for row in Row_Quadrant12: + if FRAG_MODE == "EZ": + for col in Column_Quadrant13: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_FRERAT, volume=Reagent_Vol_FRERAT + ) + for col in Column_Quadrant13: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_ERAT, volume=Reagent_Vol_ERAT + ) + for col in Column_Quadrant24: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_LIG, volume=Reagent_Vol_LIG + ) + for row in Row_Quadrant34: + for col in Column_Quadrant13: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_PCR, volume=Reagent_Vol_PCR * (1 / 12) + ) + for col in Column_Quadrant24: + reagent_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Reagent_RSB, volume=Reagent_Vol_RSB * (1 / 12) + ) + + # Reagent Plate 1 + for row in Row_Quadrant12: + for col in Column_Quadrant13: + reagent_plate_2.wells_by_name()[row + col].load_liquid( + liquid=Reagent_CleanupBead, + volume=Reagent_Vol_CleanupBead_Volume, + ) + for col in Column_Quadrant24: + reagent_plate_2.wells_by_name()[row + col].load_liquid( + liquid=Reagent_Adapter, volume=Reagent_Vol_Adapter + ) + for row in Row_Quadrant34: + for col in Column_Quadrant13: + reagent_plate_2.wells_by_name()[row + col].load_liquid( + liquid=Reagent_Barcodes, volume=5 + ) + + # Liquid Trash + for row in Row_96: + for col in Column_96: + Liquid_trash.wells_by_name()[row + col].load_liquid( + liquid=Liquid_trash_well, volume=0 + ) + + # ETOH Reservoir + for row in Row_96: + for col in Column_96: + ETOH_Reservoir.wells_by_name()[row + col].load_liquid( + liquid=Reagent_CleanupBead, volume=0 + ) + + # Sample Plate 1 + for row in Row_96: + for col in Column_96: + sample_plate_1.wells_by_name()[row + col].load_liquid( + liquid=Sample, volume=Sample_Volume + ) + + # Sample Plate 2 + for row in Row_96: + for col in Column_96: + sample_plate_2.wells_by_name()[row + col].load_liquid( + liquid=Placeholder_Sample, volume=0 + ) # ========================================= PROTOCOL START try: thermocycler.open_lid() if DRYRUN is False: protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") - thermocycler.set_block_temperature(4) - thermocycler.set_lid_temperature(100) - temp_block.set_temperature(4) + tc_block_task = thermocycler.start_set_block_temperature(4) + tc_lid_task = thermocycler.start_set_lid_temperature(100) + temp_task = temp_block.start_set_temperature(4) + protocol.wait_for_tasks([tc_block_task, tc_lid_task, temp_task]) if STEP_EZ_FRERAT: protocol.comment("==============================================") protocol.comment("--> Enzymatic Prep") @@ -216,11 +401,20 @@ def run(protocol: ProtocolContext) -> None: FRERATMixVol + 1, FRERAT.bottom(z=dot_bottom), ) - p1000.aspirate(FRERATVol + 1, FRERAT.bottom(z=dot_bottom)) + p1000.aspirate( + FRERATVol + 1, + location=FRERAT.meniscus(z=-1, target="start"), + end_location=FRERAT.meniscus(z=-1, target="end"), + ) p1000.dispense(1, FRERAT.bottom(z=dot_bottom)) p1000.dispense( FRERATVol, - sample_plate_1.wells_by_name()["A1"].bottom(z=dot_bottom), + location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="start" + ), + end_location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="end" + ), ) p1000.mix(FRERATMixRep, FRERATMixVol) p1000.move_to(sample_plate_1["A1"].top(z=-3)) @@ -280,7 +474,12 @@ def run(protocol: ProtocolContext) -> None: p1000.aspirate(ERATVol, ERAT.bottom(z=0.5)) p1000.dispense( ERATVol, - sample_plate_1.wells_by_name()["A1"].bottom(z=dot_bottom), + location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="start" + ), + end_location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="end" + ), ) p1000.mix(ERATMixRep, ERATMixVol, rate=0.5) p1000.move_to(sample_plate_1["A1"].top(z=-3)) @@ -326,11 +525,6 @@ def run(protocol: ProtocolContext) -> None: source_location=sample_plate_1, new_location=TRASH, use_gripper=True ) - # # protocol.move_labware( - # # labware=lids[0], - # new_location=TRASH, - # use_gripper=True, - # ) ################################################################## if STEP_LIG: protocol.comment("==============================================") @@ -345,10 +539,19 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== p1000.pick_up_tip(tiprack_50_2["A1"]) - p1000.aspirate(AdapterVol + 1, Adapter.bottom(z=0.5)) + p1000.aspirate( + AdapterVol + 1, + location=Adapter.meniscus(z=-1, target="start"), + end_location=Adapter.meniscus(z=-1, target="end"), + ) p1000.dispense( AdapterVol, - sample_plate_1.wells_by_name()["A1"].bottom(z=1), + location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="start" + ), + end_location=sample_plate_1.wells_by_name()["A1"].meniscus( + z=-1, target="end" + ), ) p1000.move_to(sample_plate_1["A1"].bottom(z=dot_bottom)) p1000.move_to(sample_plate_1["A1"].top(z=-3)) @@ -362,12 +565,20 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.dispense = p96x_50_flow_rate_dispense_default * 0.5 p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== - p1000.aspirate(LIGVol, LIG.bottom(z=dot_bottom)) + p1000.aspirate( + LIGVol, + location=LIG.meniscus(z=-1, target="start"), + end_location=LIG.meniscus(z=-1, target="end"), + ) p1000.default_speed = 100 p1000.move_to(LIG.top(z=3)) protocol.delay(seconds=1) p1000.default_speed = 400 - p1000.dispense(LIGVol, sample_plate_1["A1"].bottom(z=1)) + p1000.dispense( + LIGVol, + location=sample_plate_1["A1"].meniscus(z=-1, target="start"), + end_location=sample_plate_1["A1"].meniscus(z=-1, target="end"), + ) p1000.move_to(sample_plate_1["A1"].bottom(z=dot_bottom)) p1000.mix(LIGMixRep, LIGMixVol, rate=0.5) p1000.default_speed = 100 @@ -385,11 +596,6 @@ def run(protocol: ProtocolContext) -> None: source_location=lids, new_location=sample_plate_1, use_gripper=True ) - # protocol.move_labware( - # labware=lids[1], - # new_location=sample_plate_1, - # use_gripper=True, - # ) if ONDECK_THERMO: thermocycler.close_lid() if DRYRUN is False: @@ -414,12 +620,6 @@ def run(protocol: ProtocolContext) -> None: protocol.move_lid( source_location=sample_plate_1, new_location=TRASH, use_gripper=True ) - - # protocol.move_labware( - # labware=lids[1], - # new_location=TRASH, - # use_gripper=True, - # ) ######################################################################## if STEP_CLEANUP_1: @@ -441,7 +641,6 @@ def run(protocol: ProtocolContext) -> None: protocol.move_lid(tiprack_200_1, TRASH, use_gripper=True) protocol.move_labware(tiprack_200_1, tiprack_A3_adapter, use_gripper=True) # GRIPPER MOVE CleanupPlate_1 FROM: MAG PLATE --> D1 - # protocol.move_labware(labware=sample_plate_3, new_location = "", use_gripper = True) protocol.move_labware( labware=CleanupPlate_1, new_location="D1", @@ -561,7 +760,11 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_200_flow_rate_blow_out_default # =============================================== p1000.pick_up_tip(tiprack_200_X["A1"]) - p1000.aspirate(ETOHMaxVol + 10, ETOH_Reservoir["A1"].bottom(z=dot_bottom)) + p1000.aspirate( + ETOHMaxVol + 10, + location=ETOH_Reservoir["A1"].meniscus(z=-1, target="start"), + end_location=ETOH_Reservoir["A1"].meniscus(z=-1, target="end"), + ) p1000.move_to(ETOH_Reservoir["A1"].top(z=0)) p1000.move_to(ETOH_Reservoir["A1"].top(z=-5)) p1000.move_to(CleanupPlate_1["A1"].top(z=2)) @@ -625,7 +828,11 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_200_flow_rate_blow_out_default # =============================================== p1000.pick_up_tip(tiprack_200_X["A1"]) - p1000.aspirate(ETOHMaxVol + 10, ETOH_Reservoir["A1"].bottom(z=dot_bottom)) + p1000.aspirate( + ETOHMaxVol + 10, + location=ETOH_Reservoir["A1"].meniscus(z=-1, target="start"), + end_location=ETOH_Reservoir["A1"].meniscus(z=-1, target="end"), + ) p1000.move_to(ETOH_Reservoir["A1"].top(z=0)) p1000.move_to(ETOH_Reservoir["A1"].top(z=-5)) p1000.move_to(CleanupPlate_1["A1"].top(z=2)) @@ -715,12 +922,7 @@ def run(protocol: ProtocolContext) -> None: new_location=TRASH, use_gripper=True, ) - # GRIPPER MOVE sample_plate_2 FROM: B4 --> A4 - # protocol.move_labware( - # labware=sample_plate_2, - # new_location="A4", - # use_gripper=True, - # ) + # TOWER DISPENSES NEW PLATE tiprack_50_3 = stacker_50_ul_tips.retrieve() protocol.move_lid(tiprack_50_3, TRASH, use_gripper=True) @@ -750,10 +952,15 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== p1000.pick_up_tip(tiprack_50_3["A1"]) - p1000.aspirate(RSBVol, RSB.bottom(z=dot_bottom)) + p1000.aspirate( + RSBVol, + location=RSB.meniscus(z=-1, target="start"), + end_location=RSB.meniscus(z=-1, target="end"), + ) p1000.move_to(CleanupPlate_1.wells_by_name()["A1"].bottom(z=dot_bottom)) p1000.dispense( - RSBVol, CleanupPlate_1.wells_by_name()["A1"].bottom(z=dot_bottom) + RSBVol, + location=CleanupPlate_1.wells_by_name()["A1"].bottom(z=dot_bottom), ) p1000.mix(RSBMix, RSBMixVol, rate=0.5) p1000.blow_out(CleanupPlate_1.wells_by_name()["A1"].top(z=-3)) @@ -824,8 +1031,17 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.dispense = p96x_50_flow_rate_dispense_default * 0.2 p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== - p1000.aspirate(PCRVol, PCR.bottom(z=dot_bottom)) - p1000.dispense(PCRVol, sample_plate_2["A1"].bottom(z=dot_bottom)) + p1000.prepare_to_aspirate() + p1000.aspirate( + PCRVol, + location=PCR.meniscus(z=-1, target="start"), + end_location=PCR.meniscus(z=-1, target="end"), + ) + p1000.dispense( + PCRVol, + location=sample_plate_2["A1"].meniscus(z=-1, target="start"), + end_location=sample_plate_2["A1"].meniscus(z=-1, target="end"), + ) p1000.mix(PCRMixRep, PCRMixVol) p1000.move_to(sample_plate_2["A1"].top(z=-3)) protocol.delay(seconds=3) @@ -837,8 +1053,17 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.dispense = p96x_50_flow_rate_dispense_default * 0.5 p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== - p1000.aspirate(BarcodeVol, Barcodes.bottom(z=dot_bottom)) - p1000.dispense(BarcodeVol, sample_plate_2["A1"].bottom(z=dot_bottom)) + p1000.prepare_to_aspirate() + p1000.aspirate( + BarcodeVol, + location=Barcodes.meniscus(z=-1, target="start"), + end_location=Barcodes.meniscus(z=-1, target="end"), + ) + p1000.dispense( + BarcodeVol, + location=sample_plate_2["A1"].meniscus(z=-1, target="start"), + end_location=sample_plate_2["A1"].meniscus(z=-1, target="end"), + ) p1000.mix(BarcodeMixRep, BarcodeMixVol) p1000.move_to(sample_plate_2["A1"].top(z=-3)) protocol.delay(seconds=3) @@ -944,7 +1169,12 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 p1000.move_to(CleanupBead.bottom(z=1)) p1000.mix(CleanupBeadPremix, 30, rate=0.5) - p1000.aspirate(CleanupBeadVol, CleanupBead.bottom(z=1)) + p1000.prepare_to_aspirate() + p1000.aspirate( + CleanupBeadVol, + location=CleanupBead.meniscus(z=-1, target="start"), + end_location=CleanupBead.meniscus(z=-1, target="end"), + ) p1000.move_to(CleanupBead.top(z=-3)) p1000.dispense(CleanupBeadVol, CleanupPlate_2["A1"].bottom(z=0.5)) p1000.move_to(CleanupPlate_2["A1"].bottom(z=2.5)) @@ -1036,19 +1266,6 @@ def run(protocol: ProtocolContext) -> None: if DRYRUN is False: protocol.delay(minutes=0.5) - # ================================================================ - # GRIPPER MOVE tiprack_200_5 FROM: tiprack_A2_adapter --> TRASH - # protocol.move_labware( - # labware=tiprack_200_5, - # new_location=TRASH, - # use_gripper=True, - # ) - # # TOWER DISPENSES NEW PLATE - # tiprack_200_6 = stacker_200_ul_tips.retrieve() - # protocol.move_labware(tiprack_200_6, tiprack_A2_adapter, use_gripper=True) - # protocol.comment("MOVING: tiprack_200_6 = A4 --> tiprack_A2_adapter") - # =================================================================== - protocol.comment("--> Remove ETOH Wash") RemoveSup = 200 p1000.flow_rate.aspirate = p96x_200_flow_rate_aspirate_default * 0.5 @@ -1186,7 +1403,11 @@ def run(protocol: ProtocolContext) -> None: p1000.flow_rate.blow_out = p96x_50_flow_rate_blow_out_default * 0.5 # =============================================== p1000.pick_up_tip(tiprack_50_6["A1"].top(z=2)) - p1000.aspirate(RSBVol, RSB.bottom(z=dot_bottom)) + p1000.aspirate( + RSBVol, + location=RSB.meniscus(z=-1, target="start"), + end_location=RSB.meniscus(z=-1, target="end"), + ) p1000.move_to(CleanupPlate_2.wells_by_name()["A1"].bottom(z=dot_bottom)) p1000.dispense( RSBVol, @@ -1218,20 +1439,6 @@ def run(protocol: ProtocolContext) -> None: new_location=TRASH, use_gripper=True, ) - # # GRIPPER MOVE sample_plate_2 FROM: D4 --> THERMOCYCLER - # if ONDECK_THERMO: - # protocol.move_labware( - # labware=sample_plate_3, - # new_location=thermocycler, - # use_gripper=True, - # ) - # else: - # protocol.move_labware( - # labware=sample_plate_3, - # new_location="B1", - # use_gripper=True, - # ) - # ============================================================== if DRYRUN is False: protocol.delay(minutes=3) @@ -1266,200 +1473,7 @@ def run(protocol: ProtocolContext) -> None: protocol.comment("==============================================") protocol.comment("--> Report") protocol.comment("==============================================") - # ===== DEFINE LIQUIDS - if NOLABEL is False: - # PROTOCOL SETUP - LABELING - - # ====== CALCULATING LIQUIDS ====== - Sample_Volume = 19.5 - Reagent_Vol_CleanupBead_Volume = 80.5 - Reagent_Vol_RSB = 52 - Reagent_Vol_PCR = 25 - Reagent_Vol_FRERAT = 10.5 - Reagent_Vol_ERAT = 10.5 - Reagent_Vol_Adapter = 5 - Reagent_Vol_LIG = 25 - - Row_Quadrant12 = ["A", "C", "E", "G", "I", "K", "M", "O"] - Row_Quadrant34 = ["B", "D", "F", "H", "J", "L", "N", "P"] - Row_96 = ["A", "B", "C", "D", "E", "F", "G", "H"] - - Column_Quadrant13 = [ - "1", - "3", - "5", - "7", - "9", - "11", - "13", - "15", - "17", - "19", - "21", - "23", - ] - Column_Quadrant24 = [ - "2", - "4", - "6", - "8", - "10", - "12", - "14", - "16", - "18", - "20", - "22", - "24", - ] - Column_96 = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] - - # ======== DEFINING LIQUIDS ======= - Sample = protocol.define_liquid( - name="Sample", description="Sample", display_color="#52AAFF" - ) # 52AAFF = 'Sample Blue' - Reagent_CleanupBead = protocol.define_liquid( - name="EtOH", description="CleanupBead Beads", display_color="#704848" - ) # 704848 = 'CleanupBead Brown' - protocol.define_liquid( - name="EtOH", description="80% Ethanol", display_color="#9ACECB" - ) # 9ACECB = 'Ethanol Blue' - Reagent_RSB = protocol.define_liquid( - name="RSB", description="Resuspension Buffer", display_color="#00FFF2" - ) # 00FFF2 = 'Base Light Blue' - Reagent_PCR = protocol.define_liquid( - name="PCR", description="PCR Mix", display_color="#FF0000" - ) # FF0000 = 'Base Red' - Reagent_FRERAT = protocol.define_liquid( - name="FRERAT", - description="Fragmentation Enzymatic Prep", - display_color="#FFA000", - ) # FFA000 = 'Base Orange' - Reagent_ERAT = protocol.define_liquid( - name="ERAT", - description="End Repair Enzymatic Prep", - display_color="#FFA000", - ) # FFA000 = 'Base Orange' - Reagent_LIG = protocol.define_liquid( - name="LIG", description="Ligation Mix", display_color="#0EFF00" - ) # 0EFF00 = 'Base Green' - Reagent_Adapter = protocol.define_liquid( - name="Adapter", description="Adapter", display_color="#0EFF00" - ) # 0EFF00 = 'Base Green' - protocol.define_liquid( - name="PRIMER", description="PRIMER", display_color="#0EFF00" - ) # 0EFF00 = 'Base Green' - Reagent_Barcodes = protocol.define_liquid( - name="Barcodes", description="Barcodes", display_color="#7DFFC4" - ) # 7DFFC4 = 'Barcode Green' - protocol.define_liquid( - name="H20", description="H20", display_color="#AABFBF" - ) # AABFBF = 'H20' - Placeholder_Sample = protocol.define_liquid( - name="Placeholder_Sample", - description="Excess Sample", - display_color="#82A9CF", - ) # 82A9CF = 'Placeholder Sample Blue' - protocol.define_liquid( - name="Final_Sample", description="Final Sample", display_color="#82A9CF" - ) # 82A9CF = 'Placeholder Blue' - Liquid_trash_well = protocol.define_liquid( - name="Liquid_trash_well", - description="Liquid Trash", - display_color="#9B9B9B", - ) # 9B9B9B = 'Liquid Trash Grey' - - # ======== LOADING LIQUIDS ======= - # ========================== REAGENT PLATE_1 ============================ - if FRAG_MODE == "EZ": - FRERAT = reagent_plate_1["A1"] - if FRAG_MODE == "MC": - ERAT = reagent_plate_1["A1"] - LIG = reagent_plate_1["A2"] - PCR = reagent_plate_1["B1"] - RSB = reagent_plate_1["B2"] - - # ========================== REAGENT PLATE_2 ============================ - CleanupBead = reagent_plate_2["A1"] - Adapter = reagent_plate_2["A2"] - Barcodes = reagent_plate_2["B1"] - - # Reagent Plate 1 - for row in Row_Quadrant12: - if FRAG_MODE == "EZ": - for col in Column_Quadrant13: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_FRERAT, volume=Reagent_Vol_FRERAT - ) - for col in Column_Quadrant13: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_ERAT, volume=Reagent_Vol_ERAT - ) - for col in Column_Quadrant24: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_LIG, volume=Reagent_Vol_LIG - ) - for row in Row_Quadrant34: - for col in Column_Quadrant13: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_PCR, volume=Reagent_Vol_PCR * (1 / 12) - ) - for col in Column_Quadrant24: - reagent_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Reagent_RSB, volume=Reagent_Vol_RSB * (1 / 12) - ) - - # Reagent Plate 1 - for row in Row_Quadrant12: - for col in Column_Quadrant13: - reagent_plate_2.wells_by_name()[row + col].load_liquid( - liquid=Reagent_CleanupBead, - volume=Reagent_Vol_CleanupBead_Volume, - ) - for col in Column_Quadrant24: - reagent_plate_2.wells_by_name()[row + col].load_liquid( - liquid=Reagent_Adapter, volume=Reagent_Vol_Adapter - ) - for row in Row_Quadrant34: - for col in Column_Quadrant13: - reagent_plate_2.wells_by_name()[row + col].load_liquid( - liquid=Reagent_Barcodes, volume=5 - ) - - # Liquid Trash - for row in Row_96: - for col in Column_96: - Liquid_trash.wells_by_name()[row + col].load_liquid( - liquid=Liquid_trash_well, volume=0 - ) - - # ETOH Reservoir - for row in Row_96: - for col in Column_96: - ETOH_Reservoir.wells_by_name()[row + col].load_liquid( - liquid=Reagent_CleanupBead, volume=0 - ) - - # Sample Plate 1 - for row in Row_96: - for col in Column_96: - sample_plate_1.wells_by_name()[row + col].load_liquid( - liquid=Sample, volume=Sample_Volume - ) - - # Sample Plate 2 - for row in Row_96: - for col in Column_96: - sample_plate_2.wells_by_name()[row + col].load_liquid( - liquid=Placeholder_Sample, volume=0 - ) - # # Sample Plate 3 - # for row in Row_96: - # for col in Column_96: - # sample_plate_3.wells_by_name()[row + col].load_liquid( - # liquid=Final_Sample, volume=0 - # ) if not protocol.is_simulating(): slack_bot.send_run_completed_message(metadata["protocolName"]) except Exception as e: diff --git a/abr-testing/abr_testing/protocols/active_protocols/1_Simple Normalize Long Right.py b/abr-testing/abr_testing/protocols/active_protocols/1_Simple Normalize Long Right.py index 5ba35b6913e..9dc9b1e1b2f 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/1_Simple Normalize Long Right.py +++ b/abr-testing/abr_testing/protocols/active_protocols/1_Simple Normalize Long Right.py @@ -15,7 +15,7 @@ "source": "Protocol Library", } -requirements = {"robotType": "Flex", "apiLevel": "2.26"} +requirements = {"robotType": "Flex", "apiLevel": "2.27"} def add_parameters(parameters: ParameterContext) -> None: @@ -31,12 +31,13 @@ def run(protocol: ProtocolContext) -> None: probe_height_bool = protocol.params.probe_liquid_height # type: ignore[attr-defined] meniscus_z = protocol.params.meniscus_z # type: ignore[attr-defined] data = all_data[1:] - helpers.comment_protocol_version(protocol, "04") + helpers.comment_protocol_version(protocol, "05") if not protocol.is_simulating(): slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) # DECK SETUP AND LABWARE + protocol.capture_image(filename="start_of_run") protocol.comment("THIS IS A NO MODULE RUN") tiprack_x_1 = protocol.load_labware("opentrons_flex_96_tiprack_200ul", "D1") tiprack_x_2 = protocol.load_labware("opentrons_flex_96_tiprack_200ul", "D2") @@ -148,7 +149,12 @@ def run(protocol: ProtocolContext) -> None: DilutionVol = float(data[current][2]) while Diluent_1.current_liquid_volume() < DilutionVol: p1000.aspirate( - DilutionVol, Diluent_1.meniscus(z=meniscus_z, target="end") + DilutionVol, + location=Diluent_1.meniscus( + z=meniscus_z, + target="start", + ), + end_location=Diluent_1.meniscus(z=-1, target="end"), ) p1000.dispense( DilutionVol, @@ -189,7 +195,9 @@ def run(protocol: ProtocolContext) -> None: while Diluent_2.current_liquid_volume() < DilutionVol: p1000_single.pick_up_tip() p1000_single.aspirate( - DilutionVol, Diluent_2.meniscus(z=meniscus_z, target="end") + DilutionVol, + location=Diluent_2.meniscus(z=meniscus_z, target="start"), + end_location=Diluent_2.meniscus(z=meniscus_z, target="end"), ) p1000_single.dispense( DilutionVol, @@ -227,7 +235,9 @@ def run(protocol: ProtocolContext) -> None: while Diluent_3.current_liquid_volume() < DilutionVol: p1000_single.pick_up_tip() p1000_single.aspirate( - DilutionVol, Diluent_3.meniscus(z=meniscus_z, target="end") + DilutionVol, + location=Diluent_3.meniscus(z=meniscus_z, target="start"), + end_location=Diluent_3.meniscus(z=meniscus_z, target="end"), ) p1000_single.dispense( DilutionVol, @@ -267,7 +277,9 @@ def run(protocol: ProtocolContext) -> None: while Diluent_3.current_liquid_volume() < DilutionVol: p1000_single.pick_up_tip() p1000_single.aspirate( - DilutionVol, Diluent_3.meniscus(z=meniscus_z, target="end") + DilutionVol, + location=Diluent_3.meniscus(z=meniscus_z, target="start"), + end_location=Diluent_3.meniscus(z=meniscus_z, target="end"), ) p1000_single.dispense( DilutionVol, diff --git a/abr-testing/abr_testing/protocols/active_protocols/2_BMS_PCR_Protocol.py b/abr-testing/abr_testing/protocols/active_protocols/2_BMS_PCR_Protocol.py index 4e163e32099..0f00a47b453 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/2_BMS_PCR_Protocol.py +++ b/abr-testing/abr_testing/protocols/active_protocols/2_BMS_PCR_Protocol.py @@ -14,7 +14,7 @@ "protocolName": "PCR Protocol with TC Auto Sealing Lid", "author": "Rami Farawi None: @@ -30,6 +30,8 @@ def add_parameters(parameters: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Protocol.""" + protocol.capture_image(filename="start_of_run") + pipette_mount = protocol.params.pipette_mount # type: ignore[attr-defined] disposable_lid = protocol.params.disposable_lid # type: ignore[attr-defined] parsed_csv = protocol.params.parameters_csv.parse_as_csv() # type: ignore[attr-defined] @@ -37,7 +39,7 @@ def run(protocol: ProtocolContext) -> None: deactivate_modules_bool = protocol.params.deactivate_modules # type: ignore[attr-defined] probe_height_bool = protocol.params.probe_liquid_height # type: ignore[attr-defined] meniscus_z = protocol.params.meniscus_z # type: ignore[attr-defined] - helpers.comment_protocol_version(protocol, "05") + helpers.comment_protocol_version(protocol, "06") if not protocol.is_simulating(): slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) @@ -83,8 +85,11 @@ def run(protocol: ProtocolContext) -> None: protocol.load_trash_bin("A3") try: tc_mod.open_lid() - tc_mod.set_lid_temperature(105) - temp_mod.set_temperature(4) + tc_task = tc_mod.start_set_lid_temperature(105) + temp_mod_task = temp_mod.start_set_temperature(4) + protocol.wait_for_tasks( + [tc_task, temp_mod_task], + ) # LOAD LIQUIDS water: Well = reagent_rack["B1"] @@ -120,10 +125,16 @@ def run(protocol: ProtocolContext) -> None: break p50.configure_for_volume(water_vol) - p50.aspirate(water_vol, water.meniscus(z=meniscus_z, target="end")) + p50.prepare_to_aspirate() + p50.aspirate( + water_vol, + location=water.meniscus(z=meniscus_z, target="start"), + end_location=water.meniscus(z=meniscus_z, target="end"), + ) p50.dispense( water_vol, - dest_plate_1[dest_well].meniscus(z=2, target="end"), + location=dest_plate_1[dest_well].meniscus(z=2, target="start"), + end_location=dest_plate_1[dest_well].meniscus(z=2, target="end"), rate=0.5, ) p50.configure_for_volume(50) @@ -157,9 +168,17 @@ def run(protocol: ProtocolContext) -> None: break p50.configure_for_volume(mmx_vol) p50.aspirate( - mmx_vol, reagent_rack[mmx_tube].meniscus(z=meniscus_z, target="end") + mmx_vol, + location=reagent_rack[mmx_tube].meniscus(z=meniscus_z, target="start"), + end_location=reagent_rack[mmx_tube].meniscus( + z=meniscus_z, target="end" + ), + ) + p50.dispense( + mmx_vol, + location=dest_plate_1[dest_well].meniscus(z=2, target="start"), + end_location=dest_plate_1[dest_well].meniscus(z=2, target="end"), ) - p50.dispense(mmx_vol, dest_plate_1[dest_well].meniscus(z=2, target="end")) protocol.delay(seconds=2) p50.blow_out() p50.touch_tip() @@ -185,13 +204,21 @@ def run(protocol: ProtocolContext) -> None: p50.configure_for_volume(dna_vol) p50.aspirate( dna_vol, - source_plate_1[dest_and_source_well].meniscus( + location=source_plate_1[dest_and_source_well].meniscus( + z=meniscus_z, target="start" + ), + end_location=source_plate_1[dest_and_source_well].meniscus( z=meniscus_z, target="end" ), ) p50.dispense( dna_vol, - dest_plate_1[dest_and_source_well].meniscus(z=2, target="end"), + location=dest_plate_1[dest_and_source_well].meniscus( + z=2, target="start" + ), + end_location=dest_plate_1[dest_and_source_well].meniscus( + z=2, target="end" + ), rate=0.5, ) @@ -222,8 +249,8 @@ def run(protocol: ProtocolContext) -> None: final_extension_time_min=5, ) - tc_mod.set_block_temperature(4) - + block_task = tc_mod.start_set_block_temperature(4) + protocol.wait_for_tasks([block_task]) tc_mod.open_lid() if disposable_lid: protocol.move_lid(dest_plate_1, "C2", use_gripper=True) @@ -239,6 +266,7 @@ def run(protocol: ProtocolContext) -> None: helpers.find_liquid_height_of_all_wells(protocol, p50, [liquid_waste]) if deactivate_modules_bool: helpers.deactivate_modules(protocol) + protocol.capture_image(filename="end_of_run") if not protocol.is_simulating(): slack_bot.send_run_completed_message(metadata["protocolName"]) except Exception as e: diff --git a/abr-testing/abr_testing/protocols/active_protocols/4_Illumina DNA Enrichment.py b/abr-testing/abr_testing/protocols/active_protocols/4_Illumina DNA Enrichment.py index 19b4c944b12..6eb46912ea6 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/4_Illumina DNA Enrichment.py +++ b/abr-testing/abr_testing/protocols/active_protocols/4_Illumina DNA Enrichment.py @@ -25,7 +25,7 @@ requirements = { "robotType": "Flex", - "apiLevel": "2.26", + "apiLevel": "2.27", } @@ -74,6 +74,7 @@ def add_parameters(parameters: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Protocol.""" + protocol.capture_image(filename="start_of_run") heater_shaker_speed = protocol.params.heater_shaker_speed # type: ignore[attr-defined] dot_bottom = protocol.params.dot_bottom # type: ignore[attr-defined] disposable_lid = protocol.params.disposable_lid # type: ignore[attr-defined] @@ -82,7 +83,7 @@ def run(protocol: ProtocolContext) -> None: probe_liquid_height_bool = protocol.params.probe_liquid_height # type: ignore[attr-defined] deactivate_modules_bool = protocol.params.deactivate_modules # type: ignore[attr-defined] meniscus_z = protocol.params.meniscus_z # type: ignore[attr-defined] - helpers.comment_protocol_version(protocol, "04") + helpers.comment_protocol_version(protocol, "05") if not protocol.is_simulating(): slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) @@ -291,14 +292,18 @@ def tipcheck() -> None: if DRYRUN is False: if STEP_HYB == 1: protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") - thermocycler.set_block_temperature(4) - thermocycler.set_lid_temperature(100) - temp_block.set_temperature(4) + tc_block_task = thermocycler.start_set_block_temperature(4) + tc_lid_task = thermocycler.start_set_lid_temperature(100) + temp_block_task = temp_block.start_set_temperature(4) + protocol.wait_for_tasks( + [tc_block_task, tc_lid_task, temp_block_task] + ) else: protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") - thermocycler.set_block_temperature(58) - thermocycler.set_lid_temperature(58) - heatershaker.set_and_wait_for_temperature(58) + tc_block_task = thermocycler.start_set_block_temperature(58) + tc_lid_task = thermocycler.start_set_lid_temperature(58) + hs_task = heatershaker.set_target_temperature(58) + protocol.wait_for_tasks([tc_block_task, tc_lid_task, hs_task]) heatershaker.close_labware_latch() # Sample Plate contains 30ul of DNA @@ -426,11 +431,10 @@ def tipcheck() -> None: if DRYRUN is False: protocol.comment("SETTING THERMO and TEMP BLOCK Temperature") - thermocycler.set_block_temperature(58) - thermocycler.set_lid_temperature(58) - - if DRYRUN is False: - heatershaker.set_and_wait_for_temperature(58) + tc_block_task = thermocycler.start_set_block_temperature(58) + tc_lid_task = thermocycler.start_set_lid_temperature(58) + hs_task = heatershaker.set_target_temperature(58) + protocol.wait_for_tasks([tc_block_task, tc_lid_task, hs_task]) protocol.comment("--> Transfer Hybridization") TransferSup = 100 @@ -1083,6 +1087,8 @@ def tipcheck() -> None: ) if deactivate_modules_bool: helpers.deactivate_modules(protocol) + + protocol.capture_image(filename="end_of_run") if not protocol.is_simulating(): slack_bot.send_run_completed_message(metadata["protocolName"]) except Exception as e: diff --git a/abr-testing/abr_testing/protocols/active_protocols/5_MiSeq Library Preparation.py b/abr-testing/abr_testing/protocols/active_protocols/5_MiSeq Library Preparation.py index 8d9021b31bb..59897bcabb1 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/5_MiSeq Library Preparation.py +++ b/abr-testing/abr_testing/protocols/active_protocols/5_MiSeq Library Preparation.py @@ -22,7 +22,7 @@ } -requirements = {"robotType": "Flex", "apiLevel": "2.26"} +requirements = {"robotType": "Flex", "apiLevel": "2.27"} def add_parameters(parameters: ParameterContext) -> None: @@ -41,6 +41,7 @@ def add_parameters(parameters: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Protocol.""" # Load Parameters + protocol.capture_image(filename="start_of_run") dot_bottom = protocol.params.dot_bottom # type: ignore[attr-defined] deactivate_modules_bool = protocol.params.deactivate_modules # type: ignore[attr-defined] column_tip_pick_up = protocol.params.column_tip_pickup # type: ignore[attr-defined] @@ -49,7 +50,7 @@ def run(protocol: ProtocolContext) -> None: if not protocol.is_simulating(): slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) - helpers.comment_protocol_version(protocol, "02") + helpers.comment_protocol_version(protocol, "03") def transfer( pipette: InstrumentContext, @@ -75,10 +76,15 @@ def transfer( # Perform transfer if source.current_liquid_volume() < volume: - src_location = source.meniscus(z=meniscus_z, target="end") + src_start_location = source.meniscus(z=meniscus_z, target="start") + src_end_location = source.meniscus(z=meniscus_z, target="end") else: - src_location = source.bottom(z=dot_bottom) - pipette.aspirate(volume, src_location) + src_start_location = source.bottom(z=dot_bottom) + src_end_location = source.bottom(z=dot_bottom) + pipette.prepare_to_aspirate() + pipette.aspirate( + volume, location=src_start_location, end_location=src_end_location + ) pipette.move_to(source.top(), speed=5) pipette.dispense(volume, dest.bottom(z=dot_bottom)) pipette.move_to(dest.top(), speed=5) @@ -178,8 +184,9 @@ def all(pipette: InstrumentContext = p96) -> None: # Step 1-2: Set temperatures thermocycler.open_lid() - temp_module.set_temperature(8) - thermocycler.set_block_temperature(8) + temp_mod_task = temp_module.start_set_temperature(8) + tc_block_task = thermocycler.start_set_block_temperature(8) + protocol.wait_for_tasks([tc_block_task, temp_mod_task]) column_tips = partial_tiprack.rows()[0][::-1] if column_tip_pick_up: diff --git a/abr-testing/abr_testing/protocols/active_protocols/6_Olink_Target_48-96.py b/abr-testing/abr_testing/protocols/active_protocols/6_Olink_Target_48-96.py index 2896a816f12..1f8ce567aa0 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/6_Olink_Target_48-96.py +++ b/abr-testing/abr_testing/protocols/active_protocols/6_Olink_Target_48-96.py @@ -16,7 +16,7 @@ "author": "Zachary Galluzzo ", } -requirements = {"robotType": "Flex", "apiLevel": "2.26"} +requirements = {"robotType": "Flex", "apiLevel": "2.27"} open_location: Any = "A4" @@ -85,6 +85,7 @@ def add_parameters(p: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Main function to run the protocol.""" global open_location + protocol.capture_image(filename="start_of_run") # Import Parameters if not protocol.is_simulating(): @@ -102,7 +103,7 @@ def run(protocol: ProtocolContext) -> None: open_location = "B2" ninety_six = True if num_samples == 96 else False - helpers.comment_protocol_version(protocol, "02") + helpers.comment_protocol_version(protocol, "03") protocol.comment(f"\n********\nStarting Target {num_samples} Protocol\n********\n") @@ -297,7 +298,8 @@ def transfer_mm( for i in range(2 if ninety_six else 1): pip.aspirate( 49 - pip.current_volume, - src.meniscus(z=-1, target="end"), + location=src.meniscus(z=-1, target="start"), + end_location=src.meniscus(z=-1, target="end"), rate=0.2, ) # aspirate extra (backlash compensation) protocol.delay(seconds=delay_time) @@ -322,9 +324,11 @@ def transfer_mm( for i in range(length): volume = 9.1 + i * 0.15 protocol.comment(f"\nVOLUME: {volume}") + pip.prepare_to_aspirate() pip.aspirate( volume + 1.5 if i == 0 else volume, - src.meniscus(z=-1, target="end"), + location=src.meniscus(z=-1, target="start"), + end_location=src.meniscus(z=-1, target="end"), rate=0.35, ) protocol.delay(seconds=delay_time) @@ -333,7 +337,8 @@ def transfer_mm( pip.move_to(destination[i].top(10)) pip.dispense( volume, - destination[i].meniscus(z=-1, target="end"), + location=destination[i].meniscus(z=-1, target="start"), + end_location=destination[i].meniscus(z=-1, target="end"), rate=0.2 if volume <= 5 else 1, push_out=0, ) @@ -354,7 +359,11 @@ def transfer_ep(src: Well, destination: Well, volume: float) -> None: pip.configure_nozzle_layout(style=ALL) pip.configure_for_volume(volume) pip.pick_up_tip(full_tips) - pip.aspirate(volume, src.meniscus(z=-1, target="end")) + pip.aspirate( + volume, + location=src.meniscus(z=-1, target="start"), + end_location=src.meniscus(z=-1, target="end"), + ) protocol.delay(seconds=delay_time) pip.dispense( volume, destination.meniscus(z=-1, target="end") @@ -372,9 +381,19 @@ def transfer_ep(src: Well, destination: Well, volume: float) -> None: pip.pick_up_tip( col_tips[0].wells()[5 * 8 if mmx_to_sample_plate else 6 * 8] ) - pip.aspirate(volume, src.meniscus(z=-1, target="end"), rate=0.2) + pip.aspirate( + volume, + location=src.meniscus(z=-1, target="start"), + end_location=src.meniscus(z=-1, target="end"), + rate=0.2, + ) protocol.delay(seconds=delay_time) - pip.dispense(volume, destination.meniscus(z=-1, target="end"), rate=0.2) + pip.dispense( + volume, + location=destination.meniscus(z=-1, target="start"), + end_location=destination.meniscus(z=-1, target="end"), + rate=0.2, + ) pip.blow_out(destination.meniscus(z=2, target="end")) protocol.delay(seconds=delay_time) mixing(destination, 6, reps=2) # rinse sample off tips @@ -438,12 +457,15 @@ def transfer_ifp( pip.pick_up_tip(ifp_tips.pop(0)) pip.aspirate( volume + 4, - src[i].meniscus(z=-1, target="end"), + location=src[i].meniscus(z=-1, target="start"), + end_location=src[i].meniscus(z=-1, target="end"), rate=0.2 if volume <= 5 else 1, ) protocol.delay(seconds=delay_time) pip.dispense( - 2, src[i].meniscus(z=-1, target="end") + 2, + location=src[i].meniscus(z=-1, target="start"), + end_location=src[i].meniscus(z=-1, target="end"), ) # compensate for backlash # Retract pip.dispense( diff --git a/abr-testing/abr_testing/protocols/active_protocols/7_HDQ_DNA_Bacteria_Flex.py b/abr-testing/abr_testing/protocols/active_protocols/7_HDQ_DNA_Bacteria_Flex.py index 65fde43379d..355d3340a6d 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/7_HDQ_DNA_Bacteria_Flex.py +++ b/abr-testing/abr_testing/protocols/active_protocols/7_HDQ_DNA_Bacteria_Flex.py @@ -23,7 +23,7 @@ requirements = { "robotType": "Flex", - "apiLevel": "2.26", + "apiLevel": "2.27", } """ Slot A1: Tips 1000 @@ -63,12 +63,13 @@ def add_parameters(parameters: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Protocol.""" + protocol.capture_image(filename="start_of_run") heater_shaker_speed = protocol.params.heater_shaker_speed # type: ignore[attr-defined] mount = protocol.params.pipette_mount # type: ignore[attr-defined] deactivate_modules_bool = protocol.params.deactivate_modules # type: ignore[attr-defined] probe_height_bool = protocol.params.probe_liquid_height # type: ignore[attr-defined] meniscus_z = protocol.params.meniscus_z # type: ignore[attr-defined] - helpers.comment_protocol_version(protocol, "04") + helpers.comment_protocol_version(protocol, "05") if not protocol.is_simulating(): slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) @@ -342,13 +343,12 @@ def A_lysis(vol: float, source: Well) -> None: helpers.set_hs_speed(protocol, h_s, heater_shaker_speed, A_lysis_time_1, False) if not dry_run: - h_s.set_and_wait_for_temperature(55) - protocol.delay( - minutes=A_lysis_time_2, - msg="Incubating at 55C " - + str(heater_shaker_speed) - + " rpm for 10 minutes.", - ) + hs_task = h_s.set_target_temperature(55) + protocol.wait_for_tasks([hs_task]) + protocol.comment("reached 55C") + timer_task = protocol.create_timer(seconds=A_lysis_time_2 * 60) + protocol.wait_for_tasks([timer_task]) + protocol.comment("Incubated at 55C for 10 minutes") h_s.deactivate_shaker() def bind(vol: float) -> None: @@ -490,7 +490,6 @@ def elute(vol: float) -> None: m1000.flow_rate.aspirate = 150 m1000.drop_tip() if TIP_TRASH else m1000.return_tip() - h_s.set_and_wait_for_shake_speed(heater_shaker_speed * 1.1) speed_val = heater_shaker_speed * 1.1 helpers.set_hs_speed(protocol, h_s, speed_val, elute_wash_time, True) @@ -529,6 +528,7 @@ def elute(vol: float) -> None: protocol.move_lid(lid, elutionplate, use_gripper=True) helpers.move_labware_to_hs(protocol, sample_plate, h_s, h_s) + protocol.capture_image(filename="movement") """ Here is where you can call the methods defined above to fit your specific @@ -560,6 +560,8 @@ def elute(vol: float) -> None: protocol, m1000, [res1, elutionplate], waste_reservoir["A1"] ) helpers.find_liquid_height_of_all_wells(protocol, m1000, end_wells_with_liquid) + protocol.capture_image(filename="protocol_finished") + if deactivate_modules_bool: helpers.deactivate_modules(protocol) if not protocol.is_simulating(): diff --git a/abr-testing/abr_testing/protocols/active_protocols/9_Magmax_RNA_Cells_Flex.py b/abr-testing/abr_testing/protocols/active_protocols/9_Magmax_RNA_Cells_Flex.py index f136749f4db..949e1f2481c 100644 --- a/abr-testing/abr_testing/protocols/active_protocols/9_Magmax_RNA_Cells_Flex.py +++ b/abr-testing/abr_testing/protocols/active_protocols/9_Magmax_RNA_Cells_Flex.py @@ -26,7 +26,7 @@ requirements = { "robotType": "Flex", - "apiLevel": "2.26", + "apiLevel": "2.27", } """ Slot A1: Tips 200 @@ -78,6 +78,8 @@ def add_parameters(parameters: ParameterContext) -> None: def run(protocol: ProtocolContext) -> None: """Protocol.""" + protocol.capture_image(filename="start_of_run") + dry_run = False inc_lysis = True res_type = "opentrons_tough_12_reservoir_22ml" @@ -99,7 +101,7 @@ def run(protocol: ProtocolContext) -> None: slack_bot = helpers.set_up_slack() slack_bot.send_run_started_message(metadata["protocolName"]) - helpers.comment_protocol_version(protocol, "04") + helpers.comment_protocol_version(protocol, "05") plate_name_str = "hellma_plate_" + str(plate_orientation) # Protocol Parameters @@ -131,7 +133,7 @@ def run(protocol: ProtocolContext) -> None: elutionplate, temp_adapter = helpers.load_temp_adapter_and_labware( "opentrons_96_wellplate_200ul_pcr_full_skirt", temp, "Elution Plate" ) - temp.set_temperature(4) + temp_task = temp.start_set_temperature(4) magblock: MagneticBlockContext = protocol.load_module( helpers.mag_str, "C1" ) # type: ignore[assignment] @@ -326,7 +328,12 @@ def lysis(vol: float, source: List[Well]) -> None: for i in range(num_cols): tvol = vol / num_transfers for t in range(num_transfers): - m1000.aspirate(tvol, src.meniscus(z=meniscus_z, target="end")) + m1000.prepare_to_aspirate() + m1000.aspirate( + tvol, + location=src.meniscus(z=meniscus_z, target="start"), + end_location=src.meniscus(z=meniscus_z, target="end"), + ) m1000.dispense(m1000.current_volume, cells_m[i].top(-3)) if src.current_liquid_volume() < (tvol * 8): protocol.comment("-----Changing to second lysis well.------") @@ -340,9 +347,15 @@ def lysis(vol: float, source: List[Well]) -> None: for x in range(8 if not dry_run else 1): m1000.prepare_to_aspirate() m1000.aspirate( - tvol * 0.75, cells_m[i].meniscus(z=meniscus_z, target="end") + tvol * 0.75, + location=cells_m[i].meniscus(z=meniscus_z, target="start"), + end_location=cells_m[i].meniscus(z=meniscus_z, target="end"), + ) + m1000.dispense( + tvol * 0.75, + location=cells_m[i].meniscus(z=8, target="start"), + end_location=cells_m[i].meniscus(z=8, target="end"), ) - m1000.dispense(tvol * 0.75, cells_m[i].meniscus(z=8, target="end")) if x == 3: protocol.delay(minutes=0.0167) m1000.blow_out(cells_m[i].meniscus(z=1, target="end")) @@ -371,7 +384,11 @@ def bind() -> None: # Transfer cells+lysis/bind to wells with beads tiptrack(m1000) m1000.prepare_to_aspirate() - m1000.aspirate(120, cells_m[i].meniscus(z=meniscus_z, target="end")) + m1000.aspirate( + 120, + location=cells_m[i].meniscus(z=meniscus_z, target="start"), + end_location=cells_m[i].meniscus(z=meniscus_z, target="end"), + ) m1000.air_gap(10) m1000.dispense(m1000.current_volume, well.meniscus(z=8, target="end")) # Mix after transfer @@ -456,8 +473,16 @@ def dnase(vol: float, source: List[Well]) -> None: src = source[i] m1000.flow_rate.aspirate = 10 for n in range(num_trans): - m1000.aspirate(vol_per_trans, src.meniscus(z=meniscus_z, target="end")) - m1000.dispense(vol_per_trans, m.meniscus(z=3, target="end")) + m1000.aspirate( + vol_per_trans, + location=src.meniscus(z=meniscus_z, target="start"), + end_location=src.meniscus(z=meniscus_z, target="end"), + ) + m1000.dispense( + vol_per_trans, + location=m.meniscus(z=meniscus_z, target="start"), + end_location=m.meniscus(z=meniscus_z, target="end"), + ) m1000.blow_out(m.top(-3)) m1000.prepare_to_aspirate() m1000.air_gap(20) @@ -607,6 +632,7 @@ def elute(vol: float) -> None: wash(wash_vol, all_washes) wash(wash_vol, all_washes) # dnase1 treatment + protocol.wait_for_tasks([temp_task]) dnase(30, dnase1) stop_reaction(stop_vol, stopreaction) # Resume washes diff --git a/abr-testing/abr_testing/tools/log_reader.py b/abr-testing/abr_testing/tools/log_reader.py new file mode 100644 index 00000000000..22fbe13ec00 --- /dev/null +++ b/abr-testing/abr_testing/tools/log_reader.py @@ -0,0 +1,66 @@ +"""Compare run logs.""" +import json +import argparse +from datetime import datetime +from statistics import mean + + +def read_commands(path: str) -> list[dict[str, str]] | None: + """Read json file into a dictionary.""" + try: + with open(path, "r") as file: + data = json.load(file) + return data["commands"]["data"] + except FileNotFoundError: + print(f"Error: {path} not found.") + return None + except json.JSONDecodeError: + print(f"Error: Invalid JSON format in {path}") + return None + + +def avg_determine_command_time( + commands: list[dict[str, str]], desired_command: str +) -> None: + """Extract command of interest.""" + all_durations = [] + for command in commands: + if command["commandType"] == desired_command: + start_time = datetime.strptime( + command.get("startedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + end_time = datetime.strptime( + command.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + ) + duration = (end_time - start_time).total_seconds() + all_durations.append(duration) + avg_duration = mean(all_durations) + num_of_times = len(all_durations) + total_time = sum(all_durations) + print( + f"""Total Occurences: {num_of_times} + Average Duration (sec): {avg_duration} + Total Time (sec): {total_time}""" + ) + + +if __name__ == "__main__": + """Compare duration of command in two run logs.""" + parser = argparse.ArgumentParser(description="Compare duration of command") + parser.add_argument("run_log_1", metavar="RUN_LOG_1", type=str, nargs=1) + parser.add_argument("run_log_2", metavar="RUN_LOG_2", type=str, nargs=1) + parser.add_argument("command_string", metavar="COMMAND_STRING", nargs=1) + + args = parser.parse_args() + run_log_1 = args.run_log_1[0] + run_log_2 = args.run_log_2[0] + command_string = args.command_string[0] + print(f"----REVIEWING COMMAND {command_string}----") + print(f"Reviewing {run_log_1.split('/')[-1]}") + commands_1 = read_commands(run_log_1) + if commands_1: + avg_determine_command_time(commands_1, command_string) + print(f"Reviewing {run_log_2.split('/')[-1]}") + commands_2 = read_commands(run_log_2) + if commands_2: + avg_determine_command_time(commands_2, command_string) diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[004ebb2b82][OT2_S_v2_11_P10S_P300M_MM_TC1_TM_Swift].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[004ebb2b82][OT2_S_v2_11_P10S_P300M_MM_TC1_TM_Swift].json index 1281afc560b..eab87aaf3fd 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[004ebb2b82][OT2_S_v2_11_P10S_P300M_MM_TC1_TM_Swift].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[004ebb2b82][OT2_S_v2_11_P10S_P300M_MM_TC1_TM_Swift].json @@ -16515,7 +16515,32 @@ "metadata": { "apiLevel": "2.11" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "4" + }, + "model": "temperatureModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[010f0c3a8d][OT2_S_v2_11_PL_IDT-xGen-EZ-24x-for-OT2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[010f0c3a8d][OT2_S_v2_11_PL_IDT-xGen-EZ-24x-for-OT2].json index 96c9a127bb0..4139802263d 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[010f0c3a8d][OT2_S_v2_11_PL_IDT-xGen-EZ-24x-for-OT2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[010f0c3a8d][OT2_S_v2_11_PL_IDT-xGen-EZ-24x-for-OT2].json @@ -9688,7 +9688,32 @@ "protocolName": "IDT xGEN EZ", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0602eebc82][OT2_S_v2_13_PL_transient_transfection_of_HeLacells_Protocol_2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0602eebc82][OT2_S_v2_13_PL_transient_transfection_of_HeLacells_Protocol_2].json index 2c7750f705b..56c54fcd00a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0602eebc82][OT2_S_v2_13_PL_transient_transfection_of_HeLacells_Protocol_2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[0602eebc82][OT2_S_v2_13_PL_transient_transfection_of_HeLacells_Protocol_2].json @@ -9133,7 +9133,16 @@ "description": "Protocol to transfect HeLa cells using the OT-2", "protocolName": "Transfection using Lipofectamine 3000 and Fugene HD Reagent" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "heaterShakerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[160f3e77e4][OT2_S_v2_9_PL_macherey-nagel-nucleomag-virus].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[160f3e77e4][OT2_S_v2_9_PL_macherey-nagel-nucleomag-virus].json index ced3539f8de..76978a91692 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[160f3e77e4][OT2_S_v2_9_PL_macherey-nagel-nucleomag-virus].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[160f3e77e4][OT2_S_v2_9_PL_macherey-nagel-nucleomag-virus].json @@ -76663,7 +76663,16 @@ "author": "Macherey-Nagel ", "protocolName": "NucleoMag_Virus_Rev01" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[1c19a2055c][OT2_S_v2_4_P300M_None_MM_TM_Zymo].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[1c19a2055c][OT2_S_v2_4_P300M_None_MM_TM_Zymo].json index 29bdfaebe30..f4c08f498c7 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[1c19a2055c][OT2_S_v2_4_P300M_None_MM_TM_Zymo].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[1c19a2055c][OT2_S_v2_4_P300M_None_MM_TM_Zymo].json @@ -81635,7 +81635,24 @@ "author": "Opentrons ", "protocolName": "Zymo Direct-zol96 Magbead RNA" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[1c5eb55b4b][OT2_S_v2_4_PL_sci-macherey-nagel-nucleomag].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[1c5eb55b4b][OT2_S_v2_4_PL_sci-macherey-nagel-nucleomag].json index b3d21fc4600..7629a308d22 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[1c5eb55b4b][OT2_S_v2_4_PL_sci-macherey-nagel-nucleomag].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[1c5eb55b4b][OT2_S_v2_4_PL_sci-macherey-nagel-nucleomag].json @@ -19414,7 +19414,16 @@ "author": "Opentrons ", "protocolName": "NucleoMag® Virus Viral DNA/RNA Isolation" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20048cb3d1][OT2_S_v2_9_PL_macherey-nagel-nucleomag-size-select].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20048cb3d1][OT2_S_v2_9_PL_macherey-nagel-nucleomag-size-select].json index 6a6ebddc327..f67e6ce6e47 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20048cb3d1][OT2_S_v2_9_PL_macherey-nagel-nucleomag-size-select].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[20048cb3d1][OT2_S_v2_9_PL_macherey-nagel-nucleomag-size-select].json @@ -45141,7 +45141,16 @@ "author": "Macherey-Nagel ", "protocolName": "NucleoMag_NGS_double-size_select_Rev01" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[3251c6e175][OT2_S_v2_2_P300S_None_MM1_MM2_EngageMagHeightFromBase].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[3251c6e175][OT2_S_v2_2_P300S_None_MM1_MM2_EngageMagHeightFromBase].json index 4dd6bdf8eb6..63cc918013d 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[3251c6e175][OT2_S_v2_2_P300S_None_MM1_MM2_EngageMagHeightFromBase].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[3251c6e175][OT2_S_v2_2_P300S_None_MM1_MM2_EngageMagHeightFromBase].json @@ -1397,7 +1397,24 @@ "metadata": { "apiLevel": "2.2" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "4" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[38b7ac4410][OT2_S_v2_10_PL_swift-2s-turbo-pt1].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[38b7ac4410][OT2_S_v2_10_PL_swift-2s-turbo-pt1].json index 84b6318cbf7..9a25f955d5b 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[38b7ac4410][OT2_S_v2_10_PL_swift-2s-turbo-pt1].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[38b7ac4410][OT2_S_v2_10_PL_swift-2s-turbo-pt1].json @@ -9127,7 +9127,16 @@ "protocolName": "Swift 2S Turbo DNA Library Kit Protocol: Part 1/3 - Enzymatic Prep & Ligation", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[463830e283][OT2_S_v2_9_PL_macherey-nagel-nucleomag-dna-food].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[463830e283][OT2_S_v2_9_PL_macherey-nagel-nucleomag-dna-food].json index 44841bd674b..468acb6a90c 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[463830e283][OT2_S_v2_9_PL_macherey-nagel-nucleomag-dna-food].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[463830e283][OT2_S_v2_9_PL_macherey-nagel-nucleomag-dna-food].json @@ -104968,7 +104968,16 @@ "author": "Macherey-Nagel ", "protocolName": "NucleoMag_DNA_Food_Rev01" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[552e4bfb25][OT2_S_v2_13_PL_transient_transfection_of_HeLacells_Protocol_1].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[552e4bfb25][OT2_S_v2_13_PL_transient_transfection_of_HeLacells_Protocol_1].json index 23caa72fc93..c41b1df5c37 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[552e4bfb25][OT2_S_v2_13_PL_transient_transfection_of_HeLacells_Protocol_1].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[552e4bfb25][OT2_S_v2_13_PL_transient_transfection_of_HeLacells_Protocol_1].json @@ -3274,7 +3274,16 @@ "description": "Protocol to transfect HeLa and A549 cells using the OT-2", "protocolName": "Transfection using Lipofectamine 3000 Reagent" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "heaterShakerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[56c0c2e8fc][OT2_S_v2_9_PL_macherey-nagel-nucleomag-tissue].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[56c0c2e8fc][OT2_S_v2_9_PL_macherey-nagel-nucleomag-tissue].json index 5be7680b916..929546ea862 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[56c0c2e8fc][OT2_S_v2_9_PL_macherey-nagel-nucleomag-tissue].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[56c0c2e8fc][OT2_S_v2_9_PL_macherey-nagel-nucleomag-tissue].json @@ -91054,7 +91054,16 @@ "author": "Macherey-Nagel ", "protocolName": "NucleoMag_Tissue_Rev01" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5a248d53c7][OT2_S_v2_9_PL_macherey-nagel-nucleomag-rna].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5a248d53c7][OT2_S_v2_9_PL_macherey-nagel-nucleomag-rna].json index 77a056a919a..bba42fa9303 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5a248d53c7][OT2_S_v2_9_PL_macherey-nagel-nucleomag-rna].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5a248d53c7][OT2_S_v2_9_PL_macherey-nagel-nucleomag-rna].json @@ -143866,7 +143866,16 @@ "author": "Macherey-Nagel ", "protocolName": "NucleoMag_RNA_Rev01" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5cdd930bc8][OT2_S_v2_9_PL_sci-neb-next-ultra].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5cdd930bc8][OT2_S_v2_9_PL_sci-neb-next-ultra].json index ec23d3f1bb0..1c11a08849c 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5cdd930bc8][OT2_S_v2_9_PL_sci-neb-next-ultra].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5cdd930bc8][OT2_S_v2_9_PL_sci-neb-next-ultra].json @@ -44905,7 +44905,32 @@ "protocolName": "NEBNext® Ultra™ II DNA Library Prep Kit for Illumina®", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5f50127d90][OT2_S_v2_4_PL_sci-mag-bind-blood-tissue-kit].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5f50127d90][OT2_S_v2_4_PL_sci-mag-bind-blood-tissue-kit].json index 19b0a0d6761..9034a83754e 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5f50127d90][OT2_S_v2_4_PL_sci-mag-bind-blood-tissue-kit].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[5f50127d90][OT2_S_v2_4_PL_sci-mag-bind-blood-tissue-kit].json @@ -21776,7 +21776,16 @@ "author": "Opentrons ", "protocolName": "Mag-Bind® Blood & Tissue DNA HDQ 96 Kit" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[60ea56b776][OT2_S_v2_9_PL_macherey-nagel-nucleomag-clean-up].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[60ea56b776][OT2_S_v2_9_PL_macherey-nagel-nucleomag-clean-up].json index 4c336b0223f..901d6ac0273 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[60ea56b776][OT2_S_v2_9_PL_macherey-nagel-nucleomag-clean-up].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[60ea56b776][OT2_S_v2_9_PL_macherey-nagel-nucleomag-clean-up].json @@ -62896,7 +62896,16 @@ "author": "Macherey-Nagel ", "protocolName": "NucleoMag_NGS_clean_up_Rev01" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[6f3e297a11][OT2_S_v2_3_P300S_None_MM1_MM2_TM_Mix].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[6f3e297a11][OT2_S_v2_3_P300S_None_MM1_MM2_TM_Mix].json index 63423a6bf7e..09d64918369 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[6f3e297a11][OT2_S_v2_3_P300S_None_MM1_MM2_TM_Mix].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[6f3e297a11][OT2_S_v2_3_P300S_None_MM1_MM2_TM_Mix].json @@ -2792,7 +2792,32 @@ "metadata": { "apiLevel": "2.3" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "4" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7fba7321ed][OT2_S_v2_4_PL_sci-omegabiotek-extraction-fa].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7fba7321ed][OT2_S_v2_4_PL_sci-omegabiotek-extraction-fa].json index 6274d28e2bf..5abfb1aaa13 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7fba7321ed][OT2_S_v2_4_PL_sci-omegabiotek-extraction-fa].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7fba7321ed][OT2_S_v2_4_PL_sci-omegabiotek-extraction-fa].json @@ -171789,7 +171789,16 @@ "protocolName": "Mag-Bind® Blood & Tissue DNA HDQ 96 Kit", "source": "Custom Protocol Request" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[83bde2a1d4][OT2_S_v2_9_PL_macherey-nagel-nucleomag-dna-microbiome].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[83bde2a1d4][OT2_S_v2_9_PL_macherey-nagel-nucleomag-dna-microbiome].json index 9eaaba0f176..fd11ff620e6 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[83bde2a1d4][OT2_S_v2_9_PL_macherey-nagel-nucleomag-dna-microbiome].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[83bde2a1d4][OT2_S_v2_9_PL_macherey-nagel-nucleomag-dna-microbiome].json @@ -119551,7 +119551,16 @@ "author": "Macherey-Nagel ", "protocolName": "NucleoMag_DNA_Microbiome_Rev01" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8543b2a5aa][OT2_S_v2_4_PL_sci-omegabiotek-magbind-total-rna-96].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8543b2a5aa][OT2_S_v2_4_PL_sci-omegabiotek-magbind-total-rna-96].json index c85b132373b..ae7261339ab 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8543b2a5aa][OT2_S_v2_4_PL_sci-omegabiotek-magbind-total-rna-96].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8543b2a5aa][OT2_S_v2_4_PL_sci-omegabiotek-magbind-total-rna-96].json @@ -32409,7 +32409,24 @@ "author": "Opentrons ", "protocolName": "Mag-Bind® Total RNA 96 Kit" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[88d6e7fc09][OT2_S_v2_13_PL_MagneSil_RNA_OT2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[88d6e7fc09][OT2_S_v2_13_PL_MagneSil_RNA_OT2].json index 7ecde664c93..4b730834a6f 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[88d6e7fc09][OT2_S_v2_13_PL_MagneSil_RNA_OT2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[88d6e7fc09][OT2_S_v2_13_PL_MagneSil_RNA_OT2].json @@ -29963,7 +29963,32 @@ "author": "Opentrons ", "protocolName": "Promega MagneSil Total RNA Extraction from Cells & Bacteria" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "heaterShakerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9618a6623c][OT2_X_v2_11_P300S_TC1_TC2_ThermocyclerMoamError].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9618a6623c][OT2_X_v2_11_P300S_TC1_TC2_ThermocyclerMoamError].json index 1a9ab63cf4a..13d09f7c11a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9618a6623c][OT2_X_v2_11_P300S_TC1_TC2_ThermocyclerMoamError].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9618a6623c][OT2_X_v2_11_P300S_TC1_TC2_ThermocyclerMoamError].json @@ -2706,7 +2706,16 @@ "metadata": { "apiLevel": "2.11" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[96daaa8fbf][OT2_S_v2_9_PL_macherey-nagel-nucleomag-pathogen].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[96daaa8fbf][OT2_S_v2_9_PL_macherey-nagel-nucleomag-pathogen].json index d5d0f0c704e..e967e7f87f8 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[96daaa8fbf][OT2_S_v2_9_PL_macherey-nagel-nucleomag-pathogen].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[96daaa8fbf][OT2_S_v2_9_PL_macherey-nagel-nucleomag-pathogen].json @@ -96898,7 +96898,16 @@ "author": "Macherey-Nagel ", "protocolName": "NucleoMag_Pathogen_Rev01" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[a181c1ff39][OT2_S_v2_13_PL_cell_viability_and_cytotoxicity_assay_A549_cells].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[a181c1ff39][OT2_S_v2_13_PL_cell_viability_and_cytotoxicity_assay_A549_cells].json index ca8f81d2362..6ed16d47e2d 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[a181c1ff39][OT2_S_v2_13_PL_cell_viability_and_cytotoxicity_assay_A549_cells].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[a181c1ff39][OT2_S_v2_13_PL_cell_viability_and_cytotoxicity_assay_A549_cells].json @@ -14709,7 +14709,16 @@ "description": "To measure viability and cytotoxicity of A549 cells\ntreated with Thapsigargin using the OT-2", "protocolName": "Cell Viability and Cytotoxicity Assay" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "heaterShakerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[a66d700ed6][OT2_S_v2_13_P300M_P20S_HS_TC_TM_SmokeTestV3].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[a66d700ed6][OT2_S_v2_13_P300M_P20S_HS_TC_TM_SmokeTestV3].json index 2d7d7926891..30850eba814 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[a66d700ed6][OT2_S_v2_13_P300M_P20S_HS_TC_TM_SmokeTestV3].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[a66d700ed6][OT2_S_v2_13_P300M_P20S_HS_TC_TM_SmokeTestV3].json @@ -13192,7 +13192,32 @@ "protocolName": "🛠️ 2.13 Smoke Test V3 🪄", "source": "Software Testing Team" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "heaterShakerModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "9" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ad957852f3][OT2_S_v2_4_PL_sci-promega-magnesil-total-rna-mini-isolation-system].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ad957852f3][OT2_S_v2_4_PL_sci-promega-magnesil-total-rna-mini-isolation-system].json index f909cb63cf3..3a83e5b7995 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ad957852f3][OT2_S_v2_4_PL_sci-promega-magnesil-total-rna-mini-isolation-system].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ad957852f3][OT2_S_v2_4_PL_sci-promega-magnesil-total-rna-mini-isolation-system].json @@ -145895,7 +145895,24 @@ "author": "Opentrons ", "protocolName": "MagneSil Total RNA Promega" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b9b15682f9][OT2_S_v2_12_PL_sci-amplex-red].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b9b15682f9][OT2_S_v2_12_PL_sci-amplex-red].json index bd56ede85f6..f973f7ec263 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b9b15682f9][OT2_S_v2_12_PL_sci-amplex-red].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b9b15682f9][OT2_S_v2_12_PL_sci-amplex-red].json @@ -32026,7 +32026,16 @@ "description": "Protocol to measure hydrogen peroxide levels from THP-1 cells using the OT-2", "protocolName": "Amplex Red Hydrogen Peroxide Assay" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[bb91ddef8c][OT2_S_v2_9_PL_sci-idt-normalase].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[bb91ddef8c][OT2_S_v2_9_PL_sci-idt-normalase].json index 8980b19fd5b..d4471d4856f 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[bb91ddef8c][OT2_S_v2_9_PL_sci-idt-normalase].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[bb91ddef8c][OT2_S_v2_9_PL_sci-idt-normalase].json @@ -6791,7 +6791,32 @@ "protocolName": "IDT Normalase", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c3b37eae0f][Flex_S_v2_25_P200_stacker_2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c3b37eae0f][Flex_S_v2_25_P200_stacker_2].json index 6de567e644c..a3ca6ddcc65 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c3b37eae0f][Flex_S_v2_25_P200_stacker_2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c3b37eae0f][Flex_S_v2_25_P200_stacker_2].json @@ -9820,12 +9820,12 @@ "dropOffset": { "x": 0, "y": 0.52, - "z": -6 + "z": 0 }, "pickUpOffset": { "x": 0, "y": 0, - "z": 1.5 + "z": 0 } }, "lidDisposalOffsets": { diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c821e64fad][OT2_S_v2_13_P300M_P20S_MM_TC_TM_Smoke620Release].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c821e64fad][OT2_S_v2_13_P300M_P20S_MM_TC_TM_Smoke620Release].json index 90927fdfaaa..6014392758c 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c821e64fad][OT2_S_v2_13_P300M_P20S_MM_TC_TM_Smoke620Release].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c821e64fad][OT2_S_v2_13_P300M_P20S_MM_TC_TM_Smoke620Release].json @@ -10528,7 +10528,32 @@ "protocolName": "🛠 Logo-Modules-CustomLabware 🛠", "source": "Software Testing Team" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "9" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "4" + }, + "model": "temperatureModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c8d4237127][OT2_S_v2_9_PL_sci-idt-xgen-mc].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c8d4237127][OT2_S_v2_9_PL_sci-idt-xgen-mc].json index 161f1b2b1c5..b183d50081a 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c8d4237127][OT2_S_v2_9_PL_sci-idt-xgen-mc].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c8d4237127][OT2_S_v2_9_PL_sci-idt-xgen-mc].json @@ -9701,7 +9701,32 @@ "protocolName": "IDT xGEN MC", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c9e6e3d59d][OT2_X_v4_P300M_P20S_MM_TC1_TM_e2eTests].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c9e6e3d59d][OT2_X_v4_P300M_P20S_MM_TC1_TM_e2eTests].json index f5d597cd86b..b6626d02cb2 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c9e6e3d59d][OT2_X_v4_P300M_P20S_MM_TC1_TM_e2eTests].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c9e6e3d59d][OT2_X_v4_P300M_P20S_MM_TC1_TM_e2eTests].json @@ -6589,7 +6589,32 @@ "subcategory": null, "tags": [] }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[cdeb821849][OT2_S_v2_13_PL_Zymo_Magbead_DNA_OT2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[cdeb821849][OT2_S_v2_13_PL_Zymo_Magbead_DNA_OT2].json index dd3b7a2c520..97017128ec8 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[cdeb821849][OT2_S_v2_13_PL_Zymo_Magbead_DNA_OT2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[cdeb821849][OT2_S_v2_13_PL_Zymo_Magbead_DNA_OT2].json @@ -37278,7 +37278,24 @@ "author": "Zach Galluzzo ", "protocolName": "ZymoBIOMICs Magbead DNA Extraction from Cells" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "10" + }, + "model": "heaterShakerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d50ea72948][OT2_S_v2_2_PL_omega_biotek_magbind_totalpure_ngs].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d50ea72948][OT2_S_v2_2_PL_omega_biotek_magbind_totalpure_ngs].json index fd3e3b6aaaa..f759680fca5 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d50ea72948][OT2_S_v2_2_PL_omega_biotek_magbind_totalpure_ngs].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d50ea72948][OT2_S_v2_2_PL_omega_biotek_magbind_totalpure_ngs].json @@ -115181,7 +115181,16 @@ "protocolName": "Omega Bio-tek Mag-Bind TotalPure NGS", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e4660ca6df][OT2_S_v4_P300S_None_MM_TM_TM_MOAMTemps].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e4660ca6df][OT2_S_v4_P300S_None_MM_TM_TM_MOAMTemps].json index f0e73f28fa9..97935ad2d1f 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e4660ca6df][OT2_S_v4_P300S_None_MM_TM_TM_MOAMTemps].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e4660ca6df][OT2_S_v4_P300S_None_MM_TM_TM_MOAMTemps].json @@ -1531,7 +1531,32 @@ "subcategory": null, "tags": [] }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e58704f21b][OT2_S_v2_10_PL_swift-fully-automated].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e58704f21b][OT2_S_v2_10_PL_swift-fully-automated].json index baeab0f423d..c5beba57cf4 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e58704f21b][OT2_S_v2_10_PL_swift-fully-automated].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e58704f21b][OT2_S_v2_10_PL_swift-fully-automated].json @@ -26773,7 +26773,32 @@ "protocolName": "Swift 2S Turbo DNA Library Kit Protocol: Fully Automated", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e898f1b208][OT2_S_v2_4_PL_sci-zymo-directzol-magbead].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e898f1b208][OT2_S_v2_4_PL_sci-zymo-directzol-magbead].json index ad715f3ebc8..48b0b8f0a34 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e898f1b208][OT2_S_v2_4_PL_sci-zymo-directzol-magbead].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e898f1b208][OT2_S_v2_4_PL_sci-zymo-directzol-magbead].json @@ -28897,7 +28897,24 @@ "author": "Opentrons ", "protocolName": "Zymo Research Direct-zolâ„¢-96 MagBead RNA Kit" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e9ea6f5739][OT2_S_v2_9_PL_Illumina-DNA-Prep-24x-for-OT2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e9ea6f5739][OT2_S_v2_9_PL_Illumina-DNA-Prep-24x-for-OT2].json index f03e758c3da..3f177241d97 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e9ea6f5739][OT2_S_v2_9_PL_Illumina-DNA-Prep-24x-for-OT2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[e9ea6f5739][OT2_S_v2_9_PL_Illumina-DNA-Prep-24x-for-OT2].json @@ -97410,7 +97410,32 @@ "protocolName": "Illumina DNA Prep", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f345e8e33a][OT2_S_v4_P300M_P20S_MM_TM_TC1_PD40].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f345e8e33a][OT2_S_v4_P300M_P20S_MM_TM_TC1_PD40].json index 713e46d1ef0..12093edb6a0 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f345e8e33a][OT2_S_v4_P300M_P20S_MM_TM_TC1_PD40].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f345e8e33a][OT2_S_v4_P300M_P20S_MM_TM_TC1_PD40].json @@ -10030,7 +10030,32 @@ "subcategory": null, "tags": [] }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "3" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "7" + }, + "model": "thermocyclerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f62f9ee647][OT2_S_v2_13_PL_transient_transfection_of_A549cells_Protocol_2].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f62f9ee647][OT2_S_v2_13_PL_transient_transfection_of_A549cells_Protocol_2].json index 3b0336ad7fc..aff6bd0b92d 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f62f9ee647][OT2_S_v2_13_PL_transient_transfection_of_A549cells_Protocol_2].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f62f9ee647][OT2_S_v2_13_PL_transient_transfection_of_A549cells_Protocol_2].json @@ -9197,7 +9197,16 @@ "description": "Protocol to transfect A549 cells using the OT-2", "protocolName": "Transfection using Lipofectamine 3000 and Fugene HD Reagent" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "heaterShakerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f967029946][OT2_S_v2_13_PL_cell_viability_and_cytotoxicity_assay_K562_cells].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f967029946][OT2_S_v2_13_PL_cell_viability_and_cytotoxicity_assay_K562_cells].json index 17e8fe9619c..a8ce94b62b3 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f967029946][OT2_S_v2_13_PL_cell_viability_and_cytotoxicity_assay_K562_cells].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f967029946][OT2_S_v2_13_PL_cell_viability_and_cytotoxicity_assay_K562_cells].json @@ -14709,7 +14709,16 @@ "description": "To measure viability and cytotoxicity of K562 cells \ntreated with Bortezomib using the OT-2", "protocolName": "Cell Viability and Cytotoxicity Assay" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "heaterShakerModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[fa24954076][OT2_S_v2_9_PL_bc-rnadvance-viral].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[fa24954076][OT2_S_v2_9_PL_bc-rnadvance-viral].json index 39fa49f9cfc..16747f83087 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[fa24954076][OT2_S_v2_9_PL_bc-rnadvance-viral].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[fa24954076][OT2_S_v2_9_PL_bc-rnadvance-viral].json @@ -84147,7 +84147,24 @@ "author": "Opentrons ", "protocolName": "Beckman Coulter RNAdvance Viral RNA Isolation" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "6" + }, + "model": "magneticModuleV2", + "serialNumber": "UUID" + }, + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "temperatureModuleV2", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ff4a494935][OT2_S_v2_4_PL_nucleic_acid_purification_with_magnetic_beads].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ff4a494935][OT2_S_v2_4_PL_nucleic_acid_purification_with_magnetic_beads].json index aec211f82f8..bc7449db1c1 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ff4a494935][OT2_S_v2_4_PL_nucleic_acid_purification_with_magnetic_beads].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[ff4a494935][OT2_S_v2_4_PL_nucleic_acid_purification_with_magnetic_beads].json @@ -33546,7 +33546,16 @@ "protocolName": "DNA Purification", "source": "Protocol Library" }, - "modules": [], + "modules": [ + { + "id": "UUID", + "location": { + "slotName": "1" + }, + "model": "magneticModuleV1", + "serialNumber": "UUID" + } + ], "pipettes": [ { "id": "UUID", diff --git a/api-client/src/camera/createCameraImageSettings.ts b/api-client/src/camera/createCameraImageSettings.ts new file mode 100644 index 00000000000..2dc158af25c --- /dev/null +++ b/api-client/src/camera/createCameraImageSettings.ts @@ -0,0 +1,22 @@ +import { POST, request } from '../request' + +import type { CameraId } from '@opentrons/shared-data' +import type { + CameraImageSettings, + CameraImageSettingsResponse, +} from '../camera' +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' + +export function createCameraImageSettings( + config: HostConfig, + data: CameraImageSettings, + cameraId: CameraId +): ResponsePromise { + return request( + POST, + `/camera/cameraSettings/${cameraId}`, + { data }, + config + ) +} diff --git a/api-client/src/camera/getCameraImageSettings.ts b/api-client/src/camera/getCameraImageSettings.ts new file mode 100644 index 00000000000..df6adec896d --- /dev/null +++ b/api-client/src/camera/getCameraImageSettings.ts @@ -0,0 +1,18 @@ +import { GET, request } from '../request' + +import type { CameraId } from '@opentrons/shared-data' +import type { CameraImageSettingsResponse } from '../camera' +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' + +export function getCameraImageSettings( + config: HostConfig, + cameraId: CameraId +): ResponsePromise { + return request( + GET, + `/camera/cameraSettings/${cameraId}`, + null, + config + ) +} diff --git a/api-client/src/camera/index.ts b/api-client/src/camera/index.ts index 7e01be3d542..ed409db57b6 100644 --- a/api-client/src/camera/index.ts +++ b/api-client/src/camera/index.ts @@ -1,4 +1,6 @@ export * from './getCamera' export * from './createCamera' +export * from './getCameraImageSettings' +export * from './createCameraImageSettings' export * from './types' diff --git a/api-client/src/camera/types.ts b/api-client/src/camera/types.ts index f046fbd73c4..1096a4f83a3 100644 --- a/api-client/src/camera/types.ts +++ b/api-client/src/camera/types.ts @@ -4,8 +4,18 @@ export interface CameraData { errorRecoveryCameraEnabled: boolean } +export interface CameraImageSettings { + resolution?: [number, number] + zoom?: number + contrast?: number + brightness?: number + saturation?: number + pan?: [number, number] +} + export interface CreateCameraData { data: CameraData } export type CameraResponse = CameraData +export type CameraImageSettingsResponse = CameraImageSettings diff --git a/api-client/src/runs/addCameraImageSettingsToRun.ts b/api-client/src/runs/addCameraImageSettingsToRun.ts new file mode 100644 index 00000000000..7384d887615 --- /dev/null +++ b/api-client/src/runs/addCameraImageSettingsToRun.ts @@ -0,0 +1,21 @@ +import { POST, request } from '../request' + +import type { + CameraImageSettings, + CameraImageSettingsResponse, +} from '../camera' +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' + +export function addCameraImageSettingsToRun( + config: HostConfig, + runId: string, + data: CameraImageSettings +): ResponsePromise { + return request( + POST, + `/runs/${runId}/camera/cameraSettings`, + { data }, + config + ) +} diff --git a/api-client/src/runs/index.ts b/api-client/src/runs/index.ts index 2ff9dab8e70..9d8a342e952 100644 --- a/api-client/src/runs/index.ts +++ b/api-client/src/runs/index.ts @@ -12,6 +12,7 @@ export { createRunAction } from './createRunAction' export { getRunCommandErrors } from './commands/getRunCommandErrors' export { getRunCurrentState } from './getRunCurrentState' export { addCameraSettingsToRun } from './addCameraSettingsToRun' +export { addCameraImageSettingsToRun } from './addCameraImageSettingsToRun' export * from './addLabwareOffsetToRun' export * from './createLabwareDefinition' export * from './constants' diff --git a/api/docs/v2/basic_commands/liquids.rst b/api/docs/v2/basic_commands/liquids.rst index 5df26cec8f2..42c7fb3c97a 100644 --- a/api/docs/v2/basic_commands/liquids.rst +++ b/api/docs/v2/basic_commands/liquids.rst @@ -34,15 +34,21 @@ Now our pipette holds 300 µL. Aspirate by Well or Location ---------------------------- -The :py:meth:`~.InstrumentContext.aspirate` method includes a ``location`` parameter that accepts either a :py:class:`.Well` or a :py:class:`~.types.Location`. +The :py:meth:`~.InstrumentContext.aspirate` method includes the location parameters ``location`` and ``end_location``. Each accepts different location types: -If you specify a well, like ``plate["A1"]``, the pipette will aspirate from a default position 1 mm above the bottom center of that well. To change the default clearance, first set the ``aspirate`` attribute of :py:obj:`.well_bottom_clearance`:: +- ``location`` accepts either a :py:class:`.Well` or a :py:class:`~.types.Location`. +- ``end_location`` can only be used in combination with the ``location`` parameter. Both must be a :py:class:`~.types.Location`. + +.. versionchanged:: 2.27 + Use the ``end_location`` parameter to specify multiple locations during an aspirate. + +If you specify a single ``location`` like the well ``"A1"``, the pipette will aspirate from a default position 1 mm above the bottom center of that well. To change the default clearance, first set the ``aspirate`` attribute of :py:obj:`.well_bottom_clearance`:: pipette.pick_up_tip pipette.well_bottom_clearance.aspirate = 2 # tip is 2 mm above well bottom pipette.aspirate(200, plate["A1"]) -You can also aspirate from a location along the center vertical axis within a well using the :py:meth:`.Well.top` and :py:meth:`.Well.bottom` methods. These methods move the pipette to a specified distance relative to the top or bottom center of a well:: +You can also aspirate from a :py:class:`~.types.Location` along the center vertical axis within a well using the :py:meth:`.Well.top` and :py:meth:`.Well.bottom` methods. These methods move the pipette to a specified distance relative to the top or bottom center of a well:: pipette.pick_up_tip() depth = plate["A1"].bottom(z=2) # tip is 2 mm above well bottom @@ -52,7 +58,7 @@ You can also aspirate from a location along the center vertical axis within a we Use the :py:meth:`.Well.meniscus` method to aspirate relative to the meniscus of liquid in a well with a Flex pipette. First, you'll need to determine the amount of liquid in your well one of two ways: - Specify your starting liquid volume with :py:meth:`~.Labware.load_liquid`. -- Measure the height of the liquid with :py:meth:`~.InstrumentContext.measure_liquid_height`. +- Measure the height of the liquid with :py:meth:`~.InstrumentContext.measure_liquid_height`. This example measures the liquid height in well A2 of a plate and then immediately aspirates below the meniscus:: @@ -64,17 +70,39 @@ This example measures the liquid height in well A2 of a plate and then immediate ) # aspirates at 1 mm below the liquid meniscus -The liquid meniscus changes when you aspirate liquid from a well. Set ``target="end"`` to ensure the pipette stays submerged while aspirating. For more information, see :ref:`well-meniscus`. +.. versionadded:: 2.23 + Set ``target="start"`` or ``"end"`` to target the liquid meniscus during an aspirate. -``measure_liquid_height()`` works best with a new pipette tip each time. To save time and tips throughout your protocol, use ``Labware.load_liquid`` instead to specify starting liquid volumes. +To ensure the pipette stays submerged while aspirating, set ``target="end"`` for the aspirate or use multiple location parameters. For more, see :ref:`well-meniscus`. -.. versionadded:: 2.23 +!!! note:: + ``measure_liquid_height()`` works best with a new pipette tip each time. To save time and tips throughout your protocol, use ``Labware.load_liquid`` instead to specify starting liquid volumes. + +Use the ``location`` and ``end_location`` parameters in combination to direct the pipette to move to specific locations while aspirating:: + + pipette.pick_up_tip() + well_top = plate["A1"].top(z=-1) + depth = plate["A1"].bottom(z=2) + pipette.aspirate( + volume=200, + location=well_top, + end_location=depth + ) + +Here, the pipette begins aspirating at 1 mm below the well top, and finishes aspirating at 2 mm above the well bottom. + +.. note:: + When you use both the ``location`` and ``end_location`` parameters, you can optionally specify a ``movement_delay`` to ensure the pipette waits a set amount of time in seconds before moving to the ``end_location``. This can be useful when pipetting viscous liquids. An additional 1 second ``movement_delay`` can help build up pressure in the tip before liquid starts to flow. + +.. versionchanged:: 2.27 + Use the ``end_location`` and ``movement_delay`` parameters when specifying multiple locations in a single aspirate. See also: - :ref:`new-default-op-positions` for information about controlling pipette height for a particular pipette. - :ref:`position-relative-labware` for information about controlling pipette height from within a well. - :ref:`move-to` for information about moving a pipette to any reachable deck location. +- :ref:`well-meniscus` for information about pipetting relative to the liquid meniscus as it changes during an aspirate. Aspiration Flow Rates --------------------- @@ -126,9 +154,15 @@ If the pipette doesn’t move, you can specify an additional dispense action wit Dispense by Well or Location ---------------------------- -The :py:meth:`~.InstrumentContext.dispense` method includes a ``location`` parameter that accepts either a :py:class:`.Well` or a :py:class:`~.types.Location`. +The :py:meth:`~.InstrumentContext.dispense` method includes the ``location`` parameters ``location`` and ``end_location``. Each accepts different location types: + +- ``location`` accepts either a :py:class:`.Well` or a :py:class:`~.types.Location`. +- ``end_location`` can only be used in combination with the ``location`` parameter. Both must be a :py:class:`~.types.Location`. -If you specify a well, like ``plate["B1"]``, the pipette will dispense from a default position 1 mm above the bottom center of that well. To change the default clearance, you would call :py:obj:`.well_bottom_clearance`:: +.. versionchanged:: 2.27 + Use the ``end_location`` parameter to specify multiple locations during a dispense. + +If you specify a single ``location`` like the well ``"B1"``, the pipette will dispense from a default position 1 mm above the bottom center of that well. To change the default clearance, you would call :py:obj:`.well_bottom_clearance`:: pipette.well_bottom_clearance.dispense=2 # tip is 2 mm above well bottom pipette.dispense(200, plate["B1"]) @@ -153,17 +187,37 @@ This example measures the liquid height in well B1 of a plate and then immediate ) # dispenses at 1 mm below the liquid meniscus -The liquid meniscus changes when you dispense liquid into a well. Set ``target="start"`` to ensure the pipette begins the dispense at the liquid meniscus. For more information, see :ref:`well-meniscus`. +.. versionadded:: 2.23 + Set ``target="start"`` or ``"end"`` to target the liquid meniscus during a dispense. -``measure_liquid_height()`` works best with a new pipette tip each time. To save time and tips throughout your protocol, use ``Labware.load_liquid`` instead to specify starting liquid volumes. +To ensure the pipette begins the dispense at the liquid meniscus, set ``target="start"``. See :ref:`well-meniscus` for more details on pipetting relative to the liquid meniscus. -.. versionadded:: 2.23 +!!! note:: + ``measure_liquid_height()`` works best with a new pipette tip each time. To save time and tips throughout your protocol, use ``Labware.load_liquid`` instead to specify starting liquid volumes. + + +You can use the ``location`` and ``end_location`` parameters in combination to direct the pipette to move to specific locations while dispensing:: + + well_top = plate["B1"].top(z=-1) + depth = plate["B1"].bottom(z=2) + pipette.dispense( + volume=200, + location=depth, + end_location=well_top, + movement_delay=1 + ) + +Here, the pipette begins dispensing at 2 mm above the well bottom, and finishes dispensing at 1 mm below the well top. When you use both the ``location`` and ``end_location`` parameters, you can optionally specify a ``movement_delay`` to ensure the pipette waits a set amount of time, like 1 second, before moving to the ``end_location``. + +.. versionchanged:: 2.27 + Use the ``end_location`` and ``movement_delay`` parameters when specifying multiple locations in a single dispense. See also: - :ref:`new-default-op-positions` for information about controlling pipette height for a particular pipette. - :ref:`position-relative-labware` for formation about controlling pipette height from within a well. - :ref:`move-to` for information about moving a pipette to any reachable deck location. +- :ref:`well-meniscus` for information about pipetting relative to the liquid meniscus as it changes during a dispense. Dispense Flow Rates ------------------- @@ -184,8 +238,6 @@ You can also specify an absolute ``flow_rate`` to set the flow rate in µL/secon The ``rate`` and ``flow_rate`` parameters are mutually exclusive. If you specify both in the same command, the API will raise an error. - - .. _push-out-dispense: Push Out After Dispense @@ -355,47 +407,31 @@ And this example adds a push out of 10 µL after the final dispense in the mix:: .. versionchanged:: 2.24 Adds the ``aspirate_flow_rate``, ``dispense_flow_rate``, ``aspirate_delay``, ``dispense_delay``, and ``final_push_out`` parameters. -.. _dynamic_mix: +.. _dynamic-mix: Dynamic Mix =========== -The :py:meth:`~.InstrumentContext.dynamic_mix` method aspirates and dispenses repeatedly in a multiple locations. It's designed to mix the contents of a well together using a single command rather than using multiple ``aspirate()`` and ``dispense()`` calls. This method includes arguments that let you specify the number of times to mix, the volume (in µL) of liquid, and the well that contains the liquid you want to mix. +The :py:meth:`~.InstrumentContext.dynamic_mix` method lets you aspirate and dispense repeatedly in multiple locations. Like the :py:meth:`~.InstrumentContext.mix` method, it's designed to mix the contents of a well together using a single command rather than using multiple ``aspirate()`` and ``dispense()`` calls. Both methods includes argument that let you specify the number of times to mix, the volume (in µL) of liquid, and the well that contains the liquid you want to mix. :py:meth:`~.InstrumentContext.dynamic_mix` lets you additionally specify multiple aspirate and dispense locations:: -This example draws 100 µL from the current well and mixes it three times:: - - pipette.mix(repetitions=3, volume=100) - -This example draws 100 µL from well B1 and mixes it three times:: - - pipette.mix(3, 100, plate["B1"]) - -This example draws an amount equal to the pipette's maximum rated volume and mixes it three times:: - - pipette.mix(repetitions=3) - -Like an ``aspirate()`` or ``dispense()``, you can use optional arguments to specify the flow rate, a delay, or a push out after an aspirate or dispense in the mix. - -This example draws 100 µL from the current well and mixes it three times, aspirating at 50 µL/sec and with a 5 second delay after each aspirate:: - - pipette.mix( + depth = plate["A1"].bottom(z=2) + well_top = plate["A1"].top(z=-1) + pipette.dynamic_mix( + aspirate_start_location=depth, + aspirate_end_location=well_top, + dispense_start_location=well_top, + dispense_end_location=depth, repetitions=3, - volume=100, - aspirate_flow_rate=50, - aspirate_delay=5 + volume=100 ) -And this example adds a push out of 10 µL after the final dispense in the mix:: - - pipette.mix(repetitions=3, volume=100, final_push_out=10) - -.. note:: +Like the :py:meth:`~.InstrumentContext.mix` method, you can use other optional arguments to customize your dynamic mix: - In API versions 2.2 and earlier, during a mix, the pipette moves up and out of the target well. In API versions 2.3 and later, the pipette does not move while mixing. +- specify the aspirate, dispense, or mix flow rate. +- add a delay after an aspirate or dispense, or a ``movement_delay`` before moving to an ``end_location``. +- include a push out after an aspirate or dispense in the mix. -.. versionadded:: 2.0 -.. versionchanged:: 2.24 - Adds the ``aspirate_flow_rate``, ``dispense_flow_rate``, ``aspirate_delay``, ``dispense_delay``, and ``final_push_out`` parameters. +.. versionadded:: 2.27 .. _air-gap: diff --git a/api/docs/v2/basic_commands/utilities.rst b/api/docs/v2/basic_commands/utilities.rst index b1593785d31..3da3f013ceb 100644 --- a/api/docs/v2/basic_commands/utilities.rst +++ b/api/docs/v2/basic_commands/utilities.rst @@ -6,7 +6,7 @@ Utility Commands **************** -With utility commands, you can control various robot functions such as pausing or delaying a protocol, checking the robot's door, turning robot lights on/off, and more. The following sections show you how to these utility commands and include sample code. The examples used here assume that you’ve loaded the pipettes and labware from the basic :ref:`protocol template `. +With utility commands, you can control various robot functions such as pausing or delaying a protocol, taking images with the built-in camera, turning robot lights on/off, and more. The following sections show you how to use these utility commands and include sample code. The examples used here assume that you’ve loaded the pipettes and labware from the basic :ref:`protocol template `. Delay and Resume ================ @@ -66,6 +66,24 @@ Call the :py:meth:`.ProtocolContext.comment` method if you want to write and dis .. versionadded:: 2.0 +Capturing Images +===================== + +Use the :py:meth:`.ProtocolContext.capture_image` method to take an image during a protocol with the Flex or OT-2's built-in camera. You can use images to check on key protocol steps while spending more time away from the bench. + +This example uses optional parameters to home the pipette, clearing the camera's view, and give a custom name to the image file:: + + protocol.capture_image( + home_before=True, + filename="deck_view" + ) + +.. versionadded:: 2.27 + +Image filenames include your robot and protocol name, step number, and timestamps for the protocol and command running when the image was taken. Here, the custom filename ``deck_view`` is added to the beginning of the filename, making it easier to find the exact image you're looking for. + +You can further customize your images using the :py:meth:`~.ProtocolContext.capture_image` method's optional parameters, including image resolution, zoom, brightness, and more. After a protocol run, access and download your images from the Recent Protocol Runs section of the Opentrons App's robot details page. + Control and Monitor Robot Rail Lights ===================================== diff --git a/api/docs/v2/conf.py b/api/docs/v2/conf.py index bdc620fc3ad..d466e352686 100644 --- a/api/docs/v2/conf.py +++ b/api/docs/v2/conf.py @@ -99,7 +99,7 @@ # use rst_prolog to hold the subsitution # update the apiLevel value whenever a new minor version is released rst_prolog = f""" -.. |apiLevel| replace:: 2.25 +.. |apiLevel| replace:: 2.27 .. |release| replace:: {release} """ diff --git a/api/docs/v2/modules/concurrent_module.rst b/api/docs/v2/modules/concurrent_module.rst new file mode 100644 index 00000000000..d333068c848 --- /dev/null +++ b/api/docs/v2/modules/concurrent_module.rst @@ -0,0 +1,106 @@ +:og:description: How to use the concurrent module actions in a Python protocol. + +.. _concurrent-module: + +************************** +Concurrent Module Actions +************************** + +You can use multiple modules simultaneously to speed up protocol runtime and reduce your hands-on time at the bench. Beginning with API version 2.27, add concurrent module actions in Flex and OT-2 protocols: + +- Execute protocol steps in parallel with module actions, like pipetting while running a Thermocycler profile or cooling samples on the Temperature Module. +- Run multiple Heater-Shaker or Temperature Modules together, or in parallel with a Thermocycler Module. + +This section covers module tasks and explains how to run multiple module actions in the same protocol, including timing tasks to work together. + +.. note:: + In API version 2.27, lids and labware latch moves are still blocking actions. These moves happen quickly, and you'll be able to proceed with other steps of your protocol immediately after. + +Module tasks +------------- + +When you use a Heater-Shaker, Temperature, or Thermocycler Module in your protocol, you can choose to use a concurrent command for the module actions shown below. Each command returns a :py:class:`.Task` that runs in the background of your protocol and allows the robot to continue performing protocol steps, regardless of when the module reaches the target temperature or completes another action. + +.. list-table:: + :header-rows: 1 + + * - **Module** + - **Concurrent commands** + * - Heater-Shaker Module + - + - :py:meth:`~.HeaterShakerContext.set_target_temperature` + - :py:meth:`~.HeaterShakerContext.set_shake_speed` + * - Temperature Module + - + - :py:meth:`~.TemperatureModuleContext.start_set_temperature` + * - Thermocycler Module + - + - :py:meth:`~.ThermocyclerContext.start_set_lid_temperature` + - :py:meth:`~.ThermocyclerContext.start_set_block_temperature` + - :py:meth:`~.ThermocyclerContext.start_execute_profile` + +Your protocol can include multiple module tasks that run parallel to one another. The example below has the API create two tasks: one for a Temperature Module, holding samples at 4 °C, and another for a Thermocycler Module running a profile. Neither task affects the other, and neither module action will prevent the robot from continuing to the next protocol steps. + +.. code-block:: python + + temp_mod.start_set_temperature(celsius=4) + + profile = [ + {"temperature":95, "hold_time_seconds":30}, + {"temperature":57, "hold_time_seconds":30}, + {"temperature":72, "hold_time_seconds":60} + ] + tc_mod.start_execute_profile( + steps=profile, + repetitions=20, + block_max_volume=32 + ) + + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + +Although tasks are created when you use concurrent commands like :py:meth:`~.TemperatureModuleContext.start_set_temperature` or :py:meth:`~.ThermocyclerContext.start_execute_profile`, there's no need to wait for either task to finish. Here, the robot can continue pipetting while the Temperature Module cools and the Thermocycler profile runs. + +Timing module tasks +-------------------- + +Sometimes, the amount of time it takes for a module to finish a task is still important to your protocol. You might need to wait for samples on the Temperature Module to reach a target temperature before moving to the next step. The example below combines a concurrent module command with :py:meth:`.ProtocolContext.wait_for_tasks` to prevent the Flex Gripper from moving a plate until the target temperature is reached:: + + temp_adapter = temp_mod.load_adapter("opentrons_96_well_aluminum_block") + temp_plate = temp_adapter.load_labware("nest_96_wellplate_100ul_pcr_full_skirt") + heat_task = temp_mod.start_set_temperature(75) + protocol.wait_for_tasks([heat_task]) + protocol.move_labware(labware=temp_plate, new_location="D3", use_gripper=True) + + +Let's say your samples have to both reach a target temperature and incubate for a specific amount of time. The example below uses concurrent commands to heat and shake samples, and :py:meth:`.ProtocolContext.create_timer` to set an incubation time. + +.. code-block:: python + + # set Heater-Shaker temperature and shake speed + heat_task = hs_mod.start_set_temperature(75) + hs_mod.set_shake_speed(300) + + # wait for module to finish heating + protocol.wait_for_tasks([heat_task]) + + # create timer for sample incubation + hs_timer = create_timer(seconds=300) + + # hold samples at target temperature + protocol.wait_for_tasks([hs_timer]) + hs_mod.deactivate_heater() + +Here, the Heater-Shaker Module will heat and shake samples at 75 °C and 300 RPM, and a timer pauses the protocol for a 5 minute incubation. Because the Heater-Shaker could take longer than 5 minutes to reach the target temperature, ``wait_for_tasks`` ensures the timer starts only after the target temperature is reached. + +.. note:: + + Using the :py:meth:`~.ProtocolContext.wait_for_tasks` method to wait for multiple of the same task on the same module will cause the API to raise an error. For example, if you need to heat a Temperature Module to two separate target temperatures, use :py:meth:`~.ProtocolContext.wait_for_tasks` twice:: + + heat_task_1 = temp_mod.start_set_temperature(55) + protocol.wait_for_tasks([heat_task_1]) + + heat_task_2 = temp_mod.start_set_temperature(75) + protocol.wait_for_tasks([heat_task_2]) \ No newline at end of file diff --git a/api/docs/v2/modules/heater_shaker.rst b/api/docs/v2/modules/heater_shaker.rst index 2bcd047811c..fd446c3e639 100644 --- a/api/docs/v2/modules/heater_shaker.rst +++ b/api/docs/v2/modules/heater_shaker.rst @@ -151,68 +151,141 @@ Custom flat-bottom labware can be used with the Universal Flat Adapter. See the Heating and Shaking =================== -The API treats heating and shaking as separate, independent activities due to the amount of time they take. +The API treats heating and shaking as separate, independent activities due to the amount of time they take. Increasing or reducing shaking speed takes a few seconds, while heating or letting the module passively cool takes more time. -Increasing or reducing shaking speed takes a few seconds, so the API treats these actions as *blocking* commands. All other commands cannot run until the module reaches the required speed. +In both cases, the API lets you choose whether to perform other protocol steps while heating and shaking. To do this, you can design your protocol to run in a blocking or concurrent manner: -Heating the module, or letting it passively cool, takes more time than changing the shaking speed. As a result, the API gives you the flexibility to perform other pipetting actions while waiting for the module to reach a target temperature. When holding at temperature, you can design your protocol to run in a blocking or non-blocking manner. +- **Blocking commands**: The robot will pause and wait, performing no other actions until the module reaches the required temperature or shake speed. +- **Concurrent commands**: The robot continues to perform subsequent actions while heating or shaking. For example, continue pipetting, move labware, or run other modules. -.. note:: +.. list-table:: + :header-rows: 1 - Since API version 2.13, only the Heater-Shaker Module supports non-blocking command execution. All other modules' methods are blocking commands. + * - **Action** + - **Method** + - **Type** + * - Heating + - :py:meth:`~.HeaterShakerContext.set_and_wait_for_temperature` + - Blocking + * - Heating + - :py:meth:`~.HeaterShakerContext.wait_for_temperature` + - Blocking + * - Heating + - :py:meth:`~.HeaterShakerContext.set_target_temperature` + - Concurrent + * - Shaking + - :py:meth:`~.HeaterShakerContext.set_and_wait_for_shake_speed` + - Blocking + * - Shaking + - :py:meth:`~.HeaterShakerContext.set_shake_speed` + - Concurrent -Blocking commands ------------------ +The sections below cover heating and shaking samples using the Heater-Shaker Module's blocking and concurrent commands. -This example uses a blocking command and shakes a sample for one minute. No other commands will execute until a minute has elapsed. The three commands in this example start the shake, wait for one minute, and then stop the shake:: +Heating +-------- - hs_mod.set_and_wait_for_shake_speed(500) - protocol.delay(minutes=1) - hs_mod.deactivate_shaker() +Heating the Heater-Shaker Module can take a much longer time than reaching a shake speed, depending on the thermal block used, the volume and type of liquid contained in the labware, and the initial temperature of the module. -These actions will take about 65 seconds total. Compare this with similar-looking commands for holding a sample at a temperature for one minute: +The examples below use a blocking or concurrent command to set the Heater-Shaker Module to 75 °C. -.. code-block:: python +.. tabs:: - hs_mod.set_and_wait_for_temperature(75) - protocol.delay(minutes=1) - hs_mod.deactivate_heater() + .. tab:: Blocking -This may take much longer, depending on the thermal block used, the volume and type of liquid contained in the labware, and the initial temperature of the module. + .. code-block:: python + + hs_mod.set_and_wait_for_temperature(75) + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + protocol.delay(minutes=1) -Non-blocking commands ---------------------- + When you use a blocking command like :py:meth:`~.HeaterShakerContext.set_and_wait_for_temperature`, your protocol proceeds completely linearly. No other commands will execute until the Heater-Shaker Module reaches the target temperature. In this example, the robot will perform the pipetting steps and protocol delay only after the module reaches 75 °C. -To pipette while the Heater-Shaker is heating, use :py:meth:`~.HeaterShakerContext.set_target_temperature` and :py:meth:`~.HeaterShakerContext.wait_for_temperature` instead of :py:meth:`~.HeaterShakerContext.set_and_wait_for_temperature`: + .. versionadded:: 2.13 -.. code-block:: python + .. tab:: Concurrent - hs_mod.set_target_temperature(75) - pipette.pick_up_tip() - pipette.aspirate(50, plate["A1"]) - pipette.dispense(50, plate["B1"]) - pipette.drop_tip() - hs_mod.wait_for_temperature() - protocol.delay(minutes=1) - hs_mod.deactivate_heater() + .. code-block:: python + + hs_mod.set_target_temperature(75) + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + protocol.delay(minutes=1) + + To perform other actions while the module reaches its target temperature, use the concurrent :py:meth:`~.HeaterShakerContext.set_target_temperature` command. Here, the robot will continue to perform pipetting steps and a protocol delay while the Heater-Shaker heats to 75 °C. -This example would likely take just as long as the blocking version above; it’s unlikely that one aspirate and one dispense action would take longer than the time for the module to heat. However, be careful when putting a lot of commands between a ``set_target_temperature()`` call and a ``delay()`` call. In this situation, you’re relying on ``wait_for_temperature()`` to resume execution of commands once heating is complete. But if the temperature has already been reached, the delay will begin later than expected and the Heater-Shaker will hold at its target temperature longer than intended. + .. versionadded:: 2.13 + .. versionchanged:: 2.27 + Returns a :py:class:`.Task` that runs in the background of a protocol. -Additionally, if you want to pipette while the module holds a temperature for a certain length of time, you need to track the holding time yourself. One of the simplest ways to do this is with Python’s ``time`` module. First, add ``import time`` at the start of your protocol. Then, use :py:func:`time.monotonic` to set a reference time when the target is reached. Finally, add a delay that calculates how much holding time is remaining after the pipetting actions: -.. code-block:: python +If you want the robot to continue pipetting while the module heats to prepare for a sample incubation, you can use :py:meth:`.ProtocolContext.create_timer`:: + + # set target temperature + heat_task = hs_mod.set_target_temperature(75) + + # pipette while the module heats + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + + # wait for the module to finish heating + protocol.wait_for_tasks([heat_task]) + + # set and hold for a sample incubation + incubation_timer = create_timer(seconds=120) + protocol.wait_for_tasks([incubation_timer]) + hs_mod.deactivate_heater() + +.. versionadded: 2.27 + +Here, the robot will perform protocol steps placed after the concurrent :py:meth:`~.HeaterShakerContext.set_target_temperature` command. Once the protocol reaches the :py:meth:`.ProtocolContext.wait_for_tasks` command, the robot pauses for two minutes while the module holds at 75 °C. - hs_mod.set_and_wait_for_temperature(75) - start_time = time.monotonic() # set reference time - pipette.pick_up_tip() - pipette.aspirate(50, plate["A1"]) - pipette.dispense(50, plate["B1"]) - pipette.drop_tip() - # delay for the difference between now and 60 seconds after the reference time - protocol.delay(max(0, start_time+60 - time.monotonic())) - hs_mod.deactivate_heater() +Shaking +-------- + +The examples below use a blocking or concurrent command to set the Heater-Shaker Module to a shake speed of 500 RPM. + +.. tabs:: + + .. tab:: Blocking + + .. code-block:: python + + hs_mod.set_and_wait_for_shake_speed(500) + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + protocol.delay(minutes=1) + + In this example, no other commands will execute until the Heater-Shaker reaches a shake speed of 500 RPM with the blocking command :py:meth:`~.HeaterShakerContext.set_and_wait_for_shake_speed`. Because reaching the shake speed takes much less time than heating the module, these actions will take only about 85 seconds total. + + .. versionadded:: 2.13 + + .. tab:: Concurrent + + .. code-block:: python + + hs_mod.set_shake_speed(500) + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + protocol.delay(minutes=1) + + When you use a concurrent command like :py:meth:`~.HeaterShakerContext.set_shake_speed`, the robot will continue to perform pipetting steps and a protocol delay while the Heater-Shaker Module reaches the target shake speed. + + .. versionadded:: 2.27 + + +You can also use concurrent commands to heat and shake simultaneously. The amount of time it takes for the Heater-Shaker Module to reach either the target temperature or shake speed won't affect other steps in your protocol. The concurrent ``set_target_temperature()`` and ``set_shake_speed()`` methods also allow some other simultaneous module actions. For more, see the :ref:`concurrent-module` section. -Provided that the parallel pipetting actions don’t take more than one minute, this code will deactivate the heater one minute after its target was reached. If more than one minute has elapsed, the value passed to ``protocol.delay()`` will equal 0, and the protocol will continue immediately. Deactivating ============ @@ -221,5 +294,4 @@ Deactivating the heater and shaker are done separately using the :py:meth:`~.Hea .. note:: - The robot will not automatically deactivate the Heater-Shaker at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Heater-Shaker module controls on the device detail page in the Opentrons App or run these methods in Jupyter notebook. - + The robot will not automatically deactivate the Heater-Shaker at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Heater-Shaker module controls on the device detail page in the Opentrons App. \ No newline at end of file diff --git a/api/docs/v2/modules/multiple_same_type.rst b/api/docs/v2/modules/multiple_same_type.rst index 386f8d7f281..fe7e9468784 100644 --- a/api/docs/v2/modules/multiple_same_type.rst +++ b/api/docs/v2/modules/multiple_same_type.rst @@ -2,9 +2,9 @@ .. _moam: -********************************* -Multiple Modules of the Same Type -********************************* +****************************************** +Loading Multiple Modules of the Same Type +****************************************** You can use multiple modules of the same type within a single protocol. The exception is the Thermocycler Module, which has only one supported deck location because of its size. Running protocols with multiple modules of the same type requires version 4.3 or newer of the Opentrons App and robot server. diff --git a/api/docs/v2/modules/temperature_module.rst b/api/docs/v2/modules/temperature_module.rst index 0d13652658e..5cf838aa6a3 100644 --- a/api/docs/v2/modules/temperature_module.rst +++ b/api/docs/v2/modules/temperature_module.rst @@ -8,7 +8,7 @@ Temperature Module The Temperature Module acts as both a cooling and heating device. It can control the temperature of its deck between 4 °C and 95 °C with a resolution of 1 °C. -The Temperature Module is represented in code by a :py:class:`.TemperatureModuleContext` object, which has methods for setting target temperatures and reading the module's status. This example demonstrates loading a Temperature Module GEN2 and loading a well plate on top of it. +The Temperature Module is represented in code by a :py:class:`.TemperatureModuleContext` object, which has methods for setting target temperatures and reading the module's status. This example demonstrates loading a Temperature Module GEN2 on the deck. .. code-block:: python @@ -117,19 +117,47 @@ This command loads the same physical adapter and labware as the example in the S Temperature Control =================== -The primary function of the module is to control the temperature of its deck, using :py:meth:`~.TemperatureModuleContext.set_temperature`, which takes one parameter: ``celsius``. For example, to set the Temperature Module to 4 °C: +The primary function of the module is to control the temperature of its deck. As of API version 2.27, it provides both blocking and concurrent temperature commands to control how your protocol proceeds. -.. code-block:: python +Both examples below set the target temperature to 4 °C. Each takes one parameter: ``celsius``. + +.. tabs:: + + .. tab:: Blocking + + .. code-block:: python + + temp_mod.set_temperature(celsius=4) + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + + When you use the blocking :py:meth:`~.TemperatureModuleContext.start_set_temperature` command, the robot won't begin performing other protocol steps until the Temperature Module reaches the target temperature. You can pipette to and from the module only when it is holding at a temperature or idle. + + .. versionadded:: 2.0 - temp_mod.set_temperature(celsius=4) + .. tab:: Concurrent -When using ``set_temperature()``, your protocol will wait until the target temperature is reached before proceeding to further commands. In other words, you can pipette to or from the Temperature Module when it is holding at a temperature or idle, but not while it is actively changing temperature. Whenever the module reaches its target temperature, it will hold the temperature until you set a different target or call :py:meth:`~.TemperatureModuleContext.deactivate`, which will stop heating or cooling and will turn off the fan. + .. code-block:: python + + temp_mod.start_set_temperature(celsius=4) + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + + Beginning with API version 2.27, you can use the concurrent :py:meth:`~.TemperatureModuleContext.start_set_temperature` method to move on to further commands while the Temperature Module reaches its target temperature. The method also allows some other simultaneous module actions. For more, see the :ref:`concurrent-module` section. + + .. versionadded:: 2.27 + +Whenever the module reaches its target temperature, it will hold the temperature until you set a different target or call :py:meth:`~.TemperatureModuleContext.deactivate`, which will stop heating or cooling and will turn off the fan. .. note:: - Your robot will not automatically deactivate the Temperature Module at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Temperature Module controls on the device detail page in the Opentrons App or run ``deactivate()`` in Jupyter notebook. + Your robot will not automatically deactivate the Temperature Module at the end of a protocol. If you need to deactivate the module after a protocol is completed or canceled, use the Temperature Module controls on the device detail page in the Opentrons App. + -.. versionadded:: 2.0 Temperature Status ================== diff --git a/api/docs/v2/modules/thermocycler.rst b/api/docs/v2/modules/thermocycler.rst index 2bf0543d1b3..af232e4d629 100644 --- a/api/docs/v2/modules/thermocycler.rst +++ b/api/docs/v2/modules/thermocycler.rst @@ -10,7 +10,37 @@ The Thermocycler Module provides on-deck, fully automated thermocycling, and can The Thermocycler is represented in code by a :py:class:`.ThermocyclerContext` object, which has methods for controlling the lid, controlling the block, and setting *profiles* — timed heating and cooling routines that can be repeated automatically. -The examples in this section will use a Thermocycler Module GEN2 loaded as follows: +For each module action, the API lets you choose whether to perform other protocol steps while controlling the Thermocycler Module. To do this, you can design your protocol to run in a blocking or concurrent manner: + +- **Blocking commands**: The robot will pause and wait, performing no other actions until the module executes a profile or reaches the target lid or block temperature. +- **Concurrent commands**: The robot continues to perform other pipetting and some other module actions while controlling the Thermocycler Module. + +.. list-table:: + :header-rows: 1 + + * - **Action** + - **Method** + - **Type** + * - Lid Control + - :py:meth:`~.ThermocyclerContext.set_lid_temperature` + - Blocking + * - Lid Control + - :py:meth:`~.ThermocyclerContext.start_set_lid_temperature` + - Concurrent + * - Block Control + - :py:meth:`~.ThermocyclerContext.set_block_temperature` + - Blocking + * - Block Control + - :py:meth:`~.ThermocyclerContext.start_set_block_temperature` + - Concurrent + * - Profiles + - :py:meth:`~.ThermocyclerContext.execute_profile` + - Blocking + * - Profiles + - :py:meth:`~.ThermocyclerContext.start_execute_profile` + - Concurrent + +This section covers using the Thermocycler Module, including its blocking and concurrent commands. The examples in this section will use a Thermocycler Module GEN2 loaded as follows: .. code-block:: python @@ -24,23 +54,38 @@ Lid Control The Thermocycler can control the position and temperature of its lid. -To change the lid position, use :py:meth:`~.ThermocyclerContext.open_lid` and :py:meth:`~.ThermocyclerContext.close_lid`. When the lid is open, the pipettes can access the loaded labware. +To change the lid position, use :py:meth:`~.ThermocyclerContext.open_lid` and :py:meth:`~.ThermocyclerContext.close_lid`. Changes in lid position are blocking. While the Thermocycler Module's lid is opening or closing, the robot won't perform other steps in your protocol. Once the lid is open, pipettes are able to access the loaded labware. -You can also control the temperature of the lid. Acceptable target temperatures are between 37 and 110 °C. Use :py:meth:`~.ThermocyclerContext.set_lid_temperature`, which takes one parameter: the target ``temperature`` (in degrees Celsius) as an integer. For example, to set the lid to 50 °C: +You can also control the temperature of the lid. Acceptable target temperatures are between 37 and 110 °C. To set the lid temperature, choose between a blocking and concurrent command. Each takes one parameter: the target ``temperature`` (in degrees Celsius) as an integer. -.. code-block:: python +.. tabs:: + + .. tab:: Blocking + + .. code-block:: python + + tc_mod.set_lid_temperature(temperature=50) + + When you use a blocking method like :py:meth:`~.ThermocyclerContext.set_lid_temperature`, your protocol will only proceed once the lid temperature reaches 50 °C. This is the case whether the previous temperature was lower than 50 °C (in which case the lid will actively heat) or higher than 50 °C (in which case the lid will passively cool). - tc_mod.set_lid_temperature(temperature=50) + .. versionadded:: 2.0 -The protocol will only proceed once the lid temperature reaches 50 °C. This is the case whether the previous temperature was lower than 50 °C (in which case the lid will actively heat) or higher than 50 °C (in which case the lid will passively cool). + .. tab:: Concurrent + + .. code-block:: python + + tc_mod.start_set_lid_temperature(temperature=50) + + Use the concurrent :py:meth:`~.ThermocyclerContext.start_set_lid_temperature` method to allow your protocol to proceed while the lid heats. + + .. versionadded:: 2.27 You can turn off the lid heater at any time with :py:meth:`~.ThermocyclerContext.deactivate_lid`. .. note:: Lid temperature is not affected by Thermocycler profiles. Therefore you should set an appropriate lid temperature to hold during your profile *before* executing it. See :ref:`thermocycler-profiles` for more information on defining and executing profiles. - -.. versionadded:: 2.0 + Block Control ============= @@ -50,47 +95,118 @@ The Thermocycler can control its block temperature, including holding at a tempe Temperature ----------- -To set the block temperature inside the Thermocycler, use :py:meth:`~.ThermocyclerContext.set_block_temperature`. At minimum you have to specify a ``temperature`` in degrees Celsius: +To set the block temperature inside the Thermocycler, you can use either a blocking or concurrent method. At minimum, both require a ``temperature`` in degrees Celsius. -.. code-block:: python +.. tabs:: + + .. tab:: Blocking + .. code-block:: python + tc_mod.set_block_temperature(temperature=4) + + When you use a blocking method like :py:meth:`~.ThermocyclerContext.set_block_temperature`, your protocol will only proceed once the block temperature reaches 4 °C. + + .. versionadded:: 2.0 + + .. tab:: Concurrent + + .. code-block:: python + + tc_mod.start_set_block_temperature(temperature=50) + + Use the concurrent :py:meth:`~.ThermocyclerContext.start_set_block_temperature` method to perform other protocol steps while the block reaches its target temperature. + + .. versionadded:: 2.27 If you don't specify any other parameters, the Thermocycler will hold this temperature until a new temperature is set, :py:meth:`~.ThermocyclerContext.deactivate_block` is called, or the module is powered off. .. versionadded:: 2.0 -Hold Time ---------- +Timing Temperature Holds +------------------------- -You can optionally instruct the Thermocycler to hold its block temperature for a specific amount of time. You can specify ``hold_time_minutes``, ``hold_time_seconds``, or both (in which case they will be added together). For example, this will set the block to 4 °C for 4 minutes and 15 seconds:: - - tc_mod.set_block_temperature( - temperature=4, - hold_time_minutes=4, - hold_time_seconds=15) +You can optionally instruct the Thermocycler to hold its block temperature for a specific amount of time. Both examples below set the block to 4 °C for 4 minutes and 15 seconds: -.. note :: +.. tabs:: - Your protocol will not proceed to further commands while holding at a temperature. If you don't specify a hold time, the protocol will proceed as soon as the target temperature is reached. + .. tab:: Blocking -.. versionadded:: 2.0 + .. code-block:: python + + tc_mod.set_block_temperature( + temperature=4, + hold_time_minutes=4, + hold_time_seconds=15) + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + + When you use the blocking :py:meth:`~.ThermocyclerContext.set_block_temperature` command, you can specify ``hold_time_minutes``, ``hold_time_seconds``, or both (in which case they will be added together). Here, the Temperature Module reaches the target temperature and the robot holds for 4 minutes and 15 seconds. Your protocol won't proceed to further commands until the target temperature is reached and the hold is completed. + + If you don't specify a hold time, the protocol will proceed as soon as the target temperature is reached. + + .. versionadded:: 2.0 + + .. tab:: Concurrent + + .. code-block:: python + + # set block temperature + cool_task = tc_mod.start_set_block_temperature(celsius=4) + # complete pipetting actions while the block cools + pipette.pick_up_tip() + pipette.aspirate(50, plate["A1"]) + pipette.dispense(50, plate["B1"]) + pipette.drop_tip() + # wait for the block to reach the target temperature + protocol.wait_for_tasks([cool_task]) + # hold samples on the block at target temperature + block_timer = create_timer(seconds=255) + protocol.wait_for_tasks(block_timer) + + The concurrent :py:meth:`~.ThermocyclerContext.start_set_block_temperature` command doesn't accept the same time arguments. Instead, use :py:meth:`.ProtocolContext.create_timer` to proceed to the next steps in the protocol. Here, the robot will perform the pipetting actions while the block reaches its target temperature. Once the protocol reaches the :py:meth:`.ProtocolContext.wait_for_tasks` commands, the robot pauses and waits for the block to finish cooling or holds for the remainder of the timer. + + .. versionadded:: 2.27 Block Max Volume ---------------- The Thermocycler's block temperature controller varies its behavior based on the amount of liquid in the wells of its labware. Accurately specifying the liquid volume allows the Thermocycler to more precisely control the temperature of the samples. You should set the ``block_max_volume`` parameter to the amount of liquid in the *fullest* well, measured in µL. If not specified, the Thermocycler will assume samples of 25 µL. -It is especially important to specify ``block_max_volume`` when holding at a temperature. For example, say you want to hold larger samples at a temperature for a short time:: +It is especially important to specify ``block_max_volume`` when holding at a temperature. For example, say you want to hold larger samples at a temperature for a short time: +.. tabs:: + + .. tab:: Blocking + + .. code-block:: python + tc_mod.set_block_temperature( temperature=4, hold_time_seconds=20, block_max_volume=80) + + .. versionadded:: 2.0 -If the Thermocycler assumes these samples are 25 µL, it may not cool them to 4 °C before starting the 20-second timer. In fact, with such a short hold time they may not reach 4 °C at all! + .. tab:: Concurrent + + .. code-block:: python + + # set block temperature and max volume + cool_task = tc_mod.start_set_block_temperature( + temperature=4, + block_max_volume=80) + # set time to hold the block at temperature + block_timer = protocol.create_timer(seconds=20) + # wait for the block to reach and hold at temperature + protocol.wait_for_tasks([cool_task, block_timer]) + + .. versionadded:: 2.27 -.. versionadded:: 2.0 + +In both examples, if the Thermocycler assumes these samples are 25 µL, it may not cool them to 4 °C before starting the 20-second timer. In fact, with such a short hold time they may not reach 4 °C at all! .. _thermocycler-profiles: @@ -109,20 +225,49 @@ For example, this profile commands the Thermocycler to reach 10 °C and hold for {"temperature":60, "hold_time_seconds":45} ] -Once you have written the steps of your profile, execute it with :py:meth:`~.ThermocyclerContext.execute_profile`. This function executes your profile steps multiple times depending on the ``repetitions`` parameter. It also takes a ``block_max_volume`` parameter, which is the same as that of the :py:meth:`~.ThermocyclerContext.set_block_temperature` function. +Once you have written the steps of your profile, choose a blocking or concurrent command to execute it with. Both execute your profile steps multiple times depending on the ``repetitions`` parameter. Each also takes a ``block_max_volume`` parameter, which is the same as that of the :py:meth:`~.ThermocyclerContext.set_block_temperature` and :py:meth:`~.ThermocyclerContext.start_set_block_temperature` functions. For instance, a PCR prep protocol might define and execute a profile like this: -.. code-block:: python +.. tabs:: + + .. tab:: Blocking + .. code-block:: python + + profile = [ + {"temperature":95, "hold_time_seconds":30}, + {"temperature":57, "hold_time_seconds":30}, + {"temperature":72, "hold_time_seconds":60} + ] + tc_mod.execute_profile( + steps=profile, + repetitions=20, + block_max_volume=32) + + When you use the blocking :py:meth:`~.ThermocyclerContext.execute_profile` method, your protocol won't proceed until the entire profile is complete. + + .. versionadded:: 2.0 + + .. tab:: Concurrent + + .. code-block:: python + profile = [ {"temperature":95, "hold_time_seconds":30}, {"temperature":57, "hold_time_seconds":30}, {"temperature":72, "hold_time_seconds":60} ] - tc_mod.execute_profile(steps=profile, repetitions=20, block_max_volume=32) + tc_mod.start_execute_profile( + steps=profile, + repetitions=20, + block_max_volume=32) + + Beginning with API version 2.27, you can use the concurrent :py:meth:`~.ThermocyclerContext.start_execute_profile` method to let the robot perform your next pipetting steps and some other module actions while your protocol runs. For more, see the :ref:`concurrent-module` section. -In terms of the actions that the Thermocycler performs, this would be equivalent to nesting ``set_block_temperature`` commands in a ``for`` loop: + .. versionadded:: 2.27 + +In terms of the actions that the Thermocycler performs, running each of the examples above would be equivalent to nesting ``set_block_temperature`` (shown below) or ``start_set_block_temperature`` commands in a ``for`` loop: .. code-block:: python @@ -136,8 +281,7 @@ However, this code would generate 60 lines in the protocol's run log, while exec .. note:: Temperature profiles only control the temperature of the `block` in the Thermocycler. You should set a lid temperature before executing the profile using :py:meth:`~.ThermocyclerContext.set_lid_temperature`. - -.. versionadded:: 2.0 + Auto-sealing Lids ================= diff --git a/api/docs/v2/new_modules.rst b/api/docs/v2/new_modules.rst index 7b640a7536b..9b85a9e37db 100644 --- a/api/docs/v2/new_modules.rst +++ b/api/docs/v2/new_modules.rst @@ -15,6 +15,7 @@ Hardware Modules modules/magnetic_module modules/temperature_module modules/thermocycler + modules/concurrent_module modules/multiple_same_type Hardware modules are powered and unpowered deck-mounted peripherals. The Flex and OT-2 are aware of deck-mounted powered modules when they're attached via a USB connection and used in an uploaded protocol. The robots do not know about unpowered modules until you use one in a protocol and upload it to the Opentrons App. @@ -33,7 +34,8 @@ Pages in this section of the documentation cover: - :ref:`Magnetic Module ` - :ref:`Temperature Module ` - :ref:`Thermocycler Module ` - - Working with :ref:`multiple modules of the same type ` in a single protocol. + - Using :ref:`concurrent module actions ` to run modules while the robot performs other protocol steps, like pipetting, gripper, and other module actions. + - Loading :ref:`multiple modules of the same type ` in a single protocol. .. note:: diff --git a/api/docs/v2/new_protocol_api.rst b/api/docs/v2/new_protocol_api.rst index 1e4cd820b24..e0f6f5e6fc8 100644 --- a/api/docs/v2/new_protocol_api.rst +++ b/api/docs/v2/new_protocol_api.rst @@ -112,7 +112,7 @@ Temperature Module .. autoclass:: opentrons.protocol_api.TemperatureModuleContext :members: - :exclude-members: start_set_temperature, await_temperature, broker, geometry, load_labware_object + :exclude-members: await_temperature, broker, geometry, load_labware_object :inherited-members: Thermocycler diff --git a/api/docs/v2/robot_position.rst b/api/docs/v2/robot_position.rst index 1f32d3169fb..12dc33b5071 100644 --- a/api/docs/v2/robot_position.rst +++ b/api/docs/v2/robot_position.rst @@ -101,6 +101,16 @@ The liquid meniscus in a well changes during aspirating or dispensing, so you'll - Set ``target="start"`` to target the existing liquid meniscus in the destination well before a dispense. - Set ``target="end"`` to ensure the pipette stays submerged while aspirating, or to avoid touching liquid in the destination well while dispensing. +You can use the optional ``location`` and ``end_location`` parameters of the :py:meth:`~.InstrumentContext.aspirate` :py:meth:`~.InstrumentContext.dispense` methods to pipette relative to the liquid meniscus as it changes:: + + pipette.aspirate( + volume=100, + location=plate["A1"].meniscus(z=-1, target="start"), + end_location=plate["A1"].meniscus(z=-1, target="end") + ) + +Here, the pipette tip stays at 1 mm below the liquid meniscus, regardless of changes in liquid height during the aspirate. For more, see the :ref:`new-aspirate` and :ref:`new-dispense` sections. + .. note:: To use the :py:meth:`~.Well.meniscus` method, you'll first need to specify the starting liquid volume with :py:meth:`~.Labware.load_liquid` or probe for liquid with :py:meth:`~.InstrumentContext.measure_liquid_height`. diff --git a/api/docs/v2/versioning.rst b/api/docs/v2/versioning.rst index e6dbdc26ed9..f9385b243c3 100644 --- a/api/docs/v2/versioning.rst +++ b/api/docs/v2/versioning.rst @@ -68,7 +68,7 @@ The maximum supported API version for your robot is listed in the Opentrons App If you upload a protocol that specifies a higher API level than the maximum supported, your robot won't be able to analyze or run your protocol. You can increase the maximum supported version by updating your robot software and Opentrons App. -Opentrons robots running the latest software (8.7.0) support the following version ranges: +Opentrons robots running the latest software (8.8.0) support the following version ranges: * **Flex:** version 2.15–|apiLevel|. * **OT-2:** versions 2.0–|apiLevel|. @@ -84,6 +84,8 @@ This table lists the correspondence between Protocol API versions and robot soft +-------------+------------------------------+ | API Version | Introduced in Robot Software | +=============+==============================+ +| 2.27 | 8.8.0 | ++-------------+------------------------------+ | 2.26 | 8.7.0 | +-------------+------------------------------+ | 2.25 | 8.6.0 | @@ -146,6 +148,19 @@ This table lists the correspondence between Protocol API versions and robot soft Changes in API Versions ======================= +Version 2.27 +------------ +- Adds :ref:`concurrent module commands ` to perform Temperature, Heater-Shaker, or Thermocycler Module actions alongside other protocol steps: + - :py:meth:`.TemperatureModuleContext.start_set_temperature` + - :py:meth:`.HeaterShakerContext.set_shake_speed` + - :py:meth:`.ThermocyclerContext.start_set_block_temperature`, :py:meth:`.ThermocyclerContext.start_set_lid_temperature`, and :py:meth:`.ThermocyclerContext.start_execute_profile` +- Control pipette movement while aspirating or dispensing: + - Use the ``end_location`` and ``movement_delay`` parameters to control pipette movement while :ref:`aspirating ` or :ref:`dispensing `. + - Pipette relative to the :ref:`liquid meniscus ` as liquid level changes. + - Set locations to start and end aspirating and dispensing in a :ref:`dynamic-mix`. +- Take images using the Flex or OT-2's built in-camera with the new :py:meth:`.ProtocolContext.capture_image` method. +- Use the ``tips`` parameter to select tips to use during a :py:meth:`~.InstrumentContext.transfer_with_liquid_class`. + Version 2.26 ------------- diff --git a/api/release-notes.md b/api/release-notes.md index c632679c9d6..297ba63e720 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -14,10 +14,10 @@ Welcome to the v8.8.0 release of the Opentrons robot software! This release incl ### New Features -- Use new non-blocking commands for the Heater-Shaker, Temperature, and Thermocycler Modules. These commands can control multiple modules and complete pipetting actions simultaneously. +- Use new concurrent commands for the Heater-Shaker, Temperature, and Thermocycler Modules. These commands can control multiple modules and complete pipetting actions simultaneously. - Dynamic liquid tracking lets you aspirate, dispense, or mix at the liquid meniscus. Flex pipettes can track the liquid meniscus as its position changes during a pipetting action. - Capture images of the Flex or OT-2 deck during a protocol. -- Choose where your Flex pipette will select a new tip when perfomring a transfer with a liquid class. +- Choose where your Flex pipette will select a new tip when performing a transfer with a liquid class. --- diff --git a/api/src/opentrons/drivers/asyncio/communication/serial_connection.py b/api/src/opentrons/drivers/asyncio/communication/serial_connection.py index 92dc2133869..969c3581d71 100644 --- a/api/src/opentrons/drivers/asyncio/communication/serial_connection.py +++ b/api/src/opentrons/drivers/asyncio/communication/serial_connection.py @@ -12,6 +12,8 @@ ErrorResponse, BaseErrorCode, DefaultErrorCodes, + UnhandledGcode, + GCodeCacheFull, ) from .async_serial import AsyncSerial @@ -555,6 +557,23 @@ async def _consume_responses( # A read timeout, end yield "empty-unknown", data + def _raise_on_parser_error(self, data: str, response: bytes) -> None: + """Raise an exception if this response contains an error from the gcode parser on the module. + + This has to be treated specially because multiack commands won't get multiple acks if the command + fails at the parse stage. The errors handled here should be kept in sync with the module gcode + parse code. + """ + try: + str_response = self.process_raw_response( + command=data, response=response.replace(self._ack, b"").decode() + ) + self.raise_on_error(response=str_response, request=data) + except (UnhandledGcode, GCodeCacheFull): + raise + except Exception: + pass + async def _send_one_retry(self, data: str, acks: int) -> list[str]: data_encode = data.encode("utf-8") log.debug(f"{self._name}: Write -> {data_encode!r}") @@ -567,8 +586,10 @@ async def _send_one_retry(self, data: str, acks: int) -> list[str]: async for response_type, response in self._consume_responses(acks): if response_type == "error": async_errors.append(response) + self._raise_on_parser_error(data, response) elif response_type == "response": command_acks.append(response) + self._raise_on_parser_error(data, response) else: break diff --git a/api/src/opentrons/protocol_api/core/engine/_default_labware_versions.py b/api/src/opentrons/protocol_api/core/engine/_default_labware_versions.py index f044228c663..aadc6ef35ee 100644 --- a/api/src/opentrons/protocol_api/core/engine/_default_labware_versions.py +++ b/api/src/opentrons/protocol_api/core/engine/_default_labware_versions.py @@ -144,6 +144,11 @@ "usascientific_12_reservoir_22ml": 4, "usascientific_96_wellplate_2.4ml_deep": 4, }, + APIVersion(2, 28): { + "black_96_well_microtiter_plate_lid": 2, + "corning_96_wellplate_360ul_lid": 2, + "corning_falcon_384_wellplate_130ul_flat_lid": 2, + }, } diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 43166e18226..ee46866a11e 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -242,14 +242,14 @@ def aspirate( # noqa: C901 :param flow_rate: The absolute flow rate in µL/s. If ``flow_rate`` is specified, ``rate`` must not be set. :type flow_rate: float - :param end_location: Tells the robot to move between location and end_location - while aspirating liquid. When this argument is used the location and - end_location must both be :py:class:`.Location`. + :param end_location: Tells the robot to move from the specified ``location`` to the specified ``end_location`` + while aspirating liquid. When this argument is used, the ``location`` and + ``end_location`` must both be a :py:class:`.Location`. :type end_location: :py:class:`.Location` - :param movement_delay: Delay the x/y/z movement during a dynamic aspirate. - This option is only valid when using end_location. When this argument - is used, the x/y/z movement will wait movement_delay seconds after the pipette - starts to aspirate before moving. This may help when dispensing very viscous liquids + :param movement_delay: Time in seconds to delay after the pipette starts aspirating and before it begins moving from ``location`` to ``end_location``. + This option is only valid when using ``end_location``. When this argument + is used, the pipette will wait the specified time after the pipette + starts to aspirate before moving. This may help when aspirating very viscous liquids that need to build up some pressure before liquid starts to flow. :type movement_delay: float :returns: This instance. @@ -263,6 +263,8 @@ def aspirate( # noqa: C901 .. versionchanged:: 2.24 Added the ``flow_rate`` parameter. + .. versionchanged:: 2.27 + Added the ``end_location`` and ``movement_delay`` parameters. """ if flow_rate is not None: if self.api_version < APIVersion(2, 24): @@ -479,13 +481,13 @@ def dispense( # noqa: C901 ``rate`` must not be set. :type flow_rate: float - :param end_location: Tells the robot to move between location and end_location - while dispensing liquid held in the pipette. When this argument is used - the location and end_location must both be a :py:class:`.Location`. + :param end_location: Tells the robot to move from the specified ``location`` to the specified ``end_location`` + while dispensing liquid held in the pipette. When this argument is used, + the ``location`` and ``end_location`` must both be a :py:class:`.Location`. :type end_location: :py:class:`.Location` - :param movement_delay: Delay the x/y/z movement during a dynamic dispense. - This option is only valid when using end_location. When this argument - is used, the x/y/z movement will wait movement_delay seconds after the pipette + :param movement_delay: Time in seconds to delay after the pipette starts dispensing and before it begins moving from ``location`` to ``end_location``. + This option is only valid when using ``end_location``. When this argument + is used, the pipette will wait the specified time after the pipette starts to dispense before moving. This may help when dispensing very viscous liquids that need to build up some pressure before liquid starts to flow. :type movement_delay: float @@ -510,6 +512,9 @@ def dispense( # noqa: C901 .. versionchanged:: 2.24 ``location`` is no longer required if the pipette just moved to, dispensed, or blew out into a trash bin or waste chute. + + .. versionchanged:: 2.27 + Added the ``end_location`` and ``movement_delay`` parameters. """ if self.api_version < APIVersion(2, 15) and push_out: raise APIVersionError( @@ -849,7 +854,7 @@ def dynamic_mix( # noqa: C901 """ Mix a volume of liquid by repeatedly aspirating and dispensing it in a multiple locations. - See :ref:`dynamic_mix` for examples. + See :ref:`dynamic-mix` for examples. :param repetitions: Number of times to mix (default is 1). :param volume: The volume to mix, measured in µL. If unspecified, defaults @@ -860,13 +865,13 @@ def dynamic_mix( # noqa: C901 it will behave the same as a volume of ``None``/unspecified: mix the full working volume of the pipette. On API levels at or above 2.16, no liquid will be mixed. - :param aspirate_start_location: The :py:class:`~.types.Location` where the pipette will aspirate from. - :param aspirate_end_location: The :py:class:`~.types.Location` If this argument is supplied - the pipette will move between aspirate_start_location and aspirate_end_location + :param aspirate_start_location: The :py:class:`~.types.Location` where the pipette will start aspirating from. + :param aspirate_end_location: A :py:class:`~.types.Location`. If specified, + the pipette will move between the ``aspirate_start_location`` and the ``aspirate_end_location`` while performing the aspirate. - :param dispense_start_location: The :py:class:`~.types.Location` where the pipette will dispense to. - :param dispense_end_location: The :py:class:`~.types.Location` If this argument is supplied - the pipette will move between dispense_start_location and dispense_end_location + :param dispense_start_location: The :py:class:`~.types.Location` where the pipette will start dispensing to. + :param dispense_end_location: A :py:class:`~.types.Location`. If specified, + the pipette will move between the ``dispense_start_location`` and the ``dispense_end_location`` while performing the dispense. :param rate: How quickly the pipette aspirates and dispenses liquid while mixing. The aspiration flow rate is calculated as ``rate`` @@ -884,10 +889,10 @@ def dynamic_mix( # noqa: C901 pipette will not push out after earlier repetitions. If not specified or ``None``, the pipette will push out the default non-zero amount. See :ref:`push-out-dispense`. - :param movement_delay: Delay the x/y/z movement during a dynamic mix. - This option is only valid when using aspirate_end_location or dispense_end_location. - When this argument is used, the x/y/z movement will wait movement_delay seconds - after the pipette starts to aspirate/dispense before moving. This may help when mixing + :param movement_delay: Time in seconds to delay after the pipette starts aspirating or dispensing and before it begins moving from the ``aspirate_start_location`` or ``dispense_start_location`` to the ``aspirate_end_location`` or ``dispense_end_location``. + This option is only valid when using ``aspirate_end_location`` or ``dispense_end_location``. + When this argument is used, the pipette will wait the specified time + after the pipette starts to aspirate or dispense before moving. This may help when mixing very viscous liquids that need to build up some pressure before liquid starts to flow. :type movement_delay: float :raises: ``UnexpectedTipRemovalError`` -- If no tip is attached to the pipette. @@ -895,10 +900,7 @@ def dynamic_mix( # noqa: C901 .. note:: - All the arguments of ``mix`` are optional. However, if you omit one of them, - all subsequent arguments must be passed as keyword arguments. For instance, - ``pipette.mix(1, location=wellplate['A1'])`` is a valid call, but - ``pipette.mix(1, wellplate['A1'])`` is not. + The ``aspirate_start_location`` and ``dispense_start_location`` arguments of ``dynamic_mix`` are required. """ _log.debug( @@ -2267,9 +2269,11 @@ def transfer_with_liquid_class( trash_location=transfer_args.trash_location, return_tip=return_tip, keep_last_tip=verified_keep_last_tip, - tips=[tip._core for tip in transfer_args.tips] - if transfer_args.tips is not None - else None, + tips=( + [tip._core for tip in transfer_args.tips] + if transfer_args.tips is not None + else None + ), ) return self @@ -2441,9 +2445,11 @@ def distribute_with_liquid_class( trash_location=transfer_args.trash_location, return_tip=return_tip, keep_last_tip=verified_keep_last_tip, - tips=[tip._core for tip in transfer_args.tips] - if transfer_args.tips is not None - else None, + tips=( + [tip._core for tip in transfer_args.tips] + if transfer_args.tips is not None + else None + ), ) return self @@ -2615,9 +2621,11 @@ def consolidate_with_liquid_class( trash_location=transfer_args.trash_location, return_tip=return_tip, keep_last_tip=verified_keep_last_tip, - tips=[tip._core for tip in transfer_args.tips] - if transfer_args.tips is not None - else None, + tips=( + [tip._core for tip in transfer_args.tips] + if transfer_args.tips is not None + else None + ), ) return self diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index c21289c9add..1e98a980148 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -456,13 +456,14 @@ def set_temperature(self, celsius: float) -> None: @publish(command=cmds.tempdeck_set_temp) @requires_version(2, 3) def start_set_temperature(self, celsius: float) -> Task: - """Set the target temperature without waiting for the target to be hit. + """Sets the Temperature Module's target temperature and returns immediately without waiting for the module to reach the target. Allows the protocol to proceed while the Temperature Module heats. .. versionchanged:: 2.27 - Returns a task object that represents concurrent preheating. - Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for the preheat to complete. + Returns a :py:class:`Task` object that represents concurrent heating. + Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for the module to finish heating. + + In API version 2.26 or below, this function returns ``None``. - On version 2.26 or below, this function returns ``None``. :param celsius: A value between 4 and 95, representing the target temperature in °C. """ task = self._core.set_target_temperature(celsius) @@ -715,9 +716,9 @@ def start_set_block_temperature( ramp_rate: Optional[float] = None, block_max_volume: Optional[float] = None, ) -> Task: - """Starts to set the target temperature for the well block, in °C. + """Sets the target temperature for the Thermocycler Module's well block, in °C. - Returns a task object that represents concurrent preheating. + Returns a :py:class:`Task` object that represents concurrent heating. Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for the preheat to complete. @@ -726,10 +727,10 @@ def start_set_block_temperature( :param block_max_volume: The greatest volume of liquid contained in any individual well of the loaded labware, in µL. If not specified, the default is 25 µL. - After API version 2.27 it will attempt to use - the liquid tracking of the labware first and - then fall back to the 25 if there is no probed - or loaded liquid. + In API version 2.27 and newer, the API will first attempt to use + the liquid tracking in labware, + then default to 25 µL if the protocol lacks probed + or loaded liquid information. """ if block_max_volume is None: @@ -746,10 +747,6 @@ def start_set_block_temperature( def set_lid_temperature(self, temperature: float) -> None: """Set the target temperature for the heated lid, in °C. - Returns a task object that represents concurrent preheating. - Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for - the preheat to complete. - :param temperature: A value between 37 and 110, representing the target temperature in °C. @@ -765,20 +762,14 @@ def set_lid_temperature(self, temperature: float) -> None: @publish(command=cmds.thermocycler_start_set_lid_temperature) @requires_version(2, 27) def start_set_lid_temperature(self, temperature: float) -> Task: - """Set the target temperature for the heated lid, in °C. + """Sets a target temperature to heat the Thermocycler Module's lid, in °C. Returns a :py:class:`Task` object that represents concurrent heating. - Returns a task object that represents concurrent preheating. Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for - the preheat to complete. + the lid to reach the target temperature. :param temperature: A value between 37 and 110, representing the target temperature in °C. - .. note:: - - The Thermocycler will proceed to the next command immediately after - ``temperature`` is reached. - """ task = self._core.start_set_target_lid_temperature(celsius=temperature) return Task(api_version=self._api_version, core=task) @@ -824,11 +815,10 @@ def start_execute_profile( repetitions: int, block_max_volume: Optional[float] = None, ) -> Task: - """Start a Thermocycler profile and return a :py:class:`Task` representing its execution. + """Starts a defined Thermocycler Module profile and return a :py:class:`Task` representing its concurrent execution. Profile is defined as a cycle of ``steps``, for a given number of ``repetitions``. - Returns a task object that represents concurrent execution of the profile. - Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for the preheat to complete. + Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for the profile run to complete. :param steps: List of steps that make up a single cycle. Each list item should be a dictionary that maps to the parameters @@ -1104,17 +1094,17 @@ def set_target_temperature(self, celsius: float) -> Task: """Set target temperature and return immediately. Sets the Heater-Shaker's target temperature and returns immediately without - waiting for the target to be reached. Does not delay the protocol until - target temperature has reached. + waiting for the target to be reached. Allows the protocol to proceed while the module + reaches the target temperature. Use :py:meth:`~.HeaterShakerContext.wait_for_temperature` to delay - protocol execution for api levels below 2.27. + protocol execution for API levels below 2.27. .. versionchanged:: 2.25 Removed the minimum temperature limit of 37 °C. Note that temperatures under ambient are not achievable. .. versionchanged:: 2.27 - Returns a task object that represents concurrent preheating. - Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for the preheat to complete. + Returns a :py:class:`Task` object that represents concurrent heating. + Pass the task object to :py:meth:`ProtocolContext.wait_for_tasks` to wait for the module to reach the target temperature. :param celsius: A value under 95, representing the target temperature in °C. Values are automatically truncated to two decimal places, @@ -1158,11 +1148,11 @@ def set_and_wait_for_shake_speed(self, rpm: int) -> None: @requires_version(2, 27) @publish(command=cmds.heater_shaker_set_shake_speed) def set_shake_speed(self, rpm: int) -> Task: - """Set a shake speed in rpm to run in the background. + """Sets the Heater-Shaker's shake speed in RPM and returns a :py:class:`Task` that represents concurrent shaking. .. note:: - Before shaking, this command will retract the pipettes upward if they are parked adjacent to the Heater-Shaker. + Before shaking, this command retracts pipettes upward if they are adjacent to the Heater-Shaker Module. :param rpm: A value between 200 and 3000, representing the target shake speed in revolutions per minute. """ diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index b1d50c21f0c..097a596c5d2 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -1289,7 +1289,7 @@ def delay( def wait_for_tasks(self, tasks: list[Task]) -> None: """Wait for a list of tasks to complete before executing subsequent commands. - :param list Task: tasks: A list of Task objects to wait for. + :param list Task: tasks: A list of :py:class:`Task` objects to wait for. Task objects can be commands that are allowed to run concurrently. """ @@ -1299,7 +1299,7 @@ def wait_for_tasks(self, tasks: list[Task]) -> None: @publish(command=cmds.create_timer) @requires_version(2, 27) def create_timer(self, seconds: float) -> Task: - """Create a timer task that runs in the background. + """Create a timer :py:class:`Task` that runs in the background. :param float seconds: The time to delay in seconds. @@ -1833,17 +1833,15 @@ def capture_image( brightness: Optional[float] = None, saturation: Optional[float] = None, ) -> None: - """Capture an image using the camera. Captured images get saved as a result of the protocol run. - - :param home_before: Boolean to home the pipette before capturing an image. - :param filename: Filename to use when saving the captured image as a file. - :param resolution: Width/height tuple to determine the resolution to use when capturing an image. - :param zoom: Optional zoom level, with minimum/default of 1x zoom and maximum of 2x zoom. - :param contrast: Contrast level to be applied to an image, range is 0% to 100%. - :param brightness: Brightness level to be applied to an image, range is 0% to 100%. - :param saturation: Saturation level to be applied to an image, range is 0% to 100%. - - .. versionadded:: 2.27 + """Capture an image using the camera. Captured images are saved as during the protocol run. + + :param home_before: If ``True``, homes the pipette before capturing an image. + :param filename: Custom name to use when saving the captured image as a file. The custom name is added as the beginning of the filename, followed by the robot and protocol name, a timestamp for the protocol run, the step number, and a timestamp for the command running when the image was captured. + :param resolution: Accepts a width and height (as a tuple) to determine the camera's resolution when capturing the image. + :param zoom: Zoom level the camera will use. Defaults to the minimum of 1x zoom (``1.0``) and has a maximum of 2x zoom (``2.0``). + :param contrast: The contrast level to be applied to the image. The acceptable range is from 0 to 100; provided as a percentage (``0.0`` to ``100.0``). + :param brightness: The brightness level to be applied to the image. The acceptable range is from 0 to 100; provided as a percentage (``0.0`` to ``100.0``). + :param saturation: The saturation level to be applied to the image. The acceptable range is from 0 to 100; provided as a percentage (``0.0`` to ``100.0``). """ if home_before is True: diff --git a/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py b/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py index ae250d3b4c7..fcd259bd258 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py @@ -147,7 +147,17 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: model_utils=self._model_utils, movement_delay=params.movement_delay, ) + state_update.append(aspirate_result.state_update) if isinstance(aspirate_result, DefinedErrorData): + state_update.set_liquid_operated( + labware_id=params.labwareId, + well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( + params.labwareId, + params.wellName, + params.pipetteId, + ), + volume_added=CLEAR, + ) if isinstance(aspirate_result.public, OverpressureError): return DefinedErrorData( public=OverpressureError( @@ -156,15 +166,7 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: wrappedErrors=aspirate_result.public.wrappedErrors, errorInfo=aspirate_result.public.errorInfo, ), - state_update=aspirate_result.state_update.set_liquid_operated( - labware_id=params.labwareId, - well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( - params.labwareId, - params.wellName, - params.pipetteId, - ), - volume_added=CLEAR, - ), + state_update=state_update, state_update_if_false_positive=aspirate_result.state_update_if_false_positive, ) elif isinstance(aspirate_result.public, StallOrCollisionError): @@ -175,15 +177,7 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: wrappedErrors=aspirate_result.public.wrappedErrors, errorInfo=aspirate_result.public.errorInfo, ), - state_update=aspirate_result.state_update.set_liquid_operated( - labware_id=params.labwareId, - well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( - params.labwareId, - params.wellName, - params.pipetteId, - ), - volume_added=CLEAR, - ), + state_update=state_update, state_update_if_false_positive=aspirate_result.state_update_if_false_positive, ) @@ -200,7 +194,7 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: volume=aspirate_result.public.volume, position=result_deck_point, ), - state_update=aspirate_result.state_update.set_liquid_operated( + state_update=state_update.set_liquid_operated( labware_id=params.labwareId, well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( params.labwareId, diff --git a/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py b/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py index 323fbf10e4a..8880fa44219 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py @@ -143,8 +143,17 @@ async def execute(self, params: DispenseWhileTrackingParams) -> _ExecuteReturn: model_utils=self._model_utils, movement_delay=params.movement_delay, ) - + state_update.append(dispense_result.state_update) if isinstance(dispense_result, DefinedErrorData): + state_update.set_liquid_operated( + labware_id=params.labwareId, + well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( + params.labwareId, + params.wellName, + params.pipetteId, + ), + volume_added=CLEAR, + ) if isinstance(dispense_result.public, OverpressureError): return DefinedErrorData( public=OverpressureError( @@ -153,15 +162,7 @@ async def execute(self, params: DispenseWhileTrackingParams) -> _ExecuteReturn: wrappedErrors=dispense_result.public.wrappedErrors, errorInfo=dispense_result.public.errorInfo, ), - state_update=dispense_result.state_update.set_liquid_operated( - labware_id=params.labwareId, - well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( - params.labwareId, - params.wellName, - params.pipetteId, - ), - volume_added=CLEAR, - ), + state_update=state_update, state_update_if_false_positive=dispense_result.state_update_if_false_positive, ) elif isinstance(dispense_result.public, StallOrCollisionError): @@ -172,15 +173,7 @@ async def execute(self, params: DispenseWhileTrackingParams) -> _ExecuteReturn: wrappedErrors=dispense_result.public.wrappedErrors, errorInfo=dispense_result.public.errorInfo, ), - state_update=dispense_result.state_update.set_liquid_operated( - labware_id=params.labwareId, - well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( - params.labwareId, - params.wellName, - params.pipetteId, - ), - volume_added=CLEAR, - ), + state_update=state_update, state_update_if_false_positive=dispense_result.state_update_if_false_positive, ) @@ -197,7 +190,7 @@ async def execute(self, params: DispenseWhileTrackingParams) -> _ExecuteReturn: volume=dispense_result.public.volume, position=result_deck_point, ), - state_update=dispense_result.state_update.set_liquid_operated( + state_update=state_update.set_liquid_operated( labware_id=params.labwareId, well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( params.labwareId, 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/api/src/opentrons/protocol_engine/execution/command_executor.py b/api/src/opentrons/protocol_engine/execution/command_executor.py index 79c63d63796..e72f39f3764 100644 --- a/api/src/opentrons/protocol_engine/execution/command_executor.py +++ b/api/src/opentrons/protocol_engine/execution/command_executor.py @@ -248,7 +248,7 @@ async def capture_error_image(self, running_command: Command) -> None: # Only capture photos of errors if the setting to do so is enabled if ( camera_enablement.cameraEnabled - and camera_enablement.errorRecoveryEnabled + and camera_enablement.errorRecoveryCameraEnabled ): # todo(chb, 2025-10-25): Eventually we will need to pass in client provided global settings here image_data = await self._camera_provider.capture_image( diff --git a/api/src/opentrons/protocol_engine/resources/camera_provider.py b/api/src/opentrons/protocol_engine/resources/camera_provider.py index 8febc15dc52..dc0bff301ac 100644 --- a/api/src/opentrons/protocol_engine/resources/camera_provider.py +++ b/api/src/opentrons/protocol_engine/resources/camera_provider.py @@ -27,7 +27,7 @@ class CameraSettings(BaseModel): liveStreamEnabled: bool = Field( ..., description="Enablement status for the Opentrons Live Stream service." ) - errorRecoveryEnabled: bool = Field( + errorRecoveryCameraEnabled: bool = Field( ..., description="Enablement status for camera usage with Error Recovery." ) @@ -83,7 +83,7 @@ async def get_camera_settings(self) -> CameraSettings: return self._camera_settings_callback() # If we are in analysis or simulation, return as if the camera is enabled return CameraSettings( - cameraEnabled=True, liveStreamEnabled=True, errorRecoveryEnabled=True + cameraEnabled=True, liveStreamEnabled=True, errorRecoveryCameraEnabled=True ) async def capture_image( diff --git a/api/src/opentrons/protocol_runner/legacy_command_mapper.py b/api/src/opentrons/protocol_runner/legacy_command_mapper.py index 766acce5e72..3808b5b544a 100644 --- a/api/src/opentrons/protocol_runner/legacy_command_mapper.py +++ b/api/src/opentrons/protocol_runner/legacy_command_mapper.py @@ -841,8 +841,16 @@ def _map_module_load( # We just set this above, so we know it's not None. started_at=succeeded_command.startedAt, # type: ignore[arg-type] ) + state_update = StateUpdate() + state_update.set_load_module( + module_id=module_id, + definition=loaded_definition, + slot_name=module_load_info.deck_slot, + requested_model=requested_model, + serial_number=module_load_info.module_serial, + ) succeed_action = pe_actions.SucceedCommandAction( - command=succeeded_command, state_update=StateUpdate() + command=succeeded_command, state_update=state_update ) self._command_count["LOAD_MODULE"] = count + 1 diff --git a/api/src/opentrons/protocols/parameters/csv_parameter_interface.py b/api/src/opentrons/protocols/parameters/csv_parameter_interface.py index ff460b48f21..e8ec5451929 100644 --- a/api/src/opentrons/protocols/parameters/csv_parameter_interface.py +++ b/api/src/opentrons/protocols/parameters/csv_parameter_interface.py @@ -70,7 +70,7 @@ def parse_as_csv( rows: List[List[str]] = [] if detect_dialect: try: - dialect = csv.Sniffer().sniff(self.contents[:1024]) + dialect = csv.Sniffer().sniff(self.contents) reader = csv.reader(self.contents.split("\n"), dialect, **kwargs) except (UnicodeDecodeError, csv.Error): raise ParameterValueError( diff --git a/api/tests/opentrons/drivers/asyncio/communication/test_serial_connection.py b/api/tests/opentrons/drivers/asyncio/communication/test_serial_connection.py index 89f75a94aa6..9f4911c158f 100644 --- a/api/tests/opentrons/drivers/asyncio/communication/test_serial_connection.py +++ b/api/tests/opentrons/drivers/asyncio/communication/test_serial_connection.py @@ -338,7 +338,7 @@ async def test_send_data_multiple_ack_some_errors( """It should return all acks.""" successful_response = "M411" data = "M411" - error_response = "ERR003:test" + error_response = "ERR007:test" serial_successful_response = f" {successful_response} {ack}" encoded_successful_response = serial_successful_response.encode() serial_error_response = f" {error_response} {ack}" @@ -424,3 +424,15 @@ class CustomDefaultErrorCodes(BaseErrorCode): assert error.value.command == "G28" assert error.value.response == "ERR999:test" assert error.value.port == "test_port" + + +async def test_send_data_multiple_raises_unhandled( + mock_serial_port: AsyncMock, async_subject: AsyncResponseSerialConnection, ack: str +) -> None: + """It shouldn't wait for both acks before raising an unhandled gcode""" + mock_serial_port.read_until.side_effect = [ + f"ERR003:unhandled gcode {ack}".encode(), + ] + with pytest.raises(UnhandledGcode): + await async_subject._send_data_multiack(data="M411", retries=0, acks=3) + mock_serial_port.read_until.assert_called_once() diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate_while_tracking.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate_while_tracking.py index 9dca3796805..7e5d54fb5d3 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate_while_tracking.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate_while_tracking.py @@ -36,6 +36,7 @@ WellOrigin, WellOffset, DeckPoint, + LabwareWellId, ) from opentrons.protocol_engine.state import update_types @@ -195,6 +196,13 @@ async def test_aspirate_while_tracking_implementation( pipette_id="pipette-id-abc", fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=123), ), + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id-abc", + new_location=LabwareWellId( + labware_id="funky-labware", well_name="funky-well" + ), + new_deck_point=DeckPoint(x=4, y=5, z=6), + ), ), ) else: @@ -213,6 +221,13 @@ async def test_aspirate_while_tracking_implementation( well_names=["A3", "A4"], volume_added=-246.0, ), + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id-abc", + new_location=LabwareWellId( + labware_id="funky-labware", well_name="funky-well" + ), + new_deck_point=DeckPoint(x=4, y=5, z=6), + ), ), ) @@ -465,6 +480,13 @@ async def test_overpressure_error( pipette_aspirated_fluid=update_types.PipetteUnknownFluidUpdate( pipette_id="pipette-id-abc" ), + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id-abc", + new_location=LabwareWellId( + labware_id="funky-labware", well_name="funky-well" + ), + new_deck_point=DeckPoint(x=4, y=5, z=6), + ), ), ) else: @@ -484,5 +506,12 @@ async def test_overpressure_error( well_names=["A3", "A4"], volume_added=update_types.CLEAR, ), + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id-abc", + new_location=LabwareWellId( + labware_id="funky-labware", well_name="funky-well" + ), + new_deck_point=DeckPoint(x=4, y=5, z=6), + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_capture_image.py b/api/tests/opentrons/protocol_engine/commands/test_capture_image.py index 45de452fbd9..deb3890ffaf 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_capture_image.py +++ b/api/tests/opentrons/protocol_engine/commands/test_capture_image.py @@ -136,7 +136,7 @@ async def test_raises_camera_disabled_error( CameraSettings( cameraEnabled=False, liveStreamEnabled=False, - errorRecoveryEnabled=False, + errorRecoveryCameraEnabled=False, ) ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense_while_tracking.py b/api/tests/opentrons/protocol_engine/commands/test_dispense_while_tracking.py index e79f941c7c8..b4d084f4f88 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense_while_tracking.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense_while_tracking.py @@ -31,6 +31,7 @@ WellOrigin, WellOffset, DeckPoint, + LabwareWellId, ) from opentrons.protocol_engine.state import update_types @@ -191,6 +192,13 @@ async def test_dispense_while_tracking_implementation( ready_to_aspirate=update_types.PipetteAspirateReadyUpdate( pipette_id="pipette-id-abc", ready_to_aspirate=False ), + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id-abc", + new_location=LabwareWellId( + labware_id="funky-labware", well_name="funky-well" + ), + new_deck_point=DeckPoint(x=4, y=5, z=6), + ), ), ) else: @@ -212,6 +220,13 @@ async def test_dispense_while_tracking_implementation( well_names=["A3", "A4"], volume_added=84.0, ), + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id-abc", + new_location=LabwareWellId( + labware_id="funky-labware", well_name="funky-well" + ), + new_deck_point=DeckPoint(x=4, y=5, z=6), + ), ), ) @@ -354,6 +369,13 @@ async def test_overpressure_error( ready_to_aspirate=update_types.PipetteAspirateReadyUpdate( pipette_id="pipette-id", ready_to_aspirate=False ), + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id", + new_location=LabwareWellId( + labware_id="funky-labware", well_name="funky-well" + ), + new_deck_point=DeckPoint(x=4, y=5, z=6), + ), ), ) else: @@ -376,6 +398,13 @@ async def test_overpressure_error( ready_to_aspirate=update_types.PipetteAspirateReadyUpdate( pipette_id="pipette-id", ready_to_aspirate=False ), + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="pipette-id", + new_location=LabwareWellId( + labware_id="funky-labware", well_name="funky-well" + ), + new_deck_point=DeckPoint(x=4, y=5, z=6), + ), ), state_update_if_false_positive=update_types.StateUpdate(), ) diff --git a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py index 4791258c94b..c7e8a286af7 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py +++ b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py @@ -566,7 +566,7 @@ class _TestCommand( CameraSettings( cameraEnabled=True, liveStreamEnabled=True, - errorRecoveryEnabled=True, + errorRecoveryCameraEnabled=True, ) ) @@ -743,7 +743,7 @@ class _TestCommand( CameraSettings( cameraEnabled=True, liveStreamEnabled=True, - errorRecoveryEnabled=True, + errorRecoveryCameraEnabled=True, ) ) diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index 9c91690c6f6..ae260df1ca2 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -1328,7 +1328,7 @@ def test_add_camera_settings( ) -> None: """It should dispatch an AddCameraSettingsAction action.""" settings = CameraSettings( - cameraEnabled=True, liveStreamEnabled=True, errorRecoveryEnabled=True + cameraEnabled=True, liveStreamEnabled=True, errorRecoveryCameraEnabled=True ) decoy.when(subject.state_view.camera.get_enablement_settings()).then_return( settings diff --git a/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py b/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py index 1110cfa2d69..887cd4410d6 100644 --- a/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py +++ b/api/tests/opentrons/protocol_runner/test_legacy_command_mapper.py @@ -7,6 +7,7 @@ from opentrons.protocol_engine.state.update_types import ( LoadPipetteUpdate, LoadedLabwareUpdate, + LoadModuleUpdate, PipetteConfigUpdate, StateUpdate, ) @@ -460,6 +461,15 @@ def test_map_module_load( ), notes=[], ), + state_update=StateUpdate( + loaded_module=LoadModuleUpdate( + module_id=matchers.IsA(str), + definition=test_definition, + slot_name=DeckSlotName.SLOT_1, + requested_model=ModuleModel.TEMPERATURE_MODULE_V1, + serial_number="module-serial", + ) + ), ) [result_queue, result_run, result_succeed] = LegacyCommandMapper( diff --git a/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py b/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py index 1f97d8eb120..496a6552779 100644 --- a/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py +++ b/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py @@ -45,6 +45,23 @@ def csv_file_different_delimiter() -> bytes: return b"x:y:z\na,:1,:2\nb,:3,:4\nc,:5,:6" +@pytest.fixture() +def csv_file_long() -> bytes: + """A long CSV file from a customer that caused the sniffer to fail when it only looked at the first 1024 bytes.""" + return b""" +Source Labware,Source Slot,Source Well,Source Height,Dest Labware,Dest Slot,Dest Well,Volume +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical,0,A1,0,opentrons_24_aluminumblock_nest_0.5ml_screwcap,0,A1,100 +""".strip() + + @pytest.fixture def csv_file_basic_trailing_empty() -> Tuple[bytes, List[List[str]]]: """A basic CSV file with quotes around strings and a trailing newline.""" @@ -102,6 +119,19 @@ def test_csv_parameter( assert subject.parse_as_csv()[0] == ["x", "y", "z"] +def test_csv_parameter_long_file( + decoy: Decoy, api_version: APIVersion, csv_file_long: bytes +) -> None: + """It should detect the CSV dialect for files of unlimited length.""" + # The previous implementation of parse_as_csv() passed only the first 1024 bytes of + # the CSV file to the dialect sniffer, chopping up a line an unfortunate position, + # causing the sniffer to fail with "Could not determine delimiter". + subject = CSVParameter(csv_file_long, api_version) + parsed_rows = subject.parse_as_csv() + assert len(parsed_rows) == 10 + assert len(parsed_rows[0]) == 8 + + @pytest.mark.parametrize( "csv_file", [ diff --git a/app-shell-odd/package.json b/app-shell-odd/package.json index a4f1a1b599c..0cbe2e129b9 100644 --- a/app-shell-odd/package.json +++ b/app-shell-odd/package.json @@ -42,7 +42,7 @@ "@types/uuid": "^3.4.7", "ajv": "6.12.3", "dateformat": "3.0.3", - "electron-devtools-installer": "3.2.0", + "electron-devtools-installer": "4.0.0", "electron-store": "5.1.1", "electron-updater": "6.3.9", "execa": "4.0.0", diff --git a/app-shell-odd/src/main.ts b/app-shell-odd/src/main.ts index 226bac3c836..a4d8457d4a3 100644 --- a/app-shell-odd/src/main.ts +++ b/app-shell-odd/src/main.ts @@ -193,7 +193,7 @@ function installDevtools(): void { // eslint-disable-next-line @typescript-eslint/no-var-requires const devtools = require('electron-devtools-installer') const extensions = [devtools.REACT_DEVELOPER_TOOLS, devtools.REDUX_DEVTOOLS] - const install = devtools.default + const install = devtools.installExtensions const forceReinstall = config.reinstallDevtools log.debug('Installing devtools') diff --git a/app-shell/package.json b/app-shell/package.json index 43c81f5cad4..8b312943d1d 100644 --- a/app-shell/package.json +++ b/app-shell/package.json @@ -51,7 +51,7 @@ "electron-debug": "3.2.0", "electron-is-dev": "1.2.0", "electron-localshortcut": "3.2.1", - "electron-devtools-installer": "3.2.0", + "electron-devtools-installer": "4.0.0", "electron-store": "5.1.1", "electron-updater": "6.3.9", "execa": "4.0.0", diff --git a/app-shell/src/main.ts b/app-shell/src/main.ts index 3635b74b318..243414011df 100644 --- a/app-shell/src/main.ts +++ b/app-shell/src/main.ts @@ -3,7 +3,11 @@ import dns from 'dns' import { app, BrowserWindow, ipcMain } from 'electron' import contextMenu from 'electron-context-menu' import electronDebug from 'electron-debug' -import * as electronDevtoolsInstaller from 'electron-devtools-installer' +import { + installExtension, + REACT_DEVELOPER_TOOLS, + REDUX_DEVTOOLS, +} from 'electron-devtools-installer' import { getConfig, getOverrides, getStore, registerConfig } from './config' import { @@ -63,11 +67,20 @@ interface HandlerSet { // Handler caching using window ID as key const handlerSets = new Map() -// prepended listener is important here to work around Electron issue -// https://github.com/electron/electron/issues/19468#issuecomment-623529556 -app.prependOnceListener('ready', startUp) -// eslint-disable-next-line @typescript-eslint/no-misused-promises -if (config.devtools) app.once('ready', installDevtools) +app + .whenReady() + .then(async () => { + startUp() + + if (config.devtools) { + await installDevtools() + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } + } + }) + .catch(err => log.error('Startup failed', { err })) app.once('window-all-closed', () => { log.debug('all windows closed, quitting the app') @@ -203,36 +216,23 @@ function createRendererLogger(): Logger { return logger } -function installDevtools(): Promise { - const extensions = [ - electronDevtoolsInstaller.REACT_DEVELOPER_TOOLS, - electronDevtoolsInstaller.REDUX_DEVTOOLS, - ] - // @ts-expect-error the types for electron-devtools-installer are not correct - // when importing the default export via commmon JS. the installer is actually nested in - // another default object - const install = electronDevtoolsInstaller.default?.default - const forceReinstall = config.reinstallDevtools - - log.debug('Installing devtools') - - if (typeof install === 'function') { - return install(extensions, { +async function installDevtools(): Promise { + const extensions = [REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS] + + log.debug('Installing devtools with v4 API') + + try { + await installExtension(extensions, { loadExtensionOptions: { allowFileAccess: true }, - forceDownload: forceReinstall, + forceDownload: config.reinstallDevtools, + }) + + log.debug('Devtools extensions installed') + } catch (error) { + log.warn('Failed to install devtools extensions', { + forceReinstall: config.reinstallDevtools, + error, }) - .then(() => log.debug('Devtools extensions installed')) - .catch((error: unknown) => { - log.warn('Failed to install devtools extensions', { - forceReinstall, - error, - }) - }) - } else { - log.warn('could not resolve electron dev tools installer') - return Promise.reject( - new Error('could not resolve electron dev tools installer') - ) } } diff --git a/app-shell/src/secondary-windows/index.ts b/app-shell/src/secondary-windows/index.ts index dac4d490e1f..eb3008b0b18 100644 --- a/app-shell/src/secondary-windows/index.ts +++ b/app-shell/src/secondary-windows/index.ts @@ -106,7 +106,7 @@ function detailsByActionType(action: Action): SecondaryWindowDetails | null { } case STEP_DETAIL_VIEWER_UPDATE: updateStepDetailViewerData(action.payload.protocolKey, { - slot: action.payload.slot, + slot: action.payload.slot ?? undefined, command: action.payload.command, robotState: action.payload.robotState, analysis: action.payload.analysis, diff --git a/app-shell/src/usb.ts b/app-shell/src/usb.ts index d25b4708b0a..1ad7f6a0db3 100644 --- a/app-shell/src/usb.ts +++ b/app-shell/src/usb.ts @@ -133,11 +133,23 @@ async function usbListener( ...config, data, headers: { ...config.headers, ...formHeaders }, + // Axios can't create proper blob types on the node layer, so we use + // arraybuffer instead. + responseType: + config.responseType === 'blob' ? 'arraybuffer' : config.responseType, }) usbLog.silly(`${config.method} ${config.url} resolved ok`) + + // Convert ArrayBuffer to regular Array for IPC transfer, since ArrayBuffer + // objects cannot be sent across the IPC reliably. + const responseData = + config.responseType === 'blob' && response.data instanceof ArrayBuffer + ? Array.from(new Uint8Array(response.data)) + : response.data + return { error: null, - data: response.data, + data: responseData, status: response.status, statusText: response.statusText, } diff --git a/app/src/assets/localization/en/device_details.json b/app/src/assets/localization/en/device_details.json index 654d691fb70..a1e0a9c4930 100644 --- a/app/src/assets/localization/en/device_details.json +++ b/app/src/assets/localization/en/device_details.json @@ -169,7 +169,7 @@ "reset_estop": "Reset E-stop", "resume_operation": "Resume operation", "right": "right", - "robot_control_not_available": "Some robot controls are not available when run is in progress or robot is busy", + "robot_control_not_available": "Some robot controls are not available when run is in progress", "robot_initializing": "Initializing...", "run": "Run", "run_a_protocol": "Run a protocol", diff --git a/app/src/assets/localization/en/protocol_visualization.json b/app/src/assets/localization/en/protocol_visualization.json index 9e8f7734f72..469d11a6b56 100644 --- a/app/src/assets/localization/en/protocol_visualization.json +++ b/app/src/assets/localization/en/protocol_visualization.json @@ -1,33 +1,59 @@ { "active": "Active", - "deck_view": "Deck view", + "closed_and_locked": "Closed and locked", + "closed": "Closed", + "deactivated": "Deactivated", + "deck_view": "Deck View", "destination_labware": "Destination labware", "destination_tips": "Destination tips", "destination_well_view": "Destination well view", + "disengaged": "Disengaged", "disposal": "Disposal", + "engaged": "Engaged", "errors": "{{count}} error", + "fixedTrash": "Fixed trash", + "idle": "Idle", + "initialization": "Initialization", + "labware_latch": "Labware latch", "labware": "Labware", "left_mount": "LEFT MOUNT", "left_right_mount": "LEFT + RIGHT MOUNT", + "lid_status": "Lid status", "lids_in_trash": "Lids in trash", "no_errors": "No errors", "none_attached": "None attached", + "open": "Open", "percent_complete": "{{percent}}% complete", "pipette": "Pipette", + "protocol_steps": "Protocol Steps", "quantity": "Quantity: {{quantity}}", + "reference_wavelength": "Reference wavelength", "remaining_tips": "{{remaining}} tips", "right_mount": "RIGHT MOUNT", "seconds_per_step": "{{seconds}}s per step", + "slot_empty": "Slot empty", + "slot": "Slot", "source_tips": "Source tips", "source_well_view": "Source well view", + "speed": "{{speed}} RPM", + "status": "Status", "step": "Step {{number}}", - "timeline": "Timeline", - "tipPickup": "Tip pickup", + "target_block_temperature": "Target block temperature", + "target_lid_temperature": "Target lid temperature", + "target_speed": "Target speed", + "target_temperature": "Target temperature", + "TEMPERATURE_APPROACHING_TARGET": "Approaching temperature", + "TEMPERATURE_AT_TARGET": "Target temperature", + "TEMPERATURE_DEACTIVATED": "Deactivated", + "temperature": "{{temp}} °C", "tip_disposal": "Tip disposal", + "tip_pickup": "Tip pickup", "tips_in_trash": "Tips in trash", "tips_remaining": "Tips remaining", - "trash": "TRASH", "trash_bin": "TRASH BIN", + "trash": "TRASH", + "unable_to_show_steps_past_errors": "Unable to show steps past errors", + "wavelengths": "Wavelengths", "well_dimension": "{{number}} mm", "well_name": "Well {{wellName}}", "well_view": "Well view", diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index 89005e9bded..6001c92fc8f 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -78,7 +78,7 @@ "dispense_volume_µL": "Dispense volume per well (µL)", "disposal_volume": "Disposal volume", "disposal_volume_flow_rate": "Between {{min}} and {{max}}", - "disposal_volume_label": "{{volume}}, {{location}}, {{flowRate}} µL/s", + "disposal_volume_label": "{{volume}} µL, {{location}}, {{flowRate}} µL/s", "disposal_volume_µL": "Disposal volume (µL)", "distance_bottom_of_well_mm": "Distance from bottom of well (mm)", "distance_from_bottom": "Distance from bottom of well (mm)", diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index 20d99f2defa..5e41e36f266 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -68,6 +68,7 @@ "error_type": "Error: {{errorType}}", "failed_step": "Failed step", "final_step": "Final Step", + "fixture_mismatch": "Deck configuration does not match protocol requirements", "ignore_stored_data": "Ignore stored data", "image_at_step_at_timestamp": "Image at {{step}} at {{timestamp}}", "image_capture": "Image capture", @@ -77,6 +78,7 @@ "image_loading": "Image loading", "images": "Images", "images_available_download": "Image files associated with the run are available on the robot’s Recent Runs screen and Camera tab.", + "instrument_calibration_incomplete": "Instrument calibration is incomplete", "labware": "labware", "labware_offset_data": "labware offset data", "left": "Left", @@ -91,7 +93,9 @@ "loading_data": "Loading data...", "loading_protocol": "Loading Protocol", "location": "location", + "module_calibration_incomplete": "Module calibration is incomplete", "module_controls": "Module Controls", + "modules_missing": "Modules are missing from deck hardware", "module_slot_number": "Slot {{slot_number}}", "move_labware": "Move Labware", "na": "N/A", @@ -141,6 +145,7 @@ "robot_was_recalibrated": "This robot was recalibrated after this Labware Offset data was stored.", "run": "Run", "run_again": "Run again", + "run_again_disabled": "Run again is disabled for this protocol", "run_canceled": "Run canceled.", "run_canceled_splash": "Run canceled", "run_canceled_with_errors": "Run canceled with errors.", diff --git a/app/src/assets/localization/zh/anonymous.json b/app/src/assets/localization/zh/anonymous.json index 5126154f38a..942849e1799 100644 --- a/app/src/assets/localization/zh/anonymous.json +++ b/app/src/assets/localization/zh/anonymous.json @@ -7,6 +7,7 @@ "calibration_block_description": "这个金属块是一个特制的工具,完美适配您的甲板,有助于校准。如果您没有校准块,请发送电子邮件给支持团队,以便我们寄送一个给您。在您提供的信息中,请确保包括您的姓名、公司或机构名称和寄送地址。在等待校准块到达过程中,您可以暂时利用工作站里垃圾桶上的平面进行校准。", "calibration_on_opentrons_tips_is_important": "使用上述吸头和吸头盒进行校准非常重要,因为工作站的准确性是基于这些吸头的已知尺寸来确定的。", "choose_what_data_to_share": "选择要共享的工作站数据。", + "clear_images_on_desktop": "如果不通过APP清理运行记录中来释放存储空间,本次运行可能会失败", "close_stacker_doors_description": "确保侧面面板已更换,并且设备门已关闭。", "computer_in_app_is_controlling_robot": "当前正在由一台已联网的计算机控制此工作站。", "confirm_terminate": "这将立即停止计算机上进行的活动。您或其他用户可能会丢失进度或在该计算机上出现报错提示。", @@ -24,6 +25,7 @@ "estop_pressed_description": "首先,安全清理甲板上的任何实验耗材和洒出的液体。然后,顺时针旋转急停开关。最后,让工作站将龙门架移动到其原位。", "find_your_robot": "在应用程序的“设备”栏找到您的工作站,以安装软件更新。", "firmware_update_download_logs": "请与支持人员联系以获得帮助。", + "flex_camera": "摄像头", "flex_stacker_empty": "手动清空设备的外置堆栈中的所有耗材", "flex_stacker_empty_from_location": "手动清空设备 {{stackerColumn}}中的所有实验耗材", "flex_stacker_fill": "填充设备外置堆栈", @@ -40,10 +42,13 @@ "gripper_successfully_detached": "转板抓手已成功卸下", "help_us_improve_send_error_report": "通过向支持团队发送错误报告,帮助我们改进您的使用体验", "if_issue_persists_call_support": "如果问题仍然存在,请取消运行并联系技术支持人员", + "image_capture_window_title": "{{timestamp}} step {{step}} 的设备图像.jpeg", "ip_description_second": "请联系网络管理员,为工作站分配静态IP地址。", "labware_offsets_conflict_description": "设备存储的耗材校准参数已在最后一次运行后更新 {{timestamp}}。您想使用哪些耗材校准数据来再次运行此协议?", "language_preference_description": "除非您在下面选择其他语言,否则应用将与您的系统语言匹配。您可以稍后在应用设置中更改语言。", "learn_uninstalling": "了解更多有关卸载应用程序的信息", + "live_video_description_odd": "在运行协议时,可在APP中查看设备内的实时监控", + "livestream_window_title": "设备 {{robotName}} 实时摄像头", "loosen_screws_and_detach": "松开螺丝并卸下转板抓手", "modal_instructions": "有关设置模块的分步说明,请参阅随包装附带的快速指引。", "module_calibration_failed": "模块校准失败。请确保校准适配器正确放置在模块上,然后重试。如果仍然有问题,请与支持人员联系。{{error}}", diff --git a/app/src/assets/localization/zh/app_settings.json b/app/src/assets/localization/zh/app_settings.json index 8429a52ab3c..9a875a41752 100644 --- a/app/src/assets/localization/zh/app_settings.json +++ b/app/src/assets/localization/zh/app_settings.json @@ -6,7 +6,8 @@ "__dev_internal__lpcRedesign": "LPC 重新设计", "__dev_internal__protocolStats": "协议统计", "__dev_internal__protocolTimeline": "协议时间线", - "__dev_internal__quickTransferExportPython": "启用快速移液的Python导出功能", + "__dev_internal__quickTransferExportJSON": "启用快速移液的 JSON 导出功能", + "__dev_internal__quickTransferProtocolContentsLog": "启用 QT 协议内容日志记录", "__dev_internal__reactQueryDevtools": "启用开发者工具", "__dev_internal__reactScan": "启用 React 组件扫描", "add_folder_button": "添加实验耗材源文件夹", diff --git a/app/src/assets/localization/zh/branded.json b/app/src/assets/localization/zh/branded.json index cc5e49838fb..5a30833d901 100644 --- a/app/src/assets/localization/zh/branded.json +++ b/app/src/assets/localization/zh/branded.json @@ -7,6 +7,7 @@ "calibration_block_description": "这个金属块是一个特制的工具,完美适配您的甲板,有助于校准。如果您没有校准块,请发送电子邮件至support@opentrons.com,以便我们寄送一个给您。在您提供的信息中,请确保包括您的姓名、公司或机构名称和寄送地址。在等待校准块到达过程中,您可以暂时利用工作站里垃圾桶上的平面进行校准。", "calibration_on_opentrons_tips_is_important": "使用上述Opentrons吸头和吸头盒进行校准非常重要,因为工作站的准确性是基于这些吸头的已知尺寸来确定的。", "choose_what_data_to_share": "选择要与Opentrons共享的数据。", + "clear_images_on_desktop": "如果未在 Opentrons App 中清理之前运行记录中的图像来释放存储空间,本次运行可能会失败", "close_stacker_doors_description": "请确保侧面面板已更换,并且 Flex 门已关闭。", "computer_in_app_is_controlling_robot": "计算机当前正在通过Opentrons应用程序控制此工作站。", "confirm_terminate": "这将立即停止计算机上开始的活动。您或其他用户可能会丢失进度或在Opentrons应用程序中看到报错", @@ -24,6 +25,7 @@ "estop_pressed_description": "首先,安全清理甲板上的任何实验耗材或洒出液体。然后,顺时针旋转急停开关。最后,让Flex将龙门架移动到其原位。", "find_your_robot": "在Opentrons应用程序中找到您的工作站以安装软件更新。", "firmware_update_download_logs": "从Opentrons应用程序下载工作站日志并将其发送到support@opentrons.com寻求帮助。", + "flex_camera": "Flex 摄像头", "flex_stacker_empty": "手动清空 Flex 外置堆栈中的所有耗材", "flex_stacker_empty_from_location": "手动清空 Flex {{stackerColumn}} 中的所有耗材", "flex_stacker_fill": "填充 Flex 外置堆栈", @@ -40,10 +42,13 @@ "gripper_successfully_detached": "Flex转板抓手已成功卸下", "help_us_improve_send_error_report": "通过向{{support_email}}发送错误报告,帮助我们改进您的使用体验", "if_issue_persists_call_support": "如果问题仍然存在,请取消运行并联系 Opentrons 技术支持人员", + "image_capture_window_title": "{{timestamp}} step {{step}} 的Opentrons 图像.jpeg", "ip_description_second": "Opentrons建议您联系网络管理员,为工作站分配静态IP地址。", "labware_offsets_conflict_description": "您的 Flex 存储耗材校准数据已在最后一次运行后更新 {{timestamp}}。您想使用哪些耗材校准数据来再次运行此协议?", "language_preference_description": "Opentrons APP默认匹配与您的系统语言,您也可以选择使用下方其他语言。当然,后续您也可以在APP设置中进行语言更改。", "learn_uninstalling": "了解更多有关卸载Opentrons应用程序的信息", + "live_video_description_odd": "在运行协议时,您可以在 Opentrons APP 中查看设备内工作桌面的实时视频画面。", + "livestream_window_title": "Opentrons {{robotName}} 实时摄像头", "loosen_screws_and_detach": "松开螺丝并卸下Flex转板抓手", "modal_instructions": "有关设置模块的分步说明,请参阅随包装附带的快速指引。您也可以单击下面的链接或扫描二维码访问Opentrons帮助中心的模块部分。", "module_calibration_failed": "模块校准失败。请确保校准适配器正确放置在模块上,然后重试。如果仍然有问题,请与Opentrons支持人员联系。{{error}}", diff --git a/app/src/assets/localization/zh/command_type_summary.json b/app/src/assets/localization/zh/command_type_summary.json new file mode 100644 index 00000000000..4aa4879c77c --- /dev/null +++ b/app/src/assets/localization/zh/command_type_summary.json @@ -0,0 +1,115 @@ +{ + "absorbanceReader/closeLid": "关闭", + "absorbanceReader/initialize": "初始化", + "absorbanceReader/openLid": "打开", + "absorbanceReader/read": "读取", + "airGapInPlace": "吸入一段空气", + "aspirate": "吸液", + "aspirateInPlace": "吸液", + "aspirateWhileTracking": "吸液", + "blowOutInPlace": "吹出", + "blowout": "吹出", + "calibration/calibrateGripper": "校准", + "calibration/calibrateModule": "校准", + "calibration/calibratePipette": "校准", + "calibration/moveToMaintenancePosition": "校准", + "captureImage": "拍照", + "comment": "评论", + "configureForVolume": "配置", + "configureNozzleLayout": "配置", + "createTimer": "创建计时器", + "custom": "自定义", + "delay": "等待", + "dispense": "排液", + "dispenseInPlace": "排液", + "dispenseWhileTracking": "排液", + "dropTip": "丢弃吸头", + "dropTipInPlace": "丢弃吸头", + "flexStacker/closeLatch": "关闭", + "flexStacker/empty": "空", + "flexStacker/fill": "填充", + "flexStacker/openLatch": "打开", + "flexStacker/prepareShuttle": "准备", + "flexStacker/retrieve": "取出", + "flexStacker/setStoredLabware": "存储", + "flexStacker/store": "存储", + "getNextTip": "获取下一个吸头", + "getTipPresence": "获取吸头", + "heaterShaker/closeLabwareLatch": "关闭", + "heaterShaker/deactivateHeater": "停用", + "heaterShaker/deactivateShaker": "停用", + "heaterShaker/openLabwareLatch": "打开", + "heaterShaker/setAndWaitForShakeSpeed": "震荡", + "heaterShaker/setShakeSpeed": "震荡", + "heaterShaker/setTargetTemperature": "加热", + "heaterShaker/waitForTemperature": "加热", + "home": "归位", + "identifyModule": "识别", + "liquidProbe": "探测", + "loadLabware": "加载耗材", + "loadLid": "加载盖子", + "loadLidStack": "加载盖子堆栈", + "loadLiquid": "加载试剂", + "loadLiquidClass": "加载液体类型", + "loadModule": "加载模块", + "loadPipette": "加载移液器", + "magneticModule/disengage": "下降", + "magneticModule/engage": "抬升", + "moveLabware": "移动", + "moveRelative": "移动", + "moveToAddressableArea": "移动", + "moveToAddressableAreaForDropTip": "移动", + "moveToCoordinates": "移动", + "moveToSlot": "移动", + "moveToWell": "移动", + "moveTocoordinates": "移动", + "pause": "等待", + "pickUpTip": "拾取", + "prepareToAspirate": "准备", + "pressureDispense": "排液", + "reloadLabware": "重新加载耗材", + "retractAxis": "回升", + "robot/closeGripperJaw": "关闭", + "robot/moveAxesRelative": "移动", + "robot/moveAxesTo": "移动", + "robot/moveTo": "移动", + "robot/openGripperJaw": "打开", + "savePosition": "保存", + "sealPipetteToTip": "压紧移液器", + "setRailLights": "导轨灯", + "setStatusBar": "状态栏", + "setTipState": "设置吸头", + "temperatureModule/deactivate": "停用", + "temperatureModule/setTargetTemperature": "加热或冷却", + "temperatureModule/waitForTemperature": "加热或冷却", + "thermocycler/awaitProfileComplete": "等待循环", + "thermocycler/closeLid": "合上盖子", + "thermocycler/deactivateBlock": "停用", + "thermocycler/deactivateLid": "停用", + "thermocycler/openLid": "打开盖子", + "thermocycler/runExtendedProfile": "循环", + "thermocycler/runProfile": "循环", + "thermocycler/setTargetBlockTemperature": "加热或冷却", + "thermocycler/setTargetLidTemperature": "加热或冷却", + "thermocycler/startRunExtendedProfile": "开始循环", + "thermocycler/waitForBlockTemperature": "加热或冷却", + "thermocycler/waitForLidTemperature": "加热或冷却", + "touchTip": "吸头碰壁", + "tryLiquidProbe": "尝试探测", + "unknown": "未知", + "unsafe/blowOutInPlace": "不安全", + "unsafe/dropTipInPlace": "不安全", + "unsafe/engageAxes": "不安全", + "unsafe/flexStacker/closeLatch": "不安全", + "unsafe/flexStacker/manualRetrieve": "不安全", + "unsafe/flexStacker/openLatch": "不安全", + "unsafe/flexStacker/prepareShuttle": "不安全", + "unsafe/placeLabware": "不安全", + "unsafe/ungripLabware": "不安全", + "unsafe/updatePositionEstimators": "不安全", + "unsealPipetteFromTip": "弹出移液器吸头", + "verifyTipPresence": "吸头存在状态", + "waitForDuration": "等待", + "waitForResume": "等待", + "waitForTasks": "等待" +} diff --git a/app/src/assets/localization/zh/device_details.json b/app/src/assets/localization/zh/device_details.json index 140e74d5450..16abd2e35ad 100644 --- a/app/src/assets/localization/zh/device_details.json +++ b/app/src/assets/localization/zh/device_details.json @@ -9,6 +9,7 @@ "add_fixture_description": "选择下面的一个对象添加到您的桌面配置中。它将在协议分析中被引用。", "add_to": "添加到 {{slotName}}", "add_to_slot": "添加到板位{{slotName}}", + "all_images_deleted": "在此协议运行过程中拍摄的所有图像将会从运行记录中被永久删除。此操作无法撤销。", "an_error_occurred_while_updating": "更新移液器设置时发生错误。", "an_error_occurred_while_updating_module": "更新{{moduleName}}时出现错误,请重试。", "an_error_occurred_while_updating_please_try_again": "更新移液器设置时出错,请重试。", @@ -23,10 +24,15 @@ "calibrate_pipette_offset": "校准移液器数据", "calibration_needed": "需要校准。 立即校准", "calibration_needed_without_link": "需要校准", + "camera": "摄像头", + "cancel": "取消", "canceled": "已取消", "changes_will_be_lost": "更改将丢失", "changes_will_be_lost_description": "确定不保存甲板配置直接退出而吗?", "choose_protocol_to_run": "选择在{{name}}上运行的协议", + "clear_images": "清空图片", + "clear_images_from_run_record": "从运行记录中清除图像", + "clear_run_images": "清除运行图像", "close_lid": "关闭上盖", "completed": "已完成", "confirm": "确认", @@ -48,11 +54,16 @@ "delete_run": "删除协议运行记录", "detach_gripper": "卸下转板抓手", "detach_pipette": "卸下移液器", + "disable_camera": "停用相机", + "disabled": "停用", "discard_changes": "放弃更改", "disengaged": "不启用", "download_run_log": "下载协议运行日志", "drop_tips": "丢弃吸头", + "edit_settings": "编辑设置", "empty": "空", + "enable_camera": "启用摄像头", + "enabled": "启用", "error_details": "错误详情", "estop_disconnected": "急停断开。工作站运动已停止。", "estop_disengaged": "急停解除,但工作站操作仍暂停中。", @@ -116,9 +127,12 @@ "no_recent_runs_description": "运行一些协议后,它们将显示在此处。", "num_units": "{{num}}毫米", "offline_deck_configuration": "工作站必须连接网络才能查看甲板配置", - "offline_instruments_and_modules": "工作站必须连接网络才能查看已连接的设备和模块", + "offline_input_devices": "设备必须连接到网络才能看到已连接的外设。", + "offline_instruments_and_modules": "设备必须连接到网络才能看到连接的仪器、模块和外设", "offline_recent_protocol_runs": "工作站必须连接网络才能查看协议运行情况", + "on_deck": "在工作台上", "open_lid": "打开上盖", + "ot2_camera": "OT-2摄像头", "overflow_menu_about": "关于模块", "overflow_menu_deactivate_block": "停用模块", "overflow_menu_deactivate_lid": "停用上盖", @@ -130,6 +144,7 @@ "overflow_menu_mod_temp": "设置模块温度", "overflow_menu_set_block_temp": "设置模块温度", "overflow_menu_setup_instructions": "显示设置说明", + "peripherals": "外设", "pipette_cal_recommended": "建议进行移液器校准。", "pipette_calibrations_differ": "所连接的移液器校准数据差异过大。正确校准后,这些数据应相近。", "pipette_offset_calibration_needed": "需要进行移液器校准。", @@ -154,7 +169,7 @@ "reset_estop": "重置急停", "resume_operation": "继续操作", "right": "右侧", - "robot_control_not_available": "运行过程中某些工作站控制功能不可用", + "robot_control_not_available": "当运行进行中或设备忙碌时,一些设备控制不可用。", "robot_initializing": "初始化中...", "run": "运行", "run_a_protocol": "运行协议", @@ -198,6 +213,7 @@ "unknown": "未知", "update_now": "立即更新", "updating_firmware": "正在更新固件...", + "usage_settings": "使用设置", "usb_port": "USB-{{port}}", "usb_port_not_connected": "USB未连接", "usb_port_stacker": "S-{{port}}", diff --git a/app/src/assets/localization/zh/device_settings.json b/app/src/assets/localization/zh/device_settings.json index 351c987e623..cc8b8e991a9 100644 --- a/app/src/assets/localization/zh/device_settings.json +++ b/app/src/assets/localization/zh/device_settings.json @@ -4,6 +4,10 @@ "about_calibration_description_ot3": "为了让工作站精确移动,您需要对其进行校准。移液器和转板抓手校准是一个自动化过程,使用校准探头或销钉。校准完成后,您可以将校准数据以JSON文件的形式保存到计算机中。", "about_calibration_title": "关于校准", "add_new": "添加新的...", + "adjust_brightness": "调整整体亮度或暗度。", + "adjust_contrast": "改变明暗区域之间的差异以增强清晰度。", + "adjust_deck_appearance": "调整工作台的远近效果。", + "adjust_saturation": "让颜色看起来更加鲜艳或柔和。", "advanced": "高级", "alpha_description": "警告:alpha版本功能完整,但可能包含重大错误。", "alternative_security_types": "可选的安全类型", @@ -12,8 +16,10 @@ "are_you_sure_you_want_to_disconnect": "您确定要断开与{{ssid}}的连接吗?", "attach_a_pipette_before_calibrating": "在执行校准之前,请安装移液器", "authentication": "验证", + "automatically_capture_image": "在发生错误时自动拍摄工作台的图像。", "boot_scripts": "启动脚本", "both": "两者", + "brightness": "亮度", "browse_file_system": "浏览文件系统", "bug_fixes": "错误修复", "but_we_expected": "但我们预计", @@ -27,6 +33,14 @@ "calibration": "校准", "calibration_health_check_description": "检查关键校准点的精度,无需重新校准工作站。", "calibration_health_check_title": "校准运行状况检查", + "camera": "摄像头", + "camera_controls": "摄像头控制", + "camera_preferences": "摄像头偏好设置", + "camera_preferences_description": "选择在运行期间如何使用工作台摄像头。", + "camera_preferences_description_long": "工作台摄像头在协议运行期间提供实时视频监控,并支持图像拍摄。", + "camera_required": "运行此协议需要摄像头。", + "camera_status": "摄像头状态", + "camera_status_description": "工作台摄像头在协议运行期间提供实时视频监控,并支持图像拍摄——可以手动、自动进行拍摄,或在运行时错误发生时拍摄,以便于故障排除。", "cancel_software_update": "取消软件更新", "change_network": "更改网络", "characters_max": "最多17个字符", @@ -58,6 +72,7 @@ "clear_option_runs_history_subtext": "清除所有协议的过往运行信息。点击并应用", "clear_option_tip_length_calibrations": "清除吸头长度校准", "complete_and_restart_robot": "完成并重新启动工作站", + "configure_camera_settings": "配置摄像头的变焦、亮度、对比度和饱和度。", "confirm_device_reset_description": "这将永久删除所有协议、校准和其他数据。您需要重新进行初始设置才能再次使用工作站。", "confirm_device_reset_heading": "您确定要重置您的设备吗?", "connect": "连接", @@ -77,6 +92,7 @@ "connection_description_ethernet": "连接到您实验室的有线网络。", "connection_description_wifi": "在您的实验室中找到一个网络,或者输入您自己的网络。", "connection_to_robot_lost": "与工作站的连接中断", + "contrast": "对比度", "deck_calibration_description": "新工作站或搬迁工作站后需要校准甲板。重新校准甲板后也将需要重新校准移液器偏移。", "deck_calibration_missing": "缺少甲板校准", "deck_calibration_missing_no_pipette": "缺少甲板校准。安装移液器以执行甲板校准。", @@ -85,6 +101,8 @@ "deck_calibration_modal_title": "您确定要进行校准吗?", "deck_calibration_recommended": "建议进行甲板校准", "deck_calibration_title": "甲板校准", + "default": "默认设置", + "default_zoom": "1倍(默认变焦)", "dev_tools_description": "访问额外的日志记录和功能标志。", "device_reset": "设备重置", "device_reset_description": "将耗材校准、启动脚本和/或工作站校准重置为出厂设置。", @@ -93,6 +111,7 @@ "directly_connected_to_this_computer": "直接连接到这台计算机。", "disable_stacker_sensors": "禁用外置堆栈的耗材 z 轴和 x 轴的感应器", "disable_stacker_sensors_description": "禁用设备连接的所有外置堆栈的感应器", + "disabled": "已禁用", "disconnect": "断开连接", "disconnect_from_ssid": "断开与{{ssid}}的连接", "disconnect_from_wifi": "断开Wi-Fi连接", @@ -117,13 +136,19 @@ "downloading_update": "正在下载更新...", "e_stop_connected": "紧急停止按钮成功连接", "e_stop_not_connected": "将紧急停止按钮连接到工作站背面的端口。", + "edit_settings": "编辑设置", + "enable_camera_to_run": "启用摄像头以开始运行", "enable_status_light": "启用状态灯", "enable_status_light_description": "打开或关闭工作站前部的指示LED灯条。", + "enabled": "已启用", "engaged": "已连接", "enter_factory_password": "输入工厂密码", "enter_name_security_type": "输入网络名称和安全类型。", "enter_network_name": "输入网络名称", "enter_password": "输入密码", + "error_recovery": "错误恢复", + "error_recovery_camera_description": "如果发生错误,自动拍摄一张工作台的图像。", + "error_recovery_lc": "错误图像拍摄", "estop": "紧急停止按钮", "estop_disengaged": "紧急停止按钮已解除", "estop_engaged": "紧急停止按钮已连接", @@ -144,6 +169,7 @@ "find_and_join_network": "查找并加入 Wi-Fi 网络", "finish_setup": "完成设置", "firmware_version": "固件版本", + "free_disk_space": "如果未通过清除先前运行记录中的图像释放存储空间,运行可能会失败。", "fully_calibrate_before_checking_health": "在检查校准健康之前,请完全校准您的工作站", "gantry_homing": "重启时归位龙门架", "gantry_homing_description": "沿Z轴归位龙门架。", @@ -154,7 +180,12 @@ "health_check": "检查健康状态", "hide": "隐藏", "historic_offsets_description": "在设置协议时使用存储的数据。", + "image_preview_timestamp": "图像预览 {{timestamp}}", + "image_storage_almost_full": "图像存储快满了", + "image_video_settings": "图像和视频设置", + "image_video_settings_lc": "图片和视频设置", "incorrect_password_for_ssid": "哎呀!{{ssid}}的密码不正确", + "increase_deck_appearance": "增加甲板的可视距离", "install_e_stop": "安装紧急停止按钮", "installing_software": "正在安装软件...", "installing_update": "正在安装更新...", @@ -170,11 +201,18 @@ "launch_jupyter_notebook": "启动Jupyter Notebook", "legacy_settings": "遗留设置", "likely_incorrect_password": "可能网络密码不正确。", + "live_video": "实时监控", + "live_video_description": "在运行协议时查看工作台的实时监控。", + "live_video_lc": "实时监控", "mac_address": "MAC地址", "manage_oem_settings": "管理OEM设置", + "maximum": "最大值", + "maximum_zoom": "2倍(最大缩放)", "minutes": "{{minute}}分钟", "missing_calibration": "缺少校准", "model_and_serial": "移液器型号和序列号", + "moderate": "适中", + "moderate_zoom": "1.5倍(适中缩放)", "module": "模块", "module_calibration": "模块校准", "module_calibration_description": "模块校准使用移液器和连接的探头来确定模块相对于甲板的确切位置。", @@ -199,6 +237,7 @@ "no_calibration_required": "无需校准", "no_connection_found": "未找到连接", "no_gripper_attached": "未连接转板抓手", + "no_image_available": "暂无图像", "no_modules_attached": "未连接模块", "no_network_found": "未找到网络", "no_pipette_attached": "未连接移液器", @@ -231,6 +270,7 @@ "pipette_offset_calibrations_history": "查看所有移液器偏移校准历史", "pipette_offset_calibrations_title": "移液器偏移校准", "please_check_credentials": "请仔细检查你的网络凭证", + "preview_image": "预览图像", "privacy": "隐私", "problem_during_update": "此次更新耗时比平常要长。", "proceed_without_updating": "跳过更新以继续", @@ -251,13 +291,16 @@ "rename_robot_prefer_usb_connection": "为确保工作站名称更改的可靠性,请通过USB连接。", "rename_robot_title": "重命名工作站", "requires_restarting_the_robot": "更新工作站软件需要重启工作站", + "reset_settings_to_default": "重置为默认值", "reset_to_factory_settings": "重置为出厂设置?", "resets_cannot_be_undone": "重置操作无法撤销", "restart_now": "现在重启?", "restart_robot_confirmation_description": "重启{{robotName}}将需要几分钟时间。", "restart_taking_too_long": "{{robotName}}重启所需时间超出预期。请检查其设置页面中的“高级”选项卡,以确认是否已成功更新。如果工作站无响应,请手动重启。", "restarting_robot": "安装完成,工作站正在重启...", + "restore_to_default": "恢复到默认设置", "resume_robot_operations": "恢复工作站操作", + "retake_preview": "重新拍摄预览", "returns_your_device_to_new_state": "这将使您的设备恢复到新的状态。", "robot_busy_protocol": "当协议正在运行时,此工作站无法更新", "robot_calibration_data": "工作站校准数据", @@ -278,6 +321,8 @@ "robot_up_to_date_description": "您的工作站似乎已经是最新版本,但如果您遇到问题,可以重新应用最新更新。", "robot_update_available": "工作站更新可用", "robot_update_success": "工作站软件已成功更新", + "saturation": "饱和度", + "save": "保存", "search_again": "再次搜索", "searching": "搜索中", "searching_for_networks": "正在搜索网络...", @@ -296,9 +341,10 @@ "show": "显示", "show_password": "显示密码", "sign_into_wifi": "登录Wi-Fi", + "slider_value": "{{value}}%", "software_is_up_to_date": "您的软件已经是最新版本!", "software_update_error": "软件更新错误", - "some_robot_controls_are_not_available": "运行过程中无法使用工作站的控制功能", + "some_robot_controls_are_not_available": "当运行进行中或设备忙碌时,一些设备控制不可用。", "ssh_public_keys": "SSH公钥", "subnet_mask": "子网掩码", "successfully_connected": "成功连接!", @@ -340,14 +386,18 @@ "upload_custom_logo": "上传自定义Logo", "upload_custom_logo_description": "上传一个Logo,用于工作站启动时显示。", "upload_custom_logo_dimensions": "Logo必须符合 1024 x 600 的尺寸,且是 PNG 文件(.png)。", + "usage_preferences": "使用偏好设置", "usage_settings": "使用设置", "usb": "USB", "usb_to_ethernet_description": "正在查找 USB-to-Ethernet 适配器信息?", "use_older_aspirate": "使用旧版吸液动作", "use_older_aspirate_description": "使用在 3.7.0 版本之前使用的较不准确的体积校准进行吸取。如果需要与 3.7.0 版本之前的结果保持一致,请使用此设置。这仅影响 GEN1 P10S、P10M、P50M 和 P300S 移液器。", "validating_software": "正在验证软件...", + "value_percent": "{{value}}%", "view_details": "查看详细信息", "view_network_details": "查看网络详细信息", + "view_realtime_video": "在运行协议时查看工作台的实时监控。", + "view_recent_runs": "查看最近的运行记录", "view_update": "查看更新", "welcome_description": "在实验室台面上快速运行协议并检查工作站状态。", "wifi": "Wi-Fi", @@ -363,5 +413,6 @@ "wpa2_personal_description": "大多数实验室都使用此方法", "you_should_not_downgrade": "您不应降级到工作站制造日期之前的软件版本。", "your_mac_address_is": "您的MAC地址是{{macAddress}}", - "your_robot_is_ready_to_go": "您的工作站已准备就绪。" + "your_robot_is_ready_to_go": "您的工作站已准备就绪。", + "zoom": "缩放" } diff --git a/app/src/assets/localization/zh/devices_landing.json b/app/src/assets/localization/zh/devices_landing.json index 4c649c41b02..a8060497f84 100644 --- a/app/src/assets/localization/zh/devices_landing.json +++ b/app/src/assets/localization/zh/devices_landing.json @@ -29,6 +29,7 @@ "no_robots_found": "未找到工作站", "not_available": "不可用({{count}})", "ot2_quickstart_guide": "OT-2 快速入门指南", + "peripherals": "外设", "refresh": "刷新", "restart_the_app": "重启应用程序", "restart_the_robot": "重启工作站", diff --git a/app/src/assets/localization/zh/error_recovery.json b/app/src/assets/localization/zh/error_recovery.json index a1c6c0d6054..b9580fad040 100644 --- a/app/src/assets/localization/zh/error_recovery.json +++ b/app/src/assets/localization/zh/error_recovery.json @@ -8,7 +8,7 @@ "blowout_failed": "吹出液体失败", "cancel_run": "取消运行", "canceling_run": "正在取消运行", - "carefully_clear_track": "小心清理路径上所有没有固定的耗材或障碍物。继续操作之前,请关闭设备前门。", + "carefully_clear_track": "仔细清理轨道上的任何松动的耗材或障碍物。在继续之前,请关闭设备门。", "carefully_move_labware": "小心地移开放错位置的实验用品并清理溢出的液体。继续操作之前请关闭设备前门。", "change_location": "更改位置", "change_tip_pickup_location": "更换拾取吸头的位置", @@ -17,7 +17,7 @@ "clear_obstruction_in_stacker_and_retry_step": "清除外置堆栈中的障碍物并重试步骤", "clear_obstruction_in_stacker_and_skip_to_next_step": "清除外置堆栈中的障碍物并跳至下一步", "clear_obstructions_before_proceeding": "继续之前,清除障碍物 ", - "clear_track_of_obstructions": "清除障碍物", + "clear_track_of_obstructions": "清理轨道上的障碍物", "clear_track_of_obstructions_and_close_door": "小心清理路径上所有非固定耗材或障碍物。继续操作之前,请关上设备门和外置堆栈门。", "close_door_to_resume": "关闭工作站门以继续", "close_robot_and_stacker_door": "关上设备门和外置堆栈门", @@ -32,11 +32,12 @@ "door_open_robot_home": "在手动移动实验用品前,设备需要安全归位。", "droplets_or_liquid_cause_failure": "吸头内的液滴或液体可能会导致液位检测失败", "empty_shuttle_to_retry_retrieve": "清空耗材滑台,以便外置堆栈能够重试取出命令。", + "empty_shuttle_to_retry_store": "清空耗材滑台,以便外置堆栈能够重试存储命令", "empty_stacker_of_all_labware": "清空外置堆栈中闩锁上方的所有实验耗材。", "empty_stacker_of_labware_above_latch": "清空外置堆栈中闩锁上方的实验耗材。", "ensure_lw_is_accurately_placed": "确保实验耗材已准确放置在甲板槽中,防止进一步出现错误", "ensure_stacker_has_labware": "请确认外置堆栈中有实验耗材", - "ensure_stacker_shuttle_empty": "请确认外置堆栈耗材滑台是空的", + "ensure_stacker_shuttle_empty": "确保外置堆栈的耗材滑台是空的。", "error": "错误", "error_details": "错误详情", "error_on_robot": "{{robot}}上的错误", @@ -61,26 +62,29 @@ "ignore_only_this_error": "仅忽略此错误", "ignore_similar_errors_later_in_run": "要在后续的运行中忽略类似错误吗?", "inspect_the_robot": "首先,检查设备以确保它已准备好从下一步继续运行。然后,关闭设备前门,再继续操作。", - "is_there_labware_stuck_on_the_stacker_latch": "外置堆栈闩锁上是否有实验耗材卡住?", + "is_there_labware_stuck_on_the_stacker_latch": "外置堆栈的闩锁上是否卡住了耗材?", "labware_missing_detected_when": "当设备尝试从空的外置堆栈中取出实验耗材时,会报错", "labware_not_retrieved": "外置堆栈闩锁卡住了", "labware_released_from_current_height": "将从当前高度释放实验耗材", - "labware_stuck_on_latch": "卡在闩锁上的实验耗材将在稍后的恢复过程中重新被取出。", + "labware_stuck_on_latch": "卡在闩锁上的实验耗材将在稍后的恢复过程中被取出。", "latch_releasing_labware": "闩锁正在取出实验耗材", "latch_will_release_in_s": "闩锁将在 {{seconds}} s内释放实验耗材", "launch_recovery_mode": "启动恢复模式", - "load_labware_into_labware_shuttle": "将实验耗材装载到耗材滑台上", - "load_labware_into_stacker_and_retry_step": "将实验耗材装入外置堆栈并重试步骤", + "load_labware_into_labware_shuttle": "将耗材装载到耗材滑台上", + "load_labware_into_stacker_and_retry_step": "将实验耗材装入外置堆栈并重试该步骤", + "load_labware_onto_shuttle_and_retry": "将耗材加载到滑台上并重试步骤", "load_labware_shuttle_and_retry_step": "安装耗材滑台并重试步骤", - "load_labware_shuttle_onto_track": "将耗材滑台安装到轨道上", + "load_labware_shuttle_onto_track": "将耗材滑台放到轨道上", + "load_labware_shuttle_to_proceed": "用正确的耗材装载到滑台,以完成外置堆栈的存储步骤", "load_stacker_shuttle_to_proceed": "将外置堆栈滑台安装到轨道上以继续。", "load_stacker_with_correct_labware": "将正确的实验耗材装入外置堆栈以完成取出耗材的步骤", - "make_sure_loaded_correct_number_of_labware_stacker": "请确保将正确数量的实验耗材装入外置堆栈。", + "make_sure_loaded_correct_number_of_labware_stacker": "确保将正确数量的耗材装入堆栈中", "manually_fill_liquid_in_well": "手动填充孔位{{well}}中的液体", "manually_fill_well_and_retry_new_tips": "手动填充好并用新吸头重试", "manually_fill_well_and_retry_same_tips": "手动填充并用相同的提示重试", - "manually_load_labware_into_labware_shuttle_and_skip_step": "手动将实验耗材装载到滑台上并跳过步骤", - "manually_load_labware_into_shuttle_and_skip": "手动将实验耗材装载到滑台上并跳过步骤", + "manually_load_labware_into_labware_shuttle_and_skip_step": "手动将耗材装载到耗材滑台上,并跳过该步骤", + "manually_load_labware_into_shuttle_and_skip": "手动将耗材装载到耗材滑台上,并跳过该步骤", + "manually_load_labware_into_stacker_and_skip": "手动将耗材加载到外置堆栈中并跳过步骤", "manually_move_lw_and_skip": "手动移动实验耗材并跳至下一步", "manually_move_lw_on_deck": "手动移动实验耗材", "manually_replace_lw_and_retry": "手动更换实验耗材并重试此步骤", @@ -93,8 +97,8 @@ "pick_up_tips": "取吸头", "pipette_overpressure": "移液器超压", "prepare_deck_for_homing": "整理甲板,准备归位", - "prepare_for_stacker_latch_reengage": "准备重新启用外置堆栈闩锁", - "prepare_track_for_homing": "准备归位", + "prepare_for_stacker_latch_reengage": "准备让堆栈的闩锁重新锁定", + "prepare_track_for_homing": "准备轨道以便归位", "proceed_to_cancel": "继续取消", "proceed_to_home": "归位中", "proceed_to_tip_selection": "继续选择吸头", @@ -107,6 +111,7 @@ "release_labware_from_gripper": "从抓板手中释放实验耗材", "release_labware_from_latch": "从闩锁释放实验耗材", "remove_any_attached_tips": "移除任何已安装的吸头", + "remove_labware_from_shuttle_to_proceed": "从滑台上移除耗材,以完成外置堆栈的取出步骤", "replace_labware_in_stacker_and_retry": "更换外置堆栈内的耗材并重试步骤", "replace_labware_in_stacker_and_step": "更换外置堆栈内的耗材并重试该步骤", "replace_tips_and_select_loc_partial_tip": "更换吸头并选择最后用于偏转移液吸头拾取的位置。", @@ -129,7 +134,7 @@ "robot_not_attempt_to_move_lw": "采取必要的措施以准备设备从下一步继续运行。在继续之前,请关闭设备门。", "robot_retry_failed_lw_movement": "工作站将会从更换耗材的位置重新尝试失败的移液步骤。在继续之前,请关闭工作站门。", "robot_will_not_check_for_liquid": "工作站将不再检查液体。运行将从下一步继续。继续前请关闭工作站前门。", - "robot_will_retry_failed_step": "设备将重试失败的取出步骤。继续操作之前,请关上设备前门。", + "robot_will_retry_failed_step": "设备将重试执行失败的取出步骤。继续操作之前,请关上设备前门。", "robot_will_retry_with_new_tips": "工作站将使用新吸头重试失败的步骤。继续前请关闭工作站前门。", "robot_will_retry_with_same_tips": "工作站将使用相同的吸头重试失败的步骤。继续前请关闭工作站前门。", "robot_will_retry_with_tips": "工作站将使用新吸头重试失败的步骤。", @@ -145,12 +150,16 @@ "stacker_door_open_robot_home": "在手动移动实验耗材前,设备需要安全归位。", "stacker_empty": "外置堆栈为空", "stacker_error": "外置堆栈错误", + "stacker_hopper_or_shuttle_empty_error_occurs_when": "当设备要求外置堆栈状态为装满时,如果外置堆栈为空,或者耗材卡在耗材锁扣上,就会发生外置堆栈错误", "stacker_is_empty": "外置堆栈为空", "stacker_latch_is_jammed": "外置堆栈闩锁卡住", - "stacker_latch_jammed_errors_occur_when": "外置堆栈闩锁卡住错误是指实验室器具卡在外置堆栈闩锁之间。这通常是由于耗材放置不当或耗材定义不准确造成的。", - "stacker_latch_will_reengage": "外置堆栈闩锁将重新启用,以便您重新装载外置堆栈。请确保所有障碍物均已清除。", - "stacker_shuttle_full": "外置堆栈滑台已满", + "stacker_latch_jammed_errors_occur_when": "当耗材卡在堆栈的闩锁之间时,会发生堆栈闩锁卡住的错误。这通常是由于耗材放置不当或耗材定义不准确造成的", + "stacker_latch_will_reengage": "堆栈的闩锁将重新锁定,以便您可以重新装载堆栈。请确保所有障碍物已被清除。", + "stacker_shuttle_full": "堆栈滑台正在使用中", "stacker_shuttle_missing_error_occurs_when": "当外置堆栈滑台未正确放置在轨道上时,会发生“未检测到外置堆栈耗材滑台”错误。", + "stacker_shuttle_occupied_error_occurs_when": "滑台应该为空,其上却有耗材", + "stacker_shuttle_store_empty": "滑台为空", + "stacker_shuttle_store_empty_error_occurs_when": "当设备尝试将耗材从滑台存储到外置堆栈时,如果滑台上为空,会发生滑台为空错误", "stacker_stall_or_collision_error": "外置堆栈停止工作", "stacker_what_is_wrong": "外置堆栈出了什么问题?", "stall_or_collision_detected_when": "当设备电机堵塞时,检测到失速或碰撞", @@ -162,8 +171,8 @@ "stand_back_skipping_to_next_step": "请远离,正在跳到下一步骤", "static_meniscus_less_accurate": "如果使用静态弯月面移液,跳过液体存在检测时液体跟踪可能不太准确。", "take_any_necessary_precautions": "在接住实验耗材之前,请采取必要的预防措施。确认后,夹爪将开始倒计时再释放。", - "take_any_necessary_precautions_before_loading_shuttle": "将耗材滑台装载到轨道上之前,请先采取必要的预防措施。", - "take_any_necessary_precautions_for_latch_release": "请先做好必要的防护,准备好在需要时稳住或接住实验器皿。确认后会开始倒计时,然后闩锁会释放。", + "take_any_necessary_precautions_before_loading_shuttle": "在将耗材滑台加载到轨道上之前,请采取任何必要的预防措施。", + "take_any_necessary_precautions_for_latch_release": "在准备支撑或接住耗材之前,请采取任何必要的预防措施。一旦确认,闩锁释放前将开始倒计时。", "take_necessary_actions": "采取任何必要的措施,准备让设备重试失败的步骤。在继续之前,请关闭设备门。", "take_necessary_actions_failed_pickup": "首先,采取任何必要的行动,让工作站重新尝试移液器拾取。然后,在继续之前关闭工作站门。", "take_necessary_actions_failed_tip_drop": "首先,采取一切必要的措施,让设备准备好重试弹出吸头。然后,关闭设备前门,再继续操作。", @@ -173,6 +182,7 @@ "tip_drop_failed": "丢弃吸头失败", "tip_not_detected": "未检测到吸头", "tip_presence_errors_are_caused": "吸头识别错误通常是由实验器皿放置不当或器皿偏移不准确引起的。", + "troubleshoot_issue_complete_retrieve_step": "排除问题以完成外置堆栈的取出步骤", "use_dry_unused_tips": "使用干燥、未使用过的吸头以获得最佳效果", "view_error_details": "查看错误详情", "view_recovery_options": "查看恢复选项", diff --git a/app/src/assets/localization/zh/labware_position_check.json b/app/src/assets/localization/zh/labware_position_check.json index 17cb68de019..b2812813749 100644 --- a/app/src/assets/localization/zh/labware_position_check.json +++ b/app/src/assets/localization/zh/labware_position_check.json @@ -48,7 +48,7 @@ "default_location_offset_adjusted": "已调整默认耗材校准数据", "default_offset_description": "除非您调整校准数据,否则放置的耗材都将使用默认数据。", "detach_probe": "移除校准探头", - "ensure_labware_accurately_placed": "请按照说明将耗材准确放置在甲板位上。", + "ensure_labware_accurately_placed": "确保耗材根据提供的说明准确放置在工作台,以防止损坏。", "ensure_nozzle_position_desktop": "确保 {{tip_type}} 居中并与 {{item_location}} 水平对齐。如果不对齐,请使用下面的控制器或键盘来移动移液器,直到其正确对齐。", "ensure_nozzle_position_odd": "确保 {{tip_type}} 居中并与 {{item_location}} 水平对齐。如果不对齐,请点击 移动移液器,然后调整移液器,直到其正确对齐。", "ensure_probe_attached": "继续操作之前请确保其已正确连接。", diff --git a/app/src/assets/localization/zh/module_wizard_flows.json b/app/src/assets/localization/zh/module_wizard_flows.json index 5bb86622331..ffb80376d1c 100644 --- a/app/src/assets/localization/zh/module_wizard_flows.json +++ b/app/src/assets/localization/zh/module_wizard_flows.json @@ -12,7 +12,7 @@ "calibration_probe_touching_thermocycler": "校准探头将接触探测热循环仪的校准方块,以确定其确切位置", "checking_firmware": "检查{{module}}固件", "close_doors": "关闭设备门和外置堆栈门", - "close_doors_description": "在您将耗材滑台放到轨道上之前,设备需要先安全移动到其初始位置", + "close_doors_description": "设备需要安全地归位后,您才能将耗材滑台放置到轨道上。", "close_stacker_doors": "关闭设备门和所有外置堆栈门", "complete_calibration": "完成校准", "confirm_location": "确认位置", @@ -24,12 +24,12 @@ "error_during_setup": "模块设置期间发生错误", "error_prepping_module": "模块设置准备出错", "error_stacker_not_installed": "外置堆栈安装不正确", - "error_stacker_not_installed_message": "外置堆栈安装出现问题。请确保所有侧面板已安装且堆栈门已关闭。请重试安装或联系支持人员。", + "error_stacker_not_installed_message": "安装外置堆栈时出现问题。确保所有侧板已更换,并且外置堆栈门已关闭。请再次尝试安装或联系技术支持人员。", "exit": "退出", "finish": "结束", "firmware_up_to_date": "{{module}}固件已是最新版本。", "firmware_update_failed": "无法更新固件", - "firmware_update_failed_try_again": "为 {{module}} 安装最新固件时出现问题。请重试或联系支持人员。", + "firmware_update_failed_try_again": "{{module}}安装最新固件时出现问题。请再次尝试安装或联系技术支持人员。", "firmware_update_found": "发现固件更新", "firmware_update_to_latest": "在继续之前,请将{{module}}更新到最新固件", "firmware_updated": "{{module}}固件已更新!", @@ -44,7 +44,7 @@ "module_added_link": "启动设置", "module_attached_multiple": "检测到多个新模块。您想设置哪个模块?", "module_attached_select": "{{port}} 的 {{module}}", - "module_attached_to_port": "新的 {{module}} 已连接至 {{port}}!", + "module_attached_to_port": "新 {{module}} 已连接到 {{port}}!", "module_calibrating": "{{moduleName}}正在校准中,请远离", "module_calibration": "模块校准", "module_heating_or_cooling": "模块正在升降温时无法进行模块校准", @@ -59,14 +59,14 @@ "place_flush_heater_shaker": "将适配器水平放到模块上。使用热震荡模块专用螺丝和 T10 Torx 螺丝刀将适配器固定到模块上。", "place_flush_thermocycler": "确保热循环仪盖子已打开,并将适配器水平放置到模块上,即通常放置pcr板的位置。", "place_shuttle": "将耗材滑台放置在轨道上", - "place_shuttle_description": "将磁性耗材滑台与轨道顶部齐平放置", + "place_shuttle_description": "将磁性耗材滑台平整地放置在轨道顶部。", "prepping_module": "准备 {{module}} 进行模块设置", "recalibrate": "重新校准", "select_location": "选择模块放置位置", "select_the_slot": "选择您安装连接到{{port}}的{{module}}的板位", "setup_another_module": "设置另一个模块", "shuttle_install_fail": "耗材滑台安装不正确", - "shuttle_install_fail_description": "耗材滑台的安装出现问题。请重试安装,或联系支持。", + "shuttle_install_fail_description": "耗材滑台的安装出现问题。请您重新安装或联系技术支持。", "skip": "跳过", "slot_unavailable": "板位不可用", "stand_back": "正在进行校准,请远离", diff --git a/app/src/assets/localization/zh/pipette_wizard_flows.json b/app/src/assets/localization/zh/pipette_wizard_flows.json index da8bcbdb665..577f31b29a9 100644 --- a/app/src/assets/localization/zh/pipette_wizard_flows.json +++ b/app/src/assets/localization/zh/pipette_wizard_flows.json @@ -10,6 +10,7 @@ "attach_pip": "安装移液器", "attach_pipette": "安装{{mount}}移液器", "attach_probe": "安装校准探头", + "attach_wastechute": "重新连接外置垃圾槽", "backmost": "最后面的", "before_you_begin": "开始之前", "begin_calibration": "开始校准", @@ -47,6 +48,7 @@ "hold_pipette_carefully": "用手扶住移液器防止掉落。对准对接孔,安装移液器。使用螺丝刀拧紧前面的四个螺丝,确保稳固连接。", "how_to_reattach": "将右侧移液器支架推到Z轴的顶部。然后拧紧移液器支架右上方的固定螺丝。拧紧固定螺丝后,右侧支架应不能再自由上下移动。", "install_probe": "从存放位置取出校准探头。确保其锁套旋钮已拧松。将校准探头对准移液器{{location}}位置,然后向上轻推并压到顶部。拧紧锁套旋钮。用手轻触探头测试是否稳固。", + "install_waste_chute": "将外置垃圾槽连接到甲板适配器", "loose_detach": "拧松螺丝并卸下", "move_gantry_to_front": "将龙门架移至前方", "must_detach_mounting_plate": "在使用其他移液器之前,您必须卸下安装板并重新连接移液器支架z轴板。我们不建议在完成前退出此过程。", @@ -74,6 +76,7 @@ "remove_labware": "开始前,请清除甲板上的实验耗材,清理工作区,以便校准。同时收集屏幕显示的所需设备。校准探头随工作站提供,应存放在工作站的右前方支柱上。", "remove_labware_to_get_started": "开始前,请清除甲板上的实验耗材,清理工作区,以便校准。同时收集屏幕显示的所需设备。校准探头随工作站提供,应存放在工作站的右前方支柱上。", "remove_probe": "拧松校准探头,将其从喷嘴上拆下,并放回存储位置。", + "remove_wastechute": "移除外置垃圾槽", "replace_pipette": "更换{{mount}}移液器", "return_probe_error": "退出前,请将校准探头放回其存放位置。 {{error}}", "single_mount_attached_error": "当此为96通道流程时,请选择单支架移液器", @@ -84,8 +87,10 @@ "unscrew_and_detach": "拧松螺丝并卸下安装板", "unscrew_at_top": "拧松z轴板右上角的固定螺丝。这将解除右侧移液器支架的锁定,使其能够自由上下移动。", "unscrew_carriage": "拧松Z轴板螺丝", - "waste_chute_error": "在继续之前,请卸下外置垃圾槽。", - "waste_chute_warning": "如果安装了外置垃圾槽,请在继续之前将其卸下。", + "waste_chute_attach_warning": "重新连接外置垃圾槽以匹配当前的桌面配置。", + "waste_chute_error": "在校准前请取出外置垃圾槽。", + "waste_chute_warning": "如果安装了外置垃圾槽,移液器将会与其发生碰撞。", + "waste_chute_warning_probe": "如果外置垃圾槽已安装,请在继续之前将其从甲板适配器中移除。", "wrong_pip": "安装了错误的设备", "z_axis_still_attached": "Z轴板螺丝仍然处于锁定状态" } diff --git a/app/src/assets/localization/zh/protocol_details.json b/app/src/assets/localization/zh/protocol_details.json index bb958c97cb1..a201280e55a 100644 --- a/app/src/assets/localization/zh/protocol_details.json +++ b/app/src/assets/localization/zh/protocol_details.json @@ -12,6 +12,7 @@ "creation_method": "创建方法", "csv_file": "CSV文件", "csv_file_type_required": "需要CSV文件类型", + "date_added": "添加日期", "deck": "甲板", "deck_view": "甲板视图", "default_value": "默认值", @@ -97,5 +98,6 @@ "value_out_of_range": "值必须在{{min}}-{{max}}之间", "view_run_details": "查看运行详情", "view_unavailable_robots": "在设备页面查看不可用的工作站", + "visualize": "可视化", "with_lid_name": "和 {{lid}}" } diff --git a/app/src/assets/localization/zh/protocol_list.json b/app/src/assets/localization/zh/protocol_list.json index bb931b9e39d..06004f20f09 100644 --- a/app/src/assets/localization/zh/protocol_list.json +++ b/app/src/assets/localization/zh/protocol_list.json @@ -9,6 +9,7 @@ "loading_data": "正在加载数据...", "modules": "模块", "no_data": "无数据", + "peripherals": "外设", "protocol_analysis_failure": "协议分析失败。", "protocol_analysis_outdated": "协议分析已过期。", "protocol_deleted": "协议已删除", diff --git a/app/src/assets/localization/zh/protocol_setup.json b/app/src/assets/localization/zh/protocol_setup.json index 9546b15c7f1..ae8f0854830 100644 --- a/app/src/assets/localization/zh/protocol_setup.json +++ b/app/src/assets/localization/zh/protocol_setup.json @@ -45,8 +45,15 @@ "calibration_required_attach_pipette_first": "需要校准,请先连接移液器", "calibration_required_calibrate_pipette_first": "需要校准,请先校准移液器", "calibration_status": "校准状态", + "camera": "摄像头", + "camera_disabled": "摄像头已禁用", + "camera_enabled": "摄像头已启用", + "camera_settings": "摄像头设置", + "camera_setup_step_description": "检查此协议运行的摄像头偏好设置。", + "camera_setup_step_title": "摄像头", "cancel_and_restart_to_edit": "取消运行并重新启动设置以进行编辑", "check_locations_and_volumes": "检查位置和体积", + "check_preferences": "检查偏好设置", "choose_csv_file": "选择CSV文件", "choose_enum": "选择{{displayName}}", "cli_ssh": "命令行界面 (SSH)", @@ -59,6 +66,7 @@ "confirm_liquids": "确认试剂", "confirm_locations_and_volumes": "确认位置和体积", "confirm_placements": "确认放置位置", + "confirm_preferences": "确认偏好设置", "confirm_selection": "确认选择", "confirm_values": "确认这些值", "connect_all_hardware": "首先连接并校准所有硬件", @@ -85,6 +93,9 @@ "deck_map": "甲板布局图", "default_values": "默认值", "download_files": "下载文件", + "enable_camera": "启用摄像头以继续操作", + "enable_camera_to_proceed": "启用摄像头以继续", + "enabled": "已启用", "example": "示例", "exit_to_deck_configuration": "退出到甲板配置", "extension_mount": "扩展安装支架", @@ -104,6 +115,7 @@ "home_stacker_warning_description": "为了您的安全,外置堆栈归位之前,请确认门已关闭。", "home_stacker_warning_title": "关闭设备和外置堆栈的门", "how_offset_data_works": "耗材校准数据如何工作", + "image_storage_almost_full": "图像存储几乎已满", "individiual_well_volume": "单个孔体积", "initial_liquids_num": "{{count}}种初始试剂", "initial_liquids_num_plural": "{{count}}种初始试剂", @@ -204,7 +216,7 @@ "multiple_modules_learn_more": "了解更多关于使用相同类型的多个模块的信息", "multiple_modules_missing_plural": "缺少{{count}}个模块", "multiple_modules_modal": "设置相同类型的多个模块", - "multiple_of_most_modules": "通过以特定顺序连接和加载模块,可以在单个Python协议中使用多种模块类型。无论模块占用哪个甲板板位,工作站都将首先初始化连接到最小编号端口的匹配模块,。", + "multiple_of_most_modules": "通过以特定顺序连接和加载模块,可以在单个Python协议中使用多种模块类型。无论模块占用哪个甲板板位,工作站都将首先初始化连接到最小编号端口的匹配模块。", "must_have_labware_and_pip": "协议中必须加载耗材和移液器", "n_a": "不可用", "name": "名称", @@ -296,6 +308,7 @@ "restart_setup_and_try": "重新开始设置并尝试使用不同的参数值。", "restore_default": "恢复默认值", "restore_defaults": "恢复默认值", + "review_camera_preferences": "请检查您在此协议运行中的摄像头偏好设置。", "robot_cal_description": "工作站校准用于确定其相对于甲板的位置。良好的工作站校准对于成功运行协议至关重要。工作站校准包括3个部分:甲板校准、吸头长度校准和移液器偏移校准。", "robot_cal_help_title": "工作站校准的工作原理", "robot_calibration_step_description": "查看该协议所需的移液器和吸头长度校准。", @@ -356,5 +369,6 @@ "with_lid": "和 {{lidDisplayName}}", "with_the_chosen_value": "使用选定的值时,发生以下错误:", "you_can_still_adjust_offsets": "您仍然可以通过运行耗材校准来调整校准数据。", - "you_havent_confirmed": "您尚未确认 {{missingSteps}}。在继续运行协议之前,请确保这些步骤正确无误。" + "you_havent_confirmed": "您尚未确认 {{missingSteps}}。在继续运行协议之前,请确保这些步骤正确无误。", + "you_havent_confirmed_lpc_missing": "您还未确认 {{missingSteps}}。在进行协议运行之前,请确保这些步骤是正确的。开始运行将应用现有的校准数据。" } diff --git a/app/src/assets/localization/zh/protocol_visualization.json b/app/src/assets/localization/zh/protocol_visualization.json new file mode 100644 index 00000000000..0b155754385 --- /dev/null +++ b/app/src/assets/localization/zh/protocol_visualization.json @@ -0,0 +1,32 @@ +{ + "active": "活动", + "deck_view": "桌面视图", + "destination_labware": "目标耗材", + "destination_tips": "目标吸头", + "destination_well_view": "目标孔位视图", + "disposal": "废弃物投放处", + "labware": "耗材", + "left_mount": "左侧安装位", + "left_right_mount": "左侧 + 右侧安装位", + "lids_in_trash": "垃圾桶里的盖子", + "none_attached": "无附加内容", + "percent_complete": "{{percent}}% 完成", + "pipette": "移液器", + "quantity": "数量:{{quantity}}", + "remaining_tips": "{{remaining}} 个吸头", + "right_mount": "右侧安装位", + "seconds_per_step": "每步 {{seconds}} 秒", + "source_well_view": "源孔视图", + "step": "第 {{number}} 步", + "timeline": "时间轴", + "tipPickup": "吸头拾取", + "tip_disposal": "吸头处理", + "tips_in_trash": "垃圾桶里的吸头", + "tips_remaining": "吸头剩余", + "trash": "垃圾", + "trash_bin": "垃圾桶", + "well_dimension": "{{number}} mm", + "well_name": "孔位 {{wellName}}", + "well_view": "孔位视图", + "well_volume": "{{volume}} µl" +} diff --git a/app/src/assets/localization/zh/quick_transfer.json b/app/src/assets/localization/zh/quick_transfer.json index af6695c04cf..d25e0c0ff74 100644 --- a/app/src/assets/localization/zh/quick_transfer.json +++ b/app/src/assets/localization/zh/quick_transfer.json @@ -37,6 +37,7 @@ "blow_out_waste_chute": "外置垃圾槽", "blowout_flow_rate_µL": "吹出速度(µL/s)", "both_mounts": "左侧+右侧支架", + "bottom": "底部", "change_tip": "更换吸头", "character_limit_error": "字数超出限制", "column": "列", @@ -57,8 +58,8 @@ "delay": "延迟", "delay_after_aspirating": "吸液后延迟", "delay_before_dispensing": "分液前的延迟", - "delay_description_aspirate": "每次吸液和吸入一段空气后的延迟", - "delay_description_dispense": "每次排液后的延迟", + "delay_description_aspirate": "每次吸液和吸入一段空气后延迟", + "delay_description_dispense": "每次排液后延迟", "delay_duration_s": "延迟时长(s)", "delay_value": "{{delay}} s", "delete_this_transfer": "确定删除此这个快速移液?", @@ -75,12 +76,13 @@ "dispense_tip_position": "分液吸头位置", "dispense_volume": "每孔排液体积", "dispense_volume_µL": "每孔排液体积(µL)", - "disposal_volume": "额外体积", - "disposal_volume_flow_rate": "介于 {{min}} 和 {{max}} 之间", - "disposal_volume_label": "{{volume}},{{location}},{{flowRate}} µL/s", + "disposal_volume": "处理体积", + "disposal_volume_flow_rate": "在 {{min}} 和 {{max}} 之间", + "disposal_volume_label": "{{volume}} µL, {{location}}, {{flowRate}} µL/s", "disposal_volume_µL": "废液量(µL)", "distance_bottom_of_well_mm": "距离孔底的高度(mm)", "distance_from_bottom": "距离孔底部的距离(毫米)", + "distance_top_of_well_mm": "距离孔位顶部 (mm)", "distribute": "一吸多分", "distribute_volume_error": "所选源孔太小,无法从中分液。请尝试向更少的孔中分液。", "do_not_use_liquid_class": "不使用液体类型设置", @@ -90,6 +92,7 @@ "failed_analysis": "分析失败", "flow_rate_value": "{{flow_rate}} µL/s", "from_bottom": "在 0 到 {{max}} mm之间", + "from_top": "在 {{min}} 和 2 mm之间", "got_it": "明白了", "grid": "网格", "grids": "网格", @@ -138,14 +141,14 @@ "quick_transfer": "快速移液", "quick_transfer_volume": "快速移液{{volume}}µL", "reservoir": "储液槽", - "reset_kind_settings": "重置 {{transferName}} 设置?", + "reset_kind_settings": "重置 {{transferName}} 设置吗?", "reset_settings": "重置 {{transferName}} 设置", - "reset_settings_description": "继续将撤销所有更改,并将 {{transferName}} 的设置恢复为默认值。", + "reset_settings_description": "继续将撤销所有更改,并将 {{transferName}} 设置恢复为默认值。", "reset_settings_with_liquid_class_description": "继续将撤销所有更改,并将 {{transferName}} 的设置恢复为 {{liquidClassName}} 液体类型数值。", "retract": "抽离", "retract_after_aspirating": "吸液后抽离", "retract_after_dispensing": "排液后抽离", - "retract_value": "{{speed}} mm/s,{{delayDuration}} s,距底部{{position}} mm", + "retract_value": "{{speed}} 毫米/秒, {{delayDuration}} 秒, 距离 {{positionReference}} 处 {{position}} 毫米", "right_mount": "右侧支架", "run_now": "立即运行", "run_quick_transfer_now": "您想立即运行快速移液流程吗?", @@ -160,7 +163,7 @@ "select_dest_labware": "选择目标实验耗材", "select_dest_wells": "选择目标孔位", "select_liquid_class": "选择液体类型", - "select_pipette_path": "选择移液路径", + "select_pipette_path": "选择移液方式", "select_source_labware": "选择源实验耗材", "select_source_wells": "选择源孔位", "select_tip_drop_location": "选择吸头丢弃位置", @@ -180,7 +183,7 @@ "submerge_before_aspirating": "吸液前先浸没", "submerge_before_dispensing": "排液前先浸没", "submerge_dispense_description": "在排液前先将吸头浸没到液面下", - "submerge_value": "{{speed}} mm/s,{{delayDuration}} s,{{position}} mm", + "submerge_value": "{{speed}} 毫米/秒, {{delayDuration}} 秒, 距离 {{positionReference}} 处 {{position}} 毫米", "tip_change_frequency": "吸头更换频率", "tip_drop_location": "吸头丢弃位置", "tip_management": "吸头管理", @@ -189,6 +192,7 @@ "tip_rack": "吸头盒", "too_many_pins_body": "删除一个快速移液,以便向您的固定列表中添加更多传输。", "too_many_pins_header": "您已达到上限!", + "top": "顶部", "touch_tip": "碰壁动作", "touch_tip_after_aspirating": "吸液后触碰吸头", "touch_tip_after_dispensing": "排液后轻触", @@ -199,7 +203,7 @@ "touch_tip_value": "{{speed}} mm/s,距底部{{position}} mm", "transfer_analysis_failed": "快速移液分析失败", "transfer_name": "移液名称", - "transfer_pipette_path_incompatible": "所选移液路径与该液体类型不兼容", + "transfer_pipette_path_incompatible": "所选移液方式与该液体类型不兼容", "transfer_volumes_incompatible": "10 µL 或以下的移液体积与液体类型不兼容", "trashBin": "垃圾桶", "trashBin_location": "位于{{slotName}}的垃圾桶", diff --git a/app/src/assets/localization/zh/run_details.json b/app/src/assets/localization/zh/run_details.json index 5b1808a3e30..ce4bba803d7 100644 --- a/app/src/assets/localization/zh/run_details.json +++ b/app/src/assets/localization/zh/run_details.json @@ -1,10 +1,16 @@ { + "all_available_download": "与此运行相关的所有文件都可以在设备最近的运行记录上找到。图像文件也可以通过摄像头标签中访问。", "analysis_failure_on_robot": "尝试在{{robotName}}上分析{{protocolName}}时发生错误。请修复以下错误,然后再次尝试运行此协议。", "analyzing_on_robot": "移液工作站分析中", "anticipated": "预期步骤", "anticipated_step": "预期步骤", "apply_stored_data": "应用存储的数据", "apply_stored_labware_offset_data": "应用已储存的耗材校准数据?", + "camera": "摄像头", + "camera_disabled": "此运行的实时监控已禁用", + "camera_load_failed": "摄像头加载失败", + "camera_loading": "摄像头加载", + "camera_relaunch": "重新启动摄像头以重试。", "cancel_run": "取消运行", "cancel_run_alert_info_flex": "该动作将终止本次运行并使移液器归位。", "cancel_run_alert_info_ot2": "该动作将终止本次运行,已拾取的吸头将被丢弃,移液器将归位。", @@ -28,6 +34,9 @@ "comment": "注释", "comment_step": "注释", "complete_protocol_to_download": "完成协议以下载运行日志", + "confirm_camera_preferences": "确认摄像头设置", + "confirm_camera_preferences_desc": "确认您摄像头设置,以便在运行设置期间查看实时监控。", + "csv_available_download": "与此运行相关的所有文件都可以在设备的最近运行界面上找到。", "current_step": "当前步骤", "current_step_pause": "当前步骤 - 用户暂停", "current_step_pause_timer": "计时器", @@ -36,30 +45,49 @@ "data_out_of_date": "此数据可能已过期", "date": "日期", "device_details": "设备详细信息", + "disabled": "已禁用", "door_is_open": "工作站前门已打开", "door_open_pause": "当前步骤 - 暂停 - 前门已打开", "download": "下载", "download_files": "下载文件", + "download_image": "下载图像", + "download_image_files": "下载图像文件", + "download_images": "下载图片", "download_run_log": "下载运行日志", "downloading_run_log": "正在下载运行日志", "drop_tip": "在{{labware_location}}内的{{labware}}中的{{well_name}}中丢弃吸头", "duration": "持续时间", + "enable_camera": "启用摄像头以继续", + "enabled": "已启用", "end": "结束", "end_of_protocol": "协议结束", "end_step_time": "结束", "error_details": "错误详情", + "error_event": "错误事件", "error_info": "错误{{errorCode}}:{{errorType}}", "error_type": "错误:{{errorType}}", "failed_step": "步骤失败", - "files_available_robot_details": "与协议运行相关的所有文件均可在工作站详情页面查看。", "final_step": "最后一步", "ignore_stored_data": "忽略已存储的数据", + "image_at_step_at_timestamp": "步骤 {{step}} 在 {{timestamp}} 的图像", + "image_capture": "图像拍摄", + "image_during_error": "在错误事件期间拍摄的图像", + "image_gallery": "图像库", + "image_in_gallery": "图像已添加到您的图库中,您可以随时查看。", + "image_loading": "图像加载中", + "images": "图像", + "images_available_download": "与该运行相关的图像文件可在设备的“最近运行”界面和“摄像头”标签中查看。", "labware": "耗材", "labware_offset_data": "耗材校准数据", "left": "左", "listed_values": "列出的值仅供查看", + "live_camera": "实时监控", + "live_video": "实时监控", + "live_video_ended": "实时监控已结束", + "live_video_unavailable": "实时监控不可用", "load_labware_info_protocol_setup_plural": "在{{module_name}}中加载{{labware}}", "load_module_protocol_setup_plural": "加载{{module}}", + "loading": "加载中...", "loading_data": "正在加载数据...", "loading_protocol": "正在加载协议", "location": "位置", @@ -69,6 +97,7 @@ "na": "不适用", "name": "名称", "no_files_included": "未包含协议文件", + "no_images_available": "暂无可用图像", "no_of_error": "{{count}}个错误", "no_of_errors": "{{count}}个错误", "no_of_warning": "{{count}}个警告", @@ -77,12 +106,14 @@ "not_available_for_a_completed_run": "不适用于已完成的运行", "not_available_for_a_run_in_progress": "不适用于正在进行的运行", "not_started_yet": "未开始", + "num_photos": "{{count}} 张照片", "off_deck": "甲板外", "parameters": "参数", "pause": "暂停", "pause_protocol": "暂停协议", "pause_run": "暂停运行", "paused_for": "暂停时间", + "paused_on_error": "因错误暂停", "pickup_tip": "从{{labware_location}}内的{{labware}}中的{{well_name}}孔位拾取吸头", "plus_more": "+{{count}}更多", "preview_of_protocol_steps": "这是您的协议步骤预览", @@ -91,6 +122,7 @@ "protocol_completed": "协议已完成", "protocol_end": "协议结束", "protocol_files": "协议文件", + "protocol_images_viewable": "图像在拍摄后可以立即查看,并在运行完成后下载。", "protocol_paused_for": "协议暂停时间", "protocol_run_canceled": "协议运行已取消", "protocol_run_complete": "协议运行已完成", @@ -149,6 +181,9 @@ "status_stopped": "已取消", "status_succeeded": "已完成", "step": "步骤", + "step_command": "{{step}}: {{command}}", + "step_current_total": "步骤 {{current}} / {{total}}", + "step_detail": "步骤详情", "step_failed": "步骤失败", "step_na": "步骤:不适用", "step_number": "步骤{{step_number}}:", @@ -157,6 +192,8 @@ "target_temperature": "目标温度:{{temperature}}°C", "temperature_not_available": "{{temperature_type}}: n/a", "thermocycler_error_tooltip": "模块遇到异常,请联系技术支持。", + "thumbnail": "缩略图", + "timestamp": "时间戳", "total_elapsed_time": "总耗时", "total_step_count": "总计{{count}}步", "total_step_count_plural": "总计{{count}}步", @@ -166,6 +203,9 @@ "view_current_step": "查看当前步骤", "view_error": "查看错误", "view_error_details": "查看错误详情", + "view_image": "查看图像", + "view_recent_runs": "查看最近的运行", "view_warning_details": "查看警告详情", + "visualize": "可视化", "warning_details": "警告详情" } diff --git a/app/src/assets/localization/zh/shared.json b/app/src/assets/localization/zh/shared.json index 5c4a2582b30..73b16b1a67b 100644 --- a/app/src/assets/localization/zh/shared.json +++ b/app/src/assets/localization/zh/shared.json @@ -27,7 +27,7 @@ "disabled_cannot_connect": "无法连接到工作站", "disabled_connect_to_robot": "连接到工作站以进行控制", "disabled_no_pipette_attached": "安装移液器以继续", - "disabled_protocol_is_running": "协议正在运行", + "disabled_robot_is_busy": "设备忙碌中", "dont_show_me_again": "不再显示", "drag_and_drop": "拖放或 浏览 您的文件", "empty": "空闲", @@ -71,6 +71,7 @@ "robot_is_reachable_but_not_responding": "此工作站的API服务器未能正确响应IP地址{{hostname}}处的请求", "robot_was_seen_but_is_unreachable": "最近看到此工作站,但当前无法访问IP地址{{hostname}}", "save": "保存", + "slot": "板位 {{slot}}", "something_went_wrong": "出现问题", "sort_by": "排序方式", "stand_back_robot_is_in_motion": "请离开,工作站在运动", diff --git a/app/src/assets/localization/zh/top_navigation.json b/app/src/assets/localization/zh/top_navigation.json index c3440cad3ae..273a1f9c41c 100644 --- a/app/src/assets/localization/zh/top_navigation.json +++ b/app/src/assets/localization/zh/top_navigation.json @@ -13,7 +13,6 @@ "pipettes_not_calibrated": "请校准加载的协议中指定的所有移液器以继续", "please_connect_to_a_robot": "请连接到工作站以继续", "please_load_a_protocol": "请加载协议以继续", - "preview": "预览", "protocol_details": "协议详细信息", "protocol_runs": "协议运行", "protocol_timeline": "协议时间线", @@ -22,5 +21,6 @@ "robot_settings": "工作站设置", "run": "运行", "run_details": "运行详细信息", - "settings": "设置" + "settings": "设置", + "visualization": "可视化" } diff --git a/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/AlphanumericKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/AlphanumericKeyboard.stories.tsx index 8b157d26106..d5fa9cd2c65 100644 --- a/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/AlphanumericKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/AlphanumericKeyboard/AlphanumericKeyboard.stories.tsx @@ -1,4 +1,6 @@ import { useRef, useState } from 'react' +import { Provider } from 'react-redux' +import { legacy_createStore } from 'redux' import { DIRECTION_COLUMN, @@ -9,14 +11,38 @@ import { VIEWPORT, } from '@opentrons/components' +import { configReducer } from '/app/redux/config/reducer' + import { AlphanumericKeyboard } from '.' import type { Meta, StoryObj } from '@storybook/react' +import type { Store, StoreEnhancer } from 'redux' + +const dummyConfig = { + config: { + isOnDevice: false, + language: { + appLanguage: 'en', + systemLanguage: null, + }, + }, +} as any +const store: Store = legacy_createStore( + configReducer, + dummyConfig as StoreEnhancer +) const meta: Meta = { title: 'ODD/Atoms/SoftwareKeyboard/AlphanumericKeyboard', component: AlphanumericKeyboard, parameters: VIEWPORT.touchScreenViewport, + decorators: [ + Story => ( + + + + ), + ], } export default meta diff --git a/app/src/atoms/SoftwareKeyboard/FullKeyboard/FullKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/FullKeyboard/FullKeyboard.stories.tsx index 7e00da23925..38b574f6b2e 100644 --- a/app/src/atoms/SoftwareKeyboard/FullKeyboard/FullKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/FullKeyboard/FullKeyboard.stories.tsx @@ -1,4 +1,6 @@ import { useRef, useState } from 'react' +import { Provider } from 'react-redux' +import { legacy_createStore } from 'redux' import { DIRECTION_COLUMN, @@ -9,14 +11,38 @@ import { VIEWPORT, } from '@opentrons/components' +import { configReducer } from '/app/redux/config/reducer' + import { FullKeyboard } from '.' import type { Meta, StoryObj } from '@storybook/react' +import type { Store, StoreEnhancer } from 'redux' + +const dummyConfig = { + config: { + isOnDevice: false, + language: { + appLanguage: 'en', + systemLanguage: null, + }, + }, +} as any +const store: Store = legacy_createStore( + configReducer, + dummyConfig as StoreEnhancer +) const meta: Meta = { title: 'ODD/Atoms/SoftwareKeyboard/FullKeyboard', component: FullKeyboard, parameters: VIEWPORT.touchScreenViewport, + decorators: [ + Story => ( + + + + ), + ], } export default meta diff --git a/app/src/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues.ts b/app/src/local-resources/images/hooks/useCameraSettingsValues.ts similarity index 56% rename from app/src/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues.ts rename to app/src/local-resources/images/hooks/useCameraSettingsValues.ts index 9c98acdc94c..d35fc9a077b 100644 --- a/app/src/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues.ts +++ b/app/src/local-resources/images/hooks/useCameraSettingsValues.ts @@ -1,9 +1,13 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' + +import { useCameraImageSettings } from '@opentrons/react-api-client' + +import { zoomNumberToString, zoomStringToNumber } from '../utils/cameraUtils' export type CameraZoomSetting = '1x' | '1.5x' | '2x' -export interface UseStubCameraSettingsValuesResult { - zoom: CameraZoomSetting +export interface UseCameraSettingsValuesResult { + zoom: number brightness: number contrast: number saturation: number @@ -14,12 +18,26 @@ export interface UseStubCameraSettingsValuesResult { restoreToDefault: () => void } -// Stubs camera-specific settings. -export function useStubCameraSettingsValues(): UseStubCameraSettingsValuesResult { +// Camera image specific settings. +export function useCameraSettingsValues(): UseCameraSettingsValuesResult { + const { data: cameraImageSettings } = useCameraImageSettings() const [zoom, setZoom] = useState('1x') + const zoomValue = zoomStringToNumber(zoom) const [brightness, setBrightness] = useState(50) const [contrast, setContrast] = useState(50) const [saturation, setSaturation] = useState(50) + useEffect(() => { + if (cameraImageSettings) { + setZoom( + cameraImageSettings.zoom != null + ? zoomNumberToString(cameraImageSettings.zoom) + : '1x' + ) + setBrightness(cameraImageSettings.brightness ?? 50) + setContrast(cameraImageSettings.contrast ?? 50) + setSaturation(cameraImageSettings.saturation ?? 50) + } + }, [cameraImageSettings]) const adjustZoom = (value: CameraZoomSetting): void => { setZoom(value) @@ -45,7 +63,7 @@ export function useStubCameraSettingsValues(): UseStubCameraSettingsValuesResult } return { - zoom, + zoom: zoomValue, brightness, contrast, saturation, diff --git a/app/src/local-resources/images/utils/cameraUtils.ts b/app/src/local-resources/images/utils/cameraUtils.ts new file mode 100644 index 00000000000..235d16a9272 --- /dev/null +++ b/app/src/local-resources/images/utils/cameraUtils.ts @@ -0,0 +1,9 @@ +import type { CameraZoomSetting } from '../hooks/useCameraSettingsValues' + +export function zoomNumberToString(value: number): CameraZoomSetting { + return `${value}x` as CameraZoomSetting +} + +export function zoomStringToNumber(value: string): number { + return Number(value.replace(/x/i, '')) +} diff --git a/app/src/local-resources/runs/constants.ts b/app/src/local-resources/runs/constants.ts new file mode 100644 index 00000000000..454a5b7241b --- /dev/null +++ b/app/src/local-resources/runs/constants.ts @@ -0,0 +1,104 @@ +import { + RUN_STATUS_AWAITING_RECOVERY, + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, + RUN_STATUS_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_FAILED, + RUN_STATUS_FINISHING, + RUN_STATUS_IDLE, + RUN_STATUS_PAUSED, + RUN_STATUS_RUNNING, + RUN_STATUS_STOP_REQUESTED, + RUN_STATUS_STOPPED, + RUN_STATUS_SUCCEEDED, +} from '@opentrons/api-client' + +import type { RunStatus } from '@opentrons/api-client' + +export const RECOVERY_STATUSES: RunStatus[] = [ + RUN_STATUS_AWAITING_RECOVERY, + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, +] + +export const TERMINAL_STATUSES: RunStatus[] = [ + RUN_STATUS_STOPPED, + RUN_STATUS_SUCCEEDED, + RUN_STATUS_FAILED, +] + +export const START_RUN_STATUSES: RunStatus[] = [ + RUN_STATUS_IDLE, + RUN_STATUS_PAUSED, + RUN_STATUS_BLOCKED_BY_OPEN_DOOR, +] + +export const RUN_NOT_STARTED_STATUSES: RunStatus[] = [ + RUN_STATUS_IDLE, + RUN_STATUS_BLOCKED_BY_OPEN_DOOR, +] + +export const RUN_MODULE_REQUIRES_CONFIRM_STATUSES: RunStatus[] = [ + RUN_STATUS_IDLE, + RUN_STATUS_STOPPED, +] + +export const RUN_AGAIN_STATUSES: RunStatus[] = [ + ...TERMINAL_STATUSES, + RUN_STATUS_FINISHING, + RUN_STATUS_PAUSED, + RUN_STATUS_IDLE, +] + +export const DOOR_OPEN_STATUSES: RunStatus[] = [ + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, +] + +export const VALID_ER_RUN_STATUSES: RunStatus[] = [ + ...RECOVERY_STATUSES, + RUN_STATUS_STOP_REQUESTED, +] + +export const INVALID_ER_RUN_STATUSES: RunStatus[] = [ + ...TERMINAL_STATUSES, + RUN_STATUS_RUNNING, + RUN_STATUS_PAUSED, + RUN_STATUS_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_FINISHING, + RUN_STATUS_IDLE, +] + +export const DISABLED_STATUSES: RunStatus[] = [ + RUN_STATUS_FINISHING, + RUN_STATUS_STOP_REQUESTED, + RUN_STATUS_BLOCKED_BY_OPEN_DOOR, + ...RECOVERY_STATUSES, +] + +export const CANCELLABLE_STATUSES: RunStatus[] = [ + RUN_STATUS_RUNNING, + RUN_STATUS_PAUSED, + RUN_STATUS_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_IDLE, + ...RECOVERY_STATUSES, +] + +export const RUNNING_RECOVERY_STATUSES: RunStatus[] = [ + RUN_STATUS_RUNNING, + ...RECOVERY_STATUSES, +] + +export const RUNNING_STATUSES: RunStatus[] = [ + RUN_STATUS_RUNNING, + RUN_STATUS_PAUSED, + RUN_STATUS_FINISHING, + RUN_STATUS_BLOCKED_BY_OPEN_DOOR, + ...VALID_ER_RUN_STATUSES, + ...DOOR_OPEN_STATUSES, +] + +export const TERMINATING_STATUSES: RunStatus[] = [ + RUN_STATUS_FINISHING, + ...TERMINAL_STATUSES, +] diff --git a/app/src/local-resources/runs/utils.ts b/app/src/local-resources/runs/utils.ts new file mode 100644 index 00000000000..95341f9c793 --- /dev/null +++ b/app/src/local-resources/runs/utils.ts @@ -0,0 +1,117 @@ +import { + RUN_STATUS_STOP_REQUESTED, + RUN_STATUS_STOPPED, +} from '@opentrons/api-client' + +import { + CANCELLABLE_STATUSES, + DISABLED_STATUSES, + DOOR_OPEN_STATUSES, + INVALID_ER_RUN_STATUSES, + RECOVERY_STATUSES, + RUN_AGAIN_STATUSES, + RUN_MODULE_REQUIRES_CONFIRM_STATUSES, + RUN_NOT_STARTED_STATUSES, + RUNNING_RECOVERY_STATUSES, + RUNNING_STATUSES, + START_RUN_STATUSES, + TERMINAL_STATUSES, + TERMINATING_STATUSES, + VALID_ER_RUN_STATUSES, +} from './constants' + +import type { RunStatus } from '@opentrons/api-client' + +export function isTerminalRunStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && TERMINAL_STATUSES.includes(runStatus) +} +export function isTerminatingRunStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && TERMINATING_STATUSES.includes(runStatus) +} +export function isStartRunStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && START_RUN_STATUSES.includes(runStatus) +} + +export function isValidRunAgainStatus( + runStatus: RunStatus | null, + isClosingCurrentRun: boolean +): boolean { + if (runStatus !== null && RUN_AGAIN_STATUSES.includes(runStatus)) { + // The desktop app uncurrents the run when stopped, and to prevent server-side race conditions, we should wait + // until the run uncurrenting completes. + if (runStatus === RUN_STATUS_STOPPED) { + return !isClosingCurrentRun + } else { + return true + } + } + return false +} + +export function isRecoveryStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && RECOVERY_STATUSES.includes(runStatus) +} + +export function isDisabledStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && DISABLED_STATUSES.includes(runStatus) +} + +export function isDoorOpenStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && DOOR_OPEN_STATUSES.includes(runStatus) +} + +export function isCancellableStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && CANCELLABLE_STATUSES.includes(runStatus) +} +export function isRunAgainStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && RUN_AGAIN_STATUSES.includes(runStatus) +} +export function isRunningOrRecoveryStatus( + runStatus: RunStatus | null +): boolean { + return runStatus !== null && RUNNING_RECOVERY_STATUSES.includes(runStatus) +} + +export function isRunningStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && RUNNING_STATUSES.includes(runStatus) +} + +export function isStopRequestedStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && runStatus === RUN_STATUS_STOP_REQUESTED +} + +export function isModuleConfirmationStatus( + runStatus: RunStatus | null +): boolean { + return ( + runStatus !== null && + RUN_MODULE_REQUIRES_CONFIRM_STATUSES.includes(runStatus) + ) +} + +export function isTerminatingOrTerminal(runStatus: RunStatus | null): boolean { + return ( + runStatus !== null && + (isTerminalRunStatus(runStatus) || runStatus === RUN_STATUS_STOP_REQUESTED) + ) +} + +export function isStoppingOrStopped(runStatus: RunStatus | null): boolean { + return ( + runStatus !== null && + (runStatus === RUN_STATUS_STOP_REQUESTED || + runStatus === RUN_STATUS_STOPPED) + ) +} + +export function isValidERRunStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && VALID_ER_RUN_STATUSES.includes(runStatus) +} + +export function isInvalidERRunStatus(runStatus: RunStatus | null): boolean { + return runStatus !== null && INVALID_ER_RUN_STATUSES.includes(runStatus) +} + +export function isRunStatusNotStarted(runStatus: RunStatus | null): boolean { + return runStatus !== null && RUN_NOT_STARTED_STATUSES.includes(runStatus) +} diff --git a/app/src/molecules/CardButton/CardButton.stories.tsx b/app/src/molecules/CardButton/CardButton.stories.tsx index 229b38b0784..6d8c8400584 100644 --- a/app/src/molecules/CardButton/CardButton.stories.tsx +++ b/app/src/molecules/CardButton/CardButton.stories.tsx @@ -1,9 +1,6 @@ -import { - Flex, - ICON_DATA_BY_NAME, - SPACING, - VIEWPORT, -} from '@opentrons/components' +import { MemoryRouter } from 'react-router-dom' + +import { ICON_DATA_BY_NAME, SPACING, VIEWPORT } from '@opentrons/components' import { CardButton as CardButtonComponent } from './index' @@ -23,9 +20,17 @@ const meta: Meta = { }, decorators: [ Story => ( - - - + +
+ +
+
), ], } diff --git a/app/src/molecules/MiniCard/MiniCard.stories.tsx b/app/src/molecules/MiniCard/MiniCard.stories.tsx index 61e29069fc9..379c098734e 100644 --- a/app/src/molecules/MiniCard/MiniCard.stories.tsx +++ b/app/src/molecules/MiniCard/MiniCard.stories.tsx @@ -9,50 +9,49 @@ import { TYPOGRAPHY, } from '@opentrons/components' -import OT2_PNG from '/app/assets/images/OT2-R_HERO.png' -import { Slideout } from '/app/atoms/Slideout' +import { Slideout } from '../../atoms/Slideout' +import { MiniCard as MiniCardComponent } from './' -import { MiniCard } from './' +import type { Meta, StoryObj } from '@storybook/react' -import type { Meta, Story } from '@storybook/react' -import type * as React from 'react' +const ROBOT_IMG = '/images/FLEX.png' -export default { +const meta: Meta = { title: 'App/Molecules/MiniCard', - component: MiniCard, -} as Meta - -const Template: Story> = args => { - return ( - {}} isExpanded={true}> - - - - - - - ) + component: MiniCardComponent, + decorators: [ + (Story, { args }) => ( + {}} isExpanded={true}> + + + + + + + ), + ], } +export default meta + +type Story = StoryObj + const Children = ( - + - + - + MiniCard stories protocol ) -export const Primary = Template.bind({}) -Primary.args = { - onClick: () => {}, - isSelected: true, - children: Children, - isError: false, +export const MiniCard: Story = { + args: { + onClick: () => {}, + isSelected: true, + children: Children, + isError: false, + }, } diff --git a/app/src/molecules/SlotDetailsEmptyState/__tests__/SlotDetailsEmptyState.test.tsx b/app/src/molecules/SlotDetailsEmptyState/__tests__/SlotDetailsEmptyState.test.tsx new file mode 100644 index 00000000000..cb1f0e71a7e --- /dev/null +++ b/app/src/molecules/SlotDetailsEmptyState/__tests__/SlotDetailsEmptyState.test.tsx @@ -0,0 +1,31 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, it } from 'vitest' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' + +import { SlotDetailsEmptyState } from '..' + +import type { ComponentProps } from 'react' + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('SlotDetailsEmptyState', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + slotId: 'A1', + } + }) + + it('should render slot empty state', () => { + render(props) + screen.getByText('A1') + screen.getByText('Slot empty') + }) +}) diff --git a/app/src/molecules/SlotDetailsEmptyState/index.tsx b/app/src/molecules/SlotDetailsEmptyState/index.tsx new file mode 100644 index 00000000000..0634dcdedb0 --- /dev/null +++ b/app/src/molecules/SlotDetailsEmptyState/index.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next' + +import { COLORS, RobotInfoLabel, StyledText } from '@opentrons/components' + +import styles from './slotdetailsemptystate.module.css' + +interface SlotDetailsEmptyStateProps { + slotId: string +} + +export function SlotDetailsEmptyState( + props: SlotDetailsEmptyStateProps +): JSX.Element { + const { slotId } = props + const { t } = useTranslation('protocol_visualization') + return ( +
+
+ +
+
+ + {t('slot_empty')} + +
+
+ ) +} diff --git a/app/src/molecules/SlotDetailsEmptyState/slotdetailsemptystate.module.css b/app/src/molecules/SlotDetailsEmptyState/slotdetailsemptystate.module.css new file mode 100644 index 00000000000..b4b0838ebf7 --- /dev/null +++ b/app/src/molecules/SlotDetailsEmptyState/slotdetailsemptystate.module.css @@ -0,0 +1,24 @@ +.slot_empty_container { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + gap: var(--spacing-16); +} + +.slot_empty_header { + display: flex; + width: 100%; + justify-content: flex-start; +} + +.slot_empty_body { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: var(--spacing-16) var(--spacing-24); + border-radius: var(--border-radius-4); + background-color: var(--grey-10); +} diff --git a/app/src/organisms/Desktop/Camera/CameraControls/ZoomSettings.tsx b/app/src/organisms/Desktop/Camera/CameraControls/ZoomSettings.tsx index ed37a026fdd..bf2bf61ad2a 100644 --- a/app/src/organisms/Desktop/Camera/CameraControls/ZoomSettings.tsx +++ b/app/src/organisms/Desktop/Camera/CameraControls/ZoomSettings.tsx @@ -3,14 +3,15 @@ import { useTranslation } from 'react-i18next' import { StyledText } from '@opentrons/components' import { TouchControlButton } from '/app/atoms/buttons/TouchControlButton' +import { zoomNumberToString } from '/app/local-resources/images/utils/cameraUtils' import styles from './cameracontrols.module.css' -import type { UseStubCameraSettingsValuesResult } from './hooks/useStubCameraSettingsValues' +import type { UseCameraSettingsValuesResult } from '/app/local-resources/images/hooks/useCameraSettingsValues' export interface ZoomSettingsProps { - zoom: UseStubCameraSettingsValuesResult['zoom'] - adjustZoom: UseStubCameraSettingsValuesResult['adjustZoom'] + zoom: UseCameraSettingsValuesResult['zoom'] + adjustZoom: UseCameraSettingsValuesResult['adjustZoom'] } export function ZoomSettings({ @@ -28,22 +29,22 @@ export function ZoomSettings({
- + - +
) } interface ZoomBtnProps { - currentZoom: UseStubCameraSettingsValuesResult['zoom'] - btnZoomValue: UseStubCameraSettingsValuesResult['zoom'] - adjustZoom: UseStubCameraSettingsValuesResult['adjustZoom'] + currentZoom: UseCameraSettingsValuesResult['zoom'] + btnZoomValue: UseCameraSettingsValuesResult['zoom'] + adjustZoom: UseCameraSettingsValuesResult['adjustZoom'] } function ZoomBtn({ @@ -52,13 +53,14 @@ function ZoomBtn({ adjustZoom, }: ZoomBtnProps): JSX.Element { const { t } = useTranslation('device_settings') + const btnZoomValueString = zoomNumberToString(btnZoomValue) const onClick = (): void => { - adjustZoom(btnZoomValue) + adjustZoom(btnZoomValueString) } const isActive = currentZoom === btnZoomValue const buildBtnCopy = (): string => { - switch (btnZoomValue) { + switch (btnZoomValueString) { case '1x': return t('default') case '1.5x': @@ -71,7 +73,7 @@ function ZoomBtn({ return ( void + postCameraImageSettings: UseMutateFunction< + CameraImageSettingsResponse, + AxiosError, + CameraImageSettings + > } -export function CameraControls({ onClose }: CameraControlsProps): JSX.Element { +export function CameraControls({ + onClose, + postCameraImageSettings, +}: CameraControlsProps): JSX.Element { const { t } = useTranslation('device_settings') - - const settings = useStubCameraSettingsValues() + const settings = useCameraSettingsValues() + const [isLoading, setIsLoading] = useState(false) + const handleSave = (): void => { + setIsLoading(true) + postCameraImageSettings( + { + zoom: settings.zoom, + brightness: settings.brightness, + contrast: settings.contrast, + saturation: settings.saturation, + }, + { + onSuccess: () => { + onClose() + }, + onSettled: () => { + setIsLoading(false) + }, + } + ) + } return ( @@ -33,13 +73,15 @@ export function CameraControls({ onClose }: CameraControlsProps): JSX.Element { onClick={settings.restoreToDefault} buttonText={t('restore_to_default')} /> - { - console.log('Stubbed setting savings...') - }} - > - {t('save')} - +
+ {t('Cancel')} + +
+ {isLoading && } + {t('save')} +
+
+
@@ -47,7 +89,7 @@ export function CameraControls({ onClose }: CameraControlsProps): JSX.Element { } interface CameraControlSettingsProps { - settings: UseStubCameraSettingsValuesResult + settings: UseCameraSettingsValuesResult } function CameraControlSettings({ diff --git a/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx b/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx index 2f7da922177..eeeee616999 100644 --- a/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx +++ b/app/src/organisms/Desktop/Devices/ChangePipette/LevelPipette.tsx @@ -71,7 +71,7 @@ export function LevelPipette(props: LevelPipetteProps): JSX.Element { values={{ slot: mount === 'left' ? '3' : '1', side: pipetteModelName === 'p20_mutli_gen2' ? 'short' : 'tall', - direction: mount, + direction: mount === 'left' ? 'right' : 'left', }} components={{ strong: ( diff --git a/app/src/organisms/Desktop/Devices/ChangePipette/__tests__/LevelPipette.test.tsx b/app/src/organisms/Desktop/Devices/ChangePipette/__tests__/LevelPipette.test.tsx index 30a7610fa62..37f00c4eeb1 100644 --- a/app/src/organisms/Desktop/Devices/ChangePipette/__tests__/LevelPipette.test.tsx +++ b/app/src/organisms/Desktop/Devices/ChangePipette/__tests__/LevelPipette.test.tsx @@ -80,7 +80,7 @@ describe('LevelPipette', () => { ) screen.getByText( nestedTextMatcher( - 'Place the calibration block in slot 3 with the tall surface on the left side.' + 'Place the calibration block in slot 3 with the tall surface on the right side.' ) ) screen.getByText( diff --git a/app/src/organisms/Desktop/Devices/GripperCard/__tests__/GripperCard.test.tsx b/app/src/organisms/Desktop/Devices/GripperCard/__tests__/GripperCard.test.tsx index 4c6e7de259a..c37c011d883 100644 --- a/app/src/organisms/Desktop/Devices/GripperCard/__tests__/GripperCard.test.tsx +++ b/app/src/organisms/Desktop/Devices/GripperCard/__tests__/GripperCard.test.tsx @@ -39,7 +39,7 @@ describe('GripperCard', () => { }, } as GripperData, isCalibrated: true, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } vi.mocked(GripperWizardFlows).mockReturnValue(<>wizard flow launched) @@ -79,7 +79,7 @@ describe('GripperCard', () => { }, } as GripperData, isCalibrated: false, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } @@ -102,7 +102,7 @@ describe('GripperCard', () => { }, } as GripperData, isCalibrated: false, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: true, } @@ -145,7 +145,7 @@ describe('GripperCard', () => { props = { attachedGripper: null, isCalibrated: false, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -163,7 +163,7 @@ describe('GripperCard', () => { ok: false, } as any, isCalibrated: false, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -182,7 +182,7 @@ describe('GripperCard', () => { ok: false, } as any, isCalibrated: true, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) diff --git a/app/src/organisms/Desktop/Devices/GripperCard/index.tsx b/app/src/organisms/Desktop/Devices/GripperCard/index.tsx index 7f0ee6794ab..d8ee50e18e3 100644 --- a/app/src/organisms/Desktop/Devices/GripperCard/index.tsx +++ b/app/src/organisms/Desktop/Devices/GripperCard/index.tsx @@ -20,7 +20,7 @@ import type { GripperWizardFlowType } from '/app/organisms/GripperWizardFlows/ty interface GripperCardProps { attachedGripper: GripperData | BadGripper | null isCalibrated: boolean - isRobotBusy: boolean + isRunActive: boolean isEstopNotDisengaged: boolean } @@ -39,7 +39,7 @@ const POLL_DURATION_MS = 5000 export function GripperCard({ attachedGripper, isCalibrated, - isRobotBusy, + isRunActive, isEstopNotDisengaged, }: GripperCardProps): JSX.Element { const { t, i18n } = useTranslation(['device_details', 'shared']) @@ -89,7 +89,7 @@ export function GripperCard({ ? [ { label: t('attach_gripper'), - disabled: attachedGripper != null || isRobotBusy, + disabled: attachedGripper != null || isRunActive, onClick: handleAttach, }, ] @@ -99,12 +99,12 @@ export function GripperCard({ attachedGripper.data.calibratedOffset?.last_modified != null ? t('recalibrate_gripper') : t('calibrate_gripper'), - disabled: attachedGripper == null || isRobotBusy, + disabled: attachedGripper == null || isRunActive, onClick: handleCalibrate, }, { label: t('detach_gripper'), - disabled: attachedGripper == null || isRobotBusy, + disabled: attachedGripper == null || isRunActive, onClick: handleDetach, }, { diff --git a/app/src/organisms/Desktop/Devices/InstrumentsAndModules.tsx b/app/src/organisms/Desktop/Devices/InstrumentsAndModules.tsx index 23aeb7d0ef3..951b66db4d3 100644 --- a/app/src/organisms/Desktop/Devices/InstrumentsAndModules.tsx +++ b/app/src/organisms/Desktop/Devices/InstrumentsAndModules.tsx @@ -24,6 +24,7 @@ import { ModuleCard } from '/app/organisms/ModuleCard' import { useModuleApiRequests } from '/app/organisms/ModuleCard/utils' import { useIsFlex } from '/app/redux-resources/robots' import { useIsEstopNotDisengaged } from '/app/resources/devices/hooks/useIsEstopNotDisengaged' +import { useCurrentRunId, useRunStatuses } from '/app/resources/runs' import { getShowPipetteCalibrationWarning } from '/app/transformations/instruments' import { GripperCard } from './GripperCard' @@ -42,13 +43,11 @@ const EQUIPMENT_POLL_MS = 5000 interface InstrumentsAndModulesProps { robotName: string isRobotViewable: boolean - isRobotBusy: boolean } export function InstrumentsAndModules({ robotName, isRobotViewable, - isRobotBusy, }: InstrumentsAndModulesProps): JSX.Element | null { const { t } = useTranslation(['device_details', 'shared']) const isFlex = useIsFlex(robotName) @@ -59,6 +58,8 @@ export function InstrumentsAndModules({ enabled: !isFlex, } )?.data ?? { left: undefined, right: undefined } + const currentRunId = useCurrentRunId() + const { isRunTerminal, isRunRunning } = useRunStatuses() const isEstopNotDisengaged = useIsEstopNotDisengaged(robotName) const [getLatestRequestId, handleModuleApiRequests] = useModuleApiRequests() @@ -138,7 +139,7 @@ export function InstrumentsAndModules({ width="100%" flexDirection={DIRECTION_COLUMN} > - {isRobotBusy && ( + {currentRunId != null && !isRunTerminal && ( @@ -173,7 +174,7 @@ export function InstrumentsAndModules({ } mount={LEFT} robotName={robotName} - isRobotBusy={isRobotBusy} + isRunActive={currentRunId != null && isRunRunning} isEstopNotDisengaged={isEstopNotDisengaged} /> ) : ( @@ -188,7 +189,7 @@ export function InstrumentsAndModules({ : null } mount={LEFT} - isRobotBusy={isRobotBusy} + isRunActive={currentRunId != null && isRunRunning} isEstopNotDisengaged={isEstopNotDisengaged} /> @@ -235,7 +236,7 @@ export function InstrumentsAndModules({ } mount={RIGHT} robotName={robotName} - isRobotBusy={isRobotBusy} + isRunActive={currentRunId != null && isRunRunning} isEstopNotDisengaged={isEstopNotDisengaged} /> ) : null} @@ -250,7 +251,7 @@ export function InstrumentsAndModules({ : null } mount={RIGHT} - isRobotBusy={isRobotBusy} + isRunActive={currentRunId != null && isRunRunning} isEstopNotDisengaged={isEstopNotDisengaged} /> ) : null} diff --git a/app/src/organisms/Desktop/Devices/Peripherals/CameraCard.tsx b/app/src/organisms/Desktop/Devices/Peripherals/CameraCard.tsx index 63969597fde..38de9404afe 100644 --- a/app/src/organisms/Desktop/Devices/Peripherals/CameraCard.tsx +++ b/app/src/organisms/Desktop/Devices/Peripherals/CameraCard.tsx @@ -14,6 +14,7 @@ import { useMenuHandleClickOutside, useOnClickOutside, } from '@opentrons/components' +import { useCreateCameraImageSettings } from '@opentrons/react-api-client' import { getTopPortalEl } from '/app/App/portal' import systemCameraFlex from '/app/assets/images/system_camera_flex.png' @@ -84,6 +85,7 @@ export function CameraCard({ const navigateToUsageSettings = (): void => { navigate(`/devices/${robotName}/robot-settings/camera`) } + const { createCameraImageSettings } = useCreateCameraImageSettings() return (
@@ -140,7 +142,10 @@ export function CameraCard({ )} {showControls && createPortal( - , + , getTopPortalEl() )}
diff --git a/app/src/organisms/Desktop/Devices/PipetteCard/FlexPipetteCard.tsx b/app/src/organisms/Desktop/Devices/PipetteCard/FlexPipetteCard.tsx index 8dadbdb79f5..9d61a621e09 100644 --- a/app/src/organisms/Desktop/Devices/PipetteCard/FlexPipetteCard.tsx +++ b/app/src/organisms/Desktop/Devices/PipetteCard/FlexPipetteCard.tsx @@ -37,7 +37,7 @@ interface FlexPipetteCardProps { attachedPipette: PipetteData | BadPipette | null pipetteModelSpecs: PipetteModelSpecs | null mount: Mount - isRobotBusy: boolean + isRunActive: boolean isEstopNotDisengaged: boolean } @@ -57,7 +57,7 @@ export function FlexPipetteCard({ pipetteModelSpecs, attachedPipette, mount, - isRobotBusy, + isRunActive, isEstopNotDisengaged, }: FlexPipetteCardProps): JSX.Element { const { t, i18n } = useTranslation(['device_details', 'shared']) @@ -139,7 +139,7 @@ export function FlexPipetteCard({ ? [ { label: t('attach_pipette'), - disabled: attachedPipette != null || isRobotBusy, + disabled: attachedPipette != null || isRunActive, onClick: handleChoosePipette, }, ] @@ -149,12 +149,12 @@ export function FlexPipetteCard({ attachedPipette.data.calibratedOffset?.last_modified != null ? t('recalibrate_pipette') : t('calibrate_pipette'), - disabled: attachedPipette == null || isRobotBusy, + disabled: attachedPipette == null || isRunActive, onClick: handleCalibrate, }, { label: t('detach_pipette'), - disabled: attachedPipette == null || isRobotBusy, + disabled: attachedPipette == null || isRunActive, onClick: handleDetach, }, { @@ -166,7 +166,7 @@ export function FlexPipetteCard({ }, { label: i18n.format(t('drop_tips'), 'capitalize'), - disabled: attachedPipette == null || isRobotBusy, + disabled: attachedPipette == null || isRunActive, onClick: () => { enableDTWiz() }, diff --git a/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/FlexPipetteCard.test.tsx b/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/FlexPipetteCard.test.tsx index 4900ffec11e..143527283c4 100644 --- a/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/FlexPipetteCard.test.tsx +++ b/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/FlexPipetteCard.test.tsx @@ -58,7 +58,7 @@ describe('FlexPipetteCard', () => { }, } as PipetteData, mount: 'left', - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } vi.mocked(useCurrentSubsystemUpdateQuery).mockReturnValue({ @@ -110,7 +110,7 @@ describe('FlexPipetteCard', () => { }, } as PipetteData, mount: 'left', - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -136,7 +136,7 @@ describe('FlexPipetteCard', () => { }, } as PipetteData, mount: 'left', - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } @@ -164,7 +164,7 @@ describe('FlexPipetteCard', () => { }, } as PipetteData, mount: 'left', - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: true, } @@ -187,7 +187,7 @@ describe('FlexPipetteCard', () => { mount: 'left', attachedPipette: null, pipetteModelSpecs: null, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -236,7 +236,7 @@ describe('FlexPipetteCard', () => { } as any, mount: 'left', pipetteModelSpecs: null, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -256,7 +256,7 @@ describe('FlexPipetteCard', () => { } as any, mount: 'left', pipetteModelSpecs: null, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) diff --git a/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/PipetteCard.test.tsx b/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/PipetteCard.test.tsx index 6d8f281528f..ebfaabbf2cc 100644 --- a/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/PipetteCard.test.tsx +++ b/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/PipetteCard.test.tsx @@ -45,7 +45,7 @@ describe('PipetteCard', () => { mount: LEFT, robotName: mockRobotName, pipetteId: 'id', - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } vi.mocked(PipetteOverflowMenu).mockReturnValue( @@ -72,7 +72,7 @@ describe('PipetteCard', () => { mount: LEFT, robotName: mockRobotName, pipetteId: 'id', - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -86,7 +86,7 @@ describe('PipetteCard', () => { mount: RIGHT, robotName: mockRobotName, pipetteId: 'id', - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -98,7 +98,7 @@ describe('PipetteCard', () => { pipetteModelSpecs: null, mount: RIGHT, robotName: mockRobotName, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -110,7 +110,7 @@ describe('PipetteCard', () => { pipetteModelSpecs: null, mount: LEFT, robotName: mockRobotName, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -122,7 +122,7 @@ describe('PipetteCard', () => { pipetteModelSpecs: mockLeftSpecs, mount: LEFT, robotName: mockRobotName, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) @@ -133,7 +133,7 @@ describe('PipetteCard', () => { pipetteModelSpecs: mockRightSpecs, mount: RIGHT, robotName: mockRobotName, - isRobotBusy: false, + isRunActive: false, isEstopNotDisengaged: false, } render(props) diff --git a/app/src/organisms/Desktop/Devices/PipetteCard/index.tsx b/app/src/organisms/Desktop/Devices/PipetteCard/index.tsx index 544fe97c70f..94e180c9e81 100644 --- a/app/src/organisms/Desktop/Devices/PipetteCard/index.tsx +++ b/app/src/organisms/Desktop/Devices/PipetteCard/index.tsx @@ -40,7 +40,7 @@ interface PipetteCardProps { pipetteId?: AttachedPipette['id'] | null mount: Mount robotName: string - isRobotBusy: boolean + isRunActive: boolean isEstopNotDisengaged: boolean } @@ -54,7 +54,7 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { mount, robotName, pipetteId, - isRobotBusy, + isRunActive, isEstopNotDisengaged, } = props const { @@ -212,7 +212,7 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { handleSettingsSlideout={handleSettingsSlideout} handleAboutSlideout={handleAboutSlideout} pipetteSettings={settings} - isRunActive={isRobotBusy} + isRunActive={isRunActive} /> {menuOverlay} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunCamera/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunCamera/index.tsx index 7319b30e1eb..02c48f104c9 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunCamera/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunCamera/index.tsx @@ -6,7 +6,7 @@ import { useHost } from '@opentrons/react-api-client' import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { Divider } from '/app/atoms/structure' -import { isTerminalRunStatus } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import { OPENTRONS_USB } from '/app/redux/discovery' import { getCameraUsageState } from '/app/redux/protocol-runs' diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts index 26c1ec97cb1..e39554ef634 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts @@ -1,11 +1,8 @@ -import { - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, -} from '@opentrons/api-client' +import { RUN_STATUS_BLOCKED_BY_OPEN_DOOR } from '@opentrons/api-client' + +import { isCancellableStatus } from '/app/local-resources/runs/utils' import { NOT_CONFIGURED } from '../../../../../DoorOpenControl/useIsDoorOpen' -import { isCancellableStatus } from '../utils' import type { RunHeaderBannerContainerProps } from '.' import type { DoorResult } from '../../../../../DoorOpenControl/useIsDoorOpen' @@ -32,11 +29,7 @@ export function getShowGenericRunHeaderBanners({ enteredER, }: ShowGenericRunHeaderBannersParams): ShowGenericRunHeaderBannersResult { const beforeRunCondition = - doorStatus.isDoorOpen && - isCancellableStatus(runStatus) && - runStatus !== RUN_STATUS_BLOCKED_BY_OPEN_DOOR && - runStatus !== RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR && - runStatus !== RUN_STATUS_AWAITING_RECOVERY_PAUSED + doorStatus.isDoorOpen && isCancellableStatus(runStatus) const showDoorOpenBeforeRunBanner = doorStatus.moduleDoorLocation === null && beforeRunCondition diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionBtnDisabledUtils.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionBtnDisabledUtils.ts index 0b737137d33..a33725776ce 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionBtnDisabledUtils.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionBtnDisabledUtils.ts @@ -2,19 +2,27 @@ import { useTranslation } from 'react-i18next' import { RUN_STATUS_BLOCKED_BY_OPEN_DOOR } from '@opentrons/api-client' -import { useIsDoorOpen } from '../../../hooks' import { isCancellableStatus, isDisabledStatus, isStartRunStatus, -} from '../../../utils' +} from '/app/local-resources/runs/utils' +import { + useCurrentRunId, + useModuleCalibrationStatus, + useRunCalibrationStatus, + useUnmatchedModulesForProtocol, +} from '/app/resources/runs' + +import { useIsDoorOpen } from '../../../hooks' import { useIsFixtureMismatch } from './useIsFixtureMismatch' import type { BaseActionButtonProps } from '..' import type { DoorResult } from '../../../../../../../DoorOpenControl/useIsDoorOpen' interface UseActionButtonDisabledUtilsProps extends BaseActionButtonProps { - isCurrentRun: boolean + robotName: string + runId: string isValidRunAgain: boolean isSetupComplete: boolean isOtherRunCurrent: boolean @@ -34,7 +42,6 @@ export function useActionBtnDisabledUtils( props: UseActionButtonDisabledUtilsProps ): UseActionButtonDisabledUtilsResult { const { - isCurrentRun, isSetupComplete, isOtherRunCurrent, isProtocolNotReady, @@ -45,6 +52,7 @@ export function useActionBtnDisabledUtils( runId, isResetRunLoadingRef, isClosingCurrentRun, + isCameraReadyToRun, } = props const { isPlayRunActionLoading, isPauseRunActionLoading } = @@ -52,9 +60,23 @@ export function useActionBtnDisabledUtils( const doorStatus = useIsDoorOpen(robotName) const isFixtureMismatch = useIsFixtureMismatch(runId, robotName) const isResetRunLoading = isResetRunLoadingRef.current + const isCurrentRun = useCurrentRunId() === runId + const isCalibrationComplete = useRunCalibrationStatus( + robotName, + runId + ).complete + const isModuleCalibrationComplete = useModuleCalibrationStatus( + robotName, + runId + ).complete + const { missingModuleIds } = useUnmatchedModulesForProtocol(robotName, runId) + const isMissingModules = missingModuleIds.length > 0 const isDisabled = (isCurrentRun && !isSetupComplete) || + isMissingModules || + isModuleCalibrationComplete || + isCalibrationComplete || isPlayRunActionLoading || isPauseRunActionLoading || isResetRunLoading || @@ -63,16 +85,20 @@ export function useActionBtnDisabledUtils( isProtocolNotReady || isFixtureMismatch || isDisabledStatus(runStatus) || + !isCameraReadyToRun || isRobotOnWrongVersionOfSoftware || (doorStatus.isDoorOpen && runStatus !== RUN_STATUS_BLOCKED_BY_OPEN_DOOR && isCancellableStatus(runStatus)) const disabledReason = useDisabledReason({ - ...props, - doorStatus, isFixtureMismatch, + doorStatus, isResetRunLoading, + isMissingModules, + isModuleCalibrationComplete, + isCalibrationComplete, + ...props, }) return isDisabled @@ -85,12 +111,14 @@ type UseDisabledReasonProps = UseActionButtonDisabledUtilsProps & { isFixtureMismatch: boolean isResetRunLoading: boolean isClosingCurrentRun: boolean + isModuleCalibrationComplete: boolean + isMissingModules: boolean + isCalibrationComplete: boolean + isCameraReadyToRun: boolean } // The user-facing disabled explanation for why the ActionButton is disabled, if any. function useDisabledReason({ - isCurrentRun, - isSetupComplete, isFixtureMismatch, isValidRunAgain, isOtherRunCurrent, @@ -99,22 +127,30 @@ function useDisabledReason({ runStatus, isResetRunLoading, isClosingCurrentRun, + isMissingModules, + isModuleCalibrationComplete, + isCalibrationComplete, isCameraReadyToRun, }: UseDisabledReasonProps): string | null { const { t } = useTranslation(['run_details', 'shared']) - - if (!isCameraReadyToRun) { + if (isRobotOnWrongVersionOfSoftware) { + return t('shared:a_software_update_is_available') + } else if (isClosingCurrentRun) { + return t('shared:robot_is_busy') + } else if (!isCameraReadyToRun) { return t('enable_camera') - } else if ( - isCurrentRun && - (!isSetupComplete || isFixtureMismatch) && - !isValidRunAgain - ) { - return t('setup_incomplete') + } else if (isFixtureMismatch) { + return t('fixture_mismatch') + } else if (!isValidRunAgain) { + return t('run_again_disabled') } else if (isOtherRunCurrent && !isResetRunLoading) { return t('shared:robot_is_busy') - } else if (isRobotOnWrongVersionOfSoftware) { - return t('shared:a_software_update_is_available') + } else if (!isCalibrationComplete) { + return t('instrument_calibration_incomplete') + } else if (isMissingModules) { + return t('modules_missing') + } else if (!isModuleCalibrationComplete) { + return t('module_calibration_incomplete') } else if ( doorStatus.isDoorOpen && doorStatus.moduleDoorLocation !== null && @@ -123,8 +159,6 @@ function useDisabledReason({ return t('close_stacker_door') } else if (doorStatus.isDoorOpen && isStartRunStatus(runStatus)) { return t('close_door') - } else if (isClosingCurrentRun) { - return t('shared:robot_is_busy') } else { return null } diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts index 7e86794a393..4337613c112 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionButtonProperties.ts @@ -1,15 +1,18 @@ import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { - RUN_STATUS_IDLE, - RUN_STATUS_RUNNING, - RUN_STATUS_STOP_REQUESTED, - RUN_STATUS_STOPPED, -} from '@opentrons/api-client' +import { RUN_STATUS_IDLE } from '@opentrons/api-client' import { useAddCameraSettingsToRunMutation } from '@opentrons/react-api-client' +import { + isModuleConfirmationStatus, + isRunAgainStatus, + isRunningOrRecoveryStatus, + isStartRunStatus, + isStopRequestedStatus, +} from '/app/local-resources/runs/utils' import { useIsHeaterShakerInProtocol } from '/app/organisms/ModuleCard/hooks' +import { useToaster } from '/app/organisms/ToasterOven' import { useTrackProtocolRunEvent } from '/app/redux-resources/analytics' import { SOURCE_RUN_RECORD, @@ -27,11 +30,7 @@ import { } from '/app/redux/protocol-runs' import { isAnyHeaterShakerShaking } from '../../../RunHeaderModalContainer/modals' -import { - isRecoveryStatus, - isRunAgainStatus, - isStartRunStatus, -} from '../../../utils' +import { useActionBtnDisabledUtils } from './useActionBtnDisabledUtils' import type { IconName } from '@opentrons/components' import type { StepKey } from '/app/redux/protocol-runs' @@ -48,8 +47,9 @@ interface UseButtonPropertiesProps extends BaseActionButtonProps { isValidRunAgain: boolean isOtherRunCurrent: boolean isRobotOnWrongVersionOfSoftware: boolean - isClosingCurrentRun: boolean areCameraPreferencesConfirmed: boolean + isClosingCurrentRun: boolean + isCameraReadyToRun: boolean } // Returns ActionButton properties. @@ -59,8 +59,12 @@ export function useActionButtonProperties({ robotName, runId, currentRunId, + isOtherRunCurrent, + isRobotOnWrongVersionOfSoftware, confirmAttachment, confirmMissingSteps, + makeHandleJumpToStep, + runRecord, robotAnalyticsData, robotSerialNumber, protocolRunControls, @@ -69,6 +73,9 @@ export function useActionButtonProperties({ isResetRunLoadingRef, isClosingCurrentRun, areCameraPreferencesConfirmed, + isValidRunAgain, + protocolRunHeaderRef, + isCameraReadyToRun, }: UseButtonPropertiesProps): { buttonText: string handleButtonClick: () => void @@ -92,10 +99,10 @@ export function useActionButtonProperties({ const runCameraSettings = useSelector((state: State) => getCameraUsageState(state, runId) ) - let buttonText = '' let handleButtonClick = (): void => {} let buttonIconName: IconName | null = null + console.log('🚀 ~ handlePlay ~ runStatus:', runStatus) const handlePlay = (): void => { play() @@ -116,6 +123,27 @@ export function useActionButtonProperties({ recoveryCaptureEnabled: recoveryEnabled, }) } + const isSetupComplete = !missingSetupSteps || missingSetupSteps.length === 0 + const { makeSnackbar } = useToaster() + const { isDisabled, disabledReason } = useActionBtnDisabledUtils({ + robotName, + runId, + isValidRunAgain, + isSetupComplete, + isOtherRunCurrent, + isProtocolNotReady, + isRobotOnWrongVersionOfSoftware, + isClosingCurrentRun, + makeHandleJumpToStep, + runRecord, + runStatus, + isResetRunLoadingRef, + protocolRunHeaderRef, + attachedModules, + protocolRunControls, + runHeaderModalContainerUtils, + isCameraReadyToRun, + }) if (isProtocolNotReady) { buttonIconName = 'ot-spinner' @@ -123,14 +151,14 @@ export function useActionButtonProperties({ } else if (isClosingCurrentRun) { buttonIconName = 'ot-spinner' buttonText = t('shared:robot_is_busy') - } else if (runStatus === RUN_STATUS_RUNNING || isRecoveryStatus(runStatus)) { + } else if (isRunningOrRecoveryStatus(runStatus)) { buttonIconName = 'pause' buttonText = t('pause_run') handleButtonClick = () => { pause() trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.PAUSE }) } - } else if (runStatus === RUN_STATUS_STOP_REQUESTED) { + } else if (isStopRequestedStatus(runStatus)) { buttonIconName = 'ot-spinner' buttonText = t('canceling_run') } else if (isStartRunStatus(runStatus)) { @@ -138,17 +166,21 @@ export function useActionButtonProperties({ buttonText = runStatus === RUN_STATUS_IDLE ? t('start_run') : t('resume_run') handleButtonClick = () => { + if (isDisabled && disabledReason) { + makeSnackbar(disabledReason) + return + } if (isHeaterShakerShaking && isHeaterShakerInProtocol) { runHeaderModalContainerUtils.HSRunningModalUtils.toggleModal?.() } else if ( missingSetupSteps.length !== 0 && - (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + isModuleConfirmationStatus(runStatus) ) { confirmMissingSteps() } else if ( isHeaterShakerInProtocol && !isHeaterShakerShaking && - (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + isModuleConfirmationStatus(runStatus) ) { confirmAttachment() } @@ -190,6 +222,5 @@ export function useActionButtonProperties({ }) } } - return { buttonText, handleButtonClick, buttonIconName } } diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/index.tsx index b6b57f4ed90..55b7cdeecdf 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/index.tsx @@ -12,30 +12,17 @@ import { SIZE_1, SPACING, StyledText, - Tooltip, - useHoverTooltip, } from '@opentrons/components' +import { isValidRunAgainStatus } from '/app/local-resources/runs/utils' import { useRobotAnalyticsData } from '/app/redux-resources/analytics' -import { useIsFlex, useRobot } from '/app/redux-resources/robots' -import { - getCameraUsageState, - selectIsAnyNecessaryDefaultOffsetMissing, -} from '/app/redux/protocol-runs' +import { useRobot } from '/app/redux-resources/robots' +import { getCameraUsageState } from '/app/redux/protocol-runs' import { useIsRobotOnWrongVersionOfSoftware } from '/app/redux/robot-update' -import { - useCurrentRunId, - useModuleCalibrationStatus, - useProtocolDetailsForRun, - useRunCalibrationStatus, - useUnmatchedModulesForProtocol, -} from '/app/resources/runs' +import { useCurrentRunId, useProtocolDetailsForRun } from '/app/resources/runs' -import { - getFallbackRobotSerialNumber, - isValidRunAgainStatus, -} from '../../utils' -import { useActionBtnDisabledUtils, useActionButtonProperties } from './hooks' +import { getFallbackRobotSerialNumber } from '../../utils' +import { useActionButtonProperties } from './hooks' import type { MutableRefObject } from 'react' import type { State } from '/app/redux/types' @@ -60,26 +47,10 @@ export function ActionButton(props: ActionButtonProps): JSX.Element { } = props const { missingStepsModalUtils, HSConfirmationModalUtils } = runHeaderModalContainerUtils - - const isFlex = useIsFlex(robotName) - const [targetProps, tooltipProps] = useHoverTooltip() const { isProtocolAnalyzing, protocolData } = useProtocolDetailsForRun(runId) - const { missingModuleIds } = useUnmatchedModulesForProtocol(robotName, runId) - const { complete: isCalibrationComplete } = useRunCalibrationStatus( - robotName, - runId - ) - const { complete: isModuleCalibrationComplete } = useModuleCalibrationStatus( - robotName, - runId - ) const isRobotOnWrongVersionOfSoftware = useIsRobotOnWrongVersionOfSoftware(robotName) const currentRunId = useCurrentRunId() - const isRequiredOffsetMissing = useSelector( - selectIsAnyNecessaryDefaultOffsetMissing(runId) - ) - const { enabled: isCameraEnabled } = useSelector((state: State) => getCameraUsageState(state, runId) ) @@ -90,38 +61,14 @@ export function ActionButton(props: ActionButtonProps): JSX.Element { const isCameraReadyToRun = isCameraRequiredForRun ? isCameraEnabled : true const areCameraPreferencesConfirmed = runRecord?.data.cameraSettings != null - const isSetupComplete = - isCalibrationComplete && - isModuleCalibrationComplete && - missingModuleIds.length === 0 && - isCameraReadyToRun - const isRobotTypeSetupComplete = isFlex - ? isSetupComplete && !isRequiredOffsetMissing - : isSetupComplete - - const isCurrentRun = currentRunId === runId const isOtherRunCurrent = currentRunId != null && currentRunId !== runId const isProtocolNotReady = protocolData == null || !!isProtocolAnalyzing const isValidRunAgain = isValidRunAgainStatus(runStatus, isClosingCurrentRun) - const { isDisabled, disabledReason } = useActionBtnDisabledUtils({ - isCurrentRun, - isSetupComplete: isRobotTypeSetupComplete, - isOtherRunCurrent, - isProtocolNotReady, - isRobotOnWrongVersionOfSoftware, - isValidRunAgain, - isCameraReadyToRun, - ...props, - }) - const robot = useRobot(robotName) const robotSerialNumber = getFallbackRobotSerialNumber(robot) const robotAnalyticsData = useRobotAnalyticsData(robotName) - const validRunAgainButRequiresSetup = - isValidRunAgain && !isRobotTypeSetupComplete - const { buttonText, handleButtonClick, buttonIconName } = useActionButtonProperties({ isProtocolNotReady, @@ -136,9 +83,9 @@ export function ActionButton(props: ActionButtonProps): JSX.Element { isOtherRunCurrent, isRobotOnWrongVersionOfSoftware, areCameraPreferencesConfirmed, + isCameraReadyToRun, ...props, }) - return ( <> {buttonIconName != null ? ( - {disabledReason && ( - - {disabledReason} - - )} ) } diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/RunHeaderSectionUpper.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/RunHeaderSectionUpper.tsx index 27f0530b506..f34f053316e 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/RunHeaderSectionUpper.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/RunHeaderSectionUpper.tsx @@ -15,12 +15,12 @@ import { StyledText, } from '@opentrons/components' +import { isCancellableStatus } from '/app/local-resources/runs/utils' import { RunTimer } from '/app/molecules/RunTimer' import { useRunControls } from '/app/organisms/RunTimeControl/hooks' import { useRunCreatedAtTimestamp, useRunTimestamps } from '/app/resources/runs' import { DisplayRunStatus } from '../DisplayRunStatus' -import { isCancellableStatus } from '../utils' import { ActionButton } from './ActionButton' import { LabeledValue } from './LabeledValue' diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts index 4c3c2d328a6..7ee336439e4 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useMissingStepsModal.ts @@ -1,8 +1,8 @@ import { useSelector } from 'react-redux' -import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { useConditionalConfirm } from '@opentrons/components' +import { isModuleConfirmationStatus } from '/app/local-resources/runs/utils' import { useIsHeaterShakerInProtocol } from '/app/organisms/ModuleCard/hooks' import { getMissingSetupSteps, @@ -58,7 +58,7 @@ export function useMissingStepsModal({ const shouldShowHSConfirm = isHeaterShakerInProtocol && !isHeaterShakerShaking && - (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + isModuleConfirmationStatus(runStatus) // Certain steps are not confirmed by the app, so don't include these in the modal. const reportableMissingSetupSteps = missingSetupSteps.filter( diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts index d8a0a250645..7b85421f9b4 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts @@ -1,18 +1,15 @@ import { useEffect } from 'react' -import { - RUN_STATUS_IDLE, - RUN_STATUS_STOP_REQUESTED, -} from '@opentrons/api-client' +import { RUN_STATUS_IDLE } from '@opentrons/api-client' import { useErrorRecoverySettings } from '@opentrons/react-api-client' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { lastRunCommandPromptedErrorRecovery } from '/app/local-resources/commands' +import { isTerminatingOrTerminal } from '/app/local-resources/runs/utils' import { useDropTipWizardFlows } from '/app/organisms/DropTipWizardFlows' import { useTipAttachmentStatus } from '/app/resources/instruments' import { useCurrentRunCommands, useIsRunCurrent } from '/app/resources/runs' -import { isTerminalRunStatus } from '../../utils' import { useProtocolDropTipModal } from '../modals' import type { Run, RunStatus } from '@opentrons/api-client' @@ -105,11 +102,9 @@ export function useRunHeaderDropTip({ } : { showDTWiz: false, dtWizProps: null } } - + const isRunTerminatingOrTerminal = isTerminatingOrTerminal(runStatus) const { data } = useErrorRecoverySettings() const isEREnabled = data?.data.enabled ?? true - const isRunTerminatingOrTerminal = - isTerminalRunStatus(runStatus) || runStatus === RUN_STATUS_STOP_REQUESTED const runSummaryNoFixit = useCurrentRunCommands( { includeFixitCommands: false, diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmCancelModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmCancelModal.tsx index 50c499137dd..41ce08eb3b8 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmCancelModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ConfirmCancelModal.tsx @@ -2,10 +2,6 @@ import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' -import { - RUN_STATUS_STOP_REQUESTED, - RUN_STATUS_STOPPED, -} from '@opentrons/api-client' import { AlertPrimaryButton, ALIGN_CENTER, @@ -23,6 +19,7 @@ import { import { useStopRunMutation } from '@opentrons/react-api-client' import { getTopPortalEl } from '/app/App/portal' +import { isStoppingOrStopped } from '/app/local-resources/runs/utils' import { useTrackProtocolRunEvent } from '/app/redux-resources/analytics' import { useIsFlex } from '/app/redux-resources/robots' import { ANALYTICS_PROTOCOL_RUN_ACTION } from '/app/redux/analytics' @@ -81,10 +78,7 @@ export function ConfirmCancelModal( } useEffect(() => { - if ( - runStatus === RUN_STATUS_STOP_REQUESTED || - runStatus === RUN_STATUS_STOPPED - ) { + if (isStoppingOrStopped(runStatus)) { onClose() } }, [runStatus, onClose]) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolAnalysisErrorModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolAnalysisErrorModal.tsx index c494a777c8d..761b1daa63e 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolAnalysisErrorModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolAnalysisErrorModal.tsx @@ -6,12 +6,11 @@ import { DIRECTION_COLUMN, Flex, JUSTIFY_FLEX_END, - LegacyStyledText, Modal, OVERFLOW_WRAP_ANYWHERE, PrimaryButton, SPACING, - TYPOGRAPHY, + StyledText, } from '@opentrons/components' import { getTopPortalEl } from '/app/App/portal' @@ -62,19 +61,19 @@ export function useProtocolAnalysisErrorsModal({ } export interface ProtocolAnalysisErrorModalProps { - displayName: string | null errors: AnalysisError[] onClose: () => void - robotName: string + displayName?: string | null + robotName?: string } export function ProtocolAnalysisErrorModal({ - displayName, errors, onClose, robotName, + displayName, }: ProtocolAnalysisErrorModalProps): JSX.Element { - const { t } = useTranslation(['run_details', 'shared']) + const { t, i18n } = useTranslation(['run_details', 'shared']) return createPortal( - - {t('analysis_failure_on_robot', { - protocolName: displayName, - robotName, - })} - + {robotName == null && displayName == null ? null : ( + + {t('analysis_failure_on_robot', { + protocolName: displayName, + robotName, + })} + + )} {errors.map((error, index) => ( {error?.detail} ))} @@ -102,12 +106,9 @@ export function ProtocolAnalysisErrorModal({ padding={`${SPACING.spacing8} ${SPACING.spacing48}`} onClick={onClose} > - - {t('shared:close')} - + + {i18n.format(t('shared:close'), 'capitalize')} + , diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx index e26657315e0..237e5ddb2bf 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/__tests__/ProtocolRunHeader.test.tsx @@ -13,7 +13,6 @@ import { useCloseCurrentRun, useNotifyRunQuery, useProtocolDetailsForRun, - useRunStatus, } from '/app/resources/runs' import { ProtocolRunHeader } from '..' @@ -61,14 +60,18 @@ describe('ProtocolRunHeader', () => { } vi.mocked(useNavigate).mockReturnValue(mockNavigate) - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_RUNNING) vi.mocked(useIsRobotViewable).mockReturnValue(true) vi.mocked(useProtocolDetailsForRun).mockReturnValue({ protocolData: {} as any, displayName: MOCK_PROTOCOL, } as any) vi.mocked(useNotifyRunQuery).mockReturnValue({ - data: { data: { hasEverEnteredErrorRecovery: false } }, + data: { + data: { + hasEverEnteredErrorRecovery: false, + status: RUN_STATUS_RUNNING, + }, + }, } as any) vi.mocked(useModulesQuery).mockReturnValue({ data: { data: [] }, diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/hooks/useRunAnalytics.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/hooks/useRunAnalytics.ts index 3bd3907d4bc..94f09921241 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/hooks/useRunAnalytics.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/hooks/useRunAnalytics.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import { SOURCE_RUN_RECORD, useCameraAnalytics, @@ -10,9 +11,11 @@ import { import { useRobotType } from '/app/redux-resources/robots' import { ANALYTICS_PROTOCOL_RUN_ACTION } from '/app/redux/analytics' import { useRunGeneratedDataFiles } from '/app/resources/dataFiles/useRunGeneratedDataFiles' -import { useIsRunCurrent, useRunStatus } from '/app/resources/runs' - -import { isTerminalRunStatus } from '../utils' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useIsRunCurrent, + useNotifyRunQuery, +} from '/app/resources/runs' interface UseRunAnalyticsProps { runId: string @@ -31,7 +34,10 @@ export function useRunAnalytics({ const numberOfImages = outputFileIds.jpeg.length const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) const robotAnalyticsData = useRobotAnalyticsData(robotName) - const runStatus = useRunStatus(runId) + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null const isRunCurrent = useIsRunCurrent(runId) const { reportImageCaptureUsage } = useCameraAnalytics({ source: SOURCE_RUN_RECORD, diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/hooks/useRunErrors.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/hooks/useRunErrors.ts index f82921e44cb..01146ad8536 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/hooks/useRunErrors.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/hooks/useRunErrors.ts @@ -1,9 +1,8 @@ import { useRunCommandErrors } from '@opentrons/react-api-client' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import { getHighestPriorityError } from '/app/transformations/runs' -import { isTerminalRunStatus } from '../utils' - import type { Run, RunStatus } from '@opentrons/api-client' import type { RunCommandError } from '@opentrons/shared-data' diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx index cfef82de432..6e64cd20a62 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/index.tsx @@ -13,13 +13,14 @@ import { import { useModulesQuery } from '@opentrons/react-api-client' import { useInitializeCameraState } from '/app/local-resources/images/hooks/useInitializeCameraState' +import { isCancellableStatus } from '/app/local-resources/runs/utils' import { useIsRobotViewable } from '/app/redux-resources/robots' import { useRunGeneratedDataFiles } from '/app/resources/dataFiles/useRunGeneratedDataFiles' import { + DEFAULT_STATUS_REFETCH_INTERVAL, useCloseCurrentRun, useNotifyRunQuery, useProtocolDetailsForRun, - useRunStatus, } from '/app/resources/runs' import { EQUIPMENT_POLL_MS } from '../../../../DoorOpenControl/constants' @@ -32,7 +33,6 @@ import { useRunHeaderModalContainer, } from './RunHeaderModalContainer' import { RunHeaderProtocolName } from './RunHeaderProtocolName' -import { isCancellableStatus } from './utils' import type { RefObject } from 'react' @@ -50,10 +50,13 @@ export function ProtocolRunHeader( const navigate = useNavigate() - const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) + const { data: runRecord } = useNotifyRunQuery(runId, { + staleTime: Infinity, + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) const { protocolData } = useProtocolDetailsForRun(runId) const isRobotViewable = useIsRobotViewable(robotName) - const runStatus = useRunStatus(runId) + const runStatus = runRecord?.data.status ?? null const attachedModules = useModulesQuery({ @@ -62,7 +65,7 @@ export function ProtocolRunHeader( })?.data?.data ?? [] const runErrors = useRunErrors({ runRecord: runRecord ?? null, - runStatus, + runStatus: runStatus, runId, }) const { closeCurrentRun, isClosingCurrentRun } = useCloseCurrentRun() diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils.ts index f6c17d3ca1f..564cea7b068 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils.ts @@ -1,101 +1,7 @@ -import { - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_FAILED, - RUN_STATUS_FINISHING, - RUN_STATUS_IDLE, - RUN_STATUS_PAUSED, - RUN_STATUS_RUNNING, - RUN_STATUS_STOP_REQUESTED, - RUN_STATUS_STOPPED, - RUN_STATUS_SUCCEEDED, -} from '@opentrons/api-client' - import { getRobotSerialNumber } from '/app/redux/discovery' -import type { RunStatus } from '@opentrons/api-client' import type { DiscoveredRobot } from '/app/redux/discovery/types' -const START_RUN_STATUSES: RunStatus[] = [ - RUN_STATUS_IDLE, - RUN_STATUS_PAUSED, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, -] -const RUN_AGAIN_STATUSES: RunStatus[] = [ - RUN_STATUS_STOPPED, - RUN_STATUS_FINISHING, - RUN_STATUS_FAILED, - RUN_STATUS_SUCCEEDED, -] -const RECOVERY_STATUSES: RunStatus[] = [ - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, -] -const DISABLED_STATUSES: RunStatus[] = [ - RUN_STATUS_FINISHING, - RUN_STATUS_STOP_REQUESTED, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - ...RECOVERY_STATUSES, -] -const CANCELLABLE_STATUSES: RunStatus[] = [ - RUN_STATUS_RUNNING, - RUN_STATUS_PAUSED, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_IDLE, - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, -] -const TERMINAL_STATUSES: RunStatus[] = [ - RUN_STATUS_STOPPED, - RUN_STATUS_SUCCEEDED, - RUN_STATUS_FAILED, -] - -export function isTerminalRunStatus(runStatus: RunStatus | null): boolean { - return runStatus !== null && TERMINAL_STATUSES.includes(runStatus) -} - -export function isStartRunStatus(runStatus: RunStatus | null): boolean { - return runStatus !== null && START_RUN_STATUSES.includes(runStatus) -} - -export function isRunAgainStatus(runStatus: RunStatus | null): boolean { - return runStatus !== null && RUN_AGAIN_STATUSES.includes(runStatus) -} - -export function isValidRunAgainStatus( - runStatus: RunStatus | null, - isClosingCurrentRun: boolean -): boolean { - if (runStatus !== null && RUN_AGAIN_STATUSES.includes(runStatus)) { - // The desktop app uncurrents the run when stopped, and to prevent server-side race conditions, we should wait - // until the run uncurrenting completes. - if (runStatus === RUN_STATUS_STOPPED) { - return !isClosingCurrentRun - } else { - return true - } - } - - return false -} - -export function isRecoveryStatus(runStatus: RunStatus | null): boolean { - return runStatus !== null && RECOVERY_STATUSES.includes(runStatus) -} - -export function isDisabledStatus(runStatus: RunStatus | null): boolean { - return runStatus !== null && DISABLED_STATUSES.includes(runStatus) -} - -export function isCancellableStatus(runStatus: RunStatus | null): boolean { - return runStatus !== null && CANCELLABLE_STATUSES.includes(runStatus) -} - export function getFallbackRobotSerialNumber( robot: DiscoveredRobot | null ): string { diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx index d9b7eb77226..9847e4aca1d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx @@ -33,9 +33,9 @@ import { import { Divider } from '/app/atoms/structure' import { + DEFAULT_STATUS_REFETCH_INTERVAL, useMostRecentCompletedAnalysis, useNotifyRunQuery, - useRunStatus, } from '/app/resources/runs' import type { RunStatus } from '@opentrons/api-client' @@ -49,7 +49,11 @@ export function ProtocolRunRuntimeParameters({ }: ProtocolRunRuntimeParametersProps): JSX.Element { const { t } = useTranslation('protocol_setup') const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) - const runStatus = useRunStatus(runId) + const run = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }).data + const runStatus = run?.data.status ?? null + const isRunTerminal = runStatus == null ? false @@ -57,7 +61,6 @@ export function ProtocolRunRuntimeParameters({ // we access runTimeParameters from the run record rather than the most recent analysis // because the most recent analysis may not reflect the selected run (e.g. cloning a run // from a historical protocol run from the device details page) - const run = useNotifyRunQuery(runId).data const runTimeParametersFromRun = run?.data != null && 'runTimeParameters' in run?.data ? run?.data?.runTimeParameters diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/SetupRunCameraControls.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/SetupRunCameraControls.tsx index 8546de031b6..5af74ad7207 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/SetupRunCameraControls.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/SetupRunCameraControls.tsx @@ -3,16 +3,26 @@ import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { StyledText } from '@opentrons/components' +import { useAddCameraImageSettingsToRunMutation } from '@opentrons/react-api-client' import { getTopPortalEl } from '/app/App/portal' import { TertiaryButton } from '/app/atoms/buttons' import { CameraControls } from '/app/organisms/Desktop/Camera/CameraControls' import styles from '/app/organisms/Desktop/Devices/ProtocolRun/SetupCamera/setupcamera.module.css' -export function SetupRunCameraControls(): JSX.Element { +export interface SetupRunCameraControlsProps { + cameraConfirmed: boolean + runId: string +} + +export function SetupRunCameraControls({ + cameraConfirmed, + runId, +}: SetupRunCameraControlsProps): JSX.Element { const { t } = useTranslation('device_settings') const [showControls, setShowControls] = useState(false) - + const { addCameraImageSettingsToRun } = + useAddCameraImageSettingsToRunMutation(runId) const toggleControls = (): void => { setShowControls(!showControls) } @@ -31,7 +41,7 @@ export function SetupRunCameraControls(): JSX.Element { {t('configure_camera_settings')} - + {t('edit_settings')} @@ -39,7 +49,10 @@ export function SetupRunCameraControls(): JSX.Element { {showControls && createPortal( - , + , getTopPortalEl() )} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/__tests__/SetupRunCameraControls.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/__tests__/SetupRunCameraControls.test.tsx index 85da51a8990..3e77ad5271c 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/__tests__/SetupRunCameraControls.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/__tests__/SetupRunCameraControls.test.tsx @@ -11,9 +11,12 @@ import { SetupRunCameraControls } from '../SetupRunCameraControls' vi.mock('/app/organisms/Desktop/Camera/CameraControls') const render = () => { - return renderWithProviders(, { - i18nInstance: i18n, - }) + return renderWithProviders( + , + { + i18nInstance: i18n, + } + ) } describe('SetupRunCameraControls', () => { diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/index.tsx index 34e2d61ecfa..7855dcf8e8d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupCamera/index.tsx @@ -128,7 +128,12 @@ export function SetupCamera({ toggleLiveStreamEnabled={toggleLiveStreamEnabled} cameraConfirmed={cameraConfirmed} /> - {isCameraSettingsEnabled && } + {isCameraSettingsEnabled && ( + + )} )}
diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx index e8cb5400da8..4d5c1a62046 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupRobotCalibration.tsx @@ -17,7 +17,11 @@ import { ANALYTICS_PROCEED_TO_MODULE_SETUP_STEP, useTrackEvent, } from '/app/redux/analytics' -import { useRunHasStarted, useRunStatus } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useNotifyRunQuery, + useRunHasStarted, +} from '/app/resources/runs' import { SetupDeckCalibration } from './SetupDeckCalibration' import { SetupInstrumentCalibration } from './SetupInstrumentCalibration' @@ -50,7 +54,11 @@ export function SetupRobotCalibration({ const trackEvent = useTrackEvent() const runHasStarted = useRunHasStarted(runId) - const runStatus = useRunStatus(runId) + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null + const isFlex = useIsFlex(robotName) let tooltipText: string | null = null diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx index 2a0f071ffe3..358cec233b2 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx @@ -7,9 +7,9 @@ import { InfoScreen } from '@opentrons/components' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { + DEFAULT_STATUS_REFETCH_INTERVAL, useMostRecentCompletedAnalysis, useNotifyRunQuery, - useRunStatus, } from '/app/resources/runs' import { mockIdleUnstartedRun, @@ -121,10 +121,11 @@ describe('ProtocolRunRuntimeParameters', () => { .thenReturn({ runTimeParameters: mockRunTimeParameterData, } as CompletedProtocolAnalysis) - vi.mocked(useRunStatus).mockReturnValue('running') - vi.mocked(useNotifyRunQuery).mockReturnValue({ - data: { data: mockSucceededRun }, - } as unknown as UseQueryResult) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL }) + .thenReturn({ + data: { data: mockSucceededRun }, + } as unknown as UseQueryResult) }) afterEach(() => { @@ -133,7 +134,7 @@ describe('ProtocolRunRuntimeParameters', () => { it('should render title, and banner when RunTimeParameters are not empty and all values are default', () => { when(useNotifyRunQuery) - .calledWith(RUN_ID) + .calledWith(RUN_ID, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL }) .thenReturn({ data: { data: mockIdleUnstartedRun, @@ -163,7 +164,7 @@ describe('ProtocolRunRuntimeParameters', () => { ], } as CompletedProtocolAnalysis) when(useNotifyRunQuery) - .calledWith(RUN_ID) + .calledWith(RUN_ID, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL }) .thenReturn({ data: { data: mockIdleUnstartedRun, @@ -180,7 +181,7 @@ describe('ProtocolRunRuntimeParameters', () => { it('should render title, and banner when RunTimeParameters from view protocol run record overflow menu button', () => { when(useNotifyRunQuery) - .calledWith(RUN_ID) + .calledWith(RUN_ID, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL }) .thenReturn({ data: { data: { @@ -203,7 +204,6 @@ describe('ProtocolRunRuntimeParameters', () => { ], } as CompletedProtocolAnalysis) - vi.mocked(useRunStatus).mockReturnValue('succeeded') render(props) screen.getByText('Download files') screen.getByText( @@ -212,6 +212,21 @@ describe('ProtocolRunRuntimeParameters', () => { }) it('should render RunTimeParameters when RunTimeParameters are not empty', () => { + when(useNotifyRunQuery) + .calledWith(RUN_ID, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL }) + .thenReturn({ + data: { + data: { + ...mockSucceededRun, + runTimeParameters: mockRunTimeParameterData, + }, + }, + } as any) + when(vi.mocked(useMostRecentCompletedAnalysis)) + .calledWith(RUN_ID) + .thenReturn({ + runTimeParameters: [] as RunTimeParameter[], + } as CompletedProtocolAnalysis) render(props) screen.getByText('Dry Run') screen.getByText('Off') @@ -236,6 +251,16 @@ describe('ProtocolRunRuntimeParameters', () => { }) it('should render csv row if a protocol requires a csv', () => { + when(useNotifyRunQuery) + .calledWith(RUN_ID, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL }) + .thenReturn({ + data: { + data: { + ...mockSucceededRun, + runTimeParameters: [mockRunTimeParameterData, mockCsvRtp], + }, + }, + } as any) vi.mocked(useMostRecentCompletedAnalysis).mockReturnValue({ runTimeParameters: [...mockRunTimeParameterData, mockCsvRtp], } as CompletedProtocolAnalysis) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/SetupRobotCalibration.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/SetupRobotCalibration.test.tsx index f78027ef390..f14f385a171 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/SetupRobotCalibration.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/SetupRobotCalibration.test.tsx @@ -2,6 +2,8 @@ import { fireEvent, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { when } from 'vitest-when' +import { RUN_STATUS_STOPPED } from '@opentrons/api-client' + import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { useIsFlex } from '/app/redux-resources/robots' @@ -10,7 +12,11 @@ import { useTrackEvent, } from '/app/redux/analytics' import { mockDeckCalData } from '/app/redux/calibration/__fixtures__' -import { useRunHasStarted } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useNotifyRunQuery, + useRunHasStarted, +} from '/app/resources/runs' import { useDeckCalibrationData } from '../../hooks' import { SetupDeckCalibration } from '../SetupDeckCalibration' @@ -73,6 +79,17 @@ describe('SetupRobotCalibration', () => { }) when(vi.mocked(useRunHasStarted)).calledWith(RUN_ID).thenReturn(false) when(vi.mocked(useIsFlex)).calledWith(ROBOT_NAME).thenReturn(false) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL }) + .thenReturn({ + data: { + data: { + id: RUN_ID, + status: RUN_STATUS_STOPPED, + errors: [], + }, + }, + } as any) }) afterEach(() => { vi.resetAllMocks() diff --git a/app/src/organisms/Desktop/Devices/RecentProtocolRuns.tsx b/app/src/organisms/Desktop/Devices/RecentProtocolRuns.tsx index de9164bf127..8cd43c054d7 100644 --- a/app/src/organisms/Desktop/Devices/RecentProtocolRuns.tsx +++ b/app/src/organisms/Desktop/Devices/RecentProtocolRuns.tsx @@ -40,13 +40,7 @@ export function RecentProtocolRuns({ const currentRunId = useCurrentRunId() const { isRunTerminal } = useRunStatuses() const robotIsBusy = currentRunId != null ? !isRunTerminal : false - const nonQuickTransferRuns = runs?.filter(run => { - const protocol = protocols?.data?.data.find( - protocol => protocol.id === run.protocolId - ) - return protocol?.protocolKind !== 'quick-transfer' - }) - + const allRunsMutable = [...(runs ?? [])] return ( - {isRobotViewable && - nonQuickTransferRuns && - nonQuickTransferRuns?.length > 0 && ( - <> - 0 && ( + <> + + - - {t('run')} - - - {t('protocol')} - - - {t('files')} - - - {t('status')} - - - {t('run_duration')} - - - {nonQuickTransferRuns - .sort( - (a, b) => - new Date(b.createdAt).getTime() - - new Date(a.createdAt).getTime() - ) + {t('run')} + + + {t('protocol')} + + + {t('files')} + + + {t('status')} + + + {t('run_duration')} + + + {allRunsMutable + .sort( + (a, b) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime() + ) - .map((run, index) => { - const protocol = protocols?.data?.data.find( - protocol => protocol.id === run.protocolId - ) - const protocolName = - protocol?.metadata.protocolName ?? - protocol?.files[0].name ?? - t('shared:loading') ?? - '' + .map((run, index) => { + const protocol = protocols?.data?.data.find( + protocol => protocol.id === run.protocolId + ) + const protocolName = + protocol?.metadata.protocolName ?? + protocol?.files[0].name ?? + t('shared:loading') ?? + '' - return ( - - ) - })} - - )} + return ( + + ) + })} + + )} {!isRobotViewable && ( )} - {isRobotViewable && - (nonQuickTransferRuns == null || - nonQuickTransferRuns.length === 0) && ( - - {t('no_protocol_runs')} - - )} + {isRobotViewable && allRunsMutable?.length === 0 && ( + + {t('no_protocol_runs')} + + )} ) diff --git a/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx b/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx index 982bb04cd3b..72cebaf7e30 100644 --- a/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx +++ b/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/UpdateRobotSoftware.tsx @@ -21,7 +21,7 @@ import { import { TertiaryButton } from '/app/atoms/buttons' import { ExternalLink } from '/app/atoms/Link/ExternalLink' -import { isTerminalRunStatus } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import { getRobotUpdateDisplayInfo } from '/app/redux/robot-update' import { useDispatchStartRobotUpdate } from '/app/redux/robot-update/hooks' import { remote } from '/app/redux/shell/remote' diff --git a/app/src/organisms/Desktop/Devices/RobotSettings/RobotSettingsCamera/RobotSettingsCameraControls.tsx b/app/src/organisms/Desktop/Devices/RobotSettings/RobotSettingsCamera/RobotSettingsCameraControls.tsx index 4d401012493..cf73f6a57ad 100644 --- a/app/src/organisms/Desktop/Devices/RobotSettings/RobotSettingsCamera/RobotSettingsCameraControls.tsx +++ b/app/src/organisms/Desktop/Devices/RobotSettings/RobotSettingsCamera/RobotSettingsCameraControls.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { StyledText } from '@opentrons/components' +import { useCreateCameraImageSettings } from '@opentrons/react-api-client' import { getTopPortalEl } from '/app/App/portal' import { TertiaryButton } from '/app/atoms/buttons' @@ -14,7 +15,7 @@ import type { JSX } from 'react' export function RobotSettingsCameraControls(): JSX.Element { const { t } = useTranslation('device_settings') const [showControls, setShowControls] = useState(false) - + const { createCameraImageSettings } = useCreateCameraImageSettings() const toggleControls = (): void => { setShowControls(!showControls) } @@ -41,7 +42,10 @@ export function RobotSettingsCameraControls(): JSX.Element {
{showControls && createPortal( - , + , getTopPortalEl() )} diff --git a/app/src/organisms/Desktop/Devices/RunPreview/index.tsx b/app/src/organisms/Desktop/Devices/RunPreview/index.tsx index 3cf6645ac22..f0dae0bef7e 100644 --- a/app/src/organisms/Desktop/Devices/RunPreview/index.tsx +++ b/app/src/organisms/Desktop/Devices/RunPreview/index.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next' import { ViewportList } from 'react-viewport-list' import { css } from 'styled-components' -import { RUN_STATUSES_TERMINAL } from '@opentrons/api-client' import { ALIGN_CENTER, BORDERS, @@ -25,18 +24,18 @@ import { import { NAV_BAR_WIDTH } from '/app/App/constants' import { Divider } from '/app/atoms/structure' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import { CommandIcon } from '/app/molecules/Command' import { + DEFAULT_STATUS_REFETCH_INTERVAL, useLastRunCommand, useMostRecentCompletedAnalysis, useNotifyAllCommandsAsPreSerializedList, useNotifyRunQuery, - useRunStatus, } from '/app/resources/runs' import type { ForwardedRef } from 'react' import type { ViewportListRef } from 'react-viewport-list' -import type { RunStatus } from '@opentrons/api-client' import type { RobotType } from '@opentrons/shared-data' const COLOR_FADE_MS = 500 @@ -56,12 +55,11 @@ export const RunPreviewComponent = ( ): JSX.Element | null => { const { t } = useTranslation(['run_details', 'protocol_setup']) const robotSideAnalysis = useMostRecentCompletedAnalysis(runId) - const runStatus = useRunStatus(runId) - const { data: runRecord } = useNotifyRunQuery(runId) - const isRunTerminal = - runStatus != null - ? (RUN_STATUSES_TERMINAL as RunStatus[]).includes(runStatus) - : false + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null + const isRunTerminal = isTerminalRunStatus(runStatus) // we only ever want one request done for terminal runs because this is a heavy request const { data: commandsFromQueryResponse, diff --git a/app/src/organisms/Desktop/Devices/__tests__/HistoricalProtocolRun.test.tsx b/app/src/organisms/Desktop/Devices/__tests__/HistoricalProtocolRun.test.tsx index 8360d17deb0..03bab0c0186 100644 --- a/app/src/organisms/Desktop/Devices/__tests__/HistoricalProtocolRun.test.tsx +++ b/app/src/organisms/Desktop/Devices/__tests__/HistoricalProtocolRun.test.tsx @@ -1,19 +1,27 @@ import { screen } from '@testing-library/react' import { beforeEach, describe, it, vi } from 'vitest' +import { when } from 'vitest-when' import '@testing-library/jest-dom/vitest' +import { RUN_STATUS_SUCCEEDED } from '@opentrons/api-client' + import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { getStoredProtocols } from '/app/redux/protocol-storage' import { storedProtocolData as storedProtocolDataFixture } from '/app/redux/protocol-storage/__fixtures__' -import { useRunStatus, useRunTimestamps } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useNotifyRunQuery, + useRunTimestamps, +} from '/app/resources/runs' import { HistoricalProtocolRun } from '../HistoricalProtocolRun' import { HistoricalProtocolRunOverflowMenu } from '../HistoricalProtocolRunOverflowMenu' import type { ComponentProps } from 'react' -import type { RunData, RunStatus } from '@opentrons/api-client' +import type { UseQueryResult } from 'react-query' +import type { Run, RunData } from '@opentrons/api-client' import type { RunTimeParameter } from '@opentrons/shared-data' vi.mock('/app/redux/protocol-storage') @@ -24,7 +32,7 @@ const run = { current: false, id: 'test_id', protocolId: 'test_protocol_id', - status: 'succeeded' as RunStatus, + status: RUN_STATUS_SUCCEEDED, runTimeParameters: [] as RunTimeParameter[], } as RunData @@ -48,7 +56,12 @@ describe('RecentProtocolRuns', () => { vi.mocked(HistoricalProtocolRunOverflowMenu).mockReturnValue(
mock HistoricalProtocolRunOverflowMenu
) - vi.mocked(useRunStatus).mockReturnValue('succeeded') + when(vi.mocked(useNotifyRunQuery)) + .calledWith('fakeRunId', { + staleTime: Infinity, + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ data: { data: run } } as UseQueryResult) vi.mocked(useRunTimestamps).mockReturnValue({ startedAt: '2022-05-04T18:24:40.833862+00:00', pausedAt: '', diff --git a/app/src/organisms/Desktop/Devices/__tests__/InstrumentsAndModules.test.tsx b/app/src/organisms/Desktop/Devices/__tests__/InstrumentsAndModules.test.tsx index e0d93054b7d..336996c85d2 100644 --- a/app/src/organisms/Desktop/Devices/__tests__/InstrumentsAndModules.test.tsx +++ b/app/src/organisms/Desktop/Devices/__tests__/InstrumentsAndModules.test.tsx @@ -61,7 +61,6 @@ describe('InstrumentsAndModules', () => { props = { robotName: ROBOT_NAME, isRobotViewable: true, - isRobotBusy: false, } vi.mocked(useCurrentRunId).mockReturnValue(null) vi.mocked(useRunStatuses).mockReturnValue({ @@ -125,9 +124,9 @@ describe('InstrumentsAndModules', () => { }) it('renders the protocol loaded banner when protocol is loaded and not terminal state', () => { vi.mocked(useCurrentRunId).mockReturnValue('RUNID') - render({ ...props, isRobotBusy: true }) + render({ ...props }) screen.getByText( - 'Some robot controls are not available when run is in progress or robot is busy' + 'Some robot controls are not available when run is in progress' ) }) it('renders 1 pipette card when a 96 channel is attached', () => { diff --git a/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx b/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx index a76b7137eca..41897fed6b7 100644 --- a/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx +++ b/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx @@ -3,6 +3,8 @@ import { beforeEach, describe, it, vi } from 'vitest' import '@testing-library/jest-dom/vitest' +import { useAllProtocolsQuery } from '@opentrons/react-api-client' + import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { useIsRobotViewable } from '/app/redux-resources/robots' @@ -13,8 +15,9 @@ import { RecentProtocolRuns } from '../RecentProtocolRuns' import type { AxiosError } from 'axios' import type { UseQueryResult } from 'react-query' -import type { Runs } from '@opentrons/api-client' +import type { Protocols, Runs } from '@opentrons/api-client' +vi.mock('@opentrons/react-api-client') vi.mock('/app/redux-resources/robots') vi.mock('/app/resources/runs') vi.mock('../HistoricalProtocolRun') @@ -76,4 +79,35 @@ describe('RecentProtocolRuns', () => { screen.getByText('Run duration') screen.getByText('mock HistoricalProtocolRun') }) + it('renders quick transfer runs', () => { + vi.mocked(useIsRobotViewable).mockReturnValue(true) + vi.mocked(useAllProtocolsQuery).mockReturnValue({ + data: { + data: [ + { + id: 'test_protocol_id', + protocolKind: 'quick-transfer', + metadata: { + protocolName: 'test protocol', + }, + }, + ], + }, + } as any as UseQueryResult) + vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + data: { + data: [ + { + createdAt: '2022-05-04T18:24:40.833862+00:00', + current: false, + id: 'test_id', + protocolId: 'test_protocol_id', + status: 'succeeded', + }, + ] as any as Runs, + }, + } as any as UseQueryResult) + render() + screen.getByText('mock HistoricalProtocolRun') + }) }) diff --git a/app/src/organisms/Desktop/Devices/hooks/__tests__/useRunStartedOrLegacySessionInProgress.test.tsx b/app/src/organisms/Desktop/Devices/hooks/__tests__/useRunStartedOrLegacySessionInProgress.test.tsx index e41e6a0f022..e39913b0507 100644 --- a/app/src/organisms/Desktop/Devices/hooks/__tests__/useRunStartedOrLegacySessionInProgress.test.tsx +++ b/app/src/organisms/Desktop/Devices/hooks/__tests__/useRunStartedOrLegacySessionInProgress.test.tsx @@ -1,22 +1,46 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { when } from 'vitest-when' import { RUN_STATUS_IDLE, RUN_STATUS_RUNNING } from '@opentrons/api-client' import { useAllSessionsQuery } from '@opentrons/react-api-client' -import { useCurrentRunId, useRunStatus } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useCurrentRunId, + useNotifyRunQuery, +} from '/app/resources/runs' import { useRunStartedOrLegacySessionInProgress } from '..' import type { UseQueryResult } from 'react-query' -import type { Sessions } from '@opentrons/api-client' +import type { Run, Sessions } from '@opentrons/api-client' vi.mock('@opentrons/react-api-client') vi.mock('/app/resources/runs') +const runningRun = { + current: false, + id: 'test_id_running', + status: RUN_STATUS_RUNNING, +} + +const idleRun = { + current: true, + id: 'test_id_idle', + status: RUN_STATUS_IDLE, +} + describe('useRunStartedOrLegacySessionInProgress', () => { beforeEach(() => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_RUNNING) - vi.mocked(useCurrentRunId).mockReturnValue('123') + when(vi.mocked(useNotifyRunQuery)) + .calledWith('test_id_running', { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ data: { data: runningRun } } as UseQueryResult< + Run, + unknown + >) + vi.mocked(useCurrentRunId).mockReturnValue('test_id_running') vi.mocked(useAllSessionsQuery).mockReturnValue({ data: [], links: null, @@ -32,15 +56,21 @@ describe('useRunStartedOrLegacySessionInProgress', () => { }) it('returns false when run status is idle or sessions are not empty', () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) + when(vi.mocked(useNotifyRunQuery)) + .calledWith('test_id_idle', { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ data: { data: idleRun } } as UseQueryResult) + vi.mocked(useCurrentRunId).mockReturnValue('test_id_idle') vi.mocked(useAllSessionsQuery).mockReturnValue({ data: [ { - id: 'test', + id: 'test_id_idle', createdAt: '2019-08-24T14:15:22Z', details: {}, sessionType: 'calibrationCheck', createParams: {}, + status: RUN_STATUS_IDLE, }, ], links: {}, diff --git a/app/src/organisms/Desktop/Devices/hooks/useRunStartedOrLegacySessionInProgress.ts b/app/src/organisms/Desktop/Devices/hooks/useRunStartedOrLegacySessionInProgress.ts index 06df5e6bdcd..4b96f6ea5c2 100644 --- a/app/src/organisms/Desktop/Devices/hooks/useRunStartedOrLegacySessionInProgress.ts +++ b/app/src/organisms/Desktop/Devices/hooks/useRunStartedOrLegacySessionInProgress.ts @@ -1,15 +1,22 @@ import { RUN_STATUS_IDLE } from '@opentrons/api-client' import { useAllSessionsQuery } from '@opentrons/react-api-client' -import { useCurrentRunId, useRunStatus } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useCurrentRunId, + useNotifyRunQuery, +} from '/app/resources/runs' export function useRunStartedOrLegacySessionInProgress(): boolean { const runId = useCurrentRunId() - const runStatus = useRunStatus(runId) + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null const allSessionsQueryResponse = useAllSessionsQuery() return ( - (runStatus != null && runStatus !== RUN_STATUS_IDLE) || + (runStatus !== null && runStatus !== RUN_STATUS_IDLE) || (allSessionsQueryResponse?.data?.data != null && allSessionsQueryResponse?.data?.data?.length !== 0) ) diff --git a/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps/annotatedsteps.module.css b/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps/annotatedsteps.module.css index 768ef6f95d9..53f738a1947 100644 --- a/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps/annotatedsteps.module.css +++ b/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps/annotatedsteps.module.css @@ -96,13 +96,19 @@ gap: var(--spacing-4); } +.annotated_steps_icon { + width: 1rem; + height: 1rem; +} + .annotated_steps_error_header { display: flex; width: 100%; align-items: center; padding: var(--spacing-8); border-radius: var(--border-radius-4); - background-color: var(--red-20); + background-color: var(--red-30); + cursor: pointer; gap: var(--spacing-4); transition: background-color 500ms ease-out, border-color 500ms ease-out; } diff --git a/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps/index.tsx b/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps/index.tsx index 0ca41070611..d2eb4ff8ac8 100644 --- a/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps/index.tsx +++ b/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps/index.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { COLORS, @@ -7,6 +8,7 @@ import { StyledText, } from '@opentrons/components' +import { ProtocolAnalysisErrorModal } from '../../Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals' import { AnnotatedGroup } from './AnnotatedGroup' import styles from './annotatedsteps.module.css' import { IndividualCommand } from './IndividualCommand' @@ -34,6 +36,9 @@ export function AnnotatedSteps(props: AnnotatedStepsProps): JSX.Element { setSelectedCommand, handlePause, } = props + const { t } = useTranslation('protocol_visualization') + const [showErrorDetailsModal, setShowErrorDetailsModal] = + useState(false) const [scrollTargetId, setScrollTargetId] = useState(null) const isValidRobotSideAnalysis = analysis != null const allRunDefs = useMemo( @@ -90,92 +95,107 @@ export function AnnotatedSteps(props: AnnotatedStepsProps): JSX.Element { ) return ( -
-
- {groupedCommandsHighlightedInfo != null && - groupedCommandsHighlightedInfo.length > 0 - ? groupedCommandsHighlightedInfo.map((group, index) => { - const nextIndex = groupedCommandsHighlightedInfo[index + 1] - const nextIsGrouped = - nextIndex != null && 'annotationIndex' in nextIndex + <> + {showErrorDetailsModal ? ( + { + setShowErrorDetailsModal(false) + }} + /> + ) : null} +
+
+ {groupedCommandsHighlightedInfo != null && + groupedCommandsHighlightedInfo.length > 0 + ? groupedCommandsHighlightedInfo.map((group, index) => { + const nextIndex = groupedCommandsHighlightedInfo[index + 1] + const nextIsGrouped = + nextIndex != null && 'annotationIndex' in nextIndex - if ('annotationIndex' in group) { - const subCommandStartNumber = commandNumber + 1 // Starting number for this group - commandNumber += group.subCommands.length + if ('annotationIndex' in group) { + const subCommandStartNumber = commandNumber + 1 // Starting number for this group + commandNumber += group.subCommands.length - return ( - - ) - } else { - const currentCommandNumber = ++commandNumber + return ( + + ) + } else { + const currentCommandNumber = ++commandNumber + return ( + + ) + } + }) + : filteredCommands.map(command => { + const currentCommandNumber = ++commandNumber return ( ) - } - }) - : filteredCommands.map(command => { - const currentCommandNumber = ++commandNumber - return ( - - ) - })} - {analysis?.errors.length > 0 ? ( -
- {analysis?.errors.map(error => ( -
- + })} + {analysis?.errors.length > 0 ? ( +
+ {analysis?.errors.map(error => ( +
{ + setShowErrorDetailsModal(true) + }} + > +
+ +
+ + {error.detail} + +
+ ))} +
- {error.detail} + {t('unable_to_show_steps_past_errors')}
- ))} -
- - Unable to show steps past errors -
-
- ) : null} + ) : null} +
-
+ ) } diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/__tests__/CommandSteps.test.tsx b/app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/__tests__/CommandSteps.test.tsx similarity index 96% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/__tests__/CommandSteps.test.tsx rename to app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/__tests__/CommandSteps.test.tsx index e552e8ceef1..ee3d758e03f 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/__tests__/CommandSteps.test.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/__tests__/CommandSteps.test.tsx @@ -32,7 +32,7 @@ describe('CommandSteps', () => { }) it('should render header text', () => { render(props) - screen.getByText('Timeline') + screen.getByText('Protocol Steps') screen.getByText('50% complete') }) diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/commandsteps.module.css b/app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/commandsteps.module.css similarity index 97% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/commandsteps.module.css rename to app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/commandsteps.module.css index b4263566435..1037eaa8efe 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/commandsteps.module.css +++ b/app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/commandsteps.module.css @@ -30,7 +30,7 @@ } .command_step_groups { - height: 39rem; + height: 100%; min-height: 0; flex: 1; overflow-y: auto; diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/index.tsx b/app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/index.tsx similarity index 98% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/index.tsx rename to app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/index.tsx index b5f4edfbbc0..1609b1dd612 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/CommandSteps/index.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/CommandSteps/index.tsx @@ -33,7 +33,7 @@ export function CommandSteps(props: CommandStepsProps): JSX.Element {
- {t('timeline')} + {t('protocol_steps')} {t('percent_complete', { percent: percentComplete.toFixed(0) })} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/Controls/__tests__/Controls.test.tsx b/app/src/organisms/Desktop/ProtocolVisualization/Controls/__tests__/Controls.test.tsx index bdfac44f291..645f2e6330d 100644 --- a/app/src/organisms/Desktop/ProtocolVisualization/Controls/__tests__/Controls.test.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/Controls/__tests__/Controls.test.tsx @@ -39,7 +39,6 @@ describe('Controls', () => { isPlaying: false, commands: [], groupedCommands: null, - spotlightWindowData: {} as any, // TODO (kk, 2025-11-10): update this later milliSecondsPerFrame: 2000, setMilliSecondsPerFrame: vi.fn(), } diff --git a/app/src/organisms/Desktop/ProtocolVisualization/Controls/index.tsx b/app/src/organisms/Desktop/ProtocolVisualization/Controls/index.tsx index 42d77b64eaa..7034dedb352 100644 --- a/app/src/organisms/Desktop/ProtocolVisualization/Controls/index.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/Controls/index.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' import { Chip, @@ -12,8 +11,6 @@ import { TimelineScrubber, } from '@opentrons/components' -import { stepDetailViewerUpdateAction } from '/app/redux/shell' - import styles from './controls.module.css' import { PerStepOverflowMenu } from './PerStepOverflowMenu' @@ -23,12 +20,7 @@ import { PerStepOverflowMenu } from './PerStepOverflowMenu' // } from './utils' import type { Dispatch, SetStateAction } from 'react' -import type { - Liquid, - ProtocolAnalysisOutput, - RunTimeCommand, -} from '@opentrons/shared-data' -import type { InvariantContext, RobotState } from '@opentrons/step-generation' +import type { RunTimeCommand } from '@opentrons/shared-data' import type { GroupedCommands } from '/app/redux/protocol-storage' interface ControlsProps { @@ -41,15 +33,6 @@ interface ControlsProps { isPlaying: boolean commands: RunTimeCommand[] groupedCommands: GroupedCommands | null - spotlightWindowData: { - protocolKey: string - robotState: RobotState - invariantContext: InvariantContext - analysis: ProtocolAnalysisOutput - liquids: Liquid[] - slot: string | null - command?: RunTimeCommand - } milliSecondsPerFrame: number setMilliSecondsPerFrame: Dispatch> } @@ -64,40 +47,12 @@ export function Controls(props: ControlsProps): JSX.Element { isPlaying, commands, // groupedCommands, - spotlightWindowData, milliSecondsPerFrame, setMilliSecondsPerFrame, } = props const { t } = useTranslation('protocol_visualization') - const dispatch = useDispatch() const [showPerStepOverflowMenu, setShowPerStepOverflowMenu] = useState(false) - // ToDo (kk: 2025-10-03) the following will be used when TimelineScrubber is added to this component - // const currentCommandId = commands[currentCommandIndex].id - // const nextGroupFirstCommandId = getNextGroupFirstCommandId( - // groupedCommands, - // currentCommandId - // ) - // const previousGroupFirstCommandId = getPreviousGroupFirstCommandId( - // groupedCommands, - // currentCommandId - // ) - - // const handleBack = (): void => { - // if (previousGroupFirstCommandId != null) { - // setSelectedCommand(previousGroupFirstCommandId) - // } else { - // setSelectedCommand(commands[0].id) - // } - // } - // const handleForward = (): void => { - // if (nextGroupFirstCommandId != null) { - // setSelectedCommand(nextGroupFirstCommandId) - // } else { - // setSelectedCommand(commands[commands.length - 1].id) - // } - // } - const handlePerStepOverflowClick = (): void => { setShowPerStepOverflowMenu( showPerStepOverflowMenu => !showPerStepOverflowMenu @@ -114,24 +69,8 @@ export function Controls(props: ControlsProps): JSX.Element { numCommandLength - 1 ) - setSelectedCommand(commands[nextIndex].id) - - if ( - spotlightWindowData.slot != null && - spotlightWindowData.command != null - ) { - dispatch( - stepDetailViewerUpdateAction({ - protocolKey: spotlightWindowData.protocolKey, - slot: spotlightWindowData.slot, - command: spotlightWindowData.command, - robotState: spotlightWindowData.robotState, - invariantContext: spotlightWindowData.invariantContext, - analysis: spotlightWindowData.analysis, - liquids: spotlightWindowData.liquids, - }) - ) - } + const nextCommandId = commands[nextIndex].id + setSelectedCommand(nextCommandId) } const currentProgress = diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewDetails.tsx b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewDetails.tsx similarity index 94% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewDetails.tsx rename to app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewDetails.tsx index d2f0bf75bf8..57caf962eb7 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewDetails.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewDetails.tsx @@ -1,14 +1,12 @@ import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { getFixtureSummaryInfo } from '../utils/getFixtureSummaryInfo' +import { getSlotIdsBlockedBySpanningForThermocycler } from '../utils/getSlotIdsBlockedBySpanningForThermocycler' import { DeckViewLabware } from './DeckViewLabware' import { DeckViewModules } from './DeckViewModules' import { DeckViewSlots } from './DeckViewSlots' import { FixtureCommandSummary } from './FixtureCommandSummary' import { Ot2FixedTrashCommandSummary } from './Ot2FixedTrashCommandSummary' -import { - getFixtureSummaryInfo, - getSlotIdsBlockedBySpanningForThermocycler, -} from './utils' import type { Dispatch, SetStateAction } from 'react' import type { @@ -22,7 +20,7 @@ import type { InvariantContext, TimelineFrame, } from '@opentrons/step-generation' -import type { LabwareEntityExtended } from './DeckView' +import type { LabwareEntityExtended } from '.' interface DeckViewDetailsProps { labwareEntitiesExtended: Record diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewLabware.tsx b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewLabware.tsx similarity index 97% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewLabware.tsx rename to app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewLabware.tsx index f9e516790d9..2384df27de6 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewLabware.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewLabware.tsx @@ -7,10 +7,10 @@ import { } from '@opentrons/shared-data' import { getSlotInLocationStack } from '@opentrons/step-generation' +import { getActiveLayer } from '../utils/getActiveLayer' import { DeckViewOverlay } from './DeckViewOverlay' import { LabwareCommandSummary } from './LabwareCommandSummary' import { LabwareOnDeck } from './LabwareOnDeck' -import { getActiveLayer } from './utils' import type { Dispatch, SetStateAction } from 'react' import type { @@ -20,7 +20,7 @@ import type { RunTimeCommand, } from '@opentrons/shared-data' import type { InvariantContext, RobotState } from '@opentrons/step-generation' -import type { LabwareEntityExtended } from './DeckView' +import type { LabwareEntityExtended } from '.' interface DeckViewLabwareProps { robotState: RobotState diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewModules.tsx b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewModules.tsx similarity index 95% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewModules.tsx rename to app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewModules.tsx index 3032a579821..d7bf22fa710 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewModules.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewModules.tsx @@ -8,14 +8,12 @@ import { THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' +import { getActiveLayer } from '../utils/getActiveLayer' +import { getModuleInnerProps } from '../utils/getModuleInnerProps' +import { getTopmostLabwareOnModuleFromStack } from '../utils/getTopmostLabwareOnModuleFromStack' import { DeckViewOverlay } from './DeckViewOverlay' import { LabwareOnDeck } from './LabwareOnDeck' import { ModuleCommandSummary } from './ModuleCommandSummary' -import { - getActiveLayer, - getModuleInnerProps, - getTopmostLabwareOnModuleFromStack, -} from './utils' import type { Dispatch, SetStateAction } from 'react' import type { ThermocyclerVizProps } from '@opentrons/components' @@ -26,7 +24,7 @@ import type { RunTimeCommand, } from '@opentrons/shared-data' import type { InvariantContext, RobotState } from '@opentrons/step-generation' -import type { LabwareEntityExtended } from './DeckView' +import type { LabwareEntityExtended } from '../../../../organisms/Desktop/ProtocolVisualization/DeckView' interface DeckViewModulesProps { robotState: RobotState diff --git a/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewOverlay/deckviewoverlay.module.css b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewOverlay/deckviewoverlay.module.css new file mode 100644 index 00000000000..3858fb22294 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewOverlay/deckviewoverlay.module.css @@ -0,0 +1,18 @@ +.deck_overlay { + display: flex; + width: 100%; + height: 100%; + align-items: flex-end; + justify-content: center; + border-radius: var(--border-radius-4); + background-color: transparent; + gap: var(--spacing-8); +} + +.text_background { + display: flex; + width: 100%; + justify-content: center; + border-radius: 0 0 var(--border-radius-4) var(--border-radius-4); + background-color: var(--slot-fill-color); +} diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewOverlay.tsx b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewOverlay/index.tsx similarity index 98% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewOverlay.tsx rename to app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewOverlay/index.tsx index 4b0364bfc00..2fe04fd3635 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/DeckViewOverlay.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/DeckView/DeckViewOverlay/index.tsx @@ -20,7 +20,7 @@ import { THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' -import styles from './visualization.module.css' +import styles from './deckviewoverlay.module.css' import type { Dispatch, ReactNode, SetStateAction } from 'react' import type { @@ -71,7 +71,7 @@ export function DeckViewOverlay(props: SlotOverlayProps): JSX.Element | null { const stagingAreaLocations = Object.values(stagingAreaEntities)?.map( stagingArea => stagingArea.location as string ) - const wasteChuteOnSlot = + const isWasteChuteOnSlot = Object.values(wasteChuteEntities).length > 0 && slotId === 'D3' const cutoutId = @@ -138,7 +138,7 @@ export function DeckViewOverlay(props: SlotOverlayProps): JSX.Element | null { {/* This is to render the waste chute above the hover border - very gnarly but this is what design wanted */} - {wasteChuteOnSlot != null ? ( + {isWasteChuteOnSlot ? ( -
- - - {t('deck_view')} - - - {t('step', { number: selectedCommandIndex })} - - +
+
+ + {t('deck_view')} + + + {t('step', { number: selectedCommandIndex })} + +
+
+ + + {blockTargetTemp != null + ? t('temperature', { temp: blockTargetTemp }) + : t('deactivated')} + + + + + {lidTargetTemp != null + ? t('temperature', { temp: lidTargetTemp }) + : t('deactivated')} + + + + + +
+ ) + break + } + case HEATERSHAKER_MODULE_TYPE: { + const { targetSpeed, targetTemp, latchOpen } = moduleState + moduleDetails = ( +
+ + + {targetTemp != null + ? t('temperature', { temp: targetTemp }) + : t('deactivated')} + + + + + {targetSpeed != null + ? t('speed', { speed: targetSpeed }) + : t('idle')} + + + + + +
+ ) + break + } + case MAGNETIC_MODULE_TYPE: { + const { engaged } = moduleState + moduleDetails = ( +
+ + + +
+ ) + break + } + case TEMPERATURE_MODULE_TYPE: { + const { status, targetTemperature } = moduleState + moduleDetails = ( +
+ + + {targetTemperature != null + ? t('temperature', { temp: targetTemperature }) + : t('deactivated')} + + +
+ ) + break + } + case ABSORBANCE_READER_TYPE: { + const { lidOpen, initialization } = moduleState + moduleDetails = ( +
+ + + + {initialization != null ? ( + <> +
+ + {t('initialization')} + + + {initialization.mode} + +
+
+ + {t('wavelengths')} + + + {initialization.wavelengths} + +
+ {initialization.referenceWavelength != null ? ( +
+ + {t('reference_wavelength')} + + + {initialization.referenceWavelength} + +
+ ) : null} + + ) : null} +
+ ) + break + } + case FLEX_STACKER_MODULE_TYPE: { + // TODO: add this in when the flex stacker module state is finalized for PD + // const { + // maxPoolCount, + // storedLabwareDetails, + // labwareInHopper, + // labwareOnShuttle, + // } = moduleState + console.error( + "TODO: update this when PD's flex stacker module state is finalized" + ) + break + } + case MAGNETIC_BLOCK_TYPE: { + // no state to show + break + } + default: + console.error( + `ran into the default moduleContainer moduleState with module ${moduleDisplayName}` + ) + } + + return ( +
+
+ + {moduleDisplayName} + + {moduleDetails} +
+
+ ) +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/ModuleStatusContainer.tsx b/app/src/organisms/Desktop/ProtocolVisualization/ModuleStatusContainer.tsx new file mode 100644 index 00000000000..6896a995759 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/ModuleStatusContainer.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next' + +import { StyledText } from '@opentrons/components' + +import styles from './modulecontainer.module.css' + +interface ModuleStatusContainerProps { + title: string + children: React.ReactNode +} +export const ModuleStatusContainer = ( + props: ModuleStatusContainerProps +): JSX.Element => { + const { t } = useTranslation('protocol_visualization') + const { title, children } = props + return ( +
+ {t(title)} + {children} +
+ ) +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/SlotDetails/index.tsx b/app/src/organisms/Desktop/ProtocolVisualization/SlotDetails/index.tsx new file mode 100644 index 00000000000..8fc017e929e --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/SlotDetails/index.tsx @@ -0,0 +1,126 @@ +import { useTranslation } from 'react-i18next' + +import { + Divider, + MODULE_ICON_NAME_BY_TYPE, + RobotInfoLabel, +} from '@opentrons/components' +import { getIsTiprack } from '@opentrons/shared-data' +import { getFullStackFromLabwares } from '@opentrons/step-generation' + +import { SlotDetailsEmptyState } from '/app/molecules/SlotDetailsEmptyState' + +import { LabwareSlotContainer } from '../LabwareSlotContainer' +import { ModuleContainer } from '../ModuleContainer' +import { TipDisposalContainer } from '../TipDisposalContainer' +import { TipPickupContainer } from '../TipPickupContainer' +import styles from './slotdetails.module.css' + +import type { + Liquid, + ProtocolAnalysisOutput, + RunTimeCommand, +} from '@opentrons/shared-data' +import type { InvariantContext, RobotState } from '@opentrons/step-generation' + +interface SlotDetailsProps { + slotId: string + command: RunTimeCommand + robotState: RobotState + invariantContext: InvariantContext + analysis: ProtocolAnalysisOutput + liquids: Liquid[] +} +export function SlotDetails(props: SlotDetailsProps): JSX.Element { + const { slotId, command, robotState, invariantContext, analysis, liquids } = + props + const { labware, modules } = robotState + const { + labwareEntities, + trashBinEntities, + wasteChuteEntities, + moduleEntities, + pipetteEntities, + } = invariantContext + const { commands } = analysis + const { t } = useTranslation('protocol_visualization') + const stackOfLabwareOnSlot = getFullStackFromLabwares(labware, slotId) + const moduleOnSlot = Object.entries(modules).find( + ([id, module]) => module.slot === slotId + ) + const topMostLabwareOnSlot = + stackOfLabwareOnSlot?.length > 1 ? stackOfLabwareOnSlot[0] : null + const isTopmostLabwareATiprack = + topMostLabwareOnSlot != null && + getIsTiprack(labwareEntities[topMostLabwareOnSlot].def) + const isTrashOnSlot = + Object.values(trashBinEntities).some( + trash => trash.location.split('cutout')[1] === slotId + ) || + Object.values(wasteChuteEntities).some( + trash => trash.location.split('cutout')[1] === slotId + ) || + slotId === 'fixedTrash' + + const isSlotEmpty = + moduleOnSlot == null && topMostLabwareOnSlot == null && !isTrashOnSlot + + return ( + <> + {isSlotEmpty ? ( +
+ +
+ ) : null} +
+
+
+
+ + {moduleOnSlot != null ? ( + + ) : null} +
+
+ + {topMostLabwareOnSlot != null && isTopmostLabwareATiprack ? ( + + ) : null} + {topMostLabwareOnSlot != null && !isTopmostLabwareATiprack ? ( + + ) : null} + {isTrashOnSlot ? ( + + ) : null} + {moduleOnSlot != null ? ( + + ) : null} +
+
+ + ) +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/SlotDetails/slotdetails.module.css b/app/src/organisms/Desktop/ProtocolVisualization/SlotDetails/slotdetails.module.css new file mode 100644 index 00000000000..35588fab9b7 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/SlotDetails/slotdetails.module.css @@ -0,0 +1,40 @@ +.slot_detail_container { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: var(--spacing-16) var(--spacing-20); + background-color: var(--white); +} + +.slot_container { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + gap: var(--spacing-8); +} + +.slot_details { + height: 100%; + border-radius: var(--border-radius-8); + background-color: var(--white); + overflow-y: auto; +} + +.slot_details::-webkit-scrollbar { + display: none; +} + +.command_step_header { + display: flex; + justify-content: space-between; + padding: var(--spacing-16); +} + +.slot_detail_header { + display: flex; + align-items: center; + gap: var(--spacing-4); +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/StepDetailContainer.tsx b/app/src/organisms/Desktop/ProtocolVisualization/StepDetailContainer.tsx index 485ec81f843..32d3b2c0afb 100644 --- a/app/src/organisms/Desktop/ProtocolVisualization/StepDetailContainer.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/StepDetailContainer.tsx @@ -6,11 +6,9 @@ import { PipetteContainer } from './PipetteContainer' import styles from './stepdetailcontainer.module.css' import { TipDisposalContainer } from './TipDisposalContainer' import { TipPickupContainer } from './TipPickupContainer' -import { - getActiveSlotForLabwareDetails, - getActiveSlotForTiprackDetails, - getIsPipetteActive, -} from './utils' +import { getActiveSlotForLabwareDetails } from './utils/getActiveSlotForLabwareDetails' +import { getActiveSlotForTiprackDetails } from './utils/getActiveSlotForTiprackDetails' +import { getIsPipetteActive } from './utils/getIsPipetteActive' import type { Liquid, RunTimeCommand } from '@opentrons/shared-data' import type { InvariantContext, RobotState } from '@opentrons/step-generation' diff --git a/app/src/organisms/Desktop/ProtocolVisualization/TipDisposalContainer.tsx b/app/src/organisms/Desktop/ProtocolVisualization/TipDisposalContainer.tsx index 045bed58d43..620f55b5667 100644 --- a/app/src/organisms/Desktop/ProtocolVisualization/TipDisposalContainer.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/TipDisposalContainer.tsx @@ -33,10 +33,10 @@ export function TipDisposalContainer({
- + {t('tips_in_trash')} - + {t('remaining_tips', { remaining: totalEmptyTips })}
diff --git a/app/src/organisms/Desktop/ProtocolVisualization/TipPickupContainer.tsx b/app/src/organisms/Desktop/ProtocolVisualization/TipPickupContainer.tsx index a4014dcd9be..da6a7b9064a 100644 --- a/app/src/organisms/Desktop/ProtocolVisualization/TipPickupContainer.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/TipPickupContainer.tsx @@ -14,7 +14,7 @@ import { getLabwareViewBox } from '@opentrons/shared-data' import { getSlotInLocationStack } from '@opentrons/step-generation' import styles from './tippickupcontainer.module.css' -import { getMissingTips } from './utils' +import { getMissingTips } from './utils/getMissingTips' import type { TipType } from '@opentrons/components' import type { LabwareEntity, RobotState } from '@opentrons/step-generation' @@ -52,7 +52,7 @@ export function TipPickupContainer( return (
- +
@@ -78,10 +78,10 @@ export function TipPickupContainer(
- + {t('tips_remaining')} - + {t('remaining_tips', { remaining: tipsRemaining })}
diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/__tests__/VisualizerContainer.test.tsx b/app/src/organisms/Desktop/ProtocolVisualization/VisualizerContainer/__tests__/VisualizerContainer.test.tsx similarity index 85% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/__tests__/VisualizerContainer.test.tsx rename to app/src/organisms/Desktop/ProtocolVisualization/VisualizerContainer/__tests__/VisualizerContainer.test.tsx index faed039bec2..b5ec7b94ffe 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/__tests__/VisualizerContainer.test.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/VisualizerContainer/__tests__/VisualizerContainer.test.tsx @@ -2,19 +2,19 @@ import { screen } from '@testing-library/react' import { beforeEach, describe, it, vi } from 'vitest' import { renderWithProviders } from '/app/__testing-utils__' +import { CommandSteps } from '/app/organisms/Desktop/ProtocolVisualization/CommandSteps' import { Controls } from '/app/organisms/Desktop/ProtocolVisualization/Controls' +import { DeckView } from '/app/organisms/Desktop/ProtocolVisualization/DeckView' import { StepDetailContainer } from '/app/organisms/Desktop/ProtocolVisualization/StepDetailContainer' -import { CommandSteps } from '../CommandSteps' -import { DeckView } from '../DeckView' -import { VisualizerContainer } from '../VisualizerContainer' +import { VisualizerContainer } from '../../../../../organisms/Desktop/ProtocolVisualization/VisualizerContainer' import type { ComponentProps } from 'react' vi.mock('/app/organisms/Desktop/ProtocolVisualization/Controls') vi.mock('/app/organisms/Desktop/ProtocolVisualization/StepDetailContainer') -vi.mock('../CommandSteps') -vi.mock('../DeckView') +vi.mock('/app/organisms/Desktop/ProtocolVisualization/CommandSteps') +vi.mock('/app/organisms/Desktop/ProtocolVisualization/DeckView') const render = (props: ComponentProps) => { return renderWithProviders()[0] @@ -49,6 +49,9 @@ const mockAnalysis = { 'protocol-designer@chore_release-pd-8.6.0-20251016-222252', source: 'Protocol Designer', }, + modules: [], + labware: [], + pipettes: [], result: 'ok', robotType: 'OT-3 Standard', runTimeParameters: [], diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/VisualizerContainer.tsx b/app/src/organisms/Desktop/ProtocolVisualization/VisualizerContainer/index.tsx similarity index 89% rename from app/src/pages/Desktop/Protocols/ProtocolVisualization/VisualizerContainer.tsx rename to app/src/organisms/Desktop/ProtocolVisualization/VisualizerContainer/index.tsx index d56eac039be..26abc5986f0 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/VisualizerContainer.tsx +++ b/app/src/organisms/Desktop/ProtocolVisualization/VisualizerContainer/index.tsx @@ -6,17 +6,20 @@ import { THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' import { - constructInvariantContextFromRunCommands, + constructInvariantContextFromAnalysis, getResultingTimelineFrameFromRunCommands, } from '@opentrons/step-generation' +import { CommandSteps } from '/app/organisms/Desktop/ProtocolVisualization/CommandSteps' import { Controls } from '/app/organisms/Desktop/ProtocolVisualization/Controls' +import { DeckView } from '/app/organisms/Desktop/ProtocolVisualization/DeckView' import { StepDetailContainer } from '/app/organisms/Desktop/ProtocolVisualization/StepDetailContainer' -import { stepDetailViewerOpenAction } from '/app/redux/shell' +import { + stepDetailViewerOpenAction, + stepDetailViewerUpdateAction, +} from '/app/redux/shell' import { getProtocolDisplayName } from '/app/transformations/protocols' -import { CommandSteps } from './CommandSteps' -import { DeckView } from './DeckView' import styles from './visualizercontainer.module.css' import type { MouseEvent } from 'react' @@ -89,17 +92,22 @@ export function VisualizerContainer( ) const currentCommandsSlice = commands.slice(0, selectedCommandIndex + 1) - const invariantContextFromRunCommands = - constructInvariantContextFromRunCommands(commands) + const invariantContextFromAnalysis = + constructInvariantContextFromAnalysis(analysis) const { frame, invariantContext } = getResultingTimelineFrameFromRunCommands( currentCommandsSlice, - invariantContextFromRunCommands + invariantContextFromAnalysis ) const handlePlayPause = (): void => { setIsPlaying(prev => !prev) } + const { robotState } = frame + const selectedRunTimeCommand = commands.find( + command => command.id === selectedCommandId + ) + useEffect(() => { if (!isPlaying) return @@ -119,10 +127,38 @@ export function VisualizerContainer( } }, [isPlaying, commands, milliSecondsPerFrame]) - const { robotState } = frame - const selectedRunTimeCommand = commands.find( - command => command.id === selectedCommandId - ) + // update the data for the spotlight window + // whenever the command index changes + useEffect(() => { + if (selectedCommandId == null) return + + const nextIndex = commands.findIndex(c => c.id === selectedCommandId) + if (nextIndex < 0) return + + const nextSpotlight = { + protocolKey, + slot: selectedSlot, + command: commands[nextIndex], + robotState, + invariantContext: invariantContext, + analysis, + liquids, + } + + if (nextSpotlight.slot != null && nextSpotlight.command != null) { + dispatch(stepDetailViewerUpdateAction(nextSpotlight)) + } + }, [ + selectedCommandId, + selectedSlot, + protocolKey, + robotState, + invariantContext, + analysis, + liquids, + commands, + ]) + const isThermocyclerAttached = Object.keys(robotState.modules).some( id => invariantContext.moduleEntities[id].type === THERMOCYCLER_MODULE_TYPE ) @@ -258,19 +294,9 @@ export function VisualizerContainer( isPlaying={isPlaying} commands={filteredCommands} groupedCommands={groupedCommands} - spotlightWindowData={{ - protocolKey, - slot: selectedSlot, - command: selectedRunTimeCommand, - robotState, - invariantContext, - analysis, - liquids, - }} milliSecondsPerFrame={milliSecondsPerFrame} setMilliSecondsPerFrame={setMilliSecondsPerFrame} /> - - -const VOLUME_SIG_DIGITS_DEFAULT = 2 -export const formatVolume = ( - inputVolume?: string | number | null, - sigDigits: number = VOLUME_SIG_DIGITS_DEFAULT -): string => { - if (typeof inputVolume === 'number') { - const digits = inputVolume.toString().includes('.') ? sigDigits : 0 - return String(round(inputVolume, digits)) - } - return inputVolume || '' -} - -const PERCENTAGE_DECIMALS_ALLOWED = 1 -export const formatPercentage = (part: number, total: number): string => - `${round((part / total) * 100, PERCENTAGE_DECIMALS_ALLOWED)}%` - -export const getAllWellContentsAtFrame = ( - liquidState: RobotState['liquidState'], - labwareDef: LabwareDefinition2 -): WellContentsByLabware => { - const labwareLiquidState = liquidState.labware - const wellContentsByLabwareId = mapValues( - labwareLiquidState, - (labwareLiquids: SingleLabwareLiquidState, labwareId: string) => { - return _wellContentsForLabware(labwareLiquids, labwareDef) - } - ) - return wellContentsByLabwareId -} - -const getSlotFromPipetteLocation = ( - entityUnderPipette: string, - labware: RobotState['labware'], - trashBinEntities: TrashBinEntities, - wasteChuteEntities: WasteChuteEntities -): string | null => { - if (labware[entityUnderPipette] != null) { - return getSlotInLocationStack(labware[entityUnderPipette].stack) - } else if (trashBinEntities[entityUnderPipette] != null) { - return trashBinEntities[entityUnderPipette].location.split('cutout')[1] - } else if (wasteChuteEntities[entityUnderPipette] != null) { - return wasteChuteEntities[entityUnderPipette].location.split('cutout')[1] - } else - console.warn( - `expected to find slot assosciated with piette location ${entityUnderPipette} but could not` - ) - return null -} - -export const getActiveSlotForLabwareDetails = ( - robotState: RobotState, - invariantContext: InvariantContext, - currentCommand: RunTimeCommand -): DeckSlot | null => { - const { labware, pipettes } = robotState - const { trashBinEntities, wasteChuteEntities, labwareEntities } = - invariantContext - const entityUnderPipette = Object.values(pipettes).find( - pipette => pipette.entityId != null - )?.entityId - let slot = null - - if (entityUnderPipette != null) { - slot = getSlotFromPipetteLocation( - entityUnderPipette, - labware, - trashBinEntities, - wasteChuteEntities - ) - } else if ('labwareId' in currentCommand.params) { - const isTiprack = - labwareEntities[currentCommand.params.labwareId].def.parameters.isTiprack - if (!isTiprack) { - slot = currentCommand.params.labwareId - } - } - - return slot -} - -export const getActiveSlotForTiprackDetails = ( - pipettes: PipetteTemporalProperties[], - robotState: RobotState, - invariantContext: InvariantContext -): DeckSlot | null => { - const { labware } = robotState - const { trashBinEntities, wasteChuteEntities } = invariantContext - const tiprackUnderPipette = pipettes.find( - pipette => pipette.tiprackId != null - )?.tiprackId - let slot = null - - if (tiprackUnderPipette != null) { - slot = getSlotFromPipetteLocation( - tiprackUnderPipette, - labware, - trashBinEntities, - wasteChuteEntities - ) - } - - return slot -} - -export const getMissingTips = ( - tipState: RobotState['tipState'], - labwareId: string -): WellGroup | null => { - const missingTipsByLabwareId = - tipState && - mapValues(tipState.tipracks, tipMap => - reduce( - tipMap, - (acc, hasTip, wellName): WellGroup => - hasTip ? acc : { ...acc, [wellName]: null }, - {} - ) - ) - const missingTips = missingTipsByLabwareId - ? missingTipsByLabwareId[labwareId] - : null - - return missingTips -} - -interface TipSvgInfo { - tipColor: string - tipCurrentVolume: number -} - -export const getTipSvgInfo = ( - pipetteLocationLiquidState: LocationLiquidState, - liquids: Liquid[] -): TipSvgInfo => { - const ingredIds = Object.keys(pipetteLocationLiquidState) - const colorsInTip = liquids.reduce( - (acc, { id, displayColor }) => - ingredIds.includes(id) && displayColor ? [...acc, displayColor] : acc, - [] - ) - const tipColor = - colorsInTip.length > 1 ? COLORS.grey40 : (colorsInTip[0] ?? COLORS.grey40) - const tipCurrentVolume = Object.values(pipetteLocationLiquidState).reduce( - (sum, { volume }) => sum + volume, - 0 - ) - return { tipColor, tipCurrentVolume } -} - -export const getWellVolume = ( - labwareLocationLiquidState: LocationLiquidState -): number => - Object.entries(labwareLocationLiquidState).reduce( - (sum, [id, volume]) => (id !== AIR ? sum + volume.volume : sum), - 0 - ) - -export const getIsPipetteActive = ( - side: PipetteMount, - pipettes: RobotState['pipettes'], - currentCommand: RunTimeCommand -): boolean => { - const pipetteId = - Object.entries(pipettes ?? {}).find( - ([_, pipette]) => pipette.mount === side - )?.[0] ?? null - return ( - 'pipetteId' in currentCommand.params && - currentCommand.params.pipetteId === pipetteId && - pipetteId != null && - pipettes[pipetteId].entityId != null - ) -} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/formatPercentage.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/formatPercentage.ts new file mode 100644 index 00000000000..a8cc0a7aea8 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/formatPercentage.ts @@ -0,0 +1,6 @@ +import round from 'lodash/round' + +const PERCENTAGE_DECIMALS_ALLOWED = 1 + +export const formatPercentage = (part: number, total: number): string => + `${round((part / total) * 100, PERCENTAGE_DECIMALS_ALLOWED)}%` diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/formatVolume.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/formatVolume.ts new file mode 100644 index 00000000000..06dbb830621 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/formatVolume.ts @@ -0,0 +1,13 @@ +import round from 'lodash/round' + +const VOLUME_SIG_DIGITS_DEFAULT = 2 +export const formatVolume = ( + inputVolume?: string | number | null, + sigDigits: number = VOLUME_SIG_DIGITS_DEFAULT +): string => { + if (typeof inputVolume === 'number') { + const digits = inputVolume.toString().includes('.') ? sigDigits : 0 + return String(round(inputVolume, digits)) + } + return inputVolume ?? '' +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveLayer.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveLayer.ts new file mode 100644 index 00000000000..63d9f5f0ed1 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveLayer.ts @@ -0,0 +1,27 @@ +import type { RunTimeCommand } from '@opentrons/shared-data' + +interface ActiveLayer { + isActiveLayerVisible: boolean +} + +export const getActiveLayer = ( + id: string, + selectedRunTimeCommand?: RunTimeCommand +): ActiveLayer => { + const isStepAssosciatedWithLabwareId = + selectedRunTimeCommand != null && + 'labwareId' in selectedRunTimeCommand.params && + selectedRunTimeCommand.params.labwareId === id + const isMoveStepAssosciatedWithLabwareId = + selectedRunTimeCommand != null && + selectedRunTimeCommand.commandType === 'moveLabware' && + 'labwareId' in selectedRunTimeCommand.params && + selectedRunTimeCommand.params.labwareId === id + + const isStepAssosciatedWithLabware = + isStepAssosciatedWithLabwareId || isMoveStepAssosciatedWithLabwareId + + return { + isActiveLayerVisible: isStepAssosciatedWithLabware, + } +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveSlotForLabwareDetails.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveSlotForLabwareDetails.ts new file mode 100644 index 00000000000..6a34a3d88bc --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveSlotForLabwareDetails.ts @@ -0,0 +1,60 @@ +import { getSlotInLocationStack } from '@opentrons/step-generation' + +import type { RunTimeCommand } from '@opentrons/shared-data' +import type { + DeckSlot, + InvariantContext, + RobotState, + TrashBinEntities, + WasteChuteEntities, +} from '@opentrons/step-generation' + +const getSlotFromPipetteLocation = ( + entityUnderPipette: string, + labware: RobotState['labware'], + trashBinEntities: TrashBinEntities, + wasteChuteEntities: WasteChuteEntities +): string | null => { + if (labware[entityUnderPipette] != null) { + return getSlotInLocationStack(labware[entityUnderPipette].stack) + } else if (trashBinEntities[entityUnderPipette] != null) { + return trashBinEntities[entityUnderPipette].location.split('cutout')[1] + } else if (wasteChuteEntities[entityUnderPipette] != null) { + return wasteChuteEntities[entityUnderPipette].location.split('cutout')[1] + } else + console.warn( + `expected to find slot assosciated with piette location ${entityUnderPipette} but could not` + ) + return null +} + +export const getActiveSlotForLabwareDetails = ( + robotState: RobotState, + invariantContext: InvariantContext, + currentCommand: RunTimeCommand +): DeckSlot | null => { + const { labware, pipettes } = robotState + const { trashBinEntities, wasteChuteEntities, labwareEntities } = + invariantContext + const entityUnderPipette = Object.values(pipettes).find( + pipette => pipette.entityId != null + )?.entityId + let slot = null + + if (entityUnderPipette != null) { + slot = getSlotFromPipetteLocation( + entityUnderPipette, + labware, + trashBinEntities, + wasteChuteEntities + ) + } else if ('labwareId' in currentCommand.params) { + const isTiprack = + labwareEntities[currentCommand.params.labwareId].def.parameters.isTiprack + if (!isTiprack) { + slot = currentCommand.params.labwareId + } + } + + return slot +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveSlotForTiprackDetails.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveSlotForTiprackDetails.ts new file mode 100644 index 00000000000..5e1f194a1d4 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getActiveSlotForTiprackDetails.ts @@ -0,0 +1,53 @@ +import { getSlotInLocationStack } from '@opentrons/step-generation' + +import type { + DeckSlot, + InvariantContext, + PipetteTemporalProperties, + RobotState, + TrashBinEntities, + WasteChuteEntities, +} from '@opentrons/step-generation' + +const getSlotFromPipetteLocation = ( + entityUnderPipette: string, + labware: RobotState['labware'], + trashBinEntities: TrashBinEntities, + wasteChuteEntities: WasteChuteEntities +): string | null => { + if (labware[entityUnderPipette] != null) { + return getSlotInLocationStack(labware[entityUnderPipette].stack) + } else if (trashBinEntities[entityUnderPipette] != null) { + return trashBinEntities[entityUnderPipette].location.split('cutout')[1] + } else if (wasteChuteEntities[entityUnderPipette] != null) { + return wasteChuteEntities[entityUnderPipette].location.split('cutout')[1] + } else + console.warn( + `expected to find slot assosciated with piette location ${entityUnderPipette} but could not` + ) + return null +} + +export const getActiveSlotForTiprackDetails = ( + pipettes: PipetteTemporalProperties[], + robotState: RobotState, + invariantContext: InvariantContext +): DeckSlot | null => { + const { labware } = robotState + const { trashBinEntities, wasteChuteEntities } = invariantContext + const tiprackUnderPipette = pipettes.find( + pipette => pipette.tiprackId != null + )?.tiprackId + let slot = null + + if (tiprackUnderPipette != null) { + slot = getSlotFromPipetteLocation( + tiprackUnderPipette, + labware, + trashBinEntities, + wasteChuteEntities + ) + } + + return slot +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getAllWellContentsAtFrame.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getAllWellContentsAtFrame.ts new file mode 100644 index 00000000000..a1e960adb7f --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getAllWellContentsAtFrame.ts @@ -0,0 +1,26 @@ +import mapValues from 'lodash/mapValues' + +import { _wellContentsForLabware } from '@opentrons/step-generation' + +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { + ContentsByWell, + RobotState, + SingleLabwareLiquidState, +} from '@opentrons/step-generation' + +type WellContentsByLabware = Record + +export const getAllWellContentsAtFrame = ( + liquidState: RobotState['liquidState'], + labwareDef: LabwareDefinition2 +): WellContentsByLabware => { + const labwareLiquidState = liquidState.labware + const wellContentsByLabwareId = mapValues( + labwareLiquidState, + (labwareLiquids: SingleLabwareLiquidState, labwareId: string) => { + return _wellContentsForLabware(labwareLiquids, labwareDef) + } + ) + return wellContentsByLabwareId +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getChannels.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getChannels.ts new file mode 100644 index 00000000000..ab7b96fcdd9 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getChannels.ts @@ -0,0 +1,18 @@ +import { COLUMN, ROW, SINGLE } from '@opentrons/shared-data' + +import type { NozzleConfigurationStyle } from '@opentrons/shared-data' + +export const getChannels = ( + channels: number | null, + nozzles?: NozzleConfigurationStyle +): number => { + let numChannels = channels ?? 1 + if (nozzles === SINGLE) { + numChannels = 1 + } else if (nozzles === COLUMN) { + numChannels = 8 + } else if (nozzles === ROW) { + numChannels = 12 + } + return numChannels +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getFixtureSummaryInfo.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getFixtureSummaryInfo.ts new file mode 100644 index 00000000000..a57b78c98ca --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getFixtureSummaryInfo.ts @@ -0,0 +1,35 @@ +import { getIsPipetteOverTrash } from './getIsPipetteOverTrash' + +import type { CutoutId, RunTimeCommand } from '@opentrons/shared-data' +import type { + RobotState, + TrashBinEntities, + WasteChuteEntities, +} from '@opentrons/step-generation' + +export const getFixtureSummaryInfo = ( + pipettes: RobotState['pipettes'], + entities: TrashBinEntities | WasteChuteEntities, + selectedRunTimeCommand?: RunTimeCommand +): { + isPipetteOverTrash: boolean + trashLikeEntityCutoutId: CutoutId | null +} => { + const pipetteCurrentTrashId = Object.values(pipettes).find( + pipette => pipette.entityId != null && entities[pipette.entityId] != null + )?.entityId + const isPipetteOverTrash = + pipetteCurrentTrashId != null + ? getIsPipetteOverTrash( + pipettes, + pipetteCurrentTrashId, + selectedRunTimeCommand + ) + : false + const trashLikeEntityCutoutId = + pipetteCurrentTrashId != null + ? (entities[pipetteCurrentTrashId].location as CutoutId) + : null + + return { isPipetteOverTrash, trashLikeEntityCutoutId } +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsCutoutA1Active.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsCutoutA1Active.ts new file mode 100644 index 00000000000..1d5e254ffdb --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsCutoutA1Active.ts @@ -0,0 +1,28 @@ +import { THERMOCYCLER_MODULE_TYPE } from '@opentrons/shared-data' +import { getSlotInLocationStack } from '@opentrons/step-generation' + +import { getActiveLayer } from './getActiveLayer' + +import type { CutoutId, RunTimeCommand } from '@opentrons/shared-data' +import type { RobotState } from '@opentrons/step-generation' + +export const getIsCutoutA1Active = ( + labware: RobotState['labware'], + modules: RobotState['modules'], + cutoutId: CutoutId, + selectedRunTimeCommand?: RunTimeCommand +): boolean => { + const labwareOnB1 = Object.entries(labware).find( + ([_, lw]) => getSlotInLocationStack(lw.stack) === 'B1' + ) + const hasThermocycler = Object.values(modules).some( + module => module.moduleState.type === THERMOCYCLER_MODULE_TYPE + ) + + const { isActiveLayerVisible: isThermocyclerActive } = + labwareOnB1 != null + ? getActiveLayer(labwareOnB1[0], selectedRunTimeCommand) + : { isActiveLayerVisible: false } + + return isThermocyclerActive && hasThermocycler && cutoutId === 'cutoutA1' +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsPipetteActive.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsPipetteActive.ts new file mode 100644 index 00000000000..422ed5525db --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsPipetteActive.ts @@ -0,0 +1,19 @@ +import type { PipetteMount, RunTimeCommand } from '@opentrons/shared-data' +import type { RobotState } from '@opentrons/step-generation' + +export const getIsPipetteActive = ( + side: PipetteMount, + pipettes: RobotState['pipettes'], + currentCommand: RunTimeCommand +): boolean => { + const pipetteId = + Object.entries(pipettes ?? {}).find( + ([_, pipette]) => pipette.mount === side + )?.[0] ?? null + return ( + 'pipetteId' in currentCommand.params && + currentCommand.params.pipetteId === pipetteId && + pipetteId != null && + pipettes[pipetteId].entityId != null + ) +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsPipetteOverTrash.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsPipetteOverTrash.ts new file mode 100644 index 00000000000..5b549b3a3fe --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getIsPipetteOverTrash.ts @@ -0,0 +1,18 @@ +import { POTENTIAL_TRASH_COMMAND_TYPES } from '../consants' + +import type { RunTimeCommand } from '@opentrons/shared-data' +import type { RobotState } from '@opentrons/step-generation' + +// TODO: the dropTipInPlace, airGapInplace, and +// blowoutInPlace commands don't have +// any knowledge of where its dropping. would be +// nice to expand the results key to include the +// addressable area name +export const getIsPipetteOverTrash = ( + pipettes: RobotState['pipettes'], + id: string, + selectedRunTimeCommand?: RunTimeCommand +): boolean => + Object.values(pipettes).some(pipette => pipette.entityId === id) && + selectedRunTimeCommand != null && + POTENTIAL_TRASH_COMMAND_TYPES.includes(selectedRunTimeCommand.commandType) diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getLiquidDetailInfo.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getLiquidDetailInfo.ts new file mode 100644 index 00000000000..e24c6d2a3a1 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getLiquidDetailInfo.ts @@ -0,0 +1,36 @@ +import sum from 'lodash/sum' + +import { COLORS } from '@opentrons/components' +import { + getLiquidIdsOnLabware, + getVolumesPerLiquid, +} from '@opentrons/step-generation' + +import type { Liquid } from '@opentrons/shared-data' +import type { ContentsByWell } from '@opentrons/step-generation' + +interface LiquidDetailInfo { + totalVolume: number + color: string + displayName: string +} + +export const getLiquidDetailInfo = ( + wellContents: ContentsByWell, + liquids: Liquid[] +): LiquidDetailInfo[] => { + const individualIds = getLiquidIdsOnLabware(wellContents) + const volumesPerLiquid = getVolumesPerLiquid(wellContents, individualIds) + const liquidInfo: LiquidDetailInfo[] = individualIds.map(liquidId => { + const totalVolume = sum(Object.values(volumesPerLiquid[liquidId])) + const matchingLiquid = liquids.find(liquid => liquid.id === liquidId) + + return { + totalVolume, + // TODO: add default liquid color + color: matchingLiquid?.displayColor ?? COLORS.black70, + displayName: matchingLiquid?.displayName ?? 'unknown display name', + } + }) + return liquidInfo +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getMissingTips.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getMissingTips.ts new file mode 100644 index 00000000000..68357e66b88 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getMissingTips.ts @@ -0,0 +1,26 @@ +import mapValues from 'lodash/mapValues' +import reduce from 'lodash/reduce' + +import type { WellGroup } from '@opentrons/components' +import type { RobotState } from '@opentrons/step-generation' + +export const getMissingTips = ( + tipState: RobotState['tipState'], + labwareId: string +): WellGroup | null => { + const missingTipsByLabwareId = + tipState && + mapValues(tipState.tipracks, tipMap => + reduce( + tipMap, + (acc, hasTip, wellName): WellGroup => + hasTip ? acc : { ...acc, [wellName]: null }, + {} + ) + ) + const missingTips = missingTipsByLabwareId + ? missingTipsByLabwareId[labwareId] + : null + + return missingTips +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getModuleInnerProps.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getModuleInnerProps.ts new file mode 100644 index 00000000000..875e87f73a3 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getModuleInnerProps.ts @@ -0,0 +1,33 @@ +import { THERMOCYCLER_MODULE_TYPE } from '@opentrons/shared-data' + +import type { ComponentProps } from 'react' +import type { Module } from '@opentrons/components' +import type { ModuleTemporalProperties } from '@opentrons/step-generation' + +export const getModuleInnerProps = ( + moduleState: ModuleTemporalProperties['moduleState'] +): ComponentProps['innerProps'] => { + if (moduleState.type === THERMOCYCLER_MODULE_TYPE) { + let lidMotorState = 'unknown' + if (moduleState.lidOpen) { + lidMotorState = 'open' + } else if (moduleState.lidOpen === false) { + lidMotorState = 'closed' + } + return { + lidMotorState, + blockTargetTemp: moduleState.blockTargetTemp, + } + } else if ( + 'targetTemperature' in moduleState && + moduleState.type === 'temperatureModuleType' + ) { + return { + targetTemperature: moduleState.targetTemperature, + } + } else if ('targetTemp' in moduleState) { + return { + targetTemp: moduleState.targetTemp, + } + } +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getNextGroupFirstCommandId.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getNextGroupFirstCommandId.ts new file mode 100644 index 00000000000..ea216999916 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getNextGroupFirstCommandId.ts @@ -0,0 +1,32 @@ +import type { GroupedCommands } from '/app/redux/protocol-storage' + +export function getNextGroupFirstCommandId( + groupedCommands: GroupedCommands | null, + currentCommandId: string +): string | null { + if (groupedCommands == null) { + return null + } + + const currentIndex = groupedCommands.findIndex(group => { + if ('subCommands' in group) { + return group.subCommands.some( + leaf => leaf.command.id === currentCommandId + ) + } else { + return group.command.id === currentCommandId + } + }) + + if (currentIndex === -1 || currentIndex + 1 >= groupedCommands.length) { + return null // No next group + } + + const nextGroup = groupedCommands[currentIndex + 1] + + if ('subCommands' in nextGroup) { + return nextGroup.subCommands[0]?.command.id ?? null + } else { + return nextGroup.command.id + } +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getPreviousGroupFirstCommandId.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getPreviousGroupFirstCommandId.ts new file mode 100644 index 00000000000..3ba8869f754 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getPreviousGroupFirstCommandId.ts @@ -0,0 +1,32 @@ +import type { GroupedCommands } from '/app/redux/protocol-storage' + +export function getPreviousGroupFirstCommandId( + groupedCommands: GroupedCommands | null, + currentCommandId: string +): string | null { + if (!groupedCommands) { + return null + } + + const currentIndex = groupedCommands.findIndex(group => { + if ('subCommands' in group) { + return group.subCommands.some( + leaf => leaf.command.id === currentCommandId + ) + } else { + return group.command.id === currentCommandId + } + }) + + if (currentIndex <= 0) { + return null + } + + const previousGroup = groupedCommands[currentIndex - 1] + + if ('subCommands' in previousGroup) { + return previousGroup.subCommands[0]?.command.id ?? null + } else { + return previousGroup.command.id + } +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getSlotIdsBlockedBySpanningForThermocycler.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getSlotIdsBlockedBySpanningForThermocycler.ts new file mode 100644 index 00000000000..50286d5fa68 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getSlotIdsBlockedBySpanningForThermocycler.ts @@ -0,0 +1,29 @@ +import { + FLEX_ROBOT_TYPE, + OT2_ROBOT_TYPE, + THERMOCYCLER_MODULE_TYPE, +} from '@opentrons/shared-data' + +import type { RobotType } from '@opentrons/shared-data' +import type { + DeckSlot, + ModuleEntities, + RobotState, +} from '@opentrons/step-generation' + +export const getSlotIdsBlockedBySpanningForThermocycler = ( + modules: RobotState['modules'], + moduleEntities: ModuleEntities, + robotType: RobotType +): DeckSlot[] => { + const loadedThermocycler = Object.keys(modules).find( + id => moduleEntities[id].type === THERMOCYCLER_MODULE_TYPE + ) + if (loadedThermocycler != null && robotType === FLEX_ROBOT_TYPE) { + return ['A1', 'B1'] + } else if (loadedThermocycler != null && robotType === OT2_ROBOT_TYPE) { + return ['7', '8', '10', '11'] + } + + return [] +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getSlotIsEmpty.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getSlotIsEmpty.ts new file mode 100644 index 00000000000..d5c8d017fdc --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getSlotIsEmpty.ts @@ -0,0 +1,22 @@ +import values from 'lodash/values' + +import { getSlotInLocationStack } from '@opentrons/step-generation' + +import type { RobotState } from '@opentrons/step-generation' + +export const getSlotIsEmpty = ( + robotState: RobotState, + slot: string +): boolean => { + const modulesInSlot = values(robotState.modules).filter( + moduleTemporalProperties => { + return slot.includes(moduleTemporalProperties.slot) + } + ) + const labwareInSlot = values(robotState.labware).filter( + labwareTemporalProperties => + getSlotInLocationStack(labwareTemporalProperties.stack) === slot + ) + + return modulesInSlot.length === 0 && labwareInSlot.length === 0 +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getStagingAreaAddressableAreas.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getStagingAreaAddressableAreas.ts new file mode 100644 index 00000000000..28487f3f1db --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getStagingAreaAddressableAreas.ts @@ -0,0 +1,29 @@ +import { + FLEX_ROBOT_TYPE, + getDeckDefFromRobotType, + isAddressableAreaStandardSlot, + STAGING_AREA_RIGHT_SLOT_FIXTURE, +} from '@opentrons/shared-data' + +import type { AddressableAreaName, CutoutId } from '@opentrons/shared-data' + +export const getStagingAreaAddressableAreas = ( + cutoutIds: CutoutId[], + filterStandardSlots: boolean = true +): AddressableAreaName[] => { + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) + const cutoutFixtures = deckDef.cutoutFixtures + + const addressableAreasRaw = cutoutIds.flatMap(cutoutId => { + const addressableAreasOnCutout = cutoutFixtures.find( + cutoutFixture => cutoutFixture.id === STAGING_AREA_RIGHT_SLOT_FIXTURE + )?.providesAddressableAreas[cutoutId] + return addressableAreasOnCutout ?? [] + }) + if (filterStandardSlots) { + return addressableAreasRaw.filter( + aa => !isAddressableAreaStandardSlot(aa, deckDef) + ) + } + return addressableAreasRaw +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getThermocyclerOverlayText.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getThermocyclerOverlayText.ts new file mode 100644 index 00000000000..f1c78f91ef1 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getThermocyclerOverlayText.ts @@ -0,0 +1,21 @@ +import type { RunTimeCommand } from '@opentrons/shared-data' + +export const getThermocyclerOverlayText = ( + commandType: RunTimeCommand['commandType'] +): string => { + switch (commandType) { + case 'loadModule': + return 'Load Thermocycler' + case 'thermocycler/openLid': + return 'Opening lid' + case 'thermocycler/closeLid': + return 'Closing lid' + case 'thermocycler/setTargetBlockTemperature': + return 'Setting block temperature' + case 'thermocycler/waitForLidTemperature': + return 'Setting lid temperature' + default: + // TODO: the rest of the copy isn't needed for protocol viz user testing purposes + return 'Changing thermocycler state' + } +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getTipSvgInfo.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getTipSvgInfo.ts new file mode 100644 index 00000000000..3ec6e165eeb --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getTipSvgInfo.ts @@ -0,0 +1,28 @@ +import { COLORS } from '@opentrons/components' + +import type { Liquid } from '@opentrons/shared-data' +import type { LocationLiquidState } from '@opentrons/step-generation' + +interface TipSvgInfo { + tipColor: string + tipCurrentVolume: number +} + +export const getTipSvgInfo = ( + pipetteLocationLiquidState: LocationLiquidState, + liquids: Liquid[] +): TipSvgInfo => { + const ingredIds = Object.keys(pipetteLocationLiquidState) + const colorsInTip = liquids.reduce( + (acc, { id, displayColor }) => + ingredIds.includes(id) && displayColor ? [...acc, displayColor] : acc, + [] + ) + const tipColor = + colorsInTip.length > 1 ? COLORS.grey40 : (colorsInTip[0] ?? COLORS.grey40) + const tipCurrentVolume = Object.values(pipetteLocationLiquidState).reduce( + (sum, { volume }) => sum + volume, + 0 + ) + return { tipColor, tipCurrentVolume } +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getTopmostLabwareOnModuleFromStack.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getTopmostLabwareOnModuleFromStack.ts new file mode 100644 index 00000000000..f472154d421 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getTopmostLabwareOnModuleFromStack.ts @@ -0,0 +1,10 @@ +import type { LabwareTemporalProperties } from '@opentrons/step-generation' + +export const getTopmostLabwareOnModuleFromStack = ( + moduleId: string, + labware: LabwareTemporalProperties[] +): string => { + return labware + .filter(lw => lw.stack.includes(moduleId)) // all stacks involving this module + .sort((a, b) => b.stack.length - a.stack.length)[0]?.stack[0] // return topmost labware from largest stack +} diff --git a/app/src/organisms/Desktop/ProtocolVisualization/utils/getWellVolume.ts b/app/src/organisms/Desktop/ProtocolVisualization/utils/getWellVolume.ts new file mode 100644 index 00000000000..b661cf6ca61 --- /dev/null +++ b/app/src/organisms/Desktop/ProtocolVisualization/utils/getWellVolume.ts @@ -0,0 +1,11 @@ +import { AIR } from '@opentrons/step-generation' + +import type { LocationLiquidState } from '@opentrons/step-generation' + +export const getWellVolume = ( + labwareLocationLiquidState: LocationLiquidState +): number => + Object.entries(labwareLocationLiquidState).reduce( + (sum, [id, volume]) => (id !== AIR ? sum + volume.volume : sum), + 0 + ) diff --git a/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx b/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx index 2c0e9d3c3ee..216dde2a764 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/__tests__/RunProgressMeter.test.tsx @@ -23,11 +23,11 @@ import { useRunControls } from '/app/organisms/RunTimeControl' import { useModuleCommandAnalytics } from '/app/redux-resources/analytics/' import { useRunningStepCounts } from '/app/resources/protocols/hooks' import { + DEFAULT_STATUS_REFETCH_INTERVAL, useLastRunCommand, useMostRecentCompletedAnalysis, useNotifyAllCommandsQuery, useNotifyRunQuery, - useRunStatus, } from '/app/resources/runs' import { RunProgressMeter } from '..' @@ -74,7 +74,6 @@ describe('RunProgressMeter', () => { vi.mocked(InterventionModal).mockReturnValue(
MOCK_INTERVENTION_MODAL
) - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_RUNNING) when(useMostRecentCompletedAnalysis) .calledWith(NON_DETERMINISTIC_RUN_ID) .thenReturn(null) @@ -94,7 +93,18 @@ describe('RunProgressMeter', () => { .calledWith(NON_DETERMINISTIC_RUN_ID, { refetchInterval: 1000 }) .thenReturn({ key: NON_DETERMINISTIC_COMMAND_KEY } as RunCommandSummary) - vi.mocked(useNotifyRunQuery).mockReturnValue({ data: null } as any) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(NON_DETERMINISTIC_RUN_ID, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ + data: { + data: { + id: NON_DETERMINISTIC_RUN_ID, + status: RUN_STATUS_RUNNING, + }, + }, + } as any) vi.mocked(useRunningStepCounts).mockReturnValue({ totalStepCount: null, currentStepNumber: null, @@ -125,14 +135,25 @@ describe('RunProgressMeter', () => { it('should give no step info when run status is idle', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(NON_DETERMINISTIC_RUN_ID, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ + data: { + data: { + id: NON_DETERMINISTIC_RUN_ID, + status: RUN_STATUS_IDLE, + }, + }, + } as any) + render(props) expect(screen.queryByText(/Step/)).toBeNull() }) it('should render an intervention modal when showInterventionModal is true', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) vi.mocked(useInterventionModal).mockReturnValue({ showModal: true, modalProps: {} as any, @@ -145,7 +166,19 @@ describe('RunProgressMeter', () => { it('should render no text when run status is completed', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_SUCCEEDED) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(NON_DETERMINISTIC_RUN_ID, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ + data: { + data: { + id: NON_DETERMINISTIC_RUN_ID, + status: RUN_STATUS_SUCCEEDED, + }, + }, + } as any) + vi.mocked(useRunningStepCounts).mockReturnValue({ totalStepCount: 10, currentStepNumber: 10, @@ -157,7 +190,18 @@ describe('RunProgressMeter', () => { it('should render no text when the run is cancelled before running', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_STOPPED) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(NON_DETERMINISTIC_RUN_ID, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ + data: { + data: { + id: NON_DETERMINISTIC_RUN_ID, + status: RUN_STATUS_STOPPED, + }, + }, + } as any) render(props) expect(screen.queryByText(/Step/)).toBeNull() }) diff --git a/app/src/organisms/Desktop/RunProgressMeter/constants.ts b/app/src/organisms/Desktop/RunProgressMeter/constants.ts deleted file mode 100644 index 525cb55c5d3..00000000000 --- a/app/src/organisms/Desktop/RunProgressMeter/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - RUN_STATUS_FAILED, - RUN_STATUS_FINISHING, - RUN_STATUS_STOPPED, - RUN_STATUS_SUCCEEDED, -} from '@opentrons/api-client' - -import type { RunStatus } from '@opentrons/api-client' - -export const TERMINAL_RUN_STATUSES: RunStatus[] = [ - RUN_STATUS_STOPPED, - RUN_STATUS_FAILED, - RUN_STATUS_FINISHING, - RUN_STATUS_SUCCEEDED, -] diff --git a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx index 8ccc0d1a127..7fdc38cc723 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx @@ -1,20 +1,19 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_IDLE, -} from '@opentrons/api-client' +import { RUN_STATUS_IDLE } from '@opentrons/api-client' import { CommandText, getCommandTextData, getLabwareDefinitionsFromCommands, } from '@opentrons/components' +import { + isRunStatusNotStarted, + isTerminalRunStatus, +} from '/app/local-resources/runs/utils' import { useModuleCommandAnalytics } from '/app/redux-resources/analytics/' -import { isTerminalRunStatus } from '../../Devices/ProtocolRun/ProtocolRunHeader/utils' - import type { ReactNode } from 'react' import type { CommandDetail, RunStatus } from '@opentrons/api-client' import type { @@ -57,9 +56,7 @@ export function useRunProgressCopy({ const { t } = useTranslation('run_details') const runHasNotBeenStarted = - (currentStepNumber === 0 && - runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR) || - runStatus === RUN_STATUS_IDLE + currentStepNumber === 0 && isRunStatusNotStarted(runStatus) const isValidRobotSideAnalysis = analysis != null const allRunDefs = useMemo( diff --git a/app/src/organisms/Desktop/RunProgressMeter/index.tsx b/app/src/organisms/Desktop/RunProgressMeter/index.tsx index ea411164b0c..299ca5fccae 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/index.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/index.tsx @@ -20,6 +20,7 @@ import { useCommandQuery } from '@opentrons/react-api-client' import { getModalPortalEl } from '/app/App/portal' import { ProgressBar } from '/app/atoms/ProgressBar' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import { InterventionModal, useInterventionModal, @@ -28,14 +29,13 @@ import { useRunControls } from '/app/organisms/RunTimeControl' import { useRobotType } from '/app/redux-resources/robots' import { useRunningStepCounts } from '/app/resources/protocols/hooks' import { + DEFAULT_STATUS_REFETCH_INTERVAL, useMostRecentCompletedAnalysis, useNotifyAllCommandsQuery, useNotifyRunQuery, - useRunStatus, } from '/app/resources/runs' import { useDownloadRunLog } from '../Devices/hooks' -import { isTerminalRunStatus } from '../Devices/ProtocolRun/ProtocolRunHeader/utils' import { useRunProgressCopy } from './hooks' import { InterventionTicks } from './InterventionTicks' @@ -50,10 +50,12 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { const { runId, robotName, makeHandleJumpToStep } = props const { t } = useTranslation('run_details') const robotType = useRobotType(robotName) - const runStatus = useRunStatus(runId) const { play } = useRunControls(runId) - const { data: runRecord } = useNotifyRunQuery(runId) + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) const runData = runRecord?.data ?? null + const runStatus = runData?.status ?? null const { data: mostRecentCommandData } = useNotifyAllCommandsQuery(runId, { pageLength: 1, diff --git a/app/src/organisms/DeviceDetailsDeckConfiguration/TouchScreenDeckFixtureSetupInstructionModal.stories.tsx b/app/src/organisms/DeviceDetailsDeckConfiguration/TouchScreenDeckFixtureSetupInstructionModal.stories.tsx index c289adb508c..67ebd5cf632 100644 --- a/app/src/organisms/DeviceDetailsDeckConfiguration/TouchScreenDeckFixtureSetupInstructionModal.stories.tsx +++ b/app/src/organisms/DeviceDetailsDeckConfiguration/TouchScreenDeckFixtureSetupInstructionModal.stories.tsx @@ -1,27 +1,22 @@ import { VIEWPORT } from '@opentrons/components' -import { DeckFixtureSetupInstructionsModal } from './DeckFixtureSetupInstructionsModal' +import { DeckFixtureSetupInstructionsModal as DeckFixtureSetupInstructionsModalComponent } from './DeckFixtureSetupInstructionsModal' -import type { Meta, Story } from '@storybook/react' -import type * as React from 'react' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'ODD/Organisms/DeckFixtureSetupInstructionsModal', - argTypes: { - modalSize: { - options: ['small', 'medium', 'large'], - control: { type: 'radio' }, - }, - onOutsideClick: { action: 'clicked' }, - }, + component: DeckFixtureSetupInstructionsModalComponent, parameters: VIEWPORT.touchScreenViewport, -} as Meta +} + +export default meta -const Template: Story< - React.ComponentProps -> = args => -export const Default = Template.bind({}) -Default.args = { - setShowSetupInstructionsModal: () => {}, - isOnDevice: true, +type Story = StoryObj + +export const Default: Story = { + args: { + setShowSetupInstructionsModal: () => {}, + isOnDevice: true, + }, } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryTakeover.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryTakeover.tsx index 3bdbd2bcd34..6b9db3fae1b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryTakeover.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryTakeover.tsx @@ -1,11 +1,6 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, -} from '@opentrons/api-client' import { AlertPrimaryButton, COLORS, @@ -15,6 +10,7 @@ import { StyledText, } from '@opentrons/components' +import { isRecoveryStatus } from '/app/local-resources/runs/utils' import { TakeoverModal } from '/app/organisms/TakeoverModal/TakeoverModal' import { useUpdateClientDataRecovery } from '/app/resources/client_data' @@ -43,11 +39,7 @@ export function RecoveryTakeover(props: { // TODO(jh, 07-29-24): This is likely sufficient for most edge cases, but this does not account for // all terminal commands as it should. Revisit this. - const isTerminateDisabled = !( - runStatus === RUN_STATUS_AWAITING_RECOVERY || - runStatus === RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR || - runStatus === RUN_STATUS_AWAITING_RECOVERY_PAUSED - ) + const isTerminateDisabled = !isRecoveryStatus(runStatus) const buildRecoveryTakeoverProps = ( intent: ClientDataRecovery['intent'] diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useCurrentlyRecoveringFrom.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useCurrentlyRecoveringFrom.ts index 80368f21551..c1f01ba5da7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useCurrentlyRecoveringFrom.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useCurrentlyRecoveringFrom.ts @@ -1,13 +1,9 @@ import { useEffect, useState } from 'react' import { useQueryClient } from 'react-query' -import { - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, -} from '@opentrons/api-client' import { useCommandQuery, useHost } from '@opentrons/react-api-client' +import { isRecoveryStatus } from '/app/local-resources/runs/utils' import { useNotifyAllCommandsQuery } from '/app/resources/runs' import type { RunStatus } from '@opentrons/api-client' @@ -15,13 +11,6 @@ import type { FailedCommand } from '../types' const ALL_COMMANDS_POLL_MS = 5000 -// TODO(jh, 08-06-24): See EXEC-656. -const VALID_RECOVERY_FETCH_STATUSES = [ - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, -] as Array - // Return the `currentlyRecoveringFrom` command returned by the server, if any. // The command will only be returned after the initial fetches are complete to prevent rendering of stale data. export function useCurrentlyRecoveringFrom( @@ -34,7 +23,7 @@ export function useCurrentlyRecoveringFrom( // There can only be a currentlyRecoveringFrom command when the run is in recovery mode. // In case we're falling back to polling, only enable queries when that is the case. - const isRunInRecoveryMode = VALID_RECOVERY_FETCH_STATUSES.includes(runStatus) + const isRunInRecoveryMode = isRecoveryStatus(runStatus) // Prevent stale data on subsequent recoveries by clearing the query cache at the start of each recovery. useEffect(() => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts index 43d3b141c8d..cb5a79832d7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts @@ -1,19 +1,10 @@ -import { - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, -} from '@opentrons/api-client' +import { isDoorOpenStatus } from '/app/local-resources/runs/utils' import { GRIPPER_MOVE_STEPS, RECOVERY_MAP_METADATA } from '../constants' -import type { RunStatus } from '@opentrons/api-client' import type { ErrorRecoveryFlowsProps } from '../index' import type { IRecoveryMap, RouteStep } from '../types' -const DOOR_OPEN_STATUSES: RunStatus[] = [ - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, -] - export interface UseShowDoorInfoResult { /* Whether the door actually open, regardless of whether a door open event is prohibited . */ isDoorOpen: boolean @@ -30,7 +21,7 @@ export function useShowDoorInfo( // TODO(jh, 07-16-24): "recovery paused" is only used for door status and therefore // a valid way to ensure all apps show the door open prompt, however this could be problematic in the future. // Consider restructuring this check once the takeover modals are added. - const isDoorOpen = runStatus != null && DOOR_OPEN_STATUSES.includes(runStatus) + const isDoorOpen = isDoorOpenStatus(runStatus) const isProhibitedDoorOpen = isDoorOpen && !isDoorPermittedOpen(recoveryMap) && diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index c042c3cb9ac..d718f67e54c 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -1,23 +1,14 @@ import { useLayoutEffect, useState } from 'react' import { useSelector } from 'react-redux' -import { - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_FAILED, - RUN_STATUS_FINISHING, - RUN_STATUS_IDLE, - RUN_STATUS_PAUSED, - RUN_STATUS_RUNNING, - RUN_STATUS_STOP_REQUESTED, - RUN_STATUS_STOPPED, - RUN_STATUS_SUCCEEDED, -} from '@opentrons/api-client' +import { RUN_STATUS_STOP_REQUESTED } from '@opentrons/api-client' import { useHost } from '@opentrons/react-api-client' import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { + isInvalidERRunStatus, + isValidERRunStatus, +} from '/app/local-resources/runs/utils' import { getIsOnDevice } from '/app/redux/config' import { useRunLoadedLabwareDefinitionsByUri } from '/app/resources/runs' @@ -36,25 +27,6 @@ import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' import type { RunLoadedLabwareDefinitionsByUri } from '/app/resources/runs' import type { FailedCommand } from './types' -const VALID_ER_RUN_STATUSES: RunStatus[] = [ - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, - RUN_STATUS_STOP_REQUESTED, -] - -// Effectively statuses that are not an "awaiting-recovery" status OR "stop requested." -const INVALID_ER_RUN_STATUSES: RunStatus[] = [ - RUN_STATUS_RUNNING, - RUN_STATUS_PAUSED, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_FINISHING, - RUN_STATUS_STOPPED, - RUN_STATUS_FAILED, - RUN_STATUS_SUCCEEDED, - RUN_STATUS_IDLE, -] - interface UseErrorRecoveryResultBase { isERActive: boolean failedCommand: FailedCommand | null @@ -89,26 +61,19 @@ export function useErrorRecoveryFlows( status: RunStatus | null, hasSeenAwaitingRecovery: boolean ): boolean => { - return ( - status !== null && - (status === RUN_STATUS_AWAITING_RECOVERY || - (VALID_ER_RUN_STATUSES.includes(status) && hasSeenAwaitingRecovery)) - ) + return isValidERRunStatus(status) && hasSeenAwaitingRecovery } // If client accesses a valid ER runs status besides AWAITING_RECOVERY but accesses it outside of Error Recovery flows, // don't show ER. useLayoutEffect(() => { - if (runStatus != null) { - const isAwaitingRecovery = - VALID_ER_RUN_STATUSES.includes(runStatus) && - runStatus !== RUN_STATUS_STOP_REQUESTED - - if (isAwaitingRecovery) { - setIsERActive(isValidERStatus(runStatus, true)) - } else if (INVALID_ER_RUN_STATUSES.includes(runStatus)) { - setIsERActive(isValidERStatus(runStatus, false)) - } + const isAwaitingRecovery = + isValidERRunStatus(runStatus) && runStatus !== RUN_STATUS_STOP_REQUESTED + + if (isAwaitingRecovery) { + setIsERActive(isValidERStatus(runStatus, true)) + } else if (isInvalidERRunStatus(runStatus)) { + setIsERActive(isValidERStatus(runStatus, false)) } }, [runStatus, failedCommand]) diff --git a/app/src/organisms/InterventionModal/index.tsx b/app/src/organisms/InterventionModal/index.tsx index a4da3375d30..d56582687e3 100644 --- a/app/src/organisms/InterventionModal/index.tsx +++ b/app/src/organisms/InterventionModal/index.tsx @@ -2,12 +2,6 @@ import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { css } from 'styled-components' -import { - RUN_STATUS_FAILED, - RUN_STATUS_FINISHING, - RUN_STATUS_STOPPED, - RUN_STATUS_SUCCEEDED, -} from '@opentrons/api-client' import { ALIGN_CENTER, ALIGN_FLEX_START, @@ -30,6 +24,7 @@ import { } from '@opentrons/components' import { SmallButton } from '/app/atoms/buttons' +import { isTerminatingRunStatus } from '/app/local-resources/runs/utils' import { InterventionModal as InterventionModalMolecule } from '/app/molecules/InterventionModal' import { OddModal } from '/app/molecules/OddModal' import { useRobotType } from '/app/redux-resources/robots' @@ -50,13 +45,6 @@ import type { import type { IconName } from '@opentrons/components' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' -const TERMINAL_RUN_STATUSES: RunStatus[] = [ - RUN_STATUS_STOPPED, - RUN_STATUS_FAILED, - RUN_STATUS_FINISHING, - RUN_STATUS_SUCCEEDED, -] - export interface UseInterventionModalProps { runData: RunData | null lastRunCommand: RunCommandSummary | null @@ -85,7 +73,7 @@ export function useInterventionModal({ isInterventionCommand(lastRunCommand) && runData != null && runStatus != null && - !TERMINAL_RUN_STATUSES.includes(runStatus) + !isTerminatingRunStatus(runStatus) const { t } = useTranslation('run_details') if (!isValidIntervention) { diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/__tests__/useLPCLabwareInfo.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/__tests__/useLPCLabwareInfo.test.ts index f5e9c91b18c..140c1b77b6f 100644 --- a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/__tests__/useLPCLabwareInfo.test.ts +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/__tests__/useLPCLabwareInfo.test.ts @@ -1,11 +1,12 @@ import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { when } from 'vitest-when' -import { RUN_STATUS_IDLE } from '@opentrons/api-client' +import { RUN_STATUS_IDLE, RUN_STATUS_RUNNING } from '@opentrons/api-client' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { useNotifySearchLabwareOffsets } from '/app/resources/labware_offsets' -import { useNotifyRunQuery, useRunStatus } from '/app/resources/runs' +import { useNotifyRunQuery } from '/app/resources/runs' import { useLPCLabwareInfo } from '..' import { getLPCLabwareInfoFrom } from '../getLPCLabwareInfoFrom' @@ -18,6 +19,12 @@ vi.mock('../getLPCSearchParams') vi.mock('/app/resources/labware_offsets') vi.mock('/app/resources/runs') +const runningRun = { + current: false, + id: 'test_id_running', + status: RUN_STATUS_RUNNING, +} + describe('useLPCLabwareInfo', () => { const RUN_ID = 'run-123' const PROTOCOL_DATA = { commands: [] } as any @@ -35,12 +42,13 @@ describe('useLPCLabwareInfo', () => { vi.mocked(getLPCSearchParams).mockReturnValue(MOCK_SEARCH_PARAMS) vi.mocked(getLPCLabwareInfoFrom).mockReturnValue(MOCK_LABWARE_INFO) - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) vi.mocked(useNotifySearchLabwareOffsets).mockReturnValue({ data: { data: MOCK_STORED_OFFSETS }, } as any) vi.mocked(useNotifyRunQuery).mockReturnValue({ - data: { data: { labwareOffsets: MOCK_LEGACY_OFFSETS } }, + data: { + data: { labwareOffsets: MOCK_LEGACY_OFFSETS, status: RUN_STATUS_IDLE }, + }, } as any) }) @@ -60,7 +68,6 @@ describe('useLPCLabwareInfo', () => { legacyOffsets: MOCK_LEGACY_OFFSETS, }) - expect(useRunStatus).toHaveBeenCalledWith(RUN_ID) expect(getUniqueValidLwLocationInfoByAnalysis).toHaveBeenCalledWith({ labwareDefs: LABWARE_DEFS, protocolData: PROTOCOL_DATA, @@ -119,14 +126,17 @@ describe('useLPCLabwareInfo', () => { legacyOffsets: MOCK_LEGACY_OFFSETS, }) - expect(useRunStatus).toHaveBeenCalledWith(null) expect(useNotifyRunQuery).toHaveBeenCalledWith(null, { enabled: false, }) }) it('should not enable offset search if run status is not idle', () => { - vi.mocked(useRunStatus).mockReturnValue('running' as any) + when(vi.mocked(useNotifyRunQuery)) + .calledWith('test_id_running') + .thenReturn({ + data: { data: { status: runningRun } }, + } as any) const { result } = renderHook(() => { return useLPCLabwareInfo({ diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/index.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/index.ts index 4abd29f56b1..5480563acb3 100644 --- a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/index.ts +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/index.ts @@ -4,7 +4,10 @@ import { RUN_STATUS_IDLE } from '@opentrons/api-client' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { useNotifySearchLabwareOffsets } from '/app/resources/labware_offsets' -import { useNotifyRunQuery, useRunStatus } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useNotifyRunQuery, +} from '/app/resources/runs' import { getLPCLabwareInfoFrom } from './getLPCLabwareInfoFrom' import { getLPCSearchParams } from './getLPCSearchParams' @@ -50,7 +53,10 @@ function useFlexLPCLabwareInfo({ UseLPCLabwareInfoResult, 'labwareInfo' | 'storedOffsets' > { - const runStatus = useRunStatus(runId ?? null) + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null const lwLocationCombos = useMemo( () => diff --git a/app/src/organisms/ODD/CameraSettings/CameraControls/CameraControlsHome.tsx b/app/src/organisms/ODD/CameraSettings/CameraControls/CameraControlsHome.tsx index 887d06a5495..aa0574abbf2 100644 --- a/app/src/organisms/ODD/CameraSettings/CameraControls/CameraControlsHome.tsx +++ b/app/src/organisms/ODD/CameraSettings/CameraControls/CameraControlsHome.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { Icon, ListButton, StyledText } from '@opentrons/components' import { MediumButton } from '/app/atoms/buttons' +import { zoomNumberToString } from '/app/local-resources/images/utils/cameraUtils' // eslint-disable-next-line opentrons/no-imports-across-applications -- For active dev only import { usePreviewImage } from '/app/organisms/Desktop/Camera/CameraControls/PreviewSettings/hooks/usePreviewImage' import { ChildNavigation } from '/app/organisms/ODD/ChildNavigation' @@ -12,13 +13,13 @@ import styles from '../preferences.module.css' import { ImagePreviewModal } from './ImagePreviewModal' // eslint-disable-next-line opentrons/no-imports-across-applications -- For active dev only -import type { UseStubCameraSettingsValuesResult } from '/app/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues' +import type { UseCameraSettingsValuesResult } from '/app/local-resources/images/hooks/useCameraSettingsValues' import type { ActiveControlView } from '.' export interface CameraControlsHomeProps { setActiveSubView: (view: ActiveControlView) => void toggleShowControls: () => void - settings: UseStubCameraSettingsValuesResult + settings: UseCameraSettingsValuesResult } export function CameraControlsHome({ @@ -42,7 +43,8 @@ export function CameraControlsHome({ } const buildZoomText = (): string => { - switch (settings.zoom) { + const zoomString = zoomNumberToString(settings.zoom) + switch (zoomString) { case '1x': return t('default_zoom') case '1.5x': diff --git a/app/src/organisms/ODD/CameraSettings/CameraControls/CameraTileSetting.tsx b/app/src/organisms/ODD/CameraSettings/CameraControls/CameraTileSetting.tsx index 1d3069fa062..30158ab7046 100644 --- a/app/src/organisms/ODD/CameraSettings/CameraControls/CameraTileSetting.tsx +++ b/app/src/organisms/ODD/CameraSettings/CameraControls/CameraTileSetting.tsx @@ -19,6 +19,7 @@ export interface CameraTileSettingProps { adjustValue: (value: number) => void title: string subtext: string + isLoading: boolean returnToHomeView: () => void } @@ -28,6 +29,7 @@ export function CameraTileSetting({ value, subtext, adjustValue, + isLoading, }: CameraTileSettingProps): JSX.Element { const adjustedValue = roundValueToValidPercentage(value) @@ -42,7 +44,11 @@ export function CameraTileSetting({ return (
- +
{subtext}
diff --git a/app/src/organisms/ODD/CameraSettings/CameraControls/ZoomSettingsView.tsx b/app/src/organisms/ODD/CameraSettings/CameraControls/ZoomSettingsView.tsx index 1548c7b1076..4002f519dd1 100644 --- a/app/src/organisms/ODD/CameraSettings/CameraControls/ZoomSettingsView.tsx +++ b/app/src/organisms/ODD/CameraSettings/CameraControls/ZoomSettingsView.tsx @@ -2,36 +2,36 @@ import { useTranslation } from 'react-i18next' import { RadioButton, StyledText } from '@opentrons/components' +import { zoomNumberToString } from '/app/local-resources/images/utils/cameraUtils' import { ChildNavigation } from '/app/organisms/ODD/ChildNavigation' import styles from './cameracontrols.module.css' // eslint-disable-next-line opentrons/no-imports-across-applications -- For active dev only -import type { UseStubCameraSettingsValuesResult } from '/app/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues' +import type { UseCameraSettingsValuesResult } from '/app/local-resources/images/hooks/useCameraSettingsValues' -const ZOOM_VALUES: Array = [ - '1x', - '1.5x', - '2x', -] +const ZOOM_VALUES: Array = [1, 1.5, 2] export interface ZoomSettingsViewProps { - zoomValue: UseStubCameraSettingsValuesResult['zoom'] - adjustZoom: UseStubCameraSettingsValuesResult['adjustZoom'] + zoomValue: UseCameraSettingsValuesResult['zoom'] + adjustZoom: UseCameraSettingsValuesResult['adjustZoom'] returnToHomeView: () => void + isLoading: boolean } export function ZoomSettingsView({ zoomValue, adjustZoom, returnToHomeView, + isLoading, }: ZoomSettingsViewProps): JSX.Element { const { t } = useTranslation('device_settings') const buildSubLabel = ( - value: UseStubCameraSettingsValuesResult['zoom'] + value: UseCameraSettingsValuesResult['zoom'] ): string => { - switch (value) { + const zoomString = zoomNumberToString(value) + switch (zoomString) { case '1x': return t('default') case '1.5x': @@ -43,26 +43,31 @@ export function ZoomSettingsView({ return (
- +
{t('adjust_deck_appearance')}
{ZOOM_VALUES.map(val => { + const zoomString = zoomNumberToString(val) return ( { - adjustZoom(val) + adjustZoom(zoomString) }} /> ) diff --git a/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraControls.test.tsx b/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraControls.test.tsx index e1452033b02..c16b9332979 100644 --- a/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraControls.test.tsx +++ b/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraControls.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' // eslint-disable-next-line opentrons/no-imports-across-applications -- For active dev only -import { useStubCameraSettingsValues } from '/app/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues' +import { useCameraSettingsValues } from '/app/local-resources/images/hooks/useCameraSettingsValues' import { CameraControls } from '..' import { CameraControlsHome } from '../CameraControlsHome' @@ -13,9 +13,7 @@ import { ZoomSettingsView } from '../ZoomSettingsView' import type { CameraControlsProps } from '..' -vi.mock( - '/app/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues' -) +vi.mock('/app/local-resources/images/hooks/useCameraSettingsValues') vi.mock('../CameraControlsHome') vi.mock('../CameraTileSetting') vi.mock('../ZoomSettingsView') @@ -32,9 +30,10 @@ describe('CameraControls', () => { beforeEach(() => { mockProps = { toggleShowControls: vi.fn(), + runId: 'run-id', } - vi.mocked(useStubCameraSettingsValues).mockReturnValue({ - zoom: '1x', + vi.mocked(useCameraSettingsValues).mockReturnValue({ + zoom: 1, brightness: 50, contrast: 50, saturation: 50, @@ -80,7 +79,7 @@ describe('CameraControls', () => { screen.getByText('MOCK_ZOOM_SETTINGS_VIEW') expect(vi.mocked(ZoomSettingsView)).toHaveBeenCalledWith( expect.objectContaining({ - zoomValue: '1x', + zoomValue: 1, adjustZoom: expect.any(Function), returnToHomeView: expect.any(Function), }), @@ -102,6 +101,7 @@ describe('CameraControls', () => { subtext: 'Adjust the overall lightness or darkness.', adjustValue: expect.any(Function), returnToHomeView: expect.any(Function), + isLoading: false, }), {} ) diff --git a/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraControlsHome.test.tsx b/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraControlsHome.test.tsx index d9f4d93a555..5cb18e94fd4 100644 --- a/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraControlsHome.test.tsx +++ b/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraControlsHome.test.tsx @@ -11,7 +11,7 @@ import { CameraControlsHome } from '../CameraControlsHome' import { ImagePreviewModal } from '../ImagePreviewModal' // eslint-disable-next-line opentrons/no-imports-across-applications -- For active dev only -import type { UseStubCameraSettingsValuesResult } from '/app/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues' +import type { UseCameraSettingsValuesResult } from '/app/local-resources/images/hooks/useCameraSettingsValues' import type { CameraControlsHomeProps } from '../CameraControlsHome' vi.mock( @@ -28,11 +28,11 @@ const render = (props: CameraControlsHomeProps) => { describe('CameraControlsHome', () => { let mockProps: CameraControlsHomeProps - let mockSettings: UseStubCameraSettingsValuesResult + let mockSettings: UseCameraSettingsValuesResult beforeEach(() => { mockSettings = { - zoom: '1x', + zoom: 1, brightness: 50, contrast: 50, saturation: 50, @@ -95,7 +95,7 @@ describe('CameraControlsHome', () => { it('renders zoom setting button with moderate zoom text', () => { const propsWithModerateZoom = { ...mockProps, - settings: { ...mockSettings, zoom: '1.5x' as const }, + settings: { ...mockSettings, zoom: 1.5 }, } render(propsWithModerateZoom) @@ -105,7 +105,7 @@ describe('CameraControlsHome', () => { it('renders zoom setting button with maximum zoom text', () => { const propsWithMaxZoom = { ...mockProps, - settings: { ...mockSettings, zoom: '2x' as const }, + settings: { ...mockSettings, zoom: 2 }, } render(propsWithMaxZoom) diff --git a/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraTileSettings.test.tsx b/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraTileSettings.test.tsx index d700213dcc8..1d905cd418d 100644 --- a/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraTileSettings.test.tsx +++ b/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/CameraTileSettings.test.tsx @@ -27,6 +27,7 @@ describe('CameraTileSetting', () => { title: 'Test Setting', subtext: 'Test subtext description', returnToHomeView: vi.fn(), + isLoading: false, } vi.mocked(ChildNavigation).mockReturnValue(
MOCK_CHILD_NAVIGATION
) }) diff --git a/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/ZoomSettingsView.test.tsx b/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/ZoomSettingsView.test.tsx index 2ae94a0b6a6..36dff802472 100644 --- a/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/ZoomSettingsView.test.tsx +++ b/app/src/organisms/ODD/CameraSettings/CameraControls/__tests__/ZoomSettingsView.test.tsx @@ -22,9 +22,10 @@ describe('ZoomSettingsView', () => { beforeEach(() => { mockProps = { - zoomValue: '1x', + zoomValue: 1, adjustZoom: vi.fn(), returnToHomeView: vi.fn(), + isLoading: false, } vi.mocked(ChildNavigation).mockReturnValue(
MOCK_CHILD_NAVIGATION
) }) diff --git a/app/src/organisms/ODD/CameraSettings/CameraControls/index.tsx b/app/src/organisms/ODD/CameraSettings/CameraControls/index.tsx index 8494f4fb63f..7cbb02de826 100644 --- a/app/src/organisms/ODD/CameraSettings/CameraControls/index.tsx +++ b/app/src/organisms/ODD/CameraSettings/CameraControls/index.tsx @@ -1,13 +1,20 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { + useAddCameraImageSettingsToRunMutation, + useCreateCameraImageSettings, +} from '@opentrons/react-api-client' + // eslint-disable-next-line opentrons/no-imports-across-applications -- For active dev only -import { useStubCameraSettingsValues } from '/app/organisms/Desktop/Camera/CameraControls/hooks/useStubCameraSettingsValues' +import { useCameraSettingsValues } from '/app/local-resources/images/hooks/useCameraSettingsValues' import { CameraControlsHome } from './CameraControlsHome' import { CameraTileSetting } from './CameraTileSetting' import { ZoomSettingsView } from './ZoomSettingsView' +import type { CameraImageSettings } from '@opentrons/api-client' + export type ActiveControlView = | 'zoom' | 'brightness' @@ -17,18 +24,39 @@ export type ActiveControlView = export interface CameraControlsProps { toggleShowControls: () => void + runId: string | null } export function CameraControls({ toggleShowControls, + runId, }: CameraControlsProps): JSX.Element { const { t } = useTranslation('device_settings') + const [isLoading, setIsLoading] = useState(false) const [activeSubView, setActiveSubView] = useState(null) - const settings = useStubCameraSettingsValues() + const settings = useCameraSettingsValues() + const createGlobalCameraSettings = useCreateCameraImageSettings() + const createRunCameraSettings = useAddCameraImageSettingsToRunMutation( + runId || '' + ) + + const returnToHomeView = (settings: CameraImageSettings): void => { + setIsLoading(true) + + const createCameraSettings = + runId != null + ? createRunCameraSettings.addCameraImageSettingsToRun + : createGlobalCameraSettings.createCameraImageSettings - const returnToHomeView = (): void => { - setActiveSubView(null) + createCameraSettings(settings, { + onSuccess: () => { + setActiveSubView(null) + }, + onSettled: () => { + setIsLoading(false) + }, + }) } switch (activeSubView) { @@ -37,7 +65,10 @@ export function CameraControls({ { + returnToHomeView({ zoom: settings.zoom }) + }} + isLoading={isLoading} /> ) @@ -48,7 +79,10 @@ export function CameraControls({ title={t('brightness')} subtext={t('adjust_brightness')} adjustValue={settings.adjustBrightness} - returnToHomeView={returnToHomeView} + returnToHomeView={() => { + returnToHomeView({ brightness: settings.brightness }) + }} + isLoading={isLoading} /> ) @@ -59,7 +93,10 @@ export function CameraControls({ title={t('contrast')} subtext={t('adjust_contrast')} adjustValue={settings.adjustContrast} - returnToHomeView={returnToHomeView} + returnToHomeView={() => { + returnToHomeView({ contrast: settings.contrast }) + }} + isLoading={isLoading} /> ) @@ -70,7 +107,10 @@ export function CameraControls({ title={t('saturation')} subtext={t('adjust_saturation')} adjustValue={settings.adjustSaturation} - returnToHomeView={returnToHomeView} + returnToHomeView={() => { + returnToHomeView({ saturation: settings.saturation }) + }} + isLoading={isLoading} /> ) diff --git a/app/src/organisms/ODD/CameraSettings/index.tsx b/app/src/organisms/ODD/CameraSettings/index.tsx index ec4f184d49a..d371e7970c7 100644 --- a/app/src/organisms/ODD/CameraSettings/index.tsx +++ b/app/src/organisms/ODD/CameraSettings/index.tsx @@ -34,6 +34,7 @@ export interface CameraSettingsProps { not general settings context. */ storageInfo: RobotStorageInfo | null isCameraRequired: boolean | null + runId: string | null } export function CameraSettings({ @@ -48,6 +49,7 @@ export function CameraSettings({ toggleLiveStreamEnabled, isRecoveryCaptureEnabled, isLiveVideoEnabled, + runId, }: CameraSettingsProps): JSX.Element { const isCameraSettingsEnabled = useFeatureFlag('camera') const [showControls, setShowControls] = useState(false) @@ -92,7 +94,9 @@ export function CameraSettings({ } } if (showControls) { - return + return ( + + ) } else { return (
ariaDisabled?: boolean @@ -53,6 +54,7 @@ export function ChildNavigation({ onClickButton, buttonType = 'primary', iconName, + backIconName, iconPlacement, secondaryButtonProps, buttonIsDisabled, @@ -79,7 +81,11 @@ export function ChildNavigation({ onClick={onClickBack} data-testid="ChildNavigation_Back_Button" > - + ) : null} diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupCamera/index.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupCamera/index.tsx index 0091d049c1d..88bf0d8fcb1 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupCamera/index.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupCamera/index.tsx @@ -122,6 +122,7 @@ export function ProtocolSetupCamera( toggleLiveStreamEnabled={toggleLiveStreamEnabled} toggleRecoveryEnabled={toggleRecoveryEnabled} toggleCameraEnabled={toggleCameraEnabled} + runId={runId} /> ) } diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/ProtocolSetupModulesAndDeck.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/ProtocolSetupModulesAndDeck.tsx index 66fd0336c1e..f7dbd976c56 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/ProtocolSetupModulesAndDeck.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/ProtocolSetupModulesAndDeck.tsx @@ -26,8 +26,9 @@ import { useNotifyDeckConfigurationQuery } from '/app/resources/deck_configurati import { useDeckConfigurationCompatibility } from '/app/resources/deck_configuration/hooks' import { useAttachedModules } from '/app/resources/modules' import { + DEFAULT_STATUS_REFETCH_INTERVAL, useMostRecentCompletedAnalysis, - useRunStatus, + useNotifyRunQuery, } from '/app/resources/runs' import { getAttachedProtocolModuleMatches, @@ -60,7 +61,10 @@ export function ProtocolSetupModulesAndDeck({ }: ProtocolSetupModulesAndDeckProps): JSX.Element { const { i18n, t } = useTranslation('protocol_setup') const navigate = useNavigate() - const runStatus = useRunStatus(runId) + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null useEffect(() => { if (runStatus === RUN_STATUS_STOPPED) { navigate('/protocols') diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/__tests__/ProtocolSetupModulesAndDeck.test.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/__tests__/ProtocolSetupModulesAndDeck.test.tsx index de048e72770..7c98c4884ed 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/__tests__/ProtocolSetupModulesAndDeck.test.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/__tests__/ProtocolSetupModulesAndDeck.test.tsx @@ -24,8 +24,8 @@ import { useAttachedModules } from '/app/resources/modules' import { useChainLiveCommands, useMostRecentCompletedAnalysis, + useNotifyRunQuery, useRunCalibrationStatus, - useRunStatus, } from '/app/resources/runs' import { getAttachedProtocolModuleMatches, @@ -132,7 +132,9 @@ describe('ProtocolSetupModulesAndDeck', () => { chainLiveCommands: mockChainLiveCommands, } as any) vi.mocked(FixtureTable).mockReturnValue(
mock FixtureTable
) - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) + vi.mocked(useNotifyRunQuery).mockReturnValue({ + data: { data: { status: RUN_STATUS_IDLE } }, + } as any) }) afterEach(() => { diff --git a/app/src/organisms/ODD/QuickTransferFlow/Dispense/hooks/useDispenseSettingsConfig.ts b/app/src/organisms/ODD/QuickTransferFlow/Dispense/hooks/useDispenseSettingsConfig.ts index 95e7328316b..2af5135fa4c 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/Dispense/hooks/useDispenseSettingsConfig.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/Dispense/hooks/useDispenseSettingsConfig.ts @@ -55,6 +55,35 @@ export function useDispenseSettingsConfig({ return t('blow_out_into_waste_chute') } } + const getDisposalVolumeLocationCopy = (): string => { + if (state.disposalVolumeDispenseSettings?.blowOutLocation == null) { + return t('trashBin') + } + if ( + state.disposalVolumeDispenseSettings.blowOutLocation === + SOURCE_WELL_BLOWOUT_DESTINATION + ) { + return t('blow_out_source_well') + } + if ( + typeof state.disposalVolumeDispenseSettings.blowOutLocation === + 'object' && + state.disposalVolumeDispenseSettings.blowOutLocation.cutoutFixtureId === + TRASH_BIN_ADAPTER_FIXTURE + ) { + return t('trashBin') + } + if ( + typeof state.disposalVolumeDispenseSettings.blowOutLocation === + 'object' && + WASTE_CHUTE_FIXTURES.includes( + state.disposalVolumeDispenseSettings.blowOutLocation.cutoutFixtureId + ) + ) { + return t('wasteChute') + } + return t('trashBin') + } const touchTipEnabled = getIsTouchTipEnabled(state.destination) const hasLiquidClass = state.liquidClassName !== 'none' @@ -197,11 +226,7 @@ export function useDispenseSettingsConfig({ state.disposalVolumeDispenseSettings != null && isMultiTransfer ? t('disposal_volume_label', { volume: state.disposalVolumeDispenseSettings.volume, - location: - state.disposalVolumeDispenseSettings.blowOutLocation === - SOURCE_WELL_BLOWOUT_DESTINATION - ? t('blow_out_source_well') - : t('trashBin'), + location: getDisposalVolumeLocationCopy(), flowRate: state.disposalVolumeDispenseSettings.flowRate, }) : t('option_disabled'), diff --git a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx index 259398de628..99bdec034dc 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx @@ -119,7 +119,7 @@ export function AirGap(props: AirGapProps): JSX.Element { ? getAspirateAirGapVolumeRange(state.pipette, state.tipRack) : getDispenseAirGapVolumeRange( state.volume, - state?.disposalVolume ?? 0, + state?.disposalVolumeDispenseSettings?.volume ?? 0, state.path, state.pipette, state.tipRack diff --git a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/DisposalVolume.tsx b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/DisposalVolume.tsx index 23e77b98a7f..3de5602e297 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/DisposalVolume.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/DisposalVolume.tsx @@ -14,6 +14,7 @@ import { StyledText, } from '@opentrons/components' import { + FLEX_SINGLE_SLOT_BY_CUTOUT_ID, getTipTypeFromTipRackDefinition, LOW_VOLUME_PIPETTES, TRASH_BIN_ADAPTER_FIXTURE, @@ -65,41 +66,62 @@ export function DisposalVolume(props: DisposalVolumeProps): JSX.Element { if (typeof blowOut.location === 'string') { return blowOut.location } - return `trashBin:${blowOut.location.cutoutId}` + if ( + 'cutoutFixtureId' in blowOut.location && + typeof blowOut.location.cutoutFixtureId === 'string' && + WASTE_CHUTE_FIXTURES.includes(blowOut.location.cutoutFixtureId) + ) { + return `wasteChute:${blowOut.location.cutoutId}` + } + if ('cutoutId' in blowOut.location) { + return `trashBin:${blowOut.location.cutoutId}` + } + return '' } const [selectedBlowoutLocation, setSelectedBlowoutLocation] = useState(getInitialBlowoutLocation(state.blowOutDispense)) const [flowRate, setFlowRate] = useState(null) const deckConfig = useNotifyDeckConfigurationQuery().data ?? [] - const fixtureLocationOptions = deckConfig.filter( - cutoutConfig => - WASTE_CHUTE_FIXTURES.includes(cutoutConfig.cutoutFixtureId) || - TRASH_BIN_ADAPTER_FIXTURE === cutoutConfig.cutoutFixtureId - ) - - const trashBinCutoutId = fixtureLocationOptions.find( - option => option.cutoutFixtureId === TRASH_BIN_ADAPTER_FIXTURE - )?.cutoutId + const trashBinCutoutConfig = deckConfig.find( + cutoutConfig => cutoutConfig.cutoutFixtureId === TRASH_BIN_ADAPTER_FIXTURE + ) const trashBinOption: BlowOutLocation | undefined = - trashBinCutoutId != null + trashBinCutoutConfig != null ? { - cutoutId: trashBinCutoutId, + cutoutId: trashBinCutoutConfig.cutoutId, cutoutFixtureId: TRASH_BIN_ADAPTER_FIXTURE, } : undefined + const wasteChuteOptions = deckConfig + .filter( + option => + typeof option.cutoutFixtureId === 'string' && + WASTE_CHUTE_FIXTURES.includes(option.cutoutFixtureId) + ) + .map(option => ({ + option, + value: `wasteChute:${option.cutoutId}`, + description: t('wasteChute_location', { + slotName: FLEX_SINGLE_SLOT_BY_CUTOUT_ID[option.cutoutId], + }), + })) + const blowoutLocationOptions = [ ...(trashBinOption != null ? [ { option: trashBinOption, value: `trashBin:${trashBinOption.cutoutId}`, - description: t('trashBin'), + description: t('trashBin_location', { + slotName: FLEX_SINGLE_SLOT_BY_CUTOUT_ID[trashBinOption.cutoutId], + }), }, ] : []), + ...wasteChuteOptions, { option: SOURCE_WELL_BLOWOUT_DESTINATION, value: SOURCE_WELL_BLOWOUT_DESTINATION, @@ -113,11 +135,14 @@ export function DisposalVolume(props: DisposalVolumeProps): JSX.Element { const flowRatesForSupportedTip: SupportedTip | undefined = state.volume < 5 && `lowVolumeDefault` in liquidSpecs && + typeof pipetteName === 'string' && LOW_VOLUME_PIPETTES.includes(pipetteName) ? liquidSpecs.lowVolumeDefault.supportedTips[tipType] : liquidSpecs.default.supportedTips[tipType] const minFlowRate = 0.1 - const maxFlowRate = Math.floor(flowRatesForSupportedTip?.uiMaxFlowRate ?? 0) + const maxFlowRate = Math.floor( + (flowRatesForSupportedTip?.uiMaxFlowRate ?? 0) as number + ) const flowRateError = flowRate != null && (flowRate < minFlowRate || flowRate > maxFlowRate) @@ -145,11 +170,17 @@ export function DisposalVolume(props: DisposalVolumeProps): JSX.Element { return } + const selectedOption = blowoutLocationOptions.find( + opt => opt.value === selectedBlowoutLocation + ) + const blowOutLocation: BlowOutLocation = + selectedOption?.option ?? (selectedBlowoutLocation as BlowOutLocation) + dispatch({ type: ACTIONS.SET_DISPOSAL_VOLUME_DISPENSE, disposalVolumeDispenseSettings: { volume, - blowOutLocation: selectedBlowoutLocation as BlowOutLocation, + blowOutLocation, flowRate, }, }) @@ -257,7 +288,8 @@ export function DisposalVolume(props: DisposalVolumeProps): JSX.Element { key={option.value} isSelected={selectedBlowoutLocation === option.value} onChange={() => { - setSelectedBlowoutLocation(option.value) + const value = String(option.value) + setSelectedBlowoutLocation(value) }} buttonValue={option.value} buttonLabel={option.description} diff --git a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx index 77366e54ded..8c0e6797c86 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx @@ -53,7 +53,7 @@ export function PipettePath(props: PipettePathProps): JSX.Element { >(state.blowOutDispense?.location) const [disposalVolume, setDisposalVolume] = useState( - state?.disposalVolume + state?.disposalVolumeDispenseSettings?.volume ) const maxPipetteVolume = Object.values(state.pipette.liquids)[0].maxVolume const tipVolume = Object.values(state.tipRack.wells)[0].totalLiquidVolume @@ -118,8 +118,6 @@ export function PipettePath(props: PipettePathProps): JSX.Element { dispatch({ type: ACTIONS.SET_PIPETTE_PATH, path: selectedPath as PathOption, - disposalVolume, - blowOutLocation, }) trackEventWithRobotSerial({ name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, diff --git a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/__tests__/DisposalVolume.test.tsx b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/__tests__/DisposalVolume.test.tsx index ddb5f455c31..2ed2d9b13ef 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/__tests__/DisposalVolume.test.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/QuickTransferAdvancedSettings/__tests__/DisposalVolume.test.tsx @@ -83,7 +83,7 @@ describe('DisposalVolume', () => { await user.click(screen.getByText('Continue')) screen.getByText('Select blowout location') screen.getByText('Continue') - screen.getByText('Trash bin') + screen.getByText('Trash bin in C3') screen.getByText('Source well') }) diff --git a/app/src/organisms/ODD/QuickTransferFlow/SummaryAndSettings.tsx b/app/src/organisms/ODD/QuickTransferFlow/SummaryAndSettings.tsx index df6249db189..04c9cb29caf 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/SummaryAndSettings.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/SummaryAndSettings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useReducer, useState } from 'react' +import { useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' import { useQueryClient } from 'react-query' import { useNavigate } from 'react-router-dom' @@ -33,12 +33,13 @@ import { Dispense } from './Dispense' import { Overview } from './Overview' import { quickTransferSummaryReducer } from './reducers' import { SaveOrRunModal } from './SaveOrRunModal' -import { getInitialSummaryState, retrieveLiquidClassValues } from './utils' +import { initializeSummaryState } from './utils' import { createQuickTransferPythonFile } from './utils/createQuickTransferFile' import type { ComponentProps } from 'react' import type { SmallButton } from '/app/atoms/buttons' import type { QuickTransferWizardState } from './types' +import type { InitialSummaryStateProps } from './utils/getInitialSummaryState' interface SummaryAndSettingsProps { exitButtonProps: ComponentProps @@ -65,15 +66,10 @@ export function SummaryAndSettings( const [selectedCategory, setSelectedCategory] = useState('overview') const deckConfig = useNotifyDeckConfigurationQuery().data ?? [] - const initialSummaryState = getInitialSummaryState({ - // @ts-expect-error TODO figure out how to make this type non-null as we know - // none of these values will be undefined - state: wizardFlowState, - deckConfig, - }) const [state, dispatch] = useReducer( quickTransferSummaryReducer, - initialSummaryState + { state: wizardFlowState as InitialSummaryStateProps['state'], deckConfig }, + initializeSummaryState ) const { mutateAsync: createProtocolAsync } = useCreateProtocolMutation() @@ -90,19 +86,6 @@ export function SummaryAndSettings( host ) - useEffect(() => { - if (!state.liquidClassValuesInitialized) { - const liquidClassValues = retrieveLiquidClassValues(state, 'all') - dispatch({ - type: 'SET_LIQUID_CLASS_VALUES', - liquidClassValues: { - ...liquidClassValues, - liquidClassValuesInitialized: true, - }, - }) - } - }) - const isMultiTransferDispense = state?.path === 'multiDispense' const handleClickCreateTransfer = (): void => { diff --git a/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/PipettePath.test.tsx b/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/PipettePath.test.tsx index 770ea82bd55..dd3f3a07ed9 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/PipettePath.test.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/__tests__/QuickTransferAdvancedSettings/PipettePath.test.tsx @@ -156,9 +156,10 @@ describe('PipettePath', () => { state: { ...props.state, transferType: 'distribute', - disposalVolume: 20, - blowOutDispense: { - location: 'source_well', + path: 'multiDispense', + disposalVolumeDispenseSettings: { + volume: 20, + blowOutLocation: 'source_well', flowRate: 10, }, }, @@ -188,9 +189,9 @@ describe('PipettePath', () => { ...props.state, transferType: 'distribute', path: 'multiDispense', - disposalVolume: 20, - blowOutDispense: { - location: 'source_well', + disposalVolumeDispenseSettings: { + volume: 20, + blowOutLocation: 'source_well', flowRate: 10, }, }, @@ -221,9 +222,9 @@ describe('PipettePath', () => { ...props.state, transferType: 'distribute', path: 'multiDispense', - disposalVolume: 20, - blowOutDispense: { - location: 'source_well', + disposalVolumeDispenseSettings: { + volume: 20, + blowOutLocation: 'source_well', flowRate: 10, }, }, @@ -233,9 +234,5 @@ describe('PipettePath', () => { fireEvent.click(continueBtn) fireEvent.click(continueBtn) screen.getByText('Source well') - const saveBtn = screen.getByTestId('ChildNavigation_Primary_Button') - fireEvent.click(saveBtn) - expect(props.dispatch).toHaveBeenCalled() - expect(mockTrackEventWithRobotSerial).toHaveBeenCalled() }) }) diff --git a/app/src/organisms/ODD/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx b/app/src/organisms/ODD/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx index 175094d7175..3fba196382d 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx @@ -5,6 +5,7 @@ import { useCreateProtocolMutation, useCreateRunMutation, } from '@opentrons/react-api-client' +import { TRASH_BIN_ADAPTER_FIXTURE } from '@opentrons/shared-data' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' @@ -14,6 +15,7 @@ import { useNotifyDeckConfigurationQuery } from '/app/resources/deck_configurati import { NameQuickTransfer } from '../NameQuickTransfer' import { Overview } from '../Overview' +import mockQuickTransferState from '../QuickTransferAdvancedSettings/__fixtures__/QuickTransferState.json' import { SummaryAndSettings } from '../SummaryAndSettings' import { createQuickTransferPythonFile, getInitialSummaryState } from '../utils' @@ -21,6 +23,10 @@ import type { ComponentProps } from 'react' import type { NavigateFunction } from 'react-router-dom' const mockNavigate = vi.fn() +const mockFixture = { + cutoutId: 'cutoutA3', + cutoutFixtureId: TRASH_BIN_ADAPTER_FIXTURE, +} vi.mock('react-router-dom', async importOriginal => { const reactRouterDom = await importOriginal() @@ -63,15 +69,19 @@ describe('SummaryAndSettings', () => { onClick: vi.fn(), }, state: { - pipette: {} as any, + pipette: mockQuickTransferState.pipette as any, mount: 'left', - tipRack: {} as any, + tipRack: mockQuickTransferState.tipRack as any, source: {} as any, sourceWells: ['A1'], destination: {} as any, destinationWells: ['A1'], transferType: 'transfer', volume: 25, + path: 'single', + liquidClassName: 'none', + changeTip: 'once', + dropTipLocation: undefined, }, analyticsStartTime: new Date(), } @@ -79,9 +89,7 @@ describe('SummaryAndSettings', () => { () => new Promise(resolve => resolve({})) ) vi.mocked(useNotifyDeckConfigurationQuery).mockReturnValue({ - data: { - data: [], - }, + data: [mockFixture], } as any) vi.mocked(useTrackEventWithRobotSerial).mockReturnValue({ trackEventWithRobotSerial: mockTrackEventWithRobotSerial, diff --git a/app/src/organisms/ODD/QuickTransferFlow/__tests__/utils/getInitialSummaryState.test.ts b/app/src/organisms/ODD/QuickTransferFlow/__tests__/utils/getInitialSummaryState.test.ts index 69e9aa16385..82017c950c0 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/__tests__/utils/getInitialSummaryState.test.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/__tests__/utils/getInitialSummaryState.test.ts @@ -42,6 +42,13 @@ describe('getInitialSummaryState', () => { path: 'single', liquidClassValuesInitialized: false, changeTip: 'always', + blowOutDispense: { + flowRate: 75, + location: { + cutoutFixtureId: 'trashBinAdapter', + cutoutId: 'cutoutA3', + }, + }, } as any, deckConfig: [ { @@ -125,17 +132,71 @@ describe('getInitialSummaryState', () => { }) }) it('generates the summary state with correct default value for 1 to n transfer', () => { + const distributeProps = { + state: { + pipette: { + channels: 1, + liquids: { + default: { + maxVolume: 100, + supportedTips: { + t50: { + defaultAspirateFlowRate: { + default: 50, + }, + defaultDispenseFlowRate: { + default: 75, + }, + }, + }, + }, + }, + } as any, + mount: 'left', + tipRack: { + wells: { + A1: { + totalLiquidVolume: 50, + }, + }, + } as any, + source: {} as any, + sourceWells: ['A1'], + destination: 'source', + destinationWells: ['A1'], + transferType: 'transfer', + volume: 25, + path: 'single', + liquidClassValuesInitialized: false, + changeTip: 'always', + disposalVolumeDispenseSettings: { + volume: 1, + flowRate: 75, + blowoutLocation: { + cutoutFixtureId: 'trashBinAdapter', + cutoutId: 'cutoutA3', + }, + }, + } as any, + deckConfig: [ + { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, + ], + } as any + const initialSummaryState = getInitialSummaryState({ - ...props, + ...distributeProps, state: { - ...props.state, + ...distributeProps.state, volume: 1, path: 'multiDispense', transferType: 'distribute', }, }) expect(initialSummaryState).toEqual({ - ...props.state, + ...distributeProps.state, volume: 1, transferType: 'distribute', aspirateFlowRate: 50, @@ -149,11 +210,6 @@ describe('getInitialSummaryState', () => { cutoutId: 'cutoutA3', cutoutFixtureId: 'trashBinAdapter', }, - disposalVolume: 1, - blowOutDispense: { - location: { cutoutId: 'cutoutA3', cutoutFixtureId: 'trashBinAdapter' }, - flowRate: 75, - }, }) }) it('generates the summary state with correct default value for 1 to n transfer with too high of volume for multiDispense', () => { diff --git a/app/src/organisms/ODD/QuickTransferFlow/constants.ts b/app/src/organisms/ODD/QuickTransferFlow/constants.ts index ba9cd74ed5d..b4827c2f774 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/constants.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/constants.ts @@ -138,6 +138,7 @@ export const SINGLE_CHANNEL_COMPATIBLE_LABWARE = [ 'opentrons/opentrons_tough_4_reservoir_72ml/1', 'opentrons/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1', 'opentrons/smc_384_read_plate/2', + 'opentrons/thermofisher_nunc_maxisorp_lockwell_elisa/1', 'opentrons/thermoscientific_96_wellplate_800ul/1', 'opentrons/thermoscientific_abgene_96_wellplate_1.2ml/1', 'opentrons/thermoscientificnunc_96_wellplate_1300ul/3', @@ -209,6 +210,7 @@ export const EIGHT_CHANNEL_COMPATIBLE_LABWARE = [ 'opentrons/opentrons_tough_4_reservoir_72ml/1', 'opentrons/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1', 'opentrons/smc_384_read_plate/2', + 'opentrons/thermofisher_nunc_maxisorp_lockwell_elisa/1', 'opentrons/thermoscientific_96_wellplate_800ul/1', 'opentrons/thermoscientific_abgene_96_wellplate_1.2ml/1', 'opentrons/thermoscientificnunc_96_wellplate_1300ul/3', @@ -281,6 +283,7 @@ export const NINETY_SIX_CHANNEL_COMPATIBLE_LABWARE = [ 'opentrons/opentrons_tough_4_reservoir_72ml/1', 'opentrons/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1', 'opentrons/smc_384_read_plate/2', + 'opentrons/thermofisher_nunc_maxisorp_lockwell_elisa/1', 'opentrons/thermoscientific_96_wellplate_800ul/1', 'opentrons/thermoscientific_abgene_96_wellplate_1.2ml/1', 'opentrons/thermoscientificnunc_96_wellplate_1300ul/3', diff --git a/app/src/organisms/ODD/QuickTransferFlow/reducers.ts b/app/src/organisms/ODD/QuickTransferFlow/reducers.ts index 38ab279361e..dfe32e9fed4 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/reducers.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/reducers.ts @@ -96,6 +96,7 @@ export function quickTransferWizardReducer( path: action.path, } } + case 'SET_CHANGE_TIP': { return { ...state, @@ -135,22 +136,9 @@ export function quickTransferSummaryReducer( } } case 'SET_PIPETTE_PATH': { - if (action.path === 'multiDispense') { - return { - ...state, - path: action.path, - disposalVolume: action.disposalVolume, - blowOutDispense: { - location: action.blowOutLocation, - flowRate: state.dispenseFlowRate, - }, - } - } else { - return { - ...state, - path: action.path, - disposalVolume: undefined, - } + return { + ...state, + path: action.path, } } case 'SET_ASPIRATE_TIP_POSITION': { diff --git a/app/src/organisms/ODD/QuickTransferFlow/types.ts b/app/src/organisms/ODD/QuickTransferFlow/types.ts index 3c7a23cbe1c..09dcd9f2976 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/types.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/types.ts @@ -29,6 +29,11 @@ export interface QuickTransferWizardState { changeTip?: ChangeTipOptions dropTipLocation?: CutoutConfig | string liquidClassName?: string + disposalVolumeDispenseSettings?: { + volume: number + blowOutLocation: BlowOutLocation + flowRate: number + } } export type PathOption = 'single' | 'multiAspirate' | 'multiDispense' export type ChangeTipOptions = @@ -113,7 +118,6 @@ export interface QuickTransferSummaryState { } touchTipDispense?: number // specifies the tip position from the top of the well touchTipDispenseSpeed?: number - disposalVolume?: number blowOutDispense?: { location?: BlowOutLocation flowRate?: number @@ -188,8 +192,6 @@ interface SetDispenseFlowRateAction { interface SetPipettePath { type: typeof ACTIONS.SET_PIPETTE_PATH path: PathOption - disposalVolume?: number - blowOutLocation?: BlowOutLocation } interface SetAspirateTipPosition { type: typeof ACTIONS.SET_ASPIRATE_TIP_POSITION diff --git a/app/src/organisms/ODD/QuickTransferFlow/utils/generateQuickTransferArgs.ts b/app/src/organisms/ODD/QuickTransferFlow/utils/generateQuickTransferArgs.ts index 491f8de38e4..29d1e1fb571 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/utils/generateQuickTransferArgs.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/utils/generateQuickTransferArgs.ts @@ -353,30 +353,34 @@ export function generateQuickTransferArgs( ) let blowoutLocation: string | undefined + const blowOutDispenseLocation = + quickTransferState.path === 'multiDispense' + ? quickTransferState.disposalVolumeDispenseSettings?.blowOutLocation + : quickTransferState.blowOutDispense?.location + if ( - quickTransferState?.blowOutDispense?.location != null && - quickTransferState.blowOutDispense.location !== 'source_well' && - quickTransferState.blowOutDispense.location !== 'dest_well' && - 'cutoutId' in quickTransferState.blowOutDispense.location + blowOutDispenseLocation != null && + blowOutDispenseLocation !== 'source_well' && + blowOutDispenseLocation !== 'dest_well' && + typeof blowOutDispenseLocation === 'object' && + 'cutoutId' in blowOutDispenseLocation ) { const trashBinEntity = Object.values( invariantContext.trashBinEntities ).find(entity => { - const blowoutObject = quickTransferState.blowOutDispense - ?.location as CutoutConfig + const blowoutObject = blowOutDispenseLocation as CutoutConfig return entity.location === blowoutObject.cutoutId }) const wasteChuteEntity = Object.values( invariantContext.wasteChuteEntities ).find(entity => { - const blowoutObject = quickTransferState.blowOutDispense - ?.location as CutoutConfig + const blowoutObject = blowOutDispenseLocation as CutoutConfig return entity.location === blowoutObject.cutoutId }) const entity = trashBinEntity != null ? trashBinEntity : wasteChuteEntity blowoutLocation = entity?.id } else { - blowoutLocation = quickTransferState.blowOutDispense?.location + blowoutLocation = blowOutDispenseLocation as string | undefined } const dropTipTrashBinLocationEntity = Object.values( @@ -455,7 +459,10 @@ export function generateQuickTransferArgs( aspirateOffsetFromBottomMm: quickTransferState.tipPositionAspirate, dispenseOffsetFromBottomMm: quickTransferState.tipPositionDispense, blowoutLocation, - blowoutFlowRateUlSec: quickTransferState.blowOutDispense?.flowRate ?? 0, + blowoutFlowRateUlSec: + quickTransferState.path === 'multiDispense' + ? (quickTransferState.disposalVolumeDispenseSettings?.flowRate ?? 0) + : (quickTransferState.blowOutDispense?.flowRate ?? 0), blowoutOffsetFromTopMm: DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP, changeTip: quickTransferState.changeTip, preWetTip: quickTransferState.preWetTip, diff --git a/app/src/organisms/ODD/QuickTransferFlow/utils/getInitialSummaryState.ts b/app/src/organisms/ODD/QuickTransferFlow/utils/getInitialSummaryState.ts index 53334d4c4f3..5cccfd08fe7 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/utils/getInitialSummaryState.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/utils/getInitialSummaryState.ts @@ -12,13 +12,14 @@ import type { PipetteV2Specs, } from '@opentrons/shared-data' import type { + BlowOutLocation, ChangeTipOptions, PathOption, QuickTransferSummaryState, TransferType, } from '../types' -interface InitialSummaryStateProps { +export interface InitialSummaryStateProps { state: { pipette: PipetteV2Specs mount: Mount @@ -36,6 +37,11 @@ interface InitialSummaryStateProps { } changeTip: ChangeTipOptions dropTipLocation?: CutoutConfig + disposalVolumeDispenseSettings?: { + volume: number + blowOutLocation: BlowOutLocation + flowRate: number + } } deckConfig: DeckConfiguration } @@ -96,9 +102,12 @@ export function getInitialSummaryState( aspirateFlowRate: flowRatesForSupportedTip.defaultAspirateFlowRate.default, dispenseFlowRate: flowRatesForSupportedTip.defaultDispenseFlowRate.default, path, - disposalVolume: path === 'multiDispense' ? state.volume : undefined, - blowOutDispense: + disposalVolumeDispenseSettings: path === 'multiDispense' + ? state.disposalVolumeDispenseSettings + : undefined, + blowOutDispense: + path !== 'multiDispense' ? { location: trashConfigCutout, flowRate: flowRatesForSupportedTip.defaultDispenseFlowRate.default, diff --git a/app/src/organisms/ODD/QuickTransferFlow/utils/index.ts b/app/src/organisms/ODD/QuickTransferFlow/utils/index.ts index f20af11032f..c45e82c72a0 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/utils/index.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/utils/index.ts @@ -11,4 +11,5 @@ export { getMaxUiFlowRate } from './getMaxUiFlowRate' export { getPipetteName } from './getPipetteName' export { getSelectedWellCount } from './getSelectedWellCount' export { getVolumeRange } from './getVolumeRange' +export { initializeSummaryState } from './initializeSummaryState' export { retrieveLiquidClassValues } from './retrieveLiquidClassValues' diff --git a/app/src/organisms/ODD/QuickTransferFlow/utils/initializeSummaryState.ts b/app/src/organisms/ODD/QuickTransferFlow/utils/initializeSummaryState.ts new file mode 100644 index 00000000000..7aa7e33c30d --- /dev/null +++ b/app/src/organisms/ODD/QuickTransferFlow/utils/initializeSummaryState.ts @@ -0,0 +1,19 @@ +import { getInitialSummaryState } from './getInitialSummaryState' +import { retrieveLiquidClassValues } from './retrieveLiquidClassValues' + +import type { QuickTransferSummaryState } from '../types' +import type { InitialSummaryStateProps } from './getInitialSummaryState' + +export const initializeSummaryState = ( + props: InitialSummaryStateProps +): QuickTransferSummaryState => { + const baseState = getInitialSummaryState(props) + + const liquidClassValues = retrieveLiquidClassValues(baseState, 'all') + + return { + ...baseState, + ...liquidClassValues, + liquidClassValuesInitialized: true, + } +} diff --git a/app/src/organisms/ODD/QuickTransferFlow/utils/retrieveLiquidClassValues.ts b/app/src/organisms/ODD/QuickTransferFlow/utils/retrieveLiquidClassValues.ts index 5e6242d7c1f..c5376dee763 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/utils/retrieveLiquidClassValues.ts +++ b/app/src/organisms/ODD/QuickTransferFlow/utils/retrieveLiquidClassValues.ts @@ -166,7 +166,7 @@ const getNoLiquidClassValues = ( dispenseMaxUiFlowRate ) - const aspirateState = { + const aspirateState: Partial = { aspirateFlowRate: aspirateFlowRateFields.aspirate_flowRate ?? 0, tipPositionAspirate: DEFAULT_MM_OFFSET_FROM_BOTTOM, submergeAspirate: { @@ -193,7 +193,7 @@ const getNoLiquidClassValues = ( conditionAspirate: actualConditioningVolume ?? 0, } - const dispenseState = { + const dispenseState: Partial = { dispenseFlowRate: dispenseFlowRateFields.dispense_flowRate ?? 0, tipPositionDispense: DEFAULT_MM_OFFSET_FROM_BOTTOM, submergeDispense: { @@ -220,7 +220,7 @@ const getNoLiquidClassValues = ( : dispense.retract.touchTip.params?.zOffset, touchTipDispenseSpeed: dispense.retract.touchTip.params?.speed, disposalVolumeDispenseSettings: { - volume: 0, + volume: pipette.liquids.default.minVolume, blowOutLocation: convertBlowoutLocation( dispense?.retract.blowout?.params?.location, diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/CameraPreferences.tsx b/app/src/organisms/ODD/RobotSettingsDashboard/CameraPreferences.tsx index d67a9e89ada..89cb8c0ca66 100644 --- a/app/src/organisms/ODD/RobotSettingsDashboard/CameraPreferences.tsx +++ b/app/src/organisms/ODD/RobotSettingsDashboard/CameraPreferences.tsx @@ -36,6 +36,7 @@ export function CameraPreferences({ toggleLiveStreamEnabled={settings.toggleLiveVideoEnabled} storageInfo={null} isCameraRequired={null} + runId={null} /> ) } diff --git a/app/src/organisms/ODD/RunningProtocol/ConfirmCancelRunModal/index.tsx b/app/src/organisms/ODD/RunningProtocol/ConfirmCancelRunModal/index.tsx index 436a6672528..f54356a9c94 100644 --- a/app/src/organisms/ODD/RunningProtocol/ConfirmCancelRunModal/index.tsx +++ b/app/src/organisms/ODD/RunningProtocol/ConfirmCancelRunModal/index.tsx @@ -6,7 +6,6 @@ import { useNavigate } from 'react-router-dom' import { RUN_STATUS_STOPPED } from '@opentrons/api-client' import { COLORS, LegacyStyledText } from '@opentrons/components' import { - useDeleteRunMutation, useDismissCurrentRunMutation, useStopRunMutation, } from '@opentrons/react-api-client' @@ -40,20 +39,8 @@ export function ConfirmCancelRunModal({ }: ConfirmCancelRunModalProps): JSX.Element { const { t } = useTranslation(['run_details', 'shared']) const { stopRun } = useStopRunMutation() - const { deleteRun } = useDeleteRunMutation({ - onError: error => { - setIsCanceling(false) - console.error('Error deleting quick transfer run', error) - }, - }) const { dismissCurrentRun, isLoading: isDismissing } = - useDismissCurrentRunMutation({ - onSettled: () => { - if (isQuickTransfer) { - deleteRun(runId) - } - }, - }) + useDismissCurrentRunMutation() const localRobot = useSelector(getLocalRobot) const { data, isError: isRunFetchError } = useNotifyRunQuery(runId) const runStatus = data?.data.status diff --git a/app/src/organisms/PipetteWizardFlows/AttachProbe.tsx b/app/src/organisms/PipetteWizardFlows/AttachProbe.tsx index 0536b2ef420..e78a7fc0101 100644 --- a/app/src/organisms/PipetteWizardFlows/AttachProbe.tsx +++ b/app/src/organisms/PipetteWizardFlows/AttachProbe.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import capitalize from 'lodash/capitalize' import { css } from 'styled-components' import { @@ -69,7 +68,7 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { proceed() } - const { t, i18n } = useTranslation('pipette_wizard_flows') + const { t, i18n } = useTranslation(['pipette_wizard_flows', 'shared']) const pipetteWizardStep = { mount, flowType, section: SECTIONS.ATTACH_PROBE } const [showUnableToDetect, setShowUnableToDetect] = useState(false) const pipetteId = attachedPipettes[mount]?.serialNumber @@ -199,7 +198,7 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { } proceedButtonText={ is96Channel && isWasteChuteOnDeck(deckConfig) - ? capitalize('shared:continue') + ? t('shared:continue') : t('begin_calibration') } proceed={ diff --git a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx index 4a324bb8fe2..accf2a1c5ff 100644 --- a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx +++ b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx @@ -4,13 +4,13 @@ import { when } from 'vitest-when' import '@testing-library/jest-dom/vitest' +import { RUN_STATUS_RUNNING } from '@opentrons/api-client' import { useRunActionMutations } from '@opentrons/react-api-client' import { useCloneRun, useCurrentRunId, useNotifyRunQuery, - useRunStatus, } from '/app/resources/runs' import { mockPausedRun, @@ -82,9 +82,17 @@ describe('useCurrentRunStatus hook', () => { }) it('returns the run status of the current run', async () => { - when(useRunStatus).calledWith(RUN_ID_2).thenReturn('running') - const { result } = renderHook(useCurrentRunStatus) - expect(result.current).toBe('running') + when(useNotifyRunQuery) + .calledWith(RUN_ID_2, expect.any(Object)) + .thenReturn({ + data: { + data: { + ...mockRunningRun, + }, + }, + } as unknown as UseQueryResult) + const { result } = renderHook(() => useCurrentRunStatus({})) + expect(result.current).toBe(RUN_STATUS_RUNNING) }) }) diff --git a/app/src/organisms/RunTimeControl/hooks.ts b/app/src/organisms/RunTimeControl/hooks.ts index bbe44c2f5fe..f4a76db13ef 100644 --- a/app/src/organisms/RunTimeControl/hooks.ts +++ b/app/src/organisms/RunTimeControl/hooks.ts @@ -2,11 +2,11 @@ import { useRunActionMutations } from '@opentrons/react-api-client' import { DEFAULT_RUN_QUERY_REFETCH_INTERVAL, + DEFAULT_STATUS_REFETCH_INTERVAL, useCloneRun, useCurrentRunId, useMostRecentCompletedAnalysis, useNotifyRunQuery, - useRunStatus, } from '/app/resources/runs' import type { UseQueryOptions } from 'react-query' @@ -66,8 +66,11 @@ export function useCurrentRunStatus( options?: UseQueryOptions ): RunStatus | null { const currentRunId = useCurrentRunId() - - return useRunStatus(currentRunId, options) + const { data: runRecord } = useNotifyRunQuery(currentRunId, { + ...options, + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + return runRecord?.data.status ?? null } export function useRunErrors(runId: string | null): RunData['errors'] { diff --git a/app/src/pages/Desktop/Devices/DeviceDetails/DeviceDetailsComponent.tsx b/app/src/pages/Desktop/Devices/DeviceDetails/DeviceDetailsComponent.tsx index c66b5447726..a148fb9baa7 100644 --- a/app/src/pages/Desktop/Devices/DeviceDetails/DeviceDetailsComponent.tsx +++ b/app/src/pages/Desktop/Devices/DeviceDetails/DeviceDetailsComponent.tsx @@ -83,7 +83,6 @@ export function DeviceDetailsComponent({ {isRobotViewable && ( <> diff --git a/app/src/pages/Desktop/LivestreamViewer/LivestreamInfoScreen.tsx b/app/src/pages/Desktop/LivestreamViewer/LivestreamInfoScreen.tsx index edcceb74fa4..69a42bf5641 100644 --- a/app/src/pages/Desktop/LivestreamViewer/LivestreamInfoScreen.tsx +++ b/app/src/pages/Desktop/LivestreamViewer/LivestreamInfoScreen.tsx @@ -1,12 +1,11 @@ import { useTranslation } from 'react-i18next' -import { - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_IDLE, -} from '@opentrons/api-client' import { InfoScreen } from '@opentrons/components' -import { isTerminalRunStatus } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils' +import { + isRunStatusNotStarted, + isTerminalRunStatus, +} from '/app/local-resources/runs/utils' import type { CameraData, RunStatus } from '@opentrons/api-client' @@ -26,10 +25,7 @@ export function useLivestreamInfoScreen( ): LiveStreamInfoScreenType { // camera data can only undefined before a run starts unless actively being fetched. const unconfirmedSettingsDuringRunSetup = - cameraData == null && - !isRunLoading && - (runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR || - runStatus === RUN_STATUS_IDLE) + cameraData == null && !isRunLoading && isRunStatusNotStarted(runStatus) if (unconfirmedSettingsDuringRunSetup) { return 'run-setup' diff --git a/app/src/pages/Desktop/LivestreamViewer/hooks/useHlsVideo.ts b/app/src/pages/Desktop/LivestreamViewer/hooks/useHlsVideo.ts index f6df02d8245..31d164e5b19 100644 --- a/app/src/pages/Desktop/LivestreamViewer/hooks/useHlsVideo.ts +++ b/app/src/pages/Desktop/LivestreamViewer/hooks/useHlsVideo.ts @@ -3,7 +3,7 @@ import Hls from 'hls.js' import { useHost } from '@opentrons/react-api-client' -import { isTerminalRunStatus } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import type { RefObject } from 'react' import type { CameraData, RunStatus } from '@opentrons/api-client' diff --git a/app/src/pages/Desktop/LivestreamViewer/hooks/useReportWindowDurationEvent.ts b/app/src/pages/Desktop/LivestreamViewer/hooks/useReportWindowDurationEvent.ts index 28decc40165..da15cb94205 100644 --- a/app/src/pages/Desktop/LivestreamViewer/hooks/useReportWindowDurationEvent.ts +++ b/app/src/pages/Desktop/LivestreamViewer/hooks/useReportWindowDurationEvent.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' -import { isTerminalRunStatus } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/utils' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import { SOURCE_RUN_RECORD, useCameraAnalytics, diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/ModuleSlotDetails.tsx b/app/src/pages/Desktop/Protocols/ProtocolVisualization/ModuleSlotDetails.tsx deleted file mode 100644 index b5b21082f4f..00000000000 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/ModuleSlotDetails.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Chip, Divider, StyledText } from '@opentrons/components' -import { - getModuleDisplayName, - THERMOCYCLER_MODULE_TYPE, -} from '@opentrons/shared-data' - -import styles from './visualization.module.css' - -import type { ModuleEntities, RobotState } from '@opentrons/step-generation' - -interface ModuleSlotDetailsProps { - moduleId: string - moduleEntities: ModuleEntities - moduleRobotState: RobotState['modules'] -} -export function ModuleSlotDetails(props: ModuleSlotDetailsProps): JSX.Element { - const { moduleId, moduleEntities, moduleRobotState } = props - const { model } = moduleEntities[moduleId] - const moduleName = getModuleDisplayName(model) - const moduleState = moduleRobotState[moduleId].moduleState - - let moduleDetails =
- switch (moduleState.type) { - case THERMOCYCLER_MODULE_TYPE: { - moduleDetails = ( -
-
- - Block temp status - - -
-
- - Lid temp status - - -
-
- - Lid status - - - {moduleState.lidOpen || moduleState.lidOpen == null - ? 'Open' - : 'Closed'} - -
-
- ) - break - } - default: - moduleDetails =
TODO: wire up module details
- } - - return ( - <> -
-
- - {moduleName} - -
-
{moduleDetails}
-
- - - ) -} diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/SlotDetails.tsx b/app/src/pages/Desktop/Protocols/ProtocolVisualization/SlotDetails.tsx deleted file mode 100644 index 634aad4f579..00000000000 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/SlotDetails.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Divider, RobotInfoLabel, StyledText } from '@opentrons/components' -import { getModuleDeckLabel } from '@opentrons/shared-data' -import { getFullStackFromLabwares } from '@opentrons/step-generation' - -import { LabwareSlotContainer } from '/app/organisms/Desktop/ProtocolVisualization/LabwareSlotContainer' - -import { ModuleSlotDetails } from './ModuleSlotDetails' -import { SlotDetailsEmptyState } from './SlotDetailsEmptyState' -import { TrashSlotDetails } from './TrashSlotDetails' -import styles from './visualization.module.css' - -import type { - Liquid, - ProtocolAnalysisOutput, - RunTimeCommand, -} from '@opentrons/shared-data' -import type { InvariantContext, RobotState } from '@opentrons/step-generation' - -interface SlotDetailsProps { - slotId: string - command: RunTimeCommand - robotState: RobotState - invariantContext: InvariantContext - analysis: ProtocolAnalysisOutput - liquids: Liquid[] -} -export function SlotDetails(props: SlotDetailsProps): JSX.Element { - const { slotId, command, robotState, invariantContext, analysis, liquids } = - props - const { labware, modules } = robotState - const { - labwareEntities, - trashBinEntities, - wasteChuteEntities, - moduleEntities, - pipetteEntities, - } = invariantContext - const { commands } = analysis - const stackOfLabwareOnSlot = getFullStackFromLabwares(labware, slotId) - const moduleOnSlot = Object.entries(modules).find( - ([id, module]) => module.slot === slotId - ) - const topMostLabwareOnSlot = - stackOfLabwareOnSlot?.length > 1 ? stackOfLabwareOnSlot[0] : null - const isTrashOnSlot = - Object.values(trashBinEntities).some( - trash => trash.location.split('cutout')[1] === slotId - ) || - Object.values(wasteChuteEntities).some( - trash => trash.location.split('cutout')[1] === slotId - ) - return ( -
-
-
-
- Slot - -
-
- - {moduleOnSlot != null ? ( - - ) : null} - {topMostLabwareOnSlot != null ? ( - - ) : null} - {isTrashOnSlot ? ( - - ) : null} - {moduleOnSlot == null && - topMostLabwareOnSlot == null && - !isTrashOnSlot ? ( - - ) : null} -
-
- ) -} diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/SlotDetailsEmptyState.tsx b/app/src/pages/Desktop/Protocols/ProtocolVisualization/SlotDetailsEmptyState.tsx deleted file mode 100644 index 38479c02c98..00000000000 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/SlotDetailsEmptyState.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { COLORS, StyledText } from '@opentrons/components' - -import styles from './visualization.module.css' - -export function SlotDetailsEmptyState(): JSX.Element { - return ( -
-
- - Empty - -
-
-
-
-
- ) -} diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/TrashSlotDetails.tsx b/app/src/pages/Desktop/Protocols/ProtocolVisualization/TrashSlotDetails.tsx deleted file mode 100644 index 9f56d0e0e3f..00000000000 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/TrashSlotDetails.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Divider, StyledText } from '@opentrons/components' - -import styles from './visualization.module.css' - -import type { TrashBinEntities } from '@opentrons/step-generation' - -interface TrashSlotDetailsProps { - trashBinEntities: TrashBinEntities -} - -export function TrashSlotDetails(props: TrashSlotDetailsProps): JSX.Element { - const { trashBinEntities } = props - - const header = - Object.values(trashBinEntities).length > 0 ? 'Trash bin' : 'Waste Chute' - - return ( - <> -
-
- {header} -
-
- - - ) -} diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/index.tsx b/app/src/pages/Desktop/Protocols/ProtocolVisualization/index.tsx index 430a8d660f4..1ec9dd971f8 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/index.tsx +++ b/app/src/pages/Desktop/Protocols/ProtocolVisualization/index.tsx @@ -10,8 +10,8 @@ import { getStoredProtocolGroupedCommands, } from '/app/redux/protocol-storage' +import { VisualizerContainer } from '../../../../organisms/Desktop/ProtocolVisualization/VisualizerContainer' import styles from './visualization.module.css' -import { VisualizerContainer } from './VisualizerContainer' import type { DesktopRouteParams } from '/app/App/types' import type { Dispatch, State } from '/app/redux/types' diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/utils.ts b/app/src/pages/Desktop/Protocols/ProtocolVisualization/utils.ts deleted file mode 100644 index 16ea87480ae..00000000000 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/utils.ts +++ /dev/null @@ -1,388 +0,0 @@ -import mapValues from 'lodash/mapValues' -import reduce from 'lodash/reduce' -import sum from 'lodash/sum' -import values from 'lodash/values' - -import { COLORS } from '@opentrons/components' -import { - COLUMN, - FLEX_ROBOT_TYPE, - getDeckDefFromRobotType, - isAddressableAreaStandardSlot, - OT2_ROBOT_TYPE, - ROW, - SINGLE, - STAGING_AREA_RIGHT_SLOT_FIXTURE, - THERMOCYCLER_MODULE_TYPE, -} from '@opentrons/shared-data' -import { - _wellContentsForLabware, - getLiquidIdsOnLabware, - getSlotInLocationStack, - getVolumesPerLiquid, -} from '@opentrons/step-generation' - -import { POTENTIAL_TRASH_COMMAND_TYPES } from './consants' - -import type { ComponentProps } from 'react' -import type { Module, WellGroup } from '@opentrons/components' -import type { - AddressableAreaName, - CutoutId, - LabwareDefinition2, - Liquid, - NozzleConfigurationStyle, - RobotType, - RunTimeCommand, -} from '@opentrons/shared-data' -import type { - ContentsByWell, - DeckSlot, - LabwareTemporalProperties, - ModuleEntities, - ModuleTemporalProperties, - RobotState, - SingleLabwareLiquidState, - TrashBinEntities, - WasteChuteEntities, -} from '@opentrons/step-generation' -import type { GroupedCommands } from '/app/redux/protocol-storage' - -interface LiquidDetailInfo { - totalVolume: number - color: string - displayName: string -} -type WellContentsByLabware = Record - -export const getStagingAreaAddressableAreas = ( - cutoutIds: CutoutId[], - filterStandardSlots: boolean = true -): AddressableAreaName[] => { - const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) - const cutoutFixtures = deckDef.cutoutFixtures - - const addressableAreasRaw = cutoutIds.flatMap(cutoutId => { - const addressableAreasOnCutout = cutoutFixtures.find( - cutoutFixture => cutoutFixture.id === STAGING_AREA_RIGHT_SLOT_FIXTURE - )?.providesAddressableAreas[cutoutId] - return addressableAreasOnCutout ?? [] - }) - if (filterStandardSlots) { - return addressableAreasRaw.filter( - aa => !isAddressableAreaStandardSlot(aa, deckDef) - ) - } - return addressableAreasRaw -} - -export const getSlotIsEmpty = ( - robotState: RobotState, - slot: string -): boolean => { - const modulesInSlot = values(robotState.modules).filter( - moduleTemporalProperties => { - return slot.includes(moduleTemporalProperties.slot) - } - ) - const labwareInSlot = values(robotState.labware).filter( - labwareTemporalProperties => - getSlotInLocationStack(labwareTemporalProperties.stack) === slot - ) - - return modulesInSlot.length === 0 && labwareInSlot.length === 0 -} - -export const getSlotIdsBlockedBySpanningForThermocycler = ( - modules: RobotState['modules'], - moduleEntities: ModuleEntities, - robotType: RobotType -): DeckSlot[] => { - const loadedThermocycler = Object.keys(modules).find( - id => moduleEntities[id].type === THERMOCYCLER_MODULE_TYPE - ) - if (loadedThermocycler != null && robotType === FLEX_ROBOT_TYPE) { - return ['A1', 'B1'] - } else if (loadedThermocycler != null && robotType === OT2_ROBOT_TYPE) { - return ['7', '8', '10', '11'] - } - - return [] -} - -export const getAllWellContentsAtFrame = ( - liquidState: RobotState['liquidState'], - labwareDef: LabwareDefinition2 -): WellContentsByLabware => { - const labwareLiquidState = liquidState.labware - const wellContentsByLabwareId = mapValues( - labwareLiquidState, - (labwareLiquids: SingleLabwareLiquidState, labwareId: string) => { - return _wellContentsForLabware(labwareLiquids, labwareDef) - } - ) - return wellContentsByLabwareId -} - -export const getLiquidDetailInfo = ( - wellContents: ContentsByWell, - liquids: Liquid[] -): LiquidDetailInfo[] => { - const individualIds = getLiquidIdsOnLabware(wellContents) - const volumesPerLiquid = getVolumesPerLiquid(wellContents, individualIds) - const liquidInfo: LiquidDetailInfo[] = individualIds.map(liquidId => { - const totalVolume = sum(Object.values(volumesPerLiquid[liquidId])) - const matchingLiquid = liquids.find(liquid => liquid.id === liquidId) - - return { - totalVolume, - // TODO: add default liquid color - color: matchingLiquid?.displayColor ?? COLORS.black70, - displayName: matchingLiquid?.displayName ?? 'unknown display name', - } - }) - return liquidInfo -} - -export const getMissingTips = ( - tipState: RobotState['tipState'], - labwareId: string -): WellGroup | null => { - const missingTipsByLabwareId = - tipState && - mapValues(tipState.tipracks, tipMap => - reduce( - tipMap, - (acc, hasTip, wellName): WellGroup => - hasTip ? acc : { ...acc, [wellName]: null }, - {} - ) - ) - const missingTips = missingTipsByLabwareId - ? missingTipsByLabwareId[labwareId] - : null - - return missingTips -} - -interface ActiveLayer { - isActiveLayerVisible: boolean -} - -export const getActiveLayer = ( - id: string, - selectedRunTimeCommand?: RunTimeCommand -): ActiveLayer => { - const isStepAssosciatedWithLabwareId = - selectedRunTimeCommand != null && - 'labwareId' in selectedRunTimeCommand.params && - selectedRunTimeCommand.params.labwareId === id - const isMoveStepAssosciatedWithLabwareId = - selectedRunTimeCommand != null && - selectedRunTimeCommand.commandType === 'moveLabware' && - 'labwareId' in selectedRunTimeCommand.params && - selectedRunTimeCommand.params.labwareId === id - - const isStepAssosciatedWithLabware = - isStepAssosciatedWithLabwareId || isMoveStepAssosciatedWithLabwareId - - return { - isActiveLayerVisible: isStepAssosciatedWithLabware, - } -} - -export const getTopmostLabwareOnModuleFromStack = ( - moduleId: string, - labware: LabwareTemporalProperties[] -): string => { - return labware - .filter(lw => lw.stack.includes(moduleId)) // all stacks involving this module - .sort((a, b) => b.stack.length - a.stack.length)[0]?.stack[0] // return topmost labware from largest stack -} - -export const getChannels = ( - channels: number | null, - nozzles?: NozzleConfigurationStyle -): number => { - let numChannels = channels ?? 1 - if (nozzles === SINGLE) { - numChannels = 1 - } else if (nozzles === COLUMN) { - numChannels = 8 - } else if (nozzles === ROW) { - numChannels = 12 - } - return numChannels -} - -export function getNextGroupFirstCommandId( - groupedCommands: GroupedCommands | null, - currentCommandId: string -): string | null { - if (groupedCommands == null) { - return null - } - - const currentIndex = groupedCommands.findIndex(group => { - if ('subCommands' in group) { - return group.subCommands.some( - leaf => leaf.command.id === currentCommandId - ) - } else { - return group.command.id === currentCommandId - } - }) - - if (currentIndex === -1 || currentIndex + 1 >= groupedCommands.length) { - return null // No next group - } - - const nextGroup = groupedCommands[currentIndex + 1] - - if ('subCommands' in nextGroup) { - return nextGroup.subCommands[0]?.command.id ?? null - } else { - return nextGroup.command.id - } -} - -export function getPreviousGroupFirstCommandId( - groupedCommands: GroupedCommands | null, - currentCommandId: string -): string | null { - if (!groupedCommands) { - return null - } - - const currentIndex = groupedCommands.findIndex(group => { - if ('subCommands' in group) { - return group.subCommands.some( - leaf => leaf.command.id === currentCommandId - ) - } else { - return group.command.id === currentCommandId - } - }) - - if (currentIndex <= 0) { - return null - } - - const previousGroup = groupedCommands[currentIndex - 1] - - if ('subCommands' in previousGroup) { - return previousGroup.subCommands[0]?.command.id ?? null - } else { - return previousGroup.command.id - } -} - -export const getThermocyclerOverlayText = ( - commandType: RunTimeCommand['commandType'] -): string => { - switch (commandType) { - case 'loadModule': - return 'Load Thermocycler' - case 'thermocycler/openLid': - return 'Opening lid' - case 'thermocycler/closeLid': - return 'Closing lid' - case 'thermocycler/setTargetBlockTemperature': - return 'Setting block temperature' - case 'thermocycler/waitForLidTemperature': - return 'Setting lid temperature' - default: - // TODO: the rest of the copy isn't needed for protocol viz user testing purposes - return 'Changing thermocycler state' - } -} - -export const getIsCutoutA1Active = ( - labware: RobotState['labware'], - modules: RobotState['modules'], - cutoutId: CutoutId, - selectedRunTimeCommand?: RunTimeCommand -): boolean => { - const labwareOnB1 = Object.entries(labware).find( - ([_, lw]) => getSlotInLocationStack(lw.stack) === 'B1' - ) - const hasThermocycler = Object.values(modules).some( - module => module.moduleState.type === THERMOCYCLER_MODULE_TYPE - ) - - const { isActiveLayerVisible: isThermocyclerActive } = - labwareOnB1 != null - ? getActiveLayer(labwareOnB1[0], selectedRunTimeCommand) - : { isActiveLayerVisible: false } - - return isThermocyclerActive && hasThermocycler && cutoutId === 'cutoutA1' -} - -export const getModuleInnerProps = ( - moduleState: ModuleTemporalProperties['moduleState'] -): ComponentProps['innerProps'] => { - if (moduleState.type === THERMOCYCLER_MODULE_TYPE) { - let lidMotorState = 'unknown' - if (moduleState.lidOpen) { - lidMotorState = 'open' - } else if (moduleState.lidOpen === false) { - lidMotorState = 'closed' - } - return { - lidMotorState, - blockTargetTemp: moduleState.blockTargetTemp, - } - } else if ( - 'targetTemperature' in moduleState && - moduleState.type === 'temperatureModuleType' - ) { - return { - targetTemperature: moduleState.targetTemperature, - } - } else if ('targetTemp' in moduleState) { - return { - targetTemp: moduleState.targetTemp, - } - } -} - -// TODO: the dropTipInPlace, airGapInplace, and -// blowoutInPlace commands don't have -// any knowledge of where its dropping. would be -// nice to expand the results key to include the -// addressable area name -export const getIsPipetteOverTrash = ( - pipettes: RobotState['pipettes'], - id: string, - selectedRunTimeCommand?: RunTimeCommand -): boolean => - Object.values(pipettes).some(pipette => pipette.entityId === id) && - selectedRunTimeCommand != null && - POTENTIAL_TRASH_COMMAND_TYPES.includes(selectedRunTimeCommand.commandType) - -export const getFixtureSummaryInfo = ( - pipettes: RobotState['pipettes'], - entities: TrashBinEntities | WasteChuteEntities, - selectedRunTimeCommand?: RunTimeCommand -): { - isPipetteOverTrash: boolean - trashLikeEntityCutoutId: CutoutId | null -} => { - const pipetteCurrentTrashId = Object.values(pipettes).find( - pipette => pipette.entityId != null && entities[pipette.entityId] != null - )?.entityId - const isPipetteOverTrash = - pipetteCurrentTrashId != null - ? getIsPipetteOverTrash( - pipettes, - pipetteCurrentTrashId, - selectedRunTimeCommand - ) - : false - const trashLikeEntityCutoutId = - pipetteCurrentTrashId != null - ? (entities[pipetteCurrentTrashId].location as CutoutId) - : null - - return { isPipetteOverTrash, trashLikeEntityCutoutId } -} diff --git a/app/src/pages/Desktop/Protocols/ProtocolVisualization/visualization.module.css b/app/src/pages/Desktop/Protocols/ProtocolVisualization/visualization.module.css index b978e832a1a..eca1ffefe79 100644 --- a/app/src/pages/Desktop/Protocols/ProtocolVisualization/visualization.module.css +++ b/app/src/pages/Desktop/Protocols/ProtocolVisualization/visualization.module.css @@ -122,15 +122,6 @@ pointer-events: auto; } -.deck_view_container { - width: 100%; - height: 100%; - padding: 28px 32px; - border-radius: var(--border-radius-8); - background-color: var(--white); - gap: var(--spacing-20); -} - .detail_container { display: flex; height: 100%; @@ -152,19 +143,6 @@ background-color: var(--white); } -.command_step_header { - display: flex; - justify-content: space-between; - padding: var(--spacing-16); -} - -.command_step_groups { - height: 39rem; - min-height: 0; - flex: 1; - overflow-y: auto; -} - .slot_details { height: 100vh; border-radius: var(--border-radius-8); diff --git a/app/src/pages/Desktop/StepDetailViewer/index.tsx b/app/src/pages/Desktop/StepDetailViewer/index.tsx index a64144f29c1..4f9e8af4776 100644 --- a/app/src/pages/Desktop/StepDetailViewer/index.tsx +++ b/app/src/pages/Desktop/StepDetailViewer/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' -import { SlotDetails } from '../Protocols/ProtocolVisualization/SlotDetails' +import { SlotDetails } from '/app/organisms/Desktop/ProtocolVisualization/SlotDetails' import type { Liquid, diff --git a/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 03eba2d8a8b..fb2729a678f 100644 --- a/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { when } from 'vitest-when' -import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' +import { RUN_STATUS_STOPPED } from '@opentrons/api-client' import { useAddCameraSettingsToRunMutation, useAllPipetteOffsetCalibrationsQuery, @@ -80,7 +80,6 @@ import { useNotifyRunQuery, useProtocolAnalysisErrors, useRunCreatedAtTimestamp, - useRunStatus, } from '/app/resources/runs' import { getProtocolModulesInfo } from '/app/transformations/analysis' @@ -279,7 +278,6 @@ describe('ProtocolSetup', () => { isResumeRunFromRecoveryActionLoading: false, isRunControlLoading: false, }) - when(vi.mocked(useRunStatus)).calledWith(RUN_ID).thenReturn(RUN_STATUS_IDLE) vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ data: mockEmptyAnalysis, } as any) @@ -300,6 +298,7 @@ describe('ProtocolSetup', () => { data: { protocolId: PROTOCOL_ID, labwareOffsets: [mockOffset], + status: RUN_STATUS_STOPPED, }, }, } as any) @@ -607,7 +606,6 @@ describe('ProtocolSetup', () => { }) it('should redirect to the protocols page when a run is stopped', () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_STOPPED) render(`/runs/${RUN_ID}/setup/`) expect(mockNavigate).toHaveBeenCalledWith('/protocols') }) diff --git a/app/src/pages/ODD/ProtocolSetup/index.tsx b/app/src/pages/ODD/ProtocolSetup/index.tsx index e462891a411..326c7d42f0b 100644 --- a/app/src/pages/ODD/ProtocolSetup/index.tsx +++ b/app/src/pages/ODD/ProtocolSetup/index.tsx @@ -103,7 +103,6 @@ import { useMostRecentCompletedAnalysis, useNotifyRunQuery, useProtocolAnalysisErrors, - useRunStatus, } from '/app/resources/runs' import { getProtocolModulesInfo } from '/app/transformations/analysis' import { @@ -762,6 +761,7 @@ export function ProtocolSetup(): JSX.Element { staleTime: Infinity, refetchInterval: RUN_RECORD_REFETCH_MS, }) + const runStatus = runRecord?.data.status ?? null const dispatch = useDispatch() const { analysisErrors } = useProtocolAnalysisErrors(runId) const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) @@ -793,7 +793,7 @@ export function ProtocolSetup(): JSX.Element { .data?.data.id != null const navigate = useNavigate() - const runStatus = useRunStatus(runId) + if (runStatus === RUN_STATUS_STOPPED) { navigate('/protocols') } diff --git a/app/src/pages/ODD/RobotDashboard/index.tsx b/app/src/pages/ODD/RobotDashboard/index.tsx index b8370a86ad5..764eec301fe 100644 --- a/app/src/pages/ODD/RobotDashboard/index.tsx +++ b/app/src/pages/ODD/RobotDashboard/index.tsx @@ -10,7 +10,6 @@ import { SPACING, TYPOGRAPHY, } from '@opentrons/components' -import { useAllProtocolsQuery } from '@opentrons/react-api-client' import { Navigation } from '/app/organisms/ODD/Navigation' import { @@ -31,7 +30,6 @@ export function RobotDashboard(): JSX.Element { const { t } = useTranslation('device_details') const { data: allRunsQueryData, error: allRunsQueryError } = useNotifyAllRunsQuery() - const protocols = useAllProtocolsQuery() const { unfinishedUnboxingFlowRoute } = useSelector( getOnDeviceDisplaySettings @@ -46,11 +44,6 @@ export function RobotDashboard(): JSX.Element { acc.some(collectedRun => collectedRun.protocolId === run.protocolId) ) { return acc - } else if ( - protocols?.data?.data.find(protocol => protocol.id === run.protocolId) - ?.protocolKind === 'quick-transfer' - ) { - return acc } else { return [...acc, run] } diff --git a/app/src/pages/ODD/RunSummary/index.tsx b/app/src/pages/ODD/RunSummary/index.tsx index 8a6122a81b3..bda041abcc0 100644 --- a/app/src/pages/ODD/RunSummary/index.tsx +++ b/app/src/pages/ODD/RunSummary/index.tsx @@ -8,7 +8,6 @@ import { RUN_STATUS_FAILED, RUN_STATUS_STOPPED, RUN_STATUS_SUCCEEDED, - RUN_STATUSES_TERMINAL, } from '@opentrons/api-client' import { ALIGN_CENTER, @@ -43,6 +42,7 @@ import { } from '@opentrons/react-api-client' import { lastRunCommandPromptedErrorRecovery } from '/app/local-resources/commands' +import { isTerminalRunStatus } from '/app/local-resources/runs/utils' import { RunTimer } from '/app/molecules/RunTimer' import { handleTipsAttachedModal } from '/app/organisms/DropTipWizardFlows' import { RunFailedModal } from '/app/organisms/ODD/RunningProtocol' @@ -126,11 +126,6 @@ export function RunSummary(): JSX.Element { const localRobot = useSelector(getLocalRobot) const robotName = localRobot?.name ?? 'no name' const robotType = useRobotType(robotName) - const onCloneRunSuccess = (): void => { - if (isQuickTransfer) { - deleteRun(runId) - } - } const { trackProtocolRunEvent } = useTrackProtocolRunEvent( runId, @@ -146,7 +141,7 @@ export function RunSummary(): JSX.Element { } }, [isRunCurrent, enteredER]) - const { reset, isResetRunLoading } = useRunControls(runId, onCloneRunSuccess) + const { reset, isResetRunLoading } = useRunControls(runId) const trackEvent = useTrackEvent() const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() @@ -177,11 +172,7 @@ export function RunSummary(): JSX.Element { runId, { cursor: 0, pageLength: 100 }, { - enabled: - runStatus != null && - // @ts-expect-error runStatus expected to possibly not be terminal - RUN_STATUSES_TERMINAL.includes(runStatus) && - isRunCurrent, + enabled: isTerminalRunStatus(runStatus) && isRunCurrent, } ) // TODO(jh, 08-14-24): The backend never returns the "user cancelled a run" error and cancelledWithoutRecovery becomes unnecessary. diff --git a/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx b/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx index 3b19178e7ae..c06146bc620 100644 --- a/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx +++ b/app/src/pages/ODD/RunningProtocol/__tests__/RunningProtocol.test.tsx @@ -46,7 +46,6 @@ import { useMostRecentCompletedAnalysis, useNotifyAllCommandsQuery, useNotifyRunQuery, - useRunStatus, useRunTimestamps, } from '/app/resources/runs' @@ -61,14 +60,14 @@ vi.mock('/app/redux-resources/robots') vi.mock('/app/organisms/RunTimeControl/hooks') vi.mock('/app/organisms/ODD/RunningProtocol') vi.mock('/app/redux/discovery') -vi.mock('/app/organisms/ODD/RunningProtocol/CancelingRunModal') -vi.mock('/app/organisms/ODD/OpenDoorAlertModal') vi.mock('/app/resources/runs') vi.mock('/app/redux/config') vi.mock('/app/organisms/ErrorRecoveryFlows') vi.mock('/app/organisms/InterventionModal') vi.mock('/app/organisms/DoorOpenControl/useIsDoorOpen') vi.mock('/app/local-resources/images/hooks/useToastOnErrorImage') +vi.mock('/app/organisms/ODD/RunningProtocol/CancelingRunModal') +vi.mock('/app/organisms/ODD/OpenDoorAlertModal') const RUN_ID = 'run_id' const ROBOT_NAME = 'otie' @@ -102,12 +101,16 @@ const render = (path = '/') => { describe('RunningProtocol', () => { beforeEach(() => { when(vi.mocked(useNotifyRunQuery)) - .calledWith(RUN_ID, { staleTime: Infinity }) + .calledWith(RUN_ID, { + staleTime: Infinity, + refetchInterval: 5000, + }) .thenReturn({ data: { data: { id: RUN_ID, protocolId: PROTOCOL_ID, + status: RUN_STATUS_IDLE, errors: [], }, }, @@ -118,7 +121,6 @@ describe('RunningProtocol', () => { .thenReturn({ trackProtocolRunEvent: vi.fn(), }) - when(vi.mocked(useRunStatus)).calledWith(RUN_ID).thenReturn(RUN_STATUS_IDLE) when(vi.mocked(useProtocolAnalysesQuery)) .calledWith(PROTOCOL_ID, { staleTime: Infinity }, expect.any(Boolean)) .thenReturn({ @@ -196,10 +198,26 @@ describe('RunningProtocol', () => { expect(vi.mocked(RunningProtocolSkeleton)).toHaveBeenCalled() }) it('should render the canceling run modal when run status is stop requested', () => { - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID, { refetchInterval: 5000 }) - .thenReturn(RUN_STATUS_STOP_REQUESTED) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID, { + staleTime: Infinity, + refetchInterval: 5000, + }) + .thenReturn({ + key: PROTOCOL_KEY, + data: { + data: { + id: RUN_ID, + status: RUN_STATUS_STOP_REQUESTED, + protocol_id: PROTOCOL_KEY, + }, + }, + } as any) + vi.mocked(useProtocolQuery).mockReturnValue({ + data: { data: { metadata: { protocolName: 'mock protocol name' } } }, + } as any) render(`/runs/${RUN_ID}/run`) + expect(vi.mocked(CancelingRunModal)).toHaveBeenCalled() }) it('should render CurrentRunningProtocolCommand when loaded the data', () => { @@ -207,13 +225,24 @@ describe('RunningProtocol', () => { expect(vi.mocked(CurrentRunningProtocolCommand)).toHaveBeenCalled() }) - it('should render open door alert modal, when run staus is blocked by open door', () => { - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID, { refetchInterval: 5000 }) - .thenReturn(RUN_STATUS_BLOCKED_BY_OPEN_DOOR) + it('should render open door alert modal, when run status is blocked by open door', () => { when(vi.mocked(useIsDoorOpen)) .calledWith(ROBOT_NAME) .thenReturn(DOOR_RESULT) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID, { + staleTime: Infinity, + refetchInterval: 5000, + }) + .thenReturn({ + data: { data: { id: RUN_ID, status: RUN_STATUS_BLOCKED_BY_OPEN_DOOR } }, + } as any) + when(vi.mocked(useIsDoorOpen)) + .calledWith(ROBOT_NAME) + .thenReturn(DOOR_RESULT) + vi.mocked(useProtocolQuery).mockReturnValue({ + data: { data: { metadata: { protocolName: 'mockProtocol' } } }, + } as any) render(`/runs/${RUN_ID}/run`) expect(vi.mocked(OpenDoorAlertModal)).toHaveBeenCalledWith( { moduleDoorLocation: null }, @@ -222,9 +251,15 @@ describe('RunningProtocol', () => { }) it('should render open stacker door alert modal, when run staus is blocked by open stacker door', () => { - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID, { refetchInterval: 5000 }) - .thenReturn(RUN_STATUS_BLOCKED_BY_OPEN_DOOR) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID, { + staleTime: Infinity, + refetchInterval: 5000, + }) + .thenReturn({ + data: { data: { id: RUN_ID, status: RUN_STATUS_BLOCKED_BY_OPEN_DOOR } }, + } as any) + const mockOpenStacker = { isDoorOpen: true, moduleDoorLocation: 'A4', @@ -232,6 +267,9 @@ describe('RunningProtocol', () => { when(vi.mocked(useIsDoorOpen)) .calledWith(ROBOT_NAME) .thenReturn(mockOpenStacker) + vi.mocked(useProtocolQuery).mockReturnValue({ + data: { data: { metadata: { protocolName: 'mockProtocol' } } }, + } as any) render(`/runs/${RUN_ID}/run`) expect(vi.mocked(OpenDoorAlertModal)).toHaveBeenCalledWith( { moduleDoorLocation: mockOpenStacker.moduleDoorLocation }, @@ -240,9 +278,15 @@ describe('RunningProtocol', () => { }) it('should render open unconfigured stacker door alert modal, when run staus is blocked by open stacker door not in the deck config', () => { - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID, { refetchInterval: 5000 }) - .thenReturn(RUN_STATUS_BLOCKED_BY_OPEN_DOOR) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID, { + staleTime: Infinity, + refetchInterval: 5000, + }) + .thenReturn({ + data: { data: { id: RUN_ID, status: RUN_STATUS_BLOCKED_BY_OPEN_DOOR } }, + } as any) + const mockUnconfiguredOpenStacker = { isDoorOpen: true, moduleDoorLocation: NOT_CONFIGURED, @@ -250,6 +294,9 @@ describe('RunningProtocol', () => { when(vi.mocked(useIsDoorOpen)) .calledWith(ROBOT_NAME) .thenReturn(mockUnconfiguredOpenStacker) + vi.mocked(useProtocolQuery).mockReturnValue({ + data: { data: { metadata: { protocolName: 'mockProtocol' } } }, + } as any) render(`/runs/${RUN_ID}/run`) expect(vi.mocked(OpenDoorAlertModal)).toHaveBeenCalledWith( { moduleDoorLocation: NOT_CONFIGURED }, @@ -258,24 +305,43 @@ describe('RunningProtocol', () => { }) it(`should render not open door alert modal, when run status is ${RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR}`, () => { - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID, { refetchInterval: 5000 }) - .thenReturn(RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID, { + staleTime: Infinity, + refetchInterval: 5000, + }) + .thenReturn({ + data: { + data: { + id: RUN_ID, + status: RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + }, + }, + } as any) + vi.mocked(useProtocolQuery).mockReturnValue({ + data: { data: { metadata: { protocolName: 'mockProtocol' } } }, + } as any) render(`/runs/${RUN_ID}/run`) expect(vi.mocked(OpenDoorAlertModal)).not.toHaveBeenCalled() }) it(`should display a Run Paused splash screen if the run status is "${RUN_STATUS_AWAITING_RECOVERY}"`, () => { - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID, { refetchInterval: 5000 }) - .thenReturn(RUN_STATUS_AWAITING_RECOVERY) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID, { + staleTime: Infinity, + refetchInterval: 5000, + }) + .thenReturn({ + data: { data: { id: RUN_ID, status: RUN_STATUS_AWAITING_RECOVERY } }, + } as any) + vi.mocked(useProtocolQuery).mockReturnValue({ + data: { data: { metadata: { protocolName: 'mockProtocol' } } }, + } as any) render(`/runs/${RUN_ID}/run`) }) it('should render ErrorRecovery appropriately', () => { - render(`/runs/${RUN_ID}/run`) expect(screen.queryByText('MOCK ERROR RECOVERY')).not.toBeInTheDocument() - vi.mocked(useErrorRecoveryFlows).mockReturnValue({ isERActive: true, failedCommand: {} as any, diff --git a/app/src/pages/ODD/RunningProtocol/index.tsx b/app/src/pages/ODD/RunningProtocol/index.tsx index 9d2004f49d5..45ddae2698b 100644 --- a/app/src/pages/ODD/RunningProtocol/index.tsx +++ b/app/src/pages/ODD/RunningProtocol/index.tsx @@ -49,7 +49,6 @@ import { useLastRunCommand, useMostRecentCompletedAnalysis, useNotifyRunQuery, - useRunStatus, useRunTimestamps, } from '/app/resources/runs' @@ -61,8 +60,8 @@ import type { RunningProtocolCommandListProps, } from '/app/organisms/ODD/RunningProtocol' -const RUN_STATUS_REFETCH_INTERVAL = 5000 const LIVE_RUN_COMMANDS_POLL_MS = 3000 +const RUN_STATUS_REFETCH_INTERVAL = 5000 export type ScreenOption = | 'CurrentRunningProtocolCommand' @@ -95,15 +94,19 @@ export function RunningProtocol(): JSX.Element { const currentRunCommandIndex = robotSideAnalysis?.commands.findIndex( c => c.key === lastRunCommand?.key ) - const runStatus = useRunStatus(runId, { + + const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) + const { data: runRecord } = useNotifyRunQuery(runId, { + staleTime: Infinity, refetchInterval: RUN_STATUS_REFETCH_INTERVAL, }) - const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) - const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) + const runStatus = runRecord?.data.status ?? null + const protocolId = runRecord?.data.protocolId ?? null const { data: protocolRecord } = useProtocolQuery(protocolId, { staleTime: Infinity, }) + const protocolName = protocolRecord?.data.metadata.protocolName ?? protocolRecord?.data.files[0].name diff --git a/app/src/redux/shell/remote.ts b/app/src/redux/shell/remote.ts index 5475ce2de0a..7541255ce8e 100644 --- a/app/src/redux/shell/remote.ts +++ b/app/src/redux/shell/remote.ts @@ -61,6 +61,15 @@ export async function appShellRequestor( if (result?.error != null) { throw result.error } + + // Blob data doesn't serialize properly across the IPC, so we parse it from + // an Array type sent from the shell layer. + if (config.responseType === 'blob' && Array.isArray(result.data)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const uint8Array = new Uint8Array(result.data) + result.data = new Blob([uint8Array]) as Data + } + return result } diff --git a/app/src/redux/shell/types.ts b/app/src/redux/shell/types.ts index 5d402abe3ff..f9e403b3343 100644 --- a/app/src/redux/shell/types.ts +++ b/app/src/redux/shell/types.ts @@ -222,7 +222,7 @@ export interface StepDetailViewerUpdateAction { type: 'shell:STEP_DETAIL_VIEWER_UPDATE' payload: { protocolKey: string - slot: string + slot: string | null command: RunTimeCommand robotState: RobotState invariantContext: InvariantContext diff --git a/app/src/resources/runs/__tests__/useRunHasStarted.test.tsx b/app/src/resources/runs/__tests__/useRunHasStarted.test.tsx index e34dda06db5..36ee284008b 100644 --- a/app/src/resources/runs/__tests__/useRunHasStarted.test.tsx +++ b/app/src/resources/runs/__tests__/useRunHasStarted.test.tsx @@ -4,36 +4,62 @@ import { when } from 'vitest-when' import { RUN_STATUS_IDLE, RUN_STATUS_RUNNING } from '@opentrons/api-client' -import { useRunStatus } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useNotifyRunQuery, +} from '/app/resources/runs' import { useRunHasStarted } from '../useRunHasStarted' -vi.mock('/app/resources/runs') +import type { UseQueryResult } from 'react-query' +import type { Run } from '@opentrons/api-client' + +vi.mock('../useNotifyRunQuery') const MOCK_RUN_ID = '1' describe('useRunHasStarted', () => { beforeEach(() => { - when(vi.mocked(useRunStatus)).calledWith(null).thenReturn(null) + when(vi.mocked(useNotifyRunQuery)) + .calledWith(null, { refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL }) + .thenReturn({ data: null } as any) }) it('should return false when no run id is provided', () => { const { result } = renderHook(() => useRunHasStarted(null)) + expect(result.current).toEqual(false) }) it('should return false when run has not started', () => { - when(vi.mocked(useRunStatus)) - .calledWith(MOCK_RUN_ID) - .thenReturn(RUN_STATUS_IDLE) + const idleRun = { + current: true, + id: MOCK_RUN_ID, + status: RUN_STATUS_IDLE, + } + when(vi.mocked(useNotifyRunQuery)) + .calledWith(MOCK_RUN_ID, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ data: { data: idleRun } } as UseQueryResult) const { result } = renderHook(() => useRunHasStarted(MOCK_RUN_ID)) expect(result.current).toEqual(false) }) it('should return true when run has started', () => { - when(vi.mocked(useRunStatus)) - .calledWith(MOCK_RUN_ID) - .thenReturn(RUN_STATUS_RUNNING) + const runningRun = { + current: true, + id: MOCK_RUN_ID, + status: RUN_STATUS_RUNNING, + } + when(vi.mocked(useNotifyRunQuery)) + .calledWith(MOCK_RUN_ID, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + .thenReturn({ data: { data: runningRun } } as UseQueryResult< + Run, + unknown + >) const { result } = renderHook(() => useRunHasStarted(MOCK_RUN_ID)) expect(result.current).toEqual(true) }) diff --git a/app/src/resources/runs/__tests__/useRunStatus.test.ts b/app/src/resources/runs/__tests__/useRunStatus.test.ts deleted file mode 100644 index 1d50ea6b8e8..00000000000 --- a/app/src/resources/runs/__tests__/useRunStatus.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { when } from 'vitest-when' - -import { - mockIdleStartedRun, - mockIdleUnstartedRun, - mockRunningRun, - RUN_ID_2, -} from '../__fixtures__' -import { useNotifyRunQuery } from '../useNotifyRunQuery' -import { useRunStatus } from '../useRunStatus' - -import type { UseQueryResult } from 'react-query' -import type { Run } from '@opentrons/api-client' - -vi.mock('../useNotifyRunQuery') - -describe('useRunStatus hook', () => { - it('returns the run status of the run', async () => { - when(useNotifyRunQuery) - .calledWith(RUN_ID_2, expect.any(Object)) - .thenReturn({ - data: { data: mockRunningRun }, - } as unknown as UseQueryResult) - - const { result } = renderHook(() => useRunStatus(RUN_ID_2)) - expect(result.current).toBe('running') - }) - - it('returns a "idle" run status if idle and run unstarted', () => { - when(useNotifyRunQuery) - .calledWith(RUN_ID_2, expect.any(Object)) - .thenReturn({ - data: { data: mockIdleUnstartedRun }, - } as unknown as UseQueryResult) - - const { result } = renderHook(() => useRunStatus(RUN_ID_2)) - expect(result.current).toBe('idle') - }) - - it('returns a "running" run status if idle and run started', () => { - when(useNotifyRunQuery) - .calledWith(RUN_ID_2, expect.any(Object)) - .thenReturn({ - data: { data: mockIdleStartedRun }, - } as unknown as UseQueryResult) - - const { result } = renderHook(() => useRunStatus(RUN_ID_2)) - expect(result.current).toBe('running') - }) -}) diff --git a/app/src/resources/runs/__tests__/useRunStatuses.test.tsx b/app/src/resources/runs/__tests__/useRunStatuses.test.tsx index edf45065bba..634ca83846b 100644 --- a/app/src/resources/runs/__tests__/useRunStatuses.test.tsx +++ b/app/src/resources/runs/__tests__/useRunStatuses.test.tsx @@ -16,21 +16,27 @@ import { } from '@opentrons/api-client' import { useCurrentRunId } from '../useCurrentRunId' -import { useRunStatus } from '../useRunStatus' +import { useNotifyRunQuery } from '../useNotifyRunQuery' import { useRunStatuses } from '../useRunStatuses' vi.mock('../useCurrentRunId') -vi.mock('../useRunStatus') +vi.mock('../useNotifyRunQuery') + +const mockRunStatus = (status: any) => + vi.mocked(useNotifyRunQuery).mockReturnValue({ + data: { data: { status } }, + } as any) describe('useRunStatuses', () => { beforeEach(() => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_RUNNING) - vi.mocked(useCurrentRunId).mockReturnValue('123') + mockRunStatus(RUN_STATUS_RUNNING) + vi.mocked(useCurrentRunId).mockReturnValue('test_id_running') }) it('returns everything as false when run status is null', () => { - vi.mocked(useRunStatus).mockReturnValue(null) + vi.mocked(useNotifyRunQuery).mockReturnValue({ data: null } as any) const result = useRunStatuses() + expect(result).toStrictEqual({ isRunRunning: false, isRunStill: false, @@ -40,7 +46,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunStill and Terminal when run status is ${RUN_STATUS_SUCCEEDED}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_SUCCEEDED) + mockRunStatus(RUN_STATUS_SUCCEEDED) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: false, @@ -51,7 +57,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunStill and Terminal when run status is ${RUN_STATUS_STOPPED}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_STOPPED) + mockRunStatus(RUN_STATUS_STOPPED) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: false, @@ -62,7 +68,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunStill and Terminal when run status is ${RUN_STATUS_FAILED}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_FAILED) + mockRunStatus(RUN_STATUS_FAILED) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: false, @@ -73,7 +79,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunStill and isRunIdle when run status is ${RUN_STATUS_IDLE}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) + mockRunStatus(RUN_STATUS_IDLE) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: false, @@ -84,7 +90,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunRunning when status is ${RUN_STATUS_RUNNING}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_RUNNING) + mockRunStatus(RUN_STATUS_RUNNING) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: true, @@ -95,7 +101,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunRunning when status is ${RUN_STATUS_PAUSED}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_PAUSED) + mockRunStatus(RUN_STATUS_PAUSED) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: true, @@ -106,7 +112,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunRunning when status is ${RUN_STATUS_AWAITING_RECOVERY}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_AWAITING_RECOVERY) + mockRunStatus(RUN_STATUS_AWAITING_RECOVERY) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: true, @@ -117,7 +123,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunRunning when status is ${RUN_STATUS_AWAITING_RECOVERY_PAUSED}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_AWAITING_RECOVERY_PAUSED) + mockRunStatus(RUN_STATUS_AWAITING_RECOVERY_PAUSED) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: true, @@ -128,7 +134,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunRunning when status is ${RUN_STATUS_STOP_REQUESTED}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_STOP_REQUESTED) + mockRunStatus(RUN_STATUS_STOP_REQUESTED) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: true, @@ -139,7 +145,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunRunning when status is ${RUN_STATUS_FINISHING}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_FINISHING) + mockRunStatus(RUN_STATUS_FINISHING) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: true, @@ -150,7 +156,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunRunning when status is ${RUN_STATUS_BLOCKED_BY_OPEN_DOOR}`, () => { - vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_BLOCKED_BY_OPEN_DOOR) + mockRunStatus(RUN_STATUS_BLOCKED_BY_OPEN_DOOR) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: true, @@ -161,9 +167,7 @@ describe('useRunStatuses', () => { }) it(`returns true isRunRunning when status is ${RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR}`, () => { - vi.mocked(useRunStatus).mockReturnValue( - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR - ) + mockRunStatus(RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR) const result = useRunStatuses() expect(result).toStrictEqual({ isRunRunning: true, diff --git a/app/src/resources/runs/index.ts b/app/src/resources/runs/index.ts index 18a48fd533e..be2ff0ff3d9 100644 --- a/app/src/resources/runs/index.ts +++ b/app/src/resources/runs/index.ts @@ -10,7 +10,6 @@ export * from './useProtocolDetailsForRun' export * from './useCurrentRun' export * from './useRunTimestamps' export * from './useRunCommands' -export * from './useRunStatus' export * from './useCloneRun' export * from './useCloseCurrentRun' export * from './useCurrentRunCommands' diff --git a/app/src/resources/runs/useLastRunCommand.ts b/app/src/resources/runs/useLastRunCommand.ts index a62a34bc6ea..e19acb5393c 100644 --- a/app/src/resources/runs/useLastRunCommand.ts +++ b/app/src/resources/runs/useLastRunCommand.ts @@ -1,6 +1,10 @@ import { RUN_STATUSES_TERMINAL } from '@opentrons/api-client' -import { useNotifyAllCommandsQuery, useRunStatus } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useNotifyAllCommandsQuery, + useNotifyRunQuery, +} from '/app/resources/runs' import type { UseQueryOptions } from 'react-query' import type { @@ -15,7 +19,10 @@ export function useLastRunCommand( runId: string, options: UseQueryOptions = {} ): RunCommandSummary | null { - const runStatus = useRunStatus(runId) + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null const { data: commandsData } = useNotifyAllCommandsQuery( runId, { pageLength: 1 }, diff --git a/app/src/resources/runs/useRunHasStarted.ts b/app/src/resources/runs/useRunHasStarted.ts index 8472c62d31f..8f438787b46 100644 --- a/app/src/resources/runs/useRunHasStarted.ts +++ b/app/src/resources/runs/useRunHasStarted.ts @@ -1,9 +1,14 @@ import { RUN_STATUS_IDLE } from '@opentrons/api-client' -import { useRunStatus } from '/app/resources/runs' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useNotifyRunQuery, +} from '/app/resources/runs' export function useRunHasStarted(runId: string | null): boolean { - const runStatus = useRunStatus(runId) - - return runStatus != null && runStatus !== RUN_STATUS_IDLE + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null + return runStatus != null && !(runStatus === RUN_STATUS_IDLE) } diff --git a/app/src/resources/runs/useRunStatus.ts b/app/src/resources/runs/useRunStatus.ts deleted file mode 100644 index c65c8542171..00000000000 --- a/app/src/resources/runs/useRunStatus.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - RUN_ACTION_TYPE_PLAY, - RUN_STATUS_IDLE, - RUN_STATUS_RUNNING, -} from '@opentrons/api-client' - -import { DEFAULT_STATUS_REFETCH_INTERVAL } from './constants' -import { useNotifyRunQuery } from './useNotifyRunQuery' - -import type { UseQueryOptions } from 'react-query' -import type { Run, RunStatus } from '@opentrons/api-client' - -/** - * @deprecated TODO(jh, 05-05-24): Confirming MM's observation, this hook is no longer necessary - * and appears bug-prone. Let's remove it and replace with useNotifyRunQuery. - */ -export function useRunStatus( - runId: string | null, - options?: UseQueryOptions -): RunStatus | null { - const { data } = useNotifyRunQuery(runId ?? null, { - refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, - ...options, - }) - - const runStatus = data?.data?.status! - - const actions = data?.data?.actions! - const firstPlay = actions?.find( - action => action.actionType === RUN_ACTION_TYPE_PLAY - ) - const runStartTime = firstPlay?.createdAt - - // display an idle status as 'running' in the UI after a run has started. - // todo(mm, 2024-06-24): This may not be necessary anymore. It looks like it was - // working around prior (?) server behavior where a run's status would briefly flicker - // to idle in between commands. - const adjustedRunStatus: RunStatus | null = - runStatus === RUN_STATUS_IDLE && runStartTime != null - ? RUN_STATUS_RUNNING - : runStatus - - return adjustedRunStatus -} diff --git a/app/src/resources/runs/useRunStatuses.ts b/app/src/resources/runs/useRunStatuses.ts index 896b7ddff8e..a595fd1c485 100644 --- a/app/src/resources/runs/useRunStatuses.ts +++ b/app/src/resources/runs/useRunStatuses.ts @@ -1,19 +1,14 @@ -import { - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_FINISHING, - RUN_STATUS_IDLE, - RUN_STATUS_PAUSED, - RUN_STATUS_RUNNING, - RUN_STATUS_STOP_REQUESTED, - RUN_STATUSES_TERMINAL, -} from '@opentrons/api-client' - -import { useCurrentRunId, useRunStatus } from '/app/resources/runs' +import { RUN_STATUS_IDLE } from '@opentrons/api-client' -import type { RunStatus } from '@opentrons/api-client' +import { + isRunningStatus, + isTerminalRunStatus, +} from '/app/local-resources/runs/utils' +import { + DEFAULT_STATUS_REFETCH_INTERVAL, + useCurrentRunId, + useNotifyRunQuery, +} from '/app/resources/runs' interface RunStatusesInfo { isRunStill: boolean @@ -24,22 +19,14 @@ interface RunStatusesInfo { export function useRunStatuses(): RunStatusesInfo { const currentRunId = useCurrentRunId() - const runStatus = useRunStatus(currentRunId) + const { data: runRecord } = useNotifyRunQuery(currentRunId, { + refetchInterval: DEFAULT_STATUS_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null const isRunIdle = runStatus === RUN_STATUS_IDLE - const isRunRunning = - runStatus === RUN_STATUS_PAUSED || - runStatus === RUN_STATUS_RUNNING || - runStatus === RUN_STATUS_AWAITING_RECOVERY || - runStatus === RUN_STATUS_AWAITING_RECOVERY_PAUSED || - runStatus === RUN_STATUS_STOP_REQUESTED || - runStatus === RUN_STATUS_FINISHING || - runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR || - runStatus === RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR - const isRunTerminal = - runStatus != null - ? (RUN_STATUSES_TERMINAL as RunStatus[]).includes(runStatus) - : false - const isRunStill = isRunTerminal || isRunIdle + const isRunRunning = isRunningStatus(runStatus) + const isRunTerminal = isTerminalRunStatus(runStatus) + const isRunStill = isRunIdle || isTerminalRunStatus(runStatus) return { isRunStill, isRunTerminal, isRunIdle, isRunRunning } } diff --git a/app/src/resources/runs/useRunTimestamps.ts b/app/src/resources/runs/useRunTimestamps.ts index 6a95f2e88fc..849e077e56f 100644 --- a/app/src/resources/runs/useRunTimestamps.ts +++ b/app/src/resources/runs/useRunTimestamps.ts @@ -14,7 +14,6 @@ import { import { DEFAULT_RUN_QUERY_REFETCH_INTERVAL } from './constants' import { useNotifyRunQuery } from './useNotifyRunQuery' import { useRunCommands } from './useRunCommands' -import { useRunStatus } from './useRunStatus' export interface RunTimestamps { startedAt: string | null @@ -24,11 +23,12 @@ export interface RunTimestamps { } export function useRunTimestamps(runId: string | null): RunTimestamps { - const runStatus = useRunStatus(runId) - const { actions = [], errors = [] } = - useNotifyRunQuery(runId, { - refetchInterval: DEFAULT_RUN_QUERY_REFETCH_INTERVAL, - })?.data?.data ?? {} + const { data: runRecord } = useNotifyRunQuery(runId, { + refetchInterval: DEFAULT_RUN_QUERY_REFETCH_INTERVAL, + }) + const runStatus = runRecord?.data.status ?? null + const actions = runRecord?.data.actions ?? null + const errors = runRecord?.data.errors ?? null const runCommands = useRunCommands( runId, @@ -44,7 +44,7 @@ export function useRunTimestamps(runId: string | null): RunTimestamps { } ) ?? [] - const firstPlay = actions.find( + const firstPlay = actions?.find( action => action.actionType === RUN_ACTION_TYPE_PLAY ) const lastAction = last(actions) diff --git a/components/src/assets/localization/en/protocol_command_text.json b/components/src/assets/localization/en/protocol_command_text.json index e34b2dc8449..479e3223038 100644 --- a/components/src/assets/localization/en/protocol_command_text.json +++ b/components/src/assets/localization/en/protocol_command_text.json @@ -93,6 +93,7 @@ "pickup_tip": "Picking up tip(s) from {{well_range}} of {{labware}} in {{labware_location}}", "prepare_to_aspirate": "Preparing {{pipette}} to aspirate", "pressurizing_to_dispense": "Pressurize pipette to dispense {{volume}} µL from resin tip at {{flow_rate}} µL/sec", + "quantity": "Quantity: {{count}}", "reloading_labware": "Reloading {{labware}}", "return_tip": "Returning tip to {{well_name}} of {{labware}} in {{labware_location}}", "right": "Right", diff --git a/components/src/assets/localization/zh/protocol_command_text.json b/components/src/assets/localization/zh/protocol_command_text.json index 19f6dd5490e..2d6acc6271b 100644 --- a/components/src/assets/localization/zh/protocol_command_text.json +++ b/components/src/assets/localization/zh/protocol_command_text.json @@ -8,16 +8,26 @@ "adapter_in_slot": "{{adapter}}在{{slot}}上", "air_gap_in_place": "正在形成 {{volume}} µL 的空气间隙", "all_nozzles": "所有移液喷嘴", - "aspirate": "从{{labware_location}}的{{labware}}的{{well_name}}孔中以{{flow_rate}}µL/秒的速度吸液{{volume}}µL", - "aspirate_in_place": "在原位以{{flow_rate}}µL/秒的速度吸液{{volume}}µL", - "blowout": "在{{labware_location}}的{{labware}}的{{well_name}}孔中以{{flow_rate}}µL/秒的速度排空", - "blowout_in_place": "在原位以{{flow_rate}}µL/秒的速度排空", + "aspirate": "从{{labware_location}}的{{labware}}的{{well_name}}孔中以{{flow_rate}}µL/s的速度吸液{{volume}}µL", + "aspirate_in_place": "在原位以{{flow_rate}}µL/s的速度吸液{{volume}}µL", + "aspirate_while_tracking": "在 {{labware_location}} 的 {{labware}} 的孔位 {{well_name}} 的 {{track_from_location}} 和 {{track_to_location}} 之间以 {{flow_rate}} µL/秒的速度吸取 {{volume}} µL。", + "blowout": "在{{labware_location}}的{{labware}}的{{well_name}}孔中以{{flow_rate}}µL/s的速度排空", + "blowout_in_place": "在原位以{{flow_rate}}µL/s的速度排空", + "capture_image_brightness": "{{brightness}}% 亮度", + "capture_image_contrast": "{{contrast}}% 对比度", + "capture_image_list_separator": ",", + "capture_image_resolution": "{{width}}x{{height}} 分辨率", + "capture_image_saturation": "{{saturation}}% 饱和度", + "capture_image_simple": "正在拍摄图像。", + "capture_image_with_options": "使用 {{options}} 拍摄图像。", + "capture_image_zoom": "{{zoom}}X 变焦", "closing_tc_lid": "关闭热循环仪热盖", "column_layout": "列布局", "comment": "解释", "configure_for_volume": "配置{{pipette}}以吸液{{volume}}µL", "configure_nozzle_layout": "配置{{pipette}}以使用{{amount}}个通道", "confirm_and_resume": "确认并继续", + "create_timer": "创建一个 {{seconds}} 秒的后台计时器", "deactivate_hs_shake": "停用震荡功能", "deactivate_temperature_module": "停用温控模块", "deactivating_hs_heater": "停用加热功能", @@ -26,26 +36,27 @@ "degrees_c": "{{temp}}°C", "detect_liquid_presence": "正在检测{{labware}}在{{labware_location}}中的{{well_name}}孔中的液体存在", "disengaging_magnetic_module": "下降磁力架模块", - "dispense": "以{{flow_rate}}µL/秒的速度将{{volume}}µL 排液至{{labware_location}}的{{labware}}的{{well_name}}孔中", - "dispense_in_place": "在原位以{{flow_rate}}µL/秒的速度排液{{volume}}µL", - "dispense_push_out": "以{{flow_rate}}µL/秒的速度将{{volume}}µL 排液至{{labware_location}}的{{labware}}的{{well_name}}孔中,并推出{{push_out_volume}}µL", + "dispense": "以{{flow_rate}}µL/s的速度将{{volume}}µL 排液至{{labware_location}}的{{labware}}的{{well_name}}孔中", + "dispense_in_place": "在原位以{{flow_rate}}µL/s的速度排液{{volume}}µL", + "dispense_push_out": "以{{flow_rate}}µL/s的速度将{{volume}}µL 排液至{{labware_location}}的{{labware}}的{{well_name}}孔中,并推出{{push_out_volume}}µL", + "dispense_while_tracking": "在 {{labware_location}} 的 {{labware}} 中,从孔位的{{track_from_location}} 到 {{track_to_location}} {{well_name}}处,以 {{flow_rate}} µL/秒的速度排出 {{volume}} µL", "drop_tip": "将吸头丢入{{labware}}的{{well_name}}孔中", "drop_tip_in_place": "在原位丢弃吸头", "dropping_tip_in_trash": "将吸头丢入{{trash}}", "empty_stacker": "空的外置堆栈", "engaging_magnetic_module": "抬升磁力架模块", - "extension_jaw": "延伸钳口", - "extension_mount": "安装支架拓展位", + "extension_jaw": "转板抓手", + "extension_mount": "支架拓展位", "extension_z": "拓展 Z", - "fill_stacker": "填补外置堆栈", + "fill_stacker": "填充外置堆栈", "fixed_trash": "垃圾桶", "get_next_tip": "计算下一个可用吸头的位置", "home_gantry": "复位所有龙门架、移液器和柱塞轴", "in_location": "在{{location}}", "latching_hs_latch": "在热震荡模块上锁定实验耗材", "left": "左", - "left_mount": "左侧", - "left_plunger": "左侧柱塞", + "left_mount": "左侧安装支架", + "left_plunger": "左侧推杆", "left_z": "左 Z", "load_labware_to_display_location": "{{display_location}}加载{{labware}}", "load_lid": "加载PCR上盖", @@ -68,8 +79,9 @@ "move_to_addressable_area_drop_tip": "移动到{{addressable_area}}", "move_to_coordinates": "移动到 (X:{{x}}, Y:{{y}}, Z:{{z}})", "move_to_slot": "移动到{{slot_name}}号板位", - "move_to_well": "移动到好 {{well_name}} 的 {{labware}} 在 {{labware_location}}", + "move_to_well": "正在移动到{{displayLocation}}中{{labware}}的{{wellName}}孔的{{positionRelative}}的偏移 X {{xOffset}} Y {{yOffset}} Z {{zOffset}}", "multiple": "多个", + "ninety_six_channel_cam": "吸头齿条夹持结构", "notes": "备注", "off_deck": "甲板外", "offdeck": "甲板外", @@ -80,35 +92,40 @@ "pause_on": "在{{robot_name}}上暂停", "pickup_tip": "从{{labware_location}}的{{labware}}的{{well_range}}孔中拾取吸头", "prepare_to_aspirate": "准备使用{{pipette}}吸液", - "pressurizing_to_dispense": "加压移液器进行分配 {{volume}} µL 距离树脂尖端 {{flow_rate}} 微升/秒", + "pressurizing_to_dispense": "加压移液器进行分配 {{volume}} µL 距离树脂尖端 {{flow_rate}} µL/s", "reloading_labware": "正在重新加载{{labware}}", "return_tip": "将吸头返回到{{labware_location}}的{{labware}}的{{well_name}}孔中", "right": "右", - "right_mount": "右侧", - "right_plunger": "右侧柱塞", + "right_mount": "右侧安装支架", + "right_plunger": "右侧推杆", "right_z": "右 Z", "robot_close_gripper_jaw": "设备转板抓手抓取中", - "robot_move_axes_relative": "设备移动{{displacement}}", - "robot_move_axes_to": "将设备移动到 {{position}}", - "robot_move_to": "移动 {{mount}} 至 (X: {{x}},Y: {{y}},Z: {{z}})", + "robot_move_axes_relative": "设备移动 {{displacement}}", + "robot_move_axes_to": "设备移动到 {{position}}", + "robot_move_to": "移动{{mount}}至(X: {{x}}, Y: {{y}}, Z: {{z}})", "robot_open_gripper_jaw": "设备转板抓手打开中", "row_layout": "行布局", "save_position": "保存位置", "sealing_to_location": "密封至 {{labware}} 在 {{location}}", "set_and_await_hs_shake": "设置热震荡模块以{{rpm}}rpm 震动并等待达到该转速", + "set_hs_shake": "将加热振荡器设置为以 {{rpm}} 转速震动", + "set_tip_state": "将 {{labware}} 中的吸头设置为 {{tip_well_state}} 状态", "setting_hs_temp": "将热震荡模块的目标温度设置为{{temp}}", "setting_temperature_module_temp": "将温控模块设置为{{temp}}(四舍五入到最接近的整数)", - "setting_thermocycler_block_temp": "将热循环仪加热块温度设置为{{temp}},并在达到目标后保持{{hold_time_seconds}}秒", + "setting_thermocycler_block_temp": "将热循环仪加热块温度设置为{{temp}},并在达到目标后保持{{hold_time_seconds}}s", + "setting_thermocycler_block_temp_in_background": "在后台将热循环模块加热平台的温度设置为 {{temp}}", "setting_thermocycler_lid_temp": "将热循环仪热盖温度设置为{{temp}}", + "setting_thermocycler_lid_temp_in_background": "在后台将热循环仪上盖的温度设置为 {{temp}}", "single": "单个", "single_nozzle_layout": "单个移液喷嘴布局", "slot": "板位{{slot_name}}", "stacker_hopper_display": "外置堆栈 {{row}}", "target_temperature": "目标温度", "tc_awaiting_for_duration": "等待热循环仪程序完成", - "tc_run_profile_steps": "温度:{{celsius}}°C,时间:{{seconds}}秒", + "tc_run_profile_steps": "温度:{{celsius}}°C,时间:{{seconds}}s", "tc_starting_extended_profile": "运行热循环仪程序,共有{{elementCount}}个步骤和循环:", "tc_starting_extended_profile_cycle": "以下步骤重复{{repetitions}}次:", + "tc_starting_extended_profile_in_background": "在后台运行热循环仪的程序,共有 {{elementCount}} 步和循环:", "tc_starting_profile": "热循环仪开始进行由以下步骤组成的{{repetitions}}次循环:", "touch_tip": "吸头接触内壁", "trash_bin": "垃圾桶", @@ -117,13 +134,14 @@ "turning_rail_lights_on": "正在打开导轨灯", "unlatching_hs_latch": "解锁热震荡模块上的实验耗材", "unsealing_from_location": "解除封印 {{labware}} 在 {{location}}", - "wait_for_duration": "暂停{{seconds}}秒。{{message}}", + "wait_for_duration": "暂停{{seconds}}s。{{message}}", "wait_for_resume": "暂停协议", + "wait_for_tasks": "等待任务列表完成", "waiting_for_hs_to_reach": "等待热震荡模块达到目标温度", "waiting_for_tc_block_to_reach": "等待热循环仪加热块达到目标温度并保持指定时间", "waiting_for_tc_lid_to_reach": "等待热循环仪热盖达到目标温度", "waiting_to_reach_temp_module": "等待温控模块达到{{temp}}", "waste_chute": "外置垃圾槽", - "with_lid_name": " 和 {{lidDisplayName}}", + "with_lid_name": "与 {{lidDisplayName}}", "with_reference_of": "以{{wavelength}} nm为基准" } diff --git a/components/src/hardware-sim/BaseDeck/BaseDeck.stories.tsx b/components/src/hardware-sim/BaseDeck/BaseDeck.stories.tsx index 20611710d95..21c8a47f156 100644 --- a/components/src/hardware-sim/BaseDeck/BaseDeck.stories.tsx +++ b/components/src/hardware-sim/BaseDeck/BaseDeck.stories.tsx @@ -65,23 +65,23 @@ export const BaseDeck: Story = { { moduleLocation: { slotName: 'B1' }, moduleModel: THERMOCYCLER_MODULE_V2, - nestedLabwareDef: fixture96Plate as LabwareDefinition, + nestedLabwareDefsBottomToTop: fixture96Plate as LabwareDefinition, innerProps: { lidMotorState: 'open' }, }, { moduleLocation: { slotName: 'D1' }, moduleModel: TEMPERATURE_MODULE_V2, - nestedLabwareDef: fixture96Plate as LabwareDefinition, + nestedLabwareDefsBottomToTop: fixture96Plate as LabwareDefinition, }, { moduleLocation: { slotName: 'B3' }, moduleModel: HEATERSHAKER_MODULE_V1, - nestedLabwareDef: fixture96Plate as LabwareDefinition, + nestedLabwareDefsBottomToTop: fixture96Plate as LabwareDefinition, }, { moduleLocation: { slotName: 'D2' }, moduleModel: MAGNETIC_BLOCK_V1, - nestedLabwareDef: fixture96Plate as LabwareDefinition, + nestedLabwareDefsBottomToTop: fixture96Plate as LabwareDefinition, }, ], darkFill: 'rebeccapurple', diff --git a/components/src/hardware-sim/Pipette/PipetteRender.stories.tsx b/components/src/hardware-sim/Pipette/PipetteRender.stories.tsx index ae4dceee752..df8a9c511b2 100644 --- a/components/src/hardware-sim/Pipette/PipetteRender.stories.tsx +++ b/components/src/hardware-sim/Pipette/PipetteRender.stories.tsx @@ -4,19 +4,40 @@ import { getAllDefinitions, getAllPipetteNames } from '@opentrons/shared-data' import { RobotWorkSpace } from '../Deck' import { LabwareRender } from '../Labware' -import { PipetteRender } from './' +import { PipetteRender as PipetteRenderComponent } from './' -import type { Meta, Story } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' import type { LabwareDefinition, PipetteName } from '@opentrons/shared-data' const DECK_MAP_VIEWBOX = '0 -140 230 230' -const opentrons300UlTiprack = getAllDefinitions().opentrons96Tiprack300UlV1 -const opentrons10UlTiprack = getAllDefinitions().opentrons96Tiprack10UlV1 -const nest12Reservoir15ml = getAllDefinitions().nest12Reservoir15MlV1 -const axygenReservoir90ml = getAllDefinitions().axygen1Reservoir90MlV1 +const allDefinitions = getAllDefinitions() + +// Find labware definitions by loadName +const findLabwareByLoadName = ( + loadName: string +): LabwareDefinition | undefined => { + const definitions = Object.values(allDefinitions) as LabwareDefinition[] + return definitions.find(def => def.parameters.loadName === loadName) +} + +const allDefsArray = Object.values(allDefinitions) as LabwareDefinition[] +const defaultDef = allDefsArray[0] + +if (defaultDef == null) { + throw new Error('No labware definitions found') +} + +const opentrons300UlTiprack = + findLabwareByLoadName('opentrons_96_tiprack_300ul') ?? defaultDef +const opentrons10UlTiprack = + findLabwareByLoadName('opentrons_96_tiprack_10ul') ?? defaultDef +const nest12Reservoir15ml = + findLabwareByLoadName('nest_12_reservoir_15ml') ?? defaultDef +const axygenReservoir90ml = + findLabwareByLoadName('axygen_1_reservoir_90ml') ?? defaultDef const opentrons6TuberackNest50mlConical = - getAllDefinitions().opentrons6TuberackNest50MlConicalV1 + findLabwareByLoadName('opentrons_6_tuberack_nest_50ml_conical') ?? defaultDef const labwareDefMap: Record = { [opentrons300UlTiprack.metadata.displayName]: opentrons300UlTiprack, @@ -27,47 +48,57 @@ const labwareDefMap: Record = { opentrons6TuberackNest50mlConical, } const pipetteNames = Object.keys(getAllPipetteNames()) as PipetteName[] +const labwareDisplayNames = Object.keys(labwareDefMap) -export default { - title: 'Library/Molecules/Simulation/Pipette/PipetteRender', -} as Meta - -const Template: Story<{ +interface StoryArgs { labwareName: string pipetteName: PipetteName -}> = args => { - const labwareDef = labwareDefMap[args.labwareName] - return ( - - {() => ( - - - - - )} - - ) } -export const Pipette = Template.bind({}) -Pipette.argTypes = { - labwareName: { - control: { - type: 'select', - options: Object.keys(labwareDefMap).map( - d => labwareDefMap[d].metadata.displayName - ), +const meta: Meta = { + title: 'Library/Molecules/Simulation/Pipette/PipetteRender', + argTypes: { + labwareName: { + control: { + type: 'select', + }, + options: labwareDisplayNames, }, - defaultValue: opentrons300UlTiprack.metadata.displayName, - }, - pipetteName: { - control: { - type: 'select', + pipetteName: { + control: { + type: 'select', + }, options: pipetteNames, }, - defaultValue: pipetteNames[0], + }, + decorators: [ + Story => ( + + {() => } + + ), + ], +} + +export default meta + +type Story = StoryObj + +export const PipetteRender: Story = { + args: { + labwareName: opentrons300UlTiprack.metadata.displayName, + pipetteName: pipetteNames[0], + }, + render: args => { + const labwareDef = labwareDefMap[args.labwareName] ?? opentrons300UlTiprack + return ( + + + + + ) }, } diff --git a/components/src/molecules/ParametersTable/ParametersTable.stories.tsx b/components/src/molecules/ParametersTable/ParametersTable.stories.tsx index ad2ab5b1fe0..c057481cab8 100644 --- a/components/src/molecules/ParametersTable/ParametersTable.stories.tsx +++ b/components/src/molecules/ParametersTable/ParametersTable.stories.tsx @@ -1,7 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars import * as React from 'react-remove-scroll' -import { Flex } from '../../primitives' import { SPACING } from '../../ui-style-constants' import { ParametersTable } from './index' @@ -152,13 +151,13 @@ const runTimeParameters: RunTimeParameter[] = [ ] const meta: Meta = { - title: 'Library/Molecules/ParametersTable', + title: 'Helix/Molecules/ParametersTable', component: ParametersTable, decorators: [ Story => ( - +
- +
), ], } @@ -168,6 +167,6 @@ type Story = StoryObj export const DefaultParameterTable: Story = { args: { - runTimeParameters: runTimeParameters, + runTimeParameters, }, } diff --git a/components/src/molecules/TimelineScrubber/TimelineScrubber.stories.tsx b/components/src/molecules/TimelineScrubber/TimelineScrubber.stories.tsx index 65ba279ea04..44af3d610ca 100644 --- a/components/src/molecules/TimelineScrubber/TimelineScrubber.stories.tsx +++ b/components/src/molecules/TimelineScrubber/TimelineScrubber.stories.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { action } from '@storybook/addon-actions' +import { DIRECTION_COLUMN, SPACING } from '@opentrons/components' + import { TimelineScrubber as TimelineScrubberComponent } from './index' import type { Meta, StoryObj } from '@storybook/react' @@ -35,7 +37,13 @@ function TimelineScrubber(args: any): JSX.Element { } return ( -
+

Timeline Scrubber

Drag the sliders to update the values

= { + title: 'Helix/Organisms/LabwareDetailsWithCount', + component: LabwareDetailsWithCount, + decorators: [ + Story => ( +
+ +
+ ), + ], +} +export default meta + +type Story = StoryObj + +export const LabwareDetailsWithCountStory: Story = { + args: { + title: 'Opentrons Flex 96 Tip Rack 1000 µL', + subTitle: 'With tip rack lid', + quantity: 1, + }, +} diff --git a/components/src/organisms/LabwareDetailsWithCount/__tests__/LabwareDetailsWithCount.test.tsx b/components/src/organisms/LabwareDetailsWithCount/__tests__/LabwareDetailsWithCount.test.tsx new file mode 100644 index 00000000000..73b074ada79 --- /dev/null +++ b/components/src/organisms/LabwareDetailsWithCount/__tests__/LabwareDetailsWithCount.test.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next' +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LabwareDetailsWithCount } from '..' +import { renderWithProviders } from '../../../testing/utils' + +import type { ComponentProps } from 'react' + +vi.mock('react-i18next', () => ({ + useTranslation: vi.fn(), + initReactI18next: vi.fn(), +})) + +vi.mock('i18next', () => { + return { + default: { + use: () => ({ init: vi.fn() }), + createInstance: () => ({ + use: () => ({ init: vi.fn() }), + init: vi.fn(), + t: (k: string) => k, + }), + init: vi.fn(), + t: (k: string) => k, + }, + } +}) +const render = (props: ComponentProps) => { + return renderWithProviders() +} +describe('LabwareDetailsWithCount', () => { + let props: ComponentProps + const t = vi.fn(key => key) + beforeEach(() => { + props = { + title: 'Title', + subTitle: 'SubTitle', + quantity: 1, + } + vi.mocked(useTranslation).mockReturnValue({ t } as any) + }) + + it('should render title, subTitle and label', () => { + render(props) + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('SubTitle')).toBeInTheDocument() + expect(screen.getByText('quantity')).toBeInTheDocument() + }) + + it('should render title without subTitle and label', () => { + props.subTitle = undefined + props.quantity = undefined + render(props) + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.queryByText('SubTitle')).not.toBeInTheDocument() + expect(screen.queryByText('quantity')).not.toBeInTheDocument() + }) +}) diff --git a/components/src/organisms/LabwareDetailsWithCount/index.tsx b/components/src/organisms/LabwareDetailsWithCount/index.tsx new file mode 100644 index 00000000000..8f53ad1ccb2 --- /dev/null +++ b/components/src/organisms/LabwareDetailsWithCount/index.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' + +import { StyledText, Tag } from '../../atoms' +import styles from './LabwareDetailsWithCount.module.css' + +interface LabwareDetailsWithCountProps { + title: string + subTitle?: string + quantity?: number +} + +export function LabwareDetailsWithCount({ + title, + subTitle, + quantity: label, +}: LabwareDetailsWithCountProps): JSX.Element { + const { t } = useTranslation('protocol_command_text') + return ( +
+ {title} +
+ {subTitle} +
+ {label != null ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/components/src/organisms/index.ts b/components/src/organisms/index.ts index c06b079cf62..2d033a3c7ad 100644 --- a/components/src/organisms/index.ts +++ b/components/src/organisms/index.ts @@ -1,6 +1,7 @@ export * from './CommandText' export * from './DeckLabelSet' export * from './FixtureOption' +export * from './LabwareDetailsWithCount' export * from './LabwareInfoOverlay' export * from './ProtocolDeck' export * from './Toolbox' diff --git a/components/src/structure/Splash.stories.tsx b/components/src/structure/Splash.stories.tsx index 2aced8269ba..c0f95718025 100644 --- a/components/src/structure/Splash.stories.tsx +++ b/components/src/structure/Splash.stories.tsx @@ -1,25 +1,24 @@ -import { Box } from '@opentrons/components' - import { Splash as SplashComponent } from './Splash' -import type { Meta, Story } from '@storybook/react' -import type * as React from 'react' +import type { Meta, StoryObj } from '@storybook/react' -export default { - title: 'Library/Molecules/Splash', +const meta: Meta = { + title: 'Helix/Molecules/Splash', + component: SplashComponent, decorators: [ Story => ( - +
- +
), ], -} as Meta +} +export default meta + +type Story = StoryObj -const Template: Story> = args => ( - -) -export const Splash = Template.bind({}) -Splash.args = { - iconName: 'ot-logo', +export const Splash: Story = { + args: { + iconName: 'ot-logo', + }, } diff --git a/components/src/structure/Splash.tsx b/components/src/structure/Splash.tsx index bb3e2828fdf..20620f6d2f7 100644 --- a/components/src/structure/Splash.tsx +++ b/components/src/structure/Splash.tsx @@ -2,7 +2,7 @@ import cx from 'classnames' import { Icon } from '../icons' -import styles from './Splash.module.css' +import styles from './splash.module.css' import type { IconName } from '../icons' diff --git a/components/src/structure/Splash.module.css b/components/src/structure/splash.module.css similarity index 100% rename from components/src/structure/Splash.module.css rename to components/src/structure/splash.module.css diff --git a/docs/flex/docs/glossary.md b/docs/flex/docs/glossary.md index a1cc26f6849..6ffc3d75a3b 100644 --- a/docs/flex/docs/glossary.md +++ b/docs/flex/docs/glossary.md @@ -272,7 +272,7 @@ Staging area slots are ANSI/SLAS compatible deck pieces that replace the standar ##### Status light -A strip of color LEDs along the top front of the robot. This light provides at-a-glance information about the robot. Different colors and patterns of illumination can communicate various success, failure, or idle states. See the [Touchscreen and LED displays section][touchscreen-and-led-displays] in the System Description chapter. +A strip of color LEDs along the top front of the robot. This light provides at-a-glance information about the robot. Different colors and patterns of illumination can communicate various success, failure, or idle states. See the [Status Light section][status-light-flex] in the System Description chapter. ##### Thermal adapter @@ -292,7 +292,7 @@ An aluminum bracket used by the 96-channel pipette to attach a full rack of pipe ##### Touchscreen -The interactive LCD screen mounted to the front of the robot. See the [Touchscreen and LED displays section][touchscreen-and-led-displays] in the System Description chapter. +The interactive LCD screen mounted to the front of the robot. See the [Touchscreen display section][touchscreen-display] in the System Description chapter. ##### Trash bin diff --git a/docs/flex/docs/images/deck-config-multiple-slots.png b/docs/flex/docs/images/deck-config-multiple-slots.png new file mode 100644 index 00000000000..27bef32a421 Binary files /dev/null and b/docs/flex/docs/images/deck-config-multiple-slots.png differ diff --git a/docs/flex/docs/images/deck-configuration-one-staging-slot.png b/docs/flex/docs/images/deck-configuration-one-staging-slot.png deleted file mode 100644 index 117483f270e..00000000000 Binary files a/docs/flex/docs/images/deck-configuration-one-staging-slot.png and /dev/null differ diff --git a/docs/flex/docs/modules/index.md b/docs/flex/docs/modules/index.md index 3737ccdc6f2..d5e4372bdbd 100644 --- a/docs/flex/docs/modules/index.md +++ b/docs/flex/docs/modules/index.md @@ -30,6 +30,8 @@ Opentrons Flex is compatible with with the following Opentrons modules: - The [**Thermocycler Module**](thermocycler.md) provides on-deck, fully automated thermocycling, enabling automation of upstream and downstream workflow steps. Thermocycler GEN2 is fully compatible with the gripper. Thermocycler GEN1 cannot be used with the gripper, and is therefore not supported on Opentrons Flex. +Some module tasks, like heating from an ambient temperature to a high temperature or executing a Thermocycler profile, take more time than others. Starting with API version 2.27, you can use [concurrent commands](https://docs.opentrons.com/v2/modules/concurrent_module.html) to continue pipetting and other steps in your Flex protocols. + Some modules originally designed for the OT-2 are compatible with Flex, as summarized in the table below. A checkmark :material-check-bold:{ .green } indicates compatibility, and an :octicons-x-12:{ .red } indicates incompatibility. @@ -47,3 +49,5 @@ Some modules originally designed for the OT-2 are compatible with Flex, as summa | Temperature Module GEN2 | :material-check-bold:{ .green } | :material-check-bold:{ .green } | | Thermocycler Module GEN1 | :material-check-bold:{ .green } | :octicons-x-12:{ .red } | | Thermocycler Module GEN2 | :material-check-bold:{ .green } | :material-check-bold:{ .green } | + + diff --git a/docs/flex/docs/protocols/python-api.md b/docs/flex/docs/protocols/python-api.md index e587abd7e73..0b93c889a88 100644 --- a/docs/flex/docs/protocols/python-api.md +++ b/docs/flex/docs/protocols/python-api.md @@ -89,9 +89,17 @@ Unlike other protocol commands, robot motor control commands execute movements i Sensors in Flex pipettes can detect the level of liquid in a well. You can use this feature to target a [liquid meniscus](https://docs.opentrons.com/v2/robot_position.html?highlight=liquid+level#meniscus) while aspirating, dispensing, or mixing in a Python protocol. -### Non-blocking commands +### Dynamic pipetting -Some module commands that take a long time to complete (such as heating from ambient temperature to a high temperature) can be run in a *non-blocking* manner. This lets your protocol save time by continuing on to other pipetting tasks instead of waiting for the command to complete. Non-blocking commands are currently supported on the [Heater-Shaker Module](https://docs.opentrons.com/v2/modules/heater_shaker.html#non-blocking-commands). +Starting in API version 2.27, use start and end location parameters to control pipette movements during liquid transfers: + +- Continuously target the [liquid meniscus](https://docs.opentrons.com/v2/robot_position.html?highlight=liquid+level#meniscus) as it changes while pipetting liquid. +- Change the pipette's position within a well while aspirating, dispensing, or mixing. +- Mix in different locations in labware using the [dynamic mix](https://docs.opentrons.com/v2/basic_commands/liquids.html#dynamic-mix) method. + +### Concurrent commands + +Some module commands that take a long time to complete (such as executing a Thermocycler profile or heating to a high temperature) can be run in a *concurrent* manner. This lets your protocol save time by continuing on to other pipetting tasks instead of waiting for the command to complete. As of API 2.27, concurrent commands are currently supported on the [Heater-Shaker](https://docs.opentrons.com/v2/modules/heater_shaker.html#heating-and-shaking), [Temperature](https://docs.opentrons.com/v2/modules/temperature_module.hmtl#temperature-control), and [Thermocyler](https://docs.opentrons.com/v2/modules/thermocycler.html) modules. You can also run multiple modules at the same time. See [Concurrent Module Actions](https://docs.opentrons.com/v2/modules/concurrent_module.html) for more. ### Python packages diff --git a/docs/flex/docs/system-description/index.md b/docs/flex/docs/system-description/index.md index 7c8d8b72297..bba5408fd1d 100644 --- a/docs/flex/docs/system-description/index.md +++ b/docs/flex/docs/system-description/index.md @@ -5,5 +5,5 @@ title: "Opentrons Flex: System Description" This chapter describes the hardware systems of Opentrons Flex, which underlie its core lab automation features. - The [deck][deck-and-working-area], [gantry](robot.md#gantry), and instrument mounts of Opentrons Flex enable the use of precision liquid handling and labware movement components. -- The [on-device touchscreen][touchscreen-and-led-displays] enables running protocols and checking on the robot's status without needing to bring your computer to the lab bench. +- The [on-device touchscreen][touchscreen-display] enables running protocols and checking on the robot's status without needing to bring your computer to the lab bench. - [Wired and wireless connectivity](connections.md) enables additional control from the Opentrons App (see the [Opentrons App chapter](../opentrons-app.md) for more details) and extending the system's features by attaching peripherals (see the [Modules chapter](../modules/index.md)). diff --git a/docs/flex/docs/system-description/robot.md b/docs/flex/docs/system-description/robot.md index b277c5d0bdf..6dca41d95c1 100644 --- a/docs/flex/docs/system-description/robot.md +++ b/docs/flex/docs/system-description/robot.md @@ -37,10 +37,13 @@ You should leave deck slots installed in locations where you want to place stand ## Staging area -The *staging area* is additional space along the right side of the deck. You can store labware in this location after installing *staging area slots*. Labware placed in slots A4 through D4 are in the staging area. Flex pipettes cannot reach into the staging area, but the gripper can pick up and move labware to and from this location. Adding extra slots helps keep the working area available for the equipment used in your automated protocols. +The *staging area* is additional space along the far right side of the deck (column 4). Labware and modules placed in column 4 are in the staging area. To create this new space, you replace the standard deck slots in column 3 with [staging area slots](#staging-area-slots). These special fixtures span two columns by fitting into column 3 and extending the deck to create the new column 4 locations (A4–D4). An advantage of using the staging area is that it gives you extra labware storage and keeps space in the working area available for equipment essential to your protocols. + +!!!note + Flex pipettes cannot reach into the staging area, but the gripper can pick up and move labware to and from this location. Staging area slots are included in certain workstation configurations. -You can also purchase a [set of four slots](https://opentrons.com/products/opentrons-flex-deck-expansion-set-4-count) from Opentrons. +You can also purchase a [set of four staging area slots](https://opentrons.com/products/opentrons-flex-deck-expansion-set-4-count) from Opentrons.
![Staging area slots in column 4.](../images/deck-staging-area.png "Staging area slots") @@ -48,19 +51,14 @@ You can also purchase a [set of four slots](https://opentrons.com/products/opent ## Deck fixtures -Fixtures are hardware items that replace standard deck slots. They let -you customize the deck layout and add functionality to your Flex. -Currently, deck fixtures include the staging area slots, the internal -trash bin, and the external waste chute. You can only install fixtures -in a few specific deck slots. The following table lists the deck -locations for each fixture. +Fixtures are hardware items that replace standard deck slots. They let you customize the deck layout and add functionality to your Flex. Currently, deck fixtures include the staging area slots, the internal trash bin, and the external waste chute. You can only install fixtures in a few specific deck slots. The following table lists the deck locations for each fixture. | **Fixture** | **Slots** | |------------------------------------|-------------------| -| Staging area slots | A3–D3 | +| Staging area slots | A4–D4 | | Trash bin | A1–D1 and A3–D3 | | Waste chute | D3 only | -| Waste chute with staging area slot | D3 only | +| Waste chute with staging area slot | D3 and D4 | Fixtures are unpowered. They do not contain electronic or mechanical components that communicate their current state and deck location to the robot. This means you have to use the deck configuration feature to let the Flex know what fixtures are attached to the deck and where they're located. @@ -77,7 +75,7 @@ The Opentrons Flex Waste Chute transfers liquids, tips, tip racks, and well plat ## Staging area slots -Staging area slots are ANSI/SLAS compatible deck pieces that replace standard slots in column 3 and add new slots to the staging area — all without losing space in the working area. You can install a single slot or a maximum of four slots to create a new column (A4 to D4) along the right side of the deck. Note, however, that replacing deck slot A3 requires moving the trash bin. By adding staging area slots to the deck, your Flex robot can store more labware and operate more efficiently. +_Staging area slots_ are ANSI/SLAS compatible deck pieces that replace standard slots in column 3 to create new slots in the [staging area](#staging-area) (column 4). You can install a single slot or a maximum of four slots to create new location coordinates (A4 to D4) along the right side of the deck. Note, however, that replacing deck slot A3 requires moving the trash bin. By adding staging area slots to the deck, your Flex robot can store more labware and operate more efficiently.
![Flex staging area slot.](../images/staging-slot.png "Flex staging area slot") @@ -119,7 +117,7 @@ The electronics contained in the gantry provide 36 VDC power and communications
Location of instrument mounts on Flex.
-## Touchscreen and LED displays +## Touchscreen display The primary user interface is the 7-inch LCD *touchscreen*, located on the front right of the robot. The touchscreen is covered with Gorilla Glass 3 for scratch and damage resistance. Access many features of Flex right on the touchscreen, including: @@ -137,6 +135,8 @@ The primary user interface is the 7-inch LCD *touchscreen*, located on the front For more information on using Flex via the touchscreen, see the [Touchscreen chapter](../touchscreen/index.md). +## Status light { #status-light-flex } + The *status light* is a strip of LEDs along the top front of the robot that provides at-a-glance information about the robot. Different colors and patterns of illumination can communicate various success, failure, or idle states: @@ -176,10 +176,14 @@ The *status light* is a strip of LEDs along the top front of the robot that prov - + + + + + diff --git a/docs/flex/docs/touchscreen/deck-config.md b/docs/flex/docs/touchscreen/deck-config.md index da9368a56cc..b12f5e5687a 100644 --- a/docs/flex/docs/touchscreen/deck-config.md +++ b/docs/flex/docs/touchscreen/deck-config.md @@ -29,8 +29,8 @@ To add deck fixtures via the touchscreen: Click the :octicons-x-circle-fill-16: on a fixture on the deck map to remove it from the deck configuration.
-![Deck configuration screen showing the deck map. Slots A1 through D1 and A3 through C3 are blue and have plus icons. Slot C3 is dark grey and is labeled "Staging area".](../images/deck-configuration-one-staging-slot.png "Deck configuration with staging slot in D3") -
A Flex configured with a staging area slot in D3, and no other fixtures.
+![Deck configuration screen showing the deck map with various modules attached.](../images/deck-config-multiple-slots.png) +
A Flex configured with staging area slots and modules.
You can also configure the deck in the Opentrons App, on the robot details page for your Flex. diff --git a/docs/shared/opentrons-theme.css b/docs/shared/opentrons-theme.css index abd268f1652..41ac64e069f 100644 --- a/docs/shared/opentrons-theme.css +++ b/docs/shared/opentrons-theme.css @@ -89,4 +89,11 @@ article ul li::marker { .md-typeset figcaption { /* .md-typeset parent required or will not override font-style */ font-style: normal; color: #737578; +} + +/* Tab bar link to opentrons.com */ + +.md-tabs__list li:last-child { + margin-left: auto; + padding-right: 0; } \ No newline at end of file diff --git a/docs/shared/partials/tabs.html b/docs/shared/partials/tabs.html new file mode 100644 index 00000000000..3d0d3736a3c --- /dev/null +++ b/docs/shared/partials/tabs.html @@ -0,0 +1,32 @@ +{% import "partials/tabs-item.html" as item with context %} + + + \ No newline at end of file diff --git a/e2e-testing/automation/pd_pages/__init__.py b/e2e-testing/automation/pd_pages/__init__.py index 192c60af50a..05240e763b5 100644 --- a/e2e-testing/automation/pd_pages/__init__.py +++ b/e2e-testing/automation/pd_pages/__init__.py @@ -3,12 +3,15 @@ from .base_page import BasePage from .create_protocol_wizard import CreateProtocolWizard from .deck_config_page import DeckConfigPage +from .heater_shaker_step_form_page import HeaterShakerStepPage from .landing_page import LandingPage from .mix_step_form import MixStepForm from .module_config_page import ModuleConfigPage from .pipette_modal import PipetteModal from .protocol_editor_page import ProtocolEditorPage from .settings_page import SettingsPage +from .tc_step_form_page import ThermocyclerStepPage +from .tempdeck_step_form_page import TemperatureStepPage __all__ = [ "BasePage", @@ -20,4 +23,7 @@ "PipetteModal", "ProtocolEditorPage", "SettingsPage", + "ThermocyclerStepPage", + "TemperatureStepPage", + "HeaterShakerStepPage", ] diff --git a/e2e-testing/automation/pd_pages/heater_shaker_step_form_page.py b/e2e-testing/automation/pd_pages/heater_shaker_step_form_page.py new file mode 100644 index 00000000000..d3754d8c136 --- /dev/null +++ b/e2e-testing/automation/pd_pages/heater_shaker_step_form_page.py @@ -0,0 +1,103 @@ +"""Module for interactions within the Heater-Shaker Step configuration form.""" + +from enum import Enum + +from playwright.sync_api import Page + +from .base_page import BasePage + + +class LidPosition(Enum): + """Defines the allowed positions for the Heater-Shaker lid.""" + + OPEN = "open" + CLOSED = "closed" + + +class HeaterShakerStepPage(BasePage): + """Page object for configuring a Heater-Shaker step.""" + + def __init__(self, page: Page) -> None: + """Initialize the HeaterShakerStepPage.""" + super().__init__(page) + self._save_button = self.page.get_by_role("button", name="Save", exact=True) + self._target_speed_toggle = self.page.get_by_text("Target Speed").locator("..").locator(".ToggleButton_Off") + self._latch_closed_toggle = self.page.get_by_text("Labware latchClosed") + self._latch_open_toggle = self.page.get_by_text("Labware latchOpen") + + def wait_for_form_load(self) -> None: + """Wait for the Heater-Shaker step form to be visible.""" + self.wait_for_visible(self.page.get_by_text("Heater-Shaker Module", exact=False)) + + def set_target_temperature(self, temp: str) -> None: + """Enable and set the target heating temperature. + + Args: + temp: The target temperature string (e.g., "60"). + """ + self.page.get_by_text("HeaterOff").click() + try: + self.page.locator('input[name="targetTemperature"]').fill(temp) + except Exception: # noqa: BLE001 + self.page.get_by_role("textbox").fill(temp) + + def set_target_speed(self, speed: str) -> None: + """Enable and set the target shaking speed. + + Args: + speed: The target speed string (e.g., "700"). + """ + self.page.get_by_text("ShakerOff").click() + self.page.locator('input[name="targetSpeed"]').fill(speed) + + def set_lid_position(self, position: LidPosition) -> None: + """Set the position of the Heater-Shaker lid. + + Args: + position: LidPosition.OPEN or LidPosition.CLOSED. + """ + if position == LidPosition.CLOSED: + self._latch_closed_toggle.click() + elif position == LidPosition.OPEN: + self._latch_open_toggle.click() + + def save_step(self) -> None: + """Click the Save button to confirm and close the step editor.""" + self.wait_for_visible(self._save_button) + self._save_button.click() + + def pause_confirm(self) -> None: + """Confirm the pause in the step editor.""" + self.page.get_by_role("button", name="Add pause step").click() + + ## Composite step + + +def _add_heater_shaker_step(page: Page, temp: str, speed: str, timer: str) -> None: + """Add a Heater-Shaker step configured with temperature, speed, and timer. + + Args: + editor: The initialized ProtocolEditorPage object for adding steps. + page: The Playwright Page object for raw interactions. + temp: The target temperature for the module (e.g., "50"). + speed: The target RPM speed for the shaker (e.g., "300"). + timer: The optional timer duration in HH:MM format (e.g., "00:30"). + """ + hs_page = HeaterShakerStepPage(page) + hs_page.wait_for_form_load() + + hs_page.set_target_temperature(temp) + hs_page.set_target_speed(speed) + + try: + timer_input = page.locator('input[name="heaterShakerTimer"]') + timer_input.click() + timer_input.fill(timer) + print(f"✓ Heater-Shaker timer set to {timer}") + except Exception as e: # noqa: BLE001 + print(f"Warning: Could not set Heater-Shaker timer. Error: {e}") + + hs_page.save_step() + hs_page.pause_confirm() + + print(f"✓ Heater-Shaker step added (T:{temp}°C, S:{speed}rpm).") diff --git a/e2e-testing/automation/pd_pages/tc_step_form_page.py b/e2e-testing/automation/pd_pages/tc_step_form_page.py new file mode 100644 index 00000000000..124b9f348eb --- /dev/null +++ b/e2e-testing/automation/pd_pages/tc_step_form_page.py @@ -0,0 +1,533 @@ +"""Module for interactions within the Thermocycler Step configuration form.""" + +import re +from typing import Optional + +from playwright.sync_api import Page + +from .base_page import BasePage + + +class ThermocyclerStepPage(BasePage): + """Page object for configuring a Thermocycler step (state or profile mode).""" + + def __init__(self, page: Page) -> None: + """Initialize the ThermocyclerStepPage.""" + super().__init__(page) + + # ========== Part 1: State vs Profile Selection ========== + + def select_state_mode(self) -> None: + """Select the 'Change Thermocycler state' option.""" + self.page.get_by_role("button", name="Continue").click() + + def select_profile_mode(self) -> None: + """Select the 'Program a Thermocycler profile' option.""" + self.page.locator("div").filter(has_text=re.compile(r"^Program a Thermocycler profile$")).nth(1).click() + self.page.get_by_role("button", name="Continue").click() + + # ========== Block Temperature (State Mode) ========== + + def set_block_temperature(self, temp: str) -> None: + """ + Set the block target temperature in state mode. + + Args: + temp: Temperature value (e.g., "40"). + """ + textbox = self.page.get_by_role("textbox").first + self.wait_for_visible(textbox) + textbox.click() + textbox.fill(temp) + + def toggle_block_temperature(self, enable: bool) -> None: + """ + Toggle block temperature on/off in state mode. + + Args: + enable: True to turn on, False to turn off. + """ + if enable: + self.page.get_by_text("BlockOff").click() + else: + self.page.get_by_text("BlockOn").click() + + # ========== Lid Temperature (State Mode) ========== + + def set_lid_temperature(self, temp: str) -> None: + """ + Set the lid target temperature in state mode. + + Args: + temp: Temperature value (e.g., "110"). + """ + lid_input = self.page.locator('input[name="lidTargetTemp"]') + self.wait_for_visible(lid_input) + lid_input.click() + lid_input.fill(temp) + + def toggle_lid_temperature(self, enable: bool) -> None: + """ + Toggle lid temperature on/off in state mode. + + Args: + enable: True to turn on, False to turn off. + """ + if enable: + self.page.get_by_text("LidOff").click() + else: + self.page.get_by_text("LidOn").click() + + # ========== Lid Position ========== + + def set_lid_position(self, position: str) -> None: + """ + Set the lid position (open or closed). + + Args: + position: "open" or "closed". + """ + if position == "open": + self.page.get_by_text("Lid positionClosed").click() + else: + self.page.get_by_text("Lid positionOpen").click() + + # ========== Profile Mode: Well Volume & Temperatures ========== + + def set_well_volume(self, volume: str) -> None: + """ + Set the well volume in profile mode. + + Args: + volume: Volume value (e.g., "100"). + """ + vol_input = self.page.locator('input[name="profileVolume"]') + self.wait_for_visible(vol_input) + vol_input.click() + vol_input.fill(volume) + + def set_profile_lid_temperature(self, temp: str) -> None: + """ + Set the profile lid temperature in profile mode. + + Args: + temp: Temperature value (e.g., "50"). + """ + lid_input = self.page.locator('input[name="profileTargetLidTemp"]') + self.wait_for_visible(lid_input) + lid_input.click() + lid_input.fill(temp) + + def set_block_temperature_hold(self, temp: str) -> None: + """ + Set the block temperature hold value in profile mode. + + Args: + temp: Temperature value (e.g., "90"). + """ + hold_input = self.page.locator('input[name="blockTargetTempHold"]') + self.wait_for_visible(hold_input) + hold_input.click() + hold_input.fill(temp) + + def set_lid_temperature_hold(self, temp: str) -> None: + """ + Set the lid temperature hold value in profile mode. + + Args: + temp: Temperature value (e.g., "40"). + """ + hold_input = self.page.locator('input[name="lidTargetTempHold"]') + self.wait_for_visible(hold_input) + hold_input.click() + hold_input.fill(temp) + + # ========== Profile Programming Modal ========== + + def open_profile_programmer(self) -> "ThermocyclerProfileModal": + """ + Open the profile programmer modal by clicking "No profile defined". + + Returns: + ThermocyclerProfileModal page object for profile editing. + """ + no_profile_button = self.page.locator("div").filter(has_text=re.compile(r"^No profile defined$")) + self.wait_for_visible(no_profile_button) + no_profile_button.click() + + # Wait for modal to open + self.wait_for_visible(self.page.get_by_test_id("Modal_header")) + + return ThermocyclerProfileModal(self.page) + + # ========== Navigation & Saving ========== + + def save_step(self) -> None: + """Click the Save button to confirm and close the step editor.""" + save_button = self.page.get_by_role("button", name="Save").first + self.wait_for_visible(save_button) + save_button.click() + + +class ThermocyclerProfileModal(BasePage): + """Page object for the Thermocycler profile programming modal.""" + + def __init__(self, page: Page) -> None: + """Initialize the ThermocyclerProfileModal.""" + super().__init__(page) + + def wait_for_modal_load(self) -> None: + """Wait for the profile modal to be fully loaded.""" + self.wait_for_visible(self.page.get_by_test_id("Modal_header")) + + # ========== Cycle Management ========== + + def add_cycle(self) -> None: + """Add a new cycle to the profile.""" + add_button = self.page.get_by_role("button", name="Add cycle") + self.wait_for_visible(add_button) + add_button.click() + # Wait for delete button to appear (indicates cycle was added) + self.wait_for_visible(self.page.get_by_role("button", name="Delete").first) + + def delete_cycle(self, cycle_index: int) -> None: + """ + Delete a cycle by index. + + Args: + cycle_index: The index of the cycle to delete (0-based). + """ + delete_button = self.page.get_by_role("button", name="Delete").nth(cycle_index) + self.wait_for_visible(delete_button) + delete_button.click() + + def set_cycle_count(self, cycle_index: int, count: str) -> None: + """ + Set the number of repeats for a cycle. + + Args: + cycle_index: The index of the cycle (0-based). + count: The cycle count as a string (e.g., "2"). + """ + # Find the cycle container and locate the "Number of cycles" input + cycle_container = self.page.get_by_test_id("thermocyclerCycle").nth(cycle_index) + + # Click on the "Number of cycles" area + num_cycles_div = cycle_container.locator("div").filter(has_text="Number of cycles").nth(3) + num_cycles_div.click() + + # Find the input within the cycles section using a shorter variable name + cycles_input_selector = ( + "div:nth-child(3) > div > " + ".Flex-sc-1qhp8l7-0.InputField___StyledFlex-sc-1gyyvht-2 > " + ".Flex-sc-1qhp8l7-0 > " + ".InputField__StyledInput-sc-1gyyvht-0" + ) + cycles_input = self.page.locator(cycles_input_selector) + cycles_input.fill(count) + + # ========== Cycle Steps ========== + + def add_cycle_step(self, cycle_index: int) -> None: + """ + Add a step to a cycle. + + Args: + cycle_index: The index of the cycle (0-based). + """ + add_step_button = self.page.get_by_role("button", name="Add a cycle step") + add_step_button.click() + + def fill_cycle_step( + self, + cycle_index: int, + step_index: int, + step_name: str, + temperature: str, + time: str, + ) -> None: + """ + Fill in a cycle step's details. + + Args: + cycle_index: The index of the cycle (0-based). + step_index: The index of the step within the cycle (0-based). + step_name: The step name (e.g., "Cycle 1"). + temperature: The temperature (e.g., "40"). + time: The time in M:SS format (e.g., "1:00"). + """ + step_container = self.page.get_by_test_id(f"cycleStep-{step_index}") + self.wait_for_visible(step_container) + + # Fill step name + name_input = step_container.get_by_role("textbox").first + self.wait_for_visible(name_input) + name_input.click() + name_input.fill(step_name) + name_input.press("Tab") + + # Fill temperature + temp_input = step_container.get_by_role("textbox").nth(1) + self.wait_for_visible(temp_input) + temp_input.click() + temp_input.fill(temperature) + temp_input.press("Tab") + + # Fill time + time_input = step_container.get_by_role("textbox").nth(2) + self.wait_for_visible(time_input) + time_input.click() + time_input.fill(time) + + def delete_cycle_step(self, cycle_index: int, step_index: int) -> None: + """ + Delete a step from a cycle. + + Args: + cycle_index: The index of the cycle (0-based). + step_index: The index of the step within the cycle (0-based). + """ + step_container = self.page.get_by_test_id(f"cycleStep-{step_index}") + delete_button = step_container.get_by_role("button", name="Delete") + delete_button.click() + + def save_cycle(self, cycle_index: int) -> None: + """ + Save a cycle. + + Args: + cycle_index: The index of the cycle (0-based). + """ + cycle_container = self.page.get_by_test_id("thermocyclerCycle").nth(cycle_index) + save_button = cycle_container.get_by_role("button", name="Save") + save_button.click() + + # ========== Thermocycler Steps (Non-cycle steps) ========== + + def add_step(self) -> None: + """Add a standalone thermocycler step to the profile.""" + add_button = self.page.get_by_role("button", name="Add step") + self.wait_for_visible(add_button) + add_button.click() + + def fill_thermocycler_step( + self, + step_index: int, + step_name: str, + temperature: str, + time: str, + ) -> None: + """ + Fill in a standalone thermocycler step's details. + + Args: + step_index: The index of the thermocycler step (0-based). + step_name: The step name (e.g., "Thermocycler step 2"). + temperature: The temperature (e.g., "25"). + time: The time in HH:MM or M:SS format (e.g., "02:02"). + """ + step_container = self.page.get_by_test_id(f"thermocyclerStep-{step_index}") + self.wait_for_visible(step_container) + + # Fill step name + name_input = step_container.get_by_role("textbox").first + self.wait_for_visible(name_input) + name_input.click() + name_input.fill(step_name) + name_input.press("Tab") + + # Fill temperature + temp_input = step_container.get_by_role("textbox").nth(1) + self.wait_for_visible(temp_input) + temp_input.click() + temp_input.fill(temperature) + temp_input.press("Tab") + + # Fill time + time_input = step_container.get_by_role("textbox").nth(2) + self.wait_for_visible(time_input) + time_input.click() + time_input.fill(time) + + def delete_thermocycler_step(self, step_index: int) -> None: + """ + Delete a standalone thermocycler step. + + Args: + step_index: The index of the thermocycler step (0-based). + """ + delete_button = self.page.get_by_test_id("cycleStep-0").locator("path") + delete_button.click() + + def save_thermocycler_step(self, step_index: int) -> None: + """ + Save a standalone thermocycler step. + + Args: + step_index: The index of the thermocycler step (0-based). + """ + step_container = self.page.get_by_test_id(f"thermocyclerStep-{step_index}") + self.wait_for_visible(step_container) + + save_button = step_container.get_by_role("button", name="Save") + self.wait_for_visible(save_button) + save_button.click() + + # ========== Modal Navigation ========== + + def save_and_close_profile(self) -> None: + """Save the profile and close the modal.""" + modal = self.page.get_by_label("ModalShell_ModalArea") + save_button = modal.get_by_role("button", name="Save") + save_button.click() + + def cancel_and_close_profile(self) -> None: + """Cancel and close the profile modal without saving.""" + modal = self.page.get_by_label("ModalShell_ModalArea") + cancel_button = modal.get_by_role("button", name="Cancel") + cancel_button.click() + + +# Composite steps + + +def _add_thermocycler_state_step( + page: Page, + block_temp: Optional[str] = None, + lid_temp: Optional[str] = None, + lid_position: str = "open", +) -> None: + """Add a Thermocycler step in STATE mode with configurable parameters. + + Args: + page: The Playwright Page object for raw interactions. + block_temp: Block target temperature (e.g., "40"). If None, block is not toggled. + lid_temp: Lid target temperature (e.g., "110"). If None, lid is not toggled. + lid_position: Lid position ("open" or "closed"). Defaults to "open". + """ + tc_page = ThermocyclerStepPage(page) + + print("✓ Thermocycler step form loaded (State mode)") + + tc_page.select_state_mode() + print("✓ State mode selected") + + if block_temp is not None: + tc_page.toggle_block_temperature(enable=True) + tc_page.set_block_temperature(block_temp) + print(f"✓ Block temperature: ON at {block_temp}°C") + else: + print("⊘ Block temperature: not configured") + + if lid_temp is not None: + tc_page.toggle_lid_temperature(enable=True) + tc_page.set_lid_temperature(lid_temp) + print(f"✓ Lid temperature: ON at {lid_temp}°C") + else: + print("⊘ Lid temperature: not configured") + + tc_page.set_lid_position(lid_position) + print(f"✓ Lid position: {lid_position.upper()}") + + tc_page.save_step() + print("✅ Thermocycler state step saved") + + +def _add_thermocycler_profile_step( + page: Page, + well_volume: str = "100", + lid_temp: str = "50", + cycles: Optional[list] = None, +) -> None: + """Add a Thermocycler step in PROFILE mode with configurable cycle definition. + + Args: + page: The Playwright Page object for raw interactions. + well_volume: Well volume in µL (e.g., "100"). + lid_temp: Lid temperature (e.g., "50"). + cycles: List of cycle dictionaries. Each dict should contain: + { + "repeat_count": "2", + "steps": [ + { + "name": "Cycle 1", + "temperature": "40", + "time": "1:00" + }, + ... + ] + } + If None, defaults to a single cycle with 2 steps repeating 2 times. + + Example: + _add_thermocycler_profile_step( + well_volume="100", + lid_temp="50", + cycles=[{ + "repeat_count": "35", + "steps": [ + {"name": "Denature", "temperature": "95", "time": "0:30"}, + {"name": "Anneal", "temperature": "60", "time": "0:30"}, + ] + }] + ) + """ + if cycles is None: + cycles = [ + { + "repeat_count": "2", + "steps": [ + {"name": "Cycle 1", "temperature": "40", "time": "1:00"}, + {"name": "Cycle 2", "temperature": "4", "time": "0:01"}, + ], + } + ] + + tc_page = ThermocyclerStepPage(page) + + print("✓ Thermocycler step form loaded (Profile mode)") + + tc_page.select_profile_mode() + print("✓ Profile mode selected") + + tc_page.set_well_volume(well_volume) + print(f"✓ Well volume: {well_volume} µL") + + tc_page.set_profile_lid_temperature(lid_temp) + print(f"✓ Lid temperature: {lid_temp}°C") + + profile_modal = tc_page.open_profile_programmer() + profile_modal.wait_for_modal_load() + + for cycle_idx, cycle_config in enumerate(cycles): + profile_modal.add_cycle() + profile_modal.delete_thermocycler_step(step_index=0) + print(f"✓ Cycle {cycle_idx} added") + + steps = cycle_config.get("steps", []) + for step_idx, step_config in enumerate(steps): + # to avoid arbitrary + profile_modal.add_cycle_step(cycle_index=cycle_idx) + profile_modal.fill_cycle_step( + cycle_index=cycle_idx, + step_index=step_idx, + step_name=step_config["name"], + temperature=step_config["temperature"], + time=step_config["time"], + ) + print( + f" ✓ Step {step_idx}: {step_config['name']} @ {step_config['temperature']}°C for {step_config['time']}" + ) + + repeat_count = cycle_config.get("repeat_count", "1") + profile_modal.set_cycle_count(cycle_index=cycle_idx, count=repeat_count) + print(f"✓ Cycle {cycle_idx} repeat count: {repeat_count}") + + profile_modal.save_cycle(cycle_index=cycle_idx) + print(f"✓ Cycle {cycle_idx} saved") + + profile_modal.save_and_close_profile() + print("✓ Profile modal saved and closed") + + tc_page.save_step() + print("✅ Thermocycler profile step saved") diff --git a/e2e-testing/automation/pd_pages/tempdeck_step_form_page.py b/e2e-testing/automation/pd_pages/tempdeck_step_form_page.py new file mode 100644 index 00000000000..7742118bde9 --- /dev/null +++ b/e2e-testing/automation/pd_pages/tempdeck_step_form_page.py @@ -0,0 +1,60 @@ +"""Module for interactions within the Temperature Module Step configuration form.""" + +from playwright.sync_api import Page + +from .base_page import BasePage + + +class TemperatureStepPage(BasePage): + """Page object for configuring a Temperature Module step.""" + + def __init__(self, page: Page) -> None: + """Initialize the TemperatureStepPage.""" + super().__init__(page) + self._save_button = self.page.get_by_role("button", name="Save") + self._target_temp_toggle_turn_on = self.page.locator('[data-testid^="ToggleButton_"]') + self._pause_button = self.page.get_by_role("button", name="Add pause step") + self._temp_module_label = self.page.get_by_text("Temperature Module state", exact=True) + + def wait_for_form_load(self) -> None: + """Wait for the Temperature Module step form to be visible.""" + self.wait_for_visible(self._temp_module_label) + + def set_target_temperature(self, temp: str) -> None: + """ + Enable and set the target temperature. + + Args: + temp: The target temperature string (e.g., "70"). + """ + # Ensure the toggle is 'On' + + self._target_temp_toggle_turn_on.click(timeout=5000, force=True) + # Fill the temperature input + self.page.get_by_role("textbox").fill(temp) + self.page.get_by_role("textbox").press("Enter") + + def save_step(self) -> None: + """Click the Save button to confirm and close the step editor.""" + self._save_button.click() + + def add_pause(self) -> None: + """Click the button to add a pause step after the temperature setpoint is reached.""" + # self.wait_for_visible(self._pause_button) + self._pause_button.click() + + +def _add_temperature_module_step(page: Page, temp: str) -> None: + """Add a Temperature Module step and an immediate Pause step to the protocol. + + Args: + editor: The initialized ProtocolEditorPage object for adding steps. + page: The Playwright Page object for raw interactions. + temp: The target temperature for the module (e.g., "50" for 50°C). + """ + + temp_page = TemperatureStepPage(page) + temp_page.set_target_temperature(temp) + temp_page.save_step() + temp_page.add_pause() + print(f"✓ Temperature step set to {temp}°C and Pause step added.") diff --git a/e2e-testing/fixtures/protocol/9/smoke_flex_setup.py b/e2e-testing/fixtures/protocol/9/smoke_flex_setup.py new file mode 100644 index 00000000000..baabdd71a98 --- /dev/null +++ b/e2e-testing/fixtures/protocol/9/smoke_flex_setup.py @@ -0,0 +1,143 @@ +from opentrons import protocol_api + +metadata = { + "protocolName": "Protocol Onboarding Demonstration", + "created": "2025-11-07T21:33:25.229Z", + "lastModified": "2025-11-07T21:34:18.540Z", + "protocolDesigner": "8.6.2", + "source": "Protocol Designer", +} + +requirements = {"robotType": "Flex", "apiLevel": "2.27"} + + +def run(protocol: protocol_api.ProtocolContext) -> None: + # Load Modules: + protocol.load_module("thermocyclerModuleV2", "B1") + protocol.load_module("heaterShakerModuleV1", "C1") + protocol.load_module("temperatureModuleV2", "D1") + + # Load Labware: + tip_rack_1 = protocol.load_labware( + "opentrons_flex_96_filtertiprack_1000ul", + location="C2", + namespace="opentrons", + version=1, + ) + protocol.load_labware( + "opentrons_flex_96_filtertiprack_200ul", + location="B2", + namespace="opentrons", + version=1, + ) + protocol.load_labware( + "opentrons_flex_96_filtertiprack_50ul", + location="A2", + namespace="opentrons", + version=1, + ) + well_plate_1 = protocol.load_labware( + "axygen_96_wellplate_500ul", + location="D2", + namespace="opentrons", + version=2, + ) + + # Load Pipettes: + pipette_left = protocol.load_instrument("flex_1channel_1000", "left") + + # Define Liquids: + liquid_1 = protocol.define_liquid( + "Water", + display_color="#b925ff", + ) + + # Load Liquids: + well_plate_1.load_liquid( + wells=["A1"], + liquid=liquid_1, + volume=400, + ) + + # PROTOCOL STEPS + + # Step 1: transfer + pipette_left.transfer_with_liquid_class( + volume=100, + source=[well_plate_1["A1"]], + dest=[well_plate_1["A3"]], + new_tip="always", + return_tip=True, + tip_racks=[tip_rack_1], + liquid_class=protocol.define_liquid_class( + name="transfer_step_1", + properties={ + "flex_1channel_1000": { + "opentrons/opentrons_flex_96_filtertiprack_1000ul/1": { + "aspirate": { + "aspirate_position": { + "offset": {"x": 0, "y": 0, "z": 1}, + "position_reference": "well-bottom", + }, + "flow_rate_by_volume": [(0, 716)], + "pre_wet": False, + "correction_by_volume": [(0, 0)], + "delay": {"enabled": False}, + "mix": {"enabled": False}, + "submerge": { + "delay": {"enabled": False}, + "speed": 100, + "start_position": { + "offset": {"x": 0, "y": 0, "z": 2}, + "position_reference": "well-top", + }, + }, + "retract": { + "air_gap_by_volume": [(0, 0)], + "delay": {"enabled": False}, + "end_position": { + "offset": {"x": 0, "y": 0, "z": 2}, + "position_reference": "well-top", + }, + "speed": 50, + "touch_tip": {"enabled": False}, + }, + }, + "dispense": { + "dispense_position": { + "offset": {"x": 0, "y": 0, "z": 1}, + "position_reference": "well-bottom", + }, + "flow_rate_by_volume": [(0, 716)], + "delay": {"enabled": False}, + "submerge": { + "delay": {"enabled": False}, + "speed": 100, + "start_position": { + "offset": {"x": 0, "y": 0, "z": 2}, + "position_reference": "well-top", + }, + }, + "retract": { + "air_gap_by_volume": [(0, 0)], + "delay": {"enabled": False}, + "end_position": { + "offset": {"x": 0, "y": 0, "z": 2}, + "position_reference": "well-top", + }, + "speed": 50, + "touch_tip": {"enabled": False}, + "blowout": {"enabled": False}, + }, + "correction_by_volume": [(0, 0)], + "push_out_by_volume": [(0, 20)], + "mix": {"enabled": False}, + }, + } + } + }, + ), + ) + + +DESIGNER_APPLICATION = """{"robot":{"model":"OT-3 Standard"},"designerApplication":{"name":"opentrons/protocol-designer","version":"8.7.0","data":{"pipetteTiprackAssignments":{"ca4f1dac-7b41-4206-a4ad-1da7f9a18f4f":["opentrons/opentrons_flex_96_filtertiprack_1000ul/1","opentrons/opentrons_flex_96_filtertiprack_200ul/1","opentrons/opentrons_flex_96_filtertiprack_50ul/1"]},"dismissedWarnings":{"form":[],"timeline":[]},"ingredients":{"0":{"displayName":"Water","displayColor":"#b925ff","description":null,"liquidGroupId":"0"}},"ingredLocations":{"0b30bf36-dcbe-41b2-aa14-ccfc068a33d5:opentrons/axygen_96_wellplate_500ul/2":{"A1":{"0":{"volume":400}}}},"savedStepForms":{"__INITIAL_DECK_SETUP_STEP__":{"stepType":"manualIntervention","id":"__INITIAL_DECK_SETUP_STEP__","labwareLocationUpdate":{"c1d12d76-b77c-47f2-b49e-486785f00339:opentrons/opentrons_flex_96_filtertiprack_1000ul/1":"C2","8c3f08d6-774b-4ae0-8951-c05028d7d49a:opentrons/opentrons_flex_96_filtertiprack_200ul/1":"B2","d17463d2-5771-4559-9828-19f2fb50bdab:opentrons/opentrons_flex_96_filtertiprack_50ul/1":"A2","0b30bf36-dcbe-41b2-aa14-ccfc068a33d5:opentrons/axygen_96_wellplate_500ul/2":"D2"},"pipetteLocationUpdate":{"ca4f1dac-7b41-4206-a4ad-1da7f9a18f4f":"left"},"moduleLocationUpdate":{"4559a1ef-15b8-4119-8aa0-f18c48c4dfd9:thermocyclerModuleType":"B1","b0fd399e-1ee4-466a-8adb-ce5db9e7bebb:heaterShakerModuleType":"C1","6e93abc5-d16f-462f-8bed-af0117bef02e:temperatureModuleType":"D1"},"trashBinLocationUpdate":{},"wasteChuteLocationUpdate":{},"stagingAreaLocationUpdate":{"dc20c54a-34bc-4e30-a160-d21456e97aa3:stagingArea":"cutoutD3"},"gripperLocationUpdate":{"13f99a25-6698-46b3-916a-0cf67af8d2e6:gripper":"mounted"}},"e8c21882-6283-426a-9cc3-1128bb9a3104":{"id":"e8c21882-6283-426a-9cc3-1128bb9a3104","stepType":"moveLiquid","stepName":"transfer","stepDetails":"","stepNumber":0,"aspirate_airGap_checkbox":false,"aspirate_airGap_volume":"","aspirate_delay_checkbox":false,"aspirate_delay_seconds":"1","aspirate_flowRate":"716","aspirate_labware":"0b30bf36-dcbe-41b2-aa14-ccfc068a33d5:opentrons/axygen_96_wellplate_500ul/2","aspirate_mix_checkbox":false,"aspirate_mix_times":"","aspirate_mix_volume":"","aspirate_mmFromBottom":1,"aspirate_position_reference":"well-bottom","aspirate_retract_delay_seconds":"0","aspirate_retract_mmFromBottom":2,"aspirate_retract_speed":"50","aspirate_retract_x_position":0,"aspirate_retract_y_position":0,"aspirate_retract_position_reference":"well-top","aspirate_submerge_delay_seconds":"0","aspirate_submerge_speed":"100","aspirate_submerge_mmFromBottom":2,"aspirate_submerge_x_position":0,"aspirate_submerge_y_position":0,"aspirate_submerge_position_reference":"well-top","aspirate_touchTip_checkbox":false,"aspirate_touchTip_mmFromTop":-1,"aspirate_touchTip_speed":"30","aspirate_touchTip_mmFromEdge":"0.5","aspirate_wellOrder_first":"t2b","aspirate_wellOrder_second":"l2r","aspirate_wells_grouped":false,"aspirate_wells":["A1"],"aspirate_x_position":0,"aspirate_y_position":0,"blowout_checkbox":false,"blowout_flowRate":"716","blowout_location":null,"changeTip":"always","conditioning_checkbox":false,"conditioning_volume":"","dispense_airGap_checkbox":false,"dispense_airGap_volume":"","dispense_delay_checkbox":false,"dispense_delay_seconds":"1","dispense_flowRate":"716","dispense_labware":"0b30bf36-dcbe-41b2-aa14-ccfc068a33d5:opentrons/axygen_96_wellplate_500ul/2","dispense_mix_checkbox":false,"dispense_mix_times":"","dispense_mix_volume":"","dispense_mmFromBottom":1,"dispense_position_reference":"well-bottom","dispense_retract_delay_seconds":"0","dispense_retract_mmFromBottom":2,"dispense_retract_speed":"50","dispense_retract_x_position":0,"dispense_retract_y_position":0,"dispense_retract_position_reference":"well-top","dispense_submerge_delay_seconds":"0","dispense_submerge_speed":"100","dispense_submerge_mmFromBottom":2,"dispense_submerge_x_position":0,"dispense_submerge_y_position":0,"dispense_submerge_position_reference":"well-top","dispense_touchTip_checkbox":false,"dispense_touchTip_mmFromTop":-1,"dispense_touchTip_speed":"30","dispense_touchTip_mmFromEdge":"0.5","dispense_wellOrder_first":"t2b","dispense_wellOrder_second":"l2r","dispense_wells":["A3"],"dispense_x_position":0,"dispense_y_position":0,"disposalVolume_checkbox":false,"disposalVolume_volume":"","dropTip_location":"opentrons/opentrons_flex_96_filtertiprack_1000ul/1","liquidClassesSupported":true,"liquidClass":"none","nozzles":null,"path":"single","pipette":"ca4f1dac-7b41-4206-a4ad-1da7f9a18f4f","preWetTip":false,"pushOut_checkbox":true,"pushOut_volume":"20","tipRack":"opentrons/opentrons_flex_96_filtertiprack_1000ul/1","tip_tracking":"automatic","tiprack_selected":null,"tips_selected":[],"volume":"100"}},"orderedStepIds":["e8c21882-6283-426a-9cc3-1128bb9a3104"],"pipettes":{"ca4f1dac-7b41-4206-a4ad-1da7f9a18f4f":{"pipetteName":"p1000_single_flex"}},"modules":{"4559a1ef-15b8-4119-8aa0-f18c48c4dfd9:thermocyclerModuleType":{"model":"thermocyclerModuleV2"},"b0fd399e-1ee4-466a-8adb-ce5db9e7bebb:heaterShakerModuleType":{"model":"heaterShakerModuleV1"},"6e93abc5-d16f-462f-8bed-af0117bef02e:temperatureModuleType":{"model":"temperatureModuleV2"}},"labware":{"c1d12d76-b77c-47f2-b49e-486785f00339:opentrons/opentrons_flex_96_filtertiprack_1000ul/1":{"displayName":"Opentrons Flex 96 Filter Tip Rack 1000 µL","labwareDefURI":"opentrons/opentrons_flex_96_filtertiprack_1000ul/1"},"8c3f08d6-774b-4ae0-8951-c05028d7d49a:opentrons/opentrons_flex_96_filtertiprack_200ul/1":{"displayName":"Opentrons Flex 96 Filter Tip Rack 200 µL","labwareDefURI":"opentrons/opentrons_flex_96_filtertiprack_200ul/1"},"d17463d2-5771-4559-9828-19f2fb50bdab:opentrons/opentrons_flex_96_filtertiprack_50ul/1":{"displayName":"Opentrons Flex 96 Filter Tip Rack 50 µL","labwareDefURI":"opentrons/opentrons_flex_96_filtertiprack_50ul/1"},"0b30bf36-dcbe-41b2-aa14-ccfc068a33d5:opentrons/axygen_96_wellplate_500ul/2":{"displayName":"Axygen 96 Well Plate 500 µL","labwareDefURI":"opentrons/axygen_96_wellplate_500ul/2"}}}},"metadata":{"protocolName":"Protocol Onboarding Demonstration","author":"","description":"","source":"Protocol Designer","created":1762551205229,"lastModified":1762551258540}}""" # noqa: E501 diff --git a/e2e-testing/tests/pd/test_pd_smoke_test_flex.py b/e2e-testing/tests/pd/test_pd_smoke_test_flex.py new file mode 100644 index 00000000000..f6e4038340a --- /dev/null +++ b/e2e-testing/tests/pd/test_pd_smoke_test_flex.py @@ -0,0 +1,97 @@ +"""Combined smoke test: onboarding -> transfer -> temp -> pause -> heater-shaker -> thermocycler. + +This test stitches together the common onboarding flow from `test_pd_sanity.py` +and exercises module step forms using page objects where available. + +Notes: +- Uses page objects under `automation.pd_pages` for most interactions. +- For thermocycler profile programming and heater-shaker timer we use + a few direct Playwright locators because the POM provides only basic helpers. +""" + +import sys +from pathlib import Path + +import pytest +from playwright.sync_api import Page, expect + +from automation.pd_pages.heater_shaker_step_form_page import _add_heater_shaker_step +from automation.pd_pages.tc_step_form_page import _add_thermocycler_profile_step, _add_thermocycler_state_step +from automation.pd_pages.tempdeck_step_form_page import _add_temperature_module_step + +# Make the automation package importable in tests (same pattern as other tests) +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from automation.pd_pages import ( + LandingPage, + ProtocolEditorPage, +) + + +@pytest.mark.pdE2E +@pytest.mark.slow +def import_protocol_onboarding_flow(page: Page, base_url: str) -> ProtocolEditorPage: + """Import test_pd_sanity.py and return the editor page object.""" + landing_page = LandingPage(page) + landing_page.goto(base_url) + landing_page.wait_for_page_load() + print("✓ Main page loaded") + + landing_page.confirm_welcome_modal() + + landing_page.click_import_existing_protocol() + + print("✓ Protocol creation initiated") + landing_page.upload_protocol_file("fixtures/protocol/9/smoke_flex_setup.py") + print("✓ Protocol file uploaded") + + expect(page.get_by_text("Protocol Metadata")).to_be_visible(timeout=10000) + + page.get_by_role("button", name="Edit protocol").click() + + return ProtocolEditorPage(page) + + +@pytest.mark.pdE2E +@pytest.mark.slow +def test_pd_combined_smoke_flow(page: Page, base_url: str) -> None: + """Run a compact smoke flow that covers the requested module interactions. + + Steps: + 1. Onboarding (create protocol, choose pipette, enable modules) + 2. Add Temperature step (50°C) and add a Pause step after it + 3. Add Heater-Shaker step (50°C, 300 rpm) with a 00:30 timer + 4. Add Thermocycler step in STATE mode (Block 40°C, Lid 110°C, Lid OPEN) + 5. Add Thermocycler step in PROFILE mode with cycle definition + + If any Playwright action or assertion fails, the test will pause for debugging. + """ + + editor = import_protocol_onboarding_flow(page, base_url) + print("✓ File uploaded, ready for module steps") + editor.add_step("Temperature") + _add_temperature_module_step(page, "50") + editor.add_step("Heater-Shaker") + _add_heater_shaker_step(page, "50", "300", "00:30") + print("✓ Heater-Shaker step: 50°C, 300 rpm, timer 00:30") + editor.add_step("Thermocycler") + _add_thermocycler_state_step(page, block_temp="40", lid_temp="110", lid_position="open") + editor.add_step("Thermocycler") + _add_thermocycler_profile_step( + page, + well_volume="100", + lid_temp="50", + cycles=[ + { + "repeat_count": "2", + "steps": [ + {"name": "Cycle 1", "temperature": "40", "time": "1:00"}, + {"name": "Cycle 2", "temperature": "4", "time": "0:01"}, + ], + } + ], + ) + + expect(page.get_by_role("button", name="Export")).to_be_visible(timeout=10000) + + print("✅ Combined smoke flow completed successfully") diff --git a/e2e-testing/utility.py b/e2e-testing/utility.py new file mode 100644 index 00000000000..dc4691b6032 --- /dev/null +++ b/e2e-testing/utility.py @@ -0,0 +1,51 @@ +import functools + +from playwright.sync_api import Error as PlaywrightError +from playwright.sync_api import Page, TimeoutError + + +def _find_page_in_args(*args, **kwargs) -> Page | None: + """Helper function to find the Playwright Page object in function arguments.""" + # Check positional arguments + for arg in args: + if isinstance(arg, Page): + return arg + # Check keyword arguments + for val in kwargs.values(): + if isinstance(val, Page): + return val + return None + + +def troubleshoot_and_pause(func): + """ + A decorator that wraps a function in a try...except block. + + On failure, it prints the error, attempts to find the Playwright + 'page' object to call 'page.pause()' for debugging, and then re-raises + the exception. + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + # Execute the decorated function + return func(*args, **kwargs) + except (AssertionError, TimeoutError, PlaywrightError, Exception) as e: + print(f"\n🛑 Test '{func.__name__}' failed due to: {type(e).__name__} - {e}") + print("Pausing execution for debugging...") + + # Try to find the page object to pause + page = _find_page_in_args(*args, **kwargs) + + if page: + page.pause() + else: + print("⚠️ Could not find 'page' object in arguments to pause.") + print(" You can still debug the console state.") + # As a fallback, you could use pdb to pause the script itself + # import pdb; pdb.set_trace() + + raise # Re-raise the exception after pausing + + return wrapper diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index e2664daa1d4..de56f674fd3 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -2,6 +2,7 @@ import asyncio import atexit import logging +import struct from dataclasses import dataclass from datetime import datetime @@ -19,11 +20,18 @@ Union, cast, Sequence, + Any, ) from opentrons_hardware.drivers.can_bus import DriverSettings, build, CanMessenger from opentrons_hardware.drivers.can_bus import settings as can_bus_settings from opentrons_hardware.firmware_bindings.constants import SensorId from opentrons_hardware.sensors import sensor_driver, sensor_types +from opentrons_hardware.drivers.eeprom.types import ( + PropType, + MAX_DATA_LEN, + EEPROMData, + FORMAT_VERSION, +) from opentrons_shared_data.deck import load as load_deck from opentrons_shared_data.labware import load_definition as load_labware @@ -1204,3 +1212,110 @@ def _ul_per_mm_of_shaft_diameter(diameter: float) -> float: assert pip.ul_per_mm(pip.working_volume, "aspirate") == pip_nominal_ul_per_mm assert pip.ul_per_mm(1, "dispense") == pip_nominal_ul_per_mm assert pip.ul_per_mm(pip.working_volume, "dispense") == pip_nominal_ul_per_mm + + +class DirectPropId(Enum): + """The hardware-testing equivalent of a unique property id for a property.""" + + INVALID = 0xFF + FORMAT_VERSION = 1 + SERIAL_NUMBER = 2 + SKU = 3 + + +DIRECT_PROP_ID_TYPES = { + DirectPropId.FORMAT_VERSION: PropType.BYTE, + DirectPropId.SERIAL_NUMBER: PropType.STR, + DirectPropId.SKU: PropType.STR, +} + + +def _generate_packet(prop_id: DirectPropId, value: Any) -> Optional[bytes]: + data = _encode_data(prop_id, value) + if data and len(data) <= MAX_DATA_LEN: + return struct.pack("!BB", prop_id.value, len(data)) + data + return None + + +def _encode_data(prop_id: DirectPropId, value: Any) -> Optional[bytes]: + if prop_id == DirectPropId.INVALID: + return None + encoded_data: bytes = b"" + try: + prop_id = DirectPropId(prop_id) + data_type = DIRECT_PROP_ID_TYPES[prop_id] + if data_type == PropType.BYTE: + encoded_data = struct.pack("!B", value) + elif data_type == PropType.CHAR: + encoded_data = struct.pack("!B", ord(value)) + elif data_type == PropType.SHORT: + encoded_data = struct.pack("!h", value) + elif data_type == PropType.INT: + encoded_data = struct.pack("!i", value) + elif data_type == PropType.STR: + encoded_data = f"{value}".encode("utf-8") + elif data_type == PropType.BIN: + encoded_data = bytes(value) + return encoded_data + except (ValueError, TypeError, struct.error): + return None + + +@dataclass +class DirectEEPROMData: + """Hardware testing equivalent of dataclass that represents the serialized data from the eeprom.""" + + format_version: int = FORMAT_VERSION + serial_number: Optional[str] = None + machine_type: Optional[str] = None + machine_version: Optional[str] = None + programmed_date: Optional[datetime] = None + unit_number: Optional[int] = None + sku: Optional[str] = None + + def to_set(self) -> set[tuple[DirectPropId, str | int]]: + """Hardware testing equivalent of an eeprom utility that returns a set of expected data values paired with a property id.""" + eeprom_set: set[tuple[DirectPropId, str | int]] = set() + eeprom_set.add((DirectPropId.FORMAT_VERSION, self.format_version)) + if self.serial_number: + eeprom_set.add((DirectPropId.SERIAL_NUMBER, self.serial_number)) + if self.sku: + eeprom_set.add((DirectPropId.SKU, self.sku)) + return eeprom_set + + +def direct_property_write( + api: OT3API, properties: set[tuple[DirectPropId, str | int]] +) -> set[DirectPropId]: + """Hardware testing equivalent of the eeprom property write. Write the given properties to the eeprom, returning a set of the successful ones.""" + written_props: set[DirectPropId] = set() + # sort the properties so they are written in ascending order + properties = set(sorted(properties, key=lambda prop: prop[0].value)) + data: bytes = b"" + for prop_id, value in properties: + packet = _generate_packet(prop_id, value) + if packet: + written_props.add(prop_id) + data += packet + if data: + try: + api._backend.eeprom_driver._gpio.activate_eeprom_wp() # type: ignore + api._backend.eeprom_driver._write(data) # type: ignore + except RuntimeError: + # something went wrong, clear written props + written_props = set() + finally: + api._backend.eeprom_driver._gpio.deactivate_eeprom_wp() # type: ignore + return written_props + + +def direct_eeprom_data(data: EEPROMData) -> DirectEEPROMData: + """Returns the hardware testing equivalent of the eeprom data return.""" + return DirectEEPROMData( + format_version=data.format_version, + serial_number=data.serial_number, + machine_type=data.machine_type, + programmed_date=data.programmed_date, + unit_number=data.unit_number, + sku=getattr(data, "sku", None), + ) diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/__main__.py index 7663bc4d36c..80c992279b1 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/__main__.py @@ -31,11 +31,18 @@ async def _main(cfg: TestConfig) -> None: # CSV REPORT report = build_report(test_name) helpers_ot3.set_csv_report_meta_data_ot3(api, report) + sku_code: str | None = None + if cfg.use_sku: + sku_code = input("SCAN device SKU barcode: ").strip() + print(f"SKU barcode: {sku_code}") # RUN TESTS for section, test_run in cfg.tests.items(): ui.print_title(section.value) - await test_run(api, report, section.value) + if section == TestSection.PERIPHERALS: + await test_run(api, report, section.value, sku_code) + else: + await test_run(api, report, section.value) # SAVE REPORT ui.print_title("DONE") @@ -46,6 +53,7 @@ async def _main(cfg: TestConfig) -> None: if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--simulate", action="store_true") + parser.add_argument("--sku", action="store_true") # add each test-section as a skippable argument (eg: --skip-gantry) for s in TestSection: parser.add_argument(f"--skip-{s.value.lower()}", action="store_true") @@ -60,5 +68,5 @@ async def _main(cfg: TestConfig) -> None: _t_sections = { s: f for s, f in TESTS if not getattr(args, f"skip_{s.value.lower()}") } - _config = TestConfig(simulate=args.simulate, tests=_t_sections) + _config = TestConfig(simulate=args.simulate, use_sku=args.sku, tests=_t_sections) # type: ignore asyncio.run(_main(_config)) diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/config.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/config.py index a33abef328a..e0aa33528b5 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/config.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/config.py @@ -29,6 +29,7 @@ class TestConfig: """Test Config.""" simulate: bool + use_sku: bool tests: Dict[TestSection, Callable] diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_peripherals.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_peripherals.py index 8e19485fc2a..ad9838afb98 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_peripherals.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_peripherals.py @@ -1,5 +1,6 @@ """Test Peripherals.""" import asyncio +import os from pathlib import Path from subprocess import run as run_subprocess, Popen, CalledProcessError from typing import List, Union, Optional, Dict @@ -21,6 +22,11 @@ CSVLineRepeating, ) from hardware_testing.data import ui +from hardware_testing.opentrons_api.helpers_ot3 import ( + direct_property_write, + direct_eeprom_data, + DirectPropId, +) SERVER_PORT = 8083 SERVER_CMD = "{0} -m http.server {1} --directory {2}" @@ -28,10 +34,12 @@ CAM_PIC_FILE_NAME = "camera_{0}.jpg" CAM_CMD_OT3 = ( - "v4l2-ctl --device /dev/ot_system_camera --set-fmt-video=width=640,height=480,pixelformat=MJPG " + "v4l2-ctl --device /dev/video2 --set-fmt-video=width=640,height=480,pixelformat=MJPG " "--stream-mmap --stream-to={0} --stream-count=1" ) +FLEX_SKUS_NO_CAMERA = ["999-00279"] + COLOR_TO_STATE: Dict[str, Tuple[int, int, int, int]] = { "off": ( 0, @@ -173,7 +181,9 @@ def build_csv_lines() -> List[Union[CSVLine, CSVLineRepeating]]: ] -async def run(api: OT3API, report: CSVReport, section: str) -> None: +async def run( # noqa: C901 + api: OT3API, report: CSVReport, section: str, sku: str | None = None +) -> None: """Run.""" await api.set_lights(rails=True) await api.set_status_bar_state(StatusBarState.IDLE) @@ -244,12 +254,43 @@ def _get_user_confirmation(question: str) -> bool: # CAMERA ui.print_header("CAMERA") - try: - cam_pic_path = await _take_picture(api, report, section) - except Exception as e: - print(f"Take a picture failed with the following error: {e}") - if cam_pic_path: - await _run_image_check_server(api, report, section, cam_pic_path) - cam_pic_path.unlink() + if sku and sku in FLEX_SKUS_NO_CAMERA: + try: + # Assert there is no camera device at /dev/video2 to ensure it is removed + print("Verifying camera not attached.") + active_result = CSVResult.FAIL + assert not os.path.exists("/dev/video2") + active_result = CSVResult.PASS + # write the SKU to EEPROM to indicate that this is a Flex model with no Camera + print(f"Writing SKU {sku} to EEPROM.") + + # Query the existing EEPROM data and converted it to a hardware-testing handleable format + eeprom_data = api._backend.eeprom_data # type: ignore + converted_eeprom_data = direct_eeprom_data(eeprom_data) + + # Update the data set with the provided SKU and write it to the EEPROM + converted_eeprom_data.sku = sku + eeprom_set = converted_eeprom_data.to_set() + sku_result = direct_property_write(api=api, properties=eeprom_set) + + # Validate the SKU + assert DirectPropId.SKU in sku_result + + removed_result = CSVResult.PASS + except Exception as e: + print( + f"Confirming camera not attached failed with the following error: {e}" + ) + removed_result = CSVResult.FAIL + report(section, "camera-active", [active_result]) + report(section, "camera-image", [removed_result]) else: - print("skipping checking the image, because taking a picture failed") + try: + cam_pic_path = await _take_picture(api, report, section) + except Exception as e: + print(f"Take a picture failed with the following error: {e}") + if cam_pic_path: + await _run_image_check_server(api, report, section, cam_pic_path) + cam_pic_path.unlink() + else: + print("skipping checking the image, because taking a picture failed") diff --git a/hardware-testing/hardware_testing/protocols/labware_protocols/inner-well-geometry-creator.py b/hardware-testing/hardware_testing/protocols/labware_protocols/inner-well-geometry-creator.py index 034d3ff0451..0e6ba5b1fc8 100644 --- a/hardware-testing/hardware_testing/protocols/labware_protocols/inner-well-geometry-creator.py +++ b/hardware-testing/hardware_testing/protocols/labware_protocols/inner-well-geometry-creator.py @@ -9,7 +9,6 @@ ProtocolContext, ParameterContext, InstrumentContext, - Well, Labware, LiquidClass, SINGLE, @@ -23,12 +22,19 @@ import json from dataclasses import dataclass, field from collections import OrderedDict +import os +import time +import serial # type: ignore[import] ########################################### -# GLOBAL VARIABLES - START +# SET LABWARE HERE ########################################### -LABWARE = "example_labware" # change to desired labware +LABWARE = "example_labware" + +########################################### +# GLOBAL VARIABLES +########################################### RESERVOIR = "nest_1_reservoir_290ml" LIQUID_MOUNT = "right" @@ -41,13 +47,12 @@ SLOT_LABWARE = "D1" SLOT_RESERVOIR = "C1" SLOT_DIAL = "B2" -DIAL_PORT = None DIAL_PORT_NAME = "/dev/ttyUSB0" +DIAL = None DIAL_POS_WITHOUT_TIP: List[Optional[float]] = [None, None] +JUPYTER_DATA_DIR = "/var/lib/jupyter/notebooks/" RUN_ID = "" -FILE_NAME = "" -USER_DEFINED_VOLUMES = "" -CSV_SEPARATOR = "" +DATA_FILE_PATH = "" CSV_HEADER = [ "well", "step volume", @@ -58,18 +63,18 @@ "status", ] -########################################### -# GLOBAL VARIABLES - END -########################################### - metadata = { "protocolName": "inner-well-geometry-creator", "author": "hovan.ngo@opentrons.com", } requirements = {"robotType": "Flex", "apiLevel": "2.24"} +######################### +# Configuration and Data +######################### -@dataclass(frozen=True) + +@dataclass class SetupState: """Configure static data for the protocol.""" @@ -87,13 +92,26 @@ class SetupState: upper_bound: float min_step: float max_step: float - threshold: float delta_tolerance: float liquid_racks: list[Labware] liquid_mount: InstrumentContext liquid_tip: str ethanol: LiquidClass + def pick_up_tips(self) -> None: + """Pick up tips.""" + if not self.probe_pipette.has_tip: + self.probe_pipette.pick_up_tip() + if not self.liq_pipette.has_tip: + self.liq_pipette.pick_up_tip() + + def drop_tips(self) -> None: + """Drop tips.""" + if self.probe_pipette.has_tip: + self.probe_pipette.drop_tip() + if self.liq_pipette.has_tip: + self.liq_pipette.drop_tip() + @dataclass(frozen=True) class TrialResult: @@ -110,10 +128,10 @@ class TrialResult: @dataclass class TrialState: - """Data that changes per-trial.""" + """Data that changes per-trial / dispense.""" current_well: str = "none" - status: str = "pass" + status: str = "none" step_volume: float = 0.0 dispense_volume: float = 0.0 hdelta: float = 0.0 @@ -123,6 +141,114 @@ class TrialState: results: List[TrialResult] = field(default_factory=list) step: int = 0 + def get_height_of_liquid_in_well( + self, ctx: ProtocolContext, config: SetupState, source: bool = False + ) -> float: + """Get height of liquid in well.""" + + def extract_float(result: Union[float | SimulatedProbeResult]) -> float: + """Extract float.""" + if isinstance(result, SimulatedProbeResult): + return result.net_liquid_exchanged_after_probe + return float(result) + + if not ctx.is_simulating(): + if source: + return extract_float( + config.liq_pipette.measure_liquid_height(config.src["A1"]) + ) + return extract_float( + config.probe_pipette.measure_liquid_height( + config.labware[self.current_well] + ) + ) + else: + return 0.0 + + def get_liquid_height(self, ctx: ProtocolContext, config: SetupState) -> None: + """Probes the liquid height, corrects it for tip error, and stores it.""" + height = self.get_height_of_liquid_in_well(ctx, config) + if height > config.labware[self.current_well].depth: + raise ValueError("Measured height exceeded well depth.") + self.corrected_height = height + self.tip_z_error + self.corrected_heights.append(self.corrected_height) + + def get_alpha_for_height(self, config: SetupState) -> float: + """Return sensitivity factor depending on well size.""" + if config.max_volume >= 100000: + alpha = 0.3 + elif config.max_volume >= 5000: + alpha = 0.5 + elif config.max_volume >= 3000: + alpha = 0.7 + elif config.max_volume >= 350: + alpha = 0.8 + elif config.max_volume >= 90: + alpha = 1.0 + else: + alpha = 1.0 + return alpha + + # Proportional Controller + def calculate_step_volume(self, config: SetupState) -> None: + """Return a new step volume based on the hdelta error from target.""" + max_increase_multiplier = 2.0 # means at most double of previous step volume + max_decrease_multiplier = 0.5 # means at least half of previous step volume + alpha = self.get_alpha_for_height(config) + + if self.hdelta < config.lower_bound and self.hdelta > 0: + error = config.target_height - self.hdelta + new_volume = self.step_volume * min( + max_increase_multiplier, 1 + alpha * error + ) + elif self.hdelta > config.upper_bound: + error = self.hdelta - config.target_height + new_volume = self.step_volume * max( + max_decrease_multiplier, 1 - alpha * error + ) + else: + new_volume = self.step_volume + + self.step_volume = max(config.min_step, min(config.max_step, new_volume)) + # clamp step volume such that the total dispense does not exceed max volume + self.step_volume = min( + self.step_volume, config.max_volume - self.dispense_volume + ) + + def compute_height_delta(self) -> None: + """Calculates the change in liquid level.""" + self.hdelta = ( + self.corrected_heights[-1] - self.corrected_heights[-2] + if len(self.corrected_heights) > 1 + else 0.0 + ) + + def get_step_status(self, ctx: ProtocolContext, config: SetupState) -> str: + """Evaluate the step and assign status: pass, fail, final, or sim.""" + if ctx.is_simulating(): + self.status = "sim" + return self.status + if self.step == 0: + if 2.0 < self.hdelta < 3.0: + self.status = "pass" + else: + ctx.pause( + f"First dispense volume {config.first_dispense}uL too " + f"{'low' if self.hdelta < 2.0 else 'high'}. " + f"Height was {self.corrected_height}mm. Adjust and restart." + ) + self.status = "fail" + return self.status + # if 10uL away from max volume, then set final + if self.dispense_volume >= config.max_volume - 10.0: + self.status = "final" + return self.status + if config.lower_bound < self.hdelta < config.upper_bound: + self.status = "pass" + else: + self.status = "fail" + return self.status + def add_result(self) -> None: """Adds the current step's results to the results list.""" self.results.append( @@ -132,7 +258,7 @@ def add_result(self) -> None: dispense_volume=round(self.dispense_volume, 5), tip_z_error=round(self.tip_z_error, 5), height=round(self.corrected_height, 5), - hdelta=self.hdelta, + hdelta=round(self.hdelta, 5), status=self.status, ) ) @@ -142,13 +268,173 @@ def rollback_last_data_point(self) -> None: self.dispense_volume -= self.step_volume # rollback dispense volume self.corrected_heights.pop() # rollback corrected heights - def compute_hdelta(self) -> None: - """Calculates the change in liquid level.""" - self.hdelta = ( - self.corrected_heights[-1] - self.corrected_heights[-2] - if len(self.corrected_heights) > 1 - else 0.0 + +class Data: + """Class for data manipulation methods.""" + + @staticmethod + def write_line_to_csv( + ctx: ProtocolContext, file_path: str, line: list[str] + ) -> None: + """Writes a formatted line to a designated path.""" + if not ctx.is_simulating(): + formatted = [str(item).ljust(18) for item in line] + with open(file_path, "a") as f: + f.write(",".join(formatted) + "\n") + + @staticmethod + def create_file_name( + test_name: str, run_id: str, tag: str, extension: str = "csv" + ) -> str: + """Create a file name, given a test name.""" + return f"{test_name}_{run_id}_{tag}.{extension}" + + @staticmethod + def create_run_id() -> str: + """Create a run ID using the datetime string.""" + date_time_string = time.strftime("%y-%m-%d-%H-%M-%S", time.localtime()) + return f"run-{date_time_string}" + + @staticmethod + def write_trial_log(ctx: ProtocolContext, trial: TrialState) -> None: + """Writes the current step's results to the CSV.""" + trial.add_result() + trial_data = trial.results[-1] + Data.write_line_to_csv( + ctx, DATA_FILE_PATH, [str(v) for v in vars(trial_data).values()] + ) + + +class Mitutoyo_Digimatic_Indicator: + """Driver class to use dial indicator.""" + + def __init__(self, port: str = "/dev/ttyUSB0", baudrate: int = 9600) -> None: + """Initialize class.""" + self.PORT = port + self.BAUDRATE = baudrate + self.TIMEOUT = 0.1 + self.error_count = 0 + self.max_errors = 100 + self.unlimited_errors = False + self.raise_exceptions = True + self.reading_raw = "" + self.GCODE = { + "READ": "r", + } + self.gauge: serial.Serial | None = None + self.packet: str = "" + + def connect(self) -> None: + """Connect communication portrial.""" + try: + self.gauge = serial.Serial( + port=self.PORT, + baudrate=self.BAUDRATE, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + bytesize=serial.EIGHTBITS, + timeout=self.TIMEOUT, + ) + except serial.SerialException: + error = "Unable to access Serial port" + raise serial.SerialException(error) + + def disconnect(self) -> None: + """Disconnect communication portrial.""" + if self.gauge is not None: + self.gauge.close() + + def send_packet(self, packet: str) -> None: + """Sends GCODE packet to dial indicator.""" + if self.gauge is not None: + self.gauge.flush() + self.gauge.reset_input_buffer() + self.gauge.write(packet.encode()) + + def get_packet(self) -> str: + """Gets GCODE packet from dial indicator.""" + packet = "" + if self.gauge is not None: + self.gauge.reset_output_buffer() + packet = self.gauge.readline().decode("utf-8") + return packet + + def read(self) -> float: + """Reads dial indicator.""" + self.packet = self.GCODE["READ"] + self.send_packet(self.packet) + time.sleep(0.001) + reading = True + value = 0.0 # Initialize value to avoid unbound error + while reading: + data = self.get_packet() + time.sleep(0.01) + if data != "": + try: + value = float(data) + reading = False + except ValueError: + continue + return value + + def read_dial_indicator( + self, + ctx: ProtocolContext, + pipette: InstrumentContext, + dial: Labware, + front_channel: bool = False, + ) -> float: + """Reads the dial indicator value.""" + target = dial["A1"].top() + if front_channel: + target = target.move(Point(y=9 * 7)) + if pipette.channels == 96: + target = target.move(Point(x=9 * -11)) + pipette.move_to(target.move(Point(z=5))) + pipette.move_to(target) + ctx.delay(seconds=2) + if ctx.is_simulating(): + return 0.0 + dial_value = self.read() + pipette.move_to(target.move(Point(z=5))) + return dial_value + + def store_dial_baseline( + self, + ctx: ProtocolContext, + pipette: InstrumentContext, + dial: Labware, + front_channel: bool = False, + ) -> None: + """Stores the dial indicator baseline value without a tip.""" + idx = 0 if not front_channel else 1 + if DIAL_POS_WITHOUT_TIP[idx] is not None: + return + DIAL_POS_WITHOUT_TIP[idx] = self.read_dial_indicator( + ctx, pipette, dial, front_channel ) + Data.write_line_to_csv( + ctx, DATA_FILE_PATH, [f"DIALBASELINE{idx}", str(DIAL_POS_WITHOUT_TIP[idx])] + ) + + def get_tip_z_error( + self, + ctx: ProtocolContext, + pipette: InstrumentContext, + dial: Labware, + front_channel: bool = False, + ) -> float: + """Calculates the tip overlap error from dial indicator reading.""" + idx = 0 if not front_channel else 1 + baseline = DIAL_POS_WITHOUT_TIP[idx] + assert baseline is not None + new_val = self.read_dial_indicator(ctx, pipette, dial, front_channel) + return (new_val - baseline) * -1.0 + + +###################### +# End of classes +###################### def add_parameters(parameters: ParameterContext) -> None: @@ -162,7 +448,6 @@ def add_parameters(parameters: ParameterContext) -> None: {"display_name": "1ch 1000ul", "value": "flex_1channel_1000"}, {"display_name": "8ch 50ul", "value": "flex_8channel_50"}, {"display_name": "8ch 1000ul", "value": "flex_8channel_1000"}, - {"display_name": "None", "value": "none"}, ], default="flex_1channel_50", ) @@ -176,7 +461,6 @@ def add_parameters(parameters: ParameterContext) -> None: {"display_name": "1ch 1000ul", "value": "flex_1channel_1000"}, {"display_name": "8ch 50ul", "value": "flex_8channel_50"}, {"display_name": "8ch 1000ul", "value": "flex_8channel_1000"}, - {"display_name": "None", "value": "none"}, ], default="flex_1channel_1000", ) @@ -219,7 +503,7 @@ def add_parameters(parameters: ParameterContext) -> None: def _setup(ctx: ProtocolContext) -> SetupState: """Sets up the static data for the protocol.""" - global DIAL_PORT, RUN_ID, FILE_NAME, LABWARE + global RUN_ID, LABWARE, DATA_FILE_PATH labware_type = LABWARE first_dispense = ctx.params.first_dispense # type: ignore[attr-defined] @@ -261,12 +545,9 @@ def _setup(ctx: ProtocolContext) -> SetupState: max_volume = labware["A1"].max_volume dial: Optional[Labware] = None - if dial_indicator_used: + if dial_indicator_used and not ctx.is_simulating(): dial = ctx.load_labware("dial_indicator", SLOT_DIAL) - # uses a slightly lower sensitivity value underneath this height threshold - threshold = 4.5 - # if within these bounds, then feedback loop doesnt make any changes delta_tolerance = 0.2 lower_bound = target_height - delta_tolerance @@ -282,26 +563,26 @@ def _setup(ctx: ProtocolContext) -> SetupState: ethanol = ctx.get_liquid_class(name="ethanol_80") if not ctx.is_simulating(): - from hardware_testing.data import create_file_name, create_run_id - - RUN_ID = create_run_id() - FILE_NAME = create_file_name(metadata["protocolName"], RUN_ID, labware_type) - _write_line_to_csv(ctx, [RUN_ID]) - _write_line_to_csv(ctx, [right_mount]) - _write_line_to_csv(ctx, [left_mount]) - _write_line_to_csv(ctx, [labware_type]) - _write_line_to_csv(ctx, ["target height", str(target_height)]) - _write_line_to_csv(ctx, ["depth", str(labware["A1"].depth)]) + RUN_ID = Data.create_run_id() + data_folder = os.path.join(JUPYTER_DATA_DIR, "IWG-data") # makes Data folder + data_file_name = Data.create_file_name( + metadata["protocolName"], RUN_ID, labware_type + ) + os.makedirs(data_folder, exist_ok=True) + DATA_FILE_PATH = os.path.join(data_folder, data_file_name) + + Data.write_line_to_csv(ctx, DATA_FILE_PATH, [RUN_ID]) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, [right_mount]) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, [left_mount]) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, [labware_type]) + Data.write_line_to_csv( + ctx, DATA_FILE_PATH, ["target height", str(target_height)] + ) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, ["depth", str(labware["A1"].depth)]) lpc = str(labware._core.get_calibrated_offset()) - _write_line_to_csv(ctx, ["LPC Offset", labware.load_name, lpc]) - - if dial is not None: - from hardware_testing.drivers.mitutoyo_digimatic_indicator import ( - Mitutoyo_Digimatic_Indicator, - ) - - DIAL_PORT = Mitutoyo_Digimatic_Indicator(port=DIAL_PORT_NAME) - DIAL_PORT.connect() + Data.write_line_to_csv( + ctx, DATA_FILE_PATH, ["LPC Offset", labware.load_name, lpc] + ) return SetupState( liq_pipette=liq_pipette, @@ -318,7 +599,6 @@ def _setup(ctx: ProtocolContext) -> SetupState: upper_bound=upper_bound, min_step=min_step, max_step=max_step, - threshold=threshold, delta_tolerance=delta_tolerance, liquid_racks=liquid_racks, liquid_mount=right_mount, @@ -327,101 +607,7 @@ def _setup(ctx: ProtocolContext) -> SetupState: ) -def _read_dial_indicator( - ctx: ProtocolContext, - pipette: InstrumentContext, - dial: Labware, - front_channel: bool = False, -) -> float: - target = dial["A1"].top() - if front_channel: - target = target.move(Point(y=9 * 7)) - if pipette.channels == 96: - target = target.move(Point(x=9 * -11)) - pipette.move_to(target.move(Point(z=5))) - pipette.move_to(target) - ctx.delay(seconds=2) - if ctx.is_simulating(): - return 0.0 - dial_port = DIAL_PORT.read() # type: ignore[union-attr] - pipette.move_to(target.move(Point(z=5))) - return dial_port - - -def _store_dial_baseline( - ctx: ProtocolContext, - pipette: InstrumentContext, - dial: Labware, - front_channel: bool = False, -) -> None: - global DIAL_POS_WITHOUT_TIP - idx = 0 if not front_channel else 1 - if DIAL_POS_WITHOUT_TIP[idx] is not None: - return - DIAL_POS_WITHOUT_TIP[idx] = _read_dial_indicator(ctx, pipette, dial, front_channel) - tag = f"DIALBASELINE{idx}" - _write_line_to_csv(ctx, [tag, str(DIAL_POS_WITHOUT_TIP[idx])]) - - -def _write_line_to_csv(ctx: ProtocolContext, line: List[str]) -> None: - if ctx.is_simulating(): - return - from hardware_testing.data import append_data_to_file - - formatted_line = [str(item).ljust(23) for item in line] - line_str = f"{CSV_SEPARATOR.join(formatted_line)}\n" - append_data_to_file(metadata["protocolName"], RUN_ID, FILE_NAME, line_str) - - -def _get_tip_z_error( - ctx: ProtocolContext, - pipette: InstrumentContext, - dial: Labware, - front_channel: bool = False, -) -> float: - idx = 0 if not front_channel else 1 - baseline = DIAL_POS_WITHOUT_TIP[idx] - assert baseline is not None - new_val = _read_dial_indicator(ctx, pipette, dial, front_channel) - return (new_val - baseline) * -1.0 - - -def pick_up_tips( - liq_pipette: InstrumentContext, probe_pipette: InstrumentContext -) -> None: - """Pick up tips.""" - if not probe_pipette.has_tip: - probe_pipette.pick_up_tip() - if not liq_pipette.has_tip: - liq_pipette.pick_up_tip() - - -def drop_tips(liq_pipette: InstrumentContext, probe_pipette: InstrumentContext) -> None: - """Drop tips.""" - if probe_pipette.has_tip: - probe_pipette.drop_tip() - if liq_pipette.has_tip: - liq_pipette.drop_tip() - - -def _get_height_of_liquid_in_well( - pipette: InstrumentContext, well: Well, simulating: bool -) -> float: - """Get height of liquid in well.""" - - def extract_float(result: Union[float | SimulatedProbeResult]) -> float: - """Extract float.""" - if isinstance(result, SimulatedProbeResult): - return result.net_liquid_exchanged_after_probe - return float(result) - - if not simulating: - return extract_float(pipette.measure_liquid_height(well)) - else: - return 0.01 - - -def generate_frusta(data: List, labware: Labware) -> dict: +def build_frusta(data: List, labware: Labware) -> dict: """Read geometry creator results and generate frustum dimensions for the IWG.""" inner_well_json = labware._core.get_definition() well_A1 = inner_well_json["wells"]["A1"] @@ -460,8 +646,8 @@ def generate_frusta(data: List, labware: Labware) -> dict: diameter = 2 * round(np.sqrt(delta_volume / (np.pi * delta_height)), 2) section = { "shape": "conical", - "bottomDiameter": diameter, "topDiameter": diameter, + "bottomDiameter": diameter, } section.update({"bottomHeight": round(h1, 2), "topHeight": round(h2, 2)}) frusta_data.append(section) @@ -517,220 +703,172 @@ def generate_frusta(data: List, labware: Labware) -> dict: return new_iwg -def get_dispense_props(state: SetupState, ts: TrialState) -> None: - """Assigns the liquid class properties for ethanol dispense.""" - if state.liquid_tip == "1000": - dispense_offset = ts.corrected_height + state.target_height + 10 - state.liq_pipette.flow_rate.blow_out = 200 +def build_IWG_definition( + ctx: ProtocolContext, config: SetupState, trial_results: List[TrialResult] +) -> None: + """Build inner well geometry definition for the labware.""" + if not ctx.is_simulating(): + passed_trials = [ + trial + for trial in trial_results + if trial.status in ("pass", "final", "none") + ] + frusta_data = np.array( + [(trial.dispense_volume, trial.height) for trial in passed_trials] + ) + if len(frusta_data) > 2: + new_inner_well_json = build_frusta(frusta_data.tolist(), config.labware) + if new_inner_well_json: + IWG_folder = os.path.join(JUPYTER_DATA_DIR, "IWG-definitions") + IWG_name = f"{RUN_ID}_{config.labware_type}.json" + os.makedirs(IWG_folder, exist_ok=True) + IWG_file_path = os.path.join(IWG_folder, IWG_name) + with open(IWG_file_path, "w") as f: + json.dump(new_inner_well_json, f, indent=2) + ctx.pause(f"Labware Definition file: {IWG_file_path}") + + +def get_transfer_props( + expected_liquid_level: float, volume: float, config: SetupState +) -> None: + """Assigns the liquid class properties for ethanol and get the height to dispense at.""" + # changes dispense offset based on the "expected" liquid level. + + if volume > 15: + dispense_offset = max(2.0, expected_liquid_level + 5) + else: + dispense_offset = max(2.0, expected_liquid_level + 0.1) + + # set pipette behavior based on tip size + if config.liquid_tip == "50": + blowout_rate = 100 + pushout_volume = 1.5 + flow_rate = 35 + elif config.liquid_tip == "1000": + blowout_rate = 716 + pushout_volume = 2.0 + flow_rate = 80 else: - dispense_offset = ts.corrected_height + state.target_height + 3 - state.liq_pipette.flow_rate.blow_out = 50 + raise ValueError("Invalid tip size.") wb = "well-bottom" lm = "liquid-meniscus" + wt = PositionReference.WELL_TOP meniscus_z = -0.5 - - for rack in state.liquid_racks: - ethanol_props = state.ethanol.get_for(state.liquid_mount, rack) + for rack in config.liquid_racks: + ethanol_props = config.ethanol.get_for(config.liquid_mount, rack) ethanol_props.aspirate.aspirate_position.position_reference = lm # type: ignore[assignment] ethanol_props.aspirate.aspirate_position.offset.z = meniscus_z ethanol_props.dispense.dispense_position.position_reference = wb # type: ignore[assignment] ethanol_props.dispense.dispense_position.offset.z = dispense_offset - ethanol_props.dispense.push_out_by_volume.set_for_all_volumes(5) - ethanol_props.dispense.flow_rate_by_volume.set_for_all_volumes(80) - ethanol_props.dispense.retract.blowout.flow_rate = ( - state.liq_pipette.flow_rate.blow_out - ) - ethanol_props.dispense.retract.end_position.position_reference = ( - PositionReference.WELL_TOP - ) - ethanol_props.dispense.retract.end_position.offset.z = 10 - ethanol_props.dispense.retract.blowout.enabled = ( - False # disable if causing bubbles - ) - - -def get_alpha_for_height(state: SetupState, ts: TrialState) -> float: - """Return adaptive proportional factor depending on well size & current height.""" - if state.max_volume >= 100000: - alpha_low, alpha_high = 0.2, 0.3 - if state.max_volume >= 5000: - alpha_low, alpha_high = 0.2, 0.35 - elif state.max_volume >= 3000: - alpha_low, alpha_high = 0.35, 0.6 - elif state.max_volume >= 350: - alpha_low, alpha_high = 0.5, 0.8 - elif state.max_volume >= 90: - alpha_low, alpha_high = 0.8, 1.0 - else: - alpha_low, alpha_high = 0.8, 1.0 - return alpha_low if ts.corrected_height < state.threshold else alpha_high - - -# Proportional Controller -def adaptive_volume_step(ts: TrialState, state: SetupState) -> float: - """Return a new step volume based on the hdelta error from target.""" - max_increase_multiplier = 2.0 # means at most double of previous step volume - max_decrease_multiplier = 0.5 # means at least half of previous step volume - - alpha = get_alpha_for_height(state, ts) - - if state.lower_bound <= ts.hdelta <= state.upper_bound: - return ts.step_volume - - elif ts.hdelta < state.lower_bound and ts.hdelta > 0: - error = state.target_height - ts.hdelta - new_volume = ts.step_volume * min(max_increase_multiplier, 1 + alpha * error) - - elif ts.hdelta > state.upper_bound: - error = ts.hdelta - state.target_height - new_volume = ts.step_volume * max(max_decrease_multiplier, 1 - alpha * error) - else: - new_volume = ts.step_volume - - new_volume = max(state.min_step, min(state.max_step, new_volume)) - - return new_volume - - -def write_trial_log(ctx: ProtocolContext, ts: TrialState) -> None: - """Writes the current step's results to the CSV.""" - ts.add_result() - trial_data = ts.results[-1] - _write_line_to_csv(ctx, [str(v) for v in vars(trial_data).values()]) - - -def check_hdelta(ctx: ProtocolContext, state: SetupState, ts: TrialState) -> str: - """Checks if the liquid level was successful in reaching the set target height.""" - if ctx.is_simulating(): - status = "sim" - else: - if ts.step == 0: - if 2.0 < ts.hdelta < 3.0: - status = "pass" - else: - ctx.pause( - f"First dispense volume {state.first_dispense}uL too " - f"{'low' if ts.hdelta < 2.0 else 'high'}. " - f"Height was {ts.corrected_height}mm. Adjust and restart." - ) - status = "fail" - else: - status = ( - "pass" if state.lower_bound < ts.hdelta < state.upper_bound else "fail" - ) - return status + ethanol_props.dispense.flow_rate_by_volume.set_for_all_volumes(flow_rate) + ethanol_props.dispense.submerge.speed = 50 + ethanol_props.dispense.retract.speed = 50 + ethanol_props.dispense.push_out_by_volume.set_for_all_volumes(pushout_volume) + ethanol_props.dispense.retract.blowout.flow_rate = blowout_rate + ethanol_props.dispense.retract.blowout.enabled = True # disable if bubbles + ethanol_props.dispense.retract.end_position.position_reference = wt + ethanol_props.dispense.retract.end_position.offset.z = 10.0 + ethanol_props.dispense.retract.delay.enabled = True + ethanol_props.dispense.retract.delay.duration = 3.0 + + +def prepare_transfer( + ctx: ProtocolContext, trial: TrialState, config: SetupState +) -> float: + """Compute and update the trial's dispense volume for the next transfer.""" + if trial.step == 0: + trial.step_volume = config.first_dispense + trial.get_height_of_liquid_in_well(ctx, config, source=True) + trial.dispense_volume += trial.step_volume + volume_per_channel = trial.dispense_volume / config.liq_pipette.active_channels + expected_liquid_level = trial.corrected_height + config.target_height + get_transfer_props(expected_liquid_level, volume_per_channel, config) + return volume_per_channel -# Inner Well Geometry Creator def geometry_creator( - ctx: ProtocolContext, state: SetupState, ts: TrialState + ctx: ProtocolContext, + config: SetupState, + trial: TrialState, + dial: Optional[Mitutoyo_Digimatic_Indicator], ) -> List[TrialResult]: - """Run liquid dispense + measure loop and return trial results.""" - if state.dial: - _store_dial_baseline(ctx, state.probe_pipette, state.dial) - _write_line_to_csv(ctx, CSV_HEADER) # log 0th step as a baseline - write_trial_log(ctx, ts) - state.liq_pipette.pick_up_tip() - _get_height_of_liquid_in_well( - state.liq_pipette, state.src["A1"], ctx.is_simulating() - ) - ts.step_volume = state.first_dispense - final_step = False + """Run liquid dispense + measure loop and return TrialResults.""" + if dial and config.dial: + dial.store_dial_baseline(ctx, config.probe_pipette, config.dial) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, CSV_HEADER) + Data.write_trial_log(ctx, trial) # log 0th step as baseline + + while trial.dispense_volume < config.max_volume: - while ts.dispense_volume < state.max_volume: + config.pick_up_tips() - ts.current_well = state.wells[ts.step % len(state.wells)] - drop_tips(state.liq_pipette, state.probe_pipette) + trial.current_well = config.wells[trial.step % len(config.wells)] # check if out of available wells - no_more_wells = ts.step > 0 and ts.step % len(state.wells) == 0 + no_more_wells = trial.step > 0 and trial.step % len(config.wells) == 0 if no_more_wells: ctx.pause("Dump the labware and replace it.") - _get_height_of_liquid_in_well( - state.liq_pipette, state.src["A1"], ctx.is_simulating() + trial.get_height_of_liquid_in_well(ctx, config, source=True) + + if dial and config.dial: + trial.tip_z_error = dial.get_tip_z_error( + ctx, config.probe_pipette, config.dial ) # prepare for dispense - ts.dispense_volume += ts.step_volume - volume_per_channel = ts.dispense_volume / state.liq_pipette.active_channels - get_dispense_props(state, ts) - - pick_up_tips(state.liq_pipette, state.probe_pipette) - if state.dial is not None: - ts.tip_z_error = _get_tip_z_error(ctx, state.probe_pipette, state.dial) - - state.liq_pipette.transfer_with_liquid_class( - liquid_class=state.ethanol, - volume=volume_per_channel, - source=state.src["A1"], - dest=state.labware[ts.current_well], + volume = prepare_transfer(ctx, trial, config) + + config.liq_pipette.transfer_with_liquid_class( + liquid_class=config.ethanol, + volume=volume, + source=config.src["A1"], + dest=config.labware[trial.current_well], new_tip="never", return_tip=False, ) # Precheck if there's clearance for touch tip - if ts.corrected_height + state.target_height <= state.labware["A1"].depth - 4: - state.liq_pipette.touch_tip() - - height = _get_height_of_liquid_in_well( - state.probe_pipette, state.labware[ts.current_well], ctx.is_simulating() - ) - ts.corrected_height = height + ts.tip_z_error - ts.corrected_heights.append(ts.corrected_height) - - # Compute change in height from last liquid probe - ts.compute_hdelta() - ts.status = check_hdelta(ctx, state, ts) - - if ts.status == "fail": - if ts.step == 0: + if ( + trial.corrected_height + config.target_height + <= config.labware["A1"].depth - 1.0 + ): + config.liq_pipette.touch_tip(v_offset=-0.5, speed=30) + + trial.get_liquid_height(ctx, config) + trial.compute_height_delta() + result = trial.get_step_status(ctx, config) + Data.write_trial_log(ctx, trial) + + match result: + case "fail": + if trial.step == 0: + break + trial.rollback_last_data_point() + case "final": break - elif final_step: - ts.status = "pass" - write_trial_log(ctx, ts) - else: - write_trial_log(ctx, ts) - ts.rollback_last_data_point() - elif ts.status == "pass": - write_trial_log(ctx, ts) + case "sim": + print("simulating") + case "pass": + pass + case _: + raise ValueError(f"Unknown status: '{result}'") - # recalculate step volume for next step - ts.step_volume = adaptive_volume_step(ts, state) + trial.step += 1 + trial.calculate_step_volume(config) + config.drop_tips() - # prevent overflow of well - if ts.step_volume + ts.dispense_volume > state.max_volume: - ts.step_volume = state.max_volume - ts.dispense_volume - final_step = True - - ts.step += 1 - - drop_tips(state.liq_pipette, state.probe_pipette) - return ts.results + return trial.results def run(ctx: ProtocolContext) -> None: """Run the protocol.""" - state = _setup(ctx) - ts = TrialState() - trial_results = geometry_creator(ctx, state, ts) - drop_tips(state.liq_pipette, state.probe_pipette) - - if not ctx.is_simulating(): - passed_trials = [trial for trial in trial_results if trial.status == "pass"] - frusta_data = np.array( - [(trial.dispense_volume, trial.height) for trial in passed_trials] - ) - if len(frusta_data) > 2: - new_inner_well_json = generate_frusta(frusta_data.tolist(), state.labware) - - from hardware_testing import data - - user_defined_volumes = data.create_folder_for_test_data("user-defined-volumes") - udv_def_name = f"{RUN_ID}_{state.labware_type}.json" - file_path = user_defined_volumes / udv_def_name - - with open(file_path, "w") as f: - json.dump(new_inner_well_json, f, indent=2) - - ctx.pause(f"Labware Definition file: {file_path}") + config = _setup(ctx) + trial = TrialState() + dial = Mitutoyo_Digimatic_Indicator() if config.dial else None + if dial: + dial.connect() + trial_results = geometry_creator(ctx, config, trial, dial) + build_IWG_definition(ctx, config, trial_results) + + config.drop_tips() diff --git a/hardware-testing/hardware_testing/protocols/labware_protocols/labware-iwg-validator.py b/hardware-testing/hardware_testing/protocols/labware_protocols/labware-iwg-validator.py index 5f0dcbff86d..433c66b2f8c 100644 --- a/hardware-testing/hardware_testing/protocols/labware_protocols/labware-iwg-validator.py +++ b/hardware-testing/hardware_testing/protocols/labware_protocols/labware-iwg-validator.py @@ -6,23 +6,31 @@ from opentrons.protocol_api import ( ProtocolContext, Labware, + LiquidClass, InstrumentContext, ParameterContext, - Well, SINGLE, ) from opentrons_shared_data.liquid_classes.liquid_class_definition import ( PositionReference, ) -from typing import List, Optional, Union, Tuple +from typing import List, Optional, Union from opentrons.types import Point from opentrons.protocol_engine.types.liquid_level_detection import SimulatedProbeResult +from dataclasses import dataclass, field +import os +import time +import serial # type: ignore[import] ########################################### -# GLOBAL VARIABLES - START +# SET LABWARE HERE ########################################### -LABWARE = "example_labware" # change to desired labware +LABWARE = "example_labware" + +########################################### +# GLOBAL VARIABLES +########################################### SLOT_LIQUID_TIPRACKS = ["D3", "B3"] SLOT_PROBING_TIPRACK = "D2" @@ -31,18 +39,299 @@ SLOT_DIAL = "B2" CSV_SEPARATOR = "" RUN_ID = "" -FILE_NAME = "" -DIAL_PORT = None +DATA_FILE_PATH = "" DIAL_PORT_NAME = "/dev/ttyUSB0" DIAL_POS_WITHOUT_TIP: List[Optional[float]] = [None, None] - -########################################### -# GLOBAL VARIABLES - END -########################################### +JUPYTER_DATA_DIR = "/var/lib/jupyter/notebooks/" +CSV_HEADER = [ + "Well", + "Volume (ul)", + "Height (mm)", + "Expected Height", + "Error %", + "Tip-Z-Error (mm)", +] metadata = {"protocolName": "volume-validator", "author": "hovan.ngo@opentrons.com"} requirements = {"robotType": "Flex", "apiLevel": "2.24"} +######################### +# Configuration and Data +######################### + + +@dataclass +class SetupState: + """Setup and configure the protocol.""" + + labware: Labware + src: Labware + dial: Optional[Labware] + probe_pipette: InstrumentContext + liq_pipette: InstrumentContext + liquid_racks: list[Labware] + right_mount: InstrumentContext + liq_tip_size: str + n_trials: int + labware_type: str + n_regions: int + wells_and_heights: List[tuple[str, float]] + ethanol: LiquidClass + + def pick_up_tips(self) -> None: + """Pick up tips.""" + if not self.probe_pipette.has_tip: + self.probe_pipette.pick_up_tip() + if not self.liq_pipette.has_tip: + self.liq_pipette.pick_up_tip() + + def drop_tips(self) -> None: + """Drop tips.""" + if self.probe_pipette.has_tip: + self.probe_pipette.drop_tip() + if self.liq_pipette.has_tip: + self.liq_pipette.drop_tip() + + +@dataclass(frozen=True) +class TrialResult: + """Snapshot result of each trial.""" + + current_well: str + expected_volume: float + measured_height: float + expected_height: float + accuracy: float + tip_z_error: float + + +@dataclass +class TrialState: + """Data that changes per-trial / dispense.""" + + current_well: str = "None" + expected_volume: float = 0.0 + expected_height: float = 0.0 + measured_height: float = 0.0 + tip_z_error: float = 0.0 + accuracy: float = 0.0 + results: List[TrialResult] = field(default_factory=list) + idx: int = 0 + + def get_height_of_liquid_in_well( + self, ctx: ProtocolContext, config: SetupState, source: bool = False + ) -> float: + """Get height of liquid in well.""" + + def extract_float(result: Union[float | SimulatedProbeResult]) -> float: + """Extract float.""" + if isinstance(result, SimulatedProbeResult): + return result.net_liquid_exchanged_after_probe + return float(result) + + if not ctx.is_simulating(): + if source: + return extract_float( + config.liq_pipette.measure_liquid_height(config.src["A1"]) + ) + return extract_float( + config.probe_pipette.measure_liquid_height( + config.labware[self.current_well] + ) + ) + else: + return 0.0 + + def add_result(self) -> None: + """Adds the current step's results to the results list.""" + self.results.append( + TrialResult( + current_well=self.current_well, + expected_volume=round(self.expected_volume, 5), + measured_height=round(self.measured_height, 5), + expected_height=round(self.expected_height, 5), + accuracy=round(self.accuracy, 5), + tip_z_error=round(self.tip_z_error, 5), + ) + ) + + +class Data: + """Class for data manipulation methods.""" + + @staticmethod + def write_line_to_csv( + ctx: ProtocolContext, file_path: str, line: list[str] + ) -> None: + """Writes a formatted line to a designated path.""" + if not ctx.is_simulating(): + formatted = [str(item).ljust(18) for item in line] + with open(file_path, "a") as f: + f.write(",".join(formatted) + "\n") + + @staticmethod + def create_file_name( + test_name: str, run_id: str, tag: str, extension: str = "csv" + ) -> str: + """Create a file name, given a test name.""" + return f"{test_name}_{run_id}_{tag}.{extension}" + + @staticmethod + def create_run_id() -> str: + """Create a run ID using the datetime string.""" + date_time_string = time.strftime("%y-%m-%d-%H-%M-%S", time.localtime()) + return f"run-{date_time_string}" + + @staticmethod + def extract_float(result: Union[float | SimulatedProbeResult]) -> float: + """Extract float.""" + if isinstance(result, SimulatedProbeResult): + return result.net_liquid_exchanged_after_probe + return float(result) + + @staticmethod + def write_trial_log(ctx: ProtocolContext, trial: TrialState) -> None: + """Writes the current step's results to the CSV.""" + trial.add_result() + trial_data = trial.results[-1] + Data.write_line_to_csv( + ctx, DATA_FILE_PATH, [str(v) for v in vars(trial_data).values()] + ) + + +class Mitutoyo_Digimatic_Indicator: + """Driver class to use dial indicator.""" + + def __init__(self, port: str = "/dev/ttyUSB0", baudrate: int = 9600) -> None: + """Initialize class.""" + self.PORT = port + self.BAUDRATE = baudrate + self.TIMEOUT = 0.1 + self.error_count = 0 + self.max_errors = 100 + self.unlimited_errors = False + self.raise_exceptions = True + self.reading_raw = "" + self.GCODE = { + "READ": "r", + } + self.gauge: serial.Serial | None = None + self.packet: str = "" + + def connect(self) -> None: + """Connect communication ports.""" + try: + self.gauge = serial.Serial( + port=self.PORT, + baudrate=self.BAUDRATE, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + bytesize=serial.EIGHTBITS, + timeout=self.TIMEOUT, + ) + except serial.SerialException: + error = "Unable to access Serial port" + raise serial.SerialException(error) + + def disconnect(self) -> None: + """Disconnect communication ports.""" + if self.gauge is not None: + self.gauge.close() + + def send_packet(self, packet: str) -> None: + """Sends GCODE packet to dial indicator.""" + if self.gauge is not None: + self.gauge.flush() + self.gauge.reset_input_buffer() + self.gauge.write(packet.encode()) + + def get_packet(self) -> str: + """Gets packet from dial indicator.""" + packet = "" + if self.gauge is not None: + self.gauge.reset_output_buffer() + packet = self.gauge.readline().decode("utf-8") + return packet + + def read(self) -> float: + """Reads dial indicator.""" + self.packet = self.GCODE["READ"] + self.send_packet(self.packet) + time.sleep(0.001) + reading = True + value = 0.0 # Initialize value to avoid unbound error + while reading: + data = self.get_packet() + time.sleep(0.01) + if data != "": + try: + value = float(data) + reading = False + except ValueError: + continue + return value + + def read_dial_indicator( + self, + ctx: ProtocolContext, + pipette: InstrumentContext, + dial: Labware, + front_channel: bool = False, + ) -> float: + """Reads dial indicator value.""" + target = dial["A1"].top() + if front_channel: + target = target.move(Point(y=9 * 7)) + if pipette.channels == 96: + target = target.move(Point(x=9 * -11)) + pipette.move_to(target.move(Point(z=5))) + pipette.move_to(target) + ctx.delay(seconds=2) + if ctx.is_simulating(): + return 0.0 + dial_port = self.read() # type: ignore[union-attr] + pipette.move_to(target.move(Point(z=5))) + return dial_port + + def store_dial_baseline( + self, + ctx: ProtocolContext, + pipette: InstrumentContext, + dial: Labware, + front_channel: bool = False, + ) -> None: + """Stores dial baseline value without tip.""" + global DIAL_POS_WITHOUT_TIP + idx = 0 if not front_channel else 1 + if DIAL_POS_WITHOUT_TIP[idx] is not None: + return + DIAL_POS_WITHOUT_TIP[idx] = self.read_dial_indicator( + ctx, pipette, dial, front_channel + ) + tag = f"DIAL-BASELINE-{idx}" + Data.write_line_to_csv( + ctx, DATA_FILE_PATH, [tag, str(DIAL_POS_WITHOUT_TIP[idx])] + ) + + def get_tip_z_error( + self, + ctx: ProtocolContext, + pipette: InstrumentContext, + dial: Labware, + front_channel: bool = False, + ) -> float: + """Gets tip Z error value.""" + idx = 0 if not front_channel else 1 + baseline = DIAL_POS_WITHOUT_TIP[idx] + assert baseline is not None + new_val = self.read_dial_indicator(ctx, pipette, dial, front_channel) + return (new_val - baseline) * -1.0 + + +###################### +# End of classes +###################### + def add_parameters(parameters: ParameterContext) -> None: """Add parameters.""" @@ -55,7 +344,6 @@ def add_parameters(parameters: ParameterContext) -> None: {"display_name": "1ch 1000ul", "value": "flex_1channel_1000"}, {"display_name": "8ch 50ul", "value": "flex_8channel_50"}, {"display_name": "8ch 1000ul", "value": "flex_8channel_1000"}, - {"display_name": "None", "value": "none"}, ], default="flex_1channel_50", ) @@ -69,7 +357,6 @@ def add_parameters(parameters: ParameterContext) -> None: {"display_name": "1ch 1000ul", "value": "flex_1channel_1000"}, {"display_name": "8ch 50ul", "value": "flex_8channel_50"}, {"display_name": "8ch 1000ul", "value": "flex_8channel_1000"}, - {"display_name": "None", "value": "none"}, ], default="flex_1channel_1000", ) @@ -77,16 +364,16 @@ def add_parameters(parameters: ParameterContext) -> None: parameters.add_int( variable_name="n_regions", display_name="Number of Regions", - description="Number of depth intervals to test. ", + description="How many discrete depth levels to test within each well.", default=3, minimum=2, maximum=20, ) parameters.add_int( - variable_name="number_of_trials", - display_name="trials per region", - description="Number of trials per region.", + variable_name="n_trials", + display_name="Trials Per Region", + description="Number of repeated measurements to perform at each depth region.", default=3, minimum=1, maximum=20, @@ -110,30 +397,15 @@ def add_parameters(parameters: ParameterContext) -> None: ) -def _setup( - ctx: ProtocolContext, -) -> Tuple[ - InstrumentContext, - InstrumentContext, - Labware, - Labware, - List[float], - Optional[Labware], - int, - str, - List[Labware], - InstrumentContext, - str, - int, - List[float], -]: - global DIAL_PORT, RUN_ID, FILE_NAME, LABWARE +def _setup(ctx: ProtocolContext) -> SetupState: + """Setup.""" + global RUN_ID, DATA_FILE_PATH, LABWARE left_mount = ctx.params.left_mount # type: ignore[attr-defined] right_mount = ctx.params.right_mount # type: ignore[attr-defined] liq_tip_size = ctx.params.liq_tip_size # type: ignore[attr-defined] n_regions = ctx.params.n_regions # type: ignore[attr-defined] - number_of_trials = ctx.params.number_of_trials # type: ignore[attr-defined] + n_trials = ctx.params.n_trials # type: ignore[attr-defined] dial_indicator_used = ctx.params.dial_indicator_used # type: ignore[attr-defined] labware_type = LABWARE @@ -142,12 +414,13 @@ def _setup( src = ctx.load_labware("nest_1_reservoir_290ml", SLOT_RESERVOIR) ethanol_liq = ctx.define_liquid("Ethanol", display_color="#FFFFC5") + ethanol = ctx.get_liquid_class(name="ethanol_80") src["A1"].load_liquid(ethanol_liq, src["A1"].max_volume - 1000) ctx.load_trash_bin("A3") - dial = ( - ctx.load_labware("dial_indicator", SLOT_DIAL) if dial_indicator_used else None - ) + dial: Optional[Labware] = None + if dial_indicator_used and not ctx.is_simulating(): + dial = ctx.load_labware("dial_indicator", SLOT_DIAL) liquid_racks = [ ctx.load_labware(f"opentrons_flex_96_tiprack_{liq_tip_size}ul", slot) @@ -163,326 +436,235 @@ def _setup( style=SINGLE, start="H1", tip_racks=liquid_racks ) - if not ctx.is_simulating(): - from hardware_testing.data import create_file_name, create_run_id - - RUN_ID = create_run_id() - FILE_NAME = create_file_name(metadata["protocolName"], RUN_ID, labware_type) - _write_line_to_csv(ctx, [RUN_ID]) - _write_line_to_csv(ctx, [labware_type]) - heading_for_csv = [ - "Well", - "Volume (ul)", - "Height (mm)", - "Expected Height", - "Error %", - ] - _write_line_to_csv(ctx, heading_for_csv) - - if dial and DIAL_PORT is None: - from hardware_testing.drivers.mitutoyo_digimatic_indicator import ( - Mitutoyo_Digimatic_Indicator, - ) - - DIAL_PORT = Mitutoyo_Digimatic_Indicator(port=DIAL_PORT_NAME) - DIAL_PORT.connect() + wells = list(labware.wells_by_name().keys()) # Calculate region heights max_vol = labware["A1"].max_volume - max_height = extract_float(labware["A1"].height_from_volume(max_vol)) + max_height = Data.extract_float(labware["A1"].height_from_volume(max_vol)) if n_regions == 3: # special case: 3.0mm, 50%, 100% region_heights = [3.0, max_height * 0.5, max_height] else: segment_vol = max_vol / float(n_regions) region_heights = [ - extract_float(labware["A1"].height_from_volume(segment_vol * (i + 1))) + Data.extract_float(labware["A1"].height_from_volume(segment_vol * (i + 1))) for i in range(n_regions) ] - expected_heights: List = [] - for height in region_heights: - expected_heights.extend([height] * number_of_trials) - - return ( - liq_pipette, - probe_pipette, - src, - labware, - expected_heights, - dial, - number_of_trials, - labware_type, - liquid_racks, - right_mount, - liq_tip_size, - n_regions, - region_heights, - ) + for region_height in region_heights: + expected_heights.extend([region_height] * n_trials) + # mapping expected heights to wells + wells_and_heights: list[tuple[str, float]] = [] + for i, height in enumerate(expected_heights): + well = wells[i % len(wells)] + wells_and_heights.append((well, height)) -def pick_up_tips( - probe_pipette: InstrumentContext, liq_pipette: InstrumentContext -) -> None: - """Pick up tips.""" - if not probe_pipette.has_tip: - probe_pipette.pick_up_tip() - if not liq_pipette.has_tip: - liq_pipette.pick_up_tip() + if not ctx.is_simulating(): + RUN_ID = Data.create_run_id() + data_folder = os.path.join(JUPYTER_DATA_DIR, "IWG-validation") + data_file_name = Data.create_file_name( + metadata["protocolName"], RUN_ID, labware_type + ) + os.makedirs(data_folder, exist_ok=True) + DATA_FILE_PATH = os.path.join(data_folder, data_file_name) + + Data.write_line_to_csv(ctx, DATA_FILE_PATH, [RUN_ID]) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, [labware_type]) + + return SetupState( + liq_pipette=liq_pipette, + probe_pipette=probe_pipette, + labware=labware, + labware_type=labware_type, + src=src, + dial=dial, + liquid_racks=liquid_racks, + liq_tip_size=liq_tip_size, + right_mount=liq_pipette, + n_trials=n_trials, + n_regions=n_regions, + wells_and_heights=wells_and_heights, + ethanol=ethanol, + ) -def drop_tips(probe_pipette: InstrumentContext, liq_pipette: InstrumentContext) -> None: - """Drop tips.""" - if probe_pipette.has_tip: - probe_pipette.drop_tip() - if liq_pipette.has_tip: - liq_pipette.drop_tip() +def get_transfer_props(volume: float, config: SetupState) -> None: + """Get aspirate/dispense properties for ethanol liquid class. You can change these.""" + # this tries to mitigate liquid forming on the tip for small dispenses. + if volume > 15: + dispense_offset = 5.0 + else: + dispense_offset = 0.1 + + # set pipette behavior based on tip size + if config.liq_tip_size == "50": + blowout_rate = 50 + pushout_volume = 1.5 + flow_rate = 35 + elif config.liq_tip_size == "1000": + blowout_rate = 716 + pushout_volume = 2.0 + flow_rate = 80 + else: + raise ValueError("Invalid tip size.") -def _store_dial_baseline( - ctx: ProtocolContext, - pipette: InstrumentContext, - dial: Labware, - front_channel: bool = False, -) -> None: - global DIAL_POS_WITHOUT_TIP - idx = 0 if not front_channel else 1 - if DIAL_POS_WITHOUT_TIP[idx] is not None: - return - DIAL_POS_WITHOUT_TIP[idx] = _read_dial_indicator(ctx, pipette, dial, front_channel) - tag = f"DIAL-BASELINE-{idx}" - _write_line_to_csv(ctx, [tag, str(DIAL_POS_WITHOUT_TIP[idx])]) - - -def _get_tip_z_error( + meniscus_z = -0.5 + lm = "liquid-meniscus" + wt = PositionReference.WELL_TOP + for rack in config.liquid_racks: + ethanol_props = config.ethanol.get_for(config.right_mount, rack) + ethanol_props.aspirate.aspirate_position.position_reference = lm # type: ignore[assignment] + ethanol_props.aspirate.aspirate_position.offset.z = meniscus_z + ethanol_props.dispense.dispense_position.position_reference = lm # type: ignore[assignment] + ethanol_props.dispense.dispense_position.offset.z = dispense_offset + ethanol_props.dispense.flow_rate_by_volume.set_for_all_volumes(flow_rate) + ethanol_props.dispense.submerge.speed = 50 + ethanol_props.dispense.retract.speed = 50 + ethanol_props.dispense.push_out_by_volume.set_for_all_volumes(pushout_volume) + ethanol_props.dispense.retract.blowout.flow_rate = blowout_rate + ethanol_props.dispense.retract.blowout.enabled = True + ethanol_props.dispense.retract.end_position.position_reference = wt + ethanol_props.dispense.retract.end_position.offset.z = 5 + ethanol_props.dispense.retract.delay.enabled = True + ethanol_props.dispense.retract.delay.duration = 3.0 + + +def prepare_transfer( ctx: ProtocolContext, - pipette: InstrumentContext, - dial: Labware, - front_channel: bool = False, + config: SetupState, + trial: TrialState, ) -> float: - idx = 0 if not front_channel else 1 - baseline = DIAL_POS_WITHOUT_TIP[idx] - assert baseline is not None - new_val = _read_dial_indicator(ctx, pipette, dial, front_channel) - return (new_val - baseline) * -1.0 - - -def _write_line_to_csv(ctx: ProtocolContext, line: List[str]) -> None: - if ctx.is_simulating(): - return - from hardware_testing.data import append_data_to_file - - formatted_line = [str(item).ljust(23) for item in line] - line_str = f"{CSV_SEPARATOR.join(formatted_line)}\n" - append_data_to_file(metadata["protocolName"], RUN_ID, FILE_NAME, line_str) - - -def extract_float(result: Union[float | SimulatedProbeResult]) -> float: - """Extract float.""" - if isinstance(result, SimulatedProbeResult): - return result.net_liquid_exchanged_after_probe - return float(result) - - -def _get_height_of_liquid_in_well( - pipette: InstrumentContext, well: Well, simulating: bool -) -> float: - """Get height of liquid in well.""" - if not simulating: - return extract_float(pipette.measure_liquid_height(well)) - else: - return 0.01 + """Prepare for transfer and return dispense volume.""" + if trial.idx == 0: + trial.get_height_of_liquid_in_well(ctx, config, source=True) + volume_per_channel = trial.expected_volume / config.liq_pipette.active_channels + get_transfer_props(volume_per_channel, config) + return volume_per_channel def aspirate_dispense_measure( ctx: ProtocolContext, - volumes_dict: dict, - labware: Labware, - src: Labware, - dial: Optional[Labware], - probe_pipette: InstrumentContext, - liq_pipette: InstrumentContext, - expected_heights: list[float], - liquid_racks: list[Labware], - right_mount: InstrumentContext, - liq_tip_size: str, + config: SetupState, + trial: TrialState, + dial: Optional[Mitutoyo_Digimatic_Indicator], ) -> list[float]: - """Aspirate from source, dispense into labware, measure height, and record results.""" - all_corrected_heights: list[float] = [] - num_wells = len(volumes_dict) - tip_z_error = 0.0 - meniscus_z = -0.5 - dispense_offset = 10 + """Aspirate from source, dispense into labware, returns list of measured heights obtained.""" + measured_heights: list[float] = [] + num_wells = len(config.labware.wells()) + + if dial and config.dial: + dial.store_dial_baseline(ctx, config.probe_pipette, config.dial) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, CSV_HEADER) + + for trial_idx, (well, expected_height) in enumerate(config.wells_and_heights): + config.pick_up_tips() - for trial, (well, vol_list) in enumerate(volumes_dict.items()): - expected_vol = float(vol_list[0]) - pick_up_tips(probe_pipette, liq_pipette) + trial.idx = trial_idx + trial.current_well = well + trial.expected_height = expected_height - no_more_wells = trial != 0 and trial % num_wells == 0 + no_more_wells = (trial_idx > 0) and (trial_idx % num_wells == 0) if no_more_wells: ctx.pause("Dump the labware and replace it.") - _get_height_of_liquid_in_well(liq_pipette, src["A1"], ctx.is_simulating()) - - if dial: - tip_z_error = _get_tip_z_error(ctx, probe_pipette, dial) - - dispense_vol = expected_vol / liq_pipette.active_channels - expected_height = expected_heights[trial] - - lm = "liquid-meniscus" - liq_pipette.flow_rate.blow_out = 200 if liq_tip_size == "1000" else 50 - ethanol = ctx.get_liquid_class(name="ethanol_80") - for rack in liquid_racks: - ethanol_props = ethanol.get_for(right_mount, rack) - ethanol_props.aspirate.aspirate_position.position_reference = lm # type: ignore[assignment] - ethanol_props.aspirate.aspirate_position.offset.z = meniscus_z - ethanol_props.dispense.dispense_position.position_reference = lm # type: ignore[assignment] - ethanol_props.dispense.dispense_position.offset.z = dispense_offset - ethanol_props.dispense.flow_rate_by_volume.set_for_all_volumes(50) - ethanol_props.dispense.submerge.speed = 50 - ethanol_props.dispense.retract.speed = 50 - ethanol_props.dispense.push_out_by_volume.set_for_all_volumes(5) - ethanol_props.dispense.retract.blowout.flow_rate = ( - liq_pipette.flow_rate.blow_out - ) - ethanol_props.dispense.retract.blowout.enabled = False - ethanol_props.dispense.retract.end_position.position_reference = ( - PositionReference.WELL_TOP + trial.get_height_of_liquid_in_well(ctx, config, source=True) + + if dial and config.dial: + trial.tip_z_error = dial.get_tip_z_error( + ctx, config.probe_pipette, config.dial ) - ethanol_props.dispense.retract.end_position.offset.z = 10 - liq_pipette.transfer_with_liquid_class( - liquid_class=ethanol, - volume=dispense_vol, - source=src["A1"], - dest=labware[well], + trial.expected_volume = Data.extract_float( + config.labware[trial.current_well].volume_from_height(expected_height) + ) + dispense_volume = prepare_transfer(ctx, config, trial) + + config.liq_pipette.transfer_with_liquid_class( + liquid_class=config.ethanol, + volume=dispense_volume, + source=config.src["A1"], + dest=config.labware[trial.current_well], new_tip="never", return_tip=False, ) # Touch tip if clearance allows - if expected_height <= labware["A1"].depth - 4: - liq_pipette.touch_tip() + if trial.expected_height <= config.labware["A1"].depth - 1.0: + config.liq_pipette.touch_tip(v_offset=-0.5, speed=30) - height = _get_height_of_liquid_in_well( - probe_pipette, labware[well], ctx.is_simulating() - ) - corrected_height = height + tip_z_error - all_corrected_heights.append(corrected_height) + height = trial.get_height_of_liquid_in_well(ctx, config) + trial.measured_height = height + trial.tip_z_error + measured_heights.append(trial.measured_height) - acc = (corrected_height - expected_height) / expected_height * 100 - line_for_csv = [well, expected_vol, corrected_height, expected_height, acc] - _write_line_to_csv(ctx, line_for_csv) + trial.accuracy = ( + (trial.measured_height - expected_height) / expected_height * 100 + ) + Data.write_trial_log(ctx, trial) # log 0th step as baseline - drop_tips(probe_pipette, liq_pipette) + config.drop_tips() - return all_corrected_heights + return measured_heights -def _read_dial_indicator( +def build_csv_results( ctx: ProtocolContext, - pipette: InstrumentContext, - dial: Labware, - front_channel: bool = False, -) -> float: - target = dial["A1"].top() - if front_channel: - target = target.move(Point(y=9 * 7)) - if pipette.channels == 96: - target = target.move(Point(x=9 * -11)) - pipette.move_to(target.move(Point(z=5))) - pipette.move_to(target) - ctx.delay(seconds=2) - if ctx.is_simulating(): - return 0.0 - dial_port = DIAL_PORT.read() # type: ignore[union-attr] - pipette.move_to(target.move(Point(z=5))) - return dial_port + config: SetupState, + measured_heights: list[float], +) -> tuple[str, str]: + """Build CSV results to map measured heights to expected heights in each well region.""" + csv_values: List[str] = [] + csv_headers: List[str] = [] + header_str = "" + line_str = "" + + # splits up the measured heights by the region to compute error per region + if not ctx.is_simulating(): + for region_idx in range(config.n_regions): + start = region_idx * config.n_trials + end = start + config.n_trials + + region_measured = measured_heights[start:end] + region_expected = [ + height for (_, height) in config.wells_and_heights[start:end] + ] + + # compute average error for region + measured_and_expected = zip(region_measured, region_expected) + abs_errors = [abs(m - e) for m, e in measured_and_expected] + avg_error = sum(abs_errors) / len(abs_errors) if abs_errors else 0.0 + + # add trial columns + csv_headers.extend( + [f"region{region_idx+1}_trial{t+1}" for t in range(config.n_trials)] + ) + csv_values.extend([f"{m:.3f}" for m in region_measured]) + csv_headers.append(f"region{region_idx+1}_expected") + csv_values.append(f"{region_expected[0]:.3f}") + csv_headers.append(f"region{region_idx+1}_avg_error") + csv_values.append(f"{avg_error:.3f}") -def run(ctx: ProtocolContext) -> None: - """Protocol.""" - ( - liq_pipette, - probe_pipette, - src, - labware, - expected_heights, - dial, - number_of_trials, - labware_type, - liquid_racks, - right_mount, - liq_tip_size, - n_regions, - region_heights, - ) = _setup(ctx) - - wells = [str(well).split(" ")[0] for well in labware.wells()] - volumes: dict[str, List[float | SimulatedProbeResult]] = {} + header = ["labware_type"] + csv_headers + row = [config.labware_type] + csv_values - # mapping expected heights to wells - for i, height in enumerate(expected_heights): - well = wells[i % len(wells)] - volume = labware["A1"].volume_from_height(height) - volumes.setdefault(well, []).append(volume) - - if dial is not None: - _store_dial_baseline(ctx, probe_pipette, dial) - pick_up_tips(probe_pipette, liq_pipette) - _get_height_of_liquid_in_well(liq_pipette, src["A1"], ctx.is_simulating()) - liq_pipette.blow_out() - liq_pipette.drop_tip() - - all_corrected_heights = aspirate_dispense_measure( - ctx, - volumes, - labware, - src, - dial, - probe_pipette, - liq_pipette, - expected_heights, - liquid_racks, - right_mount, - liq_tip_size, - ) + header_str = ",".join(header) + line_str = ",".join(row) + return header_str, line_str - region_results: List[str] = [] - region_len = number_of_trials - if not ctx.is_simulating(): - for i in range(n_regions): - start = i * region_len - end = (i + 1) * region_len - corrected = all_corrected_heights[start:end] - expected_val = region_heights[i] - errors = [abs(c - expected_val) for c in corrected] - avg_error = sum(errors) / len(errors) if errors else 0.0 - - # Add corrected heights - region_results.extend(str(round(c, 3)) for c in corrected) - # Add expected value - region_results.append(str(round(expected_val, 3))) - # Add average error - region_results.append(str(round(avg_error, 3))) - - from hardware_testing.data import append_data_to_file - - region_headers = [] - for i in range(n_regions): - for k in range(region_len): - region_headers.append(f"region{i+1}_trial{k+1}") - region_headers.append(f"region{i+1}_expected") - region_headers.append(f"region{i+1}_avg_error") - - header = ["labware_type"] + region_headers - header_str = ",".join(header) + "\n" - line = [labware_type] + region_results - line_str = ",".join(line) + "\n" - - append_data_to_file(metadata["protocolName"], RUN_ID, FILE_NAME, header_str) - append_data_to_file(metadata["protocolName"], RUN_ID, FILE_NAME, line_str) - ctx.pause(f"{header_str}\n{line_str}") - - drop_tips(probe_pipette, liq_pipette) +def run(ctx: ProtocolContext) -> None: + """Protocol.""" + config = _setup(ctx) + trial = TrialState() + dial = Mitutoyo_Digimatic_Indicator() if config.dial else None + if dial: + dial.connect() + measured_heights = aspirate_dispense_measure(ctx, config, trial, dial) + + header_str, line_str = build_csv_results(ctx, config, measured_heights) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, [header_str]) + Data.write_line_to_csv(ctx, DATA_FILE_PATH, [line_str]) + ctx.pause(f"{header_str}\n{line_str}") + + config.drop_tips() diff --git a/hardware/opentrons_hardware/drivers/eeprom/eeprom.py b/hardware/opentrons_hardware/drivers/eeprom/eeprom.py index be2e28694d6..e18a7f98d1e 100644 --- a/hardware/opentrons_hardware/drivers/eeprom/eeprom.py +++ b/hardware/opentrons_hardware/drivers/eeprom/eeprom.py @@ -26,6 +26,10 @@ DEFAULT_ADDRESS = "0050" DEFAULT_READ_SIZE = 64 +# The expected datagram lengths for EEPROM conent +DEFAULT_MIN_SERIAL_LENGTH = 17 +DEFAULT_MIN_SKU_LENGTH = 9 + class EEPROMDriver: """This class lets you read/write to the eeprom using a sysfs file.""" @@ -241,7 +245,10 @@ def _populate_data(self) -> EEPROMData: for prop in self._properties: if prop.id == PropId.FORMAT_VERSION: self._eeprom_data.format_version = prop.value - elif prop.id == PropId.SERIAL_NUMBER and len(prop.value) >= 17: + elif ( + prop.id == PropId.SERIAL_NUMBER + and len(prop.value) >= DEFAULT_MIN_SERIAL_LENGTH + ): self._eeprom_data.serial_number = prop.value self._eeprom_data.machine_type = prop.value[:3] self._eeprom_data.machine_version = prop.value[3:6] @@ -250,4 +257,17 @@ def _populate_data(self) -> EEPROMData: date_string, "%Y%m%d" ) self._eeprom_data.unit_number = int(prop.value[14:17]) + elif ( + prop.id == PropId.SERIAL_NUMBER + and len(prop.value) < DEFAULT_MIN_SERIAL_LENGTH + ): + logging.error( + f"Could not populate eeprom data with serial number, data length of {DEFAULT_MIN_SERIAL_LENGTH} or higher required." + ) + elif prop.id == PropId.SKU and len(prop.value) >= DEFAULT_MIN_SKU_LENGTH: + self._eeprom_data.sku = str(prop.value) + elif prop.id == PropId.SKU and len(prop.value) < DEFAULT_MIN_SKU_LENGTH: + logging.error( + f"Could not populate eeprom data with device SKU, data length of {DEFAULT_MIN_SKU_LENGTH} or higher required." + ) return self._eeprom_data diff --git a/hardware/opentrons_hardware/drivers/eeprom/types.py b/hardware/opentrons_hardware/drivers/eeprom/types.py index 64704cacaf7..b5e65a94f64 100644 --- a/hardware/opentrons_hardware/drivers/eeprom/types.py +++ b/hardware/opentrons_hardware/drivers/eeprom/types.py @@ -21,6 +21,7 @@ class PropId(Enum): INVALID = 0xFF FORMAT_VERSION = 1 SERIAL_NUMBER = 2 + SKU = 3 class PropType(Enum): @@ -37,6 +38,7 @@ class PropType(Enum): PROP_ID_TYPES = { PropId.FORMAT_VERSION: PropType.BYTE, PropId.SERIAL_NUMBER: PropType.STR, + PropId.SKU: PropType.STR, } PROP_TYPE_SIZE = { @@ -69,3 +71,14 @@ class EEPROMData: machine_version: Optional[str] = None programmed_date: Optional[datetime] = None unit_number: Optional[int] = None + sku: Optional[str] = None + + def to_set(self) -> set[tuple[PropId, str | int]]: + """Returns a set of expected data values paired with a property id.""" + eeprom_set: set[tuple[PropId, str | int]] = set() + eeprom_set.add((PropId.FORMAT_VERSION, self.format_version)) + if self.serial_number: + eeprom_set.add((PropId.SERIAL_NUMBER, self.serial_number)) + if self.sku: + eeprom_set.add((PropId.SKU, self.sku)) + return eeprom_set diff --git a/labware-library/vite.config.mts b/labware-library/vite.config.mts index e7a802deb79..fb63c618f72 100644 --- a/labware-library/vite.config.mts +++ b/labware-library/vite.config.mts @@ -1,4 +1,6 @@ +import fs from 'node:fs' import path from 'path' +import { fileURLToPath } from 'url' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import postCssImport from 'postcss-import' @@ -7,6 +9,42 @@ import postColorModFunction from 'postcss-color-mod-function' import postCssPresetEnv from 'postcss-preset-env' import lostCss from 'lost' import { cssModuleSideEffect } from './cssModuleSideEffect' +import createGitVersionToolkit from '../scripts/git-version-v2.mjs' + +const { generateBuildInfoHtml } = createGitVersionToolkit({ + project: 'labware-library', +}) + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const LABWARE_IMAGE_DIR = path.resolve( + __dirname, + '../shared-data/labware/images' +) +const LABWARE_IMAGE_NAME_PATTERN = /\.(?:png|jpe?g)$/i + +const labwareImageFilenames: Set = (() => { + try { + return new Set( + fs + .readdirSync(LABWARE_IMAGE_DIR, { withFileTypes: true }) + .filter( + entry => entry.isFile() && LABWARE_IMAGE_NAME_PATTERN.test(entry.name) + ) + .map(entry => entry.name) + ) + } catch (error) { + console.warn( + '[labware-library] Unable to read shared labware images directory', + error + ) + return new Set() + } +})() + +const isLabwareReferenceImage = (assetName?: string): boolean => { + if (assetName == null) return false + return labwareImageFilenames.has(assetName) +} export default defineConfig({ // this makes imports relative rather than absolute @@ -20,6 +58,16 @@ export default defineConfig({ main: path.resolve(__dirname, 'index.html'), create: path.resolve(__dirname, 'create/index.html'), }, + output: { + assetFileNames: assetInfo => { + if (isLabwareReferenceImage(assetInfo.name)) { + // Keep labware reference images stable for hotlinking + return 'labware-images/[name][extname]' + } + + return 'assets/[name]-[hash][extname]' + }, + }, }, }, plugins: [ @@ -31,6 +79,13 @@ export default defineConfig({ }, }), cssModuleSideEffect(), // Note for treeshake + { + name: 'build-info-generator', + closeBundle: async () => { + const outputPath = path.resolve(__dirname, 'dist', 'info', 'index.html') + await generateBuildInfoHtml(outputPath) + }, + }, ], optimizeDeps: { esbuildOptions: { diff --git a/protocol-designer/Makefile b/protocol-designer/Makefile index 207d9602d65..5b842ad322e 100644 --- a/protocol-designer/Makefile +++ b/protocol-designer/Makefile @@ -60,13 +60,6 @@ serve: export NODE_OPTIONS := --max-old-space-size=8192 serve: all vite preview -# end to end tests -.PHONY: test-e2e -test-e2e: clean-downloads - concurrently --kill-others --success first --names "protocol-designer-server,protocol-designer-tests" \ - "$(MAKE) dev" \ - "wait-on http://localhost:5178/ && cypress run --browser chrome --headless --record false --quiet" - .PHONY: test test: $(MAKE) -C .. test-js-protocol-designer tests="$(tests)" test_opts="$(test_opts)" @@ -75,51 +68,6 @@ test: test-cov: make -C .. test-js-protocol-designer tests=$(tests) test_opts="$(test_opts)" cov_opts="$(cov_opts)" -CYPRESS_ESLINT_GLOB := cypress/**/*.ts cypress.config.ts -CYPRESS_PRETTIER_GLOB := cypress/**/*.{ts,md,json} cypress.config.ts - -.PHONY: cy-lint-check -cy-lint-check: cy-lint-eslint-check cy-lint-prettier-check - @echo "Cypress lint check completed." - -.PHONY: cy-lint-fix -cy-lint-fix: cy-lint-eslint-fix cy-lint-prettier-fix - @echo "Cypress lint fix applied." - -.PHONY: cy-lint-eslint-check -cy-lint-eslint-check: clean-downloads clean-screenshots - yarn eslint --ignore-path ../.eslintignore $(CYPRESS_ESLINT_GLOB) - @echo "Cypress ESLint check completed." - -.PHONY: cy-lint-eslint-fix -cy-lint-eslint-fix: clean-downloads clean-screenshots - yarn eslint --fix --ignore-pattern ../.eslintignore $(CYPRESS_ESLINT_GLOB) - @echo "Cypress ESLint fix applied." - -.PHONY: cy-lint-prettier-check -cy-lint-prettier-check: clean-downloads clean-screenshots - yarn prettier --ignore-path ../.eslintignore --check $(CYPRESS_PRETTIER_GLOB) - @echo "Cypress Prettier check completed." - -.PHONY: cy-lint-prettier-fix -cy-lint-prettier-fix: clean-downloads clean-screenshots - yarn prettier --ignore-path ../.eslintignore --write $(CYPRESS_PRETTIER_GLOB) - @echo "Cypress Prettier fix applied." - -.PHONY: cy-ui -cy-ui: - @echo "Running Cypress UI" - @echo "Dev environment must be running" - yarn cypress open - -.PHONY: clean-downloads -clean-downloads: - shx rm -rf cypress/downloads - -.PHONY: clean-screenshots -clean-screenshots: - shx rm -rf cypress/screenshots - # analyze bundle .PHONY: bundle-analyzer bundle-analyzer: diff --git a/protocol-designer/cypress.config.ts b/protocol-designer/cypress.config.ts deleted file mode 100644 index 9b4f48f7db5..00000000000 --- a/protocol-designer/cypress.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'cypress' - -module.exports = defineConfig({ - video: false, - viewportWidth: 1440, - viewportHeight: 1024, - e2e: { - baseUrl: 'http://localhost:5178', - }, -}) diff --git a/protocol-designer/cypress/README.md b/protocol-designer/cypress/README.md deleted file mode 100644 index 2fb0666f3c6..00000000000 --- a/protocol-designer/cypress/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Cypress in PD - -This is a guide to running Cypress tests in Protocol Designer. - -- `cat Makefile` to see all the available commands - -## Cypress Organization - -- `cypress/e2e` contains all the tests -- `cypress/support` contains all the support files - - `cypress/support/commands` contains added commands and may be used for actions on the home page and header - - use the files representing the different parts of the app to create reusable functions -- `../fixtures` (PD root fixtures) and `cypress/fixtures` contains all the fixtures (files) that we might want to use in tests - -## Fixtures - -We need to read data from files. `support/testFiles.ts` maps in files and provides an enum to reference them by. All files that need to be read in should be mapped through testFiles. - -## Tactics - -### Locators - - - -1. use a simple cy.contains() -1. try aria-\* attributes -1. data-testid attribute (then use getByTestId custom command) diff --git a/protocol-designer/cypress/e2e/batchEdit.cy.ts b/protocol-designer/cypress/e2e/batchEdit.cy.ts deleted file mode 100644 index e8a5bc19e6d..00000000000 --- a/protocol-designer/cypress/e2e/batchEdit.cy.ts +++ /dev/null @@ -1,104 +0,0 @@ -// TODO: refactor to work with new batch edit -describe('Batch Edit Transform', () => { - // beforeEach(() => { - // cy.visit('/') - // cy.closeAnnouncementModal() - // }) - // // import the batchEdit.json to PD - // it('Verify Flowrate, duplicate, delete functionality in Batch Edit Mode', () => { - // importProtocol() - // openDesignTab() - // const isMacOSX = Cypress.platform === 'darwin' - // // enter into the batch edit mode - // cy.get('[data-test="StepItem_1"]').click({ - // [isMacOSX ? 'metaKey' : 'ctrlKey']: true, - // }) - // cy.get('button').contains('exit batch edit').should('exist') - // // Range selection with shift - // cy.get('[data-test="StepItem_3"]').click({ - // shiftKey: true, - // }) - // cy.get('#StepSelectionBannerComponent_numberStepsSelected') - // .contains('3 steps selected') - // .should('exist') - // // Change the Flowrate to 100 and Save - // cy.get('[name="aspirate_flowRate"]').click() - // cy.get('input[name="aspirate_flowRate_customFlowRate"]').type('100') - // cy.get('button').contains('Done').click() - // cy.get('button').contains('save').click() - // // Verify that transfer step 1 has Flowrate value 100 - // cy.get('[data-test="StepItem_1"]').click({ - // shiftKey: true, - // }) - // cy.get('input[name="aspirate_flowRate"]').should('have.value', 100) - // // Verify that transfer and other step selection does not support multistep editing - // cy.get('[data-test="StepItem_4"]').click({ - // [isMacOSX ? 'metaKey' : 'ctrlKey']: true, - // }) - // cy.get('#StepSelectionBannerComponent_numberStepsSelected') - // .contains('2 steps selected') - // .should('exist') - // cy.get('[id=Text_noSharedSettings]').contains( - // 'Batch editing of settings is only available for Transfer or Mix steps' - // ) - // // Expand ALL steps - // cy.get('#ClickableIcon_expand').click() - // cy.get('[data-test="SubstepRow_aspirateWell"]') - // .contains('A1') - // .should('exist') - // // Select all the steps - // cy.get('#ClickableIcon_select').click() - // cy.get('#StepSelectionBannerComponent_numberStepsSelected') - // .contains('9 steps selected') - // .should('exist') - // // Duplicate the selected steps - // cy.get('#ClickableIcon_duplicate').click() - // cy.get('#ClickableIcon_select').click() - // cy.get('#StepSelectionBannerComponent_numberStepsSelected') - // .contains('18 steps selected') - // .should('exist') - // cy.get('[data-test="StepItem_9"]').click({ - // shiftKey: true, - // }) - // // Delete the duplicated steps - // cy.get('#ClickableIcon_delete').click() - // cy.get('button').contains('Delete steps').click() - // cy.get('#StepSelectionBannerComponent_numberStepsSelected') - // .contains('1 steps selected') - // .should('exist') - // // Exit batch edit mode - // cy.get('button').contains('exit batch edit').click() - // cy.get('button').contains('+ Add Step').should('not.be.disabled') - // }) -}) - -// function importProtocol() { -// cy.fixture('../../fixtures/protocol/5/batchEdit.json').then(fileContent => { -// cy.get('input[type=file]').upload({ -// fileContent: JSON.stringify(fileContent), -// fileName: 'fixture.json', -// mimeType: 'application/json', -// encoding: 'utf8', -// }) -// cy.get('div') -// .contains( -// 'Your protocol will be automatically updated to the latest version.' -// ) -// .should('exist') -// cy.get('button').contains('ok', { matchCase: false }).click() -// // wait until computation is done before proceeding, with generous timeout -// cy.get('[data-test="ComputingSpinner"]', { timeout: 30000 }).should( -// 'not.exist' -// ) -// }) -// } - -// function openDesignTab() { -// cy.get('button[id=NavTab_design]').click() -// cy.get('button').contains('ok').click() - -// // Verify the Design Page -// cy.get('#TitleBar_main > h1').contains('Multi select banner test protocol') -// cy.get('#TitleBar_main > h2').contains('STARTING DECK STATE') -// cy.get('button[id=StepCreationButton]').contains('+ Add Step') -// } diff --git a/protocol-designer/cypress/e2e/createNew.cy.ts b/protocol-designer/cypress/e2e/createNew.cy.ts deleted file mode 100644 index 7614a2b80a7..00000000000 --- a/protocol-designer/cypress/e2e/createNew.cy.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SetupSteps, SetupVerifications } from '../support/SetupSteps' -import { StepExecutor } from '../support/StepBuilder' -import { UniversalSteps } from '../support/UniversalSteps' - -describe('The Redesigned Create Protocol Landing Page', () => { - beforeEach(() => { - cy.visit('/') - cy.closeAnalyticsModal() - }) - - it('Checks onboarding flow for OT-2', () => { - cy.verifyCreateNewHeader() - cy.clickCreateNew() - const se: StepExecutor = new StepExecutor() - se.execute(SetupVerifications.OnStep1()) - se.execute(SetupVerifications.FlexSelected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectOT2()) - se.execute(SetupVerifications.OT2Selected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectFlex()) - se.execute(SetupVerifications.FlexSelected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupVerifications.OnStep2()) - se.execute(SetupVerifications.NinetySixChannel()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.Cancel()) - se.execute(SetupSteps.SelectOT2()) - se.execute(SetupVerifications.OnStep2()) - se.execute(SetupVerifications.NotNinetySixChannel()) - se.execute(UniversalSteps.Snapshot()) - }) -}) diff --git a/protocol-designer/cypress/e2e/createNewFlex.cy.ts b/protocol-designer/cypress/e2e/createNewFlex.cy.ts deleted file mode 100644 index a69195d08d1..00000000000 --- a/protocol-designer/cypress/e2e/createNewFlex.cy.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - CompositeSetupSteps, - SetupSteps, - SetupVerifications, -} from '../support/SetupSteps' -import { StepExecutor } from '../support/StepBuilder' -import { UniversalSteps } from '../support/UniversalSteps' - -describe('Create new Flex', () => { - beforeEach(() => { - cy.visit('/') - cy.closeAnalyticsModal() - }) - - it('Goes through onboarding workflow for Flex', () => { - cy.clickCreateNew() - cy.verifyCreateNewHeader() - - const se = new StepExecutor() - se.execute( - CompositeSetupSteps.FlexSetup({ - thermocycler: true, - heatershaker: true, - magblock: true, - tempdeck: true, - }) - ) - se.execute( - CompositeSetupSteps.AddLabwareToDeckSlot('C2', 'Bio-Rad 96 Well Plate') - ) - se.execute(SetupSteps.ChoseDeckSlotWithLabware('C2')) - se.execute(SetupSteps.AddHardwareLabware()) - se.execute(SetupSteps.AddLiquid()) - se.execute(SetupSteps.ClickLiquidButton()) - se.execute(SetupSteps.DefineLiquid()) - se.execute(SetupSteps.LiquidSaveWIP()) - se.execute(SetupSteps.WellSelector(['A1', 'A2'])) - se.execute(SetupSteps.LiquidDropdown()) - se.execute(SetupVerifications.LiquidPage()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectLiquidWells()) - se.execute(SetupSteps.SetVolumeAndSaveForWells('150')) - se.execute(SetupSteps.SelectDone()) - se.execute( - CompositeSetupSteps.AddLabwareToDeckSlot( - 'C3', - 'Armadillo 96 Well Plate 200 µL' - ) - ) - }) -}) diff --git a/protocol-designer/cypress/e2e/home.cy.ts b/protocol-designer/cypress/e2e/home.cy.ts deleted file mode 100644 index 32a7c766d8e..00000000000 --- a/protocol-designer/cypress/e2e/home.cy.ts +++ /dev/null @@ -1,11 +0,0 @@ -describe('The Home Page', () => { - beforeEach(() => { - cy.visit('/') - cy.closeAnalyticsModal() - }) - - it('successfully loads', () => { - cy.verifyFullHeader() - cy.verifyHomePage() - }) -}) diff --git a/protocol-designer/cypress/e2e/import.cy.ts b/protocol-designer/cypress/e2e/import.cy.ts deleted file mode 100644 index 2e54cb2df2f..00000000000 --- a/protocol-designer/cypress/e2e/import.cy.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - verifyImportProtocolPage, - verifyOldProtocolModal, -} from '../support/Import' -import { getTestFile, TestFilePath } from '../support/TestFiles' - -describe('The Import Page', () => { - beforeEach(() => { - cy.visit('/') - cy.closeAnalyticsModal() - }) - - it('successfully loads a protocol exported on a previous version', () => { - const protocol = getTestFile(TestFilePath.DoItAllV7) - cy.importProtocol(protocol.path) - verifyOldProtocolModal() - verifyImportProtocolPage(protocol) - }) - - it('successfully loads a protocol exported on the current version', () => { - const protocol = getTestFile(TestFilePath.DoItAllV8) - cy.importProtocol(protocol.path) - verifyImportProtocolPage(protocol) - }) -}) diff --git a/protocol-designer/cypress/e2e/mixSettings.cy.ts b/protocol-designer/cypress/e2e/mixSettings.cy.ts deleted file mode 100644 index d1690a1374b..00000000000 --- a/protocol-designer/cypress/e2e/mixSettings.cy.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - verifyImportProtocolPage, - verifyOldProtocolModal, -} from '../support/Import' -import { MixSteps, MixVerifications } from '../support/MixSteps' -import { SetupSteps } from '../support/SetupSteps' -import { StepExecutor } from '../support/StepBuilder' -import { getTestFile, TestFilePath } from '../support/TestFiles' -import { UniversalSteps } from '../support/UniversalSteps' - -describe('Redesigned Mixing Steps - Happy Path', () => { - beforeEach(() => { - cy.visit('/') - cy.closeAnalyticsModal() - const protocol = getTestFile(TestFilePath.DoItAllV8) - cy.importProtocol(protocol.path) - verifyImportProtocolPage(protocol) - verifyOldProtocolModal() - cy.contains('Edit protocol').click() - cy.get('[id="AddStepButton"]').contains('Add Step').click() - cy.verifyOverflowBtn() - }) - - it('should verify the working function of every permutation of mix checkboxes', () => { - const se = new StepExecutor() - se.execute(MixSteps.SelectMix()) - se.execute(UniversalSteps.Snapshot()) - se.execute(MixVerifications.PartOne()) - se.execute(MixSteps.SelectLabware()) - se.execute(MixSteps.SelectWellInputField()) - se.execute(MixVerifications.WellSelectPopout()) - se.execute(SetupSteps.WellSelector(['A1', 'A2'])) - se.execute(UniversalSteps.Snapshot()) - se.execute(MixSteps.Save()) - se.execute(MixSteps.EnterVolume()) - se.execute(MixSteps.EnterMixReps()) - se.execute(UniversalSteps.Snapshot()) - se.execute(MixSteps.Continue()) - se.execute(MixVerifications.PartTwo()) - se.execute(MixSteps.Continue()) - se.execute(MixVerifications.PartThreeAsp()) - se.execute(MixSteps.AspirateFlowRate()) - se.execute(MixSteps.AspWellOrder()) - se.execute(MixVerifications.AspWellOrder()) - se.execute(MixSteps.AspMixTipPos()) - se.execute(MixVerifications.AspMixTipPos()) - se.execute(MixSteps.Delay()) - se.execute(MixSteps.Dispense()) - se.execute(MixVerifications.PartThreeDisp()) - se.execute(MixSteps.DispenseFlowRate()) - se.execute(MixSteps.Delay()) - se.execute(MixSteps.PushOut()) - se.execute(MixSteps.BlowoutLocation()) - se.execute(MixSteps.BlowoutFlowRate()) - se.execute(MixSteps.BlowoutPosFromTop()) - se.execute(MixVerifications.BlowoutPopout()) - se.execute(MixVerifications.Blowout()) - se.execute(MixSteps.Continue()) - se.execute(MixSteps.SelectTipHandling()) - // steps.add(MixSteps.TouchTip()) - // steps.add(MixVerifications.TouchTipPopout()) - // steps.add(MixSteps.Save()) - // steps.add(MixVerifications.TouchTip()) - se.execute(MixSteps.Rename()) - se.execute(MixSteps.Save()) - se.execute(MixVerifications.Rename()) - se.execute(MixSteps.Save()) - }) -}) diff --git a/protocol-designer/cypress/e2e/modules.cy.ts b/protocol-designer/cypress/e2e/modules.cy.ts deleted file mode 100644 index 54a0443dc8c..00000000000 --- a/protocol-designer/cypress/e2e/modules.cy.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { ModuleSteps, ModuleVerifications } from '../support/ModuleSteps' -import { SetupSteps, SetupVerifications } from '../support/SetupSteps' -import { StepExecutor } from '../support/StepBuilder' -import { UniversalSteps } from '../support/UniversalSteps' - -describe('The Redesigned Create Protocol Landing Page', () => { - beforeEach(() => { - cy.visit('/') - cy.verifyHomePage() - cy.closeAnalyticsModal() - }) - - it('content and step 1 flow works', () => { - cy.clickCreateNew() - cy.verifyCreateNewHeader() - - const se = new StepExecutor() - se.execute(SetupVerifications.OnStep1()) - se.execute(SetupVerifications.FlexSelected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectOT2()) - se.execute(SetupVerifications.OT2Selected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectFlex()) - se.execute(SetupVerifications.FlexSelected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupVerifications.OnStep2()) - se.execute(SetupSteps.SingleChannelPipette50()) - se.execute(SetupVerifications.StepTwo50uL()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.Save()) - se.execute(SetupVerifications.StepTwoPart3()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupVerifications.OnStep3()) - se.execute(SetupSteps.YesGripper()) - se.execute(SetupSteps.NoThermocycler()) - se.execute(SetupSteps.NoWasteChute()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupVerifications.Step4Verification()) - se.execute(SetupSteps.AddThermocycler()) - se.execute(SetupSteps.AddHeaterShaker()) - se.execute(SetupSteps.AddMagBlock()) - se.execute(SetupSteps.AddTempdeck2()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupSteps.EditProtocolA()) - se.execute(SetupSteps.ChoseDeckSlot('C2')) - se.execute(SetupSteps.OpenSelectLabwareModal()) - se.execute(SetupSteps.ClickWellPlatesSection()) - se.execute(SetupSteps.SelectLabwareByDisplayName('Bio-Rad 96 Well Plate')) - se.execute(SetupSteps.ChoseDeckSlotC2Labware()) - se.execute(SetupSteps.AddHardwareLabware()) - se.execute(SetupSteps.AddLiquid()) - se.execute(SetupSteps.ClickLiquidButton()) - se.execute(SetupSteps.DefineLiquid()) - se.execute(SetupSteps.LiquidSaveWIP()) - se.execute(SetupSteps.WellSelector(['A1', 'A2'])) - se.execute(SetupSteps.LiquidDropdown()) - se.execute(SetupVerifications.LiquidPage()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectLiquidWells()) - se.execute(SetupSteps.SetVolumeAndSaveForWells('150')) - se.execute(SetupSteps.SelectDone()) - se.execute(SetupSteps.ChoseDeckSlot('C1')) - se.execute(SetupSteps.OpenSelectLabwareModal()) - se.execute(SetupSteps.AddAdapters()) - se.execute(SetupSteps.DeepWellTempModAdapter()) - se.execute(SetupSteps.AddNest96DeepWellPlate()) - se.execute(SetupSteps.SelectDone()) - se.execute(SetupSteps.AddStep()) - se.execute(ModuleSteps.AddTemperatureStep()) - se.execute(ModuleVerifications.TempeDeckInitialForm()) - se.execute(UniversalSteps.Snapshot()) - se.execute(ModuleSteps.ActivateTempdeck()) - se.execute(ModuleSteps.InputTempDeck4()) - se.execute(ModuleSteps.SaveButtonTempdeck()) - se.execute(ModuleSteps.PauseAfterSettingTempdeck()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.AddStep()) - se.execute(ModuleSteps.AddTemperatureStep()) - se.execute(ModuleSteps.ActivateTempdeck()) - se.execute(ModuleSteps.InputTempDeck95()) - se.execute(ModuleSteps.SaveButtonTempdeck()) - se.execute(ModuleSteps.PauseAfterSettingTempdeck()) - se.execute(SetupSteps.AddStep()) - se.execute(ModuleSteps.AddTemperatureStep()) - se.execute(ModuleSteps.ActivateTempdeck()) - se.execute(ModuleSteps.InputTempDeck100()) - }) -}) diff --git a/protocol-designer/cypress/e2e/plateReaderTest.cy.ts b/protocol-designer/cypress/e2e/plateReaderTest.cy.ts deleted file mode 100644 index 8b4a95db289..00000000000 --- a/protocol-designer/cypress/e2e/plateReaderTest.cy.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ModuleSteps, ModuleVerifications } from '../support/ModuleSteps' -import { SetupSteps, SetupVerifications } from '../support/SetupSteps' -import { StepExecutor } from '../support/StepBuilder' -import { UniversalSteps } from '../support/UniversalSteps' - -describe('Plate Reader Happy Path Single-Wavelength', () => { - beforeEach(() => { - cy.visit('/') - cy.verifyHomePage() - cy.closeAnalyticsModal() - }) - - it('Scans one wavelegth for plate reader and checks errors', () => { - cy.clickCreateNew() - cy.verifyCreateNewHeader() - const se = new StepExecutor() - se.execute(SetupVerifications.OnStep1()) - se.execute(SetupVerifications.FlexSelected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectOT2()) - se.execute(SetupVerifications.OT2Selected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectFlex()) - se.execute(SetupVerifications.FlexSelected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupVerifications.OnStep2()) - se.execute(SetupSteps.SingleChannelPipette50()) - se.execute(SetupVerifications.StepTwo50uL()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.Save()) - se.execute(SetupVerifications.StepTwoPart3()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupVerifications.OnStep3()) - se.execute(SetupSteps.NoGripper()) - se.execute(SetupSteps.NoThermocycler()) - se.execute(SetupSteps.NoWasteChute()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupVerifications.AbsorbanceNotSelectable()) - se.execute(SetupSteps.GoBack()) - se.execute(SetupSteps.YesGripper()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupSteps.AddPlateReader()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupSteps.EditProtocolA()) - se.execute(SetupSteps.ChoseDeckSlotWithLabware('C3')) - se.execute(SetupSteps.OpenSelectLabwareModal()) - se.execute(SetupSteps.ClickWellPlatesSection()) - se.execute(SetupSteps.SelectLabwareByDisplayName('Bio-Rad 96 Well Plate')) - se.execute(SetupSteps.ChoseDeckSlotWithLabware('C3')) - se.execute(SetupSteps.AddHardwareLabware()) - se.execute(SetupSteps.AddLiquid()) - se.execute(SetupSteps.ClickLiquidButton()) - se.execute(SetupSteps.DefineLiquid()) - se.execute(SetupSteps.LiquidSaveWIP()) - se.execute(SetupSteps.WellSelector(['A1', 'A2'])) - se.execute(SetupSteps.LiquidDropdown()) - se.execute(SetupVerifications.LiquidPage()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectLiquidWells()) - se.execute(SetupSteps.SetVolumeAndSaveForWells('150')) - se.execute(SetupSteps.SelectDone()) - // Add another labware - se.execute(SetupSteps.ChoseDeckSlotWithLabware('D2')) - - se.execute(SetupSteps.OpenSelectLabwareModal()) - se.execute(SetupSteps.ClickWellPlatesSection()) - se.execute(SetupSteps.SelectLabwareByDisplayName('Armadillo 96 Well Plate')) - se.execute(SetupSteps.AddStep()) - - // Move labware attempt to Plate Reader - se.execute(SetupSteps.AddMoveStep()) - se.execute(SetupSteps.UseGripperinMove()) - se.execute(SetupSteps.ChoseSourceMoveLabware()) - se.execute(SetupSteps.selectDropdownLabware('Armadillo 96 Well Plate')) - se.execute(SetupSteps.ChoseDestinationMoveLabware()) - se.execute(SetupSteps.selectDropdownLabware('Absorbance Plate Reader')) - se.execute(SetupSteps.MoveToPlateReader()) - se.execute(SetupSteps.Save()) - se.execute(ModuleVerifications.NoMoveToPlateReaderWhenClosed()) - // You can't move to Plate Reader while it's closed - se.execute(SetupSteps.DeleteSteps()) - se.execute(SetupSteps.AddStep()) - se.execute(ModuleSteps.StartPlateReaderStep()) - se.execute(ModuleVerifications.PlateReaderPart1NoInitilization()) - se.execute(SetupSteps.Continue()) - // Define a plate read - se.execute(ModuleVerifications.PlateReaderPart2NoInitilization()) - se.execute(ModuleSteps.DefineInitilizationSingleCheckAll()) - se.execute(ModuleSteps.DefineCustomWavelegthSingle('300')) - se.execute(SetupSteps.Save()) - }) -}) diff --git a/protocol-designer/cypress/e2e/settings.cy.ts b/protocol-designer/cypress/e2e/settings.cy.ts deleted file mode 100644 index ec5c0210f4f..00000000000 --- a/protocol-designer/cypress/e2e/settings.cy.ts +++ /dev/null @@ -1,64 +0,0 @@ -describe('The Settings Page', () => { - before(() => { - cy.visit('/') - cy.closeAnalyticsModal() - }) - - it('content and toggle state', () => { - // The settings page will not follow the same pattern as create and edit - // The Settings page is simple enough we need not abstract actions and validations into data - - // home page contains a working settings button - cy.openSettingsPage() - cy.verifySettingsPage() - // Timeline editing tips defaults to true - cy.getByAriaLabel('Settings_OT_PD_ENABLE_HOT_KEYS_DISPLAY') - .should('exist') - .should('be.visible') - .should('have.attr', 'aria-checked', 'true') - // Multiple temp modules on OT-2 defaults to false - cy.getByAriaLabel('Settings_OT_PD_ENABLE_MULTIPLE_TEMPS_OT2') - .should('exist') - .should('be.visible') - .should('have.attr', 'aria-checked', 'false') - // Disable module restrictions defaults to false - cy.getByAriaLabel('Settings_OT_PD_DISABLE_MODULE_RESTRICTIONS') - .should('exist') - .should('be.visible') - .should('have.attr', 'aria-checked', 'false') - // Share sessions with Opentrons toggle defaults to off - cy.getByAriaLabel('Settings_Privacy') - .should('exist') - .should('be.visible') - .find('path[aria-roledescription="ot-toggle-input-on"]') - .should('exist') - // Toggle the share sessions with Opentrons setting - cy.getByAriaLabel('Settings_Privacy').click() - cy.getByAriaLabel('Settings_Privacy') - .find('path[aria-roledescription="ot-toggle-input-off"]') - .should('exist') - // Navigate away from the settings page - // Then return to see privacy toggle remains toggled on - cy.visit('/') - cy.openSettingsPage() - cy.getByAriaLabel('Settings_Privacy').find( - 'path[aria-roledescription="ot-toggle-input-off"]' - ) - // Toggle off editing timeline tips - // Navigate away from the settings page - // Then return to see timeline tips remains toggled on - cy.getByAriaLabel('Settings_OT_PD_ENABLE_HOT_KEYS_DISPLAY').click() - cy.getByAriaLabel('Settings_OT_PD_ENABLE_HOT_KEYS_DISPLAY').should( - 'have.attr', - 'aria-checked', - 'false' - ) - cy.visit('/') - cy.openSettingsPage() - cy.getByAriaLabel('Settings_OT_PD_ENABLE_HOT_KEYS_DISPLAY').should( - 'have.attr', - 'aria-checked', - 'false' - ) - }) -}) diff --git a/protocol-designer/cypress/e2e/testfiles.cy.ts b/protocol-designer/cypress/e2e/testfiles.cy.ts deleted file mode 100644 index 85d56c49d74..00000000000 --- a/protocol-designer/cypress/e2e/testfiles.cy.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getTestFile, TestFilePath } from '../support/TestFiles' - -describe('Validate Test Files', () => { - it('should load and validate all test files', () => { - ;(Object.keys(TestFilePath) as Array).forEach( - key => { - const testFile = getTestFile(TestFilePath[key]) - - cy.log(`Loaded: ${testFile.basename}`) - expect(testFile).to.have.property('path') - - cy.readFile(testFile.path).then(fileContent => { - cy.log(`Loaded content for: ${testFile.basename}`) - - if ( - typeof fileContent === 'object' && - Boolean(fileContent?.metadata?.protocolName) - ) { - expect(fileContent.metadata.protocolName) - .to.be.a('string') - .and.have.length.greaterThan(0) - cy.log( - `Validated protocolName: ${fileContent.metadata.protocolName}` - ) - } - }) - } - ) - }) -}) diff --git a/protocol-designer/cypress/e2e/thermocycler.cy.ts b/protocol-designer/cypress/e2e/thermocycler.cy.ts deleted file mode 100644 index 8840fd238d5..00000000000 --- a/protocol-designer/cypress/e2e/thermocycler.cy.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - verifyImportProtocolPage, - verifyOldProtocolModal, -} from '../support/Import' -import { StepExecutor } from '../support/StepBuilder' -import { getTestFile, TestFilePath } from '../support/TestFiles' -import { - ThermocyclerEditor, - ThermoProfile, - ThermoProfileSteps, - ThermoState, - ThermoVerifications, -} from '../support/Thermocycler' -import { TimelineSteps } from '../support/Timeline' - -describe('Redesigned Thermocycler Set Up Steps - Happy Path', () => { - beforeEach(() => { - cy.visit('/') - cy.closeAnalyticsModal() - cy.closeReleaseNotesModal() - const protocol = getTestFile(TestFilePath.DoItAllV8) - cy.importProtocol(protocol.path) - verifyImportProtocolPage(protocol) - verifyOldProtocolModal() - cy.contains('Edit protocol').click() - }) - - it('It should verify the working function of thermocycler set up', () => { - const se = new StepExecutor() - se.execute( - TimelineSteps.SelectItemMenuOption(1, 'Thermocycler', 'Edit step') - ) - se.execute(ThermoVerifications.VerifyPartOne()) - se.execute(ThermocyclerEditor.SelectProfileOrState('state')) - se.execute(ThermoVerifications.VerifyThermoState()) - se.execute(ThermocyclerEditor.BlockTempOnOff('on')) - se.execute(ThermoState.BlockTempInput('99')) - se.execute(ThermocyclerEditor.BlockTempOnOff('off')) - se.execute(ThermocyclerEditor.BlockTempOnOff('on')) - se.execute(ThermoState.BlockTempInput('15')) - se.execute(ThermocyclerEditor.LidTempOnOff('on')) - se.execute(ThermoState.LidTempInput('37')) - se.execute(ThermocyclerEditor.LidTempOnOff('off')) - se.execute(ThermocyclerEditor.LidTempOnOff('on')) - se.execute(ThermoState.LidTempInput('110')) - se.execute(ThermocyclerEditor.LidOpenClosed('closed')) - se.execute(ThermocyclerEditor.LidOpenClosed('open')) - se.execute(ThermocyclerEditor.LidOpenClosed('closed')) - se.execute(ThermocyclerEditor.BackButton()) - se.execute(ThermocyclerEditor.SelectProfileOrState('state')) - se.execute(ThermoVerifications.VerifyOptionsPersist('state')) - se.execute(ThermocyclerEditor.BackButton()) - se.execute(ThermocyclerEditor.SelectProfileOrState('profile')) - se.execute(ThermoVerifications.VerifyThermoProfile()) - se.execute(ThermoProfile.WellVolumeInput('99')) - se.execute(ThermoProfile.LidTempInput('40')) - se.execute(ThermocyclerEditor.BlockTempOnOff('on')) - se.execute(ThermoProfile.BlockTempHoldInput('90')) - se.execute(ThermocyclerEditor.LidTempOnOff('on')) - se.execute(ThermoProfile.LidTempHoldInput('40')) - se.execute(ThermocyclerEditor.LidOpenClosed('open')) - se.execute(ThermocyclerEditor.BackButton()) - se.execute(ThermocyclerEditor.SelectProfileOrState('profile')) - se.execute(ThermoVerifications.VerifyOptionsPersist('profile')) - se.execute(ThermoVerifications.VerifyProfileSteps()) - se.execute(ThermoProfileSteps.AddCycle()) - se.execute(ThermoProfileSteps.DeleteCycle(0)) - se.execute(ThermoProfileSteps.AddCycle()) - se.execute(ThermoProfileSteps.SetCycleCount(0, '3')) - se.execute(ThermoProfileSteps.AddCycleStep(0)) - se.execute( - ThermoProfileSteps.FillCycleStep({ - cycle: 0, - step: 0, - stepName: 'cycle test 1', - temp: '50', - time: '05:00', - }) - ) - se.execute(ThermoProfileSteps.AddCycleStep(0)) - se.execute( - ThermoProfileSteps.FillCycleStep({ - cycle: 0, - step: 1, - stepName: 'cycle test 2', - temp: '45', - time: '05:55', - }) - ) - se.execute(ThermoProfileSteps.AddCycleStep(0)) - se.execute(ThermoProfileSteps.DeleteCycleStep(0, 2)) - se.execute(ThermoProfileSteps.AddCycleStep(0)) - se.execute( - ThermoProfileSteps.FillCycleStep({ - cycle: 0, - step: 2, - stepName: 'cycle test 3', - temp: '35', - time: '03:33', - }) - ) - se.execute(ThermoProfileSteps.SaveCycle(0)) - se.execute(ThermoProfileSteps.AddStep()) - se.execute( - ThermoProfileSteps.FillThermocyclerStep({ - step: 0, - stepName: 'Thermocycler Step 1', - temp: '30', - time: '03:01', - }) - ) - se.execute(ThermoProfileSteps.DeleteThermocyclerStep(0)) - se.execute(ThermoProfileSteps.AddStep()) - se.execute( - ThermoProfileSteps.FillThermocyclerStep({ - step: 0, - stepName: 'Thermocycler step 2', - temp: '25', - time: '02:02', - }) - ) - se.execute(ThermoProfileSteps.SaveThermocyclerStep(0)) - se.execute(ThermoProfileSteps.AddStep()) - se.execute( - ThermoProfileSteps.FillThermocyclerStep({ - step: 1, - stepName: 'Thermocycler Step 3', - temp: '49', - time: '01:59', - }) - ) - se.execute(ThermoProfileSteps.SaveThermocyclerStep(1)) - se.execute(ThermocyclerEditor.SaveProfileSteps()) - se.execute(ThermocyclerEditor.SaveButton()) - }) -}) diff --git a/protocol-designer/cypress/e2e/transferSettings.cy.ts b/protocol-designer/cypress/e2e/transferSettings.cy.ts deleted file mode 100644 index 123745ea865..00000000000 --- a/protocol-designer/cypress/e2e/transferSettings.cy.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { SetupSteps, SetupVerifications } from '../support/SetupSteps' -import { StepExecutor } from '../support/StepBuilder' -import { UniversalSteps } from '../support/UniversalSteps' - -describe('Transfer stepform testing Single Channel - Happy Path', () => { - beforeEach(() => { - cy.visit('/') - cy.verifyHomePage() - cy.closeAnalyticsModal() - }) - - it('Goes through onboarding flow and then single-channel one-to-one transfer', () => { - cy.clickCreateNew() - cy.verifyCreateNewHeader() - const se = new StepExecutor() - se.execute(SetupVerifications.OnStep1()) - se.execute(SetupVerifications.FlexSelected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectOT2()) - se.execute(SetupVerifications.OT2Selected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectFlex()) - se.execute(SetupVerifications.FlexSelected()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupVerifications.OnStep2()) - se.execute(SetupSteps.SingleChannelPipette50()) - se.execute(SetupVerifications.StepTwo50uL()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.Save()) - se.execute(SetupVerifications.StepTwoPart3()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupVerifications.OnStep3()) - se.execute(SetupSteps.YesGripper()) - se.execute(SetupSteps.NoThermocycler()) - se.execute(SetupSteps.NoWasteChute()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupVerifications.Step4Verification()) - se.execute(SetupSteps.AddThermocycler()) - se.execute(SetupSteps.AddHeaterShaker()) - se.execute(SetupSteps.AddMagBlock()) - se.execute(SetupSteps.AddTempdeck2()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupSteps.Confirm()) - se.execute(SetupSteps.EditProtocolA()) - se.execute(SetupSteps.ChoseDeckSlot('C2')) - se.execute(SetupSteps.OpenSelectLabwareModal()) - se.execute(SetupSteps.ClickWellPlatesSection()) - se.execute(SetupSteps.SelectLabwareByDisplayName('Bio-Rad 96 Well Plate')) - se.execute(SetupSteps.ChoseDeckSlotC2Labware()) - se.execute(SetupSteps.AddHardwareLabware()) - se.execute(SetupSteps.AddLiquid()) - se.execute(SetupSteps.ClickLiquidButton()) - se.execute(SetupSteps.DefineLiquid()) - se.execute(SetupSteps.LiquidSaveWIP()) - se.execute(SetupSteps.WellSelector(['A1', 'A2'])) - se.execute(SetupSteps.LiquidDropdown()) - se.execute(SetupVerifications.LiquidPage()) - se.execute(UniversalSteps.Snapshot()) - se.execute(SetupSteps.SelectLiquidWells()) - se.execute(SetupSteps.SetVolumeAndSaveForWells('150')) - se.execute(SetupSteps.SelectDone()) - se.execute(SetupSteps.ChoseDeckSlot('C3')) - se.execute(SetupSteps.OpenSelectLabwareModal()) - se.execute(SetupSteps.ClickWellPlatesSection()) - se.execute(SetupSteps.SelectLabwareByDisplayName('Bio-Rad 96 Well Plate')) - se.execute(SetupSteps.AddStep()) - se.execute(SetupVerifications.TransferPopOut()) - se.execute(UniversalSteps.Snapshot()) - }) -}) diff --git a/protocol-designer/cypress/e2e/urlNavigation.cy.ts b/protocol-designer/cypress/e2e/urlNavigation.cy.ts deleted file mode 100644 index 7db02571929..00000000000 --- a/protocol-designer/cypress/e2e/urlNavigation.cy.ts +++ /dev/null @@ -1,16 +0,0 @@ -describe('URL Navigation', () => { - it('settings', () => { - cy.visit('#/settings') - cy.verifySettingsPage() - }) - it('createNew', () => { - cy.visit('#/createNew') - // directly navigating sends you back to the home page - cy.verifyOnboardingPage() - }) - it('overview', () => { - cy.visit('#/overview') - // directly navigating sends you back to the home page - cy.verifyHomePage() - }) -}) diff --git a/protocol-designer/cypress/fixtures/garbage.txt b/protocol-designer/cypress/fixtures/garbage.txt deleted file mode 100644 index 56993df39af..00000000000 --- a/protocol-designer/cypress/fixtures/garbage.txt +++ /dev/null @@ -1 +0,0 @@ -Not a protocol diff --git a/protocol-designer/cypress/fixtures/generic_96_tiprack_200ul.json b/protocol-designer/cypress/fixtures/generic_96_tiprack_200ul.json deleted file mode 100644 index ed53e437da0..00000000000 --- a/protocol-designer/cypress/fixtures/generic_96_tiprack_200ul.json +++ /dev/null @@ -1,1014 +0,0 @@ -{ - "ordering": [ - ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], - ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], - ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], - ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], - ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], - ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], - ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], - ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], - ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], - ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"], - ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"], - ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] - ], - "brand": { - "brand": "generic" - }, - "metadata": { - "displayName": "Custom 200µL Tiprack", - "displayCategory": "tipRack", - "displayVolumeUnits": "µL", - "tags": [] - }, - "dimensions": { - "xDimension": 127.76, - "yDimension": 85.48, - "zDimension": 70 - }, - "wells": { - "A1": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 14.38, - "y": 74.24, - "z": 38 - }, - "B1": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 14.38, - "y": 65.24, - "z": 38 - }, - "C1": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 14.38, - "y": 56.24, - "z": 38 - }, - "D1": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 14.38, - "y": 47.24, - "z": 38 - }, - "E1": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 14.38, - "y": 38.24, - "z": 38 - }, - "F1": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 14.38, - "y": 29.24, - "z": 38 - }, - "G1": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 14.38, - "y": 20.24, - "z": 38 - }, - "H1": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 14.38, - "y": 11.24, - "z": 38 - }, - "A2": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 23.38, - "y": 74.24, - "z": 38 - }, - "B2": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 23.38, - "y": 65.24, - "z": 38 - }, - "C2": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 23.38, - "y": 56.24, - "z": 38 - }, - "D2": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 23.38, - "y": 47.24, - "z": 38 - }, - "E2": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 23.38, - "y": 38.24, - "z": 38 - }, - "F2": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 23.38, - "y": 29.24, - "z": 38 - }, - "G2": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 23.38, - "y": 20.24, - "z": 38 - }, - "H2": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 23.38, - "y": 11.24, - "z": 38 - }, - "A3": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 32.38, - "y": 74.24, - "z": 38 - }, - "B3": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 32.38, - "y": 65.24, - "z": 38 - }, - "C3": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 32.38, - "y": 56.24, - "z": 38 - }, - "D3": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 32.38, - "y": 47.24, - "z": 38 - }, - "E3": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 32.38, - "y": 38.24, - "z": 38 - }, - "F3": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 32.38, - "y": 29.24, - "z": 38 - }, - "G3": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 32.38, - "y": 20.24, - "z": 38 - }, - "H3": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 32.38, - "y": 11.24, - "z": 38 - }, - "A4": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 41.38, - "y": 74.24, - "z": 38 - }, - "B4": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 41.38, - "y": 65.24, - "z": 38 - }, - "C4": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 41.38, - "y": 56.24, - "z": 38 - }, - "D4": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 41.38, - "y": 47.24, - "z": 38 - }, - "E4": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 41.38, - "y": 38.24, - "z": 38 - }, - "F4": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 41.38, - "y": 29.24, - "z": 38 - }, - "G4": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 41.38, - "y": 20.24, - "z": 38 - }, - "H4": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 41.38, - "y": 11.24, - "z": 38 - }, - "A5": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 50.38, - "y": 74.24, - "z": 38 - }, - "B5": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 50.38, - "y": 65.24, - "z": 38 - }, - "C5": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 50.38, - "y": 56.24, - "z": 38 - }, - "D5": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 50.38, - "y": 47.24, - "z": 38 - }, - "E5": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 50.38, - "y": 38.24, - "z": 38 - }, - "F5": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 50.38, - "y": 29.24, - "z": 38 - }, - "G5": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 50.38, - "y": 20.24, - "z": 38 - }, - "H5": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 50.38, - "y": 11.24, - "z": 38 - }, - "A6": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 59.38, - "y": 74.24, - "z": 38 - }, - "B6": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 59.38, - "y": 65.24, - "z": 38 - }, - "C6": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 59.38, - "y": 56.24, - "z": 38 - }, - "D6": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 59.38, - "y": 47.24, - "z": 38 - }, - "E6": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 59.38, - "y": 38.24, - "z": 38 - }, - "F6": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 59.38, - "y": 29.24, - "z": 38 - }, - "G6": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 59.38, - "y": 20.24, - "z": 38 - }, - "H6": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 59.38, - "y": 11.24, - "z": 38 - }, - "A7": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 68.38, - "y": 74.24, - "z": 38 - }, - "B7": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 68.38, - "y": 65.24, - "z": 38 - }, - "C7": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 68.38, - "y": 56.24, - "z": 38 - }, - "D7": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 68.38, - "y": 47.24, - "z": 38 - }, - "E7": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 68.38, - "y": 38.24, - "z": 38 - }, - "F7": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 68.38, - "y": 29.24, - "z": 38 - }, - "G7": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 68.38, - "y": 20.24, - "z": 38 - }, - "H7": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 68.38, - "y": 11.24, - "z": 38 - }, - "A8": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 77.38, - "y": 74.24, - "z": 38 - }, - "B8": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 77.38, - "y": 65.24, - "z": 38 - }, - "C8": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 77.38, - "y": 56.24, - "z": 38 - }, - "D8": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 77.38, - "y": 47.24, - "z": 38 - }, - "E8": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 77.38, - "y": 38.24, - "z": 38 - }, - "F8": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 77.38, - "y": 29.24, - "z": 38 - }, - "G8": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 77.38, - "y": 20.24, - "z": 38 - }, - "H8": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 77.38, - "y": 11.24, - "z": 38 - }, - "A9": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 86.38, - "y": 74.24, - "z": 38 - }, - "B9": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 86.38, - "y": 65.24, - "z": 38 - }, - "C9": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 86.38, - "y": 56.24, - "z": 38 - }, - "D9": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 86.38, - "y": 47.24, - "z": 38 - }, - "E9": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 86.38, - "y": 38.24, - "z": 38 - }, - "F9": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 86.38, - "y": 29.24, - "z": 38 - }, - "G9": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 86.38, - "y": 20.24, - "z": 38 - }, - "H9": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 86.38, - "y": 11.24, - "z": 38 - }, - "A10": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 95.38, - "y": 74.24, - "z": 38 - }, - "B10": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 95.38, - "y": 65.24, - "z": 38 - }, - "C10": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 95.38, - "y": 56.24, - "z": 38 - }, - "D10": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 95.38, - "y": 47.24, - "z": 38 - }, - "E10": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 95.38, - "y": 38.24, - "z": 38 - }, - "F10": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 95.38, - "y": 29.24, - "z": 38 - }, - "G10": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 95.38, - "y": 20.24, - "z": 38 - }, - "H10": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 95.38, - "y": 11.24, - "z": 38 - }, - "A11": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 104.38, - "y": 74.24, - "z": 38 - }, - "B11": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 104.38, - "y": 65.24, - "z": 38 - }, - "C11": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 104.38, - "y": 56.24, - "z": 38 - }, - "D11": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 104.38, - "y": 47.24, - "z": 38 - }, - "E11": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 104.38, - "y": 38.24, - "z": 38 - }, - "F11": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 104.38, - "y": 29.24, - "z": 38 - }, - "G11": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 104.38, - "y": 20.24, - "z": 38 - }, - "H11": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 104.38, - "y": 11.24, - "z": 38 - }, - "A12": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 113.38, - "y": 74.24, - "z": 38 - }, - "B12": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 113.38, - "y": 65.24, - "z": 38 - }, - "C12": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 113.38, - "y": 56.24, - "z": 38 - }, - "D12": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 113.38, - "y": 47.24, - "z": 38 - }, - "E12": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 113.38, - "y": 38.24, - "z": 38 - }, - "F12": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 113.38, - "y": 29.24, - "z": 38 - }, - "G12": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 113.38, - "y": 20.24, - "z": 38 - }, - "H12": { - "depth": 32, - "shape": "circular", - "diameter": 5.2, - "totalLiquidVolume": 20, - "x": 113.38, - "y": 11.24, - "z": 38 - } - }, - "groups": [ - { - "metadata": {}, - "wells": [ - "A1", - "B1", - "C1", - "D1", - "E1", - "F1", - "G1", - "H1", - "A2", - "B2", - "C2", - "D2", - "E2", - "F2", - "G2", - "H2", - "A3", - "B3", - "C3", - "D3", - "E3", - "F3", - "G3", - "H3", - "A4", - "B4", - "C4", - "D4", - "E4", - "F4", - "G4", - "H4", - "A5", - "B5", - "C5", - "D5", - "E5", - "F5", - "G5", - "H5", - "A6", - "B6", - "C6", - "D6", - "E6", - "F6", - "G6", - "H6", - "A7", - "B7", - "C7", - "D7", - "E7", - "F7", - "G7", - "H7", - "A8", - "B8", - "C8", - "D8", - "E8", - "F8", - "G8", - "H8", - "A9", - "B9", - "C9", - "D9", - "E9", - "F9", - "G9", - "H9", - "A10", - "B10", - "C10", - "D10", - "E10", - "F10", - "G10", - "H10", - "A11", - "B11", - "C11", - "D11", - "E11", - "F11", - "G11", - "H11", - "A12", - "B12", - "C12", - "D12", - "E12", - "F12", - "G12", - "H12" - ] - } - ], - "parameters": { - "format": "96Standard", - "isTiprack": true, - "tipLength": 32, - "isMagneticModuleCompatible": false, - "loadName": "generic_96_tiprack_20ul" - }, - "namespace": "custom_beta", - "version": 1, - "schemaVersion": 2, - "cornerOffsetFromSlot": { - "x": 0, - "y": 0, - "z": 0 - } -} diff --git a/protocol-designer/cypress/fixtures/invalid_json.txt b/protocol-designer/cypress/fixtures/invalid_json.txt deleted file mode 100644 index 8bb73b625d0..00000000000 --- a/protocol-designer/cypress/fixtures/invalid_json.txt +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "Test Protocol", - "version": 1.0, - "steps": [ - { "step1": "initialize" }, - { "step2": "execute" - ] \ No newline at end of file diff --git a/protocol-designer/cypress/fixtures/invalid_labware.json b/protocol-designer/cypress/fixtures/invalid_labware.json deleted file mode 100644 index 35c9e2f0c65..00000000000 --- a/protocol-designer/cypress/fixtures/invalid_labware.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "ordering": [["A1"]], - "brand": { - "brand": "Onboarding", - "brandId": ["Onboarding1"] - }, - "metadata": { - "displayName": "Onboarding 1 Well Plate 1000 µL", - "displayCategory": "wellPlate", - "displayVolumeUnits": "µL", - "tags": [] - }, - "dimensions": { - "xDimension": 127.5, - "yDimension": 85, - "zDimension": 20 - }, - "wells": { - "A1": { - "depth": 10, - "totalLiquidVolume": 1000, - "shape": "rectangular", - "xDimension": 20, - "yDimension": 20, - "x": 63.75, - "y": 42.5, - "z": 10 - } - }, - "groups": [ - { - "metadata": { - "displayName": "Onboarding 1 Well Plate 1000 µL", - "displayCategory": "wellPlate", - "wellBottomShape": "v" - }, - "brand": { - "brand": "Onboarding", - "brandId": ["Onboarding1"] - }, - "wells": ["A1"] - } - ], - "parameters": { - "format": "irregular", - "quirks": [], - "isTiprack": false, - "isMagneticModuleCompatible": false, - "loadName": "onboarding_1_wellplate_1000ul" - }, - "namespace": "custom_beta", - "version": 1, - "schemaVersion": 2, - "cornerOffsetFromSlot": { - "x": 0, - "y": 0, - "z": 0 - } -} diff --git a/protocol-designer/cypress/fixtures/invalid_tip_rack.json b/protocol-designer/cypress/fixtures/invalid_tip_rack.json deleted file mode 100644 index 3bed4b3f0b5..00000000000 --- a/protocol-designer/cypress/fixtures/invalid_tip_rack.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "message": "invalid tip rack" -} diff --git a/protocol-designer/cypress/fixtures/invalid_tip_rack.txt b/protocol-designer/cypress/fixtures/invalid_tip_rack.txt deleted file mode 100644 index 78c831cf5d6..00000000000 --- a/protocol-designer/cypress/fixtures/invalid_tip_rack.txt +++ /dev/null @@ -1 +0,0 @@ -This is not a valid file type for a custom tip rack. diff --git a/protocol-designer/cypress/support/Import.ts b/protocol-designer/cypress/support/Import.ts deleted file mode 100644 index cddf4c84e96..00000000000 --- a/protocol-designer/cypress/support/Import.ts +++ /dev/null @@ -1,57 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { TestFile } from './TestFiles' - -export const ContentStrings = { - newLabwareDefs: 'Update protocol to use new labware definitions', - v8_1: 'The default dispense height is now 1 mm from the bottom of the well', - v8_5: 'Your protocol will be automatically updated to the latest version. We recommend making a separate copy of your file before importing.', - noBehaviorChange: - 'We have added new features since the last time this protocol was updated, but have not made any changes to existing protocol behavior', - exportButton: 'Export JSON', - continueButton: 'continue', - continueWithExport: 'Continue with export', - migrationModal: - 'Your protocol was made in an older version of Protocol Designer', - confirmButton: 'Confirm', - cancelButton: 'Cancel', - importButton: 'Import', - protocolMetadata: 'Protocol Metadata', - instruments: 'Instruments', - liquidDefinitions: 'Liquid Definitions', - protocolStartingDeck: 'Protocol Starting Deck', -} - -export const LocatorStrings = { - modalShellArea: '[aria-label="ModalShell_ModalArea"]', - exportProtocol: `button:contains(${ContentStrings.exportButton})`, - continueButton: `button:contains(${ContentStrings.continueButton})`, -} - -export const verifyOldProtocolModal = (): void => { - cy.get(LocatorStrings.modalShellArea) - .should('exist') - .should('be.visible') - .within(() => { - cy.contains(ContentStrings.migrationModal) - .should('exist') - .and('be.visible') - cy.contains(ContentStrings.importButton).should('be.visible') - cy.contains(ContentStrings.cancelButton).should('be.visible') - cy.contains(ContentStrings.importButton).click({ force: true }) - }) -} - -export const verifyImportProtocolPage = (protocol: TestFile): void => { - cy.readFile(protocol.path).then(protocolRead => { - cy.contains(ContentStrings.protocolMetadata).should('be.visible') - cy.contains(ContentStrings.instruments).should('be.visible') - cy.contains(ContentStrings.protocolStartingDeck).should('be.visible') - if (!protocolRead.metadata.protocolName) { - cy.contains('Some name!').should('be.visible') - } else { - cy.contains(String(protocolRead.metadata.protocolName)).should( - 'be.visible' - ) - } - }) -} diff --git a/protocol-designer/cypress/support/MixSteps.ts b/protocol-designer/cypress/support/MixSteps.ts deleted file mode 100644 index 5810d497c41..00000000000 --- a/protocol-designer/cypress/support/MixSteps.ts +++ /dev/null @@ -1,692 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { StepThunk } from './StepBuilder' - -enum MixContent { - Move = 'Move', - Transfer = 'Transfer', - Mix = 'Mix', - Pause = 'Pause', - HeaterShaker = 'Heater-shaker', - Thermocyler = 'Thermocycler', - Pipette = 'Pipette', - Tiprack = 'Tiprack', - Labware = 'Labware', - SelectWells = 'Select wells', - VolumePerWell = 'Volume per well', - MixRepetitions = 'Mix repetitions', - TipManagement = 'Tip management', - TipDropLocation = 'Tip drop location', - ChooseOption = 'Choose option', - Reservoir = 'Axygen 1 Well Reservoir 90 mL', - WellPlate = 'Opentrons Tough 96 Well Plate 200 µL PCR Full Skirt', - PartOne = 'Part 1 / 4', - PartTwo = 'Part 2 / 4', - PartThree = 'Part 3 / 4', - ApplyLiquidClass = 'Apply liquid class settings for this mix', - WellSelectTitle = 'Select wells using a Flex 1-Channel 1000 µL', - ClickAndDragWellSelect = 'Click and drag to select wells', - PipettePreselect = 'Flex 1-Channel 1000 µL', - TiprackPreselect = 'Opentrons Flex 96 Tip Rack 1000 µL', - BeforeEveryAsp = 'Always', - OnceAtStartStep = 'Once', - PerSourceWell = 'Per source', - PerDestWell = 'Per destination', - Never = 'Never', - WasteChute = 'Waste chute', - AspFlowRate = 'Aspirate flow rate', - AspWellOrder = 'Aspirate well order', - MixTipPosition = 'Mix tip position', - AdvancedPipSettings = 'Advanced pipetting settings', - Delay = 'Delay', - DelayDuration = 'Delay duration', - DispFlowRate = 'Dispense flow rate', - Blowout = 'Blowout', - TouchTip = 'Touch tip', - TopBottomLeRi = 'Top to bottom, Left to right', - EditWellOrder = 'Edit well order', - WellOrderDescrip = 'Change how the robot moves from well to well.', - PrimaryOrder = 'Primary order', - TopToBottom = 'Top to bottom', - BottomToTop = 'Bottom to top', - LeftToRight = 'Left to right', - RightToLeft = 'Right to left', - Then = 'then', - SecondaryOrder = 'Secondary order', - Cancel = 'Cancel', - EditMixTipPos = 'Edit mix tip position', - MixTipPosDescr = 'Change from where in the well the robot aspirates and dispenses during the mix.', - Xposition = 'X position', - Yposition = 'Y position', - Zposition = 'Z position', - StartingWellPos = 'Well position: X 0 Y 0 Z 1 (mm)', - TopView = 'Top view', - SideView = 'Side view', - BlowoutLocation = 'Blowout location', - BlowoutPos = 'Blowout position from top', - DestinationWell = 'Destination well', - BlowoutFlowRate = 'Blowout position from top', - EditBlowoutPos = 'Edit blowout position', - BlowoutPosDescrip = 'Change where in the well the robot performs the blowout.', - EditTouchTipPos = 'Edit touch tip position', - TouchTipDescrip = 'Change from where in the well the robot performs the touch tip.', - TouchTipPos = 'Touch tip position from bottom', - NameStep = 'Name step', - StepName = 'Step Name', - StepNotes = 'Step Notes', - CypressTest = 'Cypress Mix Test', - TouchTipFromTop = 'Touch tip position from top', - PushOut = 'Push out', - PushOutVolume = 'Push out volume', -} - -enum MixLocators { - Continue = 'button:contains("Continue")', - GoBack = 'button:contains("Go back")', - Back = 'button:contains("Back")', - WellInputField = '[name="wells"]', - Save = 'button:contains("Save")', - OneWellReservoirImg = '[data-wellname="A1"]', - Volume = '[name="volume"]', - MixReps = '[name="times"]', - Aspirate = 'button:contains("Aspirate")', - Dispense = 'button:contains("Dispense")', - AspFlowRateInput = '[name="aspirate_flowRate"]', - AspWellOrder = '[data-testid="WellsOrderField_ListButton_aspirate"]', - ResetToDefault = 'button:contains("Reset to default")', - PrimaryOrderDropdown = 'div[tabindex="0"].sc-bqWxrE jKLbYH iFjNDq', - CancelAspSettings = 'button:contains("Back to overview")', - MixTipPos = '[data-testid="PositionField_ListButton_mix"]', - XpositionInput = '[data-testid="TipPositionModal_x_custom_input"]', - YpositionInput = '[id="TipPositionModal_y_custom_input"]', - ZpositionInput = '[id="TipPositionModal_z_custom_input"]', - SwapView = 'button:contains("Swap view")', - Checkbox = '[class="Flex-sc-1qhp8l7-0 Checkbox___StyledFlex3-sc-1mvp7vt-0 gZwGCw btdgeU"]', - AspirateDelaySecondsInput = '[name="aspirate_delay_seconds"]', - DispenseDelaySecondsInput = '[name="dispense_delay_seconds"]', - DispFlowRate = '[name="dispense_flowRate"]', - BlowoutLtnDropdown = '[data-testid="dropdownMenu"]', - BlowoutFlowRate = '[name="blowout_flowRate"]', - BlowoutPos = '[id="TipPositionField_blowout_z_offset"]', - BlowoutZPosition = '[data-testid="TipPositionModal_custom_input"]', - PosFromBottom = '[id="TipPositionField_mix_touchTip_mmFromBottom"]', - RenameBtn = 'button:contains("Rename")', - StepNameInput = '[name="stepName_input"]', - StepNotesInput = '[data-testid="TextAreaField"]', - PosFromTop = '[data-testid="TipPositionField_mix_touchTip_mmFromTop"]', - PushOutVolumeInput = '[name="pushOut_volume"]', -} - -/** - * Each function returns a StepThunk - * Add a comment to all records - */ -export const MixSteps = { - /** - * "Select Mix" - */ - SelectMix: (): StepThunk => ({ - call: () => { - cy.get('button').contains('Mix').click() - }, - }), - - /** - * "Select on deck labware" - */ - SelectLabware: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.ChooseOption).should('be.visible').click() - cy.contains(MixContent.WellPlate).should('be.visible').click() - }, - }), - - /** - * "Select wells" - */ - SelectWellInputField: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.WellInputField).should('be.visible').click() - }, - }), - - /** - * "Enter a valid volume to mix" - */ - EnterVolume: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.Volume).should('exist').type('100') - }, - }), - - /** - * "Enter number of repetitions to mix" - */ - EnterMixReps: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.MixReps).should('exist').type('5') - }, - }), - - /** - * "Select how/if tips should be picked up for each mix" - */ - SelectTipHandling: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.BeforeEveryAsp) - .should('exist') - .should('be.visible') - .click() - cy.contains(MixContent.OnceAtStartStep) - .should('exist') - .should('be.visible') - cy.contains(MixContent.PerSourceWell) - .scrollIntoView() - .should('exist') - .should('be.visible') - cy.contains(MixContent.PerDestWell) - .scrollIntoView() - .should('exist') - .should('be.visible') - cy.contains(MixContent.Never).should('exist').should('be.visible') - cy.contains(MixContent.OnceAtStartStep).click() - }, - }), - - /** - * "Select aspirate flow rate settings" - */ - AspirateFlowRate: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.Aspirate).should('exist').should('be.visible').click() - cy.get(MixLocators.AspFlowRateInput).should('exist') - cy.get(MixLocators.AspFlowRateInput).type('{selectAll}{backspace}100') - }, - }), - - /** - * "Open well aspirate well order pop out" - */ - AspWellOrder: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.TopBottomLeRi).should('exist').should('be.visible') - cy.get(MixLocators.AspWellOrder).click() - }, - }), - - /** - * "Edit tip position for executing mix step" - */ - AspMixTipPos: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.StartingWellPos) - .should('exist') - .should('be.visible') - cy.get(MixLocators.MixTipPos).click() - cy.get(MixLocators.XpositionInput).type('{selectAll}{backspace}2') - cy.get(MixLocators.YpositionInput).type('{selectAll}{backspace}2') - cy.get(MixLocators.ZpositionInput).type('{selectAll}{backspace}4') - cy.get(MixLocators.ResetToDefault).click() - cy.get(MixLocators.XpositionInput).type('{selectAll}{backspace}2') - cy.get(MixLocators.YpositionInput).type('{selectAll}{backspace}2') - cy.get(MixLocators.ZpositionInput).type('{selectAll}{backspace}5') - cy.contains(MixContent.Cancel).should('exist').should('be.visible') - }, - }), - - /** - * "Check box for delay and input value" - */ - Delay: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.Delay).should('exist').should('be.visible') - cy.get(MixLocators.Checkbox) - .should('exist') - .should('be.visible') - .eq(0) - .click() - cy.contains(MixContent.DelayDuration).should('exist').should('be.visible') - - // Try to find any delay seconds input that exists - cy.get('body').then($body => { - if ($body.find('[name="aspirate_delay_seconds"]').length > 0) { - cy.get(MixLocators.AspirateDelaySecondsInput) - .should('exist') - .should('be.visible') - .should('have.prop', 'value') - cy.get(MixLocators.AspirateDelaySecondsInput).type( - '{selectAll}{backspace}5' - ) - } else if ($body.find('[name="dispense_delay_seconds"]').length > 0) { - cy.get(MixLocators.DispenseDelaySecondsInput) - .should('exist') - .should('be.visible') - .should('have.prop', 'value') - cy.get(MixLocators.DispenseDelaySecondsInput).type( - '{selectAll}{backspace}5' - ) - } else { - // Fallback to the generic selector - cy.get('[name$="_delay_seconds"]') - .should('exist') - .should('be.visible') - .should('have.prop', 'value') - cy.get('[name$="_delay_seconds"]') - .first() - .type('{selectAll}{backspace}5') - } - }) - }, - }), - - PushOut: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.PushOut).should('exist').should('be.visible') - cy.get(MixLocators.Checkbox) - .should('exist') - .should('be.visible') - .eq(0) - .click() - cy.contains(MixContent.PushOutVolume).should('exist').should('be.visible') - cy.get(MixLocators.PushOutVolumeInput) - .should('exist') - .should('be.visible') - .should('have.prop', 'value') - cy.get(MixLocators.PushOutVolumeInput) - .eq(0) - .type('{selectAll}{backspace}5') - }, - }), - - /** - * "Select dispense settings" - */ - Dispense: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.Dispense).should('exist').should('be.visible').click() - }, - }), - - /** - * "Select dispense flow rate settings" - */ - DispenseFlowRate: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.Dispense).should('exist').should('be.visible').click() - cy.get(MixLocators.DispFlowRate).should('exist') - cy.get(MixLocators.DispFlowRate).type('{selectAll}{backspace}300') - }, - }), - - /** - * "Select blowout settings" - */ - BlowoutLocation: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.Blowout).should('exist').should('be.visible') - cy.get(MixLocators.Checkbox) - .should('exist') - .should('be.visible') - .eq(0) - .click() - cy.contains(MixContent.ChooseOption).should('exist').should('be.visible') - cy.get(MixLocators.BlowoutLtnDropdown) - .should('exist') - .should('be.visible') - .click() - cy.contains(MixContent.WasteChute).should('exist').should('be.visible') - cy.contains(MixContent.DestinationWell) - .should('exist') - .should('be.visible') - .click() - }, - }), - - /** - * "Enter value for blow out flow rate" - */ - BlowoutFlowRate: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.BlowoutFlowRate) - .should('exist') - .should('be.visible') - .should('have.prop', 'value') - cy.get(MixLocators.BlowoutFlowRate).click() - cy.get(MixLocators.BlowoutFlowRate).type('{selectAll}{backspace}300') - }, - }), - - /** - * "Select a blow out position from top of well" - */ - BlowoutPosFromTop: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.BlowoutPos) - .should('exist') - .should('be.visible') - .should('have.prop', 'value') - cy.get(MixLocators.BlowoutPos).click({ force: true }) - cy.get(MixLocators.BlowoutZPosition).type('{selectAll}{backspace}4') - cy.get(MixLocators.ResetToDefault).click() - cy.get(MixLocators.BlowoutZPosition).type('{selectAll}{backspace}-3') - }, - }), - - /** - * "Select touch tip settings" - */ - TouchTip: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.Checkbox) - .should('exist') - .should('be.visible') - .eq(0) - .click() - cy.get(MixLocators.PosFromTop) - .should('exist') - .should('be.visible') - .should('have.prop', 'value') - cy.get(MixLocators.PosFromTop).click({ force: true }) - cy.get(MixLocators.BlowoutZPosition).type('{selectAll}{backspace}2') - cy.get(MixLocators.ResetToDefault).click() - cy.get(MixLocators.BlowoutZPosition).type('{selectAll}{backspace}-7') - }, - }), - - /** - * "Save" - */ - Save: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.Save) - .should('exist') - .should('be.visible') - .first() - .click({ force: true }) - }, - }), - - /** - * "Go back" - */ - Back: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.Back).should('exist').should('be.visible').click() - }, - }), - - /** - * "Continue" - */ - Continue: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.Continue) - .should('exist') - .should('be.visible') - .click({ force: true }) - }, - }), - - /** - * "Rename Mix step" - */ - Rename: (): StepThunk => ({ - call: () => { - cy.get(MixLocators.RenameBtn).should('exist').should('be.visible').click() - cy.contains(MixContent.NameStep).should('exist').should('be.visible') - cy.contains(MixContent.StepName).should('exist').should('be.visible') - cy.get(MixLocators.StepNameInput).should('have.value', 'Mix') - cy.contains(MixContent.StepNotes).should('exist').should('be.visible') - cy.get(MixLocators.StepNameInput) - .first() - .type('{selectAll}{backspace}Cypress Mix Test') - cy.get(MixLocators.StepNotesInput).type( - 'This is testing cypress automation in PD' - ) - cy.contains(MixContent.Cancel).should('exist').should('be.visible') - }, - }), -} - -/** - * MixVerifications: Each function returns a StepThunk, with a doc comment - * showing the original string from the MixVerifications enum on the right side of the "=". - */ -export const MixVerifications = { - /** - * "Verify Part 1, the configuration of mix settings, and check continue button" - */ - PartOne: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.PartOne).should('exist').should('be.visible') - cy.contains(MixContent.Mix).should('exist').should('be.visible') - cy.contains(MixContent.Pipette).should('exist').should('be.visible') - cy.contains(MixContent.PipettePreselect) - .should('exist') - .should('be.visible') - cy.contains(MixContent.Tiprack).should('exist').should('be.visible') - cy.contains(MixContent.TiprackPreselect) - .should('exist') - .should('be.visible') - cy.contains(MixContent.Labware).should('exist').should('be.visible') - cy.contains(MixContent.SelectWells).should('exist').should('be.visible') - cy.contains(MixContent.VolumePerWell).should('exist').should('be.visible') - cy.contains(MixContent.MixRepetitions) - .should('exist') - .should('be.visible') - cy.get(MixLocators.Continue).should('exist').should('be.visible') - }, - }), - - PartTwo: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.PartTwo).should('exist').should('be.visible') - cy.contains(MixContent.Mix).should('exist').should('be.visible') - cy.contains(MixContent.ApplyLiquidClass) - .should('exist') - .should('be.visible') - cy.get(MixLocators.Continue).should('exist').should('be.visible') - }, - }), - - /** - * "Verify labware image and available wells" - */ - WellSelectPopout: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.WellSelectTitle) - .should('exist') - .should('be.visible') - cy.contains(MixContent.ClickAndDragWellSelect) - .should('exist') - .should('be.visible') - cy.get(MixLocators.OneWellReservoirImg) - .should('exist') - .should('be.visible') - cy.get(MixLocators.Continue).should('exist').should('be.visible') - cy.get(MixLocators.Back).should('exist').should('be.visible') - }, - }), - - /** - * "Verify Part 2, the configuration of asp settings and check go back and save button" - */ - PartThreeAsp: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.PartThree).should('exist').should('be.visible') - cy.contains(MixContent.Mix).should('exist').should('be.visible') - cy.get(MixLocators.Aspirate).should('exist').should('be.visible') - cy.contains(MixContent.AspFlowRate).should('exist').should('be.visible') - cy.contains(MixContent.AspWellOrder).should('exist').should('be.visible') - cy.contains(MixContent.MixTipPosition) - .should('exist') - .should('be.visible') - cy.contains(MixContent.AdvancedPipSettings) - .should('exist') - .should('be.visible') - cy.contains(MixContent.Delay).should('exist').should('be.visible') - cy.get(MixLocators.Back).should('exist').should('be.visible') - cy.get(MixLocators.Continue).should('exist').should('be.visible') - }, - }), - - /** - * "Verify pop out for well order during aspirate" - */ - AspWellOrder: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.EditWellOrder).should('exist').should('be.visible') - cy.contains(MixContent.WellOrderDescrip) - .should('exist') - .should('be.visible') - cy.contains(MixContent.PrimaryOrder).should('exist').should('be.visible') - cy.contains(MixContent.TopToBottom) - .should('exist') - .should('be.visible') - .click() - cy.contains(MixContent.BottomToTop).should('exist').should('be.visible') - cy.contains(MixContent.LeftToRight).should('exist').should('be.visible') - cy.contains(MixContent.RightToLeft).should('exist').should('be.visible') - cy.contains(MixContent.BottomToTop).click() - cy.contains(MixContent.Then).should('exist').should('be.visible') - cy.contains(MixContent.SecondaryOrder) - .should('exist') - .should('be.visible') - cy.contains(MixContent.LeftToRight).click() - cy.contains(MixContent.RightToLeft).click() - cy.get(MixLocators.ResetToDefault).click() - cy.contains(MixContent.TopToBottom).should('exist').should('be.visible') - cy.contains(MixContent.LeftToRight).should('exist').should('be.visible') - cy.get(MixLocators.CancelAspSettings).should('exist').should('be.visible') - cy.get(MixLocators.Continue).should('exist').should('be.visible') - }, - }), - - /** - * "Verify pop out for mix tip position during aspirate" - */ - AspMixTipPos: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.EditMixTipPos).should('exist').should('be.visible') - cy.contains(MixContent.MixTipPosDescr) - .should('exist') - .should('be.visible') - cy.contains(MixContent.SideView).should('exist').should('be.visible') - cy.get(MixLocators.SwapView).should('exist').should('be.visible').click() - cy.contains(MixContent.TopView).should('exist').should('be.visible') - cy.contains(MixContent.Xposition).should('exist').should('be.visible') - cy.get(MixLocators.XpositionInput).should('exist').should('be.visible') - cy.get(MixLocators.XpositionInput).should('have.prop', 'value') - cy.contains(MixContent.Yposition).should('exist').should('be.visible') - cy.get(MixLocators.YpositionInput).should('exist').should('be.visible') - cy.get(MixLocators.YpositionInput).should('have.prop', 'value') - cy.contains(MixContent.Zposition).should('exist').should('be.visible') - cy.get(MixLocators.ZpositionInput).should('exist').should('be.visible') - cy.get(MixLocators.ZpositionInput).should('have.prop', 'value') - cy.get(MixLocators.ResetToDefault).should('exist').should('be.visible') - cy.get(MixLocators.CancelAspSettings).should('exist').should('be.visible') - cy.get(MixLocators.Save) - .should('exist') - .should('be.visible') - .first() - .click() - }, - }), - - /** - * "Verify Part 2, the configuration of dispense settings and check go back and save button" - */ - PartThreeDisp: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.PartThree).should('exist').should('be.visible') - cy.contains(MixContent.Mix).should('exist').should('be.visible') - cy.get(MixLocators.Aspirate).should('exist').should('be.visible') - cy.get(MixLocators.Dispense).should('exist').should('be.visible') - cy.contains(MixContent.DispFlowRate).should('exist').should('be.visible') - cy.get(MixLocators.DispFlowRate).should('have.prop', 'value') - cy.contains(MixContent.AdvancedPipSettings) - .should('exist') - .should('be.visible') - cy.contains(MixContent.Delay).should('exist').should('be.visible') - cy.contains(MixContent.Blowout).should('exist').should('be.visible') - cy.contains(MixContent.TouchTip).should('exist').should('be.visible') - }, - }), - - /** - * "Verify blow out settings" - */ - Blowout: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.Blowout).should('exist').should('be.visible') - cy.contains(MixContent.BlowoutLocation) - .should('exist') - .should('be.visible') - cy.contains(MixContent.BlowoutFlowRate) - .should('exist') - .should('be.visible') - cy.get(MixLocators.BlowoutFlowRate).should('have.prop', 'value') - cy.contains(MixContent.BlowoutPos).should('exist').should('be.visible') - cy.get(MixLocators.BlowoutPos).should('have.prop', 'value') - }, - }), - - /** - * "Verify blow out position and pop out" - */ - BlowoutPopout: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.EditBlowoutPos) - .should('exist') - .should('be.visible') - cy.contains(MixContent.BlowoutPosDescrip) - .should('exist') - .should('be.visible') - cy.contains(MixContent.Zposition).should('exist').should('be.visible') - cy.get(MixLocators.BlowoutZPosition).should('have.prop', 'value') - cy.contains(MixContent.Cancel).should('exist').should('be.visible') - cy.get(MixLocators.ResetToDefault).should('exist').should('be.visible') - cy.get(MixLocators.Save).should('exist').should('be.visible') - }, - }), - - /** - * "Verify touch tip settings" - */ - TouchTip: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.TouchTip).should('exist').should('be.visible') - cy.contains(MixContent.TouchTipFromTop) - .should('exist') - .should('be.visible') - cy.get(MixLocators.PosFromTop).should('have.prop', 'value') - }, - }), - - /** - * "Verify touch tip pop out" - */ - TouchTipPopout: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.EditTouchTipPos) - .should('exist') - .should('be.visible') - cy.contains(MixContent.TouchTipDescrip) - .should('exist') - .should('be.visible') - cy.contains(MixContent.Zposition).should('exist').should('be.visible') - cy.get(MixLocators.BlowoutZPosition).should('have.prop', 'value') - cy.contains(MixContent.Cancel).should('exist').should('be.visible') - cy.get(MixLocators.ResetToDefault).should('exist').should('be.visible') - cy.get(MixLocators.Save).should('exist').should('be.visible') - }, - }), - - /** - * "Verify that Mix Step was successfully renamed to "Cypress Test"" - */ - Rename: (): StepThunk => ({ - call: () => { - cy.contains(MixContent.CypressTest).should('exist').should('be.visible') - }, - }), -} diff --git a/protocol-designer/cypress/support/ModuleSteps.ts b/protocol-designer/cypress/support/ModuleSteps.ts deleted file mode 100644 index e5b09da1b32..00000000000 --- a/protocol-designer/cypress/support/ModuleSteps.ts +++ /dev/null @@ -1,200 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { StepThunk } from './StepBuilder' - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - chooseDeckSlot: (slot: string) => Cypress.Chainable - } - } -} - -export enum ModLocators { - DoneButtonLabwareSelection = '[data-testid="Toolbox_confirmButton"]', - Div = 'div', - Button = 'button', - TempdeckTempInput = 'input[name="targetTemperature"]', -} -export enum ModContent { - ModState = 'Heat or cool', - DeactivateTempDeck = 'Off', - Temperature = 'Temperature', - Save = 'Save', - Temp4CVerification = `Build a pause step to wait until Temperature Module GEN2 reaches 4°C`, - PlateReader = 'Absorbance Plate Reader Module GEN1', -} - -/** - * Each function returns a StepThunk - * Add a comment to all records - */ -export const ModuleSteps = { - /** - * Select "Done" on a step form. - */ - Done: (): StepThunk => ({ - call: () => { - cy.get(ModLocators.DoneButtonLabwareSelection) - .contains('Done') - .click({ force: true }) - }, - }), - - /** - * Selects the "Temperature Module" step. - */ - AddTemperatureStep: (): StepThunk => ({ - call: () => { - cy.contains('button', 'Temperature').click({ force: true }) - }, - }), - - /** - * Activates Temperature Module when first used. - */ - ActivateTempdeck: (): StepThunk => ({ - call: () => { - cy.contains(ModContent.DeactivateTempDeck) - cy.get('[data-testid="ToggleButton_Off"]').click() - }, - }), - - /** - * Inputs 4°C into tempdeck. - */ - InputTempDeck4: (): StepThunk => ({ - call: () => { - cy.get(ModLocators.TempdeckTempInput).type('4') - }, - }), - - /** - * Inputs 95°C into tempdeck. - */ - InputTempDeck95: (): StepThunk => ({ - call: () => { - cy.get(ModLocators.TempdeckTempInput).type('95') - }, - }), - - /** - * Inputs 100°C into tempdeck; expects an error (then exit). - */ - InputTempDeck100: (): StepThunk => ({ - call: () => { - cy.get(ModLocators.TempdeckTempInput).type('100') - }, - }), - - /** - * Exits a tempdeck command (no operation). - */ - ExitTempdeckCommand: (): StepThunk => ({ - call: () => { - // No operation required - }, - }), - - /** - * Pause protocol until the temperature is reached. - */ - PauseAfterSettingTempdeck: (): StepThunk => ({ - call: () => { - cy.contains(ModLocators.Button, 'Add pause step') - .should('exist') - .and('be.visible') - .click() - }, - }), - - /** - * Saves a temperature set (click "Save" button). - */ - SaveButtonTempdeck: (): StepThunk => ({ - call: () => { - cy.contains(ModContent.Save).click({ force: true }) - }, - }), - MoveToPlateReader: (): StepThunk => ({ - call: () => { - cy.contains(ModContent.PlateReader).click() - }, - }), - StartPlateReaderStep: (): StepThunk => ({ - call: () => { - cy.contains('Absorbance Plate Reader').click() - }, - }), - DefineInitilizationSingleCheckAll: (): StepThunk => ({ - call: () => { - // Goes through all the wavelengths - cy.contains('450 nm (blue)').click() - cy.contains('562 nm (green)').click() - cy.contains('562 nm (green)').click() - cy.contains('600 nm (orange)').click() - cy.contains('600 nm (orange)').click() - cy.contains('650 nm (red)').click() - cy.contains('650 nm (red)').click() - cy.contains('Other').click() - }, - }), - DefineCustomWavelegthSingle: (wavelength: string): StepThunk => ({ - call: () => { - // Goes through all the wavelengths - cy.contains('Custom wavelength') - .parents() - .find('input.InputField__StyledInput-sc-1gyyvht-0') // ToDo please find better selector - .type('500') - }, - }), -} - -/** - * These are your "verifications" as StepThunks. - */ -export const ModuleVerifications = { - NoMoveToPlateReaderWhenClosed: (): StepThunk => ({ - call: () => { - cy.contains('Absorbance Plate Reader Module lid closed') - cy.contains( - 'This step tries to use labware in the Absorbance Plate Reader. Open the lid before this step.' - ) - }, - }), - - TempeDeckInitialForm: (): StepThunk => ({ - call: () => { - cy.contains(ModContent.ModState) - cy.contains(ModContent.DeactivateTempDeck) - cy.contains(ModContent.Temperature) - }, - }), - - Temp4CPauseTextVerification: (): StepThunk => ({ - call: () => { - cy.contains('div', 'Pausing until') - .should('contain', 'Temperature Module GEN2') - .and('contain', 'reaches') - .find('[data-testid="Tag_default"]') - .should('contain', '4°C') - }, - }), - - PlateReaderPart1NoInitilization: (): StepThunk => ({ - call: () => { - cy.contains('Define initialization settings') - cy.contains('Change lid position') - cy.contains('Current initialization settings') - cy.contains('No settings defined') - }, - }), - PlateReaderPart2NoInitilization: (): StepThunk => ({ - call: () => { - cy.contains('Select mode type') - cy.contains('Single') - cy.contains('Multi') - cy.contains('Add reference wavelength?') - }, - }), -} diff --git a/protocol-designer/cypress/support/SetupSteps.ts b/protocol-designer/cypress/support/SetupSteps.ts deleted file mode 100644 index cb93f13c4e2..00000000000 --- a/protocol-designer/cypress/support/SetupSteps.ts +++ /dev/null @@ -1,1180 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { StepThunk } from './StepBuilder' -import { UniversalSteps } from './UniversalSteps' // Adjust the path - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - chooseDeckSlot: (slot: string) => Cypress.Chainable - } - } -} - -export enum SetupContent { - Step1Title = 'Step 1', - Step2Title = 'Step 2', - Step3Title = 'Step3', - Step4Title = 'Step4', - Cancel = 'Cancel', - AddPipette = 'Add a pipette', - NinetySixChannel = '96-Channel', - SingleChannel = '1-Channel', - EightChannel = '8-Channel', - TipRack = 'Filter Tip Rack 50 µL', - PipetteType = 'Pipette type', - PipetteVolume = 'Pipette volume', - FullP50SingleName = 'Flex 1-Channel 50 µL', - FullP50TiprackName = 'Opentrons Flex 96 Filter Tip Rack 50 µL', - GoBack = 'Go back', - Confirm = 'Confirm', - Camera = 'Camera', - OpentronsFlex = 'Opentrons Flex', - OpentronsOT2 = 'Opentrons OT-2', - LetsGetStarted = 'Let’s start with the basics', - WhatKindOfRobot = 'What kind of robot do you have?', - Volume50 = '50 µL', - Volume1000 = '1000 µL', - FilterTiprack50 = 'Filter Tip Rack 50 µL', - Tiprack50 = 'Tip Rack 50 µL', - Yes = 'Yes', - No = 'No', - Thermocycler = 'Thermocycler Module GEN2', - HeaterShaker = 'Heater-Shaker Module GEN1', - Tempdeck2 = 'Temperature Module GEN2', - MagBlock = 'Magnetic Block GEN1', - PlateReader = 'Absorbance Plate Reader Module GEN1', - ModulePageH = 'Configure your deck hardware', - ModulePageB = 'Place the modules and fixtures that you are using for this protocol onto the deck.', - EditProtocol = 'Edit protocol', - EditSlot = 'Edit slot', - AddLabwareToDeck = 'Add labware', - EditHardwareLabwareOnDeck = 'Edit labware', - LabwareH = 'Labware', - WellPlatesCat = 'Well plates', - AddLiquid = 'Add liquid', - DefineALiquid = 'Define a liquid', - LiquidButton = 'Liquids', - SampleLiquidName = 'My liquid!', - ProtocolSteps = 'Protocol steps', - AddStep = 'Add Step', - NestDeepWell = 'NEST 96 Deep Well Plate 2 mL', - Save = 'Save', -} - -export enum SetupLocators { - Confirm = 'button:contains("Confirm")', - GoBack = 'button:contains("Go back")', - Step1Indicator = 'p:contains("Step 1")', - Step2Indicator = 'p:contains("Step 2")', - FlexOption = 'button:contains("Opentrons Flex")', - OT2Option = 'button:contains("Opentrons OT-2")', - NinetySixChannel = 'div:contains("96-Channel")', - ThermocyclerImage = 'img[alt="thermocyclerModuleType"]', - MagblockImage = 'img[alt="magneticBlockType"]', - HeaterShakerImage = 'img[alt="heaterShakerModuleType"]', - TemperatureModuleImage = 'img[alt="temperatureModuleType"]', - LiquidNameInput = 'input[name="displayName"]', - ModalShellArea = 'div[aria-label="ModalShell_ModalArea"]', - SaveButton = 'button[type="submit"]', - LiquidsDropdown = '[data-testid="dropdownMenu"]', - Div = 'div', - Button = 'button', - TempdeckTempInput = 'input[name="targetTemperature"]', - DoneButtonLabwareSelection = '[data-testid="Toolbox_confirmButton"]', - - AspirateWells = 'input[name="aspirate_wells"]', - div = 'div', - button = 'button', - svg = 'svg', - exist = 'exist', - StepOptionsTestIDThreeDots = '[data-testid="StepContainer_OverflowBtn"]:visible', - AspirateCheckbox = 'div.Checkbox___StyledFlex3-sc-1mvp7vt-0.gZwGCw.btdgeU', -} - -export const RegexSetupContent = { - slotText: /(Add|Edit) labware/i, -} - -/** - * Helper function to select a labware by display name. - * No longer clicks "Done" after selecting. - */ -function selectLabwareByDisplayName(displayName: string): void { - cy.contains(displayName).click({ force: true }) -} -/** - * chooseDeckSlot is a helper returning a chainable - * that finds the correct deck slot based on x,y coords in your markup. - */ -function chooseDeckSlot(slot: string): Cypress.Chainable> { - const deckSlots: Record< - | 'A1' - | 'A2' - | 'A3' - | 'B1' - | 'B2' - | 'B3' - | 'C1' - | 'C2' - | 'C3' - | 'D1' - | 'D2' - | 'D3', - () => Cypress.Chainable> - > = { - A1: () => cy.contains('[data-testid="A1"]', RegexSetupContent.slotText), - A2: () => cy.contains('[data-testid="A2"]', RegexSetupContent.slotText), - A3: () => cy.contains('[data-testid="A3"]', RegexSetupContent.slotText), - B1: () => cy.contains('[data-testid="B1"]', RegexSetupContent.slotText), - B2: () => cy.contains('[data-testid="B2"]', RegexSetupContent.slotText), - B3: () => cy.contains('[data-testid="B3"]', RegexSetupContent.slotText), - C1: () => cy.contains('[data-testid="C1"]', RegexSetupContent.slotText), - C2: () => cy.contains('[data-testid="C2"]', RegexSetupContent.slotText), - C3: () => cy.contains('[data-testid="C3"]', RegexSetupContent.slotText), - D1: () => cy.contains('[data-testid="D1"]', RegexSetupContent.slotText), - D2: () => cy.contains('[data-testid="D2"]', RegexSetupContent.slotText), - D3: () => cy.contains('[data-testid="D3"]', RegexSetupContent.slotText), - } - - const slotAction = deckSlots[slot as keyof typeof deckSlots] - - if (typeof slotAction === 'function') { - return slotAction() - } else { - throw new Error(`Slot ${slot} not found in deck slots.`) - } -} - -/** - * Helper function to select multiple wells (like A1, B3, H12). - */ -function selectWells(wells: string[]): void { - const wellSelectors: Record< - string, - () => Cypress.Chainable> - > = {} - - // Dynamically populate (A1..H12) - const rows = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] - const columns = Array.from({ length: 12 }, (_, i) => (i + 1).toString()) - - rows.forEach(row => { - columns.forEach(column => { - const wellName = `${row}${column}` - wellSelectors[wellName] = () => - cy.get(`circle[data-wellname="${wellName}"]`).click({ force: true }) - }) - }) - - wells.forEach(well => { - const wellAction = wellSelectors[well] - if (typeof wellAction === 'function') { - wellAction() - } else { - throw new Error(`Well ${well} not found.`) - } - }) -} - -/** - * Each function returns a StepThunk - * Add a comment to all records - */ -export const SetupSteps = { - /** - * Select a labware by display name, then click "Done". - */ - SelectLabwareByDisplayName: (displayName: string): StepThunk => ({ - call: () => { - selectLabwareByDisplayName(displayName) - cy.get('button[data-testid="SelectLabwareModal_confirm"]').click() - cy.get(SetupLocators.DoneButtonLabwareSelection).click({ force: true }) - }, - }), - - selectDropdownLabware: (displayName: string): StepThunk => ({ - call: () => { - selectLabwareByDisplayName(displayName) - }, - }), - - /** - * Select the Opentrons Flex option. - */ - SelectFlex: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.OpentronsFlex).should('be.visible').click() - }, - }), - - /** - * Select the Opentrons OT-2 option. - */ - SelectOT2: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.OpentronsOT2).should('be.visible').click() - }, - }), - - /** - * Click "Confirm". - */ - Confirm: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.Confirm).should('be.visible').click() - }, - }), - - /** - * Click "Go back". - */ - GoBack: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.GoBack).should('be.visible').click() - }, - }), - - /** - * Select a single-channel pipette with volume 50 µL. - */ - SingleChannelPipette50: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.AddPipette).click() - cy.contains('label', SetupContent.SingleChannel) - .should('exist') - .and('be.visible') - .click() - cy.contains(SetupContent.Volume50).click() - cy.contains(SetupContent.Tiprack50).click() - // optional: cy.contains(SetupContent.FilterTiprack50).click() - }, - }), - - /** - * Add a Thermocycler Module GEN2. - */ - AddThermocycler: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.Thermocycler) - cy.get('button[data-testid="Thermocycler Module GEN2"]').click() - }, - }), - - /** - * Add a Heater-Shaker Module GEN1. - */ - AddHeaterShaker: (): StepThunk => ({ - call: () => { - cy.get('button[data-testid="D1"]').click() - cy.get('button[data-testid="Modules"]').click() - cy.contains(SetupContent.HeaterShaker).click() - cy.get('button[data-testid="Heater-Shaker Module GEN1"]').click() - }, - }), - - /** - * Add a Temperature Module GEN2. - */ - AddTempdeck2: (): StepThunk => ({ - call: () => { - cy.get('button[data-testid="C1"]').click() - cy.get('button[data-testid="Modules"]').click() - cy.contains(SetupContent.Tempdeck2).click() - cy.get('button[data-testid="Temperature Module GEN2"]').click() - }, - }), - - /** - * Add a Magnetic Block GEN1. - */ - AddMagBlock: (): StepThunk => ({ - call: () => { - cy.get('button[data-testid="B2"]').click() - cy.contains(SetupContent.MagBlock).click() - cy.get('button[data-testid="Magnetic Block GEN1"]').click() - }, - }), - - /** - * Click "Yes" for gripper. - */ - YesGripper: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.Yes).click() - }, - }), - - /** - * Click "No" for gripper. - */ - NoGripper: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.No).click() - }, - }), - - /** - * Click "No" for thermocycler. - */ - NoThermocycler: (): StepThunk => ({ - call: () => { - cy.get('[data-testid="BasicsButtons_thermocycler_no"]').click() - }, - }), - - /** - * Click "No" for wasteChute. - */ - NoWasteChute: (): StepThunk => ({ - call: () => { - cy.get('[data-testid="BasicsButtons_wasteChute_no"]').click() - }, - }), - - AddPlateReader: (): StepThunk => ({ - call: () => { - cy.get('button[data-testid="D3"]').click() - cy.get('button[data-testid="Modules"]').click() - cy.contains(SetupContent.PlateReader).click() - cy.get( - 'button[data-testid="Absorbance Plate Reader Module GEN1"]' - ).click() - }, - }), - - /** - * Click "Edit protocol". - */ - EditProtocolA: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.EditProtocol).click() - }, - }), - - /** - * Choose deck slot A1. - */ - ChoseDeckSlotA1: (): StepThunk => ({ - call: () => { - chooseDeckSlot('A1').click() - }, - }), - - /** - * Choose deck slot A2. - */ - ChoseDeckSlotA2: (): StepThunk => ({ - call: () => { - chooseDeckSlot('A2').click() - }, - }), - - /** - * Choose deck slot A3. - */ - ChoseDeckSlotA3: (): StepThunk => ({ - call: () => { - chooseDeckSlot('A3').click() - }, - }), - - ChoseDeckSlotC2Labware: (): StepThunk => ({ - call: () => { - chooseDeckSlot('C2') - .find('a[role="button"]') - .contains(RegexSetupContent.slotText) - .click({ force: true }) - }, - }), - /** - * Choose deck slot. - */ - ChoseDeckSlot: (deckSlot: string): StepThunk => ({ - call: () => { - chooseDeckSlot(deckSlot).click() - }, - }), - - /** - * Adds labware to a deck slot. - */ - AddHardwareLabware: (): StepThunk => ({ - call: () => { - cy.get('button[data-testid="SlotOverflowMenu_openTools"]').click() - }, - }), - - /** - * Clicks the "Labware" header. - */ - ClickLabwareHeader: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.LabwareH).click() - }, - }), - - /** - * Clicks the "Well plates" section. - */ - ClickWellPlatesSection: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.WellPlatesCat).click() - }, - }), - - /** - * Open SelectLabwareModal to a deck slot. - */ - OpenSelectLabwareModal: (): StepThunk => ({ - call: () => { - cy.get('button[data-testid="EmptySelectorButton_click"]').click() - }, - }), - - /** - * Choose deck slot C2 with a labware-locating approach. - */ - - ChoseDeckSlotLabware: (deckslot: string): StepThunk => ({ - call: () => { - chooseDeckSlot(deckslot).click({ force: true }) - }, - }), - - ChoseDeckSlotWithLabware: (deckslot: string): StepThunk => ({ - call: () => { - chooseDeckSlot(deckslot) - .contains(RegexSetupContent.slotText) - .click({ force: true }) - }, - }), - - /** - * Clicks the "Add liquid" button. - */ - AddLiquid: (): StepThunk => ({ - call: () => { - cy.get('button[data-testid="LabwareCard_addLiquid_button"]').click() - }, - }), - /** - * Start making a move step - */ - - AddMoveStep: (): StepThunk => ({ - call: () => { - cy.contains('button', 'Move').should('be.visible').click() - }, - }), - /** - * Select gripper to move with - */ - - UseGripperinMove: (): StepThunk => ({ - call: () => { - cy.contains('button', 'Use gripper').should('be.visible').click() - }, - }), - /** - * Select gripper to move labware - */ - - MoveToPlateReader: (): StepThunk => ({ - call: () => { - cy.contains('button', 'Use gripper').should('be.visible').click() - }, - }), - - /** - * Clicks the "Liquid" button. - */ - ClickLiquidButton: (): StepThunk => ({ - call: () => { - cy.contains('button', SetupContent.LiquidButton).click() - }, - }), - - /** - * Clicks the "Define a liquid" button. - */ - DefineLiquid: (): StepThunk => ({ - call: () => { - cy.contains('button', SetupContent.DefineALiquid).click() - }, - }), - - /** - * Type a sample liquid name, then save. - */ - LiquidSaveWIP: (): StepThunk => ({ - call: () => { - cy.get(SetupLocators.LiquidNameInput).type(SetupContent.SampleLiquidName) - - cy.get(SetupLocators.ModalShellArea) - .find('form') - .invoke('submit', (e: SubmitEvent) => { - e.preventDefault() - }) - - cy.get(SetupLocators.ModalShellArea) - .find(SetupLocators.SaveButton) - .contains(SetupContent.Save) - .click({ force: true }) - }, - }), - - /** - * Select an array of wells (A1, B2, etc.) - */ - WellSelector: (wells: string[]): StepThunk => ({ - call: () => { - if (Array.isArray(wells) && wells.length > 0) { - selectWells(wells) - } else { - throw new Error('Wells must be a non-empty array of strings.') - } - }, - }), - - /** - * Opens the liquids dropdown. - */ - LiquidDropdown: (): StepThunk => ({ - call: () => { - cy.get(SetupLocators.LiquidsDropdown).should('be.visible').click() - }, - }), - - /** - * Select "My liquid!" from the dropdown. - */ - SelectLiquidWells: (): StepThunk => ({ - call: () => { - cy.contains('My liquid!').click() - }, - }), - - /** - * Sets volume then saves and clicks "Done". - */ - SetVolumeAndSaveForWells: (volume: string): StepThunk => ({ - call: () => { - cy.get('input[name="volume"]').type(volume, { force: true }) - cy.contains('button', SetupContent.Save).click() - cy.contains('button', 'Done').click({ force: true }) - }, - }), - - /** - * Clicks "Protocol steps" header. - */ - ProtocolStepsH: (): StepThunk => ({ - call: () => { - cy.contains('button', SetupContent.ProtocolSteps).click() - }, - }), - - /** - * Click the "Add Step" button. - */ - AddStep: (): StepThunk => ({ - call: () => { - cy.contains('button', SetupContent.AddStep).click({ force: true }) - }, - }), - - /** - * Clicks "Adapters" (presumably in a labware context). - */ - AddAdapters: (): StepThunk => ({ - call: () => { - cy.contains('Adapters').click() - }, - }), - - /** - * Selects "Opentrons 96 Deep Well Temperature Module Adapter". - */ - DeepWellTempModAdapter: (): StepThunk => ({ - call: () => { - cy.contains('Opentrons 96 Deep Well Temperature Module Adapter').click() - }, - }), - - /** - * Adds "NEST 96 Deep Well Plate 2 mL". - */ - AddNest96DeepWellPlate: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.NestDeepWell).click() - }, - }), - - /** - * Click "Done" on a step form. - */ - SelectDone: (): StepThunk => ({ - call: () => { - cy.get(SetupLocators.DoneButtonLabwareSelection) - .contains('Done') - .click({ force: true }) - }, - }), - - /** - * Click "Cancel". - */ - Cancel: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.Cancel).should('be.visible').click() - }, - }), - - /** - * Chose source labware on a step form - */ - ChoseSourceLabware: (): StepThunk => ({ - call: () => { - cy.contains('p', 'Choose option').closest('div[tabindex="0"]').click() - }, - }), - - // Chose source to move labware on a stepform - ChoseSourceMoveLabware: (): StepThunk => ({ - call: () => { - cy.contains('Choose option').eq(0).click() - }, - }), - // Chose destination to move labware - ChoseDestinationMoveLabware: (): StepThunk => ({ - call: () => { - cy.contains('Choose option').click() - }, - }), - // Chose labware being moved to - ChoseDestinationLabware: (): StepThunk => ({ - call: () => { - cy.contains('Choose option').click() - }, - }), - // Add source labware on stepform - AddSourceLabwareDropdown: (): StepThunk => ({ - call: () => { - cy.contains('Source labware') - .parents() - .contains('Choose option') - .should('be.visible') - .click() - }, - }), - - // Select destination wells - SelectSourceWells: (): StepThunk => ({ - call: () => { - cy.get('input[name="aspirate_wells"]') - .should('have.value', 'Choose wells') - .click() - }, - }), - - // Select destination wells - SelectDestinationWells: (): StepThunk => ({ - call: () => { - cy.get('input[name="dispense_wells"]') - .should('have.value', 'Choose wells') - .click() - }, - }), - // Save selected wells - SaveSelectedWells: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.Save).click({ force: true }) - }, - }), - // Generic save button - Save: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.Save).click({ force: true }) - }, - }), - // ToDo Refactor to input any volume - - InputTransferVolume: (TransferVolume: string): StepThunk => ({ - call: () => { - cy.get('input[name="volume"]').type(TransferVolume) - }, - }), - // Continue to the next part of the transfer form - Continue: (): StepThunk => ({ - call: () => { - cy.contains('Continue').click({ force: true }) - }, - }), - - // ToDo @alexjoel42, please combine into one transfer - - // Step 1 Transfer form prewet checkbox - PrewetAspirate: (): StepThunk => ({ - call: () => { - cy.contains('Pre-wet tip') - .closest('div.Flex-sc-1qhp8l7-0.fJriNr') - .find(SetupLocators.AspirateCheckbox) - .click() - }, - }), - // Step 1 Transfer form Delay - - Delay: (): StepThunk => ({ - call: () => { - cy.contains('Delay') - .closest('div') - .find(SetupLocators.AspirateCheckbox) - .click() - }, - }), - // Step 1 Transfer form touch tip - TouchTipAspirate: (): StepThunk => ({ - call: () => { - cy.contains('Touch tip') - .closest('div') - .find(SetupLocators.AspirateCheckbox) - .click() - }, - }), - // Step 1 Transfer form mix checkbox - MixAspirate: (): StepThunk => ({ - call: () => { - cy.contains('Mix') - .closest('div') - .find(SetupLocators.AspirateCheckbox) - .click() - }, - }), - // Step 1 Transfer form airgap checkbox - AirGap: (): StepThunk => ({ - call: () => { - cy.contains('Air gap') - .closest('div') - .find(SetupLocators.AspirateCheckbox) - .click() - }, - }), - // Step 1 Transfer form mix volume - AspirateMixVolume: (MixAspirateVolume: string): StepThunk => ({ - call: () => { - cy.get('input[name = "aspirate_mix_volume"]').type(MixAspirateVolume) - }, - }), - - AspirateMixTimes: (MixTimesAspirate: string): StepThunk => ({ - call: () => { - cy.get('input[name = "aspirate_mix_times"]').type(MixTimesAspirate) - }, - }), - - AspirateAirGapVolume: (AirGapAspirateVolume: string): StepThunk => ({ - call: () => { - cy.get('input[name = "aspirate_airGap_volume"]').type( - AirGapAspirateVolume - ) - }, - }), - // Select dispense on the transfer form - - SelectDispense: (): StepThunk => ({ - call: () => { - cy.contains('Dispense').click() - }, - }), - // Dispense mix volume - DispenseMixVolume: (DispenseMixVolume: string): StepThunk => ({ - call: () => { - cy.get('input[name = "dispense_mix_volume"]').type(DispenseMixVolume) - }, - }), - - DispenseMixTimes: (): StepThunk => ({ - call: () => { - cy.get('input[name = "dispense_mix_times"]').type('2') - }, - }), - - DispenseAirGapVolume: (DispenseAirGapVolume: string): StepThunk => ({ - call: () => { - cy.get('input[name = "dispense_airGap_volume"]').type( - DispenseAirGapVolume - ) - }, - }), - - BlowoutTransferDestination: (): StepThunk => ({ - call: () => { - cy.contains('Blowout') - .closest('div.Flex-sc-1qhp8l7-0.ckuVEF') - .find('button[type="button"]') - .click() - cy.contains('Choose option').click() - cy.contains('Destination well').click() - }, - }), - - DeleteSteps: (): StepThunk => ({ - call: () => { - cy.get(SetupLocators.StepOptionsTestIDThreeDots).click() - cy.contains('Delete step').click() - cy.contains('button', 'Delete step').click() - }, - }), -} - -/** - * Each function returns a StepThunk - * Add a comment to all records - */ -export const SetupVerifications = { - /** - * Verify we are on Step 1. - */ - OnStep1: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.Step1Title).should('be.visible') - }, - }), - - /** - * Verify we are on Step 2, and the "Add a pipette" prompt is visible. - */ - OnStep2: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.AddPipette).should('be.visible') - }, - }), - - /** - * Verify the Opentrons Flex button is selected (blue background). - */ - FlexSelected: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.OpentronsFlex).click() - cy.contains(SetupContent.OpentronsFlex).should( - 'have.css', - 'background-color', - 'rgb(0, 108, 250)' - ) - }, - }), - - /** - * Verify the Opentrons OT-2 button is selected (blue background). - */ - OT2Selected: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.OpentronsOT2).should( - 'have.css', - 'background-color', - 'rgb(0, 108, 250)' - ) - }, - }), - - /** - * Verify 96-Channel option is visible. - */ - NinetySixChannel: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.AddPipette).click() - cy.contains(SetupContent.NinetySixChannel).should('be.visible') - }, - }), - - /** - * Verify 96-Channel option is *not* visible. - */ - NotNinetySixChannel: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.NinetySixChannel).should('not.exist') - }, - }), - - /** - * After selecting 50 µL, verify the volume/rack info is present. - */ - StepTwo50uL: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.PipetteVolume) - cy.contains(SetupContent.Volume50).should('be.visible') - cy.contains(SetupContent.Volume1000).should('be.visible') - cy.contains(SetupContent.Tiprack50).should('be.visible') - cy.contains(SetupContent.FilterTiprack50).should('be.visible') - }, - }), - - /** - * Verify we see the fully named pipette and tiprack, etc. - */ - StepTwoPart3: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.FullP50SingleName).should('be.visible') - cy.contains(SetupContent.FullP50TiprackName).should('be.visible') - cy.contains('Left Mount').should('be.visible') - cy.contains(SetupContent.AddPipette) - }, - }), - - /** - * Verify we are on Step 3: "Do you want to move labware automatically with the gripper?" - */ - OnStep3: (): StepThunk => ({ - call: () => { - cy.contains( - 'Do you want to move labware automatically with the gripper?' - ).should('be.visible') - cy.contains(SetupContent.Yes).should('be.visible') - cy.contains(SetupContent.No).should('be.visible') - }, - }), - - /** - * Verify Step 4: Module page is visible, with modules listed. - */ - Step4Verification: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.ModulePageH).should('be.visible') - cy.contains(SetupContent.ModulePageB).should('be.visible') - cy.get('button[data-testid="B1"]').click() - cy.get('button[data-testid="Modules"]').click() - cy.contains(SetupContent.Thermocycler).should('be.visible') - cy.contains(SetupContent.HeaterShaker).should('be.visible') - cy.contains(SetupContent.MagBlock).should('be.visible') - cy.contains(SetupContent.Tempdeck2).should('be.visible') - }, - }), - - /** - * Verify the Thermocycler image is visible. - */ - ThermocyclerImg: (): StepThunk => ({ - call: () => { - cy.get(SetupLocators.ThermocyclerImage).should('be.visible') - }, - }), - - /** - * Verify the Heater-Shaker image is visible. - */ - HeaterShakerImg: (): StepThunk => ({ - call: () => { - cy.get(SetupLocators.HeaterShakerImage).should('be.visible') - }, - }), - - /** - * Verify the Temperature Module GEN2 content is visible. - */ - Tempdeck2Img: (): StepThunk => ({ - call: () => { - cy.contains(SetupContent.Tempdeck2).should('be.visible') - }, - }), - - /** - * Verify the Liquid page content is visible. - */ - LiquidPage: (): StepThunk => ({ - call: () => { - cy.contains('Liquid').should('be.visible') - cy.contains('Add liquid').should('be.visible') - cy.contains('Liquid volume by well').should('be.visible') - cy.contains('Cancel').should('be.visible') - }, - }), - - AbsorbanceNotSelectable: (): StepThunk => ({ - call: () => { - cy.get('button[data-testid="D3"]').click() - cy.get('button[data-testid="Modules"]').click() - cy.contains(SetupContent.PlateReader) - cy.get('[data-testid="ModalHeader_icon_close_Add to Slot D3"]').click() - }, - }), - - /** - * Verify you can open the "Transfer" pop-out panel. - */ - TransferPopOut: (): StepThunk => ({ - call: () => { - cy.contains('button', 'Transfer').should('be.visible').click() - cy.contains('Source labware') - cy.contains('Select source wells') - cy.contains('Destination labware') - cy.contains('Volume per well') - }, - }), - - Delay: (): StepThunk => ({ - // Verifies that the "Delay" button has an associated SVG icon with proper attributes - call: () => { - cy.contains('Delay') - .closest('div[data-testid="ListItem_default"]') - .find('path[aria-roledescription="ot-checkbox"]') - }, - }), - - PreWet: (): StepThunk => ({ - // Verifies that the "Pre-wet tip" button has an associated SVG icon with proper attributes - call: () => { - cy.contains('PreWet') - .closest('div[data-testid="ListButton_default"]') - .find('path[aria-roledescription="ot-checkbox"]') - }, - }), - - TouchTip: (): StepThunk => ({ - // Verifies that the "Touch tip" button has an associated SVG icon with proper attributes - call: () => { - cy.contains('Touch tip') - .closest('div[data-testid="ListItem_default"]') - .find('path[aria-roledescription="ot-checkbox"]') - }, - }), - - MixT: (): StepThunk => ({ - // Verifies that the "Mix" button has an associated SVG icon with proper attributes - call: () => { - cy.contains('Mix') - .closest('div[data-testid="ListItem_default"]') - .find('path[aria-roledescription="ot-checkbox"]') - }, - }), - - AirGap: (): StepThunk => ({ - // Verifies that the "Air gap" button has an associated SVG icon with proper attributes - call: () => { - cy.contains('Air gap') - .closest('div[data-testid="ListItem_default"]') - .find('path[aria-roledescription="ot-checkbox"]') - }, - }), - - ExtraDispenseTransfer: (): StepThunk => ({ - // Verifies that all key elements related to "Blowout" in transfer settings are present - call: () => { - cy.contains('Blowout location') - cy.contains('Blowout flow rate') - // cy.contains('Blowout position from top') - }, - }), - - /** - * Verify the Magnetic Block image is visible. - */ - MagBlockImg: (): StepThunk => ({ - call: () => { - cy.get(SetupLocators.MagblockImage).should('be.visible') - }, - }), -} - -/** - * Helper function that verifies the initial "Create Protocol" page content. - */ -export const verifyCreateProtocolPage = (): void => { - cy.contains(SetupContent.Step1Title).should('exist').should('be.visible') - cy.contains(SetupContent.LetsGetStarted).should('exist').should('be.visible') - cy.contains(SetupContent.WhatKindOfRobot).should('exist').should('be.visible') - cy.contains(SetupContent.OpentronsFlex).should('exist').should('be.visible') - cy.contains(SetupContent.OpentronsOT2).should('exist').should('be.visible') - cy.contains(SetupContent.Confirm).should('exist').should('be.visible') -} - -/** - * Composite, multi-step operations bundled as individual StepThunks - */ -export const CompositeSetupSteps = { - /** - * Sets up a Flex protocol with optional modules - */ - FlexSetup: (options: { - thermocycler?: boolean - heatershaker?: boolean - magblock?: boolean - tempdeck?: boolean - plateReader?: boolean - }): StepThunk => ({ - call: () => { - const thermocycler = options.thermocycler ?? false - const heatershaker = options.heatershaker ?? false - const magblock = options.magblock ?? false - const tempdeck = options.tempdeck ?? false - const plateReader = options.plateReader ?? false - cy.log(`Running FlexSetup with options: ${JSON.stringify(options)}`) - SetupVerifications.OnStep1().call() - SetupVerifications.FlexSelected().call() - UniversalSteps.Snapshot().call() - SetupSteps.SelectOT2().call() - SetupVerifications.OT2Selected().call() - UniversalSteps.Snapshot().call() - SetupSteps.SelectFlex().call() - SetupVerifications.FlexSelected().call() - UniversalSteps.Snapshot().call() - SetupVerifications.OnStep2().call() - SetupSteps.SingleChannelPipette50().call() - SetupVerifications.StepTwo50uL().call() - UniversalSteps.Snapshot().call() - SetupSteps.Save().call() - SetupVerifications.StepTwoPart3().call() - UniversalSteps.Snapshot().call() - SetupVerifications.OnStep3().call() - SetupSteps.YesGripper().call() - SetupSteps.NoThermocycler().call() - SetupSteps.NoWasteChute().call() - SetupSteps.Confirm().call() - SetupVerifications.Step4Verification().call() - - if (thermocycler) { - SetupSteps.AddThermocycler().call() - } - - if (heatershaker) { - SetupSteps.AddHeaterShaker().call() - } - - if (magblock) { - SetupSteps.AddMagBlock().call() - } - - if (tempdeck) { - SetupSteps.AddTempdeck2().call() - } - - if (plateReader) { - SetupSteps.AddPlateReader().call() - } - - SetupSteps.Confirm().call() - SetupSteps.Confirm().call() - SetupSteps.EditProtocolA().call() - }, - }), - /** - * Adds labware to a specific deck slot - */ - AddLabwareToDeckSlot: ( - deckSlot?: string | undefined, - labwareName?: string | undefined - ): StepThunk => ({ - call: () => { - const slotToUse = deckSlot ?? 'C3' - const labwareToUse = labwareName ?? 'Bio-Rad 96 Well Plate' - cy.log( - `Running AddLabwareToDeckSlot with slot ${deckSlot} and labware ${labwareName}` - ) - SetupSteps.ChoseDeckSlotWithLabware(slotToUse).call() - // SetupSteps.AddHardwareLabware().call() - SetupSteps.OpenSelectLabwareModal().call() - SetupSteps.ClickWellPlatesSection().call() - SetupSteps.SelectLabwareByDisplayName(labwareToUse).call() - }, - }), -} diff --git a/protocol-designer/cypress/support/StepBuilder.ts b/protocol-designer/cypress/support/StepBuilder.ts deleted file mode 100644 index fc734900aa6..00000000000 --- a/protocol-designer/cypress/support/StepBuilder.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface StepThunk { - call: () => void -} - -// todo(mm, 2025-09-09): This indirection is not currently doing anything for us. -// Replace `stepExecutor.execute(Foo())` with just `foo()`? -export class StepExecutor { - execute(step: StepThunk): this { - step.call() - return this - } -} diff --git a/protocol-designer/cypress/support/TestFiles.ts b/protocol-designer/cypress/support/TestFiles.ts deleted file mode 100644 index 818148f8f3a..00000000000 --- a/protocol-designer/cypress/support/TestFiles.ts +++ /dev/null @@ -1,74 +0,0 @@ -import path from 'path' - -import { isEnumValue } from './utils' - -// //////////////////////////////////////////// -// This is the data section where we map all the protocol files -// This allows for IDE . completion and type checking -// //////////////////////////////////////////// - -export enum TestFilePath { - // Define the path relative to the protocol-designer directory - // PD root project fixtures - DoItAllV3MigratedToV6 = 'fixtures/protocol/6/doItAllV3MigratedToV6.json', - Mix_6_0_0 = 'fixtures/protocol/6/mix_6_0_0.json', - PreFlexGrandfatheredProtocolV6 = 'fixtures/protocol/6/preFlexGrandfatheredProtocolMigratedFromV1_0_0.json', - DoItAllV4MigratedToV6 = 'fixtures/protocol/6/doItAllV4MigratedToV6.json', - Example_1_1_0V6 = 'fixtures/protocol/6/example_1_1_0MigratedFromV1_0_0.json', - DoItAllV3MigratedToV7 = 'fixtures/protocol/7/doItAllV3MigratedToV7.json', - Mix_7_0_0 = 'fixtures/protocol/7/mix_7_0_0.json', - DoItAllV7 = 'fixtures/protocol/7/doItAllV7.json', - DoItAllV4MigratedToV7 = 'fixtures/protocol/7/doItAllV4MigratedToV7.json', - Example_1_1_0V7 = 'fixtures/protocol/7/example_1_1_0MigratedFromV1_0_0.json', - MinimalProtocolOldTransfer = 'fixtures/protocol/1/minimalProtocolOldTransfer.json', - Example_1_1_0 = 'fixtures/protocol/1/example_1_1_0.json', - PreFlexGrandfatheredProtocolV1 = 'fixtures/protocol/1/preFlexGrandfatheredProtocol.json', - DoItAllV1 = 'fixtures/protocol/1/doItAll.json', - PreFlexGrandfatheredProtocolV4 = 'fixtures/protocol/4/preFlexGrandfatheredProtocolMigratedFromV1_0_0.json', - DoItAllV3V4 = 'fixtures/protocol/4/doItAllV3.json', - DoItAllV4V4 = 'fixtures/protocol/4/doItAllV4.json', - NinetySixChannelFullAndColumn = 'fixtures/protocol/8/ninetySixChannelFullAndColumn.json', - NewAdvancedSettingsAndMultiTemp = 'fixtures/protocol/8/newAdvancedSettingsAndMultiTemp.json', - Example_1_1_0V8 = 'fixtures/protocol/8/example_1_1_0MigratedToV8.json', - DoItAllV4MigratedToV8 = 'fixtures/protocol/8/doItAllV4MigratedToV8.json', - DoItAllV8 = 'fixtures/protocol/8/doItAllV8.json', - DoItAllV3MigratedToV8 = 'fixtures/protocol/8/doItAllV3MigratedToV8.json', - Mix_8_0_0 = 'fixtures/protocol/8/mix_8_0_0.json', - DoItAllV7MigratedToV8 = 'fixtures/protocol/8/doItAllV7MigratedToV8.json', - MixSettingsV5 = 'fixtures/protocol/5/mixSettings.json', - DoItAllV5 = 'fixtures/protocol/5/doItAllV5.json', - BatchEditV5 = 'fixtures/protocol/5/batchEdit.json', - MultipleLiquidsV5 = 'fixtures/protocol/5/multipleLiquids.json', - PreFlexGrandfatheredProtocolV5 = 'fixtures/protocol/5/preFlexGrandfatheredProtocolMigratedFromV1_0_0.json', - DoItAllV3V5 = 'fixtures/protocol/5/doItAllV3.json', - TransferSettingsV5 = 'fixtures/protocol/5/transferSettings.json', - Mix_5_0_X = 'fixtures/protocol/5/mix_5_0_x.json', - Example_1_1_0V5 = 'fixtures/protocol/5/example_1_1_0MigratedFromV1_0_0.json', - ThermocyclerOnOt2V7 = 'fixtures/protocol/7/thermocyclerOnOt2V7.json', - ThermocyclerOnOt2V7MigratedToV8 = 'fixtures/protocol/8/thermocyclerOnOt2V7MigratedToV8.json', - // cypress fixtures - GarbageTextFile = 'cypress/fixtures/garbage.txt', - Generic96TipRack200ul = 'cypress/fixtures/generic_96_tiprack_200ul.json', - InvalidLabware = 'cypress/fixtures/invalid_labware.json', - InvalidTipRack = 'cypress/fixtures/invalid_tip_rack.json', - InvalidTipRackTxt = 'cypress/fixtures/invalid_tip_rack.txt', - InvalidJson = 'cypress/fixtures/invalid_json.txt', // a file with invalid JSON may not have .json extension because cy.readfile will not read it. -} - -export interface TestFile { - path: string - downloadsFolder: string - basename: string -} - -export const getTestFile = (id: TestFilePath): TestFile => { - if (!isEnumValue([TestFilePath], [id])) { - throw new Error(`Invalid file path: ${id as string}`) - } - - return { - path: id.valueOf(), - basename: path.basename(id.valueOf()), - downloadsFolder: Cypress.config('downloadsFolder'), - } -} diff --git a/protocol-designer/cypress/support/Thermocycler.ts b/protocol-designer/cypress/support/Thermocycler.ts deleted file mode 100644 index 40b5b5bfe23..00000000000 --- a/protocol-designer/cypress/support/Thermocycler.ts +++ /dev/null @@ -1,647 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { StepThunk } from './StepBuilder' - -enum ThermoContent { - Cancel = 'Cancel', - Save = 'Save', - DeleteStep = 'Delete step', - DuplicateStep = 'Duplicate step', - EditStep = 'Edit step', - Thermocycler = 'Thermocycler', - Rename = 'Rename', - ChangeThermoState = 'Change Thermocycler state', - ProgramThermoProfile = 'Program a Thermocycler profile', - Continue = 'Continue', - PartNumber = ' / 2', - State = 'state', - Block = 'Block', - Lid = 'Lid', - Temperature = 'temperature', - Position = 'position', - Off = 'Off', - Active = 'Active', - Open = 'Open', - Closed = 'Closed', - ProgramProfile = 'Program a Thermocycler profile', - WellVolume = 'Well volume', - ProfileSteps = 'Profile steps', - NoProfileDefined = 'No profile defined', - EndingHold = 'Ending hold', - EditProfileSteps = 'Edit Thermocycler profile steps', - AddCycle = 'Add cycle', - AddStep = 'Add step', - NoStepsDefined = 'No steps defined', -} - -enum ThermoLocators { - Button = 'button', - Div = 'div', - ThermocyclerEditor = '[data-testid^="StepContainer"]', - StateBlockTempInput = '[name="blockTargetTemp"]', - StateLidTempInput = '[name="lidTargetTemp"]', - ListButton = '[data-testid="ListButton_noActive"]', - Back = 'button:contains("Back")', - Save = 'button:contains("Save")', - Cancel = 'button:contains("Cancel")', - Delete = 'button:contains("Delete")', - WellVolumeInput = '[name="profileVolume"]', - ProfileLidTempInput = '[name="profileTargetLidTemp"]', - BlockTargetTempHold = '[name="blockTargetTempHold"]', - LidTargetTempHold = '[name="lidTargetTempHold"]', - SelectorButton = '[data-testid="EmptySelectorButton_container"]', - AddCycleStep = 'button:contains("Add a cycle step")', - ButtonSwitch = 'button[role="switch"]', - CycleContainer = '[data-testid^="thermocyclerCycle"]', - ThermocyclerStepContainer = '[data-testid^="thermocyclerStep"]', - CycleStep = '[data-testid^="cycleStep"]', - DeleteCycleStepX = 'path[aria-roledescription="close"]', - AddStepInput = '[class^="InputField"]', - ModalContainer = '[aria-label="ModalShell_ModalArea"]', -} - -/** - * Each function returns a StepThunk - * Add a comment to all records - */ - -export const ThermocyclerEditor = { - /** - * - * "Select Thermocycler Delete Step" - */ - DeleteThermocyclerStep: (): StepThunk => ({ - call: () => { - cy.contains(ThermoContent.DeleteStep).should('be.visible').click() - }, - }), - - /** - * - * "Select Thermocycler Back Button" - */ - BackButton: (): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.Back).eq(1).should('be.visible').click() - }, - }), - - /** - * - * Click profile steps save button - */ - SaveProfileSteps: (): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.ModalContainer) - .find(ThermoLocators.Save) - .first() - .should('be.visible') - .click() - }, - }), - /** - * - * "Select Thermocycler Save Button" - */ - SaveButton: (): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.Save).first().should('be.visible').click() - }, - }), - /** - * - * "Select Thermocycler Edit Step" - */ - EditThermocyclerStep: (): StepThunk => ({ - call: () => { - cy.contains(ThermoContent.EditStep).click() - }, - }), - - /** - * - * "Function for selecting State or Profile" - */ - SelectProfileOrState: (partOption: string): StepThunk => ({ - call: () => { - if (partOption === 'state') { - cy.get('input[id="Change Thermocycler state"]').click({ force: true }) - cy.contains(ThermoContent.Continue).click({ force: true }) - } else if (partOption === 'profile') { - cy.get('input[id="Program a Thermocycler profile"]').click({ - force: true, - }) - cy.contains(ThermoContent.Continue).click({ force: true }) - } - }, - }), - - /** - * Activates or deactivates Block Temperature. - * - * @param input - 'active' to activate, 'deactivate' to deactivate the Block Temperature - * @returns Cypress StepThunk - */ - BlockTempOnOff: (input: 'on' | 'off'): StepThunk => ({ - call: () => { - const shouldBeActive = input === 'on' - - cy.contains(ThermoContent.Block) - .parents(ThermoLocators.ListButton) - .find(ThermoLocators.ButtonSwitch) - .as('blockTempSwitch') - - cy.get('@blockTempSwitch') - .should('have.attr', 'aria-checked', shouldBeActive ? 'false' : 'true') - .click() - - cy.get('@blockTempSwitch').should( - 'have.attr', - 'aria-checked', - shouldBeActive ? 'true' : 'false' - ) - }, - }), - - /** - * Activates or deactivates Lid Temperature. - * - * @param input - 'active' to activate, 'deactivate' to deactivate the Lid Temperature - * @returns Cypress StepThunk - */ - LidTempOnOff: (input: 'on' | 'off'): StepThunk => ({ - call: () => { - const shouldBeActive = input === 'on' - - cy.get(ThermoLocators.ListButton) - .contains(ThermoContent.Lid) - .parents(ThermoLocators.ListButton) - .find(ThermoLocators.ButtonSwitch) - .as('lidTempSwitch') - - cy.get('@lidTempSwitch') - .should('have.attr', 'aria-checked', shouldBeActive ? 'false' : 'true') - .click() - - cy.get('@lidTempSwitch').should( - 'have.attr', - 'aria-checked', - shouldBeActive ? 'true' : 'false' - ) - }, - }), - - /** - * - * "Open or Close the TC Lid" - */ - LidOpenClosed: (input: string): StepThunk => ({ - call: () => { - if (input === 'open') { - cy.get(ThermoLocators.ListButton) - .contains('Lid position') - .parents(ThermoLocators.ListButton) - .find(ThermoLocators.ButtonSwitch) - .as('lidPositionSwitch') // alias the element for safe reuse - cy.get('@lidPositionSwitch') - .should('have.attr', 'aria-checked', 'false') - .click() - cy.get('@lidPositionSwitch').should('have.attr', 'aria-checked', 'true') - } else if (input === 'closed') { - cy.get(ThermoLocators.ListButton) - .contains('Lid position') - .parents(ThermoLocators.ListButton) - .find(ThermoLocators.ButtonSwitch) - .as('lidPositionSwitch') - cy.get('@lidPositionSwitch') - .should('have.attr', 'aria-checked', 'true') - .click() - cy.get('@lidPositionSwitch').should( - 'have.attr', - 'aria-checked', - 'false' - ) - } - }, - }), -} - -export const ThermoState = { - /** - * - * "Input Block Temp" - */ - BlockTempInput: (value: string): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.StateBlockTempInput).should('exist').type(value) - }, - }), - - /** - * - * "Input Lid Temp" - */ - LidTempInput: (value: string): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.StateLidTempInput).should('exist').type(value) - }, - }), -} - -export const ThermoProfile = { - /** - * - * "Input Well Volume" - */ - WellVolumeInput: (value: string): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.WellVolumeInput).should('exist').type(value) - }, - }), - - /** - * - * "Input Lid Temp" - */ - LidTempInput: (value: string): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.ProfileLidTempInput).should('exist').type(value) - }, - }), - - /** - * - * "Input Hold Block Temp" - */ - BlockTempHoldInput: (value: string): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.BlockTargetTempHold).should('exist').type(value) - }, - }), - - /** - * - * "Input Hold Lid Temp" - */ - LidTempHoldInput: (value: string): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.LidTargetTempHold).should('exist').type(value) - }, - }), -} - -const getCycle = (cycle: number): Cypress.Chainable => { - return cy.get(ThermoLocators.CycleContainer).eq(cycle) -} - -const getCycleStep = (cycle: number, step: number): Cypress.Chainable => { - return getCycle(cycle).find(ThermoLocators.CycleStep).eq(step) -} - -const getThermocyclerStep = (step: number): Cypress.Chainable => { - return cy.get(ThermoLocators.ThermocyclerStepContainer).eq(step) -} - -export const ThermoProfileSteps = { - /** - * - * "Add cycle to profile" - */ - AddCycle: (): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.SelectorButton) - .find('p') - .contains(ThermoContent.AddCycle) - .click() - }, - }), - - /** - * - * "Add step to profile" - */ - AddStep: (): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.SelectorButton) - .find('p') - .contains(ThermoContent.AddStep) - .click() - }, - }), - - /** - * - * "Set cycle count" - */ - SetCycleCount: (cycle: number, cycleCount: string): StepThunk => ({ - call: () => { - cy.log(`*****setting cycle count ${cycleCount}******`) - getCycle(cycle).within(() => { - cy.get(ThermoLocators.AddStepInput).then($inputs => { - const inputCount = $inputs.length - - if (inputCount === 4) { - // Set cycle count in the input at index 3 - cy.wrap($inputs).eq(3).type(cycleCount) - } else if (inputCount === 1) { - // Set cycle count in the input at index 0 - cy.wrap($inputs).eq(0).type(cycleCount) - } else { - cy.log(`Unexpected input count: ${inputCount}`) - } - }) - }) - }, - }), - - /** - * - * Fill a Cycle step - * or add a step to a cycle - */ - FillCycleStep: ({ - cycle, - step, - stepName, - temp, - time, - }: { - cycle: number - step: number - stepName: string - temp: string - time: string - }): StepThunk => ({ - call: () => { - const values = [stepName, temp, time] - getCycleStep(cycle, step) - .find(ThermoLocators.AddStepInput) - .each(($input, index) => { - if (index < values.length) { - // Split the chain and start from cy. to avoid chaining issues - cy.wrap($input).should('exist').should('be.visible') - // Start a new command chain for typing - cy.wrap($input).type(values[index]) - } - }) - }, - }), - - /** - * - * Fill a Step - * or add a step to a cycle - */ - - FillThermocyclerStep: ({ - step, - stepName, - temp, - time, - }: { - step: number - stepName: string - temp: string - time: string - }): StepThunk => ({ - call: () => { - const values = [stepName, temp, time] - getThermocyclerStep(step) - .find(ThermoLocators.AddStepInput) - .each(($input, index) => { - cy.wrap($input) - .should('exist') - .should('be.visible') - .type(values[index]) - }) - }, - }), - - /** - * - * "Delete Thermocycler step" - * specifying the step number - * to delete - */ - DeleteThermocyclerStep: (step: number): StepThunk => ({ - call: () => { - cy.log(`*****clicking X on thermocycler step ${step}******`) - getThermocyclerStep(step).find(ThermoLocators.Delete).click() - }, - }), - - /** - * - * "Save step" - */ - SaveThermocyclerStep: (step: number): StepThunk => ({ - call: () => { - cy.log(`*****clicking Save on thermocycler step ${step}******`) - getThermocyclerStep(step).find('button').contains('Save').click() - }, - }), - - /** - * - * "Delete cycle" - */ - DeleteCycle: (cycle: number): StepThunk => ({ - call: () => { - cy.log(`*****clicking delete cycle on cycle: ${cycle}******`) - getCycle(cycle).find('path[aria-roledescription="close"]').click() - }, - }), - - /** - * - * "Save cycle" - */ - SaveCycle: (step: number): StepThunk => ({ - call: () => { - cy.log(`*****clicking Save on cycle: ${step}******`) - getCycle(step).find('button').contains('Save').click() - }, - }), - - /** - * - * "Add cycle step to cycle" - * specifying the cycle number - * to add the step to - */ - AddCycleStep: (cycle: number): StepThunk => ({ - call: () => { - getCycle(cycle).find(ThermoLocators.AddCycleStep).click() - }, - }), - - /** - * - * "delete cycle step from cycle" - */ - DeleteCycleStep: (cycle: number, step: number): StepThunk => ({ - call: () => { - cy.log(`*****clicking delete cycle step ${step}******`) - getCycleStep(cycle, step).find(ThermoLocators.DeleteCycleStepX).click() - }, - }), -} - -export const ThermoVerifications = { - /** - * - * "Verify TC Header" - */ - VerifyThermoSetupHeader: (partNum: string): StepThunk => ({ - call: () => { - cy.contains(`Part ${partNum}${ThermoContent.PartNumber}`) - .should('exist') - .should('be.visible') - cy.contains(ThermoContent.Thermocycler) - .should('exist') - .should('be.visible') - cy.contains(ThermoContent.Rename).should('exist').should('be.visible') - }, - }), - - /** - * - * "Verify delete step pop out" - */ - VerifyPartOne: (): StepThunk => ({ - call: () => { - cy.log(`*****checking part 1 of TC setup******`) - ThermoVerifications.VerifyThermoSetupHeader('1').call() - cy.contains(ThermoContent.ChangeThermoState) - .should('exist') - .should('be.visible') - cy.contains(ThermoContent.ProgramThermoProfile) - .should('exist') - .should('be.visible') - cy.contains(ThermoContent.Continue).should('exist').should('be.visible') - }, - }), - - /** - * - * "Verify TC state options" - */ - VerifyThermoState: (): StepThunk => ({ - call: () => { - ThermoVerifications.VerifyThermoSetupHeader('2').call() - cy.contains(`${ThermoContent.Thermocycler} ${ThermoContent.State}`) - .should('exist') - .should('be.visible') - cy.contains(`${ThermoContent.Block}`) - .should('exist') - .should('be.visible') - .parent() - .find('p') - .contains(`${ThermoContent.Off}`) - - cy.get(ThermoLocators.ListButton) - .find('p') - .contains(`${ThermoContent.Lid} ${ThermoContent.Position}`) - .should('exist') - .should('be.visible') - cy.get(ThermoLocators.ListButton) - .find('p') - .contains(`${ThermoContent.Open}`) - .should('exist') - .should('be.visible') - cy.get('button[aria-label="Off"]').each(($btn, index) => { - cy.wrap($btn) - .should('be.visible') - .and('have.attr', 'aria-checked', 'false') - }) - - cy.get('button[aria-label="Open"]').each(($btn, index) => { - cy.wrap($btn) - .should('be.visible') - .and('have.attr', 'aria-checked', 'true') - }) - }, - }), - - /** - * - * "Verify TC profile options" - */ - VerifyThermoProfile: (): StepThunk => ({ - call: () => { - ThermoVerifications.VerifyThermoSetupHeader('2').call() - cy.contains(ThermoContent.ProgramProfile) - .should('exist') - .should('be.visible') - cy.contains(ThermoContent.WellVolume).should('exist').should('be.visible') - cy.contains(`${ThermoContent.Block}`) - .should('exist') - .should('be.visible') - .parent() - .find('p') - .contains(`${ThermoContent.Off}`) - cy.get(ThermoLocators.ListButton) - .contains(`${ThermoContent.Lid}`) - .should('exist') - .should('be.visible') - .parent() - .find('p') - .contains(`${ThermoContent.Off}`) - cy.get(ThermoLocators.ListButton) - .contains(`${ThermoContent.Lid} ${ThermoContent.Position}`) - .should('exist') - .should('be.visible') - cy.contains(ThermoContent.Closed).should('exist').should('be.visible') - }, - }), - - /** - * - * "Verify state selections persist if you go back and return" - */ - VerifyOptionsPersist: (partOption: string): StepThunk => ({ - call: () => { - if (partOption === 'state') { - cy.get(ThermoLocators.StateBlockTempInput).should('have.prop', 'value') - cy.get(ThermoLocators.StateLidTempInput).should('have.prop', 'value') - cy.get(ThermoLocators.ListButton) - .find('p') - .contains(`${ThermoContent.Closed}`) - .should('exist') - .should('be.visible') - } else if (partOption === 'profile') { - cy.get(ThermoLocators.WellVolumeInput).should('have.prop', 'value') - cy.get(ThermoLocators.ProfileLidTempInput).should('have.prop', 'value') - cy.get(ThermoLocators.BlockTargetTempHold).should('have.prop', 'value') - cy.get(ThermoLocators.LidTargetTempHold).should('have.prop', 'value') - cy.get(ThermoLocators.ListButton) - .find('p') - .contains(`${ThermoContent.Open}`) - .should('exist') - .should('be.visible') - } - }, - }), - - /** - * - * "Verify profile pop out page" - */ - VerifyProfileSteps: (): StepThunk => ({ - call: () => { - cy.get(ThermoLocators.ListButton) - .find('p') - .contains(ThermoContent.NoProfileDefined) - .click() - cy.contains(ThermoContent.EditProfileSteps) - .should('exist') - .should('be.visible') - cy.contains(ThermoContent.AddCycle).should('exist').should('be.visible') - cy.contains(ThermoContent.AddStep).should('exist').should('be.visible') - cy.contains(ThermoContent.NoStepsDefined) - .should('exist') - .should('be.visible') - cy.get(ThermoLocators.Save).should('exist').should('be.visible') - cy.get(ThermoLocators.Cancel).should('exist').should('be.visible') - }, - }), -} diff --git a/protocol-designer/cypress/support/Timeline.ts b/protocol-designer/cypress/support/Timeline.ts deleted file mode 100644 index 925bb00a876..00000000000 --- a/protocol-designer/cypress/support/Timeline.ts +++ /dev/null @@ -1,27 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { StepThunk } from './StepBuilder' - -const step = '[data-testid^="StepContainer"]' -const stepButton = `${step} button` - -export const TimelineSteps = { - /** - * Given a step like "1. Thermocycler", - * 1 is the stepNumber and "Thermocycler" is the title. - */ - SelectItemMenuOption: ( - stepNumber: number | null, - title: string, - option: 'Edit step' | 'Delete step' | 'Duplicate step' - ): StepThunk => ({ - call: () => { - cy.get(`[role="button"]`) - .filter(`:contains("${stepNumber ?? ''}")`) - .filter(`:contains("${title}")`) - .as('timelineItem') - cy.get('@timelineItem').click() - cy.get('@timelineItem').find(stepButton).click() - cy.contains(option).click() - }, - }), -} diff --git a/protocol-designer/cypress/support/UniversalSteps.ts b/protocol-designer/cypress/support/UniversalSteps.ts deleted file mode 100644 index a8ad73de026..00000000000 --- a/protocol-designer/cypress/support/UniversalSteps.ts +++ /dev/null @@ -1,20 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { StepThunk } from './StepBuilder' - -/** - * UniversalSteps is an object containing high-level or "universal" actions - * that might be used across multiple tests (e.g., snapshots, clearing caches). - */ -export const UniversalSteps = { - /** - * Placeholder for a future visual testing snapshot. - * In a real implementation, you might integrate with a visual diff tool - * or screenshot approach here. - */ - Snapshot: (): StepThunk => ({ - call: () => { - // Placeholder code for taking a snapshot - // e.g., cy.screenshot() or a call to a third-party visual testing service - }, - }), -} diff --git a/protocol-designer/cypress/support/commands.ts b/protocol-designer/cypress/support/commands.ts deleted file mode 100644 index 8f147c7cb76..00000000000 --- a/protocol-designer/cypress/support/commands.ts +++ /dev/null @@ -1,285 +0,0 @@ -import 'cypress-file-upload' - -import { SetupContent } from './SetupSteps' - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - getByTestId: (testId: string) => Cypress.Chainable> - getByAriaLabel: (value: string) => Cypress.Chainable> - verifyHeader: () => Cypress.Chainable - verifyFullHeader: () => Cypress.Chainable - verifyCreateNewHeader: () => Cypress.Chainable - clickCreateNew: () => Cypress.Chainable - closeAnalyticsModal: () => Cypress.Chainable - verifyHomePage: () => Cypress.Chainable - importProtocol: (protocolFile: string) => Cypress.Chainable - verifyImportPageOldProtocol: () => Cypress.Chainable - openFilePage: () => Cypress.Chainable - choosePipettes: ( - left_pipette_selector: string, - right_pipette_selector: string - ) => Cypress.Chainable - selectTipRacks: (left: string, right: string) => Cypress.Chainable - addLiquid: ( - liquidName: string, - liquidDesc: string, - serializeLiquid?: boolean - ) => Cypress.Chainable - openDesignPage: () => Cypress.Chainable - addStep: (stepName: string) => Cypress.Chainable - openSettingsPage: () => Cypress.Chainable - robotSelection: (robotName: string) => Cypress.Chainable - verifySettingsPage: () => Cypress.Chainable - verifyCreateNewPage: () => Cypress.Chainable - togglePreWetTip: () => Cypress.Chainable - mixaspirate: () => Cypress.Chainable - clickConfirm: () => Cypress.Chainable - verifyOverflowBtn: () => Cypress.Chainable - verifyOnboardingPage: () => Cypress.Chainable - closeReleaseNotesModal: () => Cypress.Chainable - } - } -} - -// Only Header, Home, and Settings page actions are here -// due to their simplicity -// Create and Import page actions are in their respective files - -export const content = { - siteTitle: 'Opentrons Protocol Designer', - opentrons: 'Opentrons', - charSet: 'UTF-8', - header: 'Protocol Designer', - welcome: 'Welcome to Protocol Designer!', - appSettings: 'App Info', - privacy: 'Privacy', - shareSessions: 'Share analytics with Opentrons', - move: 'Move', - transfer: 'Transfer', - mix: 'Mix', - pause: 'Pause', - heaterShaker: 'Heater-Shaker', - thermocyler: 'Thermocycler', - camera: 'Camera', -} - -export const locators = { - import: 'Import', - createNew: 'Create new', - createProtocol: 'Create a protocol', - Flex_Home: 'Opentrons Flex', - OT2_Home: 'Opentrons OT-2', - importProtocol: 'Import existing protocol', - settingsDataTestid: 'SettingsIconButton', - settings: 'Settings', - privacyPolicy: 'a[href="https://opentrons.com/privacy-policy"]', - eula: 'a[href="https://opentrons.com/eula"]', - privacyToggle: 'Settings_OT_PD_ENABLE_HOT_KEYS_DISPLAY', - analyticsToggleAriaLabel: 'Settings_Privacy', - releaseNote: '[data-testid="Toast_info"]', - confirm: 'Confirm', -} - -// General Custom Commands -Cypress.Commands.add( - 'getByTestId', - (testId: string): Cypress.Chainable> => { - return cy.get(`[data-testid="${testId}"]`) - } -) - -Cypress.Commands.add( - 'getByAriaLabel', - (value: string): Cypress.Chainable> => { - return cy.get(`[aria-label="${value}"]`) - } -) - -// Header Verifications -const verifyUniversal = (): void => { - cy.title().should('equal', content.siteTitle) - cy.document().should('have.property', 'charset').and('eq', content.charSet) - cy.contains(content.opentrons).should('be.visible') - cy.contains(content.header).should('be.visible') - cy.contains(locators.import).should('be.visible') -} - -Cypress.Commands.add('verifyFullHeader', () => { - verifyUniversal() - cy.contains(locators.createNew).should('be.visible') - cy.getByTestId(locators.settingsDataTestid).should('be.visible') -}) - -Cypress.Commands.add('verifyCreateNewHeader', () => { - verifyUniversal() -}) - -// Onboarding page -Cypress.Commands.add('verifyOnboardingPage', () => { - verifyUniversal() - cy.get(locators.privacyPolicy).should('exist').and('be.visible') - cy.get(locators.eula).should('exist').and('be.visible') - cy.contains(SetupContent.LetsGetStarted) -}) - -// Home Page -Cypress.Commands.add('verifyHomePage', () => { - cy.contains(content.welcome) - cy.get(locators.privacyPolicy).should('exist').and('be.visible') - cy.get(locators.eula).should('exist').and('be.visible') - cy.contains('button', locators.createProtocol).should('be.visible') - cy.contains('label', locators.importProtocol).should('be.visible') - cy.getByTestId(locators.settingsDataTestid).should('be.visible') -}) - -Cypress.Commands.add('clickCreateNew', () => { - cy.contains(locators.createProtocol).click() -}) - -Cypress.Commands.add('closeAnalyticsModal', () => { - cy.get('button') - .contains(locators.confirm) - .should('be.visible') - .click({ force: true }) -}) - -Cypress.Commands.add('clickConfirm', () => { - cy.contains(locators.confirm).click() -}) - -// Header Import -Cypress.Commands.add('importProtocol', (protocolFilePath: string) => { - cy.contains(locators.import).click() - cy.get('[data-cy="landing-page"]') - .find('input[type=file]') - .selectFile(protocolFilePath, { force: true }) -}) - -Cypress.Commands.add('robotSelection', (robotName: string) => { - if (robotName === 'Opentrons OT-2') { - cy.contains('label', locators.OT2_Home).should('be.visible').click() - } else { - // Just checking that the selection modal works - cy.contains('label', locators.OT2_Home).should('be.visible').click() - cy.contains('label', locators.Flex_Home).should('be.visible').click() - } - cy.contains('button', 'Confirm').should('be.visible').click() -}) - -// Settings Page Actions -Cypress.Commands.add('openSettingsPage', () => { - cy.getByTestId(locators.settingsDataTestid).click() -}) - -Cypress.Commands.add('verifySettingsPage', () => { - cy.verifyFullHeader() - cy.contains(locators.settings).should('exist').should('be.visible') - cy.contains(content.appSettings).should('exist').should('be.visible') - cy.contains(content.privacy).should('exist').should('be.visible') - cy.contains(content.shareSessions).should('exist').should('be.visible') - cy.getByAriaLabel(locators.privacyToggle).should('exist').should('be.visible') - cy.getByAriaLabel(locators.analyticsToggleAriaLabel) - .should('exist') - .should('be.visible') -}) - -Cypress.Commands.add('verifyOverflowBtn', () => { - cy.contains(content.move).should('exist').should('be.visible') - cy.contains(content.transfer).should('exist').should('be.visible') - cy.contains(content.mix).should('exist').should('be.visible') - cy.contains(content.pause).should('exist').should('be.visible') - cy.contains(content.heaterShaker).should('exist').should('be.visible') - cy.contains(content.thermocyler).should('exist').should('be.visible') - cy.contains(content.camera).should('exist').should('be.visible') -}) - -Cypress.Commands.add('closeReleaseNotesModal', () => { - cy.get(locators.releaseNote).find('button').click() -}) - -/// ///////////////////////////////////////////////////////////////// -// Legacy Code Section -// This code is deprecated and should be removed -// as soon as possible once it's no longer needed -// as a reference during test migration. -/// ///////////////////////////////////////////////////////////////// - -Cypress.Commands.add('closeAnalyticsModal', () => { - // ComputingSpinner sometimes covers the announcement modal button and prevents the button click - // this will retry until the ComputingSpinner does not exist - cy.contains('button', 'Confirm').click({ force: true }) -}) - -// -// File Page Actions -// - -Cypress.Commands.add('openFilePage', () => { - cy.get('button[id="NavTab_file"]').contains('FILE').click() -}) - -// -// Pipette Page Actions -// - -Cypress.Commands.add( - 'choosePipettes', - (leftPipetteSelector, rightPipetteSelector) => { - cy.get('[id="PipetteSelect_left"]').click() - cy.get(leftPipetteSelector).click() - cy.get('[id="PipetteSelect_right"]').click() - cy.get(rightPipetteSelector).click() - } -) - -Cypress.Commands.add('selectTipRacks', (left, right) => { - if (left.length > 0) { - cy.get("select[name*='left.tiprack']").select(left) - } - if (right.length > 0) { - cy.get("select[name*='right.tiprack']").select(right) - } -}) - -// -// Liquid Page Actions -// -Cypress.Commands.add( - 'addLiquid', - (liquidName, liquidDesc, serializeLiquid = false) => { - cy.get('button').contains('New Liquid').click() - cy.get("input[name='name']").type(liquidName) - cy.get("input[name='description']").type(liquidDesc) - if (serializeLiquid) { - // force option used because checkbox is hidden - cy.get("input[name='serialize']").check({ force: true }) - } - cy.get('button').contains('save').click() - } -) - -// -// Design Page Actions -// - -Cypress.Commands.add('openDesignPage', () => { - cy.get('button[id="NavTab_design"]').contains('DESIGN').parent().click() -}) -Cypress.Commands.add('addStep', stepName => { - cy.get('button').contains('Add Step').click() - cy.get('button').contains(stepName, { matchCase: false }).click() -}) - -// Advance Settings for Transfer Steps - -// Pre-wet tip enable/disable -Cypress.Commands.add('togglePreWetTip', () => { - cy.get('input[name="preWetTip"]').click({ force: true }) -}) - -// Mix settings select/deselect -Cypress.Commands.add('mixaspirate', () => { - cy.get('input[name="aspirate_mix_checkbox"]').click({ force: true }) -}) diff --git a/protocol-designer/cypress/support/e2e.ts b/protocol-designer/cypress/support/e2e.ts deleted file mode 100644 index b48fa28f505..00000000000 --- a/protocol-designer/cypress/support/e2e.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * I am loaded into all tests - */ - -import './commands' diff --git a/protocol-designer/cypress/support/utils.ts b/protocol-designer/cypress/support/utils.ts deleted file mode 100644 index 33be46e7c7d..00000000000 --- a/protocol-designer/cypress/support/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const isEnumValue = ( - enumObjs: T[], - values: unknown | unknown[] -): boolean => { - const valueArray = Array.isArray(values) ? values : [values] - return valueArray.every(value => - enumObjs.some(enumObj => - Object.values(enumObj).includes(value as T[keyof T]) - ) - ) -} diff --git a/protocol-designer/docs/VERSIONING.md b/protocol-designer/docs/VERSIONING.md index 4f2856fc465..3965b0493da 100644 --- a/protocol-designer/docs/VERSIONING.md +++ b/protocol-designer/docs/VERSIONING.md @@ -26,7 +26,7 @@ The PD application needs to read its own version to know which migrations to run Upon importing a protocol, PD populates its Redux store with transformed data from the protocol file. Since the Redux store data changes over time, we need to handle importing older protocols when there were different keys, data types, etc. We handle this by having "migrations" where we define functions to transform eg PD version 1.0.0 protocol data to 1.1.0, then 1.1.0 to 2.0.0, and so on up to the latest version. These are in `protocol-designer/src/load-file/migration`. -Because PD migrates protocols when they are imported, if you import a signifcantly older protocol and save it immediately with no changes, the new file may be different because it has passed through the migration process. +Because PD migrates protocols when they are imported, if you import a significantly older protocol and save it immediately with no changes, the new file may be different because it has passed through the migration process. Certain migrations can get a special modal associated with them. For example, when you import a couple years old protocol with v1 schema labware, you will get a special 'Update protocol to use new labware definitions' import modal. In most cases, you'll get the generic modal 'Your protocol was made in an older version of Protocol Designer'. (This is handled in `protocol-designer/src/components/modals/FileUploadMessageModal/modalContents.js`) @@ -37,25 +37,3 @@ We usually need to add migrations when we're adding new fields to existing step In order to allow users to keep user older versions of the robot software and not be forced to update, PD can export a few different JSON schema versions. For example, if a user has no modules in their protocol, it might be saved as schema X, but upon adding modules it will need to be saved as schema Y, because X does not yet support specifying modules in a JSON protocol. The PD version will be the same in both cases, it's the version of PD that the user has open in their browser when they're saving, and is not conditionally changed like the schema. This back-compat for older robot server versions is limited and always subject to deprecation. Eventually PD will no longer be able to export schema 3 (or whatever number) protocols, because maintaining the back-compat becomes messy after a certain point. - -# Things to consider when bumping the version - -- If it's a major or minor bump, E2E tests need to be updated. The E2E migration tests are based on protocol fixtures in `protocol-designer/fixtures/protocol/${protocolDesignerVersionThatCreatedTheProtocol}/${nameOfProtocolFile}.json`. (Because of differential export, the JSON schema of a protocol might be lower than the PD version). The spec itself at `protocol-designer/cypress/integration/migrations.spec.js` needs to be updated(\* see below) -- If it's a major or minor bump, consider whether or not you need a special import modal (see "Import migrations") to communicate noteworthy changes to the user -- If it's a major bump (or a significant minor bump), new fixtures should be added and put in the E2E migration tests - -## Relationship to release - -Every release should have a version bump. Generally when we feel ready to release, the last PR we do will bump the version and make any related changes including new migrations and adding new migration tests. - -## Addendum - -The part of `migrations.spec.js` that needs to be updated when there is a major or minor bump is this regex match assert: - -``` -assert.match( - savedFile.designerApplication.version, - /^5\.2\.\d+$/, - 'designerApplication.version is 5.2.x' -) -``` diff --git a/protocol-designer/release-notes.md b/protocol-designer/release-notes.md index 170bdac0fd8..5455e47ef16 100644 --- a/protocol-designer/release-notes.md +++ b/protocol-designer/release-notes.md @@ -8,6 +8,30 @@ By using Opentrons Protocol Designer, you agree to the Opentrons End-User Licens --- +## Opentrons Protocol Designer Changes in 8.7.0 + +**Welcome to Protocol Designer 8.7.0!** + +This release adds support for manual tip selection and camera steps in Protocol Designer, and includes feature improvements and bug fixes. + +### New Features + +- Choose between automatic and manual tip tracking for transfer and mix steps in your protocols. In manual tip tracking, Protocol Designer lets you select the tip rack and individual tips the pipette will pick up to transfer or mix liquid. +- Manual tip tracking in Protocol Designer enables reusing tips more than once in a protocol. +- Add a camera step in any Protocol Designer protocol. The Flex or OT-2's built-in camera can take a still image of your robot deck at any point during the protocol. Access your images in the Recent Protocol Runs section of the Opentrons App's robot details page. + +### Improved Features + +- Protocol Designer only shows compatible deck and labware locations to move a lid to. +- Add Opentrons Tough Universal Lids to an available Opentrons Flex Deck Riser placed on the deck. +- Move a tip rack lid to any tip rack on the deck without a lid. +- When adding labware, click to add a tip rack lid to a Flex 96-channel tip rack adapter with a tip rack inside. +- When you click **Duplicate labware**, Protocol Designer duplicates all labware, adapters, lids, and liquids in the deck slot. + +### Bug Fixes + +- Protocol Designer updates maximum flow rates to the latest version when you import an older protocol. + ## Opentrons Protocol Designer Changes in 8.6.3 **Welcome to Protocol Designer 8.6.3!** diff --git a/protocol-designer/src/assets/localization/en/application.json b/protocol-designer/src/assets/localization/en/application.json index 6c68eb2b178..3f2b76ce877 100644 --- a/protocol-designer/src/assets/localization/en/application.json +++ b/protocol-designer/src/assets/localization/en/application.json @@ -31,25 +31,26 @@ "pipettes": "Pipettes", "protocol_name": "Protocol Name", "save": "save", + "select": "Select", + "selected": "Selected", + "source": "Source", "stepType": { "absorbanceReader": "absorbance plate reader", "camera": "camera", "comment": "comment", "ending_hold": "ending hold", + "flexStacker": "flex stacker", "heaterShaker": "heater-shaker", "magnet": "magnet", "mix": "mix", "moveLabware": "move", "moveLiquid": "transfer", "pause": "pause", - "profile_steps": "profile steps", "profile": "Program a Thermocycler profile", + "profile_steps": "profile steps", "temperature": "temperature", "thermocycler": "thermocycler" }, - "select": "Select", - "selected": "Selected", - "source": "Source", "temperature": "Temperature (°C)", "time": "Time", "units": { @@ -60,8 +61,8 @@ "microliterPerSec": "µL/s", "millimeter": "mm", "millimeterPerSec": "mm/s", - "nanometer": "nm", "minutes": "m", + "nanometer": "nm", "rpm": "rpm", "seconds": "s", "seconds_long": "seconds", diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 30c48527136..0ea1a84e9cd 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -25,6 +25,9 @@ "blowout_location": "Blowout location", "blowout_position": "Blowout position from top", "bottom_of_stack": "Bottom of stack", + "camera": { + "capture_image": "Capture image of the deck" + }, "captions_for_fields": { "blockTargetTemp": "Valid range between 4 and 99 °C", "blockTargetTempHold": "Valid range between 4 and 99 °C", @@ -36,9 +39,6 @@ "targetTemperature": "Valid range between 4 and 95 °C", "volume": "Recommended between 0.1 and {{max}}" }, - "camera": { - "capture_image": "Capture image of the deck" - }, "change_tips": "Change tips", "column": "Column", "comfirm_reset_settings": { @@ -67,6 +67,29 @@ "edit_step": "Edit step", "ending_deck": "Ending deck", "engage_height": "Engage height", + "flex_stacker": { + "label": "Flex Stacker", + "module_controls": { + "empty_label": "Empty", + "empty_sublabel": "Manually empty all labware from the stacker", + "label": "Module controls", + "refill_label": "Refill", + "refill_sublabel": "Refill the stacker with labware. Manually fill the stacker with more labware", + "retrieve_label": "Retrieve", + "retrieve_sublabel": "Retrieve labware from the stacker onto the shuttle" + }, + "shuttle": { + "label": "Shuttle", + "no_labware": "No labware on shuttle" + }, + "stacker": { + "label": "Stacker", + "labware_filled": "{{amount}}/{{total}} labware filled", + "no_labware": "No labware stored on stacker", + "quantity": "Quantity: {{count}}" + } + }, + "flexStacker": "Stacker", "flow_rate_builder": "Flow rate builder", "flow_type_title": "{{type}} flow rate", "from": "from", @@ -97,8 +120,8 @@ }, "heater_shaker_state": "Heater-Shaker state", "in": "in", - "into": "into", "individual_wells": "Individual wells", + "into": "into", "labware_in": "Labware in", "labware_to": "{{labware}} to", "liquids": "{{num}} liquids", @@ -127,12 +150,12 @@ "of": "of", "off_deck": "Off-Deck", "pause": { + "forDuration": "For {{duration}}", + "pausingForDuration": "Pausing for", "pausingUntilResume": "Pausing until manually told to resume", "pausingUntilTemperature": "Pausing until{{module}}reaches", - "pausingForDuration": "Pausing for", "untilResume": "Until told to resume", - "untilTemperature": "Until {{temperature}} °C reached", - "forDuration": "For {{duration}}" + "untilTemperature": "Until {{temperature}} °C reached" }, "pipette": "Pipette", "pipette_path": "Pipette path", @@ -198,11 +221,15 @@ "block_value": "{{value}} °C", "block_value_off": "Off", "lid_label": "Lid set to", - "lid_value": "{{value}} °C", - "lid_value_off": "Off", "lid_position_label": "Lid position", + "lid_position_value_closed": "Closed", "lid_position_value_open": "Open", - "lid_position_value_closed": "Closed" + "lid_value": "{{value}} °C", + "lid_value_off": "Off" + }, + "profile_timeline": { + "start": "Start profile", + "wait_for_complete": "Wait for profile to complete" }, "repeat": "Repeat {{repetitions}} times", "substep_settings": "Set block temperature tofor", @@ -218,10 +245,6 @@ "block": "Set thermocycler block to", "lid_position": "Lid position", "lid_temperature": "Set thermocycler lid to" - }, - "profile_timeline": { - "start": "Start profile", - "wait_for_complete": "Wait for profile to complete" } }, "time": "Time", @@ -242,7 +265,11 @@ "unoccupied_stack": "Stack of {{name}}", "valid_range": "Must be between 0.1 and {{max}} {{unit}}", "view_details": "View details", - "volume_per_well": "Volume per well", + "volume_per_well": { + "multiAspirate": "Aspirate volume per well", + "multiDispense": "Dispense volume per well", + "single": "Volume per well" + }, "well_name": "Well {{wellName}}", "well_order_title": "{{prefix}} well order", "well_position": "Well position: X {{x}} Y {{y}} Z {{z}} (mm)", diff --git a/protocol-designer/src/components/organisms/AutoAddPauseUntilTempStepModal/index.tsx b/protocol-designer/src/components/organisms/BonusStepModal/index.tsx similarity index 73% rename from protocol-designer/src/components/organisms/AutoAddPauseUntilTempStepModal/index.tsx rename to protocol-designer/src/components/organisms/BonusStepModal/index.tsx index 46e9042d451..eca88f5cccd 100644 --- a/protocol-designer/src/components/organisms/AutoAddPauseUntilTempStepModal/index.tsx +++ b/protocol-designer/src/components/organisms/BonusStepModal/index.tsx @@ -18,45 +18,56 @@ import { import type { PropsWithChildren } from 'react' -interface HandleClickProps { +interface HandleSkipPauseClickProps { handleSkipPauseClick: () => void +} + +interface HandleAddPauseClickProps { handleAddPauseClick: () => void } -type AutoAddPauseUntilTempStepModalProps = +type BonusStepModalProps = | ({ + // "We've added a ___ step for you" modalType: - | 'temperatureModule' - | 'heaterShaker' - | 'thermocyclerBlock' - | 'thermocyclerLid' + | 'explainWaitForTemperatureModuleTemp' + | 'explainWaitForHeaterShakerTemp' + | 'explainWaitForThermocyclerBlockTemp' + | 'explainWaitForThermocyclerLidTemp' displayTemperature: string - } & HandleClickProps) + } & HandleAddPauseClickProps) | ({ - modalType: 'thermocyclerProfile' + modalType: 'explainWaitForThermocyclerProfile' displayTemperature?: null - } & HandleClickProps) + } & HandleAddPauseClickProps) | ({ - // todo(mm, 2025-09-26): Delete legacy mode when enableConcurrentModuleActions FF is deleted. - modalType: 'legacy' + // "Would you like to add a ___ step" + // todo(mm, 2025-09-26): Delete this modal type when enableConcurrentModuleActions FF is deleted + modalType: 'optionallyWaitForTemp' displayTemperature: string displayModule: string - } & HandleClickProps) + } & HandleAddPauseClickProps & + HandleSkipPauseClickProps) + +export type BonusStepModalType = BonusStepModalProps['modalType'] /** * Implements the several modals that are like "you just saved a set-temperature step, * would you like to also add a pause step." */ -export const AutoAddPauseUntilTempStepModal = ( - props: AutoAddPauseUntilTempStepModalProps -): JSX.Element => { - const { modalType, handleSkipPauseClick, handleAddPauseClick } = props +export const BonusStepModal = (props: BonusStepModalProps): JSX.Element => { + const { modalType } = props const { t } = useTranslation() const [rememberDismissal, setRememberDismissal] = useState(false) - // todo(mm, 2025-09-29): Delete legacy mode when enableConcurrentModuleActions FF is deleted. - if (modalType === 'legacy') { - const { displayModule, displayTemperature } = props + // todo(mm, 2025-09-26): Delete this modal type when enableConcurrentModuleActions FF is deleted + if (modalType === 'optionallyWaitForTemp') { + const { + displayModule, + displayTemperature, + handleSkipPauseClick, + handleAddPauseClick, + } = props return ( ) } else { + const { displayTemperature, handleAddPauseClick } = props + const titleKey: string = (() => { switch (modalType) { - case 'temperatureModule': + case 'explainWaitForTemperatureModuleTemp': return 'modal:auto_add_pause_until_temp_step.temperature_module.title' - case 'heaterShaker': + case 'explainWaitForHeaterShakerTemp': return 'modal:auto_add_pause_until_temp_step.heater_shaker.title' - case 'thermocyclerBlock': + case 'explainWaitForThermocyclerBlockTemp': return 'modal:auto_add_pause_until_temp_step.thermocycler_block.title' - case 'thermocyclerLid': + case 'explainWaitForThermocyclerLidTemp': return 'modal:auto_add_pause_until_temp_step.thermocycler_lid.title' - case 'thermocyclerProfile': + case 'explainWaitForThermocyclerProfile': return 'modal:auto_add_pause_until_temp_step.thermocycler_profile.title' // default omitted, for exhaustiveness checking. } })() - const title = t(titleKey, { temperature: props.displayTemperature }) + const title = t(titleKey, { temperature: displayTemperature }) const bodyParagraphsKey: string = (() => { switch (modalType) { - case 'temperatureModule': + case 'explainWaitForTemperatureModuleTemp': return 'modal:auto_add_pause_until_temp_step.temperature_module.body' - case 'heaterShaker': + case 'explainWaitForHeaterShakerTemp': return 'modal:auto_add_pause_until_temp_step.heater_shaker.body' - case 'thermocyclerBlock': + case 'explainWaitForThermocyclerBlockTemp': return 'modal:auto_add_pause_until_temp_step.thermocycler_block.body' - case 'thermocyclerLid': + case 'explainWaitForThermocyclerLidTemp': return 'modal:auto_add_pause_until_temp_step.thermocycler_lid.body' - case 'thermocyclerProfile': + case 'explainWaitForThermocyclerProfile': return 'modal:auto_add_pause_until_temp_step.thermocycler_profile.body' // default omitted, for exhaustiveness checking. } @@ -137,7 +150,7 @@ export const AutoAddPauseUntilTempStepModal = ( }} /> ) diff --git a/protocol-designer/src/components/organisms/index.ts b/protocol-designer/src/components/organisms/index.ts index 19ed2401d1c..cee72390c22 100644 --- a/protocol-designer/src/components/organisms/index.ts +++ b/protocol-designer/src/components/organisms/index.ts @@ -1,7 +1,7 @@ export * from './Alerts' export * from './AnnouncementModal' export * from './AssignLiquidsModal' -export * from './AutoAddPauseUntilTempStepModal' +export * from './BonusStepModal' export * from './BlockingHintModal' export * from './ConfirmDeleteEntityInUseModal' export * from './ConfirmDeleteModal' diff --git a/protocol-designer/src/form-types.ts b/protocol-designer/src/form-types.ts index 6679855f227..be280032d2e 100644 --- a/protocol-designer/src/form-types.ts +++ b/protocol-designer/src/form-types.ts @@ -172,6 +172,7 @@ export type StepType = | 'pause' | 'temperature' | 'thermocycler' + | 'flexStacker' export const stepIconsByType: Record = { absorbanceReader: 'ot-absorbance', @@ -186,6 +187,7 @@ export const stepIconsByType: Record = { temperature: 'ot-temperature-v2', thermocycler: 'ot-thermocycler', heaterShaker: 'ot-heater-shaker', + flexStacker: 'ot-flex-stacker', } // ===== Unprocessed form types ===== export interface AnnotationFields { @@ -498,6 +500,13 @@ export interface HydratedAbsorbanceReaderFormData extends AnnotationFields { wavelengths: string[] } +// TODO(TZ, 2025-12-03): not fully flushed out, but this is the initial hydrated form data for the flex stacker form +export interface HydratedFlexStackerFormData extends AnnotationFields { + stepType: 'flexStacker' + id: string + moduleId: string +} + // fields used in TipPositionInput export type TipZOffsetFields = | 'aspirate_mmFromBottom' @@ -616,3 +625,4 @@ export type HydratedFormData = | HydratedPauseFormData | HydratedTemperatureFormData | HydratedThermocyclerFormData + | HydratedFlexStackerFormData diff --git a/protocol-designer/src/networking/opentronsWebApi.ts b/protocol-designer/src/networking/opentronsWebApi.ts index 7e7dab7e3dc..78e443bd371 100644 --- a/protocol-designer/src/networking/opentronsWebApi.ts +++ b/protocol-designer/src/networking/opentronsWebApi.ts @@ -1,2 +1,8 @@ -export const getIsProduction = (): boolean => - global.location.host === 'designer.opentrons.com' +const PRODUCTION_HOST = 'designer.opentrons.com' +const STAGING_HOST = 'staging.designer.opentrons.com' + +const getHost = (): string => global.location.host + +export const getIsProduction = (): boolean => getHost() === PRODUCTION_HOST + +export const getIsStaging = (): boolean => getHost() === STAGING_HOST diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/DraggableSidebar.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/DraggableSidebar.tsx index 9d388075e08..6242302809b 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/DraggableSidebar.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/DraggableSidebar.tsx @@ -73,7 +73,7 @@ export function DraggableSidebar({ justifyContent={JUSTIFY_SPACE_BETWEEN} height="100%" > - + diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/VolumeField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/VolumeField.tsx index 269d77ecc89..c8f7be29f06 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/VolumeField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/VolumeField.tsx @@ -2,17 +2,24 @@ import { useTranslation } from 'react-i18next' import { InputStepFormField } from '/protocol-designer/components/molecules' +import type { PathOption } from '/protocol-designer/form-types' import type { FieldProps } from '../types' -export function VolumeField(props: FieldProps): JSX.Element { +interface VolumeFieldProps { + fieldProps: FieldProps + path?: PathOption +} + +export function VolumeField(props: VolumeFieldProps): JSX.Element { const { t } = useTranslation(['protocol_steps', 'application']) + const { fieldProps, path = 'single' } = props return ( ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index 576c36fb5a8..cf81fa893a1 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -62,6 +62,7 @@ import { AbsorbanceReaderTools, CameraTools, CommentTools, + FlexStackerToolsContainer, HeaterShakerTools, MagnetTools, MixTools, @@ -107,6 +108,7 @@ const STEP_FORM_MAP: StepFormMap = { comment: CommentTools, camera: CameraTools, absorbanceReader: AbsorbanceReaderTools, + flexStacker: FlexStackerToolsContainer, } // used to inform StepFormToolbox when to prompt user confirmation for overriding advanced settings @@ -535,7 +537,10 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { desktopStyle="bodyLargeSemiBold" css={LINE_CLAMP_TEXT_STYLE(2, true)} > - {capitalizeFirstLetter(String(formData.stepName))} + {/* TODO: use module object from form.json instead */} + {formData.stepType === 'flexStacker' + ? t(`protocol_steps:${formData.stepType}`) + : capitalizeFirstLetter(String(formData.stepName))} } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/FlexStackerTools.module.css b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/FlexStackerTools.module.css new file mode 100644 index 00000000000..e877dcc7c63 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/FlexStackerTools.module.css @@ -0,0 +1,16 @@ +.space_between { + display: flex; + justify-content: space-between; +} + +div.container { + display: flex; + width: 100%; + flex-direction: column; + padding-top: var(--spacing-16); + gap: var(--spacing-8); +} + +.padding_x { + padding: 0 var(--spacing-16); +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/__tests__/FlexStackerTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/__tests__/FlexStackerTools.test.tsx new file mode 100644 index 00000000000..e5f0e09643a --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/__tests__/FlexStackerTools.test.tsx @@ -0,0 +1,179 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { FLEX_STACKER_MODULE_TYPE } from '@opentrons/shared-data' + +import { renderWithProviders } from '/protocol-designer/__testing-utils__' +import { i18n } from '/protocol-designer/assets/localization' + +import { FlexStackerTools } from '..' + +import type { ComponentProps } from 'react' + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('FlexStackerTools', () => { + let props: ComponentProps + beforeEach(() => { + props = { + propsForFields: {} as any, + formData: { moduleId: 'mockId' } as any, + toolboxStep: 0, + showFormErrors: false, + focusHandlers: {} as any, + tab: 'aspirate', + setTab: vi.fn(), + setShowFormErrors: vi.fn(), + robotState: { + pipettes: {}, + labware: {}, + tipState: { + tipracks: {}, + pipettes: {}, + }, + liquidState: { + pipettes: {}, + labware: {}, + trashBins: {}, + wasteChute: {}, + }, + modules: { + mockId: { + moduleState: { + type: FLEX_STACKER_MODULE_TYPE, + maxPoolCount: 6, + storedLabwareDetails: { + primaryLabware: { + loadName: 'mockLabwareId', + namespace: 'mockLabwareNamespace', + version: 1, + }, + lidLabware: { + loadName: 'mockLidLabwareId', + namespace: 'mockLidLabwareNamespace', + version: 1, + }, + initialCount: 1, + }, + labwareInHopper: ['mockLabwareId'], + labwareOnShuttle: null, + }, + }, + }, + } as any, + flexStackerOptions: [{ name: 'mock module', value: 'mockId' }], + } + }) + + it('should render view only', () => { + render(props) + expect(screen.getByText('Choose option')).toBeInTheDocument() + expect(screen.getByText('Shuttle')).toBeInTheDocument() + expect(screen.getByText('Stacker')).toBeInTheDocument() + expect(screen.getByText('Module controls')).toBeInTheDocument() + expect(screen.getByText('Retrieve')).toBeInTheDocument() + expect(screen.getByText('Refill')).toBeInTheDocument() + expect(screen.getByText('Empty')).toBeInTheDocument() + }) + + it('should render view with labware in hopper', () => { + props.robotState = { + ...props.robotState!, + modules: { + ...props.robotState?.modules, + mockId: { + moduleState: { + type: FLEX_STACKER_MODULE_TYPE, + maxPoolCount: 6, + storedLabwareDetails: { + primaryLabware: { + loadName: 'mockLabwareId', + namespace: 'mockLabwareNamespace', + version: 1, + }, + lidLabware: { + loadName: 'mockLidLabwareId', + namespace: 'mockLidLabwareNamespace', + version: 1, + }, + initialCount: 1, + }, + labwareInHopper: ['mockLabwareId'], + labwareOnShuttle: null, + }, + }, + } as any, + } + render(props) + expect(screen.getByText('1/6 labware filled')).toBeInTheDocument() + expect(screen.getByText('mockLabwareId')).toBeInTheDocument() + expect(screen.getByText('mockLidLabwareId')).toBeInTheDocument() + expect(screen.getByText('Quantity: 1')).toBeInTheDocument() + }) + + it('should render view with no labware in hopper', () => { + props.robotState = { + ...props.robotState!, + modules: { + ...props.robotState?.modules, + mockId: { + moduleState: { + ...props.robotState?.modules?.mockId?.moduleState, + labwareInHopper: [], + storedLabwareDetails: null, + labwareOnShuttle: null, + }, + }, + } as any, + } + render(props) + expect(screen.getByText('No labware stored on stacker')).toBeInTheDocument() + }) + + it('should render view with labware on shuttle', () => { + props.robotState = { + ...props.robotState!, + modules: { + ...props.robotState?.modules, + mockId: { + moduleState: { + ...props.robotState?.modules?.mockId?.moduleState, + labwareOnShuttle: [ + { + primaryLabwareId: 'mockLabwareId', + adapterLabwareId: null, + lidLabwareId: null, + }, + ], + }, + }, + } as any, + } + render(props) + expect(screen.getByText('Shuttle')).toBeInTheDocument() + expect(screen.getByText('mockLabwareId')).toBeInTheDocument() + expect(screen.getByText('mockLidLabwareId')).toBeInTheDocument() + expect(screen.queryByText('Quantity: 0')).not.toBeInTheDocument() + }) + + it('should render view with no labware on shuttle', () => { + props.robotState = { + ...props.robotState!, + modules: { + ...props.robotState?.modules, + mockId: { + moduleState: { + ...props.robotState?.modules?.mockId?.moduleState, + labwareOnShuttle: null, + }, + }, + } as any, + } + render(props) + expect(screen.getByText('No labware on shuttle')).toBeInTheDocument() + }) +}) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/index.tsx new file mode 100644 index 00000000000..c9a4bf84a44 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/index.tsx @@ -0,0 +1,184 @@ +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { + Divider, + Icon, + InfoScreen, + LabwareDetailsWithCount, + RadioButton, + StyledText, +} from '@opentrons/components' + +import { DropdownStepFormField } from '/protocol-designer/components/molecules' +import { getRobotStateAtActiveItem } from '/protocol-designer/top-selectors/labware-locations' +import { getFlexStackerLabwareOptions } from '/protocol-designer/ui/modules/selectors' + +import styles from './FlexStackerTools.module.css' + +import type { DropdownOption } from '@opentrons/components' +import type { + FlexStackerModuleState, + TimelineFrame, +} from '@opentrons/step-generation' +import type { StepFormProps } from '../../types' + +export type FlexStackerToolsProps = StepFormProps & { + robotState: TimelineFrame | null + flexStackerOptions: DropdownOption[] +} + +export function FlexStackerTools(props: FlexStackerToolsProps): JSX.Element { + const { formData, propsForFields, robotState, flexStackerOptions } = props + const { moduleId } = formData + const { t } = useTranslation(['application', 'form', 'protocol_steps']) + + const { modules } = robotState ?? {} + + const flexStackerModuleState = modules?.[moduleId] + ?.moduleState as FlexStackerModuleState | null + + const labwareInHopperCount = + flexStackerModuleState?.labwareInHopper?.length ?? 0 + const maxPoolCount = flexStackerModuleState?.maxPoolCount ?? 0 + const labwareOnShuttle = flexStackerModuleState?.labwareOnShuttle ?? null + + console.log('labwareOnShuttle:', labwareOnShuttle) + const labwareFiledComponent = ( +
+ + {t('protocol_steps:flex_stacker.stacker.label')} + + + {labwareInHopperCount > 0 ? ( + + {t('protocol_steps:flex_stacker.stacker.labware_filled', { + amount: labwareInHopperCount, + total: maxPoolCount, + })} + + ) : null} +
+ ) + + return ( +
+ {}} + /> + +
+ {labwareFiledComponent} + {flexStackerModuleState?.storedLabwareDetails != null ? ( + + ) : ( + + )} +
+ +
+ + {t('protocol_steps:flex_stacker.shuttle.label')} + +
+ {labwareOnShuttle != null && + flexStackerModuleState?.storedLabwareDetails != null ? ( + + ) : ( + + )} +
+
+ +
+
+ + {t('protocol_steps:flex_stacker.module_controls.label')} + + +
+ Retrieve + } + buttonSubLabel={{ + align: 'vertical', + label: t( + 'protocol_steps:flex_stacker.module_controls.retrieve_sublabel' + ), + }} + onChange={() => {}} + largeDesktopBorderRadius + /> + { + console.log('e:', e) + }} + largeDesktopBorderRadius + /> + {}} + largeDesktopBorderRadius + /> +
+
+ ) +} + +export const FlexStackerToolsContainer = ( + props: StepFormProps +): JSX.Element => { + const robotState = useSelector(getRobotStateAtActiveItem) + const flexStackerOptions = useSelector(getFlexStackerLabwareOptions) + + return ( + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/FirstStepMixTools.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/FirstStepMixTools.tsx index aa7e4460965..c528b48f981 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/FirstStepMixTools.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/FirstStepMixTools.tsx @@ -66,7 +66,7 @@ export function FirstStepMixTools({ hasFormError={propsForFields.wells.errorToShow != null} /> - + - + ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts index a9ac37933e7..c1f80316782 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts @@ -1,4 +1,5 @@ export { AbsorbanceReaderTools } from './AbsorbanceReaderTools' +export { FlexStackerToolsContainer } from './FlexStackerTools' export { CameraTools } from './CameraTools' export { CommentTools } from './CommentTools' export { HeaterShakerTools } from './HeaterShakerTools' diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx index 61b5920b829..783075e6786 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx @@ -5,7 +5,7 @@ import { useConditionalConfirm } from '@opentrons/components' import { getModuleDisplayName } from '@opentrons/shared-data' import { - AutoAddPauseUntilTempStepModal, + BonusStepModal, CLOSE_STEP_FORM_WITH_CHANGES, CLOSE_UNSAVED_STEP_FORM, ConfirmDeleteModal, @@ -25,6 +25,7 @@ import { getDirtyFields } from './utils' import type { ConnectedComponent } from 'react-redux' import type { InvariantContext } from '@opentrons/step-generation' +import type { BonusStepModalType } from '/protocol-designer/components/organisms' import type { FormData, StepFieldName } from '/protocol-designer/form-types' import type { LabwareDefByDefURI } from '/protocol-designer/labware-defs' import type { BaseState, ThunkDispatch } from '/protocol-designer/types' @@ -33,18 +34,15 @@ interface StateProps { canSave: boolean formHasChanges: boolean isNewStep: boolean - isPristineSetTempForm: boolean - isPristineSetHeaterShakerTempForm: boolean + bonusStepModalType: BonusStepModalType | null invariantContext: InvariantContext allLabwareDefs: LabwareDefByDefURI enableConcurrentModuleActions: boolean formData?: FormData | null } interface DispatchProps { - handleClose: () => void - saveSetTempFormWithAddedPauseUntilTemp: () => void - saveHeaterShakerFormWithAddedPauseUntilTemp: () => void - saveStepForm: () => void + cancelStepForm: () => void + saveStepForm: (options?: { userWantsBonusStep?: boolean }) => void } type StepFormManagerProps = StateProps & DispatchProps @@ -53,21 +51,19 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null { canSave, formData, formHasChanges, - handleClose, + cancelStepForm, isNewStep, - isPristineSetTempForm, - isPristineSetHeaterShakerTempForm, - saveSetTempFormWithAddedPauseUntilTemp, - saveHeaterShakerFormWithAddedPauseUntilTemp, + bonusStepModalType, saveStepForm, invariantContext, allLabwareDefs, - enableConcurrentModuleActions, } = props + const [focusedField, setFocusedField] = useState(null) const [dirtyFields, setDirtyFields] = useState( getDirtyFields(isNewStep, formData) ) + const handleBlur = (fieldName: StepFieldName): void => { if (fieldName === focusedField) { setFocusedField(null) @@ -79,25 +75,16 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null { return prevDirtyFields }) } + const { confirm: confirmClose, showConfirmation: showConfirmCancelModal, cancel: cancelClose, - } = useConditionalConfirm(handleClose, isNewStep || formHasChanges) - const { - confirm: confirmAddPauseUntilTempStep, - showConfirmation: showAddPauseUntilTempStepModal, - } = useConditionalConfirm( - saveSetTempFormWithAddedPauseUntilTemp, - isPristineSetTempForm - ) - const { - confirm: confirmAddPauseUntilHeaterShakerTempStep, - showConfirmation: showAddPauseUntilHeaterShakerTempStepModal, - } = useConditionalConfirm( - saveHeaterShakerFormWithAddedPauseUntilTemp, - isPristineSetHeaterShakerTempForm - ) + } = useConditionalConfirm(cancelStepForm, isNewStep || formHasChanges) + + const [currentBonusStepDialogType, setCurrentBonusStepDialogType] = + useState(null) + // no form selected if (formData == null) { return null @@ -113,14 +100,24 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null { focus: setFocusedField, blur: handleBlur, } - let handleSave = saveStepForm - if (isPristineSetTempForm) { - handleSave = confirmAddPauseUntilTempStep - } else if ( - isPristineSetHeaterShakerTempForm && - formData.heaterShakerSetTimer !== true - ) { - handleSave = confirmAddPauseUntilHeaterShakerTempStep + + const handleSave = (): void => { + if (bonusStepModalType == null) { + // No dialog to show. Just save the step directly. + saveStepForm() + } else { + // There's a dialog we have to show before saving the step. + // Its confirm/cancel handlers will be the thing that saves the step. + setCurrentBonusStepDialogType(bonusStepModalType) + } + } + const handleSkipPauseClick = (): void => { + saveStepForm({ userWantsBonusStep: false }) + setCurrentBonusStepDialogType(null) + } + const handleAddPauseClick = (): void => { + saveStepForm({ userWantsBonusStep: true }) + setCurrentBonusStepDialogType(null) } return ( @@ -134,52 +131,47 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null { onContinueClick={confirmClose} /> )} - {showAddPauseUntilTempStepModal && - (enableConcurrentModuleActions ? ( - - ) : ( - - ))} - {showAddPauseUntilHeaterShakerTempStepModal && - (enableConcurrentModuleActions ? ( - - ) : ( - - ))} + + {currentBonusStepDialogType === 'explainWaitForTemperatureModuleTemp' && ( + + )} + {currentBonusStepDialogType === 'explainWaitForHeaterShakerTemp' && ( + + )} + {currentBonusStepDialogType === 'explainWaitForThermocyclerProfile' && ( + + )} + {currentBonusStepDialogType === 'optionallyWaitForTemp' && ( + + )} + { formData: stepFormSelectors.getUnsavedForm(state), formHasChanges: stepFormSelectors.getCurrentFormHasUnsavedChanges(state), isNewStep: stepFormSelectors.getCurrentFormIsPresaved(state), - isPristineSetHeaterShakerTempForm: - stepFormSelectors.getUnsavedFormIsPristineHeaterShakerForm(state), - isPristineSetTempForm: - stepFormSelectors.getUnsavedFormIsPristineSetTempForm(state), + bonusStepModalType: stepFormSelectors.getBonusStepModalType(state), invariantContext: getInvariantContext(state), allLabwareDefs: labwareDefSelectors.getLabwareDefsByURI(state), enableConcurrentModuleActions: getEnableConcurrentModuleActions(state), @@ -213,18 +202,13 @@ const mapStateToProps = (state: BaseState): StateProps => { } const mapDispatchToProps = (dispatch: ThunkDispatch): DispatchProps => { - const handleClose = (): void => dispatch(actions.cancelStepForm()) - const saveHeaterShakerFormWithAddedPauseUntilTemp = (): void => - dispatch(stepsActions.saveHeaterShakerFormWithAddedPauseUntilTemp()) - const saveSetTempFormWithAddedPauseUntilTemp = (): void => - dispatch(stepsActions.saveSetTempFormWithAddedPauseUntilTemp()) - const saveStepForm = (): void => dispatch(stepsActions.saveStepForm()) + const cancelStepForm = (): void => dispatch(actions.cancelStepForm()) + const saveStepForm = (options?: { userWantsBonusStep?: boolean }): void => + dispatch(stepsActions.saveStepForm(options)) return { - handleClose, - saveSetTempFormWithAddedPauseUntilTemp, + cancelStepForm, saveStepForm, - saveHeaterShakerFormWithAddedPauseUntilTemp, } } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/AddStepButton.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/AddStepButton.tsx index 0a67ec82ff7..a841842fde0 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/AddStepButton.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/AddStepButton.tsx @@ -27,6 +27,7 @@ import { } from '@opentrons/components' import { ABSORBANCE_READER_TYPE, + FLEX_STACKER_MODULE_TYPE, getIsLid, getIsTiprack, HEATERSHAKER_MODULE_TYPE, @@ -133,6 +134,7 @@ export function AddStepButton({ 'magnet', 'temperature', 'thermocycler', + 'flexStacker', ] const isStepTypeEnabled: Record< Exclude, @@ -149,6 +151,7 @@ export function AddStepButton({ thermocycler: getIsModuleOnDeck(modules, THERMOCYCLER_MODULE_TYPE), heaterShaker: getIsModuleOnDeck(modules, HEATERSHAKER_MODULE_TYPE), absorbanceReader: getIsModuleOnDeck(modules, ABSORBANCE_READER_TYPE), + flexStacker: getIsModuleOnDeck(modules, FLEX_STACKER_MODULE_TYPE), } const addStep = (stepType: StepType): ReturnType => diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx index c3d9953ed13..473116637d5 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx @@ -96,7 +96,7 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { const hoveredStep = useSelector(getHoveredStepId) const selectedStepId = useSelector(getSelectedStepId) const multiSelectItemIds = useSelector(getMultiSelectItemIds) - const orderedStepIds = useSelector(stepFormSelectors.getOrderedStepIds) + const stepHierarchy = useSelector(stepFormSelectors.getSavedStepHierarchy) const lastMultiSelectedStepId = useSelector(getMultiSelectLastSelected) const isMultiSelectMode = useSelector(getIsMultiSelectMode) const selected: boolean = @@ -155,7 +155,7 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { if (isShiftKeyPressed) { stepsToSelect = getShiftSelectedSteps( selectedStepId, - orderedStepIds, + stepHierarchy, stepId, multiSelectItemIds, lastMultiSelectedStepId diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/utils.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/utils.test.tsx index 217b3822d80..7fa78f4b43e 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/utils.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/utils.test.tsx @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { capitalizeFirstLetterAfterNumber } from '../utils' +import { + capitalizeFirstLetterAfterNumber, + getShiftSelectedSteps, +} from '../utils' + +import type { StepHierarchy } from '/protocol-designer/steplist/utils/stepHierarchy' describe('capitalizeFirstLetterAfterNumber', () => { it('should capitalize the first letter of a step type', () => { @@ -12,3 +17,79 @@ describe('capitalizeFirstLetterAfterNumber', () => { ) }) }) + +describe('getShiftSelectedSteps', () => { + it('should return a single step if no steps were selected before', () => { + const stepHierarchy: StepHierarchy = { + topLevelItems: [ + { type: 'standaloneStep', stepId: 'step1' }, + { type: 'standaloneStep', stepId: 'step2' }, + { type: 'standaloneStep', stepId: 'step3' }, + ], + } + expect( + getShiftSelectedSteps(null, stepHierarchy, 'step2', null, null) + ).toStrictEqual(['step2']) + }) + it('should return a range if a single step was selected before', () => { + const stepHierarchy: StepHierarchy = { + topLevelItems: [ + { type: 'standaloneStep', stepId: 'step1' }, + { + type: 'thermocyclerProfileGroup', + thermocyclerProfileStepId: 'step2', + concurrentSteps: [ + { type: 'standaloneStep', stepId: 'step3' }, + { type: 'standaloneStep', stepId: 'step4' }, + ], + waitForThermocyclerProfileStepId: 'step5', + }, + { type: 'standaloneStep', stepId: 'step6' }, + { type: 'standaloneStep', stepId: 'step7' }, + ], + } + expect( + getShiftSelectedSteps('step3', stepHierarchy, 'step7', null, null) + ).toStrictEqual([ + 'step3', + 'step4', + // step5 should be skipped because it's hidden in the UI + 'step6', + 'step7', + ]) + }) + it('should return a range if multiple steps were selected before', () => { + const stepHierarchy: StepHierarchy = { + topLevelItems: [ + { type: 'standaloneStep', stepId: 'step1' }, + { + type: 'thermocyclerProfileGroup', + thermocyclerProfileStepId: 'step2', + concurrentSteps: [ + { type: 'standaloneStep', stepId: 'step3' }, + { type: 'standaloneStep', stepId: 'step4' }, + ], + waitForThermocyclerProfileStepId: 'step5', + }, + { type: 'standaloneStep', stepId: 'step6' }, + { type: 'standaloneStep', stepId: 'step7' }, + ], + } + expect( + getShiftSelectedSteps( + null, + stepHierarchy, + 'step7', + ['step2', 'step3'], + 'step3' + ) + ).toStrictEqual([ + 'step2', + 'step3', + 'step4', + // step5 should be skipped because it's hidden in the UI + 'step6', + 'step7', + ]) + }) +}) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts index e6836816ca7..5d5dff43630 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts @@ -2,8 +2,12 @@ import round from 'lodash/round' import uniq from 'lodash/uniq' import { UAParser } from 'ua-parser-js' +import { getStepVisibilities } from '/protocol-designer/steplist/utils/getStepVisibilities' +import { convertStepHierarchyToArray } from '/protocol-designer/steplist/utils/stepHierarchy' + import type { MouseEvent } from 'react' import type { StepIdType } from '/protocol-designer/form-types' +import type { StepHierarchy } from '/protocol-designer/steplist/utils/stepHierarchy' export const capitalizeFirstLetterAfterNumber = (title: string): string => title.replace( @@ -36,58 +40,64 @@ export const formatPercentage = (part: number, total: number): string => { } export const getMetaSelectedSteps = ( - multiSelectItemIds: StepIdType[] | null, - stepId: StepIdType, - selectedStepId: StepIdType | null + priorMultiSelectedItemIds: StepIdType[] | null, + newlySelectedStepId: StepIdType, + priorSingleSelectedStepId: StepIdType | null ): StepIdType[] => { let stepsToSelect: StepIdType[] - if (multiSelectItemIds?.length) { + if (priorMultiSelectedItemIds?.length) { // already have a selection, add/remove the meta-clicked item - stepsToSelect = multiSelectItemIds.includes(stepId) - ? multiSelectItemIds.filter(id => id !== stepId) - : [...multiSelectItemIds, stepId] - } else if (selectedStepId && selectedStepId === stepId) { + stepsToSelect = priorMultiSelectedItemIds.includes(newlySelectedStepId) + ? priorMultiSelectedItemIds.filter(id => id !== newlySelectedStepId) + : [...priorMultiSelectedItemIds, newlySelectedStepId] + } else if ( + priorSingleSelectedStepId && + priorSingleSelectedStepId === newlySelectedStepId + ) { // meta-clicked on the selected single step - stepsToSelect = [selectedStepId] - } else if (selectedStepId) { + stepsToSelect = [priorSingleSelectedStepId] + } else if (priorSingleSelectedStepId) { // meta-clicked on a different step, multi-select both - stepsToSelect = [selectedStepId, stepId] + stepsToSelect = [priorSingleSelectedStepId, newlySelectedStepId] } else { // meta-clicked on a step when a terminal item was selected - stepsToSelect = [stepId] + stepsToSelect = [newlySelectedStepId] } return stepsToSelect } export const getShiftSelectedSteps = ( - selectedStepId: StepIdType | null, - orderedStepIds: StepIdType[], - stepId: StepIdType, - multiSelectItemIds: StepIdType[] | null, + priorSingleSelectedStepId: StepIdType | null, + stepHierarchy: StepHierarchy, + newlySelectedStepId: StepIdType, + priorMultiSelectedItemIds: StepIdType[] | null, lastMultiSelectedStepId: StepIdType | null ): StepIdType[] => { let stepsToSelect: StepIdType[] - if (selectedStepId) { - stepsToSelect = getOrderedStepsInRange( - selectedStepId, - stepId, - orderedStepIds + if (priorSingleSelectedStepId) { + stepsToSelect = getOrderedVisibleStepsInRange( + priorSingleSelectedStepId, + newlySelectedStepId, + stepHierarchy ) - } else if (multiSelectItemIds?.length && lastMultiSelectedStepId) { - const potentialStepsToSelect = getOrderedStepsInRange( + } else if (priorMultiSelectedItemIds?.length && lastMultiSelectedStepId) { + const potentialStepsToSelect = getOrderedVisibleStepsInRange( lastMultiSelectedStepId, - stepId, - orderedStepIds + newlySelectedStepId, + stepHierarchy ) const allSelected: boolean = potentialStepsToSelect .slice(1) - .every(stepId => multiSelectItemIds.includes(stepId)) + .every(stepId => priorMultiSelectedItemIds.includes(stepId)) if (allSelected) { // if they're all selected, deselect them all - if (multiSelectItemIds.length - potentialStepsToSelect.length > 0) { - stepsToSelect = multiSelectItemIds.filter( + if ( + priorMultiSelectedItemIds.length - potentialStepsToSelect.length > + 0 + ) { + stepsToSelect = priorMultiSelectedItemIds.filter( (id: StepIdType) => !potentialStepsToSelect.includes(id) ) } else { @@ -95,25 +105,33 @@ export const getShiftSelectedSteps = ( stepsToSelect = [potentialStepsToSelect[0]] } } else { - stepsToSelect = uniq([...multiSelectItemIds, ...potentialStepsToSelect]) + stepsToSelect = uniq([ + ...priorMultiSelectedItemIds, + ...potentialStepsToSelect, + ]) } } else { - stepsToSelect = [stepId] + stepsToSelect = [newlySelectedStepId] } return stepsToSelect } -const getOrderedStepsInRange = ( +const getOrderedVisibleStepsInRange = ( lastSelectedStepId: StepIdType, stepId: StepIdType, - orderedStepIds: StepIdType[] + stepHierarchy: StepHierarchy ): StepIdType[] => { + const orderedStepIds = convertStepHierarchyToArray(stepHierarchy) + const stepVisibilities = getStepVisibilities(stepHierarchy) + const prevIndex: number = orderedStepIds.indexOf(lastSelectedStepId) const currentIndex: number = orderedStepIds.indexOf(stepId) - const [startIndex, endIndex] = [prevIndex, currentIndex].sort((a, b) => a - b) - const orderedSteps = orderedStepIds.slice(startIndex, endIndex + 1) - return orderedSteps + + const orderedVisibleSteps = orderedStepIds + .slice(startIndex, endIndex + 1) + .filter(stepId => stepVisibilities[stepId].isVisibleToUser) + return orderedVisibleSteps } export const nonePressed = (keysPressed: boolean[]): boolean => diff --git a/protocol-designer/src/resources/sentry.ts b/protocol-designer/src/resources/sentry.ts index 21a1e10829c..0a81988c6a8 100644 --- a/protocol-designer/src/resources/sentry.ts +++ b/protocol-designer/src/resources/sentry.ts @@ -6,18 +6,30 @@ import { } from '@sentry/react' import { getHasOptedIn } from '../analytics/selectors' -import { getIsProduction } from '../networking/opentronsWebApi' +import { getIsProduction, getIsStaging } from '../networking/opentronsWebApi' import type { BaseState } from '../types' let isSentryInitialized = false -// Note (kk: 06/09/2025) at this moment, we are not using a dev DSN -// because we are not using Sentry in development. If we decide to use it -// in the future, we can add a dev DSN here. -const sentryDsn = getIsProduction() - ? _OT_PD_SENTRY_DSN_ - : _OT_PD_SENTRY_DEV_DSN_ +// Production DSN is shared by production and staging so sourcemaps live in one project. +// Development can still fall back to the dev DSN if it is configured locally. +const sentryDsn = _OT_PD_SENTRY_DSN_ ?? _OT_PD_SENTRY_DEV_DSN_ + +const resolveSentryEnvironment = (): + | 'production' + | 'staging' + | 'development' => { + if (getIsProduction()) { + return 'production' + } + + if (getIsStaging()) { + return 'staging' + } + + return 'development' +} export const initializeSentry = (state: BaseState): void => { const optedIn = getHasOptedIn(state)?.hasOptedIn ?? false @@ -34,7 +46,7 @@ export const initializeSentry = (state: BaseState): void => { try { init({ dsn: sentryDsn, - environment: 'production', + environment: resolveSentryEnvironment(), release: _OT_PD_VERSION_, integrations: [ captureConsoleIntegration({ levels: ['assert'] }), diff --git a/protocol-designer/src/step-forms/selectors/index.ts b/protocol-designer/src/step-forms/selectors/index.ts index fef879e436b..0d315732e0a 100644 --- a/protocol-designer/src/step-forms/selectors/index.ts +++ b/protocol-designer/src/step-forms/selectors/index.ts @@ -54,6 +54,7 @@ import type { TrashBinEntities, WasteChuteEntities, } from '@opentrons/step-generation' +import type { BonusStepModalType } from '/protocol-designer/components/organisms' import type { StepHierarchy } from '/protocol-designer/steplist/utils/stepHierarchy' import type { FormData, @@ -237,6 +238,10 @@ const ABSORBANCE_READER_INITIAL_STATE: AbsorbanceReaderState = { } const FLEX_STACKER_INITIAL_STATE: FlexStackerModuleState = { type: FLEX_STACKER_MODULE_TYPE, + maxPoolCount: 0, + storedLabwareDetails: null, + labwareInHopper: null, + labwareOnShuttle: null, } const MODULE_INITIAL_STATES_MAP: Record< @@ -814,28 +819,54 @@ export const getArgsAndErrorsByStepId: Selector< ) } ) -export const getUnsavedFormIsPristineSetTempForm = createSelector( + +export const getBonusStepModalType = createSelector( getUnsavedForm, getCurrentFormIsPresaved, - (unsavedForm, isPresaved): boolean => { - const isSetTempForm = + featureFlagSelectors.getEnableConcurrentModuleActions, + ( + unsavedForm, + currentFormIsPresaved, + enableConcurrentModuleActions + ): BonusStepModalType | null => { + // NOTE: This logic to decide whether a bonus step is warranted should be kept in + // sync with saveStepForm(). + + const isTempModSetTempForm = unsavedForm?.stepType === 'temperature' && unsavedForm?.targetTemperature != null - return isPresaved && isSetTempForm - } -) - -export const getUnsavedFormIsPristineHeaterShakerForm = createSelector( - getUnsavedForm, - getCurrentFormIsPresaved, - (unsavedForm, isPresaved): boolean => { - const isSetHsTempForm = + const isHSSetTempForm = unsavedForm?.stepType === 'heaterShaker' && - unsavedForm?.targetHeaterShakerTemperature != null - - return isPresaved && isSetHsTempForm + unsavedForm?.targetHeaterShakerTemperature != null && + unsavedForm?.heaterShakerSetTimer !== true + const isTCProfileForm = + unsavedForm?.stepType === 'thermocycler' && + unsavedForm?.thermocyclerFormType === 'thermocyclerProfile' + + const isFirstTimeSavingThisForm = currentFormIsPresaved + + // todo(mm, 2025-11-24): These should also be conditional on "Don't show again" + // not having been clicked before. https://opentrons.atlassian.net/browse/EXEC-1925 + if (isTempModSetTempForm && isFirstTimeSavingThisForm) { + return enableConcurrentModuleActions + ? 'explainWaitForTemperatureModuleTemp' + : 'optionallyWaitForTemp' + } else if (isHSSetTempForm && isFirstTimeSavingThisForm) { + return enableConcurrentModuleActions + ? 'explainWaitForHeaterShakerTemp' + : 'optionallyWaitForTemp' + } else if ( + enableConcurrentModuleActions && + isTCProfileForm && + isFirstTimeSavingThisForm + ) { + return 'explainWaitForThermocyclerProfile' + } else { + return null + } } ) + export const getFormLevelWarningsForUnsavedForm: Selector< BaseState, FormWarning[] @@ -853,6 +884,7 @@ export const getFormLevelWarningsForUnsavedForm: Selector< return getFormWarnings(unsavedForm.stepType, hydratedForm) } ) + export const getFormLevelWarningsPerStep: Selector< BaseState, Record diff --git a/protocol-designer/src/step-forms/test/selectors.test.ts b/protocol-designer/src/step-forms/test/selectors.test.ts index 79e79d55dc8..c17619be185 100644 --- a/protocol-designer/src/step-forms/test/selectors.test.ts +++ b/protocol-designer/src/step-forms/test/selectors.test.ts @@ -2,10 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getBatchEditFormHasUnsavedChanges, + getBonusStepModalType, getEquippedPipetteOptions, getNextUserVisibleStepNumber, - getUnsavedFormIsPristineHeaterShakerForm, - getUnsavedFormIsPristineSetTempForm, getUserVisibleStepNumbers, } from '../selectors' @@ -103,63 +102,84 @@ describe('getBatchEditFormHasUnsavedChanges', () => { }) }) -describe('getUnsavedFormIsPristineSetTempForm', () => { - const mockIsPresaved = true - it('should return true if temperature mod set temp is true formData ', () => { - // @ts-expect-error(jr, 4/8/22): missing module id +describe('getBonusStepModalType', () => { + it('should handle temperature module set temp form', () => { const formData: FormData = { + id: 'step_123', stepType: 'temperature', targetTemperature: 33, } - const expected = true - const result = getUnsavedFormIsPristineSetTempForm.resultFunc( - formData, - mockIsPresaved + const mockIsPresaved = true + + const resultWithoutConcurrentModuleActions = + getBonusStepModalType.resultFunc(formData, mockIsPresaved, false) + expect(resultWithoutConcurrentModuleActions).toEqual( + 'optionallyWaitForTemp' ) - expect(result).toEqual(expected) - }) - it('should return false if temperature mod is false in formData ', () => { - // @ts-expect-error(jr, 4/8/22): missing module id - const formData: FormData = { - stepType: 'temperature', - setTemperature: null, - } - const expected = false - const result = getUnsavedFormIsPristineSetTempForm.resultFunc( + + const resultWithConcurrentModuleActions = getBonusStepModalType.resultFunc( formData, - mockIsPresaved + mockIsPresaved, + true + ) + expect(resultWithConcurrentModuleActions).toEqual( + 'explainWaitForTemperatureModuleTemp' ) - expect(result).toEqual(expected) }) -}) - -describe('getUnsavedFormIsPrestineSetHeaterShakerTempForm', () => { - const mockIsPresaved = true - it('should return true if heater shaker temperature is true in formData ', () => { - // @ts-expect-error(jr, 4/8/22): missing module id + it('should handle heater shaker set temp form', () => { const formData: FormData = { + id: 'step_123', stepType: 'heaterShaker', targetHeaterShakerTemperature: '10', } - const expected = true - const result = getUnsavedFormIsPristineHeaterShakerForm.resultFunc( + const mockIsPresaved = true + + const resultWithoutConcurrentModuleActions = + getBonusStepModalType.resultFunc(formData, mockIsPresaved, false) + expect(resultWithoutConcurrentModuleActions).toEqual( + 'optionallyWaitForTemp' + ) + + const resultWithConcurrentModuleActions = getBonusStepModalType.resultFunc( formData, - mockIsPresaved + mockIsPresaved, + true + ) + expect(resultWithConcurrentModuleActions).toEqual( + 'explainWaitForHeaterShakerTemp' ) - expect(result).toEqual(expected) }) - it('should return false if heater shaker temperature is false in formData ', () => { - // @ts-expect-error(jr, 4/8/22): missing module id + it('should handle thermocycler profile form', () => { const formData: FormData = { - stepType: 'heaterShaker', - targetHeaterShakerTemperature: null, + id: 'step_123', + stepType: 'thermocycler', + thermocyclerFormType: 'thermocyclerProfile', } - const expected = false - const result = getUnsavedFormIsPristineHeaterShakerForm.resultFunc( + const mockIsPresaved = true + + const resultWithoutConcurrentModuleActions = + getBonusStepModalType.resultFunc(formData, mockIsPresaved, false) + expect(resultWithoutConcurrentModuleActions).toEqual(null) + + const resultWithConcurrentModuleActions = getBonusStepModalType.resultFunc( formData, - mockIsPresaved + mockIsPresaved, + true ) - expect(result).toEqual(expected) + expect(resultWithConcurrentModuleActions).toEqual( + 'explainWaitForThermocyclerProfile' + ) + }) + it('should return null when formData is null', () => { + const formData: FormData | null = null + const mockIsPresaved = true + const mockEnableConcurrentModuleActions = false + const result = getBonusStepModalType.resultFunc( + formData, + mockIsPresaved, + mockEnableConcurrentModuleActions + ) + expect(result).toEqual(null) }) }) diff --git a/protocol-designer/src/step-forms/types.ts b/protocol-designer/src/step-forms/types.ts index 2e7bd754846..f86848559cd 100644 --- a/protocol-designer/src/step-forms/types.ts +++ b/protocol-designer/src/step-forms/types.ts @@ -4,6 +4,8 @@ import type { CutoutId, FLEX_STACKER_MODULE_TYPE, FlexModuleCutoutFixtureId, + FlexStackerSetStoredLabwareParams, + FlexStackerStoredLabwareGroup, HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE, MAGNETIC_MODULE_TYPE, @@ -71,7 +73,10 @@ export interface MagneticBlockState { export interface FlexStackerModuleState { type: typeof FLEX_STACKER_MODULE_TYPE - // TODO: extend this state + maxPoolCount: number + storedLabwareDetails: FlexStackerSetStoredLabwareParams | null + labwareInHopper: FlexStackerStoredLabwareGroup[] | null + labwareOnShuttle: FlexStackerStoredLabwareGroup | null } export type InitializationMode = 'single' | 'multi' export interface Initialization { diff --git a/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts b/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts index 2de88ebcce3..ce8680446f5 100644 --- a/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts +++ b/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts @@ -3,6 +3,7 @@ import last from 'lodash/last' import { ABSORBANCE_READER_TYPE, ALL, + FLEX_STACKER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, @@ -319,6 +320,38 @@ const _patchHeaterShakerModuleId = return null } +const _patchFlexStackerModuleId = + (args: { + initialDeckSetup: InitialDeckSetup + orderedStepIds: OrderedStepIdsState + savedStepForms: SavedStepFormState + stepType: StepType + robotStateTimeline: Timeline + }): FormUpdater => + () => { + const { initialDeckSetup, stepType } = args + const numOfModules = + Object.values(initialDeckSetup.modules).filter( + module => module.type === FLEX_STACKER_MODULE_TYPE + )?.length ?? 1 + const hasFlexStackerModuleId = stepType === 'flexStacker' + // pre-select form type if module is set + if (hasFlexStackerModuleId && numOfModules === 1) { + const moduleId = + getModuleOnDeckByType(initialDeckSetup, FLEX_STACKER_MODULE_TYPE)?.id ?? + null + + if (moduleId == null) { + return null + } + // get labware details in hopper at moment + return { + moduleId, + } + } + return null + } + const _patchAbsorbanceReaderModuleId = (args: { initialDeckSetup: InitialDeckSetup @@ -494,6 +527,14 @@ export const createPresavedStepForm = ({ robotStateTimeline, }) + const updateFlexStackerModuleId = _patchFlexStackerModuleId({ + initialDeckSetup, + orderedStepIds, + savedStepForms, + stepType, + robotStateTimeline, + }) + const updateThermocyclerFields = _patchThermocyclerFields({ initialDeckSetup, stepType, @@ -515,6 +556,7 @@ export const createPresavedStepForm = ({ updateHeaterShakerModuleId, updateMagneticModuleId, updateAbsorbanceReaderModuleId, + updateFlexStackerModuleId, updateDefaultLabwareLocations, updateMoveLabwareFields, ].reduce( diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts index 3c23fc0fad6..325ff63828b 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -251,6 +251,15 @@ export function getDefaultsForStepType( referenceWavelengthActive: false, wavelengths: [Object.keys(ABSORBANCE_READER_COLOR_BY_WAVELENGTH)[0]], // default to first known wavelength } + // TODO(TZ, 2025-12-03): not fully flushed out, but this is the initial state for the flex stacker form + case 'flexStacker': + return { + moduleId: null, + labwareInHopper: null, + labwareOnShuttle: null, + maxPoolCount: 0, + storedLabwareDetails: null, + } default: return {} } diff --git a/protocol-designer/src/steplist/formLevel/index.ts b/protocol-designer/src/steplist/formLevel/index.ts index 73f498ae790..779859fd192 100644 --- a/protocol-designer/src/steplist/formLevel/index.ts +++ b/protocol-designer/src/steplist/formLevel/index.ts @@ -97,6 +97,7 @@ import type { HydratedAbsorbanceReaderFormData, HydratedCameraFormData, HydratedCommentFormData, + HydratedFlexStackerFormData, HydratedFormData, HydratedHeaterShakerFormData, HydratedMagnetFormData, @@ -137,6 +138,7 @@ interface StepFormDataMap { thermocycler: HydratedThermocyclerFormData comment: HydratedCommentFormData camera: HydratedCameraFormData + flexStacker: HydratedFlexStackerFormData } interface FormHelpers { getErrors: ( @@ -290,6 +292,9 @@ const stepFormHelperMap: { camera: { getErrors: composeErrors(), }, + flexStacker: { + getErrors: composeErrors(), + }, } export const getFormErrors = ( @@ -380,6 +385,12 @@ export const getFormErrors = ( moduleEntities, labwareEntities ) + case 'flexStacker': + return stepFormHelperMap[stepType].getErrors( + formData as HydratedFlexStackerFormData, + moduleEntities, + labwareEntities + ) } } diff --git a/protocol-designer/src/timelineMiddleware/__tests__/generateRobotStateTimeline.test.ts b/protocol-designer/src/timelineMiddleware/__tests__/generateRobotStateTimeline.test.ts index fedc9072bf4..4cb4665a756 100644 --- a/protocol-designer/src/timelineMiddleware/__tests__/generateRobotStateTimeline.test.ts +++ b/protocol-designer/src/timelineMiddleware/__tests__/generateRobotStateTimeline.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { fixtureTiprack300ul, @@ -24,8 +24,9 @@ import type { StepArgsAndErrorsById } from '../../steplist' vi.mock('../../labware-defs/utils') describe('generateRobotStateTimeline', () => { - it('performs eager tip dropping', () => { - const allStepArgsAndErrors: StepArgsAndErrorsById = { + let allStepArgsAndErrors: StepArgsAndErrorsById + beforeEach(() => { + allStepArgsAndErrors = { a: { errors: false, stepArgs: { @@ -216,6 +217,9 @@ describe('generateRobotStateTimeline', () => { }, }, } + }) + + it('performs eager tip dropping', () => { const orderedStepIds = ['a', 'b', 'c'] const invariantContext = makeContext() const initialRobotState = getInitialRobotStateStandard(invariantContext) @@ -317,6 +321,56 @@ mock_pipette.drop_tip() mock_pipette.pick_up_tip(location=mock_tip_rack_1) mock_pipette.mix(...) mock_pipette.drop_tip() +`.trim(), + ]) + }) + + it('does not perform eager tip dropping if the step returns tip', () => { + allStepArgsAndErrors = { + ...allStepArgsAndErrors, + a: { + ...allStepArgsAndErrors.a, + stepArgs: { + ...allStepArgsAndErrors.a.stepArgs, + dropTipLocation: getLabwareDefURI( + fixtureTiprack300ul as LabwareDefinition2 + ), + }, + }, + } as StepArgsAndErrorsById + const orderedStepIds = ['a', 'b', 'c'] + const invariantContext = makeContext() + const initialRobotState = getInitialRobotStateStandard(invariantContext) + const result = generateRobotStateTimeline({ + allStepArgsAndErrors, + orderedStepIds, + initialRobotState, + invariantContext, + }) + expect(result.errors).toBe(null) + + // The regex elides all the indented arguments in the Python code + const pythonCommandsOverview = result.timeline.map(frame => + frame.python?.replaceAll(/(\n\s+.*)+\n/g, '...') + ) + expect(pythonCommandsOverview).toEqual([ + // Step a: + ` +mock_pipette.transfer_with_liquid_class(...) +`.trim(), + // Step b: + ` +mock_pipette_p300_multi.transfer_with_liquid_class(...) +mock_pipette_p300_multi.drop_tip() +`.trim(), + // Step c: + ` +mock_pipette.pick_up_tip(location=mock_tip_rack_1) +mock_pipette.mix(...) +mock_pipette.drop_tip() +mock_pipette.pick_up_tip(location=mock_tip_rack_1) +mock_pipette.mix(...) +mock_pipette.drop_tip() `.trim(), ]) }) diff --git a/protocol-designer/src/timelineMiddleware/generateRobotStateTimeline.ts b/protocol-designer/src/timelineMiddleware/generateRobotStateTimeline.ts index 55192cfda02..e85af8ec464 100644 --- a/protocol-designer/src/timelineMiddleware/generateRobotStateTimeline.ts +++ b/protocol-designer/src/timelineMiddleware/generateRobotStateTimeline.ts @@ -1,3 +1,4 @@ +import { getIsTiprack } from '@opentrons/shared-data' import { commandCreatorsTimeline, curryCommandCreator, @@ -56,11 +57,22 @@ export const generateRobotStateTimeline = ( // we know the current tip(s) aren't going to be reused, so we can drop them // immediately after the current step is done. const pipetteId = getPipetteIdFromCCArgs(args) + const dropTipLocation = 'dropTipLocation' in args ? args.dropTipLocation : null // assume that whenever we have a pipetteId we also have a dropTipLocation if (pipetteId != null && dropTipLocation != null) { + const prevNozzleConfiguration = 'nozzles' in args ? args.nozzles : null + + // no eager tip dropping if this step returns tip + const dropTipLabware = + 'dropTipLocation' in args + ? invariantContext.labwareEntities[args.dropTipLocation] + : null + const isReturnTip = + dropTipLabware != null ? getIsTiprack(dropTipLabware.def) : false + const nextStepArgsForPipette = continuousStepArgs .slice(stepIndex + 1) .find( @@ -69,7 +81,9 @@ export const generateRobotStateTimeline = ( const willReuseTip = nextStepArgsForPipette != null && 'changeTip' in nextStepArgsForPipette && - nextStepArgsForPipette.changeTip === 'never' + nextStepArgsForPipette.changeTip === 'never' && + nextStepArgsForPipette.nozzles === prevNozzleConfiguration && + !isReturnTip const isWasteChute = invariantContext.wasteChuteEntities[dropTipLocation] != null diff --git a/protocol-designer/src/types.ts b/protocol-designer/src/types.ts index 4916e9c3e8b..8bf955b7bc5 100644 --- a/protocol-designer/src/types.ts +++ b/protocol-designer/src/types.ts @@ -33,9 +33,10 @@ export interface BaseState { } export type GetState = () => BaseState export type Selector = (arg: BaseState) => T -// eslint-disable-next-line no-use-before-define export type ThunkDispatch = (action: A | ThunkAction) => A +// todo(mm, 2025-10-15): Replace with Redux's native ThunkAction. This type definition +// predates our use of TypeScript and may predate TypeScript support in Redux. export type ThunkAction = | ((dispatch: ThunkDispatch, getState: GetState) => A) | ((dispatch: ThunkDispatch, getState: GetState) => void) diff --git a/protocol-designer/src/ui/modules/selectors.ts b/protocol-designer/src/ui/modules/selectors.ts index 4bd964286f4..1b8a3537926 100644 --- a/protocol-designer/src/ui/modules/selectors.ts +++ b/protocol-designer/src/ui/modules/selectors.ts @@ -3,6 +3,7 @@ import { createSelector } from 'reselect' import { ABSORBANCE_READER_TYPE, + FLEX_STACKER_MODULE_TYPE, getLabwareDisplayName, HEATERSHAKER_MODULE_TYPE, MAGNETIC_MODULE_TYPE, @@ -99,6 +100,21 @@ export const getAbsorbanceReaderLabwareOptions: Selector = } ) +/** Returns dropdown option for labware placed on flex stacker module */ +export const getFlexStackerLabwareOptions: Selector = + createSelector( + getInitialDeckSetup, + getLabwareNicknamesById, + (initialDeckSetup, nicknamesById) => { + const flexStackerModuleOptions = getModuleLabwareOptions( + initialDeckSetup, + nicknamesById, + FLEX_STACKER_MODULE_TYPE + ) + return flexStackerModuleOptions + } + ) + /** Get single magnetic module (assumes no multiples) */ export const getSingleMagneticModuleId: Selector = createSelector( diff --git a/protocol-designer/src/ui/steps/actions/__tests__/actions.test.ts b/protocol-designer/src/ui/steps/actions/__tests__/actions.test.ts index 6115695a9a0..964f7d43549 100644 --- a/protocol-designer/src/ui/steps/actions/__tests__/actions.test.ts +++ b/protocol-designer/src/ui/steps/actions/__tests__/actions.test.ts @@ -4,8 +4,10 @@ import { thunk } from 'redux-thunk' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { when } from 'vitest-when' +import * as featureFlagSelectors from '/protocol-designer/feature-flags/selectors' import { getRobotStateTimeline } from '/protocol-designer/file-data/selectors' import * as stepFormSelectors from '/protocol-designer/step-forms/selectors' +import * as tutorialSelectors from '/protocol-designer/tutorial/selectors' import * as utils from '/protocol-designer/utils' import { @@ -14,13 +16,10 @@ import { getSelectedStepId, } from '../../selectors' import { deselectAllSteps, selectAllSteps } from '../actions' -import { - duplicateSelectedSteps, - saveHeaterShakerFormWithAddedPauseUntilTemp, - saveSetTempFormWithAddedPauseUntilTemp, -} from '../thunks' +import { duplicateSelectedSteps, saveStepForm } from '../thunks' -import type { RobotState, Timeline } from '@opentrons/step-generation/src/types' +import type { Timeline } from '@opentrons/step-generation/src/types' +import type { FormData } from '/protocol-designer/form-types' import type { DuplicateSelectedStepsAction, SelectMultipleStepsAction, @@ -28,41 +27,13 @@ import type { } from '../types' vi.mock('/protocol-designer/step-forms/selectors') +vi.mock('/protocol-designer/feature-flags/selectors') +vi.mock('/protocol-designer/tutorial/selectors') vi.mock('../../selectors') vi.mock('/protocol-designer/file-data/selectors') const mockStore = legacy_configureStore([thunk] as any) -const initialRobotState: RobotState = { - labware: { - fixedTrash: { - stack: ['fixedTrash', '12'], - }, - tiprackId: { - stack: ['tiprackId', '1'], - }, - plateId: { - stack: ['plateId', '7'], - }, - }, - modules: {}, - pipettes: { - pipetteId: { - mount: 'left', - }, - }, - liquidState: { - pipettes: {}, - labware: {}, - trashBins: {}, - wasteChute: {}, - }, - tipState: { - pipettes: {}, - tipracks: {}, - }, -} - describe('steps actions', () => { describe('selectAllSteps', () => { let ids: string[] @@ -280,294 +251,219 @@ describe('steps actions', () => { }) }) - describe('saveHeaterShakerFormWithAddedPauseUntilTemp', () => { - const mockRobotStateTimeline: Timeline = { - timeline: [ - { - commands: [ - { - commandType: 'heaterShaker/waitForTemperature', - - params: { - moduleId: 'heaterShakerId', - }, - }, - ], - robotState: initialRobotState, - warnings: [], - }, - ], - errors: null, - } - + describe('saveStepForm', () => { beforeEach(() => { - when(vi.mocked(stepFormSelectors.getUnsavedForm)) - .calledWith(expect.anything()) - .thenReturn({ - stepType: 'heaterShaker', - targetHeaterShakerTemperature: '10', - } as any) + vi.mocked(getRobotStateTimeline).mockReturnValue({ + timeline: [], + errors: null, + } as Timeline) + vi.mocked(tutorialSelectors.shouldShowCoolingHint).mockReturnValue(false) + vi.mocked(tutorialSelectors.shouldShowWasteChuteHint).mockReturnValue( + false + ) vi.mocked( - stepFormSelectors.getUnsavedFormIsPristineHeaterShakerForm + featureFlagSelectors.getEnableConcurrentModuleActions ).mockReturnValue(true) - vi.mocked(getRobotStateTimeline).mockReturnValue(mockRobotStateTimeline) }) afterEach(() => { + vi.resetAllMocks() vi.restoreAllMocks() }) - it('should save heater shaker step with a pause until temp is reached', () => { - const HsStepWithPause = [ + describe('temperature module form', () => { + it.each([ { - payload: { - stepType: 'heaterShaker', - targetHeaterShakerTemperature: '10', - }, - type: 'SAVE_STEP_FORM', + description: + 'should add bonus step when enableConcurrentModuleActions is false and userWantsBonusStep is true', + isPresaved: true, + userWantsBonusStep: true, + shouldAddBonusStep: true, + expectedPauseTemperature: 25, }, - { - meta: { - robotStateTimeline: { - errors: null, - timeline: [ - { - commands: [ - { - commandType: 'heaterShaker/waitForTemperature', - params: { - moduleId: 'heaterShakerId', - }, - }, - ], - robotState: { - labware: { - fixedTrash: { - stack: ['fixedTrash', '12'], - }, - tiprackId: { - stack: ['tiprackId', '1'], - }, - plateId: { - stack: ['plateId', '7'], - }, - }, - liquidState: { - labware: {}, - pipettes: {}, - trashBins: {}, - wasteChute: {}, - }, - modules: {}, - pipettes: { - pipetteId: { - mount: 'left', - }, - }, - tipState: { - pipettes: {}, - tipracks: {}, - }, - }, - warnings: [], - }, - ], - }, - }, - payload: { - id: '__presaved_step__', - stepType: 'pause', - }, - type: 'ADD_STEP', + description: + 'should NOT add bonus step when userWantsBonusStep is false', + isPresaved: true, + userWantsBonusStep: false, + shouldAddBonusStep: false, }, { - payload: { - update: { - pauseAction: 'untilTemperature', - }, - }, - type: 'CHANGE_FORM_INPUT', + description: 'should add bonus step when userWantsBonusStep is true', + isPresaved: true, + userWantsBonusStep: true, + shouldAddBonusStep: true, }, + ])( + '$description', + ({ + isPresaved, + userWantsBonusStep, + shouldAddBonusStep, + expectedPauseTemperature, + }) => { + const temperatureForm: FormData = { + id: 'step_123', + stepType: 'temperature', + targetTemperature: 25, + moduleId: 'temperatureId', + } + + when(vi.mocked(stepFormSelectors.getUnsavedForm)) + .calledWith(expect.anything()) + .thenReturn(temperatureForm) + when(vi.mocked(stepFormSelectors.getCurrentFormIsPresaved)) + .calledWith(expect.anything()) + .thenReturn(isPresaved) + + const store: any = mockStore() + store.dispatch(saveStepForm({ userWantsBonusStep })) + + const actions = store.getActions() + expect(actions[0]).toEqual({ + type: 'SAVE_STEP_FORM', + payload: temperatureForm, + }) + if (shouldAddBonusStep) { + expect(actions[1].type).toStrictEqual('ADD_STEP') + expect(actions[2].payload.update.pauseAction).toStrictEqual( + 'untilTemperature' + ) + if (expectedPauseTemperature !== undefined) { + expect(actions[3].payload.update.moduleId).toStrictEqual( + 'temperatureId' + ) + expect(actions[4].payload.update.pauseTemperature).toStrictEqual( + expectedPauseTemperature + ) + expect(actions[5].type).toStrictEqual('SAVE_STEP_FORM') + } + } else { + expect(actions.length).toStrictEqual(1) + } + } + ) + }) + + describe('heater shaker form', () => { + it.each([ { - payload: { - update: { - moduleId: undefined, - }, - }, - type: 'CHANGE_FORM_INPUT', + description: 'should add bonus step when userWantsBonusStep is true', + userWantsBonusStep: true, + shouldAddBonusStep: true, + expectedPauseTemperature: '10', }, { - payload: { - update: { - pauseTemperature: '10', - }, - }, - type: 'CHANGE_FORM_INPUT', + description: + 'should NOT add bonus step when userWantsBonusStep is false', + userWantsBonusStep: false, + shouldAddBonusStep: false, }, - { - payload: { + ])( + '$description', + ({ + userWantsBonusStep, + shouldAddBonusStep, + expectedPauseTemperature, + }) => { + const heaterShakerForm: FormData = { + id: 'step_123', stepType: 'heaterShaker', targetHeaterShakerTemperature: '10', - }, - type: 'SAVE_STEP_FORM', - }, - ] + moduleId: 'heaterShakerId', + } - const store: any = mockStore() - store.dispatch(saveHeaterShakerFormWithAddedPauseUntilTemp()) + when(vi.mocked(stepFormSelectors.getUnsavedForm)) + .calledWith(expect.anything()) + .thenReturn(heaterShakerForm) + when(vi.mocked(stepFormSelectors.getCurrentFormIsPresaved)) + .calledWith(expect.anything()) + .thenReturn(true) - expect(store.getActions()).toEqual(HsStepWithPause) + const store: any = mockStore() + store.dispatch(saveStepForm({ userWantsBonusStep })) + + const actions = store.getActions() + expect(actions[0]).toEqual({ + type: 'SAVE_STEP_FORM', + payload: heaterShakerForm, + }) + if (shouldAddBonusStep) { + expect(actions[1].type).toStrictEqual('ADD_STEP') + expect(actions[2].payload.update.pauseAction).toStrictEqual( + 'untilTemperature' + ) + if (expectedPauseTemperature !== undefined) { + expect(actions[3].payload.update.moduleId).toStrictEqual( + 'heaterShakerId' + ) + expect(actions[4].payload.update.pauseTemperature).toStrictEqual( + expectedPauseTemperature + ) + expect(actions[5].type).toStrictEqual('SAVE_STEP_FORM') + } + } + } + ) }) - }) - describe('saveSetTempFormWithAddedPauseUntilTemp', () => { - const mockRobotStateTimeline: Timeline = { - timeline: [ + describe('thermocycler profile form', () => { + it.each([ { - commands: [ - { - commandType: 'temperatureModule/setTargetTemperature', - - params: { - moduleId: 'temperatureId', - celsius: 25, - }, - }, - ], - robotState: initialRobotState, - warnings: [], + description: + 'should automatically add bonus step when enableConcurrentModuleActions is true', + enableConcurrentModuleActions: true, + shouldAddBonusStep: true, }, - ], - errors: null, - } - - beforeEach(() => { - when(vi.mocked(stepFormSelectors.getUnsavedForm)) - .calledWith(expect.anything()) - .thenReturn({ - stepType: 'temperature', - setTemperature: 'true', - targetTemperature: 10, - moduleId: 'mockTemp', - } as any) - vi.mocked( - stepFormSelectors.getUnsavedFormIsPristineSetTempForm - ).mockReturnValue(true) - vi.mocked(getRobotStateTimeline).mockReturnValue(mockRobotStateTimeline) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('should save temperature step with a pause until temp is reached', () => { - const temperatureStepWithPause = [ { - payload: { - setTemperature: 'true', - stepType: 'temperature', - targetTemperature: 10, - moduleId: 'mockTemp', - }, - type: 'SAVE_STEP_FORM', + description: + 'should NOT add bonus step when enableConcurrentModuleActions is false', + enableConcurrentModuleActions: false, + shouldAddBonusStep: false, }, + ])( + '$description', + ({ enableConcurrentModuleActions, shouldAddBonusStep }) => { + const thermocyclerForm: FormData = { + id: 'step_123', + stepType: 'thermocycler', + thermocyclerFormType: 'thermocyclerProfile', + moduleId: 'thermocyclerId', + } - { - meta: { - robotStateTimeline: { - errors: null, - timeline: [ - { - commands: [ - { - commandType: 'temperatureModule/setTargetTemperature', - params: { - moduleId: 'temperatureId', - celsius: 25, - }, - }, - ], - robotState: { - labware: { - plateId: { - stack: ['plateId', '7'], - }, - tiprackId: { - stack: ['tiprackId', '1'], - }, + when(vi.mocked(stepFormSelectors.getUnsavedForm)) + .calledWith(expect.anything()) + .thenReturn(thermocyclerForm) + when(vi.mocked(stepFormSelectors.getCurrentFormIsPresaved)) + .calledWith(expect.anything()) + .thenReturn(true) + when(vi.mocked(featureFlagSelectors.getEnableConcurrentModuleActions)) + .calledWith(expect.anything()) + .thenReturn(enableConcurrentModuleActions) - fixedTrash: { - stack: ['fixedTrash', '12'], - }, - }, - liquidState: { - labware: {}, - pipettes: {}, - trashBins: {}, - wasteChute: {}, - }, - modules: {}, - pipettes: { - pipetteId: { - mount: 'left', - }, - }, - tipState: { - pipettes: {}, - tipracks: {}, - }, - }, - warnings: [], - }, - ], - }, - }, - payload: { - id: '__presaved_step__', - stepType: 'pause', - }, - type: 'ADD_STEP', - }, - { - payload: { - update: { - pauseAction: 'untilTemperature', - }, - }, - type: 'CHANGE_FORM_INPUT', - }, - { - payload: { - update: { - moduleId: 'mockTemp', - }, - }, - type: 'CHANGE_FORM_INPUT', - }, - { - payload: { - update: { - pauseTemperature: 10, - }, - }, - type: 'CHANGE_FORM_INPUT', - }, - { - payload: { - setTemperature: 'true', - stepType: 'temperature', - targetTemperature: 10, - moduleId: 'mockTemp', - }, - type: 'SAVE_STEP_FORM', - }, - ] + const store: any = mockStore() + store.dispatch(saveStepForm()) - const store: any = mockStore() - store.dispatch(saveSetTempFormWithAddedPauseUntilTemp()) + const actions = store.getActions() + expect(actions[0]).toEqual({ + type: 'SAVE_STEP_FORM', + payload: thermocyclerForm, + }) - expect(store.getActions()).toEqual(temperatureStepWithPause) + if (shouldAddBonusStep) { + expect(actions[1].type).toStrictEqual('ADD_STEP') + expect(actions[2].payload.update.pauseAction).toStrictEqual( + 'untilThermocyclerProfileComplete' + ) + expect(actions[3].payload.update.moduleId).toStrictEqual( + 'thermocyclerId' + ) + expect(actions[4].type).toStrictEqual('SAVE_STEP_FORM') + } else { + expect(actions.length).toStrictEqual(1) + } + } + ) }) }) }) diff --git a/protocol-designer/src/ui/steps/actions/actions.ts b/protocol-designer/src/ui/steps/actions/actions.ts index e7325870dc6..a9e1a7adbad 100644 --- a/protocol-designer/src/ui/steps/actions/actions.ts +++ b/protocol-designer/src/ui/steps/actions/actions.ts @@ -9,7 +9,6 @@ import { import { selectors as stepFormSelectors } from '../../../step-forms' import { PRESAVED_STEP_ID } from '../../../steplist/types' import { getMultiSelectLastSelected } from '../selectors' -import { resetScrollElements } from '../utils' import type { Timeline } from '@opentrons/step-generation' import type { AnalyticsEventAction } from '../../../analytics/actions' @@ -28,7 +27,6 @@ import type { selectDropdownItemAction, Selection, SelectMultipleStepsAction, - SelectMultipleStepsForGroupAction, SelectStepAction, SelectTerminalItemAction, SetWellSelectionLabwareKeyAction, @@ -36,9 +34,11 @@ import type { ViewSubstep, } from './types' -// adds an incremental integer ID for Step reducers. -// NOTE: if this is an "add step" directly performed by the user, -// addAndSelectStepWithHints is probably what you want +/** + * adds an incremental integer ID for Step reducers. + * NOTE: if this is an "add step" directly performed by the user, + * addAndSelectStepWithHints is probably what you want + */ export const addStep = (args: { stepType: StepType robotStateTimeline: Timeline @@ -54,10 +54,12 @@ export const addStep = (args: { }, } } + export const hoverSelection = (args: Selection): hoverSelectionAction => ({ type: 'HOVER_DROPDOWN_ITEM', payload: { id: args.id, text: args.text }, }) + export const selectDropdownItem = (args: { selection: Selection | null mode: Mode @@ -82,35 +84,41 @@ export const hoverOnSubstep = ( type: 'HOVER_ON_SUBSTEP', payload: payload, }) + export const selectTerminalItem = ( terminalId: TerminalItemId ): SelectTerminalItemAction => ({ type: 'SELECT_TERMINAL_ITEM', payload: terminalId, }) + export const hoverOnStep = ( stepId: StepIdType | null | undefined ): HoverOnStepAction => ({ type: 'HOVER_ON_STEP', payload: stepId, }) + export const hoverOnTerminalItem = ( terminalId: TerminalItemId | null | undefined ): HoverOnTerminalItemAction => ({ type: 'HOVER_ON_TERMINAL_ITEM', payload: terminalId, }) + export const setWellSelectionLabwareKey = ( labwareName: string | null | undefined ): SetWellSelectionLabwareKeyAction => ({ type: 'SET_WELL_SELECTION_LABWARE_KEY', payload: labwareName, }) + export const clearWellSelectionLabwareKey = (): ClearWellSelectionLabwareKeyAction => ({ type: 'CLEAR_WELL_SELECTION_LABWARE_KEY', payload: null, }) + export const resetSelectStep = (stepId: StepIdType): ThunkAction => (dispatch: ThunkDispatch, getState: GetState) => { @@ -133,7 +141,6 @@ export const resetSelectStep = mode: 'clear', }, }) - resetScrollElements() } const setSelection = ( @@ -225,8 +232,8 @@ export const populateForm = payload: formData, }) setSelection(formData, dispatch) - resetScrollElements() } + export const selectStep = (stepId: StepIdType): ThunkAction => (dispatch: ThunkDispatch, getState: GetState) => { @@ -243,6 +250,7 @@ export const selectStep = }) setSelection(formData, dispatch) } + // NOTE(sa, 2020-12-11): this is a thunk so that we can populate the batch edit form with things later export const selectMultipleSteps = ( @@ -259,21 +267,7 @@ export const selectMultipleSteps = } dispatch(selectStepAction) } -export const selectMultipleStepsForGroup = - ( - stepIds: StepIdType[], - lastSelected: StepIdType - ): ThunkAction => - (dispatch: ThunkDispatch, getState: GetState) => { - const selectStepAction: SelectMultipleStepsForGroupAction = { - type: 'SELECT_MULTIPLE_STEPS_FOR_GROUP', - payload: { - stepIds, - lastSelected, - }, - } - dispatch(selectStepAction) - } + export const selectAllSteps = (): ThunkAction => ( @@ -300,8 +294,10 @@ export const selectAllSteps = dispatch(analyticsEvent(selectAllStepsEvent)) } } + export const EXIT_BATCH_EDIT_MODE_BUTTON_PRESS: 'EXIT_BATCH_EDIT_MODE_BUTTON_PRESS' = 'EXIT_BATCH_EDIT_MODE_BUTTON_PRESS' + // todo(mm, 2025-10-31): "deselectAllSteps" is a bit of a misnomer, since this also selects a step. export const deselectAllSteps = ( diff --git a/protocol-designer/src/ui/steps/actions/thunks/index.ts b/protocol-designer/src/ui/steps/actions/thunks/index.ts index 5f212dd2244..5714fc07e6a 100644 --- a/protocol-designer/src/ui/steps/actions/thunks/index.ts +++ b/protocol-designer/src/ui/steps/actions/thunks/index.ts @@ -2,20 +2,24 @@ import last from 'lodash/last' import { ABSORBANCE_READER_TYPE, + FLEX_STACKER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' -import { PAUSE_UNTIL_TEMP } from '/protocol-designer/constants' +import { + PAUSE_UNTIL_TC_PROFILE_COMPLETE, + PAUSE_UNTIL_TEMP, +} from '/protocol-designer/constants' +import { getEnableConcurrentModuleActions } from '/protocol-designer/feature-flags/selectors' import * as fileDataSelectors from '/protocol-designer/file-data/selectors' import { + getCurrentFormIsPresaved, getInitialDeckSetup, getSavedStepHierarchy, getUnsavedForm, - getUnsavedFormIsPristineHeaterShakerForm, - getUnsavedFormIsPristineSetTempForm, } from '/protocol-designer/step-forms/selectors' import { changeFormInput } from '/protocol-designer/steplist/actions/actions' import { PRESAVED_STEP_ID } from '/protocol-designer/steplist/types' @@ -151,6 +155,20 @@ export const addAndSelectStep: (arg: { }) ) } + } else if (payload.stepType === 'flexStacker') { + const flexStackerModules = Object.entries(modules).filter( + ([key, module]) => module.type === FLEX_STACKER_MODULE_TYPE + ) + const flexStackerId = + flexStackerModules.length === 1 ? flexStackerModules[0][0] : null + if (flexStackerId != null) { + dispatch( + selectDropdownItem({ + selection: { id: flexStackerId, text: 'Selected', field: '1' }, + mode: 'add', + }) + ) + } } else if (payload.stepType === 'mix' || payload.stepType === 'moveLiquid') { const labwares = Object.entries(labware).filter( ([key, lw]) => @@ -319,165 +337,201 @@ export const _saveStepForm = (form: FormData): SaveStepFormAction => { } } -/** take unsavedForm state and put it into the payload */ -export const saveStepForm: () => ThunkAction = - () => (dispatch, getState) => { - const initialState = getState() - const unsavedForm = getUnsavedForm(initialState) - - // this check is only for Flow. At this point, unsavedForm should always be populated - if (!unsavedForm) { - console.assert( - false, - 'Tried to saveStepForm with falsey unsavedForm. This should never be able to happen.' - ) - return - } - - if (tutorialSelectors.shouldShowCoolingHint(initialState)) { - dispatch(tutorialActions.addHint('thermocycler_lid_passive_cooling')) - } - - if (tutorialSelectors.shouldShowWasteChuteHint(initialState)) { - dispatch(tutorialActions.addHint('waste_chute_warning')) - } +/** + * Take the current unsaved form and save it. + * + * Certain forms, when saved, also save a "bonus step" under certain circumstances. + * e.g. saving a Temperature Module step may also save a "wait for temperature" step. + * The bonus step might either be mandatory, in which case this thunk will always save it, + * or optional as decided by the user, in which case the user's preference should be + * passed in. + */ +export const saveStepForm: (options?: { + userWantsBonusStep?: boolean +}) => ThunkAction = options => (dispatch, getState) => { + const { userWantsBonusStep = false } = options ?? {} + const initialState = getState() + const unsavedForm = getUnsavedForm(initialState) + const isFirstTimeSavingThisForm = getCurrentFormIsPresaved(initialState) + const enableConcurrentModuleActions = + getEnableConcurrentModuleActions(initialState) + + // this check is only for TypeScript. At this point, unsavedForm should always be populated + if (unsavedForm == null) { + console.assert( + false, + 'Tried to saveStepForm with falsey unsavedForm. This should never be able to happen.' + ) + return + } - // save the form - dispatch(_saveStepForm(unsavedForm)) + if (tutorialSelectors.shouldShowCoolingHint(initialState)) { + dispatch(tutorialActions.addHint('thermocycler_lid_passive_cooling')) } -/** "power action", mimicking saving the never-saved "set temperature X" step, - ** then creating and saving a "pause until temp X" step */ -export const saveSetTempFormWithAddedPauseUntilTemp: () => ThunkAction = - () => (dispatch, getState) => { - const initialState = getState() - const unsavedSetTemperatureForm = getUnsavedForm(initialState) - const isPristineSetTempForm = - getUnsavedFormIsPristineSetTempForm(initialState) + if (tutorialSelectors.shouldShowWasteChuteHint(initialState)) { + dispatch(tutorialActions.addHint('waste_chute_warning')) + } - // this check is only for Flow. At this point, unsavedForm should always be populated - if (!unsavedSetTemperatureForm) { - console.assert( - false, - 'Tried to saveSetTempFormWithAddedPauseUntilTemp with falsey unsavedForm. This should never be able to happen.' - ) - return - } + // save the form + dispatch(_saveStepForm(unsavedForm)) + + // Save any bonus steps that come with it. + // NOTE: This logic to decide whether a bonus step is warranted should be kept in sync + // with getBonusStepDialogType(). + const isTempModSetTempForm = + unsavedForm?.stepType === 'temperature' && + unsavedForm?.targetTemperature != null + const isHSSetTempForm = + unsavedForm?.stepType === 'heaterShaker' && + unsavedForm?.targetHeaterShakerTemperature != null && + unsavedForm?.heaterShakerSetTimer !== true + const isTCProfileForm = + unsavedForm?.stepType === 'thermocycler' && + unsavedForm?.thermocyclerFormType === 'thermocyclerProfile' + if (isTempModSetTempForm && userWantsBonusStep) { + dispatch(saveWaitForTemperatureModuleTemp(unsavedForm)) + } else if (isHSSetTempForm && userWantsBonusStep) { + dispatch(saveWaitForHeaterShakerTemp(unsavedForm)) + } else if ( + isTCProfileForm && + // todo(mm, 2025-11-25): isFirstTimeSavingThisForm is not quite sufficient here. + // We also need to cover the case where the form existed before as a non-profile step + // and now it's being edited into a profile step. + // https://opentrons.atlassian.net/browse/EXEC-2024 + isFirstTimeSavingThisForm && + // With enableConcurrentModuleActions, TC profile forms are interpreted as + // "start profile" steps and they're always paired with separate "wait for profile + // to complete" steps. Without enableConcurrentModuleActions, TC profile forms are + // interpreted as blocking "start profile and wait for it to complete" steps, + // without a separate wait step. + enableConcurrentModuleActions + ) { + dispatch(saveWaitForThermocyclerProfile(unsavedForm)) + } +} - const { id } = unsavedSetTemperatureForm +const saveWaitForTemperatureModuleTemp: ( + unsavedSetTemperatureForm: FormData +) => ThunkAction = unsavedSetTemperatureForm => (dispatch, getState) => { + const tempertureModuleId = unsavedSetTemperatureForm?.moduleId + const temperature = unsavedSetTemperatureForm.targetTemperature - if (!isPristineSetTempForm) { - // this check should happen upstream (before dispatching saveSetTempFormWithAddedPauseUntilTemp in the first place) - console.assert( - false, - `tried to saveSetTempFormWithAddedPauseUntilTemp but form ${id} is not a pristine set temp form` - ) - return - } + console.assert( + temperature != null && temperature !== '', + `tried to auto-add a pause until temp, but targetTemperature is missing: ${temperature}` + ) - const temperature = unsavedSetTemperatureForm?.targetTemperature + dispatch( + addStep({ + stepType: 'pause', + robotStateTimeline: fileDataSelectors.getRobotStateTimeline(getState()), + }) + ) + // NOTE: fields should be set one at a time b/c dependentFieldsUpdate fns can filter out inputs + // contingent on other inputs (eg changing the pauseAction radio button may clear the pauseTemperature). + dispatch( + changeFormInput({ + update: { + pauseAction: PAUSE_UNTIL_TEMP, + }, + }) + ) + dispatch( + changeFormInput({ + update: { + moduleId: tempertureModuleId, + }, + }) + ) + dispatch( + changeFormInput({ + update: { + pauseTemperature: temperature, + }, + }) + ) + // finally save the new pause form + const unsavedPauseForm = getUnsavedForm(getState()) + if (unsavedPauseForm != null) { + dispatch(_saveStepForm(unsavedPauseForm)) + } else { + // this conditional is for TypeScript, the unsaved form should always exist console.assert( - temperature != null && temperature !== '', - `tried to auto-add a pause until temp, but targetTemperature is missing: ${temperature}` - ) - // save the set temperature step form that is currently open - dispatch(_saveStepForm(unsavedSetTemperatureForm)) - // add a new pause step form - dispatch( - addStep({ - stepType: 'pause', - robotStateTimeline: fileDataSelectors.getRobotStateTimeline(getState()), - }) - ) - // NOTE: fields should be set one at a time b/c dependentFieldsUpdate fns can filter out inputs - // contingent on other inputs (eg changing the pauseAction radio button may clear the pauseTemperature). - dispatch( - changeFormInput({ - update: { - pauseAction: PAUSE_UNTIL_TEMP, - }, - }) - ) - const tempertureModuleId = unsavedSetTemperatureForm?.moduleId - dispatch( - changeFormInput({ - update: { - moduleId: tempertureModuleId, - }, - }) - ) - dispatch( - changeFormInput({ - update: { - pauseTemperature: temperature, - }, - }) + false, + 'could not auto-save pause form, getUnsavedForm returned nullish' ) - // finally save the new pause form - const unsavedPauseForm = getUnsavedForm(getState()) - - // this conditional is for Flow, the unsaved form should always exist - if (unsavedPauseForm != null) { - dispatch(_saveStepForm(unsavedPauseForm)) - } else { - console.assert( - false, - 'could not auto-save pause form, getUnsavedForm returned' - ) - } } +} -export const saveHeaterShakerFormWithAddedPauseUntilTemp: () => ThunkAction = - () => (dispatch, getState) => { - const initialState = getState() - const unsavedHeaterShakerForm = getUnsavedForm(initialState) - const isPristineSetHeaterShakerTempForm = - getUnsavedFormIsPristineHeaterShakerForm(initialState) - - if (!unsavedHeaterShakerForm) { - console.assert( - false, - 'Tried to saveSetHeaterShakerTempFormWithAddedPauseUntilTemp with falsey unsavedForm. This should never be able to happen.' - ) - return - } - - const { id } = unsavedHeaterShakerForm - - if (!isPristineSetHeaterShakerTempForm) { - console.assert( - false, - `tried to saveSetHeaterShakerTempFormWithAddedPauseUntilTemp but form ${id} is not a pristine set heater shaker temp form` - ) - return - } +const saveWaitForHeaterShakerTemp: ( + unsavedHeaterShakerForm: FormData +) => ThunkAction = unsavedHeaterShakerForm => (dispatch, getState) => { + const heaterShakerModuleId = unsavedHeaterShakerForm.moduleId + const temperature = unsavedHeaterShakerForm.targetHeaterShakerTemperature - const temperature = unsavedHeaterShakerForm?.targetHeaterShakerTemperature + dispatch( + addStep({ + stepType: 'pause', + robotStateTimeline: fileDataSelectors.getRobotStateTimeline(getState()), + }) + ) + // NOTE: fields should be set one at a time b/c dependentFieldsUpdate fns can filter out inputs + // contingent on other inputs (eg changing the pauseAction radio button may clear the pauseTemperature). + dispatch( + changeFormInput({ + update: { + pauseAction: PAUSE_UNTIL_TEMP, + }, + }) + ) + dispatch( + changeFormInput({ + update: { + moduleId: heaterShakerModuleId, + }, + }) + ) + dispatch( + changeFormInput({ + update: { + pauseTemperature: temperature, + }, + }) + ) + // finally save the new pause form + const unsavedPauseForm = getUnsavedForm(getState()) + if (unsavedPauseForm != null) { + dispatch(_saveStepForm(unsavedPauseForm)) + } else { + // this conditional is for TypeScript, the unsaved form should always exist console.assert( - temperature != null && temperature !== '', - `tried to auto-add a pause until temp, but targetHeaterShakerTemperature is missing: ${temperature}` + false, + 'could not auto-save pause form, getUnsavedForm returned nullish' ) - dispatch(_saveStepForm(unsavedHeaterShakerForm)) + } +} + +const saveWaitForThermocyclerProfile: ( + unsavedThermocyclerProfileForm: FormData +) => ThunkAction = + unsavedThermocyclerProfileForm => (dispatch, getState) => { + const thermocyclerModuleId = unsavedThermocyclerProfileForm.moduleId + dispatch( addStep({ stepType: 'pause', robotStateTimeline: fileDataSelectors.getRobotStateTimeline(getState()), }) ) + // NOTE: fields should be set one at a time b/c dependentFieldsUpdate fns can filter out inputs + // contingent on other inputs (eg changing the pauseAction radio button may clear the pauseTemperature). dispatch( changeFormInput({ update: { - pauseAction: PAUSE_UNTIL_TEMP, - }, - }) - ) - const heaterShakerModuleId = unsavedHeaterShakerForm.moduleId - dispatch( - changeFormInput({ - update: { - moduleId: heaterShakerModuleId, + pauseAction: PAUSE_UNTIL_TC_PROFILE_COMPLETE, }, }) ) @@ -485,18 +539,20 @@ export const saveHeaterShakerFormWithAddedPauseUntilTemp: () => ThunkAction dispatch( changeFormInput({ update: { - pauseTemperature: temperature, + moduleId: thermocyclerModuleId, }, }) ) - const unsavedPauseForm = getUnsavedForm(getState()) + // finally save the new pause form + const unsavedPauseForm = getUnsavedForm(getState()) if (unsavedPauseForm != null) { dispatch(_saveStepForm(unsavedPauseForm)) } else { + // this conditional is for TypeScript, the unsaved form should always exist console.assert( false, - 'could not auto-save pause form, getUnsavedForm returned' + 'could not auto-save pause form, getUnsavedForm returned nullish' ) } } diff --git a/protocol-designer/src/ui/steps/actions/types.ts b/protocol-designer/src/ui/steps/actions/types.ts index 6074ff7f2ce..86212d7bd26 100644 --- a/protocol-designer/src/ui/steps/actions/types.ts +++ b/protocol-designer/src/ui/steps/actions/types.ts @@ -89,13 +89,6 @@ export interface SelectMultipleStepsAction { } } -export interface SelectMultipleStepsForGroupAction { - type: 'SELECT_MULTIPLE_STEPS_FOR_GROUP' - payload: { - stepIds: StepIdType[] - lastSelected: StepIdType - } -} export type ViewSubstep = StepIdType | null export interface ToggleViewSubstepAction { type: 'TOGGLE_VIEW_SUBSTEP' diff --git a/protocol-designer/src/ui/steps/utils.ts b/protocol-designer/src/ui/steps/utils.ts index bdee9ee4750..399b6e9f41c 100644 --- a/protocol-designer/src/ui/steps/utils.ts +++ b/protocol-designer/src/ui/steps/utils.ts @@ -1,23 +1,5 @@ -import forEach from 'lodash/forEach' - import type { StepFieldName } from '../../form-types' -export const MAIN_CONTENT_FORCED_SCROLL_CLASSNAME = 'main_content_forced_scroll' -// scroll to top of all elements with the special class (probably the main page wrapper) -// -// TODO (ka 2019-10-28): This is a workaround, see #4446 -// but it solves the modal positioning problem caused by main page wrapper -// being positioned absolute until we can figure out something better -export const resetScrollElements = (): void => { - forEach( - global.document.getElementsByClassName( - MAIN_CONTENT_FORCED_SCROLL_CLASSNAME - ), - elem => { - elem.scrollTop = 0 - } - ) -} type DisabledFields = Record const batchEditMoveLiquidPipetteDifferentDisabledFieldNames: StepFieldName[] = [ // aspirate diff --git a/protocol-designer/tsconfig.cypress.json b/protocol-designer/tsconfig.cypress.json deleted file mode 100644 index ba3207e4634..00000000000 --- a/protocol-designer/tsconfig.cypress.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "cypress", - "types": ["cypress", "node"], - "composite": true, - "importHelpers": false - }, - "include": ["cypress", "cypress/**/*.ts"] -} diff --git a/protocol-designer/tsconfig.json b/protocol-designer/tsconfig.json index 181a476ea18..4740242b870 100644 --- a/protocol-designer/tsconfig.json +++ b/protocol-designer/tsconfig.json @@ -12,9 +12,6 @@ }, { "path": "../step-generation" - }, - { - "path": "./tsconfig.cypress.json" } ], "compilerOptions": { diff --git a/protocol-designer/vite.config.mts b/protocol-designer/vite.config.mts index e832ecdb521..e11a7c27c6f 100644 --- a/protocol-designer/vite.config.mts +++ b/protocol-designer/vite.config.mts @@ -9,16 +9,16 @@ import postCssPresetEnv from 'postcss-preset-env' import { defineConfig } from 'vite' import { analyzer } from 'vite-bundle-analyzer' -import { - generateBuildInfoHtml, - getVersion, -} from '../scripts/git-version-protocol-designer.mjs' +import createGitVersionToolkit from '../scripts/git-version-v2.mjs' import { latestLabwareVersions } from '../scripts/git-version.mjs' import { cssModuleSideEffect } from './cssModuleSideEffect' import type { UserConfig } from 'vite' -const REQUIRED_APP_VERSION = '8.7.0' // PD requires this robot stack version or higher +const REQUIRED_APP_VERSION = '8.8.0' // PD requires this robot stack version or higher +const { getVersion, generateBuildInfoHtml } = createGitVersionToolkit({ + project: 'protocol-designer', +}) // Sentry and sourcemaps are disabled in local development const isCI = process.env.CI === 'true' @@ -113,7 +113,7 @@ export default defineConfig(async (): Promise => { esbuildOptions: { target: 'es2020', }, - // Note: this is for cypress support so when we switch e2e to playwright we can remove it + // For unknown reasons, PD whitescreens on launch unless we have this. include: ['tslib'], }, css: { @@ -145,7 +145,7 @@ export default defineConfig(async (): Promise => { }, resolve: { conditions: ['browser'], - // Note: this is for cypress support so when we switch e2e to playwright we can remove it + // For unknown reasons, PD whitescreens on launch unless we have this. dedupe: ['tslib'], alias: { // todo(mm, 2025-10-27): These cross-project aliases cause trouble like @@ -165,9 +165,6 @@ export default defineConfig(async (): Promise => { }, server: { port: 5178, - watch: { - ignored: ['**/cypress/downloads/**'], - }, }, } }) diff --git a/react-api-client/src/camera/index.ts b/react-api-client/src/camera/index.ts index 5b3608c6814..b87179aa3d4 100644 --- a/react-api-client/src/camera/index.ts +++ b/react-api-client/src/camera/index.ts @@ -1,2 +1,4 @@ -export * from './useCamera' export * from './useUpdateCamera' +export * from './useCamera' +export * from './useCameraImageSettings' +export * from './useUpdateCameraImageSettings' diff --git a/react-api-client/src/camera/useCameraImageSettings.ts b/react-api-client/src/camera/useCameraImageSettings.ts new file mode 100644 index 00000000000..fa4779328be --- /dev/null +++ b/react-api-client/src/camera/useCameraImageSettings.ts @@ -0,0 +1,28 @@ +import { useQuery } from 'react-query' + +import { getCameraImageSettings } from '@opentrons/api-client' +import { OT_SYSTEM_CAMERA } from '@opentrons/shared-data' + +import { useHost } from '../api' + +import type { AxiosError } from 'axios' +import type { UseQueryOptions, UseQueryResult } from 'react-query' +import type { CameraImageSettingsResponse } from '@opentrons/api-client' + +export function useCameraImageSettings( + options: UseQueryOptions = {} +): UseQueryResult { + const cameraId = OT_SYSTEM_CAMERA + const host = useHost() + const query = useQuery( + [host, 'camera', 'cameraSettings', cameraId], + () => + getCameraImageSettings(host!, cameraId) + .then(response => response.data) + .catch((e: AxiosError) => { + throw e + }), + { ...options } + ) + return query +} diff --git a/react-api-client/src/camera/useUpdateCameraImageSettings.ts b/react-api-client/src/camera/useUpdateCameraImageSettings.ts new file mode 100644 index 00000000000..aedc3bb86b4 --- /dev/null +++ b/react-api-client/src/camera/useUpdateCameraImageSettings.ts @@ -0,0 +1,65 @@ +import { useMutation, useQueryClient } from 'react-query' + +import { createCameraImageSettings } from '@opentrons/api-client' +import { OT_SYSTEM_CAMERA } from '@opentrons/shared-data' + +import { useHost } from '../api' + +import type { AxiosError } from 'axios' +import type { + UseMutateFunction, + UseMutationOptions, + UseMutationResult, +} from 'react-query' +import type { + CameraImageSettings, + CameraImageSettingsResponse, + ErrorResponse, +} from '@opentrons/api-client' + +export type UseCreateCameraImageSettingsMutationResult = UseMutationResult< + CameraImageSettingsResponse, + AxiosError, + CameraImageSettings +> & { + createCameraImageSettings: UseMutateFunction< + CameraImageSettingsResponse, + AxiosError, + CameraImageSettings + > +} + +export function useCreateCameraImageSettings( + options: UseMutationOptions< + CameraImageSettingsResponse, + AxiosError, + CameraImageSettings + > = {} +): UseCreateCameraImageSettingsMutationResult { + const cameraId = OT_SYSTEM_CAMERA + const host = useHost() + const queryClient = useQueryClient() + + const mutation = useMutation< + CameraImageSettingsResponse, + AxiosError, + CameraImageSettings + >( + [host, 'camera', 'cameraSettings', cameraId], + (data: CameraImageSettings) => + createCameraImageSettings(host!, data, cameraId).then(response => { + queryClient + .invalidateQueries([host, 'camera', 'cameraSettings', cameraId]) + .catch(e => { + throw e + }) + return response.data + }), + options + ) + + return { + ...mutation, + createCameraImageSettings: mutation.mutate, + } +} diff --git a/react-api-client/src/runs/index.ts b/react-api-client/src/runs/index.ts index c02d5b0c91d..512aaaefdb6 100644 --- a/react-api-client/src/runs/index.ts +++ b/react-api-client/src/runs/index.ts @@ -16,6 +16,8 @@ export { useAllCommandsQuery } from './useAllCommandsQuery' export { useAllCommandsAsPreSerializedList } from './useAllCommandsAsPreSerializedList' export { useCommandQuery } from './useCommandQuery' export { useRunCommandErrors } from './useRunCommandErrors' +export { useAddCameraImageSettingsToRunMutation } from './useAddCameraImageSettingsToRunMutation' + export * from './useAddLabwareOffsetToRunMutation' export * from './useCreateLabwareDefinitionMutation' export * from './useUpdateErrorRecoveryPolicy' diff --git a/react-api-client/src/runs/useAddCameraImageSettingsToRunMutation.ts b/react-api-client/src/runs/useAddCameraImageSettingsToRunMutation.ts new file mode 100644 index 00000000000..e0d259a1853 --- /dev/null +++ b/react-api-client/src/runs/useAddCameraImageSettingsToRunMutation.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from 'react-query' + +import { addCameraImageSettingsToRun } from '@opentrons/api-client' + +import { useHost } from '../api' + +import type { AxiosError } from 'axios' +import type { UseMutateFunction, UseMutationResult } from 'react-query' +import type { + CameraImageSettings, + CameraImageSettingsResponse, + ErrorResponse, +} from '@opentrons/api-client' + +export type UseAddCameraImageSettingsToRunMutationResult = UseMutationResult< + CameraImageSettingsResponse, + AxiosError, + CameraImageSettings +> & { + addCameraImageSettingsToRun: UseMutateFunction< + CameraImageSettingsResponse, + AxiosError, + CameraImageSettings + > +} + +export function useAddCameraImageSettingsToRunMutation( + runId: string +): UseAddCameraImageSettingsToRunMutationResult { + const host = useHost() + const queryClient = useQueryClient() + const mutation = useMutation< + CameraImageSettingsResponse, + AxiosError, + CameraImageSettings + >(settings => + addCameraImageSettingsToRun(host!, runId, settings) + .then(response => { + queryClient + .invalidateQueries([host, 'runs', runId]) + .catch((e: Error) => { + console.error(`error invalidating runs query: ${e.message}`) + }) + return response.data + }) + .catch((e: Error) => { + console.error(`error adding camera image settings: ${e.message}`) + throw e + }) + ) + + return { + ...mutation, + addCameraImageSettingsToRun: mutation.mutate, + } +} diff --git a/robot-server/robot_server/camera/provider.py b/robot-server/robot_server/camera/provider.py index 41058867bb0..7013d5ae40e 100644 --- a/robot-server/robot_server/camera/provider.py +++ b/robot-server/robot_server/camera/provider.py @@ -35,7 +35,7 @@ def get_camera_settings(self) -> CameraSettings: return CameraSettings( cameraEnabled=self._camera_settings_store.get_camera_enabled(), liveStreamEnabled=self._camera_settings_store.get_live_stream_enabled(), - errorRecoveryEnabled=self._camera_settings_store.get_error_recovery_camera_enabled(), + errorRecoveryCameraEnabled=self._camera_settings_store.get_error_recovery_camera_enabled(), ) async def process_image_capture( diff --git a/robot-server/robot_server/runs/dependencies.py b/robot-server/robot_server/runs/dependencies.py index 28db3e4d1f2..4b2b5a620fb 100644 --- a/robot-server/robot_server/runs/dependencies.py +++ b/robot-server/robot_server/runs/dependencies.py @@ -11,9 +11,6 @@ CameraSettingStore, get_camera_setting_store, ) -from robot_server.protocols.dependencies import get_protocol_store -from robot_server.protocols.protocol_models import ProtocolKind -from robot_server.protocols.protocol_store import ProtocolStore from robot_server.data_files.file_auto_deleter import DataFileAutoDeleter from robot_server.data_files.dependencies import get_data_file_auto_deleter from robot_server.file_provider.fastapi_dependencies import get_file_provider @@ -199,7 +196,6 @@ async def get_run_data_manager( async def get_run_auto_deleter( run_store: Annotated[RunStore, Depends(get_run_store)], - protocol_store: Annotated[ProtocolStore, Depends(get_protocol_store)], data_file_auto_deleter: Annotated[ DataFileAutoDeleter, Depends(get_data_file_auto_deleter) ], @@ -207,27 +203,6 @@ async def get_run_auto_deleter( """Get an `AutoDeleter` to delete old runs.""" return RunAutoDeleter( run_store=run_store, - protocol_store=protocol_store, deletion_planner=RunDeletionPlanner(maximum_runs=get_settings().maximum_runs), - protocol_kind=ProtocolKind.STANDARD, - data_file_auto_deleter=data_file_auto_deleter, - ) - - -async def get_quick_transfer_run_auto_deleter( - run_store: Annotated[RunStore, Depends(get_run_store)], - protocol_store: Annotated[ProtocolStore, Depends(get_protocol_store)], - data_file_auto_deleter: Annotated[ - DataFileAutoDeleter, Depends(get_data_file_auto_deleter) - ], -) -> RunAutoDeleter: - """Get an `AutoDeleter` to delete old runs for quick transfer prorotocols.""" - return RunAutoDeleter( - run_store=run_store, - protocol_store=protocol_store, - # NOTE: We dont store quick transfer runs, however we need an additional - # run slot so we can clone an active run. - deletion_planner=RunDeletionPlanner(maximum_runs=2), - protocol_kind=ProtocolKind.QUICK_TRANSFER, data_file_auto_deleter=data_file_auto_deleter, ) diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 3c93b54fc67..cad69f20e85 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -32,7 +32,6 @@ ) from robot_server.data_files.data_files_store import DataFilesStore from robot_server.errors.error_responses import ErrorDetails, ErrorBody -from robot_server.protocols.protocol_models import ProtocolKind from robot_server.service.dependencies import get_current_time, get_unique_id from robot_server.robot.control.dependencies import require_estop_in_good_state from robot_server.hardware import get_hardware, get_robot_type_enum @@ -76,7 +75,6 @@ from ..dependencies import ( get_run_data_manager, get_run_auto_deleter, - get_quick_transfer_run_auto_deleter, ) from robot_server.deck_configuration.fastapi_dependencies import ( @@ -195,9 +193,6 @@ async def create_run( # noqa: C901 run_auto_deleter: Annotated[RunAutoDeleter, Depends(get_run_auto_deleter)], data_files_directory: Annotated[Path, Depends(get_data_files_directory)], data_files_store: Annotated[DataFilesStore, Depends(get_data_files_store)], - quick_transfer_run_auto_deleter: Annotated[ - RunAutoDeleter, Depends(get_quick_transfer_run_auto_deleter) - ], check_estop: Annotated[bool, Depends(require_estop_in_good_state)], deck_configuration_store: Annotated[ DeckConfigurationStore, Depends(get_deck_configuration_store) @@ -216,7 +211,6 @@ async def create_run( # noqa: C901 created_at: Timestamp to attach to created run. run_auto_deleter: An interface to delete old resources to make room for the new run. - quick_transfer_run_auto_deleter: An interface to delete old quick-transfer data_files_directory: Persistence directory for data files. data_files_store: Database of data file resources. resources to make room for the new run. @@ -263,13 +257,7 @@ async def create_run( # noqa: C901 # TODO(mc, 2022-05-13): move inside `RunDataManager` or return data # to pass to `RunDataManager.create`. Right now, runs may be deleted # even if a new create is unable to succeed due to a conflict - run_deleter: RunAutoDeleter = run_auto_deleter - if ( - protocol_resource - and protocol_resource.protocol_kind == ProtocolKind.QUICK_TRANSFER - ): - run_deleter = quick_transfer_run_auto_deleter - run_deleter.make_room_for_new_run() + run_auto_deleter.make_room_for_new_run() try: run_data = await run_data_manager.create( diff --git a/robot-server/robot_server/runs/router/camera_router.py b/robot-server/robot_server/runs/router/camera_router.py index af6b6868418..1f64e32ea03 100644 --- a/robot-server/robot_server/runs/router/camera_router.py +++ b/robot-server/robot_server/runs/router/camera_router.py @@ -86,7 +86,7 @@ async def add_camera_settings( liveStreamEnabled=request_body.data.liveStreamEnabled if request_body.data.liveStreamEnabled is not None else False, - errorRecoveryEnabled=request_body.data.errorRecoveryCameraEnabled + errorRecoveryCameraEnabled=request_body.data.errorRecoveryCameraEnabled if request_body.data.errorRecoveryCameraEnabled is not None else False, ) @@ -111,7 +111,7 @@ async def add_camera_settings( data=CameraEnable( cameraEnabled=response_data.cameraEnabled, liveStreamEnabled=response_data.liveStreamEnabled, - errorRecoveryCameraEnabled=response_data.errorRecoveryEnabled, + errorRecoveryCameraEnabled=response_data.errorRecoveryCameraEnabled, ) ), status_code=status.HTTP_201_CREATED, diff --git a/robot-server/robot_server/runs/run_auto_deleter.py b/robot-server/robot_server/runs/run_auto_deleter.py index 681c2989cc0..f141929e0ce 100644 --- a/robot-server/robot_server/runs/run_auto_deleter.py +++ b/robot-server/robot_server/runs/run_auto_deleter.py @@ -2,8 +2,6 @@ from logging import getLogger from robot_server.deletion_planner import RunDeletionPlanner -from robot_server.protocols.protocol_models import ProtocolKind -from robot_server.protocols.protocol_store import ProtocolStore from robot_server.data_files.file_auto_deleter import DataFileAutoDeleter from .run_store import RunStore @@ -15,32 +13,16 @@ class RunAutoDeleter: # noqa: D101 def __init__( self, run_store: RunStore, - protocol_store: ProtocolStore, deletion_planner: RunDeletionPlanner, - protocol_kind: ProtocolKind, data_file_auto_deleter: DataFileAutoDeleter, ) -> None: self._run_store = run_store - self._protocol_store = protocol_store self._deletion_planner = deletion_planner - self._protocol_kind = protocol_kind self._data_file_auto_deleter = data_file_auto_deleter def make_room_for_new_run(self) -> None: # noqa: D102 - protocols = self._protocol_store.get_all() - protocol_ids = [p.protocol_id for p in protocols] - filtered_protocol_ids = [ - p.protocol_id for p in protocols if p.protocol_kind == self._protocol_kind - ] - - # runs with no protocols first, then oldest to newest. runs = self._run_store.get_all() - run_ids = [ - r.run_id - for r in runs - if r.protocol_id not in protocol_ids - or r.protocol_id in filtered_protocol_ids - ] + run_ids = [r.run_id for r in runs] run_ids_to_delete = self._deletion_planner.plan_for_new_run( existing_runs=run_ids diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index 3094b73b8ba..f3a0a807c90 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -35,6 +35,8 @@ run_command_table, action_table, run_csv_rtp_table, + input_data_files_table, + output_data_files_table, ) from robot_server.persistence.pydantic import ( json_to_pydantic, @@ -684,14 +686,7 @@ def get_command(self, run_id: str, command_id: str) -> Command: return _parse_command(command) def remove(self, run_id: str) -> None: - """Remove a run by its unique identifier. - - Arguments: - run_id: The run's unique identifier. - - Raises: - RunNotFoundError: The specified run ID was not found. - """ + """Remove a run by its unique identifier.""" delete_run = sqlalchemy.delete(run_table).where(run_table.c.id == run_id) delete_actions = sqlalchemy.delete(action_table).where( action_table.c.run_id == run_id @@ -702,10 +697,19 @@ def remove(self, run_id: str) -> None: delete_csv_rtps = sqlalchemy.delete(run_csv_rtp_table).where( run_csv_rtp_table.c.run_id == run_id ) + delete_input_files = sqlalchemy.delete(input_data_files_table).where( + input_data_files_table.c.run_id == run_id + ) + delete_output_files = sqlalchemy.delete(output_data_files_table).where( + output_data_files_table.c.run_id == run_id + ) + with self._sql_engine.begin() as transaction: transaction.execute(delete_actions) transaction.execute(delete_commands) transaction.execute(delete_csv_rtps) + transaction.execute(delete_input_files) + transaction.execute(delete_output_files) result = transaction.execute(delete_run) if result.rowcount < 1: diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index bf026874d95..c604b9c4dc2 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -187,7 +187,6 @@ async def test_create_run( run_id=run_id, created_at=run_created_at, run_auto_deleter=mock_run_auto_deleter, - quick_transfer_run_auto_deleter=mock_run_auto_deleter, deck_configuration_store=mock_deck_configuration_store, camera_provider=mock_camera_provider, notify_publishers=mock_notify_publishers, @@ -297,7 +296,6 @@ async def test_create_protocol_run( run_id=run_id, created_at=run_created_at, run_auto_deleter=mock_run_auto_deleter, - quick_transfer_run_auto_deleter=mock_run_auto_deleter, deck_configuration_store=mock_deck_configuration_store, camera_provider=mock_camera_provider, notify_publishers=mock_notify_publishers, @@ -340,7 +338,6 @@ async def test_create_protocol_run_bad_protocol_id( run_id="run-id", created_at=datetime.now(), run_auto_deleter=mock_run_auto_deleter, - quick_transfer_run_auto_deleter=mock_run_auto_deleter, check_estop=True, notify_publishers=mock_notify_publishers, ) @@ -388,7 +385,6 @@ async def test_create_run_conflict( protocol_store=mock_protocol_store, run_data_manager=mock_run_data_manager, run_auto_deleter=mock_run_auto_deleter, - quick_transfer_run_auto_deleter=mock_run_auto_deleter, deck_configuration_store=mock_deck_configuration_store, data_files_store=mock_data_files_store, data_files_directory=mock_data_files_directory, diff --git a/robot-server/tests/runs/test_run_auto_deleter.py b/robot-server/tests/runs/test_run_auto_deleter.py index df44e7cb5cd..344c25d58e7 100644 --- a/robot-server/tests/runs/test_run_auto_deleter.py +++ b/robot-server/tests/runs/test_run_auto_deleter.py @@ -38,9 +38,7 @@ def test_make_room_for_new_run(decoy: Decoy, caplog: pytest.LogCaptureFixture) - subject = RunAutoDeleter( run_store=mock_run_store, - protocol_store=mock_protocol_store, deletion_planner=RunDeletionPlanner(1), - protocol_kind=ProtocolKind.STANDARD, data_file_auto_deleter=mock_data_file_auto_deleter, ) @@ -81,69 +79,3 @@ def test_make_room_for_new_run(decoy: Decoy, caplog: pytest.LogCaptureFixture) - assert "run-id-1" in caplog.text assert "run-id-2" in caplog.text assert "run-id-3" in caplog.text - - -def test_quick_transfer_protocol_runs( - decoy: Decoy, - caplog: pytest.LogCaptureFixture, -) -> None: - """It should delete runs of the specified protocol kind.""" - mock_run_store = decoy.mock(cls=RunStore) - mock_protocol_store = decoy.mock(cls=ProtocolStore) - mock_protocol_source = decoy.mock(cls=ProtocolSource) - mock_data_file_auto_deleter = decoy.mock(cls=DataFileAutoDeleter) - - subject = RunAutoDeleter( - run_store=mock_run_store, - protocol_store=mock_protocol_store, - deletion_planner=RunDeletionPlanner(1), - protocol_kind=ProtocolKind.QUICK_TRANSFER, - data_file_auto_deleter=mock_data_file_auto_deleter, - ) - - protocol_resources: List[ProtocolResource] = [ - ProtocolResource( - protocol_id=f"protocol-id-{idx}", - created_at=datetime.min, - source=mock_protocol_source, - protocol_key=None, - protocol_kind=ProtocolKind.STANDARD - if idx not in [2, 5] - else ProtocolKind.QUICK_TRANSFER, - ) - for idx in range(1, 6) - ] - - run_resources: List[Union[RunResource, BadRunResource]] = [ - _make_dummy_run_resource("run-id-1", "protocol-id-1"), - _make_dummy_run_resource("run-id-2", "protocol-id-2"), - _make_dummy_run_resource("run-id-3", "protocol-id-3"), - _make_dummy_run_resource("run-id-4", "protocol-id-4"), - _make_dummy_run_resource("run-id-5", "protocol-id-5"), - _make_dummy_run_resource("run-id-6", "protocol-id-6"), - ] - - decoy.when(mock_protocol_store.get_all()).then_return(protocol_resources) - decoy.when(mock_run_store.get_all()).then_return(run_resources) - - # Run the subject, capturing log messages at least as severe as INFO. - with caplog.at_level(logging.INFO): - subject.make_room_for_new_run() - - decoy.verify( - mock_data_file_auto_deleter.make_room_for_new_generated_files( - {"run-id-2", "run-id-5", "run-id-6"} - ) - ) - decoy.verify(mock_run_store.remove(run_id="run-id-2")) - decoy.verify(mock_run_store.remove(run_id="run-id-5")) - - # It should log the quick-transfer runs deleted - assert "run-id-2" in caplog.text - assert "run-id-5" in caplog.text - # Make sure we delete quick-transfer protocol runs - assert "run-id-1" not in caplog.text - assert "run-id-3" not in caplog.text - assert "run-id-4" not in caplog.text - # Make sure we delete runs without a protocol - assert "run-id-6" in caplog.text diff --git a/scripts/GIT_VERSION_PD_README.md b/scripts/GIT_VERSION_V2_README.md similarity index 73% rename from scripts/GIT_VERSION_PD_README.md rename to scripts/GIT_VERSION_V2_README.md index 437bb8c5afe..04c79deef25 100644 --- a/scripts/GIT_VERSION_PD_README.md +++ b/scripts/GIT_VERSION_V2_README.md @@ -1,12 +1,17 @@ -# Git Version Protocol Designer - Semantic Version Resolution +# Git Version Toolkit - Semantic Version Resolution ## Summary -`scripts/git-version-protocol-designer.mjs` provides semantic version-based tag resolution and build information generation for Protocol Designer. +`scripts/git-version-v2.mjs` is the shared semantic-version toolkit that powers build information generation for multiple web apps. + +### Current Consumers + +1. **Protocol Designer** – configured via `createGitVersionToolkit({ project: 'protocol-designer' })` inside `protocol-designer/vite.config.mts` +2. **Labware Library** – configured via `createGitVersionToolkit({ project: 'labware-library' })` inside `labware-library/vite.config.mts` ### Key Features -1. **Semantic version resolution**: Finds the highest semantic version among all tags reachable from `HEAD` for both production (`protocol-designer@`) and staging (`staging-protocol-designer@`) prefixes. +1. **Semantic version resolution**: Finds the highest semantic version among all tags reachable from `HEAD` for each project's production (`@`) and staging (`staging-@`) prefixes. 2. **Prerelease support**: Properly handles prerelease versions like `-alpha.0`, `-beta.1`, etc., following semver precedence rules. 3. **Prefix priority**: When production and staging tags have identical versions, production tags win. 4. **Simple version output**: Always returns the portion to the right of `@`. @@ -14,7 +19,7 @@ ### Tag Priority and Semver Rules -For the `protocol-designer` project: +For any supported project (Protocol Designer and Labware Library today): - **Version comparison**: Tags are compared using semantic versioning (semver) rules - Stable releases take precedence over prerelease versions of the same version (e.g., `8.6.0` > `8.6.0-beta.1`) @@ -23,10 +28,10 @@ For the `protocol-designer` project: - Prerelease numbers are compared numerically (e.g., `alpha.2` > `alpha.1`) - **Prefix priority** (tie-breaker when versions are identical): - 1. `protocol-designer@*` (production) - 2. `staging-protocol-designer@*` (staging) + 1. `@*` (production) + 2. `staging-@*` (staging) -When production and staging tags have the exact same version, the production prefix wins. +When production and staging tags have the exact same version, the production prefix wins (e.g., `protocol-designer@*` beats `staging-protocol-designer@*`, `labware-library@*` beats `staging-labware-library@*`). ### Version Resolution Logic @@ -51,6 +56,7 @@ When production and staging tags have the exact same version, the production pre - `protocol-designer@8.6.0` → `8.6.0` - `staging-protocol-designer@8.7.0-alpha.1` → `8.7.0-alpha.1` - `protocol-designer@8.6.0-beta.2` → `8.6.0-beta.2` + - `labware-library@5.3.0` → `5.3.0` 5. **No tags available** - If no matching tags are found, log an error and return `0.0.0-dev`. @@ -74,8 +80,10 @@ The script automatically generates a comprehensive build information page at `di ### Accessing Build Info -- Local deployment: `http://localhost:5178/info/` — available during `make serve` -- Production: `https://designer.opentrons.com/info/` (or your deployed URL) +- Protocol Designer (local): `http://localhost:5178/info/` — available during `make serve` +- Labware Library (local): `http://localhost:5173/info/` — served by `make serve` +- Protocol Designer (production): `https://designer.opentrons.com/info/` +- Labware Library (production): `https://labware.opentrons.com/info/` ## Testing Results @@ -103,6 +111,8 @@ The script automatically generates a comprehensive build information page at `di - History: No `protocol-designer@` or `staging-protocol-designer@` tags - Result: ✅ Falls back to `0.0.0-dev` +Equivalent scenarios apply to Labware Library, substituting `labware-library@` and `staging-labware-library@` tag prefixes. + ## Implementation Notes - **Semantic versioning**: Uses the `semver` package to properly compare versions according to semver rules. @@ -113,4 +123,5 @@ The script automatically generates a comprehensive build information page at `di ## Integration -The script is integrated into Protocol Designer's build process via a Vite plugin in `vite.config.mts`, ensuring the build info page is generated after every build. +- **Protocol Designer**: `protocol-designer/vite.config.mts` registers a Vite plugin that invokes `generateBuildInfoHtml` after bundling, ensuring `/info/index.html` ships with every build. +- **Labware Library**: `labware-library/vite.config.mts` wires the same helper into its build so both the creator and main surfaces expose `/info/index.html`. diff --git a/scripts/git-version-protocol-designer.mjs b/scripts/git-version-v2.mjs similarity index 70% rename from scripts/git-version-protocol-designer.mjs rename to scripts/git-version-v2.mjs index 96d0ef87129..6fb751208cd 100644 --- a/scripts/git-version-protocol-designer.mjs +++ b/scripts/git-version-v2.mjs @@ -6,92 +6,122 @@ import git from 'simple-git' import semver from 'semver' const REPO_BASE = dirname(dirname(fileURLToPath(import.meta.url))) -const PROJECT = 'protocol-designer' - -// Tag prefixes in priority order (production > staging) -const TAG_PREFIXES = [ - `${PROJECT}@`, - `staging-${PROJECT}@` -] export function monorepoGit() { - return git({ baseDir: REPO_BASE }) + return git({ baseDir: REPO_BASE }) +} + +function defaultTagPrefixes(project) { + if (project === 'robot-stack') { + return ['v'] + } + + return [`${project}@`, `staging-${project}@`] +} + +function normalizeTagPrefixes(project, tagPrefixes) { + const prefixes = tagPrefixes?.length ? tagPrefixes : defaultTagPrefixes(project) + const unique = [...new Set(prefixes.filter(Boolean))] + + if (unique.length === 0) { + throw new Error('At least one tag prefix is required') + } + + return unique } -export const versionFromTag = tag => { - // Extract version from tag format: protocol-designer@8.6.0 or staging-protocol-designer@8.6.0 - const parts = tag.split('@') - return parts[1] +function parseTagWithPrefixes(tag, prefixes) { + for (const prefix of prefixes) { + if (tag.startsWith(prefix)) { + return { prefix, version: tag.slice(prefix.length) } + } + } + return null } -export async function getTagsPointingAtHead() { +export function createGitVersionToolkit(options) { + const { + project, + tagPrefixes, + } = options ?? {} + + if (!project) { + throw new Error('project is required') + } + + const prefixes = normalizeTagPrefixes(project, tagPrefixes) + + async function getTagsPointingAtHead() { try { - const tags = ( - await monorepoGit().raw([ - 'tag', - '--points-at', - 'HEAD', - '--list', - ...TAG_PREFIXES.map(prefix => `${prefix}*`), - ]) - ).trim() - - if (tags) { - return tags.split('\n').filter(t => t.length > 0) - } + const tags = ( + await monorepoGit().raw([ + 'tag', + '--points-at', + 'HEAD', + '--list', + ...prefixes.map(prefix => `${prefix}*`), + ]) + ).trim() + + if (tags) { + return tags.split('\n').filter(t => t.length > 0) + } } catch (error) { - // No tags found or git error + // ignore errors and fall through to empty list } return [] -} + } -export async function getCurrentBranchName() { + async function getCurrentBranchName() { const isCI = process.env.CI === 'true' try { - const branch = ( - await monorepoGit().raw(['rev-parse', '--abbrev-ref', 'HEAD']) - ).trim() - - if (isCI) { - console.log(`[git-version2] git rev-parse result: ${branch}`) - } + const branch = ( + await monorepoGit().raw(['rev-parse', '--abbrev-ref', 'HEAD']) + ).trim() - if (branch === 'HEAD') { - // Detached HEAD state - try CI environment variables - let ciBranch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME + if (isCI) { + console.log(`[git-version-v2] git rev-parse result: ${branch}`) + } - // Skip if this is a tag ref, not a branch - if (ciBranch === process.env.GITHUB_REF_NAME && - process.env.GITHUB_REF_TYPE === 'tag') { - ciBranch = null - } - - if (isCI) { - console.log(`[git-version2] Resolved CI branch: ${ciBranch}`) - } + if (branch === 'HEAD') { + let ciBranch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME - return ciBranch || null + if ( + ciBranch === process.env.GITHUB_REF_NAME && + process.env.GITHUB_REF_TYPE === 'tag' + ) { + ciBranch = null } - return branch - } catch (error) { - const fallbackBranch = process.env.GITHUB_HEAD_REF || - process.env.GITHUB_REF_NAME || - process.env.CI_COMMIT_REF_NAME || - process.env.CIRCLE_BRANCH || - null - if (isCI) { - console.log(`[git-version2] git command failed, using fallback: ${fallbackBranch}`) + console.log(`[git-version-v2] Resolved CI branch: ${ciBranch}`) } - return fallbackBranch + return ciBranch || null + } + + return branch + } catch (error) { + const fallbackBranch = + process.env.GITHUB_HEAD_REF || + process.env.GITHUB_REF_NAME || + process.env.CI_COMMIT_REF_NAME || + process.env.CIRCLE_BRANCH || + null + + if (isCI) { + console.log( + `[git-version-v2] git command failed, using fallback: ${fallbackBranch}` + ) + } + + return fallbackBranch } -} + } -export function getTimestamp() { + function getTimestamp() { const now = new Date() const year = now.getUTCFullYear() const month = String(now.getUTCMonth() + 1).padStart(2, '0') @@ -100,112 +130,104 @@ export function getTimestamp() { const minutes = String(now.getUTCMinutes()).padStart(2, '0') const seconds = String(now.getUTCSeconds()).padStart(2, '0') return `${year}${month}${day}-${hours}${minutes}${seconds}` -} + } -export async function getLatestTag() { + async function getLatestTag() { const gitClient = monorepoGit() const candidates = [] - // Get all tags reachable from HEAD (all prefixes in one call) try { - const tagsOutput = ( - await gitClient.raw([ - 'tag', - '--merged', - 'HEAD', - '--list', - ...TAG_PREFIXES.map(prefix => `${prefix}*`), - ]) - ).trim() - - if (tagsOutput.length === 0) { - throw new Error(`No matching tags found for ${PROJECT}.`) - } - - const tags = tagsOutput.split('\n').filter(t => t.length > 0) - - // Parse version from each tag and add to candidates - for (const tag of tags) { - try { - const version = versionFromTag(tag) - const parsedVersion = semver.parse(version) - - if (parsedVersion != null) { - // Determine which prefix this tag uses - const prefix = TAG_PREFIXES.find(p => tag.startsWith(p)) - - candidates.push({ - tag, - version: parsedVersion, - prefix, - }) - } - } catch (error) { - // Skip tags that don't have valid semver - continue - } - } + const tagsOutput = ( + await gitClient.raw([ + 'tag', + '--merged', + 'HEAD', + '--list', + ...prefixes.map(prefix => `${prefix}*`), + ]) + ).trim() + + if (tagsOutput.length === 0) { + throw new Error(`No matching tags found for ${project}.`) + } + + const tags = tagsOutput.split('\n').filter(t => t.length > 0) + + for (const tag of tags) { + const parsed = parseTagWithPrefixes(tag, prefixes) + if (!parsed) continue + + const semverVersion = semver.parse(parsed.version) + if (semverVersion) { + candidates.push({ + tag, + version: semverVersion, + prefix: parsed.prefix, + }) + } + } } catch (error) { - throw new Error(`No matching tags found for ${PROJECT}.`) + throw new Error(`No matching tags found for ${project}.`) } if (candidates.length === 0) { - throw new Error(`No matching tags found for ${PROJECT}.`) + throw new Error(`No matching tags found for ${project}.`) } - // Sort by semantic version (highest first), then by prefix priority candidates.sort((a, b) => { - // Compare semantic versions - const versionCompare = semver.rcompare(a.version, b.version) - - if (versionCompare !== 0) { - return versionCompare - } - - // If versions are equal, use prefix priority (production > staging) - return TAG_PREFIXES.indexOf(a.prefix) - TAG_PREFIXES.indexOf(b.prefix) + const versionCompare = semver.rcompare(a.version, b.version) + if (versionCompare !== 0) { + return versionCompare + } + return prefixes.indexOf(a.prefix) - prefixes.indexOf(b.prefix) }) return candidates[0].tag -} + } -export async function getVersion() { + async function getVersion() { return getLatestTag() - .then(tag => versionFromTag(tag)) - .catch(error => { - console.error( - `Could not find a version for ${PROJECT} (${error}) - no tags yet or no tags fetched? Using 0.0.0-dev` - ) - return '0.0.0-dev' - }) -} - -export async function generateBuildInfoHtml(outputPath) { + .then(tag => parseTagWithPrefixes(tag, prefixes)?.version) + .catch(error => { + console.error( + `Could not find a version for ${project} (${error}) - using 0.0.0-dev` + ) + return '0.0.0-dev' + }) + } + + async function generateBuildInfoHtml(outputPath) { const version = await getVersion() const timestamp = getTimestamp() const isCI = process.env.CI === 'true' let gitInfo = {} try { - const branch = await getCurrentBranchName() - const tags = await getTagsPointingAtHead() - const commitSha = (await monorepoGit().raw(['rev-parse', 'HEAD'])).trim() - const shortSha = commitSha.substring(0, 7) - const commitMessage = (await monorepoGit().raw(['log', '-1', '--pretty=%B'])).trim() - const commitAuthor = (await monorepoGit().raw(['log', '-1', '--pretty=%an'])).trim() - const commitDate = (await monorepoGit().raw(['log', '-1', '--pretty=%ci'])).trim() - - gitInfo = { - branch, - tags: tags.length > 0 ? tags : ['(none)'], - commitSha, - shortSha, - commitMessage, - commitAuthor, - commitDate - } + const branch = await getCurrentBranchName() + const tags = await getTagsPointingAtHead() + const commitSha = (await monorepoGit().raw(['rev-parse', 'HEAD'])).trim() + const shortSha = commitSha.substring(0, 7) + const commitMessage = ( + await monorepoGit().raw(['log', '-1', '--pretty=%B']) + ).trim() + const commitAuthor = ( + await monorepoGit().raw(['log', '-1', '--pretty=%an']) + ).trim() + const commitDate = ( + await monorepoGit().raw(['log', '-1', '--pretty=%ci']) + ).trim() + + gitInfo = { + branch, + tags: tags.length > 0 ? tags : ['(none)'], + commitSha, + shortSha, + commitMessage, + commitAuthor, + commitDate, + } } catch (error) { - gitInfo = { error: error.message } + gitInfo = { error: error.message } } const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com' @@ -215,79 +237,69 @@ export async function generateBuildInfoHtml(outputPath) { const baseRef = process.env.GITHUB_BASE_REF const githubInfo = { - // Run information - runId: runId || 'N/A', - runNumber: process.env.GITHUB_RUN_NUMBER || 'N/A', - runAttempt: process.env.GITHUB_RUN_ATTEMPT || 'N/A', - job: process.env.GITHUB_JOB || 'N/A', - - // Workflow information - workflow: process.env.GITHUB_WORKFLOW || 'N/A', - workflowRef: process.env.GITHUB_WORKFLOW_REF || 'N/A', - workflowSha: process.env.GITHUB_WORKFLOW_SHA || 'N/A', - - // Actor information - actor: process.env.GITHUB_ACTOR || 'N/A', - actorId: process.env.GITHUB_ACTOR_ID || 'N/A', - triggeringActor: process.env.GITHUB_TRIGGERING_ACTOR || 'N/A', - - // Event information - event: process.env.GITHUB_EVENT_NAME || 'N/A', - eventPath: process.env.GITHUB_EVENT_PATH || 'N/A', - - // Ref information - ref: process.env.GITHUB_REF || 'N/A', - refName: process.env.GITHUB_REF_NAME || 'N/A', - refType: process.env.GITHUB_REF_TYPE || 'N/A', - refProtected: process.env.GITHUB_REF_PROTECTED || 'N/A', - headRef: headRef || 'N/A', - baseRef: baseRef || 'N/A', - - // Repository information - repository: repository, - repositoryId: process.env.GITHUB_REPOSITORY_ID || 'N/A', - repositoryOwner: process.env.GITHUB_REPOSITORY_OWNER || 'N/A', - repositoryOwnerId: process.env.GITHUB_REPOSITORY_OWNER_ID || 'N/A', - - // Environment - environment: process.env.GITHUB_ENV || 'N/A', - - // URLs - serverUrl: serverUrl, - apiUrl: process.env.GITHUB_API_URL || 'https://api.github.com', - graphqlUrl: process.env.GITHUB_GRAPHQL_URL || 'https://api.github.com/graphql', - - // Constructed links - runUrl: runId && repository - ? `${serverUrl}/${repository}/actions/runs/${runId}` - : null, - compareUrl: headRef && baseRef && repository - ? `${serverUrl}/${repository}/compare/${baseRef}...${headRef}` - : null, - prUrl: headRef && repository && process.env.GITHUB_EVENT_NAME === 'pull_request' - ? `${serverUrl}/${repository}/pull/${process.env.GITHUB_REF_NAME?.replace('refs/pull/', '').replace('/merge', '')}` - : null, - branchUrl: process.env.GITHUB_REF_TYPE === 'branch' && process.env.GITHUB_REF_NAME && repository - ? `${serverUrl}/${repository}/tree/${process.env.GITHUB_REF_NAME}` - : null, - tagUrl: process.env.GITHUB_REF_TYPE === 'tag' && process.env.GITHUB_REF_NAME && repository - ? `${serverUrl}/${repository}/releases/tag/${process.env.GITHUB_REF_NAME}` - : null + runId: runId || 'N/A', + runNumber: process.env.GITHUB_RUN_NUMBER || 'N/A', + runAttempt: process.env.GITHUB_RUN_ATTEMPT || 'N/A', + job: process.env.GITHUB_JOB || 'N/A', + workflow: process.env.GITHUB_WORKFLOW || 'N/A', + workflowRef: process.env.GITHUB_WORKFLOW_REF || 'N/A', + workflowSha: process.env.GITHUB_WORKFLOW_SHA || 'N/A', + actor: process.env.GITHUB_ACTOR || 'N/A', + actorId: process.env.GITHUB_ACTOR_ID || 'N/A', + triggeringActor: process.env.GITHUB_TRIGGERING_ACTOR || 'N/A', + event: process.env.GITHUB_EVENT_NAME || 'N/A', + eventPath: process.env.GITHUB_EVENT_PATH || 'N/A', + ref: process.env.GITHUB_REF || 'N/A', + refName: process.env.GITHUB_REF_NAME || 'N/A', + refType: process.env.GITHUB_REF_TYPE || 'N/A', + refProtected: process.env.GITHUB_REF_PROTECTED || 'N/A', + headRef: headRef || 'N/A', + baseRef: baseRef || 'N/A', + repository, + repositoryId: process.env.GITHUB_REPOSITORY_ID || 'N/A', + repositoryOwner: process.env.GITHUB_REPOSITORY_OWNER || 'N/A', + repositoryOwnerId: process.env.GITHUB_REPOSITORY_OWNER_ID || 'N/A', + environment: process.env.GITHUB_ENV || 'N/A', + serverUrl, + apiUrl: process.env.GITHUB_API_URL || 'https://api.github.com', + graphqlUrl: process.env.GITHUB_GRAPHQL_URL || 'https://api.github.com/graphql', + runUrl: + runId && repository ? `${serverUrl}/${repository}/actions/runs/${runId}` : null, + compareUrl: + headRef && baseRef && repository + ? `${serverUrl}/${repository}/compare/${baseRef}...${headRef}` + : null, + prUrl: + headRef && + repository && + process.env.GITHUB_EVENT_NAME === 'pull_request' + ? `${serverUrl}/${repository}/pull/${process.env.GITHUB_REF_NAME?.replace('refs/pull/', '').replace('/merge', '')}` + : null, + branchUrl: + process.env.GITHUB_REF_TYPE === 'branch' && + process.env.GITHUB_REF_NAME && + repository + ? `${serverUrl}/${repository}/tree/${process.env.GITHUB_REF_NAME}` + : null, + tagUrl: + process.env.GITHUB_REF_TYPE === 'tag' && + process.env.GITHUB_REF_NAME && + repository + ? `${serverUrl}/${repository}/releases/tag/${process.env.GITHUB_REF_NAME}` + : null, } - // Build information const buildInfo = { - project: PROJECT, - version, - timestamp, - buildDate: new Date().toISOString(), - nodeVersion: process.version, - platform: process.platform, - arch: process.arch, - isCI + project, + version, + timestamp, + buildDate: new Date().toISOString(), + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + isCI, } - // Generate HTML const html = ` @@ -667,7 +679,8 @@ export async function generateBuildInfoHtml(outputPath) { ` : ''} ${githubInfo.baseRef !== 'N/A' ? `
-
Base Ref (PR)
+
Base Ref (PR) +
${githubInfo.baseRef}
` : ''} @@ -685,7 +698,8 @@ export async function generateBuildInfoHtml(outputPath) {
Repository ID
-
${githubInfo.repositoryId}
+
${githubInfo.repositoryId} +
Repository Owner
@@ -706,24 +720,35 @@ export async function generateBuildInfoHtml(outputPath) {
` - // Write the HTML file const fs = await import('fs') const path = await import('path') - // Ensure output directory exists const outputDir = path.dirname(outputPath) await fs.promises.mkdir(outputDir, { recursive: true }) - // Write the file await fs.promises.writeFile(outputPath, html, 'utf-8') console.log(`✅ Build info HTML generated: ${outputPath}`) return outputPath + } + + return { + project, + tagPrefixes: prefixes, + getTagsPointingAtHead, + getCurrentBranchName, + getTimestamp, + getLatestTag, + getVersion, + generateBuildInfoHtml, + } } + +export default createGitVersionToolkit diff --git a/scripts/locize_sync.py b/scripts/locize_sync.py index 573b3d3def1..a2a3a525d84 100755 --- a/scripts/locize_sync.py +++ b/scripts/locize_sync.py @@ -297,13 +297,12 @@ def push_local(api_key, project_id, dry_run=False): def download_remote(api_key, project_id, dry_run=False): """ - Download English and Chinese translations from Locize. + Sync from the latest translations in Locize to local files. Equivalent to: - npx -y locize-cli@latest download \\ + npx -y locize-cli@latest sync \\ --api-key KEY \\ --project-id ID \\ - --language en,zh \\ --path ./app/src/assets/localization \\ --ver latest @@ -314,13 +313,11 @@ def download_remote(api_key, project_id, dry_run=False): "npx", "-y", "locize-cli@latest", - "download", + "sync", "--api-key", api_key, "--project-id", project_id, - "--language", - ",".join(LANGUAGES), "--path", str(APP_LOCALIZATION), "--ver", @@ -339,11 +336,19 @@ def download_remote(api_key, project_id, dry_run=False): result = subprocess.run(cmd, cwd=REPO_ROOT) + if result.stdout: + console.print("\n[bold cyan]📄 stdout:[/]") + console.print(f"[dim]{result.stdout}[/]\n") + if result.stderr: + console.print("\n[bold yellow]⚠️ stderr:[/]") + console.print(f"[dim]{result.stderr}[/]\n") + if result.returncode != 0: + sys.exit(result.returncode) + else: console.print( - "[bold red]✗ Error:[/] Failed to download translations from Locize" + f"[dim]Sync command completed successfully. Return code: {result.returncode}[/]\n" ) - sys.exit(result.returncode) if dry_run: console.print( diff --git a/shared-data/command/types/module.ts b/shared-data/command/types/module.ts index e2879bba875..fa602138808 100644 --- a/shared-data/command/types/module.ts +++ b/shared-data/command/types/module.ts @@ -461,17 +461,18 @@ interface StackerStoredLabwareDefinitionURIs { lidLabwareURI?: string | null } +export interface FlexStackerSetStoredLabwareParams { + moduleId: string + initialCount?: number | 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 +503,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/constants.ts b/shared-data/js/constants.ts index b6405e2efa6..2dbdadb5636 100644 --- a/shared-data/js/constants.ts +++ b/shared-data/js/constants.ts @@ -70,6 +70,9 @@ export const GRIPPER_MODELS = [ export const OT2_DISPLAY_NAME: 'Opentrons OT-2' = 'Opentrons OT-2' export const FLEX_DISPLAY_NAME: 'Opentrons Flex' = 'Opentrons Flex' +// robot camera name +export const OT_SYSTEM_CAMERA = 'ot_system_camera' + // pipette display categories export const FLEX: 'FLEX' = 'FLEX' export const GEN2: 'GEN2' = 'GEN2' diff --git a/shared-data/js/fixtures.ts b/shared-data/js/fixtures.ts index 6ae3e6f0a3b..8b86ee9d038 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..f70cd9d58ba --- /dev/null +++ b/shared-data/js/helpers/__tests__/getFlexStackerHardwareProps.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } 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 console an error if the module model is invalid', () => { + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const result = getStackerMaxPoolCountByHeight(MAGNETIC_MODULE_V1, 100, 0) + expect(result).toBe(0) + expect(mockConsoleError).toHaveBeenCalledWith( + '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 console an error if the module model is invalid', () => { + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const result = getLabwareOverlapOffset( + MAGNETIC_MODULE_V1, + mockLabwareDefinition, + 'labware-name' + ) + expect(result).toStrictEqual({ x: 0, y: 0, z: 0 }) + expect(mockConsoleError).toHaveBeenCalledWith( + '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..9e0cb6ac689 --- /dev/null +++ b/shared-data/js/helpers/getFlexStackerHardwareProps.ts @@ -0,0 +1,78 @@ +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 + ) + } + console.error(`Invalid module model for max fill height: ${model}`) + return 0 +} + +export const getStackerMaxPoolCountByHeight = ( + model: ModuleModel, + poolHeight: number, + poolOverlap: number +): number => { + if (model === FLEX_STACKER_MODULE_V1) { + const maxFillHeight = getModuleMaxFillHeight(model) + if (maxFillHeight <= 0) { + console.error( + `Invalid max fill height for ${model}: ${maxFillHeight} must be greater than 0` + ) + } + return Math.floor( + (maxFillHeight - poolOverlap) / (poolHeight - poolOverlap) + ) + } + console.error(`Invalid module model for max pool count by height: ${model}`) + return 0 +} + +export const getLabwareOverlapOffset = ( + model: ModuleModel, + definition: LabwareDefinition, + belowLabwareName: string +): Vector3D => { + if (model !== FLEX_STACKER_MODULE_V1) { + console.error(`Invalid module model for labware overlap offset: ${model}`) + return { x: 0, y: 0, z: 0 } + } + 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 +} diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index b1e7496ef5b..60b6a4525de 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 894774ff523..46942ca7a32 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -569,6 +569,7 @@ export interface ModuleDimensions { labwareInterfaceXDimension?: number labwareInterfaceYDimension?: number lidHeight?: number + maxStackerFillHeight?: number } export interface ModuleCalibrationPoint { @@ -1039,6 +1040,7 @@ export type RunTimeParameter = export interface CommandPreconditions { isCameraUsed: boolean } +export type CameraId = 'ot_system_camera' // TODO(BC, 10/25/2023): this type (and others in this file) probably belong in api-client, not here export interface CompletedProtocolAnalysis { diff --git a/shared-data/labware/definitions/2/black_96_well_microtiter_plate_lid/2.json b/shared-data/labware/definitions/2/black_96_well_microtiter_plate_lid/2.json new file mode 100644 index 00000000000..c88d739a483 --- /dev/null +++ b/shared-data/labware/definitions/2/black_96_well_microtiter_plate_lid/2.json @@ -0,0 +1,110 @@ +{ + "allowedRoles": ["labware", "lid"], + "ordering": [], + "brand": { + "brand": "greiner", + "brandId": [] + }, + "metadata": { + "displayName": "Black 96-well Microtiter Plate Lid", + "displayCategory": "lid", + "displayVolumeUnits": "\u00b5L", + "tags": [] + }, + "dimensions": { + "xDimension": 127.5, + "yDimension": 85, + "zDimension": 10 + }, + "wells": {}, + "groups": [ + { + "metadata": {}, + "wells": [] + } + ], + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "parameters": { + "format": "irregular", + "quirks": ["stackingMaxFive"], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "black_96_well_microtiter_plate_lid" + }, + "namespace": "opentrons", + "version": 2, + "schemaVersion": 2, + "stackingOffsetWithLabware": { + "milliplex_r_96_well_microtiter_plate": { + "x": 0, + "y": 0, + "z": 7.4 + }, + "black_96_well_microtiter_plate_lid": { + "x": 0, + "y": 0, + "z": 1 + }, + "opentrons_flex_deck_riser": { + "x": 0, + "y": 0, + "z": 34 + }, + "protocol_engine_lid_stack_object": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "stackLimit": 5, + "compatibleParentLabware": [ + "protocol_engine_lid_stack_object", + "opentrons_flex_deck_riser", + "milliplex_r_96_well_microtiter_plate", + "black_96_well_microtiter_plate_lid" + ], + "gripForce": 10, + "gripHeightFromLabwareBottom": 7, + "gripperOffsets": { + "default": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + }, + "dropOffset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "lidOffsets": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": -5 + }, + "dropOffset": { + "x": 0, + "y": 0, + "z": -5 + } + }, + "lidDisposalOffsets": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + }, + "dropOffset": { + "x": 0, + "y": 5.0, + "z": 50.0 + } + } + } +} diff --git a/shared-data/labware/definitions/2/corning_96_wellplate_360ul_lid/2.json b/shared-data/labware/definitions/2/corning_96_wellplate_360ul_lid/2.json new file mode 100644 index 00000000000..577e8d3df14 --- /dev/null +++ b/shared-data/labware/definitions/2/corning_96_wellplate_360ul_lid/2.json @@ -0,0 +1,117 @@ +{ + "allowedRoles": ["labware", "lid"], + "ordering": [], + "brand": { + "brand": "Corning", + "brandId": [] + }, + "metadata": { + "displayName": "Corning 96 Wellplate 360ul Lid", + "displayCategory": "lid", + "displayVolumeUnits": "\u00b5L", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 85, + "zDimension": 10 + }, + "wells": {}, + "groups": [ + { + "metadata": {}, + "wells": [] + } + ], + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "parameters": { + "format": "irregular", + "quirks": ["stackingMaxFive"], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "corning_96_wellplate_360ul_lid" + }, + "namespace": "opentrons", + "version": 2, + "schemaVersion": 2, + "stackingOffsetWithModule": { + "thermocyclerModuleV2": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "stackingOffsetWithLabware": { + "corning_96_wellplate_360ul_flat": { + "x": 0, + "y": 0, + "z": 6.5 + }, + "corning_96_wellplate_360ul_lid": { + "x": 0, + "y": 0, + "z": 1 + }, + "opentrons_flex_deck_riser": { + "x": 0, + "y": 0, + "z": 34 + }, + "protocol_engine_lid_stack_object": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "stackLimit": 5, + "compatibleParentLabware": [ + "protocol_engine_lid_stack_object", + "opentrons_flex_deck_riser", + "corning_96_wellplate_360ul_flat", + "corning_96_wellplate_360ul_lid" + ], + "gripForce": 10, + "gripHeightFromLabwareBottom": 7, + "gripperOffsets": { + "default": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + }, + "dropOffset": { + "x": 0, + "y": 0, + "z": -6 + } + }, + "lidOffsets": { + "pickUpOffset": { + "x": 0.5, + "y": 0, + "z": -5 + }, + "dropOffset": { + "x": 0.5, + "y": 0, + "z": -1 + } + }, + "lidDisposalOffsets": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + }, + "dropOffset": { + "x": 0, + "y": 5.0, + "z": 50.0 + } + } + } +} diff --git a/shared-data/labware/definitions/2/corning_falcon_384_wellplate_130ul_flat_lid/2.json b/shared-data/labware/definitions/2/corning_falcon_384_wellplate_130ul_flat_lid/2.json new file mode 100644 index 00000000000..cd6b4e2a653 --- /dev/null +++ b/shared-data/labware/definitions/2/corning_falcon_384_wellplate_130ul_flat_lid/2.json @@ -0,0 +1,118 @@ +{ + "allowedRoles": ["labware", "lid"], + "ordering": [], + "brand": { + "brand": "Corning", + "brandId": [] + }, + "metadata": { + "displayName": "Corning Falcon 384 Well Microtest Plate 130 µL Lid", + "displayCategory": "lid", + "displayVolumeUnits": "\u00b5L", + "tags": [] + }, + "dimensions": { + "xDimension": 127, + "yDimension": 84.8, + "zDimension": 6.85 + }, + "wells": {}, + "groups": [ + { + "metadata": {}, + "wells": [] + } + ], + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": 0 + }, + "parameters": { + "format": "irregular", + "quirks": [], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "corning_falcon_384_wellplate_130ul_flat_lid" + }, + "namespace": "opentrons", + "version": 2, + "schemaVersion": 2, + "stackingOffsetWithModule": { + "thermocyclerModuleV2": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "stackingOffsetWithLabware": { + "corning_falcon_384_wellplate_130ul_flat": { + "x": 0, + "y": 0, + "z": 2.1 + }, + "corning_falcon_384_wellplate_130ul_flat_lid": { + "x": 0, + "y": 0, + "z": 5.9 + }, + "opentrons_flex_deck_riser": { + "x": 0, + "y": 0, + "z": 28 + }, + "protocol_engine_lid_stack_object": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "stackLimit": 5, + "compatibleParentLabware": [ + "protocol_engine_lid_stack_object", + "opentrons_flex_deck_riser", + "corning_falcon_384_wellplate_130ul_flat", + "corning_falcon_384_wellplate_130ul_flat_lid", + "opentrons_universal_flat_adapter" + ], + "gripForce": 10, + "gripHeightFromLabwareBottom": 7, + "gripperOffsets": { + "default": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + }, + "dropOffset": { + "x": 0, + "y": 0, + "z": -6 + } + }, + "lidOffsets": { + "pickUpOffset": { + "x": 0.5, + "y": 0, + "z": -5 + }, + "dropOffset": { + "x": 0.5, + "y": 0, + "z": -1 + } + }, + "lidDisposalOffsets": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 0 + }, + "dropOffset": { + "x": 0, + "y": 5.0, + "z": 50.0 + } + } + } +} diff --git a/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/2.json b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/2.json index 7015f700df2..0687e20c996 100644 --- a/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/2.json +++ b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/2.json @@ -98,12 +98,12 @@ "pickUpOffset": { "x": 0, "y": 0, - "z": 1.5 + "z": 0 }, "dropOffset": { "x": 0, "y": 0.52, - "z": -6 + "z": 0 } }, "lidOffsets": { diff --git a/shared-data/labware/definitions/2/thermofisher_nunc_maxisorp_lockwell_elisa/1.json b/shared-data/labware/definitions/2/thermofisher_nunc_maxisorp_lockwell_elisa/1.json new file mode 100644 index 00000000000..a248439517d --- /dev/null +++ b/shared-data/labware/definitions/2/thermofisher_nunc_maxisorp_lockwell_elisa/1.json @@ -0,0 +1,1168 @@ +{ + "ordering": [ + ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], + ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], + ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], + ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], + ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], + ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], + ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], + ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], + ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"], + ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"], + ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + ], + "brand": { "brand": "Thermofisher", "brandId": ["446469"] }, + "metadata": { + "displayName": "ThermoFisher Nunc MaxiSorp Lockwell ELISA", + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + "tags": [] + }, + "dimensions": { "yDimension": 85.6, "zDimension": 14.2, "xDimension": 127.7 }, + "wells": { + "A1": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 14.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B1": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 14.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C1": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 14.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D1": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 14.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E1": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 14.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F1": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 14.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G1": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 14.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H1": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 14.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A2": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 23.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B2": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 23.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C2": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 23.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D2": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 23.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E2": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 23.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F2": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 23.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G2": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 23.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H2": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 23.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A3": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 32.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B3": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 32.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C3": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 32.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D3": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 32.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E3": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 32.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F3": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 32.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G3": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 32.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H3": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 32.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A4": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 41.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B4": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 41.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C4": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 41.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D4": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 41.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E4": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 41.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F4": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 41.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G4": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 41.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H4": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 41.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A5": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 50.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B5": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 50.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C5": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 50.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D5": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 50.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E5": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 50.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F5": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 50.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G5": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 50.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H5": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 50.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A6": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 59.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B6": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 59.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C6": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 59.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D6": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 59.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E6": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 59.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F6": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 59.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G6": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 59.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H6": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 59.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A7": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 68.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B7": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 68.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C7": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 68.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D7": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 68.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E7": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 68.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F7": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 68.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G7": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 68.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H7": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 68.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A8": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 77.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B8": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 77.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C8": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 77.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D8": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 77.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E8": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 77.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F8": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 77.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G8": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 77.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H8": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 77.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A9": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 86.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B9": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 86.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C9": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 86.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D9": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 86.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E9": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 86.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F9": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 86.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G9": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 86.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H9": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 86.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A10": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 95.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B10": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 95.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C10": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 95.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D10": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 95.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E10": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 95.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F10": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 95.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G10": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 95.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H10": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 95.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A11": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 104.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B11": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 104.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C11": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 104.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D11": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 104.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E11": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 104.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F11": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 104.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G11": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 104.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H11": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 104.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + }, + "A12": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 113.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 74.1 + }, + "B12": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 113.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 65.1 + }, + "C12": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 113.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 56.1 + }, + "D12": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 113.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 47.1 + }, + "E12": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 113.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 38.1 + }, + "F12": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 113.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 29.1 + }, + "G12": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 113.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 20.1 + }, + "H12": { + "shape": "circular", + "diameter": 6.6, + "depth": 10.8, + "totalLiquidVolume": 350, + "x": 113.4, + "z": 3.4, + "geometryDefinitionId": "conicalWell", + "y": 11.1 + } + }, + "groups": [ + { + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + "metadata": { "wellBottomShape": "flat" } + } + ], + "parameters": { + "format": "96Standard", + "quirks": [], + "isTiprack": false, + "loadName": "thermofisher_nunc_maxisorp_lockwell_elisa", + "isMagneticModuleCompatible": false + }, + "namespace": "opentrons", + "version": 1, + "schemaVersion": 2, + "cornerOffsetFromSlot": { "x": 0, "y": 0, "z": 0 }, + "stackingOffsetWithLabware": { + "opentrons_universal_flat_adapter_type_b": { "x": 0, "y": 0, "z": 3.3 } + }, + "stackingOffsetWithModule": {}, + "allowedRoles": [], + "gripperOffsets": {}, + "innerLabwareGeometry": { + "conicalWell": { + "sections": [ + { + "shape": "conical", + "topDiameter": 6.6, + "bottomDiameter": 6.82, + "bottomHeight": 10.03, + "topHeight": 10.8 + }, + { + "shape": "conical", + "topDiameter": 6.82, + "bottomDiameter": 6.82, + "bottomHeight": 9.64, + "topHeight": 10.03 + }, + { + "shape": "conical", + "topDiameter": 7.22, + "bottomDiameter": 7.22, + "bottomHeight": 8.15, + "topHeight": 9.64 + }, + { + "shape": "conical", + "topDiameter": 6.64, + "bottomDiameter": 6.64, + "bottomHeight": 6.75, + "topHeight": 8.15 + }, + { + "shape": "conical", + "topDiameter": 6.4, + "bottomDiameter": 6.4, + "bottomHeight": 5.23, + "topHeight": 6.75 + }, + { + "shape": "conical", + "topDiameter": 6.48, + "bottomDiameter": 6.48, + "bottomHeight": 3.75, + "topHeight": 5.23 + }, + { + "shape": "conical", + "topDiameter": 6.3, + "bottomDiameter": 6.3, + "bottomHeight": 2.19, + "topHeight": 3.75 + }, + { + "shape": "conical", + "topDiameter": 6.82, + "bottomDiameter": 6.82, + "bottomHeight": 0, + "topHeight": 2.19 + } + ] + } + } +} diff --git a/shared-data/labware/images/thermofisher_nunc_maxisorp_lockwell_elisa.jpg b/shared-data/labware/images/thermofisher_nunc_maxisorp_lockwell_elisa.jpg new file mode 100644 index 00000000000..75e37a97a29 Binary files /dev/null and b/shared-data/labware/images/thermofisher_nunc_maxisorp_lockwell_elisa.jpg differ diff --git a/step-generation/src/__tests__/flexStackerEmpty.test.ts b/step-generation/src/__tests__/flexStackerEmpty.test.ts new file mode 100644 index 00000000000..ddc59f16e07 --- /dev/null +++ b/step-generation/src/__tests__/flexStackerEmpty.test.ts @@ -0,0 +1,111 @@ +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, + storedLabwareDetails: null, + labwareOnShuttle: null, + labwareInHopper: 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__/flexStackerFill.test.ts b/step-generation/src/__tests__/flexStackerFill.test.ts new file mode 100644 index 00000000000..5c362e065e3 --- /dev/null +++ b/step-generation/src/__tests__/flexStackerFill.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + FLEX_STACKER_MODULE_TYPE, + FLEX_STACKER_MODULE_V1, +} from '@opentrons/shared-data' + +import { flexStackerFill } from '../commandCreators/atomic/flexStackerFill' +import { getInitialRobotStateStandard, makeContext } from '../fixtures' + +import type { InvariantContext, RobotState } from '../types' + +const moduleId = 'flexStackerId' +vi.mock('../robotStateSelectors') + +describe('flexStackerStore', () => { + let invariantContext: InvariantContext + let robotState: RobotState + beforeEach(() => { + invariantContext = makeContext() + robotState = getInitialRobotStateStandard(invariantContext) + invariantContext.moduleEntities[moduleId] = { + id: moduleId, + type: FLEX_STACKER_MODULE_TYPE, + model: FLEX_STACKER_MODULE_V1, + pythonName: 'mock_flex_stacker_1', + } + }) + it('creates flex stacker fill command with count', () => { + const result = flexStackerFill( + { + moduleId, + count: 10, + strategy: 'manualWithPause', + }, + invariantContext, + robotState + ) + expect(result).toEqual({ + commands: [ + { + commandType: 'flexStacker/fill', + key: expect.any(String), + params: { + moduleId, + strategy: 'manualWithPause', + count: 10, + }, + }, + ], + python: 'mock_flex_stacker_1.fill(count=10)', + }) + }) + it('creates flex stacker fill command with message', () => { + const result = flexStackerFill( + { + moduleId, + message: 'Filling...', + strategy: 'manualWithPause', + }, + invariantContext, + robotState + ) + expect(result).toEqual({ + commands: [ + { + commandType: 'flexStacker/fill', + key: expect.any(String), + params: { + moduleId, + strategy: 'manualWithPause', + message: 'Filling...', + }, + }, + ], + python: 'mock_flex_stacker_1.fill(message="Filling...")', + }) + }) +}) diff --git a/step-generation/src/__tests__/flexStackerRetrieve.test.ts b/step-generation/src/__tests__/flexStackerRetrieve.test.ts new file mode 100644 index 00000000000..31d7fc9f65a --- /dev/null +++ b/step-generation/src/__tests__/flexStackerRetrieve.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + fixture96Plate, + FLEX_STACKER_MODULE_TYPE, + FLEX_STACKER_MODULE_V1, +} from '@opentrons/shared-data' + +import { flexStackerRetrieve } from '../commandCreators/atomic/flexStackerRetrieve' +import { HOPPER_STACKER_LOCATION } from '../constants' +import { getInitialRobotStateStandard, makeContext } from '../fixtures' + +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { InvariantContext, RobotState } from '../types' + +const mockLabwareId = 'labwareId' +const mockLabwareId2 = 'labwareId2' +const mockLabwareId3 = 'labwareId3' +const mockModuleId = 'flexStackerId' +vi.mock('../robotStateSelectors') + +describe('flexStackerRetrieve', () => { + let invariantContext: InvariantContext + let robotState: RobotState + beforeEach(() => { + invariantContext = makeContext() + robotState = getInitialRobotStateStandard(invariantContext) + invariantContext.moduleEntities[mockModuleId] = { + id: mockModuleId, + type: FLEX_STACKER_MODULE_TYPE, + model: FLEX_STACKER_MODULE_V1, + pythonName: 'mock_flex_stacker_1', + } + }) + it('creates flex stacker retrieve command', () => { + robotState = { + ...robotState, + modules: { + [mockModuleId]: { + slot: 'D3', + moduleState: {} as any, + }, + }, + labware: { + [mockLabwareId]: { + stack: [mockLabwareId, HOPPER_STACKER_LOCATION, mockModuleId, 'D3'], + }, + [mockLabwareId2]: { + stack: [ + mockLabwareId2, + mockLabwareId, + HOPPER_STACKER_LOCATION, + mockModuleId, + 'D3', + ], + }, + [mockLabwareId3]: { + stack: [mockLabwareId3, mockModuleId, 'D3'], + }, + }, + } + invariantContext = { + ...invariantContext, + labwareEntities: { + [mockLabwareId]: { + labwareDefURI: 'mockURI', + def: fixture96Plate as LabwareDefinition2, + pythonName: 'wellPlate_1', + id: mockLabwareId, + }, + [mockLabwareId2]: { + labwareDefURI: 'mockURI', + def: fixture96Plate as LabwareDefinition2, + pythonName: 'wellPlate_2', + id: mockLabwareId2, + }, + [mockLabwareId3]: { + labwareDefURI: 'mockURI', + def: fixture96Plate as LabwareDefinition2, + pythonName: 'wellPlate_3', + id: mockLabwareId3, + }, + }, + } + const result = flexStackerRetrieve( + { + moduleId: mockModuleId, + }, + invariantContext, + robotState + ) + expect(result).toEqual({ + commands: [ + { + commandType: 'flexStacker/retrieve', + key: expect.any(String), + params: { + moduleId: mockModuleId, + }, + }, + ], + python: 'wellPlate_1 = mock_flex_stacker_1.retrieve()', + }) + }) +}) diff --git a/step-generation/src/__tests__/flexStackerStore.test.ts b/step-generation/src/__tests__/flexStackerStore.test.ts new file mode 100644 index 00000000000..33f3104fec7 --- /dev/null +++ b/step-generation/src/__tests__/flexStackerStore.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + FLEX_STACKER_MODULE_TYPE, + FLEX_STACKER_MODULE_V1, +} from '@opentrons/shared-data' + +import { flexStackerStore } from '../commandCreators/atomic/flexStackerStore' +import { getInitialRobotStateStandard, makeContext } from '../fixtures' + +import type { InvariantContext, RobotState } from '../types' + +const moduleId = 'flexStackerId' +vi.mock('../robotStateSelectors') + +describe('flexStackerStore', () => { + let invariantContext: InvariantContext + let robotState: RobotState + beforeEach(() => { + invariantContext = makeContext() + robotState = getInitialRobotStateStandard(invariantContext) + invariantContext.moduleEntities[moduleId] = { + id: moduleId, + type: FLEX_STACKER_MODULE_TYPE, + model: FLEX_STACKER_MODULE_V1, + pythonName: 'mock_flex_stacker_1', + } + }) + it('creates flex stacker store command', () => { + const result = flexStackerStore( + { + moduleId, + strategy: 'automatic', + }, + invariantContext, + robotState + ) + expect(result).toEqual({ + commands: [ + { + commandType: 'flexStacker/store', + key: expect.any(String), + params: { + moduleId, + strategy: 'automatic', + }, + }, + ], + python: 'mock_flex_stacker_1.store()', + }) + }) +}) diff --git a/step-generation/src/__tests__/pythonFileUtils.test.ts b/step-generation/src/__tests__/pythonFileUtils.test.ts index d75230fd3ae..0ef13ab0130 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, @@ -31,6 +33,7 @@ import { getLoadPipettes, getLoadTrashBins, getLoadWasteChute, + getSetStoredLabware, PAPI_VERSION, pythonMetadata, pythonRequirements, @@ -110,6 +113,7 @@ describe('pythonRequirements', () => { const moduleId = '1' const moduleId2 = '2' const moduleId3 = '3' +const moduleId4 = '4' const mockModuleEntities: ModuleEntities = { [moduleId]: { id: moduleId, @@ -139,6 +143,7 @@ const labwareId6 = 'labwareId6' const labwareId7 = 'labwareId7' const labwareId8 = 'labwareId8' const deckRiserId = 'deckRiserId' +const flexStackerLabwareId = 'flexStackerLabwareId' const mockLabwareEntities: LabwareEntities = { [labwareId1]: { id: labwareId1, @@ -422,6 +427,51 @@ 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 setStoredLabware = getSetStoredLabware( + mockModuleEntitiesWithFlexStackerModule, + mockLabwareEntitiesWithFlexStackerLabware, + mockLabwareRobotStateWithFlexStackerLabware + ) + + expect(setStoredLabware).toBe( + `# Set Stored Labware: +flex_stacker_1 = protocol.set_stored_labware( + loadName="fixture_96_plate", + namespace="opentrons", + version=1, + count=1)`.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..41c901dfe87 --- /dev/null +++ b/step-generation/src/__tests__/stackerUpdates.test.ts @@ -0,0 +1,352 @@ +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, + labwareInHopper: ['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?.labwareInHopper).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?.labwareInHopper).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?.labwareInHopper).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, + labwareInHopper: ['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?.labwareInHopper).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?.labwareInHopper).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?.labwareInHopper).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, + labwareInHopper: ['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', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) // Mock to prevent actual console output + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareInHopper: [], + max_pool_count: 6, + labwareStored: LABWARE_ID, + labwareOnShuttle: null, + } as any) + forFlexStackerRetrieve({ moduleId: FLEX_STACKER_ID }, invariantContext, { + robotState, + warnings: [], + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Cannot retrieve labware bc there is no labware in the stacker' + ) + }) + + it('should raise an error if there is labware on the shuttle', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) // Mock to prevent actual console output + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareInHopper: ['tiprack1Id', 'tiprack2Id', 'tiprack4AdapterId'], + max_pool_count: 6, + labwareStored: LABWARE_ID, + labwareOnShuttle: { + primaryLabwareId: 'tiprack1Id', + adapterLabwareId: null, + lidLabwareId: null, + }, + } as any) + forFlexStackerRetrieve({ moduleId: FLEX_STACKER_ID }, invariantContext, { + robotState, + warnings: [], + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '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', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) // Mock to prevent actual console output + vi.mocked(getModuleState).mockReturnValue({ + type: FLEX_STACKER_MODULE_TYPE, + labwareInHopper: [ + { + primaryLabwareId: 'tiprack1Id', + adapterLabwareId: null, + lidLabwareId: null, + }, + { + primaryLabwareId: 'tiprack2Id', + adapterLabwareId: null, + lidLabwareId: null, + }, + { + primaryLabwareId: 'tiprack4AdapterId', + adapterLabwareId: null, + lidLabwareId: null, + }, + ], + max_pool_count: 6, + labwareStored: LABWARE_ID, + labwareOnShuttle: null, + } as any) + forFlexStackerRetrieve({ moduleId: FLEX_STACKER_ID }, invariantContext, { + robotState, + warnings: [], + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '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, + labwareInHopper: [ + { + primaryLabwareId: 'tiprack1Id', + adapterLabwareId: null, + lidLabwareId: null, + }, + { + primaryLabwareId: 'tiprack2Id', + adapterLabwareId: null, + lidLabwareId: null, + }, + { + primaryLabwareId: 'tiprack4AdapterId', + adapterLabwareId: null, + lidLabwareId: null, + }, + ], + max_pool_count: 6, + labwareStored: LABWARE_ID, + labwareOnShuttle: null, + 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?.labwareOnShuttle).not.toBeNull() + expect(moduleState?.labwareInHopper).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, + labwareInHopper: ['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, + labwareInHopper: ['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?.labwareOnShuttle).toBeNull() + expect(moduleState?.labwareInHopper).toHaveLength(4) + }) +}) diff --git a/step-generation/src/__tests__/transfer.test.ts b/step-generation/src/__tests__/transfer.test.ts index 94246a1b75c..280e94749ce 100644 --- a/step-generation/src/__tests__/transfer.test.ts +++ b/step-generation/src/__tests__/transfer.test.ts @@ -147,7 +147,7 @@ describe('pick up tip if no tip on pipette', () => { robotStateWithTip.tipState.pipettes.p300SingleId = { hasTip: true, - tiprackURI: 'tiprackId', + tiprackURI: 'tiprack1Id', } noTipArgs = { @@ -2364,7 +2364,7 @@ describe('single transfer exceeding pipette max', () => { // begin with tip on pipette robotStateWithTip.tipState.pipettes.p300SingleId = { hasTip: true, - tiprackURI: 'tiprackId', + tiprackURI: 'tiprack1Id', } const result = transfer(transferArgs, invariantContext, robotStateWithTip) @@ -2660,7 +2660,7 @@ describe('single transfer exceeding pipette max', () => { // begin with tip on pipette robotStateWithTip.tipState.pipettes.p300SingleId = { hasTip: true, - tiprackURI: 'tiprackId', + tiprackURI: 'tiprack1Id', } const result = transfer(transferArgs, invariantContext, robotStateWithTip) 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/commandCreators/atomic/flexStackerFill.ts b/step-generation/src/commandCreators/atomic/flexStackerFill.ts new file mode 100644 index 00000000000..33bd4b55eb4 --- /dev/null +++ b/step-generation/src/commandCreators/atomic/flexStackerFill.ts @@ -0,0 +1,35 @@ +import { formatPyStr, uuid } from '../../utils' + +import type { FlexStackerFillCreateCommand } from '@opentrons/shared-data' +import type { CommandCreator } from '../../types' + +export const flexStackerFill: CommandCreator< + FlexStackerFillCreateCommand['params'] +> = (args, invariantContext) => { + const { moduleId, message, count, labwareToStore } = args + const pythonName = invariantContext.moduleEntities[moduleId].pythonName + + // TODO: add error creators + + const pythonArgs = [ + ...(count != null ? [`count=${count}`] : []), + ...(message != null ? [`message=${formatPyStr(message)}`] : []), + ] + + return { + commands: [ + { + commandType: 'flexStacker/fill', + key: uuid(), + params: { + moduleId, + strategy: 'manualWithPause', + message, + count, + labwareToStore, + }, + }, + ], + python: `${pythonName}.fill(${pythonArgs.join(', ')})`, + } +} diff --git a/step-generation/src/commandCreators/atomic/flexStackerRetrieve.ts b/step-generation/src/commandCreators/atomic/flexStackerRetrieve.ts new file mode 100644 index 00000000000..93a48d3ef8c --- /dev/null +++ b/step-generation/src/commandCreators/atomic/flexStackerRetrieve.ts @@ -0,0 +1,30 @@ +import { getLabwareIdOnHopper, uuid } from '../../utils' + +import type { FlexStackerRetrieveCreateCommand } from '@opentrons/shared-data' +import type { CommandCreator } from '../../types' + +export const flexStackerRetrieve: CommandCreator< + FlexStackerRetrieveCreateCommand['params'] +> = (args, invariantContext, robotState) => { + const { moduleId } = args + const { modules, labware } = robotState + const { moduleEntities, labwareEntities } = invariantContext + const modulePythonName = moduleEntities[moduleId].pythonName + const moduleLocation = modules[moduleId].slot + const labwareIdOnModule = getLabwareIdOnHopper(labware, moduleLocation) + const labwarePythonName = labwareEntities[labwareIdOnModule]?.pythonName + // TODO: add error creator if there is no labware in the hopper + + return { + commands: [ + { + commandType: 'flexStacker/retrieve', + key: uuid(), + params: { + moduleId, + }, + }, + ], + python: `${labwarePythonName} = ${modulePythonName}.retrieve()`, + } +} diff --git a/step-generation/src/commandCreators/atomic/flexStackerStore.ts b/step-generation/src/commandCreators/atomic/flexStackerStore.ts new file mode 100644 index 00000000000..48b89b9e5c6 --- /dev/null +++ b/step-generation/src/commandCreators/atomic/flexStackerStore.ts @@ -0,0 +1,24 @@ +import { uuid } from '../../utils' + +import type { FlexStackerStoreCreateCommand } from '@opentrons/shared-data' +import type { CommandCreator } from '../../types' + +export const flexStackerStore: CommandCreator< + FlexStackerStoreCreateCommand['params'] +> = (args, invariantContext) => { + const { moduleId } = args + const pythonName = invariantContext.moduleEntities[moduleId].pythonName + return { + commands: [ + { + commandType: 'flexStacker/store', + key: uuid(), + params: { + moduleId, + strategy: 'automatic', // hardcoding here, since 'manual' should only be used in error recovery + }, + }, + ], + python: `${pythonName}.store()`, + } +} diff --git a/step-generation/src/commandCreators/compound/consolidate.ts b/step-generation/src/commandCreators/compound/consolidate.ts index 92a358d7627..f82a233c5c2 100644 --- a/step-generation/src/commandCreators/compound/consolidate.ts +++ b/step-generation/src/commandCreators/compound/consolidate.ts @@ -65,6 +65,7 @@ import type { CommandCreatorError, ConsolidateArgs, CurriedCommandCreator, + LabwareEntity, } from '../../types' export const consolidate: CommandCreator = ( @@ -138,7 +139,7 @@ export const consolidate: CommandCreator = ( pushOut, sourceLabware, sourceWells, - tipRack, + tipRack: userSelectedTipRackURI, // the tiprack the user selected, not necessarily the one used for this step tipTracking, tiprackSelected, tipsSelected, @@ -231,19 +232,31 @@ export const consolidate: CommandCreator = ( errors.push(errorCreators.dropTipLocationDoesNotExist()) } - const tiprack = Object.values(labwareEntities).find( - ({ labwareDefURI }) => labwareDefURI === tipRack - ) - if (tiprack == null) { + let tiprackEntity: LabwareEntity | undefined, tiprackURI: string + // TODO: We currently ask users to select a tip rack even if the tip handling policy + // for this step is `never`, in which case we must ignore the tip rack the user selected + // and use the tip rack from the previous step where we actually picked up the tip. + if (changeTip === 'never') { + const prevTiprackID = prevRobotState.tipState.pipettes[pipette]?.tiprackURI + // pipettes[pipette].tiprackURI is a misnomer: it's an labwareID, not a URI + tiprackEntity = invariantContext.labwareEntities[prevTiprackID ?? ''] + tiprackURI = tiprackEntity?.labwareDefURI + } else { + tiprackEntity = Object.values(labwareEntities).find( + ({ labwareDefURI }) => labwareDefURI === userSelectedTipRackURI + ) + tiprackURI = userSelectedTipRackURI + } + + if (tiprackEntity == null) { errors.push( errorCreators.labwareDoesNotExist({ actionName, - labware: tipRack, + labware: tiprackURI, }) ) } - const { def: tiprackDefinition = null, labwareDefURI: tiprackDefUri } = - tiprack ?? {} + const { def: tiprackDefinition = null } = tiprackEntity ?? {} const { spec: pipetteSpecs, name: pipetteName, @@ -259,7 +272,7 @@ export const consolidate: CommandCreator = ( ({ pipetteModel }) => pipetteModel === getFlexNameConversion(pipetteSpecs) ) - ?.byTipType.find(({ tiprack }) => tiprack === tiprackDefUri) ?? null + ?.byTipType.find(({ tiprack }) => tiprack === tiprackURI) ?? null const { aspirate } = liquidClassValuesForTip ?? {} const { multiWellHandling } = getTransferPlanAndReferenceVolumes({ pipetteSpecs, @@ -347,7 +360,7 @@ export const consolidate: CommandCreator = ( } const { tipracks } = getNextTiprack( pipette, - tipRack, + tiprackURI, invariantContext, prevRobotState, ...(nozzles != null ? [nozzles] : []) @@ -357,7 +370,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: volume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -396,7 +409,7 @@ export const consolidate: CommandCreator = ( pipetteName: isFlexPipette(pipetteName) ? getFlexNameConversion(pipetteSpecs) : pipetteName, - tiprackUri: tipRack, + tiprackUri: tiprackURI, liquidClassValuesForTip, })}`, ] @@ -513,7 +526,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -532,7 +545,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'flowRateByVolume', @@ -542,7 +555,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVolume, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'flowRateByVolume', @@ -552,7 +565,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'flowRateByVolume', @@ -562,7 +575,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVolume, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'flowRateByVolume', @@ -672,7 +685,7 @@ export const consolidate: CommandCreator = ( isReturnTip && fallBackTrashLikeId != null ? fallBackTrashLikeId : dropTipLocation, - tipRack, + tipRack: tiprackURI, ...(nozzles != null ? { nozzles } : {}), ...(tipTracking === MANUAL && nextTip != null && @@ -746,7 +759,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: airGapInTip, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'correctionByVolume', @@ -883,7 +896,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -983,7 +996,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVolume, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'correctionByVolume', @@ -1047,7 +1060,7 @@ export const consolidate: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: totalSampleDispenseVolume, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'correctionByVolume', @@ -1070,7 +1083,7 @@ export const consolidate: CommandCreator = ( ? getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: mixInDestination.volume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'flowRateByVolume', @@ -1082,7 +1095,7 @@ export const consolidate: CommandCreator = ( ? getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: mixInDestination.volume, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'flowRateByVolume', @@ -1102,7 +1115,7 @@ export const consolidate: CommandCreator = ( finalPushOut: pushOut, invariantContext, liquidClass, - tiprack: tipRack, + tiprack: tiprackURI, generatePython: false, }) : [] @@ -1167,7 +1180,7 @@ export const consolidate: CommandCreator = ( ? [ curryWithoutPython(dropTip, { pipette, - dropTipLocation: tipRack, + dropTipLocation: tiprackURI, isReturnTip, }), ] diff --git a/step-generation/src/commandCreators/compound/distribute.ts b/step-generation/src/commandCreators/compound/distribute.ts index c5d35742f7e..e18330a7c08 100644 --- a/step-generation/src/commandCreators/compound/distribute.ts +++ b/step-generation/src/commandCreators/compound/distribute.ts @@ -68,6 +68,7 @@ import type { CommandCreatorError, CurriedCommandCreator, DistributeArgs, + LabwareEntity, } from '../../types' export const distribute: CommandCreator = ( @@ -143,7 +144,7 @@ export const distribute: CommandCreator = ( pushOut, sourceLabware, sourceWell, - tipRack, + tipRack: userSelectedTipRackURI, // the tiprack the user selected, not necessarily the one used for this step tipTracking, tiprackSelected, tipsSelected, @@ -249,19 +250,31 @@ export const distribute: CommandCreator = ( errors.push(errorCreators.dropTipLocationDoesNotExist()) } - const tiprack = Object.values(labwareEntities).find( - ({ labwareDefURI }) => labwareDefURI === tipRack - ) - if (tiprack == null) { + let tiprackEntity: LabwareEntity | undefined, tiprackURI: string + // TODO: We currently ask users to select a tip rack even if the tip handling policy + // for this step is `never`, in which case we must ignore the tip rack the user selected + // and use the tip rack from the previous step where we actually picked up the tip. + if (changeTip === 'never') { + const prevTiprackID = prevRobotState.tipState.pipettes[pipette]?.tiprackURI + // pipettes[pipette].tiprackURI is a misnomer: it's an labwareID, not a URI + tiprackEntity = invariantContext.labwareEntities[prevTiprackID ?? ''] + tiprackURI = tiprackEntity?.labwareDefURI + } else { + tiprackEntity = Object.values(labwareEntities).find( + ({ labwareDefURI }) => labwareDefURI === userSelectedTipRackURI + ) + tiprackURI = userSelectedTipRackURI + } + + if (tiprackEntity == null) { errors.push( errorCreators.labwareDoesNotExist({ actionName, - labware: tipRack, + labware: tiprackURI, }) ) } - const { def: tiprackDefinition = null, labwareDefURI: tiprackDefUri } = - tiprack ?? {} + const { def: tiprackDefinition = null } = tiprackEntity ?? {} const { spec: pipetteSpecs, name: pipetteName, @@ -278,7 +291,7 @@ export const distribute: CommandCreator = ( ({ pipetteModel }) => pipetteModel === getFlexNameConversion(pipetteSpecs) ) - ?.byTipType.find(({ tiprack }) => tiprack === tiprackDefUri) ?? null + ?.byTipType.find(({ tiprack }) => tiprack === tiprackURI) ?? null const { aspirate, multiDispense } = liquidClassValuesForTip ?? {} const { multiWellHandling } = getTransferPlanAndReferenceVolumes({ pipetteSpecs, @@ -372,7 +385,7 @@ export const distribute: CommandCreator = ( ] const maxVolume = - getPipetteWithTipMaxVol(pipette, invariantContext, tipRack) - + getPipetteWithTipMaxVol(pipette, invariantContext, tiprackURI) - aspirateAirGapVolume const maxWellsPerChunk = Math.floor((maxVolume - disposalVolume) / volume) @@ -393,7 +406,7 @@ export const distribute: CommandCreator = ( } const { tipracks } = getNextTiprack( pipette, - tipRack, + tiprackURI, invariantContext, prevRobotState, ...(nozzles != null ? [nozzles] : []) @@ -403,7 +416,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: volume, liquidHandlingAction: 'multiDispense', byVolumeProperty: 'correctionByVolume', @@ -441,7 +454,7 @@ export const distribute: CommandCreator = ( pipetteName: isFlexPipette(pipetteName) ? getFlexNameConversion(pipetteSpecs) : pipetteName, - tiprackUri: tipRack, + tiprackUri: tiprackURI, liquidClassValuesForTip, })}`, ] @@ -560,7 +573,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'flowRateByVolume', @@ -570,7 +583,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVolume, liquidHandlingAction: 'multiDispense', byVolumeProperty: 'flowRateByVolume', @@ -580,7 +593,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'flowRateByVolume', @@ -590,7 +603,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVolume, liquidHandlingAction: 'multiDispense', byVolumeProperty: 'flowRateByVolume', @@ -652,7 +665,7 @@ export const distribute: CommandCreator = ( isReturnTip && fallBackTrashLikeId != null ? fallBackTrashLikeId : dropTipLocation, - tipRack, + tipRack: tiprackURI, ...(nozzles != null ? { nozzles } : {}), ...(tipTracking === MANUAL && nextTip != null && @@ -689,7 +702,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVolume, liquidHandlingAction: 'multiDispense', byVolumeProperty: 'correctionByVolume', @@ -781,7 +794,7 @@ export const distribute: CommandCreator = ( finalPushOut: 0, // according to transfer_components_executor, don't push out here invariantContext, liquidClass, - tiprack: tipRack, + tiprack: tiprackURI, generatePython: false, }) : [] @@ -798,7 +811,7 @@ export const distribute: CommandCreator = ( finalPushOut: 0, // according to transfer_components_executor, don't push out here invariantContext, liquidClass, - tiprack: tipRack, + tiprack: tiprackURI, generatePython: false, }) : [] @@ -843,7 +856,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -871,7 +884,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: totalGrossAspirateVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -881,7 +894,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: conditioningVolume, liquidHandlingAction: 'multiDispense', byVolumeProperty: 'correctionByVolume', @@ -955,7 +968,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: airGapInTip, liquidHandlingAction: 'multiDispense', byVolumeProperty: 'correctionByVolume', @@ -1091,7 +1104,7 @@ export const distribute: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVolume, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -1270,7 +1283,7 @@ export const distribute: CommandCreator = ( ? [ curryWithoutPython(dropTip, { pipette, - dropTipLocation: tipRack, + dropTipLocation: tiprackURI, isReturnTip, }), ] diff --git a/step-generation/src/commandCreators/compound/replaceTip.ts b/step-generation/src/commandCreators/compound/replaceTip.ts index 88b11f02dcc..1d9b841b11e 100644 --- a/step-generation/src/commandCreators/compound/replaceTip.ts +++ b/step-generation/src/commandCreators/compound/replaceTip.ts @@ -213,7 +213,7 @@ export const replaceTip: CommandCreator = ( const configureNozzleLayoutCommand: CurriedCommandCreator[] = // only emit the command if previous nozzle state and tiprack state are different // only check for the 96-channel since we do not support 8-channel partial tip yet - channels === 96 && + channels !== 1 && args.nozzles != null && (args.nozzles !== stateNozzles || nextTiprack.tiprackId !== stateTiprack) ? [ diff --git a/step-generation/src/commandCreators/compound/transfer.ts b/step-generation/src/commandCreators/compound/transfer.ts index 579c08da716..17ba811ceda 100644 --- a/step-generation/src/commandCreators/compound/transfer.ts +++ b/step-generation/src/commandCreators/compound/transfer.ts @@ -68,6 +68,7 @@ import type { CommandCreator, CommandCreatorError, CurriedCommandCreator, + LabwareEntity, TransferArgs, } from '../../types' @@ -153,7 +154,7 @@ export const transfer: CommandCreator = ( sourceLabware, nozzles, sourceWells, - tipRack, + tipRack: userSelectedTipRackURI, // the tiprack the user selected, not necessarily the one used for this step tipTracking, tiprackSelected, tipsSelected, @@ -179,7 +180,7 @@ export const transfer: CommandCreator = ( labwareEntities, wasteChuteEntities, trashBinEntities, - args.destLabware + destLabware ) const isTouchTipDisabled = @@ -207,10 +208,7 @@ export const transfer: CommandCreator = ( const actionName = 'transfer' const errors: CommandCreatorError[] = [] - if ( - !prevRobotState.pipettes[args.pipette] || - !pipetteEntities[args.pipette] - ) { + if (!prevRobotState.pipettes[pipette] || !pipetteEntities[pipette]) { // bail out before doing anything else errors.push( errorCreators.pipetteDoesNotExist({ @@ -275,7 +273,7 @@ export const transfer: CommandCreator = ( } if ( - !args.destLabware || + !destLabware || (!labwareEntities[destLabware] && !wasteChuteEntities[destLabware] && !trashBinEntities[destLabware]) @@ -283,14 +281,27 @@ export const transfer: CommandCreator = ( errors.push(errorCreators.equipmentDoesNotExist()) } - const tiprack = Object.values(labwareEntities).find( - ({ labwareDefURI }) => labwareDefURI === tipRack - ) - if (tiprack == null) { + let tiprackEntity: LabwareEntity | undefined, tiprackURI: string + // TODO: We currently ask users to select a tip rack even if the tip handling policy + // for this step is `never`, in which case we must ignore the tip rack the user selected + // and use the tip rack from the previous step where we actually picked up the tip. + if (changeTip === 'never') { + const prevTiprackID = prevRobotState.tipState.pipettes[pipette]?.tiprackURI + // pipettes[pipette].tiprackURI is a misnomer: it's an labwareID, not a URI + tiprackEntity = invariantContext.labwareEntities[prevTiprackID ?? ''] + tiprackURI = tiprackEntity?.labwareDefURI + } else { + tiprackEntity = Object.values(labwareEntities).find( + ({ labwareDefURI }) => labwareDefURI === userSelectedTipRackURI + ) + tiprackURI = userSelectedTipRackURI + } + + if (tiprackEntity == null) { errors.push( errorCreators.labwareDoesNotExist({ actionName, - labware: tipRack, + labware: tiprackURI, }) ) } @@ -304,7 +315,7 @@ export const transfer: CommandCreator = ( const aspirateAirGapVol = aspirateAirGapVolume || 0 const dispenseAirGapVol = dispenseAirGapVolume || 0 const effectiveTransferVol = - getPipetteWithTipMaxVol(pipette, invariantContext, tipRack) - + getPipetteWithTipMaxVol(pipette, invariantContext, tiprackURI) - aspirateAirGapVol const chunksPerSubTransfer = Math.ceil(volume / effectiveTransferVol) @@ -361,12 +372,12 @@ export const transfer: CommandCreator = ( spec: pipetteSpecs, name: pipetteName, pythonName: pythonPipetteName, - } = pipetteEntities[args.pipette] + } = pipetteEntities[pipette] - const { labwareDefURI: tiprackDefUri } = tiprack ?? {} const { tipracks } = getNextTiprack( + // TODO: This lookup is pointless for tip handling = `never` pipette, - tipRack, + tiprackURI, invariantContext, prevRobotState, ...(nozzles != null ? [nozzles] : []) @@ -381,13 +392,13 @@ export const transfer: CommandCreator = ( ({ pipetteModel }) => pipetteModel === getFlexNameConversion(pipetteSpecs) ) - ?.byTipType.find(({ tiprack }) => tiprack === tiprackDefUri) ?? null + ?.byTipType.find(({ tiprack }) => tiprack === tiprackURI) ?? null const dispenseCorrectionVolumeForSubtransferTarget = getByVolumeValue({ - liquidClass: args.liquidClass, + liquidClass: liquidClass, pipetteSpecs, - tiprackDefUri: args.tipRack, + tiprackDefUri: tiprackURI, targetVolume: subTransferVol, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'correctionByVolume', @@ -396,9 +407,9 @@ export const transfer: CommandCreator = ( const aspirateCorrectionVolumeForSubtransferTarget = getByVolumeValue({ - liquidClass: args.liquidClass, + liquidClass: liquidClass, pipetteSpecs, - tiprackDefUri: args.tipRack, + tiprackDefUri: tiprackURI, targetVolume: subTransferVol, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -423,8 +434,8 @@ export const transfer: CommandCreator = ( .map(well => `${sourceLabwarePythonName}[${formatPyStr(well)}]`) .join(', ') const pythonDestWells = - args.destWells != null && destLabwarePythonName != null - ? args.destWells + destWells != null && destLabwarePythonName != null + ? destWells .map(well => `${destLabwarePythonName}[${formatPyStr(well)}]`) .join(', ') : null @@ -439,7 +450,7 @@ export const transfer: CommandCreator = ( pipetteName: isFlexPipette(pipetteName) ? getFlexNameConversion(pipetteSpecs) : pipetteName, - tiprackUri: tipRack, + tiprackUri: tiprackURI, liquidClassValuesForTip, })}`, ] @@ -515,7 +526,7 @@ export const transfer: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVol, liquidHandlingAction: 'aspirate', byVolumeProperty: 'flowRateByVolume', @@ -525,7 +536,7 @@ export const transfer: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVol, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'flowRateByVolume', @@ -535,7 +546,7 @@ export const transfer: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVol, liquidHandlingAction: 'aspirate', byVolumeProperty: 'flowRateByVolume', @@ -545,7 +556,7 @@ export const transfer: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVol, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'flowRateByVolume', @@ -609,7 +620,7 @@ export const transfer: CommandCreator = ( isReturnTip && fallBackTrashLikeId != null ? fallBackTrashLikeId : dropTipLocation, - tipRack, + tipRack: tiprackURI, ...(nozzles != null ? { nozzles } : {}), ...(tipTracking === MANUAL && nextTip != null && @@ -708,8 +719,8 @@ export const transfer: CommandCreator = ( // if (changeTipNow && !probedWells.has(sourceWell)) { // liquidProbeCommand = [ // curryWithoutPython(liquidProbe, { - // pipetteId: args.pipette, - // labwareId: args.sourceLabware, + // pipetteId: pipette, + // labwareId: sourceLabware, // wellName: sourceWell, // wellLocation: SAFE_MOVE_TO_WELL_LOCATION, // }), @@ -725,7 +736,7 @@ export const transfer: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVol, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'correctionByVolume', @@ -816,7 +827,7 @@ export const transfer: CommandCreator = ( finalPushOut: 0, // according to transfer_components_executor, don't push out here invariantContext, liquidClass, - tiprack: tipRack, + tiprack: tiprackURI, generatePython: false, }) : [] @@ -833,7 +844,7 @@ export const transfer: CommandCreator = ( finalPushOut: 0, // according to transfer_components_executor, don't push out here invariantContext, liquidClass, - tiprack: tipRack, + tiprack: tiprackURI, generatePython: false, }) : [] @@ -877,7 +888,7 @@ export const transfer: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVol, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -938,7 +949,7 @@ export const transfer: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: aspirateAirGapVol, liquidHandlingAction: 'singleDispense', byVolumeProperty: 'correctionByVolume', @@ -1046,7 +1057,7 @@ export const transfer: CommandCreator = ( finalPushOut: pushOut, invariantContext, liquidClass, - tiprack: tipRack, + tiprack: tiprackURI, generatePython: false, }) : [] @@ -1082,7 +1093,7 @@ export const transfer: CommandCreator = ( getByVolumeValue({ liquidClass, pipetteSpecs, - tiprackDefUri: tipRack, + tiprackDefUri: tiprackURI, targetVolume: dispenseAirGapVol, liquidHandlingAction: 'aspirate', byVolumeProperty: 'correctionByVolume', @@ -1245,7 +1256,7 @@ export const transfer: CommandCreator = ( ? [ curryWithoutPython(dropTip, { pipette, - dropTipLocation: tipRack, + dropTipLocation: tiprackURI, isReturnTip, }), ] diff --git a/step-generation/src/constants.ts b/step-generation/src/constants.ts index 049b7ab1a9f..907de79da59 100644 --- a/step-generation/src/constants.ts +++ b/step-generation/src/constants.ts @@ -95,3 +95,5 @@ export const AUTOMATIC: 'automatic' = 'automatic' export const MANUAL: 'manual' = 'manual' export const STAGING_AREA_SLOTS = ['A4', 'B4', 'C4', 'D4'] + +export const HOPPER_STACKER_LOCATION = 'hopper' diff --git a/step-generation/src/errorCreators.ts b/step-generation/src/errorCreators.ts index a793c85579d..d287c925c07 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/fixtures/robotStateFixtures.ts b/step-generation/src/fixtures/robotStateFixtures.ts index 02edff7b94e..25abb2db78e 100644 --- a/step-generation/src/fixtures/robotStateFixtures.ts +++ b/step-generation/src/fixtures/robotStateFixtures.ts @@ -352,7 +352,7 @@ export const getRobotStateWithTipStandard = ( }) robotStateWithTip.tipState.pipettes[DEFAULT_PIPETTE] = { hasTip: true, - tiprackURI: 'tiprackId', + tiprackURI: 'tiprack1Id', } return robotStateWithTip } @@ -368,7 +368,7 @@ export const getRobotStatePickedUpTipStandard = ( }) robotStatePickedUpOneTip.tipState.pipettes[DEFAULT_PIPETTE] = { hasTip: true, - tiprackURI: 'tiprackId', + tiprackURI: 'tiprack1Id', } robotStatePickedUpOneTip.tipState.tipracks.tiprack1Id.A1 = EMPTY return robotStatePickedUpOneTip diff --git a/step-generation/src/getNextRobotStateAndWarnings/forDropTip.ts b/step-generation/src/getNextRobotStateAndWarnings/forDropTip.ts index 4cfaffd3490..04102d994ec 100644 --- a/step-generation/src/getNextRobotStateAndWarnings/forDropTip.ts +++ b/step-generation/src/getNextRobotStateAndWarnings/forDropTip.ts @@ -7,10 +7,6 @@ import { DIRTY } from '../constants' import type { DropTipParams } from '@opentrons/shared-data/protocol/types/schemaV6/command/pipetting' import type { InvariantContext, RobotStateAndWarnings } from '../types' -// NOTE(jr, 12/1/23): this state update is not in use currently for PD 8.0 -// since we only support dropping tip into the waste chute or trash bin -// which are both addressableAreas (so the commands are moveToAddressableArea -// and dropTipInPlace) We will use this again when we add return tip. export function forDropTip( params: DropTipParams, invariantContext: InvariantContext, diff --git a/step-generation/src/getNextRobotStateAndWarnings/index.ts b/step-generation/src/getNextRobotStateAndWarnings/index.ts index b946e6ad900..d6f5bec872e 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, @@ -118,18 +124,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 adding 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..481cbe31d5d --- /dev/null +++ b/step-generation/src/getNextRobotStateAndWarnings/stackerUpdates.ts @@ -0,0 +1,211 @@ +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, + FlexStackerStoredLabwareGroup, + 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.labwareInHopper = + moduleState?.labwareInHopper?.splice( + moduleState?.labwareInHopper?.length - 1 - count + ) ?? null + } else { + moduleState.labwareInHopper = 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?.labwareInHopper?.[0].primaryLabwareId ?? '' + ]?.def + const listOfLabwareDefinitions = Array.from( + { length: moduleState?.labwareInHopper?.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.labwareInHopper?.length ?? 0) + ) { + // create labware entities for the new labware + // TODO: wire up adapter and lid labware ids + const newLabwareIdList = Array.from({ length: count }, () => ({ + primaryLabwareId: uuid(), + adapterLabwareId: null, + lidLabwareId: null, + })) + moduleState.labwareInHopper = [ + ...(moduleState.labwareInHopper ?? []), + ...newLabwareIdList.map(id => ({ + primaryLabwareId: id, + adapterLabwareId: null, + lidLabwareId: null, + })), + ] as FlexStackerStoredLabwareGroup[] + } + } +} + +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.labwareOnShuttle !== null) { + console.error( + 'Cannot retrieve labware bc there is labware on the shuttle' + ) + return + } + if (moduleState.labwareInHopper?.length === 0) { + console.error( + 'Cannot retrieve labware bc there is no labware in the stacker' + ) + return + } + if (moduleState.storedLabwareDetails?.primaryLabware == null) { + console.error( + 'Cannot retrieve labware bc there is no stored labware details or primary labware' + ) + return + } + const labwareToRetrieve = moduleState?.labwareInHopper?.shift() ?? null + moduleState.labwareOnShuttle = labwareToRetrieve ?? null + + if (labwareToRetrieve == null) { + console.error( + 'Cannot retrieve labware bc there is no labware in the stacker' + ) + return + } + // create labware entity for retrieved labware + robotState.labware[labwareToRetrieve?.primaryLabwareId ?? ''] = { + ...robotState.labware[labwareToRetrieve?.primaryLabwareId ?? ''], + stack: + robotState.labware[ + labwareToRetrieve?.primaryLabwareId ?? '' + ]?.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.labwareOnShuttle !== null) { + console.error('Cannot store labware bc there is labware on the shuttle') + } + // get module location + const moduleLocation = robotState.modules[moduleId]?.slot + if (moduleLocation == null) { + console.error('Cannot store labware bc there is no module location') + } + if (moduleState.storedLabwareDetails?.primaryLabware == null) { + console.error('Cannot store labware bc there is no labware stored') + } + if ( + (moduleState.labwareInHopper?.length ?? 0) + 1 > + moduleState.maxPoolCount + ) { + console.error('Cannot store labware bc there is no space in the stacker') + } + // TODO: wire up labware id on the shuttle + const newLabwareId = uuid() + const moduleOnSlot = robotState.modules[moduleId].slot + const labwareToStore = Object.entries(robotState.labware).find( + ([_, labware]) => labware.stack.includes(moduleOnSlot) + )?.[0] + if (labwareToStore == null) { + console.error('Cannot store labware bc there is no labware on the module') + } + moduleState.labwareOnShuttle = null + moduleState.labwareInHopper = [ + { + primaryLabwareId: newLabwareId, + adapterLabwareId: null, + lidLabwareId: null, + }, + ...(moduleState.labwareInHopper ?? []), + ] as FlexStackerStoredLabwareGroup[] + // remove labware from entities + // 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 1b44290d1f2..b5be196d33e 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -2,6 +2,8 @@ import type { ABSORBANCE_READER_TYPE, CreateCommand, FLEX_STACKER_MODULE_TYPE, + FlexStackerSetStoredLabwareParams, + FlexStackerStoredLabwareGroup, HEATERSHAKER_MODULE_TYPE, Height, LabwareDefinition2, @@ -106,9 +108,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 + storedLabwareDetails: FlexStackerSetStoredLabwareParams | null + // labware in hopper is the bottom up + labwareInHopper: FlexStackerStoredLabwareGroup[] | null + labwareOnShuttle: FlexStackerStoredLabwareGroup | null } export type ModuleState = @@ -118,7 +124,7 @@ export type ModuleState = | HeaterShakerModuleState | MagneticBlockState | AbsorbanceReaderState - | FlexStackerState + | FlexStackerModuleState export interface ModuleTemporalProperties { slot: DeckSlot moduleState: ModuleState @@ -778,6 +784,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/constructInvariantContextFromAnalysis.ts b/step-generation/src/utils/constructInvariantContextFromAnalysis.ts new file mode 100644 index 00000000000..5439d892f94 --- /dev/null +++ b/step-generation/src/utils/constructInvariantContextFromAnalysis.ts @@ -0,0 +1,201 @@ +import { + getLabwareDefinitionsByURIForProtocol, + getModuleType, + getPipetteSpecsV2, +} from '@opentrons/shared-data' + +import { uuid } from '.' +import { GRIPPER_LOCATION } from '../constants' +import { createStagingAreaForInvariantContext } from './misc' + +import type { + PickUpTipRunTimeCommand, + ProtocolAnalysisOutput, + RunTimeCommand, +} from '@opentrons/shared-data' +import type { + InvariantContext, + LabwareEntities, + ModuleEntities, + PipetteEntities, + StagingAreaEntities, + TrashBinEntities, + WasteChuteEntities, +} from '../types' + +export function constructInvariantContextFromAnalysis( + analysis: ProtocolAnalysisOutput +): InvariantContext { + const { labware, modules, pipettes, commands } = analysis + const labwareDefinitions = getLabwareDefinitionsByURIForProtocol(commands) + + const moduleEntities = modules.reduce((acc, module) => { + const { id, model } = module + + return { + ...acc, + [id]: { + id, + type: getModuleType(model), + model, + pythonName: 'n/a', + }, + } + }, {}) + + const labwareEntities = labware.reduce( + (acc, loadedLabware) => { + const { id, definitionUri } = loadedLabware + const def = labwareDefinitions[definitionUri] + if (def.schemaVersion === 3) { + return acc + } + return { + ...acc, + [id]: { + id, + labwareDefURI: definitionUri, + def, + pythonName: 'n/a', + }, + } + }, + {} + ) + + const pipetteEntities = pipettes.reduce((acc, pipette) => { + const { id, pipetteName } = pipette + const spec = getPipetteSpecsV2(pipetteName) + const tiprackIdsAssosciatedWithPipette = commands.filter( + (command): command is PickUpTipRunTimeCommand => + command.commandType === 'pickUpTip' && command.params.pipetteId === id + ) + const matchingLabwareEntities = tiprackIdsAssosciatedWithPipette.map( + pickUpTipCommand => labwareEntities[pickUpTipCommand.params.labwareId] + ) + const tiprackDefURIs = Array.from( + new Set(matchingLabwareEntities.map(entity => entity.labwareDefURI)) + ) + const tiprackLabwareDefs = Array.from( + new Set(matchingLabwareEntities.map(entity => entity.def)) + ) + if (spec == null) { + return acc + } + + acc[id] = { + name: pipetteName, + id, + tiprackLabwareDef: tiprackLabwareDefs, + tiprackDefURI: tiprackDefURIs, + spec, + pythonName: 'n/a', + } + + return acc + }, {}) + const otherEntities = commands.reduce( + ( + acc: Omit< + InvariantContext, + 'labwareEntities' | 'moduleEntities' | 'pipetteEntities' + >, + command: RunTimeCommand + ) => { + if (command.commandType === 'loadLidStack' && command.result != null) { + const { params } = command + const newStagingAreaEntities: StagingAreaEntities = + createStagingAreaForInvariantContext(params) + + return { + ...acc, + stagingAreaEntities: { + ...acc.stagingAreaEntities, + ...newStagingAreaEntities, + }, + } + } else if ( + (command.commandType === 'loadLabware' || + command.commandType === 'loadLid') && + command.result != null + ) { + const { params } = command + + const newStagingAreaEntities: StagingAreaEntities = + createStagingAreaForInvariantContext(params) + + return { + ...acc, + stagingAreaEntities: { + ...acc.stagingAreaEntities, + ...newStagingAreaEntities, + }, + } + } else if ( + command.commandType === 'moveToAddressableArea' || + command.commandType === 'moveToAddressableAreaForDropTip' + ) { + const addressableAreaName = command.params.addressableAreaName + const id = `${uuid()}:${addressableAreaName}` + let location: string = GRIPPER_LOCATION + if (addressableAreaName === 'fixedTrash') { + location = 'cutout12' + } else if (addressableAreaName.includes('WasteChute')) { + location = 'cutoutD3' + } else if (addressableAreaName.includes('movableTrash')) { + location = `cutout${addressableAreaName.split('movableTrash')[1]}` + } + let trashBinEntities: TrashBinEntities = acc.trashBinEntities + if ( + !Object.values(acc.trashBinEntities).some( + entity => entity.location === location + ) && + addressableAreaName.includes('movableTrash') + ) { + trashBinEntities = { + ...acc.trashBinEntities, + [id]: { + pythonName: 'trash_bin_1', + id, + location, + }, + } + } + let wasteChuteEntities: WasteChuteEntities = acc.wasteChuteEntities + if (addressableAreaName.includes('WasteChute')) { + wasteChuteEntities = { + [id]: { + pythonName: 'waste_chute', + id, + location, + }, + } + } + return { + ...acc, + trashBinEntities, + wasteChuteEntities, + } + } + + return acc + }, + { + wasteChuteEntities: {}, + trashBinEntities: {}, + stagingAreaEntities: {}, + // the timeline scrubber doesn't visualize gripper right now + gripperEntities: {}, + // this util is used for the timeline scrubber. It grabs liquid info from analysis + // so this will not be wired up right now + liquidEntities: {}, + config: { OT_PD_DISABLE_MODULE_RESTRICTIONS: true }, + } + ) + return { + labwareEntities, + pipetteEntities, + moduleEntities, + ...otherEntities, + } +} diff --git a/step-generation/src/utils/constructInvariantContextFromRunCommands.ts b/step-generation/src/utils/constructInvariantContextFromRunCommands.ts deleted file mode 100644 index 2c4e8cf29f6..00000000000 --- a/step-generation/src/utils/constructInvariantContextFromRunCommands.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { - getLabwareDefURI, - getModuleType, - getPipetteSpecsV2, -} from '@opentrons/shared-data' - -import { uuid } from '.' -import { GRIPPER_LOCATION } from '../constants' -import { createStagingAreaForInvariantContext } from './misc' - -import type { - LoadLabwareRunTimeCommand, - PickUpTipRunTimeCommand, - RunTimeCommand, -} from '@opentrons/shared-data' -import type { - InvariantContext, - LabwareEntities, - ModuleEntities, - PipetteEntities, - StagingAreaEntities, - TrashBinEntities, - WasteChuteEntities, -} from '../types' - -export function constructInvariantContextFromRunCommands( - commands: RunTimeCommand[] -): InvariantContext { - return commands.reduce( - (acc: InvariantContext, command: RunTimeCommand) => { - if (command.commandType === 'loadLidStack' && command.result != null) { - const { result, params } = command - const amount = params.quantity - - const newStagingAreaEntities: StagingAreaEntities = - createStagingAreaForInvariantContext(params) - const newLabwareEntities: LabwareEntities = - // loadLabware commands from the backend can have schema 3 labware definitions. - // step-generation, and this function by extension, are not prepared to handle - // schema 3 yet. Just ignore those definitions for now. - // See also the loadPipette handling, below. - result.definition != null && result.definition.schemaVersion === 2 - ? (() => { - const def = result.definition - const labwareDefURI = getLabwareDefURI(def) - return result.labwareIds.slice(0, amount).reduce( - (entities: LabwareEntities, labwareId) => ({ - ...entities, - [labwareId]: { - id: labwareId, - labwareDefURI, - def, - pythonName: 'n/a', - }, - }), - {} - ) - })() - : {} - - return { - ...acc, - labwareEntities: { - ...acc.labwareEntities, - ...newLabwareEntities, - }, - stagingAreaEntities: { - ...acc.stagingAreaEntities, - ...newStagingAreaEntities, - }, - } - } else if ( - (command.commandType === 'loadLabware' || - command.commandType === 'loadLid') && - command.result != null - ) { - const { result, params } = command - - const newStagingAreaEntities: StagingAreaEntities = - createStagingAreaForInvariantContext(params) - const newLabwareEntities: LabwareEntities = - // todo(mm, 2025-05-16): - // loadLabware commands from the backend can have schema 3 labware definitions. - // step-generation, and this function by extension, are not prepared to handle - // schema 3 yet. Just ignore those definitions for now. - // See also the loadPipette handling, below. - result.definition != null && result.definition.schemaVersion === 2 - ? { - [result.labwareId]: { - id: result.labwareId, - labwareDefURI: getLabwareDefURI(result.definition), - def: result.definition, - // ProtocolTimelineScrubber won't need access to pythonNames - pythonName: 'n/a', - }, - } - : {} - - return { - ...acc, - labwareEntities: { - ...acc.labwareEntities, - ...newLabwareEntities, - }, - stagingAreaEntities: { - ...acc.stagingAreaEntities, - ...newStagingAreaEntities, - }, - } - } else if ( - command.commandType === 'loadModule' && - command.result != null - ) { - const result = command.result - const moduleEntities: ModuleEntities = { - ...acc.moduleEntities, - [result.moduleId]: { - id: result.moduleId, - type: getModuleType(command.params.model), - model: command.params.model, - pythonName: 'n/a', - }, - } - return { - ...acc, - moduleEntities, - } - } else if ( - command.commandType === 'loadPipette' && - command.result != null - ) { - const result = command.result - const labwareId = - commands.find( - (c): c is PickUpTipRunTimeCommand => - c.commandType === 'pickUpTip' && - c.params.pipetteId === result.pipetteId - )?.params.labwareId ?? null - const matchingCommand = - commands.find( - (c): c is LoadLabwareRunTimeCommand => - c.commandType === 'loadLabware' && - c.result != null && - c.result.labwareId === labwareId - ) ?? null - - let tiprackLabwareDef = matchingCommand?.result?.definition ?? null - // We're not prepared to handle labware schema 3 yet. See the todo comment - // in the loadLabware handling, above. - if (tiprackLabwareDef?.schemaVersion === 3) tiprackLabwareDef = null - - const specs: any = getPipetteSpecsV2(command.params.pipetteName) - - const pipetteEntities: PipetteEntities = { - ...acc.pipetteEntities, - [result.pipetteId]: { - name: command.params.pipetteName, - id: command.params.pipetteId, - tiprackLabwareDef: - tiprackLabwareDef != null ? [tiprackLabwareDef] : [], - tiprackDefURI: - tiprackLabwareDef != null - ? [getLabwareDefURI(tiprackLabwareDef)] - : [], - spec: specs, - pythonName: 'n/a', - }, - } - return { - ...acc, - pipetteEntities, - } - } else if ( - command.commandType === 'moveToAddressableArea' || - command.commandType === 'moveToAddressableAreaForDropTip' - ) { - const addressableAreaName = command.params.addressableAreaName - const id = `${uuid()}:${addressableAreaName}` - let location: string = GRIPPER_LOCATION - if (addressableAreaName === 'fixedTrash') { - location = 'cutout12' - } else if (addressableAreaName.includes('WasteChute')) { - location = 'cutoutD3' - } else if (addressableAreaName.includes('movableTrash')) { - location = `cutout${addressableAreaName.split('movableTrash')[1]}` - } - let trashBinEntities: TrashBinEntities = acc.trashBinEntities - if ( - !Object.values(acc.trashBinEntities).some( - entity => entity.location === location - ) - ) { - trashBinEntities = { - ...acc.trashBinEntities, - [id]: { - pythonName: 'trash_bin_1', - id, - location, - }, - } - } - - const wasteChuteEntities: WasteChuteEntities = { - [id]: { - pythonName: 'waste_chute', - id, - location, - }, - } - return { - ...acc, - trashBinEntities, - wasteChuteEntities, - } - } - - return acc - }, - { - labwareEntities: {}, - moduleEntities: {}, - pipetteEntities: {}, - wasteChuteEntities: {}, - trashBinEntities: {}, - stagingAreaEntities: {}, - // the timeline scrubber doesn't visualize gripper right now - gripperEntities: {}, - // this util is used for the timeline scrubber. It grabs liquid info from analysis - // so this will not be wired up right now - liquidEntities: {}, - config: { OT_PD_DISABLE_MODULE_RESTRICTIONS: true }, - } - ) -} diff --git a/step-generation/src/utils/index.ts b/step-generation/src/utils/index.ts index 195c72f446d..4fa618b9ada 100644 --- a/step-generation/src/utils/index.ts +++ b/step-generation/src/utils/index.ts @@ -21,8 +21,7 @@ export { findThermocyclerProfileRepetitions, } export * from './commandCreatorArgsGetters' -export * from './constructInvariantContextFromRunCommands' -export * from './createTimelineFromRunCommands' +export * from './constructInvariantContextFromAnalysis' export * from './createTimelineFromRunCommands' export * from './heaterShakerCollision' export * from './liquidClassUtils' diff --git a/step-generation/src/utils/misc.ts b/step-generation/src/utils/misc.ts index de1eb505051..8dc1cd20029 100644 --- a/step-generation/src/utils/misc.ts +++ b/step-generation/src/utils/misc.ts @@ -34,7 +34,13 @@ import { dispenseInTrash, dispenseInWasteChute, } from '../commandCreators/compound' -import { CLEAN, EMPTY, STAGING_AREA_SLOTS, ZERO_OFFSET } from '../constants' +import { + CLEAN, + EMPTY, + HOPPER_STACKER_LOCATION, + STAGING_AREA_SLOTS, + ZERO_OFFSET, +} from '../constants' import { curryCommandCreator } from './curryCommandCreator' import { reduceCommandCreators, uuid } from './index' @@ -1345,3 +1351,15 @@ export function createStagingAreaForInvariantContext( } return {} } + +export const getLabwareIdOnHopper = ( + labware: { + [labwareId: string]: LabwareTemporalProperties + }, + moduleSlotLocation: string +): string => { + const largestStackInSlot = getLargestStackInSlot(labware, moduleSlotLocation) + const indexOfHopper = largestStackInSlot.indexOf(HOPPER_STACKER_LOCATION) + const labwareIdOnModule = largestStackInSlot[indexOfHopper - 1] + return labwareIdOnModule +} diff --git a/step-generation/src/utils/pythonFileUtils.ts b/step-generation/src/utils/pythonFileUtils.ts index fa90d078798..2a309580f98 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, @@ -36,6 +37,7 @@ import type { LabwareEntities, LabwareEntity, LabwareLiquidState, + LabwareTemporalProperties, LiquidEntities, ModuleEntities, PipetteEntities, @@ -45,7 +47,7 @@ import type { WasteChuteEntities, } from '../types' -export const PAPI_VERSION = '2.27' // latest version from api/src/opentrons/protocols/api_support/definitions.py +export const PAPI_VERSION = '2.27' // latest version from api/src/opentrons/protocols/api_support/definitions.py from the RS release branch export const PD_APPLICATION_VERSION = '8.7.0' // latest PD version to insert into DESIGNER_APPLICATION blob export function pythonImports(): string { @@ -577,6 +579,8 @@ export function pythonDefRun( getDefineLiquids(liquidEntities), getLoadLiquids(liquidsByLabwareId, liquidEntities, labwareEntities), getLoadLiquidClasses(allUniqueLiquidClassesFromForms), + // TODO: call this when we have a way to get the labware on the shuttle location + // getSetStoredLabware(moduleEntities, labwareEntities, labware), stepCommands(robotStateTimeline), ] const functionBody = @@ -623,3 +627,44 @@ export const formatChangeTipArg = (changeTip: ChangeTipOptions): string => { } } } +export const getSetStoredLabware = ( + moduleEntities: ModuleEntities, + labwareEntities: LabwareEntities, + labware: { [labwareId: string]: LabwareTemporalProperties } +): string => { + const pythonSetStoredLabware = Object.values(moduleEntities).map(module => { + const { id, type, pythonName } = module + + if (type === FLEX_STACKER_MODULE_TYPE) { + const labwareOnModule = Object.entries(labware).find(([_, labware]) => + labware.stack?.includes(id) + ) + if (labwareOnModule == null) { + return '' + } else { + // Count only labware items (excluding slot, module, and adapters) + const labwareCount = labwareOnModule[1].stack.filter( + itemId => + itemId in labwareEntities && + !labwareEntities[itemId].def.allowedRoles?.includes('adapter') && + !labwareEntities[itemId].def.allowedRoles?.includes('lid') + ).length + const labwareEntity = labwareEntities[labwareOnModule[0]] + const setStoredLabwareArgs = [ + `loadName=${formatPyStr(labwareEntity.def.parameters.loadName)}`, + `namespace=${formatPyStr(labwareEntity.def.namespace)}`, + `version=${labwareEntity.def.version}`, + `count=${labwareCount}`, + ].join(',\n') + return `${pythonName} = ${PROTOCOL_CONTEXT_NAME}.set_stored_labware(\n${indentPyLines(setStoredLabwareArgs)})` + } + } + }) + + // filter any empty strings + const pythonLines = pythonSetStoredLabware.filter(Boolean) + + return pythonLines.length > 0 + ? `# Set Stored Labware:\n${pythonLines.join('\n').trimStart()}` + : '' +} diff --git a/vitest.config.mts b/vitest.config.mts index fc30db4864a..6c26d2e2ed9 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,81 +3,149 @@ /// // todo(mm, 2025-09-15): This file is used under confusing circumstances. -// See comments in the global vite.config.mts. +// +// For normal production bundling and dev-serving, each project has its own +// vite.config.mts. +// +// For vitest invocations, vitest would normally default to those same project-specific +// vite.config.mts files. However, because we have this single global +// vitest.config.mts, it uses this instead, completely ignoring the project-specific +// files. +// +// So, that leaves us with: +// - An arbitrary split between this global vite.config.mts the global vitest.config.mts +// - Global vite.config.mts and global vitest.config.mts comprising, together, an +// amalgamation of all projects' needs -- all projects' aliases, all projects' defines, etc. +// - Which is probably largely duplicating the existing project-local configs, +// which we'd get for free if we didn't override them with our vitest.config.mts import path from 'path' -import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' - -import viteConfig from './vite.config.mts' +import react from '@vitejs/plugin-react' +import lostCss from 'lost' +import postCssApply from 'postcss-apply' +import postColorModFunction from 'postcss-color-mod-function' +import postCssImport from 'postcss-import' +import postCssPresetEnv from 'postcss-preset-env' +import { configDefaults, defineConfig } from 'vitest/config' // eslint-disable-next-line import/no-default-export -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'jsdom', - allowOnly: true, - exclude: [...configDefaults.exclude, '**/node_modules/**', '**/dist/**', '**/lib/**'], - setupFiles: ['./setup-vitest.mts'], - coverage: { - exclude: [ - '**/node_modules/**', - '**/dist/**', - '**/__tests__/**', - '**/lib/**', - 'protocol-designer/cypress/**/*', - 'labware-library/cypress/**/*', - ...configDefaults.exclude, - ], - provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov'], +export default defineConfig({ + build: { + // Relative to the root + outDir: 'dist', + }, + plugins: [ + react({ + include: '**/*.tsx', + babel: { + // Use babel.config.js files + configFile: true, }, + }), + ], + optimizeDeps: { + esbuildOptions: { + target: 'es2020', }, - resolve: { - alias: { - // todo(mm, 2025-10-27): These cross-project aliases cause trouble like - // files being processed with the wrong config (the config from the - // consuming project vs. the config from the source project). - // Can these be replaced with regular package.json dependencies? - '@opentrons/components/styles': path.resolve( - './components/src/index.module.css' - ), - '@opentrons/components': path.resolve('./components/src/index.ts'), - '@opentrons/shared-data/pipette/fixtures/name': path.resolve( - './shared-data/pipette/fixtures/name/index.ts' - ), - '@opentrons/shared-data/labware/fixtures/1': path.resolve( - './shared-data/labware/fixtures/1/index.ts' - ), - '@opentrons/shared-data/labware/fixtures/2': path.resolve( - './shared-data/labware/fixtures/2/index.ts' - ), - '@opentrons/shared-data/labware/fixtures/3': path.resolve( - './shared-data/labware/fixtures/3/index.ts' - ), - '@opentrons/shared-data': path.resolve('./shared-data/js/index.ts'), - '@opentrons/step-generation': path.resolve( - './step-generation/src/index.ts' - ), - '@opentrons/api-client': path.resolve('./api-client/src/index.ts'), - '@opentrons/react-api-client': path.resolve( - './react-api-client/src/index.ts' - ), - '@opentrons/discovery-client': path.resolve( - './discovery-client/src/index.ts' - ), - '@opentrons/usb-bridge/node-client': path.resolve( - './usb-bridge/node-client/src/index.ts' - ), - '@opentrons/labware-library': path.resolve( - './labware-library/src/labware-creator/index.tsx' - ), - // "The resulting path (...) trailing slashes are removed unless the path is resolved to the root directory." - // https://nodejs.org/api/path.html#pathresolvepaths - '/app/': path.resolve('./app/src/') + '/', - '/protocol-designer/': path.resolve('./protocol-designer/src/') + '/', - '/ai-client/': path.resolve('./opentrons-ai-client/src/') + '/', - }, + exclude: ['node_modules'], + }, + css: { + postcss: { + plugins: [ + postCssImport({ root: 'src/' }), + postCssApply(), + postColorModFunction(), + postCssPresetEnv({ stage: 0 }), + lostCss(), + ], + }, + }, + test: { + environment: 'jsdom', + allowOnly: true, + exclude: [ + ...configDefaults.exclude, + '**/node_modules/**', + '**/dist/**', + '**/lib/**', + ], + setupFiles: ['./setup-vitest.mts'], + coverage: { + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/__tests__/**', + '**/lib/**', + 'labware-library/cypress/**/*', + ...configDefaults.exclude, + ], + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + }, + }, + define: { + // These defines mimic the ones set in various project-local vite.config.mts files. + // NOTE: For security, only include environment variables here if they're explicitly allowlisted. + _FF_ENV_VARS_: {}, + _NODE_ENV_: JSON.stringify(process.env.NODE_ENV), + _OT_AI_CLIENT_MIXPANEL_ID_: JSON.stringify( + process.env.OT_AI_CLIENT_MIXPANEL_ID + ), + _OT_APP_MIXPANEL_ID_: JSON.stringify(process.env.OT_APP_MIXPANEL_ID), + _OT_LL_MIXPANEL_DEV_ID_: JSON.stringify(process.env.OT_LL_MIXPANEL_DEV_ID), + _OT_LL_MIXPANEL_ID_: JSON.stringify(process.env.OT_LL_MIXPANEL_ID), + _OT_PD_BUILD_DATE_: JSON.stringify(process.env.OT_PD_BUILD_DATE), + _OT_PD_MIXPANEL_DEV_ID_: JSON.stringify(process.env.OT_PD_MIXPANEL_DEV_ID), + _OT_PD_MIXPANEL_ID_: JSON.stringify(process.env.OT_PD_MIXPANEL_ID), + _OT_PD_SENTRY_DEV_DSN_: JSON.stringify(process.env.OT_PD_SENTRY_DEV_DSN), + _OT_PD_SENTRY_DSN_: JSON.stringify(process.env.OT_PD_SENTRY_DSN), + _OT_PD_VERSION_: JSON.stringify(process.env.OT_PD_VERSION), + global: 'globalThis', + }, + resolve: { + alias: { + // todo(mm, 2025-10-27): These cross-project aliases cause trouble like + // files being processed with the wrong config (the config from the + // consuming project vs. the config from the source project). + // Can these be replaced with regular package.json dependencies? + '@opentrons/components/styles': path.resolve( + './components/src/index.module.css' + ), + '@opentrons/components': path.resolve('./components/src/index.ts'), + '@opentrons/shared-data/pipette/fixtures/name': path.resolve( + './shared-data/pipette/fixtures/name/index.ts' + ), + '@opentrons/shared-data/labware/fixtures/1': path.resolve( + './shared-data/labware/fixtures/1/index.ts' + ), + '@opentrons/shared-data/labware/fixtures/2': path.resolve( + './shared-data/labware/fixtures/2/index.ts' + ), + '@opentrons/shared-data/labware/fixtures/3': path.resolve( + './shared-data/labware/fixtures/3/index.ts' + ), + '@opentrons/shared-data': path.resolve('./shared-data/js/index.ts'), + '@opentrons/step-generation': path.resolve( + './step-generation/src/index.ts' + ), + '@opentrons/api-client': path.resolve('./api-client/src/index.ts'), + '@opentrons/react-api-client': path.resolve( + './react-api-client/src/index.ts' + ), + '@opentrons/discovery-client': path.resolve( + './discovery-client/src/index.ts' + ), + '@opentrons/usb-bridge/node-client': path.resolve( + './usb-bridge/node-client/src/index.ts' + ), + '@opentrons/labware-library': path.resolve( + './labware-library/src/labware-creator/index.tsx' + ), + // "The resulting path (...) trailing slashes are removed unless the path is resolved to the root directory." + // https://nodejs.org/api/path.html#pathresolvepaths + '/app/': path.resolve('./app/src/') + '/', + '/protocol-designer/': path.resolve('./protocol-designer/src/') + '/', + '/ai-client/': path.resolve('./opentrons-ai-client/src/') + '/', }, - }) -) + }, +}) diff --git a/yarn.lock b/yarn.lock index 0270c066029..0b9319a2b3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10813,14 +10813,11 @@ electron-debug@3.2.0: electron-is-dev "^1.1.0" electron-localshortcut "^3.1.0" -electron-devtools-installer@3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-3.2.0.tgz#acc48d24eb7033fe5af284a19667e73b78d406d0" - integrity sha512-t3UczsYugm4OAbqvdImMCImIMVdFzJAHgbwHpkl5jmfu1izVgUcP/mnrPqJIpEeCK1uZGpt+yHgWEN+9EwoYhQ== +electron-devtools-installer@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-4.0.0.tgz#6556a6a326cddea18194cb6d97d85c8ae329dedf" + integrity sha512-9Tntu/jtfSn0n6N/ZI6IdvRqXpDyLQiDuuIbsBI+dL+1Ef7C8J2JwByw58P3TJiNeuqyV3ZkphpNWuZK5iSY2w== dependencies: - rimraf "^3.0.2" - semver "^7.2.1" - tslib "^2.1.0" unzip-crx-3 "^0.2.0" electron-dl@^3.2.1: @@ -20480,7 +20477,7 @@ semver@^6.0.0, semver@^6.2.0, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4: +semver@^7.0.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4: version "7.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== @@ -21209,7 +21206,7 @@ string-ts@^2.2.1: resolved "https://registry.yarnpkg.com/string-ts/-/string-ts-2.2.1.tgz#9cf9a93d210f778080a9db86ca37cba37f55e44c" integrity sha512-Q2u0gko67PLLhbte5HmPfdOjNvUKbKQM+mCNQae6jE91DmoFHY6HH9GcdqCeNx87DZ2KKjiFxmA0R/42OneGWw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21227,6 +21224,15 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -21339,7 +21345,7 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -21360,6 +21366,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23656,7 +23669,7 @@ worker-plugin@^5.0.0: dependencies: loader-utils "^1.1.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23683,6 +23696,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Protocol is paused
Yellow
Abnormal states
Yellow
Abnormal states
Solid Software error
PulsingError recovery mode
Red
Emergency states
Blinks three times, repeatedly